@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.
Files changed (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +525 -0
  3. package/dist/client.cjs +7 -0
  4. package/dist/client.d.cts +3 -0
  5. package/dist/client.d.ts +3 -0
  6. package/dist/client.js +5 -0
  7. package/dist/index.cjs +7766 -0
  8. package/dist/index.d.cts +384 -0
  9. package/dist/index.d.ts +384 -0
  10. package/dist/index.js +7730 -0
  11. package/dist/views.d.cts +30 -0
  12. package/dist/views.d.ts +30 -0
  13. package/package.json +131 -0
  14. package/src/client.ts +1 -0
  15. package/src/collections/AuthLogs.ts +65 -0
  16. package/src/collections/CannedResponses.ts +69 -0
  17. package/src/collections/ChatMessages.ts +98 -0
  18. package/src/collections/EmailLogs.ts +94 -0
  19. package/src/collections/KnowledgeBase.ts +99 -0
  20. package/src/collections/Macros.ts +98 -0
  21. package/src/collections/PendingEmails.ts +122 -0
  22. package/src/collections/SatisfactionSurveys.ts +98 -0
  23. package/src/collections/SlaPolicies.ts +123 -0
  24. package/src/collections/SupportClients.ts +210 -0
  25. package/src/collections/TicketActivityLog.ts +81 -0
  26. package/src/collections/TicketMessages.ts +364 -0
  27. package/src/collections/TicketStatuses.ts +108 -0
  28. package/src/collections/Tickets.ts +704 -0
  29. package/src/collections/TimeEntries.ts +105 -0
  30. package/src/collections/WebhookEndpoints.ts +96 -0
  31. package/src/collections/index.ts +16 -0
  32. package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
  33. package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
  34. package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
  35. package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
  36. package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
  37. package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
  38. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
  39. package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
  40. package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
  41. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
  42. package/src/components/TicketConversation/config.ts +82 -0
  43. package/src/components/TicketConversation/constants.ts +74 -0
  44. package/src/components/TicketConversation/context.ts +63 -0
  45. package/src/components/TicketConversation/hooks/useAI.ts +180 -0
  46. package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
  47. package/src/components/TicketConversation/hooks/useReply.ts +190 -0
  48. package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
  49. package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
  50. package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
  51. package/src/components/TicketConversation/index.tsx +1110 -0
  52. package/src/components/TicketConversation/locales/en.json +878 -0
  53. package/src/components/TicketConversation/locales/fr.json +878 -0
  54. package/src/components/TicketConversation/types.ts +54 -0
  55. package/src/components/TicketConversation/utils.ts +25 -0
  56. package/src/endpoints/admin-chat-stream.ts +238 -0
  57. package/src/endpoints/admin-chat.ts +263 -0
  58. package/src/endpoints/admin-stats.ts +200 -0
  59. package/src/endpoints/ai.ts +199 -0
  60. package/src/endpoints/apply-macro.ts +144 -0
  61. package/src/endpoints/auth-2fa.ts +163 -0
  62. package/src/endpoints/auto-close.ts +175 -0
  63. package/src/endpoints/billing.ts +167 -0
  64. package/src/endpoints/bulk-action.ts +103 -0
  65. package/src/endpoints/chat-stream.ts +127 -0
  66. package/src/endpoints/chat.ts +188 -0
  67. package/src/endpoints/chatbot.ts +113 -0
  68. package/src/endpoints/delete-account.ts +129 -0
  69. package/src/endpoints/email-stats.ts +109 -0
  70. package/src/endpoints/export-csv.ts +84 -0
  71. package/src/endpoints/export-data.ts +104 -0
  72. package/src/endpoints/import-conversation.ts +307 -0
  73. package/src/endpoints/index.ts +154 -0
  74. package/src/endpoints/login.ts +92 -0
  75. package/src/endpoints/merge-clients.ts +132 -0
  76. package/src/endpoints/merge-tickets.ts +137 -0
  77. package/src/endpoints/oauth-google.ts +179 -0
  78. package/src/endpoints/pending-emails-process.ts +224 -0
  79. package/src/endpoints/presence.ts +104 -0
  80. package/src/endpoints/process-scheduled.ts +144 -0
  81. package/src/endpoints/purge-logs.ts +58 -0
  82. package/src/endpoints/resend-notification.ts +99 -0
  83. package/src/endpoints/round-robin-config.ts +92 -0
  84. package/src/endpoints/satisfaction.ts +93 -0
  85. package/src/endpoints/search.ts +106 -0
  86. package/src/endpoints/seed-kb.ts +153 -0
  87. package/src/endpoints/settings.ts +144 -0
  88. package/src/endpoints/signature.ts +93 -0
  89. package/src/endpoints/sla-check.ts +124 -0
  90. package/src/endpoints/split-ticket.ts +131 -0
  91. package/src/endpoints/statuses.ts +45 -0
  92. package/src/endpoints/track-open.ts +154 -0
  93. package/src/endpoints/typing.ts +101 -0
  94. package/src/endpoints/user-prefs.ts +125 -0
  95. package/src/hooks/checkSLA.ts +414 -0
  96. package/src/hooks/ticketStatusEmail.ts +182 -0
  97. package/src/index.ts +51 -0
  98. package/src/plugin.ts +157 -0
  99. package/src/portal/LiveChat.tsx +1353 -0
  100. package/src/portal/auth/ChatWidget.tsx +350 -0
  101. package/src/portal/auth/ChatbotWidget.tsx +285 -0
  102. package/src/portal/auth/SupportHeader.tsx +409 -0
  103. package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
  104. package/src/portal/auth/dashboard/page.tsx +84 -0
  105. package/src/portal/auth/faq/FAQSearch.tsx +117 -0
  106. package/src/portal/auth/faq/page.tsx +199 -0
  107. package/src/portal/auth/layout.tsx +61 -0
  108. package/src/portal/auth/profile/page.tsx +705 -0
  109. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
  110. package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
  111. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
  112. package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
  113. package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
  114. package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
  115. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
  116. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
  117. package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
  118. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
  119. package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
  120. package/src/portal/auth/tickets/detail/page.tsx +738 -0
  121. package/src/portal/auth/tickets/new/page.tsx +515 -0
  122. package/src/portal/forgot-password/page.tsx +114 -0
  123. package/src/portal/layout.tsx +26 -0
  124. package/src/portal/locales/en.json +374 -0
  125. package/src/portal/locales/fr.json +374 -0
  126. package/src/portal/login/page.tsx +351 -0
  127. package/src/portal/page.tsx +162 -0
  128. package/src/portal/register/page.tsx +281 -0
  129. package/src/portal/reset-password/page.tsx +152 -0
  130. package/src/styles/BillingView.module.scss +311 -0
  131. package/src/styles/ChatView.module.scss +438 -0
  132. package/src/styles/CommandPalette.module.scss +160 -0
  133. package/src/styles/CrmView.module.scss +554 -0
  134. package/src/styles/EmailTracking.module.scss +238 -0
  135. package/src/styles/ImportConversation.module.scss +267 -0
  136. package/src/styles/Layout.module.scss +55 -0
  137. package/src/styles/Logs.module.scss +164 -0
  138. package/src/styles/NewTicket.module.scss +143 -0
  139. package/src/styles/PendingEmails.module.scss +629 -0
  140. package/src/styles/SupportDashboard.module.scss +649 -0
  141. package/src/styles/TicketDetail.module.scss +1043 -0
  142. package/src/styles/TicketInbox.module.scss +296 -0
  143. package/src/styles/TicketingSettings.module.scss +358 -0
  144. package/src/styles/TimeDashboard.module.scss +287 -0
  145. package/src/styles/_tokens.scss +78 -0
  146. package/src/styles/theme.css +633 -0
  147. package/src/types.ts +255 -0
  148. package/src/utils/adminNotification.ts +38 -0
  149. package/src/utils/auth.ts +46 -0
  150. package/src/utils/emailTemplate.ts +343 -0
  151. package/src/utils/fireWebhooks.ts +84 -0
  152. package/src/utils/index.ts +22 -0
  153. package/src/utils/rateLimiter.ts +52 -0
  154. package/src/utils/readSettings.ts +67 -0
  155. package/src/utils/slugs.ts +54 -0
  156. package/src/utils/webhookDispatcher.ts +120 -0
  157. package/src/views/BillingView/client.tsx +137 -0
  158. package/src/views/BillingView/index.tsx +33 -0
  159. package/src/views/ChatView/client.tsx +294 -0
  160. package/src/views/ChatView/index.tsx +33 -0
  161. package/src/views/CrmView/client.tsx +206 -0
  162. package/src/views/CrmView/index.tsx +33 -0
  163. package/src/views/EmailTrackingView/client.tsx +124 -0
  164. package/src/views/EmailTrackingView/index.tsx +33 -0
  165. package/src/views/ImportConversationView/client.tsx +133 -0
  166. package/src/views/ImportConversationView/index.tsx +33 -0
  167. package/src/views/LogsView/client.tsx +151 -0
  168. package/src/views/LogsView/index.tsx +30 -0
  169. package/src/views/NewTicketView/client.tsx +227 -0
  170. package/src/views/NewTicketView/index.tsx +30 -0
  171. package/src/views/PendingEmailsView/client.tsx +177 -0
  172. package/src/views/PendingEmailsView/index.tsx +33 -0
  173. package/src/views/SupportDashboardView/client.tsx +424 -0
  174. package/src/views/SupportDashboardView/index.tsx +33 -0
  175. package/src/views/TicketDetailView/client.tsx +775 -0
  176. package/src/views/TicketDetailView/index.tsx +33 -0
  177. package/src/views/TicketInboxView/client.tsx +313 -0
  178. package/src/views/TicketInboxView/index.tsx +30 -0
  179. package/src/views/TicketingSettingsView/client.tsx +866 -0
  180. package/src/views/TicketingSettingsView/index.tsx +33 -0
  181. package/src/views/TimeDashboardView/client.tsx +144 -0
  182. package/src/views/TimeDashboardView/index.tsx +33 -0
  183. package/src/views/shared/AdminViewHeader.tsx +69 -0
  184. package/src/views/shared/ErrorBoundary.tsx +68 -0
  185. package/src/views/shared/Skeleton.tsx +125 -0
  186. package/src/views/shared/adminTokens.ts +37 -0
  187. package/src/views/shared/config.ts +82 -0
  188. package/src/views/shared/index.ts +6 -0
  189. 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
+ }