From a817976db89e46ceff53d2f1184272367ebe2572 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 8 Jul 2026 05:33:02 +0000 Subject: [PATCH] feat: make the support require nothing --- .../src/modules/support/support.controller.ts | 36 +- .../src/modules/support/support.dto.ts | 20 + .../src/modules/support/support.service.ts | 55 +++ .../src/features/support/SupportPanel.tsx | 394 +++--------------- .../src/features/support/SupportWidget.tsx | 15 +- .../src/features/support/deviceIdentity.ts | 21 + .../src/features/support/guestIdentity.ts | 24 -- .../portal/src/features/support/supportApi.ts | 82 +--- .../portal/src/features/support/useSupport.ts | 41 +- .../src/features/support/useSupportSocket.ts | 51 +-- packages/types/src/passenger/support-chat.ts | 6 + 11 files changed, 237 insertions(+), 508 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/features/support/deviceIdentity.ts delete mode 100644 apps/edr-passenger-web/portal/src/features/support/guestIdentity.ts diff --git a/apps/edr-passenger-api/src/modules/support/support.controller.ts b/apps/edr-passenger-api/src/modules/support/support.controller.ts index 63adefe5a..4404c3ea8 100644 --- a/apps/edr-passenger-api/src/modules/support/support.controller.ts +++ b/apps/edr-passenger-api/src/modules/support/support.controller.ts @@ -17,6 +17,8 @@ import { JwtGuard } from '../../common/jwt.guard'; import { CreateConversationDto, CreateGuestConversationDto, + DeviceIdBodyDto, + DeviceSendMessageDto, GuestIdBodyDto, GuestSendMessageDto, ListConversationsQueryDto, @@ -103,7 +105,39 @@ export class SupportController { return this.service.unreadCount('USER', { iamUserId: userId(req) }); } - // ---- customer: guest (unauthenticated) -------------------------------- + // ---- customer: device-scoped single thread (portal) ------------------- + // No auth, no forms. One conversation per device id (localStorage). Anyone + // with the device id can see that thread — accepted MVP trade-off. + + @Get('device/thread') + @IsPublic() + @ApiOperation({ summary: "Get the device's support thread + messages" }) + deviceThread(@Query('deviceId') deviceId: string) { + return this.service.getDeviceThread(deviceId); + } + + @Post('device/messages') + @IsPublic() + @ApiOperation({ summary: 'Send a message (creates the thread on first send)' }) + deviceSend(@Body() body: DeviceSendMessageDto) { + return this.service.sendDeviceMessage(body.deviceId, body.text); + } + + @Post('device/read') + @IsPublic() + @ApiOperation({ summary: 'Mark the device thread read' }) + deviceRead(@Body() body: DeviceIdBodyDto) { + return this.service.markDeviceRead(body.deviceId); + } + + @Get('device/unread-count') + @IsPublic() + @ApiOperation({ summary: "Count the device thread's unread messages" }) + deviceUnread(@Query('deviceId') deviceId: string) { + return this.service.unreadCount('USER', { guestId: deviceId }); + } + + // ---- customer: guest (unauthenticated, multi-ticket) ------------------ // No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer // of access — anyone with it sees that thread; accepted MVP trade-off). diff --git a/apps/edr-passenger-api/src/modules/support/support.dto.ts b/apps/edr-passenger-api/src/modules/support/support.dto.ts index 77a3ee3ed..a60ac607e 100644 --- a/apps/edr-passenger-api/src/modules/support/support.dto.ts +++ b/apps/edr-passenger-api/src/modules/support/support.dto.ts @@ -93,6 +93,26 @@ export class GuestIdBodyDto { guestId!: string; } +export class DeviceSendMessageDto { + @ApiProperty({ description: 'Client device id (localStorage).' }) + @IsString() + @Length(8, 120) + deviceId!: string; + + @ApiProperty({ description: 'Message text.' }) + @IsString() + @MinLength(1) + @MaxLength(4000) + text!: string; +} + +export class DeviceIdBodyDto { + @ApiProperty() + @IsString() + @Length(8, 120) + deviceId!: string; +} + export class UpdateStatusDto { @ApiProperty({ enum: SupportStatusDto }) @IsEnum(SupportStatusDto) diff --git a/apps/edr-passenger-api/src/modules/support/support.service.ts b/apps/edr-passenger-api/src/modules/support/support.service.ts index dfbbb3b12..77f701bf2 100644 --- a/apps/edr-passenger-api/src/modules/support/support.service.ts +++ b/apps/edr-passenger-api/src/modules/support/support.service.ts @@ -113,6 +113,61 @@ export class SupportService { return this.firstMessage(conversation, input.initialMessage); } + // ---- customer: device-scoped single thread (portal) ------------------- + + /** The device's single conversation + its messages ({conversation:null} if none). */ + async getDeviceThread(deviceId: string): Promise { + if (!deviceId) return { conversation: null, messages: [] }; + const c = (await this.prisma.supportConversation.findFirst({ + where: { guestId: deviceId }, + orderBy: { createdAt: 'asc' }, + })) as ConversationRow | null; + if (!c) return { conversation: null, messages: [] }; + const rows = await this.prisma.supportMessage.findMany({ + where: { conversationId: c.id }, + orderBy: { createdAt: 'asc' }, + }); + const unread = await this.computeUnread([c], 'USER'); + return { + conversation: this.toConversationDto(c, unread.get(c.id) ?? 0), + messages: rows.map((m) => this.toMessageDto(m)), + }; + } + + /** Append a message to the device's thread, creating it on first message. */ + async sendDeviceMessage( + deviceId: string, + text: string, + ): Promise { + let c = (await this.prisma.supportConversation.findFirst({ + where: { guestId: deviceId }, + orderBy: { createdAt: 'asc' }, + })) as ConversationRow | null; + if (!c) { + c = (await this.prisma.supportConversation.create({ + data: { guestId: deviceId, subject: 'Support chat', status: 'OPEN' }, + })) as ConversationRow; + } + const updated = await this.appendMessage(c, 'USER', text); + const last = updated.messages[updated.messages.length - 1]; + return this.toMessageDto(last); + } + + /** Mark the device's thread read (customer side). */ + async markDeviceRead(deviceId: string): Promise<{ unreadCount: number }> { + const c = await this.prisma.supportConversation.findFirst({ + where: { guestId: deviceId }, + orderBy: { createdAt: 'asc' }, + }); + if (c) { + await this.prisma.supportConversation.update({ + where: { id: c.id }, + data: { userLastReadAt: new Date() }, + }); + } + return this.unreadCount('USER', { guestId: deviceId }); + } + async listForCustomer( owner: CustomerOwner, query: ListQuery, diff --git a/apps/edr-passenger-web/portal/src/features/support/SupportPanel.tsx b/apps/edr-passenger-web/portal/src/features/support/SupportPanel.tsx index b7d4ad81b..24b6b82fd 100644 --- a/apps/edr-passenger-web/portal/src/features/support/SupportPanel.tsx +++ b/apps/edr-passenger-web/portal/src/features/support/SupportPanel.tsx @@ -1,27 +1,14 @@ 'use client'; import { Passenger } from '@edr/types'; -import { ArrowLeft, Headset, Plus, Send, X } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { Headset, Send, X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; -import { - useConversations, - useCreateConversation, - useMarkRead, - useMessages, - useSendMessage, -} from './useSupport'; +import { useMarkRead, useSendMessage, useThread } from './useSupport'; const GREEN = 'rgb(20 113 76)'; -type ConversationDto = Passenger.PassengerSupportConversationDto; type MessageDto = Passenger.PassengerSupportMessageDto; -const STATUS_CLASS: Record = { - 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); @@ -31,330 +18,24 @@ function formatTime(iso?: string | null): string { : 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({ kind: 'list' }); - - return ( -
- {view.kind === 'list' && ( - setView({ kind: 'new' })} - onOpen={(id) => setView({ kind: 'thread', id })} - /> - )} - {view.kind === 'new' && ( - setView({ kind: 'list' })} - onCreated={(id) => setView({ kind: 'thread', id })} - /> - )} - {view.kind === 'thread' && ( - setView({ kind: 'list' })} - /> - )} -
- ); -} - -function Header({ - title, - subtitle, - onClose, - onBack, -}: { - title: string; - subtitle?: string; - onClose: () => void; - onBack?: () => void; -}) { - return ( -
-
- {onBack ? ( - - ) : ( - - - - )} -
-

{title}

- {subtitle && ( -

{subtitle}

- )} -
-
- -
- ); -} - -function ConversationList({ - onClose, - onNew, - onOpen, -}: { - onClose: () => void; - onNew: () => void; - onOpen: (id: string) => void; -}) { - const { data, isLoading } = useConversations(); - const items = data?.items ?? []; - - return ( - <> -
-
- {isLoading ? ( -
Loading…
- ) : items.length === 0 ? ( -
- - - - No conversations yet. Start one and our team will help you out. -
- ) : ( - items.map((c) => ( - onOpen(c.id)} /> - )) - )} -
-
- -
- - ); -} - -function ConversationRow({ - c, - onClick, -}: { - c: ConversationDto; - onClick: () => void; -}) { - const unread = c.unreadCount > 0; - return ( - - ); -} - -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 ( - <> -
-
- {isGuest && ( - <> -
- - setName(e.target.value)} - placeholder="Full name" - className={inputClass} - /> -
-
- - setEmail(e.target.value)} - placeholder="you@example.com" - className={inputClass} - /> -
- - )} -
- - setSubject(e.target.value)} - placeholder="e.g. Refund for booking EDR-1234" - className={inputClass} - /> -
-
- -