diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 1501282fe..cdfe68787 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -31,10 +31,12 @@ "@nestjs/event-emitter": "^2.0.4", "@nestjs/microservices": "^11.1.24", "@nestjs/platform-express": "^11.1.19", + "@nestjs/platform-socket.io": "^11.1.27", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^7.4.0", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.1", + "@nestjs/websockets": "^11.1.27", "@prisma/client": "^6.19.3", "@sendgrid/mail": "^8.1.0", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", @@ -54,6 +56,7 @@ "qrcode": "^1.5.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "swagger-ui-express": "^5.0.0", "tsconfig-paths": "^4.2.0", "typeorm": "^0.3.30", diff --git a/apps/edr-passenger-api/prisma/migrations/20260707134245_enhanced_support_chat/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260707134245_enhanced_support_chat/migration.sql new file mode 100644 index 000000000..299a2abf8 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260707134245_enhanced_support_chat/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "SupportConversation" ADD COLUMN "agentLastReadAt" TIMESTAMP(3), +ADD COLUMN "lastMessageAt" TIMESTAMP(3), +ADD COLUMN "lastMessagePreview" TEXT, +ADD COLUMN "lastMessageSender" "SupportSender", +ADD COLUMN "passengerId" TEXT, +ADD COLUMN "passengerName" TEXT, +ADD COLUMN "subject" TEXT, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "userLastReadAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "SupportConversation_userId_idx" ON "SupportConversation"("userId"); + +-- CreateIndex +CREATE INDEX "SupportConversation_status_lastMessageAt_idx" ON "SupportConversation"("status", "lastMessageAt"); + +-- AddForeignKey +ALTER TABLE "SupportConversation" ADD CONSTRAINT "SupportConversation_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260707135233_add_guest_support/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260707135233_add_guest_support/migration.sql new file mode 100644 index 000000000..a6b1fc9e7 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260707135233_add_guest_support/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "SupportConversation" ADD COLUMN "guestEmail" TEXT, +ADD COLUMN "guestId" TEXT, +ADD COLUMN "guestName" TEXT, +ADD COLUMN "guestPhone" TEXT, +ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "SupportConversation_guestId_idx" ON "SupportConversation"("guestId"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 0cd1756fd..8a4224e73 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -299,6 +299,7 @@ model Passenger { travelerProfiles TravelerProfile[] savedRoutes SavedRoute[] packageBookings PackageBooking[] + supportConversations SupportConversation[] @@index([userId]) @@index([iamUserId]) @@schema("passenger") @@ -881,12 +882,29 @@ model FaqArticle { } model SupportConversation { - id String @id @default(uuid()) - userId String - assignedAgentId String? - status SupportConversationStatus @default(OPEN) - createdAt DateTime @default(now()) - messages SupportMessage[] + id String @id @default(uuid()) + userId String? + guestId String? + guestName String? + guestEmail String? + guestPhone String? + passengerId String? + passengerName String? + subject String? + assignedAgentId String? + status SupportConversationStatus @default(OPEN) + lastMessageAt DateTime? + lastMessagePreview String? + lastMessageSender SupportSender? + userLastReadAt DateTime? + agentLastReadAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @default(now()) + messages SupportMessage[] + passenger Passenger? @relation(fields: [passengerId], references: [id]) + @@index([userId]) + @@index([guestId]) + @@index([status, lastMessageAt]) @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/modules/support/support.controller.ts b/apps/edr-passenger-api/src/modules/support/support.controller.ts index c13b3cd33..63adefe5a 100644 --- a/apps/edr-passenger-api/src/modules/support/support.controller.ts +++ b/apps/edr-passenger-api/src/modules/support/support.controller.ts @@ -1,15 +1,207 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + Req, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SupportService } from './support.service'; import { JwtGuard } from '../../common/jwt.guard'; +import { + CreateConversationDto, + CreateGuestConversationDto, + GuestIdBodyDto, + GuestSendMessageDto, + ListConversationsQueryDto, + SendMessageDto, + UpdateStatusDto, +} from './support.dto'; + +function userId(req: any): string { + const id = req?.user?.id ?? req?.user?.sub; + if (!id) throw new UnauthorizedException(); + return id; +} @ApiTags('Support') @Controller('support') export class SupportController { constructor(private service: SupportService) {} - @Get('faq') @ApiOperation({ summary: 'Get FAQ categories' }) getFaqCategories() { return this.service.getFaqCategories(); } - @Get('faq/:categoryId/articles') @ApiOperation({ summary: 'Get FAQ articles for a category' }) getFaqArticles(@Param('categoryId') id: string) { return this.service.getFaqArticles(id); } - @Post('conversations') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Start a support conversation' }) startConversation(@Body('userId') userId: string) { return this.service.startConversation(userId); } - @Post('conversations/:id/messages') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Send a message in a conversation' }) sendMessage(@Param('id') id: string, @Body() body: { sender: 'USER' | 'BOT' | 'AGENT'; text: string }) { return this.service.sendMessage(id, body.sender, body.text); } - @Get('conversations/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get conversation with messages' }) getConversation(@Param('id') id: string) { return this.service.getConversation(id); } + + // ---- FAQ (public) ------------------------------------------------------ + + @Get('faq') + @IsPublic() + @ApiOperation({ summary: 'Get FAQ categories' }) + getFaqCategories() { + return this.service.getFaqCategories(); + } + + @Get('faq/:categoryId/articles') + @IsPublic() + @ApiOperation({ summary: 'Get FAQ articles for a category' }) + getFaqArticles(@Param('categoryId') id: string) { + return this.service.getFaqArticles(id); + } + + // ---- customer: authenticated passenger -------------------------------- + + @Post('conversations') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Open a new support conversation' }) + createConversation(@Req() req: any, @Body() body: CreateConversationDto) { + return this.service.createConversation(userId(req), body); + } + + @Get('conversations') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List my support conversations' }) + listMine(@Req() req: any, @Query() query: ListConversationsQueryDto) { + return this.service.listForCustomer({ iamUserId: userId(req) }, query); + } + + @Get('conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List messages in one of my conversations' }) + messages(@Req() req: any, @Param('id') id: string) { + return this.service.getMessages(id, { iamUserId: userId(req) }); + } + + @Post('conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Send a message as the customer' }) + send(@Req() req: any, @Param('id') id: string, @Body() body: SendMessageDto) { + return this.service.sendMessage(id, 'USER', body.text, { + iamUserId: userId(req), + }); + } + + @Post('conversations/:id/read') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Mark a conversation read (customer side)' }) + read(@Req() req: any, @Param('id') id: string) { + return this.service.markRead(id, 'USER', { iamUserId: userId(req) }); + } + + @Get('unread-count') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Count my unread support conversations' }) + unread(@Req() req: any) { + return this.service.unreadCount('USER', { iamUserId: userId(req) }); + } + + // ---- customer: guest (unauthenticated) -------------------------------- + // No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer + // of access — anyone with it sees that thread; accepted MVP trade-off). + + @Post('guest/conversations') + @IsPublic() + @ApiOperation({ summary: 'Open a support conversation as a guest' }) + guestCreate(@Body() body: CreateGuestConversationDto) { + return this.service.createGuestConversation(body); + } + + @Get('guest/conversations') + @IsPublic() + @ApiOperation({ summary: 'List a guest\'s conversations' }) + guestList( + @Query('guestId') guestId: string, + @Query() query: ListConversationsQueryDto, + ) { + return this.service.listForCustomer({ guestId }, query); + } + + @Get('guest/conversations/:id/messages') + @IsPublic() + @ApiOperation({ summary: 'List messages in a guest conversation' }) + guestMessages(@Param('id') id: string, @Query('guestId') guestId: string) { + return this.service.getMessages(id, { guestId }); + } + + @Post('guest/conversations/:id/messages') + @IsPublic() + @ApiOperation({ summary: 'Send a message as a guest' }) + guestSend(@Param('id') id: string, @Body() body: GuestSendMessageDto) { + return this.service.sendMessage(id, 'USER', body.text, { + guestId: body.guestId, + }); + } + + @Post('guest/conversations/:id/read') + @IsPublic() + @ApiOperation({ summary: 'Mark a guest conversation read' }) + guestRead(@Param('id') id: string, @Body() body: GuestIdBodyDto) { + return this.service.markRead(id, 'USER', { guestId: body.guestId }); + } + + @Get('guest/unread-count') + @IsPublic() + @ApiOperation({ summary: 'Count a guest\'s unread conversations' }) + guestUnread(@Query('guestId') guestId: string) { + return this.service.unreadCount('USER', { guestId }); + } + + // ---- agent (backoffice) ------------------------------------------------ + // TODO: gate agent routes with a staff permission once passenger RBAC is wired. + + @Get('agent/conversations') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List all support conversations (shared inbox)' }) + agentList(@Query() query: ListConversationsQueryDto) { + return this.service.listForAgents(query); + } + + @Get('agent/conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List messages in a conversation' }) + agentMessages(@Param('id') id: string) { + return this.service.getMessages(id); + } + + @Post('agent/conversations/:id/messages') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Reply as an agent' }) + agentSend(@Param('id') id: string, @Body() body: SendMessageDto) { + return this.service.sendMessage(id, 'AGENT', body.text); + } + + @Patch('agent/conversations/:id/status') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: "Change a conversation's status" }) + agentStatus(@Param('id') id: string, @Body() body: UpdateStatusDto) { + return this.service.setStatus(id, body.status); + } + + @Post('agent/conversations/:id/read') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Mark a conversation read (agent side)' }) + agentRead(@Param('id') id: string) { + return this.service.markRead(id, 'AGENT'); + } + + @Get('agent/unread-count') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Count unread conversations (agent side)' }) + agentUnread() { + return this.service.unreadCount('AGENT'); + } } diff --git a/apps/edr-passenger-api/src/modules/support/support.dto.ts b/apps/edr-passenger-api/src/modules/support/support.dto.ts new file mode 100644 index 000000000..77a3ee3ed --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/support.dto.ts @@ -0,0 +1,127 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Length, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export enum SupportStatusDto { + OPEN = 'OPEN', + RESOLVED = 'RESOLVED', + CLOSED = 'CLOSED', +} + +export class CreateConversationDto { + @ApiProperty({ description: 'Short subject / topic of the request.' }) + @IsString() + @Length(3, 200) + subject!: string; + + @ApiProperty({ description: 'The first message body.' }) + @IsString() + @MinLength(1) + @MaxLength(4000) + initialMessage!: string; +} + +export class SendMessageDto { + @ApiProperty({ description: 'Message text.' }) + @IsString() + @MinLength(1) + @MaxLength(4000) + text!: string; +} + +export class CreateGuestConversationDto { + @ApiProperty({ description: 'Client-generated anonymous id (localStorage).' }) + @IsString() + @Length(8, 120) + guestId!: string; + + @ApiProperty({ description: 'Guest full name.' }) + @IsString() + @Length(1, 120) + name!: string; + + @ApiProperty({ description: 'Guest email for follow-up.' }) + @IsEmail() + email!: string; + + @ApiPropertyOptional({ description: 'Guest phone (optional).' }) + @IsOptional() + @IsString() + @MaxLength(40) + phone?: string; + + @ApiProperty() + @IsString() + @Length(3, 200) + subject!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(4000) + initialMessage!: string; +} + +export class GuestSendMessageDto { + @ApiProperty({ description: 'The guest id that owns the conversation.' }) + @IsString() + @Length(8, 120) + guestId!: string; + + @ApiProperty({ description: 'Message text.' }) + @IsString() + @MinLength(1) + @MaxLength(4000) + text!: string; +} + +export class GuestIdBodyDto { + @ApiProperty() + @IsString() + @Length(8, 120) + guestId!: string; +} + +export class UpdateStatusDto { + @ApiProperty({ enum: SupportStatusDto }) + @IsEnum(SupportStatusDto) + status!: SupportStatusDto; +} + +export class ListConversationsQueryDto { + @ApiPropertyOptional({ enum: SupportStatusDto }) + @IsOptional() + @IsEnum(SupportStatusDto) + status?: SupportStatusDto; + + @ApiPropertyOptional({ description: 'Search subject / passenger name.' }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/apps/edr-passenger-api/src/modules/support/support.gateway.ts b/apps/edr-passenger-api/src/modules/support/support.gateway.ts new file mode 100644 index 000000000..203c39035 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/support.gateway.ts @@ -0,0 +1,134 @@ +import { Logger } from '@nestjs/common'; +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { Passenger as PassengerTypes } from '@edr/types'; + +import { PrismaService } from '../../common/prisma.service'; +import { WsAuthService } from './ws-auth.service'; + +/** + * Server → client push for passenger support chat. Clients only *listen* (no + * `@SubscribeMessage`); the handshake is authenticated in `handleConnection`. + * Each socket joins a room based on its side: + * - backoffice staff → the shared `backoffice` room (see every conversation). + * - passengers → their `user:` room (their own tickets only). + * + * Side is decided by the presence of a `Passenger` row for the IAM user id + * (staff have none). A message is emitted to BOTH the owner's room and the + * backoffice room so the customer thread, the sender's echo, and every agent's + * inbox update live. + */ +@WebSocketGateway({ + namespace: PassengerTypes.PASSENGER_SUPPORT_WS_NAMESPACE, + cors: { origin: true, credentials: true }, +}) +export class SupportGateway implements OnGatewayConnection { + private readonly logger = new Logger(SupportGateway.name); + + private static readonly BACKOFFICE_ROOM = 'backoffice'; + + @WebSocketServer() + private readonly server!: Server; + + constructor( + private readonly wsAuth: WsAuthService, + private readonly prisma: PrismaService, + ) {} + + async handleConnection(socket: Socket): Promise { + const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); + + // Authenticated: passenger (own room) or backoffice staff (shared room). + if (userId) { + socket.data.userId = userId; + const passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: userId }, + }); + if (passenger) { + await socket.join(`user:${userId}`); + socket.data.side = 'USER'; + } else { + await socket.join(SupportGateway.BACKOFFICE_ROOM); + socket.data.side = 'AGENT'; + } + return; + } + + // Guest: no valid token, but a client-generated guestId scopes the room. + // Anyone holding the guestId can see that thread (no account = weaker + // ownership) — an accepted MVP trade-off for guest support. + const guestId = this.extractGuestId(socket); + if (guestId) { + socket.data.guestId = guestId; + socket.data.side = 'USER'; + await socket.join(`guest:${guestId}`); + return; + } + + this.logger.debug(`Rejected passenger-support handshake ${socket.id}`); + socket.disconnect(true); + } + + /** Push a new message + updated conversation to the owner + backoffice rooms. */ + emitMessage( + ownerRoom: string | null, + conversation: PassengerTypes.PassengerSupportConversationDto, + message: PassengerTypes.PassengerSupportMessageDto, + ): void { + const payload = { conversation, message }; + for (const room of this.targetRooms(ownerRoom)) { + const to = this.server.to(room); + to.emit(PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW, payload); + to.emit( + PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, + conversation, + ); + } + } + + /** Push a conversation metadata change (e.g. status) to both rooms. */ + emitConversationUpdated( + ownerRoom: string | null, + conversation: PassengerTypes.PassengerSupportConversationDto, + ): void { + for (const room of this.targetRooms(ownerRoom)) { + this.server + .to(room) + .emit( + PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, + conversation, + ); + } + } + + private targetRooms(ownerRoom: string | null): string[] { + const rooms = [SupportGateway.BACKOFFICE_ROOM]; + if (ownerRoom) rooms.push(ownerRoom); + return rooms; + } + + private extractGuestId(socket: Socket): string | undefined { + const authGuest = socket.handshake.auth?.guestId as string | undefined; + if (authGuest) return authGuest; + const queryGuest = socket.handshake.query?.guestId; + if (typeof queryGuest === 'string') return queryGuest; + return undefined; + } + + private extractToken(socket: Socket): string | undefined { + const authToken = socket.handshake.auth?.token as string | undefined; + if (authToken) return authToken; + + const queryToken = socket.handshake.query?.token; + if (typeof queryToken === 'string') return queryToken; + + const header = socket.handshake.headers?.authorization; + if (header?.startsWith('Bearer ')) return header.slice(7); + + return undefined; + } +} diff --git a/apps/edr-passenger-api/src/modules/support/support.module.ts b/apps/edr-passenger-api/src/modules/support/support.module.ts index 17f139a07..136fcf196 100644 --- a/apps/edr-passenger-api/src/modules/support/support.module.ts +++ b/apps/edr-passenger-api/src/modules/support/support.module.ts @@ -1,6 +1,17 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; + import { SupportController } from './support.controller'; import { SupportService } from './support.service'; +import { SupportGateway } from './support.gateway'; +import { WsAuthService } from './ws-auth.service'; -@Module({ controllers: [SupportController], providers: [SupportService] }) +@Module({ + // Session is served by the app's default TypeORM DataSource (IAM schema) — + // used by WsAuthService to authenticate WebSocket handshakes. + imports: [TypeOrmModule.forFeature([Session])], + controllers: [SupportController], + providers: [SupportService, SupportGateway, WsAuthService], +}) export class SupportModule {} diff --git a/apps/edr-passenger-api/src/modules/support/support.service.ts b/apps/edr-passenger-api/src/modules/support/support.service.ts index dd155bff8..dfbbb3b12 100644 --- a/apps/edr-passenger-api/src/modules/support/support.service.ts +++ b/apps/edr-passenger-api/src/modules/support/support.service.ts @@ -1,35 +1,419 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Passenger as T } from '@edr/types'; import { PrismaService } from '../../common/prisma.service'; +import { SupportGateway } from './support.gateway'; + +type Side = 'USER' | 'AGENT'; +type PrismaSender = 'USER' | 'BOT' | 'AGENT'; +type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED'; + +/** Who the caller is on the customer side: an authed passenger or a guest. */ +export interface CustomerOwner { + iamUserId?: string | null; + guestId?: string | null; +} + +interface ListQuery { + status?: PrismaStatus; + search?: string; + page?: number; + limit?: number; +} + +type ConversationRow = { + id: string; + userId: string | null; + guestId: string | null; + guestName: string | null; + guestEmail: string | null; + guestPhone: string | null; + passengerId: string | null; + passengerName: string | null; + subject: string | null; + status: PrismaStatus; + assignedAgentId: string | null; + lastMessageAt: Date | null; + lastMessagePreview: string | null; + lastMessageSender: PrismaSender | null; + userLastReadAt: Date | null; + agentLastReadAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; @Injectable() export class SupportService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private gateway: SupportGateway, + ) {} - getFaqCategories() { return this.prisma.faqCategory.findMany({ include: { _count: { select: { articles: true } } } }); } + // ---- FAQ (unchanged) --------------------------------------------------- - getFaqArticles(categoryId: string) { return this.prisma.faqArticle.findMany({ where: { categoryId }, orderBy: { rank: 'asc' } }); } - - startConversation(userId: string) { return this.prisma.supportConversation.create({ data: { userId } }); } - - async sendMessage(conversationId: string, sender: 'USER' | 'BOT' | 'AGENT', text: string) { - const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId } }); - if (!conv) throw new NotFoundException('Conversation not found'); - const message = await this.prisma.supportMessage.create({ data: { conversationId, sender, text } }); - if (sender === 'USER') await this.prisma.supportMessage.create({ data: { conversationId, sender: 'BOT', text: this.getBotReply(text) } }); - return message; + getFaqCategories() { + return this.prisma.faqCategory.findMany({ + include: { _count: { select: { articles: true } } }, + }); } - async getConversation(conversationId: string) { - const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId }, include: { messages: { orderBy: { createdAt: 'asc' } } } }); - if (!conv) throw new NotFoundException('Conversation not found'); - return conv; + getFaqArticles(categoryId: string) { + return this.prisma.faqArticle.findMany({ + where: { categoryId }, + orderBy: { rank: 'asc' }, + }); } - private getBotReply(text: string): string { - const lower = text.toLowerCase(); - if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to Bookings and select the booking. Refunds are processed within 3-5 business days.'; - if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.'; - if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.'; - return 'Thank you for contacting EDR support. An agent will assist you shortly.'; + // ---- customer: authed passenger --------------------------------------- + + async createConversation( + iamUserId: string, + input: { subject: string; initialMessage: string }, + ): Promise { + const passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId }, + include: { user: { select: { fullName: true } } }, + }); + const conversation = (await this.prisma.supportConversation.create({ + data: { + userId: iamUserId, + passengerId: passenger?.id ?? null, + passengerName: passenger?.user?.fullName ?? null, + subject: input.subject, + status: 'OPEN', + }, + })) as ConversationRow; + return this.firstMessage(conversation, input.initialMessage); + } + + // ---- customer: guest (unauthenticated) -------------------------------- + + async createGuestConversation(input: { + guestId: string; + name: string; + email: string; + phone?: string; + subject: string; + initialMessage: string; + }): Promise { + const conversation = (await this.prisma.supportConversation.create({ + data: { + guestId: input.guestId, + guestName: input.name, + guestEmail: input.email, + guestPhone: input.phone ?? null, + passengerName: input.name, // uniform display name for the agent inbox + subject: input.subject, + status: 'OPEN', + }, + })) as ConversationRow; + return this.firstMessage(conversation, input.initialMessage); + } + + async listForCustomer( + owner: CustomerOwner, + query: ListQuery, + ): Promise { + const scope = this.ownerScope(owner); + const where = { ...this.listWhere(query), ...scope }; + const rows = (await this.prisma.supportConversation.findMany({ + where, + orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }], + take: query.limit ?? 100, + skip: ((query.page ?? 1) - 1) * (query.limit ?? 100), + })) as ConversationRow[]; + const count = await this.prisma.supportConversation.count({ where }); + return this.buildListResult(rows, count, 'USER'); + } + + // ---- agent (backoffice) ------------------------------------------------ + + async listForAgents( + query: ListQuery, + ): Promise { + const where = this.listWhere(query); + const rows = (await this.prisma.supportConversation.findMany({ + where, + orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }], + take: query.limit ?? 100, + skip: ((query.page ?? 1) - 1) * (query.limit ?? 100), + })) as ConversationRow[]; + const count = await this.prisma.supportConversation.count({ where }); + return this.buildListResult(rows, count, 'AGENT'); + } + + async setStatus( + conversationId: string, + status: PrismaStatus, + ): Promise { + await this.requireConversation(conversationId); + const updated = (await this.prisma.supportConversation.update({ + where: { id: conversationId }, + data: { status }, + })) as ConversationRow; + const dto = this.toConversationDto(updated, 0); + this.gateway.emitConversationUpdated(this.ownerRoom(updated), dto); + return dto; + } + + // ---- shared ------------------------------------------------------------ + + async getMessages( + conversationId: string, + asCustomer?: CustomerOwner, + ): Promise { + const conversation = await this.requireConversation(conversationId); + if (asCustomer) this.assertOwns(conversation, asCustomer); + const rows = await this.prisma.supportMessage.findMany({ + where: { conversationId }, + orderBy: { createdAt: 'asc' }, + }); + return rows.map((m) => this.toMessageDto(m)); + } + + async sendMessage( + conversationId: string, + sender: Side, + text: string, + asCustomer?: CustomerOwner, + ): Promise { + const conversation = await this.requireConversation(conversationId); + if (sender === 'USER') { + this.assertOwns(conversation, asCustomer ?? {}); + } + const updated = await this.appendMessage(conversation, sender, text); + const last = updated.messages[updated.messages.length - 1]; + return this.toMessageDto(last); + } + + async markRead( + conversationId: string, + side: Side, + asCustomer?: CustomerOwner, + ): Promise<{ unreadCount: number }> { + const conversation = await this.requireConversation(conversationId); + if (side === 'USER') { + this.assertOwns(conversation, asCustomer ?? {}); + await this.prisma.supportConversation.update({ + where: { id: conversationId }, + data: { userLastReadAt: new Date() }, + }); + return this.unreadCount('USER', asCustomer); + } + await this.prisma.supportConversation.update({ + where: { id: conversationId }, + data: { agentLastReadAt: new Date() }, + }); + return this.unreadCount('AGENT'); + } + + async unreadCount( + side: Side, + owner?: CustomerOwner, + ): Promise<{ unreadCount: number }> { + const rows = (await this.prisma.supportConversation.findMany({ + where: side === 'USER' ? this.ownerScope(owner ?? {}) : {}, + select: { id: true, userLastReadAt: true, agentLastReadAt: true }, + })) as Array<{ + id: string; + userLastReadAt: Date | null; + agentLastReadAt: Date | null; + }>; + const map = await this.computeUnread(rows, side); + let unreadCount = 0; + for (const n of map.values()) if (n > 0) unreadCount++; + return { unreadCount }; + } + + // ---- internals --------------------------------------------------------- + + private async firstMessage( + conversation: ConversationRow, + text: string, + ): Promise { + const { conversation: updated } = await this.appendMessageRaw( + conversation, + 'USER', + text, + ); + return this.toConversationDto(updated, 0); + } + + private async appendMessage( + conversation: ConversationRow, + sender: PrismaSender, + text: string, + ) { + const { conversation: updated } = await this.appendMessageRaw( + conversation, + sender, + text, + ); + return updated as ConversationRow & { messages: any[] }; + } + + /** Persist a message, bump the conversation's denormalized fields, emit live. */ + private async appendMessageRaw( + conversation: ConversationRow, + sender: PrismaSender, + text: string, + ): Promise<{ conversation: ConversationRow & { messages: any[] }; message: any }> { + const message = await this.prisma.supportMessage.create({ + data: { conversationId: conversation.id, sender, text }, + }); + const updated = (await this.prisma.supportConversation.update({ + where: { id: conversation.id }, + data: { + lastMessageAt: message.createdAt, + lastMessagePreview: text.slice(0, 280), + lastMessageSender: sender, + }, + include: { messages: { orderBy: { createdAt: 'asc' } } }, + })) as ConversationRow & { messages: any[] }; + + const dto = this.toConversationDto(updated, 0); + this.gateway.emitMessage(this.ownerRoom(updated), dto, this.toMessageDto(message)); + return { conversation: updated, message }; + } + + private async buildListResult( + rows: ConversationRow[], + count: number, + side: Side, + ): Promise { + const unreadMap = await this.computeUnread(rows, side); + const items = rows.map((r) => + this.toConversationDto(r, unreadMap.get(r.id) ?? 0), + ); + let unreadCount = 0; + for (const n of unreadMap.values()) if (n > 0) unreadCount++; + return { items, count, unreadCount }; + } + + private async computeUnread( + rows: Array<{ + id: string; + userLastReadAt: Date | null; + agentLastReadAt: Date | null; + }>, + side: Side, + ): Promise> { + const map = new Map(); + if (rows.length === 0) return map; + const otherSender: PrismaSender = side === 'USER' ? 'AGENT' : 'USER'; + const ids = rows.map((r) => r.id); + const msgs = await this.prisma.supportMessage.findMany({ + where: { conversationId: { in: ids }, sender: otherSender }, + select: { conversationId: true, createdAt: true }, + }); + const cursorById = new Map( + rows.map((r) => [ + r.id, + side === 'USER' ? r.userLastReadAt : r.agentLastReadAt, + ]), + ); + for (const m of msgs) { + const cursor = cursorById.get(m.conversationId) ?? null; + if (!cursor || m.createdAt > cursor) { + map.set(m.conversationId, (map.get(m.conversationId) ?? 0) + 1); + } + } + return map; + } + + private listWhere(query: ListQuery) { + const where: Record = {}; + if (query.status) where.status = query.status; + if (query.search?.trim()) { + const contains = query.search.trim(); + where.OR = [ + { subject: { contains, mode: 'insensitive' } }, + { passengerName: { contains, mode: 'insensitive' } }, + { guestEmail: { contains, mode: 'insensitive' } }, + ]; + } + return where; + } + + /** Prisma where-fragment scoping to the calling customer (authed or guest). */ + private ownerScope(owner: CustomerOwner): Record { + if (owner.iamUserId) return { userId: owner.iamUserId }; + if (owner.guestId) return { guestId: owner.guestId }; + // No identity ⇒ match nothing. + return { id: '__none__' }; + } + + private assertOwns(conversation: ConversationRow, owner: CustomerOwner): void { + const ok = + (owner.iamUserId && conversation.userId === owner.iamUserId) || + (owner.guestId && conversation.guestId === owner.guestId); + if (!ok) { + throw new ForbiddenException('This conversation belongs to someone else.'); + } + } + + private ownerRoom(c: ConversationRow): string | null { + if (c.guestId) return `guest:${c.guestId}`; + if (c.userId) return `user:${c.userId}`; + return null; + } + + private async requireConversation(id: string): Promise { + const conversation = (await this.prisma.supportConversation.findUnique({ + where: { id }, + })) as ConversationRow | null; + if (!conversation) throw new NotFoundException('Conversation not found'); + return conversation; + } + + private toConversationDto( + c: ConversationRow, + unreadCount: number, + ): T.PassengerSupportConversationDto { + return { + id: c.id, + userId: c.userId, + guestId: c.guestId, + guestEmail: c.guestEmail, + guestPhone: c.guestPhone, + passengerId: c.passengerId, + passengerName: c.passengerName ?? c.guestName ?? null, + subject: c.subject, + status: c.status as T.PassengerSupportStatus, + assignedAgentId: c.assignedAgentId, + lastMessageAt: c.lastMessageAt ? c.lastMessageAt.toISOString() : null, + lastMessagePreview: c.lastMessagePreview, + lastMessageSender: this.toDtoSender(c.lastMessageSender), + unreadCount, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + }; + } + + private toMessageDto(m: { + id: string; + conversationId: string; + sender: PrismaSender; + text: string; + createdAt: Date; + }): T.PassengerSupportMessageDto { + return { + id: m.id, + conversationId: m.conversationId, + sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT, + text: m.text, + createdAt: m.createdAt.toISOString(), + }; + } + + /** Legacy BOT messages are surfaced as AGENT to the UI. */ + private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null { + if (!s) return null; + return s === 'USER' + ? T.PassengerSupportSender.USER + : T.PassengerSupportSender.AGENT; } } diff --git a/apps/edr-passenger-api/src/modules/support/ws-auth.service.ts b/apps/edr-passenger-api/src/modules/support/ws-auth.service.ts new file mode 100644 index 000000000..2b1ad8b6b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/ws-auth.service.ts @@ -0,0 +1,48 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { verifyToken } from '@tria-plc/api-common/utils/token'; +import { ESessionStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; + +/** + * Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access + * token payload is only a *session* pointer (`{ id: }`), not the + * user — so we verify the signature (`verifyToken`), then load the IAM session + * and require it to be ACTIVE and unexpired, and read the real user id out of + * `session.userInfo`. The `Session` entity is served by the app's default + * TypeORM DataSource (the same one the shared JwtGuard queries for `iam.sessions`). + * + * Returns the IAM user id, or null for any invalid/expired/revoked/malformed token. + */ +@Injectable() +export class WsAuthService { + private readonly logger = new Logger(WsAuthService.name); + + constructor( + @InjectRepository(Session) + private readonly sessions: Repository, + ) {} + + async resolveUserId(token?: string): Promise { + if (!token) return null; + try { + const payload = verifyToken(token) as { id?: string }; + const sessionId = payload?.id; + if (!sessionId) return null; + + const session = await this.sessions.findOne({ where: { id: sessionId } }); + if (!session) return null; + if (session.status !== ESessionStatus.ACTIVE) return null; + if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) { + return null; + } + + return session.userInfo?.id ?? null; + } catch (err) { + this.logger.debug(`WS auth rejected: ${(err as Error).message}`); + return null; + } + } +} diff --git a/apps/edr-passenger-web/backoffice/package.json b/apps/edr-passenger-web/backoffice/package.json index 654e19ef9..858fcd781 100644 --- a/apps/edr-passenger-web/backoffice/package.json +++ b/apps/edr-passenger-web/backoffice/package.json @@ -21,6 +21,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "recharts": "^2.12.0", + "socket.io-client": "^4.8.3", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx index d6ad8fb38..8102cc556 100644 --- a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx @@ -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 = { + 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(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) => {conv.status} }, - { 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 ( -
-
+
+
+ + +

