mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,207 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SupportService } from './support.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import {
|
||||
CreateConversationDto,
|
||||
CreateGuestConversationDto,
|
||||
GuestIdBodyDto,
|
||||
GuestSendMessageDto,
|
||||
ListConversationsQueryDto,
|
||||
SendMessageDto,
|
||||
UpdateStatusDto,
|
||||
} from './support.dto';
|
||||
|
||||
function userId(req: any): string {
|
||||
const id = req?.user?.id ?? req?.user?.sub;
|
||||
if (!id) throw new UnauthorizedException();
|
||||
return id;
|
||||
}
|
||||
|
||||
@ApiTags('Support')
|
||||
@Controller('support')
|
||||
export class SupportController {
|
||||
constructor(private service: SupportService) {}
|
||||
@Get('faq') @ApiOperation({ summary: 'Get FAQ categories' }) getFaqCategories() { return this.service.getFaqCategories(); }
|
||||
@Get('faq/:categoryId/articles') @ApiOperation({ summary: 'Get FAQ articles for a category' }) getFaqArticles(@Param('categoryId') id: string) { return this.service.getFaqArticles(id); }
|
||||
@Post('conversations') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Start a support conversation' }) startConversation(@Body('userId') userId: string) { return this.service.startConversation(userId); }
|
||||
@Post('conversations/:id/messages') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Send a message in a conversation' }) sendMessage(@Param('id') id: string, @Body() body: { sender: 'USER' | 'BOT' | 'AGENT'; text: string }) { return this.service.sendMessage(id, body.sender, body.text); }
|
||||
@Get('conversations/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get conversation with messages' }) getConversation(@Param('id') id: string) { return this.service.getConversation(id); }
|
||||
|
||||
// ---- FAQ (public) ------------------------------------------------------
|
||||
|
||||
@Get('faq')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Get FAQ categories' })
|
||||
getFaqCategories() {
|
||||
return this.service.getFaqCategories();
|
||||
}
|
||||
|
||||
@Get('faq/:categoryId/articles')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Get FAQ articles for a category' })
|
||||
getFaqArticles(@Param('categoryId') id: string) {
|
||||
return this.service.getFaqArticles(id);
|
||||
}
|
||||
|
||||
// ---- customer: authenticated passenger --------------------------------
|
||||
|
||||
@Post('conversations')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Open a new support conversation' })
|
||||
createConversation(@Req() req: any, @Body() body: CreateConversationDto) {
|
||||
return this.service.createConversation(userId(req), body);
|
||||
}
|
||||
|
||||
@Get('conversations')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List my support conversations' })
|
||||
listMine(@Req() req: any, @Query() query: ListConversationsQueryDto) {
|
||||
return this.service.listForCustomer({ iamUserId: userId(req) }, query);
|
||||
}
|
||||
|
||||
@Get('conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List messages in one of my conversations' })
|
||||
messages(@Req() req: any, @Param('id') id: string) {
|
||||
return this.service.getMessages(id, { iamUserId: userId(req) });
|
||||
}
|
||||
|
||||
@Post('conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Send a message as the customer' })
|
||||
send(@Req() req: any, @Param('id') id: string, @Body() body: SendMessageDto) {
|
||||
return this.service.sendMessage(id, 'USER', body.text, {
|
||||
iamUserId: userId(req),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('conversations/:id/read')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Mark a conversation read (customer side)' })
|
||||
read(@Req() req: any, @Param('id') id: string) {
|
||||
return this.service.markRead(id, 'USER', { iamUserId: userId(req) });
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Count my unread support conversations' })
|
||||
unread(@Req() req: any) {
|
||||
return this.service.unreadCount('USER', { iamUserId: userId(req) });
|
||||
}
|
||||
|
||||
// ---- customer: guest (unauthenticated) --------------------------------
|
||||
// No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer
|
||||
// of access — anyone with it sees that thread; accepted MVP trade-off).
|
||||
|
||||
@Post('guest/conversations')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Open a support conversation as a guest' })
|
||||
guestCreate(@Body() body: CreateGuestConversationDto) {
|
||||
return this.service.createGuestConversation(body);
|
||||
}
|
||||
|
||||
@Get('guest/conversations')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List a guest\'s conversations' })
|
||||
guestList(
|
||||
@Query('guestId') guestId: string,
|
||||
@Query() query: ListConversationsQueryDto,
|
||||
) {
|
||||
return this.service.listForCustomer({ guestId }, query);
|
||||
}
|
||||
|
||||
@Get('guest/conversations/:id/messages')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List messages in a guest conversation' })
|
||||
guestMessages(@Param('id') id: string, @Query('guestId') guestId: string) {
|
||||
return this.service.getMessages(id, { guestId });
|
||||
}
|
||||
|
||||
@Post('guest/conversations/:id/messages')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Send a message as a guest' })
|
||||
guestSend(@Param('id') id: string, @Body() body: GuestSendMessageDto) {
|
||||
return this.service.sendMessage(id, 'USER', body.text, {
|
||||
guestId: body.guestId,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('guest/conversations/:id/read')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Mark a guest conversation read' })
|
||||
guestRead(@Param('id') id: string, @Body() body: GuestIdBodyDto) {
|
||||
return this.service.markRead(id, 'USER', { guestId: body.guestId });
|
||||
}
|
||||
|
||||
@Get('guest/unread-count')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Count a guest\'s unread conversations' })
|
||||
guestUnread(@Query('guestId') guestId: string) {
|
||||
return this.service.unreadCount('USER', { guestId });
|
||||
}
|
||||
|
||||
// ---- agent (backoffice) ------------------------------------------------
|
||||
// TODO: gate agent routes with a staff permission once passenger RBAC is wired.
|
||||
|
||||
@Get('agent/conversations')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List all support conversations (shared inbox)' })
|
||||
agentList(@Query() query: ListConversationsQueryDto) {
|
||||
return this.service.listForAgents(query);
|
||||
}
|
||||
|
||||
@Get('agent/conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List messages in a conversation' })
|
||||
agentMessages(@Param('id') id: string) {
|
||||
return this.service.getMessages(id);
|
||||
}
|
||||
|
||||
@Post('agent/conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Reply as an agent' })
|
||||
agentSend(@Param('id') id: string, @Body() body: SendMessageDto) {
|
||||
return this.service.sendMessage(id, 'AGENT', body.text);
|
||||
}
|
||||
|
||||
@Patch('agent/conversations/:id/status')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: "Change a conversation's status" })
|
||||
agentStatus(@Param('id') id: string, @Body() body: UpdateStatusDto) {
|
||||
return this.service.setStatus(id, body.status);
|
||||
}
|
||||
|
||||
@Post('agent/conversations/:id/read')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Mark a conversation read (agent side)' })
|
||||
agentRead(@Param('id') id: string) {
|
||||
return this.service.markRead(id, 'AGENT');
|
||||
}
|
||||
|
||||
@Get('agent/unread-count')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Count unread conversations (agent side)' })
|
||||
agentUnread() {
|
||||
return this.service.unreadCount('AGENT');
|
||||
}
|
||||
}
|
||||
|
||||
127
apps/edr-passenger-api/src/modules/support/support.dto.ts
Normal file
127
apps/edr-passenger-api/src/modules/support/support.dto.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export enum SupportStatusDto {
|
||||
OPEN = 'OPEN',
|
||||
RESOLVED = 'RESOLVED',
|
||||
CLOSED = 'CLOSED',
|
||||
}
|
||||
|
||||
export class CreateConversationDto {
|
||||
@ApiProperty({ description: 'Short subject / topic of the request.' })
|
||||
@IsString()
|
||||
@Length(3, 200)
|
||||
subject!: string;
|
||||
|
||||
@ApiProperty({ description: 'The first message body.' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
initialMessage!: string;
|
||||
}
|
||||
|
||||
export class SendMessageDto {
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class CreateGuestConversationDto {
|
||||
@ApiProperty({ description: 'Client-generated anonymous id (localStorage).' })
|
||||
@IsString()
|
||||
@Length(8, 120)
|
||||
guestId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Guest full name.' })
|
||||
@IsString()
|
||||
@Length(1, 120)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: 'Guest email for follow-up.' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Guest phone (optional).' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
phone?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Length(3, 200)
|
||||
subject!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
initialMessage!: string;
|
||||
}
|
||||
|
||||
export class GuestSendMessageDto {
|
||||
@ApiProperty({ description: 'The guest id that owns the conversation.' })
|
||||
@IsString()
|
||||
@Length(8, 120)
|
||||
guestId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class GuestIdBodyDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Length(8, 120)
|
||||
guestId!: string;
|
||||
}
|
||||
|
||||
export class UpdateStatusDto {
|
||||
@ApiProperty({ enum: SupportStatusDto })
|
||||
@IsEnum(SupportStatusDto)
|
||||
status!: SupportStatusDto;
|
||||
}
|
||||
|
||||
export class ListConversationsQueryDto {
|
||||
@ApiPropertyOptional({ enum: SupportStatusDto })
|
||||
@IsOptional()
|
||||
@IsEnum(SupportStatusDto)
|
||||
status?: SupportStatusDto;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Search subject / passenger name.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
134
apps/edr-passenger-api/src/modules/support/support.gateway.ts
Normal file
134
apps/edr-passenger-api/src/modules/support/support.gateway.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { Passenger as PassengerTypes } from '@edr/types';
|
||||
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { WsAuthService } from './ws-auth.service';
|
||||
|
||||
/**
|
||||
* Server → client push for passenger support chat. Clients only *listen* (no
|
||||
* `@SubscribeMessage`); the handshake is authenticated in `handleConnection`.
|
||||
* Each socket joins a room based on its side:
|
||||
* - backoffice staff → the shared `backoffice` room (see every conversation).
|
||||
* - passengers → their `user:<iamUserId>` room (their own tickets only).
|
||||
*
|
||||
* Side is decided by the presence of a `Passenger` row for the IAM user id
|
||||
* (staff have none). A message is emitted to BOTH the owner's room and the
|
||||
* backoffice room so the customer thread, the sender's echo, and every agent's
|
||||
* inbox update live.
|
||||
*/
|
||||
@WebSocketGateway({
|
||||
namespace: PassengerTypes.PASSENGER_SUPPORT_WS_NAMESPACE,
|
||||
cors: { origin: true, credentials: true },
|
||||
})
|
||||
export class SupportGateway implements OnGatewayConnection {
|
||||
private readonly logger = new Logger(SupportGateway.name);
|
||||
|
||||
private static readonly BACKOFFICE_ROOM = 'backoffice';
|
||||
|
||||
@WebSocketServer()
|
||||
private readonly server!: Server;
|
||||
|
||||
constructor(
|
||||
private readonly wsAuth: WsAuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async handleConnection(socket: Socket): Promise<void> {
|
||||
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
|
||||
|
||||
// Authenticated: passenger (own room) or backoffice staff (shared room).
|
||||
if (userId) {
|
||||
socket.data.userId = userId;
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: userId },
|
||||
});
|
||||
if (passenger) {
|
||||
await socket.join(`user:${userId}`);
|
||||
socket.data.side = 'USER';
|
||||
} else {
|
||||
await socket.join(SupportGateway.BACKOFFICE_ROOM);
|
||||
socket.data.side = 'AGENT';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Guest: no valid token, but a client-generated guestId scopes the room.
|
||||
// Anyone holding the guestId can see that thread (no account = weaker
|
||||
// ownership) — an accepted MVP trade-off for guest support.
|
||||
const guestId = this.extractGuestId(socket);
|
||||
if (guestId) {
|
||||
socket.data.guestId = guestId;
|
||||
socket.data.side = 'USER';
|
||||
await socket.join(`guest:${guestId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`Rejected passenger-support handshake ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
}
|
||||
|
||||
/** Push a new message + updated conversation to the owner + backoffice rooms. */
|
||||
emitMessage(
|
||||
ownerRoom: string | null,
|
||||
conversation: PassengerTypes.PassengerSupportConversationDto,
|
||||
message: PassengerTypes.PassengerSupportMessageDto,
|
||||
): void {
|
||||
const payload = { conversation, message };
|
||||
for (const room of this.targetRooms(ownerRoom)) {
|
||||
const to = this.server.to(room);
|
||||
to.emit(PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW, payload);
|
||||
to.emit(
|
||||
PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED,
|
||||
conversation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Push a conversation metadata change (e.g. status) to both rooms. */
|
||||
emitConversationUpdated(
|
||||
ownerRoom: string | null,
|
||||
conversation: PassengerTypes.PassengerSupportConversationDto,
|
||||
): void {
|
||||
for (const room of this.targetRooms(ownerRoom)) {
|
||||
this.server
|
||||
.to(room)
|
||||
.emit(
|
||||
PassengerTypes.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED,
|
||||
conversation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private targetRooms(ownerRoom: string | null): string[] {
|
||||
const rooms = [SupportGateway.BACKOFFICE_ROOM];
|
||||
if (ownerRoom) rooms.push(ownerRoom);
|
||||
return rooms;
|
||||
}
|
||||
|
||||
private extractGuestId(socket: Socket): string | undefined {
|
||||
const authGuest = socket.handshake.auth?.guestId as string | undefined;
|
||||
if (authGuest) return authGuest;
|
||||
const queryGuest = socket.handshake.query?.guestId;
|
||||
if (typeof queryGuest === 'string') return queryGuest;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractToken(socket: Socket): string | undefined {
|
||||
const authToken = socket.handshake.auth?.token as string | undefined;
|
||||
if (authToken) return authToken;
|
||||
|
||||
const queryToken = socket.handshake.query?.token;
|
||||
if (typeof queryToken === 'string') return queryToken;
|
||||
|
||||
const header = socket.handshake.headers?.authorization;
|
||||
if (header?.startsWith('Bearer ')) return header.slice(7);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||
|
||||
import { SupportController } from './support.controller';
|
||||
import { SupportService } from './support.service';
|
||||
import { SupportGateway } from './support.gateway';
|
||||
import { WsAuthService } from './ws-auth.service';
|
||||
|
||||
@Module({ controllers: [SupportController], providers: [SupportService] })
|
||||
@Module({
|
||||
// Session is served by the app's default TypeORM DataSource (IAM schema) —
|
||||
// used by WsAuthService to authenticate WebSocket handshakes.
|
||||
imports: [TypeOrmModule.forFeature([Session])],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService, SupportGateway, WsAuthService],
|
||||
})
|
||||
export class SupportModule {}
|
||||
|
||||
@@ -1,35 +1,419 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Passenger as T } from '@edr/types';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SupportGateway } from './support.gateway';
|
||||
|
||||
type Side = 'USER' | 'AGENT';
|
||||
type PrismaSender = 'USER' | 'BOT' | 'AGENT';
|
||||
type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED';
|
||||
|
||||
/** Who the caller is on the customer side: an authed passenger or a guest. */
|
||||
export interface CustomerOwner {
|
||||
iamUserId?: string | null;
|
||||
guestId?: string | null;
|
||||
}
|
||||
|
||||
interface ListQuery {
|
||||
status?: PrismaStatus;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
type ConversationRow = {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
guestId: string | null;
|
||||
guestName: string | null;
|
||||
guestEmail: string | null;
|
||||
guestPhone: string | null;
|
||||
passengerId: string | null;
|
||||
passengerName: string | null;
|
||||
subject: string | null;
|
||||
status: PrismaStatus;
|
||||
assignedAgentId: string | null;
|
||||
lastMessageAt: Date | null;
|
||||
lastMessagePreview: string | null;
|
||||
lastMessageSender: PrismaSender | null;
|
||||
userLastReadAt: Date | null;
|
||||
agentLastReadAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SupportService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private gateway: SupportGateway,
|
||||
) {}
|
||||
|
||||
getFaqCategories() { return this.prisma.faqCategory.findMany({ include: { _count: { select: { articles: true } } } }); }
|
||||
// ---- FAQ (unchanged) ---------------------------------------------------
|
||||
|
||||
getFaqArticles(categoryId: string) { return this.prisma.faqArticle.findMany({ where: { categoryId }, orderBy: { rank: 'asc' } }); }
|
||||
|
||||
startConversation(userId: string) { return this.prisma.supportConversation.create({ data: { userId } }); }
|
||||
|
||||
async sendMessage(conversationId: string, sender: 'USER' | 'BOT' | 'AGENT', text: string) {
|
||||
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId } });
|
||||
if (!conv) throw new NotFoundException('Conversation not found');
|
||||
const message = await this.prisma.supportMessage.create({ data: { conversationId, sender, text } });
|
||||
if (sender === 'USER') await this.prisma.supportMessage.create({ data: { conversationId, sender: 'BOT', text: this.getBotReply(text) } });
|
||||
return message;
|
||||
getFaqCategories() {
|
||||
return this.prisma.faqCategory.findMany({
|
||||
include: { _count: { select: { articles: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async getConversation(conversationId: string) {
|
||||
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId }, include: { messages: { orderBy: { createdAt: 'asc' } } } });
|
||||
if (!conv) throw new NotFoundException('Conversation not found');
|
||||
return conv;
|
||||
getFaqArticles(categoryId: string) {
|
||||
return this.prisma.faqArticle.findMany({
|
||||
where: { categoryId },
|
||||
orderBy: { rank: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
private getBotReply(text: string): string {
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to Bookings and select the booking. Refunds are processed within 3-5 business days.';
|
||||
if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.';
|
||||
if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.';
|
||||
return 'Thank you for contacting EDR support. An agent will assist you shortly.';
|
||||
// ---- customer: authed passenger ---------------------------------------
|
||||
|
||||
async createConversation(
|
||||
iamUserId: string,
|
||||
input: { subject: string; initialMessage: string },
|
||||
): Promise<T.PassengerSupportConversationDto> {
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId },
|
||||
include: { user: { select: { fullName: true } } },
|
||||
});
|
||||
const conversation = (await this.prisma.supportConversation.create({
|
||||
data: {
|
||||
userId: iamUserId,
|
||||
passengerId: passenger?.id ?? null,
|
||||
passengerName: passenger?.user?.fullName ?? null,
|
||||
subject: input.subject,
|
||||
status: 'OPEN',
|
||||
},
|
||||
})) as ConversationRow;
|
||||
return this.firstMessage(conversation, input.initialMessage);
|
||||
}
|
||||
|
||||
// ---- customer: guest (unauthenticated) --------------------------------
|
||||
|
||||
async createGuestConversation(input: {
|
||||
guestId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
subject: string;
|
||||
initialMessage: string;
|
||||
}): Promise<T.PassengerSupportConversationDto> {
|
||||
const conversation = (await this.prisma.supportConversation.create({
|
||||
data: {
|
||||
guestId: input.guestId,
|
||||
guestName: input.name,
|
||||
guestEmail: input.email,
|
||||
guestPhone: input.phone ?? null,
|
||||
passengerName: input.name, // uniform display name for the agent inbox
|
||||
subject: input.subject,
|
||||
status: 'OPEN',
|
||||
},
|
||||
})) as ConversationRow;
|
||||
return this.firstMessage(conversation, input.initialMessage);
|
||||
}
|
||||
|
||||
async listForCustomer(
|
||||
owner: CustomerOwner,
|
||||
query: ListQuery,
|
||||
): Promise<T.PassengerSupportConversationListResult> {
|
||||
const scope = this.ownerScope(owner);
|
||||
const where = { ...this.listWhere(query), ...scope };
|
||||
const rows = (await this.prisma.supportConversation.findMany({
|
||||
where,
|
||||
orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }],
|
||||
take: query.limit ?? 100,
|
||||
skip: ((query.page ?? 1) - 1) * (query.limit ?? 100),
|
||||
})) as ConversationRow[];
|
||||
const count = await this.prisma.supportConversation.count({ where });
|
||||
return this.buildListResult(rows, count, 'USER');
|
||||
}
|
||||
|
||||
// ---- agent (backoffice) ------------------------------------------------
|
||||
|
||||
async listForAgents(
|
||||
query: ListQuery,
|
||||
): Promise<T.PassengerSupportConversationListResult> {
|
||||
const where = this.listWhere(query);
|
||||
const rows = (await this.prisma.supportConversation.findMany({
|
||||
where,
|
||||
orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }],
|
||||
take: query.limit ?? 100,
|
||||
skip: ((query.page ?? 1) - 1) * (query.limit ?? 100),
|
||||
})) as ConversationRow[];
|
||||
const count = await this.prisma.supportConversation.count({ where });
|
||||
return this.buildListResult(rows, count, 'AGENT');
|
||||
}
|
||||
|
||||
async setStatus(
|
||||
conversationId: string,
|
||||
status: PrismaStatus,
|
||||
): Promise<T.PassengerSupportConversationDto> {
|
||||
await this.requireConversation(conversationId);
|
||||
const updated = (await this.prisma.supportConversation.update({
|
||||
where: { id: conversationId },
|
||||
data: { status },
|
||||
})) as ConversationRow;
|
||||
const dto = this.toConversationDto(updated, 0);
|
||||
this.gateway.emitConversationUpdated(this.ownerRoom(updated), dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
// ---- shared ------------------------------------------------------------
|
||||
|
||||
async getMessages(
|
||||
conversationId: string,
|
||||
asCustomer?: CustomerOwner,
|
||||
): Promise<T.PassengerSupportMessageDto[]> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
if (asCustomer) this.assertOwns(conversation, asCustomer);
|
||||
const rows = await this.prisma.supportMessage.findMany({
|
||||
where: { conversationId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return rows.map((m) => this.toMessageDto(m));
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
sender: Side,
|
||||
text: string,
|
||||
asCustomer?: CustomerOwner,
|
||||
): Promise<T.PassengerSupportMessageDto> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
if (sender === 'USER') {
|
||||
this.assertOwns(conversation, asCustomer ?? {});
|
||||
}
|
||||
const updated = await this.appendMessage(conversation, sender, text);
|
||||
const last = updated.messages[updated.messages.length - 1];
|
||||
return this.toMessageDto(last);
|
||||
}
|
||||
|
||||
async markRead(
|
||||
conversationId: string,
|
||||
side: Side,
|
||||
asCustomer?: CustomerOwner,
|
||||
): Promise<{ unreadCount: number }> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
if (side === 'USER') {
|
||||
this.assertOwns(conversation, asCustomer ?? {});
|
||||
await this.prisma.supportConversation.update({
|
||||
where: { id: conversationId },
|
||||
data: { userLastReadAt: new Date() },
|
||||
});
|
||||
return this.unreadCount('USER', asCustomer);
|
||||
}
|
||||
await this.prisma.supportConversation.update({
|
||||
where: { id: conversationId },
|
||||
data: { agentLastReadAt: new Date() },
|
||||
});
|
||||
return this.unreadCount('AGENT');
|
||||
}
|
||||
|
||||
async unreadCount(
|
||||
side: Side,
|
||||
owner?: CustomerOwner,
|
||||
): Promise<{ unreadCount: number }> {
|
||||
const rows = (await this.prisma.supportConversation.findMany({
|
||||
where: side === 'USER' ? this.ownerScope(owner ?? {}) : {},
|
||||
select: { id: true, userLastReadAt: true, agentLastReadAt: true },
|
||||
})) as Array<{
|
||||
id: string;
|
||||
userLastReadAt: Date | null;
|
||||
agentLastReadAt: Date | null;
|
||||
}>;
|
||||
const map = await this.computeUnread(rows, side);
|
||||
let unreadCount = 0;
|
||||
for (const n of map.values()) if (n > 0) unreadCount++;
|
||||
return { unreadCount };
|
||||
}
|
||||
|
||||
// ---- internals ---------------------------------------------------------
|
||||
|
||||
private async firstMessage(
|
||||
conversation: ConversationRow,
|
||||
text: string,
|
||||
): Promise<T.PassengerSupportConversationDto> {
|
||||
const { conversation: updated } = await this.appendMessageRaw(
|
||||
conversation,
|
||||
'USER',
|
||||
text,
|
||||
);
|
||||
return this.toConversationDto(updated, 0);
|
||||
}
|
||||
|
||||
private async appendMessage(
|
||||
conversation: ConversationRow,
|
||||
sender: PrismaSender,
|
||||
text: string,
|
||||
) {
|
||||
const { conversation: updated } = await this.appendMessageRaw(
|
||||
conversation,
|
||||
sender,
|
||||
text,
|
||||
);
|
||||
return updated as ConversationRow & { messages: any[] };
|
||||
}
|
||||
|
||||
/** Persist a message, bump the conversation's denormalized fields, emit live. */
|
||||
private async appendMessageRaw(
|
||||
conversation: ConversationRow,
|
||||
sender: PrismaSender,
|
||||
text: string,
|
||||
): Promise<{ conversation: ConversationRow & { messages: any[] }; message: any }> {
|
||||
const message = await this.prisma.supportMessage.create({
|
||||
data: { conversationId: conversation.id, sender, text },
|
||||
});
|
||||
const updated = (await this.prisma.supportConversation.update({
|
||||
where: { id: conversation.id },
|
||||
data: {
|
||||
lastMessageAt: message.createdAt,
|
||||
lastMessagePreview: text.slice(0, 280),
|
||||
lastMessageSender: sender,
|
||||
},
|
||||
include: { messages: { orderBy: { createdAt: 'asc' } } },
|
||||
})) as ConversationRow & { messages: any[] };
|
||||
|
||||
const dto = this.toConversationDto(updated, 0);
|
||||
this.gateway.emitMessage(this.ownerRoom(updated), dto, this.toMessageDto(message));
|
||||
return { conversation: updated, message };
|
||||
}
|
||||
|
||||
private async buildListResult(
|
||||
rows: ConversationRow[],
|
||||
count: number,
|
||||
side: Side,
|
||||
): Promise<T.PassengerSupportConversationListResult> {
|
||||
const unreadMap = await this.computeUnread(rows, side);
|
||||
const items = rows.map((r) =>
|
||||
this.toConversationDto(r, unreadMap.get(r.id) ?? 0),
|
||||
);
|
||||
let unreadCount = 0;
|
||||
for (const n of unreadMap.values()) if (n > 0) unreadCount++;
|
||||
return { items, count, unreadCount };
|
||||
}
|
||||
|
||||
private async computeUnread(
|
||||
rows: Array<{
|
||||
id: string;
|
||||
userLastReadAt: Date | null;
|
||||
agentLastReadAt: Date | null;
|
||||
}>,
|
||||
side: Side,
|
||||
): Promise<Map<string, number>> {
|
||||
const map = new Map<string, number>();
|
||||
if (rows.length === 0) return map;
|
||||
const otherSender: PrismaSender = side === 'USER' ? 'AGENT' : 'USER';
|
||||
const ids = rows.map((r) => r.id);
|
||||
const msgs = await this.prisma.supportMessage.findMany({
|
||||
where: { conversationId: { in: ids }, sender: otherSender },
|
||||
select: { conversationId: true, createdAt: true },
|
||||
});
|
||||
const cursorById = new Map(
|
||||
rows.map((r) => [
|
||||
r.id,
|
||||
side === 'USER' ? r.userLastReadAt : r.agentLastReadAt,
|
||||
]),
|
||||
);
|
||||
for (const m of msgs) {
|
||||
const cursor = cursorById.get(m.conversationId) ?? null;
|
||||
if (!cursor || m.createdAt > cursor) {
|
||||
map.set(m.conversationId, (map.get(m.conversationId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private listWhere(query: ListQuery) {
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query.status) where.status = query.status;
|
||||
if (query.search?.trim()) {
|
||||
const contains = query.search.trim();
|
||||
where.OR = [
|
||||
{ subject: { contains, mode: 'insensitive' } },
|
||||
{ passengerName: { contains, mode: 'insensitive' } },
|
||||
{ guestEmail: { contains, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
/** Prisma where-fragment scoping to the calling customer (authed or guest). */
|
||||
private ownerScope(owner: CustomerOwner): Record<string, unknown> {
|
||||
if (owner.iamUserId) return { userId: owner.iamUserId };
|
||||
if (owner.guestId) return { guestId: owner.guestId };
|
||||
// No identity ⇒ match nothing.
|
||||
return { id: '__none__' };
|
||||
}
|
||||
|
||||
private assertOwns(conversation: ConversationRow, owner: CustomerOwner): void {
|
||||
const ok =
|
||||
(owner.iamUserId && conversation.userId === owner.iamUserId) ||
|
||||
(owner.guestId && conversation.guestId === owner.guestId);
|
||||
if (!ok) {
|
||||
throw new ForbiddenException('This conversation belongs to someone else.');
|
||||
}
|
||||
}
|
||||
|
||||
private ownerRoom(c: ConversationRow): string | null {
|
||||
if (c.guestId) return `guest:${c.guestId}`;
|
||||
if (c.userId) return `user:${c.userId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async requireConversation(id: string): Promise<ConversationRow> {
|
||||
const conversation = (await this.prisma.supportConversation.findUnique({
|
||||
where: { id },
|
||||
})) as ConversationRow | null;
|
||||
if (!conversation) throw new NotFoundException('Conversation not found');
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private toConversationDto(
|
||||
c: ConversationRow,
|
||||
unreadCount: number,
|
||||
): T.PassengerSupportConversationDto {
|
||||
return {
|
||||
id: c.id,
|
||||
userId: c.userId,
|
||||
guestId: c.guestId,
|
||||
guestEmail: c.guestEmail,
|
||||
guestPhone: c.guestPhone,
|
||||
passengerId: c.passengerId,
|
||||
passengerName: c.passengerName ?? c.guestName ?? null,
|
||||
subject: c.subject,
|
||||
status: c.status as T.PassengerSupportStatus,
|
||||
assignedAgentId: c.assignedAgentId,
|
||||
lastMessageAt: c.lastMessageAt ? c.lastMessageAt.toISOString() : null,
|
||||
lastMessagePreview: c.lastMessagePreview,
|
||||
lastMessageSender: this.toDtoSender(c.lastMessageSender),
|
||||
unreadCount,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toMessageDto(m: {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
sender: PrismaSender;
|
||||
text: string;
|
||||
createdAt: Date;
|
||||
}): T.PassengerSupportMessageDto {
|
||||
return {
|
||||
id: m.id,
|
||||
conversationId: m.conversationId,
|
||||
sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT,
|
||||
text: m.text,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy BOT messages are surfaced as AGENT to the UI. */
|
||||
private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null {
|
||||
if (!s) return null;
|
||||
return s === 'USER'
|
||||
? T.PassengerSupportSender.USER
|
||||
: T.PassengerSupportSender.AGENT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { verifyToken } from '@tria-plc/api-common/utils/token';
|
||||
import { ESessionStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||
|
||||
/**
|
||||
* Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access
|
||||
* token payload is only a *session* pointer (`{ id: <sessionId> }`), not the
|
||||
* user — so we verify the signature (`verifyToken`), then load the IAM session
|
||||
* and require it to be ACTIVE and unexpired, and read the real user id out of
|
||||
* `session.userInfo`. The `Session` entity is served by the app's default
|
||||
* TypeORM DataSource (the same one the shared JwtGuard queries for `iam.sessions`).
|
||||
*
|
||||
* Returns the IAM user id, or null for any invalid/expired/revoked/malformed token.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WsAuthService {
|
||||
private readonly logger = new Logger(WsAuthService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Session)
|
||||
private readonly sessions: Repository<Session>,
|
||||
) {}
|
||||
|
||||
async resolveUserId(token?: string): Promise<string | null> {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = verifyToken(token) as { id?: string };
|
||||
const sessionId = payload?.id;
|
||||
if (!sessionId) return null;
|
||||
|
||||
const session = await this.sessions.findOne({ where: { id: sessionId } });
|
||||
if (!session) return null;
|
||||
if (session.status !== ESessionStatus.ACTIVE) return null;
|
||||
if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return session.userInfo?.id ?? null;
|
||||
} catch (err) {
|
||||
this.logger.debug(`WS auth rejected: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() },
|
||||
}),
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { BaseEntity } from "../common";
|
||||
|
||||
export * from "./support-chat";
|
||||
|
||||
export enum TicketStatus {
|
||||
Reserved = "RESERVED",
|
||||
Confirmed = "CONFIRMED",
|
||||
|
||||
103
packages/types/src/passenger/support-chat.ts
Normal file
103
packages/types/src/passenger/support-chat.ts
Normal 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
15
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user