This commit is contained in:
Roba Boru
2026-07-20 15:50:42 +03:00
93 changed files with 2728 additions and 1937 deletions

View File

@@ -22,6 +22,10 @@ TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
# Public origin of the freight customer portal. Password-reset links sent to
# customers are built against this — it must be browser-reachable.
FREIGHT_PORTAL_URL=http://localhost:5173
# Portal pages the payment provider redirects the browser to after payment.
# Point these at the freight portal's public payment result routes.
PAYMENT_RETURN_URL=http://localhost:5173/payment/success

View File

@@ -7,16 +7,23 @@ const SUPER_ADMIN_ROLE = 'super_admin';
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
type PermissionLike = { key?: string };
type PositionTypeLike = { key?: string };
type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?:
| {
position?: { permissions?: PermissionLike[] };
position?: {
permissions?: PermissionLike[];
positionType?: PositionTypeLike | null;
};
delegatedPositions?: { permissions?: PermissionLike[] }[];
}
| {
positions?: { permissions?: PermissionLike[] }[];
positions?: {
permissions?: PermissionLike[];
positionType?: PositionTypeLike | null;
}[];
}[]
| null;
};
@@ -90,12 +97,110 @@ export function assertFreightPermission(
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
}
/**
* The caller's IAM position-type keys (`iam.position_types.key`). A position
* type is the platform's notion of a role — it is what carries permissions via
* `iam.position_type_permissions` — and it is the vocabulary contract approval
* chains are configured in.
*
* Mirrors `collectPermissionKeys`' handling of both JWT shapes: `employee` is
* an object on some tokens and an array on others.
*
* Note delegated positions carry no `positionType` in the token, so a delegate
* is not reachable here — they authorize through the permission arm of
* `assertCanApproveContractStep` instead.
*/
export function collectPositionTypeKeys(
user: MeLikeUser | null | undefined,
): string[] {
const employee = user?.employee;
if (!employee) return [];
const keys = new Set<string>();
if (Array.isArray(employee)) {
for (const emp of employee) {
for (const pos of emp.positions ?? []) {
if (pos.positionType?.key) keys.add(pos.positionType.key);
}
}
return [...keys];
}
if (employee.position?.positionType?.key) {
keys.add(employee.position.positionType.key);
}
return [...keys];
}
/**
* Legacy chain roles predate position types. Historical `approval_rules` and
* in-flight `contract_approval_steps` rows still carry them, so map each to the
* position types that stand in for it. Without this, an approver holding a
* modern position type could not action an older step.
*/
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
LINE_STAFF: ['employee', 'teamLeader', 'officeHead', 'recordOfficer'],
DIRECTOR: ['director', 'operation-director'],
CEO: ['chief', 'deputy'],
};
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
const ANY_CONTRACT_APPROVE_PERMISSION = [
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.approveCeo,
];
/**
* May this caller action a contract approval step requiring `requiredRole`?
*
* `requiredRole` is an `iam.position_types.key` for chains configured by an
* admin, or one of the legacy LINE_STAFF/DIRECTOR/CEO strings for older rows.
* A caller passes when any of these hold:
*
* - they are a super/organization admin (blanket bypass);
* - their position type matches the step, directly or via a legacy alias;
* - they hold the approve permission the legacy role maps to;
* - they hold any contract approve permission — this covers delegates (whose
* position type is absent from the token) and staff whose IAM position has
* no position type assigned yet.
*/
export function assertCanApproveContractStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,
): void {
if (isFreightApprovalAdmin(user)) return;
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return;
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
if (aliases.some((alias) => positionTypes.includes(alias))) return;
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
if (legacyPermission && hasFreightPermission(user, legacyPermission)) return;
if (ANY_CONTRACT_APPROVE_PERMISSION.some((p) => hasFreightPermission(user, p))) {
return;
}
throw new ForbiddenException(
`You are not the required approver (${requiredRole}) for this step.`,
);
}
export function assertCanApproveBookingStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,

View File

@@ -9,6 +9,14 @@ export default registerAs("app", () => ({
env: process.env.NODE_ENV ?? "development",
port: parseInt(process.env.PORT ?? "3001", 10),
apiPrefix: "api",
/**
* Public origin of the freight customer portal. Password-reset links mailed
* or SMS'd to customers are built against this, so it must be the address the
* customer's browser can actually reach — not an internal service name.
*/
portalBaseUrl: (
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
).replace(/\/+$/, ""),
trainScheduling: {
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Bookings no longer run an approval chain — accepting an intake approves the
* booking outright and generates its contract. The approval chain is now a
* contract-only concern, so `freight.approval_rules` is read by contracts alone.
*
* Also widens the role columns: chain steps now reference IAM position-type
* keys (`iam.position_types.key`), and real keys run past the old varchar(30)
* (e.g. '-marketing-manager-/-general-manager' is 38 chars), which would fail
* on insert.
*/
export class DropBookingApprovalWidenRoles2410000000000
implements MigrationInterface
{
name = 'DropBookingApprovalWidenRoles2410000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.booking_approval_step;`,
);
for (const [table, column] of [
['approval_rules', 'required_role'],
['approval_rules', 'blocks_role'],
['contract_approval_steps', 'required_role'],
['contract_approval_steps', 'blocks_role'],
] as const) {
await queryRunner.query(
`ALTER TABLE freight.${table} ALTER COLUMN ${column} TYPE varchar(64);`,
);
}
}
/**
* No-op: the booking approval chain is retired, so re-creating the table
* would leave dead schema behind. Narrowing the role columns again would
* truncate any position-type key already stored.
*/
public async down(): Promise<void> {
// intentionally empty
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Audit trail for contract document edits. The document stays editable through
* the whole approval chain (each approver may edit on their turn), so the
* contract itself only ever holds the current snapshot — this table records who
* changed which article, and when.
*/
export class CreateContractDocumentRevisions2420000000000
implements MigrationInterface
{
name = 'CreateContractDocumentRevisions2420000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_document_revisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
contract_id uuid NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
actor_id uuid,
actor_role varchar(64),
step_id uuid,
summary varchar(255),
changes jsonb NOT NULL DEFAULT '[]'::jsonb
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_contract_document_revisions_contract
ON freight.contract_document_revisions (contract_id, created_at DESC);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.contract_document_revisions;`,
);
}
}

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Get,
NotFoundException,
Param,
ParseUUIDPipe,
@@ -11,11 +12,14 @@ 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";
import {
CustomerResetService,
CustomerResetTarget,
} 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.
* Staff-triggered password reset. The customer receives a single-use link and
* sets their own password — staff never see or handle a credential.
*/
@ApiTags("backoffice")
@Controller("backoffice/customers")
@@ -23,26 +27,45 @@ import { CustomerResetService } from "./customer-reset.service";
export class CustomerResetController {
constructor(private readonly customerResetService: CustomerResetService) {}
@Get(":companyId/reset-target")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
@ApiOperation({
summary: "The primary contact's IAM account a reset link would be sent to",
})
async resetTarget(
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<CustomerResetTarget> {
const target = await this.customerResetService.getResetTarget(companyId);
if (!target) {
throw new NotFoundException(
"This customer has no active primary-contact account to reset",
);
}
return target;
}
@Post(":companyId/reset-password")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
@ApiOperation({
summary: "Send a password-reset code to a customer's primary contact",
summary: "Send a password-reset link to a customer's primary contact",
})
async resetPassword(
@Param("companyId", ParseUUIDPipe) companyId: string,
@Body() dto: BackofficeResetPasswordDto,
) {
const maskedTarget = await this.customerResetService.sendResetToCustomer(
const sent = await this.customerResetService.sendResetLinkToCustomer(
companyId,
dto.channel,
);
if (!maskedTarget) {
if (!sent) {
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 };
return sent;
}
}

View File

@@ -1,10 +1,31 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
import { EmailClientService } from "../notifications/email-client.service";
import { SmsClientService } from "../notifications/sms-client.service";
import { ResetChannel } from "./dto/forgot-password.dto";
import { ForgotPasswordService } from "./forgot-password.service";
import {
ForgotPasswordService,
RESET_LINK_TTL_MS,
} from "./forgot-password.service";
import { maskOtpTarget } from "./mask-target.util";
/** The account a staff-triggered reset would land on. */
export interface CustomerResetTarget {
userId: string;
name: string;
email: string | null;
phone: string | null;
}
export interface SentResetLink {
channel: ResetChannel;
maskedTarget: string;
expiresAt: string;
}
@Injectable()
export class CustomerResetService {
@@ -14,19 +35,110 @@ export class CustomerResetService {
@InjectRepository(ExternalProfile)
private readonly externalProfileRepository: Repository<ExternalProfile>,
private readonly forgotPasswordService: ForgotPasswordService,
private readonly emailClient: EmailClientService,
private readonly smsClient: SmsClientService,
private readonly config: ConfigService,
) {}
/**
* 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.
* The IAM account a reset would actually reach. The backoffice shows these
* values rather than `company.email` / `company.phone`: the company row holds
* business contact detail, while the link is delivered to the primary
* contact's own login credentials — the two drift apart routinely, and showing
* the wrong one has staff telling customers to check an inbox nothing was sent
* to.
*/
async getResetTarget(companyId: string): Promise<CustomerResetTarget | null> {
const resolved = await this.resolvePrimaryContactUser(companyId);
if (!resolved) return null;
const { profile, user, userId } = resolved;
return {
userId,
name: `${profile.firstName} ${profile.lastName}`.trim(),
email: user.email ?? null,
phone: user.phoneNumber ?? null,
};
}
/**
* Mint a password-reset link and send it 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(
async sendResetLinkToCustomer(
companyId: string,
channel: ResetChannel,
): Promise<string | null> {
): Promise<SentResetLink | null> {
const resolved = await this.resolvePrimaryContactUser(companyId);
if (!resolved) return null;
const { user, userId } = resolved;
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) return null;
// Mint first, send second: a failed send leaves an unused ticket that simply
// expires, whereas sending a link before the ticket exists would hand the
// customer a URL that is dead on arrival.
const ticket = await this.forgotPasswordService.mintResetTicket(
userId,
RESET_LINK_TTL_MS,
);
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
const { queued } = target.email
? await this.emailClient.sendEmail({
to: target.email,
subject: "Reset your EDR Freight password",
text:
"A password reset was started for your EDR Freight account.\n\n" +
`Open this link to choose a new password:\n${link}\n\n` +
"The link expires in 24 hours and can only be used once. If you did " +
"not expect this, ignore this message — your password stays unchanged.",
})
: await this.smsClient.sendSms({
to: target.phone as string,
message: `Reset your EDR Freight password: ${link} (expires in 24 hours, single use)`,
});
this.logger.log(
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
);
if (!queued) {
// The ticket is committed and the backoffice is about to say "link sent",
// but nothing left this process — with RABBITMQ_ENABLED=false both clients
// are no-ops. Without this line the only symptom is a customer who never
// receives anything, indistinguishable from carrier loss.
this.logger.error(
`reset-link.dispatch.dropped channel=${channel} user=${userId} rabbitmqEnabled=${
process.env.RABBITMQ_ENABLED ?? "unset"
} — transport reported no hand-off; no link will arrive`,
);
// SECURITY: logs a live password-reset credential in cleartext. Same
// deliberate tradeoff the OTP service makes — this is the only way to
// complete a reset on an environment with no broker. Only reached when
// delivery already failed.
this.logger.warn(`Undelivered reset link for user ${userId}: ${link}`);
}
return {
channel,
maskedTarget: maskOtpTarget(target),
expiresAt: expiresAt.toISOString(),
};
}
/**
* The company's primary contact, gated on the same active-account rule the
* public flow uses — so a suspended customer cannot be reactivated by a
* staff-triggered reset (IAM's `set-password` flips `isActive` back on).
*/
private async resolvePrimaryContactUser(companyId: string) {
const profile = await this.externalProfileRepository.findOne({
where: { companyId, isPrimaryContact: true },
});
@@ -36,24 +148,28 @@ export class CustomerResetService {
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) {
if (!user?.id) {
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;
return { profile, user, userId: user.id };
}
this.logger.log(
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
);
return this.forgotPasswordService.maskTarget(target);
/**
* The portal route that trades the token for a set-password form. Params are
* URL-encoded because the token is base64url — safe as-is, but the encoding
* keeps this correct if the token format ever changes.
*/
private buildResetLink(userId: string, token: string): string {
const base = this.config.get<string>("app.portalBaseUrl");
return `${base}/reset-password?uid=${encodeURIComponent(
userId,
)}&token=${encodeURIComponent(token)}`;
}
}

View File

@@ -1,7 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
/** The channel the reset code is delivered over. */
/**
* The channel a reset LINK is delivered over. The OTP flow no longer picks one —
* it sends to every contact on the account — but the staff-triggered link flow
* still delivers over exactly one transport.
*/
export enum ResetChannel {
Email = "email",
Phone = "phone",
@@ -16,13 +20,27 @@ export class ForgotPasswordRequestDto {
@IsNotEmpty()
identifier!: string;
@ApiProperty({ enum: ResetChannel })
/**
* Accepted and ignored. The code now goes to the account's email AND phone,
* so there is nothing to choose — kept optional so clients still sending it
* (older portal/backoffice builds) are not rejected outright.
* @deprecated
*/
@ApiPropertyOptional({
enum: ResetChannel,
deprecated: true,
description: "Ignored — the code is sent to every contact on the account.",
})
@IsOptional()
@IsEnum(ResetChannel)
channel!: ResetChannel;
channel?: ResetChannel;
}
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
@ApiProperty({
description:
"The 6-digit code sent to the account's email and phone. Either delivery carries the same code.",
})
@IsString()
@IsNotEmpty()
otp!: string;
@@ -33,3 +51,19 @@ export class BackofficeResetPasswordDto {
@IsEnum(ResetChannel)
channel!: ResetChannel;
}
/**
* The two halves of a reset link's query string. Together they stand in for the
* identifier + OTP pair of the typed flow: the token proves possession of the
* inbox/handset the link was delivered to.
*/
export class ResolveResetLinkDto {
@ApiProperty({ description: "IAM user id from the reset link's `uid` param" })
@IsUUID()
userId!: string;
@ApiProperty({ description: "Opaque token from the reset link's `token` param" })
@IsString()
@IsNotEmpty()
token!: string;
}

View File

@@ -5,8 +5,13 @@ import { Public } from "@edr/api-common";
import {
ForgotPasswordRequestDto,
ForgotPasswordVerifyDto,
ResolveResetLinkDto,
} from "./dto/forgot-password.dto";
import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
import {
ForgotPasswordService,
ResetLinkAccount,
ResetTicket,
} from "./forgot-password.service";
/**
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
@@ -24,17 +29,19 @@ export class ForgotPasswordController {
@Post("forgot-password/request")
@ApiOperation({
summary: "Send a password-reset code over email or SMS",
summary: "Send a password-reset code to the account's email AND phone",
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.",
"One code, delivered over every contact the account has; either delivery " +
"verifies it. Always reports success — an unknown, inactive, or contactless " +
"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);
await this.forgotPasswordService.requestReset(user);
} catch (error) {
// A delivery failure must not change the response shape either — log it
// and let the caller sit on the OTP screen.
@@ -60,10 +67,18 @@ export class ForgotPasswordController {
"alongside the same identifier and the new password.",
})
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
return this.forgotPasswordService.verifyAndMintTicket(
dto.identifier,
dto.channel,
dto.otp,
);
return this.forgotPasswordService.verifyAndMintTicket(dto.identifier, dto.otp);
}
@Post("forgot-password/resolve-link")
@ApiOperation({
summary: "Validate a staff-issued reset link and return its set-password ticket",
description:
"Takes the link's uid/token pair. The returned { userId, identifier, verificationCode } " +
"is the body for PATCH /api/auth/set-password, so the customer never types an identifier. " +
"A bad or expired link is rejected here rather than after the password is typed.",
})
resolveLink(@Body() dto: ResolveResetLinkDto): Promise<ResetLinkAccount> {
return this.forgotPasswordService.resolveResetLink(dto.userId, dto.token);
}
}

View File

@@ -4,7 +4,7 @@ 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 { hashPassword, verifyPassword } 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";
@@ -22,11 +22,32 @@ 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;
/**
* A staff-triggered reset link lives longer than a typed OTP: the customer may
* only see the SMS/email hours after the call that prompted it.
*/
export const RESET_LINK_TTL_MS = 24 * 60 * 60 * 1000;
/** IAM refuses a ticket once its row hits this many failed attempts. */
const MAX_TICKET_ATTEMPTS = 5;
export interface ResetTicket {
userId: string;
verificationCode: string;
}
/**
* What a valid reset link resolves to. `identifier` is the value IAM's
* `set-password` matches the user on (it accepts email / username / phone), so
* the portal can spend the ticket without the customer typing anything.
*/
export interface ResetLinkAccount {
userId: string;
identifier: string;
maskedIdentifier: string;
verificationCode: string;
}
@Injectable()
export class ForgotPasswordService {
private readonly logger = new Logger(ForgotPasswordService.name);
@@ -81,8 +102,12 @@ export class ForgotPasswordService {
.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 {
/**
* A single channel of the account, for flows that genuinely deliver over one
* transport (the staff-triggered reset LINK picks email or SMS). Taken from
* the account — never from input.
*/
targetFor(user: User, channel: ResetChannel): OtpTarget | null {
if (channel === ResetChannel.Email) {
return user.email ? { email: user.email } : null;
}
@@ -90,20 +115,40 @@ export class ForgotPasswordService {
}
/**
* 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.
* Every contact the account has. The reset OTP goes to all of them and any one
* verifies it — a customer whose SMS never lands can finish from their inbox
* without restarting the flow on a different channel. An account holding only
* one of the two degrades to that channel; only a contactless account is null.
*/
targetsFor(user: User): OtpTarget | null {
const target: OtpTarget = {};
if (user.email) target.email = user.email;
if (user.phoneNumber) target.phone = user.phoneNumber;
return target.email || target.phone ? target : null;
}
/**
* The value IAM's `set-password` will match this account on. It looks the user
* up by email OR username OR phoneNumber (and lowercases whatever it is
* given), so prefer email, then phone, and fall back to username last —
* a mixed-case username would not survive that lowercasing.
*/
private identifierFor(user: User): string | null {
return user.email ?? user.phoneNumber ?? user.username ?? null;
}
/**
* Send one reset code to every contact on the account — email AND phone —
* returning 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.
* replaces every row the target overlaps. A reset request therefore overwrites
* any pending signup code for the same addresses — 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);
async requestReset(user: User): Promise<OtpTarget | null> {
const target = this.targetsFor(user);
if (!target) return null;
await this.otpService.sendOtp(target);
@@ -120,11 +165,12 @@ export class ForgotPasswordService {
*/
async verifyAndMintTicket(
identifier: string,
channel: ResetChannel,
otp: string,
): Promise<ResetTicket> {
const user = await this.resolveActiveUser(identifier);
const target = user && this.targetFor(user, channel);
// Same set of contacts `requestReset` sent to, so the code resolves whichever
// of the two the customer actually received it on.
const target = user && this.targetsFor(user);
if (!user?.id || !target) {
// Same shape as a wrong code: a caller probing for accounts learns nothing
@@ -134,9 +180,18 @@ export class ForgotPasswordService {
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
return await this.mintResetTicket(user.id, RESET_TICKET_TTL_MS);
}
/**
* Mint a single-use IAM reset ticket. Shared by the OTP flow (where the code
* is the proof of possession) and the staff-triggered link flow (where the
* ticket travels in the link and delivery to the account's own inbox/handset
* is the proof).
*/
async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
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);
@@ -147,7 +202,7 @@ export class ForgotPasswordService {
userId,
otpType: EOtpType.RESET_PASSWORD,
verificationCode,
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
expiresAt: new Date(Date.now() + ttlMs),
isUsed: false,
attemptCount: 0,
});
@@ -157,6 +212,63 @@ export class ForgotPasswordService {
return { userId, verificationCode: code };
}
/**
* Validate a reset link and hand back everything the portal needs to spend it
* on IAM's `PATCH /api/auth/set-password`.
*
* The checks mirror IAM's own — newest row, unused, unexpired, attempts left,
* argon match — so a link that resolves here is one IAM will honour. Doing
* them up front is what lets the page say "this link has expired" before the
* customer types a password rather than after.
*
* Every rejection is the same message: a link is a bearer credential, and the
* holder of a bad one learns nothing about why it failed or whether the user
* id exists.
*/
async resolveResetLink(
userId: string,
token: string,
): Promise<ResetLinkAccount> {
const invalid = new BadRequestException(
"This password-reset link is invalid or has expired. Request a new one.",
);
const user = await this.resolveActiveUserById(userId);
const identifier = user && this.identifierFor(user);
if (!user || !identifier) throw invalid;
const verification = await this.dataSource
.getRepository(UserVerification)
.findOne({
where: { userId, otpType: EOtpType.RESET_PASSWORD },
order: { createdAt: "DESC" },
});
// `expiresAt` / `attemptCount` are optional on IAM's entity but always
// written by `mintResetTicket`. A row missing either is malformed, so treat
// it as expired rather than letting it through unchecked.
if (
!verification ||
verification.isUsed ||
!verification.expiresAt ||
verification.expiresAt < new Date() ||
(verification.attemptCount ?? 0) >= MAX_TICKET_ATTEMPTS ||
!(await verifyPassword(token, verification.verificationCode))
) {
this.logger.warn(`Reset link rejected for user ${userId}`);
throw invalid;
}
return {
userId,
identifier,
maskedIdentifier: maskOtpTarget(
identifier.includes("@") ? { email: identifier } : { phone: identifier },
),
verificationCode: token,
};
}
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
maskTarget(target: OtpTarget): string {
return maskOtpTarget(target);

View File

@@ -7,6 +7,7 @@ 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 { NotificationsModule } from '../notifications/notifications.module';
import { OtpModule } from '../otp/otp.module';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
@@ -29,6 +30,8 @@ import { FreightMeService } from './freight-me.service';
Employee,
]),
OtpModule,
// Reset links go out over email/SMS directly, not through the OTP service.
NotificationsModule,
],
controllers: [
FreightMeController,

View File

@@ -1,16 +1,27 @@
import { OtpTarget } from "../otp/otp.service";
function maskEmail(email: string): string {
const [local, domain] = email.split("@");
const head = local.slice(0, 1);
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
function maskPhone(phone: string): string {
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
}
/**
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
* a caller who has not yet proven possession of the channel.
*
* A dual-channel target masks both and joins them, so the UI can say exactly
* where the code went ("a•@x.com and +251•••••4567") — a user who only checks
* one of the two otherwise assumes the other never received anything.
*/
export function maskOtpTarget(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)}`;
const parts: string[] = [];
if (target.email) parts.push(maskEmail(target.email));
if (target.phone) parts.push(maskPhone(target.phone));
return parts.join(" and ");
}

View File

@@ -253,6 +253,19 @@ export class BookingLifecycleNotifierService {
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
/**
* A booking was created under a contract. Contract drawdowns never pass
* through submit, so this is the only point at which staff learn the booking
* exists — {@link submittedToStaff} covers the direct-booking flow instead.
*/
createdToStaff(b: Booking): void {
this.inAppStaff(
b,
'New booking created',
`Booking ${this.ref(b)} was created under a contract and has entered the pipeline.`,
);
}
/** Customer submitted a booking for review. */
submittedToStaff(b: Booking): void {
this.inAppStaff(

View File

@@ -1,4 +1,3 @@
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { Booking } from './entities/booking.entity';
export interface BookingNextStep {
@@ -9,7 +8,11 @@ export interface BookingNextStep {
export function computeNextStep(
booking: Pick<Booking, 'status' | 'paymentCurrency'>,
nextPendingStep?: Pick<BookingApprovalStep, 'requiredRole' | 'stepOrder'> | null,
/**
* Retained for call-site compatibility — bookings no longer run an approval
* chain, so this is always null. Approvals are a contract-only concern.
*/
nextPendingStep?: { requiredRole: string; stepOrder: number } | null,
): BookingNextStep | null {
const { status } = booking;

View File

@@ -22,14 +22,17 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
findById: jest.fn().mockResolvedValue(booking),
};
const ruleEngineService = {
instantiateApprovalSteps: jest.fn().mockResolvedValue([]),
assertNoHardBlocks: jest.fn(),
};
const contractService = {
generateContract: jest.fn().mockResolvedValue({ id: 'b-1' }),
};
const service = new BookingTransitionService(
bookingsRepository as never,
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
contractService as never,
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
@@ -57,7 +60,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository, ruleEngineService };
return { service, bookingsRepository, ruleEngineService, contractService };
}
it('rejects accept when validity days is missing or non-positive', async () => {
@@ -81,7 +84,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
const [id, updates] = bookingsRepository.update.mock.calls[0];
expect(id).toBe('b-1');
expect(updates).toMatchObject({
status: 'PENDING_APPROVAL',
status: 'APPROVED',
approvedByStaffId: 'staff-1',
contractValidityDays: 10,
});
@@ -96,12 +99,9 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime());
});
it('instantiates the approval chain when accepting', async () => {
const { service, ruleEngineService } = makeService();
it('approves outright and generates the contract (no approval chain)', async () => {
const { service, contractService } = makeService();
await service.acceptIntake('b-1', 'staff-1', 30);
expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ freightType: 'CONTAINER' }),
);
expect(contractService.generateContract).toHaveBeenCalledWith('b-1');
});
});

View File

@@ -7,9 +7,7 @@ import {
Logger,
Optional,
} from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from './road.util';
@@ -248,16 +246,6 @@ export class BookingTransitionService {
return fresh;
}
/** Auto-create booking approval steps from system rules when none exist yet. */
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
}
async acceptIntake(
bookingId: string,
actorId: string,
@@ -283,21 +271,33 @@ export class BookingTransitionService {
const validUntil = new Date(validFrom);
validUntil.setDate(validUntil.getDate() + validityDays);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
const updated = await this.bookingsRepository.update(bookingId, {
status: "PENDING_APPROVAL",
// Bookings no longer run a multi-step approval chain — accepting the intake
// approves the booking outright and generates its contract. (The approval
// chain is a contract-only concern now; see contract-transition.service.)
await this.bookingsRepository.update(bookingId, {
status: "APPROVED",
approvedByStaffId: actorId,
approvedByStaffAt: validFrom,
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
const fresh = await this.bookingsService.findById(updated!.id);
// Generating the contract is best-effort: the acceptance is already
// committed, so a failure here must not roll it back. The booking stays
// APPROVED and staff can retry generation from the booking page.
try {
await this.contractService.generateContract(bookingId);
} catch (err) {
this.logger.warn(
`Contract generation failed after accepting booking ${bookingId}: ${err}. ` +
`The booking is APPROVED — retry generation from the booking page.`,
);
}
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.accepted(fresh);
this.notifier.approved(fresh);
return fresh;
}
@@ -324,140 +324,6 @@ export class BookingTransitionService {
return fresh;
}
async approveStep(
bookingId: string,
stepId: string,
actorId: string,
requiredRole: string,
authUser?: TCurrentUser,
): Promise<Booking> {
if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
if ((booking.approvalSteps?.length ?? 0) === 0) {
await this.ensureBookingApprovalSteps(booking);
booking = await this.bookingsService.findById(bookingId);
}
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step || step.status !== "PENDING") {
throw new BadRequestException(
"Approval step not found or already actioned",
);
}
const next =
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
"Approval steps must be completed in order",
);
}
if (step.requiredRole !== requiredRole) {
throw new BadRequestException(
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
);
}
const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(
`Role ${requiredRole} is blocked for this step`,
);
}
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
"APPROVED",
);
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === "LINE_STAFF") {
updates.status = "APPROVED_PENDING_SIGNATURE";
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === "DIRECTOR") {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === "CEO") {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
const allDone =
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
if (allDone) {
updates.status = "APPROVED";
}
if (Object.keys(updates).length > 0) {
await this.bookingsRepository.update(bookingId, updates as never);
}
if (allDone) {
const generated = await this.contractService.generateContract(bookingId);
const fresh = await this.bookingsService.findById(generated.id);
this.notifier.approved(fresh);
return fresh;
}
return this.bookingsService.findById(bookingId);
}
async rejectStep(
bookingId: string,
stepId: string,
actorId: string,
reason: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step) throw new BadRequestException("Approval step not found");
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
"REJECTED",
reason,
);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
"REJECTION",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} as never);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.rejected(fresh, reason);
return fresh;
}
async customerSign(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["CONTRACT_READY"]);
@@ -1298,12 +1164,9 @@ export class BookingTransitionService {
}
let nextStep: BookingNextStep | null = null;
try {
const nextPending =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE"
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
nextStep = computeNextStep(booking, nextPending);
// Bookings no longer carry an approval chain, so there is never a pending
// approval step to hint at.
nextStep = computeNextStep(booking, null);
} catch (err) {
this.logger.warn(
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,

View File

@@ -52,10 +52,8 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
ApproveStepDto,
CancelBookingDto,
RejectBookingDto,
RejectStepDto,
RequestChangesDto,
ReviewDocumentDto,
RequestOperationDto,
@@ -1023,47 +1021,6 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/approval-steps/:stepId/approve")
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo,
])
@ApiOperation({ summary: "Approve one approval step in sequence" })
async approveStep(
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.transitionService.approveStep(
id,
stepId,
resolveAuthUserId(user),
dto.requiredRole,
user,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/approval-steps/:stepId/reject")
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: "Reject at approval step" })
async rejectStep(
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.rejectStep(
id,
stepId,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/contract/generate")
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: "Generate contract PDF from template" })

