diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 1501282fe..cdfe68787 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -31,10 +31,12 @@ "@nestjs/event-emitter": "^2.0.4", "@nestjs/microservices": "^11.1.24", "@nestjs/platform-express": "^11.1.19", + "@nestjs/platform-socket.io": "^11.1.27", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.4.0", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.1", + "@nestjs/websockets": "^11.1.27", "@prisma/client": "^6.19.3", "@sendgrid/mail": "^8.1.0", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", @@ -54,6 +56,7 @@ "qrcode": "^1.5.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "swagger-ui-express": "^5.0.0", "tsconfig-paths": "^4.2.0", "typeorm": "^0.3.30", diff --git a/apps/edr-passenger-api/prisma/migrations/20260707134245_enhanced_support_chat/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260707134245_enhanced_support_chat/migration.sql new file mode 100644 index 000000000..299a2abf8 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260707134245_enhanced_support_chat/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "SupportConversation" ADD COLUMN "agentLastReadAt" TIMESTAMP(3), +ADD COLUMN "lastMessageAt" TIMESTAMP(3), +ADD COLUMN "lastMessagePreview" TEXT, +ADD COLUMN "lastMessageSender" "SupportSender", +ADD COLUMN "passengerId" TEXT, +ADD COLUMN "passengerName" TEXT, +ADD COLUMN "subject" TEXT, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "userLastReadAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "SupportConversation_userId_idx" ON "SupportConversation"("userId"); + +-- CreateIndex +CREATE INDEX "SupportConversation_status_lastMessageAt_idx" ON "SupportConversation"("status", "lastMessageAt"); + +-- AddForeignKey +ALTER TABLE "SupportConversation" ADD CONSTRAINT "SupportConversation_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260707135233_add_guest_support/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260707135233_add_guest_support/migration.sql new file mode 100644 index 000000000..a6b1fc9e7 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260707135233_add_guest_support/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "SupportConversation" ADD COLUMN "guestEmail" TEXT, +ADD COLUMN "guestId" TEXT, +ADD COLUMN "guestName" TEXT, +ADD COLUMN "guestPhone" TEXT, +ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "SupportConversation_guestId_idx" ON "SupportConversation"("guestId"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 0cd1756fd..8a4224e73 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -299,6 +299,7 @@ model Passenger { travelerProfiles TravelerProfile[] savedRoutes SavedRoute[] packageBookings PackageBooking[] + supportConversations SupportConversation[] @@index([userId]) @@index([iamUserId]) @@schema("passenger") @@ -881,12 +882,29 @@ model FaqArticle { } model SupportConversation { - id String @id @default(uuid()) - userId String - assignedAgentId String? - status SupportConversationStatus @default(OPEN) - createdAt DateTime @default(now()) - messages SupportMessage[] + id String @id @default(uuid()) + userId String? + guestId String? + guestName String? + guestEmail String? + guestPhone String? + passengerId String? + passengerName String? + subject String? + assignedAgentId String? + status SupportConversationStatus @default(OPEN) + lastMessageAt DateTime? + lastMessagePreview String? + lastMessageSender SupportSender? + userLastReadAt DateTime? + agentLastReadAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @default(now()) + messages SupportMessage[] + passenger Passenger? @relation(fields: [passengerId], references: [id]) + @@index([userId]) + @@index([guestId]) + @@index([status, lastMessageAt]) @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index e552fad8c..1e41076e7 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -64,7 +64,6 @@ import { TasksModule } from './modules/tasks/tasks.module'; import { AppReleasesModule } from './modules/app-releases/app-releases.module'; import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module'; import { SegmentFareSeeder } from './seed/segment-fare.seeder'; -import { EOtpType } from "@tria-plc/iamapi-common"; @Module({ imports: [ @@ -98,16 +97,6 @@ import { EOtpType } from "@tria-plc/iamapi-common"; TriaIamModule.forRoot({ applications: [EDR_PASSENGER_APPLICATION], permissions: EDR_PASSENGER_PERMISSIONS, - otpMessages: { - [EOtpType.MFA_LOGIN]: ({ otp }) => - `Your EDR Passenger login code is ${otp}. It will expire in 5 minutes.`, - [EOtpType.VERIFY_PHONE_NUMBER]: ({ otp }) => - `Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`, - [EOtpType.RESET_PASSWORD]: ({ route }) => - `Reset your EDR Passenger password using this link: ${route}`, - [EOtpType.SET_PASSWORD]: ({ route }) => - `Set your EDR Passenger password using this link: ${route}`, - }, }), SharedAuthModule, PrismaModule, diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 6e45aeab4..345cfc357 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -4,7 +4,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; export class PassengerInputDto { - @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' }) @IsString() seatId: string; + @ApiPropertyOptional({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types). Omit for free children (package bookings) — backend auto-assigns.' }) @IsOptional() @IsString() seatId?: string; @ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) @IsOptional() @IsString() leg2SeatId?: string; @ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string; @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string; @@ -24,11 +24,11 @@ export class PassengerInputDto { } export class RoundTripPassengerDto { - @ApiProperty({ description: 'Outbound journey seat ID', example: 'seat-uuid-outbound' }) - @IsString() outboundSeatId: string; + @ApiPropertyOptional({ description: 'Outbound journey seat ID', example: 'seat-uuid-outbound' }) + @IsOptional() @IsString() outboundSeatId?: string; - @ApiProperty({ description: 'Return journey seat ID', example: 'seat-uuid-return' }) - @IsString() returnSeatId: string; + @ApiPropertyOptional({ description: 'Return journey seat ID', example: 'seat-uuid-return' }) + @IsOptional() @IsString() returnSeatId?: string; @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) @IsOptional() @IsString() outboundLeg2SeatId?: string; diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index c6f27d9c3..c2acaeece 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -4,8 +4,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; export class GuestPassengerDto { - @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' }) - @IsString() seatId: string; + @ApiPropertyOptional({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types). Omit for free children (package bookings) — backend auto-assigns.' }) + @IsOptional() @IsString() seatId?: string; @ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID. Required for ROUND_TRIP and ROUND_TRIP_TRANSIT.' }) @IsOptional() @IsString() returnSeatId?: string; diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index f4139d842..1025a42b3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -260,7 +260,7 @@ export class GuestBookingService { await this.createTravelerProfiles(guestPassengerId, passengersData); // Confirm seats - await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)); + await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)); this.eventEmitter.emit('booking.created', { booking }); return { @@ -434,7 +434,7 @@ export class GuestBookingService { const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); // Create booking with outbound seats; return seats confirmed separately - const outboundSeatIds = dto.passengers.map(p => p.seatId); + const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id); const returnSeatIds = dto.passengers.map(p => p.returnSeatId!); const booking = await this.prisma.booking.create({ @@ -701,7 +701,7 @@ export class GuestBookingService { await this.createTravelerProfiles(guestPassengerId, passengersData); await Promise.all([ - this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)), + this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)), this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)), ]); this.eventEmitter.emit('booking.created', { booking }); @@ -894,7 +894,7 @@ export class GuestBookingService { await this.createTravelerProfiles(guestPassengerId, passengersData); await Promise.all([ - this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)), + this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)), this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)), this.seatsService.confirmSeats(dto.passengers.map(p => p.returnSeatId!)), this.seatsService.confirmSeats(dto.passengers.map(p => p.returnLeg2SeatId!)), diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 9a7b4311c..43d1e4d46 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -285,7 +285,11 @@ export class NotificationsService { const ref = booking?.bookingRef ?? payload.booking.bookingRef; const amount = this.formatAmount(booking ?? payload.booking); const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB'; - const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/confirmation?ref=${ref}`; + // /booking/confirmation only reads from the in-session booking store, so it's a dead + // link once opened outside that session (a different device, or later on the same + // one) — exactly the case an SMS/email link is for. /booking/detail fetches the + // booking fresh from the API by ref, so it works standalone. + const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`; // IN_APP — always created. await this.createInAppNotification( diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index a474b1edf..916a0425b 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -75,7 +75,6 @@ export class PackagesService { const maxChildren = adultCount * PKG_CHILDREN_PER_ADULT; if (childCount > maxChildren) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildren} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); - const remaining = tier.availableSeats - tier.bookedSeats; const isRoundTrip = !!pkg.returnScheduleId; const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, @@ -84,6 +83,10 @@ export class PackagesService { // Only adults and paid children need seats; free children travel without a seat const seatsNeeded = adultCount + paidChildren; const passengerCount = adultCount + childCount; + // Re-fetch tier from DB to get accurate live counts + const liveTier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } }); + if (!liveTier) throw new NotFoundException('Price tier not found'); + const remaining = liveTier.availableSeats - liveTier.bookedSeats; if (seatsNeeded > remaining) throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); @@ -392,25 +395,29 @@ export class PackagesService { if (childCount > maxChildrenBook) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildrenBook} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); const passengerCount = adultCount + childCount; - const remaining = tier.availableSeats - tier.bookedSeats; - const isRoundTrip = !!pkg.returnScheduleId; const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, ); // Only adults and paid children need seats; free children travel without a seat const seatsNeeded = adultCount + paidChildren; - if (seatsNeeded > remaining) { - throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); - } const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; - const [booking] = await this.prisma.$transaction([ - this.prisma.packageBooking.create({ + const [booking] = await this.prisma.$transaction(async (tx) => { + // Re-fetch tier inside transaction for race-condition-safe availability check + const freshTier = await tx.packagePriceTier.findUnique({ where: { id: dto.priceTierId } }); + if (!freshTier) throw new NotFoundException('Price tier not found'); + const remaining = freshTier.availableSeats - freshTier.bookedSeats; + if (seatsNeeded > remaining) { + throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); + } + + return Promise.all([ + tx.packageBooking.create({ data: { bookingRef: generateRef(), packageId: dto.packageId, @@ -448,12 +455,16 @@ export class PackagesService { }, }, }, - }), - this.prisma.packagePriceTier.update({ - where: { id: dto.priceTierId }, - data: { bookedSeats: { increment: seatsNeeded } }, - }), - ]); + }), + tx.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { + bookedSeats: { increment: seatsNeeded }, + availableSeats: { decrement: seatsNeeded }, + }, + }), + ]); + }); return { ...booking, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 5be11434a..2cdbc86e4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -33,6 +33,7 @@ import { PaymentMethodTypeEnum, PaymentPlatformDto, BookingAmountResponseDto, + ForceConfirmDto, } from "./payments.dto"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -124,6 +125,19 @@ export class PaymentsController { return this.service.refund(dto); } + @Post(":bookingId/force-confirm") + @PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ + summary: "Force-confirm payment & generate ticket (back-office only)", + description: + "Marks the payment as SUCCEEDED, confirms the booking, and generates the ticket. " + + "Use when a vendor payment completed but the webhook was never delivered. Idempotent.", + }) + forceConfirm(@Param("bookingId") bookingId: string, @Body() dto: ForceConfirmDto) { + return this.service.forceConfirmPayment(bookingId, dto); + } + @Post("methods") @PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin]) @ApiBearerAuth("IAM-auth") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 410716b85..8d24091a1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -142,3 +142,14 @@ export class BookingAmountResponseDto { @ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string; @ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number; } + +export class ForceConfirmDto { + @ApiPropertyOptional({ description: 'External payment reference / transaction ID from the vendor', example: 'TXN-123456' }) + @IsOptional() @IsString() paymentReference?: string; + + @ApiPropertyOptional({ enum: PaymentMethodTypeEnum, description: 'Payment method used externally', example: 'TELEBIRR' }) + @IsOptional() @IsEnum(PaymentMethodTypeEnum) paymentMethod?: PaymentMethodTypeEnum; + + @ApiPropertyOptional({ description: 'Internal notes about why this was force-confirmed', example: 'Vendor confirmed via phone' }) + @IsOptional() @IsString() notes?: string; +} 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 e9b512c5e..9e43139c4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -21,6 +21,7 @@ import { InitiateResponseDto, IntentStatusDto, PaymentRegionEnum, + ForceConfirmDto, } from "./payments.dto"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; import { PaymentClientService } from "./payment-client.service"; @@ -773,6 +774,51 @@ export class PaymentsService { return { processed: true, alreadyFinalized }; } + async forceConfirmPayment(bookingId: string, dto: ForceConfirmDto = {}): Promise<{ alreadyFinalized: boolean }> { + const booking = await this.prisma.booking.findUnique({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException('Booking not found'); + + const resolvedMethod = dto.paymentMethod + ? (dto.paymentMethod as unknown as PaymentMethodType) + : PaymentMethodType.TELEBIRR; + + let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId } }); + if (!intent) { + intent = await this.prisma.paymentIntent.create({ + data: { + bookingId, + amountMinor: booking.totalMinor, + currency: booking.currency, + method: resolvedMethod, + status: PaymentIntentStatus.PROCESSING, + providerRef: `FORCE-${Date.now()}`, + providerTxnId: dto.paymentReference ?? null, + failureMessage: dto.notes ?? null, + }, + }); + } else { + // Update method/reference/notes regardless of current status + const updateData: any = {}; + if (dto.paymentReference) updateData.providerTxnId = dto.paymentReference; + if (dto.paymentMethod) updateData.method = resolvedMethod; + if (dto.notes) updateData.failureMessage = dto.notes; + if (intent.status === PaymentIntentStatus.CANCELLED || intent.status === PaymentIntentStatus.FAILED) { + updateData.status = PaymentIntentStatus.PROCESSING; + } + if (Object.keys(updateData).length) { + intent = await this.prisma.paymentIntent.update({ + where: { id: intent.id }, + data: updateData, + }); + } + } + + return this.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined, + }); + } + async markPaymentFailed(input: { intentId: string; failureCode?: string; diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 2942625ca..355446544 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -64,7 +64,7 @@ export class SearchService { const outbound = [...direct, ...transit]; - if (outbound.length === 0) { + if (outbound.length === 0 && dto.journeyType !== 'ROUND_TRIP') { const alternativesOutbound = await this.searchAlternatives( dto.originStationId, dto.destinationStationId, @@ -74,7 +74,7 @@ export class SearchService { dto.nationality, ); return { - journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY', + journeyType: 'ONE_WAY', outbound: [], alternativeOutbound: alternativesOutbound, requestedDate: dto.date, @@ -110,19 +110,29 @@ export class SearchService { new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival ); - if (inbound.length === 0) { - const alternativeInbound = await this.searchAlternatives( - dto.destinationStationId, - dto.originStationId, - dto.returnDate ?? dto.date, - dto.adultCount, - dto.childCount, - dto.nationality, - ); - return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound }; + const returnDate = dto.returnDate ?? dto.date; + + if (outbound.length === 0 || inbound.length === 0) { + const [alternativeOutbound, alternativeInbound] = await Promise.all([ + outbound.length === 0 + ? this.searchAlternatives(dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality) + : Promise.resolve([]), + inbound.length === 0 + ? this.searchAlternatives(dto.destinationStationId, dto.originStationId, returnDate, dto.adultCount, dto.childCount, dto.nationality) + : Promise.resolve([]), + ]); + return { + journeyType: 'ROUND_TRIP', + outbound, + inbound, + alternativeOutbound, + alternativeInbound, + requestedDate: dto.date, + requestedReturnDate: returnDate, + }; } - return { journeyType: 'ROUND_TRIP', outbound, inbound }; + return { journeyType: 'ROUND_TRIP', outbound, inbound, requestedDate: dto.date, requestedReturnDate: returnDate }; } return { journeyType: 'ONE_WAY', outbound }; 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 4a9783101..fa233f45c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -19,7 +19,7 @@ import { ApiResponse, } from "@nestjs/swagger"; import { SeatsService } from "./seats.service"; -import { HoldSeatsDto } from "./seats.dto"; +import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; import { IamGuard } from "../../common/iam-adapter"; @@ -182,6 +182,27 @@ This makes it clear which segment of the route each seat is held for, enabling s return this.service.releaseHold(holdId); } + @Post("release") + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: "Release a seat hold by holdId (portal-server use only)", + description: + "Frees a previously-created hold's seats immediately instead of waiting for it to " + + "expire — used when a guest or logged-in user changes their seat selection, so the " + + "stale hold doesn't linger and block that seat for other travellers.\n\n" + + "This is a public endpoint (no JWT), like POST /seats/hold, since guest sessions have " + + "no login to authenticate with. It must ONLY ever be called from the passenger portal's " + + "own Next.js server (a server-side route handler), never directly from browser code — " + + "calling it straight from client JS would let anyone script mass hold-cancellation " + + "against other travellers' in-progress seat selections. The portal's server-side proxy " + + "is what keeps this endpoint's existence out of the browser's network requests.", + }) + @ApiResponse({ status: 200, description: "Hold released" }) + @ApiResponse({ status: 404, description: "Hold not found" }) + releaseSeatById(@Body() dto: ReleaseHoldDto) { + return this.service.releaseHold(dto.holdId); + } + // ── Seat Block / Unblock ─────────────────────────────────────────────────── @Post(":seatId/block") @UseGuards(IamGuard) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts index 23c3b3d46..87d47588e 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts @@ -48,3 +48,8 @@ export class HoldSeatsDto { @Type(() => PassengerSeatDto) passengers: PassengerSeatDto[]; } + +export class ReleaseHoldDto { + @ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' }) + @IsString() holdId: string; +} 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 495c394ee..b26dcb763 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -90,6 +90,7 @@ export class SeatsService { label: a.coach.number, mode: a.coach.status, name: `Coach ${a.coach.number}`, + coachTypeId: a.coach.coachType?.id ?? null, coachTypeName, isBedCoach, bedCategory, @@ -665,6 +666,7 @@ export class SeatsService { return { coachId: a.coach.id, + coachTypeId: a.coach.coachType?.id ?? null, coachNumber: a.coach.number, positionNumber: a.positionNumber, coachTypeName: a.coach.coachType?.name ?? '', diff --git a/apps/edr-passenger-api/src/modules/support/support.controller.ts b/apps/edr-passenger-api/src/modules/support/support.controller.ts index c13b3cd33..63adefe5a 100644 --- a/apps/edr-passenger-api/src/modules/support/support.controller.ts +++ b/apps/edr-passenger-api/src/modules/support/support.controller.ts @@ -1,15 +1,207 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + Req, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SupportService } from './support.service'; import { JwtGuard } from '../../common/jwt.guard'; +import { + CreateConversationDto, + CreateGuestConversationDto, + GuestIdBodyDto, + GuestSendMessageDto, + ListConversationsQueryDto, + SendMessageDto, + UpdateStatusDto, +} from './support.dto'; + +function userId(req: any): string { + const id = req?.user?.id ?? req?.user?.sub; + if (!id) throw new UnauthorizedException(); + return id; +} @ApiTags('Support') @Controller('support') export class SupportController { constructor(private service: SupportService) {} - @Get('faq') @ApiOperation({ summary: 'Get FAQ categories' }) getFaqCategories() { return this.service.getFaqCategories(); } - @Get('faq/:categoryId/articles') @ApiOperation({ summary: 'Get FAQ articles for a category' }) getFaqArticles(@Param('categoryId') id: string) { return this.service.getFaqArticles(id); } - @Post('conversations') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Start a support conversation' }) startConversation(@Body('userId') userId: string) { return this.service.startConversation(userId); } - @Post('conversations/:id/messages') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Send a message in a conversation' }) sendMessage(@Param('id') id: string, @Body() body: { sender: 'USER' | 'BOT' | 'AGENT'; text: string }) { return this.service.sendMessage(id, body.sender, body.text); } - @Get('conversations/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get conversation with messages' }) getConversation(@Param('id') id: string) { return this.service.getConversation(id); } + + // ---- FAQ (public) ------------------------------------------------------ + + @Get('faq') + @IsPublic() + @ApiOperation({ summary: 'Get FAQ categories' }) + getFaqCategories() { + return this.service.getFaqCategories(); + } + + @Get('faq/:categoryId/articles') + @IsPublic() + @ApiOperation({ summary: 'Get FAQ articles for a category' }) + getFaqArticles(@Param('categoryId') id: string) { + return this.service.getFaqArticles(id); + } + + // ---- customer: authenticated passenger -------------------------------- + + @Post('conversations') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Open a new support conversation' }) + createConversation(@Req() req: any, @Body() body: CreateConversationDto) { + return this.service.createConversation(userId(req), body); + } + + @Get('conversations') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List my support conversations' }) + listMine(@Req() req: any, @Query() query: ListConversationsQueryDto) { + return this.service.listForCustomer({ iamUserId: userId(req) }, query); + } + + @Get('conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List messages in one of my conversations' }) + messages(@Req() req: any, @Param('id') id: string) { + return this.service.getMessages(id, { iamUserId: userId(req) }); + } + + @Post('conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Send a message as the customer' }) + send(@Req() req: any, @Param('id') id: string, @Body() body: SendMessageDto) { + return this.service.sendMessage(id, 'USER', body.text, { + iamUserId: userId(req), + }); + } + + @Post('conversations/:id/read') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Mark a conversation read (customer side)' }) + read(@Req() req: any, @Param('id') id: string) { + return this.service.markRead(id, 'USER', { iamUserId: userId(req) }); + } + + @Get('unread-count') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Count my unread support conversations' }) + unread(@Req() req: any) { + return this.service.unreadCount('USER', { iamUserId: userId(req) }); + } + + // ---- customer: guest (unauthenticated) -------------------------------- + // No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer + // of access — anyone with it sees that thread; accepted MVP trade-off). + + @Post('guest/conversations') + @IsPublic() + @ApiOperation({ summary: 'Open a support conversation as a guest' }) + guestCreate(@Body() body: CreateGuestConversationDto) { + return this.service.createGuestConversation(body); + } + + @Get('guest/conversations') + @IsPublic() + @ApiOperation({ summary: 'List a guest\'s conversations' }) + guestList( + @Query('guestId') guestId: string, + @Query() query: ListConversationsQueryDto, + ) { + return this.service.listForCustomer({ guestId }, query); + } + + @Get('guest/conversations/:id/messages') + @IsPublic() + @ApiOperation({ summary: 'List messages in a guest conversation' }) + guestMessages(@Param('id') id: string, @Query('guestId') guestId: string) { + return this.service.getMessages(id, { guestId }); + } + + @Post('guest/conversations/:id/messages') + @IsPublic() + @ApiOperation({ summary: 'Send a message as a guest' }) + guestSend(@Param('id') id: string, @Body() body: GuestSendMessageDto) { + return this.service.sendMessage(id, 'USER', body.text, { + guestId: body.guestId, + }); + } + + @Post('guest/conversations/:id/read') + @IsPublic() + @ApiOperation({ summary: 'Mark a guest conversation read' }) + guestRead(@Param('id') id: string, @Body() body: GuestIdBodyDto) { + return this.service.markRead(id, 'USER', { guestId: body.guestId }); + } + + @Get('guest/unread-count') + @IsPublic() + @ApiOperation({ summary: 'Count a guest\'s unread conversations' }) + guestUnread(@Query('guestId') guestId: string) { + return this.service.unreadCount('USER', { guestId }); + } + + // ---- agent (backoffice) ------------------------------------------------ + // TODO: gate agent routes with a staff permission once passenger RBAC is wired. + + @Get('agent/conversations') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List all support conversations (shared inbox)' }) + agentList(@Query() query: ListConversationsQueryDto) { + return this.service.listForAgents(query); + } + + @Get('agent/conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List messages in a conversation' }) + agentMessages(@Param('id') id: string) { + return this.service.getMessages(id); + } + + @Post('agent/conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Reply as an agent' }) + agentSend(@Param('id') id: string, @Body() body: SendMessageDto) { + return this.service.sendMessage(id, 'AGENT', body.text); + } + + @Patch('agent/conversations/:id/status') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: "Change a conversation's status" }) + agentStatus(@Param('id') id: string, @Body() body: UpdateStatusDto) { + return this.service.setStatus(id, body.status); + } + + @Post('agent/conversations/:id/read') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Mark a conversation read (agent side)' }) + agentRead(@Param('id') id: string) { + return this.service.markRead(id, 'AGENT'); + } + + @Get('agent/unread-count') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Count unread conversations (agent side)' }) + agentUnread() { + return this.service.unreadCount('AGENT'); + } } diff --git a/apps/edr-passenger-api/src/modules/support/support.dto.ts b/apps/edr-passenger-api/src/modules/support/support.dto.ts new file mode 100644 index 000000000..77a3ee3ed --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/support.dto.ts @@ -0,0 +1,127 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Length, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export enum SupportStatusDto { + OPEN = 'OPEN', + RESOLVED = 'RESOLVED', + CLOSED = 'CLOSED', +} + +export class CreateConversationDto { + @ApiProperty({ description: 'Short subject / topic of the request.' }) + @IsString() + @Length(3, 200) + subject!: string; + + @ApiProperty({ description: 'The first message body.' }) + @IsString() + @MinLength(1) + @MaxLength(4000) + initialMessage!: string; +} + +export class SendMessageDto { + @ApiProperty({ description: 'Message text.' }) + @IsString() + @MinLength(1) + @MaxLength(4000) + text!: string; +} + +export class CreateGuestConversationDto { + @ApiProperty({ description: 'Client-generated anonymous id (localStorage).' }) + @IsString() + @Length(8, 120) + guestId!: string; + + @ApiProperty({ description: 'Guest full name.' }) + @IsString() + @Length(1, 120) + name!: string; + + @ApiProperty({ description: 'Guest email for follow-up.' }) + @IsEmail() + email!: string; + + @ApiPropertyOptional({ description: 'Guest phone (optional).' }) + @IsOptional() + @IsString() + @MaxLength(40) + phone?: string; + + @ApiProperty() + @IsString() + @Length(3, 200) + subject!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(4000) + initialMessage!: string; +} + +export class GuestSendMessageDto { + @ApiProperty({ description: 'The guest id that owns the conversation.' }) + @IsString() + @Length(8, 120) + guestId!: string; + + @ApiProperty({ description: 'Message text.' }) + @IsString() + @MinLength(1) + @MaxLength(4000) + text!: string; +} + +export class GuestIdBodyDto { + @ApiProperty() + @IsString() + @Length(8, 120) + guestId!: string; +} + +export class UpdateStatusDto { + @ApiProperty({ enum: SupportStatusDto }) + @IsEnum(SupportStatusDto) + status!: SupportStatusDto; +} + +export class ListConversationsQueryDto { + @ApiPropertyOptional({ enum: SupportStatusDto }) + @IsOptional() + @IsEnum(SupportStatusDto) + status?: SupportStatusDto; + + @ApiPropertyOptional({ description: 'Search subject / passenger name.' }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/apps/edr-passenger-api/src/modules/support/support.gateway.ts b/apps/edr-passenger-api/src/modules/support/support.gateway.ts new file mode 100644 index 000000000..203c39035 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/support.gateway.ts @@ -0,0 +1,134 @@ +import { Logger } from '@nestjs/common'; +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { Passenger as PassengerTypes } from '@edr/types'; + +import { PrismaService } from '../../common/prisma.service'; +import { WsAuthService } from './ws-auth.service'; + +/** + * Server → client push for passenger support chat. Clients only *listen* (no + * `@SubscribeMessage`); the handshake is authenticated in `handleConnection`. + * Each socket joins a room based on its side: + * - backoffice staff → the shared `backoffice` room (see every conversation). + * - passengers → their `user:` room (their own tickets only). + * + * Side is decided by the presence of a `Passenger` row for the IAM user id + * (staff have none). A message is emitted to BOTH the owner's room and the + * backoffice room so the customer thread, the sender's echo, and every agent's + * inbox update live. + */ +@WebSocketGateway({ + namespace: PassengerTypes.PASSENGER_SUPPORT_WS_NAMESPACE, + cors: { origin: true, credentials: true }, +}) +export class SupportGateway implements OnGatewayConnection { + private readonly logger = new Logger(SupportGateway.name); + + private static readonly BACKOFFICE_ROOM = 'backoffice'; + + @WebSocketServer() + private readonly server!: Server; + + constructor( + private readonly wsAuth: WsAuthService, + private readonly prisma: PrismaService, + ) {} + + async handleConnection(socket: Socket): Promise { + const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); + + // Authenticated: passenger (own room) or backoffice staff (shared room). + if (userId) { + socket.data.userId = userId; + const passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: userId }, + }); + if (passenger) { + await socket.join(`user:${userId}`); + socket.data.side = 'USER'; + } else { + await socket.join(SupportGateway.BACKOFFICE_ROOM); + socket.data.side = 'AGENT'; + } + return; + } + + // Guest: no valid token, but a client-generated guestId scopes the room. + // Anyone holding the guestId can see that thread (no account = weaker + // ownership) — an accepted MVP trade-off for guest support. + const guestId = this.extractGuestId(socket); + if (guestId) { + socket.data.guestId = guestId; + socket.data.side = 'USER'; + await socket.join(`guest:${guestId}`); + return; + } + + this.logger.debug(`Rejected passenger-support handshake ${socket.id}`); + socket.disconnect(true); + } + + /** Push a new message + updated conversation to the owner + backoffice rooms. */ + emitMessage( + ownerRoom: string | null, + conversation: PassengerTypes.PassengerSupportConversationDto, + message: PassengerTypes.PassengerSupportMessageDto, + ): void { + const payload = { conversation, message }; + for (const room of this.targetRooms(ownerRoom)) { + const to = this.server.to(room); + to.emit(PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW, payload); + to.emit( + PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, + conversation, + ); + } + } + + /** Push a conversation metadata change (e.g. status) to both rooms. */ + emitConversationUpdated( + ownerRoom: string | null, + conversation: PassengerTypes.PassengerSupportConversationDto, + ): void { + for (const room of this.targetRooms(ownerRoom)) { + this.server + .to(room) + .emit( + PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, + conversation, + ); + } + } + + private targetRooms(ownerRoom: string | null): string[] { + const rooms = [SupportGateway.BACKOFFICE_ROOM]; + if (ownerRoom) rooms.push(ownerRoom); + return rooms; + } + + private extractGuestId(socket: Socket): string | undefined { + const authGuest = socket.handshake.auth?.guestId as string | undefined; + if (authGuest) return authGuest; + const queryGuest = socket.handshake.query?.guestId; + if (typeof queryGuest === 'string') return queryGuest; + return undefined; + } + + private extractToken(socket: Socket): string | undefined { + const authToken = socket.handshake.auth?.token as string | undefined; + if (authToken) return authToken; + + const queryToken = socket.handshake.query?.token; + if (typeof queryToken === 'string') return queryToken; + + const header = socket.handshake.headers?.authorization; + if (header?.startsWith('Bearer ')) return header.slice(7); + + return undefined; + } +} diff --git a/apps/edr-passenger-api/src/modules/support/support.module.ts b/apps/edr-passenger-api/src/modules/support/support.module.ts index 17f139a07..136fcf196 100644 --- a/apps/edr-passenger-api/src/modules/support/support.module.ts +++ b/apps/edr-passenger-api/src/modules/support/support.module.ts @@ -1,6 +1,17 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; + import { SupportController } from './support.controller'; import { SupportService } from './support.service'; +import { SupportGateway } from './support.gateway'; +import { WsAuthService } from './ws-auth.service'; -@Module({ controllers: [SupportController], providers: [SupportService] }) +@Module({ + // Session is served by the app's default TypeORM DataSource (IAM schema) — + // used by WsAuthService to authenticate WebSocket handshakes. + imports: [TypeOrmModule.forFeature([Session])], + controllers: [SupportController], + providers: [SupportService, SupportGateway, WsAuthService], +}) export class SupportModule {} diff --git a/apps/edr-passenger-api/src/modules/support/support.service.ts b/apps/edr-passenger-api/src/modules/support/support.service.ts index dd155bff8..dfbbb3b12 100644 --- a/apps/edr-passenger-api/src/modules/support/support.service.ts +++ b/apps/edr-passenger-api/src/modules/support/support.service.ts @@ -1,35 +1,419 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Passenger as T } from '@edr/types'; import { PrismaService } from '../../common/prisma.service'; +import { SupportGateway } from './support.gateway'; + +type Side = 'USER' | 'AGENT'; +type PrismaSender = 'USER' | 'BOT' | 'AGENT'; +type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED'; + +/** Who the caller is on the customer side: an authed passenger or a guest. */ +export interface CustomerOwner { + iamUserId?: string | null; + guestId?: string | null; +} + +interface ListQuery { + status?: PrismaStatus; + search?: string; + page?: number; + limit?: number; +} + +type ConversationRow = { + id: string; + userId: string | null; + guestId: string | null; + guestName: string | null; + guestEmail: string | null; + guestPhone: string | null; + passengerId: string | null; + passengerName: string | null; + subject: string | null; + status: PrismaStatus; + assignedAgentId: string | null; + lastMessageAt: Date | null; + lastMessagePreview: string | null; + lastMessageSender: PrismaSender | null; + userLastReadAt: Date | null; + agentLastReadAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; @Injectable() export class SupportService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private gateway: SupportGateway, + ) {} - getFaqCategories() { return this.prisma.faqCategory.findMany({ include: { _count: { select: { articles: true } } } }); } + // ---- FAQ (unchanged) --------------------------------------------------- - getFaqArticles(categoryId: string) { return this.prisma.faqArticle.findMany({ where: { categoryId }, orderBy: { rank: 'asc' } }); } - - startConversation(userId: string) { return this.prisma.supportConversation.create({ data: { userId } }); } - - async sendMessage(conversationId: string, sender: 'USER' | 'BOT' | 'AGENT', text: string) { - const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId } }); - if (!conv) throw new NotFoundException('Conversation not found'); - const message = await this.prisma.supportMessage.create({ data: { conversationId, sender, text } }); - if (sender === 'USER') await this.prisma.supportMessage.create({ data: { conversationId, sender: 'BOT', text: this.getBotReply(text) } }); - return message; + getFaqCategories() { + return this.prisma.faqCategory.findMany({ + include: { _count: { select: { articles: true } } }, + }); } - async getConversation(conversationId: string) { - const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId }, include: { messages: { orderBy: { createdAt: 'asc' } } } }); - if (!conv) throw new NotFoundException('Conversation not found'); - return conv; + getFaqArticles(categoryId: string) { + return this.prisma.faqArticle.findMany({ + where: { categoryId }, + orderBy: { rank: 'asc' }, + }); } - private getBotReply(text: string): string { - const lower = text.toLowerCase(); - if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to Bookings and select the booking. Refunds are processed within 3-5 business days.'; - if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.'; - if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.'; - return 'Thank you for contacting EDR support. An agent will assist you shortly.'; + // ---- customer: authed passenger --------------------------------------- + + async createConversation( + iamUserId: string, + input: { subject: string; initialMessage: string }, + ): Promise { + const passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId }, + include: { user: { select: { fullName: true } } }, + }); + const conversation = (await this.prisma.supportConversation.create({ + data: { + userId: iamUserId, + passengerId: passenger?.id ?? null, + passengerName: passenger?.user?.fullName ?? null, + subject: input.subject, + status: 'OPEN', + }, + })) as ConversationRow; + return this.firstMessage(conversation, input.initialMessage); + } + + // ---- customer: guest (unauthenticated) -------------------------------- + + async createGuestConversation(input: { + guestId: string; + name: string; + email: string; + phone?: string; + subject: string; + initialMessage: string; + }): Promise { + const conversation = (await this.prisma.supportConversation.create({ + data: { + guestId: input.guestId, + guestName: input.name, + guestEmail: input.email, + guestPhone: input.phone ?? null, + passengerName: input.name, // uniform display name for the agent inbox + subject: input.subject, + status: 'OPEN', + }, + })) as ConversationRow; + return this.firstMessage(conversation, input.initialMessage); + } + + async listForCustomer( + owner: CustomerOwner, + query: ListQuery, + ): Promise { + const scope = this.ownerScope(owner); + const where = { ...this.listWhere(query), ...scope }; + const rows = (await this.prisma.supportConversation.findMany({ + where, + orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }], + take: query.limit ?? 100, + skip: ((query.page ?? 1) - 1) * (query.limit ?? 100), + })) as ConversationRow[]; + const count = await this.prisma.supportConversation.count({ where }); + return this.buildListResult(rows, count, 'USER'); + } + + // ---- agent (backoffice) ------------------------------------------------ + + async listForAgents( + query: ListQuery, + ): Promise { + const where = this.listWhere(query); + const rows = (await this.prisma.supportConversation.findMany({ + where, + orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }], + take: query.limit ?? 100, + skip: ((query.page ?? 1) - 1) * (query.limit ?? 100), + })) as ConversationRow[]; + const count = await this.prisma.supportConversation.count({ where }); + return this.buildListResult(rows, count, 'AGENT'); + } + + async setStatus( + conversationId: string, + status: PrismaStatus, + ): Promise { + await this.requireConversation(conversationId); + const updated = (await this.prisma.supportConversation.update({ + where: { id: conversationId }, + data: { status }, + })) as ConversationRow; + const dto = this.toConversationDto(updated, 0); + this.gateway.emitConversationUpdated(this.ownerRoom(updated), dto); + return dto; + } + + // ---- shared ------------------------------------------------------------ + + async getMessages( + conversationId: string, + asCustomer?: CustomerOwner, + ): Promise { + const conversation = await this.requireConversation(conversationId); + if (asCustomer) this.assertOwns(conversation, asCustomer); + const rows = await this.prisma.supportMessage.findMany({ + where: { conversationId }, + orderBy: { createdAt: 'asc' }, + }); + return rows.map((m) => this.toMessageDto(m)); + } + + async sendMessage( + conversationId: string, + sender: Side, + text: string, + asCustomer?: CustomerOwner, + ): Promise { + const conversation = await this.requireConversation(conversationId); + if (sender === 'USER') { + this.assertOwns(conversation, asCustomer ?? {}); + } + const updated = await this.appendMessage(conversation, sender, text); + const last = updated.messages[updated.messages.length - 1]; + return this.toMessageDto(last); + } + + async markRead( + conversationId: string, + side: Side, + asCustomer?: CustomerOwner, + ): Promise<{ unreadCount: number }> { + const conversation = await this.requireConversation(conversationId); + if (side === 'USER') { + this.assertOwns(conversation, asCustomer ?? {}); + await this.prisma.supportConversation.update({ + where: { id: conversationId }, + data: { userLastReadAt: new Date() }, + }); + return this.unreadCount('USER', asCustomer); + } + await this.prisma.supportConversation.update({ + where: { id: conversationId }, + data: { agentLastReadAt: new Date() }, + }); + return this.unreadCount('AGENT'); + } + + async unreadCount( + side: Side, + owner?: CustomerOwner, + ): Promise<{ unreadCount: number }> { + const rows = (await this.prisma.supportConversation.findMany({ + where: side === 'USER' ? this.ownerScope(owner ?? {}) : {}, + select: { id: true, userLastReadAt: true, agentLastReadAt: true }, + })) as Array<{ + id: string; + userLastReadAt: Date | null; + agentLastReadAt: Date | null; + }>; + const map = await this.computeUnread(rows, side); + let unreadCount = 0; + for (const n of map.values()) if (n > 0) unreadCount++; + return { unreadCount }; + } + + // ---- internals --------------------------------------------------------- + + private async firstMessage( + conversation: ConversationRow, + text: string, + ): Promise { + const { conversation: updated } = await this.appendMessageRaw( + conversation, + 'USER', + text, + ); + return this.toConversationDto(updated, 0); + } + + private async appendMessage( + conversation: ConversationRow, + sender: PrismaSender, + text: string, + ) { + const { conversation: updated } = await this.appendMessageRaw( + conversation, + sender, + text, + ); + return updated as ConversationRow & { messages: any[] }; + } + + /** Persist a message, bump the conversation's denormalized fields, emit live. */ + private async appendMessageRaw( + conversation: ConversationRow, + sender: PrismaSender, + text: string, + ): Promise<{ conversation: ConversationRow & { messages: any[] }; message: any }> { + const message = await this.prisma.supportMessage.create({ + data: { conversationId: conversation.id, sender, text }, + }); + const updated = (await this.prisma.supportConversation.update({ + where: { id: conversation.id }, + data: { + lastMessageAt: message.createdAt, + lastMessagePreview: text.slice(0, 280), + lastMessageSender: sender, + }, + include: { messages: { orderBy: { createdAt: 'asc' } } }, + })) as ConversationRow & { messages: any[] }; + + const dto = this.toConversationDto(updated, 0); + this.gateway.emitMessage(this.ownerRoom(updated), dto, this.toMessageDto(message)); + return { conversation: updated, message }; + } + + private async buildListResult( + rows: ConversationRow[], + count: number, + side: Side, + ): Promise { + const unreadMap = await this.computeUnread(rows, side); + const items = rows.map((r) => + this.toConversationDto(r, unreadMap.get(r.id) ?? 0), + ); + let unreadCount = 0; + for (const n of unreadMap.values()) if (n > 0) unreadCount++; + return { items, count, unreadCount }; + } + + private async computeUnread( + rows: Array<{ + id: string; + userLastReadAt: Date | null; + agentLastReadAt: Date | null; + }>, + side: Side, + ): Promise> { + const map = new Map(); + if (rows.length === 0) return map; + const otherSender: PrismaSender = side === 'USER' ? 'AGENT' : 'USER'; + const ids = rows.map((r) => r.id); + const msgs = await this.prisma.supportMessage.findMany({ + where: { conversationId: { in: ids }, sender: otherSender }, + select: { conversationId: true, createdAt: true }, + }); + const cursorById = new Map( + rows.map((r) => [ + r.id, + side === 'USER' ? r.userLastReadAt : r.agentLastReadAt, + ]), + ); + for (const m of msgs) { + const cursor = cursorById.get(m.conversationId) ?? null; + if (!cursor || m.createdAt > cursor) { + map.set(m.conversationId, (map.get(m.conversationId) ?? 0) + 1); + } + } + return map; + } + + private listWhere(query: ListQuery) { + const where: Record = {}; + if (query.status) where.status = query.status; + if (query.search?.trim()) { + const contains = query.search.trim(); + where.OR = [ + { subject: { contains, mode: 'insensitive' } }, + { passengerName: { contains, mode: 'insensitive' } }, + { guestEmail: { contains, mode: 'insensitive' } }, + ]; + } + return where; + } + + /** Prisma where-fragment scoping to the calling customer (authed or guest). */ + private ownerScope(owner: CustomerOwner): Record { + if (owner.iamUserId) return { userId: owner.iamUserId }; + if (owner.guestId) return { guestId: owner.guestId }; + // No identity ⇒ match nothing. + return { id: '__none__' }; + } + + private assertOwns(conversation: ConversationRow, owner: CustomerOwner): void { + const ok = + (owner.iamUserId && conversation.userId === owner.iamUserId) || + (owner.guestId && conversation.guestId === owner.guestId); + if (!ok) { + throw new ForbiddenException('This conversation belongs to someone else.'); + } + } + + private ownerRoom(c: ConversationRow): string | null { + if (c.guestId) return `guest:${c.guestId}`; + if (c.userId) return `user:${c.userId}`; + return null; + } + + private async requireConversation(id: string): Promise { + const conversation = (await this.prisma.supportConversation.findUnique({ + where: { id }, + })) as ConversationRow | null; + if (!conversation) throw new NotFoundException('Conversation not found'); + return conversation; + } + + private toConversationDto( + c: ConversationRow, + unreadCount: number, + ): T.PassengerSupportConversationDto { + return { + id: c.id, + userId: c.userId, + guestId: c.guestId, + guestEmail: c.guestEmail, + guestPhone: c.guestPhone, + passengerId: c.passengerId, + passengerName: c.passengerName ?? c.guestName ?? null, + subject: c.subject, + status: c.status as T.PassengerSupportStatus, + assignedAgentId: c.assignedAgentId, + lastMessageAt: c.lastMessageAt ? c.lastMessageAt.toISOString() : null, + lastMessagePreview: c.lastMessagePreview, + lastMessageSender: this.toDtoSender(c.lastMessageSender), + unreadCount, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + }; + } + + private toMessageDto(m: { + id: string; + conversationId: string; + sender: PrismaSender; + text: string; + createdAt: Date; + }): T.PassengerSupportMessageDto { + return { + id: m.id, + conversationId: m.conversationId, + sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT, + text: m.text, + createdAt: m.createdAt.toISOString(), + }; + } + + /** Legacy BOT messages are surfaced as AGENT to the UI. */ + private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null { + if (!s) return null; + return s === 'USER' + ? T.PassengerSupportSender.USER + : T.PassengerSupportSender.AGENT; } } diff --git a/apps/edr-passenger-api/src/modules/support/ws-auth.service.ts b/apps/edr-passenger-api/src/modules/support/ws-auth.service.ts new file mode 100644 index 000000000..2b1ad8b6b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/ws-auth.service.ts @@ -0,0 +1,48 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { verifyToken } from '@tria-plc/api-common/utils/token'; +import { ESessionStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; + +/** + * Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access + * token payload is only a *session* pointer (`{ id: }`), not the + * user — so we verify the signature (`verifyToken`), then load the IAM session + * and require it to be ACTIVE and unexpired, and read the real user id out of + * `session.userInfo`. The `Session` entity is served by the app's default + * TypeORM DataSource (the same one the shared JwtGuard queries for `iam.sessions`). + * + * Returns the IAM user id, or null for any invalid/expired/revoked/malformed token. + */ +@Injectable() +export class WsAuthService { + private readonly logger = new Logger(WsAuthService.name); + + constructor( + @InjectRepository(Session) + private readonly sessions: Repository, + ) {} + + async resolveUserId(token?: string): Promise { + if (!token) return null; + try { + const payload = verifyToken(token) as { id?: string }; + const sessionId = payload?.id; + if (!sessionId) return null; + + const session = await this.sessions.findOne({ where: { id: sessionId } }); + if (!session) return null; + if (session.status !== ESessionStatus.ACTIVE) return null; + if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) { + return null; + } + + return session.userInfo?.id ?? null; + } catch (err) { + this.logger.debug(`WS auth rejected: ${(err as Error).message}`); + return null; + } + } +} diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index e482189d2..4a1f7b36e 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -258,8 +258,8 @@ export class TicketsService { type: booking.bookingType, passenger: passengerName, seats: passengerSeats.map(ps => ({ - seat: ps.seat.seatNumber, - coach: ps.seat.coach.number, + seat: ps.seat?.seatNumber, + coach: ps.seat?.coach?.number, leg: ps.leg || 1, scheduleId: ps.scheduleId || booking.scheduleId, })), diff --git a/apps/edr-passenger-web/backoffice/package.json b/apps/edr-passenger-web/backoffice/package.json index 654e19ef9..858fcd781 100644 --- a/apps/edr-passenger-web/backoffice/package.json +++ b/apps/edr-passenger-web/backoffice/package.json @@ -21,6 +21,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "recharts": "^2.12.0", + "socket.io-client": "^4.8.3", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index be2785d43..1642f0a14 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Download, Eye, Trash2 } from 'lucide-react'; +import { Download, Eye, Trash2, Ticket } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; @@ -35,6 +35,8 @@ function BookingsPageContent() { const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' }); const [showExtraFilters, setShowExtraFilters] = useState(false); const [selectedBooking, setSelectedBooking] = useState(null); + const [generateTicketBooking, setGenerateTicketBooking] = useState(null); + const [generateTicketForm, setGenerateTicketForm] = useState({ paymentReference: '', paymentMethod: '', notes: '' }); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); const [deleteError, setDeleteError] = useState(null); @@ -63,6 +65,19 @@ function BookingsPageContent() { }), }); + const forceConfirmMutation = useMutation({ + mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) => + bookingsApi.forceConfirm(bookingId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bookings'] }); + setSuccessMessage('Payment confirmed and ticket generated successfully'); + setTimeout(() => setSuccessMessage(''), 4000); + setSelectedBooking(null); + setGenerateTicketBooking(null); + setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); + }, + }); + const deleteMutation = useMutation({ mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/bookings/${id}${cascade ? '?cascade=true' : ''}`), onSuccess: () => { @@ -258,6 +273,7 @@ function BookingsPageContent() { const actions = [ { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, + { label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') }, { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, ]; @@ -448,6 +464,25 @@ function BookingsPageContent() { + {b.paymentIntent?.status !== 'SUCCEEDED' && canManage && ( +
+

+ Payment not confirmed by vendor. If you have verified the payment was completed externally, force-confirm to confirm the booking and generate the ticket. +

+ forceConfirmMutation.mutate({ bookingId: b.id, data: {} })} + disabled={forceConfirmMutation.isPending} + > + {forceConfirmMutation.isPending ? 'Confirming…' : 'Force Confirm & Generate Ticket'} + + {forceConfirmMutation.isError && ( +

+ {(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()} +

+ )} +
+ )} {/* Seats / Passengers */} @@ -517,6 +552,93 @@ function BookingsPageContent() { onCascadeChange={(checked) => setDeleteCascadeChecked(checked)} /> + {/* Generate Ticket Modal */} + { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); forceConfirmMutation.reset(); }} + title="Generate Ticket" + size="md" + > + {generateTicketBooking && ( +
+
+

Booking

+

{generateTicketBooking.bookingRef}

+

+ {generateTicketBooking.schedule?.originStation?.name} → {generateTicketBooking.schedule?.destinationStation?.name} +

+
+ +
+ + setGenerateTicketForm(f => ({ ...f, paymentReference: e.target.value }))} + /> +
+ +
+ + +
+ +
+ +