diff --git a/.claude/skills/edr-db/SKILL.md b/.claude/skills/edr-db/SKILL.md new file mode 100644 index 000000000..47333128d --- /dev/null +++ b/.claude/skills/edr-db/SKILL.md @@ -0,0 +1,42 @@ +--- +name: edr-db +description: Query, EXPLAIN-validate, and inspect the remote EDR freight dev database. Use whenever you need to check data, verify a raw SQL statement before shipping it, list a table's columns, check schema drift, or see which migrations are recorded. psql is NOT installed on this machine — this runner is the sanctioned path. Triggers - "check the db", "query edr_dev", "does column X exist", "validate this SQL", "is migration recorded", "seed check", diagnosing a 400/500 whose cause may be data or schema. +--- + +# EDR dev-DB runner + +One script, runs from anywhere in the repo (resolves `pg` from `apps/edr-freight-api`): + +```bash +node .claude/skills/edr-db/query.cjs "SELECT ... " # run SQL, console.table output +node .claude/skills/edr-db/query.cjs explain "SELECT ..." # EXPLAIN-validate only (no rows touched) +node .claude/skills/edr-db/query.cjs columns # freight.
column list +node .claude/skills/edr-db/query.cjs migrations [like] # public.migrations rows (newest first) +node .claude/skills/edr-db/query.cjs drift
# bare column names, for diffing vs the entity +``` + +Connection comes from `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME`, +defaulting to the shared dev database (`edr_dev`). + +## Rules that go with it + +- **HARD RULE: every raw SQL statement you write into a service must pass + `explain` here before you ship it.** A typo'd column is a runtime 500 the + type-checker cannot catch. +- Never assume a recorded migration applied — check `migrations ` **and** + `columns
` together. Recorded-but-absent = schema drift; fix with a + NEW repair migration (idempotent DDL, no-op `down()`), never by editing the + recorded one. +- Writes to dev data are fine for seeding/diagnosis but keep them idempotent + (`WHERE NOT EXISTS` guards) — watch-mode API instances race `migrationsRun`, + and non-idempotent statements have double-run here before. +- Timestamps for new migrations: must be unique across `src/migrations/` AND + higher than `SELECT max(timestamp) FROM public.migrations`. + +## Diagnosing a pasted 400/500 (the recurring loop) + +1. Find the route: grep the path segment in `apps/edr-freight-api/src/modules/*/**.controller.ts`. +2. Read the service method — list its guard `throw`s. Most "bugs" are a guard + working as designed (handover unsigned, fee unpaid, not PAID, wrong direction). +3. Check the actual DB state for that record with this runner. +4. Only then decide: guard doing its job (fix the UI affordance) vs real defect. diff --git a/.claude/skills/edr-db/query.cjs b/.claude/skills/edr-db/query.cjs new file mode 100644 index 000000000..b4797bd45 --- /dev/null +++ b/.claude/skills/edr-db/query.cjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Dev-DB runner for the EDR freight database. psql is NOT installed on this + * machine; this is the sanctioned way to query, EXPLAIN-validate, and inspect + * the remote dev DB. Resolves `pg` from apps/edr-freight-api so it runs from + * anywhere in the repo. + * + * node .claude/skills/edr-db/query.cjs "SELECT ... " run SQL (console.table) + * node .claude/skills/edr-db/query.cjs explain "SELECT..." EXPLAIN-validate only + * node .claude/skills/edr-db/query.cjs columns
list freight.
columns + * node .claude/skills/edr-db/query.cjs migrations [like] public.migrations rows + * node .claude/skills/edr-db/query.cjs drift
columns vs entity check helper + * + * Connection: DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env vars, falling + * back to the shared dev database. + */ +const path = require('path'); +const { createRequire } = require('module'); + +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const apiRequire = createRequire( + path.join(repoRoot, 'apps', 'edr-freight-api', 'package.json'), +); +const { Client } = apiRequire('pg'); + +const cfg = { + host: process.env.DB_HOST ?? '10.18.7.207', + port: parseInt(process.env.DB_PORT ?? '5432', 10), + user: process.env.DB_USER ?? 'postgres', + password: process.env.DB_PASSWORD ?? 'dcba@1234', + database: process.env.DB_NAME ?? 'edr_dev', +}; + +const [, , first, ...rest] = process.argv; + +async function main() { + if (!first) { + console.error('usage: query.cjs "" | explain "" | columns
| migrations [like] | drift
'); + process.exit(2); + } + const c = new Client(cfg); + await c.connect(); + try { + if (first === 'columns') { + const r = await c.query( + `SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema='freight' AND table_name=$1 + ORDER BY ordinal_position`, + [rest[0]], + ); + console.table(r.rows); + } else if (first === 'migrations') { + const like = rest[0] ? `%${rest[0]}%` : '%'; + const r = await c.query( + `SELECT id, timestamp, name FROM public.migrations + WHERE name ILIKE $1 ORDER BY id DESC LIMIT 40`, + [like], + ); + console.table(r.rows); + } else if (first === 'drift') { + // Quick drift signal: DB columns for the table. Compare by eye against + // the entity's @Column names; a recorded-but-absent column = drift. + const r = await c.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema='freight' AND table_name=$1 ORDER BY column_name`, + [rest[0]], + ); + console.log(r.rows.map((x) => x.column_name).join('\n')); + } else if (first === 'explain') { + await c.query('EXPLAIN ' + rest.join(' ')); + console.log('OK — statement is valid against', cfg.database); + } else { + const sql = [first, ...rest].join(' '); + const started = Date.now(); + const r = await c.query(sql); + if (Array.isArray(r.rows) && r.rows.length) console.table(r.rows); + console.log(`${r.rowCount ?? 0} row(s), ${Date.now() - started}ms`); + } + } finally { + await c.end(); + } +} + +main().catch((e) => { + console.error('FAIL:', e.message); + process.exit(1); +}); diff --git a/.claude/skills/standup/SKILL.md b/.claude/skills/standup/SKILL.md new file mode 100644 index 000000000..272f280e7 --- /dev/null +++ b/.claude/skills/standup/SKILL.md @@ -0,0 +1,40 @@ +--- +name: standup +description: Produce the work report Hagernesh asks for - "what have I done today", "tasks of yesterday and today", daily/period summaries for tickets or timesheets. Builds the answer from git history plus uncommitted work, never from memory alone. +--- + +# Work report (standup / ticket summary) + +Ground every line in git. Do not reconstruct from conversation memory — commits +are the record. + +## Gather + +```bash +# Commits in the window (adjust dates; author matches "Hagernesh") +git log --since="YYYY-MM-DD 00:00" --until="YYYY-MM-DD 00:00" --author=Hagernesh \ + --pretty=format:"%h|%ad|%s" --date=short + +# What each commit actually contains (subjects lie sometimes) +git show --stat --pretty=format:"%s" | head -12 + +# In-flight work = part of "today" even if uncommitted +git status --short +git log origin/dev..HEAD --oneline # branch commits not yet in dev +``` + +## Known pitfalls in this repo + +- **Check subjects against contents.** Commit titles here sometimes mismatch the + diff (e.g. a commit titled "unload export" that actually contained ISO + container validation). Use `git show --stat` before reporting a title as fact. +- A day with no commits usually still has uncommitted/in-flight work — report it + as its own section with per-item status (done / uncommitted / blocked). +- Merge commits from other authors are noise; filter with `--author`. + +## Output format + +One table per day: `# | Task (plain language, not the commit subject verbatim) | +Commit / Status`. Follow with a short "carry-over / blocked" list naming what +blocks each item. Keep it ticket-ready: no jargon that needs the repo open to +decode. diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 000000000..a2c715db4 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,59 @@ +--- +name: verify +description: Project definition-of-done runner for the EDR platform. Use before calling any code change finished, before committing, and whenever asked "is it done / does it work". Runs the targeted checks that actually catch this repo's failure modes - type-check with turbo filters, @edr/types dist rebuild, raw-SQL EXPLAIN validation, migration safety, and honest test reporting. +--- + +# Verify a change (EDR definition of done) + +Run these in order. Report which you ran and what each said — never call +unverified work done. + +## 1. Type-check exactly what you touched + +```bash +pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice --filter=@edr/freight-portal +``` + +Drop filters you didn't touch; whole-repo runs waste minutes. **If you edited +`packages/types`, rebuild it FIRST** — consumers read its `dist/`, not `src/`: + +```bash +pnpm turbo build --filter=@edr/types +``` + +## 2. Validate every raw SQL statement + +Each new/edited `dataSource.query` / `manager.query` string must pass: + +```bash +node .claude/skills/edr-db/query.cjs explain "" +``` + +## 3. Migration checklist (if you added one) + +- Timestamp unique in `src/migrations/` **and** greater than + `node .claude/skills/edr-db/query.cjs "SELECT max(timestamp) FROM public.migrations"`. +- DDL idempotent (`IF NOT EXISTS`, guarded backfills). +- Watch-mode reload does NOT run migrations — apply the SQL to the dev DB + yourself or fully restart the API, then confirm with + `query.cjs columns
`. + +## 4. Tests — honest bar + +`pnpm test` for `@edr/freight-api` is currently red on `dev`, so a green suite +is not the bar. The bar: run the specs nearest what you touched and introduce +**no new failure**. If you touched a service constructor, update its `.spec.ts` +mocks (constructor-arity breaks are this repo's most common test regression). + +## 5. Observe the behaviour + +Compiling is not working. Hit the endpoint, drive the UI flow, or query the +resulting rows. If you genuinely could not observe it, say so explicitly in the +summary — do not imply it was seen working. + +## 6. Before commit + +- Conventional message (`fix(warehouses): …`). Git hooks do NOT run in this + repo (husky shims exist but no user hooks) — nothing will catch it for you. +- Lint the files you touched if in doubt: `pnpm turbo lint --filter=`. +- Do not commit or push unless the user asked. diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md new file mode 100644 index 000000000..11075e9f0 --- /dev/null +++ b/CLAUDE_NEW.md @@ -0,0 +1,294 @@ +# EDR Platform — Developer Guide + +> This file is the contract. If something here contradicts the code, the code is the +> truth and this file is a bug — fix it in the same PR. + +## Overview + +Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight +Management and Passenger Management applications, a payment microservice, plus shared +types, NestJS utilities, and React component libraries. + +The freight domain is the largest and most active area. Its core flow is: +**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload +→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.** +Fees (storage, demurrage, double handling, truck detention) and allocation rules +(warehouse/yard/zone) hang off the warehouse stage. + +## Apps + +| App | Package name | Purpose | Default port | +| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ | +| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 | +| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | +| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | +| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | +| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | +| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | +| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | + +`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. +Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace +packages (see `pnpm-workspace.yaml`). + +`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace +package and is not built, linted, or type-checked. Leave it alone unless asked. + +## Packages + +| Package | Purpose | +| ---------------------- | ---------------------------------------------------------------------------------- | +| `@edr/types` | Shared TypeScript interfaces and enums | +| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | +| `@edr/ui-common` | Shared React components and theme | +| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | +| `@edr/tsconfig` | Shared TypeScript configurations | +| `@edr/prettier-config` | Shared Prettier configuration | + +**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a +type in `packages/types/src` changes nothing for consumers until you rebuild: + +```bash +pnpm turbo build --filter=@edr/types +``` + +If a type-check fails on a field you just added to `@edr/types`, this is why. + +## Commands + +| Command | Description | +| --------------------------- | ---------------------------------------- | +| `pnpm install` | Install all workspace dependencies | +| `pnpm dev` | Run every app in dev mode | +| `pnpm dev:freight` | Freight API + portal + backoffice | +| `pnpm dev:freight:api` | Freight API only | +| `pnpm dev:freight:portal` | Freight portal only | +| `pnpm dev:freight:backoffice` | Freight backoffice only | +| `pnpm dev:passenger` | Passenger API + web | +| `pnpm dev:payment` | Payment API | +| `pnpm build` | Build every package and app | +| `pnpm test` | Run all tests (turbo) | +| `pnpm lint` | Lint everything | +| `pnpm type-check` | Type-check every package | +| `pnpm format` | Format all files with Prettier | + +Prefer targeted turbo filters over whole-repo runs — they are minutes faster: + +```bash +pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice +``` + +`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains, +gate-pass scenarios). Read the script before running one; several write real rows. + +## Environment & database + +- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and + no port `5433`/`5434` is published anywhere in the repo. +- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, + `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a + remote database. +- The connection sits behind a **connection pooler**. Do **not** pass + `extra.options: '-c search_path=…'` — the pooler rejects it with + `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied + per-connection in a pool `connect` handler instead. See + `apps/edr-freight-api/src/config/database.config.ts` before touching connection options. +- Each app owns its own database. **No cross-database joins**; cross-domain data flows + through API calls or message queues. +- `psql` is not installed on the dev machine. To query the database, write a short Node + script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves). + +## Hard rules + +These are non-negotiable. Everything else is a strong default. + +- **pnpm only.** Never run `npm install` or `yarn`. +- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not + reach for `any` to make an error go away. +- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false` + in every config and it has already corrupted this database twice (see *Migrations*). + All schema changes go through TypeORM migrations. +- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). +- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`, + `deletedAt` (soft delete). +- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`); + TypeScript properties are `camelCase`. +- **Controllers contain no business logic.** They validate, delegate, and shape the response. +- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`. +- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands. +- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and + offer the safe version. + +## Architecture + +### NestJS module shape + +`module → controller → service → repository`, with `entities/` and `dto/` alongside. + +### Data access — the real model + +There are two sanctioned ways to read and write, and you must pick the right one: + +1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from + `@edr/api-common`. Services inject the repository class, never `Repository` directly. +2. **Read projections, queue endpoints, cross-table reports → raw SQL** via + `this.dataSource.query(...)` or `manager.query(...)` inside a transaction. + +Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. +It carries one obligation: + +> **HARD RULE — validate every raw SQL statement against a real database before you ship it.** +> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through +> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*). + +Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository, +so they join the caller's transaction. + +**Never do slow I/O inside a database transaction.** Queue the work and fan it out after +commit. An SMS awaited inside a transaction once held capacity locks open for the whole +gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to +no timeout and will wait forever. + +### Migrations + +Migrations are the most dangerous surface in this repo. Two production-grade incidents have +already come from it. + +- `migrationsRun: true` — **migrations run automatically on API boot**, with + `migrationsTransactionMode: 'each'`. +- Consequences you must design for: + - Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent + data migration can execute twice. Keep one instance. + - A watch-mode hot reload does **not** re-run migrations. If you add a column that new + code reads, apply it to the dev database yourself (idempotently) or fully restart. +- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or + more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before + adding one, check the filename prefix is unused *and* higher than the newest recorded row. +- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and + backfills guarded by `WHERE col IS NULL`. +- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory` + was recorded in `migrations` while its column was absent — it had been dropped out of band. + TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*. +- **A repair migration's `down()` should be a no-op.** Reverting a repair must not + re-introduce the outage it fixed. + +### Auth + +Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own. + +- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub. +- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`. +- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS..)`. +- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`. + Add a permission there before referencing it. +- IAM has its own migrations, run ahead of freight migrations from the same data source, and + its own CLI scripts (`iam:migration:run`, `iam:seed:run`). + +Ownership checks are separate from permission checks. A staff user passes +`hasFreightPermission`; a customer must additionally pass an ownership assertion such as +`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed. + +## Frontend conventions + +- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version + before copying a snippet. +- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the + freight web apps. Prefer it over re-implementing a component. +- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'` + delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no + `.message` and degrades to `"Request failed with status code 400"`. Use + `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches + keep the synchronous version — their bodies are already parsed JSON. +- Server-side guards must be reflected in the UI. If the API will reject the action, the + button should be disabled, hidden, or explain the blocker — not fire and surface a 400. +- Prefer disabling a control with a visible reason over silently hiding it. + +## Notifications + +In-app notifications resolve recipients from the company's **linked portal users**. If a +company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error. +SMS and email still send, because they address the company's phone and email directly. Check +this before debugging a "missing notification". + +## PDF generation + +Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled +generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than +assume a headless browser exists. + +## Adding a new module to a NestJS app + +1. Create `modules//` with `entities/`, `dto/`, and the four + `.{module,controller,service,repository}.ts` files. +2. The entity extends `BaseEntity` from `@edr/api-common`. +3. The repository extends `BaseRepository` from `@edr/api-common`. +4. The service injects the repository class (not `Repository` directly). +5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route. +6. Register the module in the app's `app.module.ts`. + +## Adding a new shared component to `@edr/ui-common` + +1. Create `src/components//.tsx` and `src/components//index.ts`. +2. Export from `src/index.ts`. +3. Component is a functional component with a `ComponentNameProps` interface + (named-exported alongside the default). + +## Definition of done + +A change is done when **all** of these hold. State explicitly which you ran. + +1. **It type-checks.** `pnpm turbo type-check --filter=` passes. + If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first. +2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the + dev database without error. +3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds + something the new code reads — applied to the dev database, since watch mode will not run it. +4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**, + so a fully green suite is not the bar. Run the specs covering what you touched and confirm + you introduced no new failure. +5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these + automatically (see below), so run them yourself. +6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the + endpoint, or ran the query. If you could not, say so plainly. +7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in + the summary. Never describe unverified work as done. + +### Hooks do not run + +`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed +at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`, +`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever +fire.** Nothing validates your commit message or formats your staged files. Run the checks by +hand; do not assume the hook caught it. + +## Known traps + +| Trap | What happens | What to do | +| --- | --- | --- | +| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one | +| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp | +| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` | +| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout | +| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` | +| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging | +| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully | + +## Project skills + +Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps: + +| Skill | Use for | +| --- | --- | +| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. | +| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. | +| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. | + +## Working style + +- **Verify before asserting.** Read the code or query the database. Do not infer behaviour + from a filename. +- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the + trade-off before changing files. +- **Small, reviewable commits**, one logical change each, conventional message. +- **Branch from `dev`; PRs target `dev`.** +- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result. diff --git a/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts b/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts new file mode 100644 index 000000000..aa2f998a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Repairs `freight.warehouse_inventory.grn_number`. + * + * AddGrnNumberToWarehouseInventory1828000000000 is recorded in `migrations` but + * the column is absent on at least one environment - it was added, then dropped + * out-of-band (a stray `synchronize: true`, same class of damage that + * RepairSynchronizeDrift1870000000000 already had to undo). Because TypeORM has + * the original recorded, it will never re-run it. + * + * Without the column, everything that reads or writes a GRN fails with + * `column ... grn_number does not exist`: + * - bulkReceive() -> INSERT names grn_number (receive to warehouse) + * - importQueueByStatuses() -> Unloaded + Dispatch queues + * - exportInventoryByStatus() -> Received / Ready-To-Load / Loaded tabs + * - grnDocument() -> GRN PDF + * + * Idempotent: a no-op on environments where the column survived. + */ +export class RepairGrnNumberColumn2090000000000 implements MigrationInterface { + name = 'RepairGrnNumberColumn2090000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL + `); + + // Recover the GRN for rows received before the column existed: it was also + // written into the receive note as "GRN Number: ". + await queryRunner.query(` + UPDATE freight.warehouse_inventory + SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)') + WHERE grn_number IS NULL + AND notes IS NOT NULL + AND notes ~ 'GRN Number: ' + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number + ON freight.warehouse_inventory(grn_number) + WHERE grn_number IS NOT NULL + `); + } + + /** + * Deliberately a no-op. Dropping the column is what broke these environments + * in the first place, and the original 1828 migration already owns its own + * down(). Reverting this repair must not re-introduce the outage. + */ + public async down(): Promise { + // intentionally empty + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts new file mode 100644 index 000000000..2bf9f82fd --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + NotFoundException, + Param, + ParseUUIDPipe, + Post, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto"; +import { CustomerResetService } from "./customer-reset.service"; + +/** + * Staff-triggered password reset. The customer receives the code and sets their + * own password — staff never see or handle a credential. + */ +@ApiTags("backoffice") +@Controller("backoffice/customers") +@ApiBearerAuth() +export class CustomerResetController { + constructor(private readonly customerResetService: CustomerResetService) {} + + @Post(":companyId/reset-password") + @BookingStaff(FREIGHT_PERMS.customers.resetPassword) + @ApiOperation({ + summary: "Send a password-reset code to a customer's primary contact", + }) + async resetPassword( + @Param("companyId", ParseUUIDPipe) companyId: string, + @Body() dto: BackofficeResetPasswordDto, + ) { + const maskedTarget = await this.customerResetService.sendResetToCustomer( + companyId, + dto.channel, + ); + + if (!maskedTarget) { + throw new NotFoundException( + `No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`, + ); + } + + return { channel: dto.channel, maskedTarget }; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts new file mode 100644 index 000000000..4c8f67599 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { ExternalProfile } from "../companies/entities/external-profile.entity"; +import { ResetChannel } from "./dto/forgot-password.dto"; +import { ForgotPasswordService } from "./forgot-password.service"; + +@Injectable() +export class CustomerResetService { + private readonly logger = new Logger(CustomerResetService.name); + + constructor( + @InjectRepository(ExternalProfile) + private readonly externalProfileRepository: Repository, + private readonly forgotPasswordService: ForgotPasswordService, + ) {} + + /** + * Send a reset code to the company's primary contact. Returns the masked + * destination, or null when there is no eligible account for that channel. + * + * Unlike the public flow this reports failure honestly — the caller is an + * authenticated staff member, so there is nothing to enumerate. + */ + async sendResetToCustomer( + companyId: string, + channel: ResetChannel, + ): Promise { + const profile = await this.externalProfileRepository.findOne({ + where: { companyId, isPrimaryContact: true }, + }); + + if (!profile) { + this.logger.warn(`Company ${companyId} has no primary contact profile`); + return null; + } + + // Resolve through the same active-account gate the public flow uses, so a + // suspended customer cannot be reactivated by a staff-triggered reset. + const user = await this.forgotPasswordService.resolveActiveUserById( + profile.userId, + ); + if (!user) { + this.logger.warn( + `Primary contact ${profile.userId} of company ${companyId} is not an active account`, + ); + return null; + } + + const target = await this.forgotPasswordService.requestReset(user, channel); + if (!target) return null; + + this.logger.log( + `Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`, + ); + return this.forgotPasswordService.maskTarget(target); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts new file mode 100644 index 000000000..be2f9bdac --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum, IsNotEmpty, IsString } from "class-validator"; + +/** The channel the reset code is delivered over. */ +export enum ResetChannel { + Email = "email", + Phone = "phone", +} + +export class ForgotPasswordRequestDto { + @ApiProperty({ + description: "Email, username, or phone number of the account to reset", + example: "name@company.com", + }) + @IsString() + @IsNotEmpty() + identifier!: string; + + @ApiProperty({ enum: ResetChannel }) + @IsEnum(ResetChannel) + channel!: ResetChannel; +} + +export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto { + @ApiProperty({ description: "The 6-digit code sent to the chosen channel" }) + @IsString() + @IsNotEmpty() + otp!: string; +} + +export class BackofficeResetPasswordDto { + @ApiProperty({ enum: ResetChannel }) + @IsEnum(ResetChannel) + channel!: ResetChannel; +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts new file mode 100644 index 000000000..da49982d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts @@ -0,0 +1,69 @@ +import { Body, Controller, Logger, Post } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Public } from "@edr/api-common"; + +import { + ForgotPasswordRequestDto, + ForgotPasswordVerifyDto, +} from "./dto/forgot-password.dto"; +import { ForgotPasswordService, ResetTicket } from "./forgot-password.service"; + +/** + * Freight-owned reset flow. IAM ships a `forgot-password` route, but it only + * ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which + * this API does not set). These routes drive freight's own email-or-phone OTP + * service instead, then hand back a ticket for IAM's public `set-password`. + */ +@ApiTags("auth") +@Controller("auth") +@Public() +export class ForgotPasswordController { + private readonly logger = new Logger(ForgotPasswordController.name); + + constructor(private readonly forgotPasswordService: ForgotPasswordService) {} + + @Post("forgot-password/request") + @ApiOperation({ + summary: "Send a password-reset code over email or SMS", + description: + "Always reports success. An unknown, inactive, or channel-less account is " + + "indistinguishable from a real one, so this cannot be used to enumerate accounts.", + }) + async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> { + const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier); + + if (user) { + try { + await this.forgotPasswordService.requestReset(user, dto.channel); + } catch (error) { + // A delivery failure must not change the response shape either — log it + // and let the caller sit on the OTP screen. + this.logger.error( + `Reset code delivery failed for user ${user.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); + } + } else { + this.logger.log("Reset requested for an unknown or inactive account"); + } + + return { success: true }; + } + + @Post("forgot-password/verify") + @ApiOperation({ + summary: "Exchange a valid reset code for a single-use set-password ticket", + description: + "The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " + + "alongside the same identifier and the new password.", + }) + verify(@Body() dto: ForgotPasswordVerifyDto): Promise { + return this.forgotPasswordService.verifyAndMintTicket( + dto.identifier, + dto.channel, + dto.otp, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts new file mode 100644 index 000000000..42dc723d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -0,0 +1,169 @@ +import { randomBytes } from "node:crypto"; + +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; +import { DataSource, Repository } from "typeorm"; + +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity"; + +import { OtpService, OtpTarget } from "../otp/otp.service"; +import { ResetChannel } from "./dto/forgot-password.dto"; + +/** + * How long the reset ticket minted for `PATCH /api/auth/set-password` stays + * valid. The IAM `setPassword` handler enforces this via `expiresAt`. + */ +const RESET_TICKET_TTL_MS = 10 * 60 * 1000; + +/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */ +const RESET_OTP_TTL_MS = 10 * 60 * 1000; + +export interface ResetTicket { + userId: string; + verificationCode: string; +} + +@Injectable() +export class ForgotPasswordService { + private readonly logger = new Logger(ForgotPasswordService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly otpService: OtpService, + ) {} + + /** + * Resolve an account that is actually eligible for a password reset. + * + * IAM's `set-password` handler flips `isActive: true` on the user as a side + * effect, so a reset on a deactivated account would silently resurrect it. + * Gating here — rather than at the set-password call — is what keeps that + * from being reachable. Mirrors IAM's own login lookup: match on any of + * email / username / phone, and require an active credential row. + */ + async resolveActiveUser(identifier: string): Promise { + const id = identifier.trim(); + if (!id) return null; + + return await this.activeUserQuery() + .andWhere( + "(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)", + { id }, + ) + .getOne(); + } + + /** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */ + async resolveActiveUserById(userId: string): Promise { + if (!userId) return null; + return await this.activeUserQuery() + .andWhere("u.id = :userId", { userId }) + .getOne(); + } + + /** + * Base query for accounts eligible to reset. `.where()` is claimed here so + * callers must use `.andWhere()` — TypeORM's `.where()` resets the clause, + * which would silently drop the `isActive` gate. + */ + private activeUserQuery() { + return this.userRepository + .createQueryBuilder("u") + .innerJoin("u.userCredentials", "uc", "uc.isActive = true") + .where("u.isActive = true") + .orderBy("u.createdAt", "DESC"); + } + + /** The address the code goes to, taken from the account — never from input. */ + private targetFor(user: User, channel: ResetChannel): OtpTarget | null { + if (channel === ResetChannel.Email) { + return user.email ? { email: user.email } : null; + } + return user.phoneNumber ? { phone: user.phoneNumber } : null; + } + + /** + * Send a reset code to the account's own email/phone. Returns the target so + * authenticated (backoffice) callers can echo a masked version; unauthenticated + * callers must discard it. + * + * Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp` + * upserts. A reset request therefore overwrites any pending signup code for + * the same address — last code sent wins. That is the pre-existing behaviour + * between any two flows sharing this table. + */ + async requestReset( + user: User, + channel: ResetChannel, + ): Promise { + const target = this.targetFor(user, channel); + if (!target) return null; + + await this.otpService.sendOtp(target); + return target; + } + + /** + * Prove possession of the OTP, then mint an IAM reset ticket the caller can + * spend on the public `PATCH /api/auth/set-password`. + * + * Minting a `UserVerification` row rather than writing `UserCredential` + * ourselves keeps IAM as the single owner of the password write path (old + * credential deactivation, argon hashing, changed-at bookkeeping). + */ + async verifyAndMintTicket( + identifier: string, + channel: ResetChannel, + otp: string, + ): Promise { + const user = await this.resolveActiveUser(identifier); + const target = user && this.targetFor(user, channel); + + if (!user?.id || !target) { + // Same shape as a wrong code: a caller probing for accounts learns nothing + // beyond what the request step already (deliberately) refuses to tell them. + throw new BadRequestException("Invalid verification code"); + } + + await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS); + + const code = randomBytes(24).toString("base64url"); + const verificationCode = await hashPassword(code); + const userId = user.id; + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(UserVerification); + // Retire any outstanding codes so only the ticket we just minted can be + // spent — `findVerificationForPrimaryReset` reads the newest row. + await repo.update({ userId }, { isUsed: true }); + await repo.insert({ + userId, + otpType: EOtpType.RESET_PASSWORD, + verificationCode, + expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS), + isUsed: false, + attemptCount: 0, + }); + }); + + this.logger.log(`Reset ticket minted for user ${userId}`); + return { userId, verificationCode: code }; + } + + /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */ + maskTarget(target: OtpTarget): string { + if (target.email) { + const [local, domain] = target.email.split("@"); + const head = local.slice(0, 1); + return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; + } + const phone = target.phone ?? ""; + return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 16fbeffda..6415cf4c1 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -2,15 +2,35 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity'; +import { ExternalProfile } from '../companies/entities/external-profile.entity'; +import { OtpModule } from '../otp/otp.module'; import { CheckAvailabilityController } from './check-availability.controller'; import { CheckAvailabilityService } from './check-availability.service'; +import { CustomerResetController } from './customer-reset.controller'; +import { CustomerResetService } from './customer-reset.service'; +import { ForgotPasswordController } from './forgot-password.controller'; +import { ForgotPasswordService } from './forgot-password.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; @Module({ - imports: [TypeOrmModule.forFeature([User])], - controllers: [FreightMeController, CheckAvailabilityController], - providers: [FreightMeService, CheckAvailabilityService], + imports: [ + TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]), + OtpModule, + ], + controllers: [ + FreightMeController, + CheckAvailabilityController, + ForgotPasswordController, + CustomerResetController, + ], + providers: [ + FreightMeService, + CheckAvailabilityService, + ForgotPasswordService, + CustomerResetService, + ], }) export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 50b5dd767..dac739fbf 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1125,6 +1125,30 @@ export class BookingsService { ); } + /** + * Batched version of the findById flag: marks each page item whose booking + * has a generated-but-unsigned SELF_HAUL handover, so list rows (portal + * dashboard) can show "Approve delivery" for exactly the generated→signed + * window. One query for the whole page. + */ + private async attachHandoverFlags(bookings: Booking[]): Promise { + const ids = bookings.map((b) => b.id); + if (!ids.length) return; + const rows: Array<{ bookingId: string }> = await this.dataSource.query( + `SELECT DISTINCT booking_id AS "bookingId" + FROM freight.booking_handovers + WHERE booking_id = ANY($1::uuid[]) + AND signed_at IS NULL AND deleted_at IS NULL + AND mile_type = 'SELF_HAUL'`, + [ids], + ); + const pending = new Set(rows.map((r) => r.bookingId)); + for (const b of bookings) { + (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = + pending.has(b.id); + } + } + async findAll( filter: FilterBookingDto, forceCompanyId?: string, @@ -1135,7 +1159,7 @@ export class BookingsService { const statusFilter = this.parseStatusFilter(filter); const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter); - return this.bookingsRepository.findAllPaginated({ + const result = await this.bookingsRepository.findAllPaginated({ page, pageSize, ...statusFilter, @@ -1167,6 +1191,8 @@ export class BookingsService { sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); + await this.attachHandoverFlags(result.items ?? []); + return result; } /** Booking statuses at which a customer can pay (mirrors booking-payment.service). */ diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts new file mode 100644 index 000000000..73271e0af --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts @@ -0,0 +1,88 @@ +import { Repository } from "typeorm"; + +import { CompanyChangeRequestRepository } from "./company-change-request.repository"; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from "./entities/company-change-request.entity"; + +type Row = Pick & { createdAt: Date }; + +const COMPANY_ID = "company-1"; + +/** + * Stands in for the TypeORM repository over a fixed set of rows, honouring the + * `where.status` filter and the `createdAt DESC` ordering findOne relies on. + */ +function mockRepositoryOver(rows: Row[]) { + return { + findOne: jest.fn( + ({ where }: { where: Partial & { companyId: string } }) => + Promise.resolve( + rows + .filter( + (row) => + where.companyId === COMPANY_ID && + (where.status === undefined || row.status === where.status), + ) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? + null, + ), + ), + } as unknown as Repository; +} + +function subject(rows: Row[]) { + return new CompanyChangeRequestRepository(mockRepositoryOver(rows)); +} + +describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => { + const rejected: Row = { + id: "rejected", + status: ChangeRequestStatus.Rejected, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }; + + it("returns the pending request when one is open", async () => { + const pending: Row = { + id: "pending", + status: ChangeRequestStatus.Pending, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + + const result = await subject([rejected, pending]).findLatestOpenByCompanyId( + COMPANY_ID, + ); + + expect(result?.id).toBe("pending"); + }); + + it("returns the latest rejected request when nothing is pending", async () => { + const result = await subject([rejected]).findLatestOpenByCompanyId( + COMPANY_ID, + ); + + expect(result?.id).toBe("rejected"); + }); + + it("returns null once a resubmit of a rejected request is approved", async () => { + const approved: Row = { + id: "approved", + status: ChangeRequestStatus.Approved, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + + const result = await subject([ + rejected, + approved, + ]).findLatestOpenByCompanyId(COMPANY_ID); + + expect(result).toBeNull(); + }); + + it("returns null when the company has no requests", async () => { + const result = await subject([]).findLatestOpenByCompanyId(COMPANY_ID); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts index 24d988452..eb44d56cb 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -28,18 +28,23 @@ export class CompanyChangeRequestRepository extends BaseRepository { const pending = await this.findPendingByCompanyId(companyId); if (pending) return pending; - return this.repository.findOne({ - where: { companyId, status: ChangeRequestStatus.Rejected }, + const latest = await this.repository.findOne({ + where: { companyId }, order: { createdAt: "DESC" }, }); + return latest?.status === ChangeRequestStatus.Rejected ? latest : null; } async findById(id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index 15aec5ac6..9bf14cd61 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, InternalServerErrorException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { BaseRepository } from "@edr/api-common"; @@ -20,6 +20,11 @@ const PREFIX_MAP: Record = { [ProfileType.transporter]: "TR", }; +const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +/** Numbers per series letter: A00001..A99999, then B00001. */ +const SERIES_SIZE = 99_999; + @Injectable() export class CompanyProfileRepository extends BaseRepository { constructor( @@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository { const result = await this.repository.query( `SELECT nextval('${seqName}') AS next_id`, ); - const nextId = result[0].next_id as number; + const nextId = Number(result[0].next_id); + const offset = nextId - 1; + const seriesIndex = Math.floor(offset / SERIES_SIZE); + + if (seriesIndex >= SERIES_LETTERS.length) { + throw new InternalServerErrorException( + `Company profile reference series exhausted for type "${type}"`, + ); + } + + const letter = SERIES_LETTERS[seriesIndex]; + const number = (offset % SERIES_SIZE) + 1; const prefix = PREFIX_MAP[type]; - return `${prefix}-${String(nextId).padStart(5, "0")}`; + return `${prefix}-${letter}${String(number).padStart(5, "0")}`; } async findByCompanyId(companyId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 72696766f..2266b0c65 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -60,7 +60,7 @@ export class CompanyProfile extends BaseEntity { type!: ProfileType; /** - * Official profile reference (e.g. "EX-00001"). Minted only when the profile + * Official profile reference (e.g. "EX-A00001"). Minted only when the profile * is approved (status → Active); pending/unapproved profiles carry NULL. * The unique index tolerates this because Postgres treats NULLs as distinct. * API responses surface it as "" when absent — see ResponseCompanyProfileDto. diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e109d40a1..4da2677bd 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -593,7 +593,7 @@ export class ContractTransitionService { if (!dto.otpPhone || !dto.otp) { throw new BadRequestException('OTP verification is required to sign the contract'); } - await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp); + await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', diff --git a/apps/edr-freight-api/src/modules/gps-tracking/README.md b/apps/edr-freight-api/src/modules/gps-tracking/README.md new file mode 100644 index 000000000..668b581b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/README.md @@ -0,0 +1,150 @@ +# GPS Tracking (GT06) — Operations & Device Configuration + +GT06 trackers speak a **raw TCP binary protocol**, not HTTP/HTTPS. This shapes +everything about how the service is deployed and how devices are pointed at it. + +--- + +## 1. Why GPS needs its own dedicated TCP port + +- **Not HTTP.** GT06 devices send binary frames + (`0x78 0x78 | len | protocol | payload | serial | CRC16 | 0x0D 0x0A`). + An HTTP server receiving these answers `400 Bad Request` and closes. +- **Dedicated port required.** A listening socket is keyed on `(IP, port)`; two + listeners on the same pair collide (`EADDRINUSE`). The REST API already owns + its port, so GPS traffic needs a separate one. +- **No hostname routing.** GT06 frames carry no `Host` header and no TLS SNI, so + L7 proxies (Nginx `http`, AWS ALB, Cloudflare proxy) cannot route them by + domain. Routing must happen at **Layer 4 (TCP)** by port. +- **DNS carries no port.** An A record maps a name to an IP only. The tracker + config must state the port explicitly (e.g. `gps.example.com:5023`). + +### Operational requirements + +| Item | Value | +| --- | --- | +| Protocol | Raw TCP (not HTTP, not TLS) | +| Default port | `5023` (configurable via `GT06_TCP_PORT`) | +| Listener bind | `0.0.0.0` inside the `freight-gps` container | +| Edge terminator | **L4** — AWS NLB or Nginx `stream {}`. **Not** ALB / Cloudflare proxy. | + +--- + +## 2. Port configuration + +`5023` is only this project's default — **not** a GT06 protocol requirement. The +listener binds whatever `GT06_TCP_PORT` says, as long as trackers are configured +with the same number. + +Host and container ports are decoupled in `docker-compose.yaml`: + +```yaml +freight-gps: + ports: + - "${GT06_TCP_PORT:-5023}:5023" # host is configurable; container fixed + environment: + GT06_TCP_PORT: "5023" # pinned inside the container +``` + +- The **container** always listens on `5023`. +- The **host/public** port is configurable (443, 5023, 9000, …) via the root + `.env`'s `GT06_TCP_PORT`. +- This split is required because the image runs as a **non-root** user + (`nestjs`, uid 1001), which cannot bind ports `<1024`. Docker (root) binds the + host port and forwards to `5023` inside. +- Running **outside Docker** (`pnpm dev:gps`, systemd), `GT06_TCP_PORT` is the + actual bind port, so `<1024` needs root or `CAP_NET_BIND_SERVICE`. +- **443 is allowed but risky:** GT06 stays raw TCP, not TLS. Middleboxes that + expect a TLS handshake on 443 may drop the connection. + +--- + +## 3. Deployment topology + +The GT06 listener runs as its own process (`dist/main.gps.js`, module +`GpsIngestModule`) — DB + GPS only, no HTTP server. It shares the `edr_freight` +DB with the API; the DB is the seam (ingester writes `gps_devices` / +`gps_positions`, API reads them). + +``` +freight-api HTTP :3001 GT06_TCP_PORT=0 (listener off, applies migrations) +freight-gps TCP :5023 DB_MIGRATIONS_RUN=false (owns the tracker socket) +``` + +`DB_MIGRATIONS_RUN=false` keeps the second process from racing migrations. + +Horizontal scale: each tracker holds one long-lived TCP connection with +per-socket session state, so N `freight-gps` replicas can run behind an L4 LB — +each device sticks to one replica. `ensureDevice` is safe under concurrency +(unique IMEI). + +--- + +## 4. Device configuration (GT06 side) + +Config is done by **SMS to the tracker's SIM**. Commands below are the canonical +Concox/GT06 set — **verify against your unit's sheet**, syntax varies by firmware. +Default command password is usually `123456`. + +Prep: data-enabled SIM, SMS on, **SIM PIN off**, know your carrier APN. + +``` +STATUS# # 1. sanity check — returns GSM/GPS/batt/GPRS +APN,# # 2. carrier data APN (add ,user,pass if needed) +SERVER,1,gps.example.com,5023,0# # 3. point at server (1=domain). Port MUST match GT06_TCP_PORT +GPRSON,1# # 4. enable data +GPSON,1# # enable GPS +TIMER,10# # 5. upload interval, seconds (some use UPLOAD,10#) +RESET# # 6. reboot so it reconnects (many cache DNS until reboot) +``` + +Raw-IP variant of step 3: `SERVER,0,203.0.113.50,5023,0#` +Custom host port (e.g. 443): `SERVER,1,gps.example.com,443,0#` + +### Verify from the server + +```bash +docker compose logs -f freight-gps | grep -Ei "login|Auto-registering|ingester up" +nc -vz gps.example.com 5023 +curl -H "Authorization: Bearer " https://api.example.com/api/gps/positions/latest +``` + +First login packet **auto-registers** the IMEI (no manual step). `online:true` +only when `lastSeenAt` < 5 min (computed at read time). + +### Link a tracker to a vehicle (optional) + +Auto-register leaves `vehicleId` null. Attach it (needs `tracking.manage`): + +``` +PATCH /api/gps/devices/:id { "vehicleId": "", "name": "Truck 03-ET" } +``` + +### Failure map + +| Symptom | Cause | +| --- | --- | +| No SMS reply | SIM PIN on / no signal / wrong number | +| Replies but never connects | APN wrong, or `SERVER` port ≠ `GT06_TCP_PORT` | +| Connects then drops | server not ACKing, or middlebox on 443 expecting TLS | +| Registered but `online:false` | packets blocked by firewall — open inbound TCP | +| Wrong location / `positioned:false` | no GPS fix yet — open sky, cold start ~1–2 min | + +--- + +## 5. Security + +- GT06 authenticates with **IMEI only**, which is **spoofable**. Anyone who can + reach the port can inject fake positions. +- **Do not** expose the port to `0.0.0.0/0`. Restrict at the firewall / security + group to the SIM provider's **APN / IP range**. +- Trackers must use the same host+port as the server: + `SERVER,1,gps.example.com,,0#`. + +--- + +## 6. Edge (L4) termination + +See [`infrastructure/nginx/gps-stream.conf`](../../../../../infrastructure/nginx/gps-stream.conf) +for an Nginx `stream {}` example, and the AWS NLB notes in the same file. +Reminder: **L4 only** — an HTTP proxy cannot route GT06. diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts index 1d7fd27fc..80fecf1bf 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts @@ -13,8 +13,10 @@ import { Patch, Post, Query, + UseGuards, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; import { AuthUserPayload, @@ -24,6 +26,8 @@ import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; import { NotificationInboxService } from "./notification-inbox.service"; @ApiTags("notifications") +@ApiBearerAuth() +@UseGuards(JwtGuard) @Controller("notifications") export class NotificationInboxController { constructor(private readonly service: NotificationInboxService) {} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts index c14dcbaa5..0c4704170 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts @@ -15,9 +15,10 @@ import { WsAuthService } from "./ws-auth.service"; /** * Server → client push for in-app notifications. Clients only *listen* (no - * `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here; - * the handshake is authenticated in `handleConnection` and each socket joins a - * private `user:` room the service targets. + * `@SubscribeMessage` handlers), and `@UseGuards(JwtGuard)` on the REST + * controller does not cover WebSockets; the handshake is authenticated in + * `handleConnection` and each socket joins a private `user:` room the + * service targets. */ @WebSocketGateway({ namespace: NOTIFICATION_WS_NAMESPACE, diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index d8404e84c..cddc67b2d 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy { this.logger.debug(`Sending SMS to ${recipient} via ${url}`); + // axios defaults to no timeout — a hanging gateway would block the caller + // (and any transaction it sits in) indefinitely. Always bound the wait. + const timeout = Number(this.configService.get("SMS_TIMEOUT_MS") ?? 8000); + try { const response = await axios.post( url, @@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy { callbackUrl: "", }, { + timeout, headers: { accept: "*/*", "Content-Type": "application/json", diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index e26ed35c9..b98b41d8d 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -50,6 +50,9 @@ export class OtpService { await this.otpRepository.createOtp(target, otp); } + // A freshly issued code gets a fresh guess budget. + this.actionAttempts.delete(this.targetKey(target)); + if (target.email) { // send email (queued to RabbitMQ via the shared Email service) await this.emailClient.sendEmail({ @@ -121,24 +124,44 @@ export class OtpService { // --------------------------------------------------------------------------- // Fresh, single-use challenge gating a sensitive action (e.g. applying a - // contract signature). Unlike verifyOtp above — which marks a phone verified - // and leaves the code in place — this enforces a short TTL and consumes the - // code on success so it can never be replayed. + // contract signature, resetting a forgotten password). Unlike verifyOtp above + // — which marks a target verified and leaves the code in place — this enforces + // a TTL and consumes the code on success so it can never be replayed. private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; - async verifyOtpForAction(phone: string, otp: string) { - const otpData = await this.otpRepository.findByPhone(phone); + // Without a cap, a 6-digit code guarding a password reset is brute-forceable + // within its own TTL. `otp_verifications` has no attempt column, so the + // counter lives here and the code is burned once the budget is spent. + // Per-process: it resets on restart and is not shared across replicas — a + // persisted counter needs a migration on OtpVerification. + private readonly MAX_ACTION_ATTEMPTS = 5; + private readonly actionAttempts = new Map(); + + private targetKey(target: OtpTarget): string { + return target.email ? `email:${target.email}` : `phone:${target.phone}`; + } + + async verifyOtpForAction( + target: OtpTarget, + otp: string, + ttlMs: number = this.ACTION_OTP_TTL_MS, + ) { + const otpData = await this.otpRepository.findByTarget(target); + const key = this.targetKey(target); if (!otpData) { throw new BadRequestException( - "No verification code was requested for this phone", + target.email + ? "No verification code was requested for this email" + : "No verification code was requested for this phone", ); } const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); - if (ageMs > this.ACTION_OTP_TTL_MS) { + if (ageMs > ttlMs) { await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); throw new BadRequestException( "Verification code has expired. Request a new one.", @@ -146,11 +169,24 @@ export class OtpService { } if (otpData.otp !== otp) { + const attempts = (this.actionAttempts.get(key) ?? 0) + 1; + + if (attempts >= this.MAX_ACTION_ATTEMPTS) { + await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); + + throw new BadRequestException( + "Too many incorrect attempts. Request a new code.", + ); + } + + this.actionAttempts.set(key, attempts); throw new BadRequestException("Invalid verification code"); } // single-use: consume on success await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); return { success: true }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts index 681b30284..17b75b424 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; /** Records a DO / release order being sent to the customer for import pickup. */ export class ReleaseOrderDto { @@ -90,4 +90,13 @@ export class ReleaseOrderDto { @IsOptional() @IsDateString() gateOutTime?: string; + + @ApiPropertyOptional({ + description: + 'Container bookings only: the operator chose not to weigh this truck. ' + + 'Tare/gross become optional and the container weight match is skipped. Bulk always weighs.', + }) + @IsOptional() + @IsBoolean() + weighingSkipped?: boolean; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index a54f40973..b2c4ca3c4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -36,7 +36,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record { + try { + const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query( + `SELECT DISTINCT ON (booking_id) + booking_id AS "bookingId", reference + FROM freight.booking_handovers + WHERE signed_at IS NULL + AND deleted_at IS NULL + AND mile_type = 'SELF_HAUL' + ORDER BY booking_id, generated_at DESC`, + ); + if (!rows.length) return; + this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`); + for (const row of rows) { + await this.notifySignNeeded(row.bookingId, row.reference); + } + } catch (err) { + this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`); + } + } + /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ async signForBooking(bookingId: string, userId?: string | null): Promise { await this.dataSource 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 9b2f53a99..2b98856d0 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 @@ -860,6 +860,25 @@ export class WarehouseInventoryService { /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ async bulkReceive(dto: BulkReceiveDto): Promise { const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; + /** Sent after the transaction commits so the gateway never blocks the receive. */ + const pendingNotifications: Array<{ + owner: { + phone?: string | null; + ownerName?: string | null; + bookingReference?: string | null; + grnNumber: string; + direction?: string | null; + warehouseId?: string | null; + }; + booking: { + companyId?: string | null; + reference?: string | null; + hasFirstMile?: boolean; + hasLastMile?: boolean; + customerTruckAssignedAt?: string | null; + }; + bookingId: string; + }> = []; await this.dataSource.transaction(async (manager) => { await this.validateLocation(manager, { @@ -1032,21 +1051,33 @@ export class WarehouseInventoryService { manager, ); - await this.notifyOwnerInventoryReceived({ - phone: truckEntrance?.customerPhone ?? booking.customerPhone, - ownerName: truckEntrance?.ownerName ?? booking.customer, - bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, - grnNumber, - direction: dto.direction, - warehouseId: dto.warehouseId, + // Queued, not sent here: an SMS/email round-trip inside the transaction + // holds capacity/location locks open for the whole gateway latency. + pendingNotifications.push({ + owner: { + phone: truckEntrance?.customerPhone ?? booking.customerPhone, + ownerName: truckEntrance?.ownerName ?? booking.customer, + bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + }, + booking, + bookingId, }); result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); - void this.notifyTruckAssignmentNeeded(booking, bookingId); } }); + // Fan out after commit, un-awaited: the receive response must not wait on the + // SMS gateway. Both notifiers swallow their own errors. + for (const pending of pendingNotifications) { + void this.notifyOwnerInventoryReceived(pending.owner); + void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId); + } + return result; } @@ -2326,6 +2357,27 @@ export class WarehouseInventoryService { }); } + /** + * Self-haul = the customer's own truck collects the goods: either a truck + * assigned via the portal (customer_truck_assigned_at), or a walk-in truck + * registered at the gate on a booking with no EDR last-mile leg. EDR + * last-mile bookings are never self-haul. + */ + private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise { + const runner = manager ?? this.dataSource; + const [row]: Array<{ ok: number }> = await runner.query( + `SELECT 1 AS ok + FROM freight.bookings b + LEFT JOIN freight.service_types st ON st.id = b.service_type_id + WHERE b.id = $1 AND b.deleted_at IS NULL + AND (b.customer_truck_assigned_at IS NOT NULL + OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL + AND COALESCE(st.includes_last_mile, false) = false))`, + [bookingId], + ); + return Boolean(row); + } + /** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */ async release(id: string, dto: ReleaseOrderDto): Promise { const item = await this.findById(id); @@ -2335,19 +2387,17 @@ export class WarehouseInventoryService { ); } - const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); + // Leaving = gate-out captured, with either a weighed gross or an explicit + // container weighing skip (bulk always weighs). + const isTruckLeaving = + Boolean(dto.gateOutTime) && (dto.grossWeight !== undefined || dto.weighingSkipped === true); if (isTruckLeaving) { await this.invoices.assertClearanceAllowed(id); if (item.bookingId) { - const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = - await this.dataSource.query( - `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL`, - [item.bookingId], - ); - const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + // Self-haul = customer collects: a truck assigned via the portal, OR a + // walk-in truck registered at the gate on a booking with no EDR last mile. + const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId); // Self-haul: the handover must be signed before the exit paper is issued. // Prefer the structured handover record; fall back to the legacy note. const handoverSigned = @@ -2361,7 +2411,8 @@ export class WarehouseInventoryService { // Authoritative weight match: the truck's net (gross − tare) must equal the // total VGM cargo weight of the containers selected as loaded on it. - if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { + // Skipped when the operator chose not to weigh (containers only). + if (!dto.weighingSkipped && dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { const selected = dto.containerNumber .split(/[,;\n]+/) .map((n) => n.trim()) @@ -2431,13 +2482,10 @@ export class WarehouseInventoryService { [item.bookingId], ); // Self-haul: generate the per-booking handover on first truck arrival - // (idempotent). It must be signed before the truck leaves. - const [selfHaul]: Array<{ ok: number }> = await manager.query( - `SELECT 1 AS ok FROM freight.bookings - WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`, - [item.bookingId], - ); - if (selfHaul) { + // (idempotent) and notify the customer to sign it. Covers BOTH portal- + // assigned trucks and walk-in trucks registered manually at the gate + // (no portal assignment, no EDR last mile). Must be signed before leaving. + if (await this.isSelfHaulBooking(item.bookingId, manager)) { await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager); } } @@ -4422,14 +4470,17 @@ export class WarehouseInventoryService { if (!dto.driverName?.trim()) { throw new BadRequestException('Driver name is required for exit inspection'); } - if (dto.tareWeight === undefined) { + // Container bookings may skip the weighbridge entirely (weighingSkipped); + // bulk always weighs. + const weighingSkipped = dto.weighingSkipped === true; + if (dto.tareWeight === undefined && !weighingSkipped) { throw new BadRequestException('Tare weight is required for truck arrival'); } - const tareWeight = Number(dto.tareWeight); + const tareWeight = dto.tareWeight === undefined ? null : Number(dto.tareWeight); const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight); const computedNetWeight = - grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3)); + grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3)); const submittedNetWeight = dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight); @@ -4441,7 +4492,11 @@ export class WarehouseInventoryService { throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.'); } } - if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) { + if ( + !weighingSkipped && + (dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && + grossWeight == null + ) { throw new BadRequestException('Gross weight is required for truck exit'); } @@ -4457,7 +4512,8 @@ export class WarehouseInventoryService { dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null, dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null, dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null, - `Tare Weight: ${tareWeight} t`, + 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`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, @@ -4481,6 +4537,8 @@ export class WarehouseInventoryService { containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber, gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime, tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight, + // The weigh/skip decision is made at arrival and sticks for the exit. + weighingSkipped: dto.weighingSkipped || /^Weighing:\s*SKIPPED/im.test(inspection) || undefined, }; } diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 1aef33801..99dae5974 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -33,8 +33,9 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ }, { fileKey: "commercial_license", - fileLabel: "Commercial License", - helpText: "Verified against the government trade system during registration.", + fileLabel: "Commercial Registration", + helpText: + "Verified against the government trade system during registration.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -108,7 +109,8 @@ const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [ { fileKey: "business_license", fileLabel: "Business License / Trade License", - helpText: "Verified against the government trade system during registration.", + helpText: + "Verified against the government trade system during registration.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -489,9 +491,14 @@ const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ const CONTRACT_INTAKE_ENTITY = "contract_intake"; const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [ - clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, { - required: false, - }), + clearanceField( + "commercial_framework", + "Commercial Framework / Agreement", + 1, + { + required: false, + }, + ), clearanceField("onboarding_attachment", "Onboarding Attachment", 2, { required: false, }), @@ -542,7 +549,7 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); - constructor(private readonly dataSource: DataSource) {} + constructor(private readonly dataSource: DataSource) { } async run() { await this.dataSource.transaction(async (manager) => { @@ -552,35 +559,35 @@ export class FileUploadSettingsSeeder { const allSettings: Array< OnboardingDocumentSetting & { description: string } > = [ - ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ - ...s, - description: COMPANY_ONBOARDING_DESCRIPTION, - })), - ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: CLEARANCE_DESCRIPTION, - })), - ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", - })), - ...SELF_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", - })), - ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ - ...s, - description: - "Commercial/framework documents attached at contract submission.", - })), - ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: - "Documents uploaded against a driver profile (license, ID, contracts, etc.).", - })), - ]; + ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ + ...s, + description: COMPANY_ONBOARDING_DESCRIPTION, + })), + ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: CLEARANCE_DESCRIPTION, + })), + ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", + })), + ...SELF_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", + })), + ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ + ...s, + description: + "Commercial/framework documents attached at contract submission.", + })), + ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents uploaded against a driver profile (license, ID, contracts, etc.).", + })), + ]; for (const documentSetting of allSettings) { await settingRepository.upsert( @@ -601,7 +608,9 @@ export class FileUploadSettingsSeeder { }); if (!setting) { - throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`); + throw new Error( + `file_upload_setting_seed_failed:${documentSetting.code}`, + ); } await fieldRepository.delete({ settingId: setting.id }); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 5039e5672..e281e6464 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -131,6 +131,7 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [ perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'), perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'), perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'), + perm('d1a00001-0001-4000-8000-000000000006', 'edr_freight_app:customers:reset-password', 'Trigger customer password reset'), ]; // D. Finance — payments + invoices @@ -395,6 +396,7 @@ export const FREIGHT_PERMS = { update: 'edr_freight_app:customers:update', deactivate: 'edr_freight_app:customers:deactivate', verify: 'edr_freight_app:customers:verify', + resetPassword: 'edr_freight_app:customers:reset-password', }, payments: { view: 'edr_freight_app:payments:view', diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx new file mode 100644 index 000000000..bc505a21f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx @@ -0,0 +1,105 @@ +import { Button, Modal, Radio, Stack, Text } from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { KeyRound } from "lucide-react"; +import { useState } from "react"; + +import { useAuth } from "@/auth/useAuth"; +import { useToast } from "@/hooks/use-toast"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { api } from "@/services/api"; +import type { Company, ResetChannel } from "@/types/customer"; + +export interface ResetPasswordActionProps { + company: Pick; +} + +/** + * Staff-triggered password reset. Sends a one-time code to the customer's + * primary contact; the customer picks their own new password. No credential is + * ever shown to or handled by staff. + */ +export default function ResetPasswordAction({ company }: ResetPasswordActionProps) { + const { user } = useAuth(); + const { toast } = useToast(); + const [opened, setOpened] = useState(false); + const [channel, setChannel] = useState("phone"); + + const { mutate, isPending } = useMutation( + api.customers.resetPassword.mutationOptions({ + onSuccess: (result) => { + setOpened(false); + toast({ + title: "Reset code sent", + description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`, + }); + }, + onError: (error) => { + toast({ + title: "Could not send reset code", + description: error.message, + variant: "destructive", + }); + }, + }), + ); + + if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null; + + return ( + <> + + + setOpened(false)} + title="Send a password-reset code" + centered + > + + + We'll send a one-time code to this customer's primary contact. + They choose their own new password — you will not see it. + + + setChannel(v as ResetChannel)} + label="Send the code via" + > + + + + + + + + The code goes to the primary contact's own email or phone, which + may differ from the company contact details shown above. + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 2a87e3b0c..d42673d2b 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -13,5 +13,9 @@ export { ChangeRequestReview, ChangeRequestPendingBadge, } from "./ChangeRequestReview"; +export { + default as ResetPasswordAction, + type ResetPasswordActionProps, +} from "./ResetPasswordAction"; export { formatBytes, formatDate, formatMoney, humanize } from "./format"; export { TableCard, type TableCardProps } from "./TableCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 391a8157d..698b0fc2c 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -250,7 +250,10 @@ const FreightSidebar = ({ diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 78e09a7e0..5eeee7d13 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -7,7 +7,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import { warehouseService } from '@/services/warehouse.service'; -import { extractErrorMessage } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage } from './options'; import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse'; import { openPdfBlob } from './pdf'; @@ -157,7 +157,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa pdfWindow?.close(); toast({ title: 'Gate clearance recorded', - description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`, + description: `Release paper could not be opened: ${await extractDownloadErrorMessage(documentError)}`, }); } onClose(); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index b8bb43086..c4f1f5cec 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -18,7 +18,7 @@ import { LoadInventoryModal } from './LoadInventoryModal'; import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { WarehouseInventoryTable } from './WarehouseInventoryTable'; -import { extractErrorMessage } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage } from './options'; import { openPdfBlob } from './pdf'; interface InventoryWorkbenchProps { @@ -111,7 +111,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo toast({ variant: 'destructive', title: 'Release paper preview failed', - description: extractErrorMessage(error), + description: await extractDownloadErrorMessage(error), }); } finally { setBusyId(null); @@ -131,7 +131,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo toast({ variant: 'destructive', title: 'Handover document failed', - description: extractErrorMessage(error), + description: await extractDownloadErrorMessage(error), }); } finally { setBusyId(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 3b4be03db..a1b91decb 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -76,7 +76,7 @@ import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { StoreInventoryModal } from './StoreInventoryModal'; import { WarehouseInquiryTable } from './WarehouseInquiryTable'; -import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options'; import { openPdfBlob } from './pdf'; import '@/components/overview/overview.css'; @@ -112,7 +112,7 @@ function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; gr toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); } catch (error) { pdfWindow?.close(); - toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) }); } finally { setLoading(false); } @@ -900,7 +900,7 @@ function EligibleTab({ toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); } catch (error) { pdfWindow?.close(); - toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) }); } } setSelected(new Set()); @@ -1697,7 +1697,6 @@ function LoadedExportTab({ Weight Route Status - {dispatchable && Actions} @@ -1739,19 +1738,6 @@ function LoadedExportTab({ {r.status} - {dispatchable && ( - - - - )} ))} @@ -2245,6 +2231,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { handoverDocumentReference: row.handoverDocumentReference, handoverDocumentDate: row.handoverDocumentDate, deliveredAt: row.deliveredAt, + // Carries the saved [Exit Inspection] block so Truck Leaving opens with the + // arrival details (plate, driver, tare, gate-in) read-only instead of blank. + notes: row.notes, booking: row.bookingId ? { id: row.bookingId, @@ -2282,7 +2271,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); } catch (error) { pdfWindow?.close(); - toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: 'Handover document failed', description: await extractDownloadErrorMessage(error) }); } finally { setBusyId(null); } @@ -2296,7 +2285,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow); } catch (error) { pdfWindow?.close(); - toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: 'Exit paper failed', description: await extractDownloadErrorMessage(error) }); } finally { setBusyId(null); } 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 5cca0d3b2..d986e2aba 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; +import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; import { Info, Scale } from 'lucide-react'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -105,6 +105,7 @@ const parseInspectionNote = (notes: string | null | undefined) => { grossWeight: lineNumber(note, 'Gross Weight'), netWeight: lineNumber(note, 'Net Weight'), gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')), + weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''), }; }; @@ -135,6 +136,8 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const [containerNumbers, setContainerNumbers] = useState(['']); const [gateInTime, setGateInTime] = useState(''); const [tareWeight, setTareWeight] = useState(''); + // Containers may skip the weighbridge (decided at arrival, sticks for exit). Bulk always weighs. + const [weighTruck, setWeighTruck] = useState<'yes' | 'no'>('yes'); const [grossWeight, setGrossWeight] = useState(''); const [netWeight, setNetWeight] = useState(''); const [gateOutTime, setGateOutTime] = useState(''); @@ -158,6 +161,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea 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); @@ -165,7 +169,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }, [opened, item, truckPrefill]); const savedInspection = parseInspectionNote(item?.notes); - const isExitStep = savedInspection.tareWeight !== ''; + const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped; const isEntranceLocked = isExitStep; const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber); @@ -220,7 +224,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea .reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0) .toFixed(3), ); - const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0; + // Skip is only offered for container bookings; bulk always weighs. + const skipWeighing = hasContainerWeights && weighTruck === 'no'; + const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing; const systemNetWeight = useContainerNet ? selectedCargoWeight @@ -230,6 +236,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const computedNetWeight = tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null; const weightMismatch = + !skipWeighing && computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001; const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing'; @@ -239,19 +246,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea toast({ variant: 'destructive', title: 'Truck plate and driver name are required' }); return; } - if (!gateInTime || tareWeight === '') { - toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' }); + if (!gateInTime || (!skipWeighing && tareWeight === '')) { + toast({ + variant: 'destructive', + title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required', + }); return; } - if (isExitStep && (!gateOutTime || grossWeight === '')) { - toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' }); + if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) { + toast({ + variant: 'destructive', + title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required', + }); return; } if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) { toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' }); return; } - if (isExitStep && systemNetWeight === '') { + if (isExitStep && !skipWeighing && systemNetWeight === '') { toast({ variant: 'destructive', title: 'System recorded net weight is missing' }); return; } @@ -279,9 +292,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea truckType: truckType.trim() || undefined, containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined, gateInTime: toIsoDateTime(gateInTime), - tareWeight: Number(tareWeight), - grossWeight: grossWeight === '' ? undefined : Number(grossWeight), - netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, + weighingSkipped: skipWeighing || undefined, + tareWeight: skipWeighing ? undefined : Number(tareWeight), + grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight), + netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined, }, }); @@ -421,9 +435,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> + {hasContainerWeights && ( + + Weigh truck? + setWeighTruck((v as 'yes' | 'no') ?? 'yes')} + disabled={isEntranceLocked} + /> + {skipWeighing && ( + Weighbridge skipped — container passes without tare/gross. + )} + + )} - setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} /> - setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} /> + setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} /> + setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} /> { const kind = itemKind(item); const busy = busyId === item.id; - const nextAction = getNextInventoryAction(item); + // Per-booking Load and Dispatch are retired: wagon loading happens in + // the train flow and dispatch at the train level (which already + // advances inventory). Only the remaining lifecycle actions render. + const rawNextAction = getNextInventoryAction(item); + const nextAction = + rawNextAction === 'load' || rawNextAction === 'dispatch' ? null : rawNextAction; const canGenerateHandover = item.inspectionStatus === 'PASSED' && Boolean(item.bookingId) && @@ -232,26 +237,15 @@ export function WarehouseInventoryTable({ )} {item.status === 'READY_FOR_PICKUP' && ( - <> - - - + )} {item.status !== 'DISPATCHED' && ( diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 77a3bc95e..c6f98c8a2 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -86,6 +86,8 @@ export const URL_CONSTANTS = { `/bookings/by-company/${id}/customer-view`, PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`, + RESET_PASSWORD: (companyId: string) => + `/backoffice/customers/${companyId}/reset-password`, }, BILLING: { diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index e0c5c3d08..92fe85ad7 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -62,6 +62,7 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:customers:update", deactivate: "edr_freight_app:customers:deactivate", verify: "edr_freight_app:customers:verify", + resetPassword: "edr_freight_app:customers:reset-password", }, payments: { view: "edr_freight_app:payments:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index c68a4ca25..317f915fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -43,6 +43,7 @@ import { ProfileChips, ProfileStatusBadge, ProfileTypeBadge, + ResetPasswordAction, TableCard, formatBytes, formatDate, @@ -573,6 +574,7 @@ export default function CustomerDetailPage() { } + action={} /> diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index a08bec6c5..f5281c283 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -12,6 +12,8 @@ import type { CustomerPayment, PaginatedCompanies, ProfileStatus, + ResetChannel, + ResetPasswordResult, } from "@/types/customer"; import { CreateDropdownOptionDto, @@ -2269,6 +2271,16 @@ export const api = { ({ id }) => QUERY_KEYS.CUSTOMERS.payments(id), ), + resetPassword: endpoint< + { companyId: string; channel: ResetChannel }, + ResetPasswordResult + >( + "customers", + "resetPassword", + ({ companyId, channel }) => + customersService.resetPassword(companyId, channel), + ), + setProfileStatus: endpoint< { profileId: string; status: ProfileStatus; note?: string }, CompanyProfile diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index cafe0aece..0476d41bb 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -11,6 +11,8 @@ import type { CustomerPayment, PaginatedCompanies, ProfileStatus, + ResetChannel, + ResetPasswordResult, } from "@/types/customer"; const cleanParams = (params: object) => @@ -81,6 +83,22 @@ export const customersService = { .then((r) => r.data); }, + /** + * Send a password-reset code to the company's primary contact. Staff never + * receive a credential — the customer sets their own password from the code. + */ + resetPassword( + companyId: string, + channel: ResetChannel, + ): Promise { + return apiClient + .post( + URL_CONSTANTS.COMPANIES.RESET_PASSWORD(companyId), + { channel }, + ) + .then((r) => r.data); + }, + setProfileStatus( profileId: string, status: ProfileStatus, diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 8d76571d4..7328decf0 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -99,6 +99,15 @@ export interface CompanyChangeRequest { updatedAt: string; } +/** The channel a customer's password-reset code is delivered over. */ +export type ResetChannel = "email" | "phone"; + +export interface ResetPasswordResult { + channel: ResetChannel; + /** Where the code went, e.g. `+251•••4821` — safe to show to staff. */ + maskedTarget: string; +} + /** Mirrors backend `Company` (+ its `companyProfiles`). */ export interface Company { id: string; diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index e16271880..8a806201e 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -86,8 +86,9 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA return 'store'; case 'STORED': // Reserve is retired: a stored export item goes straight to loading prep - // once inspection passes. Import STORED is handled via the import queue. - if (isImport) return null; + // once inspection passes. An import item parked back into storage returns + // to pickup — otherwise Store would strand it with no action. + if (isImport) return inspected ? 'ready-for-pickup' : null; return inspected ? 'ready-for-loading' : null; case 'RESERVED': // Export loading is gated on a passed inspection. @@ -367,6 +368,8 @@ export interface ReleaseOrderPayload { grossWeight?: number; netWeight?: number; gateOutTime?: string; + /** Container bookings only: operator chose not to weigh — tare/gross omitted, match skipped. */ + weighingSkipped?: boolean; } /** Import branch: proof of delivery captured on customer pickup. */ diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 697c2f89c..e4c5685fd 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -32,6 +32,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import MySignaturePage from "./pages/MySignaturePage"; import SettingsPage from "./pages/SettingsPage"; +import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage"; import LoginPage from "./pages/accounts/LoginPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import SignupPage from "./pages/accounts/SignupPage"; @@ -252,6 +253,7 @@ const App = () => { }> } /> } /> + } /> {/* Signup-flow pages; reached while a session already exists */} diff --git a/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx b/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx new file mode 100644 index 000000000..b61bb2eab --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx @@ -0,0 +1,179 @@ +import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core"; +import { + AlertCircle, + ArrowLeft, + Mail, + RotateCw, + ShieldCheck, + Smartphone, +} from "lucide-react"; + +import { maskEmail, maskPhone } from "@/utils/identifier"; + +export type OtpChannel = "phone" | "email"; + +export const OTP_LENGTH = 6; + +export interface OtpChannelSelectProps { + value: OtpChannel; + onChange: (channel: OtpChannel) => void; + disabled?: boolean; + label?: string; +} + +/** Phone/email toggle deciding where the verification code is sent. */ +export function OtpChannelSelect({ + value, + onChange, + disabled, + label = "Send verification code via", +}: OtpChannelSelectProps) { + return ( +
+ + {label} + + onChange(v as OtpChannel)} + data={[ + { + value: "phone", + label: ( + + Phone + + ), + }, + { + value: "email", + label: ( + + Email + + ), + }, + ]} + /> +
+ ); +} + +export interface OtpChannelStepProps { + channel: OtpChannel; + /** Raw email or phone the code went to; masked before display. */ + target: string; + value: string; + onChange: (otp: string) => void; + onVerify: () => void; + onBack: () => void; + onResend: () => void; + /** Seconds until resend is allowed; 0 enables the button. */ + resendIn: number; + sending: boolean; + verifying: boolean; + error: string | null; + title?: string; + description?: string; + submitLabel: string; +} + +/** + * The "enter the code we sent you" stage. Shared by signup and the + * forgot-password flow — both send through the same `/api/otp/*` service. + */ +export default function OtpChannelStep({ + channel, + target, + value, + onChange, + onVerify, + onBack, + onResend, + resendIn, + sending, + verifying, + error, + title, + description, + submitLabel, +}: OtpChannelStepProps) { + const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target); + const busy = sending || verifying; + + return ( + +
+ + + +
+ +
+

+ {title ?? `Verify your ${channel === "email" ? "email" : "phone"}`} +

+

+ We sent a {OTP_LENGTH}-digit code to{" "} + {maskedTarget}.{" "} + {description ?? "Enter it to continue."} +

+
+ + {error ? ( + }> + {error} + + ) : null} + + + + Verification code + + + + + + +
+ + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx b/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx new file mode 100644 index 000000000..89cea7a0e --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx @@ -0,0 +1,41 @@ +import { Check, X } from "lucide-react"; + +import { passwordRequirements } from "@/utils/passwordSchema"; + +export interface PasswordChecklistProps { + /** The current password value; the checklist hides itself when empty. */ + value: string; +} + +/** Live pass/fail list of the password rules, shown under a password field. */ +export default function PasswordChecklist({ value }: PasswordChecklistProps) { + if (!value) return null; + + return ( +
+ {passwordRequirements.map((req) => { + const met = req.test(value); + return ( +
+ + {met ? ( + + ) : ( + + )} + + + {req.label} + +
+ ); + })} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index ab00965fa..849ba50c5 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -286,9 +286,13 @@ export default function OnboardingWizardDialog({ }); }, [roles, nationality, startMutation]); - // Note: no "back to role selection" — once the draft is created the role(s) - // are fixed; the form's first-step Back is a no-op so progress never resets. - const handleBackToRoles = useCallback(() => { }, []); + // Back from the form's first step returns to nationality/role selection. + // Safe to re-enter: startOnboarding is idempotent — it reuses the existing + // draft, refreshes the nationality and creates only roles that don't exist yet. + const handleBackToRoles = useCallback(() => { + setStartError(null); + setPhase("nationality-role"); + }, []); // Save the current step's fields to the draft (PATCH /profile). Returns the // server error message on failure so the form can show it (e.g. duplicate TIN). @@ -359,7 +363,6 @@ export default function OnboardingWizardDialog({ // The active step across the whole journey, driving the header + progress pill. const activeStep: WizardStep = phase === "form" ? formStep : phase; const stepMeta = STEP_META[activeStep]; - console.log({ stepMeta, activeStep, STEP_META }); const activeIdx = WIZARD_STEPS.indexOf(activeStep); // Closing from the congratulations panel also clears the completed flag so a @@ -403,7 +406,6 @@ export default function OnboardingWizardDialog({ onSubmit: handleSubmit, isPending: finishMutation.isPending, onBack: handleBackToRoles, - hideFirstStepBack: true, initialStep: effectiveResumeStep, resyncOpen: opened, onStepChange: handleStepChange, diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 89a3d57e7..5dfda7e58 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -5,6 +5,8 @@ export const URL_CONSTANTS = { REFRESH_TOKEN: "/api/auth/refresh-token", LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", + FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request", + FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify", }, USERS: { diff --git a/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts b/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts new file mode 100644 index 000000000..deeca8125 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +/** Seconds a user must wait before another OTP can be requested. */ +const DEFAULT_COOLDOWN_SECONDS = 60; + +/** + * Countdown that gates the "Resend code" button. Ticks with setTimeout rather + * than wall-clock arithmetic, so it needs no Date.now(). + */ +export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) { + const [secondsLeft, setSecondsLeft] = useState(0); + + useEffect(() => { + if (secondsLeft <= 0) return; + const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [secondsLeft]); + + return { + secondsLeft, + start: () => setSecondsLeft(seconds), + reset: () => setSecondsLeft(0), + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx index b3acc1667..007fc7fe9 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx @@ -36,7 +36,9 @@ export const BookingRow = memo(function BookingRow({ // Contract ready for signature → "View & sign" jumps straight to the // full-page contract viewer where the signature flow lives. const canSign = bookingIsSignable(booking); - const canApproveDelivery = booking.status === "COMPLETED"; + // Visible from handover generation until the customer signs (flag is attached + // by the bookings list endpoint; false again the moment it's signed). + const canApproveDelivery = Boolean(booking.handoverAwaitingSignature); const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—"; const dest = booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index cc9f81a29..abfb61551 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -51,7 +51,6 @@ export default function CompanyProfileForm({ onBack, initialStep, resyncOpen, - hideFirstStepBack, onStepChange, onSaveStep, rehydrate, @@ -73,8 +72,6 @@ export default function CompanyProfileForm({ initialStep?: CompanyStep; /** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */ resyncOpen?: boolean; - /** Hide the Back button on the first step (onboarding can't go back to role pick). */ - hideFirstStepBack?: boolean; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: CompanyStep) => void; /** Persist the current step's data before advancing; returns an error to show. */ @@ -514,10 +511,6 @@ export default function CompanyProfileForm({ else setStep(stepOrder[currentIdx - 1]); }; - // Back is hidden on the first step during onboarding (can't return to role - // selection); otherwise always available. - const showBack = !(hideFirstStepBack && step === "company"); - return ( <>
e.preventDefault()}> @@ -851,17 +844,13 @@ export default function CompanyProfileForm({ )} - {showBack ? ( - - ) : ( - - )} + + +

