Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-07 22:58:11 +03:00
32 changed files with 2736 additions and 297 deletions

View File

@@ -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",

View File

@@ -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;

View File

@@ -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");

View File

@@ -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")
}

View File

@@ -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(

View File

@@ -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');
}
}

View File

@@ -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;
}

View File

@@ -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:<iamUserId>` 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<void> {
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;
}
}

View File

@@ -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 {}

View File

@@ -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<T.PassengerSupportConversationDto> {
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<T.PassengerSupportConversationDto> {
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<T.PassengerSupportConversationListResult> {
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<T.PassengerSupportConversationListResult> {
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<T.PassengerSupportConversationDto> {
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<T.PassengerSupportMessageDto[]> {
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<T.PassengerSupportMessageDto> {
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<T.PassengerSupportConversationDto> {
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<T.PassengerSupportConversationListResult> {
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<Map<string, number>> {
const map = new Map<string, number>();
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<string, unknown> = {};
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<string, unknown> {
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<ConversationRow> {
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;
}
}

View File

@@ -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: <sessionId> }`), 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<Session>,
) {}
async resolveUserId(token?: string): Promise<string | null> {
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;
}
}
}

View File

@@ -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": {

View File

@@ -1,66 +1,362 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { supportApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { Passenger } from '@edr/types';
import { Headset, Search, Send, User } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useConversations,
useMarkRead,
useMessages,
useSendMessage,
useSetStatus,
} from '@/features/support/useSupport';
import { useSupportSocket } from '@/features/support/useSupportSocket';
const GREEN = 'rgb(20 113 76)';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
const STATUS_CLASS: Record<string, string> = {
OPEN: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
RESOLVED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
CLOSED: 'bg-gray-200 text-gray-600 dark:bg-slate-700 dark:text-slate-300',
};
const FILTERS = ['ALL', 'OPEN', 'RESOLVED', 'CLOSED'] as const;
function formatTime(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
return d.toDateString() === now.toDateString()
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
export default function SupportPage() {
const [filters, setFilters] = useState({ search: '', status: '' });
const [status, setStatus] = useState<(typeof FILTERS)[number]>('ALL');
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['support', filters],
queryFn: () => supportApi.getConversations(filters),
});
const { data, isLoading } = useConversations(
status === 'ALL' ? { search } : { status, search },
);
const items = data?.items ?? [];
const columns = [
{ key: 'subject', label: 'Subject', render: (conv: any) => conv.subject || 'No Subject' },
{ key: 'passenger', label: 'Passenger', render: (conv: any) => conv.passenger?.fullName || 'N/A' },
{ key: 'status', label: 'Status', render: (conv: any) => <Badge variant="status" status={conv.status}>{conv.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (conv: any) => formatDateTime(conv.createdAt) },
];
useSupportSocket(true);
const selected = useMemo(
() => items.find((c) => c.id === selectedId) ?? null,
[items, selectedId],
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<span
className="flex h-10 w-10 items-center justify-center rounded-lg"
style={{ background: 'rgba(20,113,76,0.1)', color: GREEN }}
>
<Headset size={20} />
</span>
<div>
<h1 className="text-2xl font-bold text-foreground">Support Center</h1>
<p className="text-muted-foreground">Manage customer support conversations</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="OPEN">Open</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="RESOLVED">Resolved</option>
<option value="CLOSED">Closed</option>
</select>
</div>
<p className="text-sm text-muted-foreground">
Shared inbox respond to passenger requests in real time
</p>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No support center found"
/>
<div className="flex h-[calc(100vh-220px)] overflow-hidden rounded-xl border border-border bg-card">
{/* Conversation list */}
<div className="flex w-[340px] shrink-0 flex-col border-r border-border">
<div className="space-y-2 border-b border-border p-3">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-2.5 text-muted-foreground"
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search subject or passenger"
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm outline-none focus:border-emerald-500"
/>
</div>
<div className="flex gap-1">
{FILTERS.map((f) => (
<button
key={f}
onClick={() => setStatus(f)}
className={`flex-1 rounded-md px-2 py-1 text-xs font-medium capitalize transition ${
status === f
? 'text-white'
: 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
style={status === f ? { background: GREEN } : undefined}
>
{f.toLowerCase()}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
Loading
</div>
) : items.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
No conversations.
</div>
) : (
items.map((c) => (
<InboxRow
key={c.id}
c={c}
active={c.id === selectedId}
onClick={() => setSelectedId(c.id)}
/>
))
)}
</div>
</div>
{/* Thread */}
<div className="min-w-0 flex-1">
{selected ? (
<ConversationThread conversation={selected} />
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<span
className="flex h-14 w-14 items-center justify-center rounded-full"
style={{ background: 'rgba(20,113,76,0.1)', color: GREEN }}
>
<Headset size={28} />
</span>
<p className="text-sm">Select a conversation to start replying.</p>
</div>
)}
</div>
</div>
</div>
);
}
function InboxRow({
c,
active,
onClick,
}: {
c: ConversationDto;
active: boolean;
onClick: () => void;
}) {
const unread = c.unreadCount > 0;
return (
<button
onClick={onClick}
className={`block w-full border-b border-border px-4 py-3 text-left transition hover:bg-muted/50 ${
active ? 'bg-muted/70' : ''
}`}
style={active ? { borderLeft: `3px solid ${GREEN}` } : { borderLeft: '3px solid transparent' }}
>
<div className="flex items-center justify-between gap-2">
<span
className={`truncate text-sm ${
unread ? 'font-bold text-foreground' : 'font-semibold text-foreground/90'
}`}
>
{c.subject || 'Support request'}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatTime(c.lastMessageAt)}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{c.passengerName || 'Passenger'}
</p>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="truncate text-xs text-muted-foreground">
{c.lastMessageSender === 'AGENT' ? 'You: ' : ''}
{c.lastMessagePreview ?? '—'}
</span>
{unread ? (
<span
className="flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold text-white"
style={{ background: GREEN }}
>
{c.unreadCount}
</span>
) : (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${
STATUS_CLASS[c.status] ?? ''
}`}
>
{c.status.toLowerCase()}
</span>
)}
</div>
</button>
);
}
function ConversationThread({ conversation }: { conversation: ConversationDto }) {
const { data: messages, isLoading } = useMessages(conversation.id);
const send = useSendMessage(conversation.id);
const setStatus = useSetStatus();
const markRead = useMarkRead();
const [draft, setDraft] = useState('');
const viewport = useRef<HTMLDivElement>(null);
useEffect(() => {
markRead.mutate(conversation.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversation.id, messages?.length]);
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [messages?.length, conversation.id]);
const submit = async () => {
const text = draft.trim();
if (!text) return;
setDraft('');
await send.mutateAsync(text);
};
const changeStatus = (status: string) =>
setStatus.mutate({ id: conversation.id, status });
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b border-border p-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-bold text-foreground">
{conversation.subject || 'Conversation'}
</span>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${
STATUS_CLASS[conversation.status] ?? ''
}`}
>
{conversation.status.toLowerCase()}
</span>
</div>
<p className="truncate text-xs text-muted-foreground">
{conversation.passengerName || 'Passenger'}
{conversation.guestId ? ' · Guest' : ''}
{conversation.guestEmail ? ` · ${conversation.guestEmail}` : ''}
{conversation.guestPhone ? ` · ${conversation.guestPhone}` : ''}
</p>
</div>
<div className="flex shrink-0 gap-2">
{conversation.status !== 'OPEN' && (
<button
onClick={() => changeStatus('OPEN')}
className="rounded-md px-3 py-1.5 text-xs font-medium text-white"
style={{ background: GREEN }}
>
Reopen
</button>
)}
{conversation.status === 'OPEN' && (
<button
onClick={() => changeStatus('RESOLVED')}
className="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Resolve
</button>
)}
{conversation.status !== 'CLOSED' && (
<button
onClick={() => changeStatus('CLOSED')}
className="rounded-md bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted/70"
>
Close
</button>
)}
</div>
</div>
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
Loading
</div>
) : (
(messages ?? []).map((m) => <AgentBubble key={m.id} m={m} />)
)}
</div>
<div className="border-t border-border p-3">
<div className="flex items-end gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder="Type your reply… (Enter to send, Shift+Enter for newline)"
className="max-h-28 flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-emerald-500"
/>
<button
onClick={submit}
disabled={!draft.trim() || send.isPending}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white transition hover:opacity-90 disabled:opacity-50"
style={{ background: GREEN }}
aria-label="Send"
>
<Send size={18} />
</button>
</div>
</div>
</div>
);
}
function AgentBubble({ m }: { m: MessageDto }) {
const mine = m.sender === 'AGENT';
return (
<div className={`flex ${mine ? 'justify-end' : 'justify-start'} items-end gap-2`}>
{!mine && (
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-muted-foreground">
<User size={14} />
</span>
)}
<div className="max-w-[70%]">
<p
className={`mb-0.5 text-xs text-muted-foreground ${
mine ? 'text-right' : 'text-left'
}`}
>
{mine ? m.authorName || 'You' : m.authorName || 'Passenger'}
</p>
<div
className={`whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm ${
mine
? 'rounded-br-sm text-white'
: 'rounded-bl-sm bg-muted text-foreground'
}`}
style={mine ? { background: GREEN } : undefined}
>
{m.text}
</div>
<p
className={`mt-0.5 text-[10px] text-muted-foreground ${
mine ? 'text-right' : 'text-left'
}`}
>
{formatTime(m.createdAt)}
</p>
</div>
</div>
);
}

View File

@@ -104,7 +104,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Customer Services',
items: [
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view },
// { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
{ name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
{ name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send },
]
},

View File

@@ -0,0 +1,36 @@
import type { Passenger } from '@edr/types';
import { apiClient } from '@/lib/api-client';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
type ListResult = Passenger.PassengerSupportConversationListResult;
export interface ListParams {
status?: string;
search?: string;
}
/** Passenger backoffice (agent) support-chat REST calls (client unwraps envelope). */
export const supportApi = {
listConversations: (params: ListParams = {}) =>
apiClient.get<ListResult>('/support/agent/conversations', { params }),
listMessages: (id: string) =>
apiClient.get<MessageDto[]>(`/support/agent/conversations/${id}/messages`),
sendMessage: (id: string, text: string) =>
apiClient.post<MessageDto>(
`/support/agent/conversations/${id}/messages`,
{ text },
),
setStatus: (id: string, status: string) =>
apiClient.patch<ConversationDto>(
`/support/agent/conversations/${id}/status`,
{ status },
),
markRead: (id: string) =>
apiClient.post<{ unreadCount: number }>(
`/support/agent/conversations/${id}/read`,
),
unreadCount: () =>
apiClient.get<{ unreadCount: number }>('/support/agent/unread-count'),
};

View File

@@ -0,0 +1,65 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { supportApi, type ListParams } from './supportApi';
export const SUPPORT_CONVERSATIONS_KEY = ['support', 'conversations'];
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
export const supportMessagesKey = (id: string) => ['support', 'messages', id];
export function useConversations(params: ListParams = {}) {
return useQuery({
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
queryFn: () => supportApi.listConversations(params),
});
}
export function useMessages(conversationId: string | null) {
return useQuery({
queryKey: supportMessagesKey(conversationId ?? ''),
queryFn: () => supportApi.listMessages(conversationId as string),
enabled: !!conversationId,
});
}
export function useUnreadCount(enabled = true) {
return useQuery({
queryKey: SUPPORT_UNREAD_KEY,
queryFn: () => supportApi.unreadCount(),
enabled,
refetchInterval: 60_000,
});
}
export function useSendMessage(conversationId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (text: string) => supportApi.sendMessage(conversationId, text),
onSuccess: () => {
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
},
});
}
export function useSetStatus() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
supportApi.setStatus(id, status),
onSuccess: () =>
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }),
});
}
export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => supportApi.markRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
},
});
}

View File

@@ -0,0 +1,67 @@
'use client';
import { Passenger } from '@edr/types';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { io } from 'socket.io-client';
import {
SUPPORT_CONVERSATIONS_KEY,
SUPPORT_UNREAD_KEY,
supportMessagesKey,
} from './useSupport';
const SOCKET_ORIGIN = String(
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
).replace(/\/api\/?$/, '');
/**
* Subscribes the signed-in agent to live support pushes for the whole shared
* inbox. Any new message / conversation change refreshes the thread, the inbox
* list, and the unread badge; `onMessage` fires for optional toasts.
*/
export function useSupportSocket(
enabled: boolean,
onMessage?: (event: Passenger.PassengerSupportMessageEvent) => void,
) {
const qc = useQueryClient();
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
if (!enabled || typeof window === 'undefined') return;
const token = localStorage.getItem('auth_token');
if (!token) return;
const socket = io(
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
{
auth: { token },
transports: ['websocket'],
withCredentials: true,
},
);
socket.on(
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
(event: Passenger.PassengerSupportMessageEvent) => {
qc.invalidateQueries({
queryKey: supportMessagesKey(event.message.conversationId),
});
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
},
);
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
});
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}

View File

@@ -28,6 +28,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.0",
"socket.io-client": "^4.8.3",
"zod": "^3.22.4",
"zustand": "^5.0.0"
},

View File

@@ -22,6 +22,7 @@ import {
} from 'lucide-react';
import { format } from 'date-fns';
import { formatTime, getTimePeriod } from '@/utils/format';
import { formatFare } from '@/utils/fare-utils';
import { markManageBookingPaymentReturn, consumeManageBookingPaymentReturn } from '@/utils/manage-booking-return';
import QRCode from 'qrcode.react';
@@ -225,6 +226,34 @@ function BookingDetailContent() {
);
};
// /bookings/:ref returns one row per passenger PER LEG for round trips (leg 1 =
// outbound, leg 2 = return) — /booking/payment's fare breakdown, by contrast, shows one
// combined row per passenger with an Outbound/Return sub-split. Group leg rows back
// together here so both pages present the same per-passenger total, not a doubled list
// of half-fare rows.
const isRoundTripBooking = booking.bookingType === 'ROUND_TRIP';
const farePassengers = (() => {
const rows: any[] = booking.passengers || [];
if (!isRoundTripBooking) {
return rows.map((p) => ({ fullName: p.fullName, category: p.category, fareMinor: p.fareMinor ?? 0 }));
}
const grouped = new Map<string, { fullName: string; category: string; outboundFareMinor: number; returnFareMinor: number }>();
rows.forEach((p) => {
const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundFareMinor: 0, returnFareMinor: 0 };
if (p.leg === 2) entry.returnFareMinor = p.fareMinor ?? 0;
else entry.outboundFareMinor = p.fareMinor ?? 0;
grouped.set(key, entry);
});
return Array.from(grouped.values()).map((p) => ({
fullName: p.fullName,
category: p.category,
fareMinor: p.outboundFareMinor + p.returnFareMinor,
outboundFareMinor: p.outboundFareMinor,
returnFareMinor: p.returnFareMinor,
}));
})();
// Order summary card — mirrors /booking/payment's OrderSummary: fare breakdown per
// passenger, Total with a loading spinner while a currency conversion is in flight, and
// a note confirming what will actually be charged once a payment method is selected.
@@ -239,19 +268,39 @@ function BookingDetailContent() {
<div className="space-y-2">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
{(booking.passengers || []).map((passenger: any, idx: number) => (
<div key={idx} className="flex justify-between border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
{passenger.fullName || `Passenger ${idx + 1}`}
{passenger.category === 'CHILD' && (
<span className="text-xs font-semibold ml-1 text-blue-600">(CHILD)</span>
{farePassengers.map((passenger: any, idx: number) => {
const isChildPassenger = passenger.category === 'CHILD';
const isFreeChild = isChildPassenger && (passenger.fareMinor ?? 0) === 0;
return (
<div key={idx} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
<div className="flex justify-between mb-0.5">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[60%]">
{passenger.fullName || `Passenger ${idx + 1}`}
{isChildPassenger && (
<span className={`text-xs font-semibold ml-1 ${isFreeChild ? 'text-green-600' : 'text-blue-600'}`}>
({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'})
</span>
)}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{formatFare(passenger.fareMinor ?? 0, displayCurrency)}
</span>
</div>
{isRoundTripBooking && !isFreeChild && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span>Outbound</span>
<span>{formatFare(passenger.outboundFareMinor ?? 0, displayCurrency)}</span>
</div>
<div className="flex justify-between">
<span>Return</span>
<span>{formatFare(passenger.returnFareMinor ?? 0, displayCurrency)}</span>
</div>
</div>
)}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{displayCurrency} {((passenger.fareMinor ?? 0) / 100).toFixed(2)}
</span>
</div>
))}
</div>
);
})}
</div>
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700">

View File

@@ -308,6 +308,11 @@ export default function SeatsPage() {
// hold) another seat on top of it. Runs once per leg; the ref stops it from fighting a
// deliberate deselect/re-pick afterwards.
const restoredLegRef = useRef<string | null>(null);
// Indices whose current passengerSeatMap entry came from the restoration above, not a
// deliberate click this visit. A passenger's next click should be treated as their
// first real pick (no fare-change modal) even though the map already has an entry for
// them — only a click AFTER that (replacing their own real pick) is an actual change.
const restoredIndicesRef = useRef<Set<number>>(new Set());
useEffect(() => {
const legKey = `${currentSchedule?.id || ''}-${currentJourneyType}`;
if (restoredLegRef.current === legKey) return;
@@ -322,6 +327,7 @@ export default function SeatsPage() {
});
if (Object.keys(restored).length > 0) {
setPassengerSeatMap(restored);
restoredIndicesRef.current = new Set(Object.keys(restored).map(Number));
}
}, [currentSchedule?.id, currentJourneyType, isCurrentLegHoldValid, seatEligibleIndices, previouslyHeldSeatIds]);
@@ -471,6 +477,21 @@ export default function SeatsPage() {
setPendingCoachLabel(coach.label);
setShowCoachPreview(false);
// The user already confirmed this fare change in the coach-switch modal above — reset
// the per-leg baseline to the NEW coach type's fare so the very next seat pick is
// compared against it, not the stale pre-switch fare. Without this, picking any seat
// right after switching would immediately re-trigger the fare-change modal in
// handleSeatClick for a change the user already agreed to.
if (newFare != null) {
if (isRoundTrip && currentJourneyType === "inbound") {
originalFaresRef.current.inbound = newFare;
} else if (isRoundTrip) {
originalFaresRef.current.outbound = newFare;
} else {
originalFaresRef.current.oneWay = newFare;
}
}
// For package bookings, sync the stored tier price with the new coach type's fare
// so the review page totals reflect the switched coach type.
if (isPackageBooking && packageId && newFare != null) {
@@ -786,6 +807,8 @@ export default function SeatsPage() {
next[activePassengerIndex] = seatId;
}
setPassengerSeatMap(next);
// Whatever happens now is a deliberate pick — no longer just a restored hold.
restoredIndicesRef.current.delete(activePassengerIndex);
if (!isDeselecting) {
// Move on to the next passenger who still needs a seat — one passenger at a time
@@ -806,22 +829,30 @@ export default function SeatsPage() {
);
if (takenByOther) return;
const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId;
const currentSeatId = passengerSeatMap[activePassengerIndex];
// A seat restored from a still-valid hold (see the restore effect above) isn't a
// choice this passenger has made THIS visit — their next click is their first real
// pick, not a "change", even though the map already has an entry for them.
const isRestoredNotYetChosen = restoredIndicesRef.current.has(activePassengerIndex);
const isDeselecting = currentSeatId === seatId;
if (isDeselecting) {
restoredIndicesRef.current.delete(activePassengerIndex);
commitSeatAssignment(seatId);
return;
}
// Bed coaches price Upper/Middle/Lower differently, so picking a seat whose fare
// differs from what the user originally selected (e.g. after switching coach type
// via the preview, or just picking a pricier berth) — or from another passenger's
// already-selected seat — needs a heads-up before it's applied.
// A fare-change warning only makes sense when there's an actual prior selection to
// change FROM — either this same passenger swapping their own pick for a
// differently-priced one, or picking a seat priced differently from another
// passenger's already-selected seat this session. A passenger's very first pick has
// neither, so it must never trigger this modal, regardless of the coach type's
// "starting from" fare.
const newSeat = validSeats?.find((s: any) => s.id === seatId);
const newFare = newSeat ? getSeatFare(newSeat) : null;
if (newFare != null) {
let referenceFare: number | null = null;
let referenceLabel = "the fare you originally selected";
let referenceLabel = "your previously selected seat";
if (isPackageBooking) {
// For package bookings, compare against the stored tier price (per leg).
@@ -829,22 +860,28 @@ export default function SeatsPage() {
if (pkgLegFare != null && newFare !== pkgLegFare) {
referenceFare = pkgLegFare;
}
} else if (currentSeatId && !isRestoredNotYetChosen) {
// This passenger already has a different seat picked — compare against that
// actual, concrete selection.
const currentSeat = validSeats?.find((s: any) => s.id === currentSeatId);
const currentFare = currentSeat ? getSeatFare(currentSeat) : null;
if (currentFare != null && currentFare !== newFare) {
referenceFare = currentFare;
}
} else {
if (originalFareForCurrentLeg != null && originalFareForCurrentLeg !== newFare) {
referenceFare = originalFareForCurrentLeg;
} else {
const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => {
if (Number(idx) === activePassengerIndex) return false;
const otherSeat = validSeats?.find((s: any) => s.id === sid);
const otherFare = otherSeat ? getSeatFare(otherSeat) : null;
return otherFare != null && otherFare !== newFare;
});
// First pick for this passenger — only compare against another passenger's
// already-selected seat in this session.
const differingEntry = Object.entries(passengerSeatMap).find(([idx, sid]) => {
if (Number(idx) === activePassengerIndex) return false;
const otherSeat = validSeats?.find((s: any) => s.id === sid);
const otherFare = otherSeat ? getSeatFare(otherSeat) : null;
return otherFare != null && otherFare !== newFare;
});
if (differingEntry) {
const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]);
referenceFare = otherSeat ? getSeatFare(otherSeat) : null;
referenceLabel = "another already-selected seat";
}
if (differingEntry) {
const otherSeat = validSeats?.find((s: any) => s.id === differingEntry[1]);
referenceFare = otherSeat ? getSeatFare(otherSeat) : null;
referenceLabel = "another already-selected seat";
}
}
@@ -898,7 +935,7 @@ export default function SeatsPage() {
commitSeatAssignment(seatId);
},
[passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, originalFareForCurrentLeg, commitSeatAssignment, isPackageBooking, packageTierPriceMinor, packageId, priceTierId, packageName, packageDepartureStationId, packageDepartureStationName, setPackageContext, isRoundTrip],
[passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, commitSeatAssignment, isPackageBooking, packageTierPriceMinor, packageId, priceTierId, packageName, packageDepartureStationId, packageDepartureStationName, setPackageContext, isRoundTrip],
);
const allSeatsAssigned =

View File

@@ -5,6 +5,7 @@ import { Providers } from './providers';
import AppHeader from '@/components/AppHeader';
import { Footer } from '@/components/Footer';
import { LoadingIndicator } from '@/components/LoadingIndicator';
import SupportWidget from '@/features/support/SupportWidget';
export const metadata: Metadata = {
title: 'EDR Passenger Portal - Book your train journey',
@@ -51,6 +52,7 @@ export default function RootLayout({
{children}
</main>
<Footer />
<SupportWidget />
</Providers>
</body>
</html>

View File

@@ -0,0 +1,445 @@
'use client';
import { Passenger } from '@edr/types';
import { ArrowLeft, Headset, Plus, Send, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useConversations,
useCreateConversation,
useMarkRead,
useMessages,
useSendMessage,
} from './useSupport';
const GREEN = 'rgb(20 113 76)';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
const STATUS_CLASS: Record<string, string> = {
OPEN: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
RESOLVED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
CLOSED: 'bg-gray-200 text-gray-600 dark:bg-slate-700 dark:text-slate-300',
};
function formatTime(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
return d.toDateString() === now.toDateString()
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
type View = { kind: 'list' } | { kind: 'new' } | { kind: 'thread'; id: string };
export function SupportPanel({
onClose,
isGuest = false,
}: {
onClose: () => void;
isGuest?: boolean;
}) {
const [view, setView] = useState<View>({ kind: 'list' });
return (
<div className="flex h-[560px] max-h-[calc(100vh-120px)] w-[min(384px,calc(100vw-32px))] flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
{view.kind === 'list' && (
<ConversationList
onClose={onClose}
onNew={() => setView({ kind: 'new' })}
onOpen={(id) => setView({ kind: 'thread', id })}
/>
)}
{view.kind === 'new' && (
<NewConversation
isGuest={isGuest}
onClose={onClose}
onBack={() => setView({ kind: 'list' })}
onCreated={(id) => setView({ kind: 'thread', id })}
/>
)}
{view.kind === 'thread' && (
<Thread
conversationId={view.id}
onClose={onClose}
onBack={() => setView({ kind: 'list' })}
/>
)}
</div>
);
}
function Header({
title,
subtitle,
onClose,
onBack,
}: {
title: string;
subtitle?: string;
onClose: () => void;
onBack?: () => void;
}) {
return (
<div
className="flex items-center justify-between gap-2 px-4 py-3 text-white"
style={{
background: `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))`,
}}
>
<div className="flex min-w-0 items-center gap-2">
{onBack ? (
<button
onClick={onBack}
className="rounded-full p-1 hover:bg-white/20"
aria-label="Back"
>
<ArrowLeft size={20} />
</button>
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
<Headset size={18} />
</span>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{title}</p>
{subtitle && (
<p className="truncate text-xs text-white/80">{subtitle}</p>
)}
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-1 hover:bg-white/20"
aria-label="Close"
>
<X size={20} />
</button>
</div>
);
}
function ConversationList({
onClose,
onNew,
onOpen,
}: {
onClose: () => void;
onNew: () => void;
onOpen: (id: string) => void;
}) {
const { data, isLoading } = useConversations();
const items = data?.items ?? [];
return (
<>
<Header
title="Support"
subtitle="We usually reply within a few minutes"
onClose={onClose}
/>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-6 py-12 text-center text-sm text-gray-500 dark:text-slate-400">
<span
className="flex h-12 w-12 items-center justify-center rounded-full"
style={{ background: 'rgba(20,113,76,0.1)', color: GREEN }}
>
<Headset size={24} />
</span>
No conversations yet. Start one and our team will help you out.
</div>
) : (
items.map((c) => (
<ConversationRow key={c.id} c={c} onClick={() => onOpen(c.id)} />
))
)}
</div>
<div className="border-t border-gray-100 p-3 dark:border-slate-700">
<button
onClick={onNew}
className="flex w-full items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
style={{ background: GREEN }}
>
<Plus size={16} /> New request
</button>
</div>
</>
);
}
function ConversationRow({
c,
onClick,
}: {
c: ConversationDto;
onClick: () => void;
}) {
const unread = c.unreadCount > 0;
return (
<button
onClick={onClick}
className={`block w-full border-b border-gray-100 px-4 py-3 text-left transition hover:bg-gray-50 dark:border-slate-800 dark:hover:bg-slate-800/60 ${
unread ? 'bg-emerald-50/60 dark:bg-emerald-900/10' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span
className={`truncate text-sm ${
unread
? 'font-bold text-gray-900 dark:text-white'
: 'font-semibold text-gray-800 dark:text-slate-200'
}`}
>
{c.subject || 'Support request'}
</span>
<span className="shrink-0 text-xs text-gray-400">
{formatTime(c.lastMessageAt)}
</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="truncate text-xs text-gray-500 dark:text-slate-400">
{c.lastMessageSender === 'AGENT' ? 'Support: ' : ''}
{c.lastMessagePreview ?? '—'}
</span>
{unread ? (
<span
className="flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold text-white"
style={{ background: GREEN }}
>
{c.unreadCount}
</span>
) : (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${
STATUS_CLASS[c.status] ?? ''
}`}
>
{c.status.toLowerCase()}
</span>
)}
</div>
</button>
);
}
function NewConversation({
isGuest,
onClose,
onBack,
onCreated,
}: {
isGuest: boolean;
onClose: () => void;
onBack: () => void;
onCreated: (id: string) => void;
}) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const create = useCreateConversation();
const guestValid =
!isGuest || (name.trim().length > 0 && /.+@.+\..+/.test(email.trim()));
const valid =
subject.trim().length >= 3 && message.trim().length > 0 && guestValid;
const submit = async () => {
if (!valid) return;
const conv = await create.mutateAsync({
subject: subject.trim(),
initialMessage: message.trim(),
...(isGuest ? { name: name.trim(), email: email.trim() } : {}),
});
onCreated(conv.id);
};
const inputClass =
'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white';
return (
<>
<Header title="New request" onClose={onClose} onBack={onBack} />
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
{isGuest && (
<>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
Your name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Full name"
className={inputClass}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={inputClass}
/>
</div>
</>
)}
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
Subject
</label>
<input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="e.g. Refund for booking EDR-1234"
className={inputClass}
/>
</div>
<div className="flex flex-1 flex-col">
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
How can we help?
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Describe your issue…"
className="min-h-[120px] flex-1 resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
/>
</div>
<button
onClick={submit}
disabled={!valid || create.isPending}
className="flex items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
style={{ background: GREEN }}
>
<Send size={16} /> {create.isPending ? 'Sending…' : 'Send request'}
</button>
</div>
</>
);
}
function Thread({
conversationId,
onClose,
onBack,
}: {
conversationId: string;
onClose: () => void;
onBack: () => void;
}) {
const { data: conversations } = useConversations();
const conversation = useMemo(
() => conversations?.items.find((c) => c.id === conversationId),
[conversations, conversationId],
);
const { data: messages, isLoading } = useMessages(conversationId);
const send = useSendMessage(conversationId);
const markRead = useMarkRead();
const [draft, setDraft] = useState('');
const viewport = useRef<HTMLDivElement>(null);
useEffect(() => {
if (conversationId) markRead.mutate(conversationId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId, messages?.length]);
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [messages?.length]);
const submit = async () => {
const text = draft.trim();
if (!text) return;
setDraft('');
await send.mutateAsync(text);
};
return (
<>
<Header
title={conversation?.subject || 'Conversation'}
subtitle={
conversation ? `Status: ${conversation.status.toLowerCase()}` : undefined
}
onClose={onClose}
onBack={onBack}
/>
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
{isLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
) : (
(messages ?? []).map((m) => <MessageBubble key={m.id} m={m} />)
)}
</div>
<div className="border-t border-gray-100 p-3 dark:border-slate-700">
<div className="flex items-end gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder="Type a message…"
className="max-h-24 flex-1 resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
/>
<button
onClick={submit}
disabled={!draft.trim() || send.isPending}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white transition hover:opacity-90 disabled:opacity-50"
style={{ background: GREEN }}
aria-label="Send"
>
<Send size={18} />
</button>
</div>
</div>
</>
);
}
function MessageBubble({ m }: { m: MessageDto }) {
const mine = m.sender === 'USER';
return (
<div className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
<div className="max-w-[78%]">
{!mine && (
<p className="mb-0.5 ml-1 text-xs text-gray-400">
{m.authorName || 'Support agent'}
</p>
)}
<div
className={`whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm ${
mine
? 'rounded-br-sm text-white'
: 'rounded-bl-sm bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-slate-100'
}`}
style={mine ? { background: GREEN } : undefined}
>
{m.text}
</div>
<p
className={`mt-0.5 text-[10px] text-gray-400 ${
mine ? 'text-right' : 'text-left'
}`}
>
{formatTime(m.createdAt)}
</p>
</div>
</div>
);
}
export default SupportPanel;

View File

@@ -0,0 +1,51 @@
'use client';
import { Headset, MessageCircle } from 'lucide-react';
import { useState } from 'react';
import { useAuthStore } from '@/lib/auth-store';
import { SupportPanel } from './SupportPanel';
import { useUnreadCount } from './useSupport';
import { useSupportSocket } from './useSupportSocket';
const GREEN = 'rgb(20 113 76)';
/**
* Floating passenger-support launcher, mounted in the app shell for everyone —
* authenticated passengers and guests (guests are scoped by a localStorage
* guestId). Live pushes keep the unread badge fresh.
*/
export function SupportWidget() {
const { isAuthenticated } = useAuthStore();
const [open, setOpen] = useState(false);
const { data } = useUnreadCount(true);
const unread = data?.unreadCount ?? 0;
// Authenticated users keep a live socket for background pushes; guests connect
// once they open the panel (avoids idle sockets for visitors who never chat).
useSupportSocket(isAuthenticated || open);
return (
<div className="fixed bottom-6 right-6 z-[100] flex flex-col items-end gap-3">
{open && <SupportPanel onClose={() => setOpen(false)} isGuest={!isAuthenticated} />}
{!open && (
<button
onClick={() => setOpen(true)}
aria-label="Open support chat"
className="relative flex h-14 w-14 items-center justify-center rounded-full text-white shadow-lg transition hover:scale-105"
style={{ background: GREEN, boxShadow: '0 8px 24px rgba(20,113,76,0.4)' }}
>
{unread > 0 ? <Headset size={26} /> : <MessageCircle size={26} />}
{unread > 0 && (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full border-2 border-white bg-red-500 px-1 text-xs font-bold text-white">
{unread > 9 ? '9+' : unread}
</span>
)}
</button>
)}
</div>
);
}
export default SupportWidget;

View File

@@ -0,0 +1,24 @@
'use client';
const GUEST_KEY = 'support_guest_id';
/** True when a real passenger auth token is present. */
export function isAuthed(): boolean {
if (typeof window === 'undefined') return false;
const t = localStorage.getItem('auth_token');
return !!t && t !== 'null' && t !== 'undefined';
}
/** Stable anonymous id for guest conversations (persisted in localStorage). */
export function getGuestId(): string {
if (typeof window === 'undefined') return '';
let id = localStorage.getItem(GUEST_KEY);
if (!id) {
id =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `guest-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
localStorage.setItem(GUEST_KEY, id);
}
return id;
}

View File

@@ -0,0 +1,75 @@
import type { Passenger } from '@edr/types';
import { apiClient } from '@/lib/api-client';
import { getGuestId, isAuthed } from './guestIdentity';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
type ListResult = Passenger.PassengerSupportConversationListResult;
export interface CreateInput {
subject: string;
initialMessage: string;
// Required only for guests:
name?: string;
email?: string;
phone?: string;
}
/**
* Passenger portal support-chat calls. Transparently uses the authenticated
* (`/support/...`) or guest (`/support/guest/...`) endpoints depending on whether
* a passenger auth token is present. Guests are scoped by a localStorage guestId.
*/
export const supportApi = {
listConversations: (): Promise<ListResult> =>
isAuthed()
? apiClient.get('/support/conversations')
: apiClient.get('/support/guest/conversations', {
params: { guestId: getGuestId() },
}),
createConversation: (input: CreateInput): Promise<ConversationDto> =>
isAuthed()
? apiClient.post('/support/conversations', {
subject: input.subject,
initialMessage: input.initialMessage,
})
: apiClient.post('/support/guest/conversations', {
guestId: getGuestId(),
name: input.name,
email: input.email,
phone: input.phone,
subject: input.subject,
initialMessage: input.initialMessage,
}),
listMessages: (id: string): Promise<MessageDto[]> =>
isAuthed()
? apiClient.get(`/support/conversations/${id}/messages`)
: apiClient.get(`/support/guest/conversations/${id}/messages`, {
params: { guestId: getGuestId() },
}),
sendMessage: (id: string, text: string): Promise<MessageDto> =>
isAuthed()
? apiClient.post(`/support/conversations/${id}/messages`, { text })
: apiClient.post(`/support/guest/conversations/${id}/messages`, {
guestId: getGuestId(),
text,
}),
markRead: (id: string): Promise<{ unreadCount: number }> =>
isAuthed()
? apiClient.post(`/support/conversations/${id}/read`)
: apiClient.post(`/support/guest/conversations/${id}/read`, {
guestId: getGuestId(),
}),
unreadCount: (): Promise<{ unreadCount: number }> =>
isAuthed()
? apiClient.get('/support/unread-count')
: apiClient.get('/support/guest/unread-count', {
params: { guestId: getGuestId() },
}),
};

View File

@@ -0,0 +1,65 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { supportApi, type CreateInput } from './supportApi';
export const SUPPORT_CONVERSATIONS_KEY = ['support', 'conversations'];
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
export const supportMessagesKey = (id: string) => ['support', 'messages', id];
export function useConversations(enabled = true) {
return useQuery({
queryKey: SUPPORT_CONVERSATIONS_KEY,
queryFn: () => supportApi.listConversations(),
enabled,
});
}
export function useMessages(conversationId: string | null) {
return useQuery({
queryKey: supportMessagesKey(conversationId ?? ''),
queryFn: () => supportApi.listMessages(conversationId as string),
enabled: !!conversationId,
});
}
export function useUnreadCount(enabled = true) {
return useQuery({
queryKey: SUPPORT_UNREAD_KEY,
queryFn: () => supportApi.unreadCount(),
enabled,
refetchInterval: 60_000,
});
}
export function useCreateConversation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateInput) => supportApi.createConversation(input),
onSuccess: () =>
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }),
});
}
export function useSendMessage(conversationId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (text: string) => supportApi.sendMessage(conversationId, text),
onSuccess: () => {
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
},
});
}
export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => supportApi.markRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
},
});
}

