diff --git a/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts new file mode 100644 index 000000000..d8119930f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS active_profile_type; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts new file mode 100644 index 000000000..e6cfe70c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface { + name = "AddCacBankPaymentMethod2460000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + // PostgreSQL does not support removing enum values directly. + // To roll back, recreate the type without the added value and update the column. + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts index 91ddaf1a9..4eb6ecc31 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -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. diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 393f892f6..e95cd65c9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 { - 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 diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 6111bd32a..43d70ce19 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { - 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) diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 3ac12b11a..db8db0d2e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository { 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, @@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository { 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 { } // 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') diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index df0e14998..3867bef3a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { 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 { 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 { 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 { - 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); diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index f71a67976..66f88e9f8 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -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 = { + 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, }); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 9a4fb330a..2634e0943 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -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 = diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 8d4910ded..ffb600e36 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -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"]) diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index 256641074..916bb940d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts deleted file mode 100644 index ac8f57a93..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { ProfileType } from '../entities/company-profile.entity'; - -export class SetActiveModeDto { - @IsEnum(ProfileType) - type!: ProfileType; -} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 93e499b5e..84f644091 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index a675adf39..65f0637f0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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, + ); + } } } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 688e0b4c7..fb4f40654 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts index cac5fdba0..3aefe7cdb 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts index 0494b48b6..00f8d107a 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -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. diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index e19323067..557548b00 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -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.", ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index b6410d0cf..6757a6e21 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -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; let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock }; let wagonTypesRepository: { findAll: jest.Mock }; @@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => { let wagonAllocationBulkLoadsRepository: Record; 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); }); }); diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts new file mode 100644 index 000000000..cb810abaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts @@ -0,0 +1,44 @@ +import { ConflictException } from '@nestjs/common'; + +import { VehiclesService } from './vehicles.service'; + +// One driver ⇒ one truck: create/update must refuse a driver already assigned +// to another (non-deleted) vehicle until they are detached. +describe('VehiclesService driver assignment guard', () => { + const otherTruck = { id: 'v2', plateNumber: '3-11111', assignedDriverId: 'd1' }; + + const makeService = (findOne: jest.Mock) => + new VehiclesService( + { findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any, + { record: jest.fn() } as any, + ); + + it('rejects create when the driver is on another truck', async () => { + // First findOne = plate uniqueness (null), second = driver holder. + const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck); + const svc = makeService(findOne); + await expect( + svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any), + ).rejects.toThrow(ConflictException); + }); + + it('rejects update when reassigning a driver still attached elsewhere', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: null }) // findById + .mockResolvedValueOnce(otherTruck); // driver holder + const svc = makeService(findOne); + await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).rejects.toThrow( + ConflictException, + ); + }); + + it('allows update that keeps the same driver on the same truck', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: 'd1' }); + const svc = makeService(findOne); + await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).resolves.toBeDefined(); + expect(findOne).toHaveBeenCalledTimes(1); // guard skipped — no holder lookup + }); +}); diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 25260e86f..98d38cbce 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -20,6 +20,25 @@ export class VehiclesService { private readonly history: FleetHistoryService, ) {} + /** + * A driver holds one truck at a time — reassignment requires detaching them + * from their current truck first. + * ponytail: app-level guard only (race window); add a partial unique index on + * assigned_driver_id if concurrent fleet edits ever become real. + */ + private async assertDriverUnassigned(driverId: string, exceptVehicleId?: string): Promise { + const holder = await this.vehicleRepo.findOne({ + where: exceptVehicleId + ? { assignedDriverId: driverId, id: Not(exceptVehicleId) } + : { assignedDriverId: driverId }, + }); + if (holder) { + throw new ConflictException( + `This driver is already assigned to truck ${holder.plateNumber ?? holder.code ?? holder.id} — detach the driver from that truck first`, + ); + } + } + async create(dto: CreateVehicleDto): Promise { const existing = await this.vehicleRepo.findOne({ where: { plateNumber: dto.plateNumber }, @@ -31,6 +50,10 @@ export class VehiclesService { ); } + if (dto.assignedDriverId) { + await this.assertDriverUnassigned(dto.assignedDriverId); + } + const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`; const vehicle = this.vehicleRepo.create({ ...dto, @@ -121,6 +144,10 @@ export class VehiclesService { } } + if (dto.assignedDriverId && dto.assignedDriverId !== vehicle.assignedDriverId) { + await this.assertDriverUnassigned(dto.assignedDriverId, id); + } + const prev = { assignedDriverId: vehicle.assignedDriverId, assignedDriverName: vehicle.assignedDriverName, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index acd31bd51..0174ef3e9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -556,6 +556,12 @@ export class WarehouseInventoryController { return this.inventoryService.bookingContainerWeights(bookingId); } + @Get('bookings/:bookingId/location') + @ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" }) + bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.inventoryService.bookingLocation(bookingId); + } + @Post(':id/deliver') @BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver) @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 50c71f485..bd702542b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1030,6 +1030,28 @@ export class WarehouseInventoryService { return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }); } + /** + * Warehouse location rows for one booking, trimmed for the customer portal: + * no staff guard on the route, so only location fields leave the API — + * never notes, fees or inspection internals. + */ + async bookingLocation(bookingId: string) { + const items = await this.inventoryRepository.findAll({ + where: { bookingId }, + relations: { warehouse: true, yard: true, zone: true }, + order: { createdAt: 'DESC' }, + }); + return items.map((i) => ({ + id: i.id, + bookingId: i.bookingId, + status: i.status, + arrivedAt: i.arrivedAt ?? null, + warehouse: i.warehouse ? { id: i.warehouse.id, name: i.warehouse.name, code: i.warehouse.code } : null, + yard: i.yard ? { id: i.yard.id, name: i.yard.name, code: i.yard.code } : null, + zone: i.zone ? { id: i.zone.id, name: i.zone.name, code: i.zone.code } : null, + })); + } + async findById(id: string): Promise { const item = await this.inventoryRepository.findById(id, { relations: { warehouse: { facility: true }, yard: true, zone: true }, @@ -2989,6 +3011,29 @@ export class WarehouseInventoryService { ); } + // A truck leaves with the containers ASSIGNED to it — never another + // truck's. Enforced whenever the truck has an assigned load on file + // (customer self-haul or EDR last-mile). + if (dto.containerNumber && dto.truckPlateNumber?.trim()) { + const selectedNumbers = dto.containerNumber + .split(/[,;\n]+/) + .map((n) => n.trim().toUpperCase()) + .filter(Boolean); + const assigned = await this.truckAssignedContainers( + item.bookingId, + dto.truckPlateNumber.trim(), + ); + if (assigned.length && selectedNumbers.length) { + const foreign = selectedNumbers.filter((n) => !assigned.includes(n)); + if (foreign.length) { + throw new BadRequestException( + `Container${foreign.length > 1 ? 's' : ''} ${foreign.join(', ')} ` + + `not assigned to truck ${dto.truckPlateNumber.trim()} — each truck may only carry out its own assigned containers`, + ); + } + } + } + // Authoritative weight match: the truck's net (gross − tare) must equal the // total VGM cargo weight of the containers selected as loaded on it. // Skipped when the operator chose not to weigh (containers only). @@ -3325,6 +3370,17 @@ export class WarehouseInventoryService { } } + /** + * customer_truck_assignments.gross_weight_kg holds TONNES for gate-out + * recorded exits but real KG for legacy departTruck rows. Exit papers always + * print tonnes — normalise on read. + */ + // ponytail: >1000 heuristic (no truck hauls 1000+ t, no weighbridge reads <1000 kg); + // migrate the column to tonnes if it ever bites. + private grossAsTons(value: number): number { + return value > 1000 ? Math.round(value) / 1000 : value; + } + async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, @@ -3384,7 +3440,40 @@ export class WarehouseInventoryService { grossWeightKg: string | number | null; departedAt: string | null; } | null = null; - if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { + // The exit-inspection note written at gate-out names the truck doing THIS + // exit — resolve by its plate first. The item's own container may not be on + // the departing truck at all (trucks pick containers freely per trip). + const notePlates = [...String(row?.notes ?? '').matchAll(/Truck Plate:\s*(\S+)/gi)]; + const exitPlate = notePlates.length ? notePlates[notePlates.length - 1][1] : null; + if (row?.tradeDirection === 'IMPORT' && row?.bookingId && exitPlate) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + string_agg(DISTINCT c.container_number, ', ' ORDER BY c.container_number) AS "containerNumbers", + COALESCE(( + SELECT SUM(bcu.vgm_tons) + FROM freight.customer_truck_containers cc + JOIN freight.booking_container_units bcu + ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + AND bc.booking_id = a.booking_id + WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL + ), 0) AS "truckWeightTons" + FROM freight.customer_truck_assignments a + LEFT JOIN freight.customer_truck_containers c + ON c.assignment_id = a.id AND c.deleted_at IS NULL + WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.gross_weight_kg, a.departed_at + LIMIT 1`, + [row.bookingId, exitPlate], + ); + truck = truckRow ?? null; + } + if (!truck && row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { const [truckRow] = await this.dataSource.query( `SELECT a.plate_number AS "plateNumber", a.driver_name AS "driverName", @@ -3414,6 +3503,26 @@ export class WarehouseInventoryService { ); truck = truckRow ?? null; } + // Bulk self-haul (no container to match) or an unmatched container: the exit + // paper is still PER TRUCK — use the latest departed customer truck and its + // weighed gross, never the booking's declared total. + if (!truck && row?.tradeDirection === 'IMPORT' && row?.bookingId) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + NULL AS "containerNumbers", + a.net_weight_tons AS "truckWeightTons" + FROM freight.customer_truck_assignments a + WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.departed_at IS NOT NULL + ORDER BY a.departed_at DESC + LIMIT 1`, + [row.bookingId], + ); + truck = truckRow ?? null; + } const bookingReference = row?.bookingReference || 'N/A'; const reference = @@ -3428,7 +3537,9 @@ export class WarehouseInventoryService { customerName: row?.customerName ?? null, freightType: row?.freightType ?? null, tradeDirection: row?.tradeDirection ?? null, - containerNumber: row?.containerNumber ?? null, + // Per-truck exit: list every container leaving on THIS truck, not just + // the inventory item's own container. + containerNumber: truck?.containerNumbers ?? row?.containerNumber ?? null, cargoDescription: row?.cargoDescription ?? null, quantity: Number(row?.quantity ?? 0), weight: Number(row?.weight ?? 0), @@ -3442,12 +3553,12 @@ export class WarehouseInventoryService { truckDriverName: truck?.driverName ?? null, truckType: truck?.truckType ?? null, truckGateOut: truck?.departedAt ?? null, - // Prefer the weighed gross captured on departure; fall back to the summed - // container VGM when the truck hasn't been weighed yet. - truckWeightKg: truck - ? Number(truck.grossWeightKg ?? 0) > 0 - ? Number(truck.grossWeightKg) - : Number(truck.truckWeightTons ?? 0) * 1000 + // Per-truck load in tonnes: the summed VGM of the containers on this truck + // (recorded net for bulk); the weighed gross only as fallback. + truckWeightTons: truck + ? Number(truck.truckWeightTons ?? 0) > 0 + ? Number(truck.truckWeightTons) + : this.grossAsTons(Number(truck.grossWeightKg ?? 0)) : null, }); @@ -3565,6 +3676,32 @@ export class WarehouseInventoryService { })); } + /** + * Container numbers assigned to a truck (by plate) on this booking, from both + * haulage paths: customer self-haul (customer_truck_containers) and EDR + * last-mile (last_mile_vehicle_containers / legacy scalar). Uppercased. + */ + private async truckAssignedContainers(bookingId: string, plate: string): Promise { + const rows: Array<{ cn: string | null }> = await this.dataSource.query( + `SELECT UPPER(cc.container_number) AS cn + FROM freight.customer_truck_assignments a + JOIN freight.customer_truck_containers cc + ON cc.assignment_id = a.id AND cc.deleted_at IS NULL + WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL + UNION + SELECT UPPER(COALESCE(vc.container_number, va.container_number)) AS cn + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + JOIN freight.vehicles v ON v.id = va.vehicle_id + WHERE l.booking_id = $1 AND va.deleted_at IS NULL + AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))`, + [bookingId, plate], + ); + return rows.map((r) => r.cn).filter((n): n is string => Boolean(n)); + } + /** * The booking's containers with their VGM cargo weight (tonnes), keyed by * container number. Drives the truck-leaving exit weighing: the selected @@ -3620,10 +3757,17 @@ export class WarehouseInventoryService { ); if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id); - const containers: Array<{ containerNumber: string; goods: string | null }> = + const containers: Array<{ containerNumber: string; goods: string | null; vgmTons: string | null }> = await this.dataSource.query( `SELECT c.container_number AS "containerNumber", - COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, + (SELECT SUM(u.vgm_tons) + FROM freight.booking_container_units u + JOIN freight.booking_container bc + ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL + WHERE u.container_number = c.container_number + AND bc.booking_id = c.booking_id + AND u.deleted_at IS NULL) AS "vgmTons" FROM freight.customer_truck_containers c JOIN freight.bookings b ON b.id = c.booking_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id @@ -3632,6 +3776,9 @@ export class WarehouseInventoryService { [assignmentId], ); + // Truck load in tonnes: summed container VGM; the weighed gross only as + // fallback (bulk trucks carry no containers). + const vgmSum = containers.reduce((s, c) => s + (Number(c.vgmTons) || 0), 0); const html = this.buildTruckExitPaperHtml({ reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`, bookingReference: truck.bookingReference, @@ -3639,7 +3786,7 @@ export class WarehouseInventoryService { plateNumber: truck.plateNumber, driverName: truck.driverName, truckType: truck.truckType, - grossWeightKg: Number(truck.grossWeightKg ?? 0), + grossWeightKg: vgmSum > 0 ? vgmSum : this.grossAsTons(Number(truck.grossWeightKg ?? 0)), gateOut: truck.departedAt, containers, }); @@ -5146,7 +5293,7 @@ export class WarehouseInventoryService { truckDriverName?: string | null; truckType?: string | null; truckGateOut?: string | null; - truckWeightKg?: number | null; + truckWeightTons?: number | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5173,8 +5320,8 @@ export class WarehouseInventoryService { ['Quantity', data.quantity], [ data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight', - `${(data.truckPlateNumber && data.truckWeightKg - ? data.truckWeightKg + `${(data.truckPlateNumber && data.truckWeightTons + ? data.truckWeightTons : data.weight ).toLocaleString()} t`, ], diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index e6ea89fbe..7ebcfb89c 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -304,4 +304,6 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, + { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, + { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, ]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 396ac95db..1f82f6e93 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -804,6 +804,48 @@ export const POSITION_PERMISSION_PRESETS = { ...ROLE_PERMISSION_PRESETS.operationsOfficer, FREIGHT_PERMS.allocation.manage, ]), + // Operations Chief: full operational authority — the entire freight + // permission catalog (all CRUD across bookings, contracts, scheduling, + // fleet, warehouse, mile, finance, settings, staff). + operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]), + // Dispatcher: warehouse floor operations — receive/GRN, move, load/unload, + // inspect, dispatch, gate, release/deliver, interchange docs, fee invoices, + // plus truck dispatch on the mile legs and read-only operational context. + // Allocation & fee rules are VIEW-ONLY — never create/update/delete. + dispatcher: dedupe([ + FREIGHT_PERMS.warehouseDashboard.view, + FREIGHT_PERMS.warehouses.view, + FREIGHT_PERMS.warehouseYards.view, + FREIGHT_PERMS.warehouseZones.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseInventory.receive, + FREIGHT_PERMS.warehouseInventory.move, + FREIGHT_PERMS.warehouseInventory.load, + FREIGHT_PERMS.warehouseInventory.unload, + FREIGHT_PERMS.warehouseInventory.dispatch, + FREIGHT_PERMS.warehouseInventory.gatePass, + FREIGHT_PERMS.warehouseInventory.release, + FREIGHT_PERMS.warehouseInventory.deliver, + FREIGHT_PERMS.warehouseInventory.inspect, + FREIGHT_PERMS.warehouseInspectionReports.view, + FREIGHT_PERMS.warehouseInspectionReports.create, + FREIGHT_PERMS.warehouseInspectionReports.update, + FREIGHT_PERMS.interchangeDocuments.view, + FREIGHT_PERMS.interchangeDocuments.generate, + FREIGHT_PERMS.interchangeDocuments.acknowledge, + FREIGHT_PERMS.warehouseFeeInvoices.view, + FREIGHT_PERMS.warehouseFeeInvoices.generate, + // View-only on the rules that govern allocation and fees. + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + // Truck dispatch on the EDR mile legs + operational context. + FREIGHT_PERMS.firstMile.view, + FREIGHT_PERMS.firstMile.assignVehicles, + FREIGHT_PERMS.lastMile.view, + FREIGHT_PERMS.lastMile.assignVehicles, + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.bookings.operations, + ]), } as const; /** Derive the module bucket from the resource segment of a permission key. */ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f81e733e0..15daad79a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -385,26 +385,31 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Import Overview", href: "/dashboard/import-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Arrival Queue", href: "/dashboard/arrival-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Inventory Inquiry", href: "/dashboard/inventory-inquiry", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -418,36 +423,43 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Export Overview", href: "/dashboard/export-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Loading Queue", href: "/dashboard/loading-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Loaded Inventory", href: "/dashboard/loaded-inventory", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Djibouti Unloading", href: "/dashboard/export-djibouti-unloading", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Interchange Documents", href: "/dashboard/interchange-documents", icon: , + permission: FREIGHT_PERMS.interchangeDocuments.view, }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -461,6 +473,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Intercity Cargo", href: "/dashboard/intercity", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -492,7 +505,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Allocation & Fees", href: "/dashboard/warehouse-rules", icon: , - permission: FREIGHT_PERMS.warehouseAllocationRules.view, + permission: [ + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + ], }, { label: "Fee Invoices", @@ -555,7 +571,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Staff", href: "/user-management", icon: , - permission: FREIGHT_PERMS.admin, + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], }, ], }, @@ -600,16 +621,27 @@ const filterSidebarByPermission = ( return keys.some((key) => hasFreightPermission(user, key)); }; - const itemAllowed = (item: SidebarItem): boolean => { - // GL positions are locked to their single clearance page. - if (etGl) return isEtClearanceItem(item); - if (djGl) return isDjClearanceItem(item); - - // Everyone else: hide the GL-only clearance pages entirely. - if (isClearanceItem(item)) return false; - - return permissionAllowed(item); - }; + // Recursive: children are filtered first; a group (item with children) stays + // only while it still has visible children — so parents without their own + // permission key never leak a whole subtree the user cannot open. + const filterItems = (items: SidebarItem[]): SidebarItem[] => + items + .map((item) => + item.children ? { ...item, children: filterItems(item.children) } : item, + ) + .filter((item) => { + if (etGl || djGl) { + // GL positions are locked to their single clearance page (parents + // survive only as the path to that page). + const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; + return isTarget(item) || (item.children?.length ?? 0) > 0; + } + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + if (!permissionAllowed(item)) return false; + if (item.children) return item.children.length > 0; + return true; + }); // Recursive: a group's own permission gates the whole subtree, leaves are // checked individually, and a group with no surviving children disappears. diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx index 2175d4d7b..f0e9b266a 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx @@ -61,8 +61,11 @@ export default function ResetPasswordAction({ 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 = - !!target && (channel === "email" ? !target.email : !target.phone); + !!target && (channel === "email" ? !target.email : !phoneUsable); return ( <> @@ -106,9 +109,13 @@ export default function ResetPasswordAction({ (null); const [note, setNote] = useState(""); 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( - { profileId, status: "rejected", note: note.trim() }, - { onSuccess: () => setRejectOpen(false) }, + { profileId, status: active.status, note: note.trim() }, + { onSuccess: () => setDecision(null) }, ); }; - const rejectModal = ( + const decisionModal = active && ( setRejectOpen(false)} - title="Reject profile" + opened + onClose={() => setDecision(null)} + title={active.title} centered radius="lg" > - Tell the customer what needs fixing. They'll see this note and can - amend and resubmit the role for approval. + {active.intro} -

Supports any format: one per line, comma-separated, or {REF1,REF2} groups.

- -
- - - - -
-
-
-
- - -
-
- -
- - - - - -
-
- - - - - - - -
- -
- - - - - - - - -
JourneyDuplicate Bookings
-
-
- - - - - diff --git a/booking-extractor.html b/booking-extractor.html deleted file mode 100644 index 844c98b2c..000000000 --- a/booking-extractor.html +++ /dev/null @@ -1,256 +0,0 @@ - - - - - - EDR Booking Extractor - - - - -

EDR Booking Extractor

- -
- -
- - Drop bookings.json here or click to browse -
-

Accepts a JSON array of bookings or an object with a bookings key.

-
- - - -
-
- -
-
-
- - - -
-
- - - - - - - - - - - - - - - - - - - - - -
#Booking RefStatusBooking TypePhoneEmailDepartureOriginDestinationPassenger(s)Coach - SeatPayment MethodPayment StatusTotal (DJF)Created At
-
-
- - - - - diff --git a/booking-proxy.mjs b/booking-proxy.mjs deleted file mode 100644 index 27f9248ee..000000000 --- a/booking-proxy.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import http from 'http'; -import https from 'https'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const PORT = 8080; -const __dir = path.dirname(fileURLToPath(import.meta.url)); - -const server = http.createServer((req, res) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } - - // Serve any .html file in the same directory - if (req.url === '/' || req.url.endsWith('.html')) { - const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1); - const filepath = path.join(__dir, filename); - if (fs.existsSync(filepath)) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - fs.createReadStream(filepath).pipe(res); - } else { - res.writeHead(404); res.end('Not found'); - } - return; - } - - // Proxy /proxy?url= - if (req.url.startsWith('/proxy?url=')) { - const target = decodeURIComponent(req.url.slice('/proxy?url='.length)); - const parsed = new URL(target); - const mod = parsed.protocol === 'https:' ? https : http; - const options = { - hostname: parsed.hostname, - port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), - path: parsed.pathname + parsed.search, - method: req.method, - headers: { ...req.headers, host: parsed.hostname }, - }; - const proxy = mod.request(options, (apiRes) => { - res.writeHead(apiRes.statusCode, apiRes.headers); - apiRes.pipe(res); - }); - proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); }); - req.pipe(proxy); - return; - } - - res.writeHead(404); res.end(); -}); - -server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`)); diff --git a/ticket-extractor.html b/ticket-extractor.html deleted file mode 100644 index 17be60576..000000000 --- a/ticket-extractor.html +++ /dev/null @@ -1,239 +0,0 @@ - - - - - - EDR Ticket Extractor - - - - -

EDR Ticket Extractor

- -
- -
- - Drop tickets.json here or click to browse -
-

Accepts a JSON array of tickets or an object with a tickets key.

-
- - - -
-
- -
-
-
- - - - - - - - - - - - - - - - - - -
#Ticket No.Booking RefPassengerPhoneEmailJourney TypeOriginDestinationSeat ClassCoachSeat
-
-
- - - - -