feat: add the support feature to the passenger api

This commit is contained in:
Nathnael
2026-07-07 14:07:15 +00:00
parent 24fc27854a
commit 105605f904
6 changed files with 926 additions and 30 deletions

View File

@@ -1,15 +1,207 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SupportService } from './support.service';
import { JwtGuard } from '../../common/jwt.guard';
import {
CreateConversationDto,
CreateGuestConversationDto,
GuestIdBodyDto,
GuestSendMessageDto,
ListConversationsQueryDto,
SendMessageDto,
UpdateStatusDto,
} from './support.dto';
function userId(req: any): string {
const id = req?.user?.id ?? req?.user?.sub;
if (!id) throw new UnauthorizedException();
return id;
}
@ApiTags('Support')
@Controller('support')
export class SupportController {
constructor(private service: SupportService) {}
@Get('faq') @ApiOperation({ summary: 'Get FAQ categories' }) getFaqCategories() { return this.service.getFaqCategories(); }
@Get('faq/:categoryId/articles') @ApiOperation({ summary: 'Get FAQ articles for a category' }) getFaqArticles(@Param('categoryId') id: string) { return this.service.getFaqArticles(id); }
@Post('conversations') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Start a support conversation' }) startConversation(@Body('userId') userId: string) { return this.service.startConversation(userId); }
@Post('conversations/:id/messages') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Send a message in a conversation' }) sendMessage(@Param('id') id: string, @Body() body: { sender: 'USER' | 'BOT' | 'AGENT'; text: string }) { return this.service.sendMessage(id, body.sender, body.text); }
@Get('conversations/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get conversation with messages' }) getConversation(@Param('id') id: string) { return this.service.getConversation(id); }
// ---- FAQ (public) ------------------------------------------------------
@Get('faq')
@IsPublic()
@ApiOperation({ summary: 'Get FAQ categories' })
getFaqCategories() {
return this.service.getFaqCategories();
}
@Get('faq/:categoryId/articles')
@IsPublic()
@ApiOperation({ summary: 'Get FAQ articles for a category' })
getFaqArticles(@Param('categoryId') id: string) {
return this.service.getFaqArticles(id);
}
// ---- customer: authenticated passenger --------------------------------
@Post('conversations')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Open a new support conversation' })
createConversation(@Req() req: any, @Body() body: CreateConversationDto) {
return this.service.createConversation(userId(req), body);
}
@Get('conversations')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List my support conversations' })
listMine(@Req() req: any, @Query() query: ListConversationsQueryDto) {
return this.service.listForCustomer({ iamUserId: userId(req) }, query);
}
@Get('conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List messages in one of my conversations' })
messages(@Req() req: any, @Param('id') id: string) {
return this.service.getMessages(id, { iamUserId: userId(req) });
}
@Post('conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Send a message as the customer' })
send(@Req() req: any, @Param('id') id: string, @Body() body: SendMessageDto) {
return this.service.sendMessage(id, 'USER', body.text, {
iamUserId: userId(req),
});
}
@Post('conversations/:id/read')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Mark a conversation read (customer side)' })
read(@Req() req: any, @Param('id') id: string) {
return this.service.markRead(id, 'USER', { iamUserId: userId(req) });
}
@Get('unread-count')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Count my unread support conversations' })
unread(@Req() req: any) {
return this.service.unreadCount('USER', { iamUserId: userId(req) });
}
// ---- customer: guest (unauthenticated) --------------------------------
// No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer
// of access — anyone with it sees that thread; accepted MVP trade-off).
@Post('guest/conversations')
@IsPublic()
@ApiOperation({ summary: 'Open a support conversation as a guest' })
guestCreate(@Body() body: CreateGuestConversationDto) {
return this.service.createGuestConversation(body);
}
@Get('guest/conversations')
@IsPublic()
@ApiOperation({ summary: 'List a guest\'s conversations' })
guestList(
@Query('guestId') guestId: string,
@Query() query: ListConversationsQueryDto,
) {
return this.service.listForCustomer({ guestId }, query);
}
@Get('guest/conversations/:id/messages')
@IsPublic()
@ApiOperation({ summary: 'List messages in a guest conversation' })
guestMessages(@Param('id') id: string, @Query('guestId') guestId: string) {
return this.service.getMessages(id, { guestId });
}
@Post('guest/conversations/:id/messages')
@IsPublic()
@ApiOperation({ summary: 'Send a message as a guest' })
guestSend(@Param('id') id: string, @Body() body: GuestSendMessageDto) {
return this.service.sendMessage(id, 'USER', body.text, {
guestId: body.guestId,
});
}
@Post('guest/conversations/:id/read')
@IsPublic()
@ApiOperation({ summary: 'Mark a guest conversation read' })
guestRead(@Param('id') id: string, @Body() body: GuestIdBodyDto) {
return this.service.markRead(id, 'USER', { guestId: body.guestId });
}
@Get('guest/unread-count')
@IsPublic()
@ApiOperation({ summary: 'Count a guest\'s unread conversations' })
guestUnread(@Query('guestId') guestId: string) {
return this.service.unreadCount('USER', { guestId });
}
// ---- agent (backoffice) ------------------------------------------------
// TODO: gate agent routes with a staff permission once passenger RBAC is wired.
@Get('agent/conversations')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all support conversations (shared inbox)' })
agentList(@Query() query: ListConversationsQueryDto) {
return this.service.listForAgents(query);
}
@Get('agent/conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List messages in a conversation' })
agentMessages(@Param('id') id: string) {
return this.service.getMessages(id);
}
@Post('agent/conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reply as an agent' })
agentSend(@Param('id') id: string, @Body() body: SendMessageDto) {
return this.service.sendMessage(id, 'AGENT', body.text);
}
@Patch('agent/conversations/:id/status')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: "Change a conversation's status" })
agentStatus(@Param('id') id: string, @Body() body: UpdateStatusDto) {
return this.service.setStatus(id, body.status);
}
@Post('agent/conversations/:id/read')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Mark a conversation read (agent side)' })
agentRead(@Param('id') id: string) {
return this.service.markRead(id, 'AGENT');
}
@Get('agent/unread-count')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Count unread conversations (agent side)' })
agentUnread() {
return this.service.unreadCount('AGENT');
}
}

