Merge pull request #511 from Tria-plc/freight/feat/chat-app

chat app support to the passenger with websocket and anonymous
This commit is contained in:
Nathnael Wondisha
2026-07-07 17:16:10 +03:00
committed by GitHub
28 changed files with 2387 additions and 102 deletions

View File

@@ -31,10 +31,12 @@
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/microservices": "^11.1.24",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.27",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^11.1.27",
"@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
@@ -54,6 +56,7 @@
"qrcode": "^1.5.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.0",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.3.30",

View File

@@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "SupportConversation" ADD COLUMN "agentLastReadAt" TIMESTAMP(3),
ADD COLUMN "lastMessageAt" TIMESTAMP(3),
ADD COLUMN "lastMessagePreview" TEXT,
ADD COLUMN "lastMessageSender" "SupportSender",
ADD COLUMN "passengerId" TEXT,
ADD COLUMN "passengerName" TEXT,
ADD COLUMN "subject" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "userLastReadAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "SupportConversation_userId_idx" ON "SupportConversation"("userId");
-- CreateIndex
CREATE INDEX "SupportConversation_status_lastMessageAt_idx" ON "SupportConversation"("status", "lastMessageAt");
-- AddForeignKey
ALTER TABLE "SupportConversation" ADD CONSTRAINT "SupportConversation_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "SupportConversation" ADD COLUMN "guestEmail" TEXT,
ADD COLUMN "guestId" TEXT,
ADD COLUMN "guestName" TEXT,
ADD COLUMN "guestPhone" TEXT,
ALTER COLUMN "userId" DROP NOT NULL;
-- CreateIndex
CREATE INDEX "SupportConversation_guestId_idx" ON "SupportConversation"("guestId");

View File

@@ -299,6 +299,7 @@ model Passenger {
travelerProfiles TravelerProfile[]
savedRoutes SavedRoute[]
packageBookings PackageBooking[]
supportConversations SupportConversation[]
@@index([userId])
@@index([iamUserId])
@@schema("passenger")
@@ -881,12 +882,29 @@ model FaqArticle {
}
model SupportConversation {
id String @id @default(uuid())
userId String
assignedAgentId String?
status SupportConversationStatus @default(OPEN)
createdAt DateTime @default(now())
messages SupportMessage[]
id String @id @default(uuid())
userId String?
guestId String?
guestName String?
guestEmail String?
guestPhone String?
passengerId String?
passengerName String?
subject String?
assignedAgentId String?
status SupportConversationStatus @default(OPEN)
lastMessageAt DateTime?
lastMessagePreview String?
lastMessageSender SupportSender?
userLastReadAt DateTime?
agentLastReadAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
messages SupportMessage[]
passenger Passenger? @relation(fields: [passengerId], references: [id])
@@index([userId])
@@index([guestId])
@@index([status, lastMessageAt])
@@schema("passenger")
}

View File

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