View File

@@ -0,0 +1,71 @@
'use client';
import { Passenger } from '@edr/types';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { io } from 'socket.io-client';
import { getGuestId, isAuthed } from './guestIdentity';
import {
SUPPORT_CONVERSATIONS_KEY,
SUPPORT_UNREAD_KEY,
supportMessagesKey,
} from './useSupport';
const SOCKET_ORIGIN = String(
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
).replace(/\/api\/?$/, '');
/**
* Subscribes the signed-in passenger to live support pushes. New messages
* refresh the affected thread + list + unread badge, and fire `onMessage`
* (the widget toasts when closed).
*/
export function useSupportSocket(
enabled: boolean,
onMessage?: (event: Passenger.PassengerSupportMessageEvent) => void,
) {
const qc = useQueryClient();
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
if (!enabled || typeof window === 'undefined') return;
// Authenticated passengers connect with their token; guests connect with
// their anonymous guestId so the gateway can join their `guest:<id>` room.
const auth = isAuthed()
? { token: localStorage.getItem('auth_token') }
: { guestId: getGuestId() };
const socket = io(
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
{
auth,
transports: ['websocket'],
withCredentials: true,
},
);
socket.on(
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
(event: Passenger.PassengerSupportMessageEvent) => {
qc.invalidateQueries({
queryKey: supportMessagesKey(event.message.conversationId),
});
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
},
);
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
});
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}

