mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add the support feature to the passenger api
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
127
apps/edr-passenger-api/src/modules/support/support.dto.ts
Normal file
127
apps/edr-passenger-api/src/modules/support/support.dto.ts
Normal 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;
|
||||
}
|
||||
134
apps/edr-passenger-api/src/modules/support/support.gateway.ts
Normal file
134
apps/edr-passenger-api/src/modules/support/support.gateway.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user