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")
@@ -882,11 +883,28 @@ model FaqArticle {
model SupportConversation {
id String @id @default(uuid())
userId String
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;
}
}
}

View File

@@ -21,6 +21,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.12.0",
"socket.io-client": "^4.8.3",
"zustand": "^5.0.0"
},
"devDependencies": {

View File

@@ -1,66 +1,362 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { supportApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { Passenger } from '@edr/types';
import { Headset, Search, Send, User } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useConversations,
useMarkRead,
useMessages,
useSendMessage,
useSetStatus,
} from '@/features/support/useSupport';
import { useSupportSocket } from '@/features/support/useSupportSocket';
const GREEN = 'rgb(20 113 76)';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
const STATUS_CLASS: Record<string, string> = {
OPEN: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
RESOLVED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
CLOSED: 'bg-gray-200 text-gray-600 dark:bg-slate-700 dark:text-slate-300',
};
const FILTERS = ['ALL', 'OPEN', 'RESOLVED', 'CLOSED'] as const;
function formatTime(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
return d.toDateString() === now.toDateString()
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
export default function SupportPage() {
const [filters, setFilters] = useState({ search: '', status: '' });
const [status, setStatus] = useState<(typeof FILTERS)[number]>('ALL');
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['support', filters],
queryFn: () => supportApi.getConversations(filters),
});
const { data, isLoading } = useConversations(
status === 'ALL' ? { search } : { status, search },
);
const items = data?.items ?? [];
const columns = [
{ key: 'subject', label: 'Subject', render: (conv: any) => conv.subject || 'No Subject' },
{ key: 'passenger', label: 'Passenger', render: (conv: any) => conv.passenger?.fullName || 'N/A' },
{ key: 'status', label: 'Status', render: (conv: any) => <Badge variant="status" status={conv.status}>{conv.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (conv: any) => formatDateTime(conv.createdAt) },
];
useSupportSocket(true);
const selected = useMemo(
() => items.find((c) => c.id === selectedId) ?? null,
[items, selectedId],
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<span
className="flex h-10 w-10 items-center justify-center rounded-lg"
style={{ background: 'rgba(20,113,76,0.1)', color: GREEN }}
>
<Headset size={20} />
</span>
<div>
<h1 className="text-2xl font-bold text-foreground">Support Center</h1>
<p className="text-muted-foreground">Manage customer support conversations</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="OPEN">Open</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="RESOLVED">Resolved</option>
<option value="CLOSED">Closed</option>
</select>
</div>
<p className="text-sm text-muted-foreground">
Shared inbox respond to passenger requests in real time
</p>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No support center found"
<div className="flex h-[calc(100vh-220px)] overflow-hidden rounded-xl border border-border bg-card">
{/* Conversation list */}
<div className="flex w-[340px] shrink-0 flex-col border-r border-border">
<div className="space-y-2 border-b border-border p-3">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-2.5 text-muted-foreground"
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search subject or passenger"
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm outline-none focus:border-emerald-500"
/>
</div>
<div className="flex gap-1">
{FILTERS.map((f) => (
<button
key={f}
onClick={() => setStatus(f)}
className={`flex-1 rounded-md px-2 py-1 text-xs font-medium capitalize transition ${
status === f
? 'text-white'
: 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
style={status === f ? { background: GREEN } : undefined}
>
{f.toLowerCase()}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
Loading
</div>
) : items.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
No conversations.
</div>
) : (
items.map((c) => (
<InboxRow
key={c.id}
c={c}
active={c.id === selectedId}
onClick={() => setSelectedId(c.id)}
/>
))
)}
</div>
</div>
{/* Thread */}
<div className="min-w-0 flex-1">
{selected ? (
<ConversationThread conversation={selected} />
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<span
className="flex h-14 w-14 items-center justify-center rounded-full"
style={{ background: 'rgba(20,113,76,0.1)', color: GREEN }}
>
<Headset size={28} />
</span>
<p className="text-sm">Select a conversation to start replying.</p>
</div>
)}
</div>
</div>
</div>
);
}
function InboxRow({
c,
active,
onClick,
}: {
c: ConversationDto;
active: boolean;
onClick: () => void;
}) {
const unread = c.unreadCount > 0;
return (
<button
onClick={onClick}
className={`block w-full border-b border-border px-4 py-3 text-left transition hover:bg-muted/50 ${
active ? 'bg-muted/70' : ''
}`}
style={active ? { borderLeft: `3px solid ${GREEN}` } : { borderLeft: '3px solid transparent' }}
>
<div className="flex items-center justify-between gap-2">
<span
className={`truncate text-sm ${
unread ? 'font-bold text-foreground' : 'font-semibold text-foreground/90'
}`}
>
{c.subject || 'Support request'}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatTime(c.lastMessageAt)}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{c.passengerName || 'Passenger'}
</p>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="truncate text-xs text-muted-foreground">
{c.lastMessageSender === 'AGENT' ? 'You: ' : ''}
{c.lastMessagePreview ?? '—'}
</span>
{unread ? (
<span
className="flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold text-white"
style={{ background: GREEN }}
>
{c.unreadCount}
</span>
) : (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${
STATUS_CLASS[c.status] ?? ''
}`}
>
{c.status.toLowerCase()}
</span>
)}
</div>
</button>
);
}
function ConversationThread({ conversation }: { conversation: ConversationDto }) {
const { data: messages, isLoading } = useMessages(conversation.id);
const send = useSendMessage(conversation.id);
const setStatus = useSetStatus();
const markRead = useMarkRead();
const [draft, setDraft] = useState('');
const viewport = useRef<HTMLDivElement>(null);
useEffect(() => {
markRead.mutate(conversation.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversation.id, messages?.length]);
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [messages?.length, conversation.id]);
const submit = async () => {
const text = draft.trim();
if (!text) return;
setDraft('');
await send.mutateAsync(text);
};
const changeStatus = (status: string) =>
setStatus.mutate({ id: conversation.id, status });
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b border-border p-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-bold text-foreground">
{conversation.subject || 'Conversation'}
</span>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${
STATUS_CLASS[conversation.status] ?? ''
}`}
>
{conversation.status.toLowerCase()}
</span>
</div>
<p className="truncate text-xs text-muted-foreground">
{conversation.passengerName || 'Passenger'}
{conversation.guestId ? ' · Guest' : ''}
{conversation.guestEmail ? ` · ${conversation.guestEmail}` : ''}
{conversation.guestPhone ? ` · ${conversation.guestPhone}` : ''}
</p>
</div>
<div className="flex shrink-0 gap-2">
{conversation.status !== 'OPEN' && (
<button
onClick={() => changeStatus('OPEN')}
className="rounded-md px-3 py-1.5 text-xs font-medium text-white"
style={{ background: GREEN }}
>
Reopen
</button>
)}
{conversation.status === 'OPEN' && (
<button
onClick={() => changeStatus('RESOLVED')}
className="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Resolve
</button>
)}
{conversation.status !== 'CLOSED' && (
<button
onClick={() => changeStatus('CLOSED')}
className="rounded-md bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted/70"
>
Close
</button>
)}
</div>
</div>
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
Loading
</div>
) : (
(messages ?? []).map((m) => <AgentBubble key={m.id} m={m} />)
)}
</div>
<div className="border-t border-border p-3">
<div className="flex items-end gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder="Type your reply… (Enter to send, Shift+Enter for newline)"
className="max-h-28 flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-emerald-500"
/>
<button
onClick={submit}
disabled={!draft.trim() || send.isPending}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white transition hover:opacity-90 disabled:opacity-50"
style={{ background: GREEN }}
aria-label="Send"
>
<Send size={18} />
</button>
</div>
</div>
</div>
);
}
function AgentBubble({ m }: { m: MessageDto }) {
const mine = m.sender === 'AGENT';
return (
<div className={`flex ${mine ? 'justify-end' : 'justify-start'} items-end gap-2`}>
{!mine && (
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-muted-foreground">
<User size={14} />
</span>
)}
<div className="max-w-[70%]">
<p
className={`mb-0.5 text-xs text-muted-foreground ${
mine ? 'text-right' : 'text-left'
}`}
>
{mine ? m.authorName || 'You' : m.authorName || 'Passenger'}
</p>
<div
className={`whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm ${
mine
? 'rounded-br-sm text-white'
: 'rounded-bl-sm bg-muted text-foreground'
}`}
style={mine ? { background: GREEN } : undefined}
>
{m.text}
</div>
<p
className={`mt-0.5 text-[10px] text-muted-foreground ${
mine ? 'text-right' : 'text-left'
}`}
>
{formatTime(m.createdAt)}
</p>
</div>
</div>
);
}

View File

@@ -104,7 +104,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Customer Services',
items: [
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view },
// { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
{ name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
{ name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send },
]
},

View File

@@ -0,0 +1,36 @@
import type { Passenger } from '@edr/types';
import { apiClient } from '@/lib/api-client';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
type ListResult = Passenger.PassengerSupportConversationListResult;
export interface ListParams {
status?: string;
search?: string;
}
/** Passenger backoffice (agent) support-chat REST calls (client unwraps envelope). */
export const supportApi = {
listConversations: (params: ListParams = {}) =>
apiClient.get<ListResult>('/support/agent/conversations', { params }),
listMessages: (id: string) =>
apiClient.get<MessageDto[]>(`/support/agent/conversations/${id}/messages`),
sendMessage: (id: string, text: string) =>
apiClient.post<MessageDto>(
`/support/agent/conversations/${id}/messages`,
{ text },
),
setStatus: (id: string, status: string) =>
apiClient.patch<ConversationDto>(
`/support/agent/conversations/${id}/status`,
{ status },
),
markRead: (id: string) =>
apiClient.post<{ unreadCount: number }>(
`/support/agent/conversations/${id}/read`,
),
unreadCount: () =>
apiClient.get<{ unreadCount: number }>('/support/agent/unread-count'),
};

View File

@@ -0,0 +1,65 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { supportApi, type ListParams } from './supportApi';
export const SUPPORT_CONVERSATIONS_KEY = ['support', 'conversations'];
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
export const supportMessagesKey = (id: string) => ['support', 'messages', id];
export function useConversations(params: ListParams = {}) {
return useQuery({
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
queryFn: () => supportApi.listConversations(params),
});
}
export function useMessages(conversationId: string | null) {
return useQuery({
queryKey: supportMessagesKey(conversationId ?? ''),
queryFn: () => supportApi.listMessages(conversationId as string),
enabled: !!conversationId,
});
}
export function useUnreadCount(enabled = true) {
return useQuery({
queryKey: SUPPORT_UNREAD_KEY,
queryFn: () => supportApi.unreadCount(),
enabled,
refetchInterval: 60_000,
});
}
export function useSendMessage(conversationId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (text: string) => supportApi.sendMessage(conversationId, text),
onSuccess: () => {
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
},
});
}
export function useSetStatus() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
supportApi.setStatus(id, status),
onSuccess: () =>
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }),
});
}
export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => supportApi.markRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
},
});
}

View File

@@ -0,0 +1,67 @@
'use client';
import { Passenger } from '@edr/types';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { io } from 'socket.io-client';
import {
SUPPORT_CONVERSATIONS_KEY,
SUPPORT_UNREAD_KEY,
supportMessagesKey,
} from './useSupport';
const SOCKET_ORIGIN = String(
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
).replace(/\/api\/?$/, '');
/**
* Subscribes the signed-in agent to live support pushes for the whole shared
* inbox. Any new message / conversation change refreshes the thread, the inbox
* list, and the unread badge; `onMessage` fires for optional toasts.
*/
export function useSupportSocket(
enabled: boolean,
onMessage?: (event: Passenger.PassengerSupportMessageEvent) => void,
) {
const qc = useQueryClient();
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
if (!enabled || typeof window === 'undefined') return;
const token = localStorage.getItem('auth_token');
if (!token) return;
const socket = io(
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
{
auth: { token },
transports: ['websocket'],
withCredentials: true,
},
);
socket.on(
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
(event: Passenger.PassengerSupportMessageEvent) => {
qc.invalidateQueries({
queryKey: supportMessagesKey(event.message.conversationId),
});
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
},
);
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
});
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}

View File

@@ -28,6 +28,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.0",
"socket.io-client": "^4.8.3",
"zod": "^3.22.4",
"zustand": "^5.0.0"
},

View File

@@ -5,6 +5,7 @@ import { Providers } from './providers';
import AppHeader from '@/components/AppHeader';
import { Footer } from '@/components/Footer';
import { LoadingIndicator } from '@/components/LoadingIndicator';
import SupportWidget from '@/features/support/SupportWidget';
export const metadata: Metadata = {
title: 'EDR Passenger Portal - Book your train journey',
@@ -51,6 +52,7 @@ export default function RootLayout({
{children}
</main>
<Footer />
<SupportWidget />
</Providers>
</body>
</html>

View File

@@ -0,0 +1,445 @@
'use client';
import { Passenger } from '@edr/types';
import { ArrowLeft, Headset, Plus, Send, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useConversations,
useCreateConversation,
useMarkRead,
useMessages,
useSendMessage,
} from './useSupport';
const GREEN = 'rgb(20 113 76)';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
const STATUS_CLASS: Record<string, string> = {
OPEN: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
RESOLVED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
CLOSED: 'bg-gray-200 text-gray-600 dark:bg-slate-700 dark:text-slate-300',
};
function formatTime(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
return d.toDateString() === now.toDateString()
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
type View = { kind: 'list' } | { kind: 'new' } | { kind: 'thread'; id: string };
export function SupportPanel({
onClose,
isGuest = false,
}: {
onClose: () => void;
isGuest?: boolean;
}) {
const [view, setView] = useState<View>({ kind: 'list' });
return (
<div className="flex h-[560px] max-h-[calc(100vh-120px)] w-[min(384px,calc(100vw-32px))] flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
{view.kind === 'list' && (
<ConversationList
onClose={onClose}
onNew={() => setView({ kind: 'new' })}
onOpen={(id) => setView({ kind: 'thread', id })}
/>
)}
{view.kind === 'new' && (
<NewConversation
isGuest={isGuest}
onClose={onClose}
onBack={() => setView({ kind: 'list' })}
onCreated={(id) => setView({ kind: 'thread', id })}
/>
)}
{view.kind === 'thread' && (
<Thread
conversationId={view.id}
onClose={onClose}
onBack={() => setView({ kind: 'list' })}
/>
)}
</div>
);
}
function Header({
title,
subtitle,
onClose,
onBack,
}: {
title: string;
subtitle?: string;
onClose: () => void;
onBack?: () => void;
}) {
return (
<div
className="flex items-center justify-between gap-2 px-4 py-3 text-white"
style={{
background: `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))`,
}}
>
<div className="flex min-w-0 items-center gap-2">
{onBack ? (
<button
onClick={onBack}
className="rounded-full p-1 hover:bg-white/20"
aria-label="Back"
>
<ArrowLeft size={20} />
</button>
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
<Headset size={18} />
</span>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{title}</p>
{subtitle && (
<p className="truncate text-xs text-white/80">{subtitle}</p>
)}
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-1 hover:bg-white/20"
aria-label="Close"
>
<X size={20} />
</button>
</div>
);
}
function ConversationList({
onClose,
onNew,
onOpen,
}: {
onClose: () => void;
onNew: () => void;
onOpen: (id: string) => void;
}) {
const { data, isLoading } = useConversations();
const items = data?.items ?? [];
return (
<>
<Header
title="Support"
subtitle="We usually reply within a few minutes"
onClose={onClose}
/>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-6 py-12 text-center text-sm text-gray-500 dark:text-slate-400">
<span
className="flex h-12 w-12 items-center justify-center rounded-full"
style={{ background: 'rgba(20,113,76,0.1)', color: GREEN }}
>
<Headset size={24} />
</span>
No conversations yet. Start one and our team will help you out.
</div>
) : (
items.map((c) => (
<ConversationRow key={c.id} c={c} onClick={() => onOpen(c.id)} />
))
)}
</div>
<div className="border-t border-gray-100 p-3 dark:border-slate-700">
<button
onClick={onNew}
className="flex w-full items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
style={{ background: GREEN }}
>
<Plus size={16} /> New request
</button>
</div>
</>
);
}
function ConversationRow({
c,
onClick,
}: {
c: ConversationDto;
onClick: () => void;
}) {
const unread = c.unreadCount > 0;
return (
<button
onClick={onClick}
className={`block w-full border-b border-gray-100 px-4 py-3 text-left transition hover:bg-gray-50 dark:border-slate-800 dark:hover:bg-slate-800/60 ${
unread ? 'bg-emerald-50/60 dark:bg-emerald-900/10' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span
className={`truncate text-sm ${
unread
? 'font-bold text-gray-900 dark:text-white'
: 'font-semibold text-gray-800 dark:text-slate-200'
}`}
>
{c.subject || 'Support request'}
</span>
<span className="shrink-0 text-xs text-gray-400">
{formatTime(c.lastMessageAt)}
</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="truncate text-xs text-gray-500 dark:text-slate-400">
{c.lastMessageSender === 'AGENT' ? 'Support: ' : ''}
{c.lastMessagePreview ?? '—'}
</span>
{unread ? (
<span
className="flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold text-white"
style={{ background: GREEN }}
>
{c.unreadCount}
</span>
) : (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${
STATUS_CLASS[c.status] ?? ''
}`}
>
{c.status.toLowerCase()}
</span>
)}
</div>
</button>
);
}
function NewConversation({
isGuest,
onClose,
onBack,
onCreated,
}: {
isGuest: boolean;
onClose: () => void;
onBack: () => void;
onCreated: (id: string) => void;
}) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const create = useCreateConversation();
const guestValid =
!isGuest || (name.trim().length > 0 && /.+@.+\..+/.test(email.trim()));
const valid =
subject.trim().length >= 3 && message.trim().length > 0 && guestValid;
const submit = async () => {
if (!valid) return;
const conv = await create.mutateAsync({
subject: subject.trim(),
initialMessage: message.trim(),
...(isGuest ? { name: name.trim(), email: email.trim() } : {}),
});
onCreated(conv.id);
};
const inputClass =
'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white';
return (
<>
<Header title="New request" onClose={onClose} onBack={onBack} />
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
{isGuest && (
<>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
Your name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Full name"
className={inputClass}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={inputClass}
/>
</div>
</>
)}
<div>
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
Subject
</label>
<input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="e.g. Refund for booking EDR-1234"
className={inputClass}
/>
</div>
<div className="flex flex-1 flex-col">
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-slate-300">
How can we help?
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Describe your issue…"
className="min-h-[120px] flex-1 resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
/>
</div>
<button
onClick={submit}
disabled={!valid || create.isPending}
className="flex items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
style={{ background: GREEN }}
>
<Send size={16} /> {create.isPending ? 'Sending…' : 'Send request'}
</button>
</div>
</>
);
}
function Thread({
conversationId,
onClose,
onBack,
}: {
conversationId: string;
onClose: () => void;
onBack: () => void;
}) {
const { data: conversations } = useConversations();
const conversation = useMemo(
() => conversations?.items.find((c) => c.id === conversationId),
[conversations, conversationId],
);
const { data: messages, isLoading } = useMessages(conversationId);
const send = useSendMessage(conversationId);
const markRead = useMarkRead();
const [draft, setDraft] = useState('');
const viewport = useRef<HTMLDivElement>(null);
useEffect(() => {
if (conversationId) markRead.mutate(conversationId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId, messages?.length]);
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [messages?.length]);
const submit = async () => {
const text = draft.trim();
if (!text) return;
setDraft('');
await send.mutateAsync(text);
};
return (
<>
<Header
title={conversation?.subject || 'Conversation'}
subtitle={
conversation ? `Status: ${conversation.status.toLowerCase()}` : undefined
}
onClose={onClose}
onBack={onBack}
/>
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
{isLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading</div>
) : (
(messages ?? []).map((m) => <MessageBubble key={m.id} m={m} />)
)}
</div>
<div className="border-t border-gray-100 p-3 dark:border-slate-700">
<div className="flex items-end gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder="Type a message…"
className="max-h-24 flex-1 resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
/>
<button
onClick={submit}
disabled={!draft.trim() || send.isPending}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white transition hover:opacity-90 disabled:opacity-50"
style={{ background: GREEN }}
aria-label="Send"
>
<Send size={18} />
</button>
</div>
</div>
</>
);
}
function MessageBubble({ m }: { m: MessageDto }) {
const mine = m.sender === 'USER';
return (
<div className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
<div className="max-w-[78%]">
{!mine && (
<p className="mb-0.5 ml-1 text-xs text-gray-400">
{m.authorName || 'Support agent'}
</p>
)}
<div
className={`whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm ${
mine
? 'rounded-br-sm text-white'
: 'rounded-bl-sm bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-slate-100'
}`}
style={mine ? { background: GREEN } : undefined}
>
{m.text}
</div>
<p
className={`mt-0.5 text-[10px] text-gray-400 ${
mine ? 'text-right' : 'text-left'
}`}
>
{formatTime(m.createdAt)}
</p>
</div>
</div>
);
}
export default SupportPanel;

View File

@@ -0,0 +1,51 @@
'use client';
import { Headset, MessageCircle } from 'lucide-react';
import { useState } from 'react';
import { useAuthStore } from '@/lib/auth-store';
import { SupportPanel } from './SupportPanel';
import { useUnreadCount } from './useSupport';
import { useSupportSocket } from './useSupportSocket';
const GREEN = 'rgb(20 113 76)';
/**
* Floating passenger-support launcher, mounted in the app shell for everyone —
* authenticated passengers and guests (guests are scoped by a localStorage
* guestId). Live pushes keep the unread badge fresh.
*/
export function SupportWidget() {
const { isAuthenticated } = useAuthStore();
const [open, setOpen] = useState(false);
const { data } = useUnreadCount(true);
const unread = data?.unreadCount ?? 0;
// Authenticated users keep a live socket for background pushes; guests connect
// once they open the panel (avoids idle sockets for visitors who never chat).
useSupportSocket(isAuthenticated || open);
return (
<div className="fixed bottom-6 right-6 z-[100] flex flex-col items-end gap-3">
{open && <SupportPanel onClose={() => setOpen(false)} isGuest={!isAuthenticated} />}
{!open && (
<button
onClick={() => setOpen(true)}
aria-label="Open support chat"
className="relative flex h-14 w-14 items-center justify-center rounded-full text-white shadow-lg transition hover:scale-105"
style={{ background: GREEN, boxShadow: '0 8px 24px rgba(20,113,76,0.4)' }}
>
{unread > 0 ? <Headset size={26} /> : <MessageCircle size={26} />}
{unread > 0 && (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full border-2 border-white bg-red-500 px-1 text-xs font-bold text-white">
{unread > 9 ? '9+' : unread}
</span>
)}
</button>
)}
</div>
);
}
export default SupportWidget;

View File

@@ -0,0 +1,24 @@
'use client';
const GUEST_KEY = 'support_guest_id';
/** True when a real passenger auth token is present. */
export function isAuthed(): boolean {
if (typeof window === 'undefined') return false;
const t = localStorage.getItem('auth_token');
return !!t && t !== 'null' && t !== 'undefined';
}
/** Stable anonymous id for guest conversations (persisted in localStorage). */
export function getGuestId(): string {
if (typeof window === 'undefined') return '';
let id = localStorage.getItem(GUEST_KEY);
if (!id) {
id =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `guest-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
localStorage.setItem(GUEST_KEY, id);
}
return id;
}

View File

@@ -0,0 +1,75 @@
import type { Passenger } from '@edr/types';
import { apiClient } from '@/lib/api-client';
import { getGuestId, isAuthed } from './guestIdentity';
type ConversationDto = Passenger.PassengerSupportConversationDto;
type MessageDto = Passenger.PassengerSupportMessageDto;
type ListResult = Passenger.PassengerSupportConversationListResult;
export interface CreateInput {
subject: string;
initialMessage: string;
// Required only for guests:
name?: string;
email?: string;
phone?: string;
}
/**
* Passenger portal support-chat calls. Transparently uses the authenticated
* (`/support/...`) or guest (`/support/guest/...`) endpoints depending on whether
* a passenger auth token is present. Guests are scoped by a localStorage guestId.
*/
export const supportApi = {
listConversations: (): Promise<ListResult> =>
isAuthed()
? apiClient.get('/support/conversations')
: apiClient.get('/support/guest/conversations', {
params: { guestId: getGuestId() },
}),
createConversation: (input: CreateInput): Promise<ConversationDto> =>
isAuthed()
? apiClient.post('/support/conversations', {
subject: input.subject,
initialMessage: input.initialMessage,
})
: apiClient.post('/support/guest/conversations', {
guestId: getGuestId(),
name: input.name,
email: input.email,
phone: input.phone,
subject: input.subject,
initialMessage: input.initialMessage,
}),
listMessages: (id: string): Promise<MessageDto[]> =>
isAuthed()
? apiClient.get(`/support/conversations/${id}/messages`)
: apiClient.get(`/support/guest/conversations/${id}/messages`, {
params: { guestId: getGuestId() },
}),
sendMessage: (id: string, text: string): Promise<MessageDto> =>
isAuthed()
? apiClient.post(`/support/conversations/${id}/messages`, { text })
: apiClient.post(`/support/guest/conversations/${id}/messages`, {
guestId: getGuestId(),
text,
}),
markRead: (id: string): Promise<{ unreadCount: number }> =>
isAuthed()
? apiClient.post(`/support/conversations/${id}/read`)
: apiClient.post(`/support/guest/conversations/${id}/read`, {
guestId: getGuestId(),
}),
unreadCount: (): Promise<{ unreadCount: number }> =>
isAuthed()
? apiClient.get('/support/unread-count')
: apiClient.get('/support/guest/unread-count', {
params: { guestId: getGuestId() },
}),
};

View File

@@ -0,0 +1,65 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { supportApi, type CreateInput } from './supportApi';
export const SUPPORT_CONVERSATIONS_KEY = ['support', 'conversations'];
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
export const supportMessagesKey = (id: string) => ['support', 'messages', id];
export function useConversations(enabled = true) {
return useQuery({
queryKey: SUPPORT_CONVERSATIONS_KEY,
queryFn: () => supportApi.listConversations(),
enabled,
});
}
export function useMessages(conversationId: string | null) {
return useQuery({
queryKey: supportMessagesKey(conversationId ?? ''),
queryFn: () => supportApi.listMessages(conversationId as string),
enabled: !!conversationId,
});
}
export function useUnreadCount(enabled = true) {
return useQuery({
queryKey: SUPPORT_UNREAD_KEY,
queryFn: () => supportApi.unreadCount(),
enabled,
refetchInterval: 60_000,
});
}
export function useCreateConversation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateInput) => supportApi.createConversation(input),
onSuccess: () =>
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }),
});
}
export function useSendMessage(conversationId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (text: string) => supportApi.sendMessage(conversationId, text),
onSuccess: () => {
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
},
});
}
export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => supportApi.markRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
},
});
}