View File

@@ -1,6 +1,6 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000";
class ApiClient {
private client: AxiosInstance;
@@ -9,13 +9,16 @@ class ApiClient {
this.client = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
this.client.interceptors.request.use((config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (token && token !== 'null' && token !== 'undefined') {
const token =
typeof window !== "undefined"
? localStorage.getItem("auth_token")
: null;
if (token && token !== "null" && token !== "undefined") {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
@@ -25,22 +28,28 @@ class ApiClient {
// there just means the user canceled/closed the Fayda popup without completing it (no
// valid verification session) — that should surface as an inline error on the page,
// not force-clear the session and redirect to /login out from under them.
const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me', '/fayda/verification'];
const PUBLIC_PREFIXES = [
"/config/",
"/auth/login",
"/auth/register",
"/passengers/me",
"/fayda/verification",
];
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const url: string = error.config?.url || '';
const url: string = error.config?.url || "";
const isPublic = PUBLIC_PREFIXES.some((p) => url.includes(p));
if (!isPublic && typeof window !== 'undefined') {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
window.location.href = '/login';
if (!isPublic && typeof window !== "undefined") {
localStorage.removeItem("auth_token");
localStorage.removeItem("auth_user");
// window.location.href = '/login';
}
}
return Promise.reject(error);
}
},
);
}
@@ -50,17 +59,29 @@ class ApiClient {
return response.data?.data || response.data;
}
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async post<T>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.post<any>(url, data, config);
return response.data?.data || response.data;
}
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async put<T>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.put<T>(url, data, config);
return response.data;
}
async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async patch<T>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.patch<T>(url, data, config);
return response.data;
}