Support Center

-

Manage customer support conversations

-
- Export -
- -
-
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +

+ Shared inbox — respond to passenger requests in real time +

- +
+ {/* Conversation list */} +
+
+
+ + 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" + /> +
+
+ {FILTERS.map((f) => ( + + ))} +
+
+
+ {isLoading ? ( +
+ Loading… +
+ ) : items.length === 0 ? ( +
+ No conversations. +
+ ) : ( + items.map((c) => ( + setSelectedId(c.id)} + /> + )) + )} +
+
+ + {/* Thread */} +
+ {selected ? ( + + ) : ( +
+ + + +

Select a conversation to start replying.

+
+ )} +
+
+
+ ); +} + +function InboxRow({ + c, + active, + onClick, +}: { + c: ConversationDto; + active: boolean; + onClick: () => void; +}) { + const unread = c.unreadCount > 0; + return ( + + ); +} + +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(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 ( +
+
+
+
+ + {conversation.subject || 'Conversation'} + + + {conversation.status.toLowerCase()} + +
+

+ {conversation.passengerName || 'Passenger'} + {conversation.guestId ? ' · Guest' : ''} + {conversation.guestEmail ? ` · ${conversation.guestEmail}` : ''} + {conversation.guestPhone ? ` · ${conversation.guestPhone}` : ''} +

+
+
+ {conversation.status !== 'OPEN' && ( + + )} + {conversation.status === 'OPEN' && ( + + )} + {conversation.status !== 'CLOSED' && ( + + )} +
+
+ +
+ {isLoading ? ( +
+ Loading… +
+ ) : ( + (messages ?? []).map((m) => ) + )} +
+ +
+
+