View File

@@ -30,7 +30,6 @@ import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
@@ -60,7 +59,6 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
Booking,
BookingContainer,
BookingCargoModifier,
BookingApprovalStep,
BookingDocumentReview,
BookingRateSnapshot,
BookingReviewNote,

View File

@@ -9,7 +9,6 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
BookingDocumentReview,
@@ -114,7 +113,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.originYard', 'oy')
.leftJoinAndSelect('booking.destinationYard', 'dy')
.leftJoinAndSelect('booking.shippingLine', 'sl')
.leftJoinAndSelect('booking.approvalSteps', 'steps')
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
@@ -435,58 +433,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
}
/** Lowest-order pending approval step (sequential enforcement). */
async findNextPendingApprovalStep(
bookingId: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, status: 'PENDING' },
order: { stepOrder: 'ASC' },
});
}
async findApprovalStepById(
bookingId: string,
stepId: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, id: stepId },
});
}
/** Get pending approval step for a role (must match next in sequence). */
async findPendingApprovalStep(
bookingId: string,
requiredRole: string,
): Promise<BookingApprovalStep | null> {
const next = await this.findNextPendingApprovalStep(bookingId);
if (!next || next.requiredRole !== requiredRole) return null;
return next;
}
/** Mark an approval step complete. */
async completeApprovalStep(
stepId: string,
actorId: string,
status: 'APPROVED' | 'REJECTED',
remarks?: string,
): Promise<void> {
await this.dataSource.getRepository(BookingApprovalStep).update(stepId, {
status,
actionedByStaffId: actorId,
actionedAt: new Date(),
remarks,
});
}
/** Check if all approval steps are approved. */
async allApprovalStepsComplete(bookingId: string): Promise<boolean> {
const pending = await this.dataSource.getRepository(BookingApprovalStep).count({
where: { bookingId, status: 'PENDING' },
});
return pending === 0;
}
// ── Clearance document reviews ────────────────────────────────────────────
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
@@ -673,7 +619,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.where('booking.status IN (:...statuses)', { statuses });
if (options.excludeBulk) {
@@ -722,7 +667,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
// Contract reference for the list column + search (no entity relation on
// Booking → contract, so join the entity by id and select just the

View File

@@ -1,48 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity';
import { Booking } from './booking.entity';
export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const;
export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number];
@Entity({ schema: 'freight', name: 'booking_approval_step' })
@Index(['bookingId'])
@Index(['status'])
@Index(['bookingId', 'stepOrder'])
export class BookingApprovalStep extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'approval_rule_id', type: 'uuid' })
approvalRuleId!: string;
@ManyToOne(() => ApprovalRule)
@JoinColumn({ name: 'approval_rule_id' })
approvalRule?: ApprovalRule;
@Column({ name: 'step_order', type: 'smallint' })
stepOrder!: number;
@Column({ name: 'required_role', type: 'varchar', length: 30 })
requiredRole!: string;
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
blocksRole?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
status!: ApprovalStepStatus;
@Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true })
actionedByStaffId?: string | null;
@Column({ name: 'actioned_at', type: 'timestamptz', nullable: true })
actionedAt?: Date | null;
@Column({ name: 'remarks', type: 'text', nullable: true })
remarks?: string | null;
}

View File

