From f39aad1d7f54a7033687cfea0d49eb284455cb5b Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 21 Jul 2026 11:57:03 +0300 Subject: [PATCH 01/14] fix: ( bookings ) make BookingSeat.scheduleId optional to fix P2032 on booking read --- apps/edr-passenger-api/prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index b58356365..db46df025 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -580,7 +580,7 @@ model BookingSeat { bookingId String seatId String leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2 - scheduleId String // which schedule this seat belongs to + scheduleId String? // which schedule this seat belongs to passengerName String dateOfBirth DateTime? passengerCategory PassengerCategory @default(ADULT) From b8b8c4adf7328ba9559631fc85fac7134d2c5086 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 21 Jul 2026 11:00:02 +0000 Subject: [PATCH 02/14] feat(e2e): add freight e2e docker-compose stack with isolated services --- .gitignore | 5 + docker-compose.e2e.yaml | 171 ++++ e2e/freight/README.md | 80 ++ e2e/freight/cypress.config.ts | 80 ++ .../cypress/e2e/api/health-and-auth.cy.ts | 79 ++ .../cypress/e2e/backoffice/login.cy.ts | 41 + .../cypress/e2e/backoffice/smoke.cy.ts | 49 ++ e2e/freight/cypress/e2e/flows/cross-app.cy.ts | 69 ++ e2e/freight/cypress/e2e/portal/login.cy.ts | 36 + e2e/freight/cypress/e2e/portal/smoke.cy.ts | 29 + e2e/freight/cypress/fixtures/seed-users.sql | 130 +++ e2e/freight/cypress/fixtures/users.json | 16 + e2e/freight/cypress/support/commands.ts | 95 ++ e2e/freight/cypress/support/e2e.ts | 18 + e2e/freight/package.json | 22 + e2e/freight/tsconfig.json | 16 + package.json | 5 + pnpm-lock.yaml | 812 ++++++++++++++++-- pnpm-workspace.yaml | 2 + 19 files changed, 1701 insertions(+), 54 deletions(-) create mode 100644 docker-compose.e2e.yaml create mode 100644 e2e/freight/README.md create mode 100644 e2e/freight/cypress.config.ts create mode 100644 e2e/freight/cypress/e2e/api/health-and-auth.cy.ts create mode 100644 e2e/freight/cypress/e2e/backoffice/login.cy.ts create mode 100644 e2e/freight/cypress/e2e/backoffice/smoke.cy.ts create mode 100644 e2e/freight/cypress/e2e/flows/cross-app.cy.ts create mode 100644 e2e/freight/cypress/e2e/portal/login.cy.ts create mode 100644 e2e/freight/cypress/e2e/portal/smoke.cy.ts create mode 100644 e2e/freight/cypress/fixtures/seed-users.sql create mode 100644 e2e/freight/cypress/fixtures/users.json create mode 100644 e2e/freight/cypress/support/commands.ts create mode 100644 e2e/freight/cypress/support/e2e.ts create mode 100644 e2e/freight/package.json create mode 100644 e2e/freight/tsconfig.json diff --git a/.gitignore b/.gitignore index ca2a5b7af..47b17bbac 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,8 @@ coverage/ \#*\# .\#* docker-compose.override.yml + +# cypress e2e artifacts +e2e/**/cypress/videos/ +e2e/**/cypress/screenshots/ +e2e/**/cypress/downloads/ diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml new file mode 100644 index 000000000..5b8e37daf --- /dev/null +++ b/docker-compose.e2e.yaml @@ -0,0 +1,171 @@ +# EDR Freight — ephemeral Cypress e2e stack. +# Fully isolated from dev: own ports, own throwaway Postgres (tmpfs — data +# vanishes on `down`), seeded test users. Requires the same .npmrc as the main +# docker-compose.yaml (GitHub Packages auth for @tria-plc). +# +# Up (build + wait healthy): docker compose -f docker-compose.e2e.yaml up -d --build --wait +# Headless run in container: docker compose -f docker-compose.e2e.yaml --profile cypress run --rm cypress +# Interactive from host: pnpm --filter @edr/freight-e2e cy:open +# Teardown: docker compose -f docker-compose.e2e.yaml down -v --remove-orphans +# +# Host ports (chosen to never collide with the dev stack): +# freight-api 3101 portal 5273 backoffice 5283 +# postgres 5533 minio 9310 (console 9311) +name: edr-freight-e2e + +services: + postgres-freight-e2e: + image: postgres:16-alpine + environment: + POSTGRES_DB: edr_freight_e2e + POSTGRES_USER: edr_e2e + POSTGRES_PASSWORD: edr_e2e + tmpfs: + - /var/lib/postgresql/data + ports: + - "5533:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U edr_e2e -d edr_freight_e2e"] + interval: 2s + timeout: 3s + retries: 30 + + minio-e2e: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: e2e-minio + MINIO_ROOT_PASSWORD: e2e-minio-secret + tmpfs: + - /data + ports: + - "9310:9000" + - "9311:9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 12 + + freight-api-e2e: + build: + context: . + dockerfile: apps/edr-freight-api/Dockerfile + secrets: + - npmrc + depends_on: + postgres-freight-e2e: + condition: service_healthy + minio-e2e: + condition: service_healthy + environment: + PORT: "3001" + DB_HOST: postgres-freight-e2e + DB_PORT: "5432" + DB_USER: edr_e2e + DB_PASSWORD: edr_e2e + DB_NAME: edr_freight_e2e + # e2e-only secrets — never reuse outside this stack + JWT_SECRET: e2e-jwt-secret + JWT_ACCESS_TOKEN_SECRET: e2e-access-secret + JWT_REFRESH_TOKEN_SECRET: e2e-refresh-secret + JWT_EXPIRES_IN: 1d + JWT_ACCESS_TOKEN_EXPIRES: 1d + JWT_REFRESH_TOKEN_EXPIRES: 7d + SERVICE_AUTH_TOKEN: e2e-service-token + # Org/unit/position boot seeders (env-gated in app code). Test USERS are + # NOT seeded by the API — Cypress inserts them via + # e2e/freight/cypress/fixtures/seed-users.sql before specs run. + SEED_EDR_ORG: "true" + SUPER_ADMIN_EMAIL: superadmin@tria.com + SUPER_ADMIN_PHONE: "+251900000000" + # Object storage + MINIO_ENDPOINT: minio-e2e + MINIO_PORT: "9000" + MINIO_USE_SSL: "false" + MINIO_ACCESS_KEY: e2e-minio + MINIO_SECRET_KEY: e2e-minio-secret + MINIO_REGION: us-east-1 + # External integrations off + RABBITMQ_ENABLED: "false" + FAYDA_ENABLED: "false" + FREIGHT_PORTAL_URL: http://localhost:5273 + ports: + - "3101:3001" + healthcheck: + # Boot runs 240+ migrations + seeders on first start — generous start_period. + test: + [ + "CMD", + "node", + "-e", + "fetch('http://localhost:3001/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 5s + timeout: 5s + retries: 12 + start_period: 180s + + freight-portal-e2e: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.web + args: + TURBO_FILTER: "@edr/freight-portal" + APP_PATH: apps/edr-freight-web/portal + # Baked at build time: browser (host or host-networked cypress + # container) reaches the API through the published host port. + VITE_API_URL: http://localhost:3101 + VITE_BASE_API_URL: http://localhost:3101 + VITE_USER_MANAGEMENT_BASE: /_um + VITE_GOOGLE_MAPS_API_KEY: "" + VITE_POSTHOG_KEY: "" + VITE_POSTHOG_HOST: "" + secrets: + - npmrc + ports: + - "5273:80" + + freight-backoffice-e2e: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.web + args: + TURBO_FILTER: "@edr/freight-backoffice" + APP_PATH: apps/edr-freight-web/backoffice + VITE_API_URL: http://localhost:3101 + VITE_BASE_API_URL: http://localhost:3101 + VITE_USER_MANAGEMENT_BASE: /_um + VITE_GOOGLE_MAPS_API_KEY: "" + VITE_POSTHOG_KEY: "" + VITE_POSTHOG_HOST: "" + secrets: + - npmrc + ports: + - "5283:80" + + # Headless runner — opt-in via `--profile cypress`. host network so the + # in-container browser uses the exact same localhost URLs as `cypress open` + # on the host (Linux only; on macOS/Windows run Cypress from the host). + cypress: + # Keep in sync with the cypress version in e2e/freight/package.json. + image: cypress/included:${CYPRESS_VERSION:-15.18.1} + profiles: ["cypress"] + network_mode: host + depends_on: + freight-api-e2e: + condition: service_healthy + working_dir: /repo/e2e/freight + # NOTE: host network shares the abstract X-socket namespace with the host. + # Cypress spawns its Xvfb on :99 — run only ONE cypress container at a + # time, and don't run it on a host whose X server occupies :99. + entrypoint: ["cypress", "run", "--browser", "chrome"] + environment: + CI: "true" + E2E_DB_URL: postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e + volumes: + - .:/repo + +secrets: + npmrc: + file: .npmrc diff --git a/e2e/freight/README.md b/e2e/freight/README.md new file mode 100644 index 000000000..6cefcdcfa --- /dev/null +++ b/e2e/freight/README.md @@ -0,0 +1,80 @@ +# @edr/freight-e2e — Cypress e2e suite for the freight system + +Containerized, fully isolated e2e environment: throwaway Postgres (tmpfs), +MinIO, freight-api, portal, and backoffice — plus a Cypress runner that works +both headless-in-Docker and interactively from the host against the same URLs. + +## Stack (`docker-compose.e2e.yaml`, project name `edr-freight-e2e`) + +| Service | Host port | Notes | +| ----------------------- | --------- | ---------------------------------------------- | +| `freight-api-e2e` | 3101 | migrations + seeders run at boot | +| `freight-portal-e2e` | 5273 | nginx static build, API baked to `:3101` | +| `freight-backoffice-e2e`| 5283 | nginx static build, API baked to `:3101` | +| `postgres-freight-e2e` | 5533 | `edr_freight_e2e`, tmpfs — gone on `down` | +| `minio-e2e` | 9310/9311 | object storage for file features | +| `cypress` | (host net)| profile `cypress`, headless chrome | + +Ports deliberately avoid the dev stack (3001/5173/5183/5433). The dev database +is never touched. + +## Usage (from repo root) + +```bash +pnpm e2e:freight:up # build + start stack, wait for healthchecks +pnpm e2e:freight:open # interactive Cypress on the host +pnpm e2e:freight:run # headless run from the host +pnpm e2e:freight:ci # headless run inside the cypress container +pnpm e2e:freight:down # teardown, drop all data +``` + +First `up` is slow (image builds + 240 migrations + seeders — healthcheck +allows 3 min). Requires the same root `.npmrc` (GitHub Packages auth for +`@tria-plc`) as the main compose file. + +The `cypress` service uses `network_mode: host` (Linux). On macOS/Windows run +Cypress from the host (`e2e:freight:open` / `e2e:freight:run`) instead of the +container. + +## Test users + +Inserted by Cypress itself — a global `before()` hook runs +`cy.task("db:seedUsers")`, which executes +`cypress/fixtures/seed-users.sql` (idempotent, pre-hashed argon2 passwords) +against the e2e database. No API code is involved; the app's user seeders +stay disabled. The API's always-on boot seeders must have run first +(org/unit/positions) — guaranteed once `freight-api-e2e` is healthy. + +- Staff (backoffice): `linestaff|chief|director|ceo|marketer|operation|gl-et|gl-dj@edr.local` + — password `password@tria` +- Customers (portal): `user@gmail.com`, `user2@gmail.com` + — password `12345678` + +Full map in `cypress/fixtures/users.json`. + +## Conventions + +- **Programmatic login** everywhere except the two dedicated UI-login specs: + `cy.loginBackoffice(email?)` / `cy.loginPortal(email?)` — `cy.session`-cached + (across specs), `POST /api/auth/login`, sets the `auth-token` / + `refresh-token` cookies the apps read. +- **Origins**: `baseUrl` is the backoffice (5283). Portal specs `cy.visit` + the absolute portal URL; a test that touches *both* apps wraps portal steps + in `cy.origin()` (different port = different origin). Cookies ignore ports — + always call the matching login command right before switching apps so + `cy.session` restores the right cookie snapshot. +- **DB access**: `cy.task("db:query", { sql, params })` runs SQL against the + e2e database (`E2E_DB_URL`, default `localhost:5533`). Use for seeding + edge-case data and asserting side effects — it can never reach the dev DB. +- **Spec layout**: + - `cypress/e2e/api/` — API contract via `cy.request` (no browser) + - `cypress/e2e/backoffice/` — staff app + - `cypress/e2e/portal/` — customer app + - `cypress/e2e/flows/` — cross-app journeys (both directions) + +## Extending + +Deep module flows (booking wizard → staff approval → scheduling → billing) +belong in `flows/`. Pattern: arrange via API/`db:query`, act through the UI of +one app, assert through the UI of the other + a `db:query` cross-check. Prefer +adding `data-testid` attributes to app code over brittle text selectors. diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts new file mode 100644 index 000000000..56c0f758a --- /dev/null +++ b/e2e/freight/cypress.config.ts @@ -0,0 +1,80 @@ +import { defineConfig } from "cypress"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { Client } from "pg"; + +/** + * Freight e2e suite. Three origins: + * backoffice http://localhost:5283 (baseUrl — most specs live here) + * portal http://localhost:5273 (env.portalUrl; portal specs cy.visit it, + * cross-app flows reach it via cy.origin) + * api http://localhost:3101 (env.apiUrl; cy.request only) + * + * All URLs are host-published ports from docker-compose.e2e.yaml. The cypress + * container in that compose file runs with network_mode: host, so the same + * localhost URLs work identically for `cypress open` on the host and for the + * containerized headless run. + */ +export default defineConfig({ + e2e: { + baseUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5283", + specPattern: "cypress/e2e/**/*.cy.ts", + supportFile: "cypress/support/e2e.ts", + video: process.env.CI === "true" || process.env.CYPRESS_VIDEO === "true", + screenshotOnRunFailure: true, + viewportWidth: 1440, + viewportHeight: 900, + defaultCommandTimeout: 10000, + requestTimeout: 15000, + retries: { runMode: 1, openMode: 0 }, + env: { + apiUrl: process.env.CYPRESS_API_URL ?? "http://localhost:3101", + portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5273", + backofficeUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5283", + // Staff users: DEFAULT_PASSWORD from docker-compose.e2e.yaml. + defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria", + // Demo portal users: hardcoded in DemoUsersSeeder. + demoPassword: "12345678", + }, + setupNodeEvents(on) { + const dbUrl = + process.env.E2E_DB_URL ?? + "postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e"; + + on("task", { + /** Run an arbitrary SQL statement against the ephemeral e2e database. */ + async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) { + const client = new Client({ connectionString: dbUrl }); + await client.connect(); + try { + const result = await client.query(sql, params as never[]); + return { rowCount: result.rowCount, rows: result.rows }; + } finally { + await client.end(); + } + }, + + /** + * Seed the test users (staff + demo) directly in SQL. The API's + * user seeders are disabled in app code, so the fixture replicates + * their output. Idempotent — safe to run before every spec file. + */ + async "db:seedUsers"() { + // cwd = the e2e/freight project root when Cypress runs. + const sql = readFileSync( + join(process.cwd(), "cypress", "fixtures", "seed-users.sql"), + "utf8", + ); + const client = new Client({ connectionString: dbUrl }); + await client.connect(); + try { + await client.query(sql); + return true; + } finally { + await client.end(); + } + }, + }); + }, + }, +}); diff --git a/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts b/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts new file mode 100644 index 000000000..c183a11df --- /dev/null +++ b/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts @@ -0,0 +1,79 @@ +/** + * API contract smoke — no browser, pure cy.request against freight-api. + * Verifies the containerized stack booted: migrations ran, seeders ran, + * auth issues tokens. + */ +const api = () => Cypress.env("apiUrl") as string; + +describe("freight-api: health + auth contract", () => { + it("GET /api/health responds", () => { + cy.request(`${api()}/api/health`).its("status").should("eq", 200); + }); + + it("rejects bad credentials", () => { + cy.request({ + method: "POST", + url: `${api()}/api/auth/login`, + body: { email: "nobody@edr.local", password: "wrong-password" }, + failOnStatusCode: false, + }) + .its("status") + .should("be.oneOf", [400, 401, 404]); + }); + + it("logs in every seeded staff user", () => { + cy.fixture("users.json").then((users) => { + Object.values<{ email: string }>(users.staff).forEach(({ email }) => { + cy.apiLogin(email); + }); + }); + }); + + it("staff token can read /api/me", () => { + cy.apiLogin("ceo@edr.local").then(({ token }) => { + cy.request({ + url: `${api()}/api/me`, + headers: { Authorization: `Bearer ${token}` }, + }).then((response) => { + expect(response.status).to.eq(200); + }); + }); + }); + + it("refresh-token rotates the session", () => { + cy.apiLogin("chief@edr.local").then(({ refreshToken }) => { + cy.request("POST", `${api()}/api/auth/refresh-token`, { refreshToken }) + .its("body.token") + .should("be.a", "string"); + }); + }); + + it("demo portal users are seeded", () => { + // DemoUsersSeeder hardcodes this password (staff users use DEFAULT_PASSWORD) + cy.apiLogin("user@gmail.com", Cypress.env("demoPassword")); + cy.apiLogin("user2@gmail.com", Cypress.env("demoPassword")); + }); +}); + +describe("freight-api: seeded database", () => { + it("migrations table is populated", () => { + cy.task<{ rowCount: number }>("db:query", { + sql: "select count(*)::int as count from migrations", + }).then(({ rows }: any) => { + expect(rows[0].count).to.be.greaterThan(100); + }); + }); + + it("staff users exist with credentials", () => { + cy.task("db:query", { + sql: `select u.email from iam.users u + join iam.user_credentials c on c.user_id = u.id + where u.email like '%@edr.local' order by u.email`, + }).then(({ rows }: any) => { + const emails = rows.map((row: { email: string }) => row.email); + expect(emails).to.include.members(["ceo@edr.local", "linestaff@edr.local"]); + }); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/backoffice/login.cy.ts b/e2e/freight/cypress/e2e/backoffice/login.cy.ts new file mode 100644 index 000000000..0a729bdae --- /dev/null +++ b/e2e/freight/cypress/e2e/backoffice/login.cy.ts @@ -0,0 +1,41 @@ +/** + * The one UI-driven login spec for backoffice — every other spec uses the + * programmatic cy.loginBackoffice() session. + * Login form: apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx + * (Mantine inputs, matched by placeholder). + */ +describe("backoffice: UI login", () => { + it("redirects unauthenticated users to /auth", () => { + cy.clearCookies(); + cy.visit("/dashboard/overview"); + cy.location("pathname").should("eq", "/auth"); + }); + + it("logs in via the form and lands on the dashboard", () => { + cy.clearCookies(); + cy.visit("/auth"); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("ceo@edr.local"); + cy.get('input[placeholder="Enter your password"]').type( + Cypress.env("defaultPassword"), + { log: false }, + ); + cy.get('button[type="submit"]').click(); + + cy.location("pathname", { timeout: 20000 }).should("match", /^\/dashboard/); + cy.getCookie("auth-token").should("exist"); + cy.getCookie("refresh-token").should("exist"); + }); + + it("shows an error for wrong credentials and stays on /auth", () => { + cy.clearCookies(); + cy.visit("/auth"); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("ceo@edr.local"); + cy.get('input[placeholder="Enter your password"]').type("definitely-wrong"); + cy.get('button[type="submit"]').click(); + + cy.location("pathname").should("eq", "/auth"); + cy.getCookie("auth-token").should("not.exist"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/backoffice/smoke.cy.ts b/e2e/freight/cypress/e2e/backoffice/smoke.cy.ts new file mode 100644 index 000000000..db3086e71 --- /dev/null +++ b/e2e/freight/cypress/e2e/backoffice/smoke.cy.ts @@ -0,0 +1,49 @@ +/** + * Route-level smoke over the backoffice shell: each key module page loads + * without bouncing back to /auth and without an unhandled crash. Deep + * per-module behavior belongs in dedicated specs — this catches the broad + * "page is broken / route is dead / guard rejects seeded role" class. + * Routes from apps/edr-freight-web/backoffice/src/App.tsx. + */ +const ROUTES = [ + "/dashboard/overview", + "/dashboard/booking-requests", + "/dashboard/customers", + "/dashboard/invoices", + "/dashboard/contract-requests", + "/dashboard/shipment-requests", + "/dashboard/profile", +]; + +describe("backoffice: route smoke (ceo)", () => { + beforeEach(() => { + cy.loginBackoffice("ceo@edr.local"); + }); + + ROUTES.forEach((route) => { + it(`renders ${route}`, () => { + cy.visit(route); + cy.location("pathname").should("not.eq", "/auth"); + cy.location("pathname").should("contain", "/dashboard"); + cy.get("#root").should("not.be.empty"); + }); + }); +}); + +describe("backoffice: role-based access", () => { + it("line staff can reach the dashboard shell", () => { + cy.loginBackoffice("linestaff@edr.local"); + cy.visit("/dashboard/overview"); + cy.location("pathname").should("not.eq", "/auth"); + cy.get("#root").should("not.be.empty"); + }); + + it("operations officer can reach the dashboard shell", () => { + cy.loginBackoffice("operation@edr.local"); + cy.visit("/dashboard/overview"); + cy.location("pathname").should("not.eq", "/auth"); + cy.get("#root").should("not.be.empty"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/cross-app.cy.ts b/e2e/freight/cypress/e2e/flows/cross-app.cy.ts new file mode 100644 index 000000000..954ed4762 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/cross-app.cy.ts @@ -0,0 +1,69 @@ +/** + * Cross-app flow: same business objects seen from both directions — + * customer (portal, port 5273) and staff (backoffice, port 5283 = baseUrl). + * Different ports = different origins, so portal steps inside a test that + * also touches backoffice run inside cy.origin(). + * + * Cookie caveat: cookies ignore ports, so both apps share the localhost + * cookie jar. Always call the matching login command immediately before + * switching apps — cy.session restores the right cookie snapshot. + * + * This spec is the template for full journeys (booking → approval → + * scheduling → billing). It verifies both sides of the fence against + * seeded data via UI + API cross-checks. + */ +const portal = () => Cypress.env("portalUrl") as string; +const api = () => Cypress.env("apiUrl") as string; + +describe("flow: customer and staff see the same world", () => { + it("staff views booking requests, customer views bookings", () => { + // Staff side on the primary origin (baseUrl) first — the first origin a + // test visits becomes primary; every other origin needs cy.origin(). + cy.loginBackoffice("ceo@edr.local"); + cy.visit("/dashboard/booking-requests"); + cy.location("pathname").should("eq", "/dashboard/booking-requests"); + cy.get("#root").should("not.be.empty"); + + // Customer side — switch session first, then enter the portal origin. + cy.loginPortal("user@gmail.com"); + cy.origin(portal(), () => { + cy.visit("/bookings"); + cy.location("pathname").should("not.eq", "/login"); + cy.get("#root").should("not.be.empty"); + }); + }); + + it("staff and customer both resolve their own /api/me identity", () => { + cy.apiLogin("ceo@edr.local").then(({ token }) => { + cy.request({ + url: `${api()}/api/me`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("eq", 200); + }); + cy.apiLogin("user@gmail.com", Cypress.env("demoPassword")).then(({ token }) => { + cy.request({ + url: `${api()}/api/me`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("eq", 200); + }); + }); + + it("cy.origin: staff dashboard then portal in a single test", () => { + cy.loginBackoffice("ceo@edr.local"); + cy.visit("/dashboard/overview"); + cy.get("#root").should("not.be.empty"); + + cy.loginPortal("user@gmail.com"); + cy.origin(Cypress.env("portalUrl") as string, () => { + cy.visit("/portal"); + cy.location("pathname").should("not.eq", "/login"); + cy.get("#root").should("not.be.empty"); + }); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/portal/login.cy.ts b/e2e/freight/cypress/e2e/portal/login.cy.ts new file mode 100644 index 000000000..c26365a22 --- /dev/null +++ b/e2e/freight/cypress/e2e/portal/login.cy.ts @@ -0,0 +1,36 @@ +/** + * UI login for the customer portal (seeded demo user). + * Form: apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx. + * Portal is a different origin (port 5273), so specs visit it via absolute + * URL — each test here stays on that single origin, no cy.origin needed. + */ +const portal = () => Cypress.env("portalUrl") as string; + +describe("portal: UI login", () => { + it("logs in via the form", () => { + cy.clearCookies(); + cy.visit(`${portal()}/login`); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("user@gmail.com"); + cy.get('input[placeholder="Enter your password"]').type( + Cypress.env("demoPassword"), + { log: false }, + ); + cy.get('button[type="submit"]').click(); + + cy.location("pathname", { timeout: 20000 }).should("not.eq", "/login"); + cy.getCookie("auth-token").should("exist"); + }); + + it("rejects wrong credentials", () => { + cy.clearCookies(); + cy.visit(`${portal()}/login`); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("user@gmail.com"); + cy.get('input[placeholder="Enter your password"]').type("definitely-wrong"); + cy.get('button[type="submit"]').click(); + + cy.location("pathname").should("eq", "/login"); + cy.getCookie("auth-token").should("not.exist"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/portal/smoke.cy.ts b/e2e/freight/cypress/e2e/portal/smoke.cy.ts new file mode 100644 index 000000000..5372d7359 --- /dev/null +++ b/e2e/freight/cypress/e2e/portal/smoke.cy.ts @@ -0,0 +1,29 @@ +/** + * Route-level smoke over the customer portal. + * Routes from apps/edr-freight-web/portal/src/App.tsx. + */ +const portal = () => Cypress.env("portalUrl") as string; + +const ROUTES = ["/portal", "/bookings", "/contracts", "/billing", "/tracking"]; + +describe("portal: route smoke (demo customer)", () => { + beforeEach(() => { + cy.loginPortal("user@gmail.com"); + }); + + ROUTES.forEach((route) => { + it(`renders ${route}`, () => { + cy.visit(`${portal()}${route}`); + cy.location("pathname").should("not.eq", "/login"); + cy.get("#root").should("not.be.empty"); + }); + }); + + it("new booking wizard opens", () => { + cy.visit(`${portal()}/bookings/new`); + cy.location("pathname").should("eq", "/bookings/new"); + cy.get("#root").should("not.be.empty"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/fixtures/seed-users.sql b/e2e/freight/cypress/fixtures/seed-users.sql new file mode 100644 index 000000000..a9f60b286 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-users.sql @@ -0,0 +1,130 @@ +-- Test users for the freight e2e stack — replicates what the (disabled) +-- FreightStaffUsersSeeder + DemoUsersSeeder would write, without touching +-- API code. Idempotent: every insert is guarded by WHERE NOT EXISTS. +-- +-- Prerequisites (created by the API's always-on boot seeders): +-- iam.organizations key='edr_freight', iam.units key='edr_freight_app', +-- iam.positions keys ceo/chief/director/marketer/operation/ethiopian_gl/djibouti_gl. +-- +-- Passwords are pre-hashed (argon2id): +-- staff (@edr.local) → password@tria +-- demo (gmail.com) → 12345678 + +-- ── Demo organization ──────────────────────────────────────────────────────── +insert into iam.organizations (id, name, key, is_super_admin, is_government_organization, status) +select gen_random_uuid(), '{"en":"Demo IAM"}'::jsonb, 'demo_iam', false, true, 'Active' +where not exists (select 1 from iam.organizations where key = 'demo_iam'); + +-- ── Roles ──────────────────────────────────────────────────────────────────── +insert into iam.roles (id, key, name) +select gen_random_uuid(), v.key, jsonb_build_object('en', v.name) +from (values + ('edr_line_staff', 'edr_line_staff'), + ('edr_org_manager', 'edr_org_manager'), + ('edr_director', 'edr_director'), + ('edr_ceo', 'edr_ceo'), + ('edr_marketing', 'edr_marketing'), + ('edr_operations_officer', 'edr_operations_officer'), + ('edr_gl_ethiopia', 'edr_gl_ethiopia'), + ('edr_gl_djibouti', 'edr_gl_djibouti'), + ('demo_user1', 'Demo User1'), + ('demo_user2', 'Demo User2') +) v(key, name) +where not exists (select 1 from iam.roles r where r.key = v.key); + +-- ── Demo permissions + role grants ─────────────────────────────────────────── +insert into iam.permissions (id, key, name) +select gen_random_uuid(), v.key, jsonb_build_object('en', v.name) +from (values + ('can:demo:user1', 'Can access demo user1'), + ('can:demo:user2', 'Can access demo user2') +) v(key, name) +where not exists (select 1 from iam.permissions p where p.key = v.key); + +insert into iam.role_permissions (id, role_id, permission_id) +select gen_random_uuid(), r.id, p.id +from (values ('demo_user1', 'can:demo:user1'), ('demo_user2', 'can:demo:user2')) v(role_key, perm_key) +join iam.roles r on r.key = v.role_key +join iam.permissions p on p.key = v.perm_key +where not exists ( + select 1 from iam.role_permissions rp where rp.role_id = r.id and rp.permission_id = p.id +); + +-- ── Users ──────────────────────────────────────────────────────────────────── +insert into iam.users (id, email, username, name, status, is_active, has_set_password, user_type) +select gen_random_uuid(), v.email, v.username, jsonb_build_object('en', v.display), + 'accepted', true, true, 'employee' +from (values + ('linestaff@edr.local', 'linestaff', 'linestaff'), + ('chief@edr.local', 'chief', 'chief'), + ('director@edr.local', 'director', 'director'), + ('ceo@edr.local', 'ceo', 'ceo'), + ('marketer@edr.local', 'marketer', 'marketer'), + ('operation@edr.local', 'operation', 'operation'), + ('gl-et@edr.local', 'gl_et', 'gl_et'), + ('gl-dj@edr.local', 'gl_dj', 'gl_dj'), + ('user@gmail.com', 'user', 'Demo User 1'), + ('user2@gmail.com', 'user2', 'Demo User 2') +) v(email, username, display) +where not exists (select 1 from iam.users u where u.email = v.email); + +-- ── Credentials ────────────────────────────────────────────────────────────── +insert into iam.user_credentials (id, user_id, password, is_active) +select gen_random_uuid(), u.id, + case when u.email like '%@edr.local' + then '$argon2id$v=19$m=65536,t=3,p=4$JFEcHu4Kp55fsrVDPbHDPg$0NfnGzaE39T/qdmzte73oCkohC0Ri+f8DcrvAF4kyH4' -- password@tria + else '$argon2id$v=19$m=65536,t=3,p=4$aBwVFf7I74pqSJqe9cBoig$gCIKa+6dCAb2X86G+0IjgPtil127cx6A6mwhvLj00Bw' -- 12345678 + end, + true +from iam.users u +where (u.email like '%@edr.local' or u.email in ('user@gmail.com', 'user2@gmail.com')) + and not exists (select 1 from iam.user_credentials c where c.user_id = u.id); + +-- ── User → role (staff under edr_freight, demo under demo_iam) ────────────── +insert into iam.user_roles (id, user_id, role_id, organization_id) +select gen_random_uuid(), u.id, r.id, o.id +from (values + ('linestaff@edr.local', 'edr_line_staff', 'edr_freight'), + ('chief@edr.local', 'edr_org_manager', 'edr_freight'), + ('director@edr.local', 'edr_director', 'edr_freight'), + ('ceo@edr.local', 'edr_ceo', 'edr_freight'), + ('marketer@edr.local', 'edr_marketing', 'edr_freight'), + ('operation@edr.local', 'edr_operations_officer', 'edr_freight'), + ('gl-et@edr.local', 'edr_gl_ethiopia', 'edr_freight'), + ('gl-dj@edr.local', 'edr_gl_djibouti', 'edr_freight'), + ('user@gmail.com', 'demo_user1', 'demo_iam'), + ('user2@gmail.com', 'demo_user2', 'demo_iam') +) v(email, role_key, org_key) +join iam.users u on u.email = v.email +join iam.roles r on r.key = v.role_key +join iam.organizations o on o.key = v.org_key +where not exists (select 1 from iam.user_roles ur where ur.user_id = u.id and ur.role_id = r.id); + +-- ── Staff employees + position assignment (drives permissions) ─────────────── +insert into iam.employees (id, is_current, status, name, organization_id, unit_id, user_id) +select gen_random_uuid(), true, 'pending', u.name, o.id, un.id, u.id +from iam.users u +join iam.organizations o on o.key = 'edr_freight' +join iam.units un on un.key = 'edr_freight_app' and un.organization_id = o.id +where u.email like '%@edr.local' + and not exists (select 1 from iam.employees e where e.user_id = u.id); + +insert into iam.employee_positions (id, is_delegate, is_current, status, unit_id, employee_id, position_id) +select gen_random_uuid(), false, true, 'APPROVED', un.id, e.id, p.id +from (values + ('linestaff@edr.local', 'operation'), + ('chief@edr.local', 'chief'), + ('director@edr.local', 'director'), + ('ceo@edr.local', 'ceo'), + ('marketer@edr.local', 'marketer'), + ('operation@edr.local', 'operation'), + ('gl-et@edr.local', 'ethiopian_gl'), + ('gl-dj@edr.local', 'djibouti_gl') +) v(email, position_key) +join iam.users u on u.email = v.email +join iam.employees e on e.user_id = u.id +join iam.units un on un.key = 'edr_freight_app' +join iam.positions p on p.key = v.position_key and p.unit_id = un.id +where not exists ( + select 1 from iam.employee_positions ep where ep.employee_id = e.id and ep.position_id = p.id +); diff --git a/e2e/freight/cypress/fixtures/users.json b/e2e/freight/cypress/fixtures/users.json new file mode 100644 index 000000000..88c56149f --- /dev/null +++ b/e2e/freight/cypress/fixtures/users.json @@ -0,0 +1,16 @@ +{ + "staff": { + "lineStaff": { "email": "linestaff@edr.local", "role": "edr_line_staff" }, + "chief": { "email": "chief@edr.local", "role": "edr_org_manager" }, + "director": { "email": "director@edr.local", "role": "edr_director" }, + "ceo": { "email": "ceo@edr.local", "role": "edr_ceo" }, + "marketer": { "email": "marketer@edr.local", "role": "edr_marketing" }, + "operation": { "email": "operation@edr.local", "role": "edr_operations_officer" }, + "glEthiopia": { "email": "gl-et@edr.local", "role": "edr_gl_ethiopia" }, + "glDjibouti": { "email": "gl-dj@edr.local", "role": "edr_gl_djibouti" } + }, + "customers": { + "demo1": { "email": "user@gmail.com" }, + "demo2": { "email": "user2@gmail.com" } + } +} diff --git a/e2e/freight/cypress/support/commands.ts b/e2e/freight/cypress/support/commands.ts new file mode 100644 index 000000000..9fc1b83ee --- /dev/null +++ b/e2e/freight/cypress/support/commands.ts @@ -0,0 +1,95 @@ +/** + * Auth model (see apps/edr-freight-api + freight web apps): + * - POST {api}/api/auth/login { email, password } + * → flattened body { success, token, refreshToken } (response interceptor + * flattens /api/auth responses — no .data nesting). + * - Both web apps read cookies `auth-token` / `refresh-token` and attach + * `Authorization: Bearer `. + * - Cookies are port-agnostic on localhost, so portal and backoffice share + * one cookie jar. cy.session snapshots/restores cookies per session id, + * which keeps staff and customer sessions from clobbering each other — + * but inside a single test, switching apps requires re-invoking the + * matching login command first (see flows specs). + */ + +export interface LoginBody { + success: boolean; + token: string; + refreshToken: string; +} + +const apiUrl = () => Cypress.env("apiUrl") as string; +const password = () => Cypress.env("defaultPassword") as string; + +function apiLogin(email: string, pass?: string): Cypress.Chainable { + return cy + .request("POST", `${apiUrl()}/api/auth/login`, { + email, + password: pass ?? password(), + }) + .then((response) => { + expect(response.status).to.eq(201); + expect(response.body.token, "login token").to.be.a("string"); + return cy.wrap(response.body, { log: false }); + }); +} + +function sessionFor(app: "backoffice" | "portal", email: string, pass?: string) { + cy.session( + [app, email], + () => { + apiLogin(email, pass).then(({ token, refreshToken }) => { + cy.setCookie("auth-token", token); + cy.setCookie("refresh-token", refreshToken); + }); + }, + { + cacheAcrossSpecs: true, + validate() { + cy.getCookie("auth-token").then((cookie) => { + expect(cookie, "auth-token cookie").to.exist; + cy.request({ + url: `${apiUrl()}/api/me`, + headers: { Authorization: `Bearer ${cookie!.value}` }, + }) + .its("status") + .should("eq", 200); + }); + }, + }, + ); +} + +Cypress.Commands.add("apiLogin", (email: string, pass?: string) => apiLogin(email, pass)); + +Cypress.Commands.add("loginBackoffice", (email = "ceo@edr.local", pass?: string) => { + sessionFor("backoffice", email, pass); +}); + +Cypress.Commands.add("loginPortal", (email = "user@gmail.com", pass?: string) => { + // Demo portal users are seeded with a hardcoded password (DemoUsersSeeder), + // unlike staff users which use DEFAULT_PASSWORD. + sessionFor("portal", email, pass ?? (Cypress.env("demoPassword") as string)); +}); + +Cypress.Commands.add("visitPortal", (path = "/") => { + cy.visit(`${Cypress.env("portalUrl")}${path}`); +}); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** POST /api/auth/login, returns the flattened token body. */ + apiLogin(email: string, pass?: string): Chainable; + /** Cached programmatic staff session (default ceo@edr.local). */ + loginBackoffice(email?: string, pass?: string): Chainable; + /** Cached programmatic customer session (default user@gmail.com). */ + loginPortal(email?: string, pass?: string): Chainable; + /** cy.visit against the portal origin (env.portalUrl). */ + visitPortal(path?: string): Chainable; + } + } +} + +export {}; diff --git a/e2e/freight/cypress/support/e2e.ts b/e2e/freight/cypress/support/e2e.ts new file mode 100644 index 000000000..49bc8540b --- /dev/null +++ b/e2e/freight/cypress/support/e2e.ts @@ -0,0 +1,18 @@ +import "./commands"; + +// The API's user seeders are disabled in app code — the SQL fixture creates +// the staff + demo test users instead. Idempotent, runs before each spec file. +before(() => { + cy.task("db:seedUsers"); +}); + +// Third-party noise (PostHog, Google Maps, socket.io reconnects) can throw +// uncaught exceptions that are irrelevant to the assertion under test. App +// errors still fail tests via failed assertions / failed intercepts. +Cypress.on("uncaught:exception", (err) => { + const ignorable = [/posthog/i, /google/i, /websocket/i, /socket\.io/i, /ResizeObserver/i]; + if (ignorable.some((pattern) => pattern.test(err.message))) { + return false; + } + return true; +}); diff --git a/e2e/freight/package.json b/e2e/freight/package.json new file mode 100644 index 000000000..4097b09da --- /dev/null +++ b/e2e/freight/package.json @@ -0,0 +1,22 @@ +{ + "name": "@edr/freight-e2e", + "version": "0.0.0", + "private": true, + "description": "Cypress end-to-end tests for the EDR freight system (portal + backoffice + API)", + "scripts": { + "cy:open": "cypress open --e2e", + "cy:run": "cypress run", + "cy:run:backoffice": "cypress run --spec 'cypress/e2e/backoffice/**'", + "cy:run:portal": "cypress run --spec 'cypress/e2e/portal/**'", + "cy:run:api": "cypress run --spec 'cypress/e2e/api/**'", + "cy:run:flows": "cypress run --spec 'cypress/e2e/flows/**'", + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "cypress": "^15.3.0", + "pg": "^8.13.0", + "typescript": "^5.5.4", + "@types/node": "^22.0.0", + "@types/pg": "^8.11.0" + } +} diff --git a/e2e/freight/tsconfig.json b/e2e/freight/tsconfig.json new file mode 100644 index 000000000..c2b049986 --- /dev/null +++ b/e2e/freight/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["cypress", "node"] + }, + "include": ["cypress/**/*.ts", "cypress.config.ts"] +} diff --git a/package.json b/package.json index 8909de6cf..d01da8714 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,11 @@ "format": "prettier --write \"**/*.{ts,tsx,json,md}\"", "docker:build": "docker compose build", "docker:up": "docker compose up -d", + "e2e:freight:up": "docker compose -f docker-compose.e2e.yaml up -d --build --wait", + "e2e:freight:open": "pnpm --filter @edr/freight-e2e cy:open", + "e2e:freight:run": "pnpm --filter @edr/freight-e2e cy:run", + "e2e:freight:ci": "docker compose -f docker-compose.e2e.yaml --profile cypress run --rm cypress", + "e2e:freight:down": "docker compose -f docker-compose.e2e.yaml down -v --remove-orphans", "prepare": "husky" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f59a388ba..72daf6a26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -580,7 +580,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1203,6 +1203,24 @@ importers: specifier: ^5.5.4 version: 5.9.3 + e2e/freight: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + '@types/pg': + specifier: ^8.11.0 + version: 8.20.0 + cypress: + specifier: ^15.3.0 + version: 15.18.1 + pg: + specifier: ^8.13.0 + version: 8.21.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + packages/api-common: dependencies: '@edr/types': @@ -1743,6 +1761,13 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@cypress/request@4.0.1': + resolution: {integrity: sha512-y20e+e6dFYkOUUJLVUZTsJRuTiXZaUQ32WD+R/ux/HBybbTx4ge7cNINcua0pU8+SNkKuRbOF12mBmzuzM8n5w==} + engines: {node: '>= 14.17.0'} + + '@cypress/xvfb@1.2.4': + resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==} + '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} @@ -4702,6 +4727,9 @@ packages: '@types/node@20.19.42': resolution: {integrity: sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/node@24.13.1': resolution: {integrity: sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==} @@ -4760,6 +4788,9 @@ packages: '@types/signature_pad@2.3.6': resolution: {integrity: sha512-v3j92gCQJoxomHhd+yaG4Vsf8tRS/XbzWKqDv85UsqjMGy4zhokuwKe4b6vhbgncKkh+thF+gpz6+fypTtnFqQ==} + '@types/sinonjs__fake-timers@8.1.1': + resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==} + '@types/sizzle@2.3.10': resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==} @@ -4778,6 +4809,9 @@ packages: '@types/tinymce@4.6.9': resolution: {integrity: sha512-pDxBUlV4v1jgJ97SlnVOSyf3KUy3OQ3s5Ddpfh1L9M5lXlBmX7TJ2OLSozx1WBxp91acHvYPWDwz2U/kMM1oxQ==} + '@types/tmp@0.2.6': + resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -5372,6 +5406,9 @@ packages: append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + archiver-utils@2.1.0: resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} engines: {node: '>= 6'} @@ -5472,6 +5509,13 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -5504,6 +5548,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + atob@2.1.2: resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} engines: {node: '>= 4.5.0'} @@ -5527,6 +5575,12 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axe-core@4.12.0: resolution: {integrity: sha512-FTavr/7Ba0IptwGOPxnQvdyW2tAsdLBMTBXz7rKH6xJ2skpyxpBxyHkDdBs4lf69yRqYpkqCdfhnwS8YULGOmg==} engines: {node: '>=4'} @@ -5663,6 +5717,9 @@ packages: resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} engines: {node: '>=10.0.0'} + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bcrypt@6.0.0: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} @@ -5684,12 +5741,18 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blob-util@2.0.2: + resolution: {integrity: sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==} + block-stream2@2.1.0: resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + body-parser@1.20.5: resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -5794,6 +5857,10 @@ packages: resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==} engines: {node: '>=0.10.0'} + cachedir@2.4.0: + resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} + engines: {node: '>=6'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -5832,6 +5899,9 @@ packages: resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} engines: {node: '>=10.0.0'} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + cfb@1.2.2: resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} engines: {node: '>=0.8'} @@ -5890,6 +5960,10 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -5931,6 +6005,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-table3@0.6.1: + resolution: {integrity: sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==} + engines: {node: 10.* || >= 12.*} + cli-table3@0.6.5: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} @@ -5939,6 +6017,10 @@ packages: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + cli-width@1.1.1: resolution: {integrity: sha512-eMU2akIeEIkCxGXUNmDnJq1KzOIiPnJ+rKqRe6hcxE3vIOPvpMrBYOn/Bl7zNlYJj/zQxXquAnozHUCf9Whnsg==} @@ -6022,6 +6104,10 @@ packages: resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} engines: {node: '>=0.1.90'} + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -6045,10 +6131,18 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + comment-json@5.0.0: resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==} engines: {node: '>= 6'} + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -6140,6 +6234,9 @@ packages: core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -6254,6 +6351,11 @@ packages: resolution: {integrity: sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==} engines: {node: '>=0.4.0'} + cypress@15.18.1: + resolution: {integrity: sha512-JtkTVtUE2lvLYgZCaug+Uai0H9IqsJirlBO49c87QwG0bJUGvAUVBz1EJve0b0oaYP244Ew9M0BkrHpcqkYxmw==} + engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0} + hasBin: true + d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} @@ -6305,6 +6407,10 @@ packages: resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} engines: {node: '>=12'} + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -6587,6 +6693,9 @@ packages: ebec@2.3.0: resolution: {integrity: sha512-bt+0tSL7223VU3PSVi0vtNLZ8pO1AfWolcPPMk2a/a5H+o/ZU9ky0n3A0zhrR4qzJTN61uPsGIO4ShhOukdzxA==} + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -6913,6 +7022,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter2@6.4.7: + resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==} + eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} @@ -6941,6 +7053,10 @@ packages: resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} engines: {node: '>=8.3.0'} + execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -6953,6 +7069,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + executable@4.1.1: + resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} + engines: {node: '>=4'} + exit-hook@1.1.1: resolution: {integrity: sha512-MsG3prOVw1WtLXAZbM3KiYtooKR1LvxHh3VHsVtIy0uiUu8usxgB/94DP2HxtD/661lLdB6yzQ09lGJSQr6nkg==} engines: {node: '>=0.10.0'} @@ -6998,6 +7118,9 @@ packages: resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==} engines: {node: '>=0.10.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extglob@2.0.4: resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==} engines: {node: '>=0.10.0'} @@ -7007,6 +7130,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + eyes@0.1.8: resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} engines: {node: '> 0.1.90'} @@ -7191,6 +7318,9 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + fork-ts-checker-webpack-plugin@9.1.0: resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} engines: {node: '>=14.21.3'} @@ -7262,6 +7392,10 @@ packages: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} @@ -7362,6 +7496,9 @@ packages: resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==} engines: {node: '>=0.10.0'} + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true @@ -7406,6 +7543,10 @@ packages: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} @@ -7501,6 +7642,10 @@ packages: resolution: {integrity: sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==} engines: {node: '>=0.10.0'} + hasha@5.2.2: + resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} + engines: {node: '>=8'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -7571,6 +7716,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-signature@1.4.0: + resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} + engines: {node: '>=0.10'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -7579,6 +7728,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -7684,6 +7837,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + ini@4.1.1: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -7852,6 +8009,10 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -7969,6 +8130,9 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -8257,6 +8421,9 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jsdom@25.0.1: resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} engines: {node: '>=18'} @@ -8286,12 +8453,18 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} json-stream@1.0.0: resolution: {integrity: sha512-H/ZGY0nIAg3QcOwE1QN/rK/Fa7gJn7Ii5obwp6zyPO4xiPNwpIMjqy2gwjBEGqzkF/vSWEIBQCBuN19hYiL6Qg==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -8330,6 +8503,10 @@ packages: jspdf@4.2.1: resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + jsprim@2.0.2: + resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} + engines: {'0': node >=0.6.0} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -8508,6 +8685,10 @@ packages: resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} engines: {node: '>=18.0.0'} + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + little-state-machine@4.8.1: resolution: {integrity: sha512-liPHqaWMQ7rzZryQUDnbZ1Gclnnai3dIyaJ0nAgwZRXMzqbYrydrlCI0NDojRUbE5VYh5vu6hygEUZiH77nQkQ==} peerDependencies: @@ -9194,6 +9375,9 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + ospath@1.2.2: + resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -9562,6 +9746,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9609,6 +9797,9 @@ packages: resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} engines: {node: '>= 14'} + proxy-from-env@1.0.0: + resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -10083,6 +10274,9 @@ packages: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} + request-progress@3.0.0: + resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -10410,6 +10604,10 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -10516,6 +10714,11 @@ packages: resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} engines: {node: '>=0.8'} + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -10593,6 +10796,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -10766,6 +10973,12 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + systeminformation@5.33.0: + resolution: {integrity: sha512-0LYSL01CCbjVeJG7iXI8fUCFU76zMjzbHd/EU3or4QpSFYCLMgslR11prwHuA3siz5jmOkqoLhjgOyDRmXBKmA==} + engines: {node: '>=10.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -10883,6 +11096,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + throttleit@1.0.1: + resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} + through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} @@ -11006,6 +11222,10 @@ packages: traverse@0.3.9: resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + trim-canvas@0.1.2: resolution: {integrity: sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ==} @@ -11090,6 +11310,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turbo@2.9.16: resolution: {integrity: sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==} hasBin: true @@ -11097,6 +11320,9 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -11113,6 +11339,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -11285,6 +11515,10 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + unzipper@0.10.14: resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} @@ -11425,6 +11659,10 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} @@ -11763,6 +12001,10 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + year@0.2.1: resolution: {integrity: sha512-9GnJUZ0QM4OgXuOzsKNzTJ5EOkums1Xc+3YQXp+Q+UxFjf7zLucp9dQ8QMIft0Szs1E1hUiXFim1OYfEKFq97w==} engines: {node: '>=0.8'} @@ -11889,11 +12131,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -11928,7 +12170,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -11937,7 +12179,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -11952,9 +12201,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -11969,13 +12218,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12128,6 +12377,18 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -12286,6 +12547,33 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@cypress/request@4.0.1': + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 4.0.5 + http-signature: 1.4.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.15.2 + safe-buffer: 5.2.1 + tough-cookie: 5.1.2 + tunnel-agent: 0.6.0 + + '@cypress/xvfb@1.2.4(supports-color@8.1.1)': + dependencies: + debug: 3.2.7(supports-color@8.1.1) + lodash.once: 4.1.1 + transitivePeerDependencies: + - supports-color + '@date-fns/tz@1.5.0': {} '@dotenvx/dotenvx@1.71.0': @@ -12324,7 +12612,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -12490,7 +12778,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -12636,7 +12924,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -13803,7 +14091,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -15871,7 +16159,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -16162,6 +16450,130 @@ snapshots: - utf-8-validate - vite + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': + dependencies: + '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) + '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) + '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) + '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) + '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 7.17.8(react@19.2.6) + '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) + '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf/renderer': 4.5.1(react@19.2.6) + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + '@tabler/icons-react': 3.44.0(react@19.2.6) + '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) + '@tanstack/react-query': 5.101.0(react@19.2.6) + '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) + '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) + '@types/dompurify': 3.2.0 + '@types/node': 24.13.1 + '@types/tinymce': 4.6.9 + axios: 1.17.0 + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + date-fns: 3.6.0 + dayjs: 1.11.21 + dompurify: 3.4.8 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-calendar-new: 1.1.0 + file-type: 18.7.0 + framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + html2canvas: 1.4.1 + i18next: 25.10.10(typescript@5.9.3) + i18next-browser-languagedetector: 8.2.1 + jquery: 3.7.1 + js-cookie: 3.0.8 + jspdf: 3.0.4 + lodash: 4.18.1 + lucide-react: 0.513.0(react@19.2.6) + mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) + next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + path: 0.12.7 + pdf-lib: 1.17.1 + qs: 6.15.2 + react: 19.2.6 + react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) + react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + react-dropzone: 14.4.1(react@19.2.6) + react-hook-form: 7.77.0(react@19.2.6) + react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-icons: 5.6.0(react@19.2.6) + react-image-crop: 11.0.10(react@19.2.6) + react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) + react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) + react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) + rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) + socket.io-client: 4.8.3 + sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tailwind-merge: 3.6.0 + tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) + tailwindcss: 4.3.0 + tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) + tinymce: 7.9.3 + url: 0.11.4 + vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + xlsx: 0.18.5 + zod: 3.25.76 + transitivePeerDependencies: + - '@babel/core' + - '@emotion/is-prop-valid' + - '@mui/icons-material' + - '@mui/material' + - '@mui/x-date-pickers' + - '@types/prop-types' + - '@types/react' + - '@types/react-dom' + - bufferutil + - debug + - pdfjs-dist + - prop-types + - react-is + - react-native + - redux + - rolldown + - rollup + - supports-color + - typescript + - utf-8-validate + - vite + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -16376,6 +16788,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + '@types/node@24.13.1': dependencies: undici-types: 7.18.2 @@ -16442,6 +16858,8 @@ snapshots: '@types/signature_pad@2.3.6': {} + '@types/sinonjs__fake-timers@8.1.1': {} + '@types/sizzle@2.3.10': {} '@types/stack-utils@2.0.3': {} @@ -16464,6 +16882,8 @@ snapshots: dependencies: '@types/jquery': 4.0.1 + '@types/tmp@0.2.6': {} + '@types/trusted-types@2.0.7': optional: true @@ -16514,7 +16934,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -16524,7 +16944,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -16543,7 +16963,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -16558,7 +16978,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -16838,7 +17258,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -17093,6 +17513,8 @@ snapshots: append-field@1.0.0: {} + arch@2.2.0: {} + archiver-utils@2.1.0: dependencies: glob: 7.2.3 @@ -17240,6 +17662,12 @@ snapshots: asap@2.0.6: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + assertion-error@2.0.1: {} assign-symbols@1.0.0: {} @@ -17264,6 +17692,8 @@ snapshots: asynckit@0.4.0: {} + at-least-node@1.0.0: {} + atob@2.1.2: {} attr-accept@2.2.5: {} @@ -17285,6 +17715,10 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + axe-core@4.12.0: {} axios@1.17.0: @@ -17348,6 +17782,16 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): + dependencies: + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + picomatch: 4.0.4 + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - supports-color + babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -17442,6 +17886,10 @@ snapshots: basic-ftp@5.3.1: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + bcrypt@6.0.0: dependencies: node-addon-api: 8.8.0 @@ -17466,12 +17914,16 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + blob-util@2.0.2: {} + block-stream2@2.1.0: dependencies: readable-stream: 3.6.2 bluebird@3.4.7: {} + bluebird@3.7.2: {} + body-parser@1.20.5: dependencies: bytes: 3.1.2 @@ -17493,7 +17945,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -17624,6 +18076,8 @@ snapshots: union-value: 1.0.1 unset-value: 1.0.0 + cachedir@2.4.0: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -17665,6 +18119,8 @@ snapshots: svg-pathdata: 6.0.3 optional: true + caseless@0.12.0: {} + cfb@1.2.2: dependencies: adler-32: 1.3.1 @@ -17731,6 +18187,8 @@ snapshots: ci-info@3.9.0: {} + ci-info@4.4.0: {} + citty@0.1.6: dependencies: consola: 3.4.2 @@ -17774,6 +18232,12 @@ snapshots: cli-spinners@2.9.2: {} + cli-table3@0.6.1: + dependencies: + string-width: 4.2.3 + optionalDependencies: + colors: 1.4.0 + cli-table3@0.6.5: dependencies: string-width: 4.2.3 @@ -17785,6 +18249,11 @@ snapshots: slice-ansi: 5.0.0 string-width: 7.2.0 + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.2 + cli-width@1.1.1: {} cli-width@4.1.0: {} @@ -17858,6 +18327,9 @@ snapshots: colors@1.0.3: {} + colors@1.4.0: + optional: true + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -17872,11 +18344,15 @@ snapshots: commander@4.1.1: {} + commander@6.2.1: {} + comment-json@5.0.0: dependencies: array-timsort: 1.0.3 esprima: 4.0.1 + common-tags@1.8.2: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -17953,6 +18429,8 @@ snapshots: core-js@3.49.0: {} + core-util-is@1.0.2: {} + core-util-is@1.0.3: {} cors@2.8.6: @@ -18084,6 +18562,48 @@ snapshots: cycle@1.0.3: {} + cypress@15.18.1: + dependencies: + '@cypress/request': 4.0.1 + '@cypress/xvfb': 1.2.4(supports-color@8.1.1) + '@types/sinonjs__fake-timers': 8.1.1 + '@types/sizzle': 2.3.10 + '@types/tmp': 0.2.6 + arch: 2.2.0 + blob-util: 2.0.2 + bluebird: 3.7.2 + buffer: 5.7.1 + cachedir: 2.4.0 + chalk: 4.1.2 + ci-info: 4.4.0 + cli-table3: 0.6.1 + commander: 6.2.1 + common-tags: 1.8.2 + dayjs: 1.11.21 + debug: 4.4.3(supports-color@8.1.1) + eventemitter2: 6.4.7 + execa: 4.1.0 + executable: 4.1.1 + fs-extra: 9.1.0 + hasha: 5.2.2 + is-installed-globally: 0.4.0 + listr2: 9.0.5 + lodash: 4.18.1 + log-symbols: 4.1.0 + minimist: 1.2.8 + ospath: 1.2.2 + pretty-bytes: 5.6.0 + process: 0.11.10 + proxy-from-env: 1.0.0 + request-progress: 3.0.0 + supports-color: 8.1.1 + systeminformation: 5.33.0 + tmp: 0.2.7 + tree-kill: 1.2.2 + tslib: 1.14.1 + untildify: 4.0.0 + yauzl: 3.4.0 + d3-array@3.2.4: dependencies: internmap: 2.0.3 @@ -18126,6 +18646,10 @@ snapshots: dargs@8.1.0: {} + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@6.0.2: {} @@ -18175,9 +18699,11 @@ snapshots: dependencies: ms: 2.0.0 - debug@3.2.7: + debug@3.2.7(supports-color@8.1.1): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 debug@4.4.3(supports-color@5.5.0): dependencies: @@ -18185,6 +18711,12 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + decamelize@1.2.0: {} decimal.js-light@2.5.1: {} @@ -18366,6 +18898,11 @@ snapshots: ebec@2.3.0: {} + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -18407,7 +18944,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -18427,7 +18964,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -18651,7 +19188,7 @@ snapshots: eslint-import-resolver-node@0.3.10: dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@8.1.1) is-core-module: 2.16.2 resolve: 2.0.0-next.7 transitivePeerDependencies: @@ -18660,7 +19197,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -18674,7 +19211,7 @@ snapshots: eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 @@ -18690,7 +19227,7 @@ snapshots: array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7 + debug: 3.2.7(supports-color@8.1.1) doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 @@ -18788,7 +19325,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -18854,6 +19391,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter2@6.4.7: {} + eventemitter2@6.4.9: {} eventemitter3@4.0.7: {} @@ -18886,6 +19425,18 @@ snapshots: unzipper: 0.10.14 uuid: 8.3.2 + execa@4.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -18925,6 +19476,10 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + executable@4.1.1: + dependencies: + pify: 2.3.0 + exit-hook@1.1.1: {} exit@0.1.2: {} @@ -19000,7 +19555,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -19036,6 +19591,8 @@ snapshots: assign-symbols: 1.0.0 is-extendable: 1.0.1 + extend@3.0.2: {} + extglob@2.0.4: dependencies: array-unique: 0.3.2 @@ -19051,7 +19608,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -19059,6 +19616,8 @@ snapshots: transitivePeerDependencies: - supports-color + extsprintf@1.3.0: {} + eyes@0.1.8: {} falsey@0.3.2: @@ -19200,7 +19759,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -19266,6 +19825,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forever-agent@0.6.1: {} + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0): dependencies: '@babel/code-frame': 7.29.7 @@ -19341,6 +19902,13 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-monkey@1.1.0: {} fs.realpath@1.0.0: {} @@ -19434,12 +20002,16 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color get-value@2.0.6: {} + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + giget@2.0.0: dependencies: citty: 0.1.6 @@ -19501,6 +20073,10 @@ snapshots: dependencies: ini: 4.1.1 + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + globals@13.24.0: dependencies: type-fest: 0.20.2 @@ -19623,6 +20199,11 @@ snapshots: is-number: 3.0.0 kind-of: 4.0.0 + hasha@5.2.2: + dependencies: + is-stream: 2.0.1 + type-fest: 0.8.1 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -19702,24 +20283,32 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color + http-signature@1.4.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 2.0.2 + sshpk: 1.18.0 + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color + human-signals@1.1.1: {} + human-signals@2.1.0: {} human-signals@5.0.0: {} @@ -19795,6 +20384,8 @@ snapshots: inherits@2.0.4: {} + ini@2.0.0: {} + ini@4.1.1: {} input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6): @@ -19960,6 +20551,11 @@ snapshots: dependencies: is-docker: 3.0.0 + is-installed-globally@0.4.0: + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + is-interactive@1.0.0: {} is-interactive@2.0.0: {} @@ -20051,6 +20647,8 @@ snapshots: dependencies: which-typed-array: 1.1.22 + is-typedarray@1.0.0: {} + is-unicode-supported@0.1.0: {} is-unicode-supported@1.3.0: {} @@ -20124,7 +20722,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -20514,6 +21112,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsbn@0.1.1: {} + jsdom@25.0.1: dependencies: cssstyle: 4.6.0 @@ -20554,10 +21154,14 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stream@1.0.0: {} + json-stringify-safe@5.0.1: {} + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -20626,6 +21230,13 @@ snapshots: dompurify: 3.4.8 html2canvas: 1.4.1 + jsprim@2.0.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -20778,7 +21389,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -20800,6 +21411,15 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + little-state-machine@4.8.1(react@19.2.6): dependencies: react: 19.2.6 @@ -21493,6 +22113,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + ospath@1.2.2: {} + outvariant@1.4.3: {} own-keys@1.0.1: @@ -21531,7 +22153,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -21658,8 +22280,7 @@ snapshots: perfect-debounce@1.0.0: {} - performance-now@2.1.0: - optional: true + performance-now@2.1.0: {} pg-cloudflare@1.4.0: optional: true @@ -21814,6 +22435,8 @@ snapshots: prettier@3.8.3: {} + pretty-bytes@5.6.0: {} + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -21860,7 +22483,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -21870,6 +22493,8 @@ snapshots: transitivePeerDependencies: - supports-color + proxy-from-env@1.0.0: {} + proxy-from-env@1.1.0: {} proxy-from-env@2.1.0: {} @@ -21887,7 +22512,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -22127,6 +22752,15 @@ snapshots: - '@babel/core' - react-is + react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - react-is + react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -22568,6 +23202,10 @@ snapshots: repeat-string@1.6.1: {} + request-progress@3.0.0: + dependencies: + throttleit: 1.0.1 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -22688,7 +23326,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -22806,7 +23444,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -22990,6 +23628,11 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} smob@1.6.2: {} @@ -23019,7 +23662,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -23029,7 +23672,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -23040,7 +23683,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -23049,7 +23692,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -23061,7 +23704,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -23122,6 +23765,18 @@ snapshots: dependencies: frac: 1.1.2 + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + stable-hash@0.0.5: {} stack-trace@0.0.10: {} @@ -23202,6 +23857,11 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -23324,6 +23984,24 @@ snapshots: transitivePeerDependencies: - '@babel/core' + styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@emotion/is-prop-valid': 1.4.0 + '@emotion/stylis': 0.8.5 + '@emotion/unitless': 0.7.5 + babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) + css-to-react-native: 3.2.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 19.2.7 + shallowequal: 1.1.0 + supports-color: 5.5.0 + transitivePeerDependencies: + - '@babel/core' + styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -23349,7 +24027,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -23403,6 +24081,8 @@ snapshots: symbol-tree@3.2.4: {} + systeminformation@5.33.0: {} + tabbable@6.4.0: {} tagged-tag@1.0.0: {} @@ -23530,6 +24210,8 @@ snapshots: dependencies: any-promise: 1.3.0 + throttleit@1.0.1: {} + through2@2.0.5: dependencies: readable-stream: 2.3.8 @@ -23639,6 +24321,8 @@ snapshots: traverse@0.3.9: {} + tree-kill@1.2.2: {} + trim-canvas@0.1.2: {} ts-api-utils@2.5.0(typescript@5.9.3): @@ -23743,6 +24427,10 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + turbo@2.9.16: optionalDependencies: '@turbo/darwin-64': 2.9.16 @@ -23754,6 +24442,8 @@ snapshots: tw-animate-css@1.4.0: {} + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -23764,6 +24454,8 @@ snapshots: type-fest@0.21.3: {} + type-fest@0.8.1: {} + type-fest@4.41.0: {} type-fest@5.7.0: @@ -23842,7 +24534,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -23866,7 +24558,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -23968,6 +24660,8 @@ snapshots: until-async@3.0.2: {} + untildify@4.0.0: {} + unzipper@0.10.14: dependencies: big-integer: 1.6.52 @@ -24118,6 +24812,12 @@ snapshots: - '@types/react' - '@types/react-dom' + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + victory-vendor@36.9.2: dependencies: '@types/d3-array': 3.2.2 @@ -24161,7 +24861,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -24197,7 +24897,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -24538,6 +25238,10 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + year@0.2.1: {} yn@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ecf919f09..e5e748478 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - "apps/edr-passenger-web/*" - "packages/*" - "packages/config/*" + - "e2e/*" verifyDepsBeforeRun: warn allowBuilds: "@nestjs/core": true @@ -14,6 +15,7 @@ allowBuilds: argon2: true bcrypt: true core-js: true + cypress: true es5-ext: true esbuild: true highlight.js: true From b616f520ae02bcbccc12b9a54511c45c1423b9d2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 09:39:49 +0000 Subject: [PATCH 03/14] feat(fleet): validate vehicle plate numbers against a letters-and-digits format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plate, power-plate and trailer accepted any free text — a vehicle could be saved with a plate of "assadasd". They must be letters, a hyphen, then digits, like ET-9875 or AA-8642. The server now enforces it on CreateVehicleDto (and UpdateVehicleDto via PartialType): each plate is trimmed and upper-cased, then matched against ^[A-Z]{2,3}-\d{2,6}$, so "et-9875" is accepted and stored as ET-9875 while an empty optional trailer/power plate still passes. The fleet form gains the same check inline: FleetFormFieldDef takes an optional pattern, the dialog tests it on submit against the upper-cased value, and the vehicle config points plate and trailer at a regex that mirrors the server's. Co-Authored-By: Claude Opus 4.8 --- .../vehicles/dto/create-vehicle.dto.spec.ts | 61 +++++++++++++++++++ .../vehicles/dto/create-vehicle.dto.ts | 29 ++++++++- .../src/components/fleet/FleetFormDialog.tsx | 10 +++ .../src/pages/fleet/config/resources.ts | 5 ++ .../src/pages/fleet/config/vehicles.ts | 14 ++++- 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts new file mode 100644 index 000000000..5696f00cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts @@ -0,0 +1,61 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CreateVehicleDto } from './create-vehicle.dto'; + +const base = { + vehicleType: 'TRUCK', + manufacturer: 'IVECO', + model: 'HYT', + year: 2020, + fuelType: 'DIESEL', + capacity: 0, + status: 'ACTIVE', +}; + +const errorsFor = (over: Record) => + validate(plainToInstance(CreateVehicleDto, { ...base, ...over })); + +const plateErrors = ( + errors: Awaited>, + property: string, +) => errors.find((e) => e.property === property && e.constraints?.matches); + +describe('CreateVehicleDto — plate format', () => { + it('accepts a plate like ET-9875', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'ET-9875' }), 'plateNumber')).toBeUndefined(); + }); + + it('accepts a plate like AA-8642', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'AA-8642' }), 'plateNumber')).toBeUndefined(); + }); + + it('upper-cases a lower-case plate before validating', async () => { + const dto = plainToInstance(CreateVehicleDto, { ...base, plateNumber: 'et-9875' }); + expect(dto.plateNumber).toBe('ET-9875'); + expect(plateErrors(await validate(dto), 'plateNumber')).toBeUndefined(); + }); + + it('rejects a free-text plate like assadasd', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'assadasd' }), 'plateNumber')).toBeDefined(); + }); + + it('rejects a plate with no letters or no digits', async () => { + expect(plateErrors(await errorsFor({ plateNumber: '1234' }), 'plateNumber')).toBeDefined(); + expect(plateErrors(await errorsFor({ plateNumber: 'ABCD' }), 'plateNumber')).toBeDefined(); + }); + + it('rejects a bad trailer plate but allows a valid one', async () => { + expect( + plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'asdasdasda' }), 'trailerPlateNo'), + ).toBeDefined(); + expect( + plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'AA-8642' }), 'trailerPlateNo'), + ).toBeUndefined(); + }); + + it('allows an empty trailer plate (optional)', async () => { + const errors = await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: '' }); + expect(plateErrors(errors, 'trailerPlateNo')).toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 33d441ecb..4dda3c053 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,7 +1,30 @@ -import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator'; +import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator'; +import { Transform } from 'class-transformer'; import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; +/** + * A vehicle plate is two or three letters, a hyphen, then two to six digits — + * e.g. ET-9875 or AA-8642. Kept in one place so plate, power-plate and trailer + * all match and the message stays consistent. + */ +export const VEHICLE_PLATE_REGEX = /^[A-Z]{2,3}-\d{2,6}$/; +export const VEHICLE_PLATE_MESSAGE = + 'must be letters and numbers like ET-9875 or AA-8642'; + +/** + * Trim and upper-case a plate before validating, so "et-9875" is accepted. An + * empty optional plate (trailer/power) becomes undefined so @IsOptional skips it + * rather than failing the pattern. + */ +const normalizePlate = ({ value }: { value: unknown }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim().toUpperCase(); + return trimmed === '' ? undefined : trimmed; +}; + export class CreateVehicleDto { + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() plateNumber!: string; @@ -47,10 +70,14 @@ export class CreateVehicleDto { code?: string; @IsOptional() + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Power plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() powerPlateNo?: string; @IsOptional() + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Trailer plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() trailerPlateNo?: string; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 8686f9cdf..5d1247358 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -249,6 +249,16 @@ const FleetFormDialog = ({ } } } + + // Format check (e.g. plate numbers). Skipped for an empty optional field — + // "required" above already owns the empty case. Upper-cased to match the + // server, which stores plates upper-case. + if (field.pattern && stringValue && stringValue !== FLEET_SELECT_NONE) { + const candidate = field.pattern.uppercase === false ? stringValue : stringValue.toUpperCase(); + if (!field.pattern.regex.test(candidate)) { + next[field.name] = field.pattern.message; + } + } }); setErrors(next); return Object.keys(next).length === 0; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 3487cbed8..0e6633eab 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -69,6 +69,11 @@ export interface FleetFormFieldDef extends FormFieldDef { * (e.g. a license expiry); "past" (default) = cannot be in the future. */ dateBound?: "past" | "future"; + /** + * Format the value must match, checked on submit. The value is upper-cased and + * trimmed before the test, matching the server. Empty optional fields skip it. + */ + pattern?: { regex: RegExp; message: string; uppercase?: boolean }; } export interface FleetListFilterDef { diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index eb1508601..f25be0ff7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -1,5 +1,15 @@ import type { FleetResourceConfig } from "./resources"; +/** + * A plate is two or three letters, a hyphen, then two to six digits — ET-9875, + * AA-8642. Mirrors VEHICLE_PLATE_REGEX on the API so the form and the server + * agree on what a plate looks like. + */ +const PLATE_PATTERN = { + regex: /^[A-Z]{2,3}-\d{2,6}$/, + message: "Use letters and numbers like ET-9875 or AA-8642", +}; + const VEHICLE_TYPE_OPTIONS = [ { label: "Truck", value: "TRUCK" }, { label: "Van", value: "VAN" }, @@ -77,9 +87,9 @@ export const vehiclesConfig: FleetResourceConfig = { ], formFields: [ { name: "code", label: "Code", type: "text" }, - { name: "plateNumber", label: "Power Plate No", type: "text", required: true }, + { name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN }, // { name: "powerPlateNo", label: "Power Plate No", type: "text" }, - { name: "trailerPlateNo", label: "Trailer Plate No", type: "text" }, + { name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN }, { name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS }, { name: "manufacturer", label: "Manufacturer", type: "text", required: true }, { name: "model", label: "Model", type: "text", required: true }, From 1fae0752a062d05d9eac9c7317714d28793b9597 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 11:02:03 +0000 Subject: [PATCH 04/14] =?UTF-8?q?Ticket:=20Warehouse=20dashboard=20Needs?= =?UTF-8?q?=20Attention=20cards=20=E2=80=94=20fix=20trucks-on-site=20aging?= =?UTF-8?q?=20counters=20(EDR=20trucks,=20UNLOADED=20status)=20and=20make?= =?UTF-8?q?=20each=20card=20open=20the=20exact=20list=20behind=20its=20cou?= =?UTF-8?q?nt=20via=20new=20inventory=20drill-down=20filters.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2440000000000-AddHandoverEdrAssignment.ts | 35 ++ .../modules/last-mile/last-mile.service.ts | 12 +- .../warehouses/dto/filter-inventory.dto.ts | 26 +- .../entities/booking-handover.entity.ts | 9 +- .../warehouses/exit-inspection-blocks.spec.ts | 66 ++++ .../modules/warehouses/handover.service.ts | 160 +++++++- .../warehouses/warehouse-inventory.service.ts | 258 ++++++++++-- .../warehouses/ReceiveInventoryModal.tsx | 10 +- .../warehouses/ReleaseOrderModal.tsx | 371 ++++++++++++------ .../warehouses/WarehouseInventoryTable.tsx | 17 +- .../warehouses/WarehouseOpsKpiStrip.tsx | 14 +- .../src/pages/warehouses/TrucksOnSitePage.tsx | 9 +- .../warehouses/WarehouseInventoryPage.tsx | 39 +- .../src/services/warehouse.service.ts | 2 + .../backoffice/src/types/warehouse.ts | 4 + packages/types/src/freight/index.ts | 1 + 16 files changed, 840 insertions(+), 193 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts diff --git a/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts new file mode 100644 index 000000000..c8f48af05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck EDR last-mile handovers. `truck_assignment_id` FKs + * customer_truck_assignments (self-haul only), so EDR trucks need their own + * link to the last-mile vehicle assignment that hauled the goods. Generated + * when the EDR truck exits the warehouse (with its exit paper) and signed by + * the customer in the portal — one per truck, or booking-level (both ids null) + * when the truck cannot be resolved. + */ +export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface { + name = 'AddHandoverEdrAssignment2440000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + ADD COLUMN IF NOT EXISTS edr_assignment_id uuid + REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck" + ON freight.booking_handovers (booking_id, edr_assignment_id) + WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`, + ); + await queryRunner.query( + `ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index b31a2ecbb..43887d468 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -333,6 +333,8 @@ export class LastMileService { driverPhone: string | null; truckType: string | null; containerNumber: string | null; + arrivedAt: string | null; + departedAt: string | null; }> > { const [lm] = await this.lastMileRepository.findAll({ @@ -347,9 +349,11 @@ export class LastMileService { ? lm.vehicleAssignments.map((va) => ({ vehicle: va.vehicle, containerNumber: va.containerNumber ?? null, + arrivedAt: va.arrivedAt ?? null, + departedAt: va.departedAt ?? null, })) : lm.vehicle - ? [{ vehicle: lm.vehicle, containerNumber: null }] + ? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }] : []; const out: Array<{ @@ -361,8 +365,10 @@ export class LastMileService { driverPhone: string | null; truckType: string | null; containerNumber: string | null; + arrivedAt: string | null; + departedAt: string | null; }> = []; - for (const { vehicle, containerNumber } of sources) { + for (const { vehicle, containerNumber, arrivedAt, departedAt } of sources) { if (!vehicle) continue; let driverName = vehicle.assignedDriverName ?? null; let driverLicense: string | null = null; @@ -386,6 +392,8 @@ export class LastMileService { driverPhone, truckType: vehicle.vehicleType || null, containerNumber, + arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null, + departedAt: departedAt ? new Date(departedAt).toISOString() : null, }); } return out; diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 49fb22fde..f86261d3c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator'; import { WAREHOUSE_INVENTORY_STATUSES, @@ -71,4 +72,27 @@ export class FilterWarehouseInventoryDto { @IsOptional() @IsString() dateTo?: string; + + // ── KPI drill-down filters ────────────────────────────────────────────── + // Each mirrors one opsStats() counter so a dashboard card's count always + // equals the length of the list it opens. + + @ApiPropertyOptional({ type: Boolean, description: 'Only items received (created) today' }) + @IsOptional() + @Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1')) + @IsBoolean() + receivedToday?: boolean; + + @ApiPropertyOptional({ type: Boolean, description: 'Only RECEIVED items with no inspection yet' }) + @IsOptional() + @Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1')) + @IsBoolean() + pendingInspection?: boolean; + + @ApiPropertyOptional({ type: Number, minimum: 1, description: 'Only in-warehouse items older than N days' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsInt() + @Min(1) + agingOverDays?: number; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts index a0a7ad70f..c90fb5a16 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -8,8 +8,9 @@ export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number]; * One import handover. A booking has a single handover when one truck takes the * whole booking (`truckAssignmentId` null = per-booking), or one per truck when * multiple trucks are used. Self-haul handovers are generated on truck arrival - * and signed before the truck leaves; EDR last-mile handovers are generated at - * delivery (after exit). + * and signed before the truck leaves; EDR last-mile handovers are generated + * when the EDR truck exits the warehouse (with its exit paper) and signed by + * the customer in the portal on delivery — one signature per truck. */ @Entity({ schema: 'freight', name: 'booking_handovers' }) @Index(['bookingId']) @@ -21,6 +22,10 @@ export class BookingHandover extends BaseEntity { @Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true }) truckAssignmentId?: string | null; + /** EDR last-mile vehicle assignment this handover belongs to; null = per-booking. */ + @Column({ name: 'edr_assignment_id', type: 'uuid', nullable: true }) + edrAssignmentId?: string | null; + /** Denormalised plate for display / EDR trucks (which aren't customer trucks). */ @Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true }) truckPlate?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts b/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts new file mode 100644 index 000000000..f89d987ce --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts @@ -0,0 +1,66 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +// Exercises the per-truck [Exit Inspection] block helpers directly (no DI). +const svc = Object.create(WarehouseInventoryService.prototype) as any; + +const arrivalA = + '[Exit Inspection]\nTruck Plate: 3-15288/56858\nDriver: Abebe Lemeno\nGate In Time: 2026-07-21T08:00:00.000Z\nTare Weight: 12 t'; +const arrivalB = + '[Exit Inspection]\nTruck Plate: 3-85957/48562\nDriver: Suleman Tamrat\nGate In Time: 2026-07-21T09:00:00.000Z\nWeighing: SKIPPED'; + +describe('per-truck exit inspection blocks', () => { + it('keeps truck A intact when truck B arrives', () => { + const afterA = svc.replaceExitInspectionNote('Receive note', arrivalA, '3-15288/56858'); + const afterB = svc.replaceExitInspectionNote(afterA, arrivalB, '3-85957/48562'); + expect(afterB).toContain('Abebe Lemeno'); + expect(afterB).toContain('Suleman Tamrat'); + expect(afterB.match(/\[Exit Inspection\]/g)).toHaveLength(2); + expect(afterB.startsWith('Receive note')).toBe(true); + }); + + it("exit for truck A updates only A's block and preserves arrival data", () => { + const notes = svc.replaceExitInspectionNote( + svc.replaceExitInspectionNote(null, arrivalA, '3-15288/56858'), + arrivalB, + '3-85957/48562', + ); + const dto = svc.preserveTruckArrivalForExit( + { truckPlateNumber: '3-15288/56858', grossWeight: 40, gateOutTime: '2026-07-21T12:00:00.000Z' }, + notes, + ); + expect(dto.driverName).toBe('Abebe Lemeno'); + expect(dto.tareWeight).toBe(12); + expect(dto.weighingSkipped).toBeUndefined(); + const exitNote = svc.buildExitInspectionNote(dto); + const replaced = svc.replaceExitInspectionNote(notes, exitNote, dto.truckPlateNumber); + expect(replaced).toContain('Gross Weight: 40 t'); + expect(replaced).toContain('Net Weight: 28 t'); + expect(replaced).toContain('Suleman Tamrat'); // B untouched + expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(2); + }); + + it('skipped weighing records the container-derived net in the note', () => { + const dto = { + truckPlateNumber: '3-85957/48562', + driverName: 'Suleman Tamrat', + weighingSkipped: true, + netWeight: 27.5, + gateInTime: '2026-07-21T09:00:00.000Z', + gateOutTime: '2026-07-21T13:00:00.000Z', + }; + const note = svc.buildExitInspectionNote(dto); + expect(note).toContain('Weighing: SKIPPED'); + expect(note).toContain('Net Weight: 27.5 t'); + }); + + it('matches a legacy comma-joined plate list and keeps foreign notes', () => { + const legacy = + 'Receive note\n\n[Exit Inspection]\nTruck Plate: 3-15288/56858, 3-85957/48562\nDriver: Abebe Lemeno\nTare Weight: 12 t\nCUSTOMER_DELIVERY_APPROVAL:{"ok":true}'; + const block = svc.extractExitInspectionForPlate(legacy, '3-15288/56858'); + expect(block).toContain('Abebe Lemeno'); + const replaced = svc.replaceExitInspectionNote(legacy, arrivalA, '3-15288/56858'); + expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(1); + expect(replaced).toContain('CUSTOMER_DELIVERY_APPROVAL:{"ok":true}'); + expect(replaced).toContain('Receive note'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index d50b27150..81f83a57a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -1,9 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; -import { DataSource, EntityManager, IsNull } from 'typeorm'; +import { DataSource, EntityManager, IsNull, Repository } from 'typeorm'; -import { BookingHandover } from './entities/booking-handover.entity'; +import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; @@ -12,7 +12,10 @@ import { sendCompanyChannels } from '../notifications/notify-company.util'; * Import handover records. A booking has one handover per truck (single truck ⇒ * one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type: * - SELF_HAUL: generated when the customer truck arrives, signed before it leaves. - * - EDR_LAST_MILE: generated at delivery (after exit). + * - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its + * exit paper), signed by the customer in the portal per truck; once every + * handover is signed the delivery auto-completes (inventory / cargo / + * booking → delivered). */ @Injectable() export class HandoverService { @@ -25,14 +28,22 @@ export class HandoverService { ) {} /** Tell the customer a handover is ready and needs their signature. */ - private async notifySignNeeded(bookingId: string, reference: string): Promise { + private async notifySignNeeded( + bookingId: string, + reference: string, + opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {}, + ): Promise { try { const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); if (!b?.companyId) return; - const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; + const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''; + const body = + opts.mileType === 'EDR_LAST_MILE' + ? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.` + : `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; await this.inbox.notify({ recipients: { companyId: b.companyId }, audience: NotificationAudience.PORTAL, @@ -117,31 +128,98 @@ export class HandoverService { return saved; } - /** - * EDR last-mile: generate a handover at delivery (after exit). One per EDR - * truck (by plate) or per booking. Idempotent by (booking, plate). - */ - async ensureAtDelivery( + /** Find an existing EDR handover by assignment, else by plate, else booking-level. */ + private async findEdrHandover( + repo: Repository, bookingId: string, - opts: { truckPlate?: string | null; truckAssignmentId?: string | null }, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, + ): Promise { + if (opts.edrAssignmentId) { + const byAssignment = await repo.findOne({ + where: { bookingId, edrAssignmentId: opts.edrAssignmentId }, + }); + if (byAssignment) return byAssignment; + } + if (opts.truckPlate) { + return repo.findOne({ + where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate }, + }); + } + return repo.findOne({ + where: { + bookingId, + mileType: 'EDR_LAST_MILE', + truckPlate: IsNull(), + edrAssignmentId: IsNull(), + }, + }); + } + + /** + * EDR last-mile: generate the handover when the EDR truck exits the warehouse + * (alongside its exit paper) and ask the customer to sign it from the portal. + * One per truck (multiple trucks ⇒ one each) or booking-level when the truck + * cannot be resolved. Idempotent by (booking, assignment) / (booking, plate). + */ + async ensureForDepartedEdrTruck( + bookingId: string, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, manager?: EntityManager, ): Promise { const m = manager ?? this.dataSource.manager; const repo = m.getRepository(BookingHandover); - const existing = await repo.findOne({ - where: { - bookingId, - truckPlate: opts.truckPlate ?? IsNull(), - truckAssignmentId: opts.truckAssignmentId ?? IsNull(), - }, - }); + const existing = await this.findEdrHandover(repo, bookingId, opts); if (existing) return existing; const reference = await this.generateReference(bookingId, m); - return repo.save( + const saved = await repo.save( repo.create({ bookingId, - truckAssignmentId: opts.truckAssignmentId ?? null, + edrAssignmentId: opts.edrAssignmentId ?? null, + truckPlate: opts.truckPlate ?? null, + mileType: 'EDR_LAST_MILE', + reference, + generatedAt: new Date(), + }), + ); + this.logger.log( + `EDR handover ${reference} generated on truck exit for booking ${bookingId}` + + (opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''), + ); + void this.notifySignNeeded(bookingId, reference, { + mileType: 'EDR_LAST_MILE', + truckPlate: opts.truckPlate, + }); + return saved; + } + + /** + * EDR last-mile: ensure a handover exists at delivery and stamp delivered_at. + * Normally the handover was already generated on truck exit — this only fills + * the delivery timestamp; a handover is created here only for legacy flows + * where the exit was recorded before this feature existed. + */ + async ensureAtDelivery( + bookingId: string, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + const repo = m.getRepository(BookingHandover); + const existing = await this.findEdrHandover(repo, bookingId, opts); + if (existing) { + if (!existing.deliveredAt) { + existing.deliveredAt = new Date(); + await repo.save(existing); + } + return existing; + } + + const reference = await this.generateReference(bookingId, m); + const saved = await repo.save( + repo.create({ + bookingId, + edrAssignmentId: opts.edrAssignmentId ?? null, truckPlate: opts.truckPlate ?? null, mileType: 'EDR_LAST_MILE', reference, @@ -149,6 +227,25 @@ export class HandoverService { deliveredAt: new Date(), }), ); + void this.notifySignNeeded(bookingId, reference, { + mileType: 'EDR_LAST_MILE', + truckPlate: opts.truckPlate, + }); + return saved; + } + + /** Re-send the sign notification for every unsigned handover on the booking. */ + async notifyUnsignedForBooking(bookingId: string): Promise { + const unsigned = await this.dataSource.getRepository(BookingHandover).find({ + where: { bookingId, signedAt: IsNull() }, + order: { generatedAt: 'ASC' }, + }); + for (const h of unsigned) { + await this.notifySignNeeded(bookingId, h.reference, { + mileType: h.mileType, + truckPlate: h.truckPlate, + }); + } } /** @@ -182,6 +279,27 @@ export class HandoverService { } } + /** + * Sign one handover (EDR last-mile: the customer signs per truck). Returns the + * fresh handover; idempotent — an already-signed handover is returned as-is. + */ + async sign( + handoverId: string, + userId?: string | null, + signerName?: string | null, + ): Promise { + const repo = this.dataSource.getRepository(BookingHandover); + const handover = await repo.findOne({ where: { id: handoverId } }); + if (!handover) { + throw new NotFoundException(`Handover ${handoverId} not found`); + } + if (handover.signedAt) return handover; + handover.signedAt = new Date(); + handover.signedByUserId = userId ?? null; + handover.signerName = signerName?.trim() || null; + return repo.save(handover); + } + /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ async signForBooking( bookingId: string, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 96122d9bd..c45764bbd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1,6 +1,18 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + Between, + DataSource, + EntityManager, + FindManyOptions, + FindOptionsWhere, + ILike, + In, + IsNull, + LessThanOrEqual, + MoreThanOrEqual, + Raw, +} from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { generateGrnNumber } from '../../common/grn.util'; @@ -68,6 +80,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) => const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:'; const HANDOVER_DOCUMENT_MARKER = '[Handover Document]'; +const EXIT_INSPECTION_MARKER = '[Exit Inspection]'; export interface InventoryInquiryResult { id: string; @@ -508,12 +521,20 @@ export class WarehouseInventoryService { WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday", (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection", - (SELECT count(*)::int FROM freight.customer_truck_assignments - WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite", + -- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite() + ((SELECT count(*)::int FROM freight.customer_truck_assignments a + JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL + WHERE a.deleted_at IS NULL AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL) + + + (SELECT count(*)::int FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL + JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL + WHERE va.deleted_at IS NULL AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL)) AS "trucksOnSite", (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL - AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED') + AND status = ANY($1) AND created_at < now() - interval '7 days') AS "itemsAging"`, + [this.IN_WAREHOUSE_STATUSES], ); return { receivedToday: row?.receivedToday ?? 0, @@ -525,7 +546,7 @@ export class WarehouseInventoryService { } /** In-warehouse statuses used by the dwell / aging metrics. */ - private readonly IN_WAREHOUSE_STATUSES = [ + private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [ 'RECEIVED', 'UNLOADED', 'STORED', @@ -953,7 +974,7 @@ export class WarehouseInventoryService { ? LessThanOrEqual(new Date(filter.dateTo)) : undefined; - const base = { + const base: FindOptionsWhere = { ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}), ...(filter.yardId ? { yardId: filter.yardId } : {}), ...(filter.zoneId ? { zoneId: filter.zoneId } : {}), @@ -967,6 +988,24 @@ export class WarehouseInventoryService { ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; + // KPI drill-down filters — predicates mirror opsStats() exactly so the + // dashboard card's count equals the length of the list it opens. + if (filter.receivedToday) { + base.createdAt = Raw((alias) => `${alias}::date = CURRENT_DATE`); + } + if (filter.pendingInspection) { + base.status = 'RECEIVED'; + base.inspectionStatus = IsNull(); + } + if (filter.agingOverDays) { + if (!filter.status && !filter.pendingInspection) { + base.status = In(this.IN_WAREHOUSE_STATUSES); + } + base.createdAt = Raw((alias) => `${alias} < now() - make_interval(days => :days)`, { + days: filter.agingOverDays, + }); + } + const search = filter.search?.trim(); const where: FindManyOptions['where'] = search ? [ @@ -2972,12 +3011,29 @@ export class WarehouseInventoryService { const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() : item.releaseDate ?? null; - const reference = isTruckLeaving - ? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item)) - : dto.reference?.trim() || (await this.generateReleaseReference(item)); - const exitInspectionDto = isTruckLeaving - ? this.preserveTruckArrivalForExit(dto, item.notes) - : dto; + // One reference per item — the first truck's arrival mints it, later trucks + // (arrival or exit) reuse it so all exit papers share the release order. + const reference = + item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item)); + const exitInspectionDto = { + ...(isTruckLeaving ? this.preserveTruckArrivalForExit(dto, item.notes) : dto), + }; + // Weighbridge skipped on exit: the recorded net still comes from what the + // truck is holding — the summed cargo weight of its selected containers. + if (isTruckLeaving && exitInspectionDto.weighingSkipped && item.bookingId) { + const selected = (exitInspectionDto.containerNumber ?? '') + .split(/[,;\n]+/) + .map((n) => n.trim()) + .filter(Boolean); + if (selected.length) { + const weights = await this.bookingContainerWeights(item.bookingId); + const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons])); + const heldTons = Number( + selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0).toFixed(3), + ); + if (heldTons > 0) exitInspectionDto.netWeight = heldTons; + } + } const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto); // The load actually leaving on this truck, in TONNES (the weighing UI is in @@ -2990,12 +3046,46 @@ export class WarehouseInventoryService { ? Math.round((grossTons - tareTons) * 1000) / 1000 : (exitInspectionDto.netWeight ?? null); + // The weight to record on the inventory when this truck leaves: prefer the + // item's own container cargo weight (a truck may carry other items too); + // fall back to the truck's recorded net. Fills an empty weight only. + let recordedItemTons: number | null = null; + if (isTruckLeaving && netTons != null) { + recordedItemTons = netTons; + if (item.containerId && item.bookingId) { + const [cont]: Array<{ containerNumber: string | null }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" FROM freight.containers WHERE id = $1`, + [item.containerId], + ); + const ownNumber = cont?.containerNumber?.trim().toUpperCase(); + if (ownNumber) { + const weights = await this.bookingContainerWeights(item.bookingId); + const own = weights.find((w) => w.containerNumber.toUpperCase() === ownNumber); + if (own && own.weightTons > 0) recordedItemTons = own.weightTons; + } + } + } + await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { releaseDate, releaseOrderReference: reference, - notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), + notes: this.replaceExitInspectionNote( + item.notes, + exitInspectionNote, + exitInspectionDto.truckPlateNumber, + ), }); + // Even an unweighed truck records the inventory weight it is holding — + // without this the handover/exit papers print "0 t" for skipped weighings. + if (recordedItemTons != null) { + await manager.query( + `UPDATE freight.warehouse_inventory + SET weight = $2, updated_at = NOW() + WHERE id = $1 AND COALESCE(weight, 0) = 0`, + [id, recordedItemTons], + ); + } if (!isTruckLeaving && item.bookingId) { // Per-truck arrival: mark the customer truck carrying THIS item's // container as arrived (matched via the physical container number). @@ -3861,7 +3951,9 @@ export class WarehouseInventoryService { `SELECT inv.id, inv.booking_id AS "bookingId", inv.quantity, - inv.weight, + -- An unweighed item still reports the cargo weight it holds: fall + -- back to the item's container VGM when no weight was recorded. + COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, 0) AS weight, inv.status, inv.notes, inv.inspection_status AS "inspectionStatus", @@ -3913,6 +4005,16 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT SUM(bcu.vgm_tons) AS tons + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id + AND bcu.deleted_at IS NULL + AND (container.container_number IS NULL + OR bcu.container_number = container.container_number) + ) item_vgm ON true LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL @@ -4009,14 +4111,25 @@ export class WarehouseInventoryService { if (!(await this.handover.isFullySigned(item.bookingId))) { throw new BadRequestException('Handover must be signed before delivery'); } - const [left]: Array<{ n: string }> = await this.dataSource.query( - `SELECT COUNT(*) AS n FROM freight.customer_truck_assignments - WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`, + const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE departed_at IS NOT NULL) AS "left" + FROM freight.customer_truck_assignments + WHERE booking_id = $1 AND deleted_at IS NULL`, [item.bookingId], ); - if (Number(left?.n ?? 0) === 0) { + const totalTrucks = Number(trucks?.total ?? 0); + const leftTrucks = Number(trucks?.left ?? 0); + if (leftTrucks === 0) { throw new BadRequestException('Deliver is available only after the customer truck has left'); } + // Multi-truck booking: every assigned truck must arrive and leave — + // each is weighed out separately before the goods count as delivered. + if (leftTrucks < totalTrucks) { + throw new BadRequestException( + `Deliver is available only after every assigned truck has left (${leftTrucks} of ${totalTrucks} so far)`, + ); + } } } @@ -5297,7 +5410,11 @@ export class WarehouseInventoryService { weighingSkipped ? 'Weighing: SKIPPED' : null, tareWeight == null ? null : `Tare Weight: ${tareWeight} t`, grossWeight == null ? null : `Gross Weight: ${grossWeight} t`, - computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`, + // Skipped weighing still records a net — the cargo weight of the + // containers the truck is holding, resolved by the caller. + (computedNetWeight ?? (weighingSkipped ? dto.netWeight : null)) == null + ? null + : `Net Weight: ${computedNetWeight ?? Number(dto.netWeight)} t`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, ]; @@ -5305,12 +5422,14 @@ export class WarehouseInventoryService { } private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto { - const inspection = this.extractExitInspectionNote(notes); + const inspection = this.extractExitInspectionForPlate(notes, dto.truckPlateNumber); if (!inspection) return dto; return { ...dto, - truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber, + // The submitted plate wins: a legacy block may store a comma-joined list + // of plates, and the exit must be recorded against the ONE truck leaving. + truckPlateNumber: dto.truckPlateNumber?.trim() || this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber, trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber, driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName, driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense, @@ -5324,25 +5443,94 @@ export class WarehouseInventoryService { }; } - private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null { + /** + * Split notes into exit-inspection blocks (one per truck, in order) and + * everything else. A block ends at the first line that isn't one of the + * known inspection labels, so appended notes (delivery approval, handover + * marker) are preserved as "other" content instead of being swallowed by + * the block they happen to follow. + */ + private splitExitInspectionSections(notes?: string | null): { others: string[]; blocks: string[] } { const trimmed = notes?.trim(); - if (!exitInspectionNote) return trimmed || null; - if (!trimmed) return exitInspectionNote; - - const marker = '[Exit Inspection]'; - const index = trimmed.lastIndexOf(marker); - if (index < 0) { - return `${trimmed}\n\n${exitInspectionNote}`; + if (!trimmed) return { others: [], blocks: [] }; + const labelPattern = + /^(Booking ID|Customer ID|Truck Plate|Trailer Plate|Driver|Driver License|Driver Phone|Truck Type|Container Number|Gate In Time|Weighing|Tare Weight|Gross Weight|Net Weight|Gate Out Time):/i; + const parts = trimmed.split(EXIT_INSPECTION_MARKER); + const others: string[] = []; + const blocks: string[] = []; + if (parts[0]?.trim()) others.push(parts[0].trim()); + for (const part of parts.slice(1)) { + const lines = part.split('\n'); + const kept: string[] = []; + let i = 0; + while (i < lines.length && !lines[i].trim()) i += 1; + for (; i < lines.length; i += 1) { + const line = lines[i].trim(); + if (!line || !labelPattern.test(line)) break; + kept.push(line); + } + if (kept.length) blocks.push(kept.join('\n')); + const tail = lines.slice(i).join('\n').trim(); + if (tail) others.push(tail); } - return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n'); + return { others, blocks }; } + /** + * A block belongs to a plate when its stored `Truck Plate` equals it, or is a + * legacy comma-joined list ("P1, P2") containing it. + */ + private blockMatchesPlate(block: string, plateNumber?: string | null): boolean { + const plate = plateNumber?.trim().toUpperCase(); + if (!plate) return false; + const stored = this.extractExitInspectionLine(block, 'Truck Plate')?.toUpperCase(); + if (!stored) return false; + if (stored === plate) return true; + return stored.split(/[,;]+/).map((p) => p.trim()).includes(plate); + } + + /** + * Replace THIS truck's inspection block (matched by plate), keeping every + * other truck's block untouched; append when the plate has no block yet. + * A single legacy block (comma-joined plates or plate-less caller) is + * replaced in place so old single-truck items keep their behaviour. + */ + private replaceExitInspectionNote( + notes: string | null | undefined, + exitInspectionNote: string | null, + plateNumber?: string | null, + ): string | null { + const { others, blocks } = this.splitExitInspectionSections(notes); + if (exitInspectionNote) { + const content = exitInspectionNote.replace(EXIT_INSPECTION_MARKER, '').trim(); + const index = plateNumber + ? blocks.findIndex((b) => this.blockMatchesPlate(b, plateNumber)) + : blocks.length - 1; + if (index >= 0) blocks[index] = content; + else blocks.push(content); + } + const sections = [...others, ...blocks.map((b) => `${EXIT_INSPECTION_MARKER}\n${b}`)]; + return sections.join('\n\n') || null; + } + + /** Latest truck's inspection block — legacy summary for documents. */ private extractExitInspectionNote(notes?: string | null): string | null { - if (!notes) return null; - const marker = '[Exit Inspection]'; - const index = notes.lastIndexOf(marker); - if (index < 0) return null; - return notes.slice(index + marker.length).trim() || null; + const { blocks } = this.splitExitInspectionSections(notes); + return blocks.length ? blocks[blocks.length - 1] : null; + } + + /** + * The inspection block for one truck. Falls back to a lone existing block so + * legacy single-truck items (saved before per-plate blocks) keep working. + */ + private extractExitInspectionForPlate( + notes: string | null | undefined, + plateNumber?: string | null, + ): string | null { + const { blocks } = this.splitExitInspectionSections(notes); + const match = blocks.find((b) => this.blockMatchesPlate(b, plateNumber)); + if (match) return match; + return blocks.length === 1 ? blocks[0] : null; } private extractExitInspectionLine(note: string | null | undefined, label: string): string | null { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 93c3bf64e..41e5fcc81 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -2647,7 +2647,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {/* Primary stage action stays visible; the rest live under the kebab. */} - {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && ( + {/* Stays visible after the first exit — multi-truck bookings + weigh each truck in and out until all have left. */} + {r.currentStatus === 'READY_FOR_PICKUP' && r.hasAssignedTruck && ( )} {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( @@ -2692,7 +2694,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { Ready for pickup )} - {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && ( + {r.currentStatus === 'READY_FOR_PICKUP' && ( } disabled={!r.hasAssignedTruck} @@ -2700,7 +2702,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { > {r.hasAssignedTruck ? r.releaseOrderReference - ? 'Truck leaving' + ? 'Truck arrival / leaving' : 'Truck arrival' : 'Truck arrival — assign a truck first'} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 5f0a8a092..fcced78d9 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react'; -import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; -import { Info, Scale } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Alert, Badge, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; +import { Info, Scale, Truck } from 'lucide-react'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; @@ -28,6 +28,8 @@ export interface ReleaseOrderTruckPrefill { containerNumber?: string | null; } +const EXIT_INSPECTION_MARKER = '[Exit Inspection]'; + const toIsoDateTime = (value: string) => { if (!value) return undefined; const date = new Date(value); @@ -70,9 +72,6 @@ const splitContainerNumbers = (value: string | null | undefined) => const getItemContainerNumber = (item: WarehouseInventoryItem | null) => (item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? ''; -const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable) => - item?.booking?.[key] == null ? '' : String(item.booking[key]); - const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => { const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null) ?.booking?.freightType; @@ -88,29 +87,66 @@ const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedConta return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? ''); }; -const parseInspectionNote = (notes: string | null | undefined) => { - const marker = '[Exit Inspection]'; - const index = notes?.lastIndexOf(marker) ?? -1; - const note = index >= 0 ? notes?.slice(index + marker.length) : notes; - return { - truckPlateNumber: lineValue(note, 'Truck Plate'), - trailerPlateNumber: lineValue(note, 'Trailer Plate'), - driverName: lineValue(note, 'Driver'), - driverLicense: lineValue(note, 'Driver License'), - driverPhone: lineValue(note, 'Driver Phone'), - truckType: lineValue(note, 'Truck Type'), - containerNumber: lineValue(note, 'Container Number'), - gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')), - tareWeight: lineNumber(note, 'Tare Weight'), - grossWeight: lineNumber(note, 'Gross Weight'), - netWeight: lineNumber(note, 'Net Weight'), - gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')), - weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''), - }; +/** One truck's saved arrival/exit weighing, parsed from its inspection block. */ +interface InspectionBlock { + truckPlateNumber: string; + trailerPlateNumber: string; + driverName: string; + driverLicense: string; + driverPhone: string; + truckType: string; + containerNumber: string; + gateInTime: string; + tareWeight: number | ''; + grossWeight: number | ''; + netWeight: number | ''; + gateOutTime: string; + weighingSkipped: boolean; +} + +const parseInspectionSection = (note: string): InspectionBlock => ({ + truckPlateNumber: lineValue(note, 'Truck Plate'), + trailerPlateNumber: lineValue(note, 'Trailer Plate'), + driverName: lineValue(note, 'Driver'), + driverLicense: lineValue(note, 'Driver License'), + driverPhone: lineValue(note, 'Driver Phone'), + truckType: lineValue(note, 'Truck Type'), + containerNumber: lineValue(note, 'Container Number'), + gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')), + tareWeight: lineNumber(note, 'Tare Weight'), + grossWeight: lineNumber(note, 'Gross Weight'), + netWeight: lineNumber(note, 'Net Weight'), + gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')), + weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note), +}); + +/** Every truck's saved block — multi-truck bookings weigh each truck separately. */ +const parseInspectionBlocks = (notes: string | null | undefined): InspectionBlock[] => + (notes ?? '') + .split(EXIT_INSPECTION_MARKER) + .slice(1) + .map(parseInspectionSection) + .filter((block) => block.truckPlateNumber); + +/** Match by plate; a legacy block may hold a comma-joined plate list. */ +const blockForPlate = (blocks: InspectionBlock[], plate: string): InspectionBlock | undefined => { + const key = plate.trim().toUpperCase(); + if (!key) return undefined; + return blocks.find((block) => { + const stored = block.truckPlateNumber.toUpperCase(); + return stored === key || stored.split(/[,;]+/).map((p) => p.trim()).includes(key); + }); }; +const blockArrived = (block: InspectionBlock | undefined) => + Boolean(block && (block.tareWeight !== '' || block.weighingSkipped)); + +const blockLeft = (block: InspectionBlock | undefined) => + Boolean(block?.gateOutTime && (block.grossWeight !== '' || block.weighingSkipped)); + export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { const { toast } = useToast(); + const queryClient = useQueryClient(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); // Some openers (inventory workbench) supply bookingId without the booking // relation — fall back to it, or the truck/container-weight queries never run. @@ -152,78 +188,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const [netWeight, setNetWeight] = useState(''); const [gateOutTime, setGateOutTime] = useState(''); const [downloading, setDownloading] = useState(false); + // Plate whose saved block was last loaded into the form — stops the + // per-plate loader effect from clobbering operator edits in a loop. + const loadedPlateRef = useRef(null); - useEffect(() => { - if (opened) { - const inspection = parseInspectionNote(item?.notes); - const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber'); - const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName'); - const assignedTruckType = assignedTruckValue(item, 'customerTruckType'); - const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber'); - const prefillContainerNumber = truckPrefill?.containerNumber ?? ''; - setReference(item?.releaseOrderReference ?? generateReleaseReference(item)); - setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || ''); - setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || ''); - setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || ''); - setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || ''); - setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || ''); - setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || ''); - setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber)); - setGateInTime(inspection.gateInTime); - setTareWeight(inspection.tareWeight); - setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes'); - setGrossWeight(inspection.grossWeight); - setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight)); - setGateOutTime(inspection.gateOutTime); - } - }, [opened, item, truckPrefill]); - - const savedInspection = parseInspectionNote(item?.notes); - const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped; - const isEntranceLocked = isExitStep; - const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); - const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber); - - // Opened from the warehouse flow (no truckPrefill prop): once the last-mile - // truck query resolves, auto-fill the first assigned EDR truck — without - // overwriting anything the operator typed or the locked exit-step values. - useEffect(() => { - if (!opened || truckPrefill || isExitStep) return; - const first = lastMileTrucks[0]; - if (!first) return; - setTruckPlateNumber((p) => p || first.truckPlateNumber || ''); - setTrailerPlateNumber((p) => p || first.trailerPlateNumber || ''); - setDriverName((p) => p || first.driverName || ''); - setDriverLicense((p) => p || first.driverLicense || ''); - setDriverPhone((p) => p || first.driverPhone || ''); - setTruckType((p) => p || first.truckType || ''); - setContainerNumbers((prev) => - prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev, - ); - }, [opened, truckPrefill, isExitStep, lastMileTrucks]); - - // The same for a customer self-haul truck. The prefill above reads the - // booking.customer_truck_* columns, but multi-truck self-haul writes the plate - // and driver to customer_truck_assignments and leaves those columns null — so - // a booking with a truck on file still opened this form blank. Only auto-fills - // a single truck: with several, the operator picks which one is at the gate. - useEffect(() => { - if (!opened || truckPrefill || isExitStep) return; - if (customerTrucks.length !== 1) return; - const [truck] = customerTrucks; - setTruckPlateNumber((p) => p || truck.plateNumber || ''); - setDriverName((p) => p || truck.driverName || ''); - setTruckType((p) => p || truck.truckType || ''); - setContainerNumbers((prev) => { - const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean); - return prev.every((n) => !n) && loaded.length ? loaded : prev; - }); - }, [opened, truckPrefill, isExitStep, customerTrucks]); + const savedBlocks = parseInspectionBlocks(item?.notes); // Registered trucks for THIS booking, from both sources: EDR last-mile // (truckPrefill) and the customer portal (customer_truck_assignments). const assignedTruckOptions = [ - ...(truckPrefill?.truckPlateNumber + ...(truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',') ? [ { value: truckPrefill.truckPlateNumber, @@ -232,6 +206,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea driverName: truckPrefill.driverName ?? '', driverPhone: truckPrefill.driverPhone ?? '', truckType: truckPrefill.truckType ?? '', + containerNumbers: splitContainerNumbers(truckPrefill.containerNumber), + arrived: false, + left: false, }, ] : []), @@ -242,6 +219,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea driverName: t.driverName, driverPhone: '', truckType: t.truckType, + containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean), + arrived: Boolean(t.arrivedAt), + left: Boolean(t.departedAt), })), ...lastMileTrucks .filter((t) => t.truckPlateNumber || t.vehicleId) @@ -252,6 +232,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea driverName: t.driverName ?? '', driverPhone: t.driverPhone ?? '', truckType: t.truckType ?? '', + containerNumbers: splitContainerNumbers(t.containerNumber), + arrived: Boolean(t.arrivedAt), + left: Boolean(t.departedAt), })), ]; // Only trucks actually assigned to THIS booking (last-mile prefill or customer @@ -261,10 +244,132 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const truckSelectOptions = [ ...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(), ]; + const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); // Neither a last-mile truck nor a customer truck has been assigned yet. const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck; - const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; - const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName); + + // Per-truck progress: every truck is weighed in and out on its own; the saved + // blocks also cover walk-in trucks that were never formally assigned. + const truckProgress = new Map(); + for (const option of truckSelectOptions) { + truckProgress.set(option.value.trim().toUpperCase(), { arrived: option.arrived, left: option.left }); + } + for (const block of savedBlocks) { + const key = block.truckPlateNumber.trim().toUpperCase(); + const prior = truckProgress.get(key); + truckProgress.set(key, { + arrived: Boolean(prior?.arrived) || blockArrived(block), + left: Boolean(prior?.left) || blockLeft(block), + }); + } + const totalTrucks = truckProgress.size; + const arrivedTrucks = [...truckProgress.values()].filter((t) => t.arrived).length; + const leftTrucks = [...truckProgress.values()].filter((t) => t.left).length; + + // The step is decided PER TRUCK: the selected plate's saved block. A new plate + // (or a truck without a saved arrival) starts at the arrival step even when + // other trucks of the booking are already mid-flow or gone. + const selectedBlock = blockForPlate(savedBlocks, truckPlateNumber); + const isExitStep = blockArrived(selectedBlock); + const hasTruckLeft = blockLeft(selectedBlock); + const isEntranceLocked = isExitStep; + const selectedOption = truckSelectOptions.find( + (option) => option.value.trim().toUpperCase() === truckPlateNumber.trim().toUpperCase(), + ); + // Identity comes from the arrival record or the assignment — locked either + // way. A walk-in truck (typed plate, no assignment) stays editable at arrival. + const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption); + const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName); + const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0; + + /** Load a truck into the form: its saved block if any, else its assignment. */ + const applyTruckSelection = (plate: string) => { + const block = blockForPlate(savedBlocks, plate); + const option = truckSelectOptions.find( + (o) => o.value.trim().toUpperCase() === plate.trim().toUpperCase(), + ); + loadedPlateRef.current = plate.trim().toUpperCase(); + setTruckPlateNumber(plate); + setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || ''); + setDriverName(block?.driverName || option?.driverName || ''); + setDriverLicense(block?.driverLicense || ''); + setDriverPhone(block?.driverPhone || option?.driverPhone || ''); + setTruckType(block?.truckType || option?.truckType || ''); + const loaded = block + ? splitContainerNumbers(block.containerNumber) + : (option?.containerNumbers ?? []); + setContainerNumbers(loaded.length ? loaded : initialContainerNumbers(item, '')); + setGateInTime(block?.gateInTime ?? ''); + setTareWeight(block?.tareWeight ?? ''); + setWeighTruck(block?.weighingSkipped ? 'no' : 'yes'); + setGrossWeight(block?.grossWeight ?? ''); + setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight))); + setGateOutTime(block?.gateOutTime ?? ''); + }; + + useEffect(() => { + if (opened) { + loadedPlateRef.current = null; + setReference(item?.releaseOrderReference ?? generateReleaseReference(item)); + // Initial truck: the caller's prefill, else the first truck still mid-flow + // (arrived but not left) — the operator can switch trucks in the select. + const prefillPlate = + truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',') + ? truckPrefill.truckPlateNumber + : ''; + const blocks = parseInspectionBlocks(item?.notes); + const inProgress = blocks.find((block) => blockArrived(block) && !blockLeft(block)); + // Legacy single-truck bookings stored the truck on the booking columns; a + // comma-joined value means several trucks, so the operator picks instead. + const bookingPlate = item?.booking?.customerTruckPlateNumber ?? ''; + const legacyPlate = bookingPlate && !bookingPlate.includes(',') ? bookingPlate : ''; + const initialPlate = prefillPlate || inProgress?.truckPlateNumber || legacyPlate || ''; + const block = blockForPlate(blocks, initialPlate); + loadedPlateRef.current = initialPlate ? initialPlate.trim().toUpperCase() : null; + setTruckPlateNumber(initialPlate); + setTrailerPlateNumber(block?.trailerPlateNumber || truckPrefill?.trailerPlateNumber || ''); + setDriverName( + block?.driverName || + truckPrefill?.driverName || + (legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckDriverName ?? '') : ''), + ); + setDriverLicense(block?.driverLicense || truckPrefill?.driverLicense || ''); + setDriverPhone(block?.driverPhone || truckPrefill?.driverPhone || ''); + setTruckType( + block?.truckType || + truckPrefill?.truckType || + (legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckType ?? '') : ''), + ); + setContainerNumbers( + initialContainerNumbers(item, block?.containerNumber || truckPrefill?.containerNumber || ''), + ); + setGateInTime(block?.gateInTime ?? ''); + setTareWeight(block?.tareWeight ?? ''); + setWeighTruck(block?.weighingSkipped ? 'no' : 'yes'); + setGrossWeight(block?.grossWeight ?? ''); + setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight))); + setGateOutTime(block?.gateOutTime ?? ''); + } + }, [opened, item, truckPrefill]); + + // No truck chosen yet and exactly one is assigned — load it. With several + // trucks the operator picks which one is at the gate. + useEffect(() => { + if (!opened || truckPlateNumber || loadedPlateRef.current) return; + if (truckSelectOptions.length !== 1) return; + applyTruckSelection(truckSelectOptions[0].value); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, truckPlateNumber, customerTrucks, lastMileTrucks]); + + // A typed plate that matches a saved arrival reloads that truck's record, so + // the exit step opens with the weigh-in data instead of blank fields. + useEffect(() => { + if (!opened) return; + const key = truckPlateNumber.trim().toUpperCase(); + if (!key || loadedPlateRef.current === key) return; + if (blockForPlate(savedBlocks, truckPlateNumber)) applyTruckSelection(truckPlateNumber); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, truckPlateNumber]); // Which containers ride this truck, and their combined cargo weight. When the // booking has container weights, that sum is the authoritative net; the @@ -294,7 +399,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea ); // Skip is only offered for container bookings; bulk always weighs. const skipWeighing = hasContainerWeights && weighTruck === 'no'; - const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing; + // Even an unweighed truck records the cargo weight it is holding — the + // selected containers' sum is the net that goes on the exit record. + const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0; const systemNetWeight = useContainerNet ? selectedCargoWeight @@ -314,6 +421,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea toast({ variant: 'destructive', title: 'Truck plate and driver name are required' }); return; } + if (hasTruckLeft) { + toast({ variant: 'destructive', title: `Truck ${truckPlateNumber} has already left — its exit record is locked` }); + return; + } if (!gateInTime || (!skipWeighing && tareWeight === '')) { toast({ variant: 'destructive', @@ -363,13 +474,17 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea weighingSkipped: skipWeighing || undefined, tareWeight: skipWeighing ? undefined : Number(tareWeight), grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight), - netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, + // Skipped weighing still records the net from what the truck holds. + netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined, }, }); + await queryClient.invalidateQueries({ queryKey: ['release-customer-trucks', bookingId] }); + await queryClient.invalidateQueries({ queryKey: ['release-last-mile-trucks', bookingId] }); if (!isExitStep) { + const remaining = totalTrucks > 1 ? ` (${Math.min(arrivedTrucks + 1, totalTrucks)} of ${totalTrucks} trucks arrived)` : ''; toast({ - title: 'Truck arrival saved', + title: `Truck ${truckPlateNumber.trim()} arrival saved${remaining}`, description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`, }); onClose(); @@ -380,11 +495,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const blob = response.data; const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`; const opened = openPdfBlob(blob, filename, pdfWindow); + const remainingExit = totalTrucks > 1 ? ` ${Math.min(leftTrucks + 1, totalTrucks)} of ${totalTrucks} trucks have left.` : ''; toast({ title: 'Release exit paper issued', - description: opened + description: (opened ? 'The PDF opened in a browser tab for printing or saving.' - : 'The browser blocked the preview tab, so the PDF was downloaded.', + : 'The browser blocked the preview tab, so the PDF was downloaded.') + remainingExit, }); onClose(); } catch (error) { @@ -411,12 +527,35 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} + {totalTrucks > 1 && ( + } color="blue" variant="light"> + + + {totalTrucks} trucks on this booking — each is weighed in and out separately. + + + {arrivedTrucks}/{totalTrucks} arrived + + + {leftTrucks}/{totalTrucks} left + + + + )} + {hasTruckLeft && ( + } color="green" variant="light"> + + Truck {truckPlateNumber} has already left — its exit record is locked. Pick another + truck to continue the remaining arrivals and exits. + + + )} setReference(e.currentTarget.value)} - readOnly={isEntranceLocked} + readOnly={referenceLocked} /> {noTruckAssigned && ( }> @@ -425,22 +564,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} {truckSelectOptions.length > 0 && ( by its label and pick an option by exact text. */ +Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string | RegExp) => { + cy.contains("label", label) + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).click({ force: true }); + }); + cy.get('[role="option"]').contains(option).click(); +}); + +/** Type a 6-digit code into a Mantine PinInput. */ +Cypress.Commands.add("typeOtp", (code: string) => { + cy.get(".mantine-PinInput-root input").should("have.length.at.least", code.length); + code.split("").forEach((digit, i) => { + cy.get(".mantine-PinInput-root input").eq(i).type(digit, { force: true }); + }); +}); + +/** + * Draw a squiggle on the signature-pad canvas (mouse events). When the account + * already has a saved signature the modal opens in "Approve signature" mode + * with no canvas — nothing to draw, the saved image is used as-is. + */ +Cypress.Commands.add("drawSignature", () => { + cy.get(".mantine-Modal-content").then(($modals) => { + if ($modals.find("canvas").length === 0) return; + drawOnCanvas(); + }); +}); + +function drawOnCanvas() { + cy.get(".mantine-Modal-content canvas") + .first() + .then(($canvas) => { + const rect = $canvas[0].getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + const midY = rect.top + rect.height / 2; + cy.wrap($canvas) + .trigger("mousedown", { clientX: midX - 60, clientY: midY, force: true }) + .trigger("mousemove", { clientX: midX - 20, clientY: midY - 15, force: true }) + .trigger("mousemove", { clientX: midX + 20, clientY: midY + 15, force: true }) + .trigger("mousemove", { clientX: midX + 60, clientY: midY, force: true }) + .trigger("mouseup", { force: true }); + }); +} + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { @@ -88,6 +161,14 @@ declare global { loginPortal(email?: string, pass?: string): Chainable; /** cy.visit against the portal origin (env.portalUrl). */ visitPortal(path?: string): Chainable; + /** Latest OTP stored for an email/phone (delivery is off in e2e). */ + getOtp(target: string): Chainable; + /** Open a Mantine Select by label, pick an option. */ + mantineSelect(label: string | RegExp, option: string | RegExp): Chainable; + /** Fill a Mantine PinInput with a code. */ + typeOtp(code: string): Chainable; + /** Scribble on the signature-pad canvas inside the open modal. */ + drawSignature(): Chainable; } } } From 281969c94b7e0601a534c4266eaffa7fd924c93f Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 21 Jul 2026 15:50:25 +0300 Subject: [PATCH 11/14] Seat discrepancy segment-awareness updates --- .../src/modules/reports/reports.service.ts | 11 +-- .../src/modules/seats/seats.service.ts | 78 ++++++++++++++++++- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index f74bf74b8..2435ac91e 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -264,13 +264,10 @@ export class ReportsService { }, bookings: { where: { status: { in: ["CONFIRMED", "BOARDED"] } }, - include: { - seats: { - where: { leg: 1 }, - include: { - seat: { include: { coach: { include: { coachType: true } } } }, - }, - }, + select: { + id: true, + originStationId: true, + destinationStationId: true, }, }, stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } }, diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index abb747710..bd73c10f5 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -1069,6 +1069,7 @@ export class SeatsService { booking: { select: { id: true, bookingRef: true, scheduleId: true, + originStationId: true, destinationStationId: true, createdAt: true, contactPhone: true, }, }, @@ -1086,7 +1087,8 @@ export class SeatsService { }); const occupiedIds = new Set(journeySegments.map(js => js.seatId!)); - // Group BookingSeat rows by (seatId::leg) to detect duplicates + // Group BookingSeat rows by seatId::leg to find candidate duplicates, + // then filter to only those whose booking segments actually overlap. type BS = (typeof bookingSeats)[number]; const groups = new Map(); for (const bs of bookingSeats) { @@ -1095,6 +1097,59 @@ export class SeatsService { groups.get(key)!.push(bs); } + // Build stop-sequence map for this schedule once + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: schedule.id }, + select: { stationId: true, sequence: true }, + }); + const seqOf = (stationId: string | null | undefined): number | undefined => + stationId ? stopTimes.find(s => s.stationId === stationId)?.sequence : undefined; + + // Fetch JourneySegment ranges for all booking IDs in candidate groups + const candidateBookingIds = [...new Set( + [...groups.values()].filter(g => g.length > 1).flatMap(g => g.map(bs => bs.booking.id)), + )]; + const candidateSegments = candidateBookingIds.length > 0 + ? await this.prisma.journeySegment.findMany({ + where: { + scheduleId: schedule.id, + journey: { bookingId: { in: candidateBookingIds }, status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { departureStationId: true, arrivalStationId: true, journey: { select: { bookingId: true } } }, + }) + : []; + + // Collapse per-booking segments into a single [from, to) range + const rangeByBookingId = new Map(); + for (const seg of candidateSegments) { + const bookingId = seg.journey.bookingId; + if (!bookingId) continue; + const depSeq = seqOf(seg.departureStationId); + const arrSeq = seqOf(seg.arrivalStationId); + if (depSeq === undefined || arrSeq === undefined) continue; + const existing = rangeByBookingId.get(bookingId); + rangeByBookingId.set(bookingId, existing + ? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) } + : { from: depSeq, to: arrSeq }); + } + + // Fall back to booking-level origin/destination when JourneySegments are missing + const rangeForBooking = (bs: BS): { from: number; to: number } | null => { + const fromSegments = rangeByBookingId.get(bs.booking.id); + if (fromSegments) return fromSegments; + // BookingSeat.scheduleId tells us which leg this seat belongs to + const bsScheduleId = bs.scheduleId ?? bs.booking.scheduleId; + if (bsScheduleId !== schedule.id) return null; + const from = seqOf(bs.booking.originStationId); + const to = seqOf(bs.booking.destinationStationId); + if (from === undefined || to === undefined) return null; + return { from, to }; + }; + + // Two bookings are true duplicates only if their segments overlap + const segmentsOverlap = (a: { from: number; to: number }, b: { from: number; to: number }) => + a.from < b.to && b.from < a.to; + // All seats held by any confirmed BookingSeat — union of JourneySegment-based // occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments // are missing (e.g. created via enhanced-seats path without bookingId) are still @@ -1114,13 +1169,30 @@ export class SeatsService { for (const [key, group] of groups) { if (group.length <= 1) continue; if (group[0].seat.coachId !== coach.id) continue; + + // Filter to bookings that actually have overlapping segments + const overlapping: BS[] = []; + for (let i = 0; i < group.length; i++) { + const rangeA = rangeForBooking(group[i]); + for (let j = i + 1; j < group.length; j++) { + const rangeB = rangeForBooking(group[j]); + // If either range is unknown, conservatively treat as overlap + const isOverlap = !rangeA || !rangeB || segmentsOverlap(rangeA, rangeB); + if (isOverlap) { + if (!overlapping.includes(group[i])) overlapping.push(group[i]); + if (!overlapping.includes(group[j])) overlapping.push(group[j]); + } + } + } + if (overlapping.length <= 1) continue; + const [seatId] = key.split('::'); const seat = coach.seats.find(s => s.id === seatId); duplicates.push({ seatId, seatNumber: seat?.seatNumber ?? seatId, - leg: group[0].leg, - bookings: group.map(bs => ({ + leg: overlapping[0].leg, + bookings: overlapping.map(bs => ({ bookingSeatId: bs.id, bookingId: bs.booking.id, bookingRef: bs.booking.bookingRef, From 9da48bd61a4f1283ebb1b2f3a12a8d1290912e19 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 12:13:57 +0000 Subject: [PATCH 12/14] fix --- .../src/modules/warehouses/warehouse-inventory.service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 7e03eb6a2..50c71f485 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -4005,6 +4005,14 @@ export class WarehouseInventoryService { [handoverId], ); if (!h) throw new NotFoundException(`Handover ${handoverId} not found`); + // Self-haul stays a single booking-level signature via approve-delivery, + // which also enforces inspection-passed + truck-arrived. Per-truck signing + // is an EDR last-mile flow only. + if (h.mileType !== 'EDR_LAST_MILE') { + throw new BadRequestException( + 'This handover is signed through Approve delivery, not per truck', + ); + } // Same gate as approve-delivery: storage/demurrage must be settled first. const [inv]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query( From 61e59036cd6e2f4c02a9e4331a52b2a9446523ae Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 12:52:35 +0000 Subject: [PATCH 13/14] Browser testing for modern teams Create tests, debug failures, and improve quality faster than ever. Get AI-powered guidance at every stage of testing, with full code ownership, on a platform that connects your team. --- .../src/common/mile-financials.util.ts | 61 +++++++++++++++++++ .../modules/first-mile/first-mile.service.ts | 2 + .../modules/last-mile/last-mile.service.ts | 2 + 3 files changed, 65 insertions(+) create mode 100644 apps/edr-freight-api/src/common/mile-financials.util.ts diff --git a/apps/edr-freight-api/src/common/mile-financials.util.ts b/apps/edr-freight-api/src/common/mile-financials.util.ts new file mode 100644 index 000000000..2f5288048 --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-financials.util.ts @@ -0,0 +1,61 @@ +import { DataSource } from 'typeorm'; + +type MileRecord = { + bookingId?: string | null; + advancedPayment?: number | string | null; + booking?: { + cargoTotalWeightVgm?: number | string | null; + bookingContainers?: Array<{ + units?: Array<{ vgmTons?: number | string | null }> | null; + }> | null; + } | null; +}; + +/** + * Display enrichment for first/last-mile lists (Assign Vehicle modal etc.): + * - Advance payment: mile records are created with advanced_payment 0 — the + * real advance is the FIRST_MILE/LAST_MILE line the customer already paid + * on the booking invoice. + * - Cargo tons: container bookings often carry tonnage on the per-unit VGMs + * while cargo_total_weight_vgm stays 0 — fall back to the summed units. + * Fills both in-memory on the loaded records; nothing is persisted. + */ +export async function attachMileFinancials( + dataSource: DataSource, + records: MileRecord[], + chargeType: 'FIRST_MILE' | 'LAST_MILE', +): Promise { + for (const r of records) { + const b = r.booking; + if (!b || Number(b.cargoTotalWeightVgm) > 0) continue; + const unitTons = (b.bookingContainers ?? []).reduce( + (sum, bc) => + sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0), + 0, + ); + if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3)); + } + + const needAdvance = records.filter( + (r) => r.bookingId && !(Number(r.advancedPayment) > 0), + ); + if (!needAdvance.length) return; + + const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query( + `SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount + FROM freight.invoice_lines il + JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL + WHERE i.source = 'booking' + AND i.status = 'PAID' + AND i.source_id = ANY($1::text[]) + AND il.charge_type = $2 + AND il.deleted_at IS NULL + GROUP BY i.source_id`, + [needAdvance.map((r) => r.bookingId), chargeType], + ); + const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)])); + for (const r of needAdvance) { + const paid = byBooking.get(r.bookingId as string); + if (paid) r.advancedPayment = paid; + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 0143f0796..948853e22 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { attachMileFinancials } from '../../common/mile-financials.util'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { BookingsRepository } from "../bookings/bookings.repository"; import { DriversService } from "../drivers/drivers.service"; @@ -66,6 +67,7 @@ export class FirstMileService { for (const r of records) { (r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await attachMileFinancials(this.dataSource, records, 'FIRST_MILE'); } /** Resolve a vehicle's driver + human labels, for stamping mile events onto diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 43887d468..601e03aec 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -12,6 +12,7 @@ import { SELF_HAUL_CONFLICT_MESSAGE, usesEdrMileService, } from '../../common/mile-haulage.util'; +import { attachMileFinancials } from '../../common/mile-financials.util'; import { assertBulkTonnageRemains, assertTruckCountWithinContainers, @@ -88,6 +89,7 @@ export class LastMileService { for (const r of records) { (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await attachMileFinancials(this.dataSource, records, 'LAST_MILE'); } /** Resolve a vehicle's driver + human labels, for stamping mile events onto From eb36aab05fd3ab2d37512dc8b843bc97466c285a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 21 Jul 2026 13:10:44 +0000 Subject: [PATCH 14/14] chore: enhance the tests --- .gitignore | 3 + docker-compose.e2e.yaml | 43 ++-- e2e/freight/README.md | 72 +++++-- .../e2e/flows/contract-lifecycle.cy.ts | 5 +- e2e/freight/scripts/e2e.mjs | 193 ++++++++++++++++++ package.json | 10 +- 6 files changed, 279 insertions(+), 47 deletions(-) create mode 100644 e2e/freight/scripts/e2e.mjs diff --git a/.gitignore b/.gitignore index 47b17bbac..21edddcd0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ docker-compose.override.yml e2e/**/cypress/videos/ e2e/**/cypress/screenshots/ e2e/**/cypress/downloads/ + +# e2e launcher state (ports of the running stack) +e2e/freight/.e2e-ports.json diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index 50a4f807e..b00402761 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -3,13 +3,14 @@ # vanishes on `down`), seeded test users. Requires the same .npmrc as the main # docker-compose.yaml (GitHub Packages auth for @tria-plc). # -# Up (build + wait healthy): docker compose -f docker-compose.e2e.yaml up -d --build --wait -# Headless run in container: docker compose -f docker-compose.e2e.yaml --profile cypress run --rm cypress -# Interactive from host: pnpm --filter @edr/freight-e2e cy:open -# Teardown: docker compose -f docker-compose.e2e.yaml down -v --remove-orphans +# Preferred entrypoint: the launcher (auto-up + free-port picking): +# pnpm e2e:freight:run|open|ci|up|down → e2e/freight/scripts/e2e.mjs # -# Host ports (chosen to never collide with the dev stacks — 5273/5283/3221 are -# taken by the second dev checkout in ~/projects/nathnael/edr-platform): +# Host ports are env-parameterized (E2E_*_PORT). Defaults below avoid the dev +# stacks (5273/5283/3221 are taken by the second dev checkout in +# ~/projects/nathnael/edr-platform); when a default is busy the launcher scans +# upward for a free port and remembers the choice in e2e/freight/.e2e-ports.json +# while the stack is up: # freight-api 3101 portal 5373 backoffice 5383 # postgres 5533 minio 9310 (console 9311) name: edr-freight-e2e @@ -24,7 +25,7 @@ services: tmpfs: - /var/lib/postgresql/data ports: - - "5533:5432" + - "${E2E_DB_PORT:-5533}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U edr_e2e -d edr_freight_e2e"] interval: 2s @@ -40,8 +41,8 @@ services: tmpfs: - /data ports: - - "9310:9000" - - "9311:9001" + - "${E2E_MINIO_PORT:-9310}:9000" + - "${E2E_MINIO_CONSOLE_PORT:-9311}:9001" healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 5s @@ -107,9 +108,9 @@ services: # SMS strategy has no kill switch and defaults to a real dev endpoint — # blackhole it so e2e never sends SMS (failures are logged, non-fatal). OZIKING_SMS_URL: http://127.0.0.1:9/sms - FREIGHT_PORTAL_URL: http://localhost:5373 + FREIGHT_PORTAL_URL: http://localhost:${E2E_PORTAL_PORT:-5373} ports: - - "3101:3001" + - "${E2E_API_PORT:-3101}:3001" healthcheck: # Boot runs 240+ migrations + seeders on first start — generous start_period. test: @@ -132,9 +133,10 @@ services: TURBO_FILTER: "@edr/freight-portal" APP_PATH: apps/edr-freight-web/portal # Baked at build time: browser (host or host-networked cypress - # container) reaches the API through the published host port. - VITE_API_URL: http://localhost:3101 - VITE_BASE_API_URL: http://localhost:3101 + # container) reaches the API through the published host port. A + # non-default API port therefore forces a web image rebuild. + VITE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_BASE_API_URL: http://localhost:${E2E_API_PORT:-3101} VITE_USER_MANAGEMENT_BASE: /_um VITE_GOOGLE_MAPS_API_KEY: "" VITE_POSTHOG_KEY: "" @@ -142,7 +144,7 @@ services: secrets: - npmrc ports: - - "5373:80" + - "${E2E_PORTAL_PORT:-5373}:80" freight-backoffice-e2e: build: @@ -151,8 +153,8 @@ services: args: TURBO_FILTER: "@edr/freight-backoffice" APP_PATH: apps/edr-freight-web/backoffice - VITE_API_URL: http://localhost:3101 - VITE_BASE_API_URL: http://localhost:3101 + VITE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_BASE_API_URL: http://localhost:${E2E_API_PORT:-3101} VITE_USER_MANAGEMENT_BASE: /_um VITE_GOOGLE_MAPS_API_KEY: "" VITE_POSTHOG_KEY: "" @@ -160,7 +162,7 @@ services: secrets: - npmrc ports: - - "5383:80" + - "${E2E_BACKOFFICE_PORT:-5383}:80" # Headless runner — opt-in via `--profile cypress`. host network so the # in-container browser uses the exact same localhost URLs as `cypress open` @@ -180,7 +182,10 @@ services: entrypoint: ["cypress", "run", "--browser", "chrome"] environment: CI: "true" - E2E_DB_URL: postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e + E2E_DB_URL: postgres://edr_e2e:edr_e2e@localhost:${E2E_DB_PORT:-5533}/edr_freight_e2e + CYPRESS_BASE_URL: http://localhost:${E2E_BACKOFFICE_PORT:-5383} + CYPRESS_API_URL: http://localhost:${E2E_API_PORT:-3101} + CYPRESS_PORTAL_URL: http://localhost:${E2E_PORTAL_PORT:-5373} volumes: - .:/repo diff --git a/e2e/freight/README.md b/e2e/freight/README.md index 69a97d704..75b877b84 100644 --- a/e2e/freight/README.md +++ b/e2e/freight/README.md @@ -6,31 +6,42 @@ both headless-in-Docker and interactively from the host against the same URLs. ## Stack (`docker-compose.e2e.yaml`, project name `edr-freight-e2e`) -| Service | Host port | Notes | -| ----------------------- | --------- | ---------------------------------------------- | -| `freight-api-e2e` | 3101 | migrations + seeders run at boot | -| `freight-portal-e2e` | 5373 | nginx static build, API baked to `:3101` | -| `freight-backoffice-e2e`| 5383 | nginx static build, API baked to `:3101` | -| `postgres-freight-e2e` | 5533 | `edr_freight_e2e`, tmpfs — gone on `down` | -| `minio-e2e` | 9310/9311 | object storage for file features | -| `cypress` | (host net)| profile `cypress`, headless chrome | +| Service | Default port | Notes | +| ----------------------- | ------------ | ---------------------------------------------- | +| `freight-api-e2e` | 3101 | migrations + seeders run at boot | +| `freight-portal-e2e` | 5373 | nginx static build, API URL baked at build | +| `freight-backoffice-e2e`| 5383 | nginx static build, API URL baked at build | +| `postgres-freight-e2e` | 5533 | `edr_freight_e2e`, tmpfs — gone on `down` | +| `minio-e2e` | 9310/9311 | object storage for file features | +| `cypress` | (host net) | profile `cypress`, headless chrome | -Ports deliberately avoid the dev stack (3001/5173/5183/5433). The dev database -is never touched. +Ports are env-parameterized (`E2E_API_PORT`, `E2E_PORTAL_PORT`, +`E2E_BACKOFFICE_PORT`, `E2E_DB_PORT`, `E2E_MINIO_PORT`, +`E2E_MINIO_CONSOLE_PORT`). Defaults avoid the dev stacks; if a default is +busy anyway, the launcher scans upward for a free port, remembers the choice +in `.e2e-ports.json` (gitignored) while the stack is up, and passes matching +URLs to both compose and Cypress. The dev database is never touched. ## Usage (from repo root) +One command — the launcher (`scripts/e2e.mjs`) auto-builds and starts the +stack if it isn't running, waits for healthchecks, then runs Cypress against +whatever ports were picked: + ```bash -pnpm e2e:freight:up # build + start stack, wait for healthchecks -pnpm e2e:freight:open # interactive Cypress on the host -pnpm e2e:freight:run # headless run from the host -pnpm e2e:freight:ci # headless run inside the cypress container -pnpm e2e:freight:down # teardown, drop all data +pnpm e2e:freight:run # headless run from the host (auto-up) +pnpm e2e:freight:open # interactive Cypress on the host (auto-up) +pnpm e2e:freight:ci # headless run inside the cypress container (auto-up) +pnpm e2e:freight:up # just start the stack +pnpm e2e:freight:down # teardown, drop all data + forget ports +pnpm e2e:freight:run --spec 'cypress/e2e/flows/**' # extra args → cypress ``` First `up` is slow (image builds + 240 migrations + seeders — healthcheck -allows 3 min). Requires the same root `.npmrc` (GitHub Packages auth for -`@tria-plc`) as the main compose file. +allows 3 min). Later runs against a live stack skip docker entirely. Requires +the same root `.npmrc` (GitHub Packages auth for `@tria-plc`) as the main +compose file. Note: a non-default API port forces a web-image rebuild (the +API URL is baked into the static builds). The `cypress` service uses `network_mode: host` (Linux). On macOS/Windows run Cypress from the host (`e2e:freight:open` / `e2e:freight:run`) instead of the @@ -39,10 +50,10 @@ container. ## Test users Inserted by Cypress itself — a global `before()` hook runs -`cy.task("db:seedUsers")`, which executes -`cypress/fixtures/seed-users.sql` (idempotent, pre-hashed argon2 passwords) -against the e2e database. No API code is involved; the app's user seeders -stay disabled. The API's always-on boot seeders must have run first +`cy.task("db:seedUsers")`, which executes `cypress/fixtures/seed-users.sql` +then `cypress/fixtures/seed-company.sql` (idempotent, pre-hashed argon2 +passwords) against the e2e database. No API code is involved; the app's user +seeders stay disabled. The API's always-on boot seeders must have run first (org/unit/positions) — guaranteed once `freight-api-e2e` is healthy. - Staff (backoffice): `linestaff|chief|director|ceo|marketer|operation|gl-et|gl-dj@edr.local` @@ -50,6 +61,12 @@ stay disabled. The API's always-on boot seeders must have run first - Customers (portal): `user@gmail.com`, `user2@gmail.com` — password `12345678` +`seed-company.sql` additionally gives `user@gmail.com` an ACTIVE company +("E2E Logistics PLC", TIN `0102030405`) with an approved importer profile — +the contract wizard's precondition — and grants `chief` the +`edr_freight_app:admin` permission (customer-profile approval is +FreightAdmin-guarded and no seeded position carries it otherwise). + Full map in `cypress/fixtures/users.json`. ## Conventions @@ -66,11 +83,22 @@ Full map in `cypress/fixtures/users.json`. - **DB access**: `cy.task("db:query", { sql, params })` runs SQL against the e2e database (`E2E_DB_URL`, default `localhost:5533`). Use for seeding edge-case data and asserting side effects — it can never reach the dev DB. +- **OTPs**: SMS/email delivery is disabled in e2e, but codes are still stored + in `freight.otp_verifications` — `cy.getOtp(emailOrPhone)` polls them out. + Used by signup verification and contract customer-signing. - **Spec layout**: - `cypress/e2e/api/` — API contract via `cy.request` (no browser) - `cypress/e2e/backoffice/` — staff app - `cypress/e2e/portal/` — customer app - - `cypress/e2e/flows/` — cross-app journeys (both directions) + - `cypress/e2e/flows/` — cross-app journeys (both directions): + - `onboarding.cy.ts` — signup → OTP → wizard (docs + license upload) → + backoffice approval → customer can contract + - `contract-lifecycle.cy.ts` — wizard → submit → accept → 2-step approval + → PDF → customer OTP-sign → staff counter-sign → `CONTRACT_ACTIVE` +- **Journey specs** (`flows/onboarding`, `flows/contract-lifecycle`) run with + `retries: 0` and resolve mid-journey state (user, company, contract) from + the DB at the start of each test: switching origin between tests reloads + the spec bundle, so module-level variables do NOT survive across tests. ## Extending diff --git a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts index 0c730b95f..e78fe1905 100644 --- a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts +++ b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts @@ -190,7 +190,10 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => { cy.get(`[id="${id}"]`).clear().type("EDR Marketer"); }); cy.drawSignature(); - cy.contains("button", /^Confirm signature$|^Approve & sign$/).click(); + // Scoped to the modal — the toolbar behind it has its own "Approve & sign". + cy.get(".mantine-Modal-content") + .contains("button", /^Confirm signature$|^Approve & sign$/) + .click(); cy.contains("counter-signed", { timeout: 30000 }).should("be.visible"); diff --git a/e2e/freight/scripts/e2e.mjs b/e2e/freight/scripts/e2e.mjs new file mode 100644 index 000000000..81c8bf216 --- /dev/null +++ b/e2e/freight/scripts/e2e.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * Freight e2e launcher — one command, no manual steps: + * + * node e2e/freight/scripts/e2e.mjs [cypress args...] + * + * - run/open/ci auto-start the docker stack (build + wait healthy) if it + * isn't already running, then launch Cypress pointed at the right ports. + * - Host ports default to 3101/5373/5383/5533/9310/9311; any default that is + * busy is replaced by the next free port. Chosen ports are written to + * e2e/freight/.e2e-ports.json (gitignored) and reused while the stack is + * up, so cypress and compose always agree. + * - Extra args are forwarded to Cypress: `pnpm e2e:freight:run --spec ...`. + * + * No dependencies — plain Node, spawns `docker compose` and `pnpm`. + */ + +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const e2eDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(e2eDir, "..", ".."); +const stateFile = join(e2eDir, ".e2e-ports.json"); +const composeBase = ["compose", "-f", join(repoRoot, "docker-compose.e2e.yaml")]; + +const DEFAULT_PORTS = { + E2E_API_PORT: 3101, + E2E_PORTAL_PORT: 5373, + E2E_BACKOFFICE_PORT: 5383, + E2E_DB_PORT: 5533, + E2E_MINIO_PORT: 9310, + E2E_MINIO_CONSOLE_PORT: 9311, +}; + +// Long-running services that must be up before tests (minio-init exits). +const SERVICES = [ + "postgres-freight-e2e", + "minio-e2e", + "freight-api-e2e", + "freight-portal-e2e", + "freight-backoffice-e2e", +]; + +function fail(msg) { + console.error(`\ne2e: ${msg}`); + process.exit(1); +} + +function preflight() { + try { + execFileSync("docker", ["info"], { stdio: "ignore" }); + } catch { + fail("docker is not running (or not installed) — start Docker and retry."); + } + if (!existsSync(join(repoRoot, ".npmrc"))) { + fail( + ".npmrc missing at repo root — image builds need GitHub Packages auth " + + "for @tria-plc (same file the main docker-compose.yaml uses).", + ); + } +} + +function isPortFree(port) { + return new Promise((res) => { + const srv = createServer(); + srv.once("error", () => res(false)); + srv.once("listening", () => srv.close(() => res(true))); + srv.listen(port); + }); +} + +function stackRunning(env) { + try { + const out = execFileSync( + "docker", + [...composeBase, "ps", "--services", "--status", "running"], + { encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"] }, + ); + const running = new Set(out.split("\n").filter(Boolean)); + return SERVICES.every((s) => running.has(s)); + } catch { + return false; + } +} + +/** + * While the stack runs, ports are whatever it was started with (state file, + * else the defaults — a hand-started stack used the compose defaults). Only a + * fresh start gets to scan for free ports. + */ +async function resolvePorts() { + if (stackRunning(process.env)) { + return existsSync(stateFile) + ? JSON.parse(readFileSync(stateFile, "utf8")) + : { ...DEFAULT_PORTS }; + } + const ports = {}; + const taken = new Set(); + for (const [name, preferred] of Object.entries(DEFAULT_PORTS)) { + let port = preferred; + while (taken.has(port) || !(await isPortFree(port))) port += 1; + taken.add(port); + ports[name] = port; + if (port !== preferred) + console.log(`e2e: port ${preferred} busy → ${name}=${port}`); + } + return ports; +} + +function envFor(ports) { + return { + ...process.env, + ...Object.fromEntries( + Object.entries(ports).map(([k, v]) => [k, String(v)]), + ), + CYPRESS_BASE_URL: `http://localhost:${ports.E2E_BACKOFFICE_PORT}`, + CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`, + CYPRESS_PORTAL_URL: `http://localhost:${ports.E2E_PORTAL_PORT}`, + E2E_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${ports.E2E_DB_PORT}/edr_freight_e2e`, + }; +} + +function compose(args, env) { + const { status } = spawnSync("docker", [...composeBase, ...args], { + stdio: "inherit", + env, + }); + return status ?? 1; +} + +function up(ports, env) { + preflight(); + console.log( + `e2e: starting stack — api :${ports.E2E_API_PORT} portal :${ports.E2E_PORTAL_PORT} backoffice :${ports.E2E_BACKOFFICE_PORT} db :${ports.E2E_DB_PORT}`, + ); + const status = compose(["up", "-d", "--build", "--wait"], env); + if (status !== 0) + fail( + "stack failed to become healthy. Inspect with:\n" + + " docker compose -f docker-compose.e2e.yaml logs freight-api-e2e", + ); + writeFileSync(stateFile, JSON.stringify(ports, null, 2) + "\n"); +} + +function ensureUp(ports, env) { + if (stackRunning(env)) return; + up(ports, env); +} + +function runPnpm(script, extra, env) { + const { status } = spawnSync( + "pnpm", + ["--filter", "@edr/freight-e2e", "run", script, ...extra], + { cwd: repoRoot, stdio: "inherit", env }, + ); + process.exit(status ?? 1); +} + +const [cmd, ...extra] = process.argv.slice(2); +const ports = await resolvePorts(); +const env = envFor(ports); + +switch (cmd) { + case "up": + up(ports, env); + break; + case "run": + ensureUp(ports, env); + runPnpm("cy:run", extra, env); + break; + case "open": + ensureUp(ports, env); + runPnpm("cy:open", extra, env); + break; + case "ci": + ensureUp(ports, env); + process.exit(compose(["--profile", "cypress", "run", "--rm", "cypress", ...extra], env)); + break; + case "down": + process.exit( + (() => { + const status = compose(["down", "-v", "--remove-orphans"], env); + rmSync(stateFile, { force: true }); + return status; + })(), + ); + break; + default: + fail(`unknown command "${cmd ?? ""}" — use up | run | open | ci | down`); +} diff --git a/package.json b/package.json index d01da8714..193732907 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,11 @@ "format": "prettier --write \"**/*.{ts,tsx,json,md}\"", "docker:build": "docker compose build", "docker:up": "docker compose up -d", - "e2e:freight:up": "docker compose -f docker-compose.e2e.yaml up -d --build --wait", - "e2e:freight:open": "pnpm --filter @edr/freight-e2e cy:open", - "e2e:freight:run": "pnpm --filter @edr/freight-e2e cy:run", - "e2e:freight:ci": "docker compose -f docker-compose.e2e.yaml --profile cypress run --rm cypress", - "e2e:freight:down": "docker compose -f docker-compose.e2e.yaml down -v --remove-orphans", + "e2e:freight:up": "node e2e/freight/scripts/e2e.mjs up", + "e2e:freight:open": "node e2e/freight/scripts/e2e.mjs open", + "e2e:freight:run": "node e2e/freight/scripts/e2e.mjs run", + "e2e:freight:ci": "node e2e/freight/scripts/e2e.mjs ci", + "e2e:freight:down": "node e2e/freight/scripts/e2e.mjs down", "prepare": "husky" }, "devDependencies": {