Merge pull request #899 from Tria-plc/freight/feat/fixes-v1

Freight/feat/fixes v1
This commit is contained in:
Nathnael Wondisha
2026-07-22 11:02:11 +03:00
committed by GitHub
41 changed files with 1491 additions and 639 deletions

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Drop the `active_profile_type` "active mode" column. A booking/contract now
* resolves its company_profile from the trade direction at creation time (with
* a forwarder passing an explicit companyProfileId), so no per-user active mode
* is stored. `onboarding_step` / `onboarding_completed` are unaffected.
*/
export class DropActiveProfileTypeFromExternalProfiles2450000000000
implements MigrationInterface
{
name = 'DropActiveProfileTypeFromExternalProfiles2450000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.external_profiles
DROP COLUMN IF EXISTS active_profile_type;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.external_profiles
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
`);
// Rebuild the mode the same way the original column was backfilled:
// importer first, then exporter, then whichever profile the company has.
await queryRunner.query(`
UPDATE freight.external_profiles ep
SET active_profile_type = cp.type
FROM (
SELECT DISTINCT ON (company_id) company_id, type
FROM freight.company_profiles
ORDER BY company_id,
CASE type
WHEN 'importer' THEN 0
WHEN 'exporter' THEN 1
ELSE 2
END
) cp
WHERE ep.company_id = cp.company_id
AND ep.active_profile_type IS NULL;
`);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface {
name = "AddCacBankPaymentMethod2460000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// The entity + frontend already list 'cac-bank' as a valid method, but the
// DB enum was never extended. Filtering payments by 'cac-bank' cast the
// literal to the enum and errored (invalid input value for enum). EDRFREIGHT-301.
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cac-bank';`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// PostgreSQL does not support removing enum values directly.
// To roll back, recreate the type without the added value and update the column.
}
}

View File

@@ -12,6 +12,7 @@ import {
RESET_LINK_TTL_MS, RESET_LINK_TTL_MS,
} from "./forgot-password.service"; } from "./forgot-password.service";
import { maskOtpTarget } from "./mask-target.util"; import { maskOtpTarget } from "./mask-target.util";
import { isDomesticPhone } from "../otp/otp.service";
/** The account a staff-triggered reset would land on. */ /** The account a staff-triggered reset would land on. */
export interface CustomerResetTarget { export interface CustomerResetTarget {
@@ -19,6 +20,12 @@ export interface CustomerResetTarget {
name: string; name: string;
email: string | null; email: string | null;
phone: string | null; phone: string | null;
/**
* Whether the SMS gateway (domestic-only) can reach `phone`. `null` when
* there is no phone. The backoffice uses this to disable the SMS channel for
* foreign numbers instead of sending a link that will never arrive.
*/
phoneIsDomestic: boolean | null;
} }
export interface SentResetLink { export interface SentResetLink {
@@ -58,6 +65,9 @@ export class CustomerResetService {
name: `${profile.firstName} ${profile.lastName}`.trim(), name: `${profile.firstName} ${profile.lastName}`.trim(),
email: user.email ?? null, email: user.email ?? null,
phone: user.phoneNumber ?? null, phone: user.phoneNumber ?? null,
phoneIsDomestic: user.phoneNumber
? isDomesticPhone(user.phoneNumber)
: null,
}; };
} }
@@ -80,6 +90,17 @@ export class CustomerResetService {
const target = this.forgotPasswordService.targetFor(user, channel); const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) return null; if (!target) return null;
// A foreign number is unreachable by the domestic-only SMS gateway — treat
// it like a missing phone rather than reporting "link sent" for a message
// that will never arrive. The backoffice disables the channel up front via
// `phoneIsDomestic`; this guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
return null;
}
// Mint first, send second: a failed send leaves an unused ticket that simply // Mint first, send second: a failed send leaves an unused ticket that simply
// expires, whereas sending a link before the ticket exists would hand the // expires, whereas sending a link before the ticket exists would hand the
// customer a URL that is dead on arrival. // customer a URL that is dead on arrival.

View File

@@ -12,7 +12,6 @@ import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference } from '@edr/api-common'; import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service'; // import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util'; import { eatDay } from '../train-scheduling/batch-window.util';
@@ -635,12 +634,9 @@ export class BookingsService {
); );
} }
const { company } = await this.companiesService.getCompanyInfoByUserId(userId); const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
// A customer can only book once their company has been approved. // A customer can only book once their company has been approved; the
if (company.status !== CompanyStatus.Active) { // helper names the real status (suspended/blacklisted) when it isn't.
throw new ForbiddenException( this.companiesService.assertCompanyActiveFor(company, 'bookings');
"Your company is awaiting approval — you can't create bookings yet.",
);
}
companyId = company.id; companyId = company.id;
} }
@@ -746,21 +742,13 @@ export class BookingsService {
); );
companyProfileId = profile.id; companyProfileId = profile.id;
} else if (companyId) { } else if (companyId) {
let fallbackType: ProfileType | null = null; // No explicit profile pin: resolve from the booking's trade direction
if (userId) { // (import→importer, export→exporter; otherwise the first profile). A
try { // forwarder booking sends dto.companyProfileId and takes the branch above.
const { profile } =
await this.companiesService.getCompanyInfoByUserId(userId);
fallbackType = profile.activeProfileType ?? null;
} catch {
// No profile (e.g. staff creating on behalf) — fall back to mapping.
}
}
companyProfileId = companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking( await this.companiesService.resolveCompanyProfileIdForBooking(
companyId, companyId,
tradeDirection, tradeDirection,
fallbackType,
); );
// A customer booking under their own account may only do so once the // A customer booking under their own account may only do so once the
@@ -1068,9 +1056,6 @@ export class BookingsService {
await this.companiesService.resolveCompanyProfileIdForBooking( await this.companiesService.resolveCompanyProfileIdForBooking(
existing.companyId, existing.companyId,
tradeDirection, tradeDirection,
existing.companyProfileId
? undefined
: (existing.companyProfile?.type as ProfileType | undefined),
); );
} }
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
@@ -1391,15 +1376,6 @@ export class BookingsService {
} }
} }
/**
* Resolve the active company_profile id a customer's bookings should be
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
* back to company-level scoping.
*/
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
return this.companiesService.resolveActiveCompanyProfileId(userId);
}
/** /**
* Authorize a customer's access to a single booking. Staff are scoped at the * Authorize a customer's access to a single booking. Staff are scoped at the
* controller (they pass `isStaff`); for a customer, the booking must belong * controller (they pass `isStaff`); for a customer, the booking must belong

View File

@@ -26,7 +26,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto";
@@ -353,21 +352,6 @@ export class CompaniesController {
return this.companiesService.removePoaDelegationLetter(user.id, fileId); return this.companiesService.removePoaDelegationLetter(user.id, fileId);
} }
@Patch("active-mode")
@ApiOperation({
summary: "Switch the current user's active operational mode (importer/exporter)",
})
async setActiveMode(
@CurrentUser() user: CurrentIamUser,
@Body() dto: SetActiveModeDto,
): Promise<CompanyInfoResponseDto> {
const { profile, company } = await this.companiesService.setActiveMode(
user.id,
dto.type,
);
return new CompanyInfoResponseDto(profile, company);
}
@Patch("onboarding-step") @Patch("onboarding-step")
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
AND ccr.deleted_at IS NULL AND ccr.deleted_at IS NULL
)`; )`;
/**
* The `sortBy = 'review'` queue ordering: whatever marketing must act on
* floats to the top. Tier 0 — submitted applications awaiting first approval
* (drafts excluded: nothing to review yet). Tier 1 — approved customers with
* a pending change request. Tier 2 — everyone else, drafts included.
*/
private static readonly REVIEW_TIER_SQL = `(CASE
WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0
WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1
ELSE 2
END)`;
constructor( constructor(
@InjectRepository(Company) @InjectRepository(Company)
repo: Repository<Company>, repo: Repository<Company>,
@@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
status, status,
onboardingCompleted, onboardingCompleted,
hasPendingChangeRequest, hasPendingChangeRequest,
sortBy = 'name', sortBy = 'review',
sortOrder = 'ASC', sortOrder = 'DESC',
} = query; } = query;
const qb = this.repository const qb = this.repository
@@ -137,8 +149,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
} }
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate. // sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
if (sortBy === 'review') {
// Queue ordering: actionable tiers first, newest first within each. The
// tier is selected under an alias because skip/take pagination with
// joins re-derives the ORDER BY in a subquery — a raw expression there
// breaks, a selected alias survives.
qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier')
.orderBy('review_tier', 'ASC')
.addOrderBy('company.createdAt', 'DESC');
} else {
qb.orderBy(`company.${sortBy}`, sortOrder);
}
const [items, total] = await qb const [items, total] = await qb
.orderBy(`company.${sortBy}`, sortOrder)
// Names are not unique and createdAt can tie on bulk imports; the id // Names are not unique and createdAt can tie on bulk imports; the id
// tiebreaker keeps paging stable instead of dropping/repeating rows. // tiebreaker keeps paging stable instead of dropping/repeating rows.
.addOrderBy('company.id', 'ASC') .addOrderBy('company.id', 'ASC')

View File

