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:
marshalyordanos
2026-08-13 08:54:20 +03:00
parent e37f1e0807
commit 9aae132dd4
42 changed files with 2463 additions and 117 deletions

View File

@@ -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"],

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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 {}

View File

@@ -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")

View File

@@ -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: [

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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));
}
}

View File

@@ -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 }),
);
});
});

View File

@@ -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 };
}
}