mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'staging' into freight/fix/pay
This commit is contained in:
7
.github/workflows/deploy.yml
vendored
7
.github/workflows/deploy.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS approved_at timestamptz`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS approved_at`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "items_per_wagon_map" jsonb`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "items_per_wagon_map"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.dropTable('freight.company_revisions', true);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// Irreversible rename-by-join: the pre-sync unit is not recorded. Both
|
||||
// units bill identically, so rolling back the code needs no data change.
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<CompanyRevisionResponseDto[]> {
|
||||
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<ChangeRequestResponseDto> {
|
||||
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" })
|
||||
|
||||
@@ -120,6 +120,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
revisionRepo: {
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
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<Ctx> = {}) {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -91,6 +91,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
revisionRepo: {
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
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<Ctx> = {}) {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Company> {
|
||||
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<string, any>,
|
||||
actorId?: string | null,
|
||||
extraChanges: CompanyRevisionChange[] = [],
|
||||
): Promise<void> {
|
||||
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<CompanyRevision[]> {
|
||||
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<CompanyRevisionChange[]> {
|
||||
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<FileRecord[]> {
|
||||
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<CompanyChangeRequest> {
|
||||
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<void> {
|
||||
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({
|
||||
|
||||
@@ -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<CompanyChangeRequest, "id" | "status"> & { 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<ChangeRequestStatus> | 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<Row> & { companyId: string } }) =>
|
||||
({
|
||||
where,
|
||||
}: {
|
||||
where: { companyId: string; status?: Row["status"] | FindOperator<Row["status"]> };
|
||||
}) =>
|
||||
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,
|
||||
|
||||
@@ -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<CompanyChangeRequest> {
|
||||
constructor(
|
||||
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The company's current pending request, if any. */
|
||||
/** The company's current open request (Pending or ChangesRequested), if any — the row the next edit appends to. */
|
||||
async findPendingByCompanyId(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { companyId, status: ChangeRequestStatus.Pending },
|
||||
where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, any>,
|
||||
): 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<string, unknown>)[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`;
|
||||
}
|
||||
@@ -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<CompanyRevision> {
|
||||
constructor(
|
||||
@InjectRepository(CompanyRevision)
|
||||
repo: Repository<CompanyRevision>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** Revision history for a company, newest first. */
|
||||
async findByCompanyId(companyId: string): Promise<CompanyRevision[]> {
|
||||
return this.repository.find({
|
||||
where: { companyId },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, any> | 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<FileRecord> {
|
||||
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 };
|
||||
|
||||
@@ -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. { "<nw5-id>": 4, "<nw7-id>": 6 }). Required for every wagonTypeId when ' +
|
||||
'unitOfMeasure is PER_ITEM.',
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'integer', minimum: 1 },
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -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<string, number> | null;
|
||||
|
||||
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface IRatesRepository {
|
||||
create(data: Partial<Rate>): Promise<Rate>;
|
||||
update(id: string, data: Partial<Rate>): Promise<Rate | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
/**
|
||||
* 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<number>;
|
||||
}
|
||||
|
||||
export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY');
|
||||
|
||||
@@ -177,4 +177,16 @@ export class RatesRepository implements IRatesRepository {
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
|
||||
async syncBulkQuantityUnit(
|
||||
cargoTypeId: string,
|
||||
unitOfMeasure: 'PER_TON' | 'PER_ITEM',
|
||||
): Promise<number> {
|
||||
const from = unitOfMeasure === 'PER_ITEM' ? 'PER_TON' : 'PER_ITEM';
|
||||
const result = await this.repo.update(
|
||||
{ cargoTypeId, rateUnit: from },
|
||||
{ rateUnit: unitOfMeasure },
|
||||
);
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, number> | null;
|
||||
}): Record<string, number> | null {
|
||||
if (input.unitOfMeasure !== CargoUnitOfMeasure.PerItem || !input.wagonTypeIds.length) {
|
||||
return null;
|
||||
}
|
||||
const map: Record<string, number> = {};
|
||||
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<CargoType> {
|
||||
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<CargoType> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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<string, number> | 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<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
|
||||
return num(slot.tareWeightTons) + num(slot.cargoTons);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
24
apps/edr-freight-api/src/scripts/migrate.ts
Normal file
24
apps/edr-freight-api/src/scripts/migrate.ts
Normal file
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
@@ -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({
|
||||
<Text size="xs" c="dimmed" mt={4} truncate>
|
||||
{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,
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
companyName: "Company name",
|
||||
companyEmail: "Company email",
|
||||
companyPhone: "Company phone",
|
||||
@@ -69,7 +68,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** 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<string, unknown>;
|
||||
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
||||
const map: Record<string, unknown> = {
|
||||
@@ -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<string | null>(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 }) {
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{pending.note && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
>
|
||||
Changes were requested on an earlier round of this same
|
||||
submission: <strong>{pending.note}</strong> — check whether
|
||||
this resubmission actually addresses it before approving.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{proposedKeys.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{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
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => {
|
||||
setActionTarget({ id: pending.id, kind: "request-changes" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
@@ -437,51 +465,31 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<Card withBorder>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} c="edr-text">
|
||||
Review history
|
||||
</Text>
|
||||
{history.map((r: CompanyChangeRequest) => (
|
||||
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
color={r.status === "approved" ? "edr-green" : "red"}
|
||||
variant="light"
|
||||
radius="md"
|
||||
tt="capitalize"
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="edr-text">
|
||||
{formatDate(r.reviewedAt ?? r.updatedAt)}
|
||||
</Text>
|
||||
{r.note && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Note: {r.note}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={rejectId !== null}
|
||||
onClose={() => setRejectId(null)}
|
||||
title="Reject changes"
|
||||
opened={actionTarget !== null}
|
||||
onClose={() => setActionTarget(null)}
|
||||
title={
|
||||
actionTarget?.kind === "reject" ? "Reject changes" : "Request changes"
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
|
||||
The customer will see this note and can amend and resubmit.
|
||||
<Alert
|
||||
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
>
|
||||
{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."}
|
||||
</Alert>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
label={
|
||||
actionTarget?.kind === "reject"
|
||||
? "Reason for rejection"
|
||||
: "What needs to change"
|
||||
}
|
||||
placeholder="e.g. The company address doesn't match the trade license."
|
||||
autosize
|
||||
minRows={3}
|
||||
@@ -492,18 +500,20 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectId(null)}
|
||||
disabled={reject.isPending}
|
||||
onClick={() => setActionTarget(null)}
|
||||
disabled={reject.isPending || requestChanges.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={reject.isPending}
|
||||
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||
loading={reject.isPending || requestChanges.isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
onClick={confirmAction}
|
||||
>
|
||||
Reject changes
|
||||
{actionTarget?.kind === "reject"
|
||||
? "Reject changes"
|
||||
: "Request changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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<TimelineEntry["kind"], { label: string; color: string }> = {
|
||||
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 (
|
||||
<Card withBorder>
|
||||
<Stack align="center" gap={6} py="xl">
|
||||
<History size={24} className="text-edr-muted" />
|
||||
<Text size="sm" c="dimmed">
|
||||
No changes recorded yet.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{entries.map((entry) => {
|
||||
const badge = KIND_BADGE[entry.kind];
|
||||
return (
|
||||
<Card key={entry.id} withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="sm">
|
||||
<Badge color={badge.color} variant="light" radius="md">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
{entry.summary && (
|
||||
<Text size="sm" c="edr-text" tt="capitalize">
|
||||
{entry.summary}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDate(entry.at)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{entry.note && (
|
||||
<Alert color="yellow" variant="light">
|
||||
<Text size="sm">
|
||||
<strong>Note:</strong> {entry.note}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{entry.fieldDiffs.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{entry.fieldDiffs.map((f) => (
|
||||
<DiffRow key={f.key} label={f.label} from={f.from} to={f.to} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{entry.docDiffs.length > 0 && (
|
||||
<Stack gap={8}>
|
||||
{entry.docDiffs.map((d) => (
|
||||
<Group key={d.key} gap={8} wrap="nowrap">
|
||||
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
|
||||
{d.label}
|
||||
</Text>
|
||||
{d.fromFile && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<FileX2 size={14} className="text-edr-muted" />
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
td="line-through"
|
||||
onClick={() => openFile(d.fromFile!)}
|
||||
>
|
||||
{d.fromFile.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
{d.fromFile && d.toFile && (
|
||||
<Text size="sm" c="edr-muted">
|
||||
→
|
||||
</Text>
|
||||
)}
|
||||
{d.toFile && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<FilePlus2 size={14} className="text-edr-muted" />
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => openFile(d.toFile!)}
|
||||
>
|
||||
{d.toFile.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{entry.fieldDiffs.length === 0 && entry.docDiffs.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No details recorded for this entry.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { CompanyTimeline } from "./CompanyTimeline";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
type RequestDocumentChangeModalProps,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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 => {
|
||||
<TableCell>{log.message}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{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"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap={2}>
|
||||
@@ -689,6 +718,9 @@ export default function CustomerDetailPage() {
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* OVERVIEW */}
|
||||
@@ -757,6 +789,18 @@ export default function CustomerDetailPage() {
|
||||
<InfoField label="TIN" value={company.tin} />
|
||||
<InfoField label="VAT number" value={company.vatNumber} />
|
||||
<InfoField label="FAN number" value={company.fanNumber} />
|
||||
<InfoField
|
||||
label="Submitted on"
|
||||
value={formatDate(company.createdAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Approved on"
|
||||
value={
|
||||
company.approvedAt
|
||||
? formatDate(company.approvedAt)
|
||||
: "Not yet approved"
|
||||
}
|
||||
/>
|
||||
<InfoField
|
||||
label="Owner identity"
|
||||
value={
|
||||
@@ -800,18 +844,29 @@ export default function CustomerDetailPage() {
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord && (
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
aria-label="Download TIN record"
|
||||
onClick={() => downloadTinRecord(company)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord ? (
|
||||
@@ -1235,6 +1290,11 @@ export default function CustomerDetailPage() {
|
||||
</Box>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* HISTORY */}
|
||||
<Tabs.Panel value="history" pt="lg">
|
||||
<CompanyTimeline company={company} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<RequestDocumentChangeModal
|
||||
|
||||
@@ -245,6 +245,16 @@ export default function CustomersPage() {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "approved",
|
||||
header: "Approved",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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<string, number> | 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<FormFieldDef[]>(
|
||||
() =>
|
||||
FORM_FIELDS.map((field) =>
|
||||
field.name === "wagonTypeIds"
|
||||
? { ...field, options: wagonTypeOptions ?? [] }
|
||||
: field,
|
||||
),
|
||||
[wagonTypeOptions],
|
||||
);
|
||||
const formFields = useMemo<FormFieldDef[]>(() => {
|
||||
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<FormMode | null>(null);
|
||||
@@ -203,7 +226,18 @@ const CargoTypesPage = () => {
|
||||
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
|
||||
|
||||
const handleSubmit = (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = { ...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<string, unknown> = {};
|
||||
const itemsPerWagonMap: Record<string, number> = {};
|
||||
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;
|
||||
|
||||
@@ -107,7 +107,14 @@ const ReminderList = () => {
|
||||
Remind {dayjs(reminder.remindAt).fromNow()}
|
||||
</p>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
({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",
|
||||
})}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<CompanyRevision[]> {
|
||||
return apiClient
|
||||
.get<CompanyRevision[]>(URL_CONSTANTS.COMPANIES.REVISIONS(companyId))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Approve a pending change request — applies the proposed changes. */
|
||||
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
|
||||
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<CompanyChangeRequest> {
|
||||
return apiClient
|
||||
.post<CompanyChangeRequest>(
|
||||
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
|
||||
|
||||
@@ -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 (
|
||||
<Card
|
||||
|
||||
@@ -213,7 +213,6 @@ export default function AuditLogPageShared({
|
||||
const hoverSoft = "hover:bg-gray-100 dark:hover:bg-gray-800";
|
||||
const primaryBtn =
|
||||
"bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-gray-200 text-white dark:text-gray-900";
|
||||
const primaryIcon = "text-gray-900 dark:text-gray-100";
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
@@ -472,10 +471,14 @@ export default function AuditLogPageShared({
|
||||
textSubtle,
|
||||
)}>
|
||||
<span className="whitespace-nowrap">
|
||||
{format(new Date(log.timestamp), "MMM d, yyyy")}
|
||||
{new Date(log.timestamp).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">
|
||||
{format(new Date(log.timestamp), "HH:mm:ss")}
|
||||
{new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2 min-w-0">
|
||||
<span className={cn(textSubtle)}>•</span>
|
||||
@@ -817,13 +820,17 @@ export default function AuditLogPageShared({
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className={cn("text-sm", textStrong)}>
|
||||
{format(
|
||||
new Date(log.timestamp),
|
||||
"MMM d, yyyy",
|
||||
{new Date(log.timestamp).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<span className={cn("text-xs", textSubtle)}>
|
||||
{format(new Date(log.timestamp), "HH:mm:ss")}
|
||||
{new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
</Button>
|
||||
)}
|
||||
{status === "verified" && mutation.data && !mutation.data.tinTaken && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
onClick={() => downloadTinRecord(tin, mutation.data!)}
|
||||
leftSection={<Download size={16} />}
|
||||
mt="24px"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{notFound && (
|
||||
|
||||
@@ -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 (
|
||||
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-700">
|
||||
<AlertTriangle size={18} />
|
||||
</span>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-semibold text-red-900">
|
||||
Your account has been suspended
|
||||
</span>
|
||||
<span className="text-xs text-red-800">
|
||||
Contact EDR support to resolve this before you can continue
|
||||
working.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||
<AlertTriangle size={18} />
|
||||
</span>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-semibold text-amber-900">
|
||||
Changes requested on your submission
|
||||
</span>
|
||||
<span className="text-xs text-amber-800">
|
||||
{reviewNote
|
||||
? `Reviewer note: ${reviewNote}`
|
||||
: "Please update the requested details and resubmit for review."}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
Review & resubmit
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Profile-edit review rejected — prompt to fix & resubmit.
|
||||
if (reviewStatus === "rejected") {
|
||||
return (
|
||||
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
|
||||
@@ -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 (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||
<Clock size={18} />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-amber-900">
|
||||
Your account is pending approval
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="border-b border-emerald-200 bg-emerald-50 px-6 py-3">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<CheckCircle2 size={18} />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-emerald-900">
|
||||
Your account is approved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const pendingLabel = pending
|
||||
.map((p) => p.type.replace(/_/g, " "))
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fz={11} c="edr-muted" className="shrink-0">
|
||||
{format(new Date(booking.createdAt), "MMM d")}
|
||||
{new Date(booking.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -294,6 +294,26 @@ export default function SettingsPage() {
|
||||
Attorney stay editable.
|
||||
</Alert>
|
||||
)}
|
||||
{reviewStatus === "changes_requested" && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Changes requested on your submission"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{profile.reviewNote && (
|
||||
<Text size="sm">
|
||||
<strong>Reviewer note:</strong> {profile.reviewNote}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm">
|
||||
Please update the requested details below and save again to
|
||||
resubmit for review.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{reviewStatus === "rejected" && (
|
||||
<Alert
|
||||
color="red"
|
||||
|
||||
@@ -106,7 +106,8 @@ function Countdown({
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
})}{" "}
|
||||
EAT
|
||||
</Text>
|
||||
{onPay && (
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucid
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
|
||||
|
||||
interface BulkTruckUploadModalProps {
|
||||
@@ -32,7 +33,7 @@ export function BulkTruckUploadModal({
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
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;
|
||||
|
||||
@@ -68,6 +68,7 @@ function formatPriceUnit(unit: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PER_CONTAINER: "per container",
|
||||
PER_TON: "per ton",
|
||||
PER_ITEM: "per item",
|
||||
PER_WAGON: "per wagon",
|
||||
PER_KM: "per km",
|
||||
FLAT: "flat",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, any>;
|
||||
documentFileIds: string[];
|
||||
note: string | null;
|
||||
|
||||
@@ -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<string, any> | null;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
BIN
local-packages/tria-plc-api-common-1.6.0.tgz
Normal file
BIN
local-packages/tria-plc-api-common-1.6.0.tgz
Normal file
Binary file not shown.
BIN
local-packages/tria-plc-iamapi-common-1.0.0.tgz
Normal file
BIN
local-packages/tria-plc-iamapi-common-1.0.0.tgz
Normal file
Binary file not shown.
@@ -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"
|
||||
},
|
||||
|
||||
48
packages/ui-common/src/lib/display-timezone.ts
Normal file
48
packages/ui-common/src/lib/display-timezone.ts
Normal file
@@ -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;
|
||||
234
pnpm-lock.yaml
generated
234
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user