@@ -201,18 +201,6 @@ export class CompaniesService {
attributes: dto.attributes ?? null, attributes: dto.attributes ?? null,
}); });
// Default active mode from the chosen role(s): importer wins when both are
// picked, otherwise the first allowed type chosen.
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
const chosenTypes = (dto.companyProfiles ?? [])
.map((p) => p.type)
.filter((t) => allowedTypes.includes(t));
const activeProfileType =
chosenTypes.find((t) => t === ProfileType.importer) ??
chosenTypes[0] ??
allowedTypes[0] ??
null;
const profile = await this.profilesRepo.create({ const profile = await this.profilesRepo.create({
userId: identity.userId, userId: identity.userId,
companyId: company.id, companyId: company.id,
@@ -220,7 +208,6 @@ export class CompaniesService {
lastName: identity.lastName, lastName: identity.lastName,
jobTitle: dto.jobTitle ?? null, jobTitle: dto.jobTitle ?? null,
isPrimaryContact: dto.isPrimaryContact ?? true, isPrimaryContact: dto.isPrimaryContact ?? true,
activeProfileType,
onboardingStep: "company", onboardingStep: "company",
}); });
@@ -293,11 +280,6 @@ export class CompaniesService {
const allowedTypes = this.getProfileTypeForCompanyType(companyType); const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
const activeProfileType =
chosenTypes.find((t) => t === ProfileType.importer) ??
chosenTypes[0] ??
allowedTypes[0] ??
null;
const company = await this.companiesRepo.create({ const company = await this.companiesRepo.create({
name: identity.firstName name: identity.firstName
@@ -316,7 +298,6 @@ export class CompaniesService {
firstName: identity.firstName, firstName: identity.firstName,
lastName: identity.lastName, lastName: identity.lastName,
isPrimaryContact: true, isPrimaryContact: true,
activeProfileType,
onboardingStep: "company", onboardingStep: "company",
onboardingCompleted: false, onboardingCompleted: false,
}); });
@@ -1090,6 +1071,23 @@ export class CompaniesService {
if (!existing) if (!existing)
throw new NotFoundException(`Company profile ${profileId} not found`); throw new NotFoundException(`Company profile ${profileId} not found`);
// Suspension and reactivation must carry a staff explanation — the customer
// sees it, so "why" can never be left blank. Reactivation is the
// active-write that leaves Suspended; a first approval stays note-free.
const reactivating =
status === ProfileStatus.Active &&
existing.status === ProfileStatus.Suspended;
if (
(status === ProfileStatus.Suspended || reactivating) &&
!note?.trim()
) {
throw new BadRequestException(
status === ProfileStatus.Suspended
? "A message explaining the suspension is required — the customer will see it."
: "A message explaining the reactivation is required — the customer will see it.",
);
}
// A self-registered company is only reviewable once its owner submits the // A self-registered company is only reviewable once its owner submits the
// onboarding wizard (markOnboardingComplete) — until then its profiles are // onboarding wizard (markOnboardingComplete) — until then its profiles are
// half-filled drafts and approving one would mint a reference against an // half-filled drafts and approving one would mint a reference against an
@@ -1176,9 +1174,13 @@ export class CompaniesService {
); );
} }
// Track the review outcome. Rejection keeps the note so the customer knows // Track the review outcome. Rejection and suspension keep the note so the
// why; approval clears it. Any decision stamps the reviewer + time. // customer knows why; approval/reactivation clears it. Any decision stamps
if (status === ProfileStatus.Rejected) { // the reviewer + time.
if (
status === ProfileStatus.Rejected ||
status === ProfileStatus.Suspended
) {
patch.reviewNote = note ?? null; patch.reviewNote = note ?? null;
} else if (status === ProfileStatus.Active) { } else if (status === ProfileStatus.Active) {
patch.reviewNote = null; patch.reviewNote = null;
@@ -1192,23 +1194,50 @@ export class CompaniesService {
if (!updated) if (!updated)
throw new NotFoundException(`Company profile ${existing.id} not found`); throw new NotFoundException(`Company profile ${existing.id} not found`);
// Approving any profile promotes a pending company to active, so the // Every reviewed transition that changes what the customer can do is told
// customer can start working as soon as their first profile is cleared. // to them, carrying the staff message so they know why. Approval has no
if (status === ProfileStatus.Active) { // message (the note is cleared); the others require one.
const change =
status === ProfileStatus.Suspended
? "suspended"
: status === ProfileStatus.Rejected
? "rejected"
: status === ProfileStatus.Active
? existing.status === ProfileStatus.Suspended
? "reactivated"
: "approved"
: null;
if (change) {
const company = await this.companiesRepo.findById(updated.companyId); const company = await this.companiesRepo.findById(updated.companyId);
if (company && company.status === CompanyStatus.Pending) { if (company) {
await this.companiesRepo.update(updated.companyId, { this.companyNotifier.profileStatusChanged(
status: CompanyStatus.Active, company,
}); updated.type,
change,
note ?? "",
);
// The first approved role promotes a pending company to active — a
// bigger event (the account itself goes live), so tell them that too.
if (
status === ProfileStatus.Active &&
company.status === CompanyStatus.Pending
) {
await this.companiesRepo.update(updated.companyId, {
status: CompanyStatus.Active,
});
this.companyNotifier.companyApproved(company);
}
} }
} }
return updated; return updated;
} }
/** /**
* Customer reapplies for a rejected operational role (after fixing whatever the * Customer reapplies for a rejected or suspended operational role (after
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and * fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it
* clear the rejection note so it re-enters the approval queue. * back to Pending and clear the review note so it re-enters the approval
* queue. Suspension is a staff lockout, so resubmitting is an appeal — the
* backoffice still has to approve before the role goes live again.
*/ */
async reapplyCompanyProfile( async reapplyCompanyProfile(
userId: string, userId: string,
@@ -1223,9 +1252,12 @@ export class CompaniesService {
if (!target || target.companyId !== companyId) { if (!target || target.companyId !== companyId) {
throw new NotFoundException(`Company profile ${profileId} not found`); throw new NotFoundException(`Company profile ${profileId} not found`);
} }
if (target.status !== ProfileStatus.Rejected) { if (
target.status !== ProfileStatus.Rejected &&
target.status !== ProfileStatus.Suspended
) {
throw new BadRequestException( throw new BadRequestException(
"Only a rejected role can be resubmitted for approval", "Only a rejected or suspended role can be resubmitted for approval",
); );
} }
@@ -1349,10 +1381,9 @@ export class CompaniesService {
/** /**
* Create a single operational profile for the current user's company. The new * Create a single operational profile for the current user's company. The new
* role starts Pending, so it deliberately does NOT become the active mode: * role starts Pending and carries no reference until a backoffice reviewer
* switching onto an unapproved profile would strip the user of `canBook` and * approves it; a booking/contract resolves its profile from the trade
* block them from creating contracts under the role they already had approved. * direction at creation time, so no "active mode" is stored.
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
*/ */
async createCompanyProfileForUser( async createCompanyProfileForUser(
userId: string, userId: string,
@@ -1387,40 +1418,6 @@ export class CompaniesService {
return created; return created;
} }
/**
* Switch the user's active operational mode. The target profile must already
* exist — clients create it first via createCompanyProfileForUser.
*/
async setActiveMode(
userId: string,
type: ProfileType,
): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
const existing = await this.companyProfilesRepo.findByType(companyId, type);
if (!existing) {
throw new ConflictException(
`No ${type} profile exists yet — create it before switching`,
);
}
await this.profilesRepo.update(profile.id, { activeProfileType: type });
return this.getCompanyInfoByUserId(userId);
}
async setOnboardingStep(userId: string, step: string): Promise<void> { async setOnboardingStep(userId: string, step: string): Promise<void> {
const profile = await this.profilesRepo.findByUserId(userId); const profile = await this.profilesRepo.findByUserId(userId);
if (!profile) if (!profile)
@@ -1611,21 +1608,68 @@ export class CompaniesService {
} }
/** /**
* Block a customer from booking under a profile that isn't approved yet. * Block a self-service action when the company account isn't active, naming
* Called from the booking-create path for self-service bookings; staff- and * the actual status — a suspended customer told "awaiting approval" has no
* government-initiated bookings bypass this. No-op when the profile can't be * idea what happened or who to call.
* found (defensive — resolution is best-effort upstream). */
assertCompanyActiveFor(company: Company, action: string): void {
if (company.status === CompanyStatus.Active) return;
switch (company.status) {
case CompanyStatus.Suspended:
throw new ForbiddenException(
`Your company account is suspended — you can't create ${action} right now. ` +
`Please contact EDR support for details.`,
);
case CompanyStatus.Blacklisted:
throw new ForbiddenException(
`Your company account is blacklisted — you can't create ${action}. ` +
`Please contact EDR support.`,
);
default:
throw new ForbiddenException(
`Your company is awaiting approval — you can't create ${action} yet.`,
);
}
}
/**
* Block a customer from booking under a profile that isn't approved yet — or
* that a reviewer has since suspended. Called from the booking/contract
* create path for self-service actions; staff- and government-initiated ones
* bypass this. No-op when the profile can't be found (defensive — resolution
* is best-effort upstream). The message names the profile's real status:
* suspension in particular is per-role, so the customer must learn which
* operation is blocked (their other roles still work).
*/ */
async assertCompanyProfileApprovedForBooking( async assertCompanyProfileApprovedForBooking(
companyProfileId: string, companyProfileId: string,
): Promise<void> { ): Promise<void> {
const profile = await this.companyProfilesRepo.findById(companyProfileId); const profile = await this.companyProfilesRepo.findById(companyProfileId);
if (!profile) return; if (!profile) return;
if (profile.status !== ProfileStatus.Active) { if (profile.status === ProfileStatus.Active) return;
const role = profile.type.replace(/_/g, " ");
throw new ForbiddenException( const role = profile.type.replace(/_/g, " ");
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`, switch (profile.status) {
); case ProfileStatus.Suspended:
throw new ForbiddenException(
`Your ${role} role is suspended${
profile.reviewNote ? `${profile.reviewNote}` : ""
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
);
case ProfileStatus.Blacklisted:
throw new ForbiddenException(
`Your ${role} role is blacklisted. Please contact EDR support.`,
);
case ProfileStatus.Rejected:
throw new ForbiddenException(
`Your ${role} role was rejected${
profile.reviewNote ? `${profile.reviewNote}` : ""
}. Amend and resubmit it from your settings page.`,
);
default:
throw new ForbiddenException(
`Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`,
);
} }
} }
@@ -2244,15 +2288,14 @@ export class CompaniesService {
/** /**
* Resolve which company_profile a new booking belongs to, from the company * Resolve which company_profile a new booking belongs to, from the company
* and the booking's trade direction. IMPORT → importer profile, EXPORT → * and the booking's trade direction. IMPORT → importer profile, EXPORT →
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or * exporter profile; for DOMESTIC (or when the natural profile doesn't exist,
* when the natural profile doesn't exist) it falls back to the user's active * e.g. a freight forwarder) it falls back to the company's first profile.
* profile, then the company's first profile. Returns null when the company * Callers that need a specific role (a forwarder) pass an explicit
* has no profiles at all. * companyProfileId instead. Returns null when the company has no profiles.
*/ */
async resolveCompanyProfileIdForBooking( async resolveCompanyProfileIdForBooking(
companyId: string, companyId: string,
tradeDirection: string, tradeDirection: string,
fallbackType?: ProfileType | null,
): Promise<string | null> { ): Promise<string | null> {
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
if (profiles.length === 0) return null; if (profiles.length === 0) return null;
@@ -2264,30 +2307,12 @@ export class CompaniesService {
? ProfileType.exporter ? ProfileType.exporter
: null; : null;
const byType = (type?: ProfileType | null) => const match =
type ? profiles.find((p) => p.type === type) : undefined; (naturalType && profiles.find((p) => p.type === naturalType)) ??
profiles[0];
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
return match?.id ?? null; return match?.id ?? null;
} }
/**
* Resolve the company_profile a customer's data should be scoped to, from
* their persisted active mode. Returns null when nothing can be resolved
* (not onboarded yet) so callers can fall back to company-level scoping.
*/
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
try {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const type = profile.activeProfileType;
if (!type) return null;
const match = company.companyProfiles?.find((p) => p.type === type);
return match?.id ?? null;
} catch {
return null;
}
}
async fetchETradeData(tin: string) { async fetchETradeData(tin: string) {
const { businessInfo, companyInfo } = const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin); await this.etradeService.resolveCompanyData(tin);

View File

@@ -59,23 +59,106 @@ export class CompanyNotifierService {
} }
} }
/** SMS + email + in-app account-status item to the company contact. */
private notifyAccount(
company: Company,
title: string,
body: string,
link = "/settings",
): void {
void this.notifyContact(company, `${title}. ${body}`);
void this.inbox.notify({
recipients: { companyId: company.id },
audience: NotificationAudience.PORTAL,
type: NotificationType.ACCOUNT_STATUS,
title,
body,
link,
data: { companyId: company.id, status: company.status },
priority: NotificationPriority.HIGH,
});
}
/** /**
* Tell the customer their account was suspended or blacklisted. Called only on * Tell the customer their account changed status. Fires on the transitions
* a real transition into one of those statuses; other status writes are silent. * that change what they can do: suspended/blacklisted (locked out) and
* reactivated (back to Active from a lockout). Silent otherwise.
*/ */
statusChanged(company: Company, previous: CompanyStatus): void { statusChanged(company: Company, previous: CompanyStatus): void {
const status = company.status; const status = company.status;
if (status === previous) return; if (status === previous) return;
if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) {
this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`);
this.notifyAccount(
company,
"Account reactivated",
"Your company account has been reactivated. " +
"You can submit new contracts and bookings again.",
);
return;
}
if (!PUNITIVE_STATUSES.includes(status)) return; if (!PUNITIVE_STATUSES.includes(status)) return;
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted"; const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
const title = `Account ${label}`;
const body =
`Your company account has been ${label}. ` +
`You will not be able to submit new contracts or bookings. ` +
`Please contact EDR support for assistance.`;
this.logger.log(`ACCOUNT_${label.toUpperCase()}${company.id}`); this.logger.log(`ACCOUNT_${label.toUpperCase()}${company.id}`);
this.notifyAccount(
company,
`Account ${label}`,
`Your company account has been ${label}. ` +
`You will not be able to submit new contracts or bookings. ` +
`Please contact EDR support for assistance.`,
);
}
/**
* Tell the customer their company account was approved and is now live — the
* first operational role clearing review promotes a pending company to Active.
*/
companyApproved(company: Company): void {
this.logger.log(`ACCOUNT_APPROVED — ${company.id}`);
this.notifyAccount(
company,
"Account approved",
"Your company account has been approved and is now active. " +
"You can start submitting bookings and contracts.",
"/dashboard",
);
}
/**
* Tell the customer one of their operational roles changed review status —
* approved, rejected, suspended, or reactivated — quoting the staff message
* when one was given (rejection/suspension/reactivation require one; approval
* carries none).
*/
profileStatusChanged(
company: Company,
profileType: string,
change: "approved" | "rejected" | "suspended" | "reactivated",
staffMessage: string,
): void {
const title = `${profileType} role ${change}`;
const consequence: Record<typeof change, string> = {
approved: "You can now operate under this role.",
rejected:
"You will not be able to operate under this role. Amend the required " +
"documents and resubmit it for approval from your settings page.",
suspended:
"You will not be able to operate under this role until it is " +
"reactivated; your other roles are unaffected.",
reactivated: "You can operate under this role again.",
};
const message = staffMessage.trim();
const body =
`Your company's ${profileType} role has been ${change}. ` +
`${consequence[change]}` +
(message ? ` Message from EDR staff: ${message}` : "");
this.logger.log(
`PROFILE_${change.toUpperCase()}${company.id} / ${profileType}`,
);
void this.notifyContact(company, `${title}. ${body}`); void this.notifyContact(company, `${title}. ${body}`);
void this.inbox.notify({ void this.inbox.notify({
recipients: { companyId: company.id }, recipients: { companyId: company.id },
@@ -84,7 +167,7 @@ export class CompanyNotifierService {
title, title,
body, body,
link: "/settings", link: "/settings",
data: { companyId: company.id, status }, data: { companyId: company.id, profileType, change, staffMessage: message },
priority: NotificationPriority.HIGH, priority: NotificationPriority.HIGH,
}); });
} }

View File

@@ -24,7 +24,7 @@ export class CompanyInfoResponseDto {
company: Company, company: Company,
changeRequest?: CompanyChangeRequest | null, changeRequest?: CompanyChangeRequest | null,
) { ) {
this.profile = new ResponseExternalProfileDto(profile, company); this.profile = new ResponseExternalProfileDto(profile);
this.company = new ResponseCompanyDto(company); this.company = new ResponseCompanyDto(company);
const open = const open =

View File

@@ -60,15 +60,19 @@ export class ListCompaniesQueryDto {
hasPendingChangeRequest?: boolean; hasPendingChangeRequest?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: ["name", "createdAt", "updatedAt"], enum: ["review", "name", "createdAt", "updatedAt"],
default: "name", default: "review",
description: "Column to order by. Defaults to name for backwards compatibility.", description:
"Column to order by. The default `review` is a review-queue ordering: " +
"companies awaiting first approval, then those with a pending change " +
"request, then everyone else — newest first within each group. The " +
"other values are plain column sorts.",
}) })
@IsOptional() @IsOptional()
@IsIn(["name", "createdAt", "updatedAt"]) @IsIn(["review", "name", "createdAt", "updatedAt"])
sortBy?: "name" | "createdAt" | "updatedAt"; sortBy?: "review" | "name" | "createdAt" | "updatedAt";
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
@IsOptional() @IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) @Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["ASC", "DESC"]) @IsIn(["ASC", "DESC"])

View File

@@ -1,8 +1,6 @@
import { Company } from '../entities/company.entity';
import { import {
ExternalProfile, ExternalProfile,
} from '../entities/external-profile.entity'; } from '../entities/external-profile.entity';
import { ProfileType } from '../entities/company-profile.entity';
export class ResponseExternalProfileDto { export class ResponseExternalProfileDto {
id: string; id: string;
@@ -13,20 +11,12 @@ export class ResponseExternalProfileDto {
nationalId?: string | null; nationalId?: string | null;
jobTitle?: string | null; jobTitle?: string | null;
isPrimaryContact: boolean; isPrimaryContact: boolean;
/** The active operational mode (importer/exporter/forwarder). */
activeProfileType?: ProfileType | null;
/**
* The id of the company_profile matching activeProfileType, resolved
* server-side so the client never re-derives it. Null until a company
* (with profiles) is loaded and a matching profile exists.
*/
activeCompanyProfileId?: string | null;
onboardingStep?: string | null; onboardingStep?: string | null;
onboardingCompleted: boolean; onboardingCompleted: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
constructor(profile: ExternalProfile, company?: Company) { constructor(profile: ExternalProfile) {
this.id = profile.id; this.id = profile.id;
this.userId = profile.userId; this.userId = profile.userId;
this.companyId = profile.companyId; this.companyId = profile.companyId;
@@ -35,13 +25,8 @@ export class ResponseExternalProfileDto {
this.nationalId = profile.nationalId; this.nationalId = profile.nationalId;
this.jobTitle = profile.jobTitle; this.jobTitle = profile.jobTitle;
this.isPrimaryContact = profile.isPrimaryContact; this.isPrimaryContact = profile.isPrimaryContact;
this.activeProfileType = profile.activeProfileType ?? null;
this.onboardingStep = profile.onboardingStep ?? null; this.onboardingStep = profile.onboardingStep ?? null;
this.onboardingCompleted = profile.onboardingCompleted ?? false; this.onboardingCompleted = profile.onboardingCompleted ?? false;
this.activeCompanyProfileId =
company?.companyProfiles?.find(
(p) => p.type === profile.activeProfileType,
)?.id ?? null;
this.createdAt = profile.createdAt; this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt; this.updatedAt = profile.updatedAt;
} }

View File

@@ -1,7 +0,0 @@
import { IsEnum } from 'class-validator';
import { ProfileType } from '../entities/company-profile.entity';
export class SetActiveModeDto {
@IsEnum(ProfileType)
type!: ProfileType;
}

View File

@@ -1,7 +1,6 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm'; import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { Company } from './company.entity'; import { Company } from './company.entity';
import { ProfileType } from './company-profile.entity';
@Entity({ schema: 'freight', name: 'external_profiles' }) @Entity({ schema: 'freight', name: 'external_profiles' })
@Index(['userId']) @Index(['userId'])
@@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity {
@Column({ name: 'is_primary_contact', type: 'boolean', default: false }) @Column({ name: 'is_primary_contact', type: 'boolean', default: false })
isPrimaryContact!: boolean; isPrimaryContact!: boolean;
/**
* The operational profile the user is currently "in" (importer vs exporter,
* or the single forwarder profile). Drives header switching and scopes the
* customer's bookings / dashboard to that company_profile. Nullable for
* users who haven't picked a role yet.
*/
@Column({
name: 'active_profile_type',
type: 'varchar',
length: 32,
nullable: true,
enum: ProfileType,
})
activeProfileType?: ProfileType | null;
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */ /** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
@Column({ @Column({
name: 'onboarding_step', name: 'onboarding_step',

View File

@@ -12,8 +12,6 @@ import { YardCountry } from '@edr/types';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { Yard } from '../rule-engine/entities/yard.entity'; import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
@@ -181,11 +179,7 @@ export class ContractsService {
); );
} }
const { company } = await this.companiesService.getCompanyInfoByUserId(userId); const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
if (company.status !== CompanyStatus.Active) { this.companiesService.assertCompanyActiveFor(company, 'contracts');
throw new ForbiddenException(
"Your company is awaiting approval — you can't create contracts yet.",
);
}
companyId = company.id; companyId = company.id;
} }
@@ -193,31 +187,31 @@ export class ContractsService {
this.assertRouteShape(dto.contractKind, dto.routes); this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
// Stamp the operational profile (importer/exporter) for portal scoping. // Stamp the operational profile for portal scoping. A forwarder contract
// pins its profile explicitly (trade direction can't tell it apart from a
// direct import/export); everything else resolves from the trade direction.
let companyProfileId: string | null = null; let companyProfileId: string | null = null;
if (!isGovernment && companyId) { if (!isGovernment && companyId) {
let fallbackType: ProfileType | null = null; if (dto.companyProfileId) {
if (userId) { const profile =
try { await this.companiesService.getActiveCompanyProfileForBooking(
const { profile } = companyId,
await this.companiesService.getCompanyInfoByUserId(userId); dto.companyProfileId,
fallbackType = profile.activeProfileType ?? null; );
} catch { companyProfileId = profile.id;
// No profile (e.g. staff creating on behalf) — fall back to mapping. } else {
} companyProfileId =
} await this.companiesService.resolveCompanyProfileIdForBooking(
companyProfileId = companyId,
await this.companiesService.resolveCompanyProfileIdForBooking( dto.tradeDirection,
companyId, );
dto.tradeDirection,
fallbackType,
);
const customerSelfBooking = !dto.companyId && !!userId; const customerSelfBooking = !dto.companyId && !!userId;
if (customerSelfBooking && companyProfileId) { if (customerSelfBooking && companyProfileId) {
await this.companiesService.assertCompanyProfileApprovedForBooking( await this.companiesService.assertCompanyProfileApprovedForBooking(
companyProfileId, companyProfileId,
); );
}
} }
} }

View File

@@ -124,6 +124,16 @@ export class CreateContractDto {
@IsUUID() @IsUUID()
companyId?: string; companyId?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Explicit company profile to stamp the contract to (a forwarder contract); ' +
'commercial contracts otherwise auto-resolve from trade direction.',
})
@IsOptional()
@IsUUID()
companyProfileId?: string;
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' }) @ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
@IsIn([...CONTRACT_KINDS]) @IsIn([...CONTRACT_KINDS])
contractKind!: string; contractKind!: string;

View File

@@ -1,5 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { OtpController } from './otp.controller'; import { OtpController } from './otp.controller';
import { OtpService } from './otp.service';
describe('OtpController', () => { describe('OtpController', () => {
let controller: OtpController; let controller: OtpController;
@@ -7,6 +8,9 @@ describe('OtpController', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [OtpController], controllers: [OtpController],
providers: [
{ provide: OtpService, useValue: { send: jest.fn(), verify: jest.fn() } },
],
}).compile(); }).compile();
controller = module.get<OtpController>(OtpController); controller = module.get<OtpController>(OtpController);

View File

@@ -1,33 +1,46 @@
import { OtpService, normalizeOtpTarget } from './otp.service'; import { OtpService, isDomesticPhone, normalizeOtpTarget } from "./otp.service";
describe('normalizeOtpTarget', () => { describe("normalizeOtpTarget", () => {
it('canonicalises Ethiopian forms to one E.164 key', () => { it("canonicalises Ethiopian forms to one E.164 key", () => {
const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099']; const forms = [
"+251986680099",
"251986680099",
"0986680099",
"+251 98 668 0099",
];
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone); const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
expect(new Set(keys)).toEqual(new Set(['+251986680099'])); expect(new Set(keys)).toEqual(new Set(["+251986680099"]));
}); });
it('maps local 07… mobile to +2517…', () => { it("canonicalises email case and surrounding whitespace to one key", () => {
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678'); const forms = ["a@b.com", "A@B.com", " a@B.COM ", "A@b.COM"];
});
it('canonicalises email case and surrounding whitespace to one key', () => {
const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM'];
const keys = forms.map((email) => normalizeOtpTarget({ email }).email); const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
expect(new Set(keys)).toEqual(new Set(['a@b.com'])); expect(new Set(keys)).toEqual(new Set(["a@b.com"]));
}); });
it('keeps an already-normalised email stable (idempotent)', () => { it("keeps an already-normalised email stable (idempotent)", () => {
const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!; const once = normalizeOtpTarget({ email: " User@Example.COM " }).email!;
expect(normalizeOtpTarget({ email: once }).email).toBe(once); expect(normalizeOtpTarget({ email: once }).email).toBe(once);
}); });
it('keeps an already-normalised number stable (idempotent)', () => { it("keeps an already-normalised number stable (idempotent)", () => {
const once = normalizeOtpTarget({ phone: '0986680099' }).phone!; const once = normalizeOtpTarget({ phone: "0986680099" }).phone!;
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once); expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
}); });
}); });
describe("isDomesticPhone", () => {
it.each(["+251986680099", "0986680099", "251986680099"])(
"accepts Ethiopian mobile form %s",
(phone) => expect(isDomesticPhone(phone)).toBe(true),
);
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
"rejects non-domestic or malformed %s",
(phone) => expect(isDomesticPhone(phone)).toBe(false),
);
});
interface FakeRow { interface FakeRow {
id: string; id: string;
phone?: string; phone?: string;
@@ -51,7 +64,8 @@ function makeService(
let nextId = 1; let nextId = 1;
const matches = (row: FakeRow, t: { phone?: string; email?: string }) => const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
(!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone); (!!t.email && row.email === t.email) ||
(!!t.phone && row.phone === t.phone);
const repo = { const repo = {
findByTarget: jest.fn( findByTarget: jest.fn(
@@ -89,30 +103,30 @@ function makeService(
return { service, sms, email, rows: () => rows }; return { service, sms, email, rows: () => rows };
} }
describe('OtpService — send/verify agree across phone formats', () => { describe("OtpService — send/verify agree across phone formats", () => {
it('verifies a code sent to +251… when verify is called with 09…', async () => { it("verifies a code sent to +251… when verify is called with 09…", async () => {
const { service, rows } = makeService(); const { service, rows } = makeService();
await service.sendOtp({ phone: '+251986680099' }); await service.sendOtp({ phone: "+251986680099" });
await expect( await expect(
service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp), service.verifyOtpForAction({ phone: "0986680099" }, rows()[0]!.otp),
).resolves.toEqual({ success: true }); ).resolves.toEqual({ success: true });
}); });
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => { it("verifies a code sent to User@X.com when verify is called with user@x.com", async () => {
const { service, rows } = makeService(); const { service, rows } = makeService();
await service.sendOtp({ email: ' User@Example.COM ' }); await service.sendOtp({ email: " User@Example.COM " });
await expect( await expect(
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp), service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
).resolves.toEqual({ success: true }); ).resolves.toEqual({ success: true });
}); });
}); });
describe('OtpService — dual-channel send', () => { describe("OtpService — dual-channel send", () => {
const both = { phone: '0986680099', email: 'User@Example.COM' }; const both = { phone: "0986680099", email: "User@Example.COM" };
it('sends ONE code to both transports', async () => { it("sends ONE code to both transports", async () => {
const { service, sms, email, rows } = makeService(); const { service, sms, email, rows } = makeService();
await service.sendOtp(both); await service.sendOtp(both);
@@ -122,72 +136,95 @@ describe('OtpService — dual-channel send', () => {
// Same secret on both messages — the user types whichever arrives first. // Same secret on both messages — the user types whichever arrives first.
expect(sms.sendSms).toHaveBeenCalledWith( expect(sms.sendSms).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
to: '+251986680099', to: "+251986680099",
message: expect.stringContaining(otp), message: expect.stringContaining(otp),
}), }),
); );
expect(email.sendEmail).toHaveBeenCalledWith( expect(email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
to: 'user@example.com', to: "user@example.com",
text: expect.stringContaining(otp), text: expect.stringContaining(otp),
}), }),
); );
// One row, both channels canonicalised. // One row, both channels canonicalised.
expect(rows()).toHaveLength(1); expect(rows()).toHaveLength(1);
expect(rows()[0]).toMatchObject({ expect(rows()[0]).toMatchObject({
phone: '+251986680099', phone: "+251986680099",
email: 'user@example.com', email: "user@example.com",
}); });
}); });
it.each([ it.each([
['phone alone', { phone: '0986680099' }], ["phone alone", { phone: "0986680099" }],
['email alone', { email: 'user@example.com' }], ["email alone", { email: "user@example.com" }],
['both', both], ["both", both],
])('verifies a dual-channel code when quoted back by %s', async (_label, target) => { ])(
const { service, rows } = makeService(); "verifies a dual-channel code when quoted back by %s",
await service.sendOtp(both); async (_label, target) => {
const { service, rows } = makeService();
await service.sendOtp(both);
await expect( await expect(
service.verifyOtpForAction(target, rows()[0]!.otp), service.verifyOtpForAction(target, rows()[0]!.otp),
).resolves.toEqual({ success: true }); ).resolves.toEqual({ success: true });
}); },
);
it('consuming the code via one channel kills the other', async () => { it("consuming the code via one channel kills the other", async () => {
const { service, rows } = makeService(); const { service, rows } = makeService();
await service.sendOtp(both); await service.sendOtp(both);
const otp = rows()[0]!.otp; const otp = rows()[0]!.otp;
await service.verifyOtpForAction({ email: 'user@example.com' }, otp); await service.verifyOtpForAction({ email: "user@example.com" }, otp);
// Single-use is per-code, not per-channel: the phone half must be dead too. // Single-use is per-code, not per-channel: the phone half must be dead too.
await expect( await expect(
service.verifyOtpForAction({ phone: '0986680099' }, otp), service.verifyOtpForAction({ phone: "0986680099" }, otp),
).rejects.toThrow(/No verification code was requested/); ).rejects.toThrow(/No verification code was requested/);
}); });
it('replaces an overlapping single-channel row instead of colliding with it', async () => { it("replaces an overlapping single-channel row instead of colliding with it", async () => {
const { service, rows } = makeService(); const { service, rows } = makeService();
// A pending signup code on the phone only, then a dual-channel send. // A pending signup code on the phone only, then a dual-channel send.
await service.sendOtp({ phone: '0986680099' }); await service.sendOtp({ phone: "0986680099" });
await service.sendOtp(both); await service.sendOtp(both);
expect(rows()).toHaveLength(1); expect(rows()).toHaveLength(1);
expect(rows()[0]).toMatchObject({ email: 'user@example.com' }); expect(rows()[0]).toMatchObject({ email: "user@example.com" });
}); });
it('degrades to one channel when the account has only one contact', async () => { it("degrades to one channel when the account has only one contact", async () => {
const { service, sms, email } = makeService(); const { service, sms, email } = makeService();
await service.sendOtp({ phone: '0986680099' }); await service.sendOtp({ phone: "0986680099" });
expect(sms.sendSms).toHaveBeenCalledTimes(1); expect(sms.sendSms).toHaveBeenCalledTimes(1);
expect(email.sendEmail).not.toHaveBeenCalled(); expect(email.sendEmail).not.toHaveBeenCalled();
}); });
it('still succeeds when one transport throws', async () => { it("skips SMS for a foreign number when email is available", async () => {
const { service, sms, email, rows } = makeService();
await service.sendOtp({ phone: "+14155550123", email: "user@example.com" });
// The gateway is domestic-only — email is the delivery route, but the
// foreign phone stays on the row so verify still matches either channel.
expect(sms.sendSms).not.toHaveBeenCalled();
expect(email.sendEmail).toHaveBeenCalledTimes(1);
await expect(
service.verifyOtpForAction({ phone: "+14155550123" }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
it("still attempts SMS for a foreign number when it is the only channel", async () => {
const { service, sms } = makeService();
await service.sendOtp({ phone: "+14155550123" });
expect(sms.sendSms).toHaveBeenCalledTimes(1);
});
it("still succeeds when one transport throws", async () => {
const { service, rows } = makeService({ const { service, rows } = makeService({
sms: async () => { sms: async () => {
throw new Error('broker down'); throw new Error("broker down");
}, },
}); });
@@ -197,24 +234,24 @@ describe('OtpService — dual-channel send', () => {
}); });
// The code is live and verifiable on the channel that worked. // The code is live and verifiable on the channel that worked.
await expect( await expect(
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp), service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
).resolves.toEqual({ success: true }); ).resolves.toEqual({ success: true });
}); });
it('fails the request when every transport throws', async () => { it("fails the request when every transport throws", async () => {
const { service } = makeService({ const { service } = makeService({
sms: async () => { sms: async () => {
throw new Error('broker down'); throw new Error("broker down");
}, },
email: async () => { email: async () => {
throw new Error('broker down'); throw new Error("broker down");
}, },
}); });
await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP'); await expect(service.sendOtp(both)).rejects.toThrow("Failed to send OTP");
}); });
it('shares one brute-force budget across both channels', async () => { it("shares one brute-force budget across both channels", async () => {
const { service, rows } = makeService(); const { service, rows } = makeService();
await service.sendOtp(both); await service.sendOtp(both);
const otp = rows()[0]!.otp; const otp = rows()[0]!.otp;
@@ -222,17 +259,17 @@ describe('OtpService — dual-channel send', () => {
// Alternating channels must not hand the attacker two independent budgets: // Alternating channels must not hand the attacker two independent budgets:
// 5 wrong guesses in total burn the code regardless of how they are split. // 5 wrong guesses in total burn the code regardless of how they are split.
for (const target of [ for (const target of [
{ phone: '0986680099' }, { phone: "0986680099" },
{ email: 'user@example.com' }, { email: "user@example.com" },
{ phone: '0986680099' }, { phone: "0986680099" },
{ email: 'user@example.com' }, { email: "user@example.com" },
]) { ]) {
await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow( await expect(
'Invalid verification code', service.verifyOtpForAction(target, "000000"),
); ).rejects.toThrow("Invalid verification code");
} }
await expect( await expect(
service.verifyOtpForAction({ email: 'user@example.com' }, '000000'), service.verifyOtpForAction({ email: "user@example.com" }, "000000"),
).rejects.toThrow(/Too many incorrect attempts/); ).rejects.toThrow(/Too many incorrect attempts/);
// Burned: even the correct code no longer works. // Burned: even the correct code no longer works.

View File

@@ -36,9 +36,9 @@ function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
*/ */
function normalizePhone(rawPhone: string): string { function normalizePhone(rawPhone: string): string {
const raw = rawPhone.trim(); const raw = rawPhone.trim();
const digits = raw.replace(/[^\d+]/g, ''); const digits = raw.replace(/[^\d+]/g, "");
if (digits.startsWith('+')) return digits; if (digits.startsWith("+")) return digits;
const bare = digits.replace(/^0+/, ''); const bare = digits.replace(/^0+/, "");
if (/^251\d{9}$/.test(digits)) return `+${digits}`; if (/^251\d{9}$/.test(digits)) return `+${digits}`;
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`; if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
// Unknown shape (foreign number, already-clean intl without +) — prefix + if // Unknown shape (foreign number, already-clean intl without +) — prefix + if
@@ -46,6 +46,16 @@ function normalizePhone(rawPhone: string): string {
return digits.length >= 11 ? `+${digits}` : raw; return digits.length >= 11 ? `+${digits}` : raw;
} }
/**
* Whether a phone is an Ethiopian mobile the SMS gateway can actually reach —
* the carrier integration is domestic-only, so a send to anything else is
* queued and silently lost. Callers use this to fall back to email instead of
* pretending an SMS is on its way.
*/
export function isDomesticPhone(rawPhone: string): boolean {
return /^\+2519\d{8}$/.test(normalizePhone(rawPhone));
}
/** /**
* Canonicalise every channel present on the target. Each field is normalised * Canonicalise every channel present on the target. Each field is normalised
* independently — a dual-channel target must end up with both halves in their * independently — a dual-channel target must end up with both halves in their
@@ -143,6 +153,20 @@ export class OtpService {
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists // /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
// in the codebase yet. // in the codebase yet.
// A foreign number is unreachable by the domestic-only SMS gateway; when
// email is also on the target, go email-only rather than queueing an SMS
// that will never arrive. With no email the SMS attempt stays — it is the
// only route there is.
const smsPhone =
target.phone && (!target.email || isDomesticPhone(target.phone))
? target.phone
: null;
if (target.phone && !smsPhone) {
this.logger.warn(
`otp.dispatch.sms-skipped target=${label} — non-domestic phone, delivering via email only`,
);
}
// Fan out to every channel the target has, independently: one transport // Fan out to every channel the target has, independently: one transport
// being down must not suppress the other, which is the whole point of // being down must not suppress the other, which is the whole point of
// sending to both. Each helper swallows its own failure so a rejected // sending to both. Each helper swallows its own failure so a rejected
@@ -150,16 +174,14 @@ export class OtpService {
const outcomes = ( const outcomes = (
await Promise.all([ await Promise.all([
target.email ? this.dispatchEmail(target.email, otp) : null, target.email ? this.dispatchEmail(target.email, otp) : null,
target.phone ? this.dispatchSms(target.phone, otp) : null, smsPhone ? this.dispatchSms(smsPhone, otp) : null,
]) ])
).filter((outcome): outcome is DispatchOutcome => outcome !== null); ).filter((outcome): outcome is DispatchOutcome => outcome !== null);
for (const outcome of outcomes) { for (const outcome of outcomes) {
this.logger.log( this.logger.log(
`otp.dispatch channel=${outcome.channel} target=${label} queued=${ `otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
outcome.queued } latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : ""
} latencyMs=${Date.now() - startedAt}${
outcome.error ? ` error=${outcome.error}` : ""
}`, }`,
); );
} }
@@ -183,8 +205,7 @@ export class OtpService {
// user who never receives a code — indistinguishable from carrier loss, // user who never receives a code — indistinguishable from carrier loss,
// and the misleading success response makes it look like our side worked. // and the misleading success response makes it look like our side worked.
this.logger.error( this.logger.error(
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${ `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset"
process.env.RABBITMQ_ENABLED ?? "unset"
} — no transport reported hand-off; no code will arrive for this send`, } — no transport reported hand-off; no code will arrive for this send`,
); );
} }
@@ -209,8 +230,7 @@ export class OtpService {
// Log the real cause (DB/SMS/email failure) with its stack so a deployed // Log the real cause (DB/SMS/email failure) with its stack so a deployed
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
this.logger.error( this.logger.error(
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${ `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt
Date.now() - startedAt
}: ${error instanceof Error ? error.message : String(error)}`, }: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined, error instanceof Error ? error.stack : undefined,
); );
@@ -270,9 +290,7 @@ export class OtpService {
* address while printing the credential next to it would buy nothing. * address while printing the credential next to it would buy nothing.
*/ */
private targetLabel(target: OtpTarget): string { private targetLabel(target: OtpTarget): string {
return ( return [target.email, target.phone].filter(Boolean).join("+") || "unknown";
[target.email, target.phone].filter(Boolean).join("+") || "unknown"
);
} }
/** /**
@@ -288,9 +306,8 @@ export class OtpService {
) { ) {
const line = `otp.verify channels=${channelsOf(target).join( const line = `otp.verify channels=${channelsOf(target).join(
"+", "+",
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${ )} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : ""
detail ? ` ${detail}` : "" }`;
}`;
if (result === "ok") this.logger.log(line); if (result === "ok") this.logger.log(line);
else this.logger.warn(line); else this.logger.warn(line);
} }
@@ -439,7 +456,12 @@ export class OtpService {
await this.otpRepository.deleteOtp(otpData); await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key); this.actionAttempts.delete(key);
this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`); this.logVerify(
target,
"action",
"expired",
`ageMs=${ageMs} ttlMs=${ttlMs}`,
);
throw new BadRequestException( throw new BadRequestException(
"Verification code has expired. Request a new one.", "Verification code has expired. Request a new one.",
); );

View File

@@ -80,7 +80,12 @@ const makeBooking = (
describe('TrainSchedulingService', () => { describe('TrainSchedulingService', () => {
let service: TrainSchedulingService; let service: TrainSchedulingService;
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock }; let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
query: jest.Mock;
manager: { getRepository: jest.Mock };
};
let bookingsRepository: Record<string, jest.Mock>; let bookingsRepository: Record<string, jest.Mock>;
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock }; let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
let wagonTypesRepository: { findAll: jest.Mock }; let wagonTypesRepository: { findAll: jest.Mock };
@@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => {
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>; let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
beforeEach(() => { beforeEach(() => {
// findGroupSiblings runs a query builder off dataSource.manager; default it
// to "no sibling schedules" so isolated unit tests don't need to wire it.
const emptySiblingQb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
};
dataSource = { dataSource = {
getRepository: jest.fn(), getRepository: jest.fn(),
transaction: jest.fn(), transaction: jest.fn(),
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows". // Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
query: jest.fn().mockResolvedValue([]), query: jest.fn().mockResolvedValue([]),
manager: {
getRepository: jest.fn(() => ({
createQueryBuilder: jest.fn(() => emptySiblingQb),
})),
},
}; };
bookingsRepository = { bookingsRepository = {
findEligibleForScheduling: jest.fn(), findEligibleForScheduling: jest.fn(),
@@ -110,9 +127,11 @@ describe('TrainSchedulingService', () => {
findByIdWithFullGraph: jest.fn(), findByIdWithFullGraph: jest.fn(),
findAll: jest.fn(), findAll: jest.fn(),
updateStatus: jest.fn(), updateStatus: jest.fn(),
maxReferenceSequence: jest.fn().mockResolvedValue(0),
}; };
trainScheduleBookingsRepository = { trainScheduleBookingsRepository = {
findByBookingIds: jest.fn(), findByBookingIds: jest.fn(),
findByScheduleId: jest.fn().mockResolvedValue([]),
createMany: jest.fn(), createMany: jest.fn(),
deleteByScheduleAndBooking: jest.fn(), deleteByScheduleAndBooking: jest.fn(),
}; };
@@ -327,7 +346,11 @@ describe('TrainSchedulingService', () => {
}); });
it('allows preview when bookings are already on the target schedule', async () => { it('allows preview when bookings are already on the target schedule', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)]; // A booking already pinned to the target schedule is exempt from the
// corridor/day/status gates — mark it so on the entity, matching the link row.
const bookings = [
{ ...makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), trainScheduleId: 'sched-target' },
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]); wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
@@ -354,7 +377,10 @@ describe('TrainSchedulingService', () => {
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
}); });
it('allows preview when selected bookings are on different schedule dates', async () => { it('flags a booking scheduled for a different day than the train departure', async () => {
// The old cross-booking "must share the same schedule date" rule is gone;
// the live rule is that every booking must match the departure day. b2
// departs a day later, so it's the one flagged.
const bookings = [ const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'), makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'), makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
@@ -375,7 +401,8 @@ describe('TrainSchedulingService', () => {
expect(result.violations).not.toContain( expect(result.violations).not.toContain(
'Selected bookings must share the same schedule date', 'Selected bookings must share the same schedule date',
); );
expect(result.valid).toBe(true); expect(result.violations.some((v) => v.includes('different day'))).toBe(true);
expect(result.valid).toBe(false);
}); });
it('rejects bookings that are not in schedulable status', async () => { it('rejects bookings that are not in schedulable status', async () => {
@@ -408,6 +435,8 @@ describe('TrainSchedulingService', () => {
originYardId: 'yard-origin', originYardId: 'yard-origin',
destinationYardId: 'yard-destination', destinationYardId: 'yard-destination',
isActive: true, isActive: true,
status: 'AVAILABLE',
direction: 'IMPORT',
}; };
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' }; const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
@@ -421,6 +450,12 @@ describe('TrainSchedulingService', () => {
const trainScheduleRepo = { const trainScheduleRepo = {
create: jest.fn().mockImplementation((value) => value), create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
// findGroupWindowAnchor looks for same-day sibling schedules; none here.
createQueryBuilder: jest.fn(() => ({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
})),
}; };
const trainSetRepo = { const trainSetRepo = {
create: jest.fn().mockImplementation((value) => value), create: jest.fn().mockImplementation((value) => value),
@@ -464,19 +499,21 @@ describe('TrainSchedulingService', () => {
callback(manager), callback(manager),
); );
// Departure must clear the import lead window (≥ importWindowLeadDays ahead
// of now), so use a comfortably-future date rather than a hardcoded one.
const futureDeparture = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
const result = await service.createContainerTrainSchedule({ const result = await service.createContainerTrainSchedule({
routeId: 'route-1', routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z', scheduleDate: futureDeparture,
locomotiveIds: ['loc-1', 'loc-2'], locomotiveIds: ['loc-1', 'loc-2'],
}); });
expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled(); expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith( // Advance scheduling locks locomotives but does NOT flip them to ASSIGNED —
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) }, // one locomotive may sit on several future schedules.
{ status: 'ASSIGNED' }, expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled();
);
expect(result.id).toBe('schedule-1'); expect(result.id).toBe('schedule-1');
}); });
@@ -492,7 +529,7 @@ describe('TrainSchedulingService', () => {
destinationYardId: 'yard-destination', destinationYardId: 'yard-destination',
status: 'PAID', status: 'PAID',
bookingContainers: [], bookingContainers: [],
cargoType: { code: 'COFFEE' }, cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] },
}; };
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => { wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
@@ -511,7 +548,9 @@ describe('TrainSchedulingService', () => {
}); });
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
expect(result.summary.wagonType).toBe('MIXED'); // Mixed freight now labels the summary by the concrete wagon type codes it uses.
expect(result.summary.wagonType).toContain('NW5');
expect(result.summary.wagonType).toContain('CW3');
expect(result.wagonPlan.length).toBeGreaterThan(2); expect(result.wagonPlan.length).toBeGreaterThan(2);
expect(result.containerUnits).toHaveLength(2); expect(result.containerUnits).toHaveLength(2);
}); });
@@ -536,9 +575,11 @@ describe('TrainSchedulingService', () => {
}); });
it('rejects create when the locked locomotive is no longer available', async () => { it('rejects create when the locked locomotive is no longer available', async () => {
// Advance scheduling only hard-blocks OUT_OF_SERVICE locomotives; other
// non-AVAILABLE states (e.g. ASSIGNED) downgrade to a warning.
const manager = { const manager = {
getRepository: jest.fn(() => ({ getRepository: jest.fn(() => ({
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }),
})), })),
}; };
@@ -551,6 +592,8 @@ describe('TrainSchedulingService', () => {
originYardId: 'yard-origin', originYardId: 'yard-origin',
destinationYardId: 'yard-destination', destinationYardId: 'yard-destination',
isActive: true, isActive: true,
status: 'AVAILABLE',
direction: 'IMPORT',
}), }),
}; };
} }
@@ -907,7 +950,7 @@ describe('TrainSchedulingService', () => {
}); });
describe('getAvailableLocomotivesForRoute', () => { describe('getAvailableLocomotivesForRoute', () => {
it('returns locomotives at the route origin yard', async () => { it('returns every in-service locomotive, annotated with origin-yard presence', async () => {
const routeId = 'route-export'; const routeId = 'route-export';
const originYardId = 'yard-addis'; const originYardId = 'yard-addis';
const routeRepo = { const routeRepo = {
@@ -915,6 +958,7 @@ describe('TrainSchedulingService', () => {
id: routeId, id: routeId,
name: 'Addis → Djibouti', name: 'Addis → Djibouti',
isActive: true, isActive: true,
status: 'AVAILABLE',
originYardId, originYardId,
originYard: { country: 'Ethiopia' }, originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Djibouti' }, destinationYard: { country: 'Djibouti' },
@@ -924,21 +968,21 @@ describe('TrainSchedulingService', () => {
if ((entity as { name?: string })?.name === 'Route') return routeRepo; if ((entity as { name?: string })?.name === 'Route') return routeRepo;
return { findOne: jest.fn(), update: jest.fn() }; return { findOne: jest.fn(), update: jest.fn() };
}); });
// Advance-scheduling picker: nothing is filtered by yard — every in-service
// locomotive is returned and annotated with whether it's at the origin yet.
locomotivesRepository.findAll.mockResolvedValue([ locomotivesRepository.findAll.mockResolvedValue([
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
{ id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' },
]); ]);
const result = await service.getAvailableLocomotivesForRoute(routeId); const result = await service.getAvailableLocomotivesForRoute(routeId);
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({ expect(result).toHaveLength(2);
where: { status: 'AVAILABLE', currentYardId: originYardId }, expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true);
order: { code: 'ASC' }, expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false);
});
expect(result).toHaveLength(1);
expect(result[0].code).toBe('EXP');
}); });
it('returns all locomotives returned by the repository for domestic routes', async () => { it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => {
const routeId = 'route-domestic'; const routeId = 'route-domestic';
const originYardId = 'yard-addis'; const originYardId = 'yard-addis';
const routeRepo = { const routeRepo = {
@@ -946,6 +990,7 @@ describe('TrainSchedulingService', () => {
id: routeId, id: routeId,
name: 'Addis → Dire Dawa', name: 'Addis → Dire Dawa',
isActive: true, isActive: true,
status: 'AVAILABLE',
originYardId, originYardId,
originYard: { country: 'Ethiopia' }, originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Ethiopia' }, destinationYard: { country: 'Ethiopia' },
@@ -955,14 +1000,10 @@ describe('TrainSchedulingService', () => {
if ((entity as { name?: string })?.name === 'Route') return routeRepo; if ((entity as { name?: string })?.name === 'Route') return routeRepo;
return { findOne: jest.fn(), update: jest.fn() }; return { findOne: jest.fn(), update: jest.fn() };
}); });
locomotivesRepository.findAll.mockResolvedValue([
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId },
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
]);
const result = await service.getAvailableLocomotivesForRoute(routeId); await expect(
service.getAvailableLocomotivesForRoute(routeId),
expect(result).toHaveLength(2); ).rejects.toBeInstanceOf(BadRequestException);
}); });
}); });

View File

@@ -61,8 +61,11 @@ export default function ResetPasswordAction({
if (!allowed) return null; if (!allowed) return null;
// SMS is domestic-only: a foreign number counts as unavailable, same as a
// missing one, so staff can't send a link that will never arrive.
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
const channelMissing = const channelMissing =
!!target && (channel === "email" ? !target.email : !target.phone); !!target && (channel === "email" ? !target.email : !phoneUsable);
return ( return (
<> <>
@@ -106,9 +109,13 @@ export default function ResetPasswordAction({
<Radio <Radio
value="phone" value="phone"
label="SMS" label="SMS"
disabled={!target.phone} disabled={!phoneUsable}
description={ description={
target.phone ?? "No phone number on this account" !target.phone
? "No phone number on this account"
: target.phoneIsDomestic === false
? `${target.phone} — foreign number, SMS unavailable; use email`
: target.phone
} }
/> />
<Radio <Radio

View File

@@ -298,34 +298,82 @@ export function ProfileApprovalActions({
const { mutate, isPending } = useMutation( const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(), api.customers.setProfileStatus.mutationOptions(),
); );
const [rejectOpen, setRejectOpen] = useState(false); const [decision, setDecision] = useState<
"reject" | "suspend" | "reactivate" | null
>(null);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const act = (next: ProfileStatus) => mutate({ profileId, status: next }); const act = (next: ProfileStatus) => mutate({ profileId, status: next });
const confirmReject = () => { // Decisions the customer must be given a reason for. Reject/suspend/reactivate
// all capture a required message through the same modal; the API refuses
// suspend/reactivate without one.
const DECISIONS = {
reject: {
title: "Reject profile",
intro:
"Tell the customer what needs fixing. They'll see this note and can " +
"amend and resubmit the role for approval.",
label: "Reason for rejection",
placeholder: "e.g. The uploaded business license is expired.",
confirmLabel: "Reject profile",
color: "red",
status: "rejected" as ProfileStatus,
},
suspend: {
title: "Suspend role",
intro:
"Explain why this role is being suspended. The customer will see this " +
"message and cannot operate under the role until it is reactivated.",
label: "Reason for suspension",
placeholder: "e.g. Outstanding invoices unpaid for over 90 days.",
confirmLabel: "Suspend role",
color: "orange",
status: "suspended" as ProfileStatus,
},
reactivate: {
title: "Reactivate role",
intro:
"Explain why this role is being reactivated. The customer will see " +
"this message and can operate under the role again.",
label: "Reactivation message",
placeholder: "e.g. Outstanding payments have been settled.",
confirmLabel: "Reactivate role",
color: "edr-green",
status: "active" as ProfileStatus,
},
} as const;
const openDecision = (kind: keyof typeof DECISIONS) => {
setNote("");
setDecision(kind);
};
const active = decision ? DECISIONS[decision] : null;
const confirmDecision = () => {
if (!active) return;
mutate( mutate(
{ profileId, status: "rejected", note: note.trim() }, { profileId, status: active.status, note: note.trim() },
{ onSuccess: () => setRejectOpen(false) }, { onSuccess: () => setDecision(null) },
); );
}; };
const rejectModal = ( const decisionModal = active && (
<Modal <Modal
opened={rejectOpen} opened
onClose={() => setRejectOpen(false)} onClose={() => setDecision(null)}
title="Reject profile" title={active.title}
centered centered
radius="lg" radius="lg"
> >
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Tell the customer what needs fixing. They'll see this note and can {active.intro}
amend and resubmit the role for approval.
</Text> </Text>
<Textarea <Textarea
label="Reason for rejection" label={active.label}
placeholder="e.g. The uploaded business license is expired." placeholder={active.placeholder}
autosize autosize
minRows={3} minRows={3}
value={note} value={note}
@@ -335,18 +383,18 @@ export function ProfileApprovalActions({
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button <Button
variant="default" variant="default"
onClick={() => setRejectOpen(false)} onClick={() => setDecision(null)}
disabled={isPending} disabled={isPending}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
color="red" color={active.color}
loading={isPending} loading={isPending}
disabled={note.trim().length === 0} disabled={note.trim().length === 0}
onClick={confirmReject} onClick={confirmDecision}
> >
Reject profile {active.confirmLabel}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -368,7 +416,7 @@ export function ProfileApprovalActions({
if (status === "pending") { if (status === "pending") {
return ( return (
<> <>
{rejectModal} {decisionModal}
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<Button <Button
size="xs" size="xs"
@@ -385,7 +433,7 @@ export function ProfileApprovalActions({
variant="light" variant="light"
color="red" color="red"
radius="md" radius="md"
onClick={() => setRejectOpen(true)} onClick={() => openDecision("reject")}
> >
Reject Reject
</Button> </Button>
@@ -411,29 +459,33 @@ export function ProfileApprovalActions({
if (status === "active") { if (status === "active") {
return ( return (
<Button <>
size="xs" {decisionModal}
variant="light" <Button
color="orange" size="xs"
radius="md" variant="light"
loading={isPending} color="orange"
onClick={() => act("suspended")} radius="md"
> loading={isPending}
Suspend onClick={() => openDecision("suspend")}
</Button> >
Suspend
</Button>
</>
); );
} }
if (status === "suspended") { if (status === "suspended") {
return ( return (
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
{decisionModal}
<Button <Button
size="xs" size="xs"
variant="light" variant="light"
color="edr-green" color="edr-green"
radius="md" radius="md"
loading={isPending} loading={isPending}
onClick={() => act("active")} onClick={() => openDecision("reactivate")}
> >
Reactivate Reactivate
</Button> </Button>

View File

@@ -81,6 +81,10 @@ const VIEW_FILTERS: Record<
}; };
const SORT_OPTIONS = [ const SORT_OPTIONS = [
// Queue ordering: awaiting first approval → pending profile changes → the
// rest, newest first within each group. The default, so whatever marketing
// must act on is always on top of the list.
{ value: "review:DESC", label: "Needs review first" },
{ value: "createdAt:DESC", label: "Newest first" }, { value: "createdAt:DESC", label: "Newest first" },
{ value: "createdAt:ASC", label: "Oldest first" }, { value: "createdAt:ASC", label: "Oldest first" },
{ value: "name:ASC", label: "Name (AZ)" }, { value: "name:ASC", label: "Name (AZ)" },
@@ -93,11 +97,11 @@ export default function CustomersPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300); const [debouncedQuery] = useDebouncedValue(query, 300);
const [view, setView] = useState<CustomerView>("all"); const [view, setView] = useState<CustomerView>("all");
const [sort, setSort] = useState<string>("createdAt:DESC"); const [sort, setSort] = useState<string>("review:DESC");
const filter = useMemo(() => { const filter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [ const [sortBy, sortOrder] = sort.split(":") as [
"name" | "createdAt" | "updatedAt", "review" | "name" | "createdAt" | "updatedAt",
"ASC" | "DESC", "ASC" | "DESC",
]; ];
return { return {

View File

@@ -131,6 +131,8 @@ export interface CustomerResetTarget {
name: string; name: string;
email: string | null; email: string | null;
phone: string | null; phone: string | null;
/** SMS gateway is domestic-only; `false` means SMS can't reach this phone. `null` = no phone. */
phoneIsDomestic: boolean | null;
} }
/** Mirrors backend `Company` (+ its `companyProfiles`). */ /** Mirrors backend `Company` (+ its `companyProfiles`). */
@@ -207,7 +209,8 @@ export interface CompanyListFilter {
* already `active`, so `status` alone can never surface them. * already `active`, so `status` alone can never surface them.
*/ */
hasPendingChangeRequest?: boolean; hasPendingChangeRequest?: boolean;
sortBy?: "name" | "createdAt" | "updatedAt"; /** `review` = queue ordering: awaiting first approval → pending changes → rest, newest first within each. */
sortBy?: "review" | "name" | "createdAt" | "updatedAt";
sortOrder?: "ASC" | "DESC"; sortOrder?: "ASC" | "DESC";
} }

View File

@@ -220,8 +220,14 @@ const sidebarItems: SidebarItem[] = [
const App = () => { const App = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, company, companyType, createProfile, isAuthenticated } = const {
useAuth(); user,
company,
companyType,
createProfile,
reapplyProfile,
isAuthenticated,
} = useAuth();
// Attribute replays and exceptions to the signed-in user (id/org only). // Attribute replays and exceptions to the signed-in user (id/org only).
useIdentify(user, company); useIdentify(user, company);
@@ -290,6 +296,7 @@ const App = () => {
companyProfiles={companyProfiles} companyProfiles={companyProfiles}
companyType={companyType} companyType={companyType}
onCreateProfile={createProfile} onCreateProfile={createProfile}
onReapplyProfile={reapplyProfile}
> >
<OnboardingGate /> <OnboardingGate />
</AppLayout> </AppLayout>

View File

@@ -1,6 +1,8 @@
import { import {
Alert,
AppShell, AppShell,
Avatar, Avatar,
Badge,
Box, Box,
Button, Button,
Divider, Divider,
@@ -19,12 +21,14 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
Ban,
ChevronDown, ChevronDown,
FileSignature, FileSignature,
LogOut, LogOut,
Menu as MenuIcon, Menu as MenuIcon,
Moon, Moon,
Plus, Plus,
RefreshCw,
Search, Search,
Settings, Settings,
Sun, Sun,
@@ -59,7 +63,13 @@ export interface AppLayoutProps {
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */ /** Operational profiles for the company — surfaced as reference chips in the account menu. */
companyProfiles?: { type: string; reference: string; status?: string }[]; companyProfiles?: {
id?: string;
type: string;
reference: string;
status?: string;
reviewNote?: string | null;
}[];
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */ /** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
companyType?: string | null; companyType?: string | null;
/** Create a new service profile of the given type (with business license). */ /** Create a new service profile of the given type (with business license). */
@@ -67,6 +77,11 @@ export interface AppLayoutProps {
type: ServiceType, type: ServiceType,
licenseFiles: File[], licenseFiles: File[],
) => Promise<SwitchResult> | void; ) => Promise<SwitchResult> | void;
/** Resubmit a rejected service for approval, optionally replacing its license. */
onReapplyProfile?: (
profileId: string,
licenseFiles: File[],
) => Promise<SwitchResult> | void;
children: ReactNode; children: ReactNode;
} }
@@ -145,6 +160,7 @@ export function AppLayout({
companyProfiles = [], companyProfiles = [],
companyType, companyType,
onCreateProfile, onCreateProfile,
onReapplyProfile,
children, children,
}: AppLayoutProps) { }: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure(); const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
@@ -178,42 +194,86 @@ export function AppLayout({
const profileExists = (type: ServiceType) => const profileExists = (type: ServiceType) =>
companyProfiles.some((p) => p.type === type); companyProfiles.some((p) => p.type === type);
const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t)); const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t));
const canAddService = isCustomer && addableServices.length > 0; // Rejected services can't be re-added (they exist), so they'd otherwise be
// invisible here — surface them for resubmission alongside addable ones.
const rejectedServices = isCustomer
? companyProfiles.filter(
(p) =>
p.status === "rejected" &&
p.id &&
CUSTOMER_SERVICES.includes(p.type as ServiceType),
)
: [];
// Suspended services are also hidden by default (the profile exists) — surface
// them so the customer can appeal by resubmitting a fresh business license.
const suspendedServices = isCustomer
? companyProfiles.filter(
(p) =>
p.status === "suspended" &&
p.id &&
CUSTOMER_SERVICES.includes(p.type as ServiceType),
)
: [];
const canManageServices =
isCustomer &&
(addableServices.length > 0 ||
rejectedServices.length > 0 ||
suspendedServices.length > 0);
const [switching, setSwitching] = useState(false); const [switching, setSwitching] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createTarget, setCreateTarget] = useState<ServiceType>("importer"); const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
// Non-null while resubmitting a rejected/suspended service; null while creating a new one.
const [reapplyId, setReapplyId] = useState<string | null>(null);
// Status of the profile being resubmitted ("rejected" | "suspended") — drives
// the modal copy; null for a brand-new profile.
const [reapplyStatus, setReapplyStatus] = useState<string | null>(null);
// Reason the profile was suspended/rejected, surfaced in the modal.
const [reapplyNote, setReapplyNote] = useState<string | null>(null);
const [licenseFiles, setLicenseFiles] = useState<File[]>([]); const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const handleAddService = (type: ServiceType) => { const openServiceModal = (
// Collect a business license, then create the profile. type: ServiceType,
profile?: { id?: string; status?: string; reviewNote?: string | null },
) => {
setCreateTarget(type); setCreateTarget(type);
setReapplyId(profile?.id ?? null);
setReapplyStatus(profile?.status ?? null);
setReapplyNote(profile?.reviewNote ?? null);
setLicenseFiles([]); setLicenseFiles([]);
setCreateError(null); setCreateError(null);
setCreateOpen(true); setCreateOpen(true);
}; };
const handleAddService = (type: ServiceType) => openServiceModal(type);
const handleCreateConfirm = async () => { const handleCreateConfirm = async () => {
if (licenseFiles.length === 0) { const isReapply = reapplyId !== null;
// A new profile needs its license up front; a resubmit may reuse the old one.
if (!isReapply && licenseFiles.length === 0) {
setCreateError("Please upload at least one business license file."); setCreateError("Please upload at least one business license file.");
return; return;
} }
setSwitching(true); setSwitching(true);
setCreateError(null); setCreateError(null);
try { try {
const res = await onCreateProfile?.(createTarget, licenseFiles); const res = isReapply
? await onReapplyProfile?.(reapplyId, licenseFiles)
: await onCreateProfile?.(createTarget, licenseFiles);
if (res && !res.success) { if (res && !res.success) {
setCreateError(res.error?.message ?? "Failed to create profile"); setCreateError(res.error?.message ?? "Failed to submit service");
return; return;
} }
setCreateOpen(false); setCreateOpen(false);
setReapplyId(null);
} finally { } finally {
setSwitching(false); setSwitching(false);
} }
}; };
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m; const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
const isSuspendedAppeal = reapplyStatus === "suspended";
const isItemActive = (item: SidebarItem) => const isItemActive = (item: SidebarItem) =>
activePath === item.href.toLowerCase() || activePath === item.href.toLowerCase() ||
@@ -287,10 +347,10 @@ export function AppLayout({
{/* Right: switch + search + bell + avatar */} {/* Right: switch + search + bell + avatar */}
<Group gap={10} wrap="nowrap" align="center"> <Group gap={10} wrap="nowrap" align="center">
{/* Add a service (customer companies that don't yet have all three) */} {/* Add a service, or resubmit a rejected one (customer companies) */}
{canAddService && ( {canManageServices && (
<Menu <Menu
width={220} width={240}
position="bottom-end" position="bottom-end"
withinPortal withinPortal
shadow="md" shadow="md"
@@ -313,16 +373,62 @@ export function AppLayout({
</Button> </Button>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Label>Add a service</Menu.Label> {addableServices.length > 0 && (
{addableServices.map((type) => ( <>
<Menu.Item <Menu.Label>Add a service</Menu.Label>
key={type} {addableServices.map((type) => (
onClick={() => handleAddService(type)} <Menu.Item
leftSection={<Plus size={15} strokeWidth={1.8} />} key={type}
> onClick={() => handleAddService(type)}
{serviceLabel(type)} leftSection={<Plus size={15} strokeWidth={1.8} />}
</Menu.Item> >
))} {serviceLabel(type)}
</Menu.Item>
))}
</>
)}
{rejectedServices.length > 0 && (
<>
{addableServices.length > 0 && <Menu.Divider />}
<Menu.Label>Rejected resubmit</Menu.Label>
{rejectedServices.map((p) => (
<Menu.Item
key={p.id}
color="red"
onClick={() =>
openServiceModal(p.type as ServiceType, p)
}
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
>
{serviceLabel(p.type as ServiceType)}
</Menu.Item>
))}
</>
)}
{suspendedServices.length > 0 && (
<>
{(addableServices.length > 0 ||
rejectedServices.length > 0) && <Menu.Divider />}
<Menu.Label>Suspended appeal</Menu.Label>
{suspendedServices.map((p) => (
<Menu.Item
key={p.id}
color="orange"
onClick={() =>
openServiceModal(p.type as ServiceType, p)
}
leftSection={<Ban size={15} strokeWidth={1.8} />}
rightSection={
<Badge size="xs" color="orange" variant="light">
Suspended
</Badge>
}
>
{serviceLabel(p.type as ServiceType)}
</Menu.Item>
))}
</>
)}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
)} )}
@@ -790,18 +896,42 @@ export function AppLayout({
<Modal <Modal
opened={createOpen} opened={createOpen}
onClose={() => (switching ? undefined : setCreateOpen(false))} onClose={() => (switching ? undefined : setCreateOpen(false))}
title={`Set up your ${serviceLabel(createTarget)} profile`} title={
isSuspendedAppeal
? `Appeal suspension — ${serviceLabel(createTarget)}`
: reapplyId
? `Resubmit your ${serviceLabel(createTarget)} service`
: `Set up your ${serviceLabel(createTarget)} profile`
}
centered centered
radius="lg" radius="lg"
> >
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
You don't have a {serviceLabel(createTarget).toLowerCase()} profile {isSuspendedAppeal
yet. Add your business license to create one and switch to{" "} ? `Your ${serviceLabel(
{serviceLabel(createTarget).toLowerCase()}. createTarget,
).toLowerCase()} service is currently suspended. Replace the business license if needed and resubmit — this sends your appeal back to EDR for review.`
: reapplyId
? `Your ${serviceLabel(
createTarget,
).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.`
: `You don't have a ${serviceLabel(
createTarget,
).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`}
</Text> </Text>
{isSuspendedAppeal && reapplyNote && (
<Alert
color="orange"
variant="light"
icon={<Ban size={16} />}
title="Reason for suspension"
>
{reapplyNote}
</Alert>
)}
<FileInput <FileInput
label="Business license" label={reapplyId ? "Business license (optional)" : "Business license"}
multiple multiple
clearable clearable
accept="application/pdf,image/png,image/jpeg" accept="application/pdf,image/png,image/jpeg"
@@ -824,7 +954,11 @@ export function AppLayout({
onClick={handleCreateConfirm} onClick={handleCreateConfirm}
loading={switching} loading={switching}
> >
Create &amp; switch {isSuspendedAppeal
? "Submit appeal"
: reapplyId
? "Resubmit"
: "Create"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -11,22 +11,21 @@ interface NewBookingButtonProps {
/** /**
* New-booking entry point that respects approval status: a customer can only * New-booking entry point that respects approval status: a customer can only
* create bookings under a profile once the backoffice has approved it. While the * create bookings once the backoffice has approved at least one operational
* active profile is pending the button is disabled with an explanation, so the * role. While every role is still pending the button is disabled with an
* gate is communicated rather than silently failing at submit time. * explanation, so the gate is communicated rather than failing at submit time.
*/ */
export function NewBookingButton({ export function NewBookingButton({
label = "New booking", label = "New booking",
size, size,
mt, mt,
}: NewBookingButtonProps) { }: NewBookingButtonProps) {
const { canBook, activeProfileStatus } = useAuth(); const { canBook, hasPendingProfile } = useAuth();
if (!canBook) { if (!canBook) {
const message = const message = hasPendingProfile
activeProfileStatus === "pending" ? "Your role is awaiting approval. You'll be able to create bookings as soon as it's approved."
? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved." : "Bookings aren't available until one of your roles is approved.";
: "Bookings aren't available for this profile yet.";
return ( return (
<Tooltip label={message} multiline w={250} withArrow position="bottom-end"> <Tooltip label={message} multiline w={250} withArrow position="bottom-end">
<Box mt={mt}> <Box mt={mt}>

View File

@@ -99,7 +99,6 @@ export const URL_CONSTANTS = {
PROFILE: "/api/companies/profile", PROFILE: "/api/companies/profile",
COMPANY_PROFILES: "/api/companies/company-profiles", COMPANY_PROFILES: "/api/companies/company-profiles",
COMPANY_PROFILE: "/api/companies/company-profile", COMPANY_PROFILE: "/api/companies/company-profile",
ACTIVE_MODE: "/api/companies/active-mode",
ONBOARDING_START: "/api/companies/onboarding/start", ONBOARDING_START: "/api/companies/onboarding/start",
ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_STEP: "/api/companies/onboarding-step",
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",

View File

@@ -158,11 +158,7 @@ const useAuth = () => {
} }
}; };
// Active-mode (importer/exporter) state, sourced from the persisted profile.
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null; const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
const activeProfileType = companyInfo?.profile?.activeProfileType ?? null;
const activeCompanyProfileId =
companyInfo?.profile?.activeCompanyProfileId ?? null;
const companyType = companyInfo?.company?.type ?? null; const companyType = companyInfo?.company?.type ?? null;
const companyStatus = companyInfo?.company?.status ?? null; const companyStatus = companyInfo?.company?.status ?? null;
// A company can create bookings only once an admin has approved it (active). // A company can create bookings only once an admin has approved it (active).
@@ -171,14 +167,13 @@ const useAuth = () => {
companyInfo?.profile?.onboardingCompleted ?? false; companyInfo?.profile?.onboardingCompleted ?? false;
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
// Booking is gated on backoffice approval of the active operational profile: // A booking/contract stamps its operational profile from the trade direction
// a customer can only book under a profile once its status is "active". // at creation time, so there's no "active mode": the customer can create work
const activeProfile = // as long as they have at least one backoffice-approved operational role.
companyInfo?.company?.companyProfiles?.find( const companyProfiles = companyInfo?.company?.companyProfiles ?? [];
(p) => p.id === activeCompanyProfileId, const hasActiveProfile = companyProfiles.some((p) => p.status === "active");
) ?? null; const hasPendingProfile = companyProfiles.some((p) => p.status === "pending");
const activeProfileStatus = activeProfile?.status ?? null; const canBook = hasActiveProfile;
const canBook = activeProfileStatus === "active";
// Profile-edit review: while a change request is pending the customer is // Profile-edit review: while a change request is pending the customer is
// locked out of editing and of creating new contracts/bookings; a rejected // locked out of editing and of creating new contracts/bookings; a rejected
@@ -188,7 +183,7 @@ const useAuth = () => {
const reviewNote = review?.note ?? null; const reviewNote = review?.note ?? null;
const isUnderReview = reviewStatus === "pending"; const isUnderReview = reviewStatus === "pending";
/** Refetch everything scoped to the active operational profile. */ /** Refetch company info, dashboard, and bookings after a profile change. */
const invalidateScopedData = async () => { const invalidateScopedData = async () => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -201,16 +196,6 @@ const useAuth = () => {
]); ]);
}; };
const switchMode = async (type: ProfileTypeValue): Promise<Result<void>> => {
try {
await api.companies.setActiveMode.call({ type });
await invalidateScopedData();
return { success: true, data: undefined };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
/** /**
* Add an operational role. The new role starts pending review, so the active * Add an operational role. The new role starts pending review, so the active
* mode is left untouched — the user keeps working under their approved role. * mode is left untouched — the user keeps working under their approved role.
@@ -231,11 +216,18 @@ const useAuth = () => {
} }
}; };
/** Resubmit a rejected operational role for approval, then refresh. */ /**
* Resubmit a rejected operational role for approval — optionally replacing its
* business license first (the common reason a role is rejected) — then refresh.
*/
const reapplyProfile = async ( const reapplyProfile = async (
profileId: string, profileId: string,
licenseFiles: File[] = [],
): Promise<Result<void>> => { ): Promise<Result<void>> => {
try { try {
if (licenseFiles.length > 0) {
await companiesService.uploadProfileLicense(profileId, licenseFiles);
}
await api.companies.reapplyProfile.call({ profileId }); await api.companies.reapplyProfile.call({ profileId });
await invalidateScopedData(); await invalidateScopedData();
return { success: true, data: undefined }; return { success: true, data: undefined };
@@ -269,10 +261,9 @@ const useAuth = () => {
user: isAuthenticated ? (authQuery.data ?? null) : null, user: isAuthenticated ? (authQuery.data ?? null) : null,
company: isAuthenticated ? (companyQuery.data ?? null) : null, company: isAuthenticated ? (companyQuery.data ?? null) : null,
customer: isAuthenticated ? (companyQuery.data ?? null) : null, customer: isAuthenticated ? (companyQuery.data ?? null) : null,
activeProfileType,
activeCompanyProfileId,
activeProfileStatus,
canBook, canBook,
hasActiveProfile,
hasPendingProfile,
companyType, companyType,
companyStatus, companyStatus,
isCompanyApproved, isCompanyApproved,
@@ -281,7 +272,6 @@ const useAuth = () => {
isUnderReview, isUnderReview,
onboardingCompleted, onboardingCompleted,
onboardingStep, onboardingStep,
switchMode,
createProfile, createProfile,
reapplyProfile, reapplyProfile,
login, login,

View File

@@ -106,7 +106,6 @@ export function captureApiError(error: unknown): void {
/** Company context, as returned by `useAuth().company`. */ /** Company context, as returned by `useAuth().company`. */
interface IdentifyCompany { interface IdentifyCompany {
company?: { id?: string; type?: string | null; status?: string | null } | null; company?: { id?: string; type?: string | null; status?: string | null } | null;
profile?: { activeProfileType?: string | null } | null;
} }
/** /**
@@ -136,7 +135,6 @@ export function useIdentify(
company_id: company?.company?.id, company_id: company?.company?.id,
company_type: company?.company?.type, company_type: company?.company?.type,
company_status: company?.company?.status, company_status: company?.company?.status,
active_profile_type: company?.profile?.activeProfileType,
}); });
}, [ }, [
user?.id, user?.id,
@@ -146,6 +144,5 @@ export function useIdentify(
company?.company?.id, company?.company?.id,
company?.company?.type, company?.company?.type,
company?.company?.status, company?.company?.status,
company?.profile?.activeProfileType,
]); ]);
} }

View File

@@ -2,12 +2,15 @@ import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
import { import {
Alert, Alert,
Anchor,
Badge, Badge,
Box, Box,
Button,
Card, Card,
Center, Center,
Container, Container,
Fieldset, Fieldset,
FileButton,
Group, Group,
Loader, Loader,
Stack, Stack,
@@ -16,7 +19,7 @@ import {
ThemeIcon, ThemeIcon,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@@ -25,13 +28,20 @@ import {
Building2, Building2,
Clock, Clock,
FileCheck, FileCheck,
FileText,
Globe, Globe,
Layers,
RefreshCw,
ShieldCheck, ShieldCheck,
UploadCloud,
User, User,
UserCheck, UserCheck,
UserCog, UserCog,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect } from "react"; import { companiesService } from "@/services/companies.service";
import { fetchViewableFile } from "@/services/files.service";
import { useFileViewer } from "@edr/ui-common";
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import { rolesForCompanyType } from "./settings/companyRoles"; import { rolesForCompanyType } from "./settings/companyRoles";
@@ -42,13 +52,16 @@ import TabDocuments from "./settings/TabDocuments";
import TabGeneralManager from "./settings/TabGeneralManager"; import TabGeneralManager from "./settings/TabGeneralManager";
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
type SettingsTab = "account" | "company" | "contact" | "gm" | "poa" | "documents"; type SettingsTab =
| "account"
| "company"
| "contact"
| "gm"
| "poa"
| "documents";
/** A section is "incomplete" when its required fields aren't filled in yet. */ /** A section is "incomplete" when its required fields aren't filled in yet. */
function tabIncomplete( function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
tabId: SettingsTab,
profile: ProfileResponse,
): boolean {
switch (tabId) { switch (tabId) {
case "company": case "company":
return ( return (
@@ -66,8 +79,8 @@ function tabIncomplete(
!profile.generalManagerPhone !profile.generalManagerPhone
); );
case "account": case "account":
// Account fields live on the IAM user, not the company profile, and are // Account fields live on the IAM user, not the company profile, and are
// always populated (signup requires them) — nothing to nag about here. // always populated (signup requires them) — nothing to nag about here.
case "poa": case "poa":
case "documents": case "documents":
return false; return false;
@@ -343,6 +356,7 @@ export default function SettingsPage() {
<Fieldset disabled={locked} variant="unstyled" p={0}> <Fieldset disabled={locked} variant="unstyled" p={0}>
<TabCompanyProfile mode="edit" profile={profile} /> <TabCompanyProfile mode="edit" profile={profile} />
</Fieldset> </Fieldset>
<OperationalServicesCard profile={profile} />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="contact"> <Tabs.Panel value="contact">
<Fieldset disabled={locked} variant="unstyled" p={0}> <Fieldset disabled={locked} variant="unstyled" p={0}>
@@ -369,3 +383,203 @@ export default function SettingsPage() {
</Container> </Container>
); );
} }
const ROLE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
const ROLE_STATUS: Record<string, { color: string; label: string }> = {
active: { color: "edr-green", label: "Approved" },
pending: { color: "yellow", label: "Awaiting approval" },
rejected: { color: "red", label: "Rejected" },
suspended: { color: "orange", label: "Suspended" },
blacklisted: { color: "red", label: "Blocked" },
};
/**
* Lists the company's operational services with approval status, and lets the
* customer resubmit a rejected one — replacing its license first if the reviewer
* flagged the document.
*/
function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
const queryClient = useQueryClient();
const { view, viewer } = useFileViewer();
const roles = profile.companyProfiles;
const refresh = () =>
Promise.all([
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
}),
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
}),
]);
// Resubmit: upload any freshly-picked license files first, then flip the role
// back to pending so it re-enters the approval queue.
const resubmit = useMutation({
mutationFn: async (args: { profileId: string; files: File[] }) => {
if (args.files.length > 0) {
await companiesService.uploadProfileLicense(args.profileId, args.files);
}
await api.companies.reapplyProfile.call({ profileId: args.profileId });
},
onSuccess: refresh,
});
if (roles.length === 0) return null;
return (
<>
<Card padding="lg" radius="lg" mt="lg">
<Group gap="sm" mb="md">
<Layers size={20} />
<Title order={3}>Operational Services</Title>
</Group>
<Stack gap="sm">
{roles.map((r) => {
const status = ROLE_STATUS[r.status] ?? {
color: "gray",
label: r.status,
};
return (
<Group
key={r.id}
justify="space-between"
align="flex-start"
wrap="nowrap"
py="xs"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={4}>
<Group gap="xs">
<Text fw={600}>{ROLE_LABELS[r.type] ?? r.type}</Text>
<Badge color={status.color} variant="light" radius="sm">
{status.label}
</Badge>
{r.reference && (
<Text size="xs" c="dimmed" ff="monospace">
{r.reference}
</Text>
)}
</Group>
{(r.status === "rejected" || r.status === "suspended") &&
r.reviewNote && (
<Text
size="sm"
c={r.status === "suspended" ? "orange.7" : "red.7"}
>
<strong>
{r.status === "suspended"
? "Suspension reason:"
: "Reviewer note:"}
</strong>{" "}
{r.reviewNote}
</Text>
)}
{r.licenseFiles.length === 0 ? (
<Text size="xs" c="edr-muted">
No license document
</Text>
) : (
<Stack gap={4}>
{r.licenseFiles.map((f) => (
<Group key={f.id} gap="xs" wrap="nowrap">
<FileText
size={14}
className="text-edr-muted"
style={{ flexShrink: 0 }}
/>
<Anchor
component="button"
type="button"
size="xs"
lineClamp={1}
onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
>
{f.name}
</Anchor>
{f.status !== "live" && (
<Badge
size="xs"
radius="sm"
variant="light"
color={
f.status === "pending_remove" ? "red" : "yellow"
}
>
{f.status === "pending_remove"
? "Removal pending"
: "Pending"}
</Badge>
)}
</Group>
))}
</Stack>
)}
</Stack>
{r.status === "rejected" && (
<ResubmitService
pending={resubmit.isPending}
onResubmit={(files) =>
resubmit.mutate({ profileId: r.id, files })
}
/>
)}
</Group>
);
})}
</Stack>
</Card>
{viewer}
</>
);
}
/** Rejected-role actions: optionally replace the license, then resubmit. */
function ResubmitService({
pending,
onResubmit,
}: {
pending: boolean;
onResubmit: (files: File[]) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
return (
<Group gap="xs" wrap="nowrap">
<FileButton onChange={setFiles} accept="application/pdf,image/*" multiple>
{(props) => (
<Button
{...props}
size="xs"
variant="light"
leftSection={<UploadCloud size={14} />}
>
{files.length > 0 ? `${files.length} file(s)` : "Replace license"}
</Button>
)}
</FileButton>
<Button
size="xs"
color="edr-green"
leftSection={<RefreshCw size={14} />}
loading={pending}
onClick={() => onResubmit(files)}
>
Resubmit
</Button>
</Group>
);
}

View File

@@ -211,10 +211,6 @@ export function BookingPaymentPanel({
queryKey: ["booking-invoices", booking.id], queryKey: ["booking-invoices", booking.id],
queryFn: () => invoicesService.listForSource("booking", booking.id), queryFn: () => invoicesService.listForSource("booking", booking.id),
}); });
// The invoice worth a prominent "Download" — the first issued one, else any.
const primary =
invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0];
const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false;
const downloadInvoice = async (inv: PortalInvoice) => { const downloadInvoice = async (inv: PortalInvoice) => {
try { try {
@@ -380,50 +376,67 @@ export function BookingPaymentPanel({
</Box> </Box>
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap">
<InvoiceStatusBadge status={inv.status} /> <InvoiceStatusBadge status={inv.status} />
<ActionIcon {invoices.length > 1 && (
variant="subtle" <>
color="gray" <ActionIcon
aria-label="Download invoice" variant="subtle"
onClick={() => downloadInvoice(inv)} color="gray"
> aria-label="Download invoice"
<Download size={16} /> onClick={() => downloadInvoice(inv)}
</ActionIcon> >
<Download size={16} />
</ActionIcon>
{Number(inv.paidAmount) > 0 && (
<ActionIcon
variant="subtle"
color="gray"
aria-label="Download receipt"
onClick={() => downloadReceipt(inv)}
>
<Receipt size={16} />
</ActionIcon>
)}
</>
)}
</Group> </Group>
</Group> </Group>
))} ))}
</Stack> </Stack>
</>
)}
{primary && ( {/* Single invoice: a prominent download instead of a lone row icon. */}
<Button {invoices.length === 1 && (
fullWidth <>
mt={16} <Button
variant="default" fullWidth
radius={10} mt={16}
leftSection={<FileText size={17} color="#475569" />} variant="default"
onClick={() => downloadInvoice(primary)} radius={10}
styles={{ leftSection={<FileText size={17} color="#475569" />}
root: { height: 46 }, onClick={() => downloadInvoice(invoices[0])}
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" }, styles={{
}} root: { height: 46 },
> label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
Download invoice }}
</Button> >
)} Download invoice
{primary && primaryPaid && ( </Button>
<Button {Number(invoices[0].paidAmount) > 0 && (
fullWidth <Button
mt={8} fullWidth
variant="subtle" mt={8}
color="gray" variant="subtle"
radius={10} color="gray"
leftSection={<Receipt size={17} />} radius={10}
onClick={() => downloadReceipt(primary)} leftSection={<Receipt size={17} />}
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }} onClick={() => downloadReceipt(invoices[0])}
> styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
Download receipt >
</Button> Download receipt
</Button>
)}
</>
)}
</>
)} )}
</SectionCard> </SectionCard>
); );

View File

@@ -1,6 +1,6 @@
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core"; import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, Download, Receipt } from "lucide-react"; import { CreditCard, Download, FileText, Receipt } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -184,23 +184,27 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
Pay Pay
</Button> </Button>
)} )}
<ActionIcon {invoices.length > 1 && (
variant="subtle" <>
color="gray" <ActionIcon
aria-label="Download invoice" variant="subtle"
onClick={() => download(inv)} color="gray"
> aria-label="Download invoice"
<Download size={16} /> onClick={() => download(inv)}
</ActionIcon> >
{Number(inv.paidAmount) > 0 && ( <Download size={16} />
<ActionIcon </ActionIcon>
variant="subtle" {Number(inv.paidAmount) > 0 && (
color="gray" <ActionIcon
aria-label="Download receipt" variant="subtle"
onClick={() => downloadReceipt(inv)} color="gray"
> aria-label="Download receipt"
<Receipt size={16} /> onClick={() => downloadReceipt(inv)}
</ActionIcon> >
<Receipt size={16} />
</ActionIcon>
)}
</>
)} )}
</Group> </Group>
</Group> </Group>
@@ -208,6 +212,40 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
})} })}
</Stack> </Stack>
{/* Single invoice: a prominent download instead of a lone row icon. */}
{invoices.length === 1 && (
<>
<Button
fullWidth
mt="md"
variant="default"
radius={10}
leftSection={<FileText size={17} color="#475569" />}
onClick={() => download(invoices[0])}
styles={{
root: { height: 46 },
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
}}
>
Download invoice
</Button>
{Number(invoices[0].paidAmount) > 0 && (
<Button
fullWidth
mt={8}
variant="subtle"
color="gray"
radius={10}
leftSection={<Receipt size={17} />}
onClick={() => downloadReceipt(invoices[0])}
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
>
Download receipt
</Button>
)}
</>
)}
<PaymentMethodModal <PaymentMethodModal
opened={payInvoice !== null} opened={payInvoice !== null}
onClose={closePayModal} onClose={closePayModal}