View File

@@ -0,0 +1,71 @@
'use client';
import { Passenger } from '@edr/types';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { io } from 'socket.io-client';
import { getGuestId, isAuthed } from './guestIdentity';
import {
SUPPORT_CONVERSATIONS_KEY,
SUPPORT_UNREAD_KEY,
supportMessagesKey,
} from './useSupport';
const SOCKET_ORIGIN = String(
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
).replace(/\/api\/?$/, '');
/**
* Subscribes the signed-in passenger to live support pushes. New messages
* refresh the affected thread + list + unread badge, and fire `onMessage`
* (the widget toasts when closed).
*/
export function useSupportSocket(
enabled: boolean,
onMessage?: (event: Passenger.PassengerSupportMessageEvent) => void,
) {
const qc = useQueryClient();
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
if (!enabled || typeof window === 'undefined') return;
// Authenticated passengers connect with their token; guests connect with
// their anonymous guestId so the gateway can join their `guest:<id>` room.
const auth = isAuthed()
? { token: localStorage.getItem('auth_token') }
: { guestId: getGuestId() };
const socket = io(
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
{
auth,
transports: ['websocket'],
withCredentials: true,
},
);
socket.on(
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
(event: Passenger.PassengerSupportMessageEvent) => {
qc.invalidateQueries({
queryKey: supportMessagesKey(event.message.conversationId),
});
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
},
);
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
});
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}

