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

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