View File

@@ -38,6 +38,7 @@ import {
bookingFormSchema, bookingFormSchema,
getRouteDirection, getRouteDirection,
initialBookingFormValues, initialBookingFormValues,
isForwarderOperation,
operationToProfileType, operationToProfileType,
operationToTradeDirection, operationToTradeDirection,
stepFields, stepFields,
@@ -384,25 +385,23 @@ export default function NewBookingPage() {
[profileTypes], [profileTypes],
); );
// Stamp the booking to the right operational profile. Import/Export (and their // The company_profile this booking belongs to, derived from the selected
// "as FF" variants) switch the active mode so the matching onboarding documents // operation: import→importer, export→exporter, and the "as FF" variants
// are attached; Intercity uses whatever profile is already active. // freight_forwarder. A forwarder booking is pinned explicitly on submit
const handleOperationSelect = (op: OperationType) => { // (companyProfileId) because trade direction alone can't distinguish it.
if (op === "intercity") return; const selectedProfile = useMemo(() => {
const target = operationToProfileType(op, profileTypes);
if (auth.activeProfileType !== target) {
void auth.switchMode(target as never);
}
};
// Onboarding documents for the active profile — shown read-only in the
// Documents step and attached to the booking on submit by the backend.
const onboardingDocs = useMemo(() => {
const profiles = auth.company?.company?.companyProfiles ?? []; const profiles = auth.company?.company?.companyProfiles ?? [];
const active = if (!operationType) return profiles[0] ?? null;
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0]; const targetType = operationToProfileType(operationType, profileTypes);
return active?.licenseFiles ?? []; return profiles.find((p) => p.type === targetType) ?? profiles[0] ?? null;
}, [auth.company, auth.activeCompanyProfileId]); }, [auth.company, operationType, profileTypes]);
// Onboarding documents for the resolved profile — shown read-only in the
// Documents step and attached to the booking on submit by the backend.
const onboardingDocs = useMemo(
() => selectedProfile?.licenseFiles ?? [],
[selectedProfile],
);
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>( const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
null, null,
@@ -483,7 +482,16 @@ export default function NewBookingPage() {
(s) => s.id === data.serviceTypeId, (s) => s.id === data.serviceTypeId,
)!; )!;
// Pin the profile only for a forwarder booking — trade direction resolves
// importer/exporter on its own, but can't tell a forwarder apart.
const forwarderProfileId =
data.operationType &&
isForwarderOperation(data.operationType, profileTypes)
? selectedProfile?.id
: undefined;
return { return {
...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}),
bookingType: isContract bookingType: isContract
? Freight.BookingType.GeneralContract ? Freight.BookingType.GeneralContract
: Freight.BookingType.OneTime, : Freight.BookingType.OneTime,
@@ -711,7 +719,6 @@ export default function NewBookingPage() {
<Step0OperationType <Step0OperationType
form={form} form={form}
allowedOperations={allowedOperations} allowedOperations={allowedOperations}
onSelect={handleOperationSelect}
/> />
)} )}
{step === 1 && ( {step === 1 && (

View File

@@ -7,6 +7,7 @@ import { type UseFormReturn } from "react-hook-form";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { import {
operationToProfileType,
type BookingDocuments, type BookingDocuments,
type BookingFormInputValues, type BookingFormInputValues,
type BookingFormValues, type BookingFormValues,
@@ -58,11 +59,20 @@ export function StepDocuments({ form }: { form: BookingForm }) {
}), }),
); );
// Documents already on file from onboarding (read-only reference). // Documents already on file from onboarding (read-only reference), for the
// profile this booking's operation resolves to (importer/exporter/forwarder).
const onboardingDocs = (() => { const onboardingDocs = (() => {
const profiles = auth.company?.company?.companyProfiles ?? []; const profiles = auth.company?.company?.companyProfiles ?? [];
const operationType = form.watch("operationType");
const targetType = operationType
? operationToProfileType(
operationType,
profiles.map((p) => p.type),
)
: null;
const active = const active =
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0]; (targetType ? profiles.find((p) => p.type === targetType) : undefined) ??
profiles[0];
return active?.licenseFiles ?? []; return active?.licenseFiles ?? [];
})(); })();

