From 9aae132dd4a3dc7fc9c89839b7b0d4abc7840af4 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Thu, 13 Aug 2026 08:54:20 +0300 Subject: [PATCH] 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. --- apps/edr-freight-api/src/app.module.ts | 2 + .../3440000000000-ShippingLineCompany.ts | 78 ++++ .../src/modules/audit/audit-endpoints.ts | 7 +- .../modules/auth/customer-reset.service.ts | 82 +++- .../modules/auth/forgot-password.service.ts | 39 +- .../src/modules/auth/freight-auth.module.ts | 3 + .../modules/companies/companies.controller.ts | 27 +- .../src/modules/companies/companies.module.ts | 4 + .../dto/account-info-response.dto.ts | 66 +++ .../dto/create-shipping-line.dto.ts | 68 +++ .../dto/shipping-line-response.dto.ts | 64 +++ .../entities/shipping-line-company.entity.ts | 64 +++ .../shipping-line-companies.controller.ts | 95 ++++ .../shipping-line-companies.module.ts | 24 ++ .../shipping-line-companies.repository.ts | 61 +++ .../shipping-line-companies.service.spec.ts | 233 ++++++++++ .../shipping-line-companies.service.ts | 223 ++++++++++ .../src/seed/freight-permissions.registry.ts | 31 ++ apps/edr-freight-web/backoffice/src/App.tsx | 9 + .../components/layout/sidebar-sections.tsx | 6 + .../shipping-lines/ResendActivationAction.tsx | 168 ++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 8 + .../backoffice/src/constants/URLS.ts | 11 + .../backoffice/src/lib/permissions.ts | 6 + .../ShippingLineCompaniesPage.tsx | 405 ++++++++++++++++++ .../backoffice/src/services/api.ts | 44 ++ .../services/shippingLineCompanies.service.ts | 54 +++ .../src/types/shippingLineCompany.ts | 50 +++ apps/edr-freight-web/portal/src/App.tsx | 301 +++++++++---- .../portal/src/components/AppLayout.tsx | 53 ++- .../onboarding/OnboardingResumeBanner.tsx | 10 +- .../portal/src/hooks/useAuth.ts | 35 +- .../portal/src/pages/accounts/LoginPage.tsx | 21 +- .../pages/accounts/ResetPasswordLinkPage.tsx | 62 ++- .../ShippingLineBookingsPage.tsx | 17 + .../shipping-line/ShippingLineHelpPage.tsx | 18 + .../shipping-line/ShippingLineHomePage.tsx | 17 + .../ShippingLineInvoicesPage.tsx | 13 + .../shipping-line/ShippingLinePlaceholder.tsx | 40 ++ .../ShippingLineSettingsPage.tsx | 17 + .../portal/src/pages/shipping-line/index.ts | 5 + .../portal/src/services/companies.service.ts | 39 +- 42 files changed, 2463 insertions(+), 117 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/shipping-lines/ResendActivationAction.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/shipping-lines/ShippingLineCompaniesPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/shippingLineCompanies.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/shippingLineCompany.ts create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineBookingsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHelpPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHomePage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineInvoicesPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLinePlaceholder.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineSettingsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/index.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 1efe750fc..4c4b9efe8 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -40,6 +40,7 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules. import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module"; import { CompaniesModule } from "./modules/companies/companies.module"; +import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; @@ -200,6 +201,7 @@ if (!process.env.APPLICATION_NAME) { TrainSchedulingModule, SchedulingRescheduleModule, CompaniesModule, + ShippingLineCompaniesModule, TrackingModule, BillingModule, NotificationsModule, diff --git a/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts b/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts new file mode 100644 index 000000000..91e78ab14 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Shipping lines — carriers registered by backoffice staff who sign in to the + * portal directly. + * + * Separate from `freight.companies` on purpose: a shipping line has no TIN, + * business licence, eTrade record, operational profile or onboarding state, so + * it shares none of the customer columns. `user_id` sits on the company row + * itself because the company IS the account — there is no contact-person row. + * + * No FK on `user_id`: `iam.users` belongs to the IAM service's schema, which + * this API reads but never owns. + */ +export class ShippingLineCompany3440000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_companies_status_enum + AS ENUM ('active', 'suspended'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.shipping_line_companies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + name varchar(200) NOT NULL, + scac_code varchar(4), + imo_number varchar(20), + bic_code varchar(20), + email varchar(150) NOT NULL, + phone_number varchar(30), + status freight.shipping_line_companies_status_enum + NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + // One login per shipping line. Partial so a soft-deleted row frees its + // account for re-registration rather than blocking it forever. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_user" + ON freight.shipping_line_companies (user_id) + WHERE deleted_at IS NULL + `); + + // SCAC identifies the carrier globally — two live lines cannot share one. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_scac" + ON freight.shipping_line_companies (scac_code) + WHERE scac_code IS NOT NULL AND deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_email" + ON freight.shipping_line_companies (lower(email)) + WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_shipping_line_companies_status" + ON freight.shipping_line_companies (status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.shipping_line_companies`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.shipping_line_companies_status_enum`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index 0526636ff..d46f63a7d 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -432,11 +432,16 @@ export const AUDIT_ENDPOINTS: Readonly> = { "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"], diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts index 4eb6ecc31..b00fd588e 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -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 { + 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 { + 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) { diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts index a3dbf1061..a2afdbbbc 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -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 { + 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) diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index ff8f803b9..557e50fb3 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index d35400f1a..175533c6d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { + ): Promise { + // 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") diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index f0a443ba8..aa0a00dff 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -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: [ diff --git a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts new file mode 100644 index 000000000..ab680f8f3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts new file mode 100644 index 000000000..5f89c916c --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts new file mode 100644 index 000000000..051c3af28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts @@ -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; + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts new file mode 100644 index 000000000..fac8c00a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts new file mode 100644 index 000000000..15ba673a3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts @@ -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 { + 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 { + 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); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts new file mode 100644 index 000000000..81d5b9cdf --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts new file mode 100644 index 000000000..997550172 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts @@ -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 { + constructor( + @InjectRepository(ShippingLineCompany) + private readonly shippingLineRepo: Repository, + ) { + super(shippingLineRepo); + } + + findByUserId(userId: string): Promise { + return this.shippingLineRepo.findOne({ where: { userId } }); + } + + /** Case-insensitive, matching the `lower(email)` unique index. */ + async existsByEmail(email: string): Promise { + const count = await this.shippingLineRepo + .createQueryBuilder("sl") + .where("lower(sl.email) = lower(:email)", { email }) + .getCount(); + return count > 0; + } + + async existsByScac(scacCode: string): Promise { + 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, + ): Promise { + const repo = manager.getRepository(ShippingLineCompany); + return repo.save(repo.create(data)); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts new file mode 100644 index 000000000..d9d429dc9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts @@ -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)({ + 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 }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts new file mode 100644 index 000000000..16b2836e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts @@ -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, + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index f0b719d63..6c891d830 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -450,6 +450,30 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// C2. Shipping lines — carriers registered by staff (no self-signup). +export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "d1a00002-0001-4000-8000-000000000001", + "edr_freight_app:shipping_lines:view", + "View shipping lines", + ), + perm( + "d1a00002-0001-4000-8000-000000000002", + "edr_freight_app:shipping_lines:create", + "Register shipping line", + ), + perm( + "d1a00002-0001-4000-8000-000000000003", + "edr_freight_app:shipping_lines:update", + "Update shipping line", + ), + perm( + "d1a00002-0001-4000-8000-000000000004", + "edr_freight_app:shipping_lines:reset-password", + "Resend shipping line activation link", + ), +]; + // D. Finance — payments + invoices export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1493,6 +1517,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...CUSTOMER_PERMISSIONS, + ...SHIPPING_LINE_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, ...FLEET_RAIL_PERMISSIONS, @@ -1699,6 +1724,12 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:customers:get_notification", }, + shippingLines: { + view: "edr_freight_app:shipping_lines:view", + create: "edr_freight_app:shipping_lines:create", + update: "edr_freight_app:shipping_lines:update", + resetPassword: "edr_freight_app:shipping_lines:reset-password", + }, payments: { view: "edr_freight_app:payments:view", }, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 64f91da72..58bca3738 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -34,6 +34,7 @@ import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetai import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; +import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage"; import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; import InvoicesPage from "./pages/invoices/InvoicesPage"; import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage"; @@ -242,6 +243,14 @@ const App = () => { } /> + + + + } + /> , permission: FREIGHT_PERMS.customers.view, }, + { + label: "Shipping Lines", + href: "/dashboard/shipping-lines", + icon: , + permission: FREIGHT_PERMS.shippingLines.view, + }, { label: "Contracts", href: "/dashboard/contract-requests", diff --git a/apps/edr-freight-web/backoffice/src/components/shipping-lines/ResendActivationAction.tsx b/apps/edr-freight-web/backoffice/src/components/shipping-lines/ResendActivationAction.tsx new file mode 100644 index 000000000..ae271a57b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/shipping-lines/ResendActivationAction.tsx @@ -0,0 +1,168 @@ +import { + ActionIcon, + Alert, + Button, + Modal, + Radio, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { Send } from "lucide-react"; +import { useState } from "react"; + +import { useAuth } from "@/auth/useAuth"; +import { useToast } from "@/hooks/use-toast"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { api } from "@/services/api"; +import type { + ResetChannel, + ShippingLineCompany, +} from "@/types/shippingLineCompany"; + +/** + * Whether the SMS gateway can actually reach this number. + * + * The carrier integration is domestic-only: anything else is queued and + * silently lost, so a foreign number counts as unavailable rather than as a + * send that quietly fails. Mirrors `isDomesticPhone` in the API's otp.service. + */ +function isDomesticPhone(rawPhone: string): boolean { + const digits = rawPhone.trim().replace(/[^\d+]/g, ""); + const normalized = digits.startsWith("+") + ? digits + : /^251\d{9}$/.test(digits) + ? `+${digits}` + : /^9\d{8}$|^7\d{8}$/.test(digits.replace(/^0+/, "")) + ? `+251${digits.replace(/^0+/, "")}` + : digits; + return /^\+2519\d{8}$/.test(normalized); +} + +export interface ResendActivationActionProps { + shippingLine: Pick; +} + +/** + * Resend a shipping line's activation link. + * + * The same single-use link registration sends: the carrier opens it and picks + * their own password, so no credential is ever shown to or handled by staff. + * Needed whenever the original send failed, expired (24h), or never arrived. + */ +export default function ResendActivationAction({ + shippingLine, +}: ResendActivationActionProps) { + const { user } = useAuth(); + const { toast } = useToast(); + const [opened, setOpened] = useState(false); + const [channel, setChannel] = useState("email"); + + const allowed = hasPermission(user, FREIGHT_PERMS.shippingLines.resetPassword); + + const { mutate, isPending } = useMutation( + api.shippingLineCompanies.resendActivation.mutationOptions({ + onSuccess: (result) => { + setOpened(false); + toast({ + title: "Activation link sent", + description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`, + }); + }, + onError: (error) => { + toast({ + title: "Could not send activation link", + description: error.message, + variant: "destructive", + }); + }, + }), + ); + + if (!allowed) return null; + + const phoneUsable = + !!shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber); + const channelMissing = channel === "phone" && !phoneUsable; + + return ( + <> + + { + // The row itself is not clickable today, but stop here anyway so + // adding a detail-page navigation later cannot swallow this click. + event.stopPropagation(); + setOpened(true); + }} + > + + + + + setOpened(false)} + title="Resend activation link" + centered + > + + + We'll send a single-use link to {shippingLine.name}. They choose + their own password — you will not see it. The link expires in 24 + hours, and sending a new one invalidates nothing they haven't + already used. + + + setChannel(v as ResetChannel)} + label="Send the link via" + > + + + + + + + {channelMissing ? ( + + + This account has no number the SMS gateway can reach. Send the + link by email instead. + + + ) : null} + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index b681a2909..f42702826 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -28,6 +28,14 @@ export const QUERY_KEYS = { byCode: (code: string) => ["dropdown-settings", "by-code", code] as const, }, + SHIPPING_LINE_COMPANIES: { + ROOT: ["shipping-line-companies"] as const, + list: (page: number, limit: number) => + ["shipping-line-companies", "list", page, limit] as const, + byId: (id: string) => + ["shipping-line-companies", "detail", id] as const, + }, + CUSTOMERS: { ROOT: ["customers"] as const, stats: ["customers", "stats"] as const, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d3566645e..f9bd29feb 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -77,6 +77,17 @@ export const URL_CONSTANTS = { BOOKINGS: (id: string | number) => `/customers/${id}/bookings`, }, + /** + * Carriers with a portal login. Distinct from `/shipping-lines`, which is the + * rule-engine's pricing lookup list (a code/label bookings reference). + */ + SHIPPING_LINE_COMPANIES: { + BASE: "/shipping-line-companies", + BY_ID: (id: string) => `/shipping-line-companies/${id}`, + RESEND_ACTIVATION: (id: string) => + `/shipping-line-companies/${id}/resend-activation`, + }, + COMPANIES: { BASE: "/companies", STATS: "/companies/stats", diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c69aa5d1b..ade07a511 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -123,6 +123,12 @@ export const FREIGHT_PERMS = { verify: "edr_freight_app:customers:verify", resetPassword: "edr_freight_app:customers:reset-password", }, + shippingLines: { + view: "edr_freight_app:shipping_lines:view", + create: "edr_freight_app:shipping_lines:create", + update: "edr_freight_app:shipping_lines:update", + resetPassword: "edr_freight_app:shipping_lines:reset-password", + }, payments: { view: "edr_freight_app:payments:view", }, diff --git a/apps/edr-freight-web/backoffice/src/pages/shipping-lines/ShippingLineCompaniesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/shipping-lines/ShippingLineCompaniesPage.tsx new file mode 100644 index 000000000..d8e0bb0d6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/shipping-lines/ShippingLineCompaniesPage.tsx @@ -0,0 +1,405 @@ +import { + Alert, + Badge, + Box, + Button, + Card, + Group, + Modal, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Info, Mail, Phone, Plus, Ship } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { useAuth } from "@/auth/useAuth"; +import { PageContainer, PageHeader } from "@/components/page"; +import ResendActivationAction from "@/components/shipping-lines/ResendActivationAction"; +import { useToast } from "@/hooks/use-toast"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { api } from "@/services/api"; +import type { ShippingLineCompany } from "@/types/shippingLineCompany"; +import { + DataTable, + DataTableFooter, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; + +/** SCAC is 2-4 letters; the API enforces the same rule. */ +const SCAC_PATTERN = /^[A-Za-z]{2,4}$/; + +interface FormValues { + name: string; + email: string; + phoneNumber: string; + scacCode: string; + imoNumber: string; + bicCode: string; +} + +const EMPTY_FORM: FormValues = { + name: "", + email: "", + phoneNumber: "", + scacCode: "", + imoNumber: "", + bicCode: "", +}; + +const formatDate = (iso: string) => + new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + +/** + * Shipping line companies — carriers with their own portal login. + * + * Registration is staff-only: there is no self-signup. Staff never set a + * password; the system emails (and texts, when the number is domestic) a + * single-use activation link that the carrier uses to choose their own. + */ +export default function ShippingLineCompaniesPage() { + const { user } = useAuth(); + const { toast } = useToast(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [registerOpen, setRegisterOpen] = useState(false); + + const canCreate = hasPermission(user, FREIGHT_PERMS.shippingLines.create); + + const { data, isLoading, isError, error, refetch } = useQuery( + api.shippingLineCompanies.list.queryOptions({ + input: { + page: pagination.pageIndex + 1, + limit: pagination.pageSize, + }, + }), + ); + + const rows = data?.items ?? []; + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const [values, setValues] = useState(EMPTY_FORM); + const [touched, setTouched] = useState(false); + + const setField = (field: keyof FormValues) => (value: string) => + setValues((prev) => ({ ...prev, [field]: value })); + + // Mirrors the API's own validation, so the obvious mistakes are caught before + // a round trip. The server still enforces all of it. + const errors = { + name: values.name.trim() ? null : "Company name is required", + // Required, unlike a customer's: the activation link is sent here, so an + // account without one could never be signed in to. + email: /^\S+@\S+\.\S+$/.test(values.email.trim()) + ? null + : "A valid email is required", + scacCode: + !values.scacCode.trim() || SCAC_PATTERN.test(values.scacCode.trim()) + ? null + : "SCAC must be 2-4 letters", + }; + const isValid = !errors.name && !errors.email && !errors.scacCode; + + const closeRegister = () => { + setRegisterOpen(false); + setValues(EMPTY_FORM); + setTouched(false); + }; + + const { mutate: register, isPending: isRegistering } = useMutation( + api.shippingLineCompanies.register.mutationOptions({ + onSuccess: (result) => { + closeRegister(); + toast({ + title: "Shipping line registered", + description: result.activationSentTo + ? `An activation link was sent to ${result.activationSentTo}. It expires in 24 hours.` + : // The account exists and is valid — only delivery failed, and the + // link can be resent, so this is a warning rather than an error. + "The account was created, but the activation link could not be sent. Use “Resend activation” to try again.", + variant: result.activationSentTo ? undefined : "destructive", + }); + }, + onError: (err) => { + toast({ + title: "Could not register shipping line", + description: err.message, + variant: "destructive", + }); + }, + }), + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "name", + header: "Shipping line", + cell: ({ row }) => { + const sl = row.original; + return ( + + + + +
+ + {sl.name} + + {sl.scacCode ? ( + + SCAC {sl.scacCode} + + ) : null} +
+
+ ); + }, + }, + { + id: "contact", + header: "Contact", + cell: ({ row }) => { + const sl = row.original; + return ( + + + + + {sl.email} + + + {sl.phoneNumber ? ( + + + + {sl.phoneNumber} + + + ) : null} + + ); + }, + }, + { + id: "identifiers", + header: "Identifiers", + cell: ({ row }) => { + const { imoNumber, bicCode } = row.original; + if (!imoNumber && !bicCode) { + return ( + + — + + ); + } + return ( + + {imoNumber ? IMO {imoNumber} : null} + {bicCode ? ( + + BIC {bicCode} + + ) : null} + + ); + }, + }, + { + id: "status", + header: "Status", + cell: ({ row }) => ( + + {row.original.status === "active" ? "Active" : "Suspended"} + + ), + }, + { + id: "createdAt", + header: "Registered", + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + { + id: "actions", + header: "", + cell: ({ row }) => ( + + + + ), + }, + ], + [], + ); + + return ( + + + } + onClick={() => setRegisterOpen(true)} + > + Register shipping line + + ) : null + } + /> + + + void refetch(), + } + : undefined + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={DataTableFooter} + /> + + + + +
{ + event.preventDefault(); + setTouched(true); + if (!isValid) return; + register({ + name: values.name.trim(), + email: values.email.trim(), + phoneNumber: values.phoneNumber.trim() || undefined, + scacCode: values.scacCode.trim() || undefined, + imoNumber: values.imoNumber.trim() || undefined, + bicCode: values.bicCode.trim() || undefined, + }); + }} + > + + } + color="blue" + variant="light" + p="sm" + > + + No password is set here. The shipping line receives a single-use + activation link and chooses their own. + + + + setField("name")(e.currentTarget.value)} + error={touched ? errors.name : null} + /> + + setField("email")(e.currentTarget.value)} + error={touched ? errors.email : null} + /> + + setField("phoneNumber")(e.currentTarget.value)} + /> + + + setField("scacCode")(e.currentTarget.value)} + error={touched ? errors.scacCode : null} + /> + setField("imoNumber")(e.currentTarget.value)} + /> + + + setField("bicCode")(e.currentTarget.value)} + /> + + + + + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 6c9c69788..bbab2b253 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -44,6 +44,12 @@ import type { PaginatedOfflineUsdInvoices, } from "@/types/invoice"; import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; +import type { + CreateShippingLineCompanyDto, + PaginatedShippingLineCompanies, + RegisterShippingLineCompanyResult, + ShippingLineCompany, +} from "@/types/shippingLineCompany"; import { RuleEngineListResult, RuleEngineRecord, @@ -150,6 +156,7 @@ import { import { containerTypesService } from "./container-types.service"; import { containerService, type Container } from "./containerService"; import { customersService } from "./customers.service"; +import { shippingLineCompaniesService } from "./shippingLineCompanies.service"; import { eimsService } from "./eims.service"; import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims"; import { invoicesService } from "./invoices.service"; @@ -2763,6 +2770,43 @@ export const api = { ), }, + shippingLineCompanies: { + list: endpoint<{ page: number; limit: number }, PaginatedShippingLineCompanies>( + "shippingLineCompanies", + "list", + ({ page, limit }) => shippingLineCompaniesService.list(page, limit), + ({ page, limit }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.list(page, limit), + ), + + getById: endpoint<{ id: string }, ShippingLineCompany>( + "shippingLineCompanies", + "getById", + ({ id }) => shippingLineCompaniesService.getById(id), + ({ id }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.byId(id), + ), + + register: endpoint< + CreateShippingLineCompanyDto, + RegisterShippingLineCompanyResult + >( + "shippingLineCompanies", + "register", + (dto) => shippingLineCompaniesService.register(dto), + undefined, + () => [QUERY_KEYS.SHIPPING_LINE_COMPANIES.ROOT], + ), + + resendActivation: endpoint< + { id: string; channel: ResetChannel }, + ResetPasswordResult + >( + "shippingLineCompanies", + "resendActivation", + ({ id, channel }) => + shippingLineCompaniesService.resendActivation(id, channel), + ), + }, + customers: { stats: endpoint, CompanyStats>( "customers", diff --git a/apps/edr-freight-web/backoffice/src/services/shippingLineCompanies.service.ts b/apps/edr-freight-web/backoffice/src/services/shippingLineCompanies.service.ts new file mode 100644 index 000000000..82e97140f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/shippingLineCompanies.service.ts @@ -0,0 +1,54 @@ +import { api as apiClient } from "@/auth/http"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { + CreateShippingLineCompanyDto, + PaginatedShippingLineCompanies, + RegisterShippingLineCompanyResult, + ResetChannel, + ResetPasswordResult, + ShippingLineCompany, +} from "@/types/shippingLineCompany"; + +export const shippingLineCompaniesService = { + list(page = 1, limit = 20): Promise { + return apiClient + .get( + URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE, + { params: { page, limit } }, + ) + .then((r) => r.data); + }, + + getById(id: string): Promise { + return apiClient + .get(URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BY_ID(id)) + .then((r) => r.data); + }, + + /** + * Creates the carrier's account and record, then sends an activation link. + * Staff never set or see a password — the line chooses its own from the link. + */ + register( + dto: CreateShippingLineCompanyDto, + ): Promise { + return apiClient + .post( + URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE, + dto, + ) + .then((r) => r.data); + }, + + resendActivation( + id: string, + channel: ResetChannel, + ): Promise { + return apiClient + .post( + URL_CONSTANTS.SHIPPING_LINE_COMPANIES.RESEND_ACTIVATION(id), + { channel }, + ) + .then((r) => r.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/shippingLineCompany.ts b/apps/edr-freight-web/backoffice/src/types/shippingLineCompany.ts new file mode 100644 index 000000000..9feb5c627 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/shippingLineCompany.ts @@ -0,0 +1,50 @@ +import type { ResetChannel, ResetPasswordResult } from "./customer"; + +export type ShippingLineStatus = "active" | "suspended"; + +/** + * A carrier with its own portal login, registered by staff. + * + * Not to be confused with the rule-engine's `ShippingLine` (types/rule-engine): + * that is a pricing lookup — a code/label a booking points at — with no account + * and no login. This one is the account. + */ +export interface ShippingLineCompany { + id: string; + name: string; + email: string; + phoneNumber: string | null; + scacCode: string | null; + imoNumber: string | null; + bicCode: string | null; + status: ShippingLineStatus; + createdAt: string; +} + +export interface CreateShippingLineCompanyDto { + name: string; + email: string; + phoneNumber?: string; + scacCode?: string; + imoNumber?: string; + bicCode?: string; + username?: string; +} + +export interface RegisterShippingLineCompanyResult { + shippingLine: ShippingLineCompany; + /** + * Masked destination the activation link went to, or null when delivery + * failed. The registration still succeeded — the link is resendable. + */ + activationSentTo: string | null; +} + +export interface PaginatedShippingLineCompanies { + items: ShippingLineCompany[]; + total: number; + page: number; + limit: number; +} + +export type { ResetChannel, ResetPasswordResult }; diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index a8f616976..e47ec9faf 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -59,6 +59,13 @@ import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; +import { + ShippingLineBookingsPage, + ShippingLineHelpPage, + ShippingLineHomePage, + ShippingLineInvoicesPage, + ShippingLineSettingsPage, +} from "./pages/shipping-line"; import FaqPage from "./pages/support/FaqPage"; import HelpPage from "./pages/support/HelpPage"; import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage"; @@ -129,12 +136,20 @@ function isOnboardingAllowedPath(pathname: string): boolean { * can be dismissed to use those pages. Visiting any other page bounces back to * home and re-opens the wizard. New users (no company yet) are treated the same * as users who haven't completed onboarding. + * + * Shipping lines are exempt: staff register them with their details already + * captured, so there is nothing for them to onboard — they go straight to home. */ function OnboardingGate() { - const { company, onboardingCompleted } = useAuth(); + const { company, onboardingCompleted, isShippingLine } = useAuth(); const location = useLocation(); - const needsOnboarding = !company || !onboardingCompleted; + // Keyed off a positive shipping-line identification, never off "no company": + // that is also true mid-fetch and on error, which would let customers slip + // past onboarding whenever the request failed. + const needsOnboarding = isShippingLine + ? false + : !company || !onboardingCompleted; const allowedHere = isOnboardingAllowedPath(location.pathname); // Open by default while onboarding is pending (covers the login case). @@ -176,21 +191,69 @@ function OnboardingGate() { ); } +/** + * Customer-only routes. A shipping line that lands on one (an old link, a + * bookmark, a hand-typed URL) is sent to its own home rather than shown a + * contract/company-shaped page that has no meaning for it. + */ +function RequireCustomer() { + const { isShippingLine, customerQuery } = useAuth(); + + // RequireCompany already awaits this query, but guard anyway: a refetch can + // flip `isPending` back on, and redirecting on a half-loaded account would + // throw the user into the wrong app. + if (customerQuery.isPending) return ; + if (isShippingLine) return ; + return ; +} + +/** The mirror of RequireCustomer: shipping-line routes, closed to customers. */ +function RequireShippingLine() { + const { isShippingLine, customerQuery } = useAuth(); + + if (customerQuery.isPending) return ; + if (!isShippingLine) return ; + return ; +} + +/** + * Where a signed-in account belongs. Shipping lines and customers have separate + * apps, so every "you're already logged in" redirect has to pick between them. + * Waits for the company query: `isShippingLine` is false while that request is + * still in flight, which would land a shipping line on the customer home first. + */ +function useHomeRoute(): { ready: boolean; href: string } { + const { isShippingLine, customerQuery } = useAuth(); + + return { + ready: !customerQuery.isPending, + href: isShippingLine ? "/shipping-line" : "/portal", + }; +} + /** Keeps authenticated users off the login/signup pages. */ function RedirectIfAuthed() { const { isPending, isAuthenticated } = useAuth(); + const home = useHomeRoute(); if (isPending) return ; - if (isAuthenticated) return ; + if (isAuthenticated) { + if (!home.ready) return ; + return ; + } return ; } /** Landing page for visitors; authenticated users go straight to the portal. */ function LandingRoute() { const { isPending, isAuthenticated } = useAuth(); + const home = useHomeRoute(); if (isPending) return ; - if (isAuthenticated) return ; + if (isAuthenticated) { + if (!home.ready) return ; + return ; + } return ; } @@ -230,6 +293,37 @@ const sidebarItems: SidebarItem[] = [ }, ]; +/** + * Sidebar for shipping lines. Intentionally its own list rather than a filtered + * view of `sidebarItems`: shipping lines have no contracts, and their Home / + * Bookings / Invoices pages are different pages at different routes. + */ +const shippingLineSidebarItems: SidebarItem[] = [ + { label: "Home", href: "/shipping-line", icon: }, + { + label: "Bookings", + href: "/shipping-line/bookings", + icon: , + }, + { + label: "Invoices", + href: "/shipping-line/invoices", + icon: , + }, + { + section: "Account", + label: "Settings", + href: "/shipping-line/settings", + icon: , + }, + { + section: "Account", + label: "Help & Support", + href: "/shipping-line/help", + icon: , + }, +]; + const App = () => { const navigate = useNavigate(); const location = useLocation(); @@ -310,81 +404,134 @@ const App = () => { }> }> - - - - } - > - } /> - {/* Bookings are created against a contract, but the full list is + {/* Shipping-line app. Its own layout and sidebar, and its own pages + at their own routes — nothing here is shared with the customer + branch below beyond the shell component itself. Contracts are + absent by design: shipping lines request bookings directly. */} + }> + + + + } + > + } + /> + } + /> + } + /> + } + /> + } + /> + {/* Old shared links land on the shipping-line equivalents. */} + } + /> + + + + {/* Customer app — unchanged. */} + }> + + + + } + > + } /> + {/* Bookings are created against a contract, but the full list is browsable here. New-booking entry still routes via a contract. */} - } /> - } - /> - } /> - } /> - } - /> - } - /> - } - /> - } /> - } /> - } - /> - } - /> - } - /> - {/* Completion of an initiated (bare) booking after per-booking + } /> + } + /> + } + /> + } /> + } + /> + } + /> + } + /> + } /> + } /> + } + /> + } + /> + } + /> + {/* Completion of an initiated (bare) booking after per-booking clearance — same form, submits to the complete endpoint. */} - } - /> - } - /> - } /> - } /> - } /> - } /> - {/* Profile was merged into Settings — keep old links working. */} - } - /> - } /> - } /> + } + /> + } + /> + } /> + } /> + } /> + } /> + {/* Profile was merged into Settings — keep old links working. */} + } + /> + } /> + } /> + diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 126c1fe2c..c5c7a0c31 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -107,23 +107,20 @@ function getActivePage( activePath: string, ): { label: string } | null { const path = activePath.toLowerCase(); - for (const item of items) { - if ( - path === item.href.toLowerCase() || - path.startsWith(item.href.toLowerCase() + "/") - ) { - return { label: item.label }; - } - if (item.children) { - const childMatch = item.children.find( - (c) => - path === c.href.toLowerCase() || - path.startsWith(c.href.toLowerCase() + "/"), - ); - if (childMatch) return { label: childMatch.label }; - } - } - return null; + + // Longest match wins, for the same reason as the sidebar's isItemActive: + // a nested href like "/shipping-line/bookings" must beat its "/shipping-line" + // parent, which a first-match-wins scan would report as "Home". + const best = items + .flatMap((item) => [item, ...(item.children ?? [])]) + .filter( + (item) => + path === item.href.toLowerCase() || + path.startsWith(item.href.toLowerCase() + "/"), + ) + .sort((a, b) => b.href.length - a.href.length)[0]; + + return best ? { label: best.label } : null; } const navClassNames = (active: boolean) => { @@ -262,9 +259,19 @@ export function AppLayout({ const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m; const isSuspendedAppeal = reapplyStatus === "suspended"; + // Longest matching href wins. A plain prefix test would light up every + // ancestor: with a "/shipping-line" home item alongside "/shipping-line/ + // bookings", Home would stay highlighted on every page beneath it. Exact + // matches still win outright, so customer routes are unaffected — their + // sidebar hrefs are siblings, never nested inside one another. + const bestMatchHref = sidebarItems + .flatMap((item) => [item, ...(item.children ?? [])]) + .map((item) => item.href.toLowerCase()) + .filter((href) => activePath === href || activePath.startsWith(href + "/")) + .sort((a, b) => b.length - a.length)[0]; + const isItemActive = (item: SidebarItem) => - activePath === item.href.toLowerCase() || - activePath.startsWith(item.href.toLowerCase() + "/"); + bestMatchHref === item.href.toLowerCase(); return ( openServiceModal(p.type as ServiceType, p) } - leftSection={} + leftSection={ + + } > {serviceLabel(p.type as ServiceType)} @@ -893,7 +902,9 @@ export function AppLayout({ )} p.status === "pending"); const approved = profiles.filter((p) => p.status === "active"); diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 3573750a9..a6e647918 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -1,6 +1,12 @@ import { api } from "@/services/api"; -import type { ProfileTypeValue } from "@/services/companies.service"; -import { companiesService } from "@/services/companies.service"; +import type { + CompanyInfoResponse, + ProfileTypeValue, +} from "@/services/companies.service"; +import { + companiesService, + isShippingLineAccount, +} from "@/services/companies.service"; import type { LoginPayload, LoginResponse, @@ -158,7 +164,21 @@ const useAuth = () => { } }; - const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null; + const accountInfo = isAuthenticated ? (companyQuery.data ?? null) : null; + + /** + * Shipping lines share the portal with customers but have no company, no + * external profile and no onboarding. Identified positively from the + * backend's discriminator — never from "company is missing", which is also + * true while the fetch is in flight or after it fails. + */ + const isShippingLine = isShippingLineAccount(accountInfo); + const shippingLine = isShippingLine ? accountInfo : null; + + // Every customer-shaped field below is null/empty for a shipping line. + const companyInfo = isShippingLine + ? null + : (accountInfo as CompanyInfoResponse | null); const companyType = companyInfo?.company?.type ?? null; const companyStatus = companyInfo?.company?.status ?? null; // A company can create bookings only once an admin has approved it (active). @@ -259,8 +279,11 @@ const useAuth = () => { isPending, isAuthenticated, user: isAuthenticated ? (authQuery.data ?? null) : null, - company: isAuthenticated ? (companyQuery.data ?? null) : null, - customer: isAuthenticated ? (companyQuery.data ?? null) : null, + // The whole getInfo payload, not the `company` field within it (historical + // name). Null for a shipping line: consumers read `.company` / `.profile` + // off it, and a shipping line has neither. + company: companyInfo, + customer: companyInfo, canBook, hasActiveProfile, hasPendingProfile, @@ -272,6 +295,8 @@ const useAuth = () => { isUnderReview, onboardingCompleted, onboardingStep, + isShippingLine, + shippingLine, createProfile, reapplyProfile, login, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 7e8e512dd..746d1648c 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,6 +1,6 @@ import { type FormEvent, useState } from "react"; import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; @@ -19,6 +19,10 @@ export default function LoginPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const passwordWasReset = + (location.state as { passwordReset?: boolean } | null)?.passwordReset === + true; + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setError(null); @@ -56,6 +60,21 @@ export default function LoginPage() { + {/* + Set by the reset flows on their way here. Without it the account + holder lands on a bare sign-in form with no sign the reset worked — + and the link is single-use, so there is no way back to check. + */} + {passwordWasReset ? ( + } + > + Your password has been updated. Sign in with your new password. + + ) : null} + (null); + const [succeeded, setSucceeded] = useState(false); useEffect(() => { if (!userId || !token) { @@ -82,7 +83,12 @@ export default function ResetPasswordLinkPage() { newPassword: password, confirmPassword, }); - navigate("/login", { replace: true, state: { passwordReset: true } }); + // Confirm in place rather than bouncing to /login: the old redirect + // passed `passwordReset: true` in route state that no page ever read, so + // the account holder landed on a bare sign-in form with no sign the reset + // had worked — and the link is single-use, so there is no way back to + // check. + setSucceeded(true); } catch (err) { setError(extractApiError(err).message); } finally { @@ -90,6 +96,58 @@ export default function ResetPasswordLinkPage() { } }; + // Password set — the link is now spent, so this is the last thing the account + // holder sees. It replaces the whole form rather than sitting above it. + if (succeeded) { + return ( + +
+
+ + + +
+ +
+

+ You're all set +

+

+ Your password has been updated + {account?.maskedIdentifier ? ` for ${account.maskedIdentifier}` : ""}. + Sign in with your new password to continue. +

+
+ + + + +

+ For your security, this reset link has now been used and will not + work again. +

+
+
+
+ ); + } + return ( } + /> + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHelpPage.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHelpPage.tsx new file mode 100644 index 000000000..84b605345 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHelpPage.tsx @@ -0,0 +1,18 @@ +import { LifeBuoy } from "lucide-react"; +import ShippingLinePlaceholder from "./ShippingLinePlaceholder"; + +/** + * Shipping-line help & support. Separate from the customer `/help` page, which + * is public (the auth screens link to it) and carries its own doc chrome; this + * one lives inside the shipping-line app layout and will hold guidance written + * for shipping lines. + */ +export default function ShippingLineHelpPage() { + return ( + } + /> + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHomePage.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHomePage.tsx new file mode 100644 index 000000000..12a789ea7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHomePage.tsx @@ -0,0 +1,17 @@ +import { Home } from "lucide-react"; +import ShippingLinePlaceholder from "./ShippingLinePlaceholder"; + +/** + * Shipping-line home / dashboard. Deliberately separate from the customer + * dashboard (`MyPortalPage`): shipping lines have no company, no operational + * profiles and no contracts, so almost none of that page's data applies. + */ +export default function ShippingLineHomePage() { + return ( + } + /> + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineInvoicesPage.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineInvoicesPage.tsx new file mode 100644 index 000000000..8c0a4cb4a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineInvoicesPage.tsx @@ -0,0 +1,13 @@ +import { Receipt } from "lucide-react"; +import ShippingLinePlaceholder from "./ShippingLinePlaceholder"; + +/** Shipping-line payments / invoices. */ +export default function ShippingLineInvoicesPage() { + return ( + } + /> + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLinePlaceholder.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLinePlaceholder.tsx new file mode 100644 index 000000000..20d647474 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLinePlaceholder.tsx @@ -0,0 +1,40 @@ +import { Card, Stack, Text, Title } from "@mantine/core"; +import type { ReactNode } from "react"; + +export interface ShippingLinePlaceholderProps { + title: string; + description: string; + icon?: ReactNode; +} + +/** + * Shell for the shipping-line pages while they are still being built out. + * Each page owns its own file so it can be filled in independently; this only + * supplies the shared empty-state chrome and is meant to be deleted from a page + * once that page has real content. + */ +export default function ShippingLinePlaceholder({ + title, + description, + icon, +}: ShippingLinePlaceholderProps) { + return ( + + + {title} + + {description} + + + + + + {icon} + + Nothing here yet — this page is still being built. + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineSettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineSettingsPage.tsx new file mode 100644 index 000000000..9e2081e58 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineSettingsPage.tsx @@ -0,0 +1,17 @@ +import { Settings } from "lucide-react"; +import ShippingLinePlaceholder from "./ShippingLinePlaceholder"; + +/** + * Shipping-line settings. Separate from the customer `SettingsPage`, which is + * built around company profiles, business licenses and contact-person review — + * none of which a shipping line has. + */ +export default function ShippingLineSettingsPage() { + return ( + } + /> + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/index.ts b/apps/edr-freight-web/portal/src/pages/shipping-line/index.ts new file mode 100644 index 000000000..cb679c8cd --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/index.ts @@ -0,0 +1,5 @@ +export { default as ShippingLineHomePage } from "./ShippingLineHomePage"; +export { default as ShippingLineBookingsPage } from "./ShippingLineBookingsPage"; +export { default as ShippingLineInvoicesPage } from "./ShippingLineInvoicesPage"; +export { default as ShippingLineSettingsPage } from "./ShippingLineSettingsPage"; +export { default as ShippingLineHelpPage } from "./ShippingLineHelpPage"; diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 4c1b93713..eda9d7e3d 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -89,7 +89,18 @@ export interface CompanyProfileResponse { updatedAt: string; } +/** + * Which kind of account is signed in. + * + * Read this instead of inferring from a missing `company`: a failed or + * in-flight company fetch also leaves `company` empty, and treating that as + * "shipping line" would skip onboarding for customers whenever the request + * failed. Absent (older responses) means `customer`. + */ +export type AccountKind = "customer" | "shipping_line"; + export interface CompanyInfoResponse { + accountKind?: AccountKind; profile: ExternalProfileResponse; company: CompanyResponse; /** @@ -104,6 +115,30 @@ export interface CompanyInfoResponse { } | null; } +/** + * A signed-in shipping line. It has no company, no external profile and no + * onboarding — the carrier record itself is the account. + */ +export interface ShippingLineInfoResponse { + accountKind: "shipping_line"; + id: string; + name: string; + email: string; + phoneNumber: string | null; + scacCode: string | null; + status: string; + company: null; + profile: null; + review: null; +} + +/** `GET /companies/getInfo` serves both portal audiences. */ +export type AccountInfoResponse = CompanyInfoResponse | ShippingLineInfoResponse; + +export const isShippingLineAccount = ( + info: AccountInfoResponse | null | undefined, +): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line"; + /** A staged profile-edit review request (portal view). */ export interface ChangeRequestResponse { id: string; @@ -249,9 +284,9 @@ export interface DashboardSummary { } export const companiesService = { - getInfo: async (): Promise => { + getInfo: async (): Promise => { try { - const response = await client.get>( + const response = await client.get>( URL_CONSTANTS.COMPANIES_API.GET_INFO, ); return unwrap(response.data);