View File

@@ -0,0 +1,127 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsEmail,
IsEnum,
IsInt,
IsOptional,
IsString,
Length,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export enum SupportStatusDto {
OPEN = 'OPEN',
RESOLVED = 'RESOLVED',
CLOSED = 'CLOSED',
}
export class CreateConversationDto {
@ApiProperty({ description: 'Short subject / topic of the request.' })
@IsString()
@Length(3, 200)
subject!: string;
@ApiProperty({ description: 'The first message body.' })
@IsString()
@MinLength(1)
@MaxLength(4000)
initialMessage!: string;
}
export class SendMessageDto {
@ApiProperty({ description: 'Message text.' })
@IsString()
@MinLength(1)
@MaxLength(4000)
text!: string;
}
export class CreateGuestConversationDto {
@ApiProperty({ description: 'Client-generated anonymous id (localStorage).' })
@IsString()
@Length(8, 120)
guestId!: string;
@ApiProperty({ description: 'Guest full name.' })
@IsString()
@Length(1, 120)
name!: string;
@ApiProperty({ description: 'Guest email for follow-up.' })
@IsEmail()
email!: string;
@ApiPropertyOptional({ description: 'Guest phone (optional).' })
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string;
@ApiProperty()
@IsString()
@Length(3, 200)
subject!: string;
@ApiProperty()
@IsString()
@MinLength(1)
@MaxLength(4000)
initialMessage!: string;
}
export class GuestSendMessageDto {
@ApiProperty({ description: 'The guest id that owns the conversation.' })
@IsString()
@Length(8, 120)
guestId!: string;
@ApiProperty({ description: 'Message text.' })
@IsString()
@MinLength(1)
@MaxLength(4000)
text!: string;
}
export class GuestIdBodyDto {
@ApiProperty()
@IsString()
@Length(8, 120)
guestId!: string;
}
export class UpdateStatusDto {
@ApiProperty({ enum: SupportStatusDto })
@IsEnum(SupportStatusDto)
status!: SupportStatusDto;
}
export class ListConversationsQueryDto {
@ApiPropertyOptional({ enum: SupportStatusDto })
@IsOptional()
@IsEnum(SupportStatusDto)
status?: SupportStatusDto;
@ApiPropertyOptional({ description: 'Search subject / passenger name.' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}

View File

@@ -0,0 +1,134 @@
import { Logger } from '@nestjs/common';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Passenger as PassengerTypes } from '@edr/types';
import { PrismaService } from '../../common/prisma.service';
import { WsAuthService } from './ws-auth.service';
/**
* Server → client push for passenger support chat. Clients only *listen* (no
* `@SubscribeMessage`); the handshake is authenticated in `handleConnection`.
* Each socket joins a room based on its side:
* - backoffice staff → the shared `backoffice` room (see every conversation).
* - passengers → their `user:<iamUserId>` room (their own tickets only).
*
* Side is decided by the presence of a `Passenger` row for the IAM user id
* (staff have none). A message is emitted to BOTH the owner's room and the
* backoffice room so the customer thread, the sender's echo, and every agent's
* inbox update live.
*/
@WebSocketGateway({
namespace: PassengerTypes.PASSENGER_SUPPORT_WS_NAMESPACE,
cors: { origin: true, credentials: true },
})
export class SupportGateway implements OnGatewayConnection {
private readonly logger = new Logger(SupportGateway.name);
private static readonly BACKOFFICE_ROOM = 'backoffice';
@WebSocketServer()
private readonly server!: Server;
constructor(
private readonly wsAuth: WsAuthService,
private readonly prisma: PrismaService,
) {}
async handleConnection(socket: Socket): Promise<void> {
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
// Authenticated: passenger (own room) or backoffice staff (shared room).
if (userId) {
socket.data.userId = userId;
const passenger = await this.prisma.passenger.findUnique({
where: { iamUserId: userId },
});
if (passenger) {
await socket.join(`user:${userId}`);
socket.data.side = 'USER';
} else {
await socket.join(SupportGateway.BACKOFFICE_ROOM);
socket.data.side = 'AGENT';
}
return;
}
// Guest: no valid token, but a client-generated guestId scopes the room.
// Anyone holding the guestId can see that thread (no account = weaker
// ownership) — an accepted MVP trade-off for guest support.
const guestId = this.extractGuestId(socket);
if (guestId) {
socket.data.guestId = guestId;
socket.data.side = 'USER';
await socket.join(`guest:${guestId}`);
return;
}
this.logger.debug(`Rejected passenger-support handshake ${socket.id}`);
socket.disconnect(true);
}
/** Push a new message + updated conversation to the owner + backoffice rooms. */
emitMessage(
ownerRoom: string | null,
conversation: PassengerTypes.PassengerSupportConversationDto,
message: PassengerTypes.PassengerSupportMessageDto,
): void {
const payload = { conversation, message };
for (const room of this.targetRooms(ownerRoom)) {
const to = this.server.to(room);
to.emit(PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW, payload);
to.emit(
PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED,
conversation,
);
}
}
/** Push a conversation metadata change (e.g. status) to both rooms. */
emitConversationUpdated(
ownerRoom: string | null,
conversation: PassengerTypes.PassengerSupportConversationDto,
): void {
for (const room of this.targetRooms(ownerRoom)) {
this.server
.to(room)
.emit(
PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED,
conversation,
);
}
}
private targetRooms(ownerRoom: string | null): string[] {
const rooms = [SupportGateway.BACKOFFICE_ROOM];
if (ownerRoom) rooms.push(ownerRoom);
return rooms;
}
private extractGuestId(socket: Socket): string | undefined {
const authGuest = socket.handshake.auth?.guestId as string | undefined;
if (authGuest) return authGuest;
const queryGuest = socket.handshake.query?.guestId;
if (typeof queryGuest === 'string') return queryGuest;
return undefined;
}
private extractToken(socket: Socket): string | undefined {
const authToken = socket.handshake.auth?.token as string | undefined;
if (authToken) return authToken;
const queryToken = socket.handshake.query?.token;
if (typeof queryToken === 'string') return queryToken;
const header = socket.handshake.headers?.authorization;
if (header?.startsWith('Bearer ')) return header.slice(7);
return undefined;
}
}

View File

@@ -1,6 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
import { SupportController } from './support.controller';
import { SupportService } from './support.service';
import { SupportGateway } from './support.gateway';
import { WsAuthService } from './ws-auth.service';
@Module({ controllers: [SupportController], providers: [SupportService] })
@Module({
// Session is served by the app's default TypeORM DataSource (IAM schema) —
// used by WsAuthService to authenticate WebSocket handshakes.
imports: [TypeOrmModule.forFeature([Session])],
controllers: [SupportController],
providers: [SupportService, SupportGateway, WsAuthService],
})
export class SupportModule {}

View File

@@ -1,35 +1,419 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Passenger as T } from '@edr/types';
import { PrismaService } from '../../common/prisma.service';
import { SupportGateway } from './support.gateway';
type Side = 'USER' | 'AGENT';
type PrismaSender = 'USER' | 'BOT' | 'AGENT';
type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED';
/** Who the caller is on the customer side: an authed passenger or a guest. */
export interface CustomerOwner {
iamUserId?: string | null;
guestId?: string | null;
}
interface ListQuery {
status?: PrismaStatus;
search?: string;
page?: number;
limit?: number;
}
type ConversationRow = {
id: string;
userId: string | null;
guestId: string | null;
guestName: string | null;
guestEmail: string | null;
guestPhone: string | null;
passengerId: string | null;
passengerName: string | null;
subject: string | null;
status: PrismaStatus;
assignedAgentId: string | null;
lastMessageAt: Date | null;
lastMessagePreview: string | null;
lastMessageSender: PrismaSender | null;
userLastReadAt: Date | null;
agentLastReadAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
@Injectable()
export class SupportService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private gateway: SupportGateway,
) {}
getFaqCategories() { return this.prisma.faqCategory.findMany({ include: { _count: { select: { articles: true } } } }); }
// ---- FAQ (unchanged) ---------------------------------------------------
getFaqArticles(categoryId: string) { return this.prisma.faqArticle.findMany({ where: { categoryId }, orderBy: { rank: 'asc' } }); }
startConversation(userId: string) { return this.prisma.supportConversation.create({ data: { userId } }); }
async sendMessage(conversationId: string, sender: 'USER' | 'BOT' | 'AGENT', text: string) {
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId } });
if (!conv) throw new NotFoundException('Conversation not found');
const message = await this.prisma.supportMessage.create({ data: { conversationId, sender, text } });
if (sender === 'USER') await this.prisma.supportMessage.create({ data: { conversationId, sender: 'BOT', text: this.getBotReply(text) } });
return message;
getFaqCategories() {
return this.prisma.faqCategory.findMany({
include: { _count: { select: { articles: true } } },
});
}
async getConversation(conversationId: string) {
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId }, include: { messages: { orderBy: { createdAt: 'asc' } } } });
if (!conv) throw new NotFoundException('Conversation not found');
return conv;
getFaqArticles(categoryId: string) {
return this.prisma.faqArticle.findMany({
where: { categoryId },
orderBy: { rank: 'asc' },
});
}
private getBotReply(text: string): string {
const lower = text.toLowerCase();
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to Bookings and select the booking. Refunds are processed within 3-5 business days.';
if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.';
if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.';
return 'Thank you for contacting EDR support. An agent will assist you shortly.';
// ---- customer: authed passenger ---------------------------------------
async createConversation(
iamUserId: string,
input: { subject: string; initialMessage: string },
): Promise<T.PassengerSupportConversationDto> {
const passenger = await this.prisma.passenger.findUnique({
where: { iamUserId },
include: { user: { select: { fullName: true } } },
});
const conversation = (await this.prisma.supportConversation.create({
data: {
userId: iamUserId,
passengerId: passenger?.id ?? null,
passengerName: passenger?.user?.fullName ?? null,
subject: input.subject,
status: 'OPEN',
},
})) as ConversationRow;
return this.firstMessage(conversation, input.initialMessage);
}
// ---- customer: guest (unauthenticated) --------------------------------
async createGuestConversation(input: {
guestId: string;
name: string;
email: string;
phone?: string;
subject: string;
initialMessage: string;
}): Promise<T.PassengerSupportConversationDto> {
const conversation = (await this.prisma.supportConversation.create({
data: {
guestId: input.guestId,
guestName: input.name,
guestEmail: input.email,
guestPhone: input.phone ?? null,
passengerName: input.name, // uniform display name for the agent inbox
subject: input.subject,
status: 'OPEN',
},
})) as ConversationRow;
return this.firstMessage(conversation, input.initialMessage);
}
async listForCustomer(
owner: CustomerOwner,
query: ListQuery,
): Promise<T.PassengerSupportConversationListResult> {
const scope = this.ownerScope(owner);
const where = { ...this.listWhere(query), ...scope };
const rows = (await this.prisma.supportConversation.findMany({
where,
orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }],
take: query.limit ?? 100,
skip: ((query.page ?? 1) - 1) * (query.limit ?? 100),
})) as ConversationRow[];
const count = await this.prisma.supportConversation.count({ where });
return this.buildListResult(rows, count, 'USER');
}
// ---- agent (backoffice) ------------------------------------------------
async listForAgents(
query: ListQuery,
): Promise<T.PassengerSupportConversationListResult> {
const where = this.listWhere(query);
const rows = (await this.prisma.supportConversation.findMany({
where,
orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }],
take: query.limit ?? 100,
skip: ((query.page ?? 1) - 1) * (query.limit ?? 100),
})) as ConversationRow[];
const count = await this.prisma.supportConversation.count({ where });
return this.buildListResult(rows, count, 'AGENT');
}
async setStatus(
conversationId: string,
status: PrismaStatus,
): Promise<T.PassengerSupportConversationDto> {
await this.requireConversation(conversationId);
const updated = (await this.prisma.supportConversation.update({
where: { id: conversationId },
data: { status },
})) as ConversationRow;
const dto = this.toConversationDto(updated, 0);
this.gateway.emitConversationUpdated(this.ownerRoom(updated), dto);
return dto;
}
// ---- shared ------------------------------------------------------------
async getMessages(
conversationId: string,
asCustomer?: CustomerOwner,
): Promise<T.PassengerSupportMessageDto[]> {
const conversation = await this.requireConversation(conversationId);
if (asCustomer) this.assertOwns(conversation, asCustomer);
const rows = await this.prisma.supportMessage.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
});
return rows.map((m) => this.toMessageDto(m));
}
async sendMessage(
conversationId: string,
sender: Side,
text: string,
asCustomer?: CustomerOwner,
): Promise<T.PassengerSupportMessageDto> {
const conversation = await this.requireConversation(conversationId);
if (sender === 'USER') {
this.assertOwns(conversation, asCustomer ?? {});
}
const updated = await this.appendMessage(conversation, sender, text);
const last = updated.messages[updated.messages.length - 1];
return this.toMessageDto(last);
}
async markRead(
conversationId: string,
side: Side,
asCustomer?: CustomerOwner,
): Promise<{ unreadCount: number }> {
const conversation = await this.requireConversation(conversationId);
if (side === 'USER') {
this.assertOwns(conversation, asCustomer ?? {});
await this.prisma.supportConversation.update({
where: { id: conversationId },
data: { userLastReadAt: new Date() },
});
return this.unreadCount('USER', asCustomer);
}
await this.prisma.supportConversation.update({
where: { id: conversationId },
data: { agentLastReadAt: new Date() },
});
return this.unreadCount('AGENT');
}
async unreadCount(
side: Side,
owner?: CustomerOwner,
): Promise<{ unreadCount: number }> {
const rows = (await this.prisma.supportConversation.findMany({
where: side === 'USER' ? this.ownerScope(owner ?? {}) : {},
select: { id: true, userLastReadAt: true, agentLastReadAt: true },
})) as Array<{
id: string;
userLastReadAt: Date | null;
agentLastReadAt: Date | null;
}>;
const map = await this.computeUnread(rows, side);
let unreadCount = 0;
for (const n of map.values()) if (n > 0) unreadCount++;
return { unreadCount };
}
// ---- internals ---------------------------------------------------------
private async firstMessage(
conversation: ConversationRow,
text: string,
): Promise<T.PassengerSupportConversationDto> {
const { conversation: updated } = await this.appendMessageRaw(
conversation,
'USER',
text,
);
return this.toConversationDto(updated, 0);
}
private async appendMessage(
conversation: ConversationRow,
sender: PrismaSender,
text: string,
) {
const { conversation: updated } = await this.appendMessageRaw(
conversation,
sender,
text,
);
return updated as ConversationRow & { messages: any[] };
}
/** Persist a message, bump the conversation's denormalized fields, emit live. */
private async appendMessageRaw(
conversation: ConversationRow,
sender: PrismaSender,
text: string,
): Promise<{ conversation: ConversationRow & { messages: any[] }; message: any }> {
const message = await this.prisma.supportMessage.create({
data: { conversationId: conversation.id, sender, text },
});
const updated = (await this.prisma.supportConversation.update({
where: { id: conversation.id },
data: {
lastMessageAt: message.createdAt,
lastMessagePreview: text.slice(0, 280),
lastMessageSender: sender,
},
include: { messages: { orderBy: { createdAt: 'asc' } } },
})) as ConversationRow & { messages: any[] };
const dto = this.toConversationDto(updated, 0);
this.gateway.emitMessage(this.ownerRoom(updated), dto, this.toMessageDto(message));
return { conversation: updated, message };
}
private async buildListResult(
rows: ConversationRow[],
count: number,
side: Side,
): Promise<T.PassengerSupportConversationListResult> {
const unreadMap = await this.computeUnread(rows, side);
const items = rows.map((r) =>
this.toConversationDto(r, unreadMap.get(r.id) ?? 0),
);
let unreadCount = 0;
for (const n of unreadMap.values()) if (n > 0) unreadCount++;
return { items, count, unreadCount };
}
private async computeUnread(
rows: Array<{
id: string;
userLastReadAt: Date | null;
agentLastReadAt: Date | null;
}>,
side: Side,
): Promise<Map<string, number>> {
const map = new Map<string, number>();
if (rows.length === 0) return map;
const otherSender: PrismaSender = side === 'USER' ? 'AGENT' : 'USER';
const ids = rows.map((r) => r.id);
const msgs = await this.prisma.supportMessage.findMany({
where: { conversationId: { in: ids }, sender: otherSender },
select: { conversationId: true, createdAt: true },
});
const cursorById = new Map(
rows.map((r) => [
r.id,
side === 'USER' ? r.userLastReadAt : r.agentLastReadAt,
]),
);
for (const m of msgs) {
const cursor = cursorById.get(m.conversationId) ?? null;
if (!cursor || m.createdAt > cursor) {
map.set(m.conversationId, (map.get(m.conversationId) ?? 0) + 1);
}
}
return map;
}
private listWhere(query: ListQuery) {
const where: Record<string, unknown> = {};
if (query.status) where.status = query.status;
if (query.search?.trim()) {
const contains = query.search.trim();
where.OR = [
{ subject: { contains, mode: 'insensitive' } },
{ passengerName: { contains, mode: 'insensitive' } },
{ guestEmail: { contains, mode: 'insensitive' } },
];
}
return where;
}
/** Prisma where-fragment scoping to the calling customer (authed or guest). */
private ownerScope(owner: CustomerOwner): Record<string, unknown> {
if (owner.iamUserId) return { userId: owner.iamUserId };
if (owner.guestId) return { guestId: owner.guestId };
// No identity ⇒ match nothing.
return { id: '__none__' };
}
private assertOwns(conversation: ConversationRow, owner: CustomerOwner): void {
const ok =
(owner.iamUserId && conversation.userId === owner.iamUserId) ||
(owner.guestId && conversation.guestId === owner.guestId);
if (!ok) {
throw new ForbiddenException('This conversation belongs to someone else.');
}
}
private ownerRoom(c: ConversationRow): string | null {
if (c.guestId) return `guest:${c.guestId}`;
if (c.userId) return `user:${c.userId}`;
return null;
}
private async requireConversation(id: string): Promise<ConversationRow> {
const conversation = (await this.prisma.supportConversation.findUnique({
where: { id },
})) as ConversationRow | null;
if (!conversation) throw new NotFoundException('Conversation not found');
return conversation;
}
private toConversationDto(
c: ConversationRow,
unreadCount: number,
): T.PassengerSupportConversationDto {
return {
id: c.id,
userId: c.userId,
guestId: c.guestId,
guestEmail: c.guestEmail,
guestPhone: c.guestPhone,
passengerId: c.passengerId,
passengerName: c.passengerName ?? c.guestName ?? null,
subject: c.subject,
status: c.status as T.PassengerSupportStatus,
assignedAgentId: c.assignedAgentId,
lastMessageAt: c.lastMessageAt ? c.lastMessageAt.toISOString() : null,
lastMessagePreview: c.lastMessagePreview,
lastMessageSender: this.toDtoSender(c.lastMessageSender),
unreadCount,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
};
}
private toMessageDto(m: {
id: string;
conversationId: string;
sender: PrismaSender;
text: string;
createdAt: Date;
}): T.PassengerSupportMessageDto {
return {
id: m.id,
conversationId: m.conversationId,
sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT,
text: m.text,
createdAt: m.createdAt.toISOString(),
};
}
/** Legacy BOT messages are surfaced as AGENT to the UI. */
private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null {
if (!s) return null;
return s === 'USER'
? T.PassengerSupportSender.USER
: T.PassengerSupportSender.AGENT;
}
}