View File

@@ -49,6 +49,7 @@ import {
} from "./new-contract-form/schema"; } from "./new-contract-form/schema";
import { import {
getRouteDirection, getRouteDirection,
isForwarderOperation,
operationToProfileType, operationToProfileType,
operationToTradeDirection, operationToTradeDirection,
} from "./new-contract-form/helpers"; } from "./new-contract-form/helpers";
@@ -124,6 +125,30 @@ export default function NewContractPage({
); );
} }
// A suspended/blacklisted account must be told its real status — falling
// through to the wizard here only to fail at submit read as "pending" before.
if (
!auth.isPending &&
(auth.companyStatus === "suspended" || auth.companyStatus === "blacklisted")
) {
return (
<GateNotice
title={
auth.companyStatus === "suspended"
? "Account Suspended"
: "Account Blacklisted"
}
body={
auth.companyStatus === "suspended"
? "Your company account has been suspended by EDR staff, so new contracts are disabled. Please contact EDR support for details."
: "Your company account has been blacklisted, so new contracts are disabled. Please contact EDR support."
}
actionLabel="Back to Contracts"
onAction={() => navigate("/contracts")}
/>
);
}
// Profile changes pending review lock out new contract creation too. // Profile changes pending review lock out new contract creation too.
if (!auth.isPending && auth.isUnderReview) { if (!auth.isPending && auth.isUnderReview) {
return ( return (
@@ -349,13 +374,19 @@ export default function NewContractPage({
// badges. Intercity rides any customer profile, so always "approved". // badges. Intercity rides any customer profile, so always "approved".
const operationStatus = useMemo( const operationStatus = useMemo(
() => () =>
(op: OperationType): "approved" | "pending" | "rejected" | "missing" => { (
op: OperationType,
): "approved" | "pending" | "rejected" | "suspended" | "missing" => {
if (op === "intercity") return "approved"; if (op === "intercity") return "approved";
const target = operationToProfileType(op, profileTypes); const target = operationToProfileType(op, profileTypes);
const status = profileStatusByType.get(target); const status = profileStatusByType.get(target);
if (!status) return "missing"; if (!status) return "missing";
if (status === "active") return "approved"; if (status === "active") return "approved";
if (status === "rejected") return "rejected"; if (status === "rejected") return "rejected";
// Suspension is per-role: the customer keeps working under their other
// roles, so this operation must say "suspended", not "pending".
if (status === "suspended" || status === "blacklisted")
return "suspended";
return "pending"; return "pending";
}, },
[profileStatusByType, profileTypes], [profileStatusByType, profileTypes],
@@ -411,7 +442,9 @@ export default function NewContractPage({
mutationFn: async (profileId: string) => { mutationFn: async (profileId: string) => {
const res = await auth.reapplyProfile(profileId); const res = await auth.reapplyProfile(profileId);
if (!res.success) { if (!res.success) {
throw new Error(res.error?.message ?? "Failed to resubmit for approval"); throw new Error(
res.error?.message ?? "Failed to resubmit for approval",
);
} }
}, },
onSuccess: () => setPendingApprovalProfile(null), onSuccess: () => setPendingApprovalProfile(null),
@@ -439,10 +472,8 @@ export default function NewContractPage({
form.setValue("operationType", undefined as never, { shouldDirty: true }); form.setValue("operationType", undefined as never, { shouldDirty: true });
return; return;
} }
// Case 1 — approved: proceed, switching the active profile if needed. // Case 1 — approved: proceed. The profile is resolved from the operation at
if (auth.activeProfileType !== target) { // submit time (a forwarder operation pins it explicitly), so nothing to set.
void auth.switchMode(target as never);
}
}; };
const handleCreateProfileConfirm = () => { const handleCreateProfileConfirm = () => {
@@ -512,16 +543,16 @@ export default function NewContractPage({
// GENERAL contract until its validity expires. // GENERAL contract until its validity expires.
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
? data.enabledContainerSizes.map((size) => ({ ? data.enabledContainerSizes.map((size) => ({
containerSize: size, containerSize: size,
// Required cargo description — what the containers carry. // Required cargo description — what the containers carry.
cargoFreeText: data.cargoFreeText.trim() || undefined, cargoFreeText: data.cargoFreeText.trim() || undefined,
})) }))
: [ : [
{ {
cargoTypeId: data.cargoTypePath?.[1] || undefined, cargoTypeId: data.cargoTypePath?.[1] || undefined,
cargoFreeText: data.cargoFreeText || undefined, cargoFreeText: data.cargoFreeText || undefined,
}, },
]; ];
// Route — a single origin→destination lane, general contracts included. // Route — a single origin→destination lane, general contracts included.
const routes: Freight.CreateContractRouteInputDto[] = [ const routes: Freight.CreateContractRouteInputDto[] = [
@@ -532,7 +563,20 @@ export default function NewContractPage({
}, },
]; ];
// Pin the profile only for a forwarder contract (trade direction can't tell
// a forwarder apart from a direct import/export).
const forwarderProfileId =
data.operationType &&
isForwarderOperation(data.operationType, profileTypes)
? (auth.company?.company?.companyProfiles ?? []).find(
(p) =>
p.type ===
operationToProfileType(data.operationType!, profileTypes),
)?.id
: undefined;
return { return {
...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}),
contractKind: isGeneral contractKind: isGeneral
? Freight.ContractKind.General ? Freight.ContractKind.General
: Freight.ContractKind.OneTime, : Freight.ContractKind.OneTime,
@@ -547,11 +591,11 @@ export default function NewContractPage({
// booking time, but only on contracts created WITH_RETURN. // booking time, but only on contracts created WITH_RETURN.
...(isContainer ...(isContainer
? { ? {
equipmentReturn: equipmentReturn:
data.equipmentReturn === "with_return" data.equipmentReturn === "with_return"
? "WITH_RETURN" ? "WITH_RETURN"
: "WITHOUT_RETURN", : "WITHOUT_RETURN",
} }
: {}), : {}),
isHazardous: data.isHazardous, isHazardous: data.isHazardous,
// Reefer is a contract-level flag for both container and bulk. // Reefer is a contract-level flag for both container and bulk.
@@ -580,7 +624,8 @@ export default function NewContractPage({
? { customsClearingEnabled: true } ? { customsClearingEnabled: true }
: { : {
customsClearingEnabled: false, customsClearingEnabled: false,
customsClearingAgent: data.customsClearingAgent?.trim() || undefined, customsClearingAgent:
data.customsClearingAgent?.trim() || undefined,
}), }),
cargoScope, cargoScope,
routes, routes,
@@ -695,10 +740,7 @@ export default function NewContractPage({
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
What the reviewer asked for: What the reviewer asked for:
</Text> </Text>
<Text <Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
size="sm"
style={{ whiteSpace: "pre-wrap" }}
>
{editContract.latestChangeRequestNote} {editContract.latestChangeRequestNote}
</Text> </Text>
<Text size="sm" c="dimmed" mt={2}> <Text size="sm" c="dimmed" mt={2}>
@@ -1166,6 +1208,8 @@ export default function NewContractPage({
? profileByType.get(pendingApprovalProfile) ? profileByType.get(pendingApprovalProfile)
: undefined; : undefined;
const isRejected = target?.status === "rejected"; const isRejected = target?.status === "rejected";
const isSuspended =
target?.status === "suspended" || target?.status === "blacklisted";
const label = pendingApprovalProfile const label = pendingApprovalProfile
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ?? ? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
pendingApprovalProfile) pendingApprovalProfile)
@@ -1174,12 +1218,42 @@ export default function NewContractPage({
<Modal <Modal
opened={pendingApprovalProfile !== null} opened={pendingApprovalProfile !== null}
onClose={() => setPendingApprovalProfile(null)} onClose={() => setPendingApprovalProfile(null)}
title={isRejected ? "Profile not approved" : "Awaiting approval"} title={
isRejected
? "Profile not approved"
: isSuspended
? "Role suspended"
: "Awaiting approval"
}
centered centered
radius="lg" radius="lg"
> >
<Stack gap="md"> <Stack gap="md">
{isRejected ? ( {isSuspended ? (
<>
<Text size="sm" c="dimmed">
Your {label} role has been suspended by EDR staff, so you
can&apos;t start a contract under it. Your other roles are
unaffected. Contact EDR support to resolve this.
</Text>
{target?.reviewNote && (
<Alert color="orange" variant="light" radius="md">
<Text size="sm">
<strong>Message from EDR staff:</strong>{" "}
{target.reviewNote}
</Text>
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
color="edr-green"
onClick={() => setPendingApprovalProfile(null)}
>
OK
</Button>
</Group>
</>
) : isRejected ? (
<> <>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Your {label} profile was not approved. Fix the issue below Your {label} profile was not approved. Fix the issue below
@@ -1215,7 +1289,8 @@ export default function NewContractPage({
<> <>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Your {label} profile was submitted and is under staff Your {label} profile was submitted and is under staff
review. You can start a contract under it once it's approved. review. You can start a contract under it once it's
approved.
</Text> </Text>
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button <Button

View File

@@ -12,6 +12,7 @@ import {
type ContractFormInputValues, type ContractFormInputValues,
type ContractFormValues, type ContractFormValues,
} from "./schema"; } from "./schema";
import { operationToProfileType } from "./helpers";
import { StepCard, StepHeader } from "./shared"; import { StepCard, StepHeader } from "./shared";
type ContractForm = UseFormReturn< type ContractForm = UseFormReturn<
@@ -73,8 +74,16 @@ export function StepDocuments({
const onboardingDocs = (() => { const onboardingDocs = (() => {
const profiles = auth.company?.company?.companyProfiles ?? []; const profiles = auth.company?.company?.companyProfiles ?? [];
const operationType = form.watch("operationType");
const targetType = operationType
? operationToProfileType(
operationType,
profiles.map((p) => p.type),
)
: null;
const active = const active =
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0]; (targetType ? profiles.find((p) => p.type === targetType) : undefined) ??
profiles[0];
return active?.licenseFiles ?? []; return active?.licenseFiles ?? [];
})(); })();

View File

@@ -45,7 +45,7 @@ export function Step1ContractType({
/** Approval state of the profile each operation maps to (for the badges). */ /** Approval state of the profile each operation maps to (for the badges). */
operationStatus?: ( operationStatus?: (
op: OperationType, op: OperationType,
) => "approved" | "pending" | "rejected" | "missing"; ) => "approved" | "pending" | "rejected" | "suspended" | "missing";
}) { }) {
const contractType = form.watch("contractType"); const contractType = form.watch("contractType");
@@ -229,6 +229,11 @@ export function Step1ContractType({
Rejected Rejected
</Badge> </Badge>
)} )}
{status === "suspended" && (
<Badge size="xs" color="orange" variant="light" radius="sm">
Suspended
</Badge>
)}
{status === "missing" && ( {status === "missing" && (
<Badge size="xs" color="gray" variant="light" radius="sm"> <Badge size="xs" color="gray" variant="light" radius="sm">
Add license Add license

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react"; import { Building2, CheckCircle2, RefreshCw, Save, XCircle } from "lucide-react";
import { import {
Button, Button,
Card, Card,
@@ -87,6 +87,22 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
}, },
}); });
// Resubmit a rejected role for review. Flips it back to Pending server-side
// and pings the backoffice, so the fix-and-resubmit loop can happen entirely
// from settings instead of only from the contract page's rejection banner.
const reapplyMutation = useMutation({
mutationFn: (profileId: string) =>
api.companies.reapplyProfile.call({ profileId }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
},
});
const handleSave = () => { const handleSave = () => {
if (selected.size === 0) return; if (selected.size === 0) return;
mutation.mutate(Array.from(selected)); mutation.mutate(Array.from(selected));
@@ -113,6 +129,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
{options.map((opt) => { {options.map((opt) => {
const existing = profileByType.get(opt.type); const existing = profileByType.get(opt.type);
const view = existing ? roleStatusView(existing) : undefined; const view = existing ? roleStatusView(existing) : undefined;
const rejected = existing?.status === "rejected";
return ( return (
<RoleCard <RoleCard
key={opt.type} key={opt.type}
@@ -124,6 +141,28 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
approved={view?.approved} approved={view?.approved}
lockedNote={view?.note} lockedNote={view?.note}
lockedNoteColor={view?.color} lockedNoteColor={view?.color}
detail={
(rejected || existing?.status === "suspended") &&
existing?.reviewNote
? `Reviewer note: ${existing.reviewNote}`
: undefined
}
action={
rejected ? (
<Button
size="xs"
variant="light"
leftSection={<RefreshCw size={14} />}
loading={
reapplyMutation.isPending &&
reapplyMutation.variables === existing.id
}
onClick={() => reapplyMutation.mutate(existing.id)}
>
Resubmit for approval
</Button>
) : undefined
}
onClick={() => toggle(opt.type)} onClick={() => toggle(opt.type)}
/> />
); );

View File

@@ -18,6 +18,14 @@ export interface RoleCardProps {
lockedNote?: string; lockedNote?: string;
/** Mantine color for {@link lockedNote}; matches the role's status. */ /** Mantine color for {@link lockedNote}; matches the role's status. */
lockedNoteColor?: string; lockedNoteColor?: string;
/** Extra muted line under {@link lockedNote}, e.g. the reviewer's note. */
detail?: string;
/**
* Interactive content (e.g. a resubmit button) rendered inside the card.
* Only honoured on a locked card — the interactive variant is itself a
* button, and buttons cannot nest.
*/
action?: React.ReactNode;
onClick?: () => void; onClick?: () => void;
} }
@@ -35,54 +43,70 @@ export default function RoleCard({
approved = false, approved = false,
lockedNote, lockedNote,
lockedNoteColor = "edr-green", lockedNoteColor = "edr-green",
detail,
action,
onClick, onClick,
}: RoleCardProps) { }: RoleCardProps) {
const highlighted = selected || approved; const highlighted = selected || approved;
return ( const className = `group block rounded-lg border! p-5! text-left transition-all duration-200 ${
<UnstyledButton highlighted
type="button" ? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
onClick={locked ? undefined : onClick} : "border-edr-border! bg-edr-card!"
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${ } ${
highlighted locked
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!" ? "cursor-default"
: "border-edr-border! bg-edr-card!" : "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
} ${ }`;
locked
? "cursor-default" const content = (
: "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft" <Group gap="md" wrap="nowrap" align="start">
}`} <ThemeIcon
> size={56}
<Group gap="md" wrap="nowrap" align="start"> radius="lg"
<ThemeIcon variant={highlighted ? "filled" : "light"}
size={56} color="edr-green"
radius="lg" className="shrink-0"
variant={highlighted ? "filled" : "light"} >
color="edr-green" {icon}
className="shrink-0" </ThemeIcon>
> <Box className="min-w-0 flex-1">
{icon} <Text fw={700} c="edr-text" fz={15}>
</ThemeIcon> {label}
<Box className="min-w-0 flex-1"> </Text>
<Text fw={700} c="edr-text" fz={15}> <Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{label} {description}
</Text>
{lockedNote && (
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
{lockedNote}
</Text> </Text>
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{description}
</Text>
{lockedNote && (
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
{lockedNote}
</Text>
)}
</Box>
{highlighted && (
<Check
size={18}
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
/>
)} )}
</Group> {locked && detail && (
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{detail}
</Text>
)}
{locked && action && <Box mt="sm">{action}</Box>}
</Box>
{highlighted && (
<Check
size={18}
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
/>
)}
</Group>
);
// A locked card is display-only, so it renders as a plain box — which also
// lets `action` hold real buttons without nesting them inside a button.
if (locked) {
return <Box className={className}>{content}</Box>;
}
return (
<UnstyledButton type="button" onClick={onClick} className={className}>
{content}
</UnstyledButton> </UnstyledButton>
); );
} }

View File

@@ -240,12 +240,6 @@ export const api = {
CompanyInfoResponse CompanyInfoResponse
>("companies", "startOnboarding", companiesService.startOnboarding), >("companies", "startOnboarding", companiesService.startOnboarding),
setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>(
"companies",
"setActiveMode",
companiesService.setActiveMode,
),
setOnboardingStep: endpoint<{ step: string }, void>( setOnboardingStep: endpoint<{ step: string }, void>(
"companies", "companies",
"setOnboardingStep", "setOnboardingStep",

View File

@@ -45,10 +45,6 @@ export interface ExternalProfileResponse {
nationalId: string | null; nationalId: string | null;
jobTitle: string | null; jobTitle: string | null;
isPrimaryContact: boolean; isPrimaryContact: boolean;
/** The active operational mode (importer/exporter/forwarder). */
activeProfileType: ProfileTypeValue | null;
/** Id of the company_profile matching activeProfileType (server-resolved). */
activeCompanyProfileId: string | null;
onboardingStep: string | null; onboardingStep: string | null;
onboardingCompleted: boolean; onboardingCompleted: boolean;
createdAt: string; createdAt: string;
@@ -323,17 +319,6 @@ export const companiesService = {
return unwrap(response.data); return unwrap(response.data);
}, },
/** Switch the active operational mode (target profile must already exist). */
setActiveMode: async (payload: {
type: ProfileTypeValue;
}): Promise<CompanyInfoResponse> => {
const response = await client.patch<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE,
payload,
);
return unwrap(response.data);
},
setOnboardingStep: async (payload: { step: string }): Promise<void> => { setOnboardingStep: async (payload: { step: string }): Promise<void> => {
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload); await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
}, },