From f71bbbf782f8aacb482a703cedc854cd3a6ea6b1 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 16 Jul 2026 12:08:45 +0000 Subject: [PATCH 01/18] feat: setup account page for customer and centeralize the otps and phone usages to use the iam user --- .../src/modules/auth/account.controller.ts | 62 +++ .../src/modules/auth/account.service.ts | 226 +++++++++++ .../src/modules/auth/dto/account.dto.ts | 60 +++ .../modules/auth/forgot-password.service.ts | 9 +- .../src/modules/auth/freight-auth.module.ts | 14 +- .../src/modules/auth/mask-target.util.ts | 16 + .../booking-lifecycle-notifier.service.ts | 9 +- .../companies/company-notifier.service.ts | 7 +- .../contracts/contract-notifier.service.ts | 9 +- .../contracts/contract-transition.service.ts | 74 ++-- .../contracts/dto/sign-contract.dto.ts | 12 +- .../notifications/notify-company.util.ts | 11 +- .../resolve-company-phone.util.ts | 60 +++ .../booking-notifier.service.ts | 9 +- .../booking-window.service.ts | 7 +- .../warehouses/warehouse-inventory.service.ts | 13 +- .../warehouses/warehouse-invoice.service.ts | 7 +- .../portal/src/constants/URLS.ts | 9 + .../portal/src/pages/SettingsPage.tsx | 25 +- .../src/pages/contracts/ContractViewPage.tsx | 9 +- .../portal/src/pages/settings/TabAccount.tsx | 374 ++++++++++++++++++ .../portal/src/services/api.ts | 25 ++ .../portal/src/services/auth.service.ts | 35 ++ .../portal/src/services/bookings.service.ts | 2 - .../portal/src/services/contracts.service.ts | 7 +- apps/edr-freight-web/portal/src/types/auth.ts | 28 ++ 26 files changed, 1057 insertions(+), 62 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/auth/account.controller.ts create mode 100644 apps/edr-freight-api/src/modules/auth/account.service.ts create mode 100644 apps/edr-freight-api/src/modules/auth/dto/account.dto.ts create mode 100644 apps/edr-freight-api/src/modules/auth/mask-target.util.ts create mode 100644 apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts create mode 100644 apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx diff --git a/apps/edr-freight-api/src/modules/auth/account.controller.ts b/apps/edr-freight-api/src/modules/auth/account.controller.ts new file mode 100644 index 000000000..d7d7f15c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.controller.ts @@ -0,0 +1,62 @@ +import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { AccountService } from "./account.service"; +import { + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; + +/** + * The caller's own account record. Everything here is scoped to the JWT's user + * id — there is no `:id` parameter to tamper with, so these routes need no + * permission key beyond being authenticated. + */ +@ApiTags("auth") +@Controller("me") +@ApiBearerAuth() +@UseGuards(JwtGuard) +export class AccountController { + constructor(private readonly accountService: AccountService) {} + + @Post("contact/otp") + @ApiOperation({ + summary: "Send a verification code to a new email/phone before changing it", + description: + "The code goes to the NEW value supplied here, proving the caller controls " + + "it. Returns the target masked — an unverified caller never gets it back in full.", + }) + sendContactOtp( + @CurrentUser() user: TCurrentUser, + @Body() dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + return this.accountService.sendContactOtp(user.id, dto); + } + + @Patch("contact") + @ApiOperation({ + summary: "Change the account's email or phone, gated by a verification code", + description: + "Verifies the code and writes the new value in one call, so the API never " + + "has to take a client's word that verification happened.", + }) + updateContact( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + return this.accountService.updateContact(user.id, dto); + } + + @Patch("name") + @ApiOperation({ summary: "Change the account's display name" }) + updateName( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + return this.accountService.updateName(user.id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/account.service.ts b/apps/edr-freight-api/src/modules/auth/account.service.ts new file mode 100644 index 000000000..b7413a649 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.service.ts @@ -0,0 +1,226 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, +} from "@nestjs/common"; +import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; +import { DataSource, EntityManager, Repository } from "typeorm"; +import { isValidPhoneNumber } from "libphonenumber-js"; + +import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum"; +import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type"; +import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; +import { OtpService, OtpTarget } from "../otp/otp.service"; +import { + ContactChannel, + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; +import { maskOtpTarget } from "./mask-target.util"; + +/** How long a contact-change code stays valid before it must be re-requested. */ +const CONTACT_OTP_TTL_MS = 10 * 60 * 1000; + +/** Postgres unique-violation SQLSTATE. */ +const PG_UNIQUE_VIOLATION = "23505"; + +/** + * Self-serve management of the caller's own IAM user record. + * + * IAM ships `PATCH /api/auth/update-profile`, but it takes email + username + + * phone + name all at once (every field `@IsNotEmpty`) and performs no + * verification — it will move an account's phone to any number the caller + * types. These routes exist so a contact change is *proven*: the code goes to + * the NEW address and the write only lands once it comes back. + */ +@Injectable() +export class AccountService { + private readonly logger = new Logger(AccountService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly otpService: OtpService, + ) {} + + /** + * Send a code to the address the caller wants to move TO. Sending to the new + * value (rather than the one on file) is the whole point — it proves control + * of the destination before anything is written. + */ + async sendContactOtp( + userId: string, + dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + const target = this.targetFor(dto.channel, value); + await this.otpService.sendOtp(target); + + return { sentTo: maskOtpTarget(target) }; + } + + /** + * Verify the code, then write the new contact value. The verify and the write + * are one call: the API never has to trust that a client "already verified" + * — unlike the signup flow, where the OTP is client-orchestrated and + * `POST /api/otp/verify` is a separate public route the client may simply skip. + */ + async updateContact( + userId: string, + dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + await this.otpService.verifyOtpForAction( + this.targetFor(dto.channel, value), + dto.otp, + CONTACT_OTP_TTL_MS, + ); + + const isEmail = dto.channel === ContactChannel.Email; + const userPatch = isEmail + ? { email: value } + : { + phoneNumber: value, + // The number just passed an OTP, which is exactly what IAM's own + // phone-verification flag means. Set it here so the freight app stops + // needing its own parallel "verified phone" bookkeeping. + isPhoneNumberVerified: true, + verifiedBy: EUserVerifiedBy.PHONE_NUMBER, + }; + const sessionPatch: Partial = isEmail + ? { email: value } + : { phoneNumber: value, isPhoneNumberVerified: true }; + + try { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, userPatch); + await this.refreshSessions(manager, userId, sessionPatch); + }); + } catch (error) { + throw this.asConflict(error, dto.channel); + } + + this.logger.log(`Account ${dto.channel} updated for user ${userId}`); + return { success: true, value }; + } + + /** Rename the account. No OTP — a name change proves nothing and grants nothing. */ + async updateName( + userId: string, + dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + const en = dto.name.en?.trim(); + const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) }; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, { name }); + // IAM mirrors the name onto the employee row. Portal customers are + // `individual` users with no employee row at all, so this is a no-op for + // them — hence an unconditional update() rather than a lookup-then-write. + await manager.getRepository(Employee).update({ userId }, { name }); + await this.refreshSessions(manager, userId, { name }); + }); + + return { success: true }; + } + + /** + * `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only + * when a session is created at login. Without patching it here, a saved change + * stays invisible to /me (and to anything reading the token's claims) until the + * user logs out and back in, which reads as "my edit didn't save". + */ + private async refreshSessions( + manager: EntityManager, + userId: string, + patch: Partial, + ): Promise { + const repo = manager.getRepository(Session); + const sessions = await repo.find({ where: { userId } }); + + await Promise.all( + sessions.map((session) => + repo.update( + { id: session.id }, + { userInfo: { ...session.userInfo, ...patch } }, + ), + ), + ); + } + + /** Canonicalise for the channel and reject anything malformed up front. */ + private normalize(channel: ContactChannel, value: string): string { + const raw = value.trim(); + + if (channel === ContactChannel.Email) { + const email = raw.toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new BadRequestException("A valid email address is required"); + } + return email; + } + + if (!isValidPhoneNumber(raw)) { + throw new BadRequestException( + "A valid international phone number is required (E.164, e.g. +251911223344)", + ); + } + // Store the same canonical form the OTP is keyed by, so the code sent here + // is findable on verify regardless of how the number was typed. + return normalizeE164(raw) as string; + } + + private targetFor(channel: ContactChannel, value: string): OtpTarget { + return channel === ContactChannel.Email ? { email: value } : { phone: value }; + } + + /** + * `iam.users.email` and `.phone_number` are each independently UNIQUE, so a + * collision would otherwise surface as a raw 500 at write time. This is a + * courtesy check, not the guard — it races, so {@link asConflict} still has to + * catch the violation. + */ + private async assertNotTaken( + channel: ContactChannel, + value: string, + userId: string, + ): Promise { + const existing = await this.userRepository.findOne({ + where: + channel === ContactChannel.Email + ? { email: value } + : { phoneNumber: value }, + select: { id: true }, + }); + + if (existing && existing.id !== userId) { + throw this.takenError(channel); + } + } + + private asConflict(error: unknown, channel: ContactChannel): Error { + const code = (error as { code?: string } | null)?.code; + if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel); + return error as Error; + } + + private takenError(channel: ContactChannel): ConflictException { + return new ConflictException( + channel === ContactChannel.Email + ? "That email address is already registered to another account" + : "That phone number is already registered to another account", + ); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts new file mode 100644 index 000000000..363e39072 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; + +/** The contact channel being changed on the caller's own account. */ +export enum ContactChannel { + Email = "email", + Phone = "phone", +} + +export class SendContactOtpDto { + @ApiProperty({ enum: ContactChannel }) + @IsEnum(ContactChannel) + channel!: ContactChannel; + + @ApiProperty({ + description: + "The NEW email or phone to verify. The code is sent here, not to the " + + "address currently on the account — that is what proves the caller " + + "controls the number/inbox they are moving to.", + example: "+251911223344", + }) + @IsString() + @IsNotEmpty() + value!: string; +} + +export class UpdateContactDto extends SendContactOtpDto { + @ApiProperty({ description: "The 6-digit code sent to the new value" }) + @IsString() + @IsNotEmpty() + otp!: string; +} + +export class AccountNameDto { + @ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" }) + @IsString() + @IsNotEmpty() + am!: string; + + @ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" }) + @IsOptional() + @IsString() + en?: string; +} + +export class UpdateAccountNameDto { + @ApiProperty({ type: AccountNameDto }) + @IsObject() + @ValidateNested() + @Type(() => AccountNameDto) + name!: AccountNameDto; +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts index 42dc723d5..b357c2cfb 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -11,6 +11,7 @@ import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user import { OtpService, OtpTarget } from "../otp/otp.service"; import { ResetChannel } from "./dto/forgot-password.dto"; +import { maskOtpTarget } from "./mask-target.util"; /** * How long the reset ticket minted for `PATCH /api/auth/set-password` stays @@ -158,12 +159,6 @@ export class ForgotPasswordService { /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */ maskTarget(target: OtpTarget): string { - if (target.email) { - const [local, domain] = target.email.split("@"); - const head = local.slice(0, 1); - return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; - } - const phone = target.phone ?? ""; - return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; + return maskOtpTarget(target); } } diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 6415cf4c1..1a375d86f 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -1,11 +1,15 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity'; import { ExternalProfile } from '../companies/entities/external-profile.entity'; import { OtpModule } from '../otp/otp.module'; +import { AccountController } from './account.controller'; +import { AccountService } from './account.service'; import { CheckAvailabilityController } from './check-availability.controller'; import { CheckAvailabilityService } from './check-availability.service'; import { CustomerResetController } from './customer-reset.controller'; @@ -17,17 +21,25 @@ import { FreightMeService } from './freight-me.service'; @Module({ imports: [ - TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]), + TypeOrmModule.forFeature([ + User, + UserVerification, + ExternalProfile, + Session, + Employee, + ]), OtpModule, ], controllers: [ FreightMeController, + AccountController, CheckAvailabilityController, ForgotPasswordController, CustomerResetController, ], providers: [ FreightMeService, + AccountService, CheckAvailabilityService, ForgotPasswordService, CustomerResetService, diff --git a/apps/edr-freight-api/src/modules/auth/mask-target.util.ts b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts new file mode 100644 index 000000000..213a14656 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts @@ -0,0 +1,16 @@ +import { OtpTarget } from "../otp/otp.service"; + +/** + * Mask an OTP target for echoing back to the caller: `+251911234567` -> + * `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to + * a caller who has not yet proven possession of the channel. + */ +export function maskOtpTarget(target: OtpTarget): string { + if (target.email) { + const [local, domain] = target.email.split("@"); + const head = local.slice(0, 1); + return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; + } + const phone = target.phone ?? ""; + return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index a4546e301..1cef9528c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the booking lifecycle: review, clearance @@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(b: Booking): string { @@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); - const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const phone = b.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) + : null; const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; if (phone) { 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 167526988..43d4e9905 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 @@ -1,4 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; import { NotificationAudience, NotificationPriority, @@ -8,6 +10,7 @@ import { import { Company, CompanyStatus } from "./entities/company.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; /** Account statuses that lock the customer out and therefore must be told to them. */ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ @@ -28,11 +31,13 @@ export class CompanyNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} /** Send SMS + email to the company contact; log-only on failure. */ private async notifyContact(company: Company, message: string): Promise { - const phone = company.contactPersonPhone ?? company.phone ?? null; + const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id); const email = company.email ?? company.generalManagerEmail ?? null; if (phone) { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 575767a87..e35bd2bf5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Contract } from './entities/contract.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the contract lifecycle. Every customer @@ -24,6 +27,8 @@ export class ContractNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(c: Contract): string { @@ -37,7 +42,9 @@ export class ContractNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(c)}`); - const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null; + const phone = c.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, c.companyId) + : null; const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; if (phone) { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 60db03ff1..00c43b258 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -4,6 +4,8 @@ import { Injectable, Logger, } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { randomUUID } from 'node:crypto'; import { Readable } from 'stream'; import { insertWithGeneratedReference } from '@edr/api-common'; @@ -102,8 +104,41 @@ export class ContractTransitionService { private readonly notifier: ContractNotifierService, private readonly contractTemplates: ContractTemplatesService, private readonly clearanceFeeService: ClearanceFeeService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} + /** + * The phone the signing OTP is sent to and verified against: the signer's own + * IAM account number. + * + * H12(b): resolved server-side from the authenticated user id, never from the + * request body — a caller-supplied number would let an attacker point the code + * at their own phone. Ownership is already gated separately by + * {@link ContractsService.assertCustomerCanAccessContract}, so this binds the + * signature to the *person* signing rather than to a company landline that may + * be shared, stale, or imported from eTrade. + */ + private async resolveSignerPhone(signerUserId?: string): Promise { + if (!signerUserId) { + // Unreachable in practice (the ownership gate rejects a missing user + // first), but never fall back to another number if it ever changes. + throw new BadRequestException('Authentication required to sign'); + } + const rows: Array<{ phone_number: string | null }> = + await this.dataSource.query( + `SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`, + [signerUserId], + ); + const phone = rows[0]?.phone_number?.trim(); + if (!phone) { + throw new BadRequestException( + 'Your account has no registered phone number. Add one in Settings → Account before signing.', + ); + } + return phone; + } + /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ async submit(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); @@ -796,10 +831,10 @@ export class ContractTransitionService { } /** - * Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone — - * the same number {@link sign} verifies against. The client never picks the - * number (that is the H12(b) trust property): it only asks us to send, and we - * resolve the phone from the contract. Returns a masked hint so the UI can + * Send the sudo-mode signing OTP to the SIGNER's own registered phone — the + * same number {@link sign} verifies against. The client never picks the number + * (that is the H12(b) trust property): it only asks us to send, and we resolve + * the phone from the authenticated user id. Returns a masked hint so the UI can * say where the code went without exposing the full number. */ async sendSigningOtp( @@ -815,14 +850,9 @@ export class ContractTransitionService { ); assertContractStatus(contract, ['CONTRACT_READY']); - const companyPhone = contract.company?.phone?.trim(); - if (!companyPhone) { - throw new BadRequestException( - 'The contract company has no registered phone on file to send the signing OTP to', - ); - } - await this.otpService.sendOtp({ phone: companyPhone }); - return { sentTo: maskPhone(companyPhone) }; + const signerPhone = await this.resolveSignerPhone(options.signerUserId); + await this.otpService.sendOtp({ phone: signerPhone }); + return { sentTo: maskPhone(signerPhone) }; } /** Customer signs the ready contract → SIGNED_CUSTOMER. */ @@ -848,20 +878,18 @@ export class ContractTransitionService { throw new BadRequestException('Customer has already signed this contract'); } // Sudo-mode gate: a fresh, single-use OTP must be verified before the - // signature is applied. H12(b): verify against the CONTRACT COMPANY's - // registered phone — never the caller-supplied dto.otpPhone, which an - // attacker could point at their own phone to sign someone else's - // contract. The OTP is issued to the company's registered number. - const companyPhone = contract.company?.phone?.trim(); - if (!companyPhone) { - throw new BadRequestException( - 'The contract company has no registered phone on file to verify the signing OTP against', - ); - } + // signature is applied. H12(b): verify against the SIGNER's own registered + // phone, resolved server-side from the authenticated user id — never a + // caller-supplied number, which an attacker could point at their own + // phone. Ownership is already asserted above, so this proves the specific + // person holding the account is present, not merely that someone reached a + // shared company line. Must resolve identically to sendSigningOtp, or send + // and verify would target different numbers. + const signerPhone = await this.resolveSignerPhone(options.signerUserId); if (!dto.otp) { throw new BadRequestException('OTP verification is required to sign the contract'); } - await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp); + await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index f0676b629..5ddffad6c 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -28,17 +28,13 @@ export class SignContractDto { consentText?: string; // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code - // SMS'd to the signer's phone, verified server-side before the signature is - // applied. `otpPhone` is the number the code was sent to (the signed-in - // customer's registered phone). + // SMS'd to the signer's registered phone, verified server-side before the + // signature is applied. The number itself is deliberately NOT part of this + // DTO — the server resolves it from the authenticated user id, so a caller + // cannot redirect the challenge to a phone they control. @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) @IsOptional() @IsString() @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) otp?: string; - - @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) - @IsOptional() - @IsString() - otpPhone?: string; } diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts index 9d7f32c3d..9f121e18a 100644 --- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -1,6 +1,10 @@ import { DataSource } from 'typeorm'; import { NotificationsService } from './notifications.service'; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from './resolve-company-phone.util'; /** * Best-effort SMS + email fan-out to a company's contacts. Looks up the @@ -15,9 +19,10 @@ export async function sendCompanyChannels( ): Promise { const [contact]: Array<{ phone: string | null; email: string | null }> = await dataSource.query( - `SELECT COALESCE(phone, etrade_phone) AS phone, email - FROM freight.companies - WHERE id = $1 AND deleted_at IS NULL`, + `SELECT ${companyNotifyPhoneExpr('co')} AS phone, co.email + FROM freight.companies co + ${primaryContactUserJoin('co')} + WHERE co.id = $1 AND co.deleted_at IS NULL`, [companyId], ); if (contact?.phone) { diff --git a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts new file mode 100644 index 000000000..511f3cf8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts @@ -0,0 +1,60 @@ +import { DataSource, EntityManager } from "typeorm"; + +/** + * Where a customer-facing SMS actually goes. + * + * The person who signs up, logs in, and receives OTPs is an IAM user, and + * `iam.users.phone_number` is the number they control and can change themselves + * (see the account settings flow). A company's own `phone` is business contact + * data — often a landline, a shared desk, or a stale eTrade import — so it is + * the fallback, not the source. + * + * `companies.contact_person_phone` is deliberately NOT consulted: the live write + * path stores that value in the `attributes` jsonb and has never populated the + * column, so every reader of it was silently falling through to `phone` anyway. + */ + +/** + * LEFT JOIN a company alias to its primary contact's IAM user, exposing + * `pc.phone_number`. + * + * LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a + * company having two `is_primary_contact` rows, and a plain join would then + * duplicate the company row — which in a fan-out query means sending the same + * customer the same SMS twice. + * + * `alias` is always a code-controlled literal, never caller input. + */ +export function primaryContactUserJoin(alias: string): string { + return ` + LEFT JOIN LATERAL ( + SELECT u.phone_number + FROM freight.external_profiles ep + JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true + WHERE ep.company_id = ${alias}.id + AND ep.is_primary_contact = true + AND ep.deleted_at IS NULL + ORDER BY ep.created_at + LIMIT 1 + ) pc ON true`; +} + +/** SQL expression for the company's SMS number, given the joined `pc` alias. */ +export function companyNotifyPhoneExpr(alias: string): string { + return `COALESCE(pc.phone_number, ${alias}.phone)`; +} + +/** The SMS number for one company, or null when neither source has one. */ +export async function resolveCompanyNotifyPhone( + db: DataSource | EntityManager, + companyId: string, +): Promise { + const rows: Array<{ phone: string | null }> = await db.query( + `SELECT ${companyNotifyPhoneExpr("co")} AS phone + FROM freight.companies co + ${primaryContactUserJoin("co")} + WHERE co.id = $1 AND co.deleted_at IS NULL`, + [companyId], + ); + return rows[0]?.phone ?? null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index f3d7697f6..3d505e113 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationPriority, @@ -9,6 +11,7 @@ import { import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { BATCH_TIMEZONE } from './booking-batch.constants'; @@ -20,6 +23,8 @@ export class BookingNotifierService { private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, private readonly trainSchedules: TrainSchedulesRepository, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} /** @@ -60,7 +65,9 @@ export class BookingNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); - const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const phone = b.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) + : null; const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; if (phone) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index f728afe6a..69f64fdf4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -13,6 +13,10 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from '../notifications/resolve-company-phone.util'; import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; @@ -527,7 +531,7 @@ export class BookingWindowService implements OnModuleInit { }> = await this.dataSource.query( `SELECT DISTINCT c.company_id, - COALESCE(co.contact_person_phone, co.phone) AS phone, + ${companyNotifyPhoneExpr('co')} AS phone, COALESCE(co.email, co.general_manager_email) AS email FROM freight.contract_routes cr JOIN freight.contracts c @@ -535,6 +539,7 @@ export class BookingWindowService implements OnModuleInit { AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') AND c.deleted_at IS NULL JOIN freight.companies co ON co.id = c.company_id + ${primaryContactUserJoin('co')} WHERE cr.origin_yard_id = $1 AND cr.destination_yard_id = $2 AND cr.deleted_at IS NULL`, 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 6dd941d26..aa04fb447 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 @@ -13,6 +13,10 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte import { LastMileService } from '../last-mile/last-mile.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from '../notifications/resolve-company-phone.util'; import { SignaturesService } from '../signatures/signatures.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; @@ -1145,7 +1149,7 @@ export class WarehouseInventoryService { b.company_id AS "customerId", company.name AS "customer", company.tin AS "customerTin", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber", bcu.seal_numbers AS "sealNumbers", bc.container_quantity AS "containerQuantity", @@ -1183,6 +1187,7 @@ export class WarehouseInventoryService { b.customer_truck_assigned_at AS "customerTruckAssignedAt" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id @@ -1288,7 +1293,7 @@ export class WarehouseInventoryService { b.cargo_total_weight_vgm AS "weight", company.name AS "customer", company.tin AS "customerTin", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", bc.container_numbers AS "containerNumber", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", @@ -1317,6 +1322,7 @@ export class WarehouseInventoryService { OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.service_types st ON st.id = b.service_type_id @@ -4765,7 +4771,7 @@ export class WarehouseInventoryService { `SELECT b.reference AS "reference", company.name AS "customer", company.tin AS "customerTin", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", b.cargo_total_weight_vgm AS "weight", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription", bc.container_numbers AS "containerNumber", @@ -4782,6 +4788,7 @@ export class WarehouseInventoryService { v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 2508e373a..6bf03c93d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -25,6 +25,10 @@ import { InvoiceDocumentService, } from "../billing/documents/invoice-document.service"; import { NotificationsService } from "../notifications/notifications.service"; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from "../notifications/resolve-company-phone.util"; import { WarehouseFeeService } from "./warehouse-fee.service"; import { WarehouseFeeInvoiceView, @@ -879,7 +883,7 @@ export class WarehouseInvoiceService { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", COALESCE( NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''), last_vehicle.assigned_driver_name, @@ -892,6 +896,7 @@ export class WarehouseInvoiceService { FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index f0b3ee5a6..efd6af448 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -19,6 +19,15 @@ export const URL_CONSTANTS = { CHECK_AVAILABILITY: "/api/auth/check-availability", }, + // The signed-in user's own account record. Distinct from COMPANIES_API.PROFILE, + // which is the company's business profile — these are the identity fields that + // OTPs and SMS notifications are actually delivered to. + ACCOUNT: { + CONTACT_OTP: "/api/me/contact/otp", + CONTACT: "/api/me/contact", + NAME: "/api/me/name", + }, + OTP: { SEND: "/api/otp/send", VERIFY: "/api/otp/verify", diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 5f6cf88d8..00b9c3043 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -29,17 +29,20 @@ import { ShieldCheck, User, UserCheck, + UserCog, } from "lucide-react"; import { useCallback, useEffect } from "react"; import { useSearchParams } from "react-router-dom"; +import useAuth from "@/hooks/useAuth"; import { rolesForCompanyType } from "./settings/companyRoles"; +import TabAccount from "./settings/TabAccount"; import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabContactPerson from "./settings/TabContactPerson"; import TabDocuments from "./settings/TabDocuments"; import TabGeneralManager from "./settings/TabGeneralManager"; import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; -type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents"; +type SettingsTab = "account" | "company" | "contact" | "gm" | "poa" | "documents"; /** A section is "incomplete" when its required fields aren't filled in yet. */ function tabIncomplete( @@ -62,6 +65,9 @@ function tabIncomplete( !profile.generalManagerEmail || !profile.generalManagerPhone ); + case "account": + // Account fields live on the IAM user, not the company profile, and are + // always populated (signup requires them) — nothing to nag about here. case "poa": case "documents": return false; @@ -69,6 +75,7 @@ function tabIncomplete( } const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ + { id: "account", label: "Account", icon: }, { id: "company", label: "Company", icon: }, { id: "contact", label: "Contact Person", icon: }, { id: "gm", label: "General Manager", icon: }, @@ -175,6 +182,7 @@ function ProfileHeader({ profile }: { profile: ProfileResponse }) { export default function SettingsPage() { const queryClient = useQueryClient(); + const { user } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const tab = (searchParams.get("tab") as SettingsTab) || "company"; @@ -313,6 +321,21 @@ export default function SettingsPage() { ))} + {/* Deliberately NOT wrapped in the `locked` fieldset below: that lock + is for company-profile edits awaiting review. Account identity is + the user's own login/notification details — they must stay editable + even mid-review, or a customer whose phone changed while pending + would be locked out of their own OTPs. */} + + {user ? ( + + ) : ( +
+ +
+ )} +
+ {/* While a change request is pending, every panel's inputs + submit buttons are disabled via the native fieldset; tab switching stays enabled so the customer can still review what they submitted. */} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index 99bbf6332..d47151774 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -56,11 +56,10 @@ export default function ContractViewPage() { const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false); - // The signing OTP goes to the CONTRACT COMPANY's registered phone (the number - // the server verifies against), NOT the signed-in user's — those can differ, - // and sending to the user's phone left the code filed under a number verify - // never checks. The server owns the number; we only get back a masked hint of - // where it landed. + // The signing OTP goes to the signed-in user's own registered phone, resolved + // server-side from their account (the same number the server verifies + // against). The client never picks the number, so send and verify can't + // disagree; we only get back a masked hint of where it landed. const [otpSentTo, setOtpSentTo] = useState(null); const { data, isLoading, isError, refetch } = useQuery({ diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx new file mode 100644 index 000000000..87916f38c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx @@ -0,0 +1,374 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CheckCircle2, Save, ShieldCheck, UserCog, XCircle } from "lucide-react"; +import { + Alert, + Button, + Card, + Group, + Modal, + PinInput, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { api } from "@/services/api"; +import { + ControlledPhoneField, + isValidPhone, + toEthiopianE164, +} from "@/components/PhoneField"; +import type { AuthUser, ContactChannel } from "@/types/auth"; + +const schema = z.object({ + phoneNumber: z + .string() + .min(1, "Phone number is required") + .refine(isValidPhone, "Enter a valid phone number"), + email: z.string().min(1, "Email is required").email("Enter a valid email"), + nameEn: z.string().min(1, "Name is required"), + nameAm: z.string().min(1, "Amharic name is required"), +}); + +type FormData = z.infer; + +/** A contact change that still needs its code entered. */ +interface PendingChange { + channel: ContactChannel; + value: string; +} + +const CHANNEL_LABEL: Record = { + phone: "phone number", + email: "email address", +}; + +const normaliseEmail = (v: string) => v.trim().toLowerCase(); + +interface TabAccountProps { + user: AuthUser; +} + +/** + * The signed-in user's own account — the phone and email that OTPs and SMS + * notifications are actually delivered to. Distinct from the company profile + * tabs, which hold business contact details for the organisation. + * + * Changing phone or email is verified: the server sends a code to the NEW value + * and only writes it once the code comes back, so a typo'd number can never + * silently take over the account's notifications. Each code is bound to a single + * channel, so changing both walks the user through one verification per channel. + */ +export default function TabAccount({ user }: TabAccountProps) { + const queryClient = useQueryClient(); + // Head of the queue is the change currently being verified. Changing phone AND + // email in one save enqueues both — a code proves one channel, never two. + const [queue, setQueue] = useState([]); + const [sentTo, setSentTo] = useState(null); + const [completed, setCompleted] = useState([]); + const [otp, setOtp] = useState(""); + + const current = queue[0] ?? null; + const step = completed.length + 1; + const totalSteps = completed.length + queue.length; + + const defaultValues = useMemo( + (): FormData => ({ + // Normalise to the same E.164 shape the phone input emits. Some accounts + // store a local `0911…`; comparing raw against the field's `+2519…` would + // read as "changed" on every save and hijack the email's turn. + phoneNumber: toEthiopianE164(user.phoneNumber), + email: user.email ?? "", + nameEn: user.name?.en ?? "", + nameAm: user.name?.am ?? "", + }), + [user], + ); + + const { + register, + control, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + values: defaultValues, + // Verifying one channel refetches the user, which re-syncs `values`. Without + // keepDirtyValues that resync would silently discard an edit the user has + // typed into the *other* field but not yet verified. + resetOptions: { keepDirtyValues: true }, + }); + + const refreshUser = () => + queryClient.invalidateQueries({ queryKey: api.auth.getMyInfo.queryKey() }); + + /** Name needs no proof of possession, so it saves straight through. */ + const nameMutation = useMutation({ + mutationFn: (data: FormData) => + api.account.updateName.call({ name: { en: data.nameEn, am: data.nameAm } }), + onSuccess: refreshUser, + }); + + /** Step 1 of a contact change: ask the server to code the new value. */ + const otpMutation = useMutation({ + mutationFn: (change: PendingChange) => api.account.sendContactOtp.call(change), + onSuccess: (res) => { + setOtp(""); + setSentTo(res.sentTo); + }, + onError: () => { + // Could not even send — drop the flow rather than strand the user in a + // modal asking for a code that was never issued. + setQueue([]); + setSentTo(null); + }, + }); + + /** Step 2: hand the code back; the server verifies and writes atomically. */ + const contactMutation = useMutation({ + mutationFn: (body: PendingChange & { otp: string }) => + api.account.updateContact.call(body), + onSuccess: async (_res, body) => { + setOtp(""); + setSentTo(null); + setCompleted((prev) => [...prev, body.channel]); + await refreshUser(); + + // Advance to the next queued channel, keeping the modal open so a + // both-changed save is one continuous flow. + const rest = queue.slice(1); + setQueue(rest); + if (rest[0]) otpMutation.mutate(rest[0]); + }, + }); + + const startQueue = (changes: PendingChange[]) => { + setCompleted([]); + setQueue(changes); + otpMutation.mutate(changes[0]); + }; + + const cancelQueue = () => { + setQueue([]); + setSentTo(null); + setOtp(""); + contactMutation.reset(); + }; + + const onSubmit = (data: FormData) => { + setCompleted([]); + + const changes: PendingChange[] = []; + if (toEthiopianE164(data.phoneNumber) !== defaultValues.phoneNumber) { + changes.push({ channel: "phone", value: data.phoneNumber }); + } + if (normaliseEmail(data.email) !== normaliseEmail(defaultValues.email)) { + changes.push({ channel: "email", value: normaliseEmail(data.email) }); + } + + // Name carries no verification, so it saves alongside rather than queueing. + if ( + data.nameEn !== defaultValues.nameEn || + data.nameAm !== defaultValues.nameAm + ) { + nameMutation.mutate(data); + } + + if (changes.length) startQueue(changes); + }; + + const errorMessage = (err: unknown): string => { + const res = (err as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data?.message; + if (Array.isArray(res)) return res[0]; + return res ?? "Something went wrong. Please try again."; + }; + + const busy = + otpMutation.isPending || contactMutation.isPending || nameMutation.isPending; + + const savedSummary = + completed.length && !queue.length + ? `Your ${completed.map((c) => CHANNEL_LABEL[c]).join(" and ")} ${ + completed.length > 1 ? "were" : "was" + } verified and updated` + : null; + + return ( + + + + Account + + + Your login details. Verification codes and SMS notifications are sent to + the phone number below. + + +
+ + + + + + + + + + + + Changing your phone number or email requires a verification code sent + to the new one. + + + + + {savedSummary && ( + + + {savedSummary} + + )} + {nameMutation.isSuccess && !savedSummary && !queue.length && ( + + + Saved successfully + + )} + {(otpMutation.isError || nameMutation.isError) && ( + + + + {errorMessage(otpMutation.error ?? nameMutation.error)} + + + )} + + + + + + +
+ + + {current && ( + + {totalSteps > 1 && ( + + Step {step} of {totalSteps} + + )} + + } color="blue"> + {sentTo ? ( + <> + We sent a 6-digit code to {sentTo}. Enter it to confirm + your new {CHANNEL_LABEL[current.channel]}. + + ) : ( + <>Sending a code to your new {CHANNEL_LABEL[current.channel]}… + )} + + + {completed.length > 0 && queue.length > 0 && ( + + + + {CHANNEL_LABEL[completed[completed.length - 1]]} updated — one + more to confirm. + + + )} + + + + {contactMutation.isError && ( + + + {errorMessage(contactMutation.error)} + + )} + + + + + + + )} + +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index a82945ecf..c5d632dbd 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -81,6 +81,11 @@ import type { ForgotPasswordRequestPayload, ForgotPasswordVerifyPayload, ResetTicket, + SendContactOtpPayload, + SendContactOtpResponse, + UpdateAccountNamePayload, + UpdateContactPayload, + UpdateContactResponse, } from "@/types/auth"; // --------------------------------------------------------------------------- @@ -147,6 +152,26 @@ export const api = { logout: endpoint("auth", "logout", authService.logout), }, + // The signed-in user's own IAM account — the phone/email that OTPs and SMS + // actually go to. Separate from `companies`, which is business profile data. + account: { + sendContactOtp: endpoint( + "account", + "sendContactOtp", + authService.sendContactOtp, + ), + updateContact: endpoint( + "account", + "updateContact", + authService.updateContact, + ), + updateName: endpoint( + "account", + "updateName", + authService.updateAccountName, + ), + }, + companies: { getInfo: endpoint( "companies", diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 3b113878d..032ce0277 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -11,11 +11,17 @@ import type { OtpPayload, OtpResponse, ResetTicket, + SendContactOtpPayload, + SendContactOtpResponse, SetPasswordPayload, SignupPayload, SignupResponse, + UpdateAccountNamePayload, + UpdateContactPayload, + UpdateContactResponse, } from "@/types/auth"; import { client } from "@/utils/api"; +import { unwrap } from "@/utils/endpoint"; import { ApiResponse } from "@edr/types"; export const authService = { @@ -105,6 +111,35 @@ export const authService = { return res.data.data; }, + // The three calls below manage the signed-in user's own account record + // (`/api/me`), which is what OTPs and SMS notifications are delivered to. + // Changing phone/email is OTP-gated server-side: the code goes to the NEW + // value, and the write only lands once it is verified. + + sendContactOtp: async (body: SendContactOtpPayload) => { + const res = await client.post>( + URL_CONSTANTS.ACCOUNT.CONTACT_OTP, + body, + ); + return unwrap(res.data); + }, + + updateContact: async (body: UpdateContactPayload) => { + const res = await client.patch>( + URL_CONSTANTS.ACCOUNT.CONTACT, + body, + ); + return unwrap(res.data); + }, + + updateAccountName: async (body: UpdateAccountNamePayload) => { + const res = await client.patch>( + URL_CONSTANTS.ACCOUNT.NAME, + body, + ); + return unwrap(res.data); + }, + refreshToken: async () => { const refreshTokenCookie = document.cookie .split("; ") diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 910ba89a0..eb69fa0bf 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -128,8 +128,6 @@ export interface SignContractPayload { consentText?: string; /** Sudo-mode OTP challenge; required when role=CUSTOMER. */ otp?: string; - /** Phone the OTP was sent to; required when role=CUSTOMER. */ - otpPhone?: string; } export interface ApproveDeliveryResponse { diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index b80c3ce36..606fad48a 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -268,9 +268,10 @@ export const contractsService = { return data.data ?? data; }, - // Ask the server to send the signing OTP to the CONTRACT COMPANY's registered - // phone. The client never picks the number (the server verifies against the - // same one), so send and verify can't disagree. Returns a masked hint. + // Ask the server to send the signing OTP to the signer's own registered phone. + // The client never picks the number (the server resolves it from the + // authenticated user and verifies against the same one), so send and verify + // can't disagree. Returns a masked hint. sendSigningOtp: async (id: string): Promise<{ sentTo: string }> => { const { data } = await client.post(C.CONTRACT_SEND_SIGNING_OTP(id)); return data.data ?? data; diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 2e9a0b611..54df04f0a 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -45,6 +45,34 @@ export interface OtpResponse { message: string; } +/** The contact channel being changed on the signed-in user's own account. */ +export type ContactChannel = "email" | "phone"; + +export interface SendContactOtpPayload { + channel: ContactChannel; + /** The NEW value being moved to — the code is sent here, not to the old one. */ + value: string; +} + +export interface SendContactOtpResponse { + /** Masked hint of where the code landed, e.g. `+251•••••4567`. */ + sentTo: string; +} + +export interface UpdateContactPayload extends SendContactOtpPayload { + otp: string; +} + +export interface UpdateContactResponse { + success: true; + /** The canonical stored value (E.164 for phone, lowercased for email). */ + value: string; +} + +export interface UpdateAccountNamePayload { + name: { am: string; en?: string }; +} + export interface CheckAvailabilityPayload { email?: string; phone?: string; From e2f42136a859864ba81620cbf1eff44413991550 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 16 Jul 2026 12:37:52 +0000 Subject: [PATCH 02/18] fix: add password form to account --- .../portal/src/constants/URLS.ts | 1 + .../src/pages/settings/ChangePasswordCard.tsx | 165 ++++++++ .../portal/src/pages/settings/TabAccount.tsx | 351 ++++++++++-------- .../portal/src/services/api.ts | 6 + .../portal/src/services/auth.service.ts | 10 + apps/edr-freight-web/portal/src/types/auth.ts | 11 + 6 files changed, 381 insertions(+), 163 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index efd6af448..c920e20d8 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -5,6 +5,7 @@ export const URL_CONSTANTS = { REFRESH_TOKEN: "/api/auth/refresh-token", LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", + CHANGE_PASSWORD: "/api/auth/change-password", FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request", FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify", }, diff --git a/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx new file mode 100644 index 000000000..eebfd50c1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx @@ -0,0 +1,165 @@ +import { useMutation } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CheckCircle2, KeyRound, Save, XCircle } from "lucide-react"; +import { + Button, + Card, + Group, + PasswordInput, + Stack, + Text, + Title, +} from "@mantine/core"; +import { api } from "@/services/api"; + +/** + * Mirrors IAM's own `IsStrongPassword` rule on ChangePasswordDto — minLength 8, + * ≥1 lowercase, ≥1 number, ≥1 symbol, uppercase NOT required. Kept in step with + * the server so the user gets a precise message inline instead of a generic 400. + */ +const strongPassword = z + .string() + .min(8, "At least 8 characters") + .regex(/[a-z]/, "Include a lowercase letter") + .regex(/\d/, "Include a number") + .regex(/[^A-Za-z0-9]/, "Include a symbol"); + +const schema = z + .object({ + oldPassword: z.string().min(1, "Current password is required"), + newPassword: strongPassword, + confirmPassword: z.string().min(1, "Confirm your new password"), + }) + .refine((d) => d.newPassword === d.confirmPassword, { + path: ["confirmPassword"], + message: "Passwords do not match", + }) + .refine((d) => d.newPassword !== d.oldPassword, { + path: ["newPassword"], + message: "New password must be different from your current one", + }); + +type FormData = z.infer; + +/** IAM returns bare error codes; turn them into something a customer can act on. */ +const MESSAGES: Record = { + unable_to_change_password: "Your current password is incorrect.", + new_password_same_as_old: + "New password must be different from your current one.", + new_passwords_do_not_match: "The new passwords do not match.", + user_credentials_not_found: "This account has no password set.", +}; + +/** + * Password changes go straight to IAM's `PATCH /api/auth/change-password`, which + * verifies the old password and owns the credential write (argon hashing, + * retiring the previous credential). The freight app deliberately implements no + * part of that — it only collects the fields. + */ +export default function ChangePasswordCard() { + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { oldPassword: "", newPassword: "", confirmPassword: "" }, + }); + + const mutation = useMutation({ + mutationFn: (data: FormData) => api.auth.changePassword.call(data), + // Never leave the old password sitting in component state after a change. + onSuccess: () => reset(), + }); + + const errorMessage = (err: unknown): string => { + const raw = ( + err as { response?: { data?: { message?: string | string[] } } } + )?.response?.data?.message; + const code = Array.isArray(raw) ? raw[0] : raw; + if (!code) return "Could not change your password. Please try again."; + return MESSAGES[code] ?? code; + }; + + return ( + + + + Password + + + Change the password you use to sign in. You'll need your current one. + + +
mutation.mutate(d))}> + + + + + + + + + {mutation.isSuccess && ( + + + + Password changed + + + )} + {mutation.isError && ( + + + + {errorMessage(mutation.error)} + + + )} + + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx index 87916f38c..68d41727c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx @@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { CheckCircle2, Save, ShieldCheck, UserCog, XCircle } from "lucide-react"; +import { + CheckCircle2, + Save, + ShieldCheck, + UserCog, + XCircle, +} from "lucide-react"; import { Alert, Button, @@ -23,6 +29,7 @@ import { toEthiopianE164, } from "@/components/PhoneField"; import type { AuthUser, ContactChannel } from "@/types/auth"; +import ChangePasswordCard from "./ChangePasswordCard"; const schema = z.object({ phoneNumber: z @@ -110,13 +117,16 @@ export default function TabAccount({ user }: TabAccountProps) { /** Name needs no proof of possession, so it saves straight through. */ const nameMutation = useMutation({ mutationFn: (data: FormData) => - api.account.updateName.call({ name: { en: data.nameEn, am: data.nameAm } }), + api.account.updateName.call({ + name: { en: data.nameEn, am: data.nameAm }, + }), onSuccess: refreshUser, }); /** Step 1 of a contact change: ask the server to code the new value. */ const otpMutation = useMutation({ - mutationFn: (change: PendingChange) => api.account.sendContactOtp.call(change), + mutationFn: (change: PendingChange) => + api.account.sendContactOtp.call(change), onSuccess: (res) => { setOtp(""); setSentTo(res.sentTo); @@ -183,14 +193,17 @@ export default function TabAccount({ user }: TabAccountProps) { }; const errorMessage = (err: unknown): string => { - const res = (err as { response?: { data?: { message?: string | string[] } } }) - ?.response?.data?.message; + const res = ( + err as { response?: { data?: { message?: string | string[] } } } + )?.response?.data?.message; if (Array.isArray(res)) return res[0]; return res ?? "Something went wrong. Please try again."; }; const busy = - otpMutation.isPending || contactMutation.isPending || nameMutation.isPending; + otpMutation.isPending || + contactMutation.isPending || + nameMutation.isPending; const savedSummary = completed.length && !queue.length @@ -200,175 +213,187 @@ export default function TabAccount({ user }: TabAccountProps) { : null; return ( - - - - Account - - - Your login details. Verification codes and SMS notifications are sent to - the phone number below. - - -
- - - - - - - - - - - - Changing your phone number or email requires a verification code sent - to the new one. + + + + + Account + + + Your login details. Verification codes and SMS notifications are sent + to the phone number below. - - - {savedSummary && ( - - - {savedSummary} - - )} - {nameMutation.isSuccess && !savedSummary && !queue.length && ( - - - Saved successfully - - )} - {(otpMutation.isError || nameMutation.isError) && ( - - - - {errorMessage(otpMutation.error ?? nameMutation.error)} - - - )} - - - - - - - - - - {current && ( +
- {totalSteps > 1 && ( - - Step {step} of {totalSteps} - - )} - - } color="blue"> - {sentTo ? ( - <> - We sent a 6-digit code to {sentTo}. Enter it to confirm - your new {CHANNEL_LABEL[current.channel]}. - - ) : ( - <>Sending a code to your new {CHANNEL_LABEL[current.channel]}… - )} - - - {completed.length > 0 && queue.length > 0 && ( - - - - {CHANNEL_LABEL[completed[completed.length - 1]]} updated — one - more to confirm. - - - )} - - - {contactMutation.isError && ( - - - {errorMessage(contactMutation.error)} - - )} + - + + + + + + + Changing your phone number or email requires a verification code + sent to the new one. + + + + + {savedSummary && ( + + + + {savedSummary} + + + )} + {nameMutation.isSuccess && !savedSummary && !queue.length && ( + + + + Saved successfully + + + )} + {(otpMutation.isError || nameMutation.isError) && ( + + + + {errorMessage(otpMutation.error ?? nameMutation.error)} + + + )} + + - - )} - - + +
+ + + {current && ( + + {totalSteps > 1 && ( + + Step {step} of {totalSteps} + + )} + + } color="blue"> + {sentTo ? ( + <> + We sent a 6-digit code to {sentTo}. Enter it to + confirm your new {CHANNEL_LABEL[current.channel]}. + + ) : ( + <> + Sending a code to your new {CHANNEL_LABEL[current.channel]}… + + )} + + + {completed.length > 0 && queue.length > 0 && ( + + + + {CHANNEL_LABEL[completed[completed.length - 1]]} updated — + one more to confirm. + + + )} + + + + {contactMutation.isError && ( + + + {errorMessage(contactMutation.error)} + + )} + + + + + + + )} + +
+ + +
); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index c5d632dbd..a984a49ae 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -68,6 +68,7 @@ import type { import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { AuthUser, + ChangePasswordPayload, CheckAvailabilityPayload, CheckAvailabilityResponse, GenerateVerificationCodePayload, @@ -149,6 +150,11 @@ export const api = { "verifyOTP", authService.verifyOTP, ), + changePassword: endpoint( + "auth", + "changePassword", + authService.changePassword, + ), logout: endpoint("auth", "logout", authService.logout), }, diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 032ce0277..f72bbfaf9 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -1,6 +1,7 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import type { AuthUser, + ChangePasswordPayload, CheckAvailabilityPayload, CheckAvailabilityResponse, ForgotPasswordRequestPayload, @@ -111,6 +112,15 @@ export const authService = { return res.data.data; }, + /** + * Change the signed-in user's password via IAM's own route: it verifies the + * old password with argon and owns the credential write (deactivating the + * previous one), so this app never touches password material. + */ + changePassword: async (body: ChangePasswordPayload) => { + await client.patch(URL_CONSTANTS.AUTH.CHANGE_PASSWORD, body); + }, + // The three calls below manage the signed-in user's own account record // (`/api/me`), which is what OTPs and SMS notifications are delivered to. // Changing phone/email is OTP-gated server-side: the code goes to the NEW diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 54df04f0a..0dadac608 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -45,6 +45,17 @@ export interface OtpResponse { message: string; } +/** + * Change the signed-in user's password. The old password is the proof of + * possession — IAM verifies it server-side and owns the credential write, so the + * freight app never hashes or stores a password itself. + */ +export interface ChangePasswordPayload { + oldPassword: string; + newPassword: string; + confirmPassword: string; +} + /** The contact channel being changed on the signed-in user's own account. */ export type ContactChannel = "email" | "phone"; From e3075d1486161bc7773fece50fcb10b3c92a3e11 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 16 Jul 2026 19:28:00 +0300 Subject: [PATCH 03/18] Seats holding, booking, blocking inconsistencies resolution --- apps/edr-passenger-api/prisma/schema.prisma | 2 + .../src/modules/seats/seats.controller.ts | 8 +- .../src/modules/seats/seats.service.ts | 113 ++++++++++-------- .../backoffice/src/app/seats/page.tsx | 12 +- .../backoffice/src/lib/api/index.ts | 2 +- 5 files changed, 78 insertions(+), 59 deletions(-) diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index cef7d0033..d2dc44751 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1312,6 +1312,7 @@ model NotificationTemplate { model SeatBlock { id String @id @default(uuid()) seatId String + scheduleId String? reason String blockedBy String approvedBy String? @@ -1320,6 +1321,7 @@ model SeatBlock { seat Seat @relation(fields: [seatId], references: [id]) @@index([seatId]) + @@index([scheduleId]) @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 752b66390..596c88bb3 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -211,8 +211,8 @@ This makes it clear which segment of the route each seat is held for, enabling s @ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" }) @ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiResponse({ status: 200, description: "Seat blocked" }) - blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string }) { - return this.service.blockSeat(seatId, body.reason); + blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string; scheduleId?: string }) { + return this.service.blockSeat(seatId, body.reason, body.scheduleId); } @Delete(":seatId/block") @@ -221,8 +221,8 @@ This makes it clear which segment of the route each seat is held for, enabling s @ApiOperation({ summary: "Unblock a seat" }) @ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiResponse({ status: 200, description: "Seat unblocked" }) - unblockSeat(@Param("seatId") seatId: string) { - return this.service.unblockSeat(seatId); + unblockSeat(@Param("seatId") seatId: string, @Query("scheduleId") scheduleId?: string) { + return this.service.unblockSeat(seatId, scheduleId); } // ── Maintenance ─────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 340900fdd..10368f380 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -224,7 +224,7 @@ export class SeatsService { } } - const [availability, persistedSeats] = await Promise.all([ + const [availability, persistedSeats, scheduleBlocks] = await Promise.all([ this.segmentsService.getSeatAvailabilityMap( scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY, ), @@ -232,16 +232,23 @@ export class SeatsService { where: { id: { in: seatIds } }, select: { id: true, status: true }, }), + this.prisma.seatBlock.findMany({ + where: { seatId: { in: seatIds }, scheduleId }, + select: { seatId: true }, + }), ]); const persistedStatus = new Map(persistedSeats.map(s => [s.id, s.status])); + const scheduleBlockedIds = new Set(scheduleBlocks.map(b => b.seatId)); for (const seatId of seatIds) { const persisted = persistedStatus.get(seatId); - // BLOCKED and UNDER_MAINTENANCE are cross-schedule flags set by admins — - // always honour them regardless of hold/booking state. + // Global BLOCKED/UNDER_MAINTENANCE (no scheduleId) — always honour if ((persisted as string) === 'BLOCKED' || (persisted as string) === 'UNDER_MAINTENANCE') { statusMap.set(seatId, persisted!); + } else if (scheduleBlockedIds.has(seatId)) { + // Schedule-scoped block — only blocked for this schedule + statusMap.set(seatId, 'BLOCKED'); } else { statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE'); } @@ -291,15 +298,12 @@ export class SeatsService { throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`); } - // Only the raw BLOCKED status (seat pulled out of service — a genuine - // cross-schedule flag) is trusted here. BOOKED is intentionally NOT checked - // against this raw column: the same physical Seat row is reused across every - // recurring date a coach runs, and Seat.status only resets to AVAILABLE via a - // trip-completion event that isn't guaranteed to fire, so a stale BOOKED value - // here would wrongly block a seat that's actually free for this schedule/leg. - // The schedule- and leg-scoped SeatHold/JourneySegment checks below are the - // authoritative source for whether a seat is actually taken. - const blocked = seats.filter(s => s.status === 'BLOCKED'); + // Only the raw BLOCKED/UNDER_MAINTENANCE status (seat pulled out of service — + // a genuine cross-schedule flag) is checked here. Seat.status is never written + // for holds/bookings because coaches are reused across schedules; the + // schedule-scoped SeatHold/JourneySegment checks below are the authoritative + // source for whether a seat is taken on this specific schedule/leg. + const blocked = seats.filter(s => s.status === 'BLOCKED' || (s.status as string) === 'UNDER_MAINTENANCE'); if (blocked.length > 0) throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`); @@ -400,11 +404,6 @@ export class SeatsService { passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })), }; - await tx.seat.updateMany({ - where: { id: { in: seatIds } }, - data: { status: 'HELD' }, - }); - return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, @@ -545,13 +544,7 @@ export class SeatsService { async releaseHold(holdId: string) { const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } }); if (!hold) throw new NotFoundException('Hold not found'); - await this.prisma.$transaction([ - this.prisma.seat.updateMany({ - where: { id: { in: hold.seatIds as string[] }, status: 'HELD' }, - data: { status: 'AVAILABLE' }, - }), - this.prisma.seatHold.delete({ where: { id: holdId } }), - ]); + await this.prisma.seatHold.delete({ where: { id: holdId } }); return { released: true, holdId }; } @@ -657,21 +650,41 @@ export class SeatsService { } async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + const seats = await this.prisma.seat.findMany({ where: { coach: { assignments: { some: { scheduleId } } }, - status: 'AVAILABLE', seatNumber: { not: '' }, - NOT: { seatNumber: { startsWith: '-' } }, + NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }], }, orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], }); - if (seats.length < count) { - throw new ConflictException(`Only ${seats.length} seats available, requested ${count}`); + const allSeatIds = seats.map(s => s.id); + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId }, + select: { stationId: true, sequence: true }, + }); + const seqOf = (id: string) => stopTimes.find(s => s.stationId === id)?.sequence; + const reqFrom = seqOf(schedule.originStationId) ?? 0; + const reqTo = seqOf(schedule.destinationStationId) ?? stopTimes.length; + + const unavailable = await this.segmentsService.getSeatAvailabilityMap( + scheduleId, allSeatIds, stopTimes, reqFrom, reqTo, + ); + + const availableSeats = seats.filter(s => !unavailable.has(s.id)); + + if (availableSeats.length < count) { + throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`); } - const assigned = this.findContiguousSeats(seats, count); + const assigned = this.findContiguousSeats(availableSeats, count); return assigned.map((s) => s.id); } @@ -774,24 +787,34 @@ export class SeatsService { return { imported, errors: errors.slice(0, 10) }; } - async blockSeat(seatId: string, reason: string) { + async blockSeat(seatId: string, reason: string, scheduleId?: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } }); - await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } }); - await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason } }); - return { blocked: true, seatId, reason }; + // Schedule-scoped block: only affects this schedule, not all schedules + // Global block (no scheduleId): sets Seat.status = BLOCKED for all schedules + if (scheduleId) { + await this.prisma.seatBlock.create({ data: { seatId, scheduleId, reason, blockedBy: 'system' } }); + } else { + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } }); + await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } }); + } + await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason, scheduleId } }); + return { blocked: true, seatId, reason, scheduleId }; } - async unblockSeat(seatId: string) { + async unblockSeat(seatId: string, scheduleId?: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } }); - await this.prisma.seatBlock.deleteMany({ where: { seatId } }); - await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE' } }); - return { unblocked: true, seatId }; + if (scheduleId) { + await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId } }); + } else { + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } }); + await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId: null } }); + } + await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE', scheduleId } }); + return { unblocked: true, seatId, scheduleId }; } async setMaintenance(seatId: string, reason: string) { @@ -929,22 +952,12 @@ export class SeatsService { } } - if (releasedSeatIds.size > 0) { - await this.prisma.seat.updateMany({ - where: { id: { in: Array.from(releasedSeatIds) }, status: 'HELD' }, - // heldUntil is cleared alongside status — leaving a stale (past) heldUntil on an - // AVAILABLE seat is stale data that any future code reading heldUntil directly - // (instead of re-deriving availability live) would misinterpret. - data: { status: 'AVAILABLE', heldUntil: null }, - }); - } - await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: now } } }); return { expiredHolds: expired.length, releasedSeatIds: Array.from(releasedSeatIds), - skippedSeatIds: Array.from(skippedSeatIds), + skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed }; } } diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index f849654b5..be5449e97 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -87,7 +87,8 @@ export default function SeatsPage() { }; const blockMutation = useMutation({ - mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }), + mutationFn: ({ seatId, reason }: any) => + seatsApi.block(seatId, { reason, ...(activeTab === 'schedule' && selectedSchedule ? { scheduleId: selectedSchedule } : {}) }), onSuccess: () => { invalidateSeatData(); setShowBlockModal(false); @@ -97,7 +98,8 @@ export default function SeatsPage() { }); const unblockMutation = useMutation({ - mutationFn: (seatId: string) => seatsApi.unblock(seatId), + mutationFn: (seatId: string) => + seatsApi.unblock(seatId, activeTab === 'schedule' ? selectedSchedule : undefined), onSuccess: () => { invalidateSeatData(); }, @@ -143,7 +145,8 @@ export default function SeatsPage() { mutationFn: async ({ coachId, reason }: any) => { const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); - return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason }))); + const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined; + return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason, ...(scheduleId ? { scheduleId } : {}) }))); }, onSuccess: () => { invalidateSeatData(); @@ -157,7 +160,8 @@ export default function SeatsPage() { mutationFn: async ({ coachId }: any) => { const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); - return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId))); + const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined; + return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId, scheduleId))); }, onSuccess: () => { invalidateSeatData(); diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 2dae6560f..e8ed56195 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -154,7 +154,7 @@ export const seatsApi = { hold: (data: any) => apiClient.post('/seats/hold', data), release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`), block: (seatId: string, data: any) => apiClient.post(`/seats/${seatId}/block`, data), - unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`), + unblock: (seatId: string, scheduleId?: string) => apiClient.delete(`/seats/${seatId}/block${scheduleId ? `?scheduleId=${scheduleId}` : ''}`), removeSeat: (seatId: string) => apiClient.patch(`/seats/${seatId}/remove`, {}), undoRemove: (seatId: string) => apiClient.patch(`/seats/${seatId}/undo-remove`, {}), setMaintenance: (seatId: string, reason: string) => apiClient.post(`/seats/${seatId}/maintenance`, { reason }), From 3726ac8390953a9c2fd97bcb4b94a1abba88a4e6 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 16 Jul 2026 20:11:42 +0300 Subject: [PATCH 04/18] Add seat block schedule id for consistency --- .../20260716170852_add_seat_block_schedule_id/migration.sql | 5 +++++ .../src/modules/payments/payments.service.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260716170852_add_seat_block_schedule_id/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260716170852_add_seat_block_schedule_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716170852_add_seat_block_schedule_id/migration.sql new file mode 100644 index 000000000..11fb856c9 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260716170852_add_seat_block_schedule_id/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "SeatBlock" ADD COLUMN "scheduleId" TEXT; + +-- CreateIndex +CREATE INDEX "SeatBlock_scheduleId_idx" ON "SeatBlock"("scheduleId"); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 8bf07c6bf..a9e4557f1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -432,8 +432,8 @@ export class PaymentsService { expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null, failureCode: snapshot.failureCode ?? null, failureMessage: snapshot.failureMessage ?? null, - rawInitiation: snapshot.providerResponse - ? (snapshot.providerResponse as unknown as Prisma.InputJsonValue) + rawInitiation: (snapshot as any).providerResponse + ? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue) : Prisma.DbNull, }; return this.prisma.paymentIntent.upsert({ From 17c012316117362e8cba9bc5f5c036b27af84b33 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 16 Jul 2026 21:10:02 +0300 Subject: [PATCH 05/18] Add unique constraint to JourneySegment for seat bookings per schedule --- .../migration.sql | 18 ++++++++++++++++++ apps/edr-passenger-api/prisma/schema.prisma | 4 ++++ 2 files changed, 22 insertions(+) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql new file mode 100644 index 000000000..2608b4cbf --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -0,0 +1,18 @@ +-- Remove duplicate JourneySegment rows, keeping the one with the lowest id +-- (earliest created) per (scheduleId, seatId, departureStationId) group. +-- This cleans up any existing double-bookings before the unique index is applied. +DELETE FROM passenger."JourneySegment" +WHERE id NOT IN ( + SELECT MIN(id) + FROM passenger."JourneySegment" + WHERE "seatId" IS NOT NULL + GROUP BY "scheduleId", "seatId", "departureStationId" +) +AND "seatId" IS NOT NULL; + +-- Prevents two confirmed bookings from occupying the same seat on the same +-- schedule hop — the hard DB backstop against application-level race conditions. +-- Partial index: seatId IS NOT NULL excludes free-child rows that have no seat. +CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" +ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") +WHERE "seatId" IS NOT NULL; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index d2dc44751..ccc68915d 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -991,6 +991,10 @@ model JourneySegment { arrivalStationId String journey Journey @relation(fields: [journeyId], references: [id]) schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + + // Prevents two confirmed bookings from occupying the same seat on the same + // schedule hop — the hard DB backstop against application-level race conditions. + @@unique([scheduleId, seatId, departureStationId]) @@schema("passenger") } From 718c384a06944aa9b655b724b0cf043af4c0c284 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 16 Jul 2026 21:15:10 +0300 Subject: [PATCH 06/18] Update tickets.controller.ts --- .../src/modules/tickets/tickets.controller.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 51d3b0630..b45b93a33 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; -import { PassengerStaff } from '../../common/passenger-guards'; +import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Tickets') @@ -207,9 +207,9 @@ export class TicketsController { } @Delete(':id') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Delete ticket (admin only)', description: 'Permanently deletes a ticket record and removes associated seat blocks' }) From 969c524afea124dcf7a0256fb9d8e9819525afa2 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 23:22:20 +0300 Subject: [PATCH 07/18] Added cron job that check duplicate seat and assign if free available --- .../modules/payments/payment-sync.service.ts | 109 ++++ .../src/modules/payments/payments.module.ts | 2 + .../src/modules/tasks/tasks.module.ts | 3 +- .../src/modules/tasks/tasks.service.ts | 475 +++++++++++++++--- 4 files changed, 523 insertions(+), 66 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts diff --git a/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts new file mode 100644 index 000000000..7be85714a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { ModuleRef } from '@nestjs/core'; +import { PrismaService } from '../../common/prisma.service'; +import { PaymentClientService } from './payment-client.service'; +import { PaymentsService } from './payments.service'; +import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types'; + +// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up). +// This service keeps only singleton deps so its @Cron method registers correctly, +// then resolves PaymentsService per-tick via ModuleRef (same pattern as +// PaymentEventsConsumer). +@Injectable() +export class PaymentSyncService { + private readonly logger = new Logger(PaymentSyncService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly paymentClient: PaymentClientService, + private readonly moduleRef: ModuleRef, + ) {} + + // ───────────────────────────────────────────────────────────────────────── + // Every 1 min: poll the payment service for any PENDING_PAYMENT bookings + // whose payment intent has moved to SUCCEEDED on the gateway but whose + // confirmation event was never delivered (missed RabbitMQ message, network + // blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running + // it for an already-confirmed booking is safe. + // + // Processes at most 50 bookings per cycle to avoid hammering the payment + // service; the next tick picks up the remainder. + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/1 * * * *') + async syncPaymentStatuses() { + const BATCH_SIZE = 50; + + const bookings = await this.prisma.booking.findMany({ + where: { + status: 'PENDING_PAYMENT', + paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } }, + }, + include: { paymentIntent: true }, + take: BATCH_SIZE, + orderBy: { createdAt: 'asc' }, + }); + + if (bookings.length === 0) return; + + let confirmed = 0; + let failed = 0; + let errored = 0; + + // resolve() (not get()) because PaymentsService is scoped — same pattern + // as PaymentEventsConsumer. + const paymentsService = await this.moduleRef.resolve( + PaymentsService, + undefined, + { strict: false }, + ); + + for (const booking of bookings) { + if (!booking.paymentIntent) continue; + + try { + const snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.BOOKING, + booking.id, + ); + + if (!snapshot) continue; + + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + const result = await paymentsService.finalizePaymentSuccess({ + intentId: booking.paymentIntent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + }); + if (!result.alreadyFinalized) { + this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`); + confirmed++; + } + } else if ( + snapshot.status === ProviderPaymentStatus.FAILED || + snapshot.status === ProviderPaymentStatus.CANCELLED + ) { + this.logger.warn( + `Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` + + `booking will be auto-cancelled at payment deadline`, + ); + failed++; + } + // REQUIRES_ACTION / PROCESSING → still pending, retry next cycle + } catch (err) { + this.logger.error( + `Payment sync error for ${booking.bookingRef}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + errored++; + } + } + + if (confirmed > 0 || failed > 0 || errored > 0) { + this.logger.log( + `Payment sync run: ${bookings.length} checked, ` + + `${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`, + ); + } + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 2a3906398..07f452e43 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -16,6 +16,7 @@ import { SupplementaryChargesService } from "./supplementary-charges.service"; import { InternalPaymentsController } from "./internal-payments.controller"; import { PaymentClientService } from "./payment-client.service"; import { PaymentEventsConsumer } from "./payment-events.consumer"; +import { PaymentSyncService } from "./payment-sync.service"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { SeatsModule } from "../seats/seats.module"; import { TicketsModule } from "../tickets/tickets.module"; @@ -70,6 +71,7 @@ function rabbitMQImport(): DynamicModule[] { SupplementaryChargesService, PaymentClientService, PaymentEventsConsumer, + PaymentSyncService, ServiceAuthGuard, ], exports: [PaymentClientService, PaymentsService], diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts index 335d11f81..fb8a29a6e 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -2,11 +2,10 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../common/prisma.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { CurrencyModule } from '../currency/currency.module'; -import { PaymentsModule } from '../payments/payments.module'; import { TasksService } from './tasks.service'; @Module({ - imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule], + imports: [PrismaModule, NotificationsModule, CurrencyModule], providers: [TasksService], }) export class TasksModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 7a9208cae..ab275ee09 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -1,11 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; +import { SeatStatus } from '@prisma/client'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; -import { PaymentsService } from '../payments/payments.service'; -import { PaymentClientService } from '../payments/payment-client.service'; -import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types'; import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; // Retention windows @@ -31,8 +29,6 @@ export class TasksService { private readonly prisma: PrismaService, private readonly sms: SmsClientService, private readonly currencyService: CurrencyService, - private readonly paymentsService: PaymentsService, - private readonly paymentClient: PaymentClientService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -259,84 +255,435 @@ export class TasksService { } // ───────────────────────────────────────────────────────────────────────── - // Every 1 min: poll the payment service for any PENDING_PAYMENT bookings - // whose payment intent has moved to SUCCEEDED on the gateway but whose - // confirmation event was never delivered (missed RabbitMQ message, network - // blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running - // it for an already-confirmed booking is safe. + // Every 1 min: detect and resolve duplicate seat assignments. // - // Processes at most 50 bookings per cycle to avoid hammering the payment - // service; the next tick picks up the remainder. + // Root cause: a stale RabbitMQ message, delivered after system recovery, + // re-confirmed a cancelled booking whose seat had already been assigned to + // a new booking — leaving two CONFIRMED bookings holding the same seat on + // the same schedule. + // + // Resolution (FCFS): + // • Earliest confirmed booking keeps the original seat. + // • All later duplicates are reassigned to the next free seat within the + // SAME coach type (same coach preferred; any coach of same type as + // fallback). + // • If no seat is available in that coach type the booking is flagged for + // manual intervention and logged as unresolved. + // + // Idempotent: after reassignment the BookingSeat/JourneySegment rows no + // longer share the same (seatId, scheduleId) key, so the next tick finds + // nothing to do for the same pair. + // + // Scope: only schedules departing in the last 24 h or in the future, to + // keep the per-tick DB scan bounded. // ───────────────────────────────────────────────────────────────────────── @Cron('*/1 * * * *') - async syncPaymentStatuses() { - const BATCH_SIZE = 50; + async resolveDuplicateSeatAssignments() { + const BATCH_SIZE = 20; + const since = new Date(Date.now() - 24 * 60 * 60 * 1000); - const bookings = await this.prisma.booking.findMany({ + // Fetch all BookingSeat rows for CONFIRMED bookings on upcoming/recent schedules. + const confirmedSeats = await this.prisma.bookingSeat.findMany({ where: { - status: 'PENDING_PAYMENT', - paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } }, + booking: { + status: 'CONFIRMED', + schedule: { departureAt: { gte: since } }, + }, + }, + include: { + booking: { + select: { + id: true, + bookingRef: true, + scheduleId: true, + createdAt: true, + contactPhone: true, + schedule: { + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + }, + }, + }, + seat: { + include: { + coach: { + include: { coachType: { select: { id: true, name: true } } }, + }, + }, + }, }, - include: { paymentIntent: true }, - take: BATCH_SIZE, - orderBy: { createdAt: 'asc' }, }); - if (bookings.length === 0) return; + // Group by (seatId, scheduleId). BookingSeat.scheduleId is per-leg for + // round-trips; fall back to Booking.scheduleId for single-leg bookings. + const groups = new Map(); + for (const bs of confirmedSeats) { + if (!bs.seatId) continue; + const scheduleId = bs.scheduleId ?? bs.booking.scheduleId; + if (!scheduleId) continue; + const key = `${bs.seatId}:${scheduleId}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(bs); + } - let confirmed = 0; - let failed = 0; - let errored = 0; + const duplicateGroups = [...groups.values()] + .filter(g => g.length > 1) + .slice(0, BATCH_SIZE); - for (const booking of bookings) { - if (!booking.paymentIntent) continue; + if (duplicateGroups.length === 0) return; - try { - const snapshot = await this.paymentClient.getIntentByReference( - PaymentReferenceType.BOOKING, - booking.id, - ); + this.logger.warn(`Seat dedup: ${duplicateGroups.length} duplicate seat group(s) detected`); - if (!snapshot) continue; + // Track seats newly assigned within this run to prevent double-assignment. + const newlyAssigned = new Map>(); // scheduleId → Set + let resolved = 0; + let unresolved = 0; - if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { - const result = await this.paymentsService.finalizePaymentSuccess({ - intentId: booking.paymentIntent.id, - providerTxnId: snapshot.providerTxnId, - paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, - }); - if (!result.alreadyFinalized) { - this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`); - confirmed++; - } - } else if ( - snapshot.status === ProviderPaymentStatus.FAILED || - snapshot.status === ProviderPaymentStatus.CANCELLED - ) { - // The payment deadline enforcer will cancel the booking when its - // window expires; log now so operations can see failed intents early. - this.logger.warn( - `Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` + - `booking will be auto-cancelled at payment deadline`, + for (const group of duplicateGroups) { + // FCFS: earliest confirmed booking keeps the seat. + const sorted = [...group].sort( + (a, b) => + new Date(a.booking.createdAt as Date).getTime() - + new Date(b.booking.createdAt as Date).getTime(), + ); + const [keeper, ...duplicates] = sorted; + + for (const dup of duplicates) { + const scheduleId = (dup.scheduleId ?? dup.booking.scheduleId)!; + const coachTypeId = dup.seat?.coach?.coachTypeId; + const oldCoachId = dup.seat?.coachId; + + if (!coachTypeId) { + this.logger.error( + `Seat dedup: missing coachTypeId for BookingSeat ${dup.id}, booking ${dup.booking.bookingRef}`, ); - failed++; + unresolved++; + continue; + } + + if (!newlyAssigned.has(scheduleId)) newlyAssigned.set(scheduleId, new Set()); + const takenThisRun = newlyAssigned.get(scheduleId)!; + + // All seats already taken: confirmed bookings + those assigned this tick. + const occupiedIds = new Set([ + ...confirmedSeats + .filter(bs => (bs.scheduleId ?? bs.booking.scheduleId) === scheduleId && bs.seatId) + .map(bs => bs.seatId as string), + ...takenThisRun, + ]); + + try { + const newSeat = await this.findReplacementSeat(scheduleId, coachTypeId, oldCoachId, occupiedIds); + + if (!newSeat) { + this.logger.warn( + `Seat dedup: no available seat for booking ${dup.booking.bookingRef} ` + + `(schedule ${scheduleId}, coachType ${coachTypeId}) — manual intervention required`, + ); + unresolved++; + continue; + } + + await this.prisma.$transaction(async (tx) => { + // 1. Update BookingSeat to the new seat. + await tx.bookingSeat.update({ + where: { id: dup.id }, + data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber }, + }); + + // 2. Update JourneySegment — look up journeyId first to avoid a + // nested-relation filter in updateMany (not supported in all Prisma versions). + const journey = await tx.journey.findUnique({ + where: { bookingId: dup.booking.id } as any, + select: { id: true }, + }); + if (journey) { + await tx.journeySegment.updateMany({ + where: { journeyId: journey.id, seatId: dup.seatId!, scheduleId }, + data: { seatId: newSeat.id, coachId: newSeat.coachId }, + }); + } + + // 3. Update Ticket seat reference (QR payload regeneration is out of scope + // here; the backoffice can trigger that separately if required). + await tx.ticket.updateMany({ + where: { bookingId: dup.booking.id, seatId: dup.seatId! }, + data: { seatId: newSeat.id }, + }); + }); + + takenThisRun.add(newSeat.id); + + const oldLabel = dup.seat?.seatNumber ?? dup.seatId ?? '?'; + const newCoach = (newSeat as any).coach; + const coachTypeName = newCoach?.coachType?.name ?? ''; + const coachNumber = newCoach?.number ?? ''; + const origin = dup.booking.schedule?.originStation?.name ?? ''; + const dest = dup.booking.schedule?.destinationStation?.name ?? ''; + + if (dup.booking.contactPhone) { + const message = + `EDR: Your booking ${dup.booking.bookingRef} (${origin} → ${dest}): ` + + `your seat has been changed from ${oldLabel} to seat ${newSeat.seatNumber} ` + + `in coach ${coachNumber} (${coachTypeName}). ` + + `We apologize for the inconvenience.`; + await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null); + } + + this.logger.log( + `Seat dedup resolved: booking ${dup.booking.bookingRef} ` + + `seat ${oldLabel} → ${newSeat.seatNumber} (coach ${coachNumber}, ${coachTypeName}), ` + + `keeper: ${keeper.booking.bookingRef}`, + ); + resolved++; + } catch (err) { + this.logger.error( + `Seat dedup error for booking ${dup.booking.bookingRef}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + unresolved++; } - // REQUIRES_ACTION / PROCESSING → still pending, retry next cycle - } catch (err) { - this.logger.error( - `Payment sync error for ${booking.bookingRef}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - errored++; } } - if (confirmed > 0 || failed > 0 || errored > 0) { - this.logger.log( - `Payment sync run: ${bookings.length} checked, ` + - `${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`, - ); + this.logger.log(`Seat dedup run: ${resolved} resolved, ${unresolved} unresolved`); + } + + private async findReplacementSeat( + scheduleId: string, + coachTypeId: string, + preferredCoachId: string | undefined, + occupiedIds: Set, + ) { + const includeCoach = { + coach: { include: { coachType: { select: { id: true, name: true } } } }, + }; + const baseWhere = (coachId?: string) => ({ + ...(coachId ? { coachId } : {}), + seatNumber: { not: '' }, + id: { notIn: [...occupiedIds] }, + coach: { coachTypeId, assignments: { some: { scheduleId } } }, + NOT: [ + { seatNumber: { startsWith: '-' } }, + { status: SeatStatus.BLOCKED }, + ], + }); + + // 1. Prefer the exact same coach. + if (preferredCoachId) { + const seat = await this.prisma.seat.findFirst({ + where: baseWhere(preferredCoachId), + include: includeCoach, + orderBy: [{ row: 'asc' }, { col: 'asc' }], + }); + if (seat) return seat; } + + // 2. Any coach of the same coach type assigned to this schedule. + return this.prisma.seat.findFirst({ + where: baseWhere(), + include: includeCoach, + orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Every 1 min: detect and resolve duplicate seat assignments caused by + // RabbitMQ-recovered events re-confirming already-cancelled bookings. + // + // Detection: group confirmed BookingSeat rows by (scheduleId, seatId, leg). + // Any group with >1 row means multiple bookings share the same physical seat. + // + // Resolution (FCFS): the booking created first keeps the seat; all later + // bookings are reassigned to an available seat in: + // 1. Same coach + same coach type (preferred) + // 2. Same coach type, any coach (fallback) + // 3. No seat available → logged, needs manual intervention + // + // Idempotency: once a duplicate's BookingSeat is updated to a new seatId it + // no longer appears in the duplicate group on the next tick — naturally safe + // to re-run without any extra flag. + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/1 * * * *') + async deduplicateSeatAssignments() { + this.logger.log('Seat dedup cron started'); + // Scan at most 500 confirmed seat rows per run to stay lightweight. + const confirmedSeats = await this.prisma.bookingSeat.findMany({ + where: { booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } }, + select: { + id: true, + seatId: true, + scheduleId: true, + leg: true, + passengerName: true, + booking: { + select: { + id: true, + bookingRef: true, + scheduleId: true, + createdAt: true, + contactPhone: true, + }, + }, + seat: { + select: { + id: true, + seatNumber: true, + coachId: true, + coach: { + select: { + id: true, + number: true, + coachTypeId: true, + coachType: { select: { id: true, name: true } }, + }, + }, + }, + }, + }, + take: 500, + }); + + // Group by (effectiveScheduleId :: seatId :: leg) + type BsRow = (typeof confirmedSeats)[number]; + const groups = new Map(); + for (const bs of confirmedSeats) { + const schedId = bs.scheduleId ?? bs.booking.scheduleId; + if (!schedId) continue; + const key = `${schedId}::${bs.seatId}::${bs.leg}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(bs); + } + + const duplicateGroups = [...groups.values()].filter(g => g.length > 1); + if (duplicateGroups.length === 0) return; + + this.logger.warn(`Seat dedup: ${duplicateGroups.length} conflict(s) detected`); + + // Build taken-seat sets keyed by (scheduleId::leg) — used when finding + // a replacement seat so we don't assign an already-occupied seat. + const takenByScheduleLeg = new Map>(); + for (const bs of confirmedSeats) { + const schedId = bs.scheduleId ?? bs.booking.scheduleId; + if (!schedId) continue; + const key = `${schedId}::${bs.leg}`; + if (!takenByScheduleLeg.has(key)) takenByScheduleLeg.set(key, new Set()); + takenByScheduleLeg.get(key)!.add(bs.seatId); + } + + let resolved = 0; + let unresolved = 0; + + for (const group of duplicateGroups) { + // FCFS: earliest booking keeps the seat + group.sort((a, b) => + new Date(a.booking.createdAt).getTime() - new Date(b.booking.createdAt).getTime(), + ); + + const [winner, ...duplicates] = group; + const schedId = winner.scheduleId ?? winner.booking.scheduleId; + const coachTypeId = winner.seat.coach.coachTypeId; + const origCoachId = winner.seat.coachId; + const taken = takenByScheduleLeg.get(`${schedId}::${winner.leg}`) ?? new Set(); + + for (const dup of duplicates) { + try { + // 1st choice: same coach + same coach type + const newSeat = + (await this.prisma.seat.findFirst({ + where: { + id: { notIn: [...taken] }, + status: { not: SeatStatus.BLOCKED }, + coachId: origCoachId, + coach: { + coachTypeId, + assignments: { some: { scheduleId: schedId } }, + }, + }, + select: { + id: true, seatNumber: true, coachId: true, + coach: { select: { number: true, coachType: { select: { name: true } } } }, + }, + })) ?? + // 2nd choice: any coach within same coach type + (await this.prisma.seat.findFirst({ + where: { + id: { notIn: [...taken] }, + status: { not: SeatStatus.BLOCKED }, + coach: { + coachTypeId, + assignments: { some: { scheduleId: schedId } }, + }, + }, + select: { + id: true, seatNumber: true, coachId: true, + coach: { select: { number: true, coachType: { select: { name: true } } } }, + }, + })); + + if (!newSeat) { + this.logger.warn( + `Seat dedup: no available seat in coach type for ` + + `booking ${dup.booking.bookingRef} (${dup.passengerName}) — manual intervention required`, + ); + unresolved++; + continue; + } + + // Atomically update BookingSeat + Ticket + JourneySegment + await this.prisma.$transaction(async (tx) => { + await tx.bookingSeat.update({ + where: { id: dup.id }, + data: { seatId: newSeat!.id, seatLabelSnapshot: newSeat!.seatNumber }, + }); + await tx.ticket.updateMany({ + where: { bookingId: dup.booking.id, seatId: dup.seatId, leg: dup.leg }, + data: { seatId: newSeat!.id }, + }); + await tx.journeySegment.updateMany({ + where: { + journey: { bookingId: dup.booking.id }, + seatId: dup.seatId, + scheduleId: schedId, + }, + data: { seatId: newSeat!.id, coachId: newSeat!.coachId }, + }); + }); + + // Claim the new seat so subsequent duplicates in this run don't use it + taken.add(newSeat.id); + + const message = + `EDR: Your seat for booking ${dup.booking.bookingRef} has been updated ` + + `due to a system correction. ` + + `New seat: ${newSeat.seatNumber}, Coach: ${newSeat.coach.number} ` + + `(${newSeat.coach.coachType.name}). We apologize for the inconvenience.`; + + if (dup.booking.contactPhone) { + await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null); + } + + this.logger.log( + `Seat dedup: booking ${dup.booking.bookingRef} (${dup.passengerName}) ` + + `seat ${dup.seat.seatNumber} → ${newSeat.seatNumber} (coach ${newSeat.coach.number})`, + ); + resolved++; + } catch (err) { + this.logger.error( + `Seat dedup error for ${dup.booking.bookingRef}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + unresolved++; + } + } + } + + this.logger.log( + `Seat dedup complete: ${resolved} reassigned, ${unresolved} unresolved ` + + `across ${duplicateGroups.length} conflict(s)`, + ); } // ───────────────────────────────────────────────────────────────────────── From e59b79747754e8eeecfade076f0a6b8b0ffc054d Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 17 Jul 2026 00:49:35 +0300 Subject: [PATCH 08/18] Minor updates --- apps/edr-passenger-api/src/main.ts | 2 +- apps/edr-passenger-web/backoffice/src/app/login/page.tsx | 2 +- apps/edr-passenger-web/backoffice/src/app/settings/page.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index f48a228d9..486094c3f 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -322,7 +322,7 @@ Payment providers send notifications to: - \`POST /payments/webhooks/card\` (International) ## Support -- **Email:** support@edr-platform.com +- **Email:** edr_@edrsc.com - **Documentation:** https://docs.edr-platform.com - **Status Page:** https://status.edr-platform.com `, diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index c0d84cae8..4f0098792 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -365,7 +365,7 @@ export default function LoginPage() { Back-office · v1.0 - Need help? support@edr.com + Need help? edr_@edrsc.com diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 3f0a266fd..873e81578 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -94,11 +94,11 @@ export default function SettingsPage() {
- +
- +
From 2366b28610f496e4abf2e174751bdd91342b4bc6 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 17 Jul 2026 08:42:42 +0300 Subject: [PATCH 09/18] remove the cron job --- .../src/modules/tasks/tasks.service.ts | 433 ------------------ 1 file changed, 433 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index ab275ee09..4fb3a4f0f 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { SeatStatus } from '@prisma/client'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; @@ -254,438 +253,6 @@ export class TasksService { } } - // ───────────────────────────────────────────────────────────────────────── - // Every 1 min: detect and resolve duplicate seat assignments. - // - // Root cause: a stale RabbitMQ message, delivered after system recovery, - // re-confirmed a cancelled booking whose seat had already been assigned to - // a new booking — leaving two CONFIRMED bookings holding the same seat on - // the same schedule. - // - // Resolution (FCFS): - // • Earliest confirmed booking keeps the original seat. - // • All later duplicates are reassigned to the next free seat within the - // SAME coach type (same coach preferred; any coach of same type as - // fallback). - // • If no seat is available in that coach type the booking is flagged for - // manual intervention and logged as unresolved. - // - // Idempotent: after reassignment the BookingSeat/JourneySegment rows no - // longer share the same (seatId, scheduleId) key, so the next tick finds - // nothing to do for the same pair. - // - // Scope: only schedules departing in the last 24 h or in the future, to - // keep the per-tick DB scan bounded. - // ───────────────────────────────────────────────────────────────────────── - @Cron('*/1 * * * *') - async resolveDuplicateSeatAssignments() { - const BATCH_SIZE = 20; - const since = new Date(Date.now() - 24 * 60 * 60 * 1000); - - // Fetch all BookingSeat rows for CONFIRMED bookings on upcoming/recent schedules. - const confirmedSeats = await this.prisma.bookingSeat.findMany({ - where: { - booking: { - status: 'CONFIRMED', - schedule: { departureAt: { gte: since } }, - }, - }, - include: { - booking: { - select: { - id: true, - bookingRef: true, - scheduleId: true, - createdAt: true, - contactPhone: true, - schedule: { - include: { - originStation: { select: { name: true } }, - destinationStation: { select: { name: true } }, - }, - }, - }, - }, - seat: { - include: { - coach: { - include: { coachType: { select: { id: true, name: true } } }, - }, - }, - }, - }, - }); - - // Group by (seatId, scheduleId). BookingSeat.scheduleId is per-leg for - // round-trips; fall back to Booking.scheduleId for single-leg bookings. - const groups = new Map(); - for (const bs of confirmedSeats) { - if (!bs.seatId) continue; - const scheduleId = bs.scheduleId ?? bs.booking.scheduleId; - if (!scheduleId) continue; - const key = `${bs.seatId}:${scheduleId}`; - if (!groups.has(key)) groups.set(key, []); - groups.get(key)!.push(bs); - } - - const duplicateGroups = [...groups.values()] - .filter(g => g.length > 1) - .slice(0, BATCH_SIZE); - - if (duplicateGroups.length === 0) return; - - this.logger.warn(`Seat dedup: ${duplicateGroups.length} duplicate seat group(s) detected`); - - // Track seats newly assigned within this run to prevent double-assignment. - const newlyAssigned = new Map>(); // scheduleId → Set - let resolved = 0; - let unresolved = 0; - - for (const group of duplicateGroups) { - // FCFS: earliest confirmed booking keeps the seat. - const sorted = [...group].sort( - (a, b) => - new Date(a.booking.createdAt as Date).getTime() - - new Date(b.booking.createdAt as Date).getTime(), - ); - const [keeper, ...duplicates] = sorted; - - for (const dup of duplicates) { - const scheduleId = (dup.scheduleId ?? dup.booking.scheduleId)!; - const coachTypeId = dup.seat?.coach?.coachTypeId; - const oldCoachId = dup.seat?.coachId; - - if (!coachTypeId) { - this.logger.error( - `Seat dedup: missing coachTypeId for BookingSeat ${dup.id}, booking ${dup.booking.bookingRef}`, - ); - unresolved++; - continue; - } - - if (!newlyAssigned.has(scheduleId)) newlyAssigned.set(scheduleId, new Set()); - const takenThisRun = newlyAssigned.get(scheduleId)!; - - // All seats already taken: confirmed bookings + those assigned this tick. - const occupiedIds = new Set([ - ...confirmedSeats - .filter(bs => (bs.scheduleId ?? bs.booking.scheduleId) === scheduleId && bs.seatId) - .map(bs => bs.seatId as string), - ...takenThisRun, - ]); - - try { - const newSeat = await this.findReplacementSeat(scheduleId, coachTypeId, oldCoachId, occupiedIds); - - if (!newSeat) { - this.logger.warn( - `Seat dedup: no available seat for booking ${dup.booking.bookingRef} ` + - `(schedule ${scheduleId}, coachType ${coachTypeId}) — manual intervention required`, - ); - unresolved++; - continue; - } - - await this.prisma.$transaction(async (tx) => { - // 1. Update BookingSeat to the new seat. - await tx.bookingSeat.update({ - where: { id: dup.id }, - data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber }, - }); - - // 2. Update JourneySegment — look up journeyId first to avoid a - // nested-relation filter in updateMany (not supported in all Prisma versions). - const journey = await tx.journey.findUnique({ - where: { bookingId: dup.booking.id } as any, - select: { id: true }, - }); - if (journey) { - await tx.journeySegment.updateMany({ - where: { journeyId: journey.id, seatId: dup.seatId!, scheduleId }, - data: { seatId: newSeat.id, coachId: newSeat.coachId }, - }); - } - - // 3. Update Ticket seat reference (QR payload regeneration is out of scope - // here; the backoffice can trigger that separately if required). - await tx.ticket.updateMany({ - where: { bookingId: dup.booking.id, seatId: dup.seatId! }, - data: { seatId: newSeat.id }, - }); - }); - - takenThisRun.add(newSeat.id); - - const oldLabel = dup.seat?.seatNumber ?? dup.seatId ?? '?'; - const newCoach = (newSeat as any).coach; - const coachTypeName = newCoach?.coachType?.name ?? ''; - const coachNumber = newCoach?.number ?? ''; - const origin = dup.booking.schedule?.originStation?.name ?? ''; - const dest = dup.booking.schedule?.destinationStation?.name ?? ''; - - if (dup.booking.contactPhone) { - const message = - `EDR: Your booking ${dup.booking.bookingRef} (${origin} → ${dest}): ` + - `your seat has been changed from ${oldLabel} to seat ${newSeat.seatNumber} ` + - `in coach ${coachNumber} (${coachTypeName}). ` + - `We apologize for the inconvenience.`; - await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null); - } - - this.logger.log( - `Seat dedup resolved: booking ${dup.booking.bookingRef} ` + - `seat ${oldLabel} → ${newSeat.seatNumber} (coach ${coachNumber}, ${coachTypeName}), ` + - `keeper: ${keeper.booking.bookingRef}`, - ); - resolved++; - } catch (err) { - this.logger.error( - `Seat dedup error for booking ${dup.booking.bookingRef}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - unresolved++; - } - } - } - - this.logger.log(`Seat dedup run: ${resolved} resolved, ${unresolved} unresolved`); - } - - private async findReplacementSeat( - scheduleId: string, - coachTypeId: string, - preferredCoachId: string | undefined, - occupiedIds: Set, - ) { - const includeCoach = { - coach: { include: { coachType: { select: { id: true, name: true } } } }, - }; - const baseWhere = (coachId?: string) => ({ - ...(coachId ? { coachId } : {}), - seatNumber: { not: '' }, - id: { notIn: [...occupiedIds] }, - coach: { coachTypeId, assignments: { some: { scheduleId } } }, - NOT: [ - { seatNumber: { startsWith: '-' } }, - { status: SeatStatus.BLOCKED }, - ], - }); - - // 1. Prefer the exact same coach. - if (preferredCoachId) { - const seat = await this.prisma.seat.findFirst({ - where: baseWhere(preferredCoachId), - include: includeCoach, - orderBy: [{ row: 'asc' }, { col: 'asc' }], - }); - if (seat) return seat; - } - - // 2. Any coach of the same coach type assigned to this schedule. - return this.prisma.seat.findFirst({ - where: baseWhere(), - include: includeCoach, - orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], - }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Every 1 min: detect and resolve duplicate seat assignments caused by - // RabbitMQ-recovered events re-confirming already-cancelled bookings. - // - // Detection: group confirmed BookingSeat rows by (scheduleId, seatId, leg). - // Any group with >1 row means multiple bookings share the same physical seat. - // - // Resolution (FCFS): the booking created first keeps the seat; all later - // bookings are reassigned to an available seat in: - // 1. Same coach + same coach type (preferred) - // 2. Same coach type, any coach (fallback) - // 3. No seat available → logged, needs manual intervention - // - // Idempotency: once a duplicate's BookingSeat is updated to a new seatId it - // no longer appears in the duplicate group on the next tick — naturally safe - // to re-run without any extra flag. - // ───────────────────────────────────────────────────────────────────────── - @Cron('*/1 * * * *') - async deduplicateSeatAssignments() { - this.logger.log('Seat dedup cron started'); - // Scan at most 500 confirmed seat rows per run to stay lightweight. - const confirmedSeats = await this.prisma.bookingSeat.findMany({ - where: { booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } }, - select: { - id: true, - seatId: true, - scheduleId: true, - leg: true, - passengerName: true, - booking: { - select: { - id: true, - bookingRef: true, - scheduleId: true, - createdAt: true, - contactPhone: true, - }, - }, - seat: { - select: { - id: true, - seatNumber: true, - coachId: true, - coach: { - select: { - id: true, - number: true, - coachTypeId: true, - coachType: { select: { id: true, name: true } }, - }, - }, - }, - }, - }, - take: 500, - }); - - // Group by (effectiveScheduleId :: seatId :: leg) - type BsRow = (typeof confirmedSeats)[number]; - const groups = new Map(); - for (const bs of confirmedSeats) { - const schedId = bs.scheduleId ?? bs.booking.scheduleId; - if (!schedId) continue; - const key = `${schedId}::${bs.seatId}::${bs.leg}`; - if (!groups.has(key)) groups.set(key, []); - groups.get(key)!.push(bs); - } - - const duplicateGroups = [...groups.values()].filter(g => g.length > 1); - if (duplicateGroups.length === 0) return; - - this.logger.warn(`Seat dedup: ${duplicateGroups.length} conflict(s) detected`); - - // Build taken-seat sets keyed by (scheduleId::leg) — used when finding - // a replacement seat so we don't assign an already-occupied seat. - const takenByScheduleLeg = new Map>(); - for (const bs of confirmedSeats) { - const schedId = bs.scheduleId ?? bs.booking.scheduleId; - if (!schedId) continue; - const key = `${schedId}::${bs.leg}`; - if (!takenByScheduleLeg.has(key)) takenByScheduleLeg.set(key, new Set()); - takenByScheduleLeg.get(key)!.add(bs.seatId); - } - - let resolved = 0; - let unresolved = 0; - - for (const group of duplicateGroups) { - // FCFS: earliest booking keeps the seat - group.sort((a, b) => - new Date(a.booking.createdAt).getTime() - new Date(b.booking.createdAt).getTime(), - ); - - const [winner, ...duplicates] = group; - const schedId = winner.scheduleId ?? winner.booking.scheduleId; - const coachTypeId = winner.seat.coach.coachTypeId; - const origCoachId = winner.seat.coachId; - const taken = takenByScheduleLeg.get(`${schedId}::${winner.leg}`) ?? new Set(); - - for (const dup of duplicates) { - try { - // 1st choice: same coach + same coach type - const newSeat = - (await this.prisma.seat.findFirst({ - where: { - id: { notIn: [...taken] }, - status: { not: SeatStatus.BLOCKED }, - coachId: origCoachId, - coach: { - coachTypeId, - assignments: { some: { scheduleId: schedId } }, - }, - }, - select: { - id: true, seatNumber: true, coachId: true, - coach: { select: { number: true, coachType: { select: { name: true } } } }, - }, - })) ?? - // 2nd choice: any coach within same coach type - (await this.prisma.seat.findFirst({ - where: { - id: { notIn: [...taken] }, - status: { not: SeatStatus.BLOCKED }, - coach: { - coachTypeId, - assignments: { some: { scheduleId: schedId } }, - }, - }, - select: { - id: true, seatNumber: true, coachId: true, - coach: { select: { number: true, coachType: { select: { name: true } } } }, - }, - })); - - if (!newSeat) { - this.logger.warn( - `Seat dedup: no available seat in coach type for ` + - `booking ${dup.booking.bookingRef} (${dup.passengerName}) — manual intervention required`, - ); - unresolved++; - continue; - } - - // Atomically update BookingSeat + Ticket + JourneySegment - await this.prisma.$transaction(async (tx) => { - await tx.bookingSeat.update({ - where: { id: dup.id }, - data: { seatId: newSeat!.id, seatLabelSnapshot: newSeat!.seatNumber }, - }); - await tx.ticket.updateMany({ - where: { bookingId: dup.booking.id, seatId: dup.seatId, leg: dup.leg }, - data: { seatId: newSeat!.id }, - }); - await tx.journeySegment.updateMany({ - where: { - journey: { bookingId: dup.booking.id }, - seatId: dup.seatId, - scheduleId: schedId, - }, - data: { seatId: newSeat!.id, coachId: newSeat!.coachId }, - }); - }); - - // Claim the new seat so subsequent duplicates in this run don't use it - taken.add(newSeat.id); - - const message = - `EDR: Your seat for booking ${dup.booking.bookingRef} has been updated ` + - `due to a system correction. ` + - `New seat: ${newSeat.seatNumber}, Coach: ${newSeat.coach.number} ` + - `(${newSeat.coach.coachType.name}). We apologize for the inconvenience.`; - - if (dup.booking.contactPhone) { - await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null); - } - - this.logger.log( - `Seat dedup: booking ${dup.booking.bookingRef} (${dup.passengerName}) ` + - `seat ${dup.seat.seatNumber} → ${newSeat.seatNumber} (coach ${newSeat.coach.number})`, - ); - resolved++; - } catch (err) { - this.logger.error( - `Seat dedup error for ${dup.booking.bookingRef}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - unresolved++; - } - } - } - - this.logger.log( - `Seat dedup complete: ${resolved} reassigned, ${unresolved} unresolved ` + - `across ${duplicateGroups.length} conflict(s)`, - ); - } - // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ───────────────────────────────────────────────────────────────────────── From 610580e15e1445cbe67b3525cd88fabb4c5d8100 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 17 Jul 2026 10:20:35 +0300 Subject: [PATCH 10/18] Added discrepancy management for duplicate seats --- .../src/modules/seats/duplicate-seats.dto.ts | 33 ++ .../src/modules/seats/seats.controller.ts | 88 +++ .../src/modules/seats/seats.module.ts | 3 +- .../src/modules/seats/seats.service.ts | 414 ++++++++++++++ .../backoffice/src/app/discrepancy/layout.tsx | 5 + .../backoffice/src/app/discrepancy/page.tsx | 519 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 4 +- .../backoffice/src/lib/api/index.ts | 4 + 8 files changed, 1068 insertions(+), 2 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx diff --git a/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts new file mode 100644 index 000000000..9018d77fb --- /dev/null +++ b/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsDateString, IsOptional, IsUUID } from 'class-validator'; + +export class GetDuplicateSeatsQuery { + @ApiProperty({ example: '2026-07-17', description: 'Schedule date (YYYY-MM-DD)' }) + @IsDateString() + date: string; + + @ApiPropertyOptional({ description: 'Filter to a specific schedule ID' }) + @IsOptional() + @IsUUID() + scheduleId?: string; +} + +export class ResolveDuplicatesDto { + @ApiProperty({ + description: 'BookingSeat IDs of the duplicate bookings to reassign', + type: [String], + example: ['uuid-booking-seat-1', 'uuid-booking-seat-2'], + }) + @IsArray() + @IsUUID(undefined, { each: true }) + bookingSeatIds: string[]; + + @ApiProperty({ + description: 'Coach IDs to source replacement seats from (searched in order; first available seat per coach is used)', + type: [String], + example: ['uuid-coach-1', 'uuid-coach-2'], + }) + @IsArray() + @IsUUID(undefined, { each: true }) + coachIds: string[]; +} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 596c88bb3..834973c71 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -17,9 +17,11 @@ import { ApiParam, ApiQuery, ApiResponse, + ApiBody, } from "@nestjs/swagger"; import { SeatsService } from "./seats.service"; import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto"; +import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -306,4 +308,90 @@ This makes it clear which segment of the route each seat is held for, enabling s ) { return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit); } + + // ── Duplicate seat management (backoffice) ──────────────────────────────── + + @Get("duplicates") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "List duplicate seat assignments by schedule date", + description: + "Returns all schedules on the given date that have bookings sharing " + + "the same seat, grouped by coach. Each coach entry includes the duplicate " + + "groups (with full booking info) and the list of currently available seats " + + "that can be used for reassignment.", + }) + @ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" }) + @ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" }) + @ApiResponse({ + status: 200, + description: "Duplicate seat report grouped by schedule → coach", + schema: { + example: { + date: "2026-07-17", + totalDuplicates: 1, + schedules: [{ + scheduleId: "uuid", + departureAt: "2026-07-17T06:00:00.000Z", + origin: "Addis Ababa", + destination: "Dire Dawa", + coaches: [{ + coachId: "uuid", + coachNumber: "C1", + coachTypeName: "SBC", + duplicates: [{ + seatId: "uuid", + seatNumber: "12A", + leg: 1, + bookings: [ + { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" }, + { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" }, + ], + }], + availableSeats: [ + { seatId: "uuid", seatNumber: "14B" }, + { seatId: "uuid", seatNumber: "15A" }, + ], + }], + }], + }, + }, + }) + getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) { + return this.service.getDuplicateSeats(query.date, query.scheduleId); + } + + @Post("duplicates/resolve") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "Auto-assign duplicate bookings to seats in selected coaches", + description: + "Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " + + "The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " + + "for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " + + "change immediately, then sends an SMS notification to the passenger. " + + "Coaches are searched in the order provided; seats within each coach are assigned by row then column.", + }) + @ApiBody({ type: ResolveDuplicatesDto }) + @ApiResponse({ + status: 200, + description: "Resolution summary — resolved count, unresolved count, per-booking results", + schema: { + example: { + resolved: 2, + unresolved: 0, + results: [ + { bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" }, + { bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" }, + ], + }, + }, + }) + @ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" }) + @ApiResponse({ status: 404, description: "BookingSeat ID not found" }) + resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) { + return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds); + } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index 0725f4393..2a3c4ee26 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -5,9 +5,10 @@ import { SeatsService } from './seats.service'; import { SegmentsModule } from '../segments/segments.module'; import { SystemConfigModule } from '../system-config/system-config.module'; import { AuditModule } from '../../common/audit.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule], + imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule, NotificationsModule], controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService], diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 10368f380..29d03206c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { SegmentsService } from '../segments/segments.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; import { checkDirectionConflict } from '../../common/utils/journey-direction.utils'; @@ -17,6 +18,7 @@ export class SeatsService { private segmentsService: SegmentsService, private systemConfig: SystemConfigService, private auditService: AuditService, + private sms: SmsClientService, ) {} async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) { @@ -960,4 +962,416 @@ export class SeatsService { skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed }; } + + // ───────────────────────────────────────────────────────────────────────── + // Duplicate-seat management (backoffice) + // ───────────────────────────────────────────────────────────────────────── + + async getDuplicateSeats(date: string, scheduleId?: string) { + const dayStart = new Date(`${date}T00:00:00.000Z`); + const dayEnd = new Date(`${date}T23:59:59.999Z`); + + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + departureAt: { gte: dayStart, lte: dayEnd }, + ...(scheduleId ? { id: scheduleId } : {}), + }, + orderBy: { departureAt: 'asc' }, + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + coachAssignments: { + orderBy: { positionNumber: 'asc' }, + include: { + coach: { + include: { + coachType: { select: { name: true } }, + seats: { + orderBy: [{ row: 'asc' }, { col: 'asc' }], + select: { id: true, seatNumber: true, status: true, coachId: true }, + }, + }, + }, + }, + }, + }, + }); + + const result = []; + + for (const schedule of schedules) { + // All confirmed BookingSeat rows for this schedule + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + OR: [ + { scheduleId: schedule.id }, + { scheduleId: null, booking: { scheduleId: schedule.id } }, + ], + booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + }, + select: { + id: true, seatId: true, scheduleId: true, leg: true, passengerName: true, + seat: { select: { coachId: true } }, + booking: { + select: { + id: true, bookingRef: true, scheduleId: true, + createdAt: true, contactPhone: true, + }, + }, + }, + }); + + // Seats occupied by any confirmed journey on this schedule (source of truth) + const journeySegments = await this.prisma.journeySegment.findMany({ + where: { + scheduleId: schedule.id, + seatId: { not: null }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true }, + }); + const occupiedIds = new Set(journeySegments.map(js => js.seatId!)); + + // Group BookingSeat rows by (seatId::leg) to detect duplicates + type BS = (typeof bookingSeats)[number]; + const groups = new Map(); + for (const bs of bookingSeats) { + const key = `${bs.seatId}::${bs.leg}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(bs); + } + + // All seats held by any confirmed BookingSeat — union of JourneySegment-based + // occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments + // are missing (e.g. created via enhanced-seats path without bookingId) are still + // excluded from the available list. + const bookedSeatIds = new Set([ + ...occupiedIds, + ...bookingSeats.map(bs => bs.seatId).filter((id): id is string => id !== null && id !== undefined), + ]); + + const coachReports = []; + + for (const assignment of schedule.coachAssignments) { + const coach = assignment.coach; + + // Duplicate groups whose seat belongs to this coach + const duplicates = []; + for (const [key, group] of groups) { + if (group.length <= 1) continue; + if (group[0].seat.coachId !== coach.id) continue; + const [seatId] = key.split('::'); + const seat = coach.seats.find(s => s.id === seatId); + duplicates.push({ + seatId, + seatNumber: seat?.seatNumber ?? seatId, + leg: group[0].leg, + bookings: group.map(bs => ({ + bookingSeatId: bs.id, + bookingId: bs.booking.id, + bookingRef: bs.booking.bookingRef, + passengerName: bs.passengerName, + contactPhone: bs.booking.contactPhone, + createdAt: bs.booking.createdAt, + })), + }); + } + + // Free seats in this coach — excludes BLOCKED, all confirmed BookingSeat + // assignments, and all confirmed JourneySegment occupancies. + const availableSeats = coach.seats + .filter(s => + (s.status as string) !== 'BLOCKED' && + !s.seatNumber.startsWith('-') && + !bookedSeatIds.has(s.id), + ) + .map(s => ({ seatId: s.id, seatNumber: s.seatNumber })); + + coachReports.push({ + coachId: coach.id, + coachNumber: coach.number, + coachTypeName: coach.coachType.name, + duplicates, + availableSeats, + }); + } + + if (coachReports.some(c => c.duplicates.length > 0)) { + result.push({ + scheduleId: schedule.id, + departureAt: schedule.departureAt, + origin: schedule.originStation.name, + destination: schedule.destinationStation.name, + coaches: coachReports, + }); + } + } + + const totalDuplicates = result.reduce( + (sum, s) => sum + s.coaches.reduce((cs, c) => cs + c.duplicates.length, 0), + 0, + ); + + return { date, schedules: result, totalDuplicates }; + } + + async resolveDuplicateSeats(bookingSeatIds: string[], coachIds: string[]) { + if (bookingSeatIds.length === 0) return { resolved: 0, unresolved: 0, results: [] }; + + // Load BookingSeat rows with full booking + schedule context + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { id: { in: bookingSeatIds } }, + select: { + id: true, seatId: true, leg: true, scheduleId: true, + seat: { select: { seatNumber: true } }, + booking: { + select: { + id: true, bookingRef: true, scheduleId: true, + status: true, contactPhone: true, passengerId: true, + totalMinor: true, currency: true, + originStationId: true, destinationStationId: true, + schedule: { + select: { + originStationId: true, + destinationStationId: true, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + departureAt: true, + }, + }, + }, + }, + }, + }); + + if (bookingSeats.length !== bookingSeatIds.length) { + const found = new Set(bookingSeats.map(bs => bs.id)); + const missing = bookingSeatIds.filter(id => !found.has(id)); + throw new NotFoundException(`BookingSeat(s) not found: ${missing.join(', ')}`); + } + + const invalid = bookingSeats.filter(bs => !['CONFIRMED', 'BOARDED'].includes(bs.booking.status)); + if (invalid.length > 0) { + throw new BadRequestException( + `Bookings must be CONFIRMED or BOARDED: ${invalid.map(bs => bs.booking.bookingRef).join(', ')}`, + ); + } + + // Load all non-blocked, non-removed seats from the selected coaches (ordered for deterministic pick) + const coachSeats = await this.prisma.seat.findMany({ + where: { + coachId: { in: coachIds }, + status: { not: 'BLOCKED' }, + NOT: { seatNumber: { startsWith: '-' } }, + }, + select: { id: true, seatNumber: true, coachId: true, row: true, col: true }, + orderBy: [{ coachId: 'asc' }, { row: 'asc' }, { col: 'asc' }], + }); + + // Build occupied-seat sets per schedule from confirmed JourneySegments + const scheduleIds = [ + ...new Set( + bookingSeats + .map(bs => bs.scheduleId ?? bs.booking.scheduleId) + .filter((id): id is string => id !== null && id !== undefined), + ), + ]; + + const occupiedBySchedule = new Map>(); + await Promise.all( + scheduleIds.map(async scheduleId => { + const segments = await this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { not: null }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT', 'BOARDED'] } }, + }, + select: { seatId: true }, + }); + occupiedBySchedule.set(scheduleId, new Set(segments.map(s => s.seatId!))); + }), + ); + + // Track seats assigned within this batch to prevent double-assignment + const assignedInBatch = new Set(); + + const results: { bookingRef: string; oldSeatNumber: string; newSeatNumber: string; contactPhone: string | null }[] = []; + const unresolved: { bookingRef: string; reason: string }[] = []; + + for (const bs of bookingSeats) { + const scheduleId = (bs.scheduleId ?? bs.booking.scheduleId)!; + const occupied = occupiedBySchedule.get(scheduleId) ?? new Set(); + + // Pick the first available seat across the selected coaches + const newSeat = coachSeats.find( + seat => + !occupied.has(seat.id) && + !assignedInBatch.has(seat.id) && + seat.id !== bs.seatId, + ); + + if (!newSeat) { + unresolved.push({ + bookingRef: bs.booking.bookingRef, + reason: 'No available seat found in selected coaches', + }); + this.logger.warn( + `Duplicate resolve: no seat available for ${bs.booking.bookingRef} (schedule ${scheduleId})`, + ); + continue; + } + + await this.prisma.$transaction(async tx => { + // 1. Change the seat on the booking and ticket. + await tx.bookingSeat.update({ + where: { id: bs.id }, + data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber }, + }); + await tx.ticket.updateMany({ + where: { bookingId: bs.booking.id, seatId: bs.seatId, leg: bs.leg }, + data: { seatId: newSeat.id }, + }); + + // 2. Point the existing JourneySegments to the new seat. + // The Journey is already linked to this booking via bookingId; + // just update the seatId in its hop rows for this schedule. + const journey = await tx.journey.findFirst({ + where: { bookingId: bs.booking.id }, + select: { id: true }, + }); + + if (!journey) { + // No Journey/JourneySegment for this booking (e.g. duplicate that was never + // processed by finalizePaymentSuccess). Create them now using the same logic, + // scoped to the booking's origin→destination leg so the seatmap shows BOOKED + // only for the correct range of stops. + const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId; + const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId; + + const stopTimes = await tx.tripStopTime.findMany({ + where: { scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true }, + }); + + const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0; + const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1; + const fromIdx = originIdx >= 0 ? originIdx : 0; + const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1; + + const newJourney = await tx.journey.create({ + data: { + passengerId: bs.booking.passengerId, + bookingId: bs.booking.id, + status: 'CONFIRMED', + totalMinor: bs.booking.totalMinor, + currency: bs.booking.currency, + } as any, + }); + + const segments = []; + for (let i = fromIdx; i < toIdx; i++) { + segments.push({ + journeyId: newJourney.id, + scheduleId, + segmentOrder: i - fromIdx, + seatId: newSeat.id, + coachId: newSeat.coachId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } + if (segments.length > 0) { + await tx.journeySegment.createMany({ data: segments, skipDuplicates: true }); + } + this.logger.log( + `No Journey for ${bs.booking.bookingRef} — created Journey + ${segments.length} segment(s) for seat ${newSeat.seatNumber}`, + ); + return; + } + + const { count } = await tx.journeySegment.updateMany({ + where: { journeyId: journey.id, scheduleId, seatId: bs.seatId }, + data: { seatId: newSeat.id }, + }); + + // Journey exists but had no segments (e.g. booking confirmed via a path + // that skipped JourneySegment creation). Create them now for the new seat + // so the seatmap reflects BOOKED. + if (count === 0) { + const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId; + const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId; + const stopTimes = await tx.tripStopTime.findMany({ + where: { scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true }, + }); + const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0; + const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1; + const fromIdx = originIdx >= 0 ? originIdx : 0; + const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1; + const segments = []; + for (let i = fromIdx; i < toIdx; i++) { + segments.push({ + journeyId: journey.id, + scheduleId, + segmentOrder: i - fromIdx, + seatId: newSeat.id, + coachId: newSeat.coachId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } + if (segments.length > 0) { + await tx.journeySegment.createMany({ data: segments, skipDuplicates: true }); + } + this.logger.log( + `Seat reassigned: ${bs.booking.bookingRef} ` + + `${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` + + `(0 existing segments — created ${segments.length} new hop(s))`, + ); + } else { + this.logger.log( + `Seat reassigned: ${bs.booking.bookingRef} ` + + `${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` + + `(${count} segment hop(s) updated)`, + ); + } + }); + + // Mark as taken so the next booking in this batch doesn't get the same seat + assignedInBatch.add(newSeat.id); + occupied.add(newSeat.id); + + const oldSeatNumber = bs.seat?.seatNumber ?? '?'; + const origin = bs.booking.schedule?.originStation?.name ?? ''; + const dest = bs.booking.schedule?.destinationStation?.name ?? ''; + + if (bs.booking.contactPhone) { + const message = + `EDR: Your booking ${bs.booking.bookingRef} (${origin} → ${dest}): ` + + `your seat has been changed from seat ${oldSeatNumber} to seat ${newSeat.seatNumber}. ` + + `We apologize for any inconvenience.`; + await this.sms.sendSms({ to: bs.booking.contactPhone, message }).catch(() => null); + } + + this.logger.log( + `Duplicate resolved: ${bs.booking.bookingRef} seat ${oldSeatNumber} → ${newSeat.seatNumber}`, + ); + + results.push({ + bookingRef: bs.booking.bookingRef, + oldSeatNumber, + newSeatNumber: newSeat.seatNumber, + contactPhone: bs.booking.contactPhone, + }); + } + + return { + resolved: results.length, + unresolved: unresolved.length, + results, + ...(unresolved.length > 0 ? { unresolvedDetails: unresolved } : {}), + }; + } } diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx new file mode 100644 index 000000000..4b98bd933 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function DiscrepancyLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx new file mode 100644 index 000000000..9545c1d2a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx @@ -0,0 +1,519 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react'; +import { seatsApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; + +// ── Types ───────────────────────────────────────────────────────────────── + +interface DuplicateBooking { + bookingSeatId: string; + bookingId: string; + bookingRef: string; + passengerName: string; + contactPhone: string | null; + createdAt: string; +} + +interface DuplicateSeatGroup { + seatId: string; + seatNumber: string; + leg: number; + bookings: DuplicateBooking[]; +} + +interface AvailableSeat { + seatId: string; + seatNumber: string; +} + +interface CoachReport { + coachId: string; + coachNumber: string; + coachTypeName: string; + duplicates: DuplicateSeatGroup[]; + availableSeats: AvailableSeat[]; +} + +interface ScheduleReport { + scheduleId: string; + origin: string; + destination: string; + departureAt: string; + coaches: CoachReport[]; +} + +interface DuplicatesResponse { + date: string; + schedules: ScheduleReport[]; + totalDuplicates: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function today() { + return new Date().toISOString().slice(0, 10); +} + +function scheduleDuplicateCount(s: ScheduleReport) { + return s.coaches.reduce((sum, c) => sum + c.duplicates.length, 0); +} + +// ── Resolve modal ───────────────────────────────────────────────────────── + +interface ResolveModalProps { + schedule: ScheduleReport; + coach: CoachReport; + onClose: () => void; + onSuccess: () => void; +} + +function ResolveModal({ schedule, coach, onClose, onSuccess }: ResolveModalProps) { + // Default: pre-select all-but-first passenger in every duplicate group + const defaultSelected = new Set( + coach.duplicates.flatMap(g => g.bookings.slice(1).map(b => b.bookingSeatId)), + ); + const [selectedSeats, setSelectedSeats] = useState>(defaultSelected); + + // Coaches that have at least one available seat (pre-select all) + const coachesWithSeats = schedule.coaches.filter(c => c.availableSeats.length > 0); + const [selectedCoachIds, setSelectedCoachIds] = useState>( + new Set(coachesWithSeats.map(c => c.coachId)), + ); + + const [successMsg, setSuccessMsg] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const mutation = useMutation({ + mutationFn: (data: { bookingSeatIds: string[]; coachIds: string[] }) => + seatsApi.resolveDuplicates(data), + onSuccess: (res) => { + setSuccessMsg( + `${res.resolved ?? 0} passenger(s) successfully reassigned.` + + (res.unresolved > 0 ? ` ${res.unresolved} could not be resolved (no available seat).` : ''), + ); + setErrorMsg(null); + onSuccess(); + }, + onError: (err: any) => { + setErrorMsg(err?.response?.data?.message ?? 'Failed to resolve duplicates.'); + }, + }); + + function toggleBookingSeat(id: string) { + setSelectedSeats(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + function toggleCoach(id: string) { + setSelectedCoachIds(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + function handleAssign() { + setErrorMsg(null); + if (selectedSeats.size === 0) { + setErrorMsg('Select at least one passenger to reassign.'); + return; + } + if (selectedCoachIds.size === 0) { + setErrorMsg('Select at least one coach to source the replacement seat from.'); + return; + } + mutation.mutate({ + bookingSeatIds: [...selectedSeats], + coachIds: [...selectedCoachIds], + }); + } + + return ( +
+
+ + {/* Header */} +
+
+

+ Resolve Duplicates — {coach.coachNumber} +

+

+ {schedule.origin} → {schedule.destination} · {formatDateTime(schedule.departureAt)} +

+
+ +
+ +
+ + {/* Duplicate seat groups */} +
+

+ Duplicate seat assignments +

+

+ Check the passengers you want to reassign to a new seat. Unchecked passengers keep their current seat. +

+ + {coach.duplicates.map(group => ( +
+
+ + + Seat {group.seatNumber} — {group.bookings.length} passengers assigned + +
+
+ {group.bookings.map((b, idx) => { + const checked = selectedSeats.has(b.bookingSeatId); + return ( + + ); + })} +
+
+ ))} +
+ + {/* Coach selection */} +
+

+ Reassign to seats in +

+

+ The system picks the first available seat in the selected coaches. +

+
+ {coachesWithSeats.length === 0 ? ( +

No coaches have available seats on this schedule.

+ ) : ( + coachesWithSeats.map(c => ( + + )) + )} +
+
+ + {/* Feedback */} + {errorMsg && ( +
+ +

{errorMsg}

+
+ )} + {successMsg && ( +
+ +

{successMsg}

+
+ )} +
+ + {/* Footer */} +
+ + {!successMsg && ( + + )} +
+
+
+ ); +} + +// ── Coach card ──────────────────────────────────────────────────────────── + +interface CoachCardProps { + coach: CoachReport; + schedule: ScheduleReport; + onResolve: () => void; +} + +function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { + const [expanded, setExpanded] = useState(false); + const hasDuplicates = coach.duplicates.length > 0; + + return ( +
+ {/* Card header */} +
+
+
+ {coach.coachNumber} + {coach.coachTypeName} +
+
+ {coach.availableSeats.length} available seats + {hasDuplicates && ( + + + {coach.duplicates.length} duplicate{coach.duplicates.length > 1 ? 's' : ''} + + )} +
+
+ +
+ {hasDuplicates && ( + + )} + {hasDuplicates && ( + + )} +
+
+ + {/* Expanded passenger list */} + {expanded && hasDuplicates && ( +
+ {coach.duplicates.map(group => ( +
+

+ Seat {group.seatNumber} — {group.bookings.length} passengers +

+
+ {group.bookings.map((b, idx) => ( +
+ + {idx + 1} + +
+ {b.passengerName || '—'} + {b.bookingRef} +
+ {b.contactPhone ?? '—'} +
+ ))} +
+
+ ))} +
+ )} +
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────── + +export default function DiscrepancyPage() { + const [date, setDate] = useState(today()); + const [searchDate, setSearchDate] = useState(''); + const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['seat-duplicates', searchDate], + queryFn: () => seatsApi.getDuplicates(searchDate), + enabled: !!searchDate, + }); + + function handleSearch() { + if (date) setSearchDate(date); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') handleSearch(); + } + + return ( +
+ + {/* Page header */} +
+
+ +
+
+

Seat Discrepancy

+

+ Detect and resolve duplicate seat assignments by schedule date +

+
+
+ + {/* Date picker */} +
+ setDate(e.target.value)} + onKeyDown={handleKeyDown} + className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+ + {/* Error */} + {isError && ( +
+ + Failed to load duplicate seat data. Please try again. +
+ )} + + {/* Summary banner */} + {data && ( +
0 + ? 'bg-orange-50 dark:bg-orange-950/20 border-orange-200 dark:border-orange-800' + : 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800' + }`}> + {data.totalDuplicates > 0 ? ( + + ) : ( + + )} + 0 + ? 'text-orange-800 dark:text-orange-300' + : 'text-green-800 dark:text-green-300' + }`}> + {data.totalDuplicates > 0 + ? `${data.totalDuplicates} duplicate seat assignment${data.totalDuplicates > 1 ? 's' : ''} found across ${data.schedules.length} schedule${data.schedules.length > 1 ? 's' : ''} on ${data.date}` + : `No duplicate seat assignments found on ${data.date}`} + +
+ )} + + {/* Results per schedule */} + {data?.schedules.map(schedule => ( +
+ {/* Schedule header */} +
+
+

+ {schedule.origin} → {schedule.destination} +

+

+ {formatDateTime(schedule.departureAt)} · {scheduleDuplicateCount(schedule)} duplicate{scheduleDuplicateCount(schedule) !== 1 ? 's' : ''} +

+
+
+ + {/* Coach cards grid */} +
+ {schedule.coaches.map(coach => ( + setResolveTarget({ schedule, coach })} + /> + ))} +
+
+ ))} + + {/* Empty state when searched but no results */} + {data && data.schedules.length === 0 && data.totalDuplicates === 0 && searchDate && ( +
+ +

All seats are correctly assigned for {data.date}

+
+ )} + + {/* Resolve modal */} + {resolveTarget && ( + setResolveTarget(null)} + onSuccess={() => { + refetch(); + // Keep modal open to show success message; user closes manually + }} + /> + )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 45da6d8ca..62fa655c7 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -37,6 +37,7 @@ import { Banknote, Activity, Smartphone, + Layers, } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; import { cn } from '@/lib/utils'; @@ -64,7 +65,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view }, { name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view }, { name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.view }, - { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, + { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, + { name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index e8ed56195..2453eafa2 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -159,6 +159,10 @@ export const seatsApi = { undoRemove: (seatId: string) => apiClient.patch(`/seats/${seatId}/undo-remove`, {}), setMaintenance: (seatId: string, reason: string) => apiClient.post(`/seats/${seatId}/maintenance`, { reason }), clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`), + getDuplicates: (date: string, scheduleId?: string) => + apiClient.get(`/seats/duplicates?date=${date}${scheduleId ? `&scheduleId=${scheduleId}` : ''}`), + resolveDuplicates: (data: { bookingSeatIds: string[]; coachIds: string[] }) => + apiClient.post('/seats/duplicates/resolve', data), }; // Payments API From e115b9b7533712a07c70a16b79039b2e467c84b2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 12:43:54 +0000 Subject: [PATCH 11/18] fix(warehouse): resolve Load-to-Train bookings from wagon allocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Load-to-Train queue was permanently empty for real traffic. loadableTrains() and trainLoadableItems() gated on freight.train_schedule_bookings, but nothing in the application writes that table — only the demo seeders do. Real wagon allocation writes wagon_booking_allocations, reached via train_schedules -> train_sets -> train_set_wagons, so an allocated export booking never satisfied the EXISTS gate and no train ever appeared. Both queries now resolve a schedule's bookings through a shared sched_bookings CTE that unions the wagon-allocation chain with train_schedule_bookings, so real allocations show up and the seeded demo scenarios keep working. The panel already groups the returned rows by booking with their containers, so the queue now lists the train, its bookings and their containers for selection. Export flow this serves: booked -> paid -> received at the warehouse (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the booking. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) 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 07c720cff..70abf635b 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 @@ -1542,11 +1542,38 @@ export class WarehouseInventoryService { // their already-allocated wagons. Reuses the single-item load() machinery. /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ + /** + * The bookings riding a train schedule. + * + * Export flow: booked -> paid -> received at the warehouse (first-mile or + * self-haul) -> GRN -> loaded onto the wagons allocated to it. A booking + * actually reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only + * ever written by the demo seeders — keying off it alone left this queue + * permanently empty for real traffic — so both sources are unioned. + */ + private readonly SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; + async loadableTrains(): Promise { const rows: Array< LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( - `SELECT ts.id AS "scheduleId", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT ts.id AS "scheduleId", ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", @@ -1554,15 +1581,15 @@ export class WarehouseInventoryService { dy.country AS "destinationCountry", ts.status AS "status", ts.scheduled_departure_date AS "departureTime", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id AND inv.status = 'LOADED') AS "loadedCount" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id @@ -1570,10 +1597,10 @@ export class WarehouseInventoryService { WHERE ts.deleted_at IS NULL AND ts.status = ANY($1) AND EXISTS ( - SELECT 1 FROM freight.train_schedule_bookings tsb2 + SELECT 1 FROM sched_bookings sb2 JOIN freight.warehouse_inventory inv2 - ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL - WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL + ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL + WHERE sb2.schedule_id = ts.id AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, @@ -1599,7 +1626,8 @@ export class WarehouseInventoryService { */ async trainLoadableItems(scheduleId: string): Promise { const rows: Array> = await this.dataSource.query( - `SELECT inv.id AS "id", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT inv.id AS "id", inv.booking_id AS "bookingId", b.reference AS "bookingReference", company.name AS "customerName", @@ -1612,9 +1640,9 @@ export class WarehouseInventoryService { wl.wagon_id AS "wagonId", wl.wagon_number AS "wagonNumber", wl.sequence_no AS "sequenceNo" - FROM freight.train_schedule_bookings tsb - JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id - JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + FROM sched_bookings sb + JOIN freight.train_schedules ts ON ts.id = sb.schedule_id + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id @@ -1631,7 +1659,7 @@ export class WarehouseInventoryService { ORDER BY tsw.sequence_no ASC NULLS LAST LIMIT 1 ) wl ON true - WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + WHERE sb.schedule_id = $1 AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, [scheduleId], From 2798fed6d8bd488c67e4f2446ed16178db6f4a2f Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:09:20 +0000 Subject: [PATCH 12/18] fix(train-scheduling): block dispatch when allocated cargo is not loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatchSchedule guarded status, Djibouti departure rules, locomotives and wagons — but never checked the cargo. A train could be dispatched while the bookings allocated to it sat received in the warehouse, silently leaving them behind. Dispatch now refuses when an allocated booking has warehouse inventory in RECEIVED/STORED/READY_FOR_LOADING, naming the bookings and pointing at the two ways out: load them, or drop the wagon allocation so they ride a later train. Bookings with no inventory at all are not blocked — allocating a wagon before the goods arrive is normal planning. Also drops RESERVED from the Load-to-Train filters: reserved stock is not awaiting loading. The sched_bookings CTE moves to common/schedule-bookings.sql so the warehouse loading queue and this dispatch guard resolve a train's bookings identically — if they drift, a train departs leaving cargo the warehouse still expects to load. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/common/schedule-bookings.sql.ts | 28 +++++++++++++++ .../train-scheduling.service.ts | 34 +++++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 34 +++++-------------- 3 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 apps/edr-freight-api/src/common/schedule-bookings.sql.ts diff --git a/apps/edr-freight-api/src/common/schedule-bookings.sql.ts b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts new file mode 100644 index 000000000..177b8549b --- /dev/null +++ b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts @@ -0,0 +1,28 @@ +/** + * SQL CTE resolving the bookings riding a train schedule, as `sched_bookings + * (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`. + * + * A booking reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only ever + * written by the demo seeders, so both sources are unioned: real allocations work + * and the seeded scenarios keep working. + * + * Shared so the warehouse loading queue and the train dispatch guard agree on + * exactly which bookings are on a train — if they drift, a train can be + * dispatched leaving cargo the warehouse still thinks it should load. + */ +export const SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index b92a7ea90..97984be5f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -19,6 +19,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { DataSource, EntityManager, @@ -2029,6 +2030,37 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } + /** + * A train must not leave carrying nothing while its cargo sits in the shed. + * Blocks dispatch when a booking allocated to this train has warehouse + * inventory that never made it onto a wagon (received / stored / ready but not + * LOADED). Either load it from the warehouse Load-to-Train queue, or drop the + * booking's wagon allocation so it travels on a later train. + * + * Bookings with no warehouse inventory at all are NOT blocked — allocating a + * wagon before the goods arrive is normal planning; they simply aren't aboard. + */ + private async assertAllocatedCargoLoaded(scheduleId: string): Promise { + const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( + `WITH ${SCHEDULE_BOOKINGS_CTE} + SELECT DISTINCT b.reference AS "reference", inv.status AS "status" + FROM sched_bookings sb + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = $1 + AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`, + [scheduleId], + ); + if (rows.length) { + const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', '); + throw new BadRequestException( + `Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` + + `Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`, + ); + } + } + async dispatchSchedule(scheduleId: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -2038,6 +2070,8 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); + // Don't leave received cargo behind on the platform. + await this.assertAllocatedCargoLoaded(scheduleId); // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); 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 70abf635b..3ad71b6fc 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 @@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; import { Company } from '../companies/entities/company.entity'; @@ -1543,30 +1544,11 @@ export class WarehouseInventoryService { /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ /** - * The bookings riding a train schedule. - * - * Export flow: booked -> paid -> received at the warehouse (first-mile or - * self-haul) -> GRN -> loaded onto the wagons allocated to it. A booking - * actually reaches a train through WAGON ALLOCATION - * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), - * which is what the allocation UI writes. `train_schedule_bookings` is only - * ever written by the demo seeders — keying off it alone left this queue - * permanently empty for real traffic — so both sources are unioned. + * Export flow this queue serves: booked -> paid -> received at the warehouse + * (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the + * booking. Which bookings ride a train comes from the shared CTE. */ - private readonly SCHEDULE_BOOKINGS_CTE = ` - sched_bookings AS ( - SELECT ts.id AS schedule_id, wba.booking_id - FROM freight.train_schedules ts - JOIN freight.train_set_wagons tsw - ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL - JOIN freight.wagon_booking_allocations wba - ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL - WHERE ts.deleted_at IS NULL - UNION - SELECT tsb.train_schedule_id, tsb.booking_id - FROM freight.train_schedule_bookings tsb - WHERE tsb.deleted_at IS NULL - )`; + private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE; async loadableTrains(): Promise { const rows: Array< @@ -1585,7 +1567,7 @@ export class WarehouseInventoryService { JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL WHERE sb.schedule_id = ts.id - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount", (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL @@ -1601,7 +1583,7 @@ export class WarehouseInventoryService { JOIN freight.warehouse_inventory inv2 ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL WHERE sb2.schedule_id = ts.id - AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, [['DRAFT', 'SCHEDULED']], @@ -1660,7 +1642,7 @@ export class WarehouseInventoryService { LIMIT 1 ) wl ON true WHERE sb.schedule_id = $1 - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, [scheduleId], ); From d9db4cbc0cd663683acb13aa83510efd102f12bf Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:21:55 +0000 Subject: [PATCH 13/18] fix(train-scheduling): scope the not-loaded dispatch guard to EXPORT only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard fired on every dispatch. Loading out of an origin warehouse is an export concept — import cargo isn't loaded from a warehouse, so its warehouse inventory says nothing about what's aboard and the check would have blocked legitimate import dispatches. Derive the route direction (reusing deriveTradeDirection, as the warehouse loading queue does) and return early for anything that isn't EXPORT. Import and domestic behave exactly as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../train-scheduling.service.ts | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 97984be5f..bb85b4422 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2031,16 +2031,37 @@ export class TrainSchedulingService { } /** - * A train must not leave carrying nothing while its cargo sits in the shed. - * Blocks dispatch when a booking allocated to this train has warehouse - * inventory that never made it onto a wagon (received / stored / ready but not - * LOADED). Either load it from the warehouse Load-to-Train queue, or drop the - * booking's wagon allocation so it travels on a later train. + * EXPORT ONLY. An export train must not leave carrying nothing while its cargo + * sits in the shed: the goods are received into the origin warehouse, GRN'd and + * loaded onto the wagons allocated to the booking, so anything still in the + * warehouse at dispatch is being left behind. Blocks dispatch when an allocated + * booking has warehouse inventory that never made it onto a wagon (received / + * stored / ready but not LOADED) — either load it from the Load-to-Train queue, + * or drop the booking's wagon allocation so it rides a later train. + * + * Import/domestic are untouched: their cargo isn't loaded out of an origin + * warehouse, so warehouse inventory says nothing about what's aboard. * * Bookings with no warehouse inventory at all are NOT blocked — allocating a * wagon before the goods arrive is normal planning; they simply aren't aboard. */ private async assertAllocatedCargoLoaded(scheduleId: string): Promise { + const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> = + await this.dataSource.query( + `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL`, + [scheduleId], + ); + if (!route) return; + const direction = deriveTradeDirection( + { country: route.originCountry }, + { country: route.destinationCountry }, + ); + if (direction !== 'EXPORT') return; + const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT DISTINCT b.reference AS "reference", inv.status AS "status" @@ -2070,7 +2091,7 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); - // Don't leave received cargo behind on the platform. + // Export only: don't leave received cargo behind in the warehouse. await this.assertAllocatedCargoLoaded(scheduleId); // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. From a26fa61d6685fd867a72766601fb036847a52522 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:32:38 +0000 Subject: [PATCH 14/18] feat(warehouse): require a GRN before export cargo can be loaded onto a train MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export chain is: paid -> received into the warehouse -> GRN raised on arrival -> loaded onto the allocated wagon. Receipt was already structural (the inventory row only exists once receive() runs) and the wagon was already required, but the GRN was merely displayed, never enforced — so cargo could be loaded and dispatched without one. - loadable now also requires a GRN, so the queue won't offer un-GRN'd cargo. - loadItemsOntoTrain skips items with no GRN, so the rule holds server-side and a hand-made API call can't bypass it. - Read the GRN from inv.grn_number (what receive() stamps) and fall back to the note only for legacy/seeded rows; it previously read the note alone, which the real receive path merely mirrors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 3ad71b6fc..19b1c0159 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 @@ -1616,7 +1616,12 @@ export class WarehouseInventoryService { ct.container_number AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", - substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber", + -- receive() stamps the GRN onto the row and mirrors it into the + -- note; prefer the column and fall back for legacy/seeded rows. + COALESCE( + inv.grn_number, + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) AS "grnNumber", inv.inspection_status AS "inspectionStatus", inv.status AS "status", wl.wagon_id AS "wagonId", @@ -1649,7 +1654,11 @@ export class WarehouseInventoryService { return rows.map((r) => ({ ...r, - loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId), + // Export flow: received at the warehouse -> GRN -> loaded onto its wagon. + // The row only exists once the goods were received, so requiring a GRN and + // an allocated wagon completes the chain. + loadable: + r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber), })); } @@ -1702,6 +1711,9 @@ export class WarehouseInventoryService { if (!item) { skip('Not assigned to this train'); continue; } if (item.status === 'LOADED') { skip('Already loaded'); continue; } if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; } + // Export: the GRN is raised when the goods arrive at the warehouse, and + // nothing rides a train without one. + if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; } if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } try { From a95667fde45c3805ecb4179783389fac38bd9b3e Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:44:02 +0000 Subject: [PATCH 15/18] fix(warehouse): raise a GRN on every warehouse receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requiring a GRN before loading only works if every path into the warehouse issues one. Two did not: autoUnloadArrived and unloadBooking created RECEIVED inventory with a null grn_number, so cargo that genuinely arrived — by first mile or self haul — would have been stuck un-loadable behind the new gate. Both now stamp a GRN, derived from the booking's trade direction, matching receive/bulkReceive/autoUnloadArrivedBookings. unloadBooking keeps an already-issued GRN when it re-unloads an existing row rather than reissuing one. Every path that creates warehouse inventory now issues a GRN, so the chain is seamless: booking arrives (first mile or self haul) -> received -> GRN -> loadable onto its allocated wagon. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 19b1c0159..3630aa9de 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 @@ -1032,6 +1032,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); continue; } + // Goods reaching the warehouse always get a GRN, whichever path brought + // them in — nothing loads onto a train without one. const saved = await this.inventoryRepository.create({ warehouseId: location.warehouseId, yardId: location.yardId, @@ -1041,6 +1043,11 @@ export class WarehouseInventoryService { weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: new Date(), + grnNumber: this.generateGrnNumber( + booking.tradeDirection ?? 'WH', + booking.id, + new Date(), + ), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); result.processedCount += 1; @@ -1061,6 +1068,14 @@ export class WarehouseInventoryService { /** Unload a single arrived booking into a chosen (or default) location. */ async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); + // Goods reaching the warehouse always get a GRN, whichever path brought them + // in — nothing loads onto a train without one. + const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( + `SELECT trade_direction AS "tradeDirection" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + const grnDirection = bookingRow?.tradeDirection ?? 'WH'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1081,6 +1096,10 @@ export class WarehouseInventoryService { zoneId: location.zoneId, status: 'RECEIVED', arrivedAt, + // Keep an already-issued GRN; only raise one if this row never got it. + ...(existing[0].grnNumber + ? {} + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1095,6 +1114,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); From fc636fbe4f77945c0ae3368041e789f8ce5a61b2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:45:50 +0000 Subject: [PATCH 16/18] fix(warehouse): scope the arrival GRN stamp to EXPORT only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made autoUnloadArrived and unloadBooking raise a GRN for any direction, which changed import behaviour. Import keeps its own GRN handling (autoUnloadArrivedBookings) and is left exactly as it was. Both paths now stamp a GRN only when the booking is EXPORT — the direction whose cargo needs one to be loaded onto a train. Import and domestic behave as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) 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 3630aa9de..c489b6f89 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 @@ -1032,8 +1032,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); continue; } - // Goods reaching the warehouse always get a GRN, whichever path brought - // them in — nothing loads onto a train without one. + // EXPORT goods get their GRN on arrival at the warehouse — nothing loads + // onto a train without one. Import GRN handling is left untouched. const saved = await this.inventoryRepository.create({ warehouseId: location.warehouseId, yardId: location.yardId, @@ -1043,11 +1043,9 @@ export class WarehouseInventoryService { weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: new Date(), - grnNumber: this.generateGrnNumber( - booking.tradeDirection ?? 'WH', - booking.id, - new Date(), - ), + ...(booking.tradeDirection === 'EXPORT' + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); result.processedCount += 1; @@ -1068,14 +1066,14 @@ export class WarehouseInventoryService { /** Unload a single arrived booking into a chosen (or default) location. */ async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); - // Goods reaching the warehouse always get a GRN, whichever path brought them - // in — nothing loads onto a train without one. + // EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto + // a train without one. Import GRN handling is left untouched. const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( `SELECT trade_direction AS "tradeDirection" FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); - const grnDirection = bookingRow?.tradeDirection ?? 'WH'; + const isExport = bookingRow?.tradeDirection === 'EXPORT'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1096,10 +1094,10 @@ export class WarehouseInventoryService { zoneId: location.zoneId, status: 'RECEIVED', arrivedAt, - // Keep an already-issued GRN; only raise one if this row never got it. - ...(existing[0].grnNumber - ? {} - : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), + // Export only, and keep an already-issued GRN rather than reissuing. + ...(isExport && !existing[0].grnNumber + ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } + : {}), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1114,7 +1112,9 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), + ...(isExport + ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } + : {}), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); From bd81bbfc2c4baff04a81194c652189b219f80099 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 17 Jul 2026 11:15:58 +0300 Subject: [PATCH 17/18] style: sidebar improvmetn --- apps/edr-freight-web/backoffice/src/App.tsx | 432 +++++++++--------- .../src/components/layout/FreightSidebar.tsx | 34 +- .../backoffice/src/components/layout/types.ts | 2 +- 3 files changed, 237 insertions(+), 231 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0da9fe3f0..81a8552e7 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -18,6 +18,7 @@ import { Send, Settings, ShieldCheck, + Settings2, Ship, SlidersHorizontal, Train, @@ -142,14 +143,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "Staff", - href: "/user-management", - icon: , - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , + label: "Customers", + href: "/dashboard/customers", + icon: , }, { label: "Contracts", @@ -157,6 +153,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.view, }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + }, // Operations hub: clearance-document review for contracts WITHOUT // customs clearing (contract-level for one-time, per-booking for general). { @@ -165,11 +166,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.opsClearanceReview, }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - }, { label: "Payments", href: "/dashboard/payments", @@ -186,183 +182,185 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ ], }, { - title: "Operations", + // title: "Port & Terminal", items: [ { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + { + label: "Shipment Requests", + href: "/dashboard/shipment-requests", + icon: , + permission: FREIGHT_PERMS.contracts.createBooking, + }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + { + label: "Self-Clearance Review", + href: "/dashboard/contracts/ops-clearance", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, ], }, { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, - }, - // Operations Path A queue: per-booking self-clearance review for - // GENERAL non-customs booking instances (and legacy self-clear bookings). - { - label: "Self-Clearance Review", - href: "/dashboard/contracts/ops-clearance", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", + label: "Fleet Management", icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.fleet.view, + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - title: "Port & Terminal", - items: [ { label: "Imports", href: "/dashboard/import-warehouse", @@ -437,35 +435,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ], }, - ], - }, - { - title: "Warehouse Management", - items: [ { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", + label: "Warehouse Management", icon: , - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], }, ], }, { - title: "Administration", + title: "Freight configuration", + mutedTitle: true, items: [ { label: "File settings", @@ -485,12 +485,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ { label: "Configuration", href: "/dashboard/configuration", @@ -513,6 +507,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: getCategorySidebarChildren("rules"), }, + + { + label: "Staff", + href: "/user-management", + icon: , + }, ], }, ]; @@ -599,7 +599,10 @@ const findActiveSidebarLabel = ( ): string | undefined => { const path = pathname.toLowerCase(); const candidates = flattenSidebarItems(sections) - .map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() })) + .map(({ href, label }) => ({ + label, + href: href.split("?")[0].toLowerCase(), + })) .sort((a, b) => b.href.length - a.href.length); return candidates.find( @@ -674,10 +677,7 @@ const App = () => { } /> {/* } /> */} - } - /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 698b0fc2c..91429356c 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -92,7 +92,9 @@ const FreightSidebar = ({ walk(item.children, key); }); }; - sections.forEach((section) => walk(section.items, section.title)); + sections.forEach((section, i) => + walk(section.items, section?.title ?? "" + i++), + ); return acc; }, [sections, isHrefActive, branchActive]); @@ -126,6 +128,7 @@ const FreightSidebar = ({ opened={isOpen} classNames={navClassNames(active)} onClick={() => toggle(key)} + childrenOffset="sm" rightSection={ @@ -166,6 +169,7 @@ const FreightSidebar = ({ active={active} component={Link} classNames={navClassNames(active)} + onClick={onClose} to={item.href!} /> ); @@ -177,19 +181,21 @@ const FreightSidebar = ({ () => sections.map((section) => ( - - {section.title} - + {section.title && ( + + {section.title} + + )} {section.items.map((item, i) => - renderItem(item, itemKey(section.title, item, i)), + renderItem(item, itemKey(section.title ?? "" + i, item, i)), )} @@ -257,7 +263,7 @@ const FreightSidebar = ({ px="sm" pb="md" > - {renderedSections} + {renderedSections} ); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/types.ts b/apps/edr-freight-web/backoffice/src/components/layout/types.ts index 051f28839..2129e05e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/types.ts @@ -12,7 +12,7 @@ export interface SidebarItem { export interface SidebarSection { /** Section label shown above a group of nav items (e.g. "Main menu"). */ - title: string; + title?: string; items: SidebarItem[]; /** When true, section title uses muted grey instead of dark text. */ mutedTitle?: boolean; From ef74bc442ab60ee779d0ad9b9f3f05ecee7e46c3 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 17 Jul 2026 08:54:24 +0000 Subject: [PATCH 18/18] fix: prevent the backoffice from approve the user before he submits --- .../modules/companies/companies.repository.ts | 74 ++++++++++++++++--- .../modules/companies/companies.service.ts | 25 +++++++ .../dto/company-stats-response.dto.ts | 3 + .../companies/dto/list-companies-query.dto.ts | 12 ++- .../companies/dto/response-company.dto.ts | 11 +++ .../src/components/customers/badges.tsx | 19 +++++ .../pages/customers/CustomerDetailPage.tsx | 50 +++++++++++-- .../src/pages/customers/CustomersPage.tsx | 49 ++++++++++-- .../backoffice/src/types/customer.ts | 33 +++++++++ 9 files changed, 253 insertions(+), 23 deletions(-) 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 15ca85c73..6a0365854 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @Injectable() export class CompaniesRepository extends BaseRepository { + /** + * A company still being filled in by its owner in the portal wizard: it was + * self-registered (so it has an external profile) and nobody has submitted + * onboarding yet. The row exists from the wizard's first click, carrying a + * placeholder name + TIN, so it must not be offered up for review. + * Staff-created companies have no external profiles and are never drafts. + */ + private static readonly DRAFT_SQL = `( + EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + AND ep.onboarding_completed = true + ) + )`; + constructor( @InjectRepository(Company) repo: Repository, @@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository { async findPaginated( query: ListCompaniesQueryDto, ): Promise<{ items: Company[]; total: number }> { - const { page = 1, pageSize = 20, search, type, kind, status } = query; + const { + page = 1, + pageSize = 20, + search, + type, + kind, + status, + onboardingCompleted, + } = query; const qb = this.repository .createQueryBuilder('company') .leftJoinAndSelect('company.companyProfiles', 'companyProfiles') + // External profiles carry onboardingCompleted, which the backoffice list + // uses to flag customers still mid-onboarding (not yet reviewable). + .leftJoinAndSelect('company.profiles', 'profiles') .where('company.deleted_at IS NULL'); if (type) { @@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.status = :status', { status }); } + if (onboardingCompleted !== undefined) { + qb.andWhere( + onboardingCompleted + ? `NOT ${CompaniesRepository.DRAFT_SQL}` + : CompaniesRepository.DRAFT_SQL, + ); + } + if (search) { const term = `%${search.trim()}%`; qb.andWhere( @@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository { } async getStats(): Promise { - const rows: { status: string; count: string }[] = await this.repository - .createQueryBuilder('company') - .select('company.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('company.deleted_at IS NULL') - .groupBy('company.status') - .getRawMany(); + // Drafts are counted separately rather than under `pending`: they carry + // status=pending from creation, which would otherwise inflate the review + // queue's KPI with customers who haven't submitted anything yet. + const rows: { status: string; is_draft: boolean; count: string }[] = + await this.repository + .createQueryBuilder('company') + .select('company.status', 'status') + .addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft') + .addSelect('COUNT(*)', 'count') + .where('company.deleted_at IS NULL') + .groupBy('company.status') + .addGroupBy(CompaniesRepository.DRAFT_SQL) + .getRawMany(); - const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)])); - const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + const map = new Map(); + let onboarding = 0; + let total = 0; + for (const row of rows) { + const count = parseInt(row.count, 10); + total += count; + if (row.is_draft) onboarding += count; + else map.set(row.status, (map.get(row.status) ?? 0) + count); + } return { total, active: map.get('active') ?? 0, pending: map.get('pending') ?? 0, + onboarding, suspended: map.get('suspended') ?? 0, blacklisted: map.get('blacklisted') ?? 0, }; 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 1027de955..04b8790cd 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -372,6 +372,9 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); + // External profiles carry the onboarding flag the backoffice gates + // approval decisions on (see ResponseCompanyDto.onboardingCompleted). + company.profiles = await this.profilesRepo.findByCompanyId(id); return company; } @@ -962,6 +965,28 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // 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 + // application that doesn't exist yet. Staff-created companies have no + // external profiles and are exempt. + // + // Only the review decision itself is gated (a profile still awaiting one: + // Pending, or Rejected and awaiting re-approval). Profiles already in + // service stay managable so staff can suspend/blacklist them — including to + // undo an approval granted before this guard existed. + const awaitingReview = + existing.status === ProfileStatus.Pending || + existing.status === ProfileStatus.Rejected; + if (awaitingReview) { + const owners = await this.profilesRepo.findByCompanyId(existing.companyId); + if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) { + throw new BadRequestException( + "This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.", + ); + } + } + // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts index a6b8b3b6e..c054b3531 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -1,7 +1,10 @@ export class CompanyStatsResponseDto { total!: number; active!: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending!: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding!: number; suspended!: number; blacklisted!: number; } 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 4dbb932cb..adaa12479 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 @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; import { Transform } from "class-transformer"; import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; @@ -37,4 +37,14 @@ export class ListCompaniesQueryDto { @IsOptional() @IsIn(Object.values(CompanyStatus)) status?: CompanyStatus; + + @ApiPropertyOptional({ + description: + "Filter by onboarding submission. `true` = reviewable applications; " + + "`false` = drafts still in the portal wizard. Omit for both.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + onboardingCompleted?: boolean; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 0c783cbcf..a05812558 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -62,6 +62,13 @@ export class ResponseCompanyDto { attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; companyProfiles?: ResponseCompanyProfileDto[]; + /** + * Whether the owning portal user has submitted the onboarding wizard. + * Approval decisions are blocked while this is false. Staff-created + * companies (no external profiles) count as completed. Undefined when the + * external profiles weren't loaded. + */ + onboardingCompleted?: boolean; createdAt: Date; updatedAt: Date; @@ -84,6 +91,10 @@ export class ResponseCompanyDto { this.companyProfiles = company.companyProfiles?.map( (p) => new ResponseCompanyProfileDto(p), ); + this.onboardingCompleted = company.profiles + ? company.profiles.length === 0 || + company.profiles.some((p) => p.onboardingCompleted) + : undefined; this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 76555a014..6cb6759e7 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -280,13 +280,20 @@ export function InvoiceStatusBadge({ * Transitions: pending → approve / reject-with-note | rejected → approve (override) | * active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate. * Rejecting captures a note the customer sees so they can fix and reapply. + * + * `locked` (customer hasn't submitted onboarding) withholds the review decision + * only — there's no application to judge yet, and the API rejects the call + * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so + * an already-active profile is still managable. */ export function ProfileApprovalActions({ profileId, status, + locked = false, }: { profileId: string; status: ProfileStatus; + locked?: boolean; }) { const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), @@ -346,6 +353,18 @@ export function ProfileApprovalActions({ ); + // Pending/rejected are the two states awaiting a reviewer's decision — the + // exact pair the API gates on until the customer submits. + if (locked && (status === "pending" || status === "rejected")) { + return ( + + + Awaiting submission + + + ); + } + if (status === "pending") { return ( <> diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index dc682f1c7..9ae5a771b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Alert, Anchor, Badge, Box, @@ -22,6 +23,7 @@ import { Download, Eye, FileText, + Hourglass, IdCard, LayoutGrid, Package, @@ -60,6 +62,7 @@ import type { CustomerDocument, CustomerPayment, } from "@/types/customer"; +import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer"; import type { Invoice } from "@/types/invoice"; import { DataTable, @@ -166,6 +169,13 @@ export default function CustomerDetailPage() { ); const paidCurrency = payments[0]?.currency ?? "ETB"; + // The company row is created on the wizard's first click, so a draft reaches + // this page with a placeholder name/TIN. `stillOnboarding` drives the banner + // and badge; `canReview` gates the approve/reject buttons and mirrors the + // API's rule exactly, so no button is offered that the server would reject. + const stillOnboarding = company ? isOnboardingDraft(company) : false; + const canReview = company ? hasSubmittedOnboarding(company) : true; + const profileColumns: ColumnDef[] = useMemo( () => [ { @@ -273,11 +283,12 @@ export default function CustomerDetailPage() { ), }, ], - [view], + [view, canReview], ); const bookingColumns: ColumnDef[] = useMemo( @@ -602,7 +613,13 @@ export default function CustomerDetailPage() { meta={ - + {stillOnboarding ? ( + + Onboarding in progress + + ) : ( + + )} } @@ -631,6 +648,21 @@ export default function CustomerDetailPage() { {/* OVERVIEW */} + {stillOnboarding && ( + } + title="This customer hasn't submitted their application yet" + > + They're still filling in the onboarding wizard, so the details + below are an unfinished draft — the company name and TIN are + placeholders until they reach those steps. Role profiles become + reviewable once the application is submitted. + + )} + p.status === "pending", - ).length, + // A draft's profiles are all `pending` by construction, which + // would read as a review backlog that doesn't exist yet. + label: stillOnboarding + ? "Awaiting submission" + : "Pending approval", + value: stillOnboarding + ? "—" + : company.companyProfiles.filter( + (p) => p.status === "pending", + ).length, icon: IdCard, color: "yellow", }, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index aff353055..89325ae79 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -16,6 +16,7 @@ import { Building2, CheckCircle2, Clock, + Hourglass, Mail, Phone, RefreshCw, @@ -36,6 +37,7 @@ import { import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; import type { Company, CompanyStatus } from "@/types/customer"; +import { isOnboardingDraft } from "@/types/customer"; import { DataTable, DataTableFooter, @@ -43,22 +45,39 @@ import { type ColumnDef, } from "@edr/ui-common"; +/** + * The list's segmented views. "Pending approval" means submitted-and-awaiting- + * review, so it excludes drafts — a company row exists from the onboarding + * wizard's first click and would otherwise pad the review queue. Those drafts + * get their own view instead of disappearing, so staff can still chase them. + */ +type CustomerView = "all" | "pending" | "onboarding" | "active"; + +const VIEW_FILTERS: Record< + CustomerView, + { status?: CompanyStatus; onboardingCompleted?: boolean } +> = { + all: {}, + pending: { status: "pending", onboardingCompleted: true }, + onboarding: { onboardingCompleted: false }, + active: { status: "active" }, +}; + export default function CustomersPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); - // "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review). - const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>(""); + const [view, setView] = useState("all"); const filter = useMemo( () => ({ page: pagination.pageIndex + 1, pageSize: pagination.pageSize, search: debouncedQuery, - status: statusFilter || undefined, + ...VIEW_FILTERS[view], }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], + [pagination.pageIndex, pagination.pageSize, debouncedQuery, view], ); const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); @@ -114,6 +133,17 @@ export default function CustomersPage() { id: "status", header: "Status", cell: ({ row }) => { + // A draft's profiles are all `pending` by construction, so the + // "N pending" review hint would be a lie until they submit. + if (isOnboardingDraft(row.original)) { + return ( + + + Onboarding + + + ); + } const pending = (row.original.companyProfiles ?? []).filter( (p) => p.status === "pending", ).length; @@ -206,6 +236,12 @@ export default function CustomersPage() { { label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" }, { label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" }, { label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" }, + { + label: "Onboarding", + value: stats?.onboarding ?? "—", + icon: Hourglass, + color: "gray", + }, { label: "Blacklisted", value: stats?.blacklisted ?? "—", @@ -243,14 +279,15 @@ export default function CustomersPage() { { - setStatusFilter(v === "all" ? "" : (v as CompanyStatus)); + setView(v as CustomerView); setPagination((prev) => ({ ...prev, pageIndex: 0 })); }} data={[ { label: "All", value: "all" }, { label: "Pending approval", value: "pending" }, + { label: "Onboarding", value: "onboarding" }, { label: "Active", value: "active" }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 8fb03cf86..ef88403a9 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -146,10 +146,38 @@ export interface Company { website?: string | null; attributes?: Record | null; companyProfiles: CompanyProfile[]; + /** + * Whether the customer submitted their onboarding application. A company row + * is created on the wizard's first click, so a `pending` company with this + * false is a half-filled draft — not reviewable. Staff-created companies are + * always true. Undefined on endpoints that don't load external profiles. + */ + onboardingCompleted?: boolean; createdAt: string; updatedAt: string; } +/** + * Whether the customer has submitted their onboarding application. Mirrors the + * API's review gate (`setCompanyProfileStatus`): until this is true, a role + * awaiting a decision cannot be approved or rejected. Companies loaded without + * external profiles (`undefined`) are treated as submitted — absence of the + * flag must not lock staff out. + */ +export function hasSubmittedOnboarding(company: Company): boolean { + return company.onboardingCompleted !== false; +} + +/** + * A pristine draft: still `pending` and never submitted, so its name/TIN are + * placeholders and there is nothing to review. Drives presentation only — the + * approval gate is `hasSubmittedOnboarding`, which also covers the (corrupted) + * case of a company activated before that gate existed. + */ +export function isOnboardingDraft(company: Company): boolean { + return company.status === "pending" && !hasSubmittedOnboarding(company); +} + /** Query parameters for the company list. */ export interface CompanyListFilter { page: number; @@ -158,6 +186,8 @@ export interface CompanyListFilter { type?: CompanyType; kind?: CompanyKind; status?: CompanyStatus; + /** `true` = submitted applications only; `false` = drafts only; omit for both. */ + onboardingCompleted?: boolean; } /** Standard paginated list envelope (matches the bookings service shape). */ @@ -170,7 +200,10 @@ export interface PaginatedCompanies { export interface CompanyStats { total: number; active: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding: number; suspended: number; blacklisted: number; }