feat: enhance booking management with shipping line support and cargo handling improvements

This commit is contained in:
Marshal
2026-08-15 18:48:33 +00:00
parent 156fa9d2e4
commit 9f53114778
13 changed files with 230 additions and 62 deletions

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
@@ -11,9 +10,10 @@ import { ResetChannel } from "./dto/forgot-password.dto";
import {
ForgotPasswordService,
RESET_LINK_TTL_MS,
type ResetTicket,
} from "./forgot-password.service";
import { maskOtpTarget } from "./mask-target.util";
import { isDomesticPhone } from "../otp/otp.service";
import { isDomesticPhone, type OtpTarget } from "../otp/otp.service";
/** The account a staff-triggered reset would land on. */
export interface CustomerResetTarget {
@@ -116,6 +116,22 @@ export class CustomerResetService {
channel: ResetChannel,
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink | null> {
const sent = await this.sendResetLinkToUserOnChannels(userId, [channel], options);
return sent[0] ?? null;
}
/**
* One ticket, several channels. Minting retires every earlier ticket for the
* user (`mintResetTicket`), so sending email and SMS as two separate mints
* makes the first link dead on arrival — the same link must go to both.
* Returns one entry per channel that was actually sent (unreachable channels
* are skipped, not errors).
*/
async sendResetLinkToUserOnChannels(
userId: string,
channels: ResetChannel[],
options?: { scope?: string; allowWithoutCredential?: boolean },
): Promise<SentResetLink[]> {
const user = options?.allowWithoutCredential
? await this.forgotPasswordService.resolveActivatableUserById(userId)
: await this.forgotPasswordService.resolveActiveUserById(userId);
@@ -128,51 +144,58 @@ export class CustomerResetService {
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
}`,
);
return null;
return [];
}
return this.deliverResetLink(user, user.id, channel, options?.scope);
// Mint once, before any send: 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.
let ticket: ResetTicket | null = null;
const sent: SentResetLink[] = [];
for (const channel of channels) {
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) continue;
// The gateway silently drops foreign numbers — treat like a missing phone
// rather than reporting "link sent" for a message that will never arrive.
// The backoffice disables the channel up front via `phoneIsDomestic`; this
// guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
continue;
}
ticket ??= await this.forgotPasswordService.mintResetTicket(
user.id,
RESET_LINK_TTL_MS,
);
const result = await this.deliverResetLink(
target,
user.id,
channel,
ticket,
options?.scope,
);
if (result) sent.push(result);
}
return sent;
}
/**
* Shared tail: target selection → SMS reachability → mint → send → report.
* Callers have already resolved `user` to an active account.
* Shared tail: send the already-minted ticket to a resolved target → report.
*/
private async deliverResetLink(
user: User,
target: OtpTarget,
userId: string,
channel: ResetChannel,
ticket: ResetTicket,
scope?: string,
): Promise<SentResetLink | null> {
this.logger.log(
`Staff-triggered shipping line ${"link"}`,
);
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) return null;
// A foreign number is unreachable by the domestic-only SMS gateway — treat
// it like a missing phone rather than reporting "link sent" for a message
// that will never arrive. The backoffice disables the channel up front via
// `phoneIsDomestic`; this guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
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);
this.logger.log(
`Staff-triggered shipping line ${link}`,
);
const { queued } = target.email
? await this.emailClient.sendEmail({

View File

@@ -212,7 +212,9 @@ export class ForgotPasswordService {
* is the proof).
*/
async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
const code = randomBytes(24).toString("base64url");
// Hex, not base64url: the token rides in an SMS, and the GSM-7 alphabet has
// no "_" — gateways substitute a space and the link arrives broken.
const code = randomBytes(24).toString("hex");
const verificationCode = await hashPassword(code);
await this.dataSource.transaction(async (manager) => {

View File

@@ -80,6 +80,8 @@ export interface BookingListFilterOptions {
originYardId?: string;
destinationYardId?: string;
isGovernment?: 'true' | 'false';
/** Shipping-line bookings vs ordinary customer bookings (exactly one owner is set). */
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
consolidationPaired?: string;
}
@@ -1043,6 +1045,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
} else if (options.isGovernment === 'false') {
qb.andWhere('booking.is_government = FALSE');
}
if (options.customerKind === 'SHIPPING_LINE') {
qb.andWhere('booking.shipping_line_company_id IS NOT NULL');
} else if (options.customerKind === 'CUSTOMER') {
qb.andWhere('booking.shipping_line_company_id IS NULL');
}
if (omit !== 'tradeDirection' && options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,

View File

@@ -1766,6 +1766,38 @@ export class BookingsService {
pending.has(b.id);
}
this.attachPaymentDrainEnds(bookings);
await this.attachShippingLineCompanies(bookings);
}
/**
* Batched name lookup for shipping-line-owned bookings (`companyId` null,
* `shippingLineCompanyId` set). No relation on the entity — the shipping-line
* module sits above bookings — so a raw query keyed off the loaded ids fills
* `shippingLineCompany` the way `company` is filled for customers.
*/
private async attachShippingLineCompanies(bookings: Booking[]): Promise<void> {
const ids = [
...new Set(
bookings
.map((b) => b.shippingLineCompanyId)
.filter((id): id is string => id != null),
),
];
if (!ids.length) return;
const rows: Array<{ id: string; name: string; email: string | null; phoneNumber: string | null }> =
await this.dataSource.query(
`SELECT id, name, email, phone_number AS "phoneNumber"
FROM freight.shipping_line_companies
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
[ids],
);
const byId = new Map(rows.map((r) => [r.id, r]));
for (const b of bookings) {
const line = b.shippingLineCompanyId ? byId.get(b.shippingLineCompanyId) : undefined;
if (line) {
(b as Booking & { shippingLineCompany?: typeof line }).shippingLineCompany = line;
}
}
}
/**
@@ -1822,6 +1854,7 @@ export class BookingsService {
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
customerKind: filter.customerKind,
consolidationPaired: filter.consolidationPaired,
// DTO carries 'true'/'false' strings (query params); the repo option is a
// real boolean — convert, preserving "not filtered" when absent.
@@ -2048,6 +2081,7 @@ export class BookingsService {
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
customerKind: filter.customerKind,
consolidationPaired: filter.consolidationPaired,
};
@@ -2134,6 +2168,8 @@ export class BookingsService {
{ path: "booking" },
);
await this.attachShippingLineCompanies([booking]);
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {

View File

@@ -111,6 +111,14 @@ export class FilterBookingDto {
@IsIn(['true', 'false'])
isGovernment?: 'true' | 'false';
@ApiPropertyOptional({
enum: ['SHIPPING_LINE', 'CUSTOMER'],
description: 'Who booked: a shipping line (owned by shipping_line_company_id) or an ordinary customer company',
})
@IsOptional()
@IsIn(['SHIPPING_LINE', 'CUSTOMER'])
customerKind?: 'SHIPPING_LINE' | 'CUSTOMER';
@ApiPropertyOptional({
enum: ['true', 'false'],
description: 'Filter customs vs self-clearance (non-customs) bookings',

View File

@@ -132,37 +132,33 @@ export class ShippingLineCompaniesService {
* Email always goes out — it is required at registration and is the only
* channel guaranteed to reach a foreign-registered line. SMS is sent in
* addition when the number is domestic, since the gateway silently drops
* anything else (see `CustomerResetService`). Two links are two independent
* single-use tickets; whichever the line opens first works.
* anything else (see `CustomerResetService`). Both carry the SAME single-use
* ticket: minting retires earlier tickets, so two mints would kill the email
* link the moment the SMS went out.
*
* Reports the email send, as that is the one that is always attempted.
*/
async sendActivationLink(shippingLine: ShippingLineCompany) {
const scope = `shipping line ${shippingLine.id}`;
const channels = [ResetChannel.Email];
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
channels.push(ResetChannel.Phone);
}
const emailed = await this.customerResetService.sendResetLinkToUser(
const sent = await this.customerResetService.sendResetLinkToUserOnChannels(
shippingLine.userId,
ResetChannel.Email,
channels,
{ scope, allowWithoutCredential: true },
);
const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null;
if (!emailed) {
this.logger.error(
`Activation email not sent for shipping line ${shippingLine.id} — no reachable address`,
);
}
if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) {
const texted = await this.customerResetService.sendResetLinkToUser(
shippingLine.userId,
ResetChannel.Phone,
{ scope, allowWithoutCredential: true },
);
if (!texted) {
this.logger.warn(
`Activation SMS not sent for shipping line ${shippingLine.id}`,
);
}
if (channels.includes(ResetChannel.Phone) && !sent.some((s) => s.channel === ResetChannel.Phone)) {
this.logger.warn(`Activation SMS not sent for shipping line ${shippingLine.id}`);
}
return emailed;

View File

@@ -1,5 +1,5 @@
import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
import { SimpleGrid, Divider, Box, Group, Table, Text, Badge } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -29,12 +29,37 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
);
const isBulk = booking.freightType === "BULK";
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
// the freight kind, with the shipper's own description alongside.
const cargoHeadline = isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "Bulk cargo")
: "Containers";
const cargoDescription = booking.cargoFreeText?.trim() || null;
return (
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
<Group gap="sm" align="center" mb="md" wrap="wrap">
<Text fw={800} fz={22} lh={1.1}>
{cargoHeadline}
</Text>
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
{isBulk ? "Bulk" : "Container"}
</Badge>
{cargoDescription ? (
<Text size="sm" c="dimmed">
{cargoDescription}
</Text>
) : null}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile
label="Cargo type"
value={booking.cargoType?.label ?? booking.freightType}
label={isBulk ? "Commodity" : "Cargo type"}
value={
isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "—")
: (cargoDescription ?? booking.freightType)
}
/>
<MetricTile label="Total VGM" value={`${tons} tons`} />
{items != null && <MetricTile label="Items" value={`${items}`} />}