View File

@@ -0,0 +1,48 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { verifyToken } from '@tria-plc/api-common/utils/token';
import { ESessionStatus } from '@tria-plc/api-common/utils/enums/user.enum';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
/**
* Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access
* token payload is only a *session* pointer (`{ id: <sessionId> }`), not the
* user — so we verify the signature (`verifyToken`), then load the IAM session
* and require it to be ACTIVE and unexpired, and read the real user id out of
* `session.userInfo`. The `Session` entity is served by the app's default
* TypeORM DataSource (the same one the shared JwtGuard queries for `iam.sessions`).
*
* Returns the IAM user id, or null for any invalid/expired/revoked/malformed token.
*/
@Injectable()
export class WsAuthService {
private readonly logger = new Logger(WsAuthService.name);
constructor(
@InjectRepository(Session)
private readonly sessions: Repository<Session>,
) {}
async resolveUserId(token?: string): Promise<string | null> {
if (!token) return null;
try {
const payload = verifyToken(token) as { id?: string };
const sessionId = payload?.id;
if (!sessionId) return null;
const session = await this.sessions.findOne({ where: { id: sessionId } });
if (!session) return null;
if (session.status !== ESessionStatus.ACTIVE) return null;
if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) {
return null;
}
return session.userInfo?.id ?? null;
} catch (err) {
this.logger.debug(`WS auth rejected: ${(err as Error).message}`);
return null;
}
}
}