diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 44be07550..bdb4c5ff5 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -144,15 +144,16 @@ jobs:
run: ./scripts/deploy/create-npmrc.sh
- name: Resolve env file path for ${{ matrix.service }}
- if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
+ if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
case "${{ matrix.service }}" in
+ freight-api) echo "SERVICE_ENV_FILE=apps/edr-freight-api/.env" >> "$GITHUB_ENV" ;;
passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;;
payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;;
esac
- name: Build migration image for ${{ matrix.service }}
- if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
+ if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
set -euo pipefail
docker build \
@@ -163,7 +164,7 @@ jobs:
.
- name: Run migrations for ${{ matrix.service }}
- if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
+ if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
set -euo pipefail
docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration"
diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md
index 11075e9f0..a99f64d0a 100644
--- a/CLAUDE_NEW.md
+++ b/CLAUDE_NEW.md
@@ -154,13 +154,32 @@ no timeout and will wait forever.
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'`.
+- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
+ one-shot step, via the Dockerfile's `migration` build target (`docker build --target
+ migration`), with `migrationsTransactionMode: 'each'`.
+ - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
+ (`docker run --rm --env-file ...`) *before* building/deploying the app image.
+ - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
+ `freight-api-e2e` depends on it (`condition: service_completed_successfully`).
+ - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
+ migrations yourself before `docker compose up freight-api`, e.g.
+ `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
+ then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
+ use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
+ output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
+ It silently applies zero freight migrations while exiting 0.
- 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.
+ - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
+ hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
+ notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
+ `forFeature()` registrations), but the standalone migration `DataSource`
+ (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
+ entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
+ single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
+ for this to break again** — diff the package's entity classes against `iamEntities`
+ when bumping it.
- **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.
diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile
index 3c05c8a9f..a1d86723c 100644
--- a/apps/edr-freight-api/Dockerfile
+++ b/apps/edr-freight-api/Dockerfile
@@ -29,7 +29,12 @@ COPY --from=builder /app/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
+FROM deployer AS migration
+WORKDIR /deploy
+CMD ["node", "dist/scripts/migrate.js"]
+
FROM base AS runner
+RUN apk add --no-cache libc6-compat
ENV NODE_ENV=production
WORKDIR /app
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index 20bfa1bad..b3ed1c802 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -27,6 +27,7 @@
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
+ "backfill:missing-unload-inventory": "ts-node -r tsconfig-paths/register src/scripts/backfill-missing-unload-inventory.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
@@ -36,6 +37,7 @@
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
+ "migration:run": "node dist/scripts/migrate.js",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
},
"dependencies": {
@@ -57,8 +59,8 @@
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^11.1.27",
- "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
- "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.15.tgz",
+ "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
+ "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",
diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts
index dcf09fbd9..0591cd0b1 100644
--- a/apps/edr-freight-api/src/config/database.config.ts
+++ b/apps/edr-freight-api/src/config/database.config.ts
@@ -1,5 +1,6 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
+import { DataSourceOptions } from "typeorm";
import { join, dirname } from "path";
import {
DefaultPosition,
@@ -44,8 +45,30 @@ import {
NotificationTemplate,
} from "@tria-plc/iamapi-common";
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
+import { UnitSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-setting.entity";
+import { UnitDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-detail.entity";
+import { OrganizationDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-detail.entity";
+import { UnitCluster } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-cluster.entity";
+import { Location } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location.entity";
+import { LocationType } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location-type.entity";
+import { DelegationTerminationReason } from "@tria-plc/iamapi-common/entities/iam/organization-structure/delegation-termination-reason.entity";
+import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee-position-active-period.entity";
+import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
+import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
+import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
const iamEntities = [
+ UnitSetting,
+ UnitDetail,
+ OrganizationDetail,
+ UnitCluster,
+ Location,
+ LocationType,
+ DelegationTerminationReason,
+ EmployeePositionActivePeriod,
+ UnitConfiguration,
+ Site,
+ SiteSetting,
DefaultPosition,
DefaultUnit,
EmployeePosition,
@@ -95,7 +118,7 @@ const iamMigrationsGlob = join(
);
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
-export default registerAs("database", (): TypeOrmModuleOptions => {
+export function buildDataSourceOptions(): DataSourceOptions {
return {
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
@@ -111,17 +134,19 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
// The search_path is instead applied per-connection via a pool `connect`
// handler in app.module.ts (see setPoolSearchPath).
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
- autoLoadEntities: true,
migrations: [
- // IAM schema + tables must be created before freight migrations
iamMigrationsGlob,
freightMigrationsGlob,
],
- migrationsRun: true,
migrationsTransactionMode: "each",
- // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false,
logging:
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
};
-});
+}
+
+export default registerAs("database", (): TypeOrmModuleOptions => ({
+ ...buildDataSourceOptions(),
+ autoLoadEntities: true,
+ migrationsRun: false,
+}));
diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts
index 2ae202ebd..4b46912b7 100644
--- a/apps/edr-freight-api/src/data-source.ts
+++ b/apps/edr-freight-api/src/data-source.ts
@@ -1,21 +1,8 @@
// apps/edr-freight-api/src/data-source.ts
-import 'dotenv/config';
-import { DataSource } from 'typeorm';
-//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
+import "dotenv/config";
+import { DataSource } from "typeorm";
+import { buildDataSourceOptions } from "./config/database.config";
-export const AppDataSource = new DataSource({
- type: 'postgres',
- host: process.env.DB_HOST ?? 'localhost',
- port: Number(process.env.DB_PORT ?? 5433),
- username: process.env.DB_USER ?? 'postgres',
- password: process.env.DB_PASSWORD ?? '',
- database: process.env.DB_NAME ?? 'edr_freight',
- schema: 'freight', // default schema for entities without an explicit schema
- entities: [__dirname + '/**/*.entity{.ts,.js}'],
- migrations: [__dirname + '/migrations/*{.ts,.js}'],
- synchronize: false,
- logging: process.env.TYPEORM_LOGGING === 'true',
-});
+export const AppDataSource = new DataSource(buildDataSourceOptions());
-// Optional: call ensurePostgresSchemas before initializing
-// But you can also run it separately.
+export default AppDataSource;
diff --git a/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts b/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts
new file mode 100644
index 000000000..976aca798
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts
@@ -0,0 +1,24 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Pending→Active is the only company-level approval event; `updatedAt` can't
+ * stand in for it since any field edit bumps that too. Nullable — existing
+ * companies (approved before this column existed) have no recorded moment.
+ */
+export class AddApprovedAtToCompanies3120000000000 implements MigrationInterface {
+ name = "AddApprovedAtToCompanies3120000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.companies
+ ADD COLUMN IF NOT EXISTS approved_at timestamptz`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.companies
+ DROP COLUMN IF EXISTS approved_at`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts b/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts
new file mode 100644
index 000000000..153928b04
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts
@@ -0,0 +1,24 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Break-bulk (PER_ITEM) cargo needs a physical items-fit per allowed wagon
+ * type (e.g. cars → NW5: 4, NW7: 6): a wagon runs out of floor space before it
+ * runs out of rated tonnage, so allocation must respect BOTH limits. Stored as
+ * a jsonb map { [wagonTypeId]: itemsFit } on cargo_types — keys mirror the
+ * cargo_type_wagon_types join rows, kept in sync by the cargo-types service.
+ */
+export class AddCargoTypeItemsPerWagonMap3120000000000 implements MigrationInterface {
+ name = 'AddCargoTypeItemsPerWagonMap3120000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "items_per_wagon_map" jsonb`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "items_per_wagon_map"`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts b/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts
new file mode 100644
index 000000000..01c1c6991
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts
@@ -0,0 +1,48 @@
+import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
+
+/**
+ * Append-only audit of company edits made before the company reaches Active
+ * (the onboarding phase) — that write path has no approval gate and, until
+ * now, left no trace of what changed (e.g. a phone number or a document).
+ */
+export class CreateCompanyRevisions3130000000000 implements MigrationInterface {
+ name = 'CreateCompanyRevisions3130000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.createTable(
+ new Table({
+ schema: 'freight',
+ name: 'company_revisions',
+ columns: [
+ { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
+ { name: 'company_id', type: 'uuid' },
+ { name: 'actor_id', type: 'uuid', isNullable: true },
+ { name: 'summary', type: 'varchar', length: '255' },
+ { name: 'changes', type: 'jsonb', default: "'[]'::jsonb" },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ foreignKeys: [
+ {
+ columnNames: ['company_id'],
+ referencedSchema: 'freight',
+ referencedTableName: 'companies',
+ referencedColumnNames: ['id'],
+ onDelete: 'CASCADE',
+ },
+ ],
+ }),
+ true,
+ );
+
+ await queryRunner.createIndex(
+ 'freight.company_revisions',
+ new TableIndex({ name: 'idx_company_revisions_company', columnNames: ['company_id'] }),
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropTable('freight.company_revisions', true);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts b/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts
new file mode 100644
index 000000000..b2a931542
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts
@@ -0,0 +1,36 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Rates created before their commodity's unit_of_measure was flipped kept the
+ * old bulk-quantity unit, so bookings of a PER_ITEM commodity (e.g. Machinery)
+ * quoted "per ton". PER_TON and PER_ITEM bill the same stored quantity — only
+ * the name differs — so renaming is safe. Going forward the cargo-types
+ * service syncs rates on every uom change; this backfills the drift.
+ */
+export class SyncBulkRateUnitsToCargoUom3140000000000 implements MigrationInterface {
+ name = 'SyncBulkRateUnitsToCargoUom3140000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `UPDATE "freight"."rates" r
+ SET "rate_unit" = 'PER_ITEM'
+ FROM "freight"."cargo_types" ct
+ WHERE ct."id" = r."cargo_type_id"
+ AND ct."unit_of_measure" = 'PER_ITEM'
+ AND r."rate_unit" = 'PER_TON'`,
+ );
+ await queryRunner.query(
+ `UPDATE "freight"."rates" r
+ SET "rate_unit" = 'PER_TON'
+ FROM "freight"."cargo_types" ct
+ WHERE ct."id" = r."cargo_type_id"
+ AND ct."unit_of_measure" = 'PER_TON'
+ AND r."rate_unit" = 'PER_ITEM'`,
+ );
+ }
+
+ public async down(): Promise {
+ // Irreversible rename-by-join: the pre-sync unit is not recorded. Both
+ // units bill identically, so rolling back the code needs no data change.
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
index 0eb65bf5d..b44c1c5e0 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
@@ -16,7 +16,7 @@ import {
containersPerWagonForSize,
wagonsPerUnitForSize,
} from '../rule-engine/container-type.util';
-import { bulkItemWagonsRequired } from '../train-scheduling/train-capacity.util';
+import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util';
import { BookingsRepository } from './bookings.repository';
import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -1188,8 +1188,9 @@ export class BookingPricingService {
);
if (!(capacity > 0)) return null;
// Break-bulk (PER_ITEM): `tons` above is the item count; size by
- // indivisible items instead of pretending the count is tonnage.
- const byItems = bulkItemWagonsRequired(booking, capacity);
+ // indivisible items instead of pretending the count is tonnage. Best
+ // count across allowed wagon types, each capped by its items-fit.
+ const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity);
if (byItems > 0) return byItems;
return Math.max(1, Math.ceil(tons / capacity));
} catch {
diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
index ee75460c9..7665d82eb 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
@@ -62,6 +62,7 @@ import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-stat
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
+import { CompanyRevisionResponseDto } from "./dto/company-revision-response.dto";
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto";
@@ -685,6 +686,17 @@ export class CompaniesController {
return requests.map((r) => new ChangeRequestResponseDto(r));
}
+ @Get(":companyId/revisions")
+ @BookingStaff(FREIGHT_PERMS.customers.view)
+ @ApiOperation({ summary: "Onboarding-phase edit history (version history)" })
+ async listCompanyRevisions(
+ @Param("companyId", ParseUUIDPipe) companyId: string,
+ ): Promise {
+ const revisions =
+ await this.companiesService.listCompanyRevisions(companyId);
+ return revisions.map((r) => new CompanyRevisionResponseDto(r));
+ }
+
@Post("change-requests/:id/approve")
@BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({
@@ -719,6 +731,25 @@ export class CompaniesController {
return new ChangeRequestResponseDto(request);
}
+ @Post("change-requests/:id/request-changes")
+ @BookingStaff(FREIGHT_PERMS.customers.verify)
+ @ApiOperation({
+ summary:
+ "Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)",
+ })
+ async requestChangeRequestChanges(
+ @CurrentUser() user: CurrentIamUser,
+ @Param("id", ParseUUIDPipe) id: string,
+ @Body() dto: RejectChangeRequestDto,
+ ): Promise {
+ const request = await this.companiesService.requestChangeRequestChanges(
+ id,
+ dto.note,
+ user.id,
+ );
+ return new ChangeRequestResponseDto(request);
+ }
+
@Post(":companyId/profiles")
@BookingStaff(FREIGHT_PERMS.customers.update)
@ApiOperation({ summary: "Add a profile (employee) to a company" })
diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts
index c13f79f5e..511c3aa25 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts
@@ -120,6 +120,13 @@ function makeService(overrides: Partial = {}) {
})),
update: jest.fn(async () => ({ id: "cr-1" })),
},
+ revisionRepo: {
+ create: jest.fn(async (row: Record) => ({
+ id: "rev-1",
+ ...row,
+ })),
+ findByCompanyId: jest.fn(async () => []),
+ },
profilesRepo: {
findByCompanyId: jest.fn(async () => []),
findByUserId: jest.fn(async () => ({
@@ -144,6 +151,7 @@ function makeService(overrides: Partial = {}) {
deps.companiesRepo as never,
deps.companyProfilesRepo as never,
deps.changeRequestRepo as never,
+ deps.revisionRepo as never,
deps.profilesRepo as never,
{} as never,
deps.filesService as never,
diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts
index 450222657..f0a443ba8 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.module.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts
@@ -15,9 +15,11 @@ import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import { CompanyProfile } from "./entities/company-profile.entity";
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
+import { CompanyRevision } from "./entities/company-revision.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
+import { CompanyRevisionRepository } from "./company-revision.repository";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { VerifaydaModule } from "../verifayda/verifayda.module";
@@ -29,6 +31,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
ExternalProfile,
CompanyProfile,
CompanyChangeRequest,
+ CompanyRevision,
Booking,
]),
HttpModule,
@@ -49,6 +52,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
ExternalProfileRepository,
CompanyProfileRepository,
CompanyChangeRequestRepository,
+ CompanyRevisionRepository,
CompanyDashboardRepository,
ETradeService,
CompanyNotifierService,
diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts
index 27c42e581..365bd4639 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts
@@ -91,6 +91,13 @@ function makeService(overrides: Partial = {}) {
})),
update: jest.fn(async () => ({ id: "cr-1" })),
},
+ revisionRepo: {
+ create: jest.fn(async (row: Record) => ({
+ id: "rev-1",
+ ...row,
+ })),
+ findByCompanyId: jest.fn(async () => []),
+ },
profilesRepo: {
findByCompanyId: jest.fn(async () => []),
findByUserId: jest.fn(async () => ({
@@ -121,6 +128,7 @@ function makeService(overrides: Partial = {}) {
deps.companiesRepo as never,
deps.companyProfilesRepo as never,
deps.changeRequestRepo as never,
+ deps.revisionRepo as never,
deps.profilesRepo as never,
{} as never,
deps.filesService as never,
diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts
index f22123f61..5961be90f 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts
@@ -39,6 +39,7 @@ function makeService(existing: ExistingProfile[]) {
companiesRepo as never,
companyProfilesRepo as never,
{} as never,
+ {} as never,
profilesRepo as never,
{} as never,
{} as never,
diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts
index c198d149f..d9798af5d 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.service.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts
@@ -1,5 +1,6 @@
import {
Injectable,
+ Logger,
NotFoundException,
ConflictException,
BadRequestException,
@@ -9,6 +10,11 @@ import { DataSource, EntityManager } from "typeorm";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
+import { CompanyRevisionRepository } from "./company-revision.repository";
+import {
+ diffCompanyUpdate,
+ summarizeCompanyChanges,
+} from "./company-revision-diff.util";
import { ExternalProfileRepository } from "./external-profile.repository";
import {
CompanyDashboardRepository,
@@ -64,6 +70,10 @@ import {
DocumentChangeIntent,
LicenseChangeIntent,
} from "./entities/company-change-request.entity";
+import {
+ CompanyRevision,
+ CompanyRevisionChange,
+} from "./entities/company-revision.entity";
/** FileRecord `resource` + `code` slots for business-license documents. */
const LICENSE_RESOURCE = "company_profiles";
@@ -176,10 +186,13 @@ export interface UserIdentity {
@Injectable()
export class CompaniesService {
+ private readonly logger = new Logger(CompaniesService.name);
+
constructor(
private readonly companiesRepo: CompaniesRepository,
private readonly companyProfilesRepo: CompanyProfileRepository,
private readonly changeRequestRepo: CompanyChangeRequestRepository,
+ private readonly revisionRepo: CompanyRevisionRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
private readonly filesService: FilesService,
@@ -682,7 +695,16 @@ export class CompaniesService {
async updateCompany(id: string, dto: UpdateCompanyDto): Promise {
const before = await this.findCompanyById(id);
- const updated = await this.companiesRepo.update(id, dto);
+ const patch: UpdateCompanyDto & { approvedAt?: Date } = { ...dto };
+ // Staff can also promote Pending -> Active directly through this generic
+ // endpoint (not just via the first-profile-approval path), so stamp it here too.
+ if (
+ dto.status === CompanyStatus.Active &&
+ before.status !== CompanyStatus.Active
+ ) {
+ patch.approvedAt = new Date();
+ }
+ const updated = await this.companiesRepo.update(id, patch);
if (!updated) throw new NotFoundException(`Company ${id} not found`);
// Suspending or blacklisting locks the customer out, so they must be told.
@@ -840,6 +862,34 @@ export class CompaniesService {
return companyUpdates;
}
+ /**
+ * Append a version-history entry for an onboarding-phase edit (the company
+ * is not yet Active, so the change went straight to the live row with no
+ * approval gate to carry a record of it). Best-effort: a no-op patch or a
+ * failure to write history must never break the edit that triggered it.
+ */
+ private async recordCompanyRevision(
+ before: Company,
+ patch: Record,
+ actorId?: string | null,
+ extraChanges: CompanyRevisionChange[] = [],
+ ): Promise {
+ try {
+ const changes = [...diffCompanyUpdate(before, patch), ...extraChanges];
+ if (changes.length === 0) return;
+ await this.revisionRepo.create({
+ companyId: before.id,
+ actorId: actorId ?? null,
+ summary: summarizeCompanyChanges(changes),
+ changes,
+ });
+ } catch (err) {
+ this.logger.error(
+ `Failed to record company revision for ${before.id}: ${String(err)}`,
+ );
+ }
+ }
+
/** Reject a TIN already registered to a *different* company. */
private async assertTinAvailable(
company: Company,
@@ -902,6 +952,7 @@ export class CompaniesService {
);
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
+ await this.recordCompanyRevision(company, companyUpdates, userId);
return new ProfileResponseDto(profile, updated);
}
@@ -945,10 +996,14 @@ export class CompaniesService {
if (existing) {
request =
(await this.changeRequestRepo.update(existing.id, {
+ // Note is left untouched: if this request was ChangesRequested, the
+ // reviewer's ask stays visible on the resubmitted (Pending) row —
+ // clearing it here would hide what was asked for right when the
+ // reviewer comes back to check whether it was actually addressed.
snapshot: { ...(existing.snapshot ?? {}), ...staged },
submittedBy: userId,
submittedAt: now,
- note: null,
+ status: ChangeRequestStatus.Pending,
})) ?? existing;
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
} else {
@@ -986,6 +1041,53 @@ export class CompaniesService {
return this.changeRequestRepo.findByCompanyId(companyId);
}
+ /** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
+ async listCompanyRevisions(companyId: string): Promise {
+ await this.findCompanyById(companyId);
+ return this.revisionRepo.findByCompanyId(companyId);
+ }
+
+ /**
+ * Pair adjacent remove-then-add intents into one before/after revision
+ * change — that's exactly how a "replace" is staged (see
+ * `replaceProfileLicenseFile`: `[{op:'remove',...}, {op:'add',...}]`
+ * pushed together, and later merges only ever append after that pair, so
+ * adjacency is preserved). A remove or add with no adjacent partner (a pure
+ * add, or a pure removal) stands alone.
+ */
+ private pairReplaceIntents<
+ T extends { op: "add" | "remove"; fileId: string; fileName?: string },
+ >(intents: T[], labelFor: (intent: T) => string): CompanyRevisionChange[] {
+ const changes: CompanyRevisionChange[] = [];
+ let i = 0;
+ while (i < intents.length) {
+ const current = intents[i];
+ const next = intents[i + 1];
+ if (current.op === "remove" && next?.op === "add") {
+ changes.push({
+ field: `document:${current.fileId}`,
+ label: labelFor(next),
+ from: current.fileName ?? null,
+ to: next.fileName ?? null,
+ fromFileId: current.fileId,
+ toFileId: next.fileId,
+ });
+ i += 2;
+ continue;
+ }
+ changes.push({
+ field: `document:${current.fileId}`,
+ label: labelFor(current),
+ from: current.op === "remove" ? (current.fileName ?? null) : null,
+ to: current.op === "add" ? (current.fileName ?? null) : null,
+ fromFileId: current.op === "remove" ? current.fileId : null,
+ toFileId: current.op === "add" ? current.fileId : null,
+ });
+ i += 1;
+ }
+ return changes;
+ }
+
/**
* Approve a pending change request: apply its snapshot to the live Company and
* mark the request approved. Any staged documents are already attached to the
@@ -1015,6 +1117,30 @@ export class CompaniesService {
await this.applyLicenseChanges(request);
await this.applyDocumentChanges(request);
+ // This is the ONLY place post-approval FIELD/license/PoA-document changes
+ // land on the live row — without this call, everything the #419
+ // change-request flow does to those is invisible in Version History.
+ // `documentFileIds` (the general bulk company-documents upload) is
+ // deliberately NOT re-recorded here — those documents go live immediately
+ // at upload time and are already recorded there (see
+ // `uploadCompanyDocuments`); redoing it here would double the entry.
+ const documentChanges: CompanyRevisionChange[] = [
+ ...this.pairReplaceIntents(
+ request.documents?.licenseChanges ?? [],
+ () => "Business license",
+ ),
+ ...this.pairReplaceIntents(
+ request.documents?.documentChanges ?? [],
+ (intent) => intent.code,
+ ),
+ ];
+ await this.recordCompanyRevision(
+ company,
+ companyUpdates,
+ reviewerId,
+ documentChanges,
+ );
+
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Approved,
@@ -1025,12 +1151,62 @@ export class CompaniesService {
);
}
+ /**
+ * A fresh upload under a single-file document slot (`isMultiple: false`)
+ * replaces whatever was there, not adds to it — soft-delete the prior live
+ * file(s) for that code, and describe each replacement (plus each genuinely
+ * new upload) as a revision change carrying both file ids, so the reviewer
+ * can open the previous and current file. Multi-file slots are left alone
+ * (genuinely additive, no single "the" document to diff against). Unrecognised
+ * codes (no matching field in the nationality's document setting) are also
+ * left alone — safer to under-clean than to guess wrong. Independent of the
+ * change-request review outcome: nothing else in this flow ever retires a
+ * superseded document, on approve OR reject — these documents go live the
+ * moment they're uploaded.
+ */
+ private async replaceSingleFileCompanyDocuments(
+ company: Company,
+ before: FileRecord[],
+ uploaded: FileRecord[],
+ ): Promise {
+ const setting = await this.fileUploadSettingsService
+ .getByCode(this.documentSettingCodeFor(company.nationality))
+ .catch(() => null);
+ const fields = setting?.fields ?? [];
+ const singleFileCodes = new Set(
+ fields.filter((f) => !f.isMultiple).map((f) => f.fileKey),
+ );
+ const labelByCode = new Map(fields.map((f) => [f.fileKey, f.fileLabel]));
+ const uploadedIds = new Set(uploaded.map((f) => f.id));
+
+ const changes: CompanyRevisionChange[] = [];
+ const toRemove: FileRecord[] = [];
+ for (const file of uploaded) {
+ if (!singleFileCodes.has(file.code)) continue;
+ const prior = before.find(
+ (f) => f.code === file.code && !uploadedIds.has(f.id),
+ );
+ changes.push({
+ field: `document:${file.code}`,
+ label: labelByCode.get(file.code) ?? file.code,
+ from: prior?.name ?? null,
+ to: file.name,
+ fromFileId: prior?.id ?? null,
+ toFileId: file.id,
+ });
+ if (prior) toRemove.push(prior);
+ }
+ await Promise.all(toRemove.map((f) => this.filesService.remove(f.id)));
+ return changes;
+ }
+
/**
* Upload company documents. For an approved company this also opens/updates a
* pending change request (recording the uploaded file ids) so the upload is
* reviewed and the customer is locked until it clears — consistent with the
* field-edit review. During onboarding (company not yet active) it's a plain
- * upload with no review.
+ * upload with no review. Either way the documents go live immediately, so
+ * the revision history is recorded right away too, not gated on a decision.
*/
async uploadCompanyDocuments(
companyId: string,
@@ -1038,11 +1214,20 @@ export class CompaniesService {
submittedBy?: string,
): Promise {
const company = await this.findCompanyById(companyId);
+ const before = await this.filesService.findByResource(
+ companyId,
+ "companies",
+ );
const uploaded = await this.filesService.uploadMany(
companyId,
"companies",
files,
);
+ const documentChanges = await this.replaceSingleFileCompanyDocuments(
+ company,
+ before,
+ uploaded,
+ );
await this.resolveDocumentChangeRequests(
companyId,
"companies",
@@ -1056,6 +1241,9 @@ export class CompaniesService {
submittedBy,
);
}
+ if (documentChanges.length > 0) {
+ await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
+ }
return uploaded;
}
@@ -1170,7 +1358,8 @@ export class CompaniesService {
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
- note: null,
+ // Note left untouched — see the comment in updateProfile's merge branch.
+ status: ChangeRequestStatus.Pending,
});
if (company) {
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
@@ -1231,6 +1420,37 @@ export class CompaniesService {
);
}
+ /**
+ * Ask for specific fixes without rejecting outright: unlike
+ * {@link rejectChangeRequest}, staged license/document intents are kept (the
+ * row stays open), so the customer's next edit is appended to this SAME
+ * request — via the merge branches in `updateProfile`/`stageDocumentChange`/
+ * `stageLicenseChange`/`stageDocumentIntent`/`stageIdentityChange` — instead
+ * of starting a fresh cycle.
+ */
+ async requestChangeRequestChanges(
+ id: string,
+ note: string,
+ reviewerId?: string,
+ ): Promise {
+ const request = await this.changeRequestRepo.findById(id);
+ if (!request)
+ throw new NotFoundException(`Change request ${id} not found`);
+ if (request.status !== ChangeRequestStatus.Pending) {
+ throw new BadRequestException(
+ `Change request ${id} is already ${request.status}`,
+ );
+ }
+ return (
+ (await this.changeRequestRepo.update(id, {
+ status: ChangeRequestStatus.ChangesRequested,
+ note,
+ reviewedBy: reviewerId ?? null,
+ reviewedAt: new Date(),
+ })) ?? request
+ );
+ }
+
async deleteCompany(id: string): Promise {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);
@@ -1472,6 +1692,7 @@ export class CompaniesService {
) {
await companyRepo.update(updated.companyId, {
status: CompanyStatus.Active,
+ approvedAt: new Date(),
});
this.companyNotifier.companyApproved(company);
}
@@ -2264,7 +2485,8 @@ export class CompaniesService {
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
- note: null,
+ // Note left untouched — see the comment in updateProfile's merge branch.
+ status: ChangeRequestStatus.Pending,
});
} else {
await this.changeRequestRepo.create({
@@ -2590,7 +2812,8 @@ export class CompaniesService {
snapshot,
submittedBy: userId,
submittedAt: now,
- note: null,
+ // Note left untouched — see the comment in updateProfile's merge branch.
+ status: ChangeRequestStatus.Pending,
});
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
return;
@@ -2870,7 +3093,8 @@ export class CompaniesService {
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
- note: null,
+ // Note left untouched — see the comment in updateProfile's merge branch.
+ status: ChangeRequestStatus.Pending,
});
} else {
await this.changeRequestRepo.create({
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
index 73271e0af..1dc4b5a84 100644
--- 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
@@ -1,4 +1,4 @@
-import { Repository } from "typeorm";
+import { FindOperator, Repository } from "typeorm";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import {
@@ -10,6 +10,16 @@ type Row = Pick & { createdAt: Date };
const COMPANY_ID = "company-1";
+/** Matches a row's status against either a plain value or an `In([...])` operator. */
+function statusMatches(
+ rowStatus: ChangeRequestStatus,
+ where: ChangeRequestStatus | FindOperator | undefined,
+): boolean {
+ if (where === undefined) return true;
+ if (where instanceof FindOperator) return where.value.includes(rowStatus);
+ return rowStatus === where;
+}
+
/**
* 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.
@@ -17,13 +27,17 @@ const COMPANY_ID = "company-1";
function mockRepositoryOver(rows: Row[]) {
return {
findOne: jest.fn(
- ({ where }: { where: Partial & { companyId: string } }) =>
+ ({
+ where,
+ }: {
+ where: { companyId: string; status?: Row["status"] | FindOperator };
+ }) =>
Promise.resolve(
rows
.filter(
(row) =>
where.companyId === COMPANY_ID &&
- (where.status === undefined || row.status === where.status),
+ statusMatches(row.status, where.status),
)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
null,
@@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
expect(result?.id).toBe("pending");
});
+ it("treats a changes-requested request as open, same as pending", async () => {
+ const changesRequested: Row = {
+ id: "changes-requested",
+ status: ChangeRequestStatus.ChangesRequested,
+ createdAt: new Date("2026-01-02T00:00:00.000Z"),
+ };
+
+ const result = await subject([
+ rejected,
+ changesRequested,
+ ]).findLatestOpenByCompanyId(COMPANY_ID);
+
+ expect(result?.id).toBe("changes-requested");
+ });
+
it("returns the latest rejected request when nothing is pending", async () => {
const result = await subject([rejected]).findLatestOpenByCompanyId(
COMPANY_ID,
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 eb44d56cb..8fb81e6f5 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
@@ -1,12 +1,18 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
-import { Repository } from "typeorm";
+import { In, Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from "./entities/company-change-request.entity";
+/** Statuses that mean "still open, awaiting the customer's next edit" — Pending and ChangesRequested behave identically here, they just carry a note or not. */
+const OPEN_FOR_EDIT_STATUSES = [
+ ChangeRequestStatus.Pending,
+ ChangeRequestStatus.ChangesRequested,
+];
+
@Injectable()
export class CompanyChangeRequestRepository extends BaseRepository {
constructor(
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository {
return this.repository.findOne({
- where: { companyId, status: ChangeRequestStatus.Pending },
+ where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
order: { createdAt: "DESC" },
});
}
diff --git a/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts
new file mode 100644
index 000000000..83fa539e3
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts
@@ -0,0 +1,90 @@
+import type { Company } from "./entities/company.entity";
+import type { CompanyRevisionChange } from "./entities/company-revision.entity";
+
+/** Human label per audited company field — anything not listed here is skipped (internal/lock fields like `*FaydaSub`). */
+export const COMPANY_FIELD_LABELS: Record = {
+ name: "Company name",
+ phone: "Phone",
+ email: "Email",
+ address: "Address",
+ country: "Country",
+ tin: "TIN",
+ vatNumber: "VAT number",
+ fanNumber: "FAN number",
+ nationality: "Nationality",
+ website: "Website",
+ licenceNumber: "Licence number",
+ region: "Region",
+ zone: "Zone",
+ woreda: "Woreda",
+ kebele: "Kebele",
+ houseNo: "House No",
+ contactPersonName: "Contact person name",
+ contactPersonPhone: "Contact person phone",
+ contactPersonEmail: "Contact person email",
+ contactPersonPosition: "Contact person position",
+ generalManagerName: "General manager name",
+ generalManagerPhone: "General manager phone",
+ generalManagerEmail: "General manager email",
+ poaName: "PoA name",
+ poaPhone: "PoA phone",
+ poaEmail: "PoA email",
+ poaLocation: "PoA location",
+ poaAddress: "PoA address",
+ documents: "Document",
+};
+
+function displayValue(value: unknown): string | null {
+ if (value === null || value === undefined || value === "") return null;
+ if (typeof value === "boolean") return value ? "Yes" : "No";
+ return String(value);
+}
+
+/**
+ * Compare the company row before a write against the patch about to be
+ * applied (the same shape `mapProfileDtoToCompanyUpdates` returns: scalar
+ * columns plus a merged `attributes` blob). Only fields with a known label
+ * are reported, so identity-lock bookkeeping (`ownerFaydaSub`, etc.) never
+ * shows up as noise.
+ */
+export function diffCompanyUpdate(
+ before: Company,
+ patch: Record,
+): CompanyRevisionChange[] {
+ const changes: CompanyRevisionChange[] = [];
+ const { attributes: attrPatch, ...columnPatch } = patch;
+
+ for (const [field, nextRaw] of Object.entries(columnPatch)) {
+ const label = COMPANY_FIELD_LABELS[field];
+ if (!label) continue;
+ const next = displayValue(nextRaw);
+ const previous = displayValue((before as unknown as Record)[field]);
+ if (next === previous) continue;
+ changes.push({ field, label, from: previous, to: next });
+ }
+
+ if (attrPatch) {
+ const beforeAttrs = before.attributes ?? {};
+ for (const [field, nextRaw] of Object.entries(attrPatch)) {
+ const label = COMPANY_FIELD_LABELS[field];
+ if (!label) continue;
+ const next = displayValue(nextRaw);
+ const previous = displayValue(beforeAttrs[field]);
+ if (next === previous) continue;
+ changes.push({ field, label, from: previous, to: next });
+ }
+ }
+
+ return changes;
+}
+
+/** Short human summary of a change set, e.g. "phone, address changed". */
+export function summarizeCompanyChanges(
+ changes: CompanyRevisionChange[],
+): string {
+ if (changes.length === 0) return "No changes";
+ const labels = changes.map((c) => c.label.toLowerCase());
+ return labels.length <= 3
+ ? `${labels.join(", ")} changed`
+ : `${labels.length} fields changed`;
+}
diff --git a/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts b/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts
new file mode 100644
index 000000000..b04bb35de
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts
@@ -0,0 +1,23 @@
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+import { BaseRepository } from "@edr/api-common";
+import { CompanyRevision } from "./entities/company-revision.entity";
+
+@Injectable()
+export class CompanyRevisionRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(CompanyRevision)
+ repo: Repository,
+ ) {
+ super(repo);
+ }
+
+ /** Revision history for a company, newest first. */
+ async findByCompanyId(companyId: string): Promise {
+ return this.repository.find({
+ where: { companyId },
+ order: { createdAt: "DESC" },
+ });
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts
index 2634e0943..34071e813 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts
@@ -13,9 +13,12 @@ export class CompanyInfoResponseDto {
/**
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
* settings + new-contract/booking creation disabled) and the reapply banner.
+ * `changes_requested` is the soft variant of `rejected`: same edit-and-resubmit
+ * call to action, but the customer's edit appends to this SAME request
+ * instead of starting a fresh one.
*/
review: {
- status: 'pending' | 'rejected';
+ status: 'pending' | 'rejected' | 'changes_requested';
note: string | null;
} | null;
@@ -30,12 +33,13 @@ export class CompanyInfoResponseDto {
const open =
changeRequest &&
(changeRequest.status === ChangeRequestStatus.Pending ||
- changeRequest.status === ChangeRequestStatus.Rejected)
+ changeRequest.status === ChangeRequestStatus.Rejected ||
+ changeRequest.status === ChangeRequestStatus.ChangesRequested)
? changeRequest
: null;
this.review = open
? {
- status: open.status as 'pending' | 'rejected',
+ status: open.status as 'pending' | 'rejected' | 'changes_requested',
note: open.note ?? null,
}
: null;
diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts
new file mode 100644
index 000000000..c93c7387c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts
@@ -0,0 +1,23 @@
+import {
+ CompanyRevision,
+ CompanyRevisionChange,
+} from "../entities/company-revision.entity";
+
+/** One version-history entry, shown on the backoffice customer detail page. */
+export class CompanyRevisionResponseDto {
+ id: string;
+ companyId: string;
+ actorId: string | null;
+ summary: string;
+ changes: CompanyRevisionChange[];
+ createdAt: Date;
+
+ constructor(revision: CompanyRevision) {
+ this.id = revision.id;
+ this.companyId = revision.companyId;
+ this.actorId = revision.actorId ?? null;
+ this.summary = revision.summary;
+ this.changes = revision.changes ?? [];
+ this.createdAt = revision.createdAt;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
index 6072268dc..f0a19dad7 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
@@ -68,10 +68,12 @@ export class ProfileResponseDto {
/**
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
- * settings page; `"rejected"` surfaces the note and prefills the (declined)
- * proposed values from `pendingChanges` so the customer can amend & resubmit.
+ * settings page; `"rejected"`/`"changes_requested"` both surface the note and
+ * prefill the proposed values from `pendingChanges` so the customer can amend
+ * & resubmit — `"changes_requested"` just appends the edit to this same
+ * request instead of starting a fresh one.
*/
- reviewStatus: "pending" | "rejected" | null;
+ reviewStatus: "pending" | "rejected" | "changes_requested" | null;
reviewNote: string | null;
pendingChanges: Record | null;
@@ -127,7 +129,8 @@ export class ProfileResponseDto {
const openReview =
changeRequest &&
(changeRequest.status === ChangeRequestStatus.Pending ||
- changeRequest.status === ChangeRequestStatus.Rejected)
+ changeRequest.status === ChangeRequestStatus.Rejected ||
+ changeRequest.status === ChangeRequestStatus.ChangesRequested)
? changeRequest
: null;
this.reviewStatus =
@@ -135,7 +138,9 @@ export class ProfileResponseDto {
? "pending"
: openReview?.status === ChangeRequestStatus.Rejected
? "rejected"
- : null;
+ : openReview?.status === ChangeRequestStatus.ChangesRequested
+ ? "changes_requested"
+ : null;
this.reviewNote = openReview?.note ?? null;
this.pendingChanges = openReview?.snapshot ?? null;
this.identity = buildCompanyIdentityState(company);
diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
index 60f9f9a34..d705e7323 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts
@@ -97,6 +97,7 @@ export class ResponseCompanyDto {
createdAt: Date;
updatedAt: Date;
+ approvedAt: Date | null;
constructor(company: Company) {
this.id = company.id;
@@ -135,5 +136,6 @@ export class ResponseCompanyDto {
this.identity = buildCompanyIdentityState(company);
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
+ this.approvedAt = company.approvedAt ?? null;
}
}
diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts
index 5ee6739de..2ba39ecad 100644
--- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts
+++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts
@@ -5,14 +5,18 @@ import { Company } from "./company.entity";
/**
* Lifecycle of a customer's proposed profile change. Edits made on the portal
* settings page by an already-approved company are staged here (not written to
- * the live Company row) until a backoffice reviewer approves — at which point
- * the snapshot is applied — or rejects with a note, after which the customer can
- * amend and resubmit.
+ * the live Company row) until a backoffice reviewer resolves it:
+ * - Approved — the snapshot is applied to the live Company row.
+ * - Rejected — terminal for this row; the customer's next edit starts a fresh one.
+ * - ChangesRequested — soft: the row stays open with the reviewer's note attached,
+ * so the customer's next edit is appended (merged) into this SAME row instead
+ * of starting a new cycle.
*/
export enum ChangeRequestStatus {
Pending = "pending",
Approved = "approved",
Rejected = "rejected",
+ ChangesRequested = "changes_requested",
}
/**
diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts
new file mode 100644
index 000000000..222a8364f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts
@@ -0,0 +1,46 @@
+import { BaseEntity } from "@edr/api-common";
+import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
+import { Company } from "./company.entity";
+
+/**
+ * One recorded field/document change, as shown on the customer's version
+ * history. A document change carries `fromFileId`/`toFileId` alongside the
+ * display names, so the reviewer can open the previous and current file —
+ * not just read that "a document changed."
+ */
+export interface CompanyRevisionChange {
+ field: string;
+ label: string;
+ from: string | null;
+ to: string | null;
+ fromFileId?: string | null;
+ toFileId?: string | null;
+}
+
+/**
+ * Append-only audit of edits made to a company record BEFORE it reaches
+ * `Active` (the onboarding phase), where {@link CompaniesService.updateProfile}
+ * and {@link CompaniesService.uploadCompanyDocuments} write straight to the
+ * live row with no approval gate — and, until this entity, no trace at all.
+ * Post-approval edits already get history via `CompanyChangeRequest`; this
+ * covers the gap before that gate exists.
+ */
+@Entity({ schema: "freight", name: "company_revisions" })
+@Index(["companyId"])
+export class CompanyRevision extends BaseEntity {
+ @Column({ name: "company_id", type: "uuid" })
+ companyId!: string;
+
+ @ManyToOne(() => Company, { onDelete: "CASCADE" })
+ @JoinColumn({ name: "company_id" })
+ company?: Company;
+
+ @Column({ name: "actor_id", type: "uuid", nullable: true })
+ actorId?: string | null;
+
+ @Column({ name: "summary", type: "varchar", length: 255 })
+ summary!: string;
+
+ @Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` })
+ changes!: CompanyRevisionChange[];
+}
diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
index 5fe3a3f67..f254e0121 100644
--- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
+++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
@@ -61,6 +61,10 @@ export class Company extends BaseEntity {
})
status!: CompanyStatus;
+ /** Set when the company is first promoted Pending → Active. Null for companies approved before this column existed. */
+ @Column({ name: "approved_at", type: "timestamptz", nullable: true })
+ approvedAt?: Date | null;
+
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
tin!: string;
diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts
index 6978ad446..fc58a731c 100644
--- a/apps/edr-freight-api/src/modules/files/files.controller.ts
+++ b/apps/edr-freight-api/src/modules/files/files.controller.ts
@@ -51,7 +51,11 @@ export class FilesController {
@Query("download") download: string | undefined,
@Res() res: Response,
) {
- const record = await this.filesService.findById(fileId);
+ // Includes soft-deleted records: a superseded document (replaced via a
+ // single-file document slot, or resolved as part of a license/PoA swap)
+ // is only reachable by UUID through the change-request/version-history
+ // diff, where reviewers need to open the "previous" file to compare it.
+ const record = await this.filesService.findByIdIncludingDeleted(fileId);
// Chat attachments are cross-tenant sensitive and this route has no
// ownership check, so a leaked/guessed UUID would hand one company's file to
@@ -63,7 +67,9 @@ export class FilesController {
);
}
- const { stream } = await this.filesService.streamById(fileId);
+ const { stream } = await this.filesService.streamById(fileId, {
+ includeDeleted: true,
+ });
const forceDownload = download === "1" || download === "true";
const disposition = forceDownload ? "attachment" : "inline";
diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts
index bc446b508..e76240704 100644
--- a/apps/edr-freight-api/src/modules/files/files.service.ts
+++ b/apps/edr-freight-api/src/modules/files/files.service.ts
@@ -245,6 +245,23 @@ export class FilesService {
return record;
}
+ /**
+ * Same as {@link findById}, but also matches a soft-deleted record — a
+ * superseded document (replaced via a single-file slot, or a resolved
+ * license/PoA swap) is exactly this: gone from every live listing, but its
+ * id is still handed to reviewers in the change-request/version-history
+ * diff so they can open the "previous" file for comparison. Only the
+ * preview/download route should use this; every other caller wants the
+ * default (soft-deleted = not found).
+ */
+ async findByIdIncludingDeleted(id: string): Promise {
+ const record = await this.filesRepository.findById(id, {
+ withDeleted: true,
+ });
+ if (!record) throw new NotFoundException(`File ${id} not found`);
+ return record;
+ }
+
/**
* Record a reviewer verdict on one document. `change_requested` keeps the note
* (the customer sees it verbatim); any other verdict clears it, so a stale
@@ -360,8 +377,11 @@ export class FilesService {
async streamById(
id: string,
+ opts: { includeDeleted?: boolean } = {},
): Promise<{ stream: Readable; record: FileRecord }> {
- const record = await this.findById(id);
+ const record = opts.includeDeleted
+ ? await this.findByIdIncludingDeleted(id)
+ : await this.findById(id);
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);
return { stream, record };
diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts
index 6f7882c76..6d9cb82a2 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts
@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { CargoUnitOfMeasure } from '@edr/types';
-import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
+import { IsArray, IsBoolean, IsEnum, IsInt, IsObject, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateCargoTypeDto {
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
@@ -32,6 +32,18 @@ export class CreateCargoTypeDto {
@IsUUID('4', { each: true })
wagonTypeIds?: string[];
+ @ApiPropertyOptional({
+ description:
+ 'PER_ITEM cargo only: items that physically fit one wagon, keyed by wagon-type id ' +
+ '(e.g. { "": 4, "": 6 }). Required for every wagonTypeId when ' +
+ 'unitOfMeasure is PER_ITEM.',
+ type: 'object',
+ additionalProperties: { type: 'integer', minimum: 1 },
+ })
+ @IsOptional()
+ @IsObject()
+ itemsPerWagonMap?: Record | null;
+
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts
index 037bf08be..b096613bf 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts
@@ -50,6 +50,16 @@ export class CargoType extends BaseEntity {
})
wagonTypes?: WagonType[];
+ /**
+ * PER_ITEM (break-bulk) only: how many whole items physically fit each
+ * allowed wagon type, keyed by wagon-type id (e.g. cars → { NW5: 4, NW7: 6 }).
+ * Allocation loads min(this fit, floor(capacityTons / perItemTons)) per
+ * wagon — floor space and rated tonnage bind independently. Keys are kept a
+ * subset of the wagonTypes join rows by the cargo-types service.
+ */
+ @Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true })
+ itemsPerWagonMap?: Record | null;
+
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;
diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts
index b3ee54d20..1570e0d23 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts
@@ -28,6 +28,13 @@ export interface IRatesRepository {
create(data: Partial): Promise;
update(id: string, data: Partial): Promise;
softDelete(id: string): Promise;
+ /**
+ * Flip a commodity's PER_TON↔PER_ITEM rates to match its unit of measure.
+ * Both units bill the same stored quantity — only the name differs — so a
+ * uom change must rename the units or bookings keep quoting "per ton" for
+ * counted cargo. Returns the number of rates flipped.
+ */
+ syncBulkQuantityUnit(cargoTypeId: string, unitOfMeasure: 'PER_TON' | 'PER_ITEM'): Promise;
}
export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY');
diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts
index abd10cd98..43c716a3b 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts
@@ -177,4 +177,16 @@ export class RatesRepository implements IRatesRepository {
async softDelete(id: string): Promise {
await this.repo.softDelete(id);
}
+
+ async syncBulkQuantityUnit(
+ cargoTypeId: string,
+ unitOfMeasure: 'PER_TON' | 'PER_ITEM',
+ ): Promise {
+ const from = unitOfMeasure === 'PER_ITEM' ? 'PER_TON' : 'PER_ITEM';
+ const result = await this.repo.update(
+ { cargoTypeId, rateUnit: from },
+ { rateUnit: unitOfMeasure },
+ );
+ return result.affected ?? 0;
+ }
}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts
index 8a72cf1f8..34287a023 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts
@@ -1,5 +1,11 @@
-import { PaginatedResponse } from '@edr/types';
-import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
+import { CargoUnitOfMeasure, PaginatedResponse } from '@edr/types';
+import {
+ BadRequestException,
+ ConflictException,
+ Inject,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -11,6 +17,7 @@ import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../interfaces/cargo-types.repository.interface';
+import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
@@ -18,6 +25,8 @@ export class CargoTypesService {
constructor(
@Inject(CARGO_TYPES_REPOSITORY)
private readonly repository: ICargoTypesRepository,
+ @Inject(RATES_REPOSITORY)
+ private readonly ratesRepository: IRatesRepository,
private readonly displayOrder: DisplayOrderService,
) {}
@@ -38,6 +47,34 @@ export class CargoTypesService {
return this.repository.findByCode(code);
}
+ /**
+ * PER_ITEM (break-bulk) cargo must carry a whole-items-fit for EVERY allowed
+ * wagon type — allocation caps each wagon at min(fit, tonnage) and a missing
+ * fit would silently fall back to tonnage-only loading. Returns the map
+ * trimmed to the allowed ids (stale keys from a removed wagon type drop out);
+ * null when the cargo is not PER_ITEM or has no wagon types.
+ */
+ private resolveItemsPerWagonMap(input: {
+ unitOfMeasure?: CargoUnitOfMeasure | null;
+ wagonTypeIds: string[];
+ itemsPerWagonMap?: Record | null;
+ }): Record | null {
+ if (input.unitOfMeasure !== CargoUnitOfMeasure.PerItem || !input.wagonTypeIds.length) {
+ return null;
+ }
+ const map: Record = {};
+ for (const wagonTypeId of input.wagonTypeIds) {
+ const fit = Number(input.itemsPerWagonMap?.[wagonTypeId]);
+ if (!Number.isInteger(fit) || fit < 1) {
+ throw new BadRequestException(
+ `itemsPerWagonMap must define how many items fit wagon type ${wagonTypeId} (integer >= 1) for PER_ITEM cargo`,
+ );
+ }
+ map[wagonTypeId] = fit;
+ }
+ return map;
+ }
+
/** Create a new cargo type. */
async create(dto: CreateCargoTypeDto): Promise {
const code = generateCode(dto.cargoTypeName);
@@ -62,26 +99,58 @@ export class CargoTypesService {
unitOfMeasure: dto.unitOfMeasure ?? null,
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType),
+ itemsPerWagonMap: this.resolveItemsPerWagonMap({
+ unitOfMeasure: dto.unitOfMeasure ?? null,
+ wagonTypeIds: dto.wagonTypeIds ?? [],
+ itemsPerWagonMap: dto.itemsPerWagonMap,
+ }),
displayOrder,
});
}
/** Update an existing cargo type. */
async update(id: string, dto: UpdateCargoTypeDto): Promise {
- await this.findById(id);
+ const existing = await this.findById(id);
if (dto.parentGroupId) {
if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent');
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
- const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto;
+ const { wagonTypeIds, itemsPerWagonMap, insertAfterId: _insertAfterId, ...columns } = dto;
+ // Re-validate the fit map whenever anything it depends on moves — a partial
+ // update merges with the stored values so e.g. adding a wagon type without
+ // its fit still 400s. Untouched fields leave the stored map alone.
+ const touchesItemsFit =
+ wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined;
const updated = await this.repository.update(id, {
...columns,
...(wagonTypeIds
? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) }
: {}),
+ ...(touchesItemsFit
+ ? {
+ itemsPerWagonMap: this.resolveItemsPerWagonMap({
+ unitOfMeasure:
+ dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure,
+ wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id),
+ itemsPerWagonMap:
+ itemsPerWagonMap !== undefined ? itemsPerWagonMap : existing.itemsPerWagonMap,
+ }),
+ }
+ : {}),
});
if (!updated) throw new NotFoundException(`Cargo type ${id} not found`);
+ // A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the
+ // same stored quantity) — sync them or bookings keep quoting "per ton" for
+ // counted cargo.
+ if (
+ dto.unitOfMeasure !== undefined &&
+ dto.unitOfMeasure !== existing.unitOfMeasure &&
+ (dto.unitOfMeasure === CargoUnitOfMeasure.PerTon ||
+ dto.unitOfMeasure === CargoUnitOfMeasure.PerItem)
+ ) {
+ await this.ratesRepository.syncBulkQuantityUnit(id, dto.unitOfMeasure);
+ }
return updated;
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
index 5ce75abd2..5203c3a14 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
@@ -69,6 +69,7 @@ import {
LocomotiveLimits,
WagonTypeDimensions,
bookingCargoTons,
+ bulkItemsFitFor,
bulkItemWagonsRequired,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
@@ -4060,7 +4061,13 @@ export class BookingBatchService implements OnModuleInit {
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
- const byItems = bulkItemWagonsRequired(booking, capacityTons);
+ // `dimsFor` resolved dims from the first allowed wagon type, so charge that
+ // same type's configured items-fit alongside its capacity.
+ const byItems = bulkItemWagonsRequired(
+ booking,
+ capacityTons,
+ bulkItemsFitFor(booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id),
+ );
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems);
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
index 631078ef6..bea01af0a 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
@@ -1,4 +1,4 @@
-import { bookingCargoTons, bulkItemWagonsRequired } from './train-capacity.util';
+import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util';
import type { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
@@ -53,8 +53,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
if (booking.freightType === 'BULK') {
const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1;
// Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm`
- // holds the item count there, not tons.
- const byItems = bulkItemWagonsRequired(booking, capacity);
+ // holds the item count there, not tons. No wagon type is fixed yet, so use
+ // the best count across the cargo's allowed types (per-type items-fit
+ // respected); falls back to `capacity` when the relation isn't loaded.
+ const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity);
if (byItems > 0) return byItems;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
return Math.max(1, Math.ceil(weight / capacity));
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts
index fd5fe05ed..ead3a4e8b 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts
@@ -2,6 +2,7 @@ import {
bookingCargoTons,
bookingGrossWeightTons,
bookingTrainLengthMeters,
+ bulkItemWagonsForAllowedTypes,
bulkItemWagonsRequired,
consistUsage,
consistViolations,
@@ -76,6 +77,62 @@ describe('train-capacity.util', () => {
expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0);
expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0);
});
+
+ describe('configured items-fit (floor space vs tonnage)', () => {
+ it('weight binds: 50 cars × 20T on a 70T wagon that fits 4 → 3 per wagon → 17', () => {
+ // floor(70/20) = 3 by tonnage < 4 by floor space.
+ expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 4)).toBe(17);
+ });
+
+ it('floor space binds: 50 cars × 10T on a 70T wagon that fits 4 → 4 per wagon → 13', () => {
+ // floor(70/10) = 7 by tonnage, but only 4 fit physically.
+ expect(bulkItemWagonsRequired(breakBulk(50, 500), 70, 4)).toBe(13);
+ });
+
+ it('ignores an absent/invalid fit (legacy cargo types): tonnage-only', () => {
+ expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, null)).toBe(17);
+ expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 0)).toBe(17);
+ // floor(70/10) = 7 per wagon → ceil(50/7) = 8 wagons.
+ expect(bulkItemWagonsRequired(breakBulk(50, 500), 70)).toBe(8);
+ });
+ });
+ });
+
+ describe('bulkItemWagonsForAllowedTypes', () => {
+ const breakBulk = (quantity: number, weightTons: number) => ({
+ freightType: 'BULK',
+ cargoTotalWeightVgm: quantity,
+ bulkTotalWeightTons: weightTons,
+ });
+
+ it('picks the fewest-wagon allowed type, each capped by its own fit', () => {
+ const cargoType = {
+ wagonTypes: [
+ { id: 'nw5', capacityTons: 70 },
+ { id: 'nw7', capacityTons: 80 },
+ ],
+ itemsPerWagonMap: { nw5: 4, nw7: 6 },
+ };
+ // 50 cars × 20T: NW5 → min(4, floor(70/20)=3) = 3/wagon = 17 wagons;
+ // NW7 → min(6, floor(80/20)=4) = 4/wagon = 13 wagons. Best = 13.
+ expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 70)).toBe(13);
+ });
+
+ it('equals the old max-capacity estimate when no fits are configured', () => {
+ const cargoType = {
+ wagonTypes: [
+ { id: 'a', capacityTons: 50 },
+ { id: 'b', capacityTons: 70 },
+ ],
+ };
+ // Tonnage-only best = biggest wagon: floor(70/20) = 3/wagon → 17.
+ expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 1)).toBe(17);
+ });
+
+ it('falls back to the given capacity when the relation is missing', () => {
+ expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), null, 70)).toBe(17);
+ expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), { wagonTypes: [] }, 70)).toBe(17);
+ });
});
describe('bookingCargoTons (break-bulk weight preference)', () => {
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts
index 061dc0dbd..867da564b 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts
@@ -120,6 +120,12 @@ export function bookingCargoTons(booking: {
* 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons.
* Returns 0 when the booking is not item-counted (PER_TON bulk, containers) —
* callers then fall back to the pooled-tonnage math.
+ *
+ * `itemsFit` is the wagon type's PHYSICAL item capacity (floor space — from
+ * cargoType.itemsPerWagonMap). It binds independently of tonnage: a 70T wagon
+ * that fits 4 cars takes 3 cars of 20T (weight binds) but only 4 cars of 10T
+ * (floor binds, 30T of rated capacity ride empty). Absent/invalid fit falls
+ * back to tonnage-only (legacy cargo types without a configured fit).
*/
export function bulkItemWagonsRequired(
booking: {
@@ -128,6 +134,7 @@ export function bulkItemWagonsRequired(
bulkTotalWeightTons?: number | string | null;
},
capacityTons: number,
+ itemsFit?: number | null,
): number {
if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0;
const quantity = num(booking.cargoTotalWeightVgm);
@@ -136,10 +143,56 @@ export function bulkItemWagonsRequired(
const perItemTons = totalWeightTons / quantity;
// ponytail: an item heavier than a whole wagon still charges 1 wagon per
// item; reject such bookings at creation time if the case turns real.
- const itemsPerWagon = Math.max(1, Math.floor(capacityTons / perItemTons));
+ const byTonnage = Math.max(1, Math.floor(capacityTons / perItemTons));
+ const byFloor = num(itemsFit) >= 1 ? Math.floor(num(itemsFit)) : Infinity;
+ const itemsPerWagon = Math.min(byTonnage, byFloor);
return Math.max(1, Math.ceil(quantity / itemsPerWagon));
}
+type ItemFitCargoType = {
+ wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null;
+ itemsPerWagonMap?: Record | null;
+} | null;
+
+/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */
+export function bulkItemsFitFor(
+ cargoType: ItemFitCargoType | undefined,
+ wagonTypeId: string | null | undefined,
+): number | null {
+ const fit = wagonTypeId ? Number(cargoType?.itemsPerWagonMap?.[wagonTypeId]) : NaN;
+ return Number.isFinite(fit) && fit >= 1 ? fit : null;
+}
+
+/**
+ * Break-bulk wagon count when no single wagon type is fixed yet: the best
+ * (fewest-wagon) count across the cargo type's allowed wagon types, each
+ * respecting its own items-fit. With no fits configured this equals the old
+ * max-capacity estimate; with no allowed types it degrades to
+ * `fallbackCapacityTons` tonnage-only.
+ */
+export function bulkItemWagonsForAllowedTypes(
+ booking: {
+ freightType?: string | null;
+ cargoTotalWeightVgm?: number | string | null;
+ bulkTotalWeightTons?: number | string | null;
+ },
+ cargoType: ItemFitCargoType | undefined,
+ fallbackCapacityTons: number,
+): number {
+ const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0);
+ if (!allowed.length) return bulkItemWagonsRequired(booking, fallbackCapacityTons);
+ let best = 0;
+ for (const wagonType of allowed) {
+ const wagons = bulkItemWagonsRequired(
+ booking,
+ num(wagonType.capacityTons),
+ bulkItemsFitFor(cargoType, wagonType.id),
+ );
+ if (wagons > 0 && (best === 0 || wagons < best)) best = wagons;
+ }
+ return best;
+}
+
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
export function grossWagonWeightTons(slot: Pick): number {
return num(slot.tareWeightTons) + num(slot.cargoTons);
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts
index 619ebbde6..ef3089925 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts
@@ -3,7 +3,12 @@ import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
-import { bookingCargoTons, bulkItemWagonsRequired, consistViolations } from './train-capacity.util';
+import {
+ bookingCargoTons,
+ bulkItemsFitFor,
+ bulkItemWagonsRequired,
+ consistViolations,
+} from './train-capacity.util';
export const MAX_TRAIN_WEIGHT_TONS = 3500;
export const MAX_TRAIN_LENGTH_METERS = 760;
@@ -175,7 +180,11 @@ export function buildBulkWagonPlan(
// Break-bulk (PER_ITEM) bookings size by indivisible items per booking —
// their tonnage must NOT pool with PER_TON cargo (an item can't split
// across wagons the way loose tonnage can).
- const itemSlotsByBooking = bookings.map((b) => bulkItemWagonsRequired(b, capacity));
+ const itemSlotsByBooking = bookings.map((b) =>
+ // The plan fixed THIS wagon type, so its configured items-fit binds — not
+ // the best fit across the cargo's allowed types.
+ bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)),
+ );
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
const totalWeight = roundTons(
bookings.reduce(
diff --git a/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts
new file mode 100644
index 000000000..9d51107fe
--- /dev/null
+++ b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts
@@ -0,0 +1,63 @@
+import 'reflect-metadata';
+import { config } from 'dotenv';
+import { resolve } from 'path';
+import { NestFactory } from '@nestjs/core';
+import { DataSource } from 'typeorm';
+
+config({ path: resolve(__dirname, '../../.env') });
+process.env.TYPEORM_LOGGING = 'false';
+
+import { AppModule } from '../app.module';
+import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service';
+
+/**
+ * One-off backfill for bookings caught by the autoArriveAtFinalYard bug
+ * (fixed in booking-journey.service.ts): the bulk final-yard arrival used to
+ * flip booking status to ARRIVED/COMPLETED without ever emitting
+ * booking.unloadedAtYard, so WarehouseInventoryService never created their
+ * warehouse_inventory row. Reuses the same idempotent listener the live
+ * event now calls, so it's safe to re-run.
+ */
+async function main() {
+ const app = await NestFactory.createApplicationContext(AppModule, {
+ logger: ['error', 'warn'],
+ });
+
+ try {
+ const dataSource = app.get(DataSource);
+ const inventory = app.get(WarehouseInventoryService);
+
+ const bookings: { id: string; tradeDirection: string }[] = await dataSource.query(
+ `SELECT b.id, b.trade_direction AS "tradeDirection"
+ FROM freight.bookings b
+ WHERE b.deleted_at IS NULL
+ AND b.trade_direction IN ('IMPORT', 'DOMESTIC')
+ AND b.status IN ('ARRIVED', 'COMPLETED')
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.warehouse_inventory wi
+ WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL
+ )`,
+ );
+
+ if (bookings.length === 0) {
+ console.log('No bookings missing their unload inventory row.');
+ return;
+ }
+
+ console.log(`Backfilling ${bookings.length} booking(s)...`);
+ for (const booking of bookings) {
+ await inventory.handleBookingUnloadedAtYard({
+ bookingId: booking.id,
+ tradeDirection: booking.tradeDirection,
+ });
+ console.log(` - ${booking.id} (${booking.tradeDirection})`);
+ }
+ } finally {
+ await app.close();
+ }
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/apps/edr-freight-api/src/scripts/migrate.ts b/apps/edr-freight-api/src/scripts/migrate.ts
new file mode 100644
index 000000000..7f331e1d3
--- /dev/null
+++ b/apps/edr-freight-api/src/scripts/migrate.ts
@@ -0,0 +1,24 @@
+import "dotenv/config";
+import { AppDataSource } from "../data-source";
+import { ensurePostgresSchemas } from "../config/ensure-postgres-schemas";
+import { buildDataSourceOptions } from "../config/database.config";
+
+async function main(): Promise {
+ await ensurePostgresSchemas(buildDataSourceOptions());
+
+ await AppDataSource.initialize();
+ try {
+ const applied = await AppDataSource.runMigrations();
+ for (const migration of applied) {
+ console.log(`applied: ${migration.name}`);
+ }
+ if (applied.length === 0) console.log("no pending migrations");
+ } finally {
+ await AppDataSource.destroy();
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx
index 8f213e4ec..852a89cc1 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx
@@ -29,7 +29,6 @@ import {
Upload,
UserCheck,
} from "lucide-react";
-import dayjs from "dayjs";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
@@ -299,7 +298,14 @@ function DocumentRow({
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
{doc.uploadedByName ?? "Global Logistics"} ·{" "}
- {dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")}
+ {new Date(doc.uploadedAt).toLocaleString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ })}
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
index 253d8aea5..196161f92 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
@@ -2,7 +2,6 @@ import {
Alert,
Anchor,
Badge,
- Box,
Button,
Card,
Group,
@@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api";
-import type { Company, CompanyChangeRequest } from "@/types/customer";
+import type { Company } from "@/types/customer";
import { formatDate, humanize } from "./format";
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
-const FIELD_LABELS: Record = {
+export const FIELD_LABELS: Record = {
companyName: "Company name",
companyEmail: "Company email",
companyPhone: "Company phone",
@@ -69,7 +68,7 @@ const FIELD_LABELS: Record = {
};
/** Best-effort current value on the live company for a proposed field key. */
-function currentValue(company: Company, key: string): string {
+export function currentValue(company: Company, key: string): string {
const c = company as unknown as Record;
const attrs = (company.attributes ?? {}) as Record;
const map: Record = {
@@ -160,7 +159,7 @@ function FaydaIdentityDiff({
);
}
-function DiffRow({
+export function DiffRow({
label,
from,
to,
@@ -201,8 +200,9 @@ function DiffRow({
/**
* Backoffice review surface for a customer's staged profile edits. Shows the
- * pending change request as a proposed-vs-current diff with Approve / Reject
- * (with note) actions, plus a short history of past decisions.
+ * pending change request as a proposed-vs-current diff with Approve / Reject /
+ * Request changes actions. Past decisions live in the History tab's unified
+ * timeline (see {@link CompanyTimeline}), not here.
*/
export function ChangeRequestReview({ company }: { company: Company }) {
const { user } = useAuth();
@@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) {
const reject = useMutation(
api.customers.rejectChangeRequest.mutationOptions(),
);
+ const requestChanges = useMutation(
+ api.customers.requestChangeRequestChanges.mutationOptions(),
+ );
const { view, viewer } = useFileViewer();
- const [rejectId, setRejectId] = useState(null);
+ const [actionTarget, setActionTarget] = useState<{
+ id: string;
+ kind: "reject" | "request-changes";
+ } | null>(null);
const [note, setNote] = useState("");
const requests = query.data ?? [];
const pending = requests.find((r) => r.status === "pending");
- const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
- if (!pending && history.length === 0) return null;
+ if (!pending) return null;
const proposedKeys = pending
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
@@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
const licenseChanges = pending?.licenseChanges ?? [];
const documentChanges = pending?.documentChanges ?? [];
- const confirmReject = () => {
- if (!rejectId) return;
- reject.mutate(
- { id: rejectId, note: note.trim() },
+ const confirmAction = () => {
+ if (!actionTarget) return;
+ const mutation = actionTarget.kind === "reject" ? reject : requestChanges;
+ mutation.mutate(
+ { id: actionTarget.id, note: note.trim() },
{
onSuccess: () => {
- setRejectId(null);
+ setActionTarget(null);
setNote("");
},
},
@@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) {
+ {pending.note && (
+ }
+ >
+ Changes were requested on an earlier round of this same
+ submission: {pending.note} — check whether
+ this resubmission actually addresses it before approving.
+
+ )}
+
{proposedKeys.length > 0 ? (
{proposedKeys.map((key) => (
@@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) {
variant="light"
color="red"
onClick={() => {
- setRejectId(pending.id);
+ setActionTarget({ id: pending.id, kind: "reject" });
setNote("");
}}
>
Reject
+ {
+ setActionTarget({ id: pending.id, kind: "request-changes" });
+ setNote("");
+ }}
+ >
+ Request changes
+
)}
- {history.length > 0 && (
-
-
-
- Review history
-
- {history.map((r: CompanyChangeRequest) => (
-
-
- {r.status}
-
-
-
- {formatDate(r.reviewedAt ?? r.updatedAt)}
-
- {r.note && (
-
- Note: {r.note}
-
- )}
-
-
- ))}
-
-
- )}
-
setRejectId(null)}
- title="Reject changes"
+ opened={actionTarget !== null}
+ onClose={() => setActionTarget(null)}
+ title={
+ actionTarget?.kind === "reject" ? "Reject changes" : "Request changes"
+ }
centered
radius="lg"
>
- }>
- The customer will see this note and can amend and resubmit.
+ }
+ >
+ {actionTarget?.kind === "reject"
+ ? "The customer will see this note and can amend and resubmit."
+ : "The customer will see this note and can keep editing this same request — no need to start over."}
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx
new file mode 100644
index 000000000..cd421d9bd
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx
@@ -0,0 +1,299 @@
+import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
+import { useQuery } from "@tanstack/react-query";
+import { FilePlus2, FileX2, History } from "lucide-react";
+import { useFileViewer } from "@edr/ui-common";
+
+import { fetchViewableFile } from "@/services/files.service";
+import { api } from "@/services/api";
+import type {
+ Company,
+ CompanyChangeRequest,
+ CompanyRevision,
+ CompanyRevisionChange,
+ DocumentChangeIntent,
+ LicenseChangeIntent,
+} from "@/types/customer";
+import { DiffRow, FIELD_LABELS, currentValue } from "./ChangeRequestReview";
+import { formatDate, humanize } from "./format";
+
+interface DocDiff {
+ key: string;
+ label: string;
+ fromFile: { id: string; name: string } | null;
+ toFile: { id: string; name: string } | null;
+}
+
+interface FieldDiff {
+ key: string;
+ label: string;
+ from: string;
+ to: string;
+}
+
+interface TimelineEntry {
+ id: string;
+ kind: "approved" | "rejected" | "changes_requested" | "revision";
+ at: string;
+ note?: string | null;
+ summary?: string;
+ fieldDiffs: FieldDiff[];
+ docDiffs: DocDiff[];
+}
+
+const KIND_BADGE: Record = {
+ approved: { label: "Approved", color: "edr-green" },
+ rejected: { label: "Rejected", color: "red" },
+ changes_requested: { label: "Changes requested", color: "yellow" },
+ revision: { label: "Recorded", color: "blue" },
+};
+
+/**
+ * Pair adjacent remove-then-add intents into one before/after doc diff — a
+ * "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together
+ * (see `replaceProfileLicenseFile` and friends), and later merges only ever
+ * append after that pair, so adjacency survives. A remove or add with no
+ * adjacent partner stands alone.
+ */
+function pairIntents(
+ intents: (LicenseChangeIntent | DocumentChangeIntent)[],
+ labelFor: (intent: LicenseChangeIntent | DocumentChangeIntent) => string,
+): DocDiff[] {
+ const diffs: DocDiff[] = [];
+ let i = 0;
+ while (i < intents.length) {
+ const current = intents[i];
+ const next = intents[i + 1];
+ if (current.op === "remove" && next?.op === "add") {
+ diffs.push({
+ key: `${current.fileId}-${next.fileId}`,
+ label: labelFor(next),
+ fromFile: { id: current.fileId, name: current.fileName ?? "Document" },
+ toFile: { id: next.fileId, name: next.fileName ?? "Document" },
+ });
+ i += 2;
+ continue;
+ }
+ diffs.push({
+ key: `${current.fileId}-${i}`,
+ label: labelFor(current),
+ fromFile:
+ current.op === "remove"
+ ? { id: current.fileId, name: current.fileName ?? "Document" }
+ : null,
+ toFile:
+ current.op === "add"
+ ? { id: current.fileId, name: current.fileName ?? "Document" }
+ : null,
+ });
+ i += 1;
+ }
+ return diffs;
+}
+
+/**
+ * Historical field diffs on a change request only ever recorded the proposed
+ * ("to") value — there is no stored "before" snapshot — so `from` reads the
+ * CURRENT company value. That's exact for the most recent entry; for an older
+ * one it can drift if the field changed again since. A real limitation of the
+ * data model, not something this view can reconstruct.
+ */
+function fromChangeRequest(
+ r: CompanyChangeRequest,
+ company: Company,
+): TimelineEntry {
+ const proposedKeys = Object.keys(r.snapshot ?? {}).filter(
+ (k) => k !== "faydaIdentity",
+ );
+ const fieldDiffs: FieldDiff[] = proposedKeys.map((key) => ({
+ key,
+ label: FIELD_LABELS[key] ?? humanize(key),
+ from: currentValue(company, key),
+ to:
+ r.snapshot[key] === null || r.snapshot[key] === undefined || r.snapshot[key] === ""
+ ? "—"
+ : String(r.snapshot[key]),
+ }));
+
+ const docDiffs: DocDiff[] = [
+ ...pairIntents(r.licenseChanges, () => "Business license"),
+ ...pairIntents(r.documentChanges, (c) =>
+ humanize((c as DocumentChangeIntent).code),
+ ),
+ ...r.documentFileIds.map((fileId, i) => ({
+ key: fileId,
+ label: "Document",
+ fromFile: null,
+ toFile: { id: fileId, name: `Document ${i + 1}` },
+ })),
+ ];
+
+ return {
+ id: r.id,
+ kind: r.status as TimelineEntry["kind"],
+ at: r.reviewedAt ?? r.updatedAt,
+ note: r.note,
+ fieldDiffs,
+ docDiffs,
+ };
+}
+
+function fromRevision(rev: CompanyRevision): TimelineEntry {
+ const isDocChange = (c: CompanyRevisionChange) => c.field.startsWith("document:");
+ const fieldDiffs: FieldDiff[] = rev.changes
+ .filter((c) => !isDocChange(c))
+ .map((c) => ({ key: c.field, label: c.label, from: c.from ?? "—", to: c.to ?? "—" }));
+ const docDiffs: DocDiff[] = rev.changes
+ .filter(isDocChange)
+ .map((c) => ({
+ key: c.field,
+ label: c.label,
+ fromFile: c.fromFileId ? { id: c.fromFileId, name: c.from ?? "Document" } : null,
+ toFile: c.toFileId ? { id: c.toFileId, name: c.to ?? "Document" } : null,
+ }));
+
+ return {
+ id: rev.id,
+ kind: "revision",
+ at: rev.createdAt,
+ summary: rev.summary,
+ fieldDiffs,
+ docDiffs,
+ };
+}
+
+/**
+ * One combined, chronological timeline of everything that's happened to a
+ * company's record: onboarding-phase edits (no approval gate, from
+ * `CompanyRevision`) and post-approval settings changes (reviewed via
+ * `CompanyChangeRequest`) used to live in two separate, differently-shaped
+ * lists — merged here into one sorted feed so "what changed and when" has a
+ * single answer instead of two places to check.
+ */
+export function CompanyTimeline({ company }: { company: Company }) {
+ const { view, viewer } = useFileViewer();
+ const changeRequestsQuery = useQuery(
+ api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
+ );
+ const revisionsQuery = useQuery(
+ api.customers.revisions.queryOptions({ input: { id: company.id } }),
+ );
+
+ const entries: TimelineEntry[] = [
+ ...(changeRequestsQuery.data ?? [])
+ .filter((r) => r.status !== "pending")
+ .map((r) => fromChangeRequest(r, company)),
+ ...(revisionsQuery.data ?? []).map(fromRevision),
+ ].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
+
+ const openFile = (file: { id: string; name: string }) =>
+ void fetchViewableFile(file.id, file.name).then(view);
+
+ if (entries.length === 0) {
+ return (
+
+
+
+
+ No changes recorded yet.
+
+
+
+ );
+ }
+
+ return (
+
+ {entries.map((entry) => {
+ const badge = KIND_BADGE[entry.kind];
+ return (
+
+
+
+
+
+ {badge.label}
+
+ {entry.summary && (
+
+ {entry.summary}
+
+ )}
+
+
+ {formatDate(entry.at)}
+
+
+
+ {entry.note && (
+
+
+ Note: {entry.note}
+
+
+ )}
+
+ {entry.fieldDiffs.length > 0 && (
+
+ {entry.fieldDiffs.map((f) => (
+
+ ))}
+
+ )}
+
+ {entry.docDiffs.length > 0 && (
+
+ {entry.docDiffs.map((d) => (
+
+
+ {d.label}
+
+ {d.fromFile && (
+
+
+ openFile(d.fromFile!)}
+ >
+ {d.fromFile.name}
+
+
+ )}
+ {d.fromFile && d.toFile && (
+
+ →
+
+ )}
+ {d.toFile && (
+
+
+ openFile(d.toFile!)}
+ >
+ {d.toFile.name}
+
+
+ )}
+
+ ))}
+
+ )}
+
+ {entry.fieldDiffs.length === 0 && entry.docDiffs.length === 0 && (
+
+ No details recorded for this entry.
+
+ )}
+
+
+ );
+ })}
+ {viewer}
+
+ );
+}
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 dd35c3c48..1ccd29f41 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
@@ -13,6 +13,7 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
+export { CompanyTimeline } from "./CompanyTimeline";
export {
RequestDocumentChangeModal,
type RequestDocumentChangeModalProps,
diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
index c57a607d5..6e95a27fc 100644
--- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -42,6 +42,8 @@ export const QUERY_KEYS = {
["customers", "detail", id, "reset-target"] as const,
changeRequests: (id: string) =>
["customers", "detail", id, "change-requests"] as const,
+ revisions: (id: string) =>
+ ["customers", "detail", id, "revisions"] as const,
},
INVOICES: {
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 142e6c3e7..f7f1f9cbc 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -78,10 +78,13 @@ export const URL_CONSTANTS = {
`/companies/company-profiles/${profileId}/status`,
CHANGE_REQUESTS: (companyId: string) =>
`/companies/${companyId}/change-requests`,
+ REVISIONS: (companyId: string) => `/companies/${companyId}/revisions`,
CHANGE_REQUEST_APPROVE: (id: string) =>
`/companies/change-requests/${id}/approve`,
CHANGE_REQUEST_REJECT: (id: string) =>
`/companies/change-requests/${id}/reject`,
+ CHANGE_REQUEST_REQUEST_CHANGES: (id: string) =>
+ `/companies/change-requests/${id}/request-changes`,
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
`/companies/documents/${fileId}/request-change`,
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/lib/display-timezone.test.ts b/apps/edr-freight-web/backoffice/src/lib/display-timezone.test.ts
new file mode 100644
index 000000000..33ac36437
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/lib/display-timezone.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+
+import "@edr/ui-common/display-timezone";
+
+// The pin must hold on ANY machine timezone — these assertions are the bug:
+// before the patch they only passed on a PC already set to UTC+3.
+describe("display-timezone pin (EAT, UTC+3)", () => {
+ const utcMidnight = new Date("2026-01-01T00:00:00Z");
+
+ it("formats Date.toLocale* in EAT regardless of machine timezone", () => {
+ expect(utcMidnight.toLocaleTimeString("en-GB", { hour12: false })).toBe(
+ "03:00:00",
+ );
+ // 22:00 UTC is already the NEXT day in EAT.
+ expect(new Date("2026-01-01T22:00:00Z").toLocaleDateString("en-CA")).toBe(
+ "2026-01-02",
+ );
+ });
+
+ it("formats Intl.DateTimeFormat in EAT and keeps instanceof/statics", () => {
+ const fmt = new Intl.DateTimeFormat("en-GB", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+ expect(fmt.format(utcMidnight)).toBe("03:00");
+ expect(fmt).toBeInstanceOf(Intl.DateTimeFormat);
+ expect(Intl.DateTimeFormat.supportedLocalesOf(["en-GB"])).toContain(
+ "en-GB",
+ );
+ });
+
+ it("respects an explicit timeZone option", () => {
+ expect(
+ utcMidnight.toLocaleTimeString("en-GB", {
+ hour12: false,
+ timeZone: "UTC",
+ }),
+ ).toBe("00:00:00");
+ });
+});
diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx
index aad3bbb89..3de483c5b 100644
--- a/apps/edr-freight-web/backoffice/src/main.tsx
+++ b/apps/edr-freight-web/backoffice/src/main.tsx
@@ -1,3 +1,6 @@
+// Must stay the first import: pins all date/time display to EAT before any
+// module can create a formatter in the PC's local timezone.
+import "@edr/ui-common/display-timezone";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
diff --git a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx
index 2fe61e56a..5374bcee7 100644
--- a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx
@@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react";
-import { format } from "date-fns";
import { Input } from "@/shared/common/ui/input";
import {
Select,
@@ -219,7 +218,7 @@ const buildQuery = (): CollectionQueryDTO => {
{log.message}
{log.timestamp && !isNaN(new Date(log.timestamp).getTime())
- ? format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")
+ ? new Date(log.timestamp).toLocaleString("sv-SE")
: "N/A"}
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 666069d98..a9e56a6d8 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -23,6 +23,7 @@ import {
Download,
Eye,
FileText,
+ History,
Hourglass,
IdCard,
LayoutGrid,
@@ -40,6 +41,7 @@ import {
ChangeRequestPendingBadge,
ChangeRequestReview,
CompanyStatusBadge,
+ CompanyTimeline,
CompanyTypeBadge,
InvoiceStatusBadge,
PaymentStatusBadge,
@@ -64,6 +66,7 @@ import {
} from "@/services/files.service";
import { api } from "@/services/api";
import type {
+ Company,
CompanyProfile,
CustomerBooking,
CustomerDocument,
@@ -78,6 +81,32 @@ import {
type ColumnDef,
} from "@edr/ui-common";
+/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
+function downloadTinRecord(company: Company) {
+ const lines = [
+ `TIN: ${company.tin}`,
+ `Company name: ${company.name}`,
+ `Licence number: ${company.licenceNumber ?? ""}`,
+ `Status: ${company.statusDescription ?? ""}`,
+ `Date registered: ${company.dateRegistered ?? ""}`,
+ `Renewed from: ${company.renewedFrom ?? ""}`,
+ `Renewal date: ${company.renewalDate ?? ""}`,
+ `Renewed to: ${company.renewedTo ?? ""}`,
+ `Address: ${[company.region, company.zone, company.woreda, company.kebele, company.houseNo].filter(Boolean).join(", ")}`,
+ ];
+ const blob = new Blob([lines.join("\n")], {
+ type: "text/plain;charset=utf-8",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `tin-${company.tin}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
+}
+
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
@@ -689,6 +718,9 @@ export default function CustomerDetailPage() {
}>
Invoices
+ }>
+ History
+
{/* OVERVIEW */}
@@ -757,6 +789,18 @@ export default function CustomerDetailPage() {
+
+
-
-
- eTrade registration
-
- {hasEtradeRecord ? (
-
- Verified with eTrade
-
- ) : (
-
- No eTrade record
-
+
+
+
+ eTrade registration
+
+ {hasEtradeRecord ? (
+
+ Verified with eTrade
+
+ ) : (
+
+ No eTrade record
+
+ )}
+
+ {hasEtradeRecord && (
+ downloadTinRecord(company)}
+ >
+
+
)}
{hasEtradeRecord ? (
@@ -1235,6 +1290,11 @@ export default function CustomerDetailPage() {
+
+ {/* HISTORY */}
+
+
+
),
},
+ {
+ id: "approved",
+ header: "Approved",
+ meta: { headerClassName: "text-right", cellClassName: "text-right" },
+ cell: ({ row }) => (
+
+ {row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"}
+
+ ),
+ },
],
[],
);
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx
index a515e5614..e37db97cc 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx
@@ -58,10 +58,15 @@ interface CargoNode extends RuleEngineRecord {
unitOfMeasure?: string | null;
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
wagonTypes?: { id: string; code?: string; name?: string }[];
+ /** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */
+ itemsPerWagonMap?: Record | null;
isActive?: boolean;
displayOrder?: number;
}
+/** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */
+const ITEMS_FIT_PREFIX = "itemsFit__";
+
const str = (v: unknown): string => (v == null ? "" : String(v));
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
@@ -131,15 +136,33 @@ const CargoTypesPage = () => {
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate);
- const formFields = useMemo(
- () =>
- FORM_FIELDS.map((field) =>
- field.name === "wagonTypeIds"
- ? { ...field, options: wagonTypeOptions ?? [] }
- : field,
- ),
- [wagonTypeOptions],
- );
+ const formFields = useMemo(() => {
+ const base = FORM_FIELDS.map((field) =>
+ field.name === "wagonTypeIds"
+ ? { ...field, options: wagonTypeOptions ?? [] }
+ : field,
+ );
+ // PER_ITEM cargo: one "items per wagon" input per SELECTED wagon type — how
+ // many whole items physically fit that wagon (floor space binds before
+ // tonnage). Shown only while the wagon type is picked; the API requires a
+ // fit for every selected type on PER_ITEM cargo.
+ const fitFields: FormFieldDef[] = (wagonTypeOptions ?? []).map((opt) => ({
+ name: `${ITEMS_FIT_PREFIX}${opt.value}`,
+ label: `Items per ${opt.label} wagon`,
+ type: "number",
+ required: true,
+ placeholder: "e.g. 4",
+ showIf: (values) =>
+ values.unitOfMeasure === "PER_ITEM" &&
+ Array.isArray(values.wagonTypeIds) &&
+ (values.wagonTypeIds as string[]).includes(opt.value),
+ getInitialValue: (record) =>
+ (record as CargoNode).itemsPerWagonMap?.[opt.value],
+ }));
+ const wagonTypesAt = base.findIndex((field) => field.name === "wagonTypeIds");
+ base.splice(wagonTypesAt + 1, 0, ...fitFields);
+ return base;
+ }, [wagonTypeOptions]);
const [search, setSearch] = useState("");
const [formMode, setFormMode] = useState(null);
@@ -203,7 +226,18 @@ const CargoTypesPage = () => {
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
const handleSubmit = (values: Record) => {
- const payload: Record = { ...values };
+ // Fold the per-wagon-type fit inputs into the API's map shape. Null when
+ // none are visible (not PER_ITEM) so an update clears stale fits.
+ const payload: Record = {};
+ const itemsPerWagonMap: Record = {};
+ for (const [key, value] of Object.entries(values)) {
+ if (key.startsWith(ITEMS_FIT_PREFIX)) {
+ itemsPerWagonMap[key.slice(ITEMS_FIT_PREFIX.length)] = Number(value);
+ } else {
+ payload[key] = value;
+ }
+ }
+ payload.itemsPerWagonMap = Object.keys(itemsPerWagonMap).length ? itemsPerWagonMap : null;
// Add always attaches to the page we're on; edit keeps the node's parent.
if (formMode?.kind === "create" && current) {
payload.parentGroupId = current.id;
diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx
index 20ed30fad..4bba1d713 100644
--- a/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx
+++ b/apps/edr-freight-web/backoffice/src/record-management/components/ReminderList.tsx
@@ -107,7 +107,14 @@ const ReminderList = () => {
Remind {dayjs(reminder.remindAt).fromNow()}
- ({dayjs(reminder.remindAt).format("MMM D, h:mm A")})
+ (
+ {new Date(reminder.remindAt).toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ })}
+ )
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index 48cf53be3..9933fbccf 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -8,6 +8,7 @@ import type {
CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
+ CompanyRevision,
CompanyStats,
CustomerBooking,
CustomerDocument,
@@ -2778,6 +2779,13 @@ export const api = {
({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
),
+ revisions: endpoint<{ id: string }, CompanyRevision[]>(
+ "customers",
+ "revisions",
+ ({ id }) => customersService.revisions(id),
+ ({ id }) => QUERY_KEYS.CUSTOMERS.revisions(id),
+ ),
+
approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
"customers",
"approveChangeRequest",
@@ -2802,6 +2810,21 @@ export const api = {
],
),
+ requestChangeRequestChanges: endpoint<
+ { id: string; note: string },
+ CompanyChangeRequest
+ >(
+ "customers",
+ "requestChangeRequestChanges",
+ ({ id, note }) => customersService.requestChangeRequestChanges(id, note),
+ undefined,
+ (_input, data) => [
+ QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
+ QUERY_KEYS.CUSTOMERS.byId(data.companyId),
+ QUERY_KEYS.CUSTOMERS.ROOT,
+ ],
+ ),
+
/**
* Ask the customer to correct one document. Invalidates the documents list
* and the company itself, since an open request blocks role approval.
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 6c6d94304..eb86dd752 100644
--- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts
@@ -5,6 +5,7 @@ import type {
CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
+ CompanyRevision,
CompanyStats,
CustomerBooking,
CustomerDocument,
@@ -146,6 +147,13 @@ export const customersService = {
.then((r) => r.data);
},
+ /** Onboarding-phase edit history for a company (version history), newest first. */
+ revisions(companyId: string): Promise {
+ return apiClient
+ .get(URL_CONSTANTS.COMPANIES.REVISIONS(companyId))
+ .then((r) => r.data);
+ },
+
/** Approve a pending change request — applies the proposed changes. */
approveChangeRequest(id: string): Promise {
return apiClient
@@ -165,6 +173,19 @@ export const customersService = {
.then((r) => r.data);
},
+ /** Ask for specific changes without rejecting — the request stays open for the customer's next edit to append to. */
+ requestChangeRequestChanges(
+ id: string,
+ note: string,
+ ): Promise {
+ return apiClient
+ .post(
+ URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REQUEST_CHANGES(id),
+ { note },
+ )
+ .then((r) => r.data);
+ },
+
/**
* Ask the customer to correct one uploaded document. Narrower than rejecting
* the whole role: the customer keeps their other documents and only re-uploads
diff --git a/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx b/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx
index 92d4b624a..78ca90a56 100644
--- a/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx
+++ b/apps/edr-freight-web/backoffice/src/shared/components/activity-log/activity-card.tsx
@@ -1,9 +1,7 @@
-import * as React from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Badge } from "@/shared/common/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/common/ui/avatar";
-import { format } from "date-fns";
import {
Clock,
User,
@@ -101,8 +99,14 @@ export function ActivityCard({ activity }: { activity: ActivityCardProps }) {
const iconBg = "bg-gray-100 dark:bg-gray-800";
const whiteBg = "bg-white dark:bg-gray-900";
- const formattedDate = format(new Date(activity.timestamp), "MMM d, yyyy");
- const formattedTime = format(new Date(activity.timestamp), "HH:mm:ss");
+ const formattedDate = new Date(activity.timestamp).toLocaleDateString(
+ "en-US",
+ { month: "short", day: "numeric", year: "numeric" },
+ );
+ const formattedTime = new Date(activity.timestamp).toLocaleTimeString(
+ "en-GB",
+ { hour12: false },
+ );
return (
{
switch (severity) {
@@ -472,10 +471,14 @@ export default function AuditLogPageShared({
textSubtle,
)}>
- {format(new Date(log.timestamp), "MMM d, yyyy")}
+ {new Date(log.timestamp).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
- {format(new Date(log.timestamp), "HH:mm:ss")}
+ {new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
•
@@ -817,13 +820,17 @@ export default function AuditLogPageShared({
- {format(
- new Date(log.timestamp),
- "MMM d, yyyy",
+ {new Date(log.timestamp).toLocaleDateString(
+ "en-US",
+ {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ },
)}
- {format(new Date(log.timestamp), "HH:mm:ss")}
+ {new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts
index 7e69b19d7..ed77307f9 100644
--- a/apps/edr-freight-web/backoffice/src/types/customer.ts
+++ b/apps/edr-freight-web/backoffice/src/types/customer.ts
@@ -69,7 +69,11 @@ export interface CompanyProfile {
}
/** Lifecycle of a staged customer profile-edit review. */
-export type ChangeRequestStatus = "pending" | "approved" | "rejected";
+export type ChangeRequestStatus =
+ | "pending"
+ | "approved"
+ | "rejected"
+ | "changes_requested";
/** A staged business-license add/remove on one profile, awaiting review. */
export interface LicenseChangeIntent {
@@ -110,6 +114,33 @@ export interface CompanyChangeRequest {
updatedAt: string;
}
+/**
+ * One field/document change recorded on a company revision. A document change
+ * carries `fromFileId`/`toFileId` alongside the display names, so the
+ * previous and current file can both be opened, not just named.
+ */
+export interface CompanyRevisionChange {
+ field: string;
+ label: string;
+ from: string | null;
+ to: string | null;
+ fromFileId?: string | null;
+ toFileId?: string | null;
+}
+
+/**
+ * Onboarding-phase edit history — records what changed on a company record
+ * before it reached Active, the write path that has no approval gate.
+ */
+export interface CompanyRevision {
+ id: string;
+ companyId: string;
+ actorId: string | null;
+ summary: string;
+ changes: CompanyRevisionChange[];
+ createdAt: string;
+}
+
/** The channel a customer's password-reset link is delivered over. */
export type ResetChannel = "email" | "phone";
@@ -215,6 +246,7 @@ export interface Company {
onboardingCompleted?: boolean;
createdAt: string;
updatedAt: string;
+ approvedAt?: string | null;
}
/**
diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json
index 1e7b7155d..9582058ef 100644
--- a/apps/edr-freight-web/portal/package.json
+++ b/apps/edr-freight-web/portal/package.json
@@ -14,7 +14,7 @@
"dependencies": {
"@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*",
- "@hookform/resolvers": "^5.4.0",
+ "@hookform/resolvers": "^5.6.0",
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx
index d36845dd9..082331c7b 100644
--- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx
+++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx
@@ -28,6 +28,31 @@ interface ETradeInfoProps {
const isValidTin = (tin: string) => tin.length === 10;
+/** Plain-text summary of the fetched eTrade record, downloaded client-side (eTrade returns data, not a document). */
+function downloadTinRecord(tin: string, data: CompanyRegistrationData) {
+ const lines = [
+ `TIN: ${tin}`,
+ `Company name: ${data.companyName}`,
+ `Licence number: ${data.licenceNumber}`,
+ `Status: ${data.statusDescription}`,
+ `Date registered: ${data.dateRegistered}`,
+ `Renewed from: ${data.renewedFrom}`,
+ `Renewal date: ${data.renewalDate}`,
+ `Renewed to: ${data.renewedTo}`,
+ `Address: ${[data.region, data.zone, data.woreda, data.kebele, data.houseNo].filter(Boolean).join(", ")}`,
+ `Manager: ${data.managerName}`,
+ ];
+ const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `tin-${tin}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
+}
+
export default function ETradeInfo({
tin,
register,
@@ -123,6 +148,17 @@ export default function ETradeInfo({
{isLoading ? "Getting..." : "Get Data"}
)}
+ {status === "verified" && mutation.data && !mutation.data.tinTaken && (
+ downloadTinRecord(tin, mutation.data!)}
+ leftSection={ }
+ mt="24px"
+ >
+ Download
+
+ )}
{notFound && (
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx
index 21d4d0746..4d078c2cb 100644
--- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx
+++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
-import { AlertTriangle, ArrowRight, Clock } from "lucide-react";
+import { AlertTriangle, ArrowRight, CheckCircle2, Clock } from "lucide-react";
import { api } from "@/services/api";
import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service";
@@ -150,18 +150,46 @@ export default function OnboardingResumeBanner({
/**
* Post-onboarding review banner. Surfaces (in priority order):
+ * 0. The account is suspended/blacklisted — hard lock.
* 1. A pending profile-edit review — the whole account is locked until an admin
* approves the submitted changes.
- * 2. A rejected profile-edit review — links to Settings to amend & resubmit.
- * 3. Per-operational-profile approval — bookings unlock as each role clears.
+ * 2. Backoffice requested changes — soft: same edit-and-resubmit call to
+ * action as a rejection, but the edit appends to the same request.
+ * 3. A rejected profile-edit review — links to Settings to amend & resubmit
+ * (starts a fresh request).
+ * 4/5. Company- and per-operational-profile approval — bookings unlock as
+ * each role clears.
* Self-hides when there's nothing outstanding.
*/
export function AccountReviewBanner() {
- const { company, reviewStatus, reviewNote } = useAuth();
+ const { company, companyStatus, reviewStatus, reviewNote } = useAuth();
const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active");
+ // 0. Account suspended/blacklisted — the hardest lock, takes priority over
+ // everything else since nothing below matters if the account is shut down.
+ if (companyStatus === "suspended" || companyStatus === "blacklisted") {
+ return (
+
+
+
+
+
+
+
+ Your account has been suspended
+
+
+ Contact EDR support to resolve this before you can continue
+ working.
+
+
+
+
+ );
+ }
+
// 1. Profile-edit review pending — the account-wide lock.
if (reviewStatus === "pending") {
return (
@@ -184,7 +212,41 @@ export function AccountReviewBanner() {
);
}
- // 2. Profile-edit review rejected — prompt to fix & resubmit.
+ // 2. Backoffice asked for specific changes — soft: same edit-and-resubmit
+ // call to action as a rejection, but the copy stays collaborative since
+ // the edit appends to this same request instead of starting over.
+ if (reviewStatus === "changes_requested") {
+ return (
+
+
+
+
+
+
+
+
+ Changes requested on your submission
+
+
+ {reviewNote
+ ? `Reviewer note: ${reviewNote}`
+ : "Please update the requested details and resubmit for review."}
+
+
+
+
+ Review & resubmit
+
+
+
+
+ );
+ }
+
+ // 3. Profile-edit review rejected — prompt to fix & resubmit.
if (reviewStatus === "rejected") {
return (
@@ -216,8 +278,45 @@ export function AccountReviewBanner() {
);
}
- // 3. Per-operational-profile approval (existing behaviour).
- if (profiles.length === 0 || pending.length === 0) return null;
+ // 4. Company approved and awaiting its first operational profile — nothing
+ // profile-specific to report yet, but the account itself is pending.
+ if (profiles.length === 0) {
+ if (companyStatus === "pending") {
+ return (
+
+
+
+
+
+
+ Your account is pending approval
+
+
+
+ );
+ }
+ return null;
+ }
+
+ // 5. Per-operational-profile approval (existing behaviour).
+ if (pending.length === 0) {
+ // Nothing outstanding — a quiet confirmation that the account is live.
+ if (companyStatus === "active") {
+ return (
+
+
+
+
+
+
+ Your account is approved
+
+
+
+ );
+ }
+ return null;
+ }
const pendingLabel = pending
.map((p) => p.type.replace(/_/g, " "))
diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index 928281042..22421400f 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -130,6 +130,8 @@ export const URL_CONSTANTS = {
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
+ CUSTOMER_TRUCKS_BULK: (id: string) =>
+ `/api/bookings/${id}/customer-trucks/bulk`,
CUSTOMER_TRUCK: (id: string, assignmentId: string) =>
`/api/bookings/${id}/customer-trucks/${assignmentId}`,
},
diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx
index 983aa5b7a..49fec0edf 100644
--- a/apps/edr-freight-web/portal/src/main.tsx
+++ b/apps/edr-freight-web/portal/src/main.tsx
@@ -1,3 +1,6 @@
+// Must stay the first import: pins all date/time display to EAT before any
+// module can create a formatter in the PC's local timezone.
+import "@edr/ui-common/display-timezone";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx
index ea06ae3e5..bba2b5696 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx
@@ -1,5 +1,4 @@
import { Box, Group, Text } from "@mantine/core";
-import { format } from "date-fns";
import { memo } from "react";
import { STATUS_CONFIG, cv } from "../constants";
@@ -56,7 +55,10 @@ export const ActivityRow = memo(function ActivityRow({
- {format(new Date(booking.createdAt), "MMM d")}
+ {new Date(booking.createdAt).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })}
);
diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
index 8bbe59284..ce357467e 100644
--- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
@@ -294,6 +294,26 @@ export default function SettingsPage() {
Attorney stay editable.
)}
+ {reviewStatus === "changes_requested" && (
+
}
+ title="Changes requested on your submission"
+ >
+
+ {profile.reviewNote && (
+
+ Reviewer note: {profile.reviewNote}
+
+ )}
+
+ Please update the requested details below and save again to
+ resubmit for review.
+
+
+
+ )}
{reviewStatus === "rejected" && (
{onPay && (
{
- const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, {
+ const { data } = await client.post(URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(bookingId), {
trucks: parsed,
});
return data;
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
index 8cf4deefa..5a8705610 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
@@ -68,6 +68,7 @@ function formatPriceUnit(unit: string): string {
const map: Record = {
PER_CONTAINER: "per container",
PER_TON: "per ton",
+ PER_ITEM: "per item",
PER_WAGON: "per wagon",
PER_KM: "per km",
FLAT: "flat",
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
index f76608690..b5605f598 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
@@ -10,7 +10,6 @@ import {
Text,
Textarea,
} from "@mantine/core";
-import { format } from "date-fns";
import {
Calendar,
CheckCircle2,
@@ -239,7 +238,12 @@ export function Step8Review({
values.destinationYard;
const scheduleLabel = values.scheduledDate
- ? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy")
+ ? new Date(values.scheduledDate).toLocaleDateString("en-US", {
+ weekday: "long",
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
: "—";
const directionLabel = direction
diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts
index e360906ec..5fba278dd 100644
--- a/apps/edr-freight-web/portal/src/services/companies.service.ts
+++ b/apps/edr-freight-web/portal/src/services/companies.service.ts
@@ -94,10 +94,12 @@ export interface CompanyInfoResponse {
company: CompanyResponse;
/**
* Open profile-edit review, if any. `pending` locks the settings page + new
- * contract/booking creation; `rejected` surfaces the note for reapply.
+ * contract/booking creation; `rejected`/`changes_requested` both surface the
+ * note for reapply — `changes_requested` just means the edit appends to the
+ * same request instead of starting a fresh one.
*/
review?: {
- status: "pending" | "rejected";
+ status: "pending" | "rejected" | "changes_requested";
note: string | null;
} | null;
}
@@ -106,7 +108,7 @@ export interface CompanyInfoResponse {
export interface ChangeRequestResponse {
id: string;
companyId: string;
- status: "pending" | "approved" | "rejected";
+ status: "pending" | "approved" | "rejected" | "changes_requested";
snapshot: Record;
documentFileIds: string[];
note: string | null;
diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts
index 2e1324876..7b7ce692c 100644
--- a/apps/edr-freight-web/portal/src/types/profile.ts
+++ b/apps/edr-freight-web/portal/src/types/profile.ts
@@ -51,10 +51,12 @@ export interface ProfileResponse {
profileId: string;
/**
* Open profile-edit review. `pending` → the settings page is read-only until an
- * admin decides; `rejected` → the note explains why and the forms prefill the
- * declined values so the customer can amend & resubmit.
+ * admin decides; `rejected`/`changes_requested` → the note explains why and
+ * the forms prefill the declined values so the customer can amend & resubmit
+ * (`changes_requested` appends that edit to this same request instead of
+ * starting a fresh one).
*/
- reviewStatus?: "pending" | "rejected" | null;
+ reviewStatus?: "pending" | "rejected" | "changes_requested" | null;
reviewNote?: string | null;
pendingChanges?: Record | null;
}
diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml
index 5ce7702c3..43388877c 100644
--- a/docker-compose.e2e.yaml
+++ b/docker-compose.e2e.yaml
@@ -61,6 +61,27 @@ services:
- mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc
restart: "no"
+ # One-shot: runs schema creation + migrations against postgres-freight-e2e,
+ # then exits. freight-api-e2e no longer migrates itself on boot (migrationsRun
+ # is false) — this is the CI "migration" stage, run here the same way.
+ freight-migration-e2e:
+ build:
+ context: .
+ dockerfile: apps/edr-freight-api/Dockerfile
+ target: migration
+ secrets:
+ - npmrc
+ depends_on:
+ postgres-freight-e2e:
+ condition: service_healthy
+ environment:
+ DB_HOST: postgres-freight-e2e
+ DB_PORT: "5432"
+ DB_USER: edr_e2e
+ DB_PASSWORD: edr_e2e
+ DB_NAME: edr_freight_e2e
+ restart: "no"
+
# Stand-in for eSignet's token/userinfo endpoints (see fayda-mock/server.js).
# The real Fayda authorization step (phone + SMS OTP) can't run in e2e —
# Cypress bypasses the popup and drives POST start / POST complete directly,
@@ -139,6 +160,8 @@ services:
condition: service_healthy
minio-init-e2e:
condition: service_completed_successfully
+ freight-migration-e2e:
+ condition: service_completed_successfully
fayda-mock-e2e:
condition: service_healthy
etrade-mock-e2e:
@@ -195,7 +218,8 @@ services:
ports:
- "${E2E_API_PORT:-3101}:3001"
healthcheck:
- # Boot runs 240+ migrations + seeders on first start — generous start_period.
+ # Migrations run in freight-migration-e2e before this container even
+ # starts (depends_on above) — boot here is just Nest bootstrap + seeders.
test:
[
"CMD",
@@ -206,7 +230,7 @@ services:
interval: 5s
timeout: 5s
retries: 12
- start_period: 180s
+ start_period: 60s
freight-portal-e2e:
build:
diff --git a/local-packages/tria-plc-api-common-1.6.0.tgz b/local-packages/tria-plc-api-common-1.6.0.tgz
new file mode 100644
index 000000000..3796d671a
Binary files /dev/null and b/local-packages/tria-plc-api-common-1.6.0.tgz differ
diff --git a/local-packages/tria-plc-iamapi-common-1.0.0.tgz b/local-packages/tria-plc-iamapi-common-1.0.0.tgz
new file mode 100644
index 000000000..a19d462d6
Binary files /dev/null and b/local-packages/tria-plc-iamapi-common-1.0.0.tgz differ
diff --git a/packages/ui-common/package.json b/packages/ui-common/package.json
index 0fa74439b..df7c13683 100644
--- a/packages/ui-common/package.json
+++ b/packages/ui-common/package.json
@@ -7,6 +7,7 @@
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
+ "./display-timezone": "./src/lib/display-timezone.ts",
"./styles.css": "./dist/index.css",
"./theme.css": "./src/styles/theme.css"
},
diff --git a/packages/ui-common/src/lib/display-timezone.ts b/packages/ui-common/src/lib/display-timezone.ts
new file mode 100644
index 000000000..8adccd67b
--- /dev/null
+++ b/packages/ui-common/src/lib/display-timezone.ts
@@ -0,0 +1,48 @@
+/**
+ * Side-effect module: pins every Intl-based date/time display to East Africa
+ * Time (UTC+3) so all users see the same wall-clock times no matter what
+ * timezone their PC is set to. Import it FIRST in the app entry, before any
+ * other app module, so no module-scope formatter is created unpatched:
+ *
+ * import "@edr/ui-common/display-timezone";
+ *
+ * Call sites that pass an explicit `timeZone` option keep it. date-fns and
+ * dayjs `format()` do NOT go through Intl and stay PC-local — don't use them
+ * to display API timestamps.
+ */
+export const DISPLAY_TIME_ZONE = "Africa/Addis_Ababa";
+
+type Locales = string | string[] | undefined;
+type LocaleMethod = (
+ this: Date,
+ locales?: Locales,
+ options?: Intl.DateTimeFormatOptions,
+) => string;
+
+function pinned(original: LocaleMethod): LocaleMethod {
+ return function (locales, options) {
+ return original.call(this, locales, {
+ ...options,
+ timeZone: options?.timeZone ?? DISPLAY_TIME_ZONE,
+ });
+ };
+}
+
+Date.prototype.toLocaleString = pinned(Date.prototype.toLocaleString);
+Date.prototype.toLocaleDateString = pinned(Date.prototype.toLocaleDateString);
+Date.prototype.toLocaleTimeString = pinned(Date.prototype.toLocaleTimeString);
+
+const OriginalDateTimeFormat = Intl.DateTimeFormat;
+function PinnedDateTimeFormat(
+ locales?: Locales,
+ options?: Intl.DateTimeFormatOptions,
+): Intl.DateTimeFormat {
+ return new OriginalDateTimeFormat(locales, {
+ ...options,
+ timeZone: options?.timeZone ?? DISPLAY_TIME_ZONE,
+ });
+}
+// Keep `instanceof Intl.DateTimeFormat` and the static method working.
+PinnedDateTimeFormat.prototype = OriginalDateTimeFormat.prototype;
+PinnedDateTimeFormat.supportedLocalesOf = OriginalDateTimeFormat.supportedLocalesOf;
+Intl.DateTimeFormat = PinnedDateTimeFormat as unknown as typeof Intl.DateTimeFormat;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a3871acd7..6051e12e8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -97,11 +97,11 @@ importers:
specifier: ^11.1.27
version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@tria-plc/api-common':
- specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz
- version: file:local-packages/tria-plc-api-common-1.4.3.tgz(56f7abaee02dde7d68b15d9ab04572e5)
+ specifier: file:../../local-packages/tria-plc-api-common-1.6.0.tgz
+ version: file:local-packages/tria-plc-api-common-1.6.0.tgz(d194f7ef21135288330b07fecd9a2489)
'@tria-plc/iamapi-common':
- specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.15.tgz
- version: file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(cb6b1db7b4758cd12009f4c537b4221f)
+ specifier: file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz
+ version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(2079b56e9fb8fa788c1a142167448388)
amqp-connection-manager:
specifier: ^5.0.0
version: 5.0.0(amqplib@2.0.1)
@@ -570,8 +570,8 @@ importers:
specifier: workspace:*
version: link:../../../packages/ui-common
'@hookform/resolvers':
- specifier: ^5.4.0
- version: 5.4.0(react-hook-form@7.77.0(react@19.2.6))
+ specifier: ^5.6.0
+ version: 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3)
'@mantine/core':
specifier: ^9.3.0
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -2146,6 +2146,84 @@ packages:
peerDependencies:
react-hook-form: ^7.55.0
+ '@hookform/resolvers@5.6.0':
+ resolution: {integrity: sha512-qtgE4NUK/WQFPq8aDe+GOhr0/UiUSKT0m9ta9SMnDS5ZE63yC850ShAGz1lxtwO+dBjhUKvUxmHwkp4eN7/kBQ==}
+ peerDependencies:
+ '@sinclair/typebox': '>=0.25.24'
+ '@standard-schema/spec': ^1.0.0
+ '@typeschema/main': '>=0.13.7'
+ '@vinejs/vine': ^2.0.0 || ^3.0.0
+ ajv: ^8.12.0
+ ajv-errors: ^3.0.0
+ ajv-formats: ^2.1.1
+ arktype: ^2.0.0
+ ata-validator: ^0.7.0
+ class-transformer: '>=0.4.0'
+ class-validator: '>=0.12.0'
+ computed-types: ^1.0.0
+ effect: ^3.10.3
+ fluentvalidation-ts: ^3.0.0
+ fp-ts: ^2.7.0
+ io-ts: ^2.0.0
+ joi: ^17.0.0
+ nope-validator: '>=0.12.0'
+ react-hook-form: ^7.55.0
+ superstruct: '>=0.12.0'
+ typanion: ^3.3.2
+ valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc'
+ vest: '>=3.0.0'
+ yup: ^1.0.0
+ zod: ^3.25.0 || ^4.0.0
+ peerDependenciesMeta:
+ '@sinclair/typebox':
+ optional: true
+ '@standard-schema/spec':
+ optional: true
+ '@typeschema/main':
+ optional: true
+ '@vinejs/vine':
+ optional: true
+ ajv:
+ optional: true
+ ajv-errors:
+ optional: true
+ ajv-formats:
+ optional: true
+ arktype:
+ optional: true
+ ata-validator:
+ optional: true
+ class-transformer:
+ optional: true
+ class-validator:
+ optional: true
+ computed-types:
+ optional: true
+ effect:
+ optional: true
+ fluentvalidation-ts:
+ optional: true
+ fp-ts:
+ optional: true
+ io-ts:
+ optional: true
+ joi:
+ optional: true
+ nope-validator:
+ optional: true
+ superstruct:
+ optional: true
+ typanion:
+ optional: true
+ valibot:
+ optional: true
+ vest:
+ optional: true
+ yup:
+ optional: true
+ zod:
+ optional: true
+
'@humanwhocodes/config-array@0.13.0':
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'}
@@ -4537,9 +4615,25 @@ packages:
rxjs: ^7.8.0
typeorm: ^0.3.0
- '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.15.tgz':
- resolution: {integrity: sha512-3I2rMhQ30ok446WQHS5X0PYZ0HdaAapeT7tPrU6zxemL8ClURGgeUPckYLtL+BFQIrMMQDoM0S6+RvflDIXhyA==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.15.tgz}
- version: 0.7.15
+ '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz':
+ resolution: {integrity: sha512-SZomla65xesBQZ12n8xH+9eX0TRbXNWToQ3SNURLhP1zlHJWUMTVHoRXTd5zWoe4mqah2Lr83L8ueHERsqCTFw==, tarball: file:local-packages/tria-plc-api-common-1.6.0.tgz}
+ version: 1.6.0
+ peerDependencies:
+ '@nestjs/common': ^11.0.0
+ '@nestjs/core': ^11.0.0
+ '@nestjs/jwt': ^11.0.0
+ '@nestjs/microservices': ^11.0.0
+ '@nestjs/passport': ^11.0.0
+ '@nestjs/swagger': ^11.0.0
+ '@nestjs/throttler': ^6.0.0
+ '@nestjs/typeorm': ^11.0.0
+ reflect-metadata: ^0.2.0
+ rxjs: ^7.8.0
+ typeorm: ^0.3.0
+
+ '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz':
+ resolution: {integrity: sha512-Y6SDEJUR4NcwLXrRJFZ+SbknpczybMwp59cR000vjFEu09RClLr5Gzv8TaJbOeDytyhdgjkZriVNPb2Dt6tipA==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.9.tgz}
+ version: 0.7.9
engines: {node: '>=20'}
peerDependencies:
'@nestjs/axios': ^4.0.0
@@ -4559,9 +4653,9 @@ packages:
rxjs: ^7.8.0
typeorm: ^0.3.0
- '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz':
- resolution: {integrity: sha512-Y6SDEJUR4NcwLXrRJFZ+SbknpczybMwp59cR000vjFEu09RClLr5Gzv8TaJbOeDytyhdgjkZriVNPb2Dt6tipA==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.9.tgz}
- version: 0.7.9
+ '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz':
+ resolution: {integrity: sha512-rfHSXOm/0VUMTj7HrvYysrEAqxItqfPs4Rd35qWJyaAk+snF1wTKFRVz6cRGPoQNj8fhplkVAefnvuKBuHT+xQ==, tarball: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz}
+ version: 1.0.0
engines: {node: '>=20'}
peerDependencies:
'@nestjs/axios': ^4.0.0
@@ -13036,6 +13130,20 @@ snapshots:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.77.0(react@19.2.6)
+ '@hookform/resolvers@5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3)':
+ dependencies:
+ '@standard-schema/utils': 0.3.0
+ react-hook-form: 7.77.0(react@19.2.6)
+ optionalDependencies:
+ '@sinclair/typebox': 0.27.10
+ '@standard-schema/spec': 1.1.0
+ ajv: 8.20.0
+ ajv-formats: 2.1.1(ajv@8.20.0)
+ class-transformer: 0.5.1
+ class-validator: 0.14.4
+ effect: 3.21.0
+ zod: 4.4.3
+
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@@ -16287,51 +16395,7 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {}
- '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(56f7abaee02dde7d68b15d9ab04572e5)':
- dependencies:
- '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
- '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
- '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
- '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
- '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
- '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
- '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(cb6b1db7b4758cd12009f4c537b4221f)
- argon2: 0.43.1
- axios: 1.17.0
- change-case: 5.4.4
- class-transformer: 0.5.1
- class-validator: 0.14.4
- dotenv: 16.6.1
- ethiopian-calendar-date-converter: 2.1.6
- ethiopian-date: 0.0.6
- exceljs: 4.4.0
- file-type: 21.3.4
- handlebars: 4.7.9
- handlebars-helpers: 0.10.0
- jmespath: 0.16.0
- jose: 5.10.0
- jsonwebtoken: 9.0.3
- libphonenumber-js: 1.13.6
- libreoffice-convert: 1.8.1
- nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
- passport-jwt: 4.0.1
- qrcode: 1.5.4
- reflect-metadata: 0.2.2
- rxjs: 7.8.2
- style-object-to-css-string: 1.1.3
- typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
- typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
- uuid: 11.1.1
- xlsx: 0.18.5
- transitivePeerDependencies:
- - '@faker-js/faker'
- - debug
- - supports-color
-
- '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)':
+ '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(59a15a37c5b1c12685ed78e172f27e65)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -16342,7 +16406,7 @@ snapshots:
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
- '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)
+ '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(c97ba831ddde82920910406ab5262991)
argon2: 0.43.1
axios: 1.17.0
change-case: 5.4.4
@@ -16375,7 +16439,7 @@ snapshots:
- debug
- supports-color
- '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(cb6b1db7b4758cd12009f4c537b4221f)':
+ '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz(d194f7ef21135288330b07fecd9a2489)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -16386,7 +16450,50 @@ snapshots:
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
- '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(56f7abaee02dde7d68b15d9ab04572e5)
+ argon2: 0.43.1
+ axios: 1.17.0
+ change-case: 5.4.4
+ class-transformer: 0.5.1
+ class-validator: 0.14.4
+ dotenv: 16.6.1
+ ethiopian-calendar-date-converter: 2.1.6
+ ethiopian-date: 0.0.6
+ exceljs: 4.4.0
+ file-type: 21.3.4
+ handlebars: 4.7.9
+ handlebars-helpers: 0.10.0
+ jmespath: 0.16.0
+ jose: 5.10.0
+ jsonwebtoken: 9.0.3
+ libphonenumber-js: 1.13.6
+ libreoffice-convert: 1.8.1
+ nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
+ passport-jwt: 4.0.1
+ qrcode: 1.5.4
+ reflect-metadata: 0.2.2
+ rxjs: 7.8.2
+ style-object-to-css-string: 1.1.3
+ typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
+ typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
+ uuid: 11.1.1
+ xlsx: 0.18.5
+ transitivePeerDependencies:
+ - '@faker-js/faker'
+ - debug
+ - supports-color
+
+ '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(c97ba831ddde82920910406ab5262991)':
+ dependencies:
+ '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
+ '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
+ '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
+ '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
+ '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
+ '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
+ '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(59a15a37c5b1c12685ed78e172f27e65)
api-common: 1.2.2
argon2: 0.43.1
axios: 1.17.0
@@ -16410,7 +16517,7 @@ snapshots:
- '@faker-js/faker'
- supports-color
- '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)':
+ '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(2079b56e9fb8fa788c1a142167448388)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -16418,11 +16525,10 @@ snapshots:
'@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
- '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
+ '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
- '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)
- api-common: 1.2.2
+ '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.6.0.tgz(d194f7ef21135288330b07fecd9a2489)
argon2: 0.43.1
axios: 1.17.0
class-transformer: 0.5.1