View File

@@ -20,9 +20,13 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
reference: booking.reference,
contractReference: booking.contractReference ?? null,
contractId: booking.contractId ?? null,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
// Shipping-line bookings have no customer company — the line IS the customer.
customerLabel: booking.shippingLineCompany
? booking.shippingLineCompany.name
: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
isShippingLine: Boolean(booking.shippingLineCompany ?? booking.shippingLineCompanyId),
// customerLabel: labelFromRef(booking.customer, booking.customerId),
status: booking.status,
scheduledDate: booking.scheduledDate,

View File

@@ -13,12 +13,14 @@ import {
MoreHorizontal,
Package,
RefreshCw,
Ship,
Truck,
Wallet,
Weight,
} from "lucide-react";
import {
ActionIcon,
Badge,
Box,
Button,
Center,
@@ -174,7 +176,14 @@ export default function BookingRequestDetailPage() {
};
const company = booking.company;
const shippingLine = booking.shippingLineCompany ?? null;
const customerName = toBookingListRow(booking).customerLabel;
// What is being shipped, in words: bulk → the commodity (Wheat, Steel…);
// containers → the shipper's own description when given.
const cargoLabel =
booking.freightType === "BULK"
? (booking.cargoType?.label ?? booking.cargoType?.name ?? null)
: (booking.cargoFreeText?.trim() || null);
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
@@ -237,10 +246,27 @@ export default function BookingRequestDetailPage() {
}
subtitle={
<Group gap={6} wrap="wrap">
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
{shippingLine ? (
<Group gap={6} wrap="nowrap">
<Ship size={14} />
<Text size="sm" fw={600}>
{shippingLine.name}
</Text>
<Badge size="xs" radius="sm" variant="light" color="teal">
Shipping line
</Badge>
</Group>
) : (
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
)}
{cargoLabel ? (
<Text size="sm" c="dimmed">
· {cargoLabel}
</Text>
) : null}
<Text size="sm" c="dimmed">
· Scheduled {booking.scheduledDate}
</Text>

View File

@@ -18,6 +18,7 @@ import {
Package,
Plus,
RefreshCw,
Ship,
User,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -62,6 +63,12 @@ const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [
{ value: "GENERAL_CONTRACT", label: "General booking" },
];
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
const CUSTOMER_KIND_OPTIONS = [
{ value: "SHIPPING_LINE", label: "Shipping line" },
{ value: "CUSTOMER", label: "Customer" },
];
/** Status options for the filter select — built from the shared status styles. */
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
([value, { label }]) => ({ value, label }),
@@ -132,6 +139,7 @@ export default function BookingRequestsPage() {
// split), so a deep link can never land behind "More filters" unseen.
const bookingFilterDefs: FilterDef[] = useMemo(
() => [
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{
@@ -301,8 +309,17 @@ export default function BookingRequestsPage() {
</Badge>
</div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{b.isShippingLine ? (
<Ship className="size-3 shrink-0 opacity-70" />
) : (
<User className="size-3 shrink-0 opacity-70" />
)}
{b.customerLabel}
{b.isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
Shipping line
</Badge>
) : null}
</p>
</div>
</div>

View File

@@ -36,6 +36,8 @@ export interface BookingListFilter {
scheduledTo?: string;
originYardId?: string;
destinationYardId?: string;
/** SHIPPING_LINE = booked by a shipping line; CUSTOMER = ordinary customer company. */
customerKind?: "SHIPPING_LINE" | "CUSTOMER";
/** "true" = government bookings only, "false" = private only. */
isGovernment?: "true" | "false";
/** Free-text search: booking reference, customer name, contract reference (server-side). */
@@ -151,6 +153,7 @@ export const bookingsService = {
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
}
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
params,
@@ -184,6 +187,7 @@ export const bookingsService = {
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
if (filter.customsClearingEnabled)
params.customsClearingEnabled = filter.customsClearingEnabled;
if (filter.search) params.search = filter.search;

View File

@@ -251,6 +251,10 @@ export interface BookingDetail {
updatedAt: string;
// customer?: BookingNamedRef & { companyName?: string };
company?: BookingNamedRef & Partial<BookingCompany>;
/** Set when booked by a shipping line (then `companyId`/`company` are null). */
shippingLineCompanyId?: string | null;
/** Owner when booked by a shipping line (then `company` is absent). Hydrated server-side. */
shippingLineCompany?: { id: string; name: string; email?: string | null; phoneNumber?: string | null };
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
@@ -273,6 +277,8 @@ export interface BookingListRow {
/** Needed to link the reference to the contract's detail page. */
contractId?: string | null;
customerLabel: string;
/** True when the booking is owned by a shipping line rather than a customer company. */
isShippingLine?: boolean;
status: BookingStatus;
scheduledDate: string;
totalAmount: number;

View File

@@ -1,18 +1,32 @@
/**
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw
* VGM column showed a 20-item / 100T booking as "20 tons".
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Container
* bookings never store a total at all — `cargoTotalWeightVgm` stays 0 and the
* weight lives per line (`quantity × vgmPerUnitTons`). Every other booking
* stores tons in `cargoTotalWeightVgm` directly. Rendering the raw VGM column
* showed a 20-item / 100T booking as "20 tons" and every container booking as
* "0 tons".
*/
export function cargoTonsAndItems(booking: {
freightType?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
bookingContainers?: Array<{
quantity?: number | string | null;
vgmPerUnitTons?: number | string | null;
}> | null;
}): { tons: number; items: number | null } {
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
if (booking.freightType === "BULK" && bulkTons > 0) {
return { tons: bulkTons, items: vgm > 0 ? vgm : null };
}
if (vgm <= 0 && booking.bookingContainers?.length) {
const lineTons = booking.bookingContainers.reduce(
(sum, c) => sum + Number(c.quantity ?? 0) * Number(c.vgmPerUnitTons ?? 0),
0,
);
return { tons: Math.round(lineTons * 1000) / 1000, items: null };
}
return { tons: vgm, items: null };
}