From f71bbbf782f8aacb482a703cedc854cd3a6ea6b1 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 16 Jul 2026 12:08:45 +0000 Subject: [PATCH 1/4] 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 2/4] 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 2366b28610f496e4abf2e174751bdd91342b4bc6 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 17 Jul 2026 08:42:42 +0300 Subject: [PATCH 3/4] 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 4/4] 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