mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: enhance booking management with shipping line support and cargo handling improvements
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user