View File

@@ -1,6 +1,6 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000";
class ApiClient {
private client: AxiosInstance;
@@ -9,13 +9,16 @@ class ApiClient {
this.client = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
this.client.interceptors.request.use((config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (token && token !== 'null' && token !== 'undefined') {
const token =
typeof window !== "undefined"
? localStorage.getItem("auth_token")
: null;
if (token && token !== "null" && token !== "undefined") {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
@@ -25,22 +28,28 @@ class ApiClient {
// there just means the user canceled/closed the Fayda popup without completing it (no
// valid verification session) — that should surface as an inline error on the page,
// not force-clear the session and redirect to /login out from under them.
const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me', '/fayda/verification'];
const PUBLIC_PREFIXES = [
"/config/",
"/auth/login",
"/auth/register",
"/passengers/me",
"/fayda/verification",
];
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const url: string = error.config?.url || '';
const url: string = error.config?.url || "";
const isPublic = PUBLIC_PREFIXES.some((p) => url.includes(p));
if (!isPublic && typeof window !== 'undefined') {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
window.location.href = '/login';
if (!isPublic && typeof window !== "undefined") {
localStorage.removeItem("auth_token");
localStorage.removeItem("auth_user");
// window.location.href = '/login';
}
}
return Promise.reject(error);
}
},
);
}
@@ -50,17 +59,29 @@ class ApiClient {
return response.data?.data || response.data;
}
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async post<T>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.post<any>(url, data, config);
return response.data?.data || response.data;
}
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async put<T>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.put<T>(url, data, config);
return response.data;
}
async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async patch<T>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.patch<T>(url, data, config);
return response.data;
}

