mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal file
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal file
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal file
@@ -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<User>,
|
||||
@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<TCurrentTokenUser> = 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<TCurrentTokenUser>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal file
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
16
apps/edr-freight-api/src/modules/auth/mask-target.util.ts
Normal file
16
apps/edr-freight-api/src/modules/auth/mask-target.util.ts
Normal file
@@ -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)}`;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<void> {
|
||||
const phone = company.contactPersonPhone ?? company.phone ?? null;
|
||||
const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id);
|
||||
const email = company.email ?? company.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<string> {
|
||||
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<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
@@ -804,10 +839,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(
|
||||
@@ -823,14 +858,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. */
|
||||
@@ -856,20 +886,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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<string | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
@@ -4946,7 +4952,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",
|
||||
@@ -4963,6 +4969,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
@@ -19,6 +20,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",
|
||||
|
||||
@@ -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: <UserCog size={16} /> },
|
||||
{ id: "company", label: "Company", icon: <Building2 size={16} /> },
|
||||
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
|
||||
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
|
||||
@@ -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() {
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{/* 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. */}
|
||||
<Tabs.Panel value="account">
|
||||
{user ? (
|
||||
<TabAccount user={user} />
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* 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. */}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
|
||||
/** IAM returns bare error codes; turn them into something a customer can act on. */
|
||||
const MESSAGES: Record<string, string> = {
|
||||
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<FormData>({
|
||||
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 (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<KeyRound size={20} />
|
||||
<Title order={3}>Password</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
Change the password you use to sign in. You'll need your current one.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
|
||||
<Stack gap="md">
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
autoComplete="current-password"
|
||||
error={errors.oldPassword?.message}
|
||||
{...register("oldPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New Password"
|
||||
description="At least 8 characters, with a lowercase letter, a number and a symbol."
|
||||
autoComplete="new-password"
|
||||
error={errors.newPassword?.message}
|
||||
{...register("newPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Confirm New Password"
|
||||
autoComplete="new-password"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="xl"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{mutation.isSuccess && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Password changed
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{mutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{errorMessage(mutation.error)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
399
apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx
Normal file
399
apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
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";
|
||||
import ChangePasswordCard from "./ChangePasswordCard";
|
||||
|
||||
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<typeof schema>;
|
||||
|
||||
/** A contact change that still needs its code entered. */
|
||||
interface PendingChange {
|
||||
channel: ContactChannel;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const CHANNEL_LABEL: Record<ContactChannel, string> = {
|
||||
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<PendingChange[]>([]);
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
const [completed, setCompleted] = useState<ContactChannel[]>([]);
|
||||
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<FormData>({
|
||||
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 (
|
||||
<Stack gap="lg">
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<UserCog size={20} />
|
||||
<Title order={3}>Account</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
Your login details. Verification codes and SMS notifications are sent
|
||||
to the phone number below.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="phoneNumber"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="you@company.com"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Abebe Bekele"
|
||||
error={errors.nameEn?.message}
|
||||
{...register("nameEn")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Full Name (Amharic)"
|
||||
placeholder="አበበ በቀለ"
|
||||
error={errors.nameAm?.message}
|
||||
{...register("nameAm")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Text c="edr-muted" size="xs" mt="sm">
|
||||
Changing your phone number or email requires a verification code
|
||||
sent to the new one.
|
||||
</Text>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="xl"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{savedSummary && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{savedSummary}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{nameMutation.isSuccess && !savedSummary && !queue.length && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Saved successfully
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{(otpMutation.isError || nameMutation.isError) && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{errorMessage(otpMutation.error ?? nameMutation.error)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={busy}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
<Modal
|
||||
opened={current !== null}
|
||||
onClose={cancelQueue}
|
||||
title="Verify your new contact details"
|
||||
centered
|
||||
>
|
||||
{current && (
|
||||
<Stack gap="md">
|
||||
{totalSteps > 1 && (
|
||||
<Text size="sm" c="edr-muted">
|
||||
Step {step} of {totalSteps}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Alert icon={<ShieldCheck size={16} />} color="blue">
|
||||
{sentTo ? (
|
||||
<>
|
||||
We sent a 6-digit code to <b>{sentTo}</b>. Enter it to
|
||||
confirm your new {CHANNEL_LABEL[current.channel]}.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sending a code to your new {CHANNEL_LABEL[current.channel]}…
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{completed.length > 0 && queue.length > 0 && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm">
|
||||
{CHANNEL_LABEL[completed[completed.length - 1]]} updated —
|
||||
one more to confirm.
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otp}
|
||||
onChange={setOtp}
|
||||
disabled={!sentTo || otpMutation.isPending}
|
||||
aria-label="Verification code"
|
||||
/>
|
||||
|
||||
{contactMutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm">{errorMessage(contactMutation.error)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={otpMutation.isPending || !sentTo}
|
||||
onClick={() => {
|
||||
// Clear any "invalid code" error first: the user is asking for
|
||||
// a fresh code, not retrying the old one, so leaving the
|
||||
// failure on screen would describe a request they didn't make.
|
||||
contactMutation.reset();
|
||||
otpMutation.mutate(current);
|
||||
}}
|
||||
>
|
||||
Resend code
|
||||
</Button>
|
||||
<Button
|
||||
loading={contactMutation.isPending}
|
||||
disabled={otp.length !== 6 || !sentTo}
|
||||
onClick={() => contactMutation.mutate({ ...current, otp })}
|
||||
>
|
||||
Verify & Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
|
||||
<ChangePasswordCard />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import type {
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type {
|
||||
AuthUser,
|
||||
ChangePasswordPayload,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
GenerateVerificationCodePayload,
|
||||
@@ -81,6 +82,11 @@ import type {
|
||||
ForgotPasswordRequestPayload,
|
||||
ForgotPasswordVerifyPayload,
|
||||
ResetTicket,
|
||||
SendContactOtpPayload,
|
||||
SendContactOtpResponse,
|
||||
UpdateAccountNamePayload,
|
||||
UpdateContactPayload,
|
||||
UpdateContactResponse,
|
||||
} from "@/types/auth";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -144,9 +150,34 @@ export const api = {
|
||||
"verifyOTP",
|
||||
authService.verifyOTP,
|
||||
),
|
||||
changePassword: endpoint<ChangePasswordPayload, void>(
|
||||
"auth",
|
||||
"changePassword",
|
||||
authService.changePassword,
|
||||
),
|
||||
logout: endpoint<void, void>("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<SendContactOtpPayload, SendContactOtpResponse>(
|
||||
"account",
|
||||
"sendContactOtp",
|
||||
authService.sendContactOtp,
|
||||
),
|
||||
updateContact: endpoint<UpdateContactPayload, UpdateContactResponse>(
|
||||
"account",
|
||||
"updateContact",
|
||||
authService.updateContact,
|
||||
),
|
||||
updateName: endpoint<UpdateAccountNamePayload, { success: true }>(
|
||||
"account",
|
||||
"updateName",
|
||||
authService.updateAccountName,
|
||||
),
|
||||
},
|
||||
|
||||
companies: {
|
||||
getInfo: endpoint<void, CompanyInfoResponse | null>(
|
||||
"companies",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
AuthUser,
|
||||
ChangePasswordPayload,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
ForgotPasswordRequestPayload,
|
||||
@@ -11,11 +12,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 +112,44 @@ 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
|
||||
// value, and the write only lands once it is verified.
|
||||
|
||||
sendContactOtp: async (body: SendContactOtpPayload) => {
|
||||
const res = await client.post<ApiResponse<SendContactOtpResponse>>(
|
||||
URL_CONSTANTS.ACCOUNT.CONTACT_OTP,
|
||||
body,
|
||||
);
|
||||
return unwrap(res.data);
|
||||
},
|
||||
|
||||
updateContact: async (body: UpdateContactPayload) => {
|
||||
const res = await client.patch<ApiResponse<UpdateContactResponse>>(
|
||||
URL_CONSTANTS.ACCOUNT.CONTACT,
|
||||
body,
|
||||
);
|
||||
return unwrap(res.data);
|
||||
},
|
||||
|
||||
updateAccountName: async (body: UpdateAccountNamePayload) => {
|
||||
const res = await client.patch<ApiResponse<{ success: true }>>(
|
||||
URL_CONSTANTS.ACCOUNT.NAME,
|
||||
body,
|
||||
);
|
||||
return unwrap(res.data);
|
||||
},
|
||||
|
||||
refreshToken: async () => {
|
||||
const refreshTokenCookie = document.cookie
|
||||
.split("; ")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -45,6 +45,45 @@ 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";
|
||||
|
||||
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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string, BS[]>();
|
||||
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<string>([
|
||||
...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<string, Set<string>>();
|
||||
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<string>();
|
||||
|
||||
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<string>();
|
||||
|
||||
// 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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, typeof confirmedSeats>();
|
||||
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<string, Set<string>>(); // scheduleId → Set<seatId>
|
||||
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<string>,
|
||||
) {
|
||||
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<string, BsRow[]>();
|
||||
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<string, Set<string>>();
|
||||
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<string>();
|
||||
|
||||
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.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function DiscrepancyLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
519
apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx
Normal file
519
apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx
Normal file
@@ -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<string>(
|
||||
coach.duplicates.flatMap(g => g.bookings.slice(1).map(b => b.bookingSeatId)),
|
||||
);
|
||||
const [selectedSeats, setSelectedSeats] = useState<Set<string>>(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<Set<string>>(
|
||||
new Set(coachesWithSeats.map(c => c.coachId)),
|
||||
);
|
||||
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl bg-white dark:bg-gray-900 shadow-2xl flex flex-col">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Resolve Duplicates — {coach.coachNumber}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{schedule.origin} → {schedule.destination} · {formatDateTime(schedule.departureAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
|
||||
|
||||
{/* Duplicate seat groups */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Duplicate seat assignments
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Check the passengers you want to reassign to a new seat. Unchecked passengers keep their current seat.
|
||||
</p>
|
||||
|
||||
{coach.duplicates.map(group => (
|
||||
<div
|
||||
key={`${group.seatId}-${group.leg}`}
|
||||
className="rounded-lg border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-950/30 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertCircle className="w-4 h-4 text-orange-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-orange-800 dark:text-orange-300">
|
||||
Seat {group.seatNumber} — {group.bookings.length} passengers assigned
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{group.bookings.map((b, idx) => {
|
||||
const checked = selectedSeats.has(b.bookingSeatId);
|
||||
return (
|
||||
<label
|
||||
key={b.bookingSeatId}
|
||||
className="flex items-start gap-3 cursor-pointer rounded-lg px-3 py-2 hover:bg-orange-100 dark:hover:bg-orange-900/30 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleBookingSeat(b.bookingSeatId)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{b.passengerName || '—'}
|
||||
</span>
|
||||
<span className="text-xs font-mono bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 px-1.5 py-0.5 rounded">
|
||||
{b.bookingRef}
|
||||
</span>
|
||||
{idx === 0 && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-1.5 py-0.5 rounded">
|
||||
earliest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{b.contactPhone ?? 'No phone'} · Booked {formatDateTime(b.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Coach selection */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Reassign to seats in
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
The system picks the first available seat in the selected coaches.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{coachesWithSeats.length === 0 ? (
|
||||
<p className="text-sm text-red-500">No coaches have available seats on this schedule.</p>
|
||||
) : (
|
||||
coachesWithSeats.map(c => (
|
||||
<label
|
||||
key={c.coachId}
|
||||
className="flex items-center gap-3 cursor-pointer rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCoachIds.has(c.coachId)}
|
||||
onChange={() => toggleCoach(c.coachId)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{c.coachNumber}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
{c.coachTypeName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
{c.availableSeats.length} available
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{errorMsg && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{errorMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 px-4 py-3">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-700 dark:text-green-400">{successMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{successMsg ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
{!successMsg && (
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={mutation.isPending || selectedSeats.size === 0 || selectedCoachIds.size === 0}
|
||||
className="flex items-center gap-2 px-5 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Assign {selectedSeats.size > 0 ? `${selectedSeats.size} passenger${selectedSeats.size > 1 ? 's' : ''}` : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className={`rounded-xl border ${hasDuplicates ? 'border-orange-200 dark:border-orange-800' : 'border-gray-200 dark:border-gray-700'} bg-white dark:bg-gray-900 overflow-hidden`}>
|
||||
{/* Card header */}
|
||||
<div className="flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{coach.coachNumber}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{coach.coachTypeName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{coach.availableSeats.length} available seats</span>
|
||||
{hasDuplicates && (
|
||||
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400 font-medium">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{coach.duplicates.length} duplicate{coach.duplicates.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasDuplicates && (
|
||||
<button
|
||||
onClick={onResolve}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
{hasDuplicates && (
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 transition-colors"
|
||||
title={expanded ? 'Collapse' : 'View passengers'}
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded passenger list */}
|
||||
{expanded && hasDuplicates && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{coach.duplicates.map(group => (
|
||||
<div key={`${group.seatId}-${group.leg}`} className="px-5 py-3">
|
||||
<p className="text-xs font-semibold text-orange-600 dark:text-orange-400 mb-2">
|
||||
Seat {group.seatNumber} — {group.bookings.length} passengers
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{group.bookings.map((b, idx) => (
|
||||
<div key={b.bookingSeatId} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-5 h-5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs flex items-center justify-center font-medium shrink-0">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-gray-900 dark:text-white">{b.passengerName || '—'}</span>
|
||||
<span className="ml-2 text-xs font-mono text-gray-500 dark:text-gray-400">{b.bookingRef}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{b.contactPhone ?? '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<DuplicatesResponse>({
|
||||
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 (
|
||||
<div className="p-6 space-y-6 max-w-5xl mx-auto">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-950/40">
|
||||
<Layers className="w-6 h-6 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seat Discrepancy</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Detect and resolve duplicate seat assignments by schedule date
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date picker */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!date || isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{isError && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
Failed to load duplicate seat data. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary banner */}
|
||||
{data && (
|
||||
<div className={`rounded-xl border px-5 py-4 flex items-center gap-3 ${
|
||||
data.totalDuplicates > 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 ? (
|
||||
<AlertCircle className="w-5 h-5 text-orange-500 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
||||
)}
|
||||
<span className={`text-sm font-medium ${
|
||||
data.totalDuplicates > 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}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results per schedule */}
|
||||
{data?.schedules.map(schedule => (
|
||||
<div key={schedule.scheduleId} className="space-y-3">
|
||||
{/* Schedule header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{schedule.origin} → {schedule.destination}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDateTime(schedule.departureAt)} · {scheduleDuplicateCount(schedule)} duplicate{scheduleDuplicateCount(schedule) !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coach cards grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{schedule.coaches.map(coach => (
|
||||
<CoachCard
|
||||
key={coach.coachId}
|
||||
coach={coach}
|
||||
schedule={schedule}
|
||||
onResolve={() => setResolveTarget({ schedule, coach })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Empty state when searched but no results */}
|
||||
{data && data.schedules.length === 0 && data.totalDuplicates === 0 && searchDate && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-400 mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">All seats are correctly assigned for {data.date}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolve modal */}
|
||||
{resolveTarget && (
|
||||
<ResolveModal
|
||||
schedule={resolveTarget.schedule}
|
||||
coach={resolveTarget.coach}
|
||||
onClose={() => setResolveTarget(null)}
|
||||
onSuccess={() => {
|
||||
refetch();
|
||||
// Keep modal open to show success message; user closes manually
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -159,6 +159,10 @@ export const seatsApi = {
|
||||
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
|
||||
setMaintenance: (seatId: string, reason: string) => apiClient.post<any>(`/seats/${seatId}/maintenance`, { reason }),
|
||||
clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`),
|
||||
getDuplicates: (date: string, scheduleId?: string) =>
|
||||
apiClient.get<any>(`/seats/duplicates?date=${date}${scheduleId ? `&scheduleId=${scheduleId}` : ''}`),
|
||||
resolveDuplicates: (data: { bookingSeatIds: string[]; coachIds: string[] }) =>
|
||||
apiClient.post<any>('/seats/duplicates/resolve', data),
|
||||
};
|
||||
|
||||
// Payments API
|
||||
|
||||
Reference in New Issue
Block a user