mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
Merge pull request #899 from Tria-plc/freight/feat/fixes-v1
Freight/feat/fixes v1
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
RESET_LINK_TTL_MS,
|
||||
} from "./forgot-password.service";
|
||||
import { maskOtpTarget } from "./mask-target.util";
|
||||
import { isDomesticPhone } from "../otp/otp.service";
|
||||
|
||||
/** The account a staff-triggered reset would land on. */
|
||||
export interface CustomerResetTarget {
|
||||
@@ -19,6 +20,12 @@ export interface CustomerResetTarget {
|
||||
name: string;
|
||||
email: 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 {
|
||||
@@ -58,6 +65,9 @@ export class CustomerResetService {
|
||||
name: `${profile.firstName} ${profile.lastName}`.trim(),
|
||||
email: user.email ?? 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);
|
||||
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
|
||||
// expires, whereas sending a link before the ticket exists would hand the
|
||||
// customer a URL that is dead on arrival.
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
@@ -635,12 +634,9 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
// A customer can only book once their company has been approved.
|
||||
if (company.status !== CompanyStatus.Active) {
|
||||
throw new ForbiddenException(
|
||||
"Your company is awaiting approval — you can't create bookings yet.",
|
||||
);
|
||||
}
|
||||
// A customer can only book once their company has been approved; the
|
||||
// helper names the real status (suspended/blacklisted) when it isn't.
|
||||
this.companiesService.assertCompanyActiveFor(company, 'bookings');
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
@@ -746,21 +742,13 @@ export class BookingsService {
|
||||
);
|
||||
companyProfileId = profile.id;
|
||||
} else if (companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
const { profile } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
fallbackType = profile.activeProfileType ?? null;
|
||||
} catch {
|
||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
||||
}
|
||||
}
|
||||
// No explicit profile pin: resolve from the booking's trade direction
|
||||
// (import→importer, export→exporter; otherwise the first profile). A
|
||||
// forwarder booking sends dto.companyProfileId and takes the branch above.
|
||||
companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
tradeDirection,
|
||||
fallbackType,
|
||||
);
|
||||
|
||||
// A customer booking under their own account may only do so once the
|
||||
@@ -1068,9 +1056,6 @@ export class BookingsService {
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
existing.companyId,
|
||||
tradeDirection,
|
||||
existing.companyProfileId
|
||||
? undefined
|
||||
: (existing.companyProfile?.type as ProfileType | undefined),
|
||||
);
|
||||
}
|
||||
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
|
||||
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||
|
||||
@@ -26,7 +26,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.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 { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||
@@ -353,21 +352,6 @@ export class CompaniesController {
|
||||
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")
|
||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
|
||||
@@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
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(
|
||||
@InjectRepository(Company)
|
||||
repo: Repository<Company>,
|
||||
@@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
status,
|
||||
onboardingCompleted,
|
||||
hasPendingChangeRequest,
|
||||
sortBy = 'name',
|
||||
sortOrder = 'ASC',
|
||||
sortBy = 'review',
|
||||
sortOrder = 'DESC',
|
||||
} = query;
|
||||
|
||||
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.
|
||||
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
|
||||
.orderBy(`company.${sortBy}`, sortOrder)
|
||||
// Names are not unique and createdAt can tie on bulk imports; the id
|
||||
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
||||
.addOrderBy('company.id', 'ASC')
|
||||
|
||||
@@ -201,18 +201,6 @@ export class CompaniesService {
|
||||
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({
|
||||
userId: identity.userId,
|
||||
companyId: company.id,
|
||||
@@ -220,7 +208,6 @@ export class CompaniesService {
|
||||
lastName: identity.lastName,
|
||||
jobTitle: dto.jobTitle ?? null,
|
||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||
activeProfileType,
|
||||
onboardingStep: "company",
|
||||
});
|
||||
|
||||
@@ -293,11 +280,6 @@ export class CompaniesService {
|
||||
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
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({
|
||||
name: identity.firstName
|
||||
@@ -316,7 +298,6 @@ export class CompaniesService {
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
isPrimaryContact: true,
|
||||
activeProfileType,
|
||||
onboardingStep: "company",
|
||||
onboardingCompleted: false,
|
||||
});
|
||||
@@ -1090,6 +1071,23 @@ export class CompaniesService {
|
||||
if (!existing)
|
||||
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
|
||||
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
||||
// 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
|
||||
// why; approval clears it. Any decision stamps the reviewer + time.
|
||||
if (status === ProfileStatus.Rejected) {
|
||||
// Track the review outcome. Rejection and suspension keep the note so the
|
||||
// customer knows why; approval/reactivation clears it. Any decision stamps
|
||||
// the reviewer + time.
|
||||
if (
|
||||
status === ProfileStatus.Rejected ||
|
||||
status === ProfileStatus.Suspended
|
||||
) {
|
||||
patch.reviewNote = note ?? null;
|
||||
} else if (status === ProfileStatus.Active) {
|
||||
patch.reviewNote = null;
|
||||
@@ -1192,23 +1194,50 @@ export class CompaniesService {
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||
|
||||
// Approving any profile promotes a pending company to active, so the
|
||||
// customer can start working as soon as their first profile is cleared.
|
||||
if (status === ProfileStatus.Active) {
|
||||
// Every reviewed transition that changes what the customer can do is told
|
||||
// to them, carrying the staff message so they know why. Approval has no
|
||||
// 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);
|
||||
if (company && company.status === CompanyStatus.Pending) {
|
||||
await this.companiesRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.profileStatusChanged(
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer reapplies for a rejected operational role (after fixing whatever the
|
||||
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
|
||||
* clear the rejection note so it re-enters the approval queue.
|
||||
* Customer reapplies for a rejected or suspended operational role (after
|
||||
* fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it
|
||||
* 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(
|
||||
userId: string,
|
||||
@@ -1223,9 +1252,12 @@ export class CompaniesService {
|
||||
if (!target || target.companyId !== companyId) {
|
||||
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(
|
||||
"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
|
||||
* role starts Pending, so it deliberately does NOT become the active mode:
|
||||
* switching onto an unapproved profile would strip the user of `canBook` and
|
||||
* block them from creating contracts under the role they already had approved.
|
||||
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
|
||||
* role starts Pending and carries no reference until a backoffice reviewer
|
||||
* approves it; a booking/contract resolves its profile from the trade
|
||||
* direction at creation time, so no "active mode" is stored.
|
||||
*/
|
||||
async createCompanyProfileForUser(
|
||||
userId: string,
|
||||
@@ -1387,40 +1418,6 @@ export class CompaniesService {
|
||||
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> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
@@ -1611,21 +1608,68 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a customer from booking under a profile that isn't approved yet.
|
||||
* Called from the booking-create path for self-service bookings; staff- and
|
||||
* government-initiated bookings bypass this. No-op when the profile can't be
|
||||
* found (defensive — resolution is best-effort upstream).
|
||||
* Block a self-service action when the company account isn't active, naming
|
||||
* the actual status — a suspended customer told "awaiting approval" has no
|
||||
* idea what happened or who to call.
|
||||
*/
|
||||
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(
|
||||
companyProfileId: string,
|
||||
): Promise<void> {
|
||||
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
||||
if (!profile) return;
|
||||
if (profile.status !== ProfileStatus.Active) {
|
||||
const role = profile.type.replace(/_/g, " ");
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`,
|
||||
);
|
||||
if (profile.status === ProfileStatus.Active) return;
|
||||
|
||||
const role = profile.type.replace(/_/g, " ");
|
||||
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
|
||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
|
||||
* when the natural profile doesn't exist) it falls back to the user's active
|
||||
* profile, then the company's first profile. Returns null when the company
|
||||
* has no profiles at all.
|
||||
* exporter profile; for DOMESTIC (or when the natural profile doesn't exist,
|
||||
* e.g. a freight forwarder) it falls back to the company's first profile.
|
||||
* Callers that need a specific role (a forwarder) pass an explicit
|
||||
* companyProfileId instead. Returns null when the company has no profiles.
|
||||
*/
|
||||
async resolveCompanyProfileIdForBooking(
|
||||
companyId: string,
|
||||
tradeDirection: string,
|
||||
fallbackType?: ProfileType | null,
|
||||
): Promise<string | null> {
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
if (profiles.length === 0) return null;
|
||||
@@ -2264,30 +2307,12 @@ export class CompaniesService {
|
||||
? ProfileType.exporter
|
||||
: null;
|
||||
|
||||
const byType = (type?: ProfileType | null) =>
|
||||
type ? profiles.find((p) => p.type === type) : undefined;
|
||||
|
||||
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
|
||||
const match =
|
||||
(naturalType && profiles.find((p) => p.type === naturalType)) ??
|
||||
profiles[0];
|
||||
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) {
|
||||
const { businessInfo, companyInfo } =
|
||||
await this.etradeService.resolveCompanyData(tin);
|
||||
|
||||
@@ -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
|
||||
* a real transition into one of those statuses; other status writes are silent.
|
||||
* Tell the customer their account changed status. Fires on the transitions
|
||||
* 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 {
|
||||
const status = company.status;
|
||||
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;
|
||||
|
||||
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.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.inbox.notify({
|
||||
recipients: { companyId: company.id },
|
||||
@@ -84,7 +167,7 @@ export class CompanyNotifierService {
|
||||
title,
|
||||
body,
|
||||
link: "/settings",
|
||||
data: { companyId: company.id, status },
|
||||
data: { companyId: company.id, profileType, change, staffMessage: message },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export class CompanyInfoResponseDto {
|
||||
company: Company,
|
||||
changeRequest?: CompanyChangeRequest | null,
|
||||
) {
|
||||
this.profile = new ResponseExternalProfileDto(profile, company);
|
||||
this.profile = new ResponseExternalProfileDto(profile);
|
||||
this.company = new ResponseCompanyDto(company);
|
||||
|
||||
const open =
|
||||
|
||||
@@ -60,15 +60,19 @@ export class ListCompaniesQueryDto {
|
||||
hasPendingChangeRequest?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["name", "createdAt", "updatedAt"],
|
||||
default: "name",
|
||||
description: "Column to order by. Defaults to name for backwards compatibility.",
|
||||
enum: ["review", "name", "createdAt", "updatedAt"],
|
||||
default: "review",
|
||||
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()
|
||||
@IsIn(["name", "createdAt", "updatedAt"])
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
@IsIn(["review", "name", "createdAt", "updatedAt"])
|
||||
sortBy?: "review" | "name" | "createdAt" | "updatedAt";
|
||||
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||
@IsIn(["ASC", "DESC"])
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import {
|
||||
ExternalProfile,
|
||||
} from '../entities/external-profile.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class ResponseExternalProfileDto {
|
||||
id: string;
|
||||
@@ -13,20 +11,12 @@ export class ResponseExternalProfileDto {
|
||||
nationalId?: string | null;
|
||||
jobTitle?: string | null;
|
||||
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;
|
||||
onboardingCompleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(profile: ExternalProfile, company?: Company) {
|
||||
constructor(profile: ExternalProfile) {
|
||||
this.id = profile.id;
|
||||
this.userId = profile.userId;
|
||||
this.companyId = profile.companyId;
|
||||
@@ -35,13 +25,8 @@ export class ResponseExternalProfileDto {
|
||||
this.nationalId = profile.nationalId;
|
||||
this.jobTitle = profile.jobTitle;
|
||||
this.isPrimaryContact = profile.isPrimaryContact;
|
||||
this.activeProfileType = profile.activeProfileType ?? null;
|
||||
this.onboardingStep = profile.onboardingStep ?? null;
|
||||
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
||||
this.activeCompanyProfileId =
|
||||
company?.companyProfiles?.find(
|
||||
(p) => p.type === profile.activeProfileType,
|
||||
)?.id ?? null;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class SetActiveModeDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Company } from './company.entity';
|
||||
import { ProfileType } from './company-profile.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||
@Index(['userId'])
|
||||
@@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity {
|
||||
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||
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'). */
|
||||
@Column({
|
||||
name: 'onboarding_step',
|
||||
|
||||
@@ -12,8 +12,6 @@ import { YardCountry } from '@edr/types';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
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 { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -181,11 +179,7 @@ export class ContractsService {
|
||||
);
|
||||
}
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
if (company.status !== CompanyStatus.Active) {
|
||||
throw new ForbiddenException(
|
||||
"Your company is awaiting approval — you can't create contracts yet.",
|
||||
);
|
||||
}
|
||||
this.companiesService.assertCompanyActiveFor(company, 'contracts');
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
@@ -193,31 +187,31 @@ export class ContractsService {
|
||||
this.assertRouteShape(dto.contractKind, 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;
|
||||
if (!isGovernment && companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
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 =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
dto.tradeDirection,
|
||||
fallbackType,
|
||||
);
|
||||
if (dto.companyProfileId) {
|
||||
const profile =
|
||||
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||
companyId,
|
||||
dto.companyProfileId,
|
||||
);
|
||||
companyProfileId = profile.id;
|
||||
} else {
|
||||
companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const customerSelfBooking = !dto.companyId && !!userId;
|
||||
if (customerSelfBooking && companyProfileId) {
|
||||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||
companyProfileId,
|
||||
);
|
||||
const customerSelfBooking = !dto.companyId && !!userId;
|
||||
if (customerSelfBooking && companyProfileId) {
|
||||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||
companyProfileId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,16 @@ export class CreateContractDto {
|
||||
@IsUUID()
|
||||
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' })
|
||||
@IsIn([...CONTRACT_KINDS])
|
||||
contractKind!: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { OtpController } from './otp.controller';
|
||||
import { OtpService } from './otp.service';
|
||||
|
||||
describe('OtpController', () => {
|
||||
let controller: OtpController;
|
||||
@@ -7,6 +8,9 @@ describe('OtpController', () => {
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [OtpController],
|
||||
providers: [
|
||||
{ provide: OtpService, useValue: { send: jest.fn(), verify: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<OtpController>(OtpController);
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
import { OtpService, normalizeOtpTarget } from './otp.service';
|
||||
import { OtpService, isDomesticPhone, normalizeOtpTarget } from "./otp.service";
|
||||
|
||||
describe('normalizeOtpTarget', () => {
|
||||
it('canonicalises Ethiopian forms to one E.164 key', () => {
|
||||
const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099'];
|
||||
describe("normalizeOtpTarget", () => {
|
||||
it("canonicalises Ethiopian forms to one E.164 key", () => {
|
||||
const forms = [
|
||||
"+251986680099",
|
||||
"251986680099",
|
||||
"0986680099",
|
||||
"+251 98 668 0099",
|
||||
];
|
||||
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…', () => {
|
||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
||||
});
|
||||
|
||||
it('canonicalises email case and surrounding whitespace to one key', () => {
|
||||
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);
|
||||
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)', () => {
|
||||
const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!;
|
||||
it("keeps an already-normalised email stable (idempotent)", () => {
|
||||
const once = normalizeOtpTarget({ email: " User@Example.COM " }).email!;
|
||||
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
|
||||
});
|
||||
|
||||
it('keeps an already-normalised number stable (idempotent)', () => {
|
||||
const once = normalizeOtpTarget({ phone: '0986680099' }).phone!;
|
||||
it("keeps an already-normalised number stable (idempotent)", () => {
|
||||
const once = normalizeOtpTarget({ phone: "0986680099" }).phone!;
|
||||
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 {
|
||||
id: string;
|
||||
phone?: string;
|
||||
@@ -51,7 +64,8 @@ function makeService(
|
||||
let nextId = 1;
|
||||
|
||||
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 = {
|
||||
findByTarget: jest.fn(
|
||||
@@ -89,30 +103,30 @@ function makeService(
|
||||
return { service, sms, email, rows: () => rows };
|
||||
}
|
||||
|
||||
describe('OtpService — send/verify agree across phone formats', () => {
|
||||
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
||||
describe("OtpService — send/verify agree across phone formats", () => {
|
||||
it("verifies a code sent to +251… when verify is called with 09…", async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp({ phone: '+251986680099' });
|
||||
await service.sendOtp({ phone: "+251986680099" });
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp),
|
||||
service.verifyOtpForAction({ phone: "0986680099" }, rows()[0]!.otp),
|
||||
).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();
|
||||
await service.sendOtp({ email: ' User@Example.COM ' });
|
||||
await service.sendOtp({ email: " User@Example.COM " });
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('OtpService — dual-channel send', () => {
|
||||
const both = { phone: '0986680099', email: 'User@Example.COM' };
|
||||
describe("OtpService — dual-channel send", () => {
|
||||
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();
|
||||
await service.sendOtp(both);
|
||||
|
||||
@@ -122,72 +136,95 @@ describe('OtpService — dual-channel send', () => {
|
||||
// Same secret on both messages — the user types whichever arrives first.
|
||||
expect(sms.sendSms).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: '+251986680099',
|
||||
to: "+251986680099",
|
||||
message: expect.stringContaining(otp),
|
||||
}),
|
||||
);
|
||||
expect(email.sendEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'user@example.com',
|
||||
to: "user@example.com",
|
||||
text: expect.stringContaining(otp),
|
||||
}),
|
||||
);
|
||||
// One row, both channels canonicalised.
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0]).toMatchObject({
|
||||
phone: '+251986680099',
|
||||
email: 'user@example.com',
|
||||
phone: "+251986680099",
|
||||
email: "user@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['phone alone', { phone: '0986680099' }],
|
||||
['email alone', { email: 'user@example.com' }],
|
||||
['both', both],
|
||||
])('verifies a dual-channel code when quoted back by %s', async (_label, target) => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
["phone alone", { phone: "0986680099" }],
|
||||
["email alone", { email: "user@example.com" }],
|
||||
["both", both],
|
||||
])(
|
||||
"verifies a dual-channel code when quoted back by %s",
|
||||
async (_label, target) => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp(both);
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction(target, rows()[0]!.otp),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
await expect(
|
||||
service.verifyOtpForAction(target, rows()[0]!.otp),
|
||||
).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();
|
||||
await service.sendOtp(both);
|
||||
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.
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, otp),
|
||||
service.verifyOtpForAction({ phone: "0986680099" }, otp),
|
||||
).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();
|
||||
// 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);
|
||||
|
||||
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();
|
||||
await service.sendOtp({ phone: '0986680099' });
|
||||
await service.sendOtp({ phone: "0986680099" });
|
||||
|
||||
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||
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({
|
||||
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.
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
||||
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
|
||||
).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({
|
||||
sms: async () => {
|
||||
throw new Error('broker down');
|
||||
throw new Error("broker down");
|
||||
},
|
||||
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();
|
||||
await service.sendOtp(both);
|
||||
const otp = rows()[0]!.otp;
|
||||
@@ -222,17 +259,17 @@ describe('OtpService — dual-channel send', () => {
|
||||
// Alternating channels must not hand the attacker two independent budgets:
|
||||
// 5 wrong guesses in total burn the code regardless of how they are split.
|
||||
for (const target of [
|
||||
{ phone: '0986680099' },
|
||||
{ email: 'user@example.com' },
|
||||
{ phone: '0986680099' },
|
||||
{ email: 'user@example.com' },
|
||||
{ phone: "0986680099" },
|
||||
{ email: "user@example.com" },
|
||||
{ phone: "0986680099" },
|
||||
{ email: "user@example.com" },
|
||||
]) {
|
||||
await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow(
|
||||
'Invalid verification code',
|
||||
);
|
||||
await expect(
|
||||
service.verifyOtpForAction(target, "000000"),
|
||||
).rejects.toThrow("Invalid verification code");
|
||||
}
|
||||
await expect(
|
||||
service.verifyOtpForAction({ email: 'user@example.com' }, '000000'),
|
||||
service.verifyOtpForAction({ email: "user@example.com" }, "000000"),
|
||||
).rejects.toThrow(/Too many incorrect attempts/);
|
||||
|
||||
// Burned: even the correct code no longer works.
|
||||
|
||||
@@ -36,9 +36,9 @@ function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
|
||||
*/
|
||||
function normalizePhone(rawPhone: string): string {
|
||||
const raw = rawPhone.trim();
|
||||
const digits = raw.replace(/[^\d+]/g, '');
|
||||
if (digits.startsWith('+')) return digits;
|
||||
const bare = digits.replace(/^0+/, '');
|
||||
const digits = raw.replace(/[^\d+]/g, "");
|
||||
if (digits.startsWith("+")) return digits;
|
||||
const bare = digits.replace(/^0+/, "");
|
||||
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
// 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
|
||||
// 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
|
||||
@@ -150,16 +174,14 @@ export class OtpService {
|
||||
const outcomes = (
|
||||
await Promise.all([
|
||||
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);
|
||||
|
||||
for (const outcome of outcomes) {
|
||||
this.logger.log(
|
||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
|
||||
outcome.queued
|
||||
} latencyMs=${Date.now() - startedAt}${
|
||||
outcome.error ? ` error=${outcome.error}` : ""
|
||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
|
||||
} 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,
|
||||
// and the misleading success response makes it look like our side worked.
|
||||
this.logger.error(
|
||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
|
||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
} — 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
|
||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||
this.logger.error(
|
||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
|
||||
Date.now() - startedAt
|
||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt
|
||||
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
@@ -270,9 +290,7 @@ export class OtpService {
|
||||
* address while printing the credential next to it would buy nothing.
|
||||
*/
|
||||
private targetLabel(target: OtpTarget): string {
|
||||
return (
|
||||
[target.email, target.phone].filter(Boolean).join("+") || "unknown"
|
||||
);
|
||||
return [target.email, target.phone].filter(Boolean).join("+") || "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,9 +306,8 @@ export class OtpService {
|
||||
) {
|
||||
const line = `otp.verify channels=${channelsOf(target).join(
|
||||
"+",
|
||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
||||
detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
if (result === "ok") this.logger.log(line);
|
||||
else this.logger.warn(line);
|
||||
}
|
||||
@@ -439,7 +456,12 @@ export class OtpService {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
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(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
|
||||
@@ -80,7 +80,12 @@ const makeBooking = (
|
||||
|
||||
describe('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 locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let wagonTypesRepository: { findAll: jest.Mock };
|
||||
@@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => {
|
||||
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||||
|
||||
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 = {
|
||||
getRepository: jest.fn(),
|
||||
transaction: jest.fn(),
|
||||
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
manager: {
|
||||
getRepository: jest.fn(() => ({
|
||||
createQueryBuilder: jest.fn(() => emptySiblingQb),
|
||||
})),
|
||||
},
|
||||
};
|
||||
bookingsRepository = {
|
||||
findEligibleForScheduling: jest.fn(),
|
||||
@@ -110,9 +127,11 @@ describe('TrainSchedulingService', () => {
|
||||
findByIdWithFullGraph: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
maxReferenceSequence: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
trainScheduleBookingsRepository = {
|
||||
findByBookingIds: jest.fn(),
|
||||
findByScheduleId: jest.fn().mockResolvedValue([]),
|
||||
createMany: jest.fn(),
|
||||
deleteByScheduleAndBooking: jest.fn(),
|
||||
};
|
||||
@@ -327,7 +346,11 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
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]);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||
@@ -354,7 +377,10 @@ describe('TrainSchedulingService', () => {
|
||||
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 = [
|
||||
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'),
|
||||
@@ -375,7 +401,8 @@ describe('TrainSchedulingService', () => {
|
||||
expect(result.violations).not.toContain(
|
||||
'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 () => {
|
||||
@@ -408,6 +435,8 @@ describe('TrainSchedulingService', () => {
|
||||
originYardId: 'yard-origin',
|
||||
destinationYardId: 'yard-destination',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
direction: 'IMPORT',
|
||||
};
|
||||
|
||||
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
|
||||
@@ -421,6 +450,12 @@ describe('TrainSchedulingService', () => {
|
||||
const trainScheduleRepo = {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
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 = {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
@@ -464,19 +499,21 @@ describe('TrainSchedulingService', () => {
|
||||
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({
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
scheduleDate: futureDeparture,
|
||||
locomotiveIds: ['loc-1', 'loc-2'],
|
||||
});
|
||||
|
||||
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||||
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
|
||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
|
||||
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
|
||||
{ status: 'ASSIGNED' },
|
||||
);
|
||||
// Advance scheduling locks locomotives but does NOT flip them to ASSIGNED —
|
||||
// one locomotive may sit on several future schedules.
|
||||
expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled();
|
||||
expect(result.id).toBe('schedule-1');
|
||||
});
|
||||
|
||||
@@ -492,7 +529,7 @@ describe('TrainSchedulingService', () => {
|
||||
destinationYardId: 'yard-destination',
|
||||
status: 'PAID',
|
||||
bookingContainers: [],
|
||||
cargoType: { code: 'COFFEE' },
|
||||
cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] },
|
||||
};
|
||||
|
||||
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
|
||||
@@ -511,7 +548,9 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
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.containerUnits).toHaveLength(2);
|
||||
});
|
||||
@@ -536,9 +575,11 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
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 = {
|
||||
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',
|
||||
destinationYardId: 'yard-destination',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
direction: 'IMPORT',
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -907,7 +950,7 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
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 originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
@@ -915,6 +958,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: routeId,
|
||||
name: 'Addis → Djibouti',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Djibouti' },
|
||||
@@ -924,21 +968,21 @@ describe('TrainSchedulingService', () => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
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([
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
{ id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({
|
||||
where: { status: 'AVAILABLE', currentYardId: originYardId },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].code).toBe('EXP');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true);
|
||||
expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false);
|
||||
});
|
||||
|
||||
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 originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
@@ -946,6 +990,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: routeId,
|
||||
name: 'Addis → Dire Dawa',
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Ethiopia' },
|
||||
@@ -955,14 +1000,10 @@ describe('TrainSchedulingService', () => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
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);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
await expect(
|
||||
service.getAvailableLocomotivesForRoute(routeId),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user