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