@consilioweb/payload-support 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +525 -0
- package/dist/client.cjs +7 -0
- package/dist/client.d.cts +3 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.js +5 -0
- package/dist/index.cjs +7766 -0
- package/dist/index.d.cts +384 -0
- package/dist/index.d.ts +384 -0
- package/dist/index.js +7730 -0
- package/dist/views.d.cts +30 -0
- package/dist/views.d.ts +30 -0
- package/package.json +131 -0
- package/src/client.ts +1 -0
- package/src/collections/AuthLogs.ts +65 -0
- package/src/collections/CannedResponses.ts +69 -0
- package/src/collections/ChatMessages.ts +98 -0
- package/src/collections/EmailLogs.ts +94 -0
- package/src/collections/KnowledgeBase.ts +99 -0
- package/src/collections/Macros.ts +98 -0
- package/src/collections/PendingEmails.ts +122 -0
- package/src/collections/SatisfactionSurveys.ts +98 -0
- package/src/collections/SlaPolicies.ts +123 -0
- package/src/collections/SupportClients.ts +210 -0
- package/src/collections/TicketActivityLog.ts +81 -0
- package/src/collections/TicketMessages.ts +364 -0
- package/src/collections/TicketStatuses.ts +108 -0
- package/src/collections/Tickets.ts +704 -0
- package/src/collections/TimeEntries.ts +105 -0
- package/src/collections/WebhookEndpoints.ts +96 -0
- package/src/collections/index.ts +16 -0
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
- package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
- package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
- package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
- package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
- package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
- package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
- package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
- package/src/components/TicketConversation/config.ts +82 -0
- package/src/components/TicketConversation/constants.ts +74 -0
- package/src/components/TicketConversation/context.ts +63 -0
- package/src/components/TicketConversation/hooks/useAI.ts +180 -0
- package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
- package/src/components/TicketConversation/hooks/useReply.ts +190 -0
- package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
- package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
- package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
- package/src/components/TicketConversation/index.tsx +1110 -0
- package/src/components/TicketConversation/locales/en.json +878 -0
- package/src/components/TicketConversation/locales/fr.json +878 -0
- package/src/components/TicketConversation/types.ts +54 -0
- package/src/components/TicketConversation/utils.ts +25 -0
- package/src/endpoints/admin-chat-stream.ts +238 -0
- package/src/endpoints/admin-chat.ts +263 -0
- package/src/endpoints/admin-stats.ts +200 -0
- package/src/endpoints/ai.ts +199 -0
- package/src/endpoints/apply-macro.ts +144 -0
- package/src/endpoints/auth-2fa.ts +163 -0
- package/src/endpoints/auto-close.ts +175 -0
- package/src/endpoints/billing.ts +167 -0
- package/src/endpoints/bulk-action.ts +103 -0
- package/src/endpoints/chat-stream.ts +127 -0
- package/src/endpoints/chat.ts +188 -0
- package/src/endpoints/chatbot.ts +113 -0
- package/src/endpoints/delete-account.ts +129 -0
- package/src/endpoints/email-stats.ts +109 -0
- package/src/endpoints/export-csv.ts +84 -0
- package/src/endpoints/export-data.ts +104 -0
- package/src/endpoints/import-conversation.ts +307 -0
- package/src/endpoints/index.ts +154 -0
- package/src/endpoints/login.ts +92 -0
- package/src/endpoints/merge-clients.ts +132 -0
- package/src/endpoints/merge-tickets.ts +137 -0
- package/src/endpoints/oauth-google.ts +179 -0
- package/src/endpoints/pending-emails-process.ts +224 -0
- package/src/endpoints/presence.ts +104 -0
- package/src/endpoints/process-scheduled.ts +144 -0
- package/src/endpoints/purge-logs.ts +58 -0
- package/src/endpoints/resend-notification.ts +99 -0
- package/src/endpoints/round-robin-config.ts +92 -0
- package/src/endpoints/satisfaction.ts +93 -0
- package/src/endpoints/search.ts +106 -0
- package/src/endpoints/seed-kb.ts +153 -0
- package/src/endpoints/settings.ts +144 -0
- package/src/endpoints/signature.ts +93 -0
- package/src/endpoints/sla-check.ts +124 -0
- package/src/endpoints/split-ticket.ts +131 -0
- package/src/endpoints/statuses.ts +45 -0
- package/src/endpoints/track-open.ts +154 -0
- package/src/endpoints/typing.ts +101 -0
- package/src/endpoints/user-prefs.ts +125 -0
- package/src/hooks/checkSLA.ts +414 -0
- package/src/hooks/ticketStatusEmail.ts +182 -0
- package/src/index.ts +51 -0
- package/src/plugin.ts +157 -0
- package/src/portal/LiveChat.tsx +1353 -0
- package/src/portal/auth/ChatWidget.tsx +350 -0
- package/src/portal/auth/ChatbotWidget.tsx +285 -0
- package/src/portal/auth/SupportHeader.tsx +409 -0
- package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
- package/src/portal/auth/dashboard/page.tsx +84 -0
- package/src/portal/auth/faq/FAQSearch.tsx +117 -0
- package/src/portal/auth/faq/page.tsx +199 -0
- package/src/portal/auth/layout.tsx +61 -0
- package/src/portal/auth/profile/page.tsx +705 -0
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
- package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
- package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
- package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
- package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
- package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
- package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
- package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
- package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
- package/src/portal/auth/tickets/detail/page.tsx +738 -0
- package/src/portal/auth/tickets/new/page.tsx +515 -0
- package/src/portal/forgot-password/page.tsx +114 -0
- package/src/portal/layout.tsx +26 -0
- package/src/portal/locales/en.json +374 -0
- package/src/portal/locales/fr.json +374 -0
- package/src/portal/login/page.tsx +351 -0
- package/src/portal/page.tsx +162 -0
- package/src/portal/register/page.tsx +281 -0
- package/src/portal/reset-password/page.tsx +152 -0
- package/src/styles/BillingView.module.scss +311 -0
- package/src/styles/ChatView.module.scss +438 -0
- package/src/styles/CommandPalette.module.scss +160 -0
- package/src/styles/CrmView.module.scss +554 -0
- package/src/styles/EmailTracking.module.scss +238 -0
- package/src/styles/ImportConversation.module.scss +267 -0
- package/src/styles/Layout.module.scss +55 -0
- package/src/styles/Logs.module.scss +164 -0
- package/src/styles/NewTicket.module.scss +143 -0
- package/src/styles/PendingEmails.module.scss +629 -0
- package/src/styles/SupportDashboard.module.scss +649 -0
- package/src/styles/TicketDetail.module.scss +1043 -0
- package/src/styles/TicketInbox.module.scss +296 -0
- package/src/styles/TicketingSettings.module.scss +358 -0
- package/src/styles/TimeDashboard.module.scss +287 -0
- package/src/styles/_tokens.scss +78 -0
- package/src/styles/theme.css +633 -0
- package/src/types.ts +255 -0
- package/src/utils/adminNotification.ts +38 -0
- package/src/utils/auth.ts +46 -0
- package/src/utils/emailTemplate.ts +343 -0
- package/src/utils/fireWebhooks.ts +84 -0
- package/src/utils/index.ts +22 -0
- package/src/utils/rateLimiter.ts +52 -0
- package/src/utils/readSettings.ts +67 -0
- package/src/utils/slugs.ts +54 -0
- package/src/utils/webhookDispatcher.ts +120 -0
- package/src/views/BillingView/client.tsx +137 -0
- package/src/views/BillingView/index.tsx +33 -0
- package/src/views/ChatView/client.tsx +294 -0
- package/src/views/ChatView/index.tsx +33 -0
- package/src/views/CrmView/client.tsx +206 -0
- package/src/views/CrmView/index.tsx +33 -0
- package/src/views/EmailTrackingView/client.tsx +124 -0
- package/src/views/EmailTrackingView/index.tsx +33 -0
- package/src/views/ImportConversationView/client.tsx +133 -0
- package/src/views/ImportConversationView/index.tsx +33 -0
- package/src/views/LogsView/client.tsx +151 -0
- package/src/views/LogsView/index.tsx +30 -0
- package/src/views/NewTicketView/client.tsx +227 -0
- package/src/views/NewTicketView/index.tsx +30 -0
- package/src/views/PendingEmailsView/client.tsx +177 -0
- package/src/views/PendingEmailsView/index.tsx +33 -0
- package/src/views/SupportDashboardView/client.tsx +424 -0
- package/src/views/SupportDashboardView/index.tsx +33 -0
- package/src/views/TicketDetailView/client.tsx +775 -0
- package/src/views/TicketDetailView/index.tsx +33 -0
- package/src/views/TicketInboxView/client.tsx +313 -0
- package/src/views/TicketInboxView/index.tsx +30 -0
- package/src/views/TicketingSettingsView/client.tsx +866 -0
- package/src/views/TicketingSettingsView/index.tsx +33 -0
- package/src/views/TimeDashboardView/client.tsx +144 -0
- package/src/views/TimeDashboardView/index.tsx +33 -0
- package/src/views/shared/AdminViewHeader.tsx +69 -0
- package/src/views/shared/ErrorBoundary.tsx +68 -0
- package/src/views/shared/Skeleton.tsx +125 -0
- package/src/views/shared/adminTokens.ts +37 -0
- package/src/views/shared/config.ts +82 -0
- package/src/views/shared/index.ts +6 -0
- package/src/views.ts +16 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface Attachment {
|
|
2
|
+
file: { id: number; url?: string; filename?: string; mimeType?: string; sizes?: Record<string, { url?: string }> } | number
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface Message {
|
|
6
|
+
id: string | number
|
|
7
|
+
body: string
|
|
8
|
+
bodyHtml?: string
|
|
9
|
+
authorType: 'client' | 'admin' | 'email'
|
|
10
|
+
isInternal?: boolean
|
|
11
|
+
isSolution?: boolean
|
|
12
|
+
attachments?: Attachment[]
|
|
13
|
+
createdAt: string
|
|
14
|
+
fromChat?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TimeEntry {
|
|
18
|
+
id: string | number
|
|
19
|
+
duration: number
|
|
20
|
+
description?: string
|
|
21
|
+
date: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ClientInfo {
|
|
25
|
+
id: number
|
|
26
|
+
company: string
|
|
27
|
+
firstName: string
|
|
28
|
+
lastName: string
|
|
29
|
+
email: string
|
|
30
|
+
phone?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CannedResponse {
|
|
34
|
+
id: string | number
|
|
35
|
+
title: string
|
|
36
|
+
body: string
|
|
37
|
+
category?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ActivityEntry {
|
|
41
|
+
id: string | number
|
|
42
|
+
action: string
|
|
43
|
+
detail?: string
|
|
44
|
+
actorType?: string
|
|
45
|
+
actorEmail?: string
|
|
46
|
+
createdAt: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SatisfactionSurvey {
|
|
50
|
+
id: string | number
|
|
51
|
+
rating: number
|
|
52
|
+
comment?: string
|
|
53
|
+
createdAt: string
|
|
54
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function getDateLabel(dateStr: string): string {
|
|
2
|
+
const date = new Date(dateStr)
|
|
3
|
+
const today = new Date()
|
|
4
|
+
const yesterday = new Date(today)
|
|
5
|
+
yesterday.setDate(yesterday.getDate() - 1)
|
|
6
|
+
|
|
7
|
+
if (date.toDateString() === today.toDateString()) return "Aujourd'hui"
|
|
8
|
+
if (date.toDateString() === yesterday.toDateString()) return 'Hier'
|
|
9
|
+
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatMessageDate(dateStr: string): string {
|
|
13
|
+
const date = new Date(dateStr)
|
|
14
|
+
const today = new Date()
|
|
15
|
+
const yesterday = new Date(today)
|
|
16
|
+
yesterday.setDate(yesterday.getDate() - 1)
|
|
17
|
+
const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
|
18
|
+
|
|
19
|
+
if (date.toDateString() === today.toDateString()) return time
|
|
20
|
+
if (date.toDateString() === yesterday.toDateString()) return `Hier, ${time}`
|
|
21
|
+
if (date.getFullYear() === today.getFullYear()) {
|
|
22
|
+
return `${date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}, ${time}`
|
|
23
|
+
}
|
|
24
|
+
return `${date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })}, ${time}`
|
|
25
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/support/admin-chat-stream?session=xxx
|
|
7
|
+
* Server-Sent Events endpoint for real-time chat messages (admin-side).
|
|
8
|
+
* Streams new messages + typing indicators for a specific session.
|
|
9
|
+
*
|
|
10
|
+
* Without ?session parameter, streams session list updates (new sessions, status changes).
|
|
11
|
+
*/
|
|
12
|
+
export function createAdminChatStreamEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
13
|
+
return {
|
|
14
|
+
path: '/support/admin-chat-stream',
|
|
15
|
+
method: 'get',
|
|
16
|
+
handler: async (req) => {
|
|
17
|
+
try {
|
|
18
|
+
requireAdmin(req, slugs)
|
|
19
|
+
} catch (error) {
|
|
20
|
+
const authResponse = handleAuthError(error)
|
|
21
|
+
if (authResponse) return authResponse
|
|
22
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const url = new URL(req.url!)
|
|
26
|
+
const sessionId = url.searchParams.get('session')
|
|
27
|
+
|
|
28
|
+
// Session-specific stream: messages for one chat session
|
|
29
|
+
if (sessionId) {
|
|
30
|
+
return createSessionStream(req, slugs, sessionId)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Session list stream: updates on all active sessions
|
|
34
|
+
return createSessionListStream(req, slugs)
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stream messages for a specific chat session (admin view).
|
|
41
|
+
*/
|
|
42
|
+
function createSessionStream(
|
|
43
|
+
req: any,
|
|
44
|
+
slugs: CollectionSlugs,
|
|
45
|
+
sessionId: string,
|
|
46
|
+
): Response {
|
|
47
|
+
let lastCheck = new Date().toISOString()
|
|
48
|
+
const encoder = new TextEncoder()
|
|
49
|
+
|
|
50
|
+
const stream = new ReadableStream({
|
|
51
|
+
start(controller) {
|
|
52
|
+
controller.enqueue(
|
|
53
|
+
encoder.encode(
|
|
54
|
+
`data: ${JSON.stringify({ type: 'connected', session: sessionId })}\n\n`,
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// Poll DB every 2 seconds for new messages in this session
|
|
59
|
+
const interval = setInterval(async () => {
|
|
60
|
+
try {
|
|
61
|
+
const messages = await req.payload.find({
|
|
62
|
+
collection: slugs.chatMessages as any,
|
|
63
|
+
where: {
|
|
64
|
+
session: { equals: sessionId },
|
|
65
|
+
createdAt: { greater_than: lastCheck },
|
|
66
|
+
},
|
|
67
|
+
sort: 'createdAt',
|
|
68
|
+
limit: 50,
|
|
69
|
+
depth: 1,
|
|
70
|
+
overrideAccess: true,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
if (messages.docs.length > 0) {
|
|
74
|
+
const data = JSON.stringify({
|
|
75
|
+
type: 'messages',
|
|
76
|
+
data: messages.docs,
|
|
77
|
+
})
|
|
78
|
+
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
79
|
+
lastCheck = (messages.docs[messages.docs.length - 1] as any).createdAt
|
|
80
|
+
|
|
81
|
+
// Check if session was closed
|
|
82
|
+
const lastMsg = messages.docs[messages.docs.length - 1] as any
|
|
83
|
+
if (lastMsg.status === 'closed') {
|
|
84
|
+
controller.enqueue(
|
|
85
|
+
encoder.encode(
|
|
86
|
+
`data: ${JSON.stringify({ type: 'closed', session: sessionId })}\n\n`,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.warn('[support] admin-chat-stream SSE poll error:', error)
|
|
93
|
+
}
|
|
94
|
+
}, 2000)
|
|
95
|
+
|
|
96
|
+
// Heartbeat every 30s
|
|
97
|
+
const heartbeat = setInterval(() => {
|
|
98
|
+
try {
|
|
99
|
+
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
|
|
100
|
+
} catch {
|
|
101
|
+
// Stream already closed
|
|
102
|
+
}
|
|
103
|
+
}, 30000)
|
|
104
|
+
|
|
105
|
+
req.signal?.addEventListener('abort', () => {
|
|
106
|
+
clearInterval(interval)
|
|
107
|
+
clearInterval(heartbeat)
|
|
108
|
+
try {
|
|
109
|
+
controller.close()
|
|
110
|
+
} catch {
|
|
111
|
+
// Already closed
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return new Response(stream, {
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'text/event-stream',
|
|
120
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
121
|
+
Connection: 'keep-alive',
|
|
122
|
+
'X-Accel-Buffering': 'no',
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Stream session list updates (admin overview of all chat sessions).
|
|
129
|
+
* Emits a snapshot of active/closed sessions every 5 seconds.
|
|
130
|
+
*/
|
|
131
|
+
function createSessionListStream(req: any, slugs: CollectionSlugs): Response {
|
|
132
|
+
const encoder = new TextEncoder()
|
|
133
|
+
let lastSessionHash = ''
|
|
134
|
+
|
|
135
|
+
const stream = new ReadableStream({
|
|
136
|
+
start(controller) {
|
|
137
|
+
controller.enqueue(
|
|
138
|
+
encoder.encode(`data: ${JSON.stringify({ type: 'connected', mode: 'sessions' })}\n\n`),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const interval = setInterval(async () => {
|
|
142
|
+
try {
|
|
143
|
+
const recentMessages = await req.payload.find({
|
|
144
|
+
collection: slugs.chatMessages as any,
|
|
145
|
+
sort: '-createdAt',
|
|
146
|
+
limit: 50,
|
|
147
|
+
depth: 1,
|
|
148
|
+
overrideAccess: true,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const sessionsMap = new Map<
|
|
152
|
+
string,
|
|
153
|
+
{
|
|
154
|
+
session: string
|
|
155
|
+
client: unknown
|
|
156
|
+
lastMessage: string
|
|
157
|
+
lastMessageAt: string
|
|
158
|
+
senderType: string
|
|
159
|
+
status: string
|
|
160
|
+
messageCount: number
|
|
161
|
+
unreadCount: number
|
|
162
|
+
}
|
|
163
|
+
>()
|
|
164
|
+
|
|
165
|
+
for (const msg of recentMessages.docs) {
|
|
166
|
+
const m = msg as any
|
|
167
|
+
const sid = m.session
|
|
168
|
+
if (!sessionsMap.has(sid)) {
|
|
169
|
+
sessionsMap.set(sid, {
|
|
170
|
+
session: sid,
|
|
171
|
+
client: m.client,
|
|
172
|
+
lastMessage: m.message,
|
|
173
|
+
lastMessageAt: m.createdAt,
|
|
174
|
+
senderType: m.senderType,
|
|
175
|
+
status: m.status || 'active',
|
|
176
|
+
messageCount: 0,
|
|
177
|
+
unreadCount: 0,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
const s = sessionsMap.get(sid)!
|
|
181
|
+
s.messageCount++
|
|
182
|
+
if (m.senderType === 'client') s.unreadCount++
|
|
183
|
+
else if (m.senderType === 'agent') s.unreadCount = 0
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const sessions = Array.from(sessionsMap.values()).sort(
|
|
187
|
+
(a, b) => new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime(),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const active = sessions.filter((s) => s.status === 'active')
|
|
191
|
+
const closed = sessions.filter((s) => s.status === 'closed')
|
|
192
|
+
|
|
193
|
+
// Only emit if data changed (simple hash comparison)
|
|
194
|
+
const hash = JSON.stringify({ active: active.length, closed: closed.length, lastMsg: active[0]?.lastMessageAt })
|
|
195
|
+
if (hash !== lastSessionHash) {
|
|
196
|
+
lastSessionHash = hash
|
|
197
|
+
|
|
198
|
+
const data = JSON.stringify({
|
|
199
|
+
type: 'sessions',
|
|
200
|
+
data: { active, closed, totalActive: active.length },
|
|
201
|
+
})
|
|
202
|
+
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.warn('[support] admin-chat-stream sessions SSE error:', error)
|
|
206
|
+
}
|
|
207
|
+
}, 5000)
|
|
208
|
+
|
|
209
|
+
// Heartbeat every 30s
|
|
210
|
+
const heartbeat = setInterval(() => {
|
|
211
|
+
try {
|
|
212
|
+
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
|
|
213
|
+
} catch {
|
|
214
|
+
// Stream already closed
|
|
215
|
+
}
|
|
216
|
+
}, 30000)
|
|
217
|
+
|
|
218
|
+
req.signal?.addEventListener('abort', () => {
|
|
219
|
+
clearInterval(interval)
|
|
220
|
+
clearInterval(heartbeat)
|
|
221
|
+
try {
|
|
222
|
+
controller.close()
|
|
223
|
+
} catch {
|
|
224
|
+
// Already closed
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
return new Response(stream, {
|
|
231
|
+
headers: {
|
|
232
|
+
'Content-Type': 'text/event-stream',
|
|
233
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
234
|
+
Connection: 'keep-alive',
|
|
235
|
+
'X-Accel-Buffering': 'no',
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { Where } from 'payload'
|
|
3
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
4
|
+
import { RateLimiter } from '../utils/rateLimiter'
|
|
5
|
+
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
6
|
+
|
|
7
|
+
const adminChatLimiter = new RateLimiter(60_000, 30) // 30 per minute
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GET /api/support/admin-chat?session=xxx&after=timestamp
|
|
11
|
+
* GET /api/support/admin-chat (no session = list active sessions)
|
|
12
|
+
*/
|
|
13
|
+
export function createAdminChatGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
14
|
+
return {
|
|
15
|
+
path: '/support/admin-chat',
|
|
16
|
+
method: 'get',
|
|
17
|
+
handler: async (req) => {
|
|
18
|
+
try {
|
|
19
|
+
const payload = req.payload
|
|
20
|
+
|
|
21
|
+
requireAdmin(req, slugs)
|
|
22
|
+
|
|
23
|
+
const url = new URL(req.url!)
|
|
24
|
+
const session = url.searchParams.get('session')
|
|
25
|
+
const after = url.searchParams.get('after')
|
|
26
|
+
|
|
27
|
+
if (session) {
|
|
28
|
+
const where: Where = { session: { equals: session } }
|
|
29
|
+
if (after) where.createdAt = { greater_than: after }
|
|
30
|
+
|
|
31
|
+
const messages = await payload.find({
|
|
32
|
+
collection: slugs.chatMessages as any,
|
|
33
|
+
where,
|
|
34
|
+
sort: 'createdAt',
|
|
35
|
+
limit: 200,
|
|
36
|
+
depth: 1,
|
|
37
|
+
overrideAccess: true,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return new Response(JSON.stringify({ messages: messages.docs, session }), {
|
|
41
|
+
headers: {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'Cache-Control': 'private, max-age=300, stale-while-revalidate=600',
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// List recent sessions: fetch only recent messages, limit to 50
|
|
49
|
+
const recentMessages = await payload.find({
|
|
50
|
+
collection: slugs.chatMessages as any,
|
|
51
|
+
sort: '-createdAt',
|
|
52
|
+
limit: 50,
|
|
53
|
+
depth: 1,
|
|
54
|
+
overrideAccess: true,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const sessionsMap = new Map<string, {
|
|
58
|
+
session: string
|
|
59
|
+
client: unknown
|
|
60
|
+
lastMessage: string
|
|
61
|
+
lastMessageAt: string
|
|
62
|
+
senderType: string
|
|
63
|
+
status: string
|
|
64
|
+
messageCount: number
|
|
65
|
+
unreadCount: number
|
|
66
|
+
}>()
|
|
67
|
+
|
|
68
|
+
for (const msg of recentMessages.docs) {
|
|
69
|
+
const m = msg as any
|
|
70
|
+
const sid = m.session
|
|
71
|
+
if (!sessionsMap.has(sid)) {
|
|
72
|
+
sessionsMap.set(sid, {
|
|
73
|
+
session: sid,
|
|
74
|
+
client: m.client,
|
|
75
|
+
lastMessage: m.message,
|
|
76
|
+
lastMessageAt: m.createdAt,
|
|
77
|
+
senderType: m.senderType,
|
|
78
|
+
status: m.status || 'active',
|
|
79
|
+
messageCount: 0,
|
|
80
|
+
unreadCount: 0,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
const s = sessionsMap.get(sid)!
|
|
84
|
+
s.messageCount++
|
|
85
|
+
if (m.senderType === 'client') s.unreadCount++
|
|
86
|
+
else if (m.senderType === 'agent') s.unreadCount = 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sessions = Array.from(sessionsMap.values())
|
|
90
|
+
.sort((a, b) => new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime())
|
|
91
|
+
|
|
92
|
+
const activeSessions = sessions.filter((s) => s.status === 'active')
|
|
93
|
+
const closedSessions = sessions.filter((s) => s.status === 'closed')
|
|
94
|
+
|
|
95
|
+
return new Response(JSON.stringify({ active: activeSessions, closed: closedSessions, totalActive: activeSessions.length }), {
|
|
96
|
+
headers: {
|
|
97
|
+
'Content-Type': 'application/json',
|
|
98
|
+
'Cache-Control': 'private, max-age=300, stale-while-revalidate=600',
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const authResponse = handleAuthError(error)
|
|
103
|
+
if (authResponse) return authResponse
|
|
104
|
+
console.error('[admin-chat] GET Error:', error)
|
|
105
|
+
return Response.json({ error: 'Erreur interne du serveur' }, { status: 500 })
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* POST /api/support/admin-chat
|
|
113
|
+
* Admin sends a message or closes a session.
|
|
114
|
+
*/
|
|
115
|
+
export function createAdminChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
116
|
+
return {
|
|
117
|
+
path: '/support/admin-chat',
|
|
118
|
+
method: 'post',
|
|
119
|
+
handler: async (req) => {
|
|
120
|
+
try {
|
|
121
|
+
const payload = req.payload
|
|
122
|
+
|
|
123
|
+
requireAdmin(req, slugs)
|
|
124
|
+
|
|
125
|
+
let body: { action?: string; session?: string; message?: string }
|
|
126
|
+
try {
|
|
127
|
+
body = await req.json!()
|
|
128
|
+
} catch {
|
|
129
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
130
|
+
}
|
|
131
|
+
const { action, session, message } = body
|
|
132
|
+
|
|
133
|
+
if (!session) {
|
|
134
|
+
return Response.json({ error: 'Session requise' }, { status: 400 })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const sessionMsg = await payload.find({
|
|
138
|
+
collection: slugs.chatMessages as any,
|
|
139
|
+
where: { session: { equals: session } },
|
|
140
|
+
limit: 1,
|
|
141
|
+
depth: 0,
|
|
142
|
+
overrideAccess: true,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
if (sessionMsg.docs.length === 0) {
|
|
146
|
+
return Response.json({ error: 'Session introuvable' }, { status: 404 })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const clientId = typeof (sessionMsg.docs[0] as any).client === 'object'
|
|
150
|
+
? (sessionMsg.docs[0] as any).client.id
|
|
151
|
+
: (sessionMsg.docs[0] as any).client
|
|
152
|
+
|
|
153
|
+
// Agent sends a message
|
|
154
|
+
if (action === 'send' && message) {
|
|
155
|
+
if (adminChatLimiter.check(String(req.user.id))) {
|
|
156
|
+
return Response.json({ error: 'Rate limit atteint.' }, { status: 429 })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const trimmedMessage = String(message).trim()
|
|
160
|
+
if (!trimmedMessage || trimmedMessage.length > 2000) {
|
|
161
|
+
return Response.json({ error: 'Message invalide (1-2000 caractères).' }, { status: 400 })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const newMsg = await payload.create({
|
|
165
|
+
collection: slugs.chatMessages as any,
|
|
166
|
+
data: {
|
|
167
|
+
session,
|
|
168
|
+
client: clientId,
|
|
169
|
+
senderType: 'agent',
|
|
170
|
+
agent: req.user.id,
|
|
171
|
+
message: trimmedMessage,
|
|
172
|
+
status: 'active',
|
|
173
|
+
},
|
|
174
|
+
overrideAccess: true,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Try to link to ticket
|
|
178
|
+
try {
|
|
179
|
+
const linkedTicket = await payload.find({
|
|
180
|
+
collection: slugs.tickets as any,
|
|
181
|
+
where: { chatSession: { equals: session } },
|
|
182
|
+
limit: 1,
|
|
183
|
+
depth: 0,
|
|
184
|
+
overrideAccess: true,
|
|
185
|
+
})
|
|
186
|
+
if (linkedTicket.docs.length > 0) {
|
|
187
|
+
const ticketId = linkedTicket.docs[0].id
|
|
188
|
+
await payload.create({
|
|
189
|
+
collection: slugs.ticketMessages as any,
|
|
190
|
+
data: {
|
|
191
|
+
ticket: ticketId,
|
|
192
|
+
body: trimmedMessage,
|
|
193
|
+
authorType: 'admin',
|
|
194
|
+
skipNotification: true,
|
|
195
|
+
},
|
|
196
|
+
overrideAccess: true,
|
|
197
|
+
})
|
|
198
|
+
await payload.update({
|
|
199
|
+
collection: slugs.chatMessages as any,
|
|
200
|
+
id: newMsg.id,
|
|
201
|
+
data: { ticket: ticketId },
|
|
202
|
+
overrideAccess: true,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.error('[admin-chat] Failed to link message to ticket:', err)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return Response.json({ message: newMsg })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Admin closes session
|
|
213
|
+
if (action === 'close') {
|
|
214
|
+
await payload.create({
|
|
215
|
+
collection: slugs.chatMessages as any,
|
|
216
|
+
data: {
|
|
217
|
+
session,
|
|
218
|
+
client: clientId,
|
|
219
|
+
senderType: 'system',
|
|
220
|
+
message: 'Chat terminé par un agent.',
|
|
221
|
+
status: 'closed',
|
|
222
|
+
},
|
|
223
|
+
overrideAccess: true,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const linkedTicket = await payload.find({
|
|
228
|
+
collection: slugs.tickets as any,
|
|
229
|
+
where: { chatSession: { equals: session } },
|
|
230
|
+
limit: 1,
|
|
231
|
+
depth: 0,
|
|
232
|
+
overrideAccess: true,
|
|
233
|
+
})
|
|
234
|
+
if (linkedTicket.docs.length > 0) {
|
|
235
|
+
await payload.create({
|
|
236
|
+
collection: slugs.ticketMessages as any,
|
|
237
|
+
data: {
|
|
238
|
+
ticket: linkedTicket.docs[0].id,
|
|
239
|
+
body: 'Session de chat terminée par un agent.',
|
|
240
|
+
authorType: 'admin',
|
|
241
|
+
isInternal: true,
|
|
242
|
+
skipNotification: true,
|
|
243
|
+
},
|
|
244
|
+
overrideAccess: true,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
console.error('[admin-chat] Failed to update ticket on close:', err)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return Response.json({ closed: true })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return Response.json({ error: 'Action invalide' }, { status: 400 })
|
|
255
|
+
} catch (error) {
|
|
256
|
+
const authResponse = handleAuthError(error)
|
|
257
|
+
if (authResponse) return authResponse
|
|
258
|
+
console.error('[admin-chat] POST Error:', error)
|
|
259
|
+
return Response.json({ error: 'Erreur interne du serveur' }, { status: 500 })
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
}
|