View File

@@ -1,5 +1,7 @@
import type { BaseEntity } from "../common";
export * from "./support-chat";
export enum TicketStatus {
Reserved = "RESERVED",
Confirmed = "CONFIRMED",

View File

@@ -0,0 +1,103 @@
/**
* Shared contracts for the passenger in-app customer-support chat.
*
* Unlike freight (company-scoped), a passenger conversation ("ticket") belongs to a
* single **individual passenger** (keyed by their IAM user id). Backoffice agents
* work a shared inbox (no assignment). Messages are text-only for the MVP.
*
* These are the wire (JSON) shapes — dates are ISO strings — plus the frozen
* Socket.IO event/namespace constants shared by the gateway (emitter) and both
* passenger web apps (subscribers).
*/
/** Lifecycle of a support conversation. Mirrors the Prisma `SupportConversationStatus`. */
export enum PassengerSupportStatus {
OPEN = "OPEN",
RESOLVED = "RESOLVED",
CLOSED = "CLOSED",
}
/** Author of a message. The Prisma `SupportSender` also has `BOT` (legacy, unused here). */
export enum PassengerSupportSender {
USER = "USER",
AGENT = "AGENT",
}
/** A single chat message on the wire. */
export interface PassengerSupportMessageDto {
id: string;
conversationId: string;
sender: PassengerSupportSender;
/** Display name of the author, best-effort. */
authorName?: string | null;
text: string;
createdAt: string;
}
/** A conversation ("ticket") on the wire, with denormalized last-message fields. */
export interface PassengerSupportConversationDto {
id: string;
/** Set for authenticated passengers; null for guest conversations. */
userId?: string | null;
/** Set for guest (unauthenticated) conversations. */
guestId?: string | null;
guestEmail?: string | null;
guestPhone?: string | null;
passengerId?: string | null;
/** Display name — passenger's name for authed, guest's name for guests. */
passengerName?: string | null;
subject?: string | null;
status: PassengerSupportStatus;
assignedAgentId?: string | null;
lastMessageAt?: string | null;
lastMessagePreview?: string | null;
lastMessageSender?: PassengerSupportSender | null;
/** Unread count for the caller's side (messages from the other sender after their cursor). */
unreadCount: number;
createdAt: string;
updatedAt: string;
}
/** Customer opens a new ticket: subject + first message. */
export interface CreatePassengerSupportConversationDto {
subject: string;
initialMessage: string;
}
/** Guest (unauthenticated) opens a ticket: identity + contact captured up front. */
export interface CreateGuestSupportConversationDto {
guestId: string;
name: string;
email: string;
phone?: string;
subject: string;
initialMessage: string;
}
/** Post a message into an existing conversation. */
export interface SendPassengerSupportMessageDto {
text: string;
}
/** Paginated list envelope for the conversations list endpoints. */
export interface PassengerSupportConversationListResult {
items: PassengerSupportConversationDto[];
count: number;
/** Total unread conversations for the caller's side (badge source). */
unreadCount: number;
}
/** Socket.io event names pushed server → client on the passenger support namespace. */
export const PASSENGER_SUPPORT_WS_EVENTS = {
MESSAGE_NEW: "passenger-support:message-new",
CONVERSATION_UPDATED: "passenger-support:conversation-updated",
} as const;
/** Socket.io namespace the passenger support gateway listens on. */
export const PASSENGER_SUPPORT_WS_NAMESPACE = "passenger-support-chat";
/** Payload for {@link PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW}. */
export interface PassengerSupportMessageEvent {
conversation: PassengerSupportConversationDto;
message: PassengerSupportMessageDto;
}

15
pnpm-lock.yaml generated
View File

@@ -501,6 +501,9 @@ importers:
'@nestjs/platform-express':
specifier: ^11.1.19
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
'@nestjs/platform-socket.io':
specifier: ^11.1.27
version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.27)(rxjs@7.8.2)
'@nestjs/schedule':
specifier: ^6.1.3
version: 6.1.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
@@ -513,6 +516,9 @@ importers:
'@nestjs/typeorm':
specifier: ^11.0.1
version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@nestjs/websockets':
specifier: ^11.1.27
version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@prisma/client':
specifier: ^6.19.3
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
@@ -570,6 +576,9 @@ importers:
rxjs:
specifier: ^7.8.1
version: 7.8.2
socket.io:
specifier: ^4.8.3
version: 4.8.3
swagger-ui-express:
specifier: ^5.0.0
version: 5.0.1(express@4.22.2)
@@ -673,6 +682,9 @@ importers:
recharts:
specifier: ^2.12.0
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
socket.io-client:
specifier: ^4.8.3
version: 4.8.3
zustand:
specifier: ^5.0.0
version: 5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
@@ -758,6 +770,9 @@ importers:
react-hook-form:
specifier: ^7.51.0
version: 7.77.0(react@18.3.1)
socket.io-client:
specifier: ^4.8.3
version: 4.8.3
zod:
specifier: ^3.22.4
version: 3.25.76