@@ -10,7 +10,6 @@ import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Train } from '../../trains/entities/train.entity';
import { FileRecord } from '../../files/entities/file.entity';
import { BookingApprovalStep } from './booking-approval-step.entity';
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
import { BookingContainer } from './booking-container.entity';
import { BookingContainerAllocation } from './booking-container-allocation.entity';
@@ -557,8 +556,6 @@ export class Booking extends BaseEntity {
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
cargoModifiers?: BookingCargoModifier[];
@OneToMany(() => BookingApprovalStep, (s) => s.booking)
approvalSteps?: BookingApprovalStep[];
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
rateSnapshots?: BookingRateSnapshot[];

View File

@@ -27,6 +27,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // workflowService
{} as never, // invoiceService
{} as never, // clearanceFeeService
{ createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService

View File

@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
{} as never, // workflowService
invoiceService as never,
{} as never, // clearanceFeeService
{ createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService

View File

@@ -18,6 +18,7 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
@@ -97,6 +98,7 @@ export class ContractBookingService {
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly clearanceFeeService: ClearanceFeeService,
private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
@@ -352,6 +354,12 @@ export class ContractBookingService {
const withContainers = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
// Tell staff the booking exists. Placed after the zero-price rollback (which
// hard-deletes the row) and before the consolidation gate, so it fires
// exactly once whether the booking parks for a partner or finalizes inline.
this.bookingNotifier.createdToStaff(withContainers ?? booking);
const intendedStatus =
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
@@ -481,6 +489,7 @@ export class ContractBookingService {
);
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
this.bookingNotifier.createdToStaff(result ?? booking);
return { booking: result ?? booking, warnings: [] };
}
@@ -568,7 +577,10 @@ export class ContractBookingService {
await this.clearanceFeeService.issueForBooking(booking, contract);
}
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
const created =
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
this.bookingNotifier.createdToStaff(created);
return created;
}
/**

View File

@@ -0,0 +1,168 @@
import type {
ContractDocumentArticle,
ContractDocumentSnapshot,
} from './entities/contract.entity';
/**
* One recorded change between two document snapshots. Granularity is per
* article: a body edit is reported as "the body changed", not as a text diff.
*/
export type ContractDocumentChange =
| { kind: 'ARTICLE_ADDED'; articleId: string; title: string }
| { kind: 'ARTICLE_REMOVED'; articleId: string; title: string }
| {
kind: 'ARTICLE_RENAMED';
articleId: string;
title: string;
fromTitle: string;
}
| { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string }
| {
kind: 'ARTICLE_REORDERED';
articleId: string;
title: string;
fromOrder: number;
toOrder: number;
}
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number };
type SnapshotLike = Pick<
ContractDocumentSnapshot,
'documentTitle' | 'whereasClauses' | 'articles'
> | null;
/** Match on id when present, else on normalized title (editors may omit ids). */
function articleKey(article: ContractDocumentArticle): string {
return article.id || `title:${article.title.trim().toLowerCase()}`;
}
function indexArticles(
articles: ContractDocumentArticle[] | undefined,
): Map<string, ContractDocumentArticle> {
const map = new Map<string, ContractDocumentArticle>();
for (const article of articles ?? []) {
map.set(articleKey(article), article);
}
return map;
}
/**
* Compare two document snapshots and describe what changed, article by article.
* Returns an empty array when the snapshots are equivalent, so callers can skip
* recording a no-op revision.
*/
export function diffSnapshots(
before: SnapshotLike,
after: SnapshotLike,
): ContractDocumentChange[] {
const changes: ContractDocumentChange[] = [];
const beforeTitle = before?.documentTitle ?? null;
const afterTitle = after?.documentTitle ?? null;
if (beforeTitle !== afterTitle && afterTitle !== null) {
changes.push({
kind: 'DOCUMENT_TITLE_CHANGED',
title: afterTitle,
fromTitle: beforeTitle,
});
}
const beforeWhereas = before?.whereasClauses ?? [];
const afterWhereas = after?.whereasClauses ?? [];
const beforeWhereasSet = new Set(beforeWhereas);
const afterWhereasSet = new Set(afterWhereas);
const whereasAdded = afterWhereas.filter((c) => !beforeWhereasSet.has(c)).length;
const whereasRemoved = beforeWhereas.filter((c) => !afterWhereasSet.has(c)).length;
if (whereasAdded > 0 || whereasRemoved > 0) {
changes.push({
kind: 'WHEREAS_CHANGED',
added: whereasAdded,
removed: whereasRemoved,
});
}
const beforeArticles = indexArticles(before?.articles);
const afterArticles = indexArticles(after?.articles);
for (const [key, article] of afterArticles) {
const previous = beforeArticles.get(key);
if (!previous) {
changes.push({
kind: 'ARTICLE_ADDED',
articleId: article.id,
title: article.title,
});
continue;
}
if (previous.title !== article.title) {
changes.push({
kind: 'ARTICLE_RENAMED',
articleId: article.id,
title: article.title,
fromTitle: previous.title,
});
}
if (previous.body !== article.body) {
changes.push({
kind: 'ARTICLE_BODY_CHANGED',
articleId: article.id,
title: article.title,
});
}
if (previous.order !== article.order) {
changes.push({
kind: 'ARTICLE_REORDERED',
articleId: article.id,
title: article.title,
fromOrder: previous.order,
toOrder: article.order,
});
}
}
for (const [key, article] of beforeArticles) {
if (afterArticles.has(key)) continue;
changes.push({
kind: 'ARTICLE_REMOVED',
articleId: article.id,
title: article.title,
});
}
return changes;
}
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
export function summarizeChanges(changes: ContractDocumentChange[]): string {
if (changes.length === 0) return 'No changes';
const articleVerbs: Record<string, string> = {
ARTICLE_ADDED: 'added',
ARTICLE_REMOVED: 'removed',
ARTICLE_RENAMED: 'renamed',
ARTICLE_BODY_CHANGED: 'edited',
ARTICLE_REORDERED: 'reordered',
};
const counts = new Map<string, number>();
const parts: string[] = [];
for (const change of changes) {
const verb = articleVerbs[change.kind];
if (verb) {
counts.set(verb, (counts.get(verb) ?? 0) + 1);
} else if (change.kind === 'DOCUMENT_TITLE_CHANGED') {
parts.push('document title changed');
} else if (change.kind === 'WHEREAS_CHANGED') {
parts.push('recitals changed');
}
}
const articleParts = [...counts.entries()].map(
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
);
return [...articleParts, ...parts].join(', ');
}

View File

@@ -0,0 +1,61 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { diffSnapshots, summarizeChanges } from './contract-document-diff.util';
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
import type { ContractDocumentSnapshot } from './entities/contract.entity';
export interface RecordRevisionInput {
contractId: string;
before: ContractDocumentSnapshot | null;
after: ContractDocumentSnapshot | null;
actorId?: string | null;
actorRole?: string | null;
stepId?: string | null;
}
@Injectable()
export class ContractDocumentHistoryService {
private readonly logger = new Logger(ContractDocumentHistoryService.name);
constructor(
@InjectRepository(ContractDocumentRevision)
private readonly revisionRepo: Repository<ContractDocumentRevision>,
) {}
/**
* Append a revision describing what an edit changed. Best-effort: recording
* history must never break the edit that triggered it, so failures are logged
* and swallowed. A no-op edit records nothing.
*/
async record(input: RecordRevisionInput): Promise<void> {
try {
const changes = diffSnapshots(input.before, input.after);
if (changes.length === 0) return;
await this.revisionRepo.save(
this.revisionRepo.create({
contractId: input.contractId,
actorId: input.actorId ?? null,
actorRole: input.actorRole ?? null,
stepId: input.stepId ?? null,
summary: summarizeChanges(changes),
changes,
}),
);
} catch (err) {
this.logger.error(
`Failed to record document revision for contract ${input.contractId}: ${String(err)}`,
);
}
}
/** Revision history for a contract, newest first. */
list(contractId: string): Promise<ContractDocumentRevision[]> {
return this.revisionRepo.find({
where: { contractId },
order: { createdAt: 'DESC' },
});
}
}

View File

@@ -3,6 +3,7 @@ import {
ConflictException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@@ -17,7 +18,8 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service';
import { FileRecord } from '../files/entities/file.entity';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { assertCanApproveContractStep } from '../../common/freight-permission.util';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
@@ -48,8 +50,12 @@ export interface ContractDocumentDraft {
articles: ContractDocumentArticle[];
code: string | null;
name: string | null;
/** True once the document may no longer be edited/regenerated. */
/** True when THIS caller may not edit — the inverse of `editableByMe`. */
locked: boolean;
/** Whether the requesting user is the approver whose turn it is to edit. */
editableByMe: boolean;
/** Role holding editing rights right now, for "locked because…" messaging. */
nextApproverRole: string | null;
generatedAt: Date | null;
status: string;
}
@@ -62,6 +68,28 @@ export interface ContractDocumentDraft {
*/
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
/**
* Approval chains are configured in IAM position types, so a step's role no
* longer maps onto the contract's fixed approver columns. These sets keep those
* legacy columns populated for the roles that still correspond to one — both the
* original role strings on historical rows and the position types that replaced
* them. Steps outside these sets are recorded only in `contract_approval_steps`,
* which is the source of truth.
*/
const LEGACY_STAFF_ROLES = new Set([
'LINE_STAFF',
'employee',
'teamLeader',
'officeHead',
'recordOfficer',
]);
const LEGACY_DIRECTOR_ROLES = new Set([
'DIRECTOR',
'director',
'operation-director',
]);
const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']);
/**
* Mask a phone for display — keep the last 4 digits, star the rest
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
@@ -73,6 +101,27 @@ function maskPhone(phone: string): string {
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
}
/** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */
function maskEmail(email: string): string {
const [local, domain] = email.trim().split('@');
if (!domain) return email.trim();
return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
/**
* Where the signing code went, for the "we sent a code to …" line in the UI.
* Both contacts are listed when both were used — a signer who only watches their
* handset otherwise has no idea the email carries the same code.
*/
function maskSignerContacts(contacts: { phone?: string; email?: string }): string {
return [
contacts.email ? maskEmail(contacts.email) : null,
contacts.phone ? maskPhone(contacts.phone) : null,
]
.filter(Boolean)
.join(' and ');
}
/** Status-machine guard mirroring booking-status.util. */
function assertContractStatus(contract: Contract, allowed: string[]): void {
if (!allowed.includes(contract.status)) {
@@ -87,6 +136,7 @@ export class ContractTransitionService {
private readonly logger = new Logger(ContractTransitionService.name);
constructor(
private readonly documentHistory: ContractDocumentHistoryService,
private readonly contractsRepository: ContractsRepository,
private readonly contractsService: ContractsService,
private readonly pricingService: ContractPricingService,
@@ -109,34 +159,39 @@ export class ContractTransitionService {
) {}
/**
* The phone the signing OTP is sent to and verified against: the signer's own
* IAM account number.
* The contacts the signing OTP is sent to and verified against: the signer's
* own IAM account phone AND email. One code goes to both and either delivery
* verifies it, so a signer whose SMS is delayed can still complete from their
* inbox instead of abandoning a ready contract.
*
* H12(b): resolved server-side from the authenticated user id, never from the
* request body — a caller-supplied number would let an attacker point the code
* at their own phone. Ownership is already gated separately by
* request body — caller-supplied contacts would let an attacker point the code
* at their own phone or mailbox. Ownership is already gated separately by
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
* signature to the *person* signing rather than to a company landline that may
* be shared, stale, or imported from eTrade.
*/
private async resolveSignerPhone(signerUserId?: string): Promise<string> {
private async resolveSignerContacts(
signerUserId?: string,
): Promise<{ phone?: string; email?: string }> {
if (!signerUserId) {
// Unreachable in practice (the ownership gate rejects a missing user
// first), but never fall back to another number if it ever changes.
// first), but never fall back to another account if it ever changes.
throw new BadRequestException('Authentication required to sign');
}
const rows: Array<{ phone_number: string | null }> =
const rows: Array<{ phone_number: string | null; email: string | null }> =
await this.dataSource.query(
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
`SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`,
[signerUserId],
);
const phone = rows[0]?.phone_number?.trim();
if (!phone) {
const email = rows[0]?.email?.trim();
if (!phone && !email) {
throw new BadRequestException(
'Your account has no registered phone number. Add one in Settings → Account before signing.',
'Your account has no registered phone number or email. Add one in Settings → Account before signing.',
);
}
return phone;
return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) };
}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -229,18 +284,22 @@ export class ContractTransitionService {
*/
async getContractDocumentDraft(
contractId: string,
user?: TCurrentUser | null,
): Promise<ContractDocumentDraft> {
const contract = await this.contractsService.findById(contractId);
const snapshot =
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
(await this.resolveDocumentSnapshot(contract));
const editableByMe = await this.documentIsEditableBy(contract, user);
return {
documentTitle: snapshot?.documentTitle ?? null,
whereasClauses: snapshot?.whereasClauses ?? [],
articles: snapshot?.articles ?? [],
code: snapshot?.code ?? null,
name: snapshot?.name ?? null,
locked: !this.documentIsEditable(contract),
locked: !editableByMe,
editableByMe,
nextApproverRole: await this.nextApproverRole(contract),
generatedAt: contract.contractGeneratedAt ?? null,
status: contract.status,
};
@@ -255,10 +314,12 @@ export class ContractTransitionService {
async updateContractDocument(
contractId: string,
input: ContractDocumentSnapshotInput,
user?: TCurrentUser | null,
actorId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL']);
this.assertDocumentEditable(contract);
await this.assertDocumentEditable(contract, user);
const current =
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
@@ -270,9 +331,25 @@ export class ContractTransitionService {
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
articles: input.articles ?? current?.articles ?? [],
};
const next = this.normalizeSnapshot(merged);
await this.contractsRepository.update(contractId, {
documentSnapshot: this.normalizeSnapshot(merged),
documentSnapshot: next,
} as never);
// Audit the edit after it lands. Recording history must never break the
// edit itself, so the history service swallows its own failures.
const step = await this.contractsRepository.findNextPendingApprovalStep(
contractId,
);
await this.documentHistory.record({
contractId,
before: current,
after: next,
actorId: actorId ?? null,
actorRole: step?.requiredRole ?? null,
stepId: step?.id ?? null,
});
return this.contractsService.findById(contractId);
}
@@ -334,23 +411,54 @@ export class ContractTransitionService {
}
/**
* The per-contract document may be edited/regenerated while the contract is at
* the accept stage (SUBMITTED) or in approval with NO approver having acted
* yet. The first approval action freezes it.
* The contract document stays editable for the whole approval chain, but only
* by the approver whose turn it is: whoever can action the next pending step.
* Approving therefore hands editing rights to the next approver in the chain.
*
* Edits never reset approvals already given — earlier approvers stay approved.
*/
private documentIsEditable(contract: Contract): boolean {
private async documentIsEditableBy(
contract: Contract,
user?: TCurrentUser | null,
): Promise<boolean> {
if (contract.status === 'SUBMITTED') return true;
if (contract.status !== 'PENDING_APPROVAL') return false;
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
const next = await this.contractsRepository.findNextPendingApprovalStep(
contract.id,
);
if (!next) return false;
if (!user) return false;
try {
assertCanApproveContractStep(user, next.requiredRole);
return true;
} catch {
return false;
}
}
private assertDocumentEditable(contract: Contract): void {
if (!this.documentIsEditable(contract)) {
throw new ConflictException(
'The contract document is locked — an approver has already acted or the ' +
'contract has advanced. It can no longer be edited or regenerated.',
);
}
/** The role that currently holds editing rights, for UI messaging. */
private async nextApproverRole(contract: Contract): Promise<string | null> {
if (contract.status !== 'PENDING_APPROVAL') return null;
const next = await this.contractsRepository.findNextPendingApprovalStep(
contract.id,
);
return next?.requiredRole ?? null;
}
private async assertDocumentEditable(
contract: Contract,
user?: TCurrentUser | null,
): Promise<void> {
if (await this.documentIsEditableBy(contract, user)) return;
const role = await this.nextApproverRole(contract);
throw new ConflictException(
role
? `The contract document can only be edited by the current approver (${role}).`
: 'The contract document is locked — the contract has advanced beyond approval.',
);
}
/**
@@ -513,25 +621,11 @@ export class ContractTransitionService {
contractId: string,
stepId: string,
actorId: string,
requiredRole: string,
authUser?: TCurrentUser,
): Promise<Contract> {
if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
// Approvers review the generated contract document, so it must exist before
// the first approval can be recorded. Staff generate it (from the frozen,
// optionally-edited snapshot) at the accept stage.
if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
throw new BadRequestException(
'Generate the contract document before it can be approved.',
);
}
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
@@ -541,31 +635,34 @@ export class ContractTransitionService {
if (!next || next.id !== step.id) {
throw new BadRequestException('Approval steps must be completed in order');
}
if (step.requiredRole !== requiredRole) {
throw new BadRequestException(
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
);
}
if (step.blocksRole && step.blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
// The role is the step's own — never the caller's claim about themselves.
const requiredRole = step.requiredRole;
if (authUser) {
assertCanApproveContractStep(authUser, requiredRole);
}
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
// Record who acted on this step, but DO NOT advance the contract status here —
// approving one step (e.g. LINE_STAFF) must not finalize the chain while later
// steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once
// every step in the chain is complete; until then the contract stays in
// PENDING_APPROVAL so the next required role can act.
// approving one step must not finalize the chain while later steps are still
// pending. Status only moves to APPROVED once every step in the chain is
// complete; until then the contract stays in PENDING_APPROVAL so the next
// required approver can act.
//
// `contract_approval_steps` is the source of truth for who approved what — a
// chain is an arbitrary sequence of position types and cannot be represented
// by fixed columns. The legacy columns below are still stamped, best-effort,
// for the three roles that map onto them so older readers keep working.
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === 'LINE_STAFF') {
if (LEGACY_STAFF_ROLES.has(requiredRole)) {
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') {
} else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === 'CEO') {
} else if (LEGACY_CEO_ROLES.has(requiredRole)) {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
@@ -579,14 +676,19 @@ export class ContractTransitionService {
const updated = await this.contractsService.findById(contractId);
if (allDone) {
this.notifier.approved(updated);
// Every step approved → CONTRACT_READY. The document was already generated
// (and reviewed) at the accept stage, so we reuse it rather than
// re-rendering. Best-effort: a hiccup must not roll back the approval.
// Final approval is what produces the contract PDF — until now there was
// only a live preview. The approval steps are already committed, so a
// render failure must not roll them back; surface it instead of swallowing
// it, since an APPROVED contract with no document needs operator action.
try {
return await this.finalizeApprovedContract(contractId);
} catch (err) {
this.logger.warn(
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
this.logger.error(
`Contract PDF generation failed after final approval for ${updated.reference}: ${err}`,
);
throw new ServiceUnavailableException(
'All approvals were recorded, but generating the contract PDF failed. ' +
'Retry generation from the contract page.',
);
}
}
@@ -594,24 +696,13 @@ export class ContractTransitionService {
}
/**
* Staff (re)generate the contract PDF. Two stages:
* - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
* snapshot so approvers review the real document. Status is UNCHANGED, and
* it is blocked once an approver has acted (the document is then locked).
* - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
* CONTRACT_READY.
* PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
* transition — the document re-renders lazily on view/download.
* Retry path for a contract that finished approval but whose PDF failed to
* render (Chromium unavailable, etc.). The normal flow generates the document
* automatically on the final approval — there is no manual generate step
* before that, only the live preview.
*/
async generateContract(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (contract.status === 'PENDING_APPROVAL') {
this.assertDocumentEditable(contract);
await this.renderContractDocument(contract);
return this.contractsService.findById(contractId);
}
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
await this.renderContractDocument(contract);
await this.contractsRepository.update(contractId, {
@@ -626,11 +717,17 @@ export class ContractTransitionService {
* changes status. Rendering is best-effort — a Chromium hiccup defers the file
* (it re-renders on view/download) but the timestamp is still stamped.
*/
private async renderContractDocument(contract: Contract): Promise<void> {
private async renderContractDocument(
contract: Contract,
options: { strict?: boolean } = {},
): Promise<void> {
const { view } = await this.documentViewModelBuilder.build(contract.id);
try {
await this.upsertContractPdf(contract.id, contract.reference, view);
} catch (err) {
// Strict callers (final approval) need to know the PDF is missing — it is
// the artifact of the completed chain, not a cache that can refill later.
if (options.strict) throw err;
this.logger.warn(
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
@@ -642,15 +739,14 @@ export class ContractTransitionService {
}
/**
* Every approval step landed → CONTRACT_READY. The document was already
* generated (and reviewed) at the accept stage, so reuse it; render now only
* if it was somehow never generated. Never re-renders over an existing file.
* Every approval step landed → generate the contract PDF, then CONTRACT_READY.
* This is the only point at which the document is produced: approvers review a
* live preview, and the final approval is what turns it into a PDF. Renders
* unconditionally so the file reflects every edit made during the chain.
*/
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (!contract.contractGeneratedAt) {
await this.renderContractDocument(contract);
}
await this.renderContractDocument(contract, { strict: true });
await this.contractsRepository.update(contractId, {
status: 'CONTRACT_READY',
} as never);
@@ -839,11 +935,11 @@ export class ContractTransitionService {
}
/**
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
* same number {@link sign} verifies against. The client never picks the number
* (that is the H12(b) trust property): it only asks us to send, and we resolve
* the phone from the authenticated user id. Returns a masked hint so the UI can
* say where the code went without exposing the full number.
* Send the sudo-mode signing OTP to the SIGNER's own registered phone and
* email — the same contacts {@link sign} verifies against. The client never
* picks them (that is the H12(b) trust property): it only asks us to send, and
* we resolve them from the authenticated user id. Returns a masked hint so the
* UI can say where the code went without exposing the full values.
*/
async sendSigningOtp(
contractId: string,
@@ -858,9 +954,9 @@ export class ContractTransitionService {
);
assertContractStatus(contract, ['CONTRACT_READY']);
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
await this.otpService.sendOtp({ phone: signerPhone });
return { sentTo: maskPhone(signerPhone) };
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
await this.otpService.sendOtp(signerContacts);
return { sentTo: maskSignerContacts(signerContacts) };
}
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
@@ -887,17 +983,17 @@ export class ContractTransitionService {
}
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
// signature is applied. H12(b): verify against the SIGNER's own registered
// phone, resolved server-side from the authenticated user id — never a
// caller-supplied number, which an attacker could point at their own
// phone. Ownership is already asserted above, so this proves the specific
// person holding the account is present, not merely that someone reached a
// shared company line. Must resolve identically to sendSigningOtp, or send
// and verify would target different numbers.
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
// contacts, resolved server-side from the authenticated user id — never
// caller-supplied ones, which an attacker could point at their own phone
// or mailbox. Ownership is already asserted above, so this proves the
// specific person holding the account is present, not merely that someone
// reached a shared company line. Must resolve identically to
// sendSigningOtp, or send and verify would target different contacts.
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
if (!dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
await this.otpService.verifyOtpForAction(signerContacts, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',

View File

@@ -33,6 +33,7 @@ import {
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
assertFreightPermission,
@@ -61,7 +62,6 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto';
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
import {
ApproveStepDto,
RejectContractDto,
RejectStepDto,
RequestChangesDto,
@@ -91,6 +91,7 @@ import {
@ApiBearerAuth()
export class ContractsController {
constructor(
private readonly documentHistory: ContractDocumentHistoryService,
private readonly contractsService: ContractsService,
private readonly pricingService: ContractPricingService,
private readonly transitionService: ContractTransitionService,
@@ -353,8 +354,22 @@ export class ContractsController {
summary:
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
})
getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.getContractDocumentDraft(id);
getContractDocumentDraft(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
// Editability depends on WHO is asking — only the approver whose turn it is
// may edit — so the caller is part of the draft lookup.
return this.transitionService.getContractDocumentDraft(id, user);
}
@Get(':id/document/revisions')
@BookingStaff(FREIGHT_PERMS.contracts.view)
@ApiOperation({
summary: 'Audit trail of edits to this contract\'s document (newest first)',
})
getContractDocumentRevisions(@Param('id', ParseUUIDPipe) id: string) {
return this.documentHistory.list(id);
}
@Put(':id/document/articles')
@@ -366,8 +381,14 @@ export class ContractsController {
updateContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateContractDocumentDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.updateContractDocument(id, dto);
return this.transitionService.updateContractDocument(
id,
dto,
user,
resolveAuthUserId(user),
);
}
@Post(':id/staff/request-changes')
@@ -397,23 +418,20 @@ export class ContractsController {
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff([
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.approveCeo,
])
@BookingStaff(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Approve one approval step in sequence' })
approveStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
// Whether this caller may approve depends on the step's own required role
// (an IAM position type), so the service resolves the step and authorizes
// against it — the client never declares its own role.
return this.transitionService.approveStep(
id,
stepId,
resolveAuthUserId(user),
dto.requiredRole,
user,
);
}

View File

@@ -41,6 +41,8 @@ import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
import { ContractSignature } from './entities/contract-signature.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
import { ContractReviewNote } from './entities/contract-review-note.entity';
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ContractDocumentReview } from './entities/contract-document-review.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
@@ -64,6 +66,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractSignature,
ContractApprovalStep,
ContractReviewNote,
ContractDocumentRevision,
ContractClearanceCycle,
ContractDocumentReview,
ClearanceMilestone,
@@ -107,6 +110,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ClearanceFeeService,
ContractNotifierService,
ContractTransitionService,
ContractDocumentHistoryService,
ContractClearanceService,
ClearanceWorkflowService,
BookingClearanceService,

View File

@@ -26,10 +26,10 @@ export class ContractApprovalStep extends BaseEntity {
@Column({ name: 'step_order', type: 'smallint', default: 0 })
stepOrder!: number;
@Column({ name: 'required_role', type: 'varchar', length: 40 })
@Column({ name: 'required_role', type: 'varchar', length: 64 })
requiredRole!: string;
@Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true })
@Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true })
blocksRole?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })

View File

@@ -0,0 +1,36 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import type { ContractDocumentChange } from '../contract-document-diff.util';
import { Contract } from './contract.entity';
/**
* Append-only audit of contract document edits. The document stays editable
* through the whole approval chain, so this records who changed which article
* and when — the contract itself only ever holds the current snapshot.
*/
@Entity({ schema: 'freight', name: 'contract_document_revisions' })
@Index(['contractId'])
export class ContractDocumentRevision extends BaseEntity {
@Column({ name: 'contract_id', type: 'uuid' })
contractId!: string;
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
actorId?: string | null;
/** The approval step's required role at the time of the edit. */
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
actorRole?: string | null;
@Column({ name: 'step_id', type: 'uuid', nullable: true })
stepId?: string | null;
@Column({ name: 'summary', type: 'varchar', length: 255, nullable: true })
summary?: string | null;
@Column({ name: 'changes', type: 'jsonb', default: () => `'[]'::jsonb` })
changes!: ContractDocumentChange[];
}

View File

@@ -11,12 +11,17 @@ import {
import { OtpService, OtpTarget } from "./otp.service";
import { Public } from "@edr/api-common";
// Exactly one of phone/email must be present per request — the channel the
// code is sent through / checked against.
// At least one of phone/email must be present. When BOTH are given the code is
// sent to both and either one verifies it — the caller no longer picks a single
// channel, it just states every address it knows for the account.
function toTarget(phone?: string, email?: string): OtpTarget {
if (email) return { email };
if (phone) return { phone };
throw new BadRequestException("phone or email is required");
const target: OtpTarget = {};
if (email?.trim()) target.email = email;
if (phone?.trim()) target.phone = phone;
if (!target.email && !target.phone) {
throw new BadRequestException("phone or email is required");
}
return target;
}
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS

View File

@@ -4,10 +4,12 @@ import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { FindOptionsWhere, Repository } from "typeorm";
import { OtpVerification } from "./otp.entity";
type Target = { phone?: string; email?: string };
@Injectable()
export class OtpRepository {
constructor(
@@ -46,54 +48,112 @@ export class OtpRepository {
}
// ---------------------------------------------------------------------------
// Find By Target (either channel)
// Find By Target (any named channel)
// ---------------------------------------------------------------------------
async findByTarget(
target: { phone?: string; email?: string }
) {
return target.email
? this.findByEmail(target.email)
: this.findByPhone(target.phone!);
}
/**
* OR across every channel the target names. A code sent to both phone and
* email lives in ONE row carrying both values, so a verify that quotes either
* one resolves the same row — that is what makes "sent to both, verify with
* either" work.
*/
private whereForTarget(
target: Target
): FindOptionsWhere<OtpVerification>[] {
const where: FindOptionsWhere<OtpVerification>[] =
[];
// ---------------------------------------------------------------------------
// Create OTP
// ---------------------------------------------------------------------------
async createOtp(
target: { phone?: string; email?: string },
otp: string
) {
const entity =
this.repository.create({
phone: target.phone,
if (target.email)
where.push({
email: target.email,
otp,
verified: false,
});
return this.repository.save(
entity
if (target.phone)
where.push({
phone: target.phone,
});
return where;
}
async findAllByTarget(
target: Target
) {
const where =
this.whereForTarget(target);
if (!where.length) return [];
// Newest first: a target that somehow overlaps two legacy single-channel
// rows should resolve to the most recently issued code, not an arbitrary one.
return this.repository.find({
where,
order: { updatedAt: "DESC" },
});
}
async findByTarget(
target: Target
) {
const [
newest,
] = await this.findAllByTarget(
target
);
return newest ?? null;
}
// ---------------------------------------------------------------------------
// Update OTP
// Replace OTP (upsert across every channel the target names)
// ---------------------------------------------------------------------------
async updateOtp(
otpVerification: OtpVerification,
/**
* Drop every row this target overlaps and write a single fresh one holding
* all its channels.
*
* `phone` and `email` are each UNIQUE, so a dual-channel send can collide with
* up to two pre-existing single-channel rows (say an old signup code on the
* phone and a reset code on the email). Merging into one row instead of
* updating in place is what keeps that from raising a unique violation, and it
* preserves the single-use guarantee: consuming the code deletes one row and
* kills every channel it was sent to at once.
*
* "Last code sent wins" was already the behaviour between any two flows
* sharing this table — this only widens it from one channel to all of them.
*/
async replaceOtp(
target: Target,
otp: string
) {
otpVerification.otp = otp;
): Promise<{
record: OtpVerification;
rotated: boolean;
}> {
const existing =
await this.findAllByTarget(
target
);
otpVerification.verified =
false;
if (existing.length) {
await this.repository.remove(
existing
);
}
return this.repository.save(
otpVerification
);
const record =
await this.repository.save(
this.repository.create({
phone: target.phone,
email: target.email,
otp,
verified: false,
})
);
return {
record,
rotated: existing.length > 0,
};
}
// ---------------------------------------------------------------------------
@@ -115,8 +175,8 @@ export class OtpRepository {
// Delete OTP (single-use consume)
// ---------------------------------------------------------------------------
// Hard delete so the unique `phone` row is freed and a fresh code can be
// requested for the same number on the next action.
// Hard delete so the unique `phone`/`email` rows are freed and a fresh code can
// be requested for the same target on the next action.
async deleteOtp(
otpVerification: OtpVerification
) {
@@ -124,4 +184,4 @@ export class OtpRepository {
otpVerification
);
}
}
}

View File

@@ -28,55 +28,216 @@ describe('normalizeOtpTarget', () => {
});
});
describe('OtpService — send/verify agree across phone formats', () => {
// In-memory fake keyed by the exact phone string the service stores under, so
// the test proves normalisation makes send and verify collide on one key.
function makeService() {
const rows = new Map<string, { phone?: string; email?: string; otp: string; updatedAt: Date }>();
const repo = {
findByTarget: jest.fn(async (t: { phone?: string; email?: string }) =>
rows.get(t.email ?? t.phone!) ?? null,
),
updateOtp: jest.fn(async (existing: { otp: string }, otp: string) => {
existing.otp = otp;
}),
createOtp: jest.fn(async (t: { phone?: string; email?: string }, otp: string) => {
rows.set(t.phone ?? t.email!, { ...t, otp, updatedAt: new Date(0) });
}),
deleteOtp: jest.fn(async (row: { phone?: string; email?: string }) => {
rows.delete(row.phone ?? row.email!);
}),
};
// Both clients return `{ queued }` — the service reads it to tell a published
// code apart from one the transport silently dropped.
const sms = { sendSms: jest.fn().mockResolvedValue({ queued: true }) };
const email = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) };
const service = new OtpService(repo as never, sms as never, email as never);
return { service, rows };
}
interface FakeRow {
id: string;
phone?: string;
email?: string;
otp: string;
updatedAt: Date;
}
/**
* In-memory stand-in for OtpRepository, mirroring the two properties the service
* depends on: rows are matched by OR across every channel named, and a send
* replaces all overlapping rows with one row carrying every channel.
*/
function makeService(
transports: {
sms?: () => Promise<{ queued: boolean }>;
email?: () => Promise<{ queued: boolean }>;
} = {},
) {
let rows: FakeRow[] = [];
let nextId = 1;
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
(!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone);
const repo = {
findByTarget: jest.fn(
async (t: { phone?: string; email?: string }) =>
rows.filter((row) => matches(row, t))[0] ?? null,
),
replaceOtp: jest.fn(
async (t: { phone?: string; email?: string }, otp: string) => {
const overlapping = rows.filter((row) => matches(row, t));
rows = rows.filter((row) => !overlapping.includes(row));
const record: FakeRow = {
id: String(nextId++),
...t,
otp,
updatedAt: new Date(),
};
rows.push(record);
return { record, rotated: overlapping.length > 0 };
},
),
deleteOtp: jest.fn(async (row: FakeRow) => {
rows = rows.filter((r) => r !== row);
}),
};
// Both clients return `{ queued }` — the service reads it to tell a published
// code apart from one the transport silently dropped.
const sms = {
sendSms: jest.fn(transports.sms ?? (async () => ({ queued: true }))),
};
const email = {
sendEmail: jest.fn(transports.email ?? (async () => ({ queued: true }))),
};
const service = new OtpService(repo as never, sms as never, email as never);
return { service, sms, email, rows: () => rows };
}
describe('OtpService — send/verify agree across phone formats', () => {
it('verifies a code sent to +251… when verify is called with 09…', async () => {
const { service, rows } = makeService();
await service.sendOtp({ phone: '+251986680099' });
const stored = [...rows.values()][0]!.otp;
// Fresh TTL: stamp updatedAt to now so the action verifier does not expire it.
[...rows.values()][0]!.updatedAt = new Date();
await expect(
service.verifyOtpForAction({ phone: '0986680099' }, stored),
service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => {
const { service, rows } = makeService();
await service.sendOtp({ email: ' User@Example.COM ' });
const stored = [...rows.values()][0]!.otp;
[...rows.values()][0]!.updatedAt = new Date();
await expect(
service.verifyOtpForAction({ email: 'user@example.com' }, stored),
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
});
describe('OtpService — dual-channel send', () => {
const both = { phone: '0986680099', email: 'User@Example.COM' };
it('sends ONE code to both transports', async () => {
const { service, sms, email, rows } = makeService();
await service.sendOtp(both);
const otp = rows()[0]!.otp;
expect(sms.sendSms).toHaveBeenCalledTimes(1);
expect(email.sendEmail).toHaveBeenCalledTimes(1);
// Same secret on both messages — the user types whichever arrives first.
expect(sms.sendSms).toHaveBeenCalledWith(
expect.objectContaining({
to: '+251986680099',
message: expect.stringContaining(otp),
}),
);
expect(email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'user@example.com',
text: expect.stringContaining(otp),
}),
);
// One row, both channels canonicalised.
expect(rows()).toHaveLength(1);
expect(rows()[0]).toMatchObject({
phone: '+251986680099',
email: 'user@example.com',
});
});
it.each([
['phone alone', { phone: '0986680099' }],
['email alone', { email: 'user@example.com' }],
['both', both],
])('verifies a dual-channel code when quoted back by %s', async (_label, target) => {
const { service, rows } = makeService();
await service.sendOtp(both);
await expect(
service.verifyOtpForAction(target, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
it('consuming the code via one channel kills the other', async () => {
const { service, rows } = makeService();
await service.sendOtp(both);
const otp = rows()[0]!.otp;
await service.verifyOtpForAction({ email: 'user@example.com' }, otp);
// Single-use is per-code, not per-channel: the phone half must be dead too.
await expect(
service.verifyOtpForAction({ phone: '0986680099' }, otp),
).rejects.toThrow(/No verification code was requested/);
});
it('replaces an overlapping single-channel row instead of colliding with it', async () => {
const { service, rows } = makeService();
// A pending signup code on the phone only, then a dual-channel send.
await service.sendOtp({ phone: '0986680099' });
await service.sendOtp(both);
expect(rows()).toHaveLength(1);
expect(rows()[0]).toMatchObject({ email: 'user@example.com' });
});
it('degrades to one channel when the account has only one contact', async () => {
const { service, sms, email } = makeService();
await service.sendOtp({ phone: '0986680099' });
expect(sms.sendSms).toHaveBeenCalledTimes(1);
expect(email.sendEmail).not.toHaveBeenCalled();
});
it('still succeeds when one transport throws', async () => {
const { service, rows } = makeService({
sms: async () => {
throw new Error('broker down');
},
});
await expect(service.sendOtp(both)).resolves.toMatchObject({
success: true,
delivered: true,
});
// The code is live and verifiable on the channel that worked.
await expect(
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
it('fails the request when every transport throws', async () => {
const { service } = makeService({
sms: async () => {
throw new Error('broker down');
},
email: async () => {
throw new Error('broker down');
},
});
await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP');
});
it('shares one brute-force budget across both channels', async () => {
const { service, rows } = makeService();
await service.sendOtp(both);
const otp = rows()[0]!.otp;
// Alternating channels must not hand the attacker two independent budgets:
// 5 wrong guesses in total burn the code regardless of how they are split.
for (const target of [
{ phone: '0986680099' },
{ email: 'user@example.com' },
{ phone: '0986680099' },
{ email: 'user@example.com' },
]) {
await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow(
'Invalid verification code',
);
}
await expect(
service.verifyOtpForAction({ email: 'user@example.com' }, '000000'),
).rejects.toThrow(/Too many incorrect attempts/);
// Burned: even the correct code no longer works.
await expect(service.verifyOtpForAction(both, otp)).rejects.toThrow(
/No verification code was requested/,
);
});
});

View File

@@ -8,10 +8,24 @@ import { OtpRepository } from "./otp.repository";
import { SmsClientService } from "../notifications/sms-client.service";
import { EmailClientService } from "../notifications/email-client.service";
// Exactly one of phone/email is set — enforced by the controller before it
// reaches here.
/**
* Where a code goes. At least one of phone/email must be set — enforced by the
* controller and re-checked here. When BOTH are set the same code is sent to
* both and either one can be used to verify it: a user who never receives the
* SMS can still finish from their inbox, and vice versa. Callers that resolve
* contacts from IAM pass whatever the account actually has, so an account with
* only one of the two silently degrades to a single channel.
*/
export type OtpTarget = { phone?: string; email?: string };
/** Which transports a target resolves to, in a stable order for logging. */
function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
const channels: Array<"email" | "sms"> = [];
if (target.email) channels.push("email");
if (target.phone) channels.push("sms");
return channels;
}
/**
* Canonicalise a phone to E.164 so the code stored on send and the one looked
* up on verify collide regardless of how the number was typed. Without this,
@@ -19,30 +33,51 @@ export type OtpTarget = { phone?: string; email?: string };
* a code sent to one is invisible to the others — the send/verify halves must
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
* Email targets pass through untouched.
*/
function normalizePhone(rawPhone: string): string {
const raw = rawPhone.trim();
const digits = raw.replace(/[^\d+]/g, '');
if (digits.startsWith('+')) return digits;
const bare = digits.replace(/^0+/, '');
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
// it looks like a full international number, else leave as typed.
return digits.length >= 11 ? `+${digits}` : raw;
}
/**
* Canonicalise every channel present on the target. Each field is normalised
* independently — a dual-channel target must end up with both halves in their
* canonical form, since verify may arrive naming either one.
*/
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
if (target.email) {
// Same contract as the phone branch below: the string stored on send and the
// one looked up on verify must be byte-identical, or the code is invisible to
// the verifier. Addresses reach us from a raw `@Body("email")` with no DTO or
// ValidationPipe, so `User@X.com`, `user@x.com` and a copy-paste with a
const normalized: OtpTarget = {};
if (target.email?.trim()) {
// Same contract as the phone branch: the string stored on send and the one
// looked up on verify must be byte-identical, or the code is invisible to
// the verifier. Addresses reach us from a raw `@Body("email")` with no DTO
// or ValidationPipe, so `User@X.com`, `user@x.com` and a copy-paste with a
// trailing space are three different keys for one mailbox. Domains are
// case-insensitive (RFC 1035); local-parts are formally case-sensitive
// (RFC 5321 §2.4) but no mail provider in practice treats them so, and
// matching what users expect beats matching the letter of the spec here.
return { email: target.email.trim().toLowerCase() };
normalized.email = target.email.trim().toLowerCase();
}
if (!target.phone) return target;
const raw = target.phone.trim();
const digits = raw.replace(/[^\d+]/g, '');
if (digits.startsWith('+')) return { phone: digits };
const bare = digits.replace(/^0+/, '');
if (/^251\d{9}$/.test(digits)) return { phone: `+${digits}` };
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return { phone: `+251${bare}` };
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
// it looks like a full international number, else leave as typed.
return { phone: digits.length >= 11 ? `+${digits}` : raw };
if (target.phone?.trim()) {
normalized.phone = normalizePhone(target.phone);
}
return normalized;
}
/** One transport's hand-off outcome. Never thrown — collected and reported. */
interface DispatchOutcome {
channel: "email" | "sms";
queued: boolean;
error?: string;
}
@Injectable()
@@ -69,35 +104,35 @@ export class OtpService {
// ---------------------------------------------------------------------------
async sendOtp(rawTarget: OtpTarget) {
// Store under the canonical E.164 key so verify (which normalises the same
// way) always finds this row regardless of how either side typed the number.
// Store under the canonical keys so verify (which normalises the same way)
// always finds this row regardless of how either side typed the number.
const target = normalizeOtpTarget(rawTarget);
const channel = target.email ? "email" : "sms";
const channels = channelsOf(target);
const label = this.targetLabel(target);
const startedAt = Date.now();
if (channels.length === 0) {
throw new BadRequestException("phone or email is required");
}
try {
// The verification code is generated server-side — never supplied by the
// caller — so the OTP stays a secret known only to the server and the
// recipient of the SMS/email.
// recipient of the SMS/email. ONE code covers every channel: the user
// types whichever message reaches them first.
const otp = this.generateOtp();
// find existing row for this channel
const existing = await this.otpRepository.findByTarget(target);
// update existing otp
if (existing) {
await this.otpRepository.updateOtp(existing, otp);
} else {
// create new otp
await this.otpRepository.createOtp(target, otp);
}
// Replaces every row this target overlaps with, so a dual-channel send
// leaves exactly one row holding both halves — verify then resolves the
// same row whichever channel it is given.
const { rotated } = await this.otpRepository.replaceOtp(target, otp);
// `rotate` means a code already existed for this target and was replaced —
// the previous one is now dead. A user holding a slow-to-arrive SMS and
// typing its code will fail against the row; this line is how that shows up
// in the log rather than as an unexplained "invalid OTP" report.
this.logger.log(
`otp.issue channel=${channel} target=${label} action=${existing ? "rotate" : "create"}`,
`otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`,
);
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
@@ -108,35 +143,49 @@ export class OtpService {
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
// in the codebase yet.
// Fan out to every channel the target has, independently: one transport
// being down must not suppress the other, which is the whole point of
// sending to both. Each helper swallows its own failure so a rejected
// email publish still leaves the SMS delivered (and the code valid).
const outcomes = (
await Promise.all([
target.email ? this.dispatchEmail(target.email, otp) : null,
target.phone ? this.dispatchSms(target.phone, otp) : null,
])
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
for (const outcome of outcomes) {
this.logger.log(
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
outcome.queued
} latencyMs=${Date.now() - startedAt}${
outcome.error ? ` error=${outcome.error}` : ""
}`,
);
}
// Every channel threw. Nothing can arrive and there is no partial success
// to preserve — fail the request the way a single-channel send always did.
if (outcomes.every((outcome) => outcome.error)) {
throw new Error(
outcomes.map((o) => `${o.channel}: ${o.error}`).join("; "),
);
}
// Both clients report hand-off, not delivery — capture it rather than
// discarding it, so "queued=false" is distinguishable from a code that was
// published fine and lost downstream at the carrier.
const { queued } = target.email
? await this.emailClient.sendEmail({
to: target.email,
subject: "Your EDR Freight verification code",
text: `Your verification code is ${otp}`,
})
: await this.smsClient.sendSms({
to: target.phone as string,
message: `Your verification code is ${otp}`,
});
// discarding it, so "delivered=false" is distinguishable from a code that
// was published fine and lost downstream at the carrier.
const delivered = outcomes.some((outcome) => outcome.queued);
this.logger.log(
`otp.dispatch channel=${channel} target=${label} queued=${queued} latencyMs=${
Date.now() - startedAt
}`,
);
if (!queued) {
if (!delivered) {
// The row is committed and we are about to answer "OTP sent successfully",
// but nothing left this process. Without this line the only symptom is a
// user who never receives a code — indistinguishable from carrier loss,
// and the misleading success response makes it look like our side worked.
this.logger.error(
`otp.dispatch.dropped channel=${channel} target=${label} rabbitmqEnabled=${
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
process.env.RABBITMQ_ENABLED ?? "unset"
} — transport reported no hand-off; no code will arrive for this send`,
} no transport reported hand-off; no code will arrive for this send`,
);
}
@@ -146,13 +195,13 @@ export class OtpService {
// aggregation is the debugging path for flaky SMS here) — if that tradeoff
// is ever revisited, gate on an env flag rather than deleting the line, so
// dev keeps its workflow.
this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`);
this.logger.log(`OTP send for ${label}: ${otp}`);
return {
success: true,
// Distinguishes "we published it" from "the transport is a no-op". The
// HTTP response shape is unchanged; the controller drops this field.
delivered: queued,
delivered,
message: "OTP sent successfully",
};
@@ -160,7 +209,7 @@ export class OtpService {
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
this.logger.error(
`otp.dispatch.failed channel=${channel} target=${label} latencyMs=${
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
Date.now() - startedAt
}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
@@ -170,13 +219,60 @@ export class OtpService {
}
/**
* Correlation key shared by every `otp.*` line for one address, so a send and
* its later verify can be joined with a single grep. The raw target is used
* Publish to one transport, converting a throw into a reported outcome. A
* broker error on one channel must not abort the other — with dual-channel
* sends the user still has a working route to the code.
*/
private async dispatchEmail(
email: string,
otp: string,
): Promise<DispatchOutcome> {
try {
const { queued } = await this.emailClient.sendEmail({
to: email,
subject: "Your EDR Freight verification code",
text: `Your verification code is ${otp}`,
});
return { channel: "email", queued };
} catch (error) {
return {
channel: "email",
queued: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/** SMS half of {@link dispatchEmail}; same swallow-and-report contract. */
private async dispatchSms(
phone: string,
otp: string,
): Promise<DispatchOutcome> {
try {
const { queued } = await this.smsClient.sendSms({
to: phone,
message: `Your verification code is ${otp}`,
});
return { channel: "sms", queued };
} catch (error) {
return {
channel: "sms",
queued: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Correlation key shared by every `otp.*` line for one target, so a send and
* its later verify can be joined with a single grep. The raw values are used
* because the code itself is already logged in cleartext above — hashing the
* address while printing the credential next to it would buy nothing.
*/
private targetLabel(target: OtpTarget): string {
return target.email ?? target.phone ?? "unknown";
return (
[target.email, target.phone].filter(Boolean).join("+") || "unknown"
);
}
/**
@@ -190,15 +286,37 @@ export class OtpService {
result: "ok" | "invalid" | "expired" | "exhausted" | "not_found",
detail?: string,
) {
const line = `otp.verify channel=${
target.email ? "email" : "sms"
} target=${this.targetLabel(target)} mode=${mode} result=${result}${
const line = `otp.verify channels=${channelsOf(target).join(
"+",
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
detail ? ` ${detail}` : ""
}`;
if (result === "ok") this.logger.log(line);
else this.logger.warn(line);
}
/**
* "No code for this target" phrased for whichever channels were named. A
* dual-channel caller gets a neutral message — naming one channel would be
* misleading when the code went to both.
*/
private notFoundMessage(target: OtpTarget, requested: boolean): string {
const channels = channelsOf(target);
if (channels.length !== 1) {
return requested
? "No verification code was requested for this account"
: "No verification code found for this account";
}
if (target.email) {
return requested
? "No verification code was requested for this email"
: "Email address not found";
}
return requested
? "No verification code was requested for this phone"
: "Phone number not found";
}
// ---------------------------------------------------------------------------
// Verify OTP
// ---------------------------------------------------------------------------
@@ -207,20 +325,24 @@ export class OtpService {
// Same canonicalisation as sendOtp so a code stored under +2519… is found
// when verify is called with 09… (or any equivalent form).
const target = normalizeOtpTarget(rawTarget);
// find the channel's row
// Matches on ANY channel the caller named, so a code sent to both phone and
// email verifies whichever one the user quotes back.
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);
// not found
if (!otpData) {
// No row for this key. Most often a normalisation mismatch or a code that
// was already consumed/burned — not necessarily a caller who never asked.
// No row for this target. Most often a normalisation mismatch or a code
// that was already consumed/burned — not necessarily a caller who never
// asked.
this.logVerify(target, "simple", "not_found");
throw new BadRequestException(
target.email ? "Email address not found" : "Phone number not found",
);
throw new BadRequestException(this.notFoundMessage(target, false));
}
// Key the attempt budget on the ROW, not on the channels the caller happened
// to name — otherwise guessing alternately by phone and by email would hand
// an attacker two independent budgets against the same code.
const key = otpData.id;
// TTL: reuse the same age window as the hardened action verifier — an old
// code can't be verified.
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
@@ -265,7 +387,8 @@ export class OtpService {
throw new BadRequestException("Invalid OTP");
}
// single-use: consume the code on success so it can't be replayed.
// single-use: consume the code on success so it can't be replayed. One row
// covers every channel it was sent to, so this kills all of them at once.
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
this.logVerify(target, "simple", "ok", `ageMs=${ageMs}`);
@@ -273,9 +396,7 @@ export class OtpService {
return {
success: true,
message: target.email
? "Email verified successfully"
: "Phone verified successfully",
message: "Verification successful",
};
}
@@ -297,10 +418,6 @@ export class OtpService {
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(
rawTarget: OtpTarget,
otp: string,
@@ -308,17 +425,14 @@ export class OtpService {
) {
const target = normalizeOtpTarget(rawTarget);
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);
if (!otpData) {
this.logVerify(target, "action", "not_found");
throw new BadRequestException(
target.email
? "No verification code was requested for this email"
: "No verification code was requested for this phone",
);
throw new BadRequestException(this.notFoundMessage(target, true));
}
// Row-keyed for the same reason as verifyOtp: one code, one budget.
const key = otpData.id;
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
if (ageMs > ttlMs) {

View File

@@ -31,6 +31,15 @@ export class ApprovalRulesController {
return this.service.findChain(flag === 'true');
}
@Get('position-types')
@RuleEngineView('approval-rules')
@ApiOperation({
summary: 'IAM position types to choose from when building an approval chain',
})
listPositionTypes() {
return this.service.listPositionTypes();
}
@Post('reorder')
@RuleEngineManage('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -1,8 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const;
export class CreateApprovalRuleDto {
@ApiProperty({ description: 'True = Director+CEO chain; False = LineStaff+Director chain' })
@IsBoolean()
@@ -19,9 +17,12 @@ export class CreateApprovalRuleDto {
@IsUUID('4')
insertAfterId?: string;
@ApiProperty({ enum: ROLES, description: 'Role required to action this step' })
@ApiProperty({
description:
'IAM position-type key required to action this step (see GET /approval-rules/position-types)',
})
@IsString()
@MaxLength(30)
@MaxLength(64)
requiredRole!: string;
@ApiProperty({ description: 'Label shown in UI, e.g. "Review & Approve"', maxLength: 50 })
@@ -29,9 +30,11 @@ export class CreateApprovalRuleDto {
@MaxLength(50)
actionLabel!: string;
@ApiPropertyOptional({ enum: ROLES, description: 'Role explicitly blocked from actioning this step' })
@ApiPropertyOptional({
description: 'IAM position-type key explicitly blocked from actioning this step',
})
@IsOptional()
@IsString()
@MaxLength(30)
@MaxLength(64)
blocksRole?: string;
}

View File

@@ -12,12 +12,12 @@ export class ApprovalRule extends BaseEntity {
@Column({ name: 'step_order', type: 'smallint' })
stepOrder!: number;
@Column({ name: 'required_role', type: 'varchar', length: 30 })
@Column({ name: 'required_role', type: 'varchar', length: 64 })
requiredRole!: string;
@Column({ name: 'action_label', type: 'varchar', length: 50 })
actionLabel!: string;
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
@Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true })
blocksRole?: string | null;
}

View File

@@ -64,7 +64,6 @@ import { RuleEngineService } from './rule-engine.service';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
@@ -87,7 +86,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ApprovalRule,
BookingContainer,
BookingCargoModifier,
BookingApprovalStep,
BookingRateSnapshot,
]),
// Team notifications for the priority-rule approval workflow.

View File

@@ -1,6 +1,5 @@
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
import { Rate, RateTrigger } from './entities/rate.entity';
import {
@@ -23,15 +22,10 @@ import {
IRatesRepository,
RATES_REPOSITORY,
} from './interfaces/rates.repository.interface';
import {
IApprovalRulesRepository,
APPROVAL_RULES_REPOSITORY,
} from './interfaces/approval-rules.repository.interface';
import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from './interfaces/shipping-lines.repository.interface';
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
export interface BookingContainerEvalInput {
@@ -124,8 +118,6 @@ export class RuleEngineService {
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
@Inject(RATES_REPOSITORY)
private readonly ratesRepo: IRatesRepository,
@Inject(APPROVAL_RULES_REPOSITORY)
private readonly approvalRulesRepo: IApprovalRulesRepository,
@Inject(SHIPPING_LINES_REPOSITORY)
private readonly shippingLinesRepo: IShippingLinesRepository,
private readonly dataSource: DataSource,
@@ -390,79 +382,6 @@ export class RuleEngineService {
return violations;
}
/**
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
*/
async ensureDefaultApprovalRules(): Promise<void> {
for (const flag of [false, true] as const) {
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
if (existing.length > 0) continue;
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
(r) => r.requiresDirectorApproval === flag,
);
for (const row of rows) {
await this.approvalRulesRepo.create({
requiresDirectorApproval: row.requiresDirectorApproval,
stepOrder: row.stepOrder,
requiredRole: row.requiredRole,
actionLabel: row.actionLabel,
blocksRole: row.blocksRole,
});
}
}
}
/**
* Instantiate booking_approval_step rows from approval_rules by freight type.
*/
async instantiateApprovalSteps(
bookingId: string,
options: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
},
): Promise<BookingApprovalStep[]> {
await this.ensureDefaultApprovalRules();
let requiresDirectorApproval = false;
if (options.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
if (!cargoType) {
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
}
requiresDirectorApproval = cargoType.requiresDirectorApproval;
}
const chain = await this.approvalRulesRepo.findChainForCargo(
requiresDirectorApproval,
);
if (chain.length === 0) {
throw new BadRequestException(
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
);
}
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
const steps: BookingApprovalStep[] = [];
for (const rule of chain) {
const step = stepRepo.create({
bookingId,
approvalRuleId: rule.id,
stepOrder: rule.stepOrder,
requiredRole: rule.requiredRole,
blocksRole: rule.blocksRole ?? null,
status: 'PENDING',
});
steps.push(await stepRepo.save(step));
}
return steps;
}
/**
* Snapshot only the rates used in a booking's final price.
*/

View File

@@ -1,5 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
@@ -17,6 +18,7 @@ export class ApprovalRulesService {
@Inject(APPROVAL_RULES_REPOSITORY)
private readonly repository: IApprovalRulesRepository,
private readonly displayOrder: DisplayOrderService,
private readonly dataSource: DataSource,
) {}
/** List approval rules — standard paginated envelope with server-side search. */
@@ -29,6 +31,21 @@ export class ApprovalRulesService {
return this.repository.findChainForCargo(requiresDirectorApproval);
}
/**
* IAM position types, for the approval-step role picker. A chain step names
* the position type that must approve it, so this is the vocabulary an admin
* builds chains from. Read straight from the shared `iam` schema — the same
* pattern the freight API already uses for `iam.users`.
*/
async listPositionTypes(): Promise<Array<{ label: string; value: string }>> {
const rows = await this.dataSource.query<
Array<{ key: string; label: string }>
>(`SELECT key, COALESCE(name->>'en', key) AS label
FROM iam.position_types
ORDER BY 2`);
return rows.map((row) => ({ label: row.label, value: row.key }));
}
/** Get an approval rule by ID. */
async findById(id: string): Promise<ApprovalRule> {
const entity = await this.repository.findById(id);

View File

@@ -1,10 +1,6 @@
import axios from "axios";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
import {
AUTH_TOKEN_COOKIE,
@@ -120,8 +116,8 @@ api.interceptors.response.use(
error.response.status !== 401 &&
!originalRequest?.suppressErrorModal
) {
const payload = extractApiErrorPayload(error);
if (payload) emitApiError(payload);
// const payload = extractApiErrorPayload(error);
// if (payload) emitApiError(payload);
}
return Promise.reject(error);
}

View File

@@ -58,13 +58,9 @@ export interface LoginResponse extends Partial<AuthTokens> {
mfaRequired?: boolean;
}
/** The channel a password-reset code is delivered over. */
export type ResetChannel = "email" | "phone";
export interface ForgotPasswordRequestPayload {
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
identifier: string;
channel: ResetChannel;
}
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {

View File

@@ -1,70 +1,20 @@
import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
import {
AlertCircle,
ArrowLeft,
Mail,
RotateCw,
ShieldCheck,
Smartphone,
} from "lucide-react";
import { Alert, Button, PinInput, Stack, Text } from "@mantine/core";
import { AlertCircle, ArrowLeft, RotateCw, ShieldCheck } from "lucide-react";
import { maskEmail, maskPhone } from "@/utils/identifier";
export type OtpChannel = "phone" | "email";
export const OTP_LENGTH = 6;
export interface OtpChannelSelectProps {
value: OtpChannel;
onChange: (channel: OtpChannel) => void;
disabled?: boolean;
label?: string;
}
/** Phone/email toggle deciding where the verification code is sent. */
export function OtpChannelSelect({
value,
onChange,
disabled,
label = "Send verification code via",
}: OtpChannelSelectProps) {
return (
<div className="space-y-1.5">
<Text size="sm" fw={500} c="edr-text">
{label}
</Text>
<SegmentedControl
fullWidth
disabled={disabled}
value={value}
onChange={(v) => onChange(v as OtpChannel)}
data={[
{
value: "phone",
label: (
<span className="flex items-center justify-center gap-1.5">
<Smartphone size={14} /> Phone
</span>
),
},
{
value: "email",
label: (
<span className="flex items-center justify-center gap-1.5">
<Mail size={14} /> Email
</span>
),
},
]}
/>
</div>
);
}
export interface OtpChannelStepProps {
channel: OtpChannel;
/** Raw email or phone the code went to; masked before display. */
target: string;
/**
* Raw contacts the code was sent to; masked before display. The API sends one
* code to every contact on the account, so both are usually set — pass only
* what the client actually knows. Omit both when the client cannot know them
* (the forgot-password flow deliberately never reveals an account's contacts)
* and a generic line is shown instead.
*/
email?: string;
phone?: string;
value: string;
onChange: (otp: string) => void;
onVerify: () => void;
@@ -82,11 +32,13 @@ export interface OtpChannelStepProps {
/**
* The "enter the code we sent you" stage. Shared by signup and the
* forgot-password flow — both send through the same `/api/otp/*` service.
* forgot-password flow — both send through the same `/api/otp/*` service, which
* delivers a single code to the account's email AND phone; whichever message
* arrives first can be typed here.
*/
export default function OtpChannelStep({
channel,
target,
email,
phone,
value,
onChange,
onVerify,
@@ -100,7 +52,10 @@ export default function OtpChannelStep({
description,
submitLabel,
}: OtpChannelStepProps) {
const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target);
const maskedTargets = [
email ? maskEmail(email) : null,
phone ? maskPhone(phone) : null,
].filter(Boolean) as string[];
const busy = sending || verifying;
return (
@@ -113,12 +68,23 @@ export default function OtpChannelStep({
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
{title ?? `Verify your ${channel === "email" ? "email" : "phone"}`}
{title ?? "Verify it's you"}
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a {OTP_LENGTH}-digit code to{" "}
<span className="font-medium text-gray-700">{maskedTarget}</span>.{" "}
{description ?? "Enter it to continue."}
{maskedTargets.length ? (
maskedTargets.map((target, index) => (
<span key={target}>
{index > 0 ? " and " : null}
<span className="font-medium text-gray-700">{target}</span>
</span>
))
) : (
<span className="font-medium text-gray-700">
the email and phone on your account
</span>
)}
. {description ?? "Enter it to continue."}
</p>
</div>

View File

@@ -1,217 +0,0 @@
import { useMemo, useState } from "react";
import { Check, ShieldCheck } from "lucide-react";
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useAuth } from "@/auth/useAuth";
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import {
buildApproveActionForStep,
canActOnApprovalStep,
getNextPendingApprovalStep,
} from "@/features/bookings/booking-actions.config";
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
import { SectionCard } from "./detail/SectionCard";
type Mutations = ReturnType<typeof useBookingMutations>;
interface ApprovalStepsCardProps {
booking: BookingDetail;
mutations: Mutations;
}
/** Approval chain with inline approve on the current pending step. */
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
const { user } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(null);
const steps = useMemo(
() => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder),
[booking.approvalSteps],
);
const nextPending = getNextPendingApprovalStep(steps);
const summary = formatApprovalProgress(booking.status, steps);
const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null;
const openApprove = (step: BookingApprovalStep) => {
setPendingStep(step);
setConfirmOpen(true);
};
const closeApprove = () => {
setConfirmOpen(false);
setPendingStep(null);
};
const runApprove = () => {
if (!pendingStep) return;
mutations.approveStep.mutate(
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
{ onSuccess: () => closeApprove() },
);
};
const subtitle =
summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin");
return (
<>
<SectionCard
icon={ShieldCheck}
title="Approval chain"
extra={
<Badge color="edr-green" variant="light" radius="sm">
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
</Badge>
}
>
<Text size="xs" c="dimmed" mb="sm">
{subtitle}
</Text>
{steps.length === 0 ? (
<Text
size="sm"
c="dimmed"
ta="center"
py="lg"
px="md"
style={{
borderRadius: 8,
border: "1px dashed var(--mantine-color-gray-3)",
background: "var(--mantine-color-gray-0)",
}}
>
Use <strong>Accept for approval</strong> in staff actions to instantiate steps.
</Text>
) : (
<Stack gap="xs">
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
steps={steps}
user={user}
isNext={nextPending?.id === step.id}
isPending={mutations.approveStep.isPending}
onApprove={openApprove}
/>
))}
</Stack>
)}
</SectionCard>
<BookingConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
if (!open) closeApprove();
else setConfirmOpen(true);
}}
action={pendingAction}
reference={booking.reference}
inputValue=""
onInputChange={() => {}}
onConfirm={runApprove}
isPending={mutations.approveStep.isPending}
/>
</>
);
}
function StepRow({
step,
steps,
user,
isNext,
isPending,
onApprove,
}: {
step: BookingApprovalStep;
steps: BookingApprovalStep[];
user: ReturnType<typeof useAuth>["user"];
isNext: boolean;
isPending: boolean;
onApprove: (step: BookingApprovalStep) => void;
}) {
const canApprove = canActOnApprovalStep(user, step, steps);
const statusColor =
step.status === "APPROVED"
? "edr-green"
: step.status === "REJECTED"
? "red"
: isNext
? "edr-green"
: "gray";
return (
<Group
justify="space-between"
wrap="nowrap"
gap="sm"
px="sm"
py="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
borderLeft: isNext
? "3px solid var(--freight-brand)"
: "1px solid var(--mantine-color-gray-2)",
background: isNext ? "var(--mantine-color-gray-0)" : "white",
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
flexShrink: 0,
fontSize: 12,
fontWeight: 700,
background: "var(--mantine-color-gray-1)",
color: isNext ? "var(--mantine-color-gray-7)" : "var(--mantine-color-gray-6)",
}}
>
{step.stepOrder}
</Box>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{step.requiredRole}
</Text>
{step.remarks && (
<Text size="xs" c="dimmed" truncate>
{step.remarks}
</Text>
)}
</Box>
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{canApprove && (
<Button
size="compact-sm"
color="edr-green"
leftSection={<Check size={14} />}
disabled={isPending}
onClick={() => onApprove(step)}
>
Approve
</Button>
)}
<Badge variant="light" color={statusColor} size="sm" radius="sm" tt="uppercase">
{step.status}
</Badge>
</Group>
</Group>
);
}

View File

@@ -6,7 +6,6 @@ import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import {
getNextPendingApprovalStep,
isAllocateAction,
isClearanceNavAction,
isContractNavAction,
@@ -37,7 +36,6 @@ export function BookingActionsMenu({
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: row.reference,
approvalSteps: row.approvalSteps,
schedulingStatus: row.schedulingStatus,
customsClearingEnabled: row.customsClearingEnabled,
};
@@ -192,28 +190,6 @@ function ActionDialog({
}}
isPending={flow.mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled}
extra={
flow.detailLoading ? (
<Text size="sm" c="dimmed">
Loading approval steps
</Text>
) : pendingAction?.id === "approve" &&
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
<Text
size="sm"
c="orange.9"
p="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-orange-2)",
background: "var(--mantine-color-orange-0)",
}}
>
No pending approval step. Refresh the page after staff accept, or reject the
booking.
</Text>
) : null
}
/>
);
}

View File

@@ -1,29 +0,0 @@
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
interface BookingApprovalProgressCellProps {
row: BookingListRow;
}
export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCellProps) {
const summary = formatApprovalProgress(row.status, row.approvalSteps);
return (
<div className="min-w-[8.5rem] py-1">
<p
className={cn(
"text-sm font-semibold",
summary.complete ? "text-[color:var(--freight-brand)]" : "text-foreground",
)}
>
{summary.label}
</p>
{summary.detail ? (
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{summary.detail}
</p>
) : null}
</div>
);
}

View File

@@ -1,69 +0,0 @@
import { CheckCircle, Clock, XCircle } from "lucide-react";
import { Group, Text, Badge, Timeline } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import {
approvalStatusColor,
formatDateTime,
type BookingApprovalStepView,
} from "./booking-detail.styles";
export interface BookingApprovalCardProps {
steps: BookingApprovalStepView[];
approvedCount: number;
}
/** Vertical timeline of the booking's approval chain. */
export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCardProps) {
return (
<SectionCard
icon={CheckCircle}
title="Approval Workflow"
accent="edr-green"
extra={
<Badge color="edr-green" variant="light" radius="sm">
{approvedCount} / {steps.length} approved
</Badge>
}
>
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="edr-green">
{steps.map((step) => (
<Timeline.Item
key={step.id}
color={approvalStatusColor(step.status)}
bullet={
step.status === "APPROVED" ? (
<CheckCircle size={14} />
) : step.status === "REJECTED" ? (
<XCircle size={14} />
) : (
<Clock size={14} />
)
}
title={
<Group gap="sm">
<Text fw={600} size="sm">
{step.requiredRole.replace(/_/g, " ")}
</Text>
<Badge
color={approvalStatusColor(step.status)}
size="xs"
radius="sm"
variant="light"
>
{step.status}
</Badge>
</Group>
}
>
{step.actionedAt && (
<Text size="xs" c="dimmed">
{formatDateTime(step.actionedAt)}
</Text>
)}
</Timeline.Item>
))}
</Timeline>
</SectionCard>
);
}

View File

@@ -102,14 +102,6 @@ export interface BookingContainerView {
};
}
export interface BookingApprovalStepView {
id: string;
stepOrder: number;
requiredRole: string;
status: string;
actionedAt?: string | null;
}
export interface BookingReviewNoteView {
id: string;
note: string;
@@ -150,7 +142,6 @@ export interface BookingDetailView {
cargoType?: BookingNamedRefView;
shippingLine?: BookingNamedRefView;
bookingContainers?: BookingContainerView[];
approvalSteps?: BookingApprovalStepView[];
reviewNotes?: BookingReviewNoteView[];
files?: BookingFileView[];
}

View File

@@ -10,7 +10,6 @@ export * from "./BookingLifecycleStepper";
export * from "./BookingRouteCard";
export * from "./BookingContainersCard";
export * from "./BookingContainerUnitsCard";
export * from "./BookingApprovalCard";
export * from "./BookingReviewNotesCard";
export * from "./BookingPaymentCard";
export * from "./BookingPaymentCountdownCard";

View File

@@ -2,12 +2,11 @@ import { useCallback, useState } from "react";
import {
getBookingActions,
getNextPendingApprovalStep,
type BookingActionContext,
type BookingActionDef,
} from "@/features/bookings/booking-actions.config";
import { useAuth } from "@/auth/useAuth";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
import { useBookingMutations } from "@/hooks/bookings/useBookings";
/** A contract validity window must be a whole number of days, 1365. */
function isValidValidityDays(value: string): boolean {
@@ -24,22 +23,11 @@ export function useBookingActionDialog(
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const needsApprovalSteps =
pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
// Bookings no longer have an approval chain, so the dialog needs nothing
// beyond the list-row context it was handed.
const detailLoading = false;
const needsApprovalContext =
context.status === "PENDING_APPROVAL" ||
context.status === "APPROVED_PENDING_SIGNATURE";
const { data: detail, isLoading: detailLoading } = useBookingDetail(
needsApprovalSteps || needsApprovalContext ? bookingId : undefined,
);
const mergedContext: BookingActionContext = {
...context,
approvalSteps: detail?.approvalSteps ?? context.approvalSteps,
reference: detail?.reference ?? context.reference,
};
const mergedContext: BookingActionContext = { ...context };
const { user } = useAuth();
const mutations = useBookingMutations(bookingId);
@@ -86,24 +74,6 @@ export function useBookingActionDialog(
{ onSuccess },
);
break;
case "approve": {
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
if (!step) return;
mutations.approveStep.mutate(
{ stepId: step.id, requiredRole: step.requiredRole },
{ onSuccess },
);
break;
}
case "rejectApproval": {
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
if (!step) return;
mutations.rejectStep.mutate(
{ stepId: step.id, reason: inputValue.trim() },
{ onSuccess },
);
break;
}
case "viewContract":
break;
case "startTransit":
@@ -122,16 +92,12 @@ export function useBookingActionDialog(
pendingAction,
inputValue,
selectedFile,
mergedContext.approvalSteps,
mutations,
closeDialog,
]);
const confirmDisabled =
mutations.isPending ||
(needsApprovalSteps && detailLoading) ||
(pendingAction?.id === "approve" &&
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim()) ||

View File

@@ -4,11 +4,10 @@ import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Check,
FileCheck,
Eye,
FilePen,
FileSignature,
MessageSquareWarning,
RefreshCw,
ShieldCheck,
XCircle,
Zap,
@@ -16,8 +15,10 @@ import {
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal";
import { ContractPreviewModal } from "@/components/contracts/ContractPreviewModal";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
/** Dropdown-settings code holding the admin-configured contract validity days. */
@@ -51,11 +52,21 @@ export function ContractActionsToolbar({
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
const [previewOpen, setPreviewOpen] = useState(false);
const [changesOpen, setChangesOpen] = useState(false);
const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
// Whether the document is editable depends on WHO is viewing — only the
// approver whose turn it is may edit — so the server decides, not the client.
const { data: draft } = useQuery({
queryKey: ["contracts", contract.id, "document-draft"],
queryFn: () => contractsService.getContractDocumentDraft(contract.id),
enabled: contract.status === "PENDING_APPROVAL",
staleTime: 0,
});
// Admin-configured validity durations (days) for the accept dialog. Staff can
// only pick one of these — no free-typing. Read-only setting, fetched once.
const { data: validitySetting, isLoading: validityLoading } = useQuery({
@@ -87,18 +98,11 @@ export function ContractActionsToolbar({
}
const canAccept = status === "SUBMITTED";
// While the contract is PENDING_APPROVAL and NO approver has acted yet, staff
// can edit this contract's articles and (re)generate its PDF. The first
// approval action locks the document.
const docLocked =
status !== "PENDING_APPROVAL" ||
(contract.approvalSteps ?? []).some((s) => s.status !== "PENDING");
const canEditGenerate = status === "PENDING_APPROVAL" && !docLocked;
const documentGenerated = Boolean(contract.contractGeneratedAt);
// Legacy fallback: if a contract ever lands on APPROVED without a document
// (older flow), still offer a manual generate that moves it to CONTRACT_READY.
const needsManualGenerate =
status === "APPROVED" && !contract.contractGeneratedAt;
// The document stays editable for the whole approval chain, but only by the
// approver whose turn it is. The server resolves that against the caller's
// position type; the client cannot derive it.
const canEditDocument = Boolean(draft?.editableByMe);
const inApproval = status === "PENDING_APPROVAL";
// Signing now happens on the contract VIEW page (staff must open and read the
// generated contract before signing) — no sign button in this toolbar.
const canViewContract =
@@ -154,56 +158,41 @@ export function ContractActionsToolbar({
</>
)}
{canEditGenerate && (
{inApproval && (
<>
<Text size="xs" c="dimmed">
{documentGenerated
? "Document generated. Approvers can now review it. You can still edit and regenerate until the first approval."
: "Review the contract document, edit its articles if needed, then generate it so approvers can review."}
{canEditDocument
? "It is your turn to approve. You can edit the articles before approving — the PDF is generated automatically once the last approver approves."
: draft?.nextApproverRole
? `Awaiting ${draft.nextApproverRole}. Only the current approver can edit the document.`
: "Awaiting approval."}
</Text>
<Button
fullWidth
variant="light"
color="gray"
leftSection={<FilePen size={16} />}
onClick={() => {
setEditorMode("edit");
setEditorOpen(true);
}}
leftSection={<Eye size={16} />}
onClick={() => setPreviewOpen(true)}
>
Edit contract articles
</Button>
<Button
fullWidth
color="edr-green"
leftSection={
documentGenerated ? (
<RefreshCw size={16} />
) : (
<FileCheck size={16} />
)
}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
{documentGenerated ? "Regenerate contract" : "Generate contract"}
Preview document
</Button>
{canEditDocument && (
<Button
fullWidth
variant="light"
color="gray"
leftSection={<FilePen size={16} />}
onClick={() => {
setEditorMode("edit");
setEditorOpen(true);
}}
>
Edit contract articles
</Button>
)}
</>
)}
{needsManualGenerate && (
<Button
fullWidth
variant="light"
color="orange"
leftSection={<FileCheck size={16} />}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
Generate contract
</Button>
)}
{canViewContract && (
<Button
fullWidth
@@ -233,8 +222,7 @@ export function ContractActionsToolbar({
the customer creates the booking in the portal. */}
{!canAccept &&
!canEditGenerate &&
!needsManualGenerate &&
!inApproval &&
!canViewContract &&
!canReviewClearance && (
<Text size="sm" c="dimmed">
@@ -267,6 +255,12 @@ export function ContractActionsToolbar({
}
/>
<ContractPreviewModal
opened={previewOpen}
onClose={() => setPreviewOpen(false)}
contractId={contract.id}
/>
{/* Request changes */}
<Modal
opened={changesOpen}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react";
import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -31,7 +31,6 @@ export function ContractApprovalStepsCard({
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState<Freight.IContractApprovalStep | null>(null);
const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectStepRow, setRejectStepRow] =
useState<Freight.IContractApprovalStep | null>(null);
@@ -48,17 +47,9 @@ export function ContractApprovalStepsCard({
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
// Approvers must review the GENERATED contract document before approving. If
// it has not been generated yet, block the approval and tell staff to generate
// it first (via "Generate contract" in Staff actions) — mirrors the server
// guard so the user sees a clear reason, not a generic failure toast.
const documentGenerated = Boolean(contract.contractGeneratedAt);
// Approvers review a live preview of the document; there is no PDF to
// generate first — the final approval is what produces it.
const openApprove = (step: Freight.IContractApprovalStep) => {
if (contract.status === "PENDING_APPROVAL" && !documentGenerated) {
setNeedsGenerateOpen(true);
return;
}
setPendingStep(step);
setConfirmOpen(true);
};
@@ -71,7 +62,7 @@ export function ContractApprovalStepsCard({
const runApprove = () => {
if (!pendingStep) return;
mutations.approveStep.mutate(
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
{ stepId: pendingStep.id },
{ onSuccess: () => closeApprove() },
);
};
@@ -192,46 +183,6 @@ export function ContractApprovalStepsCard({
</Stack>
</Modal>
<Modal
opened={needsGenerateOpen}
onClose={() => setNeedsGenerateOpen(false)}
title={
<Group gap="xs">
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" />
<Text fw={700}>Generate the contract first</Text>
</Group>
}
radius="md"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
The contract document for{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
has not been generated yet. Approvers must review the generated
document before it can be approved.
</Text>
<Text size="sm" c="dimmed">
Use{" "}
<Text span fw={600} c="dark">
Generate contract
</Text>{" "}
in the Staff actions panel edit the articles first if needed then
return here to approve.
</Text>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<FileCheck size={16} />}
onClick={() => setNeedsGenerateOpen(false)}
>
Got it
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={rejectOpen}

View File

@@ -116,7 +116,9 @@ export function ContractDocumentEditorModal({
}
}, [mode, validityDays, validityOptions]);
const locked = mode === "edit" && Boolean(draft?.locked);
// Editing rights belong to the approver whose turn it is, so the server
// decides per-caller — the client cannot derive this from the contract alone.
const locked = mode === "edit" && !draft?.editableByMe;
const moveArticle = (index: number, delta: number) => {
setArticles((prev) => {
@@ -215,7 +217,9 @@ export function ContractDocumentEditorModal({
icon={locked ? <Lock size={16} /> : <Info size={16} />}
>
{locked
? "This document is locked — an approver has already acted, so it can no longer be edited."
? draft?.nextApproverRole
? `Only the current approver (${draft.nextApproverRole}) can edit this document right now.`
: "This document can no longer be edited — the contract has advanced beyond approval."
: "Edits apply to THIS contract only. The six shared templates are never changed."}
</Alert>

View File

@@ -0,0 +1,78 @@
import { useQuery } from "@tanstack/react-query";
import { Alert, Group, Loader, Modal, Text } from "@mantine/core";
import { Info } from "lucide-react";
import { contractsService } from "@/services/contracts.service";
interface ContractPreviewModalProps {
opened: boolean;
onClose: () => void;
contractId: string;
}
/**
* Live preview of the contract document. Renders server-side HTML, not the
* stored PDF — the PDF is only produced once the final approver approves, so
* before that this is the document. Served in an iframe so the contract's own
* styles stay sandboxed away from the app.
*/
export function ContractPreviewModal({
opened,
onClose,
contractId,
}: ContractPreviewModalProps) {
const { data, isLoading, isError } = useQuery({
queryKey: ["contracts", contractId, "contract-view"],
queryFn: () => contractsService.getContractView(contractId),
enabled: opened,
// The document changes as approvers edit it, so never serve a stale render.
staleTime: 0,
});
return (
<Modal
opened={opened}
onClose={onClose}
size="xl"
title="Contract document preview"
>
<Alert
icon={<Info size={16} />}
color="blue"
variant="light"
mb="sm"
p="xs"
>
<Text size="xs">
Draft preview. The PDF is generated automatically once the final
approver approves.
</Text>
</Alert>
{isLoading ? (
<Group gap="xs" py="xl" justify="center">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Rendering document
</Text>
</Group>
) : isError || !data?.html ? (
<Text size="sm" c="red">
The document could not be rendered. Check that the contract has a
template and try again.
</Text>
) : (
<iframe
srcDoc={data.html}
title="Contract document preview"
style={{
width: "100%",
minHeight: "70vh",
border: "none",
background: "white",
}}
/>
)}
</Modal>
);
}

View File

@@ -0,0 +1,134 @@
import { useQuery } from "@tanstack/react-query";
import { History } from "lucide-react";
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
interface ContractRevisionTimelineProps {
contractId: string;
}
type Change = Freight.IContractDocumentChange;
/** Badge colour + verb per change kind, so a revision reads at a glance. */
const CHANGE_STYLES: Record<Change["kind"], { color: string; label: string }> = {
ARTICLE_ADDED: { color: "green", label: "Added" },
ARTICLE_REMOVED: { color: "red", label: "Removed" },
ARTICLE_RENAMED: { color: "violet", label: "Renamed" },
ARTICLE_BODY_CHANGED: { color: "blue", label: "Edited" },
ARTICLE_REORDERED: { color: "gray", label: "Reordered" },
DOCUMENT_TITLE_CHANGED: { color: "grape", label: "Title" },
WHEREAS_CHANGED: { color: "teal", label: "Recitals" },
};
/** What the change applies to — an article title, or the document itself. */
function changeSubject(change: Change): string {
switch (change.kind) {
case "DOCUMENT_TITLE_CHANGED":
return change.fromTitle
? `${change.fromTitle}” → “${change.title}`
: change.title;
case "WHEREAS_CHANGED": {
const parts: string[] = [];
if (change.added) parts.push(`+${change.added}`);
if (change.removed) parts.push(`${change.removed}`);
return parts.join(" ") || "changed";
}
case "ARTICLE_RENAMED":
return `${change.fromTitle}” → “${change.title}`;
case "ARTICLE_REORDERED":
return `${change.title} (${change.fromOrder}${change.toOrder})`;
default:
return change.title;
}
}
function formatWhen(iso: string): string {
const date = new Date(iso);
return date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
/**
* Audit trail of edits to the contract document. The document stays editable
* through the approval chain, so this is the record of who changed what.
*/
export function ContractRevisionTimeline({
contractId,
}: ContractRevisionTimelineProps) {
const { data: revisions, isLoading } = useQuery({
queryKey: ["contracts", contractId, "document-revisions"],
queryFn: () => contractsService.getContractDocumentRevisions(contractId),
});
return (
<SectionCard icon={History} title="Document history">
{isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading history
</Text>
</Group>
) : !revisions?.length ? (
<Text size="sm" c="dimmed">
No edits recorded yet. Changes made to the contract articles during
approval will appear here.
</Text>
) : (
<Timeline
active={revisions.length}
bulletSize={18}
lineWidth={2}
color="edr-green"
>
{revisions.map((revision) => (
<Timeline.Item
key={revision.id}
title={
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600}>
{revision.actorRole ?? "Staff"}
</Text>
<Text size="xs" c="dimmed">
{formatWhen(revision.createdAt)}
</Text>
</Group>
}
>
<Stack gap={6} mt={4}>
{revision.summary && (
<Text size="xs" c="dimmed">
{revision.summary}
</Text>
)}
{revision.changes.map((change, index) => {
const style = CHANGE_STYLES[change.kind];
return (
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
<Badge
size="xs"
variant="light"
color={style?.color ?? "gray"}
style={{ flexShrink: 0 }}
>
{style?.label ?? change.kind}
</Badge>
<Text size="xs" style={{ lineHeight: 1.5 }}>
{changeSubject(change)}
</Text>
</Group>
);
})}
</Stack>
</Timeline.Item>
))}
</Timeline>
)}
</SectionCard>
);
}

View File

@@ -1,5 +1,5 @@
import { Button, Modal, Radio, Stack, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Alert, Button, Loader, Modal, Radio, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { KeyRound } from "lucide-react";
import { useState } from "react";
@@ -10,32 +10,48 @@ import { api } from "@/services/api";
import type { Company, ResetChannel } from "@/types/customer";
export interface ResetPasswordActionProps {
company: Pick<Company, "id" | "email" | "phone">;
company: Pick<Company, "id">;
}
/**
* Staff-triggered password reset. Sends a one-time code to the customer's
* primary contact; the customer picks their own new password. No credential is
* ever shown to or handled by staff.
* Staff-triggered password reset. Sends a single-use link to the customer's
* primary contact; the customer opens it and picks their own new password. No
* credential is ever shown to or handled by staff.
*/
export default function ResetPasswordAction({ company }: ResetPasswordActionProps) {
export default function ResetPasswordAction({
company,
}: ResetPasswordActionProps) {
const { user } = useAuth();
const { toast } = useToast();
const [opened, setOpened] = useState(false);
const [channel, setChannel] = useState<ResetChannel>("phone");
const allowed = hasPermission(user, FREIGHT_PERMS.customers.resetPassword);
// The destination is the primary contact's IAM account, not the company
// record — those are different fields and routinely hold different values, so
// showing `company.phone` here would tell staff the wrong number. Only fetched
// once the modal is open.
const targetQuery = useQuery(
api.customers.resetTarget.queryOptions({
input: { companyId: company.id },
enabled: allowed && opened,
}),
);
const target = targetQuery.data;
const { mutate, isPending } = useMutation(
api.customers.resetPassword.mutationOptions({
onSuccess: (result) => {
setOpened(false);
toast({
title: "Reset code sent",
description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`,
title: "Reset link sent",
description: `The customer can set a new password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
});
},
onError: (error) => {
toast({
title: "Could not send reset code",
title: "Could not send reset link",
description: error.message,
variant: "destructive",
});
@@ -43,7 +59,10 @@ export default function ResetPasswordAction({ company }: ResetPasswordActionProp
}),
);
if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null;
if (!allowed) return null;
const channelMissing =
!!target && (channel === "email" ? !target.email : !target.phone);
return (
<>
@@ -58,46 +77,66 @@ export default function ResetPasswordAction({ company }: ResetPasswordActionProp
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Send a password-reset code"
title="Send a password-reset link"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
We&apos;ll send a one-time code to this customer&apos;s primary contact.
They choose their own new password you will not see it.
We&apos;ll send a single-use link to this customer&apos;s primary
contact. They choose their own new password you will not see it.
The link expires in 24 hours.
</Text>
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
label="Send the code via"
>
<Stack gap="xs" mt="xs">
<Radio
value="phone"
label="SMS"
description={company.phone ?? "No phone on the company record"}
/>
<Radio
value="email"
label="Email"
description={company.email ?? "No email on the company record"}
/>
{targetQuery.isLoading ? (
<Stack align="center" py="md">
<Loader size="sm" />
</Stack>
</Radio.Group>
) : targetQuery.isError ? (
<Alert color="red" variant="light">
{targetQuery.error.message}
</Alert>
) : target ? (
<>
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
label={`Send the link to ${target.name || "the primary contact"} via`}
>
<Stack gap="xs" mt="xs">
<Radio
value="phone"
label="SMS"
disabled={!target.phone}
description={
target.phone ?? "No phone number on this account"
}
/>
<Radio
value="email"
label="Email"
disabled={!target.email}
description={
target.email ?? "No email address on this account"
}
/>
</Stack>
</Radio.Group>
<Text size="xs" c="dimmed">
The code goes to the primary contact&apos;s own email or phone, which
may differ from the company contact details shown above.
</Text>
<Text size="xs" c="dimmed">
These are the primary contact&apos;s own login details, which may
differ from the company contact details on the profile.
</Text>
<Button
color="edr-green"
loading={isPending}
onClick={() => mutate({ companyId: company.id, channel })}
>
Send reset code
</Button>
<Button
color="edr-green"
loading={isPending}
disabled={channelMissing}
onClick={() => mutate({ companyId: company.id, channel })}
>
Send reset link
</Button>
</>
) : null}
</Stack>
</Modal>
</>

View File

@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
documents: (id: string) =>
["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
resetTarget: (id: string) =>
["customers", "detail", id, "reset-target"] as const,
changeRequests: (id: string) =>
["customers", "detail", id, "change-requests"] as const,
},

View File

@@ -88,6 +88,8 @@ export const URL_CONSTANTS = {
`/payments/by-company/${id}/customer-view`,
RESET_PASSWORD: (companyId: string) =>
`/backoffice/customers/${companyId}/reset-password`,
RESET_TARGET: (companyId: string) =>
`/backoffice/customers/${companyId}/reset-target`,
},
BILLING: {
@@ -123,10 +125,6 @@ export const URL_CONSTANTS = {
`/bookings/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
GOVERNMENT_EXPEDITE: (id: string) => `/bookings/${id}/government-expedite`,
APPROVE_STEP: (id: string, stepId: string) =>
`/bookings/${id}/approval-steps/${stepId}/approve`,
REJECT_STEP: (id: string, stepId: string) =>
`/bookings/${id}/approval-steps/${stepId}/reject`,
CONTRACT_GENERATE: (id: string) => `/bookings/${id}/contract/generate`,
CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
@@ -177,6 +175,8 @@ export const URL_CONSTANTS = {
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
CONTRACT_DOCUMENT_DRAFT: (id: string) => `/contracts/${id}/document/draft`,
CONTRACT_DOCUMENT_REVISIONS: (id: string) =>
`/contracts/${id}/document/revisions`,
CONTRACT_DOCUMENT_ARTICLES: (id: string) =>
`/contracts/${id}/document/articles`,
CLEARANCE_QUEUE: "/contracts/clearance/queue",
@@ -437,6 +437,7 @@ export const URL_CONSTANTS = {
APPROVAL_RULES: "/approval-rules",
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
APPROVAL_RULES_POSITION_TYPES: "/approval-rules/position-types",
},
RATE_MATRIX: {
BASE: "/api/rate-matrices",

View File

@@ -1,79 +0,0 @@
import type { BookingApprovalStep, BookingStatus } from "@/types/booking";
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
export interface ApprovalProgressSummary {
label: string;
detail: string;
complete: boolean;
}
/** Compact approval chain summary for list rows and badges. */
export function formatApprovalProgress(
status: BookingStatus | string,
steps?: BookingApprovalStep[] | null,
): ApprovalProgressSummary {
const sorted = [...(steps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder);
if (sorted.length === 0) {
if (status === "SUBMITTED") {
return {
label: "Awaiting accept",
detail: "Staff must accept intake",
complete: false,
};
}
if (
status === "PENDING_APPROVAL" ||
status === "APPROVED_PENDING_SIGNATURE"
) {
return {
label: "No steps",
detail: "Approval chain not started",
complete: false,
};
}
if (
[
"APPROVED",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"PAID",
"COMPLETED",
].includes(status)
) {
return {
label: "Approved",
detail: "Internal approval complete",
complete: true,
};
}
return { label: "—", detail: "", complete: false };
}
const approved = sorted.filter((s) => s.status === "APPROVED").length;
const total = sorted.length;
const next = getNextPendingApprovalStep(sorted);
if (!next && approved === total) {
return {
label: `${approved}/${total} done`,
detail: sorted.map((s) => `${s.requiredRole}`).join(" · "),
complete: true,
};
}
if (next) {
return {
label: `${approved}/${total}`,
detail: `Next: ${next.requiredRole} (step ${next.stepOrder})`,
complete: false,
};
}
return {
label: `${approved}/${total}`,
detail: sorted.map((s) => `${s.requiredRole}: ${s.status}`).join(" · "),
complete: approved === total,
};
}

View File

@@ -14,18 +14,12 @@ import {
hasPermission,
isFreightApprovalAdmin,
} from "@/lib/permissions";
import type {
BookingApprovalStep,
BookingDetail,
BookingStatus,
} from "@/types/booking";
import type { BookingDetail, BookingStatus } from "@/types/booking";
export type BookingActionId =
| "accept"
| "requestChanges"
| "reject"
| "approve"
| "rejectApproval"
| "viewContract"
| "signContractStaff"
| "reviewClearance"
@@ -62,7 +56,6 @@ export type BookingActionContext = Pick<
BookingDetail,
| "status"
| "paymentCurrency"
| "approvalSteps"
| "reference"
| "schedulingStatus"
| "customsClearingEnabled"
@@ -86,39 +79,6 @@ export function canAllocateBooking(
);
}
export function getNextPendingApprovalStep(
steps?: BookingApprovalStep[] | null,
): BookingApprovalStep | undefined {
if (!steps?.length) return undefined;
return [...steps]
.sort((a, b) => a.stepOrder - b.stepOrder)
.find((s) => s.status === "PENDING");
}
function approvalActions(
steps?: BookingApprovalStep[] | null,
): BookingActionDef[] {
const next = getNextPendingApprovalStep(steps);
if (!next) return [];
return [
buildApproveActionForStep(next),
{
id: "rejectApproval",
label: "Reject approval",
shortLabel: "Reject",
description: "Reject at the current approval step",
confirmTitle: "Reject at approval step?",
confirmDescription:
"The booking will be marked rejected. This action cannot be undone from the UI.",
variant: "destructive",
icon: XCircle,
input: "reason",
inputLabel: "Rejection reason",
inputPlaceholder: "Explain why this booking is rejected…",
},
];
}
const SUBMITTED_ACTIONS: BookingActionDef[] = [
{
id: "accept",
@@ -232,7 +192,6 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
accept: FREIGHT_PERMS.bookings.staffAccept,
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
reject: FREIGHT_PERMS.bookings.reject,
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
viewContract: FREIGHT_PERMS.bookings.view,
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
reviewClearance: FREIGHT_PERMS.bookings.reviewDocuments,
@@ -244,55 +203,12 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
cancel: FREIGHT_PERMS.bookings.cancel,
};
const approvePermissionForRole = (role: string): string | undefined => {
if (role === "LINE_STAFF") return FREIGHT_PERMS.bookings.approveLineStaff;
if (role === "DIRECTOR") return FREIGHT_PERMS.bookings.approveDirector;
if (role === "CEO") return FREIGHT_PERMS.bookings.approveCeo;
return undefined;
};
/** True when this step is the current pending step and the user may approve it. */
export function canActOnApprovalStep(
user: AuthUser | null | undefined,
step: BookingApprovalStep,
steps?: BookingApprovalStep[] | null,
): boolean {
if (step.status !== "PENDING") return false;
const next = getNextPendingApprovalStep(steps);
if (!next || next.id !== step.id) return false;
if (isFreightApprovalAdmin(user)) return true;
const perm = approvePermissionForRole(step.requiredRole);
return perm ? hasPermission(user, perm) : false;
}
export function buildApproveActionForStep(
step: BookingApprovalStep,
): BookingActionDef {
return {
id: "approve",
label: `Approve (${step.requiredRole})`,
shortLabel: "Approve",
description: `Complete step ${step.stepOrder} as ${step.requiredRole}`,
confirmTitle: `Approve as ${step.requiredRole}?`,
confirmDescription:
"This records your approval and advances the booking to the next step in the chain.",
variant: "default",
icon: Check,
primary: true,
};
}
function filterActionsByUser(
actions: BookingActionDef[],
user: AuthUser | null | undefined,
approvalSteps?: BookingApprovalStep[] | null,
): BookingActionDef[] {
if (!user) return [];
const next = getNextPendingApprovalStep(approvalSteps);
return actions.filter((action) => {
if (action.id === "approve" && next) {
return canActOnApprovalStep(user, next, approvalSteps);
}
const perm = ACTION_PERMISSION[action.id];
return perm ? hasPermission(user, perm) : true;
});
@@ -303,7 +219,7 @@ export function getBookingActions(
ctx: BookingActionContext,
user?: AuthUser | null,
): BookingActionDef[] {
const { status, approvalSteps } = ctx;
const { status } = ctx;
let actions: BookingActionDef[];
@@ -313,8 +229,6 @@ export function getBookingActions(
break;
case "PENDING_APPROVAL":
case "APPROVED_PENDING_SIGNATURE":
actions = withCancel(approvalActions(approvalSteps));
break;
case "APPROVED":
actions = [CANCEL_ACTION];
break;
@@ -370,7 +284,7 @@ export function getBookingActions(
}
if (user === undefined) return actions;
return filterActionsByUser(actions, user, approvalSteps);
return filterActionsByUser(actions, user);
}
/** Opens contract page without confirmation dialog. */
@@ -392,7 +306,6 @@ export function listRowHasActions(
row: {
status: BookingStatus;
paymentCurrency: string;
approvalSteps?: BookingApprovalStep[] | null;
customsClearingEnabled?: boolean;
},
user?: AuthUser | null,
@@ -402,7 +315,6 @@ export function listRowHasActions(
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: "",
approvalSteps: row.approvalSteps ?? undefined,
schedulingStatus: row.status,
customsClearingEnabled: row.customsClearingEnabled,
},

View File

@@ -20,7 +20,6 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
reference: booking.reference,
contractReference: booking.contractReference ?? null,
contractId: booking.contractId ?? null,
approvalSteps: booking.approvalSteps,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
@@ -46,6 +45,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
consolidationPartnerId: booking.consolidationPartnerId ?? null,
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
customsClearingEnabled: booking.customsClearingEnabled ?? false,
bookingKind:
booking.contractKind === "GENERAL" ? "GENERAL_CONTRACT" : "ONE_TIME",
createdAt: booking.createdAt,
};
}

View File

@@ -87,40 +87,6 @@ export function useBookingMutations(bookingId: string) {
},
});
const approveStep = useMutation({
mutationFn: ({
stepId,
requiredRole,
}: {
stepId: string;
requiredRole: string;
}) =>
api.bookings.approveStep.call({
id: bookingId,
stepId,
requiredRole,
}),
onSuccess: (data) => onSuccess(data, "Approval step completed"),
onError: (error) => toast.error(parseApiError(error, "Failed to approve step")),
});
const rejectStep = useMutation({
mutationFn: ({
stepId,
reason,
}: {
stepId: string;
reason: string;
}) =>
api.bookings.rejectStep.call({
id: bookingId,
stepId,
reason,
}),
onSuccess: (data) => onSuccess(data, "Booking rejected at approval step"),
onError: (error) => toast.error(parseApiError(error, "Failed to reject step")),
});
const generateContract = useMutation({
mutationFn: () => api.bookings.generateContract.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Contract generated"),
@@ -167,8 +133,6 @@ export function useBookingMutations(bookingId: string) {
staffAccept.isPending ||
requestChanges.isPending ||
staffReject.isPending ||
approveStep.isPending ||
rejectStep.isPending ||
generateContract.isPending ||
signContract.isPending ||
payBooking.isPending ||
@@ -182,8 +146,6 @@ export function useBookingMutations(bookingId: string) {
requestChanges,
staffReject,
reviewOperation,
approveStep,
rejectStep,
generateContract,
signContract,
payBooking,

View File

@@ -122,6 +122,15 @@ export function useContractMutations(contractId: string) {
const onSuccess = (data: { id: string }, message: string) => {
toast.success(message);
void invalidateContractDetail(qc, data.id);
// Approving or editing can change who holds document-editing rights (it
// passes to the next approver), and edits add revisions — so both the draft
// and the history are refreshed on every contract mutation.
void qc.invalidateQueries({
queryKey: ["contracts", data.id, "document-draft"],
});
void qc.invalidateQueries({
queryKey: ["contracts", data.id, "document-revisions"],
});
};
const staffAccept = useMutation({
@@ -160,21 +169,16 @@ export function useContractMutations(contractId: string) {
});
const approveStep = useMutation({
mutationFn: ({
stepId,
requiredRole,
}: {
stepId: string;
requiredRole: string;
}) =>
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
// The server derives the required role from the step itself, so the client
// does not send one.
mutationFn: ({ stepId }: { stepId: string }) =>
contractsService.approveStep({ id: contractId, stepId }),
onSuccess: (data) => {
// The document is generated at the accept stage and reviewed during
// approval, so the final approval moves the contract straight to
// CONTRACT_READY on the server — no client-side generate call here.
// The final approval is what generates the PDF and moves the contract to
// CONTRACT_READY — before that approvers review a live preview.
const message =
data.status === "CONTRACT_READY"
? "Final approval complete — contract ready to sign"
? "Final approval complete — contract generated and ready to sign"
: "Approval step completed";
onSuccess(data, message);
},

View File

@@ -9,7 +9,10 @@ import {
type SubmitPriorityRuleChangePayload,
type SubmitRateChangePayload,
} from "@/services/ruleEngine/ruleEngine.service";
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
import {
LEGACY_APPROVAL_ROLES,
RULE_ENGINE_SELECT_NONE,
} from "@/pages/ruleEngine/config/resources";
import type {
RuleEngineRecord,
RuleEngineResourceSlug,
@@ -155,6 +158,33 @@ export const useContainerTypeOptions = (
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
});
/**
* Approval-step role options, sourced from the live IAM position types. The
* three pre-IAM role strings are appended (marked "(legacy)") so an approval
* rule still stored against one of them renders its label instead of an empty
* select; a position type that reuses one of those values wins the dedupe.
*/
export const useApprovalRoleOptions = (enabled = true) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("approval-rules", {
positionTypes: true,
}),
queryFn: () => ruleEngineService.getApprovalPositionTypes(),
enabled,
select: (rows): { label: string; value: string }[] => {
const byValue = new Map<string, { label: string; value: string }>();
for (const row of rows) {
const value = String(row?.value ?? "").trim();
if (!value) continue;
byValue.set(value, { label: String(row.label ?? "").trim() || value, value });
}
for (const legacy of LEGACY_APPROVAL_ROLES) {
if (!byValue.has(legacy.value)) byValue.set(legacy.value, legacy);
}
return [...byValue.values()];
},
});
/** A yard option that remembers its country, so callers can filter by leg. */
export interface YardOption {
label: string;

View File

@@ -10,11 +10,7 @@ import {
} from "@/auth/api";
import type { ResetTicket } from "@/auth/types";
import AuthShell from "@/components/auth/AuthShell";
import OtpChannelStep, {
OTP_LENGTH,
OtpChannelSelect,
type OtpChannel,
} from "@/components/auth/OtpChannelStep";
import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep";
import PasswordChecklist from "@/components/auth/PasswordChecklist";
import { useResendCooldown } from "@/hooks/useResendCooldown";
import { normaliseIdentifier } from "@/utils/identifier";
@@ -28,7 +24,6 @@ const ForgotPasswordPage = () => {
const [stage, setStage] = useState<Stage>("identify");
const [identifier, setIdentifier] = useState("");
const [channel, setChannel] = useState<OtpChannel>("phone");
const [otpCode, setOtpCode] = useState("");
// The reset ticket lives in memory only — persisting it would leave a
// password-change credential sitting in localStorage.
@@ -45,7 +40,7 @@ const ForgotPasswordPage = () => {
const normalised = normaliseIdentifier(identifier);
const sendCode = async () => {
await requestPasswordResetRequest({ identifier: normalised, channel });
await requestPasswordResetRequest({ identifier: normalised });
setOtpCode("");
resendCooldown.start();
};
@@ -89,7 +84,6 @@ const ForgotPasswordPage = () => {
try {
const result = await verifyPasswordResetOtpRequest({
identifier: normalised,
channel,
otp: otpCode.trim(),
});
setTicket(result);
@@ -138,13 +132,10 @@ const ForgotPasswordPage = () => {
}
};
const identifierLabel =
channel === "email" ? "the email on your account" : "the phone on your account";
return (
<AuthShell
tagline="Recover your account"
taglineBody="Reset your EDR Freight backoffice password with a one-time code sent to your email or phone."
taglineBody="Reset your EDR Freight backoffice password with a one-time code sent to your email and phone."
>
<div className="flex w-full flex-col">
{stage === "identify" ? (
@@ -176,16 +167,9 @@ const ForgotPasswordPage = () => {
onChange={(event) => setIdentifier(event.target.value)}
/>
<OtpChannelSelect
value={channel}
onChange={setChannel}
disabled={sending}
label="Send the code to"
/>
<p className="text-xs text-gray-500">
The code goes to {identifierLabel}, which may differ from what you
typed above.
The code goes to the email and phone on your account, which may
differ from what you typed above.
</p>
{error ? (
@@ -217,8 +201,6 @@ const ForgotPasswordPage = () => {
{stage === "otp" ? (
<OtpChannelStep
channel={channel}
target={normalised}
value={otpCode}
onChange={setOtpCode}
onVerify={handleVerify}

View File

@@ -4,7 +4,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
BookingApprovalCard,
BookingContainersCard,
BookingDetailToolbar,
BookingDocumentsCard,
@@ -75,29 +74,6 @@ const BookingDetailPage = () => {
containerType: { label: "20FT Standard", sizeFt: 20 },
},
],
approvalSteps: [
{
id: "1",
stepOrder: 1,
requiredRole: "LINE_STAFF",
status: "APPROVED",
actionedAt: "2026-06-05T11:00:00Z",
},
// {
// id: "2",
// stepOrder: 2,
// requiredRole: "DIRECTOR",
// status: "APPROVED",
// actionedAt: "2026-06-05T13:30:00Z",
// },
{
id: "3",
stepOrder: 3,
requiredRole: "CEO",
status: "APPROVED",
actionedAt: "2026-06-05T15:45:00Z",
},
],
reviewNotes: [
{
id: "1",
@@ -119,11 +95,6 @@ const BookingDetailPage = () => {
],
};
const approvalSteps = booking.approvalSteps ?? [];
const approvedCount = approvalSteps.filter(
(s) => s.status === "APPROVED",
).length;
return (
<div style={detailStyles.page}>
<Container size="xxl" py="lg">
@@ -136,13 +107,6 @@ const BookingDetailPage = () => {
{ label: booking.reference },
]}
/>
{/*
<BookingDetailHeader
booking={booking}
approvedCount={approvedCount}
totalSteps={totalSteps}
/> */}
<BookingLifecycleStepper status={booking.status} />
<Grid>
@@ -164,10 +128,6 @@ const BookingDetailPage = () => {
await allocateMutation.mutateAsync({ allocations });
}}
/>
<BookingApprovalCard
steps={approvalSteps}
approvedCount={approvedCount}
/>
<BookingReviewNotesCard notes={booking.reviewNotes ?? []} />
</Stack>
</Grid.Col>

View File

@@ -23,7 +23,6 @@ import {
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
@@ -130,10 +129,6 @@ export default function BookingRequestDetailPage() {
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
const showApprovalCard =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE";
// Non-customs clearance is reviewed here by Marketing in its own tab; customs
// bookings are handled in the Global Logistics clearance queue instead.
const showClearanceTab =
@@ -283,9 +278,6 @@ export default function BookingRequestDetailPage() {
View document clearance
</Button>
)}
{showApprovalCard && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
</Stack>
</Box>
</Grid.Col>

View File

@@ -7,7 +7,6 @@ import {
MultiSelect,
Select,
Stack,
Tabs,
Text,
TextInput,
} from "@mantine/core";
@@ -33,7 +32,6 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
@@ -60,10 +58,10 @@ import {
type ColumnDef,
} from "@edr/ui-common";
/** The two booking-kind tabs: one-time vs general-contract bookings. */
type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT";
/** Booking kind: one-time vs general-contract bookings. Now a filter, not a tab. */
type BookingKind = "ONE_TIME" | "GENERAL_CONTRACT";
const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [
const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [
{ value: "ONE_TIME", label: "One-time booking" },
{ value: "GENERAL_CONTRACT", label: "General booking" },
];
@@ -128,9 +126,9 @@ export default function BookingRequestsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
// Per-tab filter controls (empty/null = "all").
// Booking kind is a filter now — one list holds both kinds (null = "all").
const [kindFilter, setKindFilter] = useState<BookingKind | null>(null);
// Filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
@@ -158,9 +156,9 @@ export default function BookingRequestsPage() {
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
// React Query cache key per kind tab.
tab: kindTab,
bookingType: kindTab,
// React Query cache key per kind selection ("ALL" when unfiltered).
tab: kindFilter ?? "ALL",
...(kindFilter ? { bookingType: kindFilter } : {}),
// Server-side free-text search (booking ref, customer, contract ref).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
@@ -182,7 +180,7 @@ export default function BookingRequestsPage() {
}, [
pagination.pageIndex,
pagination.pageSize,
kindTab,
kindFilter,
debouncedQuery,
statusFilter,
directionFilter,
@@ -226,6 +224,7 @@ export default function BookingRequestsPage() {
}, [setPagination, pagination.pageSize]);
const activeFilterCount =
(kindFilter ? 1 : 0) +
(statusFilter.length ? 1 : 0) +
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
@@ -237,6 +236,7 @@ export default function BookingRequestsPage() {
(scheduledFrom || scheduledTo ? 1 : 0);
const clearFilters = useCallback(() => {
setKindFilter(null);
setStatusFilter([]);
setDirectionFilter(null);
setFreightTypeFilter(null);
@@ -327,6 +327,23 @@ export default function BookingRequestsPage() {
);
},
},
{
id: "bookingKind",
header: () => <span className={bookingTable.headerCell}>Type</span>,
cell: ({ row }) => {
const isGeneral = row.original.bookingKind === "GENERAL_CONTRACT";
return (
<div className="py-1">
<Badge
variant={isGeneral ? "secondary" : "outline"}
className="h-5 px-1.5 text-[10px] font-medium"
>
{isGeneral ? "General" : "One-time"}
</Badge>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
@@ -376,13 +393,6 @@ export default function BookingRequestsPage() {
cellClassName: "min-w-[11rem]",
},
},
{
id: "approval",
header: () => (
<span className={bookingTable.headerCell}>Approval</span>
),
cell: ({ row }) => <BookingApprovalProgressCell row={row.original} />,
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
@@ -482,22 +492,6 @@ export default function BookingRequestsPage() {
/>
*/}
<Tabs
value={kindTab}
onChange={(value) => {
setKindTab((value as BookingKindTab) ?? "ONE_TIME");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
>
<Tabs.List>
{BOOKING_KIND_TABS.map((t) => (
<Tabs.Tab key={t.value} value={t.value}>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
@@ -535,6 +529,18 @@ export default function BookingRequestsPage() {
</Text>
</Group>
<Group gap="sm" wrap="wrap">
<Select
placeholder="All booking types"
data={BOOKING_KIND_OPTIONS}
value={kindFilter}
onChange={(v) => {
setKindFilter((v as BookingKind | null) ?? null);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 190 }}
/>
<MultiSelect
placeholder={statusFilter.length ? undefined : "All statuses"}
data={STATUS_OPTIONS}

View File

@@ -50,6 +50,7 @@ import { ContractActionsToolbar } from "@/components/contracts/ContractActionsTo
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
import {
ContractCustomerCard,
ContractDocumentsCard,
@@ -493,6 +494,7 @@ export default function ContractRequestDetailPage() {
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
<ContractRevisionTimeline contractId={contract.id} />
<ContractDocumentsCard
files={profileDocuments}
title="Customer profile documents"

View File

@@ -36,6 +36,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
import {
useApprovalChain,
useApprovalRoleOptions,
useCargoLeafOptions,
useCargoTypeParentOptions,
useContainerTypeOptions,
@@ -247,6 +248,13 @@ const RuleEngineResourcePage = () => {
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
const usesApprovalRoleField = Boolean(
config?.formFields.some(
(f) => f.name === "requiredRole" || f.name === "blocksRole",
),
);
const { data: approvalRoleOptions, isLoading: approvalRoleOptionsLoading } =
useApprovalRoleOptions(usesApprovalRoleField);
// Full rule list backing the auto-filled "min wagon count": the next range
// always continues the chain for the selected type (per currency), so the
@@ -321,6 +329,20 @@ const RuleEngineResourcePage = () => {
options: wagonTypeOptions ?? [],
};
}
// Approval steps are configured against live IAM position types; until
// they load, the static legacy list on the field config stands in so an
// existing row's role still shows a label.
if (field.name === "requiredRole" || field.name === "blocksRole") {
if (!approvalRoleOptions) return field;
const includeNone = field.name === "blocksRole";
return {
...field,
type: "select" as const,
options: includeNone
? [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...approvalRoleOptions]
: approvalRoleOptions,
};
}
// Each end of the leg only offers yards in the country that end of the
// trade actually sits in, so an import can't be configured as if it
// started inland. Resolved per keystroke because the legal set changes
@@ -336,7 +358,7 @@ const RuleEngineResourcePage = () => {
}
return field;
});
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, isPriorityRules, allPriorityRules, editing, editingId]);
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, approvalRoleOptions, isPriorityRules, allPriorityRules, editing, editingId]);
const rows = data?.items ?? [];
const meta = data?.meta;
@@ -765,7 +787,8 @@ const RuleEngineResourcePage = () => {
(usesCargoTypeField && cargoLeafOptionsLoading) ||
(usesLiveRateField && liveRateOptionsLoading) ||
(usesWagonTypeField && wagonTypeOptionsLoading) ||
(usesYardField && yardOptionsLoading)
(usesYardField && yardOptionsLoading) ||
(usesApprovalRoleField && approvalRoleOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}
positionLoading={createPositionLoading}

View File

@@ -118,10 +118,16 @@ const YARD_COUNTRIES = [
{ label: "Djibouti", value: "Djibouti" },
];
const APPROVAL_ROLES = [
{ label: "Line staff", value: "LINE_STAFF" },
{ label: "Director", value: "DIRECTOR" },
{ label: "CEO", value: "CEO" },
/**
* The three role strings the approval chain was hardcoded to before it was
* driven by IAM position types. Kept only so rows still stored against them
* render a readable label instead of a blank select — the live options come
* from GET /approval-rules/position-types (see `useApprovalRoleOptions`).
*/
export const LEGACY_APPROVAL_ROLES = [
{ label: "Line staff (legacy)", value: "LINE_STAFF" },
{ label: "Director (legacy)", value: "DIRECTOR" },
{ label: "CEO (legacy)", value: "CEO" },
];
/**
@@ -718,7 +724,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Required role",
type: "select",
required: true,
options: APPROVAL_ROLES,
// Replaced at render time with live IAM position types (+ legacy values).
options: LEGACY_APPROVAL_ROLES,
},
{ name: "actionLabel", label: "Action label", type: "text", required: true },
{
@@ -726,7 +733,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Blocks role",
type: "select",
optional: true,
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...APPROVAL_ROLES],
// Replaced at render time with live IAM position types (+ legacy values).
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...LEGACY_APPROVAL_ROLES],
},
],
},

View File

@@ -10,6 +10,7 @@ import type {
CustomerBooking,
CustomerDocument,
CustomerPayment,
CustomerResetTarget,
PaginatedCompanies,
ProfileStatus,
ResetChannel,
@@ -132,9 +133,7 @@ import { endpoint } from "@/utils/endpoint";
import {
BookingListFilter,
bookingsService,
type ApproveStepPayload,
type PaginatedBookings,
type RejectStepPayload,
} from "./bookings.service";
import { cargoTypesService } from "./cargo-types.service";
import {
@@ -2481,18 +2480,6 @@ export const api = {
bookingsService.reviewOperation(id, decision, { note }),
),
approveStep: endpoint<ApproveStepPayload, BookingDetail>(
"bookings",
"approveStep",
(payload) => bookingsService.approveStep(payload),
),
rejectStep: endpoint<RejectStepPayload, BookingDetail>(
"bookings",
"rejectStep",
(payload) => bookingsService.rejectStep(payload),
),
generateContract: endpoint<{ id: string }, BookingDetail>(
"bookings",
"generateContract",
@@ -2581,6 +2568,13 @@ export const api = {
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
),
resetTarget: endpoint<{ companyId: string }, CustomerResetTarget>(
"customers",
"resetTarget",
({ companyId }) => customersService.resetTarget(companyId),
({ companyId }) => QUERY_KEYS.CUSTOMERS.resetTarget(companyId),
),
resetPassword: endpoint<
{ companyId: string; channel: ResetChannel },
ResetPasswordResult

View File

@@ -72,18 +72,6 @@ export interface BookingListSummary {
tabs: BookingListSummaryTabs;
}
export interface ApproveStepPayload {
id: string;
stepId: string;
requiredRole: string;
}
export interface RejectStepPayload {
id: string;
stepId: string;
reason: string;
}
export interface ContractView {
bookingId: string;
reference: string;
@@ -255,12 +243,6 @@ export const bookingsService = {
...options,
}),
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),
rejectStep: ({ id, stepId, reason }: RejectStepPayload) =>
postBooking<BookingDetail>(B.REJECT_STEP(id, stepId), { reason }),
generateContract: (id: string) =>
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),

View File

@@ -196,6 +196,14 @@ export const contractsService = {
return unwrap(response.data) as Freight.IContractDocumentDraft;
},
/** Audit trail of edits to this contract's document, newest first. */
getContractDocumentRevisions: async (
id: string,
): Promise<Freight.IContractDocumentRevision[]> => {
const response = await client.get(C.CONTRACT_DOCUMENT_REVISIONS(id));
return unwrap(response.data) as Freight.IContractDocumentRevision[];
},
/** Save this contract's edited document articles (never touches the templates). */
updateContractDocument: async (
id: string,
@@ -214,18 +222,12 @@ export const contractsService = {
reject: (id: string, reason: string) =>
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
approveStep: ({
id,
stepId,
requiredRole,
}: {
id: string;
stepId: string;
requiredRole: string;
}) =>
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId), {
requiredRole,
}),
/**
* Approve the next pending step. The server resolves the step's required role
* and authorizes against it — the client never declares its own role.
*/
approveStep: ({ id, stepId }: { id: string; stepId: string }) =>
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId)),
rejectStep: ({
id,

View File

@@ -9,6 +9,7 @@ import type {
CustomerBooking,
CustomerDocument,
CustomerPayment,
CustomerResetTarget,
PaginatedCompanies,
ProfileStatus,
ResetChannel,
@@ -89,8 +90,20 @@ export const customersService = {
},
/**
* Send a password-reset code to the company's primary contact. Staff never
* receive a credential the customer sets their own password from the code.
* The IAM account a reset link would go to. Read before offering the action
* so staff see the credentials the link actually reaches, not the company's
* business contact details.
*/
resetTarget(companyId: string): Promise<CustomerResetTarget> {
return apiClient
.get<CustomerResetTarget>(URL_CONSTANTS.COMPANIES.RESET_TARGET(companyId))
.then((r) => r.data);
},
/**
* Send a password-reset link to the company's primary contact. Staff never
* receive a credential — the customer opens the link and sets their own
* password.
*/
resetPassword(
companyId: string,

View File

@@ -72,6 +72,12 @@ export interface SubmitRateChangePayload {
update: Record<string, unknown>;
}
/** One selectable IAM position type, as returned by /approval-rules/position-types. */
export interface ApprovalPositionType {
label: string;
value: string;
}
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
@@ -368,6 +374,28 @@ export const ruleEngineService = {
return unwrap(response.data) as RateChangeRequest;
},
/**
* IAM position types that an approval step can require/block. Replaces the
* old hardcoded LINE_STAFF/DIRECTOR/CEO triple — the chain is configured from
* whatever positions IAM actually defines.
*/
getApprovalPositionTypes: async (): Promise<ApprovalPositionType[]> => {
const response = await client.get(
URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_POSITION_TYPES,
);
const body = unwrap(response.data) as unknown;
if (Array.isArray(body)) return body as ApprovalPositionType[];
if (
body &&
typeof body === "object" &&
"data" in body &&
Array.isArray((body as { data: unknown }).data)
) {
return (body as { data: ApprovalPositionType[] }).data;
}
return [];
},
getApprovalChain: async (
requiresDirectorApproval = true,
): Promise<RuleEngineRecord[]> => {

View File

@@ -101,16 +101,6 @@ export interface BookingContainerLine {
units?: BookingContainerUnit[];
}
export interface BookingApprovalStep {
id: string;
stepOrder: number;
requiredRole: string;
blocksRole?: string | null;
status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
actionedAt?: string | null;
remarks?: string | null;
}
export interface BookingNextStep {
action: string;
description: string;
@@ -219,7 +209,6 @@ export interface BookingDetail {
cargoType?: BookingNamedRef;
shippingLine?: BookingNamedRef;
bookingContainers?: BookingContainerLine[];
approvalSteps?: BookingApprovalStep[];
reviewNotes?: BookingReviewNote[];
files?: BookingFile[];
cargoModifiers?: Array<{
@@ -236,7 +225,6 @@ export interface BookingListRow {
/** Needed to link the reference to the contract's detail page. */
contractId?: string | null;
customerLabel: string;
approvalSteps?: BookingApprovalStep[];
status: BookingStatus;
scheduledDate: string;
totalAmount: number;
@@ -255,5 +243,11 @@ export interface BookingListRow {
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
customsClearingEnabled?: boolean;
/**
* Derived booking kind for the list "Type" column. Mirrors the server's
* bookingType filter: bookings under a GENERAL contract are general,
* everything else is one-time.
*/
bookingKind?: "ONE_TIME" | "GENERAL_CONTRACT";
createdAt: string;
}

View File

@@ -110,13 +110,27 @@ export interface CompanyChangeRequest {
updatedAt: string;
}
/** The channel a customer's password-reset code is delivered over. */
/** The channel a customer's password-reset link is delivered over. */
export type ResetChannel = "email" | "phone";
export interface ResetPasswordResult {
channel: ResetChannel;
/** Where the code went, e.g. `+251•••4821` — safe to show to staff. */
/** Where the link went, e.g. `+251•••4821` — safe to show to staff. */
maskedTarget: string;
/** ISO timestamp after which the link stops working. */
expiresAt: string;
}
/**
* The IAM account a reset link would reach — the company's primary contact.
* Distinct from `Company.email` / `Company.phone`, which are business contact
* details and routinely differ from the credentials the customer logs in with.
*/
export interface CustomerResetTarget {
userId: string;
name: string;
email: string | null;
phone: string | null;
}
/** Mirrors backend `Company` (+ its `companyProfiles`). */

View File

@@ -35,6 +35,7 @@ import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage";
import SettingsPage from "./pages/SettingsPage";
import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage";
import ResetPasswordLinkPage from "./pages/accounts/ResetPasswordLinkPage";
import LoginPage from "./pages/accounts/LoginPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import SignupPage from "./pages/accounts/SignupPage";
@@ -265,6 +266,11 @@ const App = () => {
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
</Route>
{/* Staff-issued reset links land here. Deliberately outside
RedirectIfAuthed: a customer with a stale session still needs the link
to work, and the token — not the session — is what authorises it. */}
<Route path="/reset-password" element={<ResetPasswordLinkPage />} />
{/* Signup-flow pages; reached while a session already exists */}
<Route path="/otp" element={<VerificationOtpPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />

View File

@@ -1,70 +1,20 @@
import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
import {
AlertCircle,
ArrowLeft,
Mail,
RotateCw,
ShieldCheck,
Smartphone,
} from "lucide-react";
import { Alert, Button, PinInput, Stack, Text } from "@mantine/core";
import { AlertCircle, ArrowLeft, RotateCw, ShieldCheck } from "lucide-react";
import { maskEmail, maskPhone } from "@/utils/identifier";
export type OtpChannel = "phone" | "email";
export const OTP_LENGTH = 6;
export interface OtpChannelSelectProps {
value: OtpChannel;
onChange: (channel: OtpChannel) => void;
disabled?: boolean;
label?: string;
}
/** Phone/email toggle deciding where the verification code is sent. */
export function OtpChannelSelect({
value,
onChange,
disabled,
label = "Send verification code via",
}: OtpChannelSelectProps) {
return (
<div className="space-y-1.5">
<Text size="sm" fw={500} c="edr-text">
{label}
</Text>
<SegmentedControl
fullWidth
disabled={disabled}
value={value}
onChange={(v) => onChange(v as OtpChannel)}
data={[
{
value: "phone",
label: (
<span className="flex items-center justify-center gap-1.5">
<Smartphone size={14} /> Phone
</span>
),
},
{
value: "email",
label: (
<span className="flex items-center justify-center gap-1.5">
<Mail size={14} /> Email
</span>
),
},
]}
/>
</div>
);
}
export interface OtpChannelStepProps {
channel: OtpChannel;
/** Raw email or phone the code went to; masked before display. */
target: string;
/**
* Raw contacts the code was sent to; masked before display. The API sends one
* code to every contact on the account, so both are usually set — pass only
* what the client actually knows. Omit both when the client cannot know them
* (the forgot-password flow deliberately never reveals an account's contacts)
* and a generic line is shown instead.
*/
email?: string;
phone?: string;
value: string;
onChange: (otp: string) => void;
onVerify: () => void;
@@ -82,11 +32,13 @@ export interface OtpChannelStepProps {
/**
* The "enter the code we sent you" stage. Shared by signup and the
* forgot-password flow — both send through the same `/api/otp/*` service.
* forgot-password flow — both send through the same `/api/otp/*` service, which
* delivers a single code to the account's email AND phone; whichever message
* arrives first can be typed here.
*/
export default function OtpChannelStep({
channel,
target,
email,
phone,
value,
onChange,
onVerify,
@@ -100,7 +52,10 @@ export default function OtpChannelStep({
description,
submitLabel,
}: OtpChannelStepProps) {
const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target);
const maskedTargets = [
email ? maskEmail(email) : null,
phone ? maskPhone(phone) : null,
].filter(Boolean) as string[];
const busy = sending || verifying;
return (
@@ -113,12 +68,23 @@ export default function OtpChannelStep({
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
{title ?? `Verify your ${channel === "email" ? "email" : "phone"}`}
{title ?? "Verify it's you"}
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a {OTP_LENGTH}-digit code to{" "}
<span className="font-medium text-gray-700">{maskedTarget}</span>.{" "}
{description ?? "Enter it to continue."}
{maskedTargets.length ? (
maskedTargets.map((target, index) => (
<span key={target}>
{index > 0 ? " and " : null}
<span className="font-medium text-gray-700">{target}</span>
</span>
))
) : (
<span className="font-medium text-gray-700">
the email and phone on your account
</span>
)}
. {description ?? "Enter it to continue."}
</p>
</div>

View File

@@ -31,6 +31,7 @@ const EXCLUDED_PATH_PATTERNS = [
/^\/forgot-password/,
/^\/otp/,
/^\/set-password/,
/^\/reset-password/,
/warehouse/i,
/first-mile/i,
/last-mile/i,

View File

@@ -8,6 +8,7 @@ export const URL_CONSTANTS = {
CHANGE_PASSWORD: "/api/auth/change-password",
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
FORGOT_PASSWORD_RESOLVE_LINK: "/api/auth/forgot-password/resolve-link",
},
USERS: {

View File

@@ -9,10 +9,11 @@ import {
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
import { AlertCircle, ArrowLeft, ArrowRight, Info } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
@@ -293,22 +294,28 @@ export default function CompanyProfileForm({
const [contactSameAsGm, setContactSameAsGm] = useState(false);
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
// General Manager source: the eTrade-registered business owner when a TIN
// lookup found one, otherwise the registering user's own account details.
// General Manager source. The company step's email/phone are seeded from
// eTrade (and the account email) but stay editable, so the link reads the
// CURRENT form values rather than the frozen eTrade snapshot — an edit on the
// company step propagates here, the same way "Same as General Manager" tracks
// the general manager's live values. eTrade's owner name has no editable
// field of its own, so it falls back to the registering user's account name.
const companyEmail = watch("companyEmail");
const companyPhone = watch("companyPhone");
const gmSourceName = etradeOwner?.name ?? user.name?.en ?? "";
const gmSourcePhone = etradeOwner
? etradeOwner.phone
: toEthiopianE164(user.phoneNumber);
const gmSourceEmail = companyEmail || user.email || "";
const gmSourcePhone =
companyPhone || etradeOwner?.phone || toEthiopianE164(user.phoneNumber) || "";
useEffect(() => {
if (!gmSameAsOwner) return;
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
setValue("generalManagerEmail", user.email ?? "", { shouldValidate: true });
setValue("generalManagerPhone", gmSourcePhone ?? "", {
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true });
setValue("generalManagerPhone", gmSourcePhone, {
shouldValidate: true,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gmSameAsOwner, gmSourceName, gmSourcePhone, user.email]);
}, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]);
const toggleGmSameAsOwner = (checked: boolean) => {
setGmSameAsOwner(checked);
@@ -336,13 +343,28 @@ export default function CompanyProfileForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
// The contact-person step has no location/address of its own, so the linked
// PoA takes the company's — location as entered, address as composed from the
// company's address fields. Both stay mirrored while the link is checked.
const companyLocation = watch("companyLocation");
const companyAddress = watch("companyAddress");
useEffect(() => {
if (!poaSameAsContact) return;
setValue("poaName", contactName ?? "");
setValue("poaEmail", contactEmail ?? "");
setValue("poaPhone", contactPhone ?? "");
setValue("poaLocation", companyLocation ?? "");
setValue("poaAddress", companyAddress ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poaSameAsContact, contactName, contactEmail, contactPhone]);
}, [
poaSameAsContact,
contactName,
contactEmail,
contactPhone,
companyLocation,
companyAddress,
]);
const toggleContactSameAsGm = (checked: boolean) => {
setContactSameAsGm(checked);
@@ -360,6 +382,8 @@ export default function CompanyProfileForm({
setValue("poaName", "");
setValue("poaEmail", "");
setValue("poaPhone", "");
setValue("poaLocation", "");
setValue("poaAddress", "");
}
};
@@ -624,7 +648,24 @@ export default function CompanyProfileForm({
{...register("vatNumber")}
/>
<TextInput
label="FAN Number (16 digits)"
label={
<Group gap={6} align="center" wrap="nowrap">
<span>FAN Number (16 digits)</span>
<Tooltip
label="The FAN must belong to the person with power of attorney. If the company has no power of attorney, use the general manager's FAN."
multiline
w={260}
withArrow
position="top-start"
>
<Info
size={14}
color="var(--mantine-color-gray-6)"
className="cursor-help"
/>
</Tooltip>
</Group>
}
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
@@ -743,8 +784,8 @@ export default function CompanyProfileForm({
title="Same as business owner"
description={
etradeOwner
? "Reuse the eTrade-registered owner's name and phone (email from your account). Uncheck to enter different details."
: "Reuse your account's name, email and phone. Uncheck to enter different details."
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
}
/>
<TextInput
@@ -828,7 +869,7 @@ export default function CompanyProfileForm({
checked={poaSameAsContact}
onToggle={togglePoaSameAsContact}
title="Same as contact person"
description="Reuse the contact person's name, email and phone. Uncheck to enter different details."
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
/>
)}
<TextInput

View File

@@ -5,11 +5,7 @@ import { Link, useNavigate } from "react-router-dom";
import { useResendCooldown } from "@/hooks/useResendCooldown";
import AuthShell from "@/components/auth/AuthShell";
import OtpChannelStep, {
OTP_LENGTH,
OtpChannelSelect,
type OtpChannel,
} from "@/components/auth/OtpChannelStep";
import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep";
import PasswordChecklist from "@/components/auth/PasswordChecklist";
import { api } from "@/services/api";
import type { ResetTicket } from "@/types/auth";
@@ -24,7 +20,6 @@ export default function ForgotPasswordPage() {
const [stage, setStage] = useState<Stage>("identify");
const [identifier, setIdentifier] = useState("");
const [channel, setChannel] = useState<OtpChannel>("phone");
const [otpCode, setOtpCode] = useState("");
// The reset ticket lives in memory only — persisting it would leave a
// password-change credential sitting in localStorage.
@@ -41,7 +36,7 @@ export default function ForgotPasswordPage() {
const normalised = normaliseIdentifier(identifier);
const sendCode = async () => {
await api.auth.requestPasswordReset.call({ identifier: normalised, channel });
await api.auth.requestPasswordReset.call({ identifier: normalised });
setOtpCode("");
resendCooldown.start();
};
@@ -85,7 +80,6 @@ export default function ForgotPasswordPage() {
try {
const result = await api.auth.verifyPasswordResetOtp.call({
identifier: normalised,
channel,
otp: otpCode.trim(),
});
setTicket(result);
@@ -134,13 +128,10 @@ export default function ForgotPasswordPage() {
}
};
const identifierLabel =
channel === "email" ? "the email on your account" : "the phone on your account";
return (
<AuthShell
tagline="Recover your account"
taglineBody="Reset your EDR Freight password with a one-time code sent to your email or phone."
taglineBody="Reset your EDR Freight password with a one-time code sent to your email and phone."
>
<div className="flex w-full flex-col">
{stage === "identify" ? (
@@ -172,16 +163,9 @@ export default function ForgotPasswordPage() {
onChange={(event) => setIdentifier(event.target.value)}
/>
<OtpChannelSelect
value={channel}
onChange={setChannel}
disabled={sending}
label="Send the code to"
/>
<p className="text-xs text-gray-500">
The code goes to {identifierLabel}, which may differ from what you
typed above.
The code goes to the email and phone on your account, which may
differ from what you typed above.
</p>
{error ? (
@@ -213,8 +197,6 @@ export default function ForgotPasswordPage() {
{stage === "otp" ? (
<OtpChannelStep
channel={channel}
target={normalised}
value={otpCode}
onChange={setOtpCode}
onVerify={handleVerify}

View File

@@ -0,0 +1,208 @@
import { type FormEvent, useEffect, useState } from "react";
import { Alert, Button, Loader, PasswordInput, Stack } from "@mantine/core";
import { AlertCircle, KeyRound } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import AuthShell from "@/components/auth/AuthShell";
import PasswordChecklist from "@/components/auth/PasswordChecklist";
import { api } from "@/services/api";
import type { ResetLinkAccount } from "@/types/auth";
import { meetsAllRequirements } from "@/utils/passwordSchema";
import { extractApiError } from "@/utils/result";
/**
* Lands the password-reset link a staff member sends from the backoffice
* (`/reset-password?uid=…&token=…`).
*
* The link itself is the proof of possession — it was delivered to the address
* on the account — so there is no code to type. The token is validated before
* the form appears, which is what lets an expired link say so up front instead
* of after a password has been chosen.
*/
export default function ResetPasswordLinkPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const userId = params.get("uid") ?? "";
const token = params.get("token") ?? "";
const [account, setAccount] = useState<ResetLinkAccount | null>(null);
const [checking, setChecking] = useState(true);
const [linkError, setLinkError] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!userId || !token) {
setLinkError(
"This password-reset link is incomplete. Open the full link from your email or SMS.",
);
setChecking(false);
return;
}
let cancelled = false;
api.auth.resolveResetLink
.call({ userId, token })
.then((resolved) => {
if (!cancelled) setAccount(resolved);
})
.catch((err) => {
if (!cancelled) setLinkError(extractApiError(err).message);
})
.finally(() => {
if (!cancelled) setChecking(false);
});
return () => {
cancelled = true;
};
}, [userId, token]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
if (!account) return;
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
setSubmitting(true);
try {
await api.auth.resetPassword.call({
userId: account.userId,
// Resolved server-side from the token — the account holder never types
// an identifier, so there is nothing here to get wrong.
email: account.identifier,
verificationCode: account.verificationCode,
newPassword: password,
confirmPassword,
});
navigate("/login", { replace: true, state: { passwordReset: true } });
} catch (err) {
setError(extractApiError(err).message);
} finally {
setSubmitting(false);
}
};
return (
<AuthShell
tagline="Set a new password"
taglineBody="Choose a new password for your EDR Freight account."
>
<div className="flex w-full flex-col">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<KeyRound size={22} />
</span>
</div>
<div className="mb-4 mt-3 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Choose a new password
</h1>
<p className="text-sm leading-relaxed text-gray-500">
{checking
? "Checking your reset link…"
: account
? `Resetting the password for ${account.maskedIdentifier}.`
: "This link can no longer be used."}
</p>
</div>
{checking ? (
<div className="flex justify-center py-6">
<Loader size="sm" />
</div>
) : null}
{!checking && linkError ? (
<Stack gap="md">
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{linkError}
</Alert>
<Button
color="edr-green"
fullWidth
onClick={() => navigate("/forgot-password")}
>
Request a new link
</Button>
<p className="text-center text-sm text-gray-500">
Remembered it?{" "}
<Link
to="/login"
className="font-semibold text-primary hover:underline"
>
Back to sign in
</Link>
</p>
</Stack>
) : null}
{!checking && account ? (
<form onSubmit={handleSubmit} className="flex w-full flex-col">
<Stack gap="md">
<div>
<PasswordInput
label="New password"
placeholder="Create a strong password"
autoComplete="new-password"
required
disabled={submitting}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<PasswordChecklist value={password} />
</div>
<PasswordInput
label="Confirm new password"
placeholder="Re-enter your password"
autoComplete="new-password"
required
disabled={submitting}
error={
confirmPassword && confirmPassword !== password
? "Passwords do not match"
: undefined
}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
/>
{error ? (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
>
{error}
</Alert>
) : null}
<Button
type="submit"
color="edr-green"
fullWidth
loading={submitting}
disabled={
submitting ||
!meetsAllRequirements(password) ||
password !== confirmPassword
}
>
Reset password
</Button>
</Stack>
</form>
) : null}
</div>
</AuthShell>
);
}

View File

@@ -18,11 +18,7 @@ import useAuth from "@/hooks/useAuth";
import { useResendCooldown } from "@/hooks/useResendCooldown";
import type { SignupPayload } from "@/types/auth";
import AuthShell from "@/components/auth/AuthShell";
import OtpChannelStep, {
OTP_LENGTH,
OtpChannelSelect,
type OtpChannel,
} from "@/components/auth/OtpChannelStep";
import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep";
import PasswordChecklist from "@/components/auth/PasswordChecklist";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { api } from "@/services/api";
@@ -64,15 +60,13 @@ export default function SignupPage() {
const { signup } = useAuth();
const [error, setError] = useState<string | null>(null);
// Two-stage signup: fill the form, then a mandatory OTP challenge on the
// chosen channel before the account is actually created. The account is only
// created after the code is verified — the OTP is a hard requirement.
// Two-stage signup: fill the form, then a mandatory OTP challenge before the
// account is actually created. The code goes to BOTH the email and phone just
// entered — one code, either delivery verifies it — so there is nothing for
// the user to choose. The account is only created after the code is verified;
// the OTP is a hard requirement.
const [stage, setStage] = useState<"form" | "otp">("form");
const [pendingData, setPendingData] = useState<FormData | null>(null);
// Which contact method the code was sent to — chosen on the form, locked in
// once the challenge is sent.
const [channel, setChannel] = useState<OtpChannel>("phone");
const [otpChannel, setOtpChannel] = useState<OtpChannel>("phone");
const [sending, setSending] = useState(false);
const [verifying, setVerifying] = useState(false);
const [otpCode, setOtpCode] = useState("");
@@ -101,8 +95,8 @@ export default function SignupPage() {
const passwordValue = watch("password") ?? "";
// Step 1 — form is valid: make sure the email/phone aren't already
// registered, then send a fresh code to the chosen channel and move to
// the OTP challenge.
// registered, then send a fresh code to both of them and move to the OTP
// challenge.
const requestOtp = async (data: FormData) => {
setError(null);
setSending(true);
@@ -124,11 +118,8 @@ export default function SignupPage() {
return;
}
await api.auth.sendOTP.call(
channel === "email" ? { email: data.email } : { phone: data.phone },
);
await api.auth.sendOTP.call({ email: data.email, phone: data.phone });
setPendingData(data);
setOtpChannel(channel);
setOtpCode("");
setOtpError(null);
resendCooldown.start();
@@ -145,11 +136,10 @@ export default function SignupPage() {
setOtpError(null);
setSending(true);
try {
await api.auth.sendOTP.call(
otpChannel === "email"
? { email: pendingData.email }
: { phone: pendingData.phone },
);
await api.auth.sendOTP.call({
email: pendingData.email,
phone: pendingData.phone,
});
setOtpCode("");
resendCooldown.start();
} catch (err) {
@@ -170,9 +160,8 @@ export default function SignupPage() {
setVerifying(true);
try {
await api.auth.verifyOTP.call({
...(otpChannel === "email"
? { email: pendingData.email }
: { phone: pendingData.phone }),
email: pendingData.email,
phone: pendingData.phone,
otp: otpCode.trim(),
});
const payload: SignupPayload = {
@@ -259,12 +248,6 @@ export default function SignupPage() {
disabled={sending}
/>
<OtpChannelSelect
value={channel}
onChange={setChannel}
disabled={sending}
/>
<div>
<PasswordInput
label="Password"
@@ -320,12 +303,8 @@ export default function SignupPage() {
</form>
) : (
<OtpChannelStep
channel={otpChannel}
target={
otpChannel === "email"
? (pendingData?.email ?? "")
: (pendingData?.phone ?? "")
}
email={pendingData?.email}
phone={pendingData?.phone}
value={otpCode}
onChange={setOtpCode}
onVerify={confirmOtp}

View File

@@ -56,9 +56,10 @@ export default function ContractViewPage() {
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
// The signing OTP goes to the signed-in user's own registered phone, resolved
// server-side from their account (the same number the server verifies
// against). The client never picks the number, so send and verify can't
// The signing OTP goes to the signed-in user's own registered phone AND email,
// resolved server-side from their account (the same contacts the server
// verifies against). One code covers both, so a delayed SMS doesn't strand the
// signer. The client never picks the contacts, so send and verify can't
// disagree; we only get back a masked hint of where it landed.
const [otpSentTo, setOtpSentTo] = useState<string | null>(null);
@@ -168,8 +169,9 @@ export default function ContractViewPage() {
if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
// The server resolves and validates the company phone; if none is on file it
// returns a clear 400 that surfaces via the mutation's onError.
// The server resolves and validates the signer's own contacts; if the
// account has neither phone nor email it returns a clear 400 that surfaces
// via the mutation's onError.
setOtpCode("");
sendOtpMutation.mutate();
};
@@ -405,8 +407,8 @@ export default function ContractViewPage() {
/>
</Box>
<Text size="sm" c="dimmed">
For security, enter the 6-digit code we sent by SMS to the
contract company&apos;s registered number
For security, enter the 6-digit code we sent to your registered
contacts
{otpSentTo ? (
<>
{" "}

View File

@@ -81,7 +81,9 @@ import type {
SignupResponse,
ForgotPasswordRequestPayload,
ForgotPasswordVerifyPayload,
ResetLinkAccount,
ResetTicket,
ResolveResetLinkPayload,
SendContactOtpPayload,
SendContactOtpResponse,
UpdateAccountNamePayload,
@@ -130,6 +132,11 @@ export const api = {
"verifyPasswordResetOtp",
authService.verifyPasswordResetOtp,
),
resolveResetLink: endpoint<ResolveResetLinkPayload, ResetLinkAccount>(
"auth",
"resolveResetLink",
authService.resolveResetLink,
),
resetPassword: endpoint<SetPasswordPayload, void>(
"auth",
"resetPassword",

View File

@@ -11,7 +11,9 @@ import type {
LoginResponse,
OtpPayload,
OtpResponse,
ResetLinkAccount,
ResetTicket,
ResolveResetLinkPayload,
SendContactOtpPayload,
SendContactOtpResponse,
SetPasswordPayload,
@@ -79,6 +81,19 @@ export const authService = {
return { userId: res.data.userId, verificationCode: res.data.verificationCode };
},
/**
* Validate a staff-issued reset link before showing the password form, and
* pick up the ticket it carries. Rejected links (expired, already spent) fail
* here rather than after the customer has typed a new password.
*/
resolveResetLink: async (body: ResolveResetLinkPayload) => {
const res = await client.post<ResetLinkAccount>(
URL_CONSTANTS.AUTH.FORGOT_PASSWORD_RESOLVE_LINK,
body,
);
return res.data;
},
/**
* Spend the reset ticket. Distinct from `setPassword` above, which the
* authenticated post-signup flow drives through `useAuth` — this one carries

View File

@@ -33,7 +33,10 @@ export interface SignupResponse {
}
export interface OtpPayload {
/** Exactly one of phone/email — the channel the code is sent through. */
/**
* At least one of phone/email. Send both and the API delivers one code to
* both, verifiable by quoting either back.
*/
phone?: string;
email?: string;
/** Required on verify; omitted on send (the server generates the code). */
@@ -102,13 +105,9 @@ export interface SetPasswordPayload {
verificationCode: string;
}
/** The channel a password-reset code is delivered over. */
export type ResetChannel = "email" | "phone";
export interface ForgotPasswordRequestPayload {
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
identifier: string;
channel: ResetChannel;
}
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
@@ -121,6 +120,21 @@ export interface ResetTicket {
verificationCode: string;
}
/** The `uid` / `token` pair carried by a staff-issued password-reset link. */
export interface ResolveResetLinkPayload {
userId: string;
token: string;
}
/**
* A validated reset link. Carries the identifier IAM matches the account on, so
* the customer never has to type one — plus a masked copy safe to display.
*/
export interface ResetLinkAccount extends ResetTicket {
identifier: string;
maskedIdentifier: string;
}
export interface GenerateVerificationCodePayload {
email: string;
phoneNumber: string;

View File

@@ -2,10 +2,6 @@ import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { URL_CONSTANTS } from "@/constants/URLS";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
const client = axios.create({
@@ -63,10 +59,9 @@ async function refreshSessionTokens(): Promise<string> {
}
type TokenPair = { token: string; refreshToken: string };
const { data } = await client.post<Partial<TokenPair> & { data?: TokenPair }>(
URL_CONSTANTS.AUTH.REFRESH_TOKEN,
{ refreshToken },
);
const { data } = await client.post<
Partial<TokenPair> & { data?: TokenPair }
>(URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken });
// The API returns the pair flat ({ success, token, refreshToken }); accept
// a { data: { ... } }-wrapped shape too so a transform change can't
// silently break refresh again.
@@ -118,8 +113,8 @@ client.interceptors.response.use(
// Surface the server's actual error message in the global error modal
// (401s are handled by the session flow below/redirects, so skip them).
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
if (payload) emitApiError(payload);
// const payload = extractApiErrorPayload(error);
// if (payload) emitApiError(payload);
}
return Promise.reject(error);
}

View File

@@ -219,12 +219,54 @@ export interface IContractDocumentDraft {
articles: IContractDocumentArticle[];
code: string | null;
name: string | null;
/** True once the document may no longer be edited/regenerated. */
/** True when THIS caller may not edit — the inverse of `editableByMe`. */
locked: boolean;
/**
* Whether the requesting user is the approver whose turn it is. The document
* stays editable through the whole approval chain, but only by the approver
* currently holding it, so editability is caller-dependent.
*/
editableByMe: boolean;
/** Role holding editing rights right now, for "locked because…" messaging. */
nextApproverRole: string | null;
generatedAt: string | null;
status: ContractStatus;
}
/** One recorded change within a contract-document revision. */
export type IContractDocumentChange =
| { kind: 'ARTICLE_ADDED'; articleId: string; title: string }
| { kind: 'ARTICLE_REMOVED'; articleId: string; title: string }
| {
kind: 'ARTICLE_RENAMED';
articleId: string;
title: string;
fromTitle: string;
}
| { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string }
| {
kind: 'ARTICLE_REORDERED';
articleId: string;
title: string;
fromOrder: number;
toOrder: number;
}
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number };
/** An audit entry for one edit to a contract's document. */
export interface IContractDocumentRevision {
id: string;
contractId: string;
actorId: string | null;
/** The approval step's required role at the time of the edit. */
actorRole: string | null;
stepId: string | null;
summary: string | null;
changes: IContractDocumentChange[];
createdAt: string;
}
export type ContractApprovalStepStatus =
| "PENDING"
| "APPROVED"