+ Remembered it?{" "} + + Back to sign in + +

+ + + ) : null} + + {stage === "otp" ? ( + { + setStage("identify"); + setError(null); + }} + onResend={handleResend} + resendIn={resendCooldown.secondsLeft} + sending={sending} + verifying={verifying} + error={error} + title="Enter your reset code" + description="Enter it to choose a new password." + submitLabel="Verify code" + /> + ) : null} + + {stage === "password" ? ( +
+
+

+ Choose a new password +

+

+ Pick something strong you haven't used before. +

+
+ + +
+ setPassword(event.target.value)} + /> + +
+ + setConfirmPassword(event.target.value)} + /> + + {error ? ( + }> + {error} + + ) : null} + + + + +
+ + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 885888a05..7e8e512dd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -5,21 +5,11 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import AuthShell from "@/components/auth/AuthShell"; +import { normaliseIdentifier } from "@/utils/identifier"; import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; -/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ -function normaliseIdentifier(raw: string): string { - const v = raw.trim(); - const digits = v.replace(/\D/g, ""); - if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { - const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); - return `+251${local}`; - } - return v.toLowerCase(); -} - export default function LoginPage() { const navigate = useNavigate(); const location = useLocation(); @@ -80,7 +70,7 @@ export default function LoginPage() {
Password Forgot password? diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index 87702bdd8..04625f2cd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -1,4 +1,13 @@ -import { Alert, Box, Button, Group, PasswordInput, Stack, Text, ThemeIcon } from "@mantine/core"; +import { + Alert, + Box, + Button, + Group, + PasswordInput, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowRight, Check, LockKeyhole, X } from "lucide-react"; import { useMemo, useState } from "react"; @@ -8,27 +17,19 @@ import { z } from "zod"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; - -const passwordRequirements = [ - { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, - { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, - { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, - { label: "One number", test: (v: string) => /\d/.test(v) }, - { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, -] as const; +import { + confirmPasswordField, + passwordField, + passwordRequirements, + samePassword, +} from "@/utils/passwordSchema"; const passwordSchema = z .object({ - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(/[A-Z]/, "Password must include an uppercase letter") - .regex(/[a-z]/, "Password must include a lowercase letter") - .regex(/\d/, "Password must include a number") - .regex(/[^A-Za-z0-9]/, "Password must include a special character"), - confirmPassword: z.string().min(1, "Please confirm your password"), + password: passwordField, + confirmPassword: confirmPasswordField, }) - .refine((data) => data.password === data.confirmPassword, { + .refine(samePassword, { message: "Passwords do not match", path: ["confirmPassword"], }); @@ -54,7 +55,8 @@ export default function SetPasswordPage() { const password = watch("password"); const requirements = useMemo( - () => passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })), + () => + passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })), [password], ); @@ -93,11 +95,21 @@ export default function SetPasswordPage() { "Secure freight operations", "Advanced authentication system", ], - stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" }, + stats: { + label: "Security Protection", + value: "256-bit", + footer: "Encrypted", + progress: "w-[98%]", + }, }} > - + diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 5746eefd5..72dafe9f7 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,50 +1,38 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { Alert, Button, PasswordInput, - PinInput, - SegmentedControl, SimpleGrid, Stack, - Text, TextInput, } from "@mantine/core"; -import { - AlertCircle, - ArrowLeft, - ArrowRight, - Check, - Mail, - RotateCw, - ShieldCheck, - Smartphone, - X, -} from "lucide-react"; +import { AlertCircle, ArrowRight } from "lucide-react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; +import { useResendCooldown } from "@/hooks/useResendCooldown"; import type { SignupPayload } from "@/types/auth"; import AuthShell from "@/components/auth/AuthShell"; +import OtpChannelStep, { + OTP_LENGTH, + OtpChannelSelect, + type OtpChannel, +} from "@/components/auth/OtpChannelStep"; +import PasswordChecklist from "@/components/auth/PasswordChecklist"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { api } from "@/services/api"; +import { + confirmPasswordField, + passwordField, + samePassword, +} from "@/utils/passwordSchema"; import { extractApiError } from "@/utils/result"; -const passwordRequirements = [ - { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, - { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, - { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, - { label: "One number", test: (v: string) => /\d/.test(v) }, - { - label: "One special character", - test: (v: string) => /[^A-Za-z0-9]/.test(v), - }, -] as const; - const userSchema = z .object({ email: z.string().email("Invalid email address"), @@ -61,43 +49,23 @@ const userSchema = z en: z.string().min(2, "Name is required"), am: z.string().nullable(), }), - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(/[A-Z]/, "Password must include an uppercase letter") - .regex(/[a-z]/, "Password must include a lowercase letter") - .regex(/\d/, "Password must include a number") - .regex(/[^A-Za-z0-9]/, "Password must include a special character"), - confirmPassword: z.string().min(1, "Please confirm your password"), + password: passwordField, + confirmPassword: confirmPasswordField, }) - .refine((data) => data.password === data.confirmPassword, { + .refine(samePassword, { message: "Passwords do not match", path: ["confirmPassword"], }); type FormData = z.infer; -/** Mask all but the first 7 chars of an E.164 phone for display. */ -const maskPhone = (p: string) => - p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; - -/** Mask the local part of an email for display (j***e@example.com). */ -const maskEmail = (email: string) => { - const [local, domain] = email.split("@"); - if (!local || !domain) return email; - if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`; - return `${local[0]}***${local[local.length - 1]}@${domain}`; -}; - -type OtpChannel = "phone" | "email"; - export default function SignupPage() { const navigate = useNavigate(); const { signup } = useAuth(); const [error, setError] = useState(null); - // Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the - // phone number before the account is actually created. The account is only + // Two-stage signup: fill the form, then a mandatory OTP challenge on the + // chosen channel before the account is actually created. The account is only // created after the code is verified — the OTP is a hard requirement. const [stage, setStage] = useState<"form" | "otp">("form"); const [pendingData, setPendingData] = useState(null); @@ -109,14 +77,7 @@ export default function SignupPage() { const [verifying, setVerifying] = useState(false); const [otpCode, setOtpCode] = useState(""); const [otpError, setOtpError] = useState(null); - const [resendIn, setResendIn] = useState(0); - - // Resend cooldown countdown (pure setTimeout ticks — no Date.now needed). - useEffect(() => { - if (resendIn <= 0) return; - const t = setTimeout(() => setResendIn((s) => s - 1), 1000); - return () => clearTimeout(t); - }, [resendIn]); + const resendCooldown = useResendCooldown(); const { register, @@ -170,7 +131,7 @@ export default function SignupPage() { setOtpChannel(channel); setOtpCode(""); setOtpError(null); - setResendIn(60); + resendCooldown.start(); setStage("otp"); } catch (err) { setError(extractApiError(err).message); @@ -190,7 +151,7 @@ export default function SignupPage() { : { phone: pendingData.phone }, ); setOtpCode(""); - setResendIn(60); + resendCooldown.start(); } catch (err) { setOtpError(extractApiError(err).message); } finally { @@ -202,8 +163,8 @@ export default function SignupPage() { const confirmOtp = async () => { if (!pendingData) return; setOtpError(null); - if (otpCode.trim().length !== 6) { - setOtpError("Enter the 6-digit code we sent you."); + if (otpCode.trim().length !== OTP_LENGTH) { + setOtpError(`Enter the ${OTP_LENGTH}-digit code we sent you.`); return; } setVerifying(true); @@ -298,35 +259,11 @@ export default function SignupPage() { disabled={sending} /> -
- - Send verification code via - - setChannel(v as OtpChannel)} - data={[ - { - value: "phone", - label: ( - - Phone - - ), - }, - { - value: "email", - label: ( - - Email - - ), - }, - ]} - /> -
+
- {passwordValue.length > 0 ? ( -
- {passwordRequirements.map((req) => { - const met = req.test(passwordValue); - return ( -
- - {met ? ( - - ) : ( - - )} - - - {req.label} - -
- ); - })} -
- ) : null} +
) : ( - -
- - - -
-
-

- Verify your {otpChannel === "email" ? "email" : "phone"} -

-

- We sent a 6 - digit code to{" "} - - {otpChannel === "email" - ? maskEmail(pendingData?.email ?? "") - : maskPhone(pendingData?.phone ?? "")} - - .Enter it to finish creating your account. -

-
- - {otpError ? ( - } - > - {otpError} - - ) : null} - - - - Verification code - - - - - - -
- - -
-
+ { + setStage("form"); + setOtpError(null); + }} + onResend={resendOtp} + resendIn={resendCooldown.secondsLeft} + sending={sending} + verifying={verifying} + error={otpError} + description="Enter it to finish creating your account." + submitLabel="Verify & create account" + /> )}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 7384bbc4d..f135078c1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -107,10 +107,12 @@ export function ReadonlyBookingView({ (isGeneralContract ? status === "FULLY_EXECUTED" : status === "SELECTED_FOR_BATCH"); - const canApproveDelivery = - status === "COMPLETED" || - Boolean(booking.handoverAwaitingSignature) || - (status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt)); + // Approve delivery tracks the handover lifecycle exactly: the button appears + // the moment a handover is generated (truck arrival, or an operator's + // signature request) and disappears the moment the customer signs it. The + // backend flag counts only unsigned SELF_HAUL handovers, so no status + // heuristics are needed here. + const canApproveDelivery = Boolean(booking.handoverAwaitingSignature); const usesCustomerTruck = booking.tradeDirection === "IMPORT" ? !booking.lastMileDeliveryAddress diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts index d8b660413..0a2f1a30f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts @@ -10,7 +10,7 @@ const LABEL_BY_CODE = new Map([ ...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const), // Company onboarding document codes (see file-upload-settings seeder). ["tin_certificate", "TIN Certificate"], - ["commercial_license", "Commercial License"], + ["commercial_license", "Commercial Registration"], ["business_license", "Business License / Trade License"], ["investment_license", "Investment License"], ["national_id", "National ID"], diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index d2bd8b88f..b6ba870d6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -9,6 +9,7 @@ import { Group, Loader, Paper, + Popover, Select, Stack, Table, @@ -17,6 +18,7 @@ import { Title, } from "@mantine/core"; import { + AlertTriangle, CheckCircle2, ChevronDown, ChevronLeft, @@ -31,6 +33,8 @@ import { X, } from "lucide-react"; +import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; +import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction"; import type { ContractListFilter } from "@/services/contracts.service"; @@ -58,14 +62,26 @@ function primaryRoute(contract: Freight.IContract) { export default function ContractsList() { const navigate = useNavigate(); + const { company } = useAuth(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); + const [disclaimerOpen, setDisclaimerOpen] = useState(false); const [freightFilter, setFreightFilter] = useState(null); const [kindFilter, setKindFilter] = useState(null); const [createdFrom, setCreatedFrom] = useState(""); const [createdTo, setCreatedTo] = useState(""); const [expanded, setExpanded] = useState>(new Set()); + // A contract can only be created under an approved profile — NewContractPage + // blocks every operation whose profile isn't "active". With none approved the + // page is reachable but unusable, so warn before sending the user there. + const profiles = company?.company?.companyProfiles ?? []; + const noActiveProfile = + profiles.length > 0 && !profiles.some((p) => p.status === "active"); + + const openNewContract = () => + navigate("/contracts/new", { state: { fresh: true } }); + const toggleExpanded = (id: string) => setExpanded((prev) => { const nextSet = new Set(prev); @@ -137,9 +153,11 @@ export default function ContractsList() { const stats = useMemo(() => { const items = data?.items ?? []; const active = items.filter((c) => - ["CONTRACT_ACTIVE", "FULLY_EXECUTED", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes( - c.status, - ), + [ + "CONTRACT_ACTIVE", + "FULLY_EXECUTED", + "ACTIVE_SHIPMENT_IN_PROGRESS", + ].includes(c.status), ).length; const pending = items.filter((c) => [ @@ -158,7 +176,7 @@ export default function ContractsList() { return { active, pending, total }; }, [data]); - const total = data?.meta?.total ?? (data?.items?.length ?? 0); + const total = data?.meta?.total ?? data?.items?.length ?? 0; const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); const pageIndex = pagination.pageIndex; const start = total === 0 ? 0 : pageIndex * pagination.pageSize + 1; @@ -175,19 +193,63 @@ export default function ContractsList() { {/* Header */} - + <Title + order={1} + fw={800} + fz={26} + style={{ letterSpacing: "-0.01em" }} + > Contracts - + + + + + + + + + None of your profiles are active yet + + + + + Contracts can only be created under a profile EDR has + approved. You can continue, but every operation stays locked + until at least one profile is approved. + + + + {/* Summary strip */} @@ -378,7 +440,11 @@ export default function ContractsList() { - + No contracts yet. Create one from New Contract. @@ -400,154 +466,159 @@ export default function ContractsList() { const isOpen = expanded.has(c.id); return ( - navigate(`/contracts/${c.id}`)} - > - - { - e.stopPropagation(); - toggleExpanded(c.id); - }} - style={{ - display: "flex", - alignItems: "center", - justifyContent: "center", - width: 28, - height: 28, - borderRadius: 8, - border: `1px solid ${BORDER}`, - background: isOpen ? GREEN : "#FFFFFF", - color: isOpen ? "#FFFFFF" : MUTED, - cursor: "pointer", - transition: "all 140ms ease", - }} - > - navigate(`/contracts/${c.id}`)} + > + + { + e.stopPropagation(); + toggleExpanded(c.id); }} - /> - - - - - {c.reference} - - - {isContainer ? "Containerised" : "Bulk"} - - - - - {isGeneral ? "General" : "One-Time"} - - - - - {isContainer ? ( - - ) : ( - - )} - - {isContainer ? "Container" : "Bulk"} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 28, + height: 28, + borderRadius: 8, + border: `1px solid ${BORDER}`, + background: isOpen ? GREEN : "#FFFFFF", + color: isOpen ? "#FFFFFF" : MUTED, + cursor: "pointer", + transition: "all 140ms ease", + }} + > + + + + + + {c.reference} -
- - - - {origin}{" "} - - → - {" "} - {destination} - {count > 1 && ( - - {" "} - +{count - 1} + + {isContainer ? "Containerised" : "Bulk"} + + + + + {isGeneral ? "General" : "One-Time"} + + + + + {isContainer ? ( + + ) : ( + + )} + + {isContainer ? "Container" : "Bulk"} - )} - - - - - {tradeLabel} - - - - - {c.paymentCurrency ?? "—"} - - - - - {c.createdAt - ? new Date(c.createdAt).toLocaleDateString() - : "—"} - - - - - {c.contractValidUntil - ? new Date( + + + + + {origin}{" "} + + → + {" "} + {destination} + {count > 1 && ( + + {" "} + +{count - 1} + + )} + + + + + {tradeLabel} + + + + + {c.paymentCurrency ?? "—"} + + + + + {c.createdAt + ? new Date(c.createdAt).toLocaleDateString() + : "—"} + + + + + {c.contractValidUntil + ? new Date( c.contractValidUntil, ).toLocaleDateString() - : "—"} - - - - - - - - e.stopPropagation()} - /> - - - - - {isOpen && ( - - - + : "—"} + + + + + + + + e.stopPropagation()} + /> + + - )} + {isOpen && ( + + + + + + )} ); })} @@ -564,7 +635,10 @@ export default function ContractsList() { gap="md" px={20} py={14} - style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }} + style={{ + borderTop: `1px solid ${BORDER}`, + background: "#FCFDFE", + }} > @@ -574,8 +648,7 @@ export default function ContractsList() { data={["10", "25", "50"]} value={String(pagination.pageSize)} onChange={(v) => - v && - setPagination({ pageIndex: 0, pageSize: Number(v) }) + v && setPagination({ pageIndex: 0, pageSize: Number(v) }) } radius="md" size="xs" diff --git a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx index 5b779847a..2115ec083 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx @@ -26,7 +26,7 @@ export default function NationalitySelect({ } selected={value === "ethiopian"} onClick={() => onChange("ethiopian")} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index bad712689..04aaf4153 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -77,6 +77,9 @@ import type { SetPasswordPayload, SignupPayload, SignupResponse, + ForgotPasswordRequestPayload, + ForgotPasswordVerifyPayload, + ResetTicket, } from "@/types/auth"; // --------------------------------------------------------------------------- @@ -110,6 +113,21 @@ export const api = { "setPassword", authService.setPassword, ), + requestPasswordReset: endpoint( + "auth", + "requestPasswordReset", + authService.requestPasswordReset, + ), + verifyPasswordResetOtp: endpoint( + "auth", + "verifyPasswordResetOtp", + authService.verifyPasswordResetOtp, + ), + resetPassword: endpoint( + "auth", + "resetPassword", + authService.resetPassword, + ), checkAvailability: endpoint( "auth", "checkAvailability", diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 3f9ef4e53..3b113878d 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -3,11 +3,14 @@ import type { AuthUser, CheckAvailabilityPayload, CheckAvailabilityResponse, + ForgotPasswordRequestPayload, + ForgotPasswordVerifyPayload, GenerateVerificationCodePayload, LoginPayload, LoginResponse, OtpPayload, OtpResponse, + ResetTicket, SetPasswordPayload, SignupPayload, SignupResponse, @@ -53,6 +56,31 @@ export const authService = { return res.data.data; }, + // The three calls below drive the unauthenticated forgot-password flow. + // Responses under /api/auth are *flattened* by the API's response + // interceptor ({ success, ...payload }), so there is no `.data.data` here. + + requestPasswordReset: async (body: ForgotPasswordRequestPayload) => { + await client.post(URL_CONSTANTS.AUTH.FORGOT_PASSWORD_REQUEST, body); + }, + + verifyPasswordResetOtp: async (body: ForgotPasswordVerifyPayload) => { + const res = await client.post( + URL_CONSTANTS.AUTH.FORGOT_PASSWORD_VERIFY, + body, + ); + return { userId: res.data.userId, verificationCode: res.data.verificationCode }; + }, + + /** + * Spend the reset ticket. Distinct from `setPassword` above, which the + * authenticated post-signup flow drives through `useAuth` — this one carries + * its own userId/verificationCode and never touches the session. + */ + resetPassword: async (body: SetPasswordPayload) => { + await client.patch(URL_CONSTANTS.USERS.SET_PASSWORD, body); + }, + checkAvailability: async (params: CheckAvailabilityPayload) => { const res = await client.get( URL_CONSTANTS.USERS.CHECK_AVAILABILITY, diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 04357a9ca..2e9a0b611 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -63,6 +63,25 @@ export interface SetPasswordPayload { verificationCode: string; } +/** The channel a password-reset code is delivered over. */ +export type ResetChannel = "email" | "phone"; + +export interface ForgotPasswordRequestPayload { + /** Email, username, or E.164 phone — whatever the user typed, normalised. */ + identifier: string; + channel: ResetChannel; +} + +export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload { + otp: string; +} + +/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */ +export interface ResetTicket { + userId: string; + verificationCode: string; +} + export interface GenerateVerificationCodePayload { email: string; phoneNumber: string; diff --git a/apps/edr-freight-web/portal/src/utils/identifier.ts b/apps/edr-freight-web/portal/src/utils/identifier.ts new file mode 100644 index 000000000..72677f73c --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/identifier.ts @@ -0,0 +1,22 @@ +/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ +export function normaliseIdentifier(raw: string): string { + const v = raw.trim(); + const digits = v.replace(/\D/g, ""); + if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { + const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); + return `+251${local}`; + } + return v.toLowerCase(); +} + +/** Mask all but the first 7 chars of an E.164 phone for display. */ +export const maskPhone = (p: string) => + p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; + +/** Mask the local part of an email for display (j***e@example.com). */ +export const maskEmail = (email: string) => { + const [local, domain] = email.split("@"); + if (!local || !domain) return email; + if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`; + return `${local[0]}***${local[local.length - 1]}@${domain}`; +}; diff --git a/apps/edr-freight-web/portal/src/utils/passwordSchema.ts b/apps/edr-freight-web/portal/src/utils/passwordSchema.ts new file mode 100644 index 000000000..207d9dc2b --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/passwordSchema.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +/** Live checklist shown under the password field. Mirrors {@link passwordField}. */ +export const passwordRequirements = [ + { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, + { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, + { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, + { label: "One number", test: (v: string) => /\d/.test(v) }, + { + label: "One special character", + test: (v: string) => /[^A-Za-z0-9]/.test(v), + }, +] as const; + +/** + * Must stay in step with IAM's `@IsStrongPassword()` on `InitialResetPasswordDto` + * — a password this accepts but the API rejects surfaces as an opaque 400. + */ +export const passwordField = z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must include an uppercase letter") + .regex(/[a-z]/, "Password must include a lowercase letter") + .regex(/\d/, "Password must include a number") + .regex(/[^A-Za-z0-9]/, "Password must include a special character"); + +export const confirmPasswordField = z + .string() + .min(1, "Please confirm your password"); + +export const samePassword = (data: { + password: string; + confirmPassword: string; +}) => data.password === data.confirmPassword; + +/** Every requirement in {@link passwordRequirements} is satisfied. */ +export const meetsAllRequirements = (value: string) => + passwordRequirements.every((r) => r.test(value)); diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index d8be18e74..1f7fcd288 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AgentsService } from './agents.service'; import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; @@ -34,6 +34,12 @@ export class AgentsController { updateAgent(@Param('id') id: string, @Body() dto: Partial & { active?: boolean }) { return this.service.updateAgent(id, dto); } + + @Delete(':id') + @ApiOperation({ summary: 'Delete agent profile' }) + deleteAgent(@Param('id') id: string) { + return this.service.deleteAgent(id); + } @Post('bookings') @ApiOperation({ summary: 'Create agent booking with cash payment' }) createBooking(@Body() dto: CreateAgentBookingDto) { diff --git a/apps/edr-passenger-api/src/modules/agents/agents.service.ts b/apps/edr-passenger-api/src/modules/agents/agents.service.ts index cd17da441..4ba7afcf1 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -210,4 +210,11 @@ export class AgentsService { }, }); } + + async deleteAgent(id: string) { + const agent = await this.prisma.agent.findUnique({ where: { id } }); + if (!agent) throw new NotFoundException('Agent not found'); + await this.prisma.agent.delete({ where: { id } }); + return { deleted: true }; + } } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 8d01e7113..551cc8881 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { IsInt, IsPositive, IsString } from 'class-validator'; import { ExcessBaggageService } from './excess-baggage.service'; @@ -26,7 +26,8 @@ export class ExcessBaggageAgentController { @Post() @ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' }) - logCharge(@Body() dto: LogExcessBaggageDto) { + logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) { + dto.agentId = req.user?.id ?? req.user?.sub ?? dto.agentId; return this.service.logCharge(dto); } @@ -50,24 +51,6 @@ export class ExcessBaggageAgentController { }); } - @Get(':id') - @ApiOperation({ summary: 'Get a single charge by ID (agent polling)' }) - getCharge(@Param('id') id: string) { - return this.service.getCharge(id); - } - - @Post(':id/resend') - @ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' }) - resendLink(@Param('id') id: string) { - return this.service.resendLink(id); - } - - @Patch(':id/waive') - @ApiOperation({ summary: 'Waive a charge (supervisor only)' }) - waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) { - return this.service.waiveCharge(id, dto); - } - @Get('allowances') @ApiOperation({ summary: 'List all baggage allowance rules' }) getAllowances() { @@ -92,6 +75,24 @@ export class ExcessBaggageAgentController { return this.service.deleteAllowance(id); } + @Get(':id') + @ApiOperation({ summary: 'Get a single charge by ID (agent polling)' }) + getCharge(@Param('id') id: string) { + return this.service.getCharge(id); + } + + @Post(':id/resend') + @ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' }) + resendLink(@Param('id') id: string) { + return this.service.resendLink(id); + } + + @Patch(':id/waive') + @ApiOperation({ summary: 'Waive a charge (supervisor only)' }) + waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) { + return this.service.waiveCharge(id, dto); + } + @Delete(':id') @ApiOperation({ summary: 'Delete excess baggage charge (admin only)' }) deleteCharge(@Param('id') id: string) { diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts index 58bf2bf84..4379ae28d 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -3,7 +3,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class LogExcessBaggageDto { @ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string; - @ApiProperty({ example: 'agent-uuid' }) @IsString() agentId: string; + @ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' }) + @IsOptional() @IsString() agentId?: string; @ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' }) @IsInt() @IsPositive() excessWeightKg: number; @ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' }) diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index 24e09b87c..30ab0fd5e 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -75,7 +75,7 @@ export class ExcessBaggageService { const charge = await this.prisma.excessBaggageCharge.create({ data: { bookingId: dto.bookingId, - agentId: dto.agentId, + agentId: dto.agentId ?? '', excessWeightKg: dto.excessWeightKg, feePerKgMinor, totalMinor, diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 4de0f0d21..a28be295e 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -154,43 +154,54 @@ export class SearchService { ) { const [y, m, d] = dateStr.split('-').map(Number); const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0); - - const now = new Date(); - const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000)); - const daysAfter = 14 - daysBefore; - - const windowStart = new Date(requestedDate); - windowStart.setDate(windowStart.getDate() - daysBefore); - if (windowStart < now) windowStart.setTime(now.getTime()); - - const windowEnd = new Date(requestedDate); - windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); - - const totalPassengers = adultCount + (childCount ?? 0); - const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const now = new Date(); + const totalPassengers = adultCount + (childCount ?? 0); + const NEEDED = 3; - const schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: 'SCHEDULED', - isPackageOnly: false, - OR: [ - { departureAt: { gte: windowStart, lt: requestedDate } }, - { departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } }, - ], - stopTimes: { some: { stationId: originStationId } }, - coachAssignments: { some: {} }, - }, - include: SCHEDULE_INCLUDE, - orderBy: { departureAt: 'asc' }, - }); + const baseWhere = { + status: 'SCHEDULED', + isPackageOnly: false, + stopTimes: { some: { stationId: originStationId } }, + coachAssignments: { some: {} }, + } as const; - const results = await Promise.all( - schedules.map(schedule => - this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) - ) - ); - return results.filter((r): r is NonNullable => !!r && r.hasAvailability); + // Fetch candidates before and after in parallel; take more than needed to + // account for routes that don't serve the destination or have no availability. + const FETCH_LIMIT = NEEDED * 5; + + const [beforeCandidates, afterCandidates] = await Promise.all([ + this.prisma.trainSchedule.findMany({ + where: { ...baseWhere, departureAt: { gte: now < requestedDate ? now : new Date(0), lt: requestedDate } }, + include: SCHEDULE_INCLUDE, + orderBy: { departureAt: 'desc' }, + take: FETCH_LIMIT, + }), + this.prisma.trainSchedule.findMany({ + where: { ...baseWhere, departureAt: { gte: requestedNextDay > now ? requestedNextDay : now } }, + include: SCHEDULE_INCLUDE, + orderBy: { departureAt: 'asc' }, + take: FETCH_LIMIT, + }), + ]); + + const pickN = async (candidates: typeof beforeCandidates, limit: number) => { + const out: NonNullable>>[] = []; + for (const schedule of candidates) { + if (out.length >= limit) break; + const r = await this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality); + if (r?.hasAvailability) out.push(r); + } + return out; + }; + + const [before, after] = await Promise.all([ + pickN(beforeCandidates, NEEDED), + pickN(afterCandidates, NEEDED), + ]); + + // before was fetched desc (closest first); reverse so result is chronological + return [...before.reverse(), ...after]; } private async searchSchedules( @@ -415,7 +426,7 @@ export class SearchService { } } - const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass); + const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; @@ -638,6 +649,10 @@ export class SearchService { ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); + const nationalityUpper = (nationality ?? '').toUpperCase(); + const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') + ? 'LOCAL' : 'INTERNATIONAL'; + // Collect seat class IDs from the schedule include for the ID set, // but fetch fresh records from DB so updated baseFareMinor is always current const seatClassIdSet = new Set(); @@ -647,7 +662,14 @@ export class SearchService { } } const freshSeatClasses = await this.prisma.seatClass.findMany({ - where: { id: { in: Array.from(seatClassIdSet) }, isActive: true }, + where: { + id: { in: Array.from(seatClassIdSet) }, + isActive: true, + OR: [ + { nationalityType: null }, + { nationalityType: nationalityType }, + ], + }, }); const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc])); const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor); @@ -725,6 +747,7 @@ export class SearchService { private buildCoachTypeDetails( schedule: ScheduleWithIncludes, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, + nationality?: string, ): Array<{ coachTypeId: string; coachTypeName: string; @@ -749,8 +772,16 @@ export class SearchService { }); } + const nationalityUpper = (nationality ?? '').toUpperCase(); + const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') + ? 'LOCAL' : 'INTERNATIONAL'; + const entry = coachTypeMap.get(coachType.id)!; - coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name)); + coachType.seatClasses?.forEach((sc: any) => { + // Exclude classes that belong to the wrong nationality type + if (sc.nationalityType && sc.nationalityType !== resolvedNationalityType) return; + if (faresByClass.some(f => f.seatClassName === sc.name)) entry.classNames.add(sc.name); + }); } const result = []; @@ -769,6 +800,7 @@ export class SearchService { .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); + if (classes.length === 0) continue; result.push({ coachTypeId: coachType.id, coachTypeName: coachType.name, diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx index 39e02dc3c..9efd7ab19 100644 --- a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -2,11 +2,12 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Edit, Eye } from 'lucide-react'; +import { Plus, Edit, Eye, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Badge from '@/components/ui/Badge'; import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { agentsApi, apiClient } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; @@ -48,6 +49,19 @@ export default function AgentsPage() { const [editingAgent, setEditingAgent] = useState(null); const [editError, setEditError] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; agent: any | null }>({ isOpen: false, agent: null }); + const [deleteError, setDeleteError] = useState(null); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => agentsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agents'] }); + setDeleteConfirm({ isOpen: false, agent: null }); + setDeleteError(null); + }, + onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete agent'), + }); + const editMutation = useMutation({ mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data), onSuccess: () => { @@ -122,6 +136,12 @@ export default function AgentsPage() { variant: 'secondary' as const, icon: Eye, }, + { + label: 'Delete', + onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); }, + variant: 'danger' as const, + icon: Trash2, + }, ]; return ( @@ -169,6 +189,18 @@ export default function AgentsPage() { emptyMessage="No agents found" /> + { setDeleteConfirm({ isOpen: false, agent: null }); setDeleteError(null); }} + onConfirm={async () => { if (deleteConfirm.agent) await deleteMutation.mutateAsync(deleteConfirm.agent.id); }} + title="Delete Agent" + message={`Are you sure you want to delete agent ${deleteConfirm.agent?.agentCode}? This action cannot be undone.`} + confirmText="Delete" + isDanger + isLoading={deleteMutation.isPending} + error={deleteError ?? undefined} + /> + {/* Agent Details Modal */} setSelected(null)} title="Agent Details" size="xl"> {selected && (() => { diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index 7b5db209a..3c13e952b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -2,13 +2,14 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { RefreshCw, Send, Trash2 } from 'lucide-react'; +import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import { excessBaggageApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { useAuthStore } from '@/lib/auth-store'; const STATUS_VARIANT: Record = { PENDING: 'PENDING', @@ -22,9 +23,16 @@ export default function ExcessBaggagePage() { const queryClient = useQueryClient(); const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' }); const [showExtraFilters, setShowExtraFilters] = useState(false); + const user = useAuthStore((s) => s.user); const [waiveModal, setWaiveModal] = useState(null); const [waiveReason, setWaiveReason] = useState(''); const [waiveError, setWaiveError] = useState(null); + const [logModal, setLogModal] = useState(false); + const [logForm, setLogForm] = useState({ bookingId: '', excessWeightKg: '', collectCash: false }); + const [logError, setLogError] = useState(null); + const [resendModal, setResendModal] = useState(null); + const [resendSuccess, setResendSuccess] = useState(false); + const [resendError, setResendError] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['excess-baggage', filters], @@ -37,6 +45,17 @@ export default function ExcessBaggagePage() { }), }); + const logMutation = useMutation({ + mutationFn: (data: any) => excessBaggageApi.logCharge(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); + setLogModal(false); + setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); + setLogError(null); + }, + onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'), + }); + const waiveMutation = useMutation({ mutationFn: ({ id, reason }: { id: string; reason: string }) => excessBaggageApi.waive(id, { waivedBy: 'supervisor', waivedReason: reason }), @@ -51,7 +70,12 @@ export default function ExcessBaggagePage() { const resendMutation = useMutation({ mutationFn: (id: string) => excessBaggageApi.resendLink(id), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); + setResendSuccess(true); + setResendError(null); + }, + onError: (e: any) => setResendError(e?.response?.data?.message || e?.message || 'Failed to resend link'), }); const deleteMutation = useMutation({ @@ -115,7 +139,7 @@ export default function ExcessBaggagePage() { label: 'Resend Link', icon: Send, variant: 'secondary' as const, - onClick: (c: any) => resendMutation.mutate(c.id), + onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); }, show: (c: any) => c.status === 'PENDING', }, { @@ -145,6 +169,9 @@ export default function ExcessBaggagePage() {

Excess Lugagge

Track and manage excess luggage charges at boarding

+ { setLogModal(true); setLogError(null); setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); }}> + Log Excess Luggage +
@@ -194,6 +221,104 @@ export default function ExcessBaggagePage() { emptyMessage="No excess baggage charges found" /> + {/* Log Excess Luggage Modal */} + setLogModal(false)} title="Log Excess Luggage" size="sm"> +
+ {user && ( +
+ Logging as agent: {user.fullName} +
+ )} +
+ + setLogForm({ ...logForm, bookingId: e.target.value })} + /> +
+
+ + setLogForm({ ...logForm, excessWeightKg: e.target.value })} + /> +
+ + {!logForm.collectCash && ( +

+ A payment link will be sent to the passenger's email and phone on file. +

+ )} + {logError &&

{logError}

} +
+ setLogModal(false)}>Cancel + { + if (!logForm.bookingId.trim() || !logForm.excessWeightKg) { + setLogError('Booking ID and excess weight are required'); + return; + } + logMutation.mutate({ + bookingId: logForm.bookingId.trim(), + excessWeightKg: parseInt(logForm.excessWeightKg), + collectCash: logForm.collectCash, + }); + }} + > + {logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'} + +
+
+
+ + {/* Resend Link Modal */} + setResendModal(null)} title="Resend Payment Link" size="sm"> + {resendModal && ( +
+ {resendSuccess ? ( +
+ ✓ Payment link resent successfully. Expiry extended by 20 minutes. +
+ ) : ( + <> +

+ Resend payment link for booking{' '} + {resendModal.booking?.bookingRef}? +

+
+ {resendModal.contactPhone &&
📱 {resendModal.contactPhone}
} + {resendModal.contactEmail &&
✉ {resendModal.contactEmail}
} +
+

Amount: {formatCurrency(resendModal.totalMinor, resendModal.currency)}. Expiry will be extended by 20 minutes.

+ {resendError &&

{resendError}

} + + )} +
+ setResendModal(null)}>Close + {!resendSuccess && ( + resendMutation.mutate(resendModal.id)}> + Resend + + )} +
+
+ )} +
+ {/* Waive Modal */} ('login'); - const [forgotEmail, setForgotEmail] = useState(''); - const [forgotLoading, setForgotLoading] = useState(false); - const [forgotError, setForgotError] = useState(''); - const [forgotSent, setForgotSent] = useState(false); - const [forgotFocused, setForgotFocused] = useState(false); + const [view, setView] = useState<'login' | 'forgot'>('login'); + const [forgotIdentifier, setForgotIdentifier] = useState(''); + const [forgotLoading, setForgotLoading] = useState(false); + const [forgotError, setForgotError] = useState(''); + const [forgotSent, setForgotSent] = useState(false); + const [forgotFocused, setForgotFocused] = useState(false); const router = useRouter(); const { login } = useAuthStore(); @@ -66,13 +66,13 @@ export default function LoginPage() { setForgotLoading(true); setForgotError(''); try { - await iamAuthApi.forgotPassword(forgotEmail); + await iamAuthApi.forgotPassword(forgotIdentifier.trim()); setForgotSent(true); } catch (err: any) { const msg = err.response?.data?.message || err.message || ''; setForgotError( msg === 'user_not_found' - ? 'No account found with that email address.' + ? 'No account found with that email or phone number.' : msg || 'Failed to send the reset link. Please try again.' ); } finally { @@ -209,7 +209,7 @@ export default function LoginPage() {
@@ -295,10 +295,10 @@ export default function LoginPage() { )}
- {/* Email field */} + {/* Email or phone field */}
{ setForgotEmail(e.target.value); setForgotError(''); }} + type="text" + value={forgotIdentifier} + onChange={(e) => { setForgotIdentifier(e.target.value); setForgotError(''); }} onFocus={() => setForgotFocused(true)} onBlur={() => setForgotFocused(false)} className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none" - placeholder="name@edr.com" + placeholder="name@edr.com or +251..." required - autoComplete="email" + autoComplete="username" />
@@ -322,9 +322,9 @@ export default function LoginPage() { {/* Submit */}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index ceb54acd9..6ce8b5f66 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -72,10 +72,10 @@ export default function PaymentPage() { // split equally across both legs. This guarantees leg totals are consistent with the // per-passenger breakdown rows and the overall reviewed total. const outboundBaseFare = isRoundTrip - ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0) + ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.outboundFareMinor ?? Math.round(f.fareMinor / 2))), 0) : 0; const inboundBaseFare = isRoundTrip - ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0) + ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0) : 0; // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display @@ -188,10 +188,10 @@ export default function PaymentPage() { if (!bookingId && !pnr) { return ( -
+
-

Loading payment details...

+

Loading payment details...

); @@ -307,11 +307,11 @@ export default function PaymentPage() {
Outbound - {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)} + {formatFare(reviewed?.outboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
Return - {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)} + {formatFare(reviewed?.inboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
)} @@ -372,10 +372,10 @@ export default function PaymentPage() { ); return ( -
+
-

Complete payment

+

Complete payment

{/* Reservation confirmation banner */}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 0f8ac2004..fb44dce41 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -197,17 +197,12 @@ export default function ResultsPage() { // Alternatives are surfaced whenever a leg returns no exact-date results. const alternativeOutbound: Schedule[] = !!results && outboundSchedules.length === 0 - ? results?.alternativeOutbound || [] + ? results?.alternativeOutbound || results?.outboundAlternatives || [] : []; const alternativeInbound: Schedule[] = isRoundTrip && !!results && inboundSchedules.length === 0 - ? results?.alternativeInbound || [] + ? results?.alternativeInbound || results?.inboundAlternatives || [] : []; - const requestedDate: string = - (results && results.requestedDate) || searchData.date; - const requestedReturnDate: string = - (results && results.requestedReturnDate) || searchData.returnDate || ""; - const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; // Round-trip: show results view if either leg has exact results OR alternatives. @@ -298,7 +293,17 @@ export default function ResultsPage() { // For round trip inbound, proceed with both schedules if (isRoundTrip && !isOutbound) { - setInboundSchedule(scheduleData); + // Mirror the outbound's coachTypes (fares) onto the inbound schedule so the + // return seat selection page shows the same prices as the outbound leg. + const inboundScheduleData = outboundScheduleData + ? { + ...scheduleData, + baseFareAdult: outboundScheduleData.baseFareAdult, + baseFareChild: outboundScheduleData.baseFareChild, + coachTypes: outboundScheduleData.coachTypes, + } + : scheduleData; + setInboundSchedule(inboundScheduleData); setSelectedSchedule(outboundScheduleData); // Set primary as outbound } else { // For one-way @@ -529,7 +534,7 @@ export default function ResultsPage() { e.stopPropagation(); handleSelect(classModal, isOutbound); }} - className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]" + className="btn-primary w-full mt-3 text-sm active:scale-[0.98]" > {isRoundTrip && isOutbound @@ -647,9 +652,9 @@ export default function ResultsPage() {
-
+
-
+
{schedule.departureAt ? formatTime(schedule.departureAt) : "--:--"} @@ -664,9 +669,9 @@ export default function ResultsPage() {
-
-
- +
+
+ {durationStr}
@@ -674,15 +679,15 @@ export default function ResultsPage() {
{schedule.stops && schedule.stops.length > 0 && ( -
- +
+ {schedule.stops.length - 2} stops
)}
-
+
{schedule.arrivalAt ? formatTime(schedule.arrivalAt) : "--:--"} @@ -744,7 +749,7 @@ export default function ResultsPage() { if (isLoading) { return ( -
+
{/* Progress Header */} @@ -943,28 +948,19 @@ export default function ResultsPage() { !!results && outboundSchedules.length === 0 && inboundSchedules.length === 0 && - alternativeOutbound.length === 0 && - alternativeInbound.length === 0; + (results?.alternativeOutbound || []).length === 0 && + (results?.alternativeInbound || []).length === 0; if (isRoundTripNoResults) { return ( -
+
-
-
-
- +
+
+
+ + No trains found for your selected dates or route.
-

- No trains found -

-

- We couldn't find any trains for your trip. Try adjusting - your dates or route. -

-
@@ -975,34 +971,20 @@ export default function ResultsPage() { } if (isOneWayNoOutbound) { - const requestedDateLabel = requestedDate - ? format(new Date(`${requestedDate}T00:00:00`), "EEEE, MMMM d, yyyy") - : "your selected date"; const hasAlternatives = alternativeOutbound.length > 0; return ( -
+
{renderClassModal()}
-
-
- +
+
+ + No trains available on {searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}.
-

- No trains available -

-

- No trains are available on{" "} - - {requestedDateLabel} - -

-
@@ -1031,24 +1013,15 @@ export default function ResultsPage() { } return ( -
+
-
-
-
- +
+
+
+ + No trains found matching your search. Try adjusting your dates or route.
-

- No trains found -

-

- We couldn't find any trains matching your search criteria.{" "} -
Try adjusting your dates or route. -

-
@@ -1059,7 +1032,7 @@ export default function ResultsPage() { } return ( -
+
{renderClassModal()} @@ -1172,29 +1145,13 @@ export default function ResultsPage() { {outboundSchedules.length === 0 && alternativeOutbound.length > 0 && (
-
-
- +
+
+ + No trains on {searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}.
-

- No trains available -

-

- No trains are available on{" "} - - {requestedDate - ? format( - new Date(`${requestedDate}T00:00:00`), - "EEEE, MMMM d, yyyy", - ) - : "your selected date"} - -

-
@@ -1272,29 +1229,13 @@ export default function ResultsPage() { {inboundSchedules.length === 0 && alternativeInbound.length > 0 && (
-
-
- +
+
+ + No trains on {searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}.
-

- No trains available -

-

- No trains are available on{" "} - - {requestedReturnDate - ? format( - new Date(`${requestedReturnDate}T00:00:00`), - "EEEE, MMMM d, yyyy", - ) - : "your selected return date"} - -

-
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index cf14cc87c..251f8ae8b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -445,7 +445,9 @@ export default function ReviewPage() { const fareMinor = isPackageBooking ? (isFreeChild ? 0 : (seatFare ?? pkgFallback)) : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); - return { fareMinor, isFree: isFreeChild }; + const outboundFareMinor = isRoundTrip ? ((p as any).outboundSeatFareMinor ?? undefined) : undefined; + const inboundFareMinor = isRoundTrip ? ((p as any).inboundSeatFareMinor ?? undefined) : undefined; + return { fareMinor, isFree: isFreeChild, outboundFareMinor, inboundFareMinor }; }); setReviewedTotal(computedTotal, passengerFares); @@ -560,6 +562,14 @@ export default function ReviewPage() { const isFreeChild = isPackageBooking ? isPkgFreeChild(i) : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); + + // Per-leg fares for round trips + const outboundFare: number | null = isRoundTrip + ? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).outboundSeatFareMinor ?? null)) + : null; + const inboundFare: number | null = isRoundTrip + ? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).inboundSeatFareMinor ?? null)) + : null; const seatFare = getPassengerSeatFare(p); const passengerTotal = isPackageBooking ? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare))) @@ -582,6 +592,19 @@ export default function ReviewPage() { {formatFare(passengerTotal, displayCurrency)}
+ {/* Round-trip: show outbound + inbound breakdown */} + {isRoundTrip && !isFreeChild && ( +
+
+ ↗ Outbound + {outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'} +
+
+ ↙ Return + {inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'} +
+
+ )}
); })} @@ -613,10 +636,10 @@ export default function ReviewPage() { ); return ( -
+
-

+

{packageName ? `Review your ${packageName} booking` : 'Review your booking'}

diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 574b08487..02c1fcd35 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -608,6 +608,12 @@ export default function SearchPage() { reValidateMode: "onSubmit", defaultValues: { tripType: "ONE_WAY", + // originStationId/destinationStationId must default to "" rather than being omitted — + // Zod's base string type check runs before .min(1, "..."), so an `undefined` value hits + // its generic "Required" message instead of our custom one. An empty string is still a + // string, so .min() (and its custom message) is what actually fires. + originStationId: "", + destinationStationId: "", adultCount: 1, childCount: 0, // No default nationality — the user must explicitly pick one. Left blank (not a valid @@ -827,19 +833,21 @@ export default function SearchPage() { /> )} - {/* ── 90vh hero with banner image ── */} - {/* Round trip stacks an extra Return Date field into the widget on mobile, which grows + {/* ── 90vh hero with banner image (desktop) / top-aligned widget only (mobile) ── */} + {/* Round trip stacks an extra Return Date field into the widget on desktop, which grows upward from its bottom-anchored position — give the hero extra height there so the - widget's top edge doesn't creep up into the sticky header. */} + widget's top edge doesn't creep up into the sticky header. On mobile the widget is + in normal flow (not bottom-anchored), so this only applies at md: and up. */}
- {/* Background image with zoom - fully isolated */} -
+ {/* Background image with zoom — desktop only; mobile drops the hero image entirely + so the booking widget can sit at the top and use the available space. */} +
- {/* Gradient overlay */} -
-
+ {/* Gradient overlay — desktop only (exists to keep the hero text readable over the image) */} +
+
- {/* Hero headline — top area */} -
+ {/* Hero headline — desktop only; removed on mobile along with the banner image */} +

Where are you
headed today? @@ -867,14 +875,28 @@ export default function SearchPage() {

- {/* ── Widget — absolutely positioned at bottom with margin ── */} + {/* ── Widget — top-aligned, normal flow on mobile; absolutely positioned at + bottom on desktop over the hero image. z-[35] sits above the floating + support chat launcher (z-30) but below the sidebar/tab bar (z-40), so it + never covers either. Modals opened from inside the widget (date picker + etc.) are portaled to — see ModernDatePicker — so they aren't + capped by this wrapper's own stacking context. ── */}
+ {/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */} +
+

+ Where are you headed today? +

+

+ Book your train journey across East Africa +

+
-
+
{error && (
⚠️ @@ -950,6 +972,11 @@ export default function SearchPage() {
+ {hasInteracted && errors.originStationId && ( +

+ {errors.originStationId.message} +

+ )}
@@ -998,6 +1025,11 @@ export default function SearchPage() {
+ {hasInteracted && errors.destinationStationId && ( +

+ {errors.destinationStationId.message} +

+ )}
+ {errors.departureDate && ( +

+ {errors.departureDate.message} +

+ )}
{tripType === "ROUND_TRIP" && (
@@ -1047,6 +1085,7 @@ export default function SearchPage() { : new Date() } placeholder="Select return date" + error={!!errors.returnDate} />
{errors.returnDate && ( @@ -1079,7 +1118,7 @@ export default function SearchPage() {
{errors.departureDate && ( @@ -1219,7 +1259,7 @@ export default function SearchPage() {
{errors.departureDate && ( @@ -1357,6 +1398,7 @@ export default function SearchPage() { : new Date() } placeholder="Select date" + error={!!errors.returnDate} />
{errors.returnDate && ( @@ -1482,7 +1524,7 @@ export default function SearchPage() {
{isLoading ? ( -
-
-

- Loading seat map... -

+
+
+ + +
+
+ {Array.from({ length: 24 }).map((_, i) => ( + + ))} +
) : error ? (
diff --git a/apps/edr-passenger-web/portal/src/app/contact/page.tsx b/apps/edr-passenger-web/portal/src/app/contact/page.tsx index f2c0f4296..669a99d0b 100644 --- a/apps/edr-passenger-web/portal/src/app/contact/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/contact/page.tsx @@ -3,16 +3,18 @@ import { useEffect, useState } from 'react'; import { getTranslation, Language, useLanguage } from '@/lib/i18n'; import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react'; +import { Footer } from '@/components/Footer'; const styles = ` .contact-hero { padding: 60px 20px; - background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent); + background: #ffffff; text-align: center; color: #111827; } - + .dark .contact-hero { + background: #111827; color: #f3f4f6; } @@ -370,6 +372,7 @@ export default function Contact() {
+
); } diff --git a/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx b/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx index 254b65eb8..34b41c202 100644 --- a/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx @@ -6,7 +6,7 @@ import { Train, MailCheck, ArrowLeft } from 'lucide-react'; import { iamAuthApi } from '@/lib/api/auth'; export default function ForgotPasswordPage() { - const [email, setEmail] = useState(''); + const [identifier, setIdentifier] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [sent, setSent] = useState(false); @@ -16,13 +16,13 @@ export default function ForgotPasswordPage() { setLoading(true); setError(''); try { - await iamAuthApi.forgotPassword(email); + await iamAuthApi.forgotPassword(identifier.trim()); setSent(true); } catch (err: any) { const msg = err.response?.data?.message || err.message || ''; setError( msg === 'user_not_found' - ? 'No account found with that email address.' + ? 'No account found with that email or phone number.' : msg || 'Failed to send the reset link. Please try again.' ); } finally { @@ -41,7 +41,7 @@ export default function ForgotPasswordPage() {

Reset your password

- Enter your email and we'll send a reset link to the phone number on your account. + Enter your email or phone number and we'll send a reset link to the phone number on your account.

@@ -69,19 +69,19 @@ export default function ForgotPasswordPage() { )}
- + { setEmail(e.target.value); setError(''); }} + type="text" + value={identifier} + onChange={(e) => { setIdentifier(e.target.value); setError(''); }} className="input-field" - placeholder="your@email.com" - autoComplete="email" + placeholder="your@email.com or +251..." + autoComplete="username" required />
- diff --git a/apps/edr-passenger-web/portal/src/app/globals.css b/apps/edr-passenger-web/portal/src/app/globals.css index 9714d54bc..9296f3030 100644 --- a/apps/edr-passenger-web/portal/src/app/globals.css +++ b/apps/edr-passenger-web/portal/src/app/globals.css @@ -26,19 +26,17 @@ } .btn-ghost { - @apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors; + @apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors hover:bg-[rgba(20,113,76,0.1)] dark:hover:bg-[rgba(20,113,76,0.2)]; } - - .btn-ghost:hover { - background-color: rgba(20, 113, 76, 0.1); + + .booking-page { + @apply min-h-screen bg-gray-50 dark:bg-gray-900 py-6 md:py-8; } - - @media (prefers-color-scheme: dark) { - .btn-ghost:hover { - background-color: rgba(20, 113, 76, 0.2); - } + + .booking-container { + @apply container mx-auto px-4 max-w-6xl; } - + .input-field { @apply w-full px-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-[rgb(20_113_76)] focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-all duration-200 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100; } @@ -162,7 +160,18 @@ opacity: 1; } } - + + @keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .animate-bounce-in { animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); } @@ -188,6 +197,10 @@ animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1); } + .animate-fade-in-up { + animation: fade-in-up 0.35s ease-out; + } + .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; diff --git a/apps/edr-passenger-web/portal/src/app/go/page.tsx b/apps/edr-passenger-web/portal/src/app/go/page.tsx index d9f6f583f..feed05990 100644 --- a/apps/edr-passenger-web/portal/src/app/go/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/go/page.tsx @@ -13,7 +13,7 @@ import { useSearchParams } from "next/navigation"; * hop is browser-dependent and can be stripped) must NOT be used here. * * It fires immediately (no delay) and paints a bare white full-screen cover - * above the sticky header (z-[60]) — no portal chrome, no text on the happy + * above the sidebar/tab bar (z-40) — no portal chrome, no text on the happy * path. A short message shows only when the link is missing/untrusted. * * `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the diff --git a/apps/edr-passenger-web/portal/src/app/layout.tsx b/apps/edr-passenger-web/portal/src/app/layout.tsx index 3c18f1c13..1128124e3 100644 --- a/apps/edr-passenger-web/portal/src/app/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/layout.tsx @@ -2,10 +2,11 @@ import type { Metadata } from 'next'; import { headers } from 'next/headers'; import './globals.css'; import { Providers } from './providers'; -import AppHeader from '@/components/AppHeader'; -import { Footer } from '@/components/Footer'; +import AppSidebar from '@/components/AppSidebar'; +import MobileTopBar from '@/components/MobileTopBar'; +import BottomTabBar from '@/components/BottomTabBar'; import { LoadingIndicator } from '@/components/LoadingIndicator'; -import SupportWidget from '@/features/support/SupportWidget'; +import SupportWidget from '@/features/support/SupportWidgetLazy'; export const metadata: Metadata = { title: 'EDR Passenger Portal - Book your train journey', @@ -21,7 +22,7 @@ export default function RootLayout({ const nonce = headers().get('x-nonce') ?? undefined; return ( - +