mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 05:30:55 +00:00
feat: add shipping line companies management
- Implement ShippingLineCompaniesService for registering and managing shipping line companies. - Create ResendActivationAction component for resending activation links to shipping lines. - Develop ShippingLineCompaniesPage for listing and registering shipping lines with validation. - Introduce shippingLineCompanies.service for API interactions related to shipping lines. - Define types for shipping line companies, including registration and pagination. - Add placeholder pages for shipping line portal, including home, bookings, help, invoices, and settings.
This commit is contained in:
@@ -432,11 +432,16 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
|
||||
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
|
||||
|
||||
// Shipping Line
|
||||
// Shipping Line (rule-engine lookup list — a code/label bookings reference,
|
||||
// not an account)
|
||||
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
|
||||
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
|
||||
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
|
||||
|
||||
// Shipping Line Company (carrier with a portal login, registered by staff)
|
||||
"POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"],
|
||||
"POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"],
|
||||
|
||||
// Signature
|
||||
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -86,7 +87,66 @@ export class CustomerResetService {
|
||||
const resolved = await this.resolvePrimaryContactUser(companyId);
|
||||
if (!resolved) return null;
|
||||
|
||||
const { user, userId } = resolved;
|
||||
return this.sendResetLinkToUser(resolved.userId, channel, {
|
||||
scope: `company ${companyId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint and deliver a reset link to a specific IAM account.
|
||||
*
|
||||
* The delivery half of {@link sendResetLinkToCustomer}, split out so callers
|
||||
* that resolve their target differently can reuse it: a customer is found via
|
||||
* the company's primary contact, while a shipping line has no contact row at
|
||||
* all and resolves straight off its own record. Everything below the lookup —
|
||||
* active-account gating, the domestic-SMS rule, mint-before-send, the
|
||||
* undelivered-link diagnostic — is identical for both and must stay that way.
|
||||
*
|
||||
* `scope` only labels the log line with whatever the caller resolved from.
|
||||
*
|
||||
* `allowWithoutCredential` relaxes the lookup for first-time activation:
|
||||
* the default gate requires an existing active credential (so a reset cannot
|
||||
* revive a suspended account), but an account that has never set a password
|
||||
* has no credential row yet and would be excluded from its own activation
|
||||
* link. Callers pass it only when the account is expected to be
|
||||
* password-less — see ShippingLineCompaniesService.
|
||||
*/
|
||||
async sendResetLinkToUser(
|
||||
userId: string,
|
||||
channel: ResetChannel,
|
||||
options?: { scope?: string; allowWithoutCredential?: boolean },
|
||||
): Promise<SentResetLink | null> {
|
||||
const user = options?.allowWithoutCredential
|
||||
? await this.forgotPasswordService.resolveActivatableUserById(userId)
|
||||
: await this.forgotPasswordService.resolveActiveUserById(userId);
|
||||
|
||||
if (!user?.id) {
|
||||
this.logger.warn(
|
||||
`User ${userId} is not an active account${
|
||||
options?.allowWithoutCredential
|
||||
? ""
|
||||
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.deliverResetLink(user, user.id, channel, options?.scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared tail: target selection → SMS reachability → mint → send → report.
|
||||
* Callers have already resolved `user` to an active account.
|
||||
*/
|
||||
private async deliverResetLink(
|
||||
user: User,
|
||||
userId: string,
|
||||
channel: ResetChannel,
|
||||
scope?: string,
|
||||
): Promise<SentResetLink | null> {
|
||||
this.logger.log(
|
||||
`Staff-triggered shipping line ${"link"}`,
|
||||
);
|
||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||
if (!target) return null;
|
||||
|
||||
@@ -110,6 +170,9 @@ export class CustomerResetService {
|
||||
);
|
||||
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({
|
||||
@@ -127,7 +190,22 @@ export class CustomerResetService {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
|
||||
`Staff-triggered shipping line ${channel} reset link sent to user ${userId}${
|
||||
scope ? ` (${scope})` : ""
|
||||
} queued=${queued}`,
|
||||
);
|
||||
|
||||
// SECURITY: logs a live password-reset credential in cleartext. Anyone with
|
||||
// read access to the log stream can set the password for the account named
|
||||
// on the same line — including on sends that succeeded, not just failures.
|
||||
// Kept deliberately: log aggregation is the debugging path for flaky
|
||||
// email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If
|
||||
// that is ever revisited, gate this on an env flag rather than deleting it,
|
||||
// so dev keeps its workflow.
|
||||
this.logger.warn(
|
||||
`reset-link.cleartext channel=${channel} user=${userId}${
|
||||
scope ? ` (${scope})` : ""
|
||||
} link=${link}`,
|
||||
);
|
||||
|
||||
if (!queued) {
|
||||
|
||||
@@ -89,6 +89,28 @@ export class ForgotPasswordService {
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Active account by id, WITHOUT requiring an existing credential.
|
||||
*
|
||||
* {@link activeUserQuery} inner-joins an active `user_credentials` row, which
|
||||
* is right for a *reset*: it stops a staff-triggered link from reactivating a
|
||||
* suspended account. But an account that has never set a password has no
|
||||
* credential row yet, so that join excludes exactly the accounts a first-time
|
||||
* *activation* link is for — shipping lines are created deliberately without
|
||||
* one (see ShippingLineCompaniesService.register).
|
||||
*
|
||||
* The `isActive` gate is kept; only the credential requirement is dropped.
|
||||
*/
|
||||
async resolveActivatableUserById(userId: string): Promise<User | null> {
|
||||
if (!userId) return null;
|
||||
return await this.userRepository
|
||||
.createQueryBuilder("u")
|
||||
.where("u.isActive = true")
|
||||
.andWhere("u.id = :userId", { userId })
|
||||
.orderBy("u.createdAt", "DESC")
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base query for accounts eligible to reset. `.where()` is claimed here so
|
||||
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
|
||||
@@ -233,9 +255,22 @@ export class ForgotPasswordService {
|
||||
"This password-reset link is invalid or has expired. Request a new one.",
|
||||
);
|
||||
|
||||
const user = await this.resolveActiveUserById(userId);
|
||||
// Credential-less on purpose: this resolves links for *setting* a password,
|
||||
// which includes first-time activation of an account that has never had one
|
||||
// (shipping lines are created without a credential row). Requiring one here
|
||||
// rejected a perfectly valid activation link before its token was ever
|
||||
// checked. The ticket checks below are what actually authorise the reset.
|
||||
const user = await this.resolveActivatableUserById(userId);
|
||||
const identifier = user && this.identifierFor(user);
|
||||
if (!user || !identifier) throw invalid;
|
||||
if (!user || !identifier) {
|
||||
// Logged because the early return above bypasses the rejection warning
|
||||
// below — without this, an account that fails the lookup produces no
|
||||
// diagnostic at all and looks identical to a bad token.
|
||||
this.logger.warn(
|
||||
`Reset link rejected for user ${userId} — no active account or no usable identifier`,
|
||||
);
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
const verification = await this.dataSource
|
||||
.getRepository(UserVerification)
|
||||
|
||||
@@ -51,5 +51,8 @@ import { ListUsersService } from './list-users.service';
|
||||
ForgotPasswordService,
|
||||
CustomerResetService,
|
||||
],
|
||||
// Shipping-line registration mints activation links through the same
|
||||
// staff-triggered reset path customers use.
|
||||
exports: [CustomerResetService],
|
||||
})
|
||||
export class FreightAuthModule {}
|
||||
|
||||
@@ -52,6 +52,11 @@ import {
|
||||
} from "./entities/company-profile.entity";
|
||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import {
|
||||
AccountInfoResponse,
|
||||
ShippingLineInfoResponseDto,
|
||||
} from "./dto/account-info-response.dto";
|
||||
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
@@ -96,6 +101,7 @@ export class CompaniesController {
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -119,16 +125,31 @@ export class CompaniesController {
|
||||
|
||||
@Get("getInfo")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "Get company info for the current user" })
|
||||
@ApiOperation({
|
||||
summary: "Get account info for the current user (customer or shipping line)",
|
||||
})
|
||||
async getInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
): Promise<AccountInfoResponse> {
|
||||
// A shipping line has no company and no external profile, so the customer
|
||||
// lookup below would 404. Checked first, and reported with an explicit
|
||||
// `accountKind` so the portal can skip onboarding for shipping lines
|
||||
// without inferring it from a missing company.
|
||||
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
|
||||
user.id,
|
||||
);
|
||||
if (shippingLine) {
|
||||
return new ShippingLineInfoResponseDto(shippingLine);
|
||||
}
|
||||
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
company.id,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company, review);
|
||||
return Object.assign(new CompanyInfoResponseDto(profile, company, review), {
|
||||
accountKind: "customer" as const,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("profile")
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CompanyProfile } from "./entities/company-profile.entity";
|
||||
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
@@ -44,6 +45,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
forwardRef(() => NotificationInboxModule),
|
||||
// Fayda identity verification for the company's owner and PoA.
|
||||
VerifaydaModule,
|
||||
// `GET /companies/getInfo` serves both portal audiences: it must recognise a
|
||||
// shipping-line session, which has no company row to look up.
|
||||
ShippingLineCompaniesModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { CompanyInfoResponseDto } from "./company-info-response.dto";
|
||||
|
||||
/**
|
||||
* What kind of account is signed in to the portal.
|
||||
*
|
||||
* The portal keys its onboarding gate off this rather than off "is `company`
|
||||
* missing?": a failed or slow company fetch also leaves `company` empty, and
|
||||
* treating that as "no onboarding needed" would let customers skip onboarding
|
||||
* whenever the request failed. A shipping line is identified positively, and
|
||||
* anything else defaults to `customer`.
|
||||
*/
|
||||
export type AccountKind = "customer" | "shipping_line";
|
||||
|
||||
/** The signed-in shipping line. No company, no profile, no onboarding. */
|
||||
export class ShippingLineInfoResponseDto {
|
||||
@ApiProperty({ enum: ["shipping_line"] })
|
||||
accountKind: "shipping_line" = "shipping_line";
|
||||
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
scacCode?: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
/**
|
||||
* Always null. Present so the portal can read `company` / `profile` off either
|
||||
* payload shape without narrowing the union first — the fields a customer
|
||||
* session carries simply have no shipping-line equivalent.
|
||||
*/
|
||||
@ApiProperty({ nullable: true })
|
||||
company: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
profile: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
review: null = null;
|
||||
|
||||
constructor(entity: ShippingLineCompany) {
|
||||
this.id = entity.id;
|
||||
this.name = entity.name;
|
||||
this.email = entity.email;
|
||||
this.phoneNumber = entity.phoneNumber ?? null;
|
||||
this.scacCode = entity.scacCode ?? null;
|
||||
this.status = entity.status;
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountInfoResponse =
|
||||
| (CompanyInfoResponseDto & { accountKind: "customer" })
|
||||
| ShippingLineInfoResponseDto;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||
|
||||
export class CreateShippingLineDto {
|
||||
@ApiProperty({ example: "Ethiopian Shipping Lines" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
/**
|
||||
* Becomes the IAM account's email — the activation link is sent here, so it
|
||||
* is required even though the customer equivalent is optional.
|
||||
*/
|
||||
@ApiProperty({ example: "ops@esl.com.et" })
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
email!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "+251911223344" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
@IsValidPhone()
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "ESLK",
|
||||
description: "Standard Carrier Alpha Code — 2-4 letters",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[A-Za-z]{2,4}$/, {
|
||||
message: "SCAC must be 2-4 letters",
|
||||
})
|
||||
scacCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "IMO9074729" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
imoNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "ESLU" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
bicCode?: string;
|
||||
|
||||
/**
|
||||
* Login name. Optional — defaults to the email, which is what the line will
|
||||
* naturally try first.
|
||||
*/
|
||||
@ApiPropertyOptional({ example: "esl-ops" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
username?: string;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
ShippingLineCompany,
|
||||
ShippingLineStatus,
|
||||
} from "../entities/shipping-line-company.entity";
|
||||
|
||||
export class ShippingLineResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
scacCode?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
imoNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
bicCode?: string | null;
|
||||
|
||||
@ApiProperty({ enum: ShippingLineStatus })
|
||||
status: ShippingLineStatus;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
constructor(entity: ShippingLineCompany) {
|
||||
this.id = entity.id;
|
||||
this.name = entity.name;
|
||||
this.email = entity.email;
|
||||
this.phoneNumber = entity.phoneNumber ?? null;
|
||||
this.scacCode = entity.scacCode ?? null;
|
||||
this.imoNumber = entity.imoNumber ?? null;
|
||||
this.bicCode = entity.bicCode ?? null;
|
||||
this.status = entity.status;
|
||||
this.createdAt = entity.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
export class RegisterShippingLineResponseDto {
|
||||
@ApiProperty({ type: ShippingLineResponseDto })
|
||||
shippingLine: ShippingLineResponseDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Masked destination the activation link was sent to, or null if delivery failed.",
|
||||
example: "o**@esl.com.et",
|
||||
})
|
||||
activationSentTo: string | null;
|
||||
|
||||
constructor(shippingLine: ShippingLineCompany, activationSentTo: string | null) {
|
||||
this.shippingLine = new ShippingLineResponseDto(shippingLine);
|
||||
this.activationSentTo = activationSentTo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
export enum ShippingLineStatus {
|
||||
Active = "active",
|
||||
Suspended = "suspended",
|
||||
}
|
||||
|
||||
/**
|
||||
* A shipping line — a carrier that books rail capacity directly, registered by
|
||||
* backoffice staff rather than self-signing up.
|
||||
*
|
||||
* Deliberately NOT a {@link Company} of a new {@link CompanyType}: a shipping
|
||||
* line carries none of what `companies` exists to hold — no TIN, no business
|
||||
* licence, no eTrade authenticity lookup, no operational `company_profiles`, no
|
||||
* onboarding wizard state. Modelling it there would mean making all of that
|
||||
* nullable for one row shape that never uses it.
|
||||
*
|
||||
* The company IS the account: there is no contact-person row (customers get one
|
||||
* via `external_profiles`), so `user_id` lives here and the login credentials
|
||||
* are the company's own. That is also why the password-reset flow resolves a
|
||||
* shipping line straight off this table instead of through a primary contact.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "shipping_line_companies" })
|
||||
@Index(["status"])
|
||||
export class ShippingLineCompany extends BaseEntity {
|
||||
/**
|
||||
* The IAM account (`iam.users`, userType `individual`) that signs in as this
|
||||
* shipping line. No FK: `iam` is a separate schema owned by the IAM service,
|
||||
* and the rest of the codebase reaches it by query rather than by relation.
|
||||
*/
|
||||
@Column({ name: "user_id", type: "uuid", unique: true })
|
||||
userId!: string;
|
||||
|
||||
@Column({ name: "name", type: "varchar", length: 200 })
|
||||
name!: string;
|
||||
|
||||
/** Standard Carrier Alpha Code — 2-4 letters identifying the carrier. */
|
||||
@Column({ name: "scac_code", type: "varchar", length: 4, nullable: true })
|
||||
scacCode?: string | null;
|
||||
|
||||
/** IMO number of the vessel operator. */
|
||||
@Column({ name: "imo_number", type: "varchar", length: 20, nullable: true })
|
||||
imoNumber?: string | null;
|
||||
|
||||
/** BIC code — the container prefix the line's equipment is registered under. */
|
||||
@Column({ name: "bic_code", type: "varchar", length: 20, nullable: true })
|
||||
bicCode?: string | null;
|
||||
|
||||
/** Mirrors the IAM account's email; the activation link is sent here. */
|
||||
@Column({ name: "email", type: "varchar", length: 150 })
|
||||
email!: string;
|
||||
|
||||
@Column({ name: "phone_number", type: "varchar", length: 30, nullable: true })
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ShippingLineStatus,
|
||||
default: ShippingLineStatus.Active,
|
||||
})
|
||||
status!: ShippingLineStatus;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto";
|
||||
import { CreateShippingLineDto } from "./dto/create-shipping-line.dto";
|
||||
import {
|
||||
RegisterShippingLineResponseDto,
|
||||
ShippingLineResponseDto,
|
||||
} from "./dto/shipping-line-response.dto";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/**
|
||||
* Shipping line *companies* — carriers with a portal login, registered by staff
|
||||
* (there is no self-signup). The line receives a single-use activation link and
|
||||
* sets its own password, so staff never see or handle a credential.
|
||||
*
|
||||
* Distinct from `freight.shipping_lines` behind `/shipping-lines`
|
||||
* (rule-engine): that is a pricing lookup list — a code/label a booking points
|
||||
* at via `shipping_line_id` — with no account, no user and no login. Same words,
|
||||
* different concept, hence the separate route.
|
||||
*/
|
||||
@ApiTags("shipping-line-companies")
|
||||
@Controller("shipping-line-companies")
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLineCompaniesController {
|
||||
constructor(private readonly shippingLineCompaniesService: ShippingLineCompaniesService) {}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLines.create)
|
||||
@ApiOperation({
|
||||
summary: "Register a shipping line and send its activation link",
|
||||
})
|
||||
async register(
|
||||
@Body() dto: CreateShippingLineDto,
|
||||
): Promise<RegisterShippingLineResponseDto> {
|
||||
const { shippingLine, activationSentTo } =
|
||||
await this.shippingLineCompaniesService.register(dto);
|
||||
return new RegisterShippingLineResponseDto(shippingLine, activationSentTo);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLines.view)
|
||||
@ApiOperation({ summary: "List shipping lines (paginated)" })
|
||||
async list(
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string,
|
||||
): Promise<{
|
||||
items: ShippingLineResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}> {
|
||||
const result = await this.shippingLineCompaniesService.list(
|
||||
page ? Number(page) : undefined,
|
||||
limit ? Number(limit) : undefined,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map((item) => new ShippingLineResponseDto(item)),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLines.view)
|
||||
@ApiOperation({ summary: "Get a shipping line by id" })
|
||||
async findOne(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
): Promise<ShippingLineResponseDto> {
|
||||
return new ShippingLineResponseDto(
|
||||
await this.shippingLineCompaniesService.findById(id),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/resend-activation")
|
||||
@BookingStaff(FREIGHT_PERMS.shippingLines.resetPassword)
|
||||
@ApiOperation({
|
||||
summary: "Resend a shipping line's activation / password-reset link",
|
||||
})
|
||||
async resendActivation(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: BackofficeResetPasswordDto,
|
||||
) {
|
||||
return this.shippingLineCompaniesService.resendActivation(id, dto.channel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { FreightAuthModule } from "../auth/freight-auth.module";
|
||||
import { OtpModule } from "../otp/otp.module";
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
import { ShippingLineCompaniesController } from "./shipping-line-companies.controller";
|
||||
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ShippingLineCompany, User]),
|
||||
// CustomerResetService — activation links reuse the staff-triggered reset path.
|
||||
FreightAuthModule,
|
||||
OtpModule,
|
||||
],
|
||||
controllers: [ShippingLineCompaniesController],
|
||||
providers: [ShippingLineCompaniesService, ShippingLineCompaniesRepository],
|
||||
exports: [ShippingLineCompaniesService],
|
||||
})
|
||||
export class ShippingLineCompaniesModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EntityManager, Repository } from "typeorm";
|
||||
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineCompaniesRepository extends BaseRepository<ShippingLineCompany> {
|
||||
constructor(
|
||||
@InjectRepository(ShippingLineCompany)
|
||||
private readonly shippingLineRepo: Repository<ShippingLineCompany>,
|
||||
) {
|
||||
super(shippingLineRepo);
|
||||
}
|
||||
|
||||
findByUserId(userId: string): Promise<ShippingLineCompany | null> {
|
||||
return this.shippingLineRepo.findOne({ where: { userId } });
|
||||
}
|
||||
|
||||
/** Case-insensitive, matching the `lower(email)` unique index. */
|
||||
async existsByEmail(email: string): Promise<boolean> {
|
||||
const count = await this.shippingLineRepo
|
||||
.createQueryBuilder("sl")
|
||||
.where("lower(sl.email) = lower(:email)", { email })
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async existsByScac(scacCode: string): Promise<boolean> {
|
||||
const count = await this.shippingLineRepo
|
||||
.createQueryBuilder("sl")
|
||||
.where("upper(sl.scacCode) = upper(:scacCode)", { scacCode })
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
findAllPaginated(
|
||||
skip: number,
|
||||
take: number,
|
||||
): Promise<[ShippingLineCompany[], number]> {
|
||||
return this.shippingLineRepo.findAndCount({
|
||||
order: { createdAt: "DESC" },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert inside a caller-supplied transaction, so the shipping-line row and
|
||||
* the IAM user it points at commit together — a row referencing a user that
|
||||
* was rolled back (or vice versa) is an account nobody can sign in to.
|
||||
*/
|
||||
createInTransaction(
|
||||
manager: EntityManager,
|
||||
data: Partial<ShippingLineCompany>,
|
||||
): Promise<ShippingLineCompany> {
|
||||
const repo = manager.getRepository(ShippingLineCompany);
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { ConflictException } from "@nestjs/common";
|
||||
import {
|
||||
EUserStatus,
|
||||
EUserType,
|
||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
|
||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||
|
||||
/**
|
||||
* Registration is the whole feature: an IAM account and a carrier record
|
||||
* created together, then an activation link the line uses to set its own
|
||||
* password. These lock the parts that would silently break the login.
|
||||
*/
|
||||
describe("ShippingLineCompaniesService.register", () => {
|
||||
const savedUser = { id: "user-1" };
|
||||
|
||||
let shippingLinesRepo: {
|
||||
existsByEmail: jest.Mock;
|
||||
existsByScac: jest.Mock;
|
||||
createInTransaction: jest.Mock;
|
||||
findById: jest.Mock;
|
||||
findByUserId: jest.Mock;
|
||||
};
|
||||
let userRepository: { findOne: jest.Mock };
|
||||
let customerResetService: { sendResetLinkToUser: jest.Mock };
|
||||
let dataSource: { transaction: jest.Mock };
|
||||
let userRepoInTx: { create: jest.Mock; save: jest.Mock };
|
||||
let service: ShippingLineCompaniesService;
|
||||
|
||||
const dto = {
|
||||
name: "Ethiopian Shipping Lines",
|
||||
email: "Ops@ESL.com.et",
|
||||
phoneNumber: "+251911223344",
|
||||
scacCode: "eslk",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
userRepoInTx = {
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn().mockResolvedValue(savedUser),
|
||||
};
|
||||
|
||||
shippingLinesRepo = {
|
||||
existsByEmail: jest.fn().mockResolvedValue(false),
|
||||
existsByScac: jest.fn().mockResolvedValue(false),
|
||||
createInTransaction: jest
|
||||
.fn()
|
||||
.mockImplementation((_m, data) => ({ id: "sl-1", ...data })),
|
||||
findById: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
};
|
||||
userRepository = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
customerResetService = {
|
||||
sendResetLinkToUser: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ maskedTarget: "o**@esl.com.et", channel: "email" }),
|
||||
};
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb) =>
|
||||
cb({ getRepository: () => userRepoInTx }),
|
||||
),
|
||||
};
|
||||
|
||||
service = new ShippingLineCompaniesService(
|
||||
shippingLinesRepo as never,
|
||||
userRepository as never,
|
||||
customerResetService as never,
|
||||
dataSource as never,
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the IAM account with no password set", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
const created = userRepoInTx.create.mock.calls[0][0];
|
||||
expect(created).toMatchObject({
|
||||
userType: EUserType.INDIVIDUAL,
|
||||
isActive: true,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
// The line sets its own password from the activation link. Employee
|
||||
// creation seeds a shared default here; a shipping line must not get one.
|
||||
hasSetPassword: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("never writes a credential row", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
// Only the User repository is touched inside the transaction — a
|
||||
// UserCredential insert would mean the account has a password nobody chose.
|
||||
for (const call of userRepoInTx.save.mock.calls) {
|
||||
expect(call[0]).not.toHaveProperty("password");
|
||||
}
|
||||
});
|
||||
|
||||
it("normalises email and SCAC before storing", async () => {
|
||||
const result = await service.register(dto as never);
|
||||
|
||||
expect(result.shippingLine).toMatchObject({
|
||||
email: "ops@esl.com.et",
|
||||
scacCode: "ESLK",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates the account and the record in one transaction", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(shippingLinesRepo.createInTransaction).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the activation link outside the transaction, after commit", async () => {
|
||||
const order: string[] = [];
|
||||
dataSource.transaction.mockImplementation(async (cb: never) => {
|
||||
order.push("tx");
|
||||
return (cb as unknown as (m: unknown) => Promise<unknown>)({
|
||||
getRepository: () => userRepoInTx,
|
||||
});
|
||||
});
|
||||
customerResetService.sendResetLinkToUser.mockImplementation(async () => {
|
||||
order.push("send");
|
||||
return { maskedTarget: "o**@esl.com.et", channel: "email" };
|
||||
});
|
||||
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(order[0]).toBe("tx");
|
||||
expect(order).toContain("send");
|
||||
});
|
||||
|
||||
it("emails the link, and also texts it when the number is domestic", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
const channels = customerResetService.sendResetLinkToUser.mock.calls.map(
|
||||
(c) => c[1],
|
||||
);
|
||||
expect(channels).toContain(ResetChannel.Email);
|
||||
expect(channels).toContain(ResetChannel.Phone);
|
||||
});
|
||||
|
||||
it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => {
|
||||
await service.register({ ...dto, phoneNumber: "+441234567890" } as never);
|
||||
|
||||
const channels = customerResetService.sendResetLinkToUser.mock.calls.map(
|
||||
(c) => c[1],
|
||||
);
|
||||
expect(channels).toEqual([ResetChannel.Email]);
|
||||
});
|
||||
|
||||
it("keeps the registration when the activation link fails to send", async () => {
|
||||
customerResetService.sendResetLinkToUser.mockResolvedValue(null);
|
||||
|
||||
const result = await service.register(dto as never);
|
||||
|
||||
// The account is valid without the link and the link is resendable —
|
||||
// a delivery failure must not roll back the registration.
|
||||
expect(result.shippingLine).toMatchObject({ id: "sl-1" });
|
||||
expect(result.activationSentTo).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a duplicate email", async () => {
|
||||
shippingLinesRepo.existsByEmail.mockResolvedValue(true);
|
||||
|
||||
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a duplicate SCAC", async () => {
|
||||
shippingLinesRepo.existsByScac.mockResolvedValue(true);
|
||||
|
||||
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses credentials already belonging to another account", async () => {
|
||||
// Reusing an existing IAM user would let one login resolve to both a
|
||||
// customer and a shipping line.
|
||||
userRepository.findOne.mockResolvedValue({ id: "existing" });
|
||||
|
||||
await expect(service.register(dto as never)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults the username to the email", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(userRepoInTx.create.mock.calls[0][0]).toMatchObject({
|
||||
username: "ops@esl.com.et",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The default reset lookup inner-joins an active `user_credentials` row so a
|
||||
* reset cannot revive a suspended account. A shipping line has no credential
|
||||
* until it uses the activation link, so without this flag the account is
|
||||
* excluded from its own activation — the link is never minted, never logged,
|
||||
* and resend answers 404.
|
||||
*/
|
||||
it("requests the credential-less lookup for every activation send", async () => {
|
||||
await service.register(dto as never);
|
||||
|
||||
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalled();
|
||||
for (const call of customerResetService.sendResetLinkToUser.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ allowWithoutCredential: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requests the credential-less lookup when resending", async () => {
|
||||
shippingLinesRepo.findById.mockResolvedValue({
|
||||
id: "sl-1",
|
||||
userId: "user-1",
|
||||
phoneNumber: "+251911223344",
|
||||
});
|
||||
|
||||
await service.resendActivation("sl-1", ResetChannel.Email);
|
||||
|
||||
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
ResetChannel.Email,
|
||||
expect.objectContaining({ allowWithoutCredential: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import {
|
||||
EUserStatus,
|
||||
EUserType,
|
||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
// Subpath import (not the package root) so ts-jest can resolve it when this
|
||||
// file lands in a spec's compile graph — same reason as backoffice.service.ts.
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import { DataSource, Repository } from "typeorm";
|
||||
|
||||
import { CustomerResetService } from "../auth/customer-reset.service";
|
||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||
import { isDomesticPhone } from "../otp/otp.service";
|
||||
import { CreateShippingLineDto } from "./dto/create-shipping-line.dto";
|
||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||
import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository";
|
||||
|
||||
export interface RegisteredShippingLine {
|
||||
shippingLine: ShippingLineCompany;
|
||||
/** Masked destination of the activation link, or null if none was sent. */
|
||||
activationSentTo: string | null;
|
||||
activationChannel: ResetChannel | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ShippingLineCompaniesService {
|
||||
private readonly logger = new Logger(ShippingLineCompaniesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly shippingLineCompaniesRepo: ShippingLineCompaniesRepository,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly customerResetService: CustomerResetService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register a shipping line: create its IAM account and its record together,
|
||||
* then send an activation link so the line sets its own password.
|
||||
*
|
||||
* The IAM mechanics follow `BackofficeService.createOrganizationUser` — same
|
||||
* entities, same transaction shape — with one deliberate difference: no
|
||||
* `UserCredential` row is written and `hasSetPassword` stays false. Staff
|
||||
* creating an employee seed a shared default password; a shipping line must
|
||||
* come through the activation link instead, so no credential exists until the
|
||||
* line sets one.
|
||||
*/
|
||||
async register(dto: CreateShippingLineDto): Promise<RegisteredShippingLine> {
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const username = (dto.username?.trim() || email).toLowerCase();
|
||||
const phoneNumber = dto.phoneNumber?.trim() || undefined;
|
||||
const scacCode = dto.scacCode?.trim().toUpperCase();
|
||||
|
||||
if (await this.shippingLineCompaniesRepo.existsByEmail(email)) {
|
||||
throw new ConflictException(
|
||||
`A shipping line with email ${email} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
if (scacCode && (await this.shippingLineCompaniesRepo.existsByScac(scacCode))) {
|
||||
throw new ConflictException(
|
||||
`A shipping line with SCAC ${scacCode} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
// An existing IAM account means these credentials already belong to a
|
||||
// customer or an employee. Reusing it would let one login resolve to two
|
||||
// different account kinds, so this is refused rather than merged — unlike
|
||||
// employee creation, which legitimately re-uses a person's existing user.
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: [{ email }, { username }],
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new ConflictException(
|
||||
"email_or_username_already_in_use",
|
||||
);
|
||||
}
|
||||
|
||||
const shippingLine = await this.dataSource.transaction(async (manager) => {
|
||||
const userRepo = manager.getRepository(User);
|
||||
const user = await userRepo.save(
|
||||
userRepo.create({
|
||||
email,
|
||||
username,
|
||||
phoneNumber,
|
||||
name: { en: dto.name.trim() },
|
||||
userType: EUserType.INDIVIDUAL,
|
||||
isActive: true,
|
||||
// No credential row is written: the account has no password until the
|
||||
// activation link is used. `hasSetPassword` must stay false or the
|
||||
// portal treats the account as ready to sign in with a password that
|
||||
// does not exist.
|
||||
hasSetPassword: false,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
}),
|
||||
);
|
||||
|
||||
return this.shippingLineCompaniesRepo.createInTransaction(manager, {
|
||||
userId: user.id as string,
|
||||
name: dto.name.trim(),
|
||||
email,
|
||||
phoneNumber: phoneNumber ?? null,
|
||||
scacCode: scacCode ?? null,
|
||||
imoNumber: dto.imoNumber?.trim() || null,
|
||||
bicCode: dto.bicCode?.trim() || null,
|
||||
});
|
||||
});
|
||||
|
||||
// Outside the transaction on purpose: a delivery failure must not roll back
|
||||
// a registered line. The link is resendable, and the account is already
|
||||
// valid without it.
|
||||
const activation = await this.sendActivationLink(shippingLine);
|
||||
|
||||
return {
|
||||
shippingLine,
|
||||
activationSentTo: activation?.maskedTarget ?? null,
|
||||
activationChannel: activation?.channel ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the activation link on registration.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Reports the email send, as that is the one that is always attempted.
|
||||
*/
|
||||
async sendActivationLink(shippingLine: ShippingLineCompany) {
|
||||
const scope = `shipping line ${shippingLine.id}`;
|
||||
|
||||
const emailed = await this.customerResetService.sendResetLinkToUser(
|
||||
shippingLine.userId,
|
||||
ResetChannel.Email,
|
||||
{ scope, allowWithoutCredential: true },
|
||||
);
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return emailed;
|
||||
}
|
||||
|
||||
async resendActivation(id: string, channel: ResetChannel) {
|
||||
const shippingLine = await this.shippingLineCompaniesRepo.findById(id);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException("Shipping line not found");
|
||||
}
|
||||
|
||||
if (
|
||||
channel === ResetChannel.Phone &&
|
||||
(!shippingLine.phoneNumber || !isDomesticPhone(shippingLine.phoneNumber))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"This shipping line has no domestic phone number — the SMS gateway cannot reach it",
|
||||
);
|
||||
}
|
||||
|
||||
const sent = await this.customerResetService.sendResetLinkToUser(
|
||||
shippingLine.userId,
|
||||
channel,
|
||||
{ scope: `shipping line ${shippingLine.id}`, allowWithoutCredential: true },
|
||||
);
|
||||
|
||||
if (!sent) {
|
||||
throw new NotFoundException(
|
||||
`No active account with ${
|
||||
channel === ResetChannel.Email ? "an email address" : "a phone number"
|
||||
} for this shipping line`,
|
||||
);
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ShippingLineCompany> {
|
||||
const shippingLine = await this.shippingLineCompaniesRepo.findById(id);
|
||||
if (!shippingLine) {
|
||||
throw new NotFoundException("Shipping line not found");
|
||||
}
|
||||
return shippingLine;
|
||||
}
|
||||
|
||||
/** The shipping line signed in as `userId`, or null for any other account. */
|
||||
findByUserId(userId: string): Promise<ShippingLineCompany | null> {
|
||||
return this.shippingLineCompaniesRepo.findByUserId(userId);
|
||||
}
|
||||
|
||||
async list(page = 1, limit = 20) {
|
||||
const [items, total] = await this.shippingLineCompaniesRepo.findAllPaginated(
|
||||
(page - 1) * limit,
|
||||
limit,
|
||||
);
|
||||
return { items, total, page, limit };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user