Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-07-09 21:14:09 +00:00
115 changed files with 11050 additions and 1459 deletions

View File

@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Repairs `freight.warehouse_inventory.grn_number`.
*
* AddGrnNumberToWarehouseInventory1828000000000 is recorded in `migrations` but
* the column is absent on at least one environment - it was added, then dropped
* out-of-band (a stray `synchronize: true`, same class of damage that
* RepairSynchronizeDrift1870000000000 already had to undo). Because TypeORM has
* the original recorded, it will never re-run it.
*
* Without the column, everything that reads or writes a GRN fails with
* `column ... grn_number does not exist`:
* - bulkReceive() -> INSERT names grn_number (receive to warehouse)
* - importQueueByStatuses() -> Unloaded + Dispatch queues
* - exportInventoryByStatus() -> Received / Ready-To-Load / Loaded tabs
* - grnDocument() -> GRN PDF
*
* Idempotent: a no-op on environments where the column survived.
*/
export class RepairGrnNumberColumn2090000000000 implements MigrationInterface {
name = 'RepairGrnNumberColumn2090000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL
`);
// Recover the GRN for rows received before the column existed: it was also
// written into the receive note as "GRN Number: <value>".
await queryRunner.query(`
UPDATE freight.warehouse_inventory
SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)')
WHERE grn_number IS NULL
AND notes IS NOT NULL
AND notes ~ 'GRN Number: '
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number
ON freight.warehouse_inventory(grn_number)
WHERE grn_number IS NOT NULL
`);
}
/**
* Deliberately a no-op. Dropping the column is what broke these environments
* in the first place, and the original 1828 migration already owns its own
* down(). Reverting this repair must not re-introduce the outage.
*/
public async down(): Promise<void> {
// intentionally empty
}
}

View File

@@ -0,0 +1,48 @@
import {
Body,
Controller,
NotFoundException,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
import { CustomerResetService } from "./customer-reset.service";
/**
* Staff-triggered password reset. The customer receives the code and sets their
* own password — staff never see or handle a credential.
*/
@ApiTags("backoffice")
@Controller("backoffice/customers")
@ApiBearerAuth()
export class CustomerResetController {
constructor(private readonly customerResetService: CustomerResetService) {}
@Post(":companyId/reset-password")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
@ApiOperation({
summary: "Send a password-reset code to a customer's primary contact",
})
async resetPassword(
@Param("companyId", ParseUUIDPipe) companyId: string,
@Body() dto: BackofficeResetPasswordDto,
) {
const maskedTarget = await this.customerResetService.sendResetToCustomer(
companyId,
dto.channel,
);
if (!maskedTarget) {
throw new NotFoundException(
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
);
}
return { channel: dto.channel, maskedTarget };
}
}

View File

@@ -0,0 +1,59 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
import { ResetChannel } from "./dto/forgot-password.dto";
import { ForgotPasswordService } from "./forgot-password.service";
@Injectable()
export class CustomerResetService {
private readonly logger = new Logger(CustomerResetService.name);
constructor(
@InjectRepository(ExternalProfile)
private readonly externalProfileRepository: Repository<ExternalProfile>,
private readonly forgotPasswordService: ForgotPasswordService,
) {}
/**
* Send a reset code to the company's primary contact. Returns the masked
* destination, or null when there is no eligible account for that channel.
*
* Unlike the public flow this reports failure honestly — the caller is an
* authenticated staff member, so there is nothing to enumerate.
*/
async sendResetToCustomer(
companyId: string,
channel: ResetChannel,
): Promise<string | null> {
const profile = await this.externalProfileRepository.findOne({
where: { companyId, isPrimaryContact: true },
});
if (!profile) {
this.logger.warn(`Company ${companyId} has no primary contact profile`);
return null;
}
// Resolve through the same active-account gate the public flow uses, so a
// suspended customer cannot be reactivated by a staff-triggered reset.
const user = await this.forgotPasswordService.resolveActiveUserById(
profile.userId,
);
if (!user) {
this.logger.warn(
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
);
return null;
}
const target = await this.forgotPasswordService.requestReset(user, channel);
if (!target) return null;
this.logger.log(
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
);
return this.forgotPasswordService.maskTarget(target);
}
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
/** The channel the reset code is delivered over. */
export enum ResetChannel {
Email = "email",
Phone = "phone",
}
export class ForgotPasswordRequestDto {
@ApiProperty({
description: "Email, username, or phone number of the account to reset",
example: "name@company.com",
})
@IsString()
@IsNotEmpty()
identifier!: string;
@ApiProperty({ enum: ResetChannel })
@IsEnum(ResetChannel)
channel!: ResetChannel;
}
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
@IsString()
@IsNotEmpty()
otp!: string;
}
export class BackofficeResetPasswordDto {
@ApiProperty({ enum: ResetChannel })
@IsEnum(ResetChannel)
channel!: ResetChannel;
}

View File

@@ -0,0 +1,69 @@
import { Body, Controller, Logger, Post } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import {
ForgotPasswordRequestDto,
ForgotPasswordVerifyDto,
} from "./dto/forgot-password.dto";
import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
/**
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
* ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which
* this API does not set). These routes drive freight's own email-or-phone OTP
* service instead, then hand back a ticket for IAM's public `set-password`.
*/
@ApiTags("auth")
@Controller("auth")
@Public()
export class ForgotPasswordController {
private readonly logger = new Logger(ForgotPasswordController.name);
constructor(private readonly forgotPasswordService: ForgotPasswordService) {}
@Post("forgot-password/request")
@ApiOperation({
summary: "Send a password-reset code over email or SMS",
description:
"Always reports success. An unknown, inactive, or channel-less account is " +
"indistinguishable from a real one, so this cannot be used to enumerate accounts.",
})
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
if (user) {
try {
await this.forgotPasswordService.requestReset(user, dto.channel);
} catch (error) {
// A delivery failure must not change the response shape either — log it
// and let the caller sit on the OTP screen.
this.logger.error(
`Reset code delivery failed for user ${user.id}: ${
error instanceof Error ? error.message : String(error)
}`,
error instanceof Error ? error.stack : undefined,
);
}
} else {
this.logger.log("Reset requested for an unknown or inactive account");
}
return { success: true };
}
@Post("forgot-password/verify")
@ApiOperation({
summary: "Exchange a valid reset code for a single-use set-password ticket",
description:
"The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " +
"alongside the same identifier and the new password.",
})
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
return this.forgotPasswordService.verifyAndMintTicket(
dto.identifier,
dto.channel,
dto.otp,
);
}
}

View File

@@ -0,0 +1,169 @@
import { randomBytes } from "node:crypto";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, Repository } from "typeorm";
import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
import { OtpService, OtpTarget } from "../otp/otp.service";
import { ResetChannel } from "./dto/forgot-password.dto";
/**
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
* valid. The IAM `setPassword` handler enforces this via `expiresAt`.
*/
const RESET_TICKET_TTL_MS = 10 * 60 * 1000;
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
export interface ResetTicket {
userId: string;
verificationCode: string;
}
@Injectable()
export class ForgotPasswordService {
private readonly logger = new Logger(ForgotPasswordService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly otpService: OtpService,
) {}
/**
* Resolve an account that is actually eligible for a password reset.
*
* IAM's `set-password` handler flips `isActive: true` on the user as a side
* effect, so a reset on a deactivated account would silently resurrect it.
* Gating here — rather than at the set-password call — is what keeps that
* from being reachable. Mirrors IAM's own login lookup: match on any of
* email / username / phone, and require an active credential row.
*/
async resolveActiveUser(identifier: string): Promise<User | null> {
const id = identifier.trim();
if (!id) return null;
return await this.activeUserQuery()
.andWhere(
"(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)",
{ id },
)
.getOne();
}
/** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */
async resolveActiveUserById(userId: string): Promise<User | null> {
if (!userId) return null;
return await this.activeUserQuery()
.andWhere("u.id = :userId", { userId })
.getOne();
}
/**
* Base query for accounts eligible to reset. `.where()` is claimed here so
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
* which would silently drop the `isActive` gate.
*/
private activeUserQuery() {
return this.userRepository
.createQueryBuilder("u")
.innerJoin("u.userCredentials", "uc", "uc.isActive = true")
.where("u.isActive = true")
.orderBy("u.createdAt", "DESC");
}
/** The address the code goes to, taken from the account — never from input. */
private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
if (channel === ResetChannel.Email) {
return user.email ? { email: user.email } : null;
}
return user.phoneNumber ? { phone: user.phoneNumber } : null;
}
/**
* Send a reset code to the account's own email/phone. Returns the target so
* authenticated (backoffice) callers can echo a masked version; unauthenticated
* callers must discard it.
*
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
* upserts. A reset request therefore overwrites any pending signup code for
* the same address — last code sent wins. That is the pre-existing behaviour
* between any two flows sharing this table.
*/
async requestReset(
user: User,
channel: ResetChannel,
): Promise<OtpTarget | null> {
const target = this.targetFor(user, channel);
if (!target) return null;
await this.otpService.sendOtp(target);
return target;
}
/**
* Prove possession of the OTP, then mint an IAM reset ticket the caller can
* spend on the public `PATCH /api/auth/set-password`.
*
* Minting a `UserVerification` row rather than writing `UserCredential`
* ourselves keeps IAM as the single owner of the password write path (old
* credential deactivation, argon hashing, changed-at bookkeeping).
*/
async verifyAndMintTicket(
identifier: string,
channel: ResetChannel,
otp: string,
): Promise<ResetTicket> {
const user = await this.resolveActiveUser(identifier);
const target = user && this.targetFor(user, channel);
if (!user?.id || !target) {
// Same shape as a wrong code: a caller probing for accounts learns nothing
// beyond what the request step already (deliberately) refuses to tell them.
throw new BadRequestException("Invalid verification code");
}
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
const code = randomBytes(24).toString("base64url");
const verificationCode = await hashPassword(code);
const userId = user.id;
await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(UserVerification);
// Retire any outstanding codes so only the ticket we just minted can be
// spent — `findVerificationForPrimaryReset` reads the newest row.
await repo.update({ userId }, { isUsed: true });
await repo.insert({
userId,
otpType: EOtpType.RESET_PASSWORD,
verificationCode,
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
isUsed: false,
attemptCount: 0,
});
});
this.logger.log(`Reset ticket minted for user ${userId}`);
return { userId, verificationCode: code };
}
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
maskTarget(target: OtpTarget): string {
if (target.email) {
const [local, domain] = target.email.split("@");
const head = local.slice(0, 1);
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
const phone = target.phone ?? "";
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
}
}

View File

@@ -2,15 +2,35 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
import { ExternalProfile } from '../companies/entities/external-profile.entity';
import { OtpModule } from '../otp/otp.module';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerResetController } from './customer-reset.controller';
import { CustomerResetService } from './customer-reset.service';
import { ForgotPasswordController } from './forgot-password.controller';
import { ForgotPasswordService } from './forgot-password.service';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [FreightMeController, CheckAvailabilityController],
providers: [FreightMeService, CheckAvailabilityService],
imports: [
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
OtpModule,
],
controllers: [
FreightMeController,
CheckAvailabilityController,
ForgotPasswordController,
CustomerResetController,
],
providers: [
FreightMeService,
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,
],
})
export class FreightAuthModule {}

View File

@@ -1125,6 +1125,30 @@ export class BookingsService {
);
}
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
* dashboard) can show "Approve delivery" for exactly the generated→signed
* window. One query for the whole page.
*/
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
const ids = bookings.map((b) => b.id);
if (!ids.length) return;
const rows: Array<{ bookingId: string }> = await this.dataSource.query(
`SELECT DISTINCT booking_id AS "bookingId"
FROM freight.booking_handovers
WHERE booking_id = ANY($1::uuid[])
AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'`,
[ids],
);
const pending = new Set(rows.map((r) => r.bookingId));
for (const b of bookings) {
(b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
pending.has(b.id);
}
}
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
@@ -1135,7 +1159,7 @@ export class BookingsService {
const statusFilter = this.parseStatusFilter(filter);
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
return this.bookingsRepository.findAllPaginated({
const result = await this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
@@ -1167,6 +1191,8 @@ export class BookingsService {
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
await this.attachHandoverFlags(result.items ?? []);
return result;
}
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */

View File

@@ -0,0 +1,88 @@
import { Repository } from "typeorm";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from "./entities/company-change-request.entity";
type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
const COMPANY_ID = "company-1";
/**
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
*/
function mockRepositoryOver(rows: Row[]) {
return {
findOne: jest.fn(
({ where }: { where: Partial<Row> & { companyId: string } }) =>
Promise.resolve(
rows
.filter(
(row) =>
where.companyId === COMPANY_ID &&
(where.status === undefined || row.status === where.status),
)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
null,
),
),
} as unknown as Repository<CompanyChangeRequest>;
}
function subject(rows: Row[]) {
return new CompanyChangeRequestRepository(mockRepositoryOver(rows));
}
describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
const rejected: Row = {
id: "rejected",
status: ChangeRequestStatus.Rejected,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
};
it("returns the pending request when one is open", async () => {
const pending: Row = {
id: "pending",
status: ChangeRequestStatus.Pending,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
const result = await subject([rejected, pending]).findLatestOpenByCompanyId(
COMPANY_ID,
);
expect(result?.id).toBe("pending");
});
it("returns the latest rejected request when nothing is pending", async () => {
const result = await subject([rejected]).findLatestOpenByCompanyId(
COMPANY_ID,
);
expect(result?.id).toBe("rejected");
});
it("returns null once a resubmit of a rejected request is approved", async () => {
const approved: Row = {
id: "approved",
status: ChangeRequestStatus.Approved,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
const result = await subject([
rejected,
approved,
]).findLatestOpenByCompanyId(COMPANY_ID);
expect(result).toBeNull();
});
it("returns null when the company has no requests", async () => {
const result = await subject([]).findLatestOpenByCompanyId(COMPANY_ID);
expect(result).toBeNull();
});
});

View File

@@ -28,18 +28,23 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
/**
* The company's latest "open" request — pending (locks the customer) or the
* most recent rejected one (drives the reapply banner + prefill). Approved
* requests are terminal and ignored here.
* most recent rejected one (drives the reapply banner + prefill).
*
* Only the company's newest request may be open. A rejection is superseded the
* moment the customer resubmits: that resubmit opens a *new* request, so once
* it is approved the newest request is terminal and nothing is open — even
* though the older rejected row still sits in the table as history.
*/
async findLatestOpenByCompanyId(
companyId: string,
): Promise<CompanyChangeRequest | null> {
const pending = await this.findPendingByCompanyId(companyId);
if (pending) return pending;
return this.repository.findOne({
where: { companyId, status: ChangeRequestStatus.Rejected },
const latest = await this.repository.findOne({
where: { companyId },
order: { createdAt: "DESC" },
});
return latest?.status === ChangeRequestStatus.Rejected ? latest : null;
}
async findById(id: string): Promise<CompanyChangeRequest | null> {

View File

@@ -1,4 +1,4 @@
import { Injectable } from "@nestjs/common";
import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
@@ -20,6 +20,11 @@ const PREFIX_MAP: Record<ProfileType, string> = {
[ProfileType.transporter]: "TR",
};
const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
/** Numbers per series letter: A00001..A99999, then B00001. */
const SERIES_SIZE = 99_999;
@Injectable()
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
constructor(
@@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
const result = await this.repository.query(
`SELECT nextval('${seqName}') AS next_id`,
);
const nextId = result[0].next_id as number;
const nextId = Number(result[0].next_id);
const offset = nextId - 1;
const seriesIndex = Math.floor(offset / SERIES_SIZE);
if (seriesIndex >= SERIES_LETTERS.length) {
throw new InternalServerErrorException(
`Company profile reference series exhausted for type "${type}"`,
);
}
const letter = SERIES_LETTERS[seriesIndex];
const number = (offset % SERIES_SIZE) + 1;
const prefix = PREFIX_MAP[type];
return `${prefix}-${String(nextId).padStart(5, "0")}`;
return `${prefix}-${letter}${String(number).padStart(5, "0")}`;
}
async findByCompanyId(companyId: string): Promise<CompanyProfile[]> {

View File

@@ -60,7 +60,7 @@ export class CompanyProfile extends BaseEntity {
type!: ProfileType;
/**
* Official profile reference (e.g. "EX-00001"). Minted only when the profile
* Official profile reference (e.g. "EX-A00001"). Minted only when the profile
* is approved (status → Active); pending/unapproved profiles carry NULL.
* The unique index tolerates this because Postgres treats NULLs as distinct.
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.

View File

@@ -593,7 +593,7 @@ export class ContractTransitionService {
if (!dto.otpPhone || !dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp);
await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',

View File

@@ -0,0 +1,150 @@
# GPS Tracking (GT06) — Operations & Device Configuration
GT06 trackers speak a **raw TCP binary protocol**, not HTTP/HTTPS. This shapes
everything about how the service is deployed and how devices are pointed at it.
---
## 1. Why GPS needs its own dedicated TCP port
- **Not HTTP.** GT06 devices send binary frames
(`0x78 0x78 | len | protocol | payload | serial | CRC16 | 0x0D 0x0A`).
An HTTP server receiving these answers `400 Bad Request` and closes.
- **Dedicated port required.** A listening socket is keyed on `(IP, port)`; two
listeners on the same pair collide (`EADDRINUSE`). The REST API already owns
its port, so GPS traffic needs a separate one.
- **No hostname routing.** GT06 frames carry no `Host` header and no TLS SNI, so
L7 proxies (Nginx `http`, AWS ALB, Cloudflare proxy) cannot route them by
domain. Routing must happen at **Layer 4 (TCP)** by port.
- **DNS carries no port.** An A record maps a name to an IP only. The tracker
config must state the port explicitly (e.g. `gps.example.com:5023`).
### Operational requirements
| Item | Value |
| --- | --- |
| Protocol | Raw TCP (not HTTP, not TLS) |
| Default port | `5023` (configurable via `GT06_TCP_PORT`) |
| Listener bind | `0.0.0.0` inside the `freight-gps` container |
| Edge terminator | **L4** — AWS NLB or Nginx `stream {}`. **Not** ALB / Cloudflare proxy. |
---
## 2. Port configuration
`5023` is only this project's default — **not** a GT06 protocol requirement. The
listener binds whatever `GT06_TCP_PORT` says, as long as trackers are configured
with the same number.
Host and container ports are decoupled in `docker-compose.yaml`:
```yaml
freight-gps:
ports:
- "${GT06_TCP_PORT:-5023}:5023" # host is configurable; container fixed
environment:
GT06_TCP_PORT: "5023" # pinned inside the container
```
- The **container** always listens on `5023`.
- The **host/public** port is configurable (443, 5023, 9000, …) via the root
`.env`'s `GT06_TCP_PORT`.
- This split is required because the image runs as a **non-root** user
(`nestjs`, uid 1001), which cannot bind ports `<1024`. Docker (root) binds the
host port and forwards to `5023` inside.
- Running **outside Docker** (`pnpm dev:gps`, systemd), `GT06_TCP_PORT` is the
actual bind port, so `<1024` needs root or `CAP_NET_BIND_SERVICE`.
- **443 is allowed but risky:** GT06 stays raw TCP, not TLS. Middleboxes that
expect a TLS handshake on 443 may drop the connection.
---
## 3. Deployment topology
The GT06 listener runs as its own process (`dist/main.gps.js`, module
`GpsIngestModule`) — DB + GPS only, no HTTP server. It shares the `edr_freight`
DB with the API; the DB is the seam (ingester writes `gps_devices` /
`gps_positions`, API reads them).
```
freight-api HTTP :3001 GT06_TCP_PORT=0 (listener off, applies migrations)
freight-gps TCP :5023 DB_MIGRATIONS_RUN=false (owns the tracker socket)
```
`DB_MIGRATIONS_RUN=false` keeps the second process from racing migrations.
Horizontal scale: each tracker holds one long-lived TCP connection with
per-socket session state, so N `freight-gps` replicas can run behind an L4 LB —
each device sticks to one replica. `ensureDevice` is safe under concurrency
(unique IMEI).
---
## 4. Device configuration (GT06 side)
Config is done by **SMS to the tracker's SIM**. Commands below are the canonical
Concox/GT06 set — **verify against your unit's sheet**, syntax varies by firmware.
Default command password is usually `123456`.
Prep: data-enabled SIM, SMS on, **SIM PIN off**, know your carrier APN.
```
STATUS# # 1. sanity check — returns GSM/GPS/batt/GPRS
APN,<apn># # 2. carrier data APN (add ,user,pass if needed)
SERVER,1,gps.example.com,5023,0# # 3. point at server (1=domain). Port MUST match GT06_TCP_PORT
GPRSON,1# # 4. enable data
GPSON,1# # enable GPS
TIMER,10# # 5. upload interval, seconds (some use UPLOAD,10#)
RESET# # 6. reboot so it reconnects (many cache DNS until reboot)
```
Raw-IP variant of step 3: `SERVER,0,203.0.113.50,5023,0#`
Custom host port (e.g. 443): `SERVER,1,gps.example.com,443,0#`
### Verify from the server
```bash
docker compose logs -f freight-gps | grep -Ei "login|Auto-registering|ingester up"
nc -vz gps.example.com 5023
curl -H "Authorization: Bearer <token>" https://api.example.com/api/gps/positions/latest
```
First login packet **auto-registers** the IMEI (no manual step). `online:true`
only when `lastSeenAt` < 5 min (computed at read time).
### Link a tracker to a vehicle (optional)
Auto-register leaves `vehicleId` null. Attach it (needs `tracking.manage`):
```
PATCH /api/gps/devices/:id { "vehicleId": "<uuid>", "name": "Truck 03-ET" }
```
### Failure map
| Symptom | Cause |
| --- | --- |
| No SMS reply | SIM PIN on / no signal / wrong number |
| Replies but never connects | APN wrong, or `SERVER` port `GT06_TCP_PORT` |
| Connects then drops | server not ACKing, or middlebox on 443 expecting TLS |
| Registered but `online:false` | packets blocked by firewall open inbound TCP |
| Wrong location / `positioned:false` | no GPS fix yet open sky, cold start ~12 min |
---
## 5. Security
- GT06 authenticates with **IMEI only**, which is **spoofable**. Anyone who can
reach the port can inject fake positions.
- **Do not** expose the port to `0.0.0.0/0`. Restrict at the firewall / security
group to the SIM provider's **APN / IP range**.
- Trackers must use the same host+port as the server:
`SERVER,1,gps.example.com,<port>,0#`.
---
## 6. Edge (L4) termination
See [`infrastructure/nginx/gps-stream.conf`](../../../../../infrastructure/nginx/gps-stream.conf)
for an Nginx `stream {}` example, and the AWS NLB notes in the same file.
Reminder: **L4 only** an HTTP proxy cannot route GT06.

View File

@@ -13,8 +13,10 @@ import {
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import {
AuthUserPayload,
@@ -24,6 +26,8 @@ import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
import { NotificationInboxService } from "./notification-inbox.service";
@ApiTags("notifications")
@ApiBearerAuth()
@UseGuards(JwtGuard)
@Controller("notifications")
export class NotificationInboxController {
constructor(private readonly service: NotificationInboxService) {}

View File

@@ -15,9 +15,10 @@ import { WsAuthService } from "./ws-auth.service";
/**
* Server → client push for in-app notifications. Clients only *listen* (no
* `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here;
* the handshake is authenticated in `handleConnection` and each socket joins a
* private `user:<id>` room the service targets.
* `@SubscribeMessage` handlers), and `@UseGuards(JwtGuard)` on the REST
* controller does not cover WebSockets; the handshake is authenticated in
* `handleConnection` and each socket joins a private `user:<id>` room the
* service targets.
*/
@WebSocketGateway({
namespace: NOTIFICATION_WS_NAMESPACE,

View File

@@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy {
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
// axios defaults to no timeout — a hanging gateway would block the caller
// (and any transaction it sits in) indefinitely. Always bound the wait.
const timeout = Number(this.configService.get<string>("SMS_TIMEOUT_MS") ?? 8000);
try {
const response = await axios.post(
url,
@@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy {
callbackUrl: "",
},
{
timeout,
headers: {
accept: "*/*",
"Content-Type": "application/json",

View File

@@ -50,6 +50,9 @@ export class OtpService {
await this.otpRepository.createOtp(target, otp);
}
// A freshly issued code gets a fresh guess budget.
this.actionAttempts.delete(this.targetKey(target));
if (target.email) {
// send email (queued to RabbitMQ via the shared Email service)
await this.emailClient.sendEmail({
@@ -121,24 +124,44 @@ export class OtpService {
// ---------------------------------------------------------------------------
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
// contract signature). Unlike verifyOtp above — which marks a phone verified
// and leaves the code in place — this enforces a short TTL and consumes the
// code on success so it can never be replayed.
// contract signature, resetting a forgotten password). Unlike verifyOtp above
// — which marks a target verified and leaves the code in place — this enforces
// a TTL and consumes the code on success so it can never be replayed.
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
async verifyOtpForAction(phone: string, otp: string) {
const otpData = await this.otpRepository.findByPhone(phone);
// Without a cap, a 6-digit code guarding a password reset is brute-forceable
// within its own TTL. `otp_verifications` has no attempt column, so the
// counter lives here and the code is burned once the budget is spent.
// Per-process: it resets on restart and is not shared across replicas — a
// persisted counter needs a migration on OtpVerification.
private readonly MAX_ACTION_ATTEMPTS = 5;
private readonly actionAttempts = new Map<string, number>();
private targetKey(target: OtpTarget): string {
return target.email ? `email:${target.email}` : `phone:${target.phone}`;
}
async verifyOtpForAction(
target: OtpTarget,
otp: string,
ttlMs: number = this.ACTION_OTP_TTL_MS,
) {
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);
if (!otpData) {
throw new BadRequestException(
"No verification code was requested for this phone",
target.email
? "No verification code was requested for this email"
: "No verification code was requested for this phone",
);
}
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
if (ageMs > this.ACTION_OTP_TTL_MS) {
if (ageMs > ttlMs) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
throw new BadRequestException(
"Verification code has expired. Request a new one.",
@@ -146,11 +169,24 @@ export class OtpService {
}
if (otpData.otp !== otp) {
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
throw new BadRequestException(
"Too many incorrect attempts. Request a new code.",
);
}
this.actionAttempts.set(key, attempts);
throw new BadRequestException("Invalid verification code");
}
// single-use: consume on success
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
return { success: true };
}

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
/** Records a DO / release order being sent to the customer for import pickup. */
export class ReleaseOrderDto {
@@ -90,4 +90,13 @@ export class ReleaseOrderDto {
@IsOptional()
@IsDateString()
gateOutTime?: string;
@ApiPropertyOptional({
description:
'Container bookings only: the operator chose not to weigh this truck. ' +
'Tare/gross become optional and the container weight match is skipped. Bulk always weighs.',
})
@IsOptional()
@IsBoolean()
weighingSkipped?: boolean;
}

View File

@@ -36,7 +36,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
// Reserve is retired from the operator flow — a stored export item advances
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
STORED: ['RESERVED', 'READY_FOR_LOADING'],
// READY_FOR_PICKUP is the way back out for an IMPORT item that was parked in
// storage from READY_FOR_PICKUP; without it, Store is a one-way door.
STORED: ['RESERVED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP'],
RESERVED: ['READY_FOR_LOADING'],
READY_FOR_LOADING: ['LOADED'],
LOADED: ['DISPATCHED'],

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, IsNull } from 'typeorm';
@@ -150,6 +151,37 @@ export class HandoverService {
);
}
/**
* Reminder loop: until a self-haul handover is signed, re-send the sign
* notification (in-app + SMS + email) every 5 minutes. One reminder per
* booking per tick, newest unsigned handover's reference. Stops the moment
* signForBooking() stamps signed_at.
*
* NB: runs in every API instance — keep a single instance in dev or the
* customer is reminded once per instance per tick.
*/
@Cron(CronExpression.EVERY_5_MINUTES, { name: 'handover-sign-reminder' })
async remindUnsignedHandovers(): Promise<void> {
try {
const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query(
`SELECT DISTINCT ON (booking_id)
booking_id AS "bookingId", reference
FROM freight.booking_handovers
WHERE signed_at IS NULL
AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'
ORDER BY booking_id, generated_at DESC`,
);
if (!rows.length) return;
this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`);
for (const row of rows) {
await this.notifySignNeeded(row.bookingId, row.reference);
}
} catch (err) {
this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`);
}
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
await this.dataSource

View File

@@ -860,6 +860,25 @@ export class WarehouseInventoryService {
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
/** Sent after the transaction commits so the gateway never blocks the receive. */
const pendingNotifications: Array<{
owner: {
phone?: string | null;
ownerName?: string | null;
bookingReference?: string | null;
grnNumber: string;
direction?: string | null;
warehouseId?: string | null;
};
booking: {
companyId?: string | null;
reference?: string | null;
hasFirstMile?: boolean;
hasLastMile?: boolean;
customerTruckAssignedAt?: string | null;
};
bookingId: string;
}> = [];
await this.dataSource.transaction(async (manager) => {
await this.validateLocation(manager, {
@@ -1032,21 +1051,33 @@ export class WarehouseInventoryService {
manager,
);
await this.notifyOwnerInventoryReceived({
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
ownerName: truckEntrance?.ownerName ?? booking.customer,
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
grnNumber,
direction: dto.direction,
warehouseId: dto.warehouseId,
// Queued, not sent here: an SMS/email round-trip inside the transaction
// holds capacity/location locks open for the whole gateway latency.
pendingNotifications.push({
owner: {
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
ownerName: truckEntrance?.ownerName ?? booking.customer,
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
grnNumber,
direction: dto.direction,
warehouseId: dto.warehouseId,
},
booking,
bookingId,
});
result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
void this.notifyTruckAssignmentNeeded(booking, bookingId);
}
});
// Fan out after commit, un-awaited: the receive response must not wait on the
// SMS gateway. Both notifiers swallow their own errors.
for (const pending of pendingNotifications) {
void this.notifyOwnerInventoryReceived(pending.owner);
void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
}
return result;
}
@@ -2326,6 +2357,27 @@ export class WarehouseInventoryService {
});
}
/**
* Self-haul = the customer's own truck collects the goods: either a truck
* assigned via the portal (customer_truck_assigned_at), or a walk-in truck
* registered at the gate on a booking with no EDR last-mile leg. EDR
* last-mile bookings are never self-haul.
*/
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
const runner = manager ?? this.dataSource;
const [row]: Array<{ ok: number }> = await runner.query(
`SELECT 1 AS ok
FROM freight.bookings b
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
WHERE b.id = $1 AND b.deleted_at IS NULL
AND (b.customer_truck_assigned_at IS NOT NULL
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
AND COALESCE(st.includes_last_mile, false) = false))`,
[bookingId],
);
return Boolean(row);
}
/** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
const item = await this.findById(id);
@@ -2335,19 +2387,17 @@ export class WarehouseInventoryService {
);
}
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
// Leaving = gate-out captured, with either a weighed gross or an explicit
// container weighing skip (bulk always weighs).
const isTruckLeaving =
Boolean(dto.gateOutTime) && (dto.grossWeight !== undefined || dto.weighingSkipped === true);
if (isTruckLeaving) {
await this.invoices.assertClearanceAllowed(id);
if (item.bookingId) {
const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> =
await this.dataSource.query(
`SELECT customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
// Self-haul = customer collects: a truck assigned via the portal, OR a
// walk-in truck registered at the gate on a booking with no EDR last mile.
const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId);
// Self-haul: the handover must be signed before the exit paper is issued.
// Prefer the structured handover record; fall back to the legacy note.
const handoverSigned =
@@ -2361,7 +2411,8 @@ export class WarehouseInventoryService {
// Authoritative weight match: the truck's net (gross tare) must equal the
// total VGM cargo weight of the containers selected as loaded on it.
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
// Skipped when the operator chose not to weigh (containers only).
if (!dto.weighingSkipped && dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
const selected = dto.containerNumber
.split(/[,;\n]+/)
.map((n) => n.trim())
@@ -2431,13 +2482,10 @@ export class WarehouseInventoryService {
[item.bookingId],
);
// Self-haul: generate the per-booking handover on first truck arrival
// (idempotent). It must be signed before the truck leaves.
const [selfHaul]: Array<{ ok: number }> = await manager.query(
`SELECT 1 AS ok FROM freight.bookings
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (selfHaul) {
// (idempotent) and notify the customer to sign it. Covers BOTH portal-
// assigned trucks and walk-in trucks registered manually at the gate
// (no portal assignment, no EDR last mile). Must be signed before leaving.
if (await this.isSelfHaulBooking(item.bookingId, manager)) {
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
}
}
@@ -4422,14 +4470,17 @@ export class WarehouseInventoryService {
if (!dto.driverName?.trim()) {
throw new BadRequestException('Driver name is required for exit inspection');
}
if (dto.tareWeight === undefined) {
// Container bookings may skip the weighbridge entirely (weighingSkipped);
// bulk always weighs.
const weighingSkipped = dto.weighingSkipped === true;
if (dto.tareWeight === undefined && !weighingSkipped) {
throw new BadRequestException('Tare weight is required for truck arrival');
}
const tareWeight = Number(dto.tareWeight);
const tareWeight = dto.tareWeight === undefined ? null : Number(dto.tareWeight);
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
const computedNetWeight =
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight =
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
@@ -4441,7 +4492,11 @@ export class WarehouseInventoryService {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
}
}
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
if (
!weighingSkipped &&
(dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) &&
grossWeight == null
) {
throw new BadRequestException('Gross weight is required for truck exit');
}
@@ -4457,7 +4512,8 @@ export class WarehouseInventoryService {
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
`Tare Weight: ${tareWeight} t`,
weighingSkipped ? 'Weighing: SKIPPED' : null,
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
@@ -4481,6 +4537,8 @@ export class WarehouseInventoryService {
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
// The weigh/skip decision is made at arrival and sticks for the exit.
weighingSkipped: dto.weighingSkipped || /^Weighing:\s*SKIPPED/im.test(inspection) || undefined,
};
}

View File

@@ -33,8 +33,9 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
},
{
fileKey: "commercial_license",
fileLabel: "Commercial License",
helpText: "Verified against the government trade system during registration.",
fileLabel: "Commercial Registration",
helpText:
"Verified against the government trade system during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -108,7 +109,8 @@ const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [
{
fileKey: "business_license",
fileLabel: "Business License / Trade License",
helpText: "Verified against the government trade system during registration.",
helpText:
"Verified against the government trade system during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -489,9 +491,14 @@ const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
const CONTRACT_INTAKE_ENTITY = "contract_intake";
const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [
clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, {
required: false,
}),
clearanceField(
"commercial_framework",
"Commercial Framework / Agreement",
1,
{
required: false,
},
),
clearanceField("onboarding_attachment", "Onboarding Attachment", 2, {
required: false,
}),
@@ -542,7 +549,7 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
constructor(private readonly dataSource: DataSource) { }
async run() {
await this.dataSource.transaction(async (manager) => {
@@ -552,35 +559,35 @@ export class FileUploadSettingsSeeder {
const allSettings: Array<
OnboardingDocumentSetting & { description: string }
> = [
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
...s,
description: COMPANY_ONBOARDING_DESCRIPTION,
})),
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
...s,
description: CLEARANCE_DESCRIPTION,
})),
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
})),
...SELF_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
})),
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
...s,
description:
"Commercial/framework documents attached at contract submission.",
})),
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
];
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
...s,
description: COMPANY_ONBOARDING_DESCRIPTION,
})),
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
...s,
description: CLEARANCE_DESCRIPTION,
})),
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
})),
...SELF_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
})),
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
...s,
description:
"Commercial/framework documents attached at contract submission.",
})),
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
];
for (const documentSetting of allSettings) {
await settingRepository.upsert(
@@ -601,7 +608,9 @@ export class FileUploadSettingsSeeder {
});
if (!setting) {
throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`);
throw new Error(
`file_upload_setting_seed_failed:${documentSetting.code}`,
);
}
await fieldRepository.delete({ settingId: setting.id });

View File

@@ -131,6 +131,7 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'),
perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'),
perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'),
perm('d1a00001-0001-4000-8000-000000000006', 'edr_freight_app:customers:reset-password', 'Trigger customer password reset'),
];
// D. Finance — payments + invoices
@@ -395,6 +396,7 @@ export const FREIGHT_PERMS = {
update: 'edr_freight_app:customers:update',
deactivate: 'edr_freight_app:customers:deactivate',
verify: 'edr_freight_app:customers:verify',
resetPassword: 'edr_freight_app:customers:reset-password',
},
payments: {
view: 'edr_freight_app:payments:view',