View File

@@ -1,5 +1,4 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import QRCode from 'qrcode';
interface ScheduleInfo {
@@ -30,12 +29,24 @@ interface PassengerVoucherData {
createdAt: string;
}
// ─── shared drawing helpers ───────────────────────────────────────────────────
// ─── palette ───────────────────────────────────────────────────────────────
// A restrained, mostly-neutral palette (ink / slate / hairline / surface) with the
// brand green reserved for the few elements that should draw the eye — the PNR,
// times, and the fare — rather than tinting large areas of the page.
const PRIMARY = [20, 113, 76] as const;
const DARK = [51, 51, 51] as const;
const MED = [102, 102, 102] as const;
const LIGHT = [200, 200, 200] as const;
const BRAND = [20, 113, 76] as const; // brand green — accents only
const BRAND_SOFT = [235, 245, 240] as const; // pale green tint for subtle fills
const INK = [24, 28, 33] as const; // headings, high-emphasis text
const BODY = [71, 85, 105] as const; // slate-600 — body text
const MUTED = [148, 163, 184] as const; // slate-400 — labels/captions
const HAIRLINE = [226, 232, 240] as const; // slate-200 — borders/dividers
const SURFACE = [250, 250, 251] as const; // near-white card fill
const SUCCESS = [21, 128, 61] as const; // green-700
const AMBER_TEXT = [146, 64, 14] as const; // amber-800
const AMBER_FILL = [255, 251, 235] as const; // amber-50
const AMBER_BORDER = [251, 191, 36] as const; // amber-400
const PAGE_MARGIN = 18;
// ─── QR code ───────────────────────────────────────────────────────────────
// Encodes everything a gate scanner needs to verify this specific ticket without
@@ -63,7 +74,7 @@ async function generateTicketQrDataUrl(data: PassengerVoucherData): Promise<stri
width: 240,
margin: 0,
errorCorrectionLevel: 'M',
color: { dark: '#0f172a', light: '#ffffff' },
color: { dark: '#181c21', light: '#ffffff' },
});
} catch (error) {
console.error('Failed to generate ticket QR code:', error);
@@ -71,11 +82,30 @@ async function generateTicketQrDataUrl(data: PassengerVoucherData): Promise<stri
}
}
// ─── shared drawing helpers ───────────────────────────────────────────────────
function label(doc: jsPDF, text: string, x: number, y: number, opts?: { align?: 'left' | 'right' | 'center'; color?: readonly [number, number, number] }): void {
doc.setFont('helvetica', 'normal');
doc.setFontSize(7.5);
const c = opts?.color ?? MUTED;
doc.setTextColor(c[0], c[1], c[2]);
doc.text(text.toUpperCase(), x, y, { align: opts?.align ?? 'left', charSpace: 0.3 });
}
function hairline(doc: jsPDF, x1: number, y: number, x2: number): void {
doc.setDrawColor(...HAIRLINE);
doc.setLineWidth(0.25);
doc.line(x1, y, x2, y);
}
// ─── header ────────────────────────────────────────────────────────────────
async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
const pageWidth = doc.internal.pageSize.getWidth();
const bandHeight = 24;
doc.setFillColor(...PRIMARY);
doc.rect(0, 0, pageWidth, 30, 'F');
doc.setFillColor(...BRAND);
doc.rect(0, 0, pageWidth, bandHeight, 'F');
try {
const logoImg = await fetch('/edr-logo.png');
@@ -87,206 +117,243 @@ async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
});
const img = new Image();
await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; });
const logoH = 18;
const logoH = 13;
const logoW = (img.width / img.height) * logoH;
doc.addImage(logoDataUrl, 'PNG', margin, 6, logoW, logoH);
const textX = margin + logoW + 5;
doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH);
doc.setTextColor(255, 255, 255);
doc.setFontSize(18); doc.setFont('helvetica', 'bold');
doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoW + 5, 14);
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text('Premium Travel Experience', margin + logoW + 5, 20);
doc.setFontSize(13); doc.setFont('helvetica', 'bold');
doc.text('ETHIO-DJIBOUTI RAILWAY', textX, bandHeight / 2 - 1);
doc.setFontSize(7.5); doc.setFont('helvetica', 'normal');
doc.setTextColor(230, 240, 236);
doc.text('E-TICKET · BOARDING VOUCHER', textX, bandHeight / 2 + 5, { charSpace: 0.4 });
} catch {
doc.setTextColor(255, 255, 255);
doc.setFontSize(22); doc.setFont('helvetica', 'bold');
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 13, { align: 'center' });
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text('Premium Travel Experience', pageWidth / 2, 20, { align: 'center' });
doc.setFontSize(15); doc.setFont('helvetica', 'bold');
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, bandHeight / 2 - 1, { align: 'center' });
doc.setFontSize(7.5); doc.setFont('helvetica', 'normal');
doc.setTextColor(230, 240, 236);
doc.text('E-TICKET · BOARDING VOUCHER', pageWidth / 2, bandHeight / 2 + 5, { align: 'center', charSpace: 0.4 });
}
return 40;
return bandHeight + 16;
}
function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: number): number {
const label = (status === 'TICKETED' || status === 'CONFIRMED') ? 'CONFIRMED' : status;
const color = (status === 'TICKETED' || status === 'CONFIRMED') ? [34, 197, 94] : [234, 179, 8];
// ─── status pill ───────────────────────────────────────────────────────────
function drawStatusPill(doc: jsPDF, status: string, x: number, y: number, align: 'left' | 'right' = 'right'): void {
const isConfirmed = status === 'TICKETED' || status === 'CONFIRMED';
const text = isConfirmed ? 'CONFIRMED' : status;
const color = isConfirmed ? SUCCESS : [180, 83, 9] as const;
doc.setFontSize(7.5); doc.setFont('helvetica', 'bold');
const textWidth = doc.getTextWidth(text.toUpperCase());
const padX = 3.5;
const pillH = 5.5;
const pillW = textWidth + padX * 2;
const pillX = align === 'right' ? x - pillW : x;
doc.setFillColor(color[0], color[1], color[2]);
doc.rect(pageWidth / 2 - 22, y - 4, 44, 8, 'F');
doc.roundedRect(pillX, y, pillW, pillH, pillH / 2, pillH / 2, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
doc.text(label, pageWidth / 2, y + 1, { align: 'center' });
return y + 12;
doc.text(text.toUpperCase(), pillX + pillW / 2, y + pillH / 2 + 1.4, { align: 'center', charSpace: 0.3 });
}
function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number {
const boxHeight = 32;
const qrSize = 24;
const qrPad = 3;
const qrBlockWidth = qrDataUrl ? qrSize + qrPad * 2 + 5 : 0;
// ─── hero card: PNR + ticket number + QR ──────────────────────────────────
doc.setFillColor(245, 245, 245);
doc.roundedRect(margin, y, pageWidth - margin * 2, boxHeight, 2, 2, 'F');
function drawTicketHero(doc: jsPDF, bookingRef: string, ticketNumber: string, status: string, qrDataUrl: string | null, y: number, margin: number, pageWidth: number): number {
const cardH = 32;
const qrSize = 22;
const qrPad = 2.5;
const cardSize = qrSize + qrPad * 2;
// Booking reference (top-left)
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.text('BOOKING REFERENCE', margin + 5, y + 8);
doc.setTextColor(...PRIMARY); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
doc.text(bookingRef, margin + 5, y + 18);
doc.setFillColor(...SURFACE);
doc.setDrawColor(...HAIRLINE);
doc.setLineWidth(0.3);
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'FD');
// Ticket number, stacked below — leaves room on the right for the QR block
const textRightBound = pageWidth - margin - qrBlockWidth - 5;
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.text('TICKET NUMBER', textRightBound, y + 8, { align: 'right' });
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
doc.text(ticketNumber, textRightBound, y + 16, { align: 'right' });
const padX = 7;
drawStatusPill(doc, status, pageWidth - margin - padX, y + 5.5, 'right');
label(doc, 'Booking reference', margin + padX, y + 12);
doc.setTextColor(...INK); doc.setFontSize(21); doc.setFont('helvetica', 'bold');
doc.text(bookingRef, margin + padX, y + 23, { charSpace: 0.6 });
label(doc, 'Ticket no.', margin + padX, y + 28.5);
doc.setTextColor(...BODY); doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text(ticketNumber, margin + padX + 22, y + 28.7);
// QR code — clean white card with a thin border, right-aligned in the box
if (qrDataUrl) {
const cardSize = qrSize + qrPad * 2;
const cardX = pageWidth - margin - cardSize - 3;
const cardY = y + (boxHeight - cardSize) / 2;
const cardY = y + (cardH - cardSize) / 2;
doc.setFillColor(255, 255, 255);
doc.setDrawColor(...LIGHT);
doc.setLineWidth(0.4);
doc.setDrawColor(...HAIRLINE);
doc.setLineWidth(0.3);
doc.roundedRect(cardX, cardY, cardSize, cardSize, 2, 2, 'FD');
doc.addImage(qrDataUrl, 'PNG', cardX + qrPad, cardY + qrPad, qrSize, qrSize);
}
return y + boxHeight + 6;
return y + cardH + 10;
}
function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number {
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
doc.text(label ? `JOURNEY DETAILS — ${label.toUpperCase()}` : 'JOURNEY DETAILS', margin, y);
y += 7;
// ─── journey card ──────────────────────────────────────────────────────────
doc.setDrawColor(...LIGHT); doc.setLineWidth(0.5);
doc.rect(margin, y, pageWidth - margin * 2, 40);
function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, legLabel: string | null, y: number, margin: number, pageWidth: number): number {
const cardW = pageWidth - margin * 2;
const routeH = 30;
const trainRowH = 9;
const cardH = routeH + trainRowH;
// Origin
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('FROM', margin + 5, y + 6);
doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.text(schedule.origin.code, margin + 5, y + 14);
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text(schedule.origin.name, margin + 5, y + 20);
doc.setFontSize(8); doc.setTextColor(...MED);
doc.text(schedule.origin.city, margin + 5, y + 25);
doc.setDrawColor(...HAIRLINE);
doc.setLineWidth(0.3);
doc.roundedRect(margin, y, cardW, cardH, 3, 3, 'D');
const dep = new Date(schedule.departureAt);
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + 5, y + 33);
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38);
// Arrow
doc.setDrawColor(...PRIMARY); doc.setLineWidth(0.8);
const ax = pageWidth / 2, ay = y + 20;
doc.line(ax - 10, ay, ax + 10, ay);
doc.line(ax + 10, ay, ax + 7, ay - 2);
doc.line(ax + 10, ay, ax + 7, ay + 2);
// Destination
const dx = pageWidth - margin - 50;
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('TO', dx, y + 6);
doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.text(schedule.destination.code, dx, y + 14);
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
doc.text(schedule.destination.name, dx, y + 20);
doc.setFontSize(8); doc.setTextColor(...MED);
doc.text(schedule.destination.city, dx, y + 25);
const arr = new Date(schedule.arrivalAt);
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), dx, y + 33);
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38);
y += 47;
// Train info bar
doc.setFillColor(248, 248, 248);
doc.rect(margin, y, pageWidth - margin * 2, 12, 'F');
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('TRAIN', margin + 5, y + 5);
doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.text(schedule.trainNumber + (schedule.trainName ? `${schedule.trainName}` : ''), margin + 20, y + 9);
if (schedule.seatClass) {
doc.setFont('helvetica', 'normal'); doc.setTextColor(...MED);
doc.text(schedule.seatClass, pageWidth - margin - 5, y + 9, { align: 'right' });
if (legLabel) {
doc.setFillColor(...BRAND);
doc.roundedRect(margin + 6, y - 3, doc.getTextWidth(legLabel.toUpperCase()) + 7, 6, 3, 3, 'F');
doc.setTextColor(255, 255, 255); doc.setFontSize(7.5); doc.setFont('helvetica', 'bold');
doc.text(legLabel.toUpperCase(), margin + 6 + (doc.getTextWidth(legLabel.toUpperCase()) + 7) / 2, y, { align: 'center', charSpace: 0.3 });
}
return y + 18;
const padX = 8;
const topY = y + (legLabel ? 12 : 8);
// Origin block
label(doc, 'From', margin + padX, topY);
doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
doc.text(schedule.origin.code, margin + padX, topY + 8);
doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal');
doc.text(schedule.origin.city || schedule.origin.name, margin + padX, topY + 13);
const dep = new Date(schedule.departureAt);
doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold');
doc.text(dep.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), margin + padX, topY + 20.5);
doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal');
doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), margin + padX, topY + 25);
// Destination block (right-aligned)
const dx = pageWidth - margin - padX;
label(doc, 'To', dx, topY, { align: 'right' });
doc.setTextColor(...INK); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
doc.text(schedule.destination.code, dx, topY + 8, { align: 'right' });
doc.setTextColor(...BODY); doc.setFontSize(8.5); doc.setFont('helvetica', 'normal');
doc.text(schedule.destination.city || schedule.destination.name, dx, topY + 13, { align: 'right' });
const arr = new Date(schedule.arrivalAt);
doc.setTextColor(...BRAND); doc.setFontSize(11.5); doc.setFont('helvetica', 'bold');
doc.text(arr.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), dx, topY + 20.5, { align: 'right' });
doc.setTextColor(...MUTED); doc.setFontSize(7); doc.setFont('helvetica', 'normal');
doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }), dx, topY + 25, { align: 'right' });
// Dashed route line with endpoint markers, connecting the two blocks
const lineY = topY + 8.5;
const lineX1 = margin + padX + 24;
const lineX2 = dx - 24;
doc.setDrawColor(...HAIRLINE);
doc.setLineWidth(0.5);
doc.setLineDashPattern([1, 1.2], 0);
doc.line(lineX1, lineY, lineX2, lineY);
doc.setLineDashPattern([], 0);
doc.setFillColor(...BRAND);
doc.circle(lineX1, lineY, 0.9, 'F');
doc.circle(lineX2, lineY, 0.9, 'F');
// Train info sub-row
const rowY = y + routeH;
hairline(doc, margin, rowY, margin + cardW);
doc.setFontSize(8); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
doc.text('TRAIN', margin + padX, rowY + 6, { charSpace: 0.3 });
doc.setTextColor(...INK); doc.setFont('helvetica', 'bold');
doc.text(schedule.trainNumber + (schedule.trainName ? ` · ${schedule.trainName}` : ''), margin + padX + 15, rowY + 6);
if (schedule.seatClass) {
doc.setFont('helvetica', 'normal'); doc.setTextColor(...BODY);
doc.text(schedule.seatClass, pageWidth - margin - padX, rowY + 6, { align: 'right' });
}
return y + cardH + 8;
}
function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number): number {
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
doc.text('PASSENGER DETAILS', margin, y);
// ─── passenger details ─────────────────────────────────────────────────────
function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number, pageWidth: number): number {
label(doc, 'Passenger details', margin, y);
y += 7;
const rows: [string, string][] = [
['Full Name', data.passengerName || '—'],
['Date of Birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'],
['Full name', data.passengerName || '—'],
['Date of birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'],
['Nationality', data.nationality || '—'],
];
if (data.isRoundTrip) {
rows.push(['Outbound Seat', data.outboundSeatNumber || '—']);
rows.push(['Return Seat', data.inboundSeatNumber || '—']);
rows.push(['Outbound seat', data.outboundSeatNumber || '—']);
rows.push(['Return seat', data.inboundSeatNumber || '—']);
} else {
rows.push(['Seat', data.seatNumber || '—']);
}
autoTable(doc, {
startY: y,
body: rows,
theme: 'plain',
styles: { fontSize: 9, cellPadding: 3 },
columnStyles: {
0: { fontStyle: 'bold', textColor: [MED[0], MED[1], MED[2]], cellWidth: 45 },
1: { textColor: [DARK[0], DARK[1], DARK[2]] },
},
alternateRowStyles: { fillColor: [248, 248, 248] },
margin: { left: margin, right: margin },
const rowH = 8;
rows.forEach(([k, v], i) => {
const rowY = y + i * rowH;
doc.setFontSize(8.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
doc.text(k.toUpperCase(), margin, rowY + 5, { charSpace: 0.2 });
doc.setFontSize(9.5); doc.setTextColor(...INK); doc.setFont('helvetica', 'bold');
doc.text(v, pageWidth - margin, rowY + 5, { align: 'right' });
if (i < rows.length - 1) hairline(doc, margin, rowY + rowH, pageWidth - margin);
});
return (doc as any).lastAutoTable.finalY + 8;
return y + rows.length * rowH + 6;
}
// ─── fare summary ──────────────────────────────────────────────────────────
function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number {
doc.setFillColor(248, 248, 248);
doc.rect(margin, y, pageWidth - margin * 2, 20, 'F');
doc.setFontSize(9); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('Fare', margin + 5, y + 7);
doc.setFontSize(15); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - 5, y + 7, { align: 'right' });
doc.setFontSize(9); doc.setTextColor(34, 197, 94); doc.setFont('helvetica', 'bold');
doc.text('✓ PAID', margin + 5, y + 15);
return y + 26;
const cardH = 20;
doc.setFillColor(...BRAND_SOFT);
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F');
const padX = 7;
label(doc, 'Total fare paid', margin + padX, y + 8, { color: BODY });
doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...SUCCESS);
doc.text('✓ PAID', margin + padX, y + 15);
doc.setTextColor(...BRAND); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - padX, y + 13, { align: 'right' });
return y + cardH + 8;
}
// ─── instructions ──────────────────────────────────────────────────────────
function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number {
doc.setFillColor(252, 211, 77);
doc.rect(margin, y, pageWidth - margin * 2, 18, 'F');
doc.setFontSize(9); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, y + 6);
const cardH = 17;
const barW = 1.4;
doc.setFillColor(...AMBER_FILL);
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 2, 2, 'F');
doc.setFillColor(...AMBER_BORDER);
doc.rect(margin, y, barW, cardH, 'F');
const padX = 6;
doc.setFontSize(8); doc.setTextColor(...AMBER_TEXT); doc.setFont('helvetica', 'bold');
doc.text('BEFORE YOU TRAVEL', margin + padX, y + 6, { charSpace: 0.3 });
doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
doc.text('Present this voucher at the terminal for boarding', margin + 5, y + 11);
doc.text('• Arrive at least 30 minutes before departure', margin + 5, y + 15);
return y + 24;
doc.text('Present this voucher (printed or on your phone) at the terminal for boarding.', margin + padX, y + 11);
doc.text('Please arrive at least 30 minutes before scheduled departure.', margin + padX, y + 15);
return y + cardH + 6;
}
// ─── footer ────────────────────────────────────────────────────────────────
function drawFooter(doc: jsPDF, createdAt: string): void {
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const footerY = pageHeight - 22;
const footerY = pageHeight - 20;
doc.setDrawColor(...LIGHT);
doc.line(15, footerY, pageWidth - 15, footerY);
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' });
doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' });
doc.setFontSize(7);
doc.text(`Generated: ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' });
hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN);
doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal');
doc.text('support@edr.com · +251-11-XXX-XXXX · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' });
doc.setFontSize(6.5);
doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' });
}
// ─── public API ──────────────────────────────────────────────────────────────
@@ -295,25 +362,22 @@ function drawFooter(doc: jsPDF, createdAt: string): void {
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<void> => {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageW = doc.internal.pageSize.getWidth();
const margin = 15;
const margin = PAGE_MARGIN;
let y = await drawHeader(doc, margin);
const qrDataUrl = await generateTicketQrDataUrl(data);
// Title
doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
doc.text('PASSENGER VOUCHER', pageW / 2, y, { align: 'center' });
y += 10;
y = drawTicketHero(doc, data.bookingRef, data.ticketNumber, data.status, qrDataUrl, y, margin, pageW);
y = drawStatusBadge(doc, data.status, y, pageW);
y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, qrDataUrl, y, margin, pageW);
label(doc, 'Journey details', margin, y);
y += 7;
y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW);
if (data.isRoundTrip && data.inboundSchedule) {
y = drawJourneyLeg(doc, data.inboundSchedule, 'Return', y, margin, pageW);
}
y = drawPassengerDetails(doc, data, y, margin);
y = drawPassengerDetails(doc, data, y, margin, pageW);
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW);
drawInstructions(doc, y, margin, pageW);
drawFooter(doc, data.createdAt);

View File

@@ -1,5 +1,7 @@
import type { BaseEntity } from "../common";
export * from "./support-chat";
export enum TicketStatus {
Reserved = "RESERVED",
Confirmed = "CONFIRMED",

View File

@@ -0,0 +1,103 @@
/**
* Shared contracts for the passenger in-app customer-support chat.
*
* Unlike freight (company-scoped), a passenger conversation ("ticket") belongs to a
* single **individual passenger** (keyed by their IAM user id). Backoffice agents
* work a shared inbox (no assignment). Messages are text-only for the MVP.
*
* These are the wire (JSON) shapes — dates are ISO strings — plus the frozen
* Socket.IO event/namespace constants shared by the gateway (emitter) and both
* passenger web apps (subscribers).
*/
/** Lifecycle of a support conversation. Mirrors the Prisma `SupportConversationStatus`. */
export enum PassengerSupportStatus {
OPEN = "OPEN",
RESOLVED = "RESOLVED",
CLOSED = "CLOSED",
}
/** Author of a message. The Prisma `SupportSender` also has `BOT` (legacy, unused here). */
export enum PassengerSupportSender {
USER = "USER",
AGENT = "AGENT",
}
/** A single chat message on the wire. */
export interface PassengerSupportMessageDto {
id: string;
conversationId: string;
sender: PassengerSupportSender;
/** Display name of the author, best-effort. */
authorName?: string | null;
text: string;
createdAt: string;
}
/** A conversation ("ticket") on the wire, with denormalized last-message fields. */
export interface PassengerSupportConversationDto {
id: string;
/** Set for authenticated passengers; null for guest conversations. */
userId?: string | null;
/** Set for guest (unauthenticated) conversations. */
guestId?: string | null;
guestEmail?: string | null;
guestPhone?: string | null;
passengerId?: string | null;
/** Display name — passenger's name for authed, guest's name for guests. */
passengerName?: string | null;
subject?: string | null;
status: PassengerSupportStatus;
assignedAgentId?: string | null;
lastMessageAt?: string | null;
lastMessagePreview?: string | null;
lastMessageSender?: PassengerSupportSender | null;
/** Unread count for the caller's side (messages from the other sender after their cursor). */
unreadCount: number;
createdAt: string;
updatedAt: string;
}
/** Customer opens a new ticket: subject + first message. */
export interface CreatePassengerSupportConversationDto {
subject: string;
initialMessage: string;
}
/** Guest (unauthenticated) opens a ticket: identity + contact captured up front. */
export interface CreateGuestSupportConversationDto {
guestId: string;
name: string;
email: string;
phone?: string;
subject: string;
initialMessage: string;
}
/** Post a message into an existing conversation. */
export interface SendPassengerSupportMessageDto {
text: string;
}
/** Paginated list envelope for the conversations list endpoints. */
export interface PassengerSupportConversationListResult {
items: PassengerSupportConversationDto[];
count: number;
/** Total unread conversations for the caller's side (badge source). */
unreadCount: number;
}
/** Socket.io event names pushed server → client on the passenger support namespace. */
export const PASSENGER_SUPPORT_WS_EVENTS = {
MESSAGE_NEW: "passenger-support:message-new",
CONVERSATION_UPDATED: "passenger-support:conversation-updated",
} as const;
/** Socket.io namespace the passenger support gateway listens on. */
export const PASSENGER_SUPPORT_WS_NAMESPACE = "passenger-support-chat";
/** Payload for {@link PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW}. */
export interface PassengerSupportMessageEvent {
conversation: PassengerSupportConversationDto;
message: PassengerSupportMessageDto;
}

15
pnpm-lock.yaml generated
View File

@@ -501,6 +501,9 @@ importers:
'@nestjs/platform-express':
specifier: ^11.1.19
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
'@nestjs/platform-socket.io':
specifier: ^11.1.27
version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.27)(rxjs@7.8.2)
'@nestjs/schedule':
specifier: ^6.1.3
version: 6.1.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
@@ -513,6 +516,9 @@ importers:
'@nestjs/typeorm':
specifier: ^11.0.1
version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@nestjs/websockets':
specifier: ^11.1.27
version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@prisma/client':
specifier: ^6.19.3
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
@@ -570,6 +576,9 @@ importers:
rxjs:
specifier: ^7.8.1
version: 7.8.2
socket.io:
specifier: ^4.8.3
version: 4.8.3
swagger-ui-express:
specifier: ^5.0.0
version: 5.0.1(express@4.22.2)
@@ -673,6 +682,9 @@ importers:
recharts:
specifier: ^2.12.0
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
socket.io-client:
specifier: ^4.8.3
version: 4.8.3
zustand:
specifier: ^5.0.0
version: 5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
@@ -758,6 +770,9 @@ importers:
react-hook-form:
specifier: ^7.51.0
version: 7.77.0(react@18.3.1)
socket.io-client:
specifier: ^4.8.3
version: 4.8.3
zod:
specifier: ^3.22.4
version: 3.25.76