diff --git a/apps/edr-freight-api/src/common/schedule-bookings.sql.ts b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts new file mode 100644 index 000000000..177b8549b --- /dev/null +++ b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts @@ -0,0 +1,28 @@ +/** + * SQL CTE resolving the bookings riding a train schedule, as `sched_bookings + * (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`. + * + * A booking reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only ever + * written by the demo seeders, so both sources are unioned: real allocations work + * and the seeded scenarios keep working. + * + * Shared so the warehouse loading queue and the train dispatch guard agree on + * exactly which bookings are on a train — if they drift, a train can be + * dispatched leaving cargo the warehouse still thinks it should load. + */ +export const SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; diff --git a/apps/edr-freight-api/src/modules/auth/account.controller.ts b/apps/edr-freight-api/src/modules/auth/account.controller.ts new file mode 100644 index 000000000..d7d7f15c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.controller.ts @@ -0,0 +1,62 @@ +import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { AccountService } from "./account.service"; +import { + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; + +/** + * The caller's own account record. Everything here is scoped to the JWT's user + * id — there is no `:id` parameter to tamper with, so these routes need no + * permission key beyond being authenticated. + */ +@ApiTags("auth") +@Controller("me") +@ApiBearerAuth() +@UseGuards(JwtGuard) +export class AccountController { + constructor(private readonly accountService: AccountService) {} + + @Post("contact/otp") + @ApiOperation({ + summary: "Send a verification code to a new email/phone before changing it", + description: + "The code goes to the NEW value supplied here, proving the caller controls " + + "it. Returns the target masked — an unverified caller never gets it back in full.", + }) + sendContactOtp( + @CurrentUser() user: TCurrentUser, + @Body() dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + return this.accountService.sendContactOtp(user.id, dto); + } + + @Patch("contact") + @ApiOperation({ + summary: "Change the account's email or phone, gated by a verification code", + description: + "Verifies the code and writes the new value in one call, so the API never " + + "has to take a client's word that verification happened.", + }) + updateContact( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + return this.accountService.updateContact(user.id, dto); + } + + @Patch("name") + @ApiOperation({ summary: "Change the account's display name" }) + updateName( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + return this.accountService.updateName(user.id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/account.service.ts b/apps/edr-freight-api/src/modules/auth/account.service.ts new file mode 100644 index 000000000..b7413a649 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.service.ts @@ -0,0 +1,226 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, +} from "@nestjs/common"; +import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; +import { DataSource, EntityManager, Repository } from "typeorm"; +import { isValidPhoneNumber } from "libphonenumber-js"; + +import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum"; +import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type"; +import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; +import { OtpService, OtpTarget } from "../otp/otp.service"; +import { + ContactChannel, + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; +import { maskOtpTarget } from "./mask-target.util"; + +/** How long a contact-change code stays valid before it must be re-requested. */ +const CONTACT_OTP_TTL_MS = 10 * 60 * 1000; + +/** Postgres unique-violation SQLSTATE. */ +const PG_UNIQUE_VIOLATION = "23505"; + +/** + * Self-serve management of the caller's own IAM user record. + * + * IAM ships `PATCH /api/auth/update-profile`, but it takes email + username + + * phone + name all at once (every field `@IsNotEmpty`) and performs no + * verification — it will move an account's phone to any number the caller + * types. These routes exist so a contact change is *proven*: the code goes to + * the NEW address and the write only lands once it comes back. + */ +@Injectable() +export class AccountService { + private readonly logger = new Logger(AccountService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly otpService: OtpService, + ) {} + + /** + * Send a code to the address the caller wants to move TO. Sending to the new + * value (rather than the one on file) is the whole point — it proves control + * of the destination before anything is written. + */ + async sendContactOtp( + userId: string, + dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + const target = this.targetFor(dto.channel, value); + await this.otpService.sendOtp(target); + + return { sentTo: maskOtpTarget(target) }; + } + + /** + * Verify the code, then write the new contact value. The verify and the write + * are one call: the API never has to trust that a client "already verified" + * — unlike the signup flow, where the OTP is client-orchestrated and + * `POST /api/otp/verify` is a separate public route the client may simply skip. + */ + async updateContact( + userId: string, + dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + await this.otpService.verifyOtpForAction( + this.targetFor(dto.channel, value), + dto.otp, + CONTACT_OTP_TTL_MS, + ); + + const isEmail = dto.channel === ContactChannel.Email; + const userPatch = isEmail + ? { email: value } + : { + phoneNumber: value, + // The number just passed an OTP, which is exactly what IAM's own + // phone-verification flag means. Set it here so the freight app stops + // needing its own parallel "verified phone" bookkeeping. + isPhoneNumberVerified: true, + verifiedBy: EUserVerifiedBy.PHONE_NUMBER, + }; + const sessionPatch: Partial = isEmail + ? { email: value } + : { phoneNumber: value, isPhoneNumberVerified: true }; + + try { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, userPatch); + await this.refreshSessions(manager, userId, sessionPatch); + }); + } catch (error) { + throw this.asConflict(error, dto.channel); + } + + this.logger.log(`Account ${dto.channel} updated for user ${userId}`); + return { success: true, value }; + } + + /** Rename the account. No OTP — a name change proves nothing and grants nothing. */ + async updateName( + userId: string, + dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + const en = dto.name.en?.trim(); + const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) }; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, { name }); + // IAM mirrors the name onto the employee row. Portal customers are + // `individual` users with no employee row at all, so this is a no-op for + // them — hence an unconditional update() rather than a lookup-then-write. + await manager.getRepository(Employee).update({ userId }, { name }); + await this.refreshSessions(manager, userId, { name }); + }); + + return { success: true }; + } + + /** + * `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only + * when a session is created at login. Without patching it here, a saved change + * stays invisible to /me (and to anything reading the token's claims) until the + * user logs out and back in, which reads as "my edit didn't save". + */ + private async refreshSessions( + manager: EntityManager, + userId: string, + patch: Partial, + ): Promise { + const repo = manager.getRepository(Session); + const sessions = await repo.find({ where: { userId } }); + + await Promise.all( + sessions.map((session) => + repo.update( + { id: session.id }, + { userInfo: { ...session.userInfo, ...patch } }, + ), + ), + ); + } + + /** Canonicalise for the channel and reject anything malformed up front. */ + private normalize(channel: ContactChannel, value: string): string { + const raw = value.trim(); + + if (channel === ContactChannel.Email) { + const email = raw.toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new BadRequestException("A valid email address is required"); + } + return email; + } + + if (!isValidPhoneNumber(raw)) { + throw new BadRequestException( + "A valid international phone number is required (E.164, e.g. +251911223344)", + ); + } + // Store the same canonical form the OTP is keyed by, so the code sent here + // is findable on verify regardless of how the number was typed. + return normalizeE164(raw) as string; + } + + private targetFor(channel: ContactChannel, value: string): OtpTarget { + return channel === ContactChannel.Email ? { email: value } : { phone: value }; + } + + /** + * `iam.users.email` and `.phone_number` are each independently UNIQUE, so a + * collision would otherwise surface as a raw 500 at write time. This is a + * courtesy check, not the guard — it races, so {@link asConflict} still has to + * catch the violation. + */ + private async assertNotTaken( + channel: ContactChannel, + value: string, + userId: string, + ): Promise { + const existing = await this.userRepository.findOne({ + where: + channel === ContactChannel.Email + ? { email: value } + : { phoneNumber: value }, + select: { id: true }, + }); + + if (existing && existing.id !== userId) { + throw this.takenError(channel); + } + } + + private asConflict(error: unknown, channel: ContactChannel): Error { + const code = (error as { code?: string } | null)?.code; + if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel); + return error as Error; + } + + private takenError(channel: ContactChannel): ConflictException { + return new ConflictException( + channel === ContactChannel.Email + ? "That email address is already registered to another account" + : "That phone number is already registered to another account", + ); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts new file mode 100644 index 000000000..363e39072 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; + +/** The contact channel being changed on the caller's own account. */ +export enum ContactChannel { + Email = "email", + Phone = "phone", +} + +export class SendContactOtpDto { + @ApiProperty({ enum: ContactChannel }) + @IsEnum(ContactChannel) + channel!: ContactChannel; + + @ApiProperty({ + description: + "The NEW email or phone to verify. The code is sent here, not to the " + + "address currently on the account — that is what proves the caller " + + "controls the number/inbox they are moving to.", + example: "+251911223344", + }) + @IsString() + @IsNotEmpty() + value!: string; +} + +export class UpdateContactDto extends SendContactOtpDto { + @ApiProperty({ description: "The 6-digit code sent to the new value" }) + @IsString() + @IsNotEmpty() + otp!: string; +} + +export class AccountNameDto { + @ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" }) + @IsString() + @IsNotEmpty() + am!: string; + + @ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" }) + @IsOptional() + @IsString() + en?: string; +} + +export class UpdateAccountNameDto { + @ApiProperty({ type: AccountNameDto }) + @IsObject() + @ValidateNested() + @Type(() => AccountNameDto) + name!: AccountNameDto; +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts index 42dc723d5..b357c2cfb 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -11,6 +11,7 @@ import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user import { OtpService, OtpTarget } from "../otp/otp.service"; import { ResetChannel } from "./dto/forgot-password.dto"; +import { maskOtpTarget } from "./mask-target.util"; /** * How long the reset ticket minted for `PATCH /api/auth/set-password` stays @@ -158,12 +159,6 @@ export class ForgotPasswordService { /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */ maskTarget(target: OtpTarget): string { - if (target.email) { - const [local, domain] = target.email.split("@"); - const head = local.slice(0, 1); - return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; - } - const phone = target.phone ?? ""; - return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; + return maskOtpTarget(target); } } diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 6415cf4c1..1a375d86f 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -1,11 +1,15 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity'; import { ExternalProfile } from '../companies/entities/external-profile.entity'; import { OtpModule } from '../otp/otp.module'; +import { AccountController } from './account.controller'; +import { AccountService } from './account.service'; import { CheckAvailabilityController } from './check-availability.controller'; import { CheckAvailabilityService } from './check-availability.service'; import { CustomerResetController } from './customer-reset.controller'; @@ -17,17 +21,25 @@ import { FreightMeService } from './freight-me.service'; @Module({ imports: [ - TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]), + TypeOrmModule.forFeature([ + User, + UserVerification, + ExternalProfile, + Session, + Employee, + ]), OtpModule, ], controllers: [ FreightMeController, + AccountController, CheckAvailabilityController, ForgotPasswordController, CustomerResetController, ], providers: [ FreightMeService, + AccountService, CheckAvailabilityService, ForgotPasswordService, CustomerResetService, diff --git a/apps/edr-freight-api/src/modules/auth/mask-target.util.ts b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts new file mode 100644 index 000000000..213a14656 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts @@ -0,0 +1,16 @@ +import { OtpTarget } from "../otp/otp.service"; + +/** + * Mask an OTP target for echoing back to the caller: `+251911234567` -> + * `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to + * a caller who has not yet proven possession of the channel. + */ +export function maskOtpTarget(target: OtpTarget): string { + if (target.email) { + const [local, domain] = target.email.split("@"); + const head = local.slice(0, 1); + return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; + } + const phone = target.phone ?? ""; + return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index a4546e301..1cef9528c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the booking lifecycle: review, clearance @@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(b: Booking): string { @@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); - const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const phone = b.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) + : null; const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; if (phone) { diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 15ca85c73..6a0365854 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @Injectable() export class CompaniesRepository extends BaseRepository { + /** + * A company still being filled in by its owner in the portal wizard: it was + * self-registered (so it has an external profile) and nobody has submitted + * onboarding yet. The row exists from the wizard's first click, carrying a + * placeholder name + TIN, so it must not be offered up for review. + * Staff-created companies have no external profiles and are never drafts. + */ + private static readonly DRAFT_SQL = `( + EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + AND ep.onboarding_completed = true + ) + )`; + constructor( @InjectRepository(Company) repo: Repository, @@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository { async findPaginated( query: ListCompaniesQueryDto, ): Promise<{ items: Company[]; total: number }> { - const { page = 1, pageSize = 20, search, type, kind, status } = query; + const { + page = 1, + pageSize = 20, + search, + type, + kind, + status, + onboardingCompleted, + } = query; const qb = this.repository .createQueryBuilder('company') .leftJoinAndSelect('company.companyProfiles', 'companyProfiles') + // External profiles carry onboardingCompleted, which the backoffice list + // uses to flag customers still mid-onboarding (not yet reviewable). + .leftJoinAndSelect('company.profiles', 'profiles') .where('company.deleted_at IS NULL'); if (type) { @@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.status = :status', { status }); } + if (onboardingCompleted !== undefined) { + qb.andWhere( + onboardingCompleted + ? `NOT ${CompaniesRepository.DRAFT_SQL}` + : CompaniesRepository.DRAFT_SQL, + ); + } + if (search) { const term = `%${search.trim()}%`; qb.andWhere( @@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository { } async getStats(): Promise { - const rows: { status: string; count: string }[] = await this.repository - .createQueryBuilder('company') - .select('company.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('company.deleted_at IS NULL') - .groupBy('company.status') - .getRawMany(); + // Drafts are counted separately rather than under `pending`: they carry + // status=pending from creation, which would otherwise inflate the review + // queue's KPI with customers who haven't submitted anything yet. + const rows: { status: string; is_draft: boolean; count: string }[] = + await this.repository + .createQueryBuilder('company') + .select('company.status', 'status') + .addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft') + .addSelect('COUNT(*)', 'count') + .where('company.deleted_at IS NULL') + .groupBy('company.status') + .addGroupBy(CompaniesRepository.DRAFT_SQL) + .getRawMany(); - const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)])); - const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + const map = new Map(); + let onboarding = 0; + let total = 0; + for (const row of rows) { + const count = parseInt(row.count, 10); + total += count; + if (row.is_draft) onboarding += count; + else map.set(row.status, (map.get(row.status) ?? 0) + count); + } return { total, active: map.get('active') ?? 0, pending: map.get('pending') ?? 0, + onboarding, suspended: map.get('suspended') ?? 0, blacklisted: map.get('blacklisted') ?? 0, }; diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 1027de955..04b8790cd 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -372,6 +372,9 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); + // External profiles carry the onboarding flag the backoffice gates + // approval decisions on (see ResponseCompanyDto.onboardingCompleted). + company.profiles = await this.profilesRepo.findByCompanyId(id); return company; } @@ -962,6 +965,28 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // A self-registered company is only reviewable once its owner submits the + // onboarding wizard (markOnboardingComplete) — until then its profiles are + // half-filled drafts and approving one would mint a reference against an + // application that doesn't exist yet. Staff-created companies have no + // external profiles and are exempt. + // + // Only the review decision itself is gated (a profile still awaiting one: + // Pending, or Rejected and awaiting re-approval). Profiles already in + // service stay managable so staff can suspend/blacklist them — including to + // undo an approval granted before this guard existed. + const awaitingReview = + existing.status === ProfileStatus.Pending || + existing.status === ProfileStatus.Rejected; + if (awaitingReview) { + const owners = await this.profilesRepo.findByCompanyId(existing.companyId); + if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) { + throw new BadRequestException( + "This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.", + ); + } + } + // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 167526988..43d4e9905 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; import { NotificationAudience, NotificationPriority, @@ -8,6 +10,7 @@ import { import { Company, CompanyStatus } from "./entities/company.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; /** Account statuses that lock the customer out and therefore must be told to them. */ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ @@ -28,11 +31,13 @@ export class CompanyNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} /** Send SMS + email to the company contact; log-only on failure. */ private async notifyContact(company: Company, message: string): Promise { - const phone = company.contactPersonPhone ?? company.phone ?? null; + const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id); const email = company.email ?? company.generalManagerEmail ?? null; if (phone) { diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts index a6b8b3b6e..c054b3531 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -1,7 +1,10 @@ export class CompanyStatsResponseDto { total!: number; active!: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending!: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding!: number; suspended!: number; blacklisted!: number; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 4dbb932cb..adaa12479 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; import { Transform } from "class-transformer"; import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; @@ -37,4 +37,14 @@ export class ListCompaniesQueryDto { @IsOptional() @IsIn(Object.values(CompanyStatus)) status?: CompanyStatus; + + @ApiPropertyOptional({ + description: + "Filter by onboarding submission. `true` = reviewable applications; " + + "`false` = drafts still in the portal wizard. Omit for both.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + onboardingCompleted?: boolean; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 0c783cbcf..a05812558 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -62,6 +62,13 @@ export class ResponseCompanyDto { attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; companyProfiles?: ResponseCompanyProfileDto[]; + /** + * Whether the owning portal user has submitted the onboarding wizard. + * Approval decisions are blocked while this is false. Staff-created + * companies (no external profiles) count as completed. Undefined when the + * external profiles weren't loaded. + */ + onboardingCompleted?: boolean; createdAt: Date; updatedAt: Date; @@ -84,6 +91,10 @@ export class ResponseCompanyDto { this.companyProfiles = company.companyProfiles?.map( (p) => new ResponseCompanyProfileDto(p), ); + this.onboardingCompleted = company.profiles + ? company.profiles.length === 0 || + company.profiles.some((p) => p.onboardingCompleted) + : undefined; this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 575767a87..e35bd2bf5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Contract } from './entities/contract.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the contract lifecycle. Every customer @@ -24,6 +27,8 @@ export class ContractNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(c: Contract): string { @@ -37,7 +42,9 @@ export class ContractNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(c)}`); - const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null; + const phone = c.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, c.companyId) + : null; const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; if (phone) { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 4eede9352..bba044c85 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -4,6 +4,8 @@ import { Injectable, Logger, } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { randomUUID } from 'node:crypto'; import { Readable } from 'stream'; import { insertWithGeneratedReference } from '@edr/api-common'; @@ -102,8 +104,41 @@ export class ContractTransitionService { private readonly notifier: ContractNotifierService, private readonly contractTemplates: ContractTemplatesService, private readonly clearanceFeeService: ClearanceFeeService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} + /** + * The phone the signing OTP is sent to and verified against: the signer's own + * IAM account number. + * + * H12(b): resolved server-side from the authenticated user id, never from the + * request body — a caller-supplied number would let an attacker point the code + * at their own phone. Ownership is already gated separately by + * {@link ContractsService.assertCustomerCanAccessContract}, so this binds the + * signature to the *person* signing rather than to a company landline that may + * be shared, stale, or imported from eTrade. + */ + private async resolveSignerPhone(signerUserId?: string): Promise { + if (!signerUserId) { + // Unreachable in practice (the ownership gate rejects a missing user + // first), but never fall back to another number if it ever changes. + throw new BadRequestException('Authentication required to sign'); + } + const rows: Array<{ phone_number: string | null }> = + await this.dataSource.query( + `SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`, + [signerUserId], + ); + const phone = rows[0]?.phone_number?.trim(); + if (!phone) { + throw new BadRequestException( + 'Your account has no registered phone number. Add one in Settings → Account before signing.', + ); + } + return phone; + } + /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ async submit(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); @@ -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', diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index f0676b629..5ddffad6c 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -28,17 +28,13 @@ export class SignContractDto { consentText?: string; // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code - // SMS'd to the signer's phone, verified server-side before the signature is - // applied. `otpPhone` is the number the code was sent to (the signed-in - // customer's registered phone). + // SMS'd to the signer's registered phone, verified server-side before the + // signature is applied. The number itself is deliberately NOT part of this + // DTO — the server resolves it from the authenticated user id, so a caller + // cannot redirect the challenge to a phone they control. @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) @IsOptional() @IsString() @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) otp?: string; - - @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) - @IsOptional() - @IsString() - otpPhone?: string; } diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts index 9d7f32c3d..9f121e18a 100644 --- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -1,6 +1,10 @@ import { DataSource } from 'typeorm'; import { NotificationsService } from './notifications.service'; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from './resolve-company-phone.util'; /** * Best-effort SMS + email fan-out to a company's contacts. Looks up the @@ -15,9 +19,10 @@ export async function sendCompanyChannels( ): Promise { const [contact]: Array<{ phone: string | null; email: string | null }> = await dataSource.query( - `SELECT COALESCE(phone, etrade_phone) AS phone, email - FROM freight.companies - WHERE id = $1 AND deleted_at IS NULL`, + `SELECT ${companyNotifyPhoneExpr('co')} AS phone, co.email + FROM freight.companies co + ${primaryContactUserJoin('co')} + WHERE co.id = $1 AND co.deleted_at IS NULL`, [companyId], ); if (contact?.phone) { diff --git a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts new file mode 100644 index 000000000..511f3cf8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts @@ -0,0 +1,60 @@ +import { DataSource, EntityManager } from "typeorm"; + +/** + * Where a customer-facing SMS actually goes. + * + * The person who signs up, logs in, and receives OTPs is an IAM user, and + * `iam.users.phone_number` is the number they control and can change themselves + * (see the account settings flow). A company's own `phone` is business contact + * data — often a landline, a shared desk, or a stale eTrade import — so it is + * the fallback, not the source. + * + * `companies.contact_person_phone` is deliberately NOT consulted: the live write + * path stores that value in the `attributes` jsonb and has never populated the + * column, so every reader of it was silently falling through to `phone` anyway. + */ + +/** + * LEFT JOIN a company alias to its primary contact's IAM user, exposing + * `pc.phone_number`. + * + * LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a + * company having two `is_primary_contact` rows, and a plain join would then + * duplicate the company row — which in a fan-out query means sending the same + * customer the same SMS twice. + * + * `alias` is always a code-controlled literal, never caller input. + */ +export function primaryContactUserJoin(alias: string): string { + return ` + LEFT JOIN LATERAL ( + SELECT u.phone_number + FROM freight.external_profiles ep + JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true + WHERE ep.company_id = ${alias}.id + AND ep.is_primary_contact = true + AND ep.deleted_at IS NULL + ORDER BY ep.created_at + LIMIT 1 + ) pc ON true`; +} + +/** SQL expression for the company's SMS number, given the joined `pc` alias. */ +export function companyNotifyPhoneExpr(alias: string): string { + return `COALESCE(pc.phone_number, ${alias}.phone)`; +} + +/** The SMS number for one company, or null when neither source has one. */ +export async function resolveCompanyNotifyPhone( + db: DataSource | EntityManager, + companyId: string, +): Promise { + const rows: Array<{ phone: string | null }> = await db.query( + `SELECT ${companyNotifyPhoneExpr("co")} AS phone + FROM freight.companies co + ${primaryContactUserJoin("co")} + WHERE co.id = $1 AND co.deleted_at IS NULL`, + [companyId], + ); + return rows[0]?.phone ?? null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index f3d7697f6..3d505e113 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationPriority, @@ -9,6 +11,7 @@ import { import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { BATCH_TIMEZONE } from './booking-batch.constants'; @@ -20,6 +23,8 @@ export class BookingNotifierService { private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, private readonly trainSchedules: TrainSchedulesRepository, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} /** @@ -60,7 +65,9 @@ export class BookingNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); - const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const phone = b.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) + : null; const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; if (phone) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index f728afe6a..69f64fdf4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -13,6 +13,10 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from '../notifications/resolve-company-phone.util'; import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; @@ -527,7 +531,7 @@ export class BookingWindowService implements OnModuleInit { }> = await this.dataSource.query( `SELECT DISTINCT c.company_id, - COALESCE(co.contact_person_phone, co.phone) AS phone, + ${companyNotifyPhoneExpr('co')} AS phone, COALESCE(co.email, co.general_manager_email) AS email FROM freight.contract_routes cr JOIN freight.contracts c @@ -535,6 +539,7 @@ export class BookingWindowService implements OnModuleInit { AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') AND c.deleted_at IS NULL JOIN freight.companies co ON co.id = c.company_id + ${primaryContactUserJoin('co')} WHERE cr.origin_yard_id = $1 AND cr.destination_yard_id = $2 AND cr.deleted_at IS NULL`, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 614c4fabd..a08b12378 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -21,6 +21,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { DataSource, EntityManager, @@ -2037,6 +2038,58 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } + /** + * EXPORT ONLY. An export train must not leave carrying nothing while its cargo + * sits in the shed: the goods are received into the origin warehouse, GRN'd and + * loaded onto the wagons allocated to the booking, so anything still in the + * warehouse at dispatch is being left behind. Blocks dispatch when an allocated + * booking has warehouse inventory that never made it onto a wagon (received / + * stored / ready but not LOADED) — either load it from the Load-to-Train queue, + * or drop the booking's wagon allocation so it rides a later train. + * + * Import/domestic are untouched: their cargo isn't loaded out of an origin + * warehouse, so warehouse inventory says nothing about what's aboard. + * + * Bookings with no warehouse inventory at all are NOT blocked — allocating a + * wagon before the goods arrive is normal planning; they simply aren't aboard. + */ + private async assertAllocatedCargoLoaded(scheduleId: string): Promise { + const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> = + await this.dataSource.query( + `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL`, + [scheduleId], + ); + if (!route) return; + const direction = deriveTradeDirection( + { country: route.originCountry }, + { country: route.destinationCountry }, + ); + if (direction !== 'EXPORT') return; + + const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( + `WITH ${SCHEDULE_BOOKINGS_CTE} + SELECT DISTINCT b.reference AS "reference", inv.status AS "status" + FROM sched_bookings sb + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = $1 + AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`, + [scheduleId], + ); + if (rows.length) { + const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', '); + throw new BadRequestException( + `Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` + + `Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`, + ); + } + } + async dispatchSchedule(scheduleId: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -2046,6 +2099,8 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); + // Export only: don't leave received cargo behind in the warehouse. + await this.assertAllocatedCargoLoaded(scheduleId); // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 85311f049..c489b6f89 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; import { Company } from '../companies/entities/company.entity'; @@ -13,6 +14,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'; @@ -1027,6 +1032,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); continue; } + // EXPORT goods get their GRN on arrival at the warehouse — nothing loads + // onto a train without one. Import GRN handling is left untouched. const saved = await this.inventoryRepository.create({ warehouseId: location.warehouseId, yardId: location.yardId, @@ -1036,6 +1043,9 @@ export class WarehouseInventoryService { weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: new Date(), + ...(booking.tradeDirection === 'EXPORT' + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); result.processedCount += 1; @@ -1056,6 +1066,14 @@ export class WarehouseInventoryService { /** Unload a single arrived booking into a chosen (or default) location. */ async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); + // EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto + // a train without one. Import GRN handling is left untouched. + const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( + `SELECT trade_direction AS "tradeDirection" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + const isExport = bookingRow?.tradeDirection === 'EXPORT'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1076,6 +1094,10 @@ export class WarehouseInventoryService { zoneId: location.zoneId, status: 'RECEIVED', arrivedAt, + // Export only, and keep an already-issued GRN rather than reissuing. + ...(isExport && !existing[0].grnNumber + ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } + : {}), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1090,6 +1112,9 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, + ...(isExport + ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } + : {}), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); @@ -1145,7 +1170,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 +1208,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 +1314,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 +1343,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 @@ -1536,11 +1563,19 @@ export class WarehouseInventoryService { // their already-allocated wagons. Reuses the single-item load() machinery. /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ + /** + * Export flow this queue serves: booked -> paid -> received at the warehouse + * (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the + * booking. Which bookings ride a train comes from the shared CTE. + */ + private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE; + async loadableTrains(): Promise { const rows: Array< LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( - `SELECT ts.id AS "scheduleId", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT ts.id AS "scheduleId", ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", @@ -1548,15 +1583,15 @@ export class WarehouseInventoryService { dy.country AS "destinationCountry", ts.status AS "status", ts.scheduled_departure_date AS "departureTime", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount", + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id AND inv.status = 'LOADED') AS "loadedCount" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id @@ -1564,11 +1599,11 @@ export class WarehouseInventoryService { WHERE ts.deleted_at IS NULL AND ts.status = ANY($1) AND EXISTS ( - SELECT 1 FROM freight.train_schedule_bookings tsb2 + SELECT 1 FROM sched_bookings sb2 JOIN freight.warehouse_inventory inv2 - ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL - WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL - AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL + WHERE sb2.schedule_id = ts.id + AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, [['DRAFT', 'SCHEDULED']], @@ -1593,22 +1628,28 @@ export class WarehouseInventoryService { */ async trainLoadableItems(scheduleId: string): Promise { const rows: Array> = await this.dataSource.query( - `SELECT inv.id AS "id", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT inv.id AS "id", inv.booking_id AS "bookingId", b.reference AS "bookingReference", company.name AS "customerName", ct.container_number AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", - substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber", + -- receive() stamps the GRN onto the row and mirrors it into the + -- note; prefer the column and fall back for legacy/seeded rows. + COALESCE( + inv.grn_number, + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) AS "grnNumber", inv.inspection_status AS "inspectionStatus", inv.status AS "status", wl.wagon_id AS "wagonId", wl.wagon_number AS "wagonNumber", wl.sequence_no AS "sequenceNo" - FROM freight.train_schedule_bookings tsb - JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id - JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + FROM sched_bookings sb + JOIN freight.train_schedules ts ON ts.id = sb.schedule_id + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id @@ -1625,15 +1666,19 @@ export class WarehouseInventoryService { ORDER BY tsw.sequence_no ASC NULLS LAST LIMIT 1 ) wl ON true - WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + WHERE sb.schedule_id = $1 + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, [scheduleId], ); return rows.map((r) => ({ ...r, - loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId), + // Export flow: received at the warehouse -> GRN -> loaded onto its wagon. + // The row only exists once the goods were received, so requiring a GRN and + // an allocated wagon completes the chain. + loadable: + r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber), })); } @@ -1686,6 +1731,9 @@ export class WarehouseInventoryService { if (!item) { skip('Not assigned to this train'); continue; } if (item.status === 'LOADED') { skip('Already loaded'); continue; } if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; } + // Export: the GRN is raised when the goods arrive at the warehouse, and + // nothing rides a train without one. + if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; } if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } try { @@ -4946,7 +4994,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 +5011,7 @@ export class WarehouseInventoryService { v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 2508e373a..6bf03c93d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -25,6 +25,10 @@ import { InvoiceDocumentService, } from "../billing/documents/invoice-document.service"; import { NotificationsService } from "../notifications/notifications.service"; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from "../notifications/resolve-company-phone.util"; import { WarehouseFeeService } from "./warehouse-fee.service"; import { WarehouseFeeInvoiceView, @@ -879,7 +883,7 @@ export class WarehouseInvoiceService { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", COALESCE( NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''), last_vehicle.assigned_driver_name, @@ -892,6 +896,7 @@ export class WarehouseInvoiceService { FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 03449fbb2..471849c95 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -18,6 +18,7 @@ import { Send, Settings, ShieldCheck, + Settings2, Ship, SlidersHorizontal, Train, @@ -142,14 +143,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "Staff", - href: "/user-management", - icon: , - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , + label: "Customers", + href: "/dashboard/customers", + icon: , }, { label: "Contracts", @@ -157,6 +153,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.view, }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + }, // Operations hub: clearance-document review for contracts WITHOUT // customs clearing (contract-level for one-time, per-booking for general). { @@ -165,11 +166,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.opsClearanceReview, }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - }, { label: "Payments", href: "/dashboard/payments", @@ -186,183 +182,185 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ ], }, { - title: "Operations", + // title: "Port & Terminal", items: [ { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, ], }, - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, - // Operations Path A queue: per-booking self-clearance review for - // GENERAL non-customs booking instances (and legacy self-clear bookings). - // { - // label: "Self-Clearance Review", - // href: "/dashboard/contracts/ops-clearance", - // icon: , - // permission: FREIGHT_PERMS.contracts.opsClearanceReview, - // }, { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", + label: "Fleet Management", icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.fleet.view, + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - title: "Port & Terminal", - items: [ { label: "Imports", href: "/dashboard/import-warehouse", @@ -437,35 +435,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ], }, - ], - }, - { - title: "Warehouse Management", - items: [ { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", + label: "Warehouse Management", icon: , - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], }, ], }, { - title: "Administration", + title: "Freight configuration", + mutedTitle: true, items: [ { label: "File settings", @@ -485,12 +485,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ { label: "Configuration", href: "/dashboard/configuration", @@ -513,6 +507,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: getCategorySidebarChildren("rules"), }, + + { + label: "Staff", + href: "/user-management", + icon: , + }, ], }, ]; @@ -599,7 +599,10 @@ const findActiveSidebarLabel = ( ): string | undefined => { const path = pathname.toLowerCase(); const candidates = flattenSidebarItems(sections) - .map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() })) + .map(({ href, label }) => ({ + label, + href: href.split("?")[0].toLowerCase(), + })) .sort((a, b) => b.href.length - a.href.length); return candidates.find( @@ -674,10 +677,7 @@ const App = () => { } /> {/* } /> */} - } - /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 76555a014..6cb6759e7 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -280,13 +280,20 @@ export function InvoiceStatusBadge({ * Transitions: pending → approve / reject-with-note | rejected → approve (override) | * active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate. * Rejecting captures a note the customer sees so they can fix and reapply. + * + * `locked` (customer hasn't submitted onboarding) withholds the review decision + * only — there's no application to judge yet, and the API rejects the call + * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so + * an already-active profile is still managable. */ export function ProfileApprovalActions({ profileId, status, + locked = false, }: { profileId: string; status: ProfileStatus; + locked?: boolean; }) { const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), @@ -346,6 +353,18 @@ export function ProfileApprovalActions({ ); + // Pending/rejected are the two states awaiting a reviewer's decision — the + // exact pair the API gates on until the customer submits. + if (locked && (status === "pending" || status === "rejected")) { + return ( + + + Awaiting submission + + + ); + } + if (status === "pending") { return ( <> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 698b0fc2c..91429356c 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -92,7 +92,9 @@ const FreightSidebar = ({ walk(item.children, key); }); }; - sections.forEach((section) => walk(section.items, section.title)); + sections.forEach((section, i) => + walk(section.items, section?.title ?? "" + i++), + ); return acc; }, [sections, isHrefActive, branchActive]); @@ -126,6 +128,7 @@ const FreightSidebar = ({ opened={isOpen} classNames={navClassNames(active)} onClick={() => toggle(key)} + childrenOffset="sm" rightSection={ @@ -166,6 +169,7 @@ const FreightSidebar = ({ active={active} component={Link} classNames={navClassNames(active)} + onClick={onClose} to={item.href!} /> ); @@ -177,19 +181,21 @@ const FreightSidebar = ({ () => sections.map((section) => ( - - {section.title} - + {section.title && ( + + {section.title} + + )} {section.items.map((item, i) => - renderItem(item, itemKey(section.title, item, i)), + renderItem(item, itemKey(section.title ?? "" + i, item, i)), )} @@ -257,7 +263,7 @@ const FreightSidebar = ({ px="sm" pb="md" > - {renderedSections} + {renderedSections} ); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/types.ts b/apps/edr-freight-web/backoffice/src/components/layout/types.ts index 051f28839..2129e05e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/types.ts @@ -12,7 +12,7 @@ export interface SidebarItem { export interface SidebarSection { /** Section label shown above a group of nav items (e.g. "Main menu"). */ - title: string; + title?: string; items: SidebarItem[]; /** When true, section title uses muted grey instead of dark text. */ mutedTitle?: boolean; diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index dc682f1c7..9ae5a771b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Alert, Anchor, Badge, Box, @@ -22,6 +23,7 @@ import { Download, Eye, FileText, + Hourglass, IdCard, LayoutGrid, Package, @@ -60,6 +62,7 @@ import type { CustomerDocument, CustomerPayment, } from "@/types/customer"; +import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer"; import type { Invoice } from "@/types/invoice"; import { DataTable, @@ -166,6 +169,13 @@ export default function CustomerDetailPage() { ); const paidCurrency = payments[0]?.currency ?? "ETB"; + // The company row is created on the wizard's first click, so a draft reaches + // this page with a placeholder name/TIN. `stillOnboarding` drives the banner + // and badge; `canReview` gates the approve/reject buttons and mirrors the + // API's rule exactly, so no button is offered that the server would reject. + const stillOnboarding = company ? isOnboardingDraft(company) : false; + const canReview = company ? hasSubmittedOnboarding(company) : true; + const profileColumns: ColumnDef[] = useMemo( () => [ { @@ -273,11 +283,12 @@ export default function CustomerDetailPage() { ), }, ], - [view], + [view, canReview], ); const bookingColumns: ColumnDef[] = useMemo( @@ -602,7 +613,13 @@ export default function CustomerDetailPage() { meta={ - + {stillOnboarding ? ( + + Onboarding in progress + + ) : ( + + )} } @@ -631,6 +648,21 @@ export default function CustomerDetailPage() { {/* OVERVIEW */} + {stillOnboarding && ( + } + title="This customer hasn't submitted their application yet" + > + They're still filling in the onboarding wizard, so the details + below are an unfinished draft — the company name and TIN are + placeholders until they reach those steps. Role profiles become + reviewable once the application is submitted. + + )} + p.status === "pending", - ).length, + // A draft's profiles are all `pending` by construction, which + // would read as a review backlog that doesn't exist yet. + label: stillOnboarding + ? "Awaiting submission" + : "Pending approval", + value: stillOnboarding + ? "—" + : company.companyProfiles.filter( + (p) => p.status === "pending", + ).length, icon: IdCard, color: "yellow", }, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index aff353055..89325ae79 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -16,6 +16,7 @@ import { Building2, CheckCircle2, Clock, + Hourglass, Mail, Phone, RefreshCw, @@ -36,6 +37,7 @@ import { import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; import type { Company, CompanyStatus } from "@/types/customer"; +import { isOnboardingDraft } from "@/types/customer"; import { DataTable, DataTableFooter, @@ -43,22 +45,39 @@ import { type ColumnDef, } from "@edr/ui-common"; +/** + * The list's segmented views. "Pending approval" means submitted-and-awaiting- + * review, so it excludes drafts — a company row exists from the onboarding + * wizard's first click and would otherwise pad the review queue. Those drafts + * get their own view instead of disappearing, so staff can still chase them. + */ +type CustomerView = "all" | "pending" | "onboarding" | "active"; + +const VIEW_FILTERS: Record< + CustomerView, + { status?: CompanyStatus; onboardingCompleted?: boolean } +> = { + all: {}, + pending: { status: "pending", onboardingCompleted: true }, + onboarding: { onboardingCompleted: false }, + active: { status: "active" }, +}; + export default function CustomersPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); - // "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review). - const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>(""); + const [view, setView] = useState("all"); const filter = useMemo( () => ({ page: pagination.pageIndex + 1, pageSize: pagination.pageSize, search: debouncedQuery, - status: statusFilter || undefined, + ...VIEW_FILTERS[view], }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], + [pagination.pageIndex, pagination.pageSize, debouncedQuery, view], ); const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); @@ -114,6 +133,17 @@ export default function CustomersPage() { id: "status", header: "Status", cell: ({ row }) => { + // A draft's profiles are all `pending` by construction, so the + // "N pending" review hint would be a lie until they submit. + if (isOnboardingDraft(row.original)) { + return ( + + + Onboarding + + + ); + } const pending = (row.original.companyProfiles ?? []).filter( (p) => p.status === "pending", ).length; @@ -206,6 +236,12 @@ export default function CustomersPage() { { label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" }, { label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" }, { label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" }, + { + label: "Onboarding", + value: stats?.onboarding ?? "—", + icon: Hourglass, + color: "gray", + }, { label: "Blacklisted", value: stats?.blacklisted ?? "—", @@ -243,14 +279,15 @@ export default function CustomersPage() { { - setStatusFilter(v === "all" ? "" : (v as CompanyStatus)); + setView(v as CustomerView); setPagination((prev) => ({ ...prev, pageIndex: 0 })); }} data={[ { label: "All", value: "all" }, { label: "Pending approval", value: "pending" }, + { label: "Onboarding", value: "onboarding" }, { label: "Active", value: "active" }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 8fb03cf86..ef88403a9 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -146,10 +146,38 @@ export interface Company { website?: string | null; attributes?: Record | null; companyProfiles: CompanyProfile[]; + /** + * Whether the customer submitted their onboarding application. A company row + * is created on the wizard's first click, so a `pending` company with this + * false is a half-filled draft — not reviewable. Staff-created companies are + * always true. Undefined on endpoints that don't load external profiles. + */ + onboardingCompleted?: boolean; createdAt: string; updatedAt: string; } +/** + * Whether the customer has submitted their onboarding application. Mirrors the + * API's review gate (`setCompanyProfileStatus`): until this is true, a role + * awaiting a decision cannot be approved or rejected. Companies loaded without + * external profiles (`undefined`) are treated as submitted — absence of the + * flag must not lock staff out. + */ +export function hasSubmittedOnboarding(company: Company): boolean { + return company.onboardingCompleted !== false; +} + +/** + * A pristine draft: still `pending` and never submitted, so its name/TIN are + * placeholders and there is nothing to review. Drives presentation only — the + * approval gate is `hasSubmittedOnboarding`, which also covers the (corrupted) + * case of a company activated before that gate existed. + */ +export function isOnboardingDraft(company: Company): boolean { + return company.status === "pending" && !hasSubmittedOnboarding(company); +} + /** Query parameters for the company list. */ export interface CompanyListFilter { page: number; @@ -158,6 +186,8 @@ export interface CompanyListFilter { type?: CompanyType; kind?: CompanyKind; status?: CompanyStatus; + /** `true` = submitted applications only; `false` = drafts only; omit for both. */ + onboardingCompleted?: boolean; } /** Standard paginated list envelope (matches the bookings service shape). */ @@ -170,7 +200,10 @@ export interface PaginatedCompanies { export interface CompanyStats { total: number; active: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding: number; suspended: number; blacklisted: number; } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index f0b3ee5a6..c920e20d8 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -5,6 +5,7 @@ export const URL_CONSTANTS = { REFRESH_TOKEN: "/api/auth/refresh-token", LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", + CHANGE_PASSWORD: "/api/auth/change-password", FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request", FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify", }, @@ -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", diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 5f6cf88d8..00b9c3043 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -29,17 +29,20 @@ import { ShieldCheck, User, UserCheck, + UserCog, } from "lucide-react"; import { useCallback, useEffect } from "react"; import { useSearchParams } from "react-router-dom"; +import useAuth from "@/hooks/useAuth"; import { rolesForCompanyType } from "./settings/companyRoles"; +import TabAccount from "./settings/TabAccount"; import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabContactPerson from "./settings/TabContactPerson"; import TabDocuments from "./settings/TabDocuments"; import TabGeneralManager from "./settings/TabGeneralManager"; import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; -type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents"; +type SettingsTab = "account" | "company" | "contact" | "gm" | "poa" | "documents"; /** A section is "incomplete" when its required fields aren't filled in yet. */ function tabIncomplete( @@ -62,6 +65,9 @@ function tabIncomplete( !profile.generalManagerEmail || !profile.generalManagerPhone ); + case "account": + // Account fields live on the IAM user, not the company profile, and are + // always populated (signup requires them) — nothing to nag about here. case "poa": case "documents": return false; @@ -69,6 +75,7 @@ function tabIncomplete( } const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ + { id: "account", label: "Account", icon: }, { id: "company", label: "Company", icon: }, { id: "contact", label: "Contact Person", icon: }, { id: "gm", label: "General Manager", icon: }, @@ -175,6 +182,7 @@ function ProfileHeader({ profile }: { profile: ProfileResponse }) { export default function SettingsPage() { const queryClient = useQueryClient(); + const { user } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const tab = (searchParams.get("tab") as SettingsTab) || "company"; @@ -313,6 +321,21 @@ export default function SettingsPage() { ))} + {/* Deliberately NOT wrapped in the `locked` fieldset below: that lock + is for company-profile edits awaiting review. Account identity is + the user's own login/notification details — they must stay editable + even mid-review, or a customer whose phone changed while pending + would be locked out of their own OTPs. */} + + {user ? ( + + ) : ( +
+ +
+ )} +
+ {/* While a change request is pending, every panel's inputs + submit buttons are disabled via the native fieldset; tab switching stays enabled so the customer can still review what they submitted. */} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index 99bbf6332..d47151774 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -56,11 +56,10 @@ export default function ContractViewPage() { const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false); - // The signing OTP goes to the CONTRACT COMPANY's registered phone (the number - // the server verifies against), NOT the signed-in user's — those can differ, - // and sending to the user's phone left the code filed under a number verify - // never checks. The server owns the number; we only get back a masked hint of - // where it landed. + // The signing OTP goes to the signed-in user's own registered phone, resolved + // server-side from their account (the same number the server verifies + // against). The client never picks the number, so send and verify can't + // disagree; we only get back a masked hint of where it landed. const [otpSentTo, setOtpSentTo] = useState(null); const { data, isLoading, isError, refetch } = useQuery({ diff --git a/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx new file mode 100644 index 000000000..eebfd50c1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx @@ -0,0 +1,165 @@ +import { useMutation } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CheckCircle2, KeyRound, Save, XCircle } from "lucide-react"; +import { + Button, + Card, + Group, + PasswordInput, + Stack, + Text, + Title, +} from "@mantine/core"; +import { api } from "@/services/api"; + +/** + * Mirrors IAM's own `IsStrongPassword` rule on ChangePasswordDto — minLength 8, + * ≥1 lowercase, ≥1 number, ≥1 symbol, uppercase NOT required. Kept in step with + * the server so the user gets a precise message inline instead of a generic 400. + */ +const strongPassword = z + .string() + .min(8, "At least 8 characters") + .regex(/[a-z]/, "Include a lowercase letter") + .regex(/\d/, "Include a number") + .regex(/[^A-Za-z0-9]/, "Include a symbol"); + +const schema = z + .object({ + oldPassword: z.string().min(1, "Current password is required"), + newPassword: strongPassword, + confirmPassword: z.string().min(1, "Confirm your new password"), + }) + .refine((d) => d.newPassword === d.confirmPassword, { + path: ["confirmPassword"], + message: "Passwords do not match", + }) + .refine((d) => d.newPassword !== d.oldPassword, { + path: ["newPassword"], + message: "New password must be different from your current one", + }); + +type FormData = z.infer; + +/** IAM returns bare error codes; turn them into something a customer can act on. */ +const MESSAGES: Record = { + unable_to_change_password: "Your current password is incorrect.", + new_password_same_as_old: + "New password must be different from your current one.", + new_passwords_do_not_match: "The new passwords do not match.", + user_credentials_not_found: "This account has no password set.", +}; + +/** + * Password changes go straight to IAM's `PATCH /api/auth/change-password`, which + * verifies the old password and owns the credential write (argon hashing, + * retiring the previous credential). The freight app deliberately implements no + * part of that — it only collects the fields. + */ +export default function ChangePasswordCard() { + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { oldPassword: "", newPassword: "", confirmPassword: "" }, + }); + + const mutation = useMutation({ + mutationFn: (data: FormData) => api.auth.changePassword.call(data), + // Never leave the old password sitting in component state after a change. + onSuccess: () => reset(), + }); + + const errorMessage = (err: unknown): string => { + const raw = ( + err as { response?: { data?: { message?: string | string[] } } } + )?.response?.data?.message; + const code = Array.isArray(raw) ? raw[0] : raw; + if (!code) return "Could not change your password. Please try again."; + return MESSAGES[code] ?? code; + }; + + return ( + + + + Password + + + Change the password you use to sign in. You'll need your current one. + + +
mutation.mutate(d))}> + + + + + + + + + {mutation.isSuccess && ( + + + + Password changed + + + )} + {mutation.isError && ( + + + + {errorMessage(mutation.error)} + + + )} + + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx new file mode 100644 index 000000000..68d41727c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx @@ -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; + +/** A contact change that still needs its code entered. */ +interface PendingChange { + channel: ContactChannel; + value: string; +} + +const CHANNEL_LABEL: Record = { + phone: "phone number", + email: "email address", +}; + +const normaliseEmail = (v: string) => v.trim().toLowerCase(); + +interface TabAccountProps { + user: AuthUser; +} + +/** + * The signed-in user's own account — the phone and email that OTPs and SMS + * notifications are actually delivered to. Distinct from the company profile + * tabs, which hold business contact details for the organisation. + * + * Changing phone or email is verified: the server sends a code to the NEW value + * and only writes it once the code comes back, so a typo'd number can never + * silently take over the account's notifications. Each code is bound to a single + * channel, so changing both walks the user through one verification per channel. + */ +export default function TabAccount({ user }: TabAccountProps) { + const queryClient = useQueryClient(); + // Head of the queue is the change currently being verified. Changing phone AND + // email in one save enqueues both — a code proves one channel, never two. + const [queue, setQueue] = useState([]); + const [sentTo, setSentTo] = useState(null); + const [completed, setCompleted] = useState([]); + const [otp, setOtp] = useState(""); + + const current = queue[0] ?? null; + const step = completed.length + 1; + const totalSteps = completed.length + queue.length; + + const defaultValues = useMemo( + (): FormData => ({ + // Normalise to the same E.164 shape the phone input emits. Some accounts + // store a local `0911…`; comparing raw against the field's `+2519…` would + // read as "changed" on every save and hijack the email's turn. + phoneNumber: toEthiopianE164(user.phoneNumber), + email: user.email ?? "", + nameEn: user.name?.en ?? "", + nameAm: user.name?.am ?? "", + }), + [user], + ); + + const { + register, + control, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + values: defaultValues, + // Verifying one channel refetches the user, which re-syncs `values`. Without + // keepDirtyValues that resync would silently discard an edit the user has + // typed into the *other* field but not yet verified. + resetOptions: { keepDirtyValues: true }, + }); + + const refreshUser = () => + queryClient.invalidateQueries({ queryKey: api.auth.getMyInfo.queryKey() }); + + /** Name needs no proof of possession, so it saves straight through. */ + const nameMutation = useMutation({ + mutationFn: (data: FormData) => + api.account.updateName.call({ + name: { en: data.nameEn, am: data.nameAm }, + }), + onSuccess: refreshUser, + }); + + /** Step 1 of a contact change: ask the server to code the new value. */ + const otpMutation = useMutation({ + mutationFn: (change: PendingChange) => + api.account.sendContactOtp.call(change), + onSuccess: (res) => { + setOtp(""); + setSentTo(res.sentTo); + }, + onError: () => { + // Could not even send — drop the flow rather than strand the user in a + // modal asking for a code that was never issued. + setQueue([]); + setSentTo(null); + }, + }); + + /** Step 2: hand the code back; the server verifies and writes atomically. */ + const contactMutation = useMutation({ + mutationFn: (body: PendingChange & { otp: string }) => + api.account.updateContact.call(body), + onSuccess: async (_res, body) => { + setOtp(""); + setSentTo(null); + setCompleted((prev) => [...prev, body.channel]); + await refreshUser(); + + // Advance to the next queued channel, keeping the modal open so a + // both-changed save is one continuous flow. + const rest = queue.slice(1); + setQueue(rest); + if (rest[0]) otpMutation.mutate(rest[0]); + }, + }); + + const startQueue = (changes: PendingChange[]) => { + setCompleted([]); + setQueue(changes); + otpMutation.mutate(changes[0]); + }; + + const cancelQueue = () => { + setQueue([]); + setSentTo(null); + setOtp(""); + contactMutation.reset(); + }; + + const onSubmit = (data: FormData) => { + setCompleted([]); + + const changes: PendingChange[] = []; + if (toEthiopianE164(data.phoneNumber) !== defaultValues.phoneNumber) { + changes.push({ channel: "phone", value: data.phoneNumber }); + } + if (normaliseEmail(data.email) !== normaliseEmail(defaultValues.email)) { + changes.push({ channel: "email", value: normaliseEmail(data.email) }); + } + + // Name carries no verification, so it saves alongside rather than queueing. + if ( + data.nameEn !== defaultValues.nameEn || + data.nameAm !== defaultValues.nameAm + ) { + nameMutation.mutate(data); + } + + if (changes.length) startQueue(changes); + }; + + const errorMessage = (err: unknown): string => { + const res = ( + err as { response?: { data?: { message?: string | string[] } } } + )?.response?.data?.message; + if (Array.isArray(res)) return res[0]; + return res ?? "Something went wrong. Please try again."; + }; + + const busy = + otpMutation.isPending || + contactMutation.isPending || + nameMutation.isPending; + + const savedSummary = + completed.length && !queue.length + ? `Your ${completed.map((c) => CHANNEL_LABEL[c]).join(" and ")} ${ + completed.length > 1 ? "were" : "was" + } verified and updated` + : null; + + return ( + + + + + Account + + + Your login details. Verification codes and SMS notifications are sent + to the phone number below. + + +
+ + + + + + + + + + + + Changing your phone number or email requires a verification code + sent to the new one. + + + + + {savedSummary && ( + + + + {savedSummary} + + + )} + {nameMutation.isSuccess && !savedSummary && !queue.length && ( + + + + Saved successfully + + + )} + {(otpMutation.isError || nameMutation.isError) && ( + + + + {errorMessage(otpMutation.error ?? nameMutation.error)} + + + )} + + + + + + +
+ + + {current && ( + + {totalSteps > 1 && ( + + Step {step} of {totalSteps} + + )} + + } color="blue"> + {sentTo ? ( + <> + We sent a 6-digit code to {sentTo}. Enter it to + confirm your new {CHANNEL_LABEL[current.channel]}. + + ) : ( + <> + Sending a code to your new {CHANNEL_LABEL[current.channel]}… + + )} + + + {completed.length > 0 && queue.length > 0 && ( + + + + {CHANNEL_LABEL[completed[completed.length - 1]]} updated — + one more to confirm. + + + )} + + + + {contactMutation.isError && ( + + + {errorMessage(contactMutation.error)} + + )} + + + + + + + )} + +
+ + +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index a82945ecf..a984a49ae 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -68,6 +68,7 @@ import type { import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { AuthUser, + ChangePasswordPayload, CheckAvailabilityPayload, CheckAvailabilityResponse, GenerateVerificationCodePayload, @@ -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( + "auth", + "changePassword", + authService.changePassword, + ), logout: endpoint("auth", "logout", authService.logout), }, + // The signed-in user's own IAM account — the phone/email that OTPs and SMS + // actually go to. Separate from `companies`, which is business profile data. + account: { + sendContactOtp: endpoint( + "account", + "sendContactOtp", + authService.sendContactOtp, + ), + updateContact: endpoint( + "account", + "updateContact", + authService.updateContact, + ), + updateName: endpoint( + "account", + "updateName", + authService.updateAccountName, + ), + }, + companies: { getInfo: endpoint( "companies", diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 3b113878d..f72bbfaf9 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -1,6 +1,7 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import type { AuthUser, + ChangePasswordPayload, CheckAvailabilityPayload, CheckAvailabilityResponse, ForgotPasswordRequestPayload, @@ -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>( + URL_CONSTANTS.ACCOUNT.CONTACT_OTP, + body, + ); + return unwrap(res.data); + }, + + updateContact: async (body: UpdateContactPayload) => { + const res = await client.patch>( + URL_CONSTANTS.ACCOUNT.CONTACT, + body, + ); + return unwrap(res.data); + }, + + updateAccountName: async (body: UpdateAccountNamePayload) => { + const res = await client.patch>( + URL_CONSTANTS.ACCOUNT.NAME, + body, + ); + return unwrap(res.data); + }, + refreshToken: async () => { const refreshTokenCookie = document.cookie .split("; ") diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 910ba89a0..eb69fa0bf 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -128,8 +128,6 @@ export interface SignContractPayload { consentText?: string; /** Sudo-mode OTP challenge; required when role=CUSTOMER. */ otp?: string; - /** Phone the OTP was sent to; required when role=CUSTOMER. */ - otpPhone?: string; } export interface ApproveDeliveryResponse { diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index b80c3ce36..606fad48a 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -268,9 +268,10 @@ export const contractsService = { return data.data ?? data; }, - // Ask the server to send the signing OTP to the CONTRACT COMPANY's registered - // phone. The client never picks the number (the server verifies against the - // same one), so send and verify can't disagree. Returns a masked hint. + // Ask the server to send the signing OTP to the signer's own registered phone. + // The client never picks the number (the server resolves it from the + // authenticated user and verifies against the same one), so send and verify + // can't disagree. Returns a masked hint. sendSigningOtp: async (id: string): Promise<{ sentTo: string }> => { const { data } = await client.post(C.CONTRACT_SEND_SIGNING_OTP(id)); return data.data ?? data; diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 2e9a0b611..0dadac608 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -45,6 +45,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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260716170852_add_seat_block_schedule_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716170852_add_seat_block_schedule_id/migration.sql new file mode 100644 index 000000000..11fb856c9 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260716170852_add_seat_block_schedule_id/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "SeatBlock" ADD COLUMN "scheduleId" TEXT; + +-- CreateIndex +CREATE INDEX "SeatBlock_scheduleId_idx" ON "SeatBlock"("scheduleId"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql new file mode 100644 index 000000000..2608b4cbf --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -0,0 +1,18 @@ +-- Remove duplicate JourneySegment rows, keeping the one with the lowest id +-- (earliest created) per (scheduleId, seatId, departureStationId) group. +-- This cleans up any existing double-bookings before the unique index is applied. +DELETE FROM passenger."JourneySegment" +WHERE id NOT IN ( + SELECT MIN(id) + FROM passenger."JourneySegment" + WHERE "seatId" IS NOT NULL + GROUP BY "scheduleId", "seatId", "departureStationId" +) +AND "seatId" IS NOT NULL; + +-- Prevents two confirmed bookings from occupying the same seat on the same +-- schedule hop — the hard DB backstop against application-level race conditions. +-- Partial index: seatId IS NOT NULL excludes free-child rows that have no seat. +CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" +ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") +WHERE "seatId" IS NOT NULL; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index cef7d0033..ccc68915d 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -991,6 +991,10 @@ model JourneySegment { arrivalStationId String journey Journey @relation(fields: [journeyId], references: [id]) schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + + // Prevents two confirmed bookings from occupying the same seat on the same + // schedule hop — the hard DB backstop against application-level race conditions. + @@unique([scheduleId, seatId, departureStationId]) @@schema("passenger") } @@ -1312,6 +1316,7 @@ model NotificationTemplate { model SeatBlock { id String @id @default(uuid()) seatId String + scheduleId String? reason String blockedBy String approvedBy String? @@ -1320,6 +1325,7 @@ model SeatBlock { seat Seat @relation(fields: [seatId], references: [id]) @@index([seatId]) + @@index([scheduleId]) @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index f48a228d9..486094c3f 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -322,7 +322,7 @@ Payment providers send notifications to: - \`POST /payments/webhooks/card\` (International) ## Support -- **Email:** support@edr-platform.com +- **Email:** edr_@edrsc.com - **Documentation:** https://docs.edr-platform.com - **Status Page:** https://status.edr-platform.com `, diff --git a/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts new file mode 100644 index 000000000..7be85714a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { ModuleRef } from '@nestjs/core'; +import { PrismaService } from '../../common/prisma.service'; +import { PaymentClientService } from './payment-client.service'; +import { PaymentsService } from './payments.service'; +import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types'; + +// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up). +// This service keeps only singleton deps so its @Cron method registers correctly, +// then resolves PaymentsService per-tick via ModuleRef (same pattern as +// PaymentEventsConsumer). +@Injectable() +export class PaymentSyncService { + private readonly logger = new Logger(PaymentSyncService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly paymentClient: PaymentClientService, + private readonly moduleRef: ModuleRef, + ) {} + + // ───────────────────────────────────────────────────────────────────────── + // Every 1 min: poll the payment service for any PENDING_PAYMENT bookings + // whose payment intent has moved to SUCCEEDED on the gateway but whose + // confirmation event was never delivered (missed RabbitMQ message, network + // blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running + // it for an already-confirmed booking is safe. + // + // Processes at most 50 bookings per cycle to avoid hammering the payment + // service; the next tick picks up the remainder. + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/1 * * * *') + async syncPaymentStatuses() { + const BATCH_SIZE = 50; + + const bookings = await this.prisma.booking.findMany({ + where: { + status: 'PENDING_PAYMENT', + paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } }, + }, + include: { paymentIntent: true }, + take: BATCH_SIZE, + orderBy: { createdAt: 'asc' }, + }); + + if (bookings.length === 0) return; + + let confirmed = 0; + let failed = 0; + let errored = 0; + + // resolve() (not get()) because PaymentsService is scoped — same pattern + // as PaymentEventsConsumer. + const paymentsService = await this.moduleRef.resolve( + PaymentsService, + undefined, + { strict: false }, + ); + + for (const booking of bookings) { + if (!booking.paymentIntent) continue; + + try { + const snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.BOOKING, + booking.id, + ); + + if (!snapshot) continue; + + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + const result = await paymentsService.finalizePaymentSuccess({ + intentId: booking.paymentIntent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + }); + if (!result.alreadyFinalized) { + this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`); + confirmed++; + } + } else if ( + snapshot.status === ProviderPaymentStatus.FAILED || + snapshot.status === ProviderPaymentStatus.CANCELLED + ) { + this.logger.warn( + `Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` + + `booking will be auto-cancelled at payment deadline`, + ); + failed++; + } + // REQUIRES_ACTION / PROCESSING → still pending, retry next cycle + } catch (err) { + this.logger.error( + `Payment sync error for ${booking.bookingRef}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + errored++; + } + } + + if (confirmed > 0 || failed > 0 || errored > 0) { + this.logger.log( + `Payment sync run: ${bookings.length} checked, ` + + `${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`, + ); + } + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 2a3906398..07f452e43 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -16,6 +16,7 @@ import { SupplementaryChargesService } from "./supplementary-charges.service"; import { InternalPaymentsController } from "./internal-payments.controller"; import { PaymentClientService } from "./payment-client.service"; import { PaymentEventsConsumer } from "./payment-events.consumer"; +import { PaymentSyncService } from "./payment-sync.service"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { SeatsModule } from "../seats/seats.module"; import { TicketsModule } from "../tickets/tickets.module"; @@ -70,6 +71,7 @@ function rabbitMQImport(): DynamicModule[] { SupplementaryChargesService, PaymentClientService, PaymentEventsConsumer, + PaymentSyncService, ServiceAuthGuard, ], exports: [PaymentClientService, PaymentsService], diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 8bf07c6bf..a9e4557f1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -432,8 +432,8 @@ export class PaymentsService { expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null, failureCode: snapshot.failureCode ?? null, failureMessage: snapshot.failureMessage ?? null, - rawInitiation: snapshot.providerResponse - ? (snapshot.providerResponse as unknown as Prisma.InputJsonValue) + rawInitiation: (snapshot as any).providerResponse + ? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue) : Prisma.DbNull, }; return this.prisma.paymentIntent.upsert({ diff --git a/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts new file mode 100644 index 000000000..9018d77fb --- /dev/null +++ b/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsDateString, IsOptional, IsUUID } from 'class-validator'; + +export class GetDuplicateSeatsQuery { + @ApiProperty({ example: '2026-07-17', description: 'Schedule date (YYYY-MM-DD)' }) + @IsDateString() + date: string; + + @ApiPropertyOptional({ description: 'Filter to a specific schedule ID' }) + @IsOptional() + @IsUUID() + scheduleId?: string; +} + +export class ResolveDuplicatesDto { + @ApiProperty({ + description: 'BookingSeat IDs of the duplicate bookings to reassign', + type: [String], + example: ['uuid-booking-seat-1', 'uuid-booking-seat-2'], + }) + @IsArray() + @IsUUID(undefined, { each: true }) + bookingSeatIds: string[]; + + @ApiProperty({ + description: 'Coach IDs to source replacement seats from (searched in order; first available seat per coach is used)', + type: [String], + example: ['uuid-coach-1', 'uuid-coach-2'], + }) + @IsArray() + @IsUUID(undefined, { each: true }) + coachIds: string[]; +} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 752b66390..834973c71 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -17,9 +17,11 @@ import { ApiParam, ApiQuery, ApiResponse, + ApiBody, } from "@nestjs/swagger"; import { SeatsService } from "./seats.service"; import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto"; +import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -211,8 +213,8 @@ This makes it clear which segment of the route each seat is held for, enabling s @ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" }) @ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiResponse({ status: 200, description: "Seat blocked" }) - blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string }) { - return this.service.blockSeat(seatId, body.reason); + blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string; scheduleId?: string }) { + return this.service.blockSeat(seatId, body.reason, body.scheduleId); } @Delete(":seatId/block") @@ -221,8 +223,8 @@ This makes it clear which segment of the route each seat is held for, enabling s @ApiOperation({ summary: "Unblock a seat" }) @ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiResponse({ status: 200, description: "Seat unblocked" }) - unblockSeat(@Param("seatId") seatId: string) { - return this.service.unblockSeat(seatId); + unblockSeat(@Param("seatId") seatId: string, @Query("scheduleId") scheduleId?: string) { + return this.service.unblockSeat(seatId, scheduleId); } // ── Maintenance ─────────────────────────────────────────────────────────── @@ -306,4 +308,90 @@ This makes it clear which segment of the route each seat is held for, enabling s ) { return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit); } + + // ── Duplicate seat management (backoffice) ──────────────────────────────── + + @Get("duplicates") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "List duplicate seat assignments by schedule date", + description: + "Returns all schedules on the given date that have bookings sharing " + + "the same seat, grouped by coach. Each coach entry includes the duplicate " + + "groups (with full booking info) and the list of currently available seats " + + "that can be used for reassignment.", + }) + @ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" }) + @ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" }) + @ApiResponse({ + status: 200, + description: "Duplicate seat report grouped by schedule → coach", + schema: { + example: { + date: "2026-07-17", + totalDuplicates: 1, + schedules: [{ + scheduleId: "uuid", + departureAt: "2026-07-17T06:00:00.000Z", + origin: "Addis Ababa", + destination: "Dire Dawa", + coaches: [{ + coachId: "uuid", + coachNumber: "C1", + coachTypeName: "SBC", + duplicates: [{ + seatId: "uuid", + seatNumber: "12A", + leg: 1, + bookings: [ + { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" }, + { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" }, + ], + }], + availableSeats: [ + { seatId: "uuid", seatNumber: "14B" }, + { seatId: "uuid", seatNumber: "15A" }, + ], + }], + }], + }, + }, + }) + getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) { + return this.service.getDuplicateSeats(query.date, query.scheduleId); + } + + @Post("duplicates/resolve") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "Auto-assign duplicate bookings to seats in selected coaches", + description: + "Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " + + "The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " + + "for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " + + "change immediately, then sends an SMS notification to the passenger. " + + "Coaches are searched in the order provided; seats within each coach are assigned by row then column.", + }) + @ApiBody({ type: ResolveDuplicatesDto }) + @ApiResponse({ + status: 200, + description: "Resolution summary — resolved count, unresolved count, per-booking results", + schema: { + example: { + resolved: 2, + unresolved: 0, + results: [ + { bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" }, + { bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" }, + ], + }, + }, + }) + @ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" }) + @ApiResponse({ status: 404, description: "BookingSeat ID not found" }) + resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) { + return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds); + } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index 0725f4393..2a3c4ee26 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -5,9 +5,10 @@ import { SeatsService } from './seats.service'; import { SegmentsModule } from '../segments/segments.module'; import { SystemConfigModule } from '../system-config/system-config.module'; import { AuditModule } from '../../common/audit.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule], + imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule, NotificationsModule], controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService], diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 340900fdd..29d03206c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { SegmentsService } from '../segments/segments.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; import { checkDirectionConflict } from '../../common/utils/journey-direction.utils'; @@ -17,6 +18,7 @@ export class SeatsService { private segmentsService: SegmentsService, private systemConfig: SystemConfigService, private auditService: AuditService, + private sms: SmsClientService, ) {} async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) { @@ -224,7 +226,7 @@ export class SeatsService { } } - const [availability, persistedSeats] = await Promise.all([ + const [availability, persistedSeats, scheduleBlocks] = await Promise.all([ this.segmentsService.getSeatAvailabilityMap( scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY, ), @@ -232,16 +234,23 @@ export class SeatsService { where: { id: { in: seatIds } }, select: { id: true, status: true }, }), + this.prisma.seatBlock.findMany({ + where: { seatId: { in: seatIds }, scheduleId }, + select: { seatId: true }, + }), ]); const persistedStatus = new Map(persistedSeats.map(s => [s.id, s.status])); + const scheduleBlockedIds = new Set(scheduleBlocks.map(b => b.seatId)); for (const seatId of seatIds) { const persisted = persistedStatus.get(seatId); - // BLOCKED and UNDER_MAINTENANCE are cross-schedule flags set by admins — - // always honour them regardless of hold/booking state. + // Global BLOCKED/UNDER_MAINTENANCE (no scheduleId) — always honour if ((persisted as string) === 'BLOCKED' || (persisted as string) === 'UNDER_MAINTENANCE') { statusMap.set(seatId, persisted!); + } else if (scheduleBlockedIds.has(seatId)) { + // Schedule-scoped block — only blocked for this schedule + statusMap.set(seatId, 'BLOCKED'); } else { statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE'); } @@ -291,15 +300,12 @@ export class SeatsService { throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`); } - // Only the raw BLOCKED status (seat pulled out of service — a genuine - // cross-schedule flag) is trusted here. BOOKED is intentionally NOT checked - // against this raw column: the same physical Seat row is reused across every - // recurring date a coach runs, and Seat.status only resets to AVAILABLE via a - // trip-completion event that isn't guaranteed to fire, so a stale BOOKED value - // here would wrongly block a seat that's actually free for this schedule/leg. - // The schedule- and leg-scoped SeatHold/JourneySegment checks below are the - // authoritative source for whether a seat is actually taken. - const blocked = seats.filter(s => s.status === 'BLOCKED'); + // Only the raw BLOCKED/UNDER_MAINTENANCE status (seat pulled out of service — + // a genuine cross-schedule flag) is checked here. Seat.status is never written + // for holds/bookings because coaches are reused across schedules; the + // schedule-scoped SeatHold/JourneySegment checks below are the authoritative + // source for whether a seat is taken on this specific schedule/leg. + const blocked = seats.filter(s => s.status === 'BLOCKED' || (s.status as string) === 'UNDER_MAINTENANCE'); if (blocked.length > 0) throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`); @@ -400,11 +406,6 @@ export class SeatsService { passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })), }; - await tx.seat.updateMany({ - where: { id: { in: seatIds } }, - data: { status: 'HELD' }, - }); - return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, @@ -545,13 +546,7 @@ export class SeatsService { async releaseHold(holdId: string) { const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } }); if (!hold) throw new NotFoundException('Hold not found'); - await this.prisma.$transaction([ - this.prisma.seat.updateMany({ - where: { id: { in: hold.seatIds as string[] }, status: 'HELD' }, - data: { status: 'AVAILABLE' }, - }), - this.prisma.seatHold.delete({ where: { id: holdId } }), - ]); + await this.prisma.seatHold.delete({ where: { id: holdId } }); return { released: true, holdId }; } @@ -657,21 +652,41 @@ export class SeatsService { } async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + const seats = await this.prisma.seat.findMany({ where: { coach: { assignments: { some: { scheduleId } } }, - status: 'AVAILABLE', seatNumber: { not: '' }, - NOT: { seatNumber: { startsWith: '-' } }, + NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }], }, orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], }); - if (seats.length < count) { - throw new ConflictException(`Only ${seats.length} seats available, requested ${count}`); + const allSeatIds = seats.map(s => s.id); + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId }, + select: { stationId: true, sequence: true }, + }); + const seqOf = (id: string) => stopTimes.find(s => s.stationId === id)?.sequence; + const reqFrom = seqOf(schedule.originStationId) ?? 0; + const reqTo = seqOf(schedule.destinationStationId) ?? stopTimes.length; + + const unavailable = await this.segmentsService.getSeatAvailabilityMap( + scheduleId, allSeatIds, stopTimes, reqFrom, reqTo, + ); + + const availableSeats = seats.filter(s => !unavailable.has(s.id)); + + if (availableSeats.length < count) { + throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`); } - const assigned = this.findContiguousSeats(seats, count); + const assigned = this.findContiguousSeats(availableSeats, count); return assigned.map((s) => s.id); } @@ -774,24 +789,34 @@ export class SeatsService { return { imported, errors: errors.slice(0, 10) }; } - async blockSeat(seatId: string, reason: string) { + async blockSeat(seatId: string, reason: string, scheduleId?: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } }); - await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } }); - await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason } }); - return { blocked: true, seatId, reason }; + // Schedule-scoped block: only affects this schedule, not all schedules + // Global block (no scheduleId): sets Seat.status = BLOCKED for all schedules + if (scheduleId) { + await this.prisma.seatBlock.create({ data: { seatId, scheduleId, reason, blockedBy: 'system' } }); + } else { + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } }); + await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } }); + } + await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason, scheduleId } }); + return { blocked: true, seatId, reason, scheduleId }; } - async unblockSeat(seatId: string) { + async unblockSeat(seatId: string, scheduleId?: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } }); - await this.prisma.seatBlock.deleteMany({ where: { seatId } }); - await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE' } }); - return { unblocked: true, seatId }; + if (scheduleId) { + await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId } }); + } else { + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } }); + await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId: null } }); + } + await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE', scheduleId } }); + return { unblocked: true, seatId, scheduleId }; } async setMaintenance(seatId: string, reason: string) { @@ -929,22 +954,424 @@ export class SeatsService { } } - if (releasedSeatIds.size > 0) { - await this.prisma.seat.updateMany({ - where: { id: { in: Array.from(releasedSeatIds) }, status: 'HELD' }, - // heldUntil is cleared alongside status — leaving a stale (past) heldUntil on an - // AVAILABLE seat is stale data that any future code reading heldUntil directly - // (instead of re-deriving availability live) would misinterpret. - data: { status: 'AVAILABLE', heldUntil: null }, - }); - } - await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: now } } }); return { expiredHolds: expired.length, releasedSeatIds: Array.from(releasedSeatIds), - skippedSeatIds: Array.from(skippedSeatIds), + skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed + }; + } + + // ───────────────────────────────────────────────────────────────────────── + // Duplicate-seat management (backoffice) + // ───────────────────────────────────────────────────────────────────────── + + async getDuplicateSeats(date: string, scheduleId?: string) { + const dayStart = new Date(`${date}T00:00:00.000Z`); + const dayEnd = new Date(`${date}T23:59:59.999Z`); + + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + departureAt: { gte: dayStart, lte: dayEnd }, + ...(scheduleId ? { id: scheduleId } : {}), + }, + orderBy: { departureAt: 'asc' }, + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + coachAssignments: { + orderBy: { positionNumber: 'asc' }, + include: { + coach: { + include: { + coachType: { select: { name: true } }, + seats: { + orderBy: [{ row: 'asc' }, { col: 'asc' }], + select: { id: true, seatNumber: true, status: true, coachId: true }, + }, + }, + }, + }, + }, + }, + }); + + const result = []; + + for (const schedule of schedules) { + // All confirmed BookingSeat rows for this schedule + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + OR: [ + { scheduleId: schedule.id }, + { scheduleId: null, booking: { scheduleId: schedule.id } }, + ], + booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + }, + select: { + id: true, seatId: true, scheduleId: true, leg: true, passengerName: true, + seat: { select: { coachId: true } }, + booking: { + select: { + id: true, bookingRef: true, scheduleId: true, + createdAt: true, contactPhone: true, + }, + }, + }, + }); + + // Seats occupied by any confirmed journey on this schedule (source of truth) + const journeySegments = await this.prisma.journeySegment.findMany({ + where: { + scheduleId: schedule.id, + seatId: { not: null }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true }, + }); + const occupiedIds = new Set(journeySegments.map(js => js.seatId!)); + + // Group BookingSeat rows by (seatId::leg) to detect duplicates + type BS = (typeof bookingSeats)[number]; + const groups = new Map(); + for (const bs of bookingSeats) { + const key = `${bs.seatId}::${bs.leg}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(bs); + } + + // All seats held by any confirmed BookingSeat — union of JourneySegment-based + // occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments + // are missing (e.g. created via enhanced-seats path without bookingId) are still + // excluded from the available list. + const bookedSeatIds = new Set([ + ...occupiedIds, + ...bookingSeats.map(bs => bs.seatId).filter((id): id is string => id !== null && id !== undefined), + ]); + + const coachReports = []; + + for (const assignment of schedule.coachAssignments) { + const coach = assignment.coach; + + // Duplicate groups whose seat belongs to this coach + const duplicates = []; + for (const [key, group] of groups) { + if (group.length <= 1) continue; + if (group[0].seat.coachId !== coach.id) continue; + const [seatId] = key.split('::'); + const seat = coach.seats.find(s => s.id === seatId); + duplicates.push({ + seatId, + seatNumber: seat?.seatNumber ?? seatId, + leg: group[0].leg, + bookings: group.map(bs => ({ + bookingSeatId: bs.id, + bookingId: bs.booking.id, + bookingRef: bs.booking.bookingRef, + passengerName: bs.passengerName, + contactPhone: bs.booking.contactPhone, + createdAt: bs.booking.createdAt, + })), + }); + } + + // Free seats in this coach — excludes BLOCKED, all confirmed BookingSeat + // assignments, and all confirmed JourneySegment occupancies. + const availableSeats = coach.seats + .filter(s => + (s.status as string) !== 'BLOCKED' && + !s.seatNumber.startsWith('-') && + !bookedSeatIds.has(s.id), + ) + .map(s => ({ seatId: s.id, seatNumber: s.seatNumber })); + + coachReports.push({ + coachId: coach.id, + coachNumber: coach.number, + coachTypeName: coach.coachType.name, + duplicates, + availableSeats, + }); + } + + if (coachReports.some(c => c.duplicates.length > 0)) { + result.push({ + scheduleId: schedule.id, + departureAt: schedule.departureAt, + origin: schedule.originStation.name, + destination: schedule.destinationStation.name, + coaches: coachReports, + }); + } + } + + const totalDuplicates = result.reduce( + (sum, s) => sum + s.coaches.reduce((cs, c) => cs + c.duplicates.length, 0), + 0, + ); + + return { date, schedules: result, totalDuplicates }; + } + + async resolveDuplicateSeats(bookingSeatIds: string[], coachIds: string[]) { + if (bookingSeatIds.length === 0) return { resolved: 0, unresolved: 0, results: [] }; + + // Load BookingSeat rows with full booking + schedule context + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { id: { in: bookingSeatIds } }, + select: { + id: true, seatId: true, leg: true, scheduleId: true, + seat: { select: { seatNumber: true } }, + booking: { + select: { + id: true, bookingRef: true, scheduleId: true, + status: true, contactPhone: true, passengerId: true, + totalMinor: true, currency: true, + originStationId: true, destinationStationId: true, + schedule: { + select: { + originStationId: true, + destinationStationId: true, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + departureAt: true, + }, + }, + }, + }, + }, + }); + + if (bookingSeats.length !== bookingSeatIds.length) { + const found = new Set(bookingSeats.map(bs => bs.id)); + const missing = bookingSeatIds.filter(id => !found.has(id)); + throw new NotFoundException(`BookingSeat(s) not found: ${missing.join(', ')}`); + } + + const invalid = bookingSeats.filter(bs => !['CONFIRMED', 'BOARDED'].includes(bs.booking.status)); + if (invalid.length > 0) { + throw new BadRequestException( + `Bookings must be CONFIRMED or BOARDED: ${invalid.map(bs => bs.booking.bookingRef).join(', ')}`, + ); + } + + // Load all non-blocked, non-removed seats from the selected coaches (ordered for deterministic pick) + const coachSeats = await this.prisma.seat.findMany({ + where: { + coachId: { in: coachIds }, + status: { not: 'BLOCKED' }, + NOT: { seatNumber: { startsWith: '-' } }, + }, + select: { id: true, seatNumber: true, coachId: true, row: true, col: true }, + orderBy: [{ coachId: 'asc' }, { row: 'asc' }, { col: 'asc' }], + }); + + // Build occupied-seat sets per schedule from confirmed JourneySegments + const scheduleIds = [ + ...new Set( + bookingSeats + .map(bs => bs.scheduleId ?? bs.booking.scheduleId) + .filter((id): id is string => id !== null && id !== undefined), + ), + ]; + + const occupiedBySchedule = new Map>(); + await Promise.all( + scheduleIds.map(async scheduleId => { + const segments = await this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { not: null }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT', 'BOARDED'] } }, + }, + select: { seatId: true }, + }); + occupiedBySchedule.set(scheduleId, new Set(segments.map(s => s.seatId!))); + }), + ); + + // Track seats assigned within this batch to prevent double-assignment + const assignedInBatch = new Set(); + + const results: { bookingRef: string; oldSeatNumber: string; newSeatNumber: string; contactPhone: string | null }[] = []; + const unresolved: { bookingRef: string; reason: string }[] = []; + + for (const bs of bookingSeats) { + const scheduleId = (bs.scheduleId ?? bs.booking.scheduleId)!; + const occupied = occupiedBySchedule.get(scheduleId) ?? new Set(); + + // Pick the first available seat across the selected coaches + const newSeat = coachSeats.find( + seat => + !occupied.has(seat.id) && + !assignedInBatch.has(seat.id) && + seat.id !== bs.seatId, + ); + + if (!newSeat) { + unresolved.push({ + bookingRef: bs.booking.bookingRef, + reason: 'No available seat found in selected coaches', + }); + this.logger.warn( + `Duplicate resolve: no seat available for ${bs.booking.bookingRef} (schedule ${scheduleId})`, + ); + continue; + } + + await this.prisma.$transaction(async tx => { + // 1. Change the seat on the booking and ticket. + await tx.bookingSeat.update({ + where: { id: bs.id }, + data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber }, + }); + await tx.ticket.updateMany({ + where: { bookingId: bs.booking.id, seatId: bs.seatId, leg: bs.leg }, + data: { seatId: newSeat.id }, + }); + + // 2. Point the existing JourneySegments to the new seat. + // The Journey is already linked to this booking via bookingId; + // just update the seatId in its hop rows for this schedule. + const journey = await tx.journey.findFirst({ + where: { bookingId: bs.booking.id }, + select: { id: true }, + }); + + if (!journey) { + // No Journey/JourneySegment for this booking (e.g. duplicate that was never + // processed by finalizePaymentSuccess). Create them now using the same logic, + // scoped to the booking's origin→destination leg so the seatmap shows BOOKED + // only for the correct range of stops. + const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId; + const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId; + + const stopTimes = await tx.tripStopTime.findMany({ + where: { scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true }, + }); + + const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0; + const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1; + const fromIdx = originIdx >= 0 ? originIdx : 0; + const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1; + + const newJourney = await tx.journey.create({ + data: { + passengerId: bs.booking.passengerId, + bookingId: bs.booking.id, + status: 'CONFIRMED', + totalMinor: bs.booking.totalMinor, + currency: bs.booking.currency, + } as any, + }); + + const segments = []; + for (let i = fromIdx; i < toIdx; i++) { + segments.push({ + journeyId: newJourney.id, + scheduleId, + segmentOrder: i - fromIdx, + seatId: newSeat.id, + coachId: newSeat.coachId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } + if (segments.length > 0) { + await tx.journeySegment.createMany({ data: segments, skipDuplicates: true }); + } + this.logger.log( + `No Journey for ${bs.booking.bookingRef} — created Journey + ${segments.length} segment(s) for seat ${newSeat.seatNumber}`, + ); + return; + } + + const { count } = await tx.journeySegment.updateMany({ + where: { journeyId: journey.id, scheduleId, seatId: bs.seatId }, + data: { seatId: newSeat.id }, + }); + + // Journey exists but had no segments (e.g. booking confirmed via a path + // that skipped JourneySegment creation). Create them now for the new seat + // so the seatmap reflects BOOKED. + if (count === 0) { + const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId; + const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId; + const stopTimes = await tx.tripStopTime.findMany({ + where: { scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true }, + }); + const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0; + const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1; + const fromIdx = originIdx >= 0 ? originIdx : 0; + const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1; + const segments = []; + for (let i = fromIdx; i < toIdx; i++) { + segments.push({ + journeyId: journey.id, + scheduleId, + segmentOrder: i - fromIdx, + seatId: newSeat.id, + coachId: newSeat.coachId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } + if (segments.length > 0) { + await tx.journeySegment.createMany({ data: segments, skipDuplicates: true }); + } + this.logger.log( + `Seat reassigned: ${bs.booking.bookingRef} ` + + `${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` + + `(0 existing segments — created ${segments.length} new hop(s))`, + ); + } else { + this.logger.log( + `Seat reassigned: ${bs.booking.bookingRef} ` + + `${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` + + `(${count} segment hop(s) updated)`, + ); + } + }); + + // Mark as taken so the next booking in this batch doesn't get the same seat + assignedInBatch.add(newSeat.id); + occupied.add(newSeat.id); + + const oldSeatNumber = bs.seat?.seatNumber ?? '?'; + const origin = bs.booking.schedule?.originStation?.name ?? ''; + const dest = bs.booking.schedule?.destinationStation?.name ?? ''; + + if (bs.booking.contactPhone) { + const message = + `EDR: Your booking ${bs.booking.bookingRef} (${origin} → ${dest}): ` + + `your seat has been changed from seat ${oldSeatNumber} to seat ${newSeat.seatNumber}. ` + + `We apologize for any inconvenience.`; + await this.sms.sendSms({ to: bs.booking.contactPhone, message }).catch(() => null); + } + + this.logger.log( + `Duplicate resolved: ${bs.booking.bookingRef} seat ${oldSeatNumber} → ${newSeat.seatNumber}`, + ); + + results.push({ + bookingRef: bs.booking.bookingRef, + oldSeatNumber, + newSeatNumber: newSeat.seatNumber, + contactPhone: bs.booking.contactPhone, + }); + } + + return { + resolved: results.length, + unresolved: unresolved.length, + results, + ...(unresolved.length > 0 ? { unresolvedDetails: unresolved } : {}), }; } } diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts index 335d11f81..fb8a29a6e 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -2,11 +2,10 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../common/prisma.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { CurrencyModule } from '../currency/currency.module'; -import { PaymentsModule } from '../payments/payments.module'; import { TasksService } from './tasks.service'; @Module({ - imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule], + imports: [PrismaModule, NotificationsModule, CurrencyModule], providers: [TasksService], }) export class TasksModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 7a9208cae..4fb3a4f0f 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -3,9 +3,6 @@ import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; -import { PaymentsService } from '../payments/payments.service'; -import { PaymentClientService } from '../payments/payment-client.service'; -import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types'; import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; // Retention windows @@ -31,8 +28,6 @@ export class TasksService { private readonly prisma: PrismaService, private readonly sms: SmsClientService, private readonly currencyService: CurrencyService, - private readonly paymentsService: PaymentsService, - private readonly paymentClient: PaymentClientService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -258,87 +253,6 @@ export class TasksService { } } - // ───────────────────────────────────────────────────────────────────────── - // Every 1 min: poll the payment service for any PENDING_PAYMENT bookings - // whose payment intent has moved to SUCCEEDED on the gateway but whose - // confirmation event was never delivered (missed RabbitMQ message, network - // blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running - // it for an already-confirmed booking is safe. - // - // Processes at most 50 bookings per cycle to avoid hammering the payment - // service; the next tick picks up the remainder. - // ───────────────────────────────────────────────────────────────────────── - @Cron('*/1 * * * *') - async syncPaymentStatuses() { - const BATCH_SIZE = 50; - - const bookings = await this.prisma.booking.findMany({ - where: { - status: 'PENDING_PAYMENT', - paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } }, - }, - include: { paymentIntent: true }, - take: BATCH_SIZE, - orderBy: { createdAt: 'asc' }, - }); - - if (bookings.length === 0) return; - - let confirmed = 0; - let failed = 0; - let errored = 0; - - for (const booking of bookings) { - if (!booking.paymentIntent) continue; - - try { - const snapshot = await this.paymentClient.getIntentByReference( - PaymentReferenceType.BOOKING, - booking.id, - ); - - if (!snapshot) continue; - - if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { - const result = await this.paymentsService.finalizePaymentSuccess({ - intentId: booking.paymentIntent.id, - providerTxnId: snapshot.providerTxnId, - paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, - }); - if (!result.alreadyFinalized) { - this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`); - confirmed++; - } - } else if ( - snapshot.status === ProviderPaymentStatus.FAILED || - snapshot.status === ProviderPaymentStatus.CANCELLED - ) { - // The payment deadline enforcer will cancel the booking when its - // window expires; log now so operations can see failed intents early. - this.logger.warn( - `Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` + - `booking will be auto-cancelled at payment deadline`, - ); - failed++; - } - // REQUIRES_ACTION / PROCESSING → still pending, retry next cycle - } catch (err) { - this.logger.error( - `Payment sync error for ${booking.bookingRef}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - errored++; - } - } - - if (confirmed > 0 || failed > 0 || errored > 0) { - this.logger.log( - `Payment sync run: ${bookings.length} checked, ` + - `${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`, - ); - } - } - // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ───────────────────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 51d3b0630..b45b93a33 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; -import { PassengerStaff } from '../../common/passenger-guards'; +import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Tickets') @@ -207,9 +207,9 @@ export class TicketsController { } @Delete(':id') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Delete ticket (admin only)', description: 'Permanently deletes a ticket record and removes associated seat blocks' }) diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx new file mode 100644 index 000000000..4b98bd933 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function DiscrepancyLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx new file mode 100644 index 000000000..9545c1d2a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx @@ -0,0 +1,519 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react'; +import { seatsApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; + +// ── Types ───────────────────────────────────────────────────────────────── + +interface DuplicateBooking { + bookingSeatId: string; + bookingId: string; + bookingRef: string; + passengerName: string; + contactPhone: string | null; + createdAt: string; +} + +interface DuplicateSeatGroup { + seatId: string; + seatNumber: string; + leg: number; + bookings: DuplicateBooking[]; +} + +interface AvailableSeat { + seatId: string; + seatNumber: string; +} + +interface CoachReport { + coachId: string; + coachNumber: string; + coachTypeName: string; + duplicates: DuplicateSeatGroup[]; + availableSeats: AvailableSeat[]; +} + +interface ScheduleReport { + scheduleId: string; + origin: string; + destination: string; + departureAt: string; + coaches: CoachReport[]; +} + +interface DuplicatesResponse { + date: string; + schedules: ScheduleReport[]; + totalDuplicates: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function today() { + return new Date().toISOString().slice(0, 10); +} + +function scheduleDuplicateCount(s: ScheduleReport) { + return s.coaches.reduce((sum, c) => sum + c.duplicates.length, 0); +} + +// ── Resolve modal ───────────────────────────────────────────────────────── + +interface ResolveModalProps { + schedule: ScheduleReport; + coach: CoachReport; + onClose: () => void; + onSuccess: () => void; +} + +function ResolveModal({ schedule, coach, onClose, onSuccess }: ResolveModalProps) { + // Default: pre-select all-but-first passenger in every duplicate group + const defaultSelected = new Set( + coach.duplicates.flatMap(g => g.bookings.slice(1).map(b => b.bookingSeatId)), + ); + const [selectedSeats, setSelectedSeats] = useState>(defaultSelected); + + // Coaches that have at least one available seat (pre-select all) + const coachesWithSeats = schedule.coaches.filter(c => c.availableSeats.length > 0); + const [selectedCoachIds, setSelectedCoachIds] = useState>( + new Set(coachesWithSeats.map(c => c.coachId)), + ); + + const [successMsg, setSuccessMsg] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const mutation = useMutation({ + mutationFn: (data: { bookingSeatIds: string[]; coachIds: string[] }) => + seatsApi.resolveDuplicates(data), + onSuccess: (res) => { + setSuccessMsg( + `${res.resolved ?? 0} passenger(s) successfully reassigned.` + + (res.unresolved > 0 ? ` ${res.unresolved} could not be resolved (no available seat).` : ''), + ); + setErrorMsg(null); + onSuccess(); + }, + onError: (err: any) => { + setErrorMsg(err?.response?.data?.message ?? 'Failed to resolve duplicates.'); + }, + }); + + function toggleBookingSeat(id: string) { + setSelectedSeats(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + function toggleCoach(id: string) { + setSelectedCoachIds(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + function handleAssign() { + setErrorMsg(null); + if (selectedSeats.size === 0) { + setErrorMsg('Select at least one passenger to reassign.'); + return; + } + if (selectedCoachIds.size === 0) { + setErrorMsg('Select at least one coach to source the replacement seat from.'); + return; + } + mutation.mutate({ + bookingSeatIds: [...selectedSeats], + coachIds: [...selectedCoachIds], + }); + } + + return ( +
+
+ + {/* Header */} +
+
+

+ Resolve Duplicates — {coach.coachNumber} +

+

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

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

+ Duplicate seat assignments +

+

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

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

+ Reassign to seats in +

+

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

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

No coaches have available seats on this schedule.

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

{errorMsg}

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

{successMsg}

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

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

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

Seat Discrepancy

+

+ Detect and resolve duplicate seat assignments by schedule date +

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

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

+

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

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

All seats are correctly assigned for {data.date}

+
+ )} + + {/* Resolve modal */} + {resolveTarget && ( + setResolveTarget(null)} + onSuccess={() => { + refetch(); + // Keep modal open to show success message; user closes manually + }} + /> + )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index c0d84cae8..4f0098792 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -365,7 +365,7 @@ export default function LoginPage() { Back-office · v1.0 - Need help? support@edr.com + Need help? edr_@edrsc.com diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index f849654b5..be5449e97 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -87,7 +87,8 @@ export default function SeatsPage() { }; const blockMutation = useMutation({ - mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }), + mutationFn: ({ seatId, reason }: any) => + seatsApi.block(seatId, { reason, ...(activeTab === 'schedule' && selectedSchedule ? { scheduleId: selectedSchedule } : {}) }), onSuccess: () => { invalidateSeatData(); setShowBlockModal(false); @@ -97,7 +98,8 @@ export default function SeatsPage() { }); const unblockMutation = useMutation({ - mutationFn: (seatId: string) => seatsApi.unblock(seatId), + mutationFn: (seatId: string) => + seatsApi.unblock(seatId, activeTab === 'schedule' ? selectedSchedule : undefined), onSuccess: () => { invalidateSeatData(); }, @@ -143,7 +145,8 @@ export default function SeatsPage() { mutationFn: async ({ coachId, reason }: any) => { const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); - return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason }))); + const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined; + return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason, ...(scheduleId ? { scheduleId } : {}) }))); }, onSuccess: () => { invalidateSeatData(); @@ -157,7 +160,8 @@ export default function SeatsPage() { mutationFn: async ({ coachId }: any) => { const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); - return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId))); + const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined; + return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId, scheduleId))); }, onSuccess: () => { invalidateSeatData(); diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 3f0a266fd..873e81578 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -94,11 +94,11 @@ export default function SettingsPage() {
- +
- +
diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 45da6d8ca..62fa655c7 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -37,6 +37,7 @@ import { Banknote, Activity, Smartphone, + Layers, } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; import { cn } from '@/lib/utils'; @@ -64,7 +65,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view }, { name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view }, { name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.view }, - { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, + { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, + { name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 2dae6560f..2453eafa2 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -154,11 +154,15 @@ export const seatsApi = { hold: (data: any) => apiClient.post('/seats/hold', data), release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`), block: (seatId: string, data: any) => apiClient.post(`/seats/${seatId}/block`, data), - unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`), + unblock: (seatId: string, scheduleId?: string) => apiClient.delete(`/seats/${seatId}/block${scheduleId ? `?scheduleId=${scheduleId}` : ''}`), removeSeat: (seatId: string) => apiClient.patch(`/seats/${seatId}/remove`, {}), undoRemove: (seatId: string) => apiClient.patch(`/seats/${seatId}/undo-remove`, {}), setMaintenance: (seatId: string, reason: string) => apiClient.post(`/seats/${seatId}/maintenance`, { reason }), clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`), + getDuplicates: (date: string, scheduleId?: string) => + apiClient.get(`/seats/duplicates?date=${date}${scheduleId ? `&scheduleId=${scheduleId}` : ''}`), + resolveDuplicates: (data: { bookingSeatIds: string[]; coachIds: string[] }) => + apiClient.post('/seats/duplicates/resolve', data), }; // Payments API