@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,127 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireClient, handleAuthError } from '../utils/auth'
4
+
5
+ /**
6
+ * GET /api/support/chat-stream?session=xxx
7
+ * Server-Sent Events endpoint for real-time chat messages (client-side).
8
+ * Replaces polling on GET /api/support/chat for connected clients.
9
+ * The existing polling endpoint is kept as fallback.
10
+ */
11
+ export function createChatStreamEndpoint(slugs: CollectionSlugs): Endpoint {
12
+ return {
13
+ path: '/support/chat-stream',
14
+ method: 'get',
15
+ handler: async (req) => {
16
+ try {
17
+ requireClient(req, slugs)
18
+ } catch (error) {
19
+ const authResponse = handleAuthError(error)
20
+ if (authResponse) return authResponse
21
+ return Response.json({ error: 'Unauthorized' }, { status: 401 })
22
+ }
23
+
24
+ const url = new URL(req.url!)
25
+ const sessionId = url.searchParams.get('session')
26
+ const userId = req.user!.id
27
+
28
+ if (!sessionId) {
29
+ return Response.json({ error: 'Missing session parameter' }, { status: 400 })
30
+ }
31
+
32
+ // Verify session belongs to user
33
+ const existing = await req.payload.find({
34
+ collection: slugs.chatMessages as any,
35
+ where: {
36
+ session: { equals: sessionId },
37
+ client: { equals: userId },
38
+ },
39
+ limit: 1,
40
+ overrideAccess: true,
41
+ })
42
+
43
+ if (existing.docs.length === 0) {
44
+ return Response.json({ error: 'Session invalide' }, { status: 403 })
45
+ }
46
+
47
+ let lastCheck = new Date().toISOString()
48
+ const encoder = new TextEncoder()
49
+
50
+ const stream = new ReadableStream({
51
+ start(controller) {
52
+ // Send initial connection event
53
+ controller.enqueue(
54
+ encoder.encode(`data: ${JSON.stringify({ type: 'connected', session: sessionId })}\n\n`),
55
+ )
56
+
57
+ // Poll DB every 2 seconds for new messages
58
+ const interval = setInterval(async () => {
59
+ try {
60
+ const messages = await req.payload.find({
61
+ collection: slugs.chatMessages as any,
62
+ where: {
63
+ session: { equals: sessionId },
64
+ createdAt: { greater_than: lastCheck },
65
+ },
66
+ sort: 'createdAt',
67
+ limit: 50,
68
+ depth: 1,
69
+ overrideAccess: true,
70
+ })
71
+
72
+ if (messages.docs.length > 0) {
73
+ const data = JSON.stringify({
74
+ type: 'messages',
75
+ data: messages.docs,
76
+ })
77
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`))
78
+ lastCheck = (messages.docs[messages.docs.length - 1] as any).createdAt
79
+
80
+ // Check if session was closed
81
+ const lastMsg = messages.docs[messages.docs.length - 1] as any
82
+ if (lastMsg.status === 'closed') {
83
+ controller.enqueue(
84
+ encoder.encode(
85
+ `data: ${JSON.stringify({ type: 'closed', session: sessionId })}\n\n`,
86
+ ),
87
+ )
88
+ }
89
+ }
90
+ } catch (error) {
91
+ console.warn('[support] chat-stream SSE poll error:', error)
92
+ }
93
+ }, 2000)
94
+
95
+ // Heartbeat every 30s to keep connection alive
96
+ const heartbeat = setInterval(() => {
97
+ try {
98
+ controller.enqueue(encoder.encode(`: heartbeat\n\n`))
99
+ } catch {
100
+ // Stream already closed
101
+ }
102
+ }, 30000)
103
+
104
+ // Cleanup on client disconnect
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', // Disable nginx buffering
123
+ },
124
+ })
125
+ },
126
+ }
127
+ }
@@ -0,0 +1,188 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { Where } from 'payload'
3
+ import type { CollectionSlugs } from '../utils/slugs'
4
+ import crypto from 'crypto'
5
+ import { RateLimiter } from '../utils/rateLimiter'
6
+ import { requireClient, handleAuthError } from '../utils/auth'
7
+
8
+ const chatSessionLimiter = new RateLimiter(3_600_000, 5) // 5 sessions per hour
9
+ const chatMessageLimiter = new RateLimiter(60_000, 15) // 15 messages per minute
10
+
11
+ /**
12
+ * GET /api/support/chat?session=xxx&after=timestamp
13
+ * Fetch chat messages for a session (polling). Client-only.
14
+ */
15
+ export function createChatGetEndpoint(slugs: CollectionSlugs): Endpoint {
16
+ return {
17
+ path: '/support/chat',
18
+ method: 'get',
19
+ handler: async (req) => {
20
+ try {
21
+ const payload = req.payload
22
+
23
+ requireClient(req, slugs)
24
+
25
+ const url = new URL(req.url!)
26
+ const session = url.searchParams.get('session')
27
+ const after = url.searchParams.get('after')
28
+
29
+ if (!session) {
30
+ return Response.json({ error: 'Session requise' }, { status: 400 })
31
+ }
32
+
33
+ const where: Where = {
34
+ session: { equals: session },
35
+ client: { equals: req.user.id },
36
+ }
37
+
38
+ if (after) {
39
+ where.createdAt = { greater_than: after }
40
+ }
41
+
42
+ const messages = await payload.find({
43
+ collection: slugs.chatMessages as any,
44
+ where,
45
+ sort: 'createdAt',
46
+ limit: 100,
47
+ depth: 1,
48
+ overrideAccess: true,
49
+ })
50
+
51
+ return new Response(JSON.stringify({ messages: messages.docs, session }), {
52
+ headers: {
53
+ 'Content-Type': 'application/json',
54
+ 'Cache-Control': 'private, max-age=300, stale-while-revalidate=600',
55
+ },
56
+ })
57
+ } catch (error) {
58
+ const authResponse = handleAuthError(error)
59
+ if (authResponse) return authResponse
60
+ console.error('[support/chat] GET Error:', error)
61
+ return Response.json({ error: 'Erreur interne du serveur' }, { status: 500 })
62
+ }
63
+ },
64
+ }
65
+ }
66
+
67
+ /**
68
+ * POST /api/support/chat
69
+ * Send a message or start a new chat session. Client-only.
70
+ */
71
+ export function createChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
72
+ return {
73
+ path: '/support/chat',
74
+ method: 'post',
75
+ handler: async (req) => {
76
+ try {
77
+ const payload = req.payload
78
+
79
+ requireClient(req, slugs)
80
+
81
+ let body: { action?: string; session?: string; message?: string }
82
+ try {
83
+ body = await req.json!()
84
+ } catch {
85
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
86
+ }
87
+ const { action, session, message } = body
88
+ const userId = String(req.user.id)
89
+
90
+ // Start a new session
91
+ if (action === 'start') {
92
+ if (chatSessionLimiter.check(userId)) {
93
+ return Response.json({ error: 'Trop de sessions créées. Réessayez plus tard.' }, { status: 429 })
94
+ }
95
+
96
+ const sessionId = `chat_${crypto.randomBytes(16).toString('hex')}`
97
+
98
+ const systemMsg = await payload.create({
99
+ collection: slugs.chatMessages as any,
100
+ data: {
101
+ session: sessionId,
102
+ client: req.user.id,
103
+ senderType: 'system',
104
+ message: 'Chat démarré. Un agent vous répondra sous peu.',
105
+ status: 'active',
106
+ },
107
+ overrideAccess: true,
108
+ })
109
+
110
+ return Response.json({ session: sessionId, messages: [systemMsg] })
111
+ }
112
+
113
+ // Send a message
114
+ if (action === 'send' && session && message) {
115
+ if (chatMessageLimiter.check(userId)) {
116
+ return Response.json({ error: 'Trop de messages. Attendez un moment.' }, { status: 429 })
117
+ }
118
+
119
+ const trimmedMessage = String(message).trim()
120
+ if (!trimmedMessage || trimmedMessage.length > 2000) {
121
+ return Response.json({ error: 'Message invalide (1-2000 caractères).' }, { status: 400 })
122
+ }
123
+
124
+ // Verify session belongs to user
125
+ const existing = await payload.find({
126
+ collection: slugs.chatMessages as any,
127
+ where: { session: { equals: session }, client: { equals: req.user.id } },
128
+ limit: 1,
129
+ overrideAccess: true,
130
+ })
131
+
132
+ if (existing.docs.length === 0) {
133
+ return Response.json({ error: 'Session invalide' }, { status: 403 })
134
+ }
135
+
136
+ const newMsg = await payload.create({
137
+ collection: slugs.chatMessages as any,
138
+ data: {
139
+ session,
140
+ client: req.user.id,
141
+ senderType: 'client',
142
+ message: trimmedMessage,
143
+ status: 'active',
144
+ },
145
+ overrideAccess: true,
146
+ })
147
+
148
+ return Response.json({ message: newMsg })
149
+ }
150
+
151
+ // Close session
152
+ if (action === 'close' && session) {
153
+ const existing = await payload.find({
154
+ collection: slugs.chatMessages as any,
155
+ where: { session: { equals: session }, client: { equals: req.user.id } },
156
+ limit: 1,
157
+ overrideAccess: true,
158
+ })
159
+
160
+ if (existing.docs.length === 0) {
161
+ return Response.json({ error: 'Session invalide' }, { status: 403 })
162
+ }
163
+
164
+ await payload.create({
165
+ collection: slugs.chatMessages as any,
166
+ data: {
167
+ session,
168
+ client: req.user.id,
169
+ senderType: 'system',
170
+ message: 'Chat terminé par le client.',
171
+ status: 'closed',
172
+ },
173
+ overrideAccess: true,
174
+ })
175
+
176
+ return Response.json({ closed: true })
177
+ }
178
+
179
+ return Response.json({ error: 'Action invalide' }, { status: 400 })
180
+ } catch (error) {
181
+ const authResponse = handleAuthError(error)
182
+ if (authResponse) return authResponse
183
+ console.error('[support/chat] POST Error:', error)
184
+ return Response.json({ error: 'Erreur interne du serveur' }, { status: 500 })
185
+ }
186
+ },
187
+ }
188
+ }
@@ -0,0 +1,113 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { RateLimiter } from '../utils/rateLimiter'
4
+
5
+ const chatbotLimiter = new RateLimiter(60_000, 10) // 10 requests per minute per IP
6
+
7
+ /**
8
+ * POST /api/support/chatbot
9
+ * AI chatbot that answers from the knowledge base before creating a ticket.
10
+ * Public endpoint (accessible from the support portal).
11
+ */
12
+ export function createChatbotEndpoint(slugs: CollectionSlugs): Endpoint {
13
+ return {
14
+ path: '/support/chatbot',
15
+ method: 'post',
16
+ handler: async (req) => {
17
+ try {
18
+ const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
19
+ if (chatbotLimiter.check(ip)) {
20
+ return Response.json({ error: 'Too many requests. Please wait a moment.' }, { status: 429 })
21
+ }
22
+
23
+ let body: { question?: string }
24
+ try {
25
+ body = await req.json!()
26
+ } catch {
27
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
28
+ }
29
+ const { question } = body
30
+
31
+ if (!question?.trim() || question.trim().length < 5) {
32
+ return Response.json({ error: 'Question too short' }, { status: 400 })
33
+ }
34
+
35
+ const payload = req.payload
36
+
37
+ const articles = await payload.find({
38
+ collection: slugs.knowledgeBase as any,
39
+ where: { published: { equals: true } },
40
+ limit: 100,
41
+ depth: 0,
42
+ overrideAccess: true,
43
+ })
44
+
45
+ if (articles.docs.length === 0) {
46
+ return Response.json({
47
+ answer: null,
48
+ confidence: 0,
49
+ suggestion: 'create_ticket',
50
+ message: 'Aucun article disponible. Créez un ticket pour obtenir de l\'aide.',
51
+ })
52
+ }
53
+
54
+ const knowledgeContext = articles.docs
55
+ .map((a: any) => `## ${a.title}\n${JSON.stringify(a.body || '').slice(0, 500)}`)
56
+ .join('\n\n---\n\n')
57
+
58
+ const apiKey = process.env.ANTHROPIC_API_KEY
59
+ if (!apiKey) {
60
+ return Response.json({
61
+ answer: null,
62
+ confidence: 0,
63
+ suggestion: 'create_ticket',
64
+ message: 'Le chatbot IA n\'est pas configuré.',
65
+ })
66
+ }
67
+
68
+ const Anthropic = require('@anthropic-ai/sdk').default
69
+ const anthropic = new Anthropic({ apiKey })
70
+
71
+ const response = await anthropic.messages.create({
72
+ model: 'claude-haiku-4-5-20251001',
73
+ max_tokens: 500,
74
+ messages: [
75
+ {
76
+ role: 'user',
77
+ content: `Tu es un assistant de support. Tu dois répondre à la question du client en utilisant UNIQUEMENT les articles de la base de connaissances ci-dessous. Si la réponse n'est pas dans la base, dis-le clairement.
78
+
79
+ BASE DE CONNAISSANCES :
80
+ ${knowledgeContext}
81
+
82
+ QUESTION DU CLIENT :
83
+ ${question}
84
+
85
+ Réponds en français, de manière concise et utile. Si tu ne trouves pas la réponse dans la base, réponds exactement "INCONNU" et rien d'autre.`,
86
+ },
87
+ ],
88
+ })
89
+
90
+ const answer = response.content[0].type === 'text' ? response.content[0].text : ''
91
+
92
+ if (answer.trim() === 'INCONNU' || answer.trim().length < 10) {
93
+ return Response.json({
94
+ answer: null,
95
+ confidence: 0,
96
+ suggestion: 'create_ticket',
97
+ message: 'Je n\'ai pas trouvé de réponse dans notre base de connaissances. Souhaitez-vous créer un ticket de support ?',
98
+ })
99
+ }
100
+
101
+ return Response.json({
102
+ answer: answer.trim(),
103
+ confidence: 1,
104
+ suggestion: 'resolved',
105
+ message: null,
106
+ })
107
+ } catch (error) {
108
+ console.error('[chatbot] Error:', error)
109
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
110
+ }
111
+ },
112
+ }
113
+ }
@@ -0,0 +1,129 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireClient, handleAuthError } from '../utils/auth'
4
+
5
+ /**
6
+ * POST /api/support/delete-account
7
+ * RGPD — Right to erasure (Article 17).
8
+ * Allows a support client to request permanent deletion of their account.
9
+ * Uses batch deletes via `where` clauses instead of sequential per-document deletion.
10
+ */
11
+ export function createDeleteAccountEndpoint(slugs: CollectionSlugs): Endpoint {
12
+ return {
13
+ path: '/support/delete-account',
14
+ method: 'post',
15
+ handler: async (req) => {
16
+ try {
17
+ const payload = req.payload
18
+
19
+ requireClient(req, slugs)
20
+
21
+ const body = await req.json!()
22
+ const { confirmPassword } = body
23
+
24
+ if (!confirmPassword) {
25
+ return Response.json(
26
+ { error: 'Mot de passe requis pour confirmer la suppression.' },
27
+ { status: 400 },
28
+ )
29
+ }
30
+
31
+ // Verify password
32
+ try {
33
+ await payload.login({
34
+ collection: slugs.supportClients as any,
35
+ data: { email: req.user.email!, password: confirmPassword },
36
+ })
37
+ } catch {
38
+ return Response.json(
39
+ { error: 'Mot de passe incorrect.' },
40
+ { status: 403 },
41
+ )
42
+ }
43
+
44
+ const clientId = req.user.id
45
+
46
+ // 1. Find all ticket IDs for this client
47
+ const tickets = await payload.find({
48
+ collection: slugs.tickets as any,
49
+ where: { client: { equals: clientId } },
50
+ limit: 10000,
51
+ depth: 0,
52
+ overrideAccess: true,
53
+ select: { id: true },
54
+ })
55
+
56
+ const ticketIds = tickets.docs.map((t) => t.id)
57
+
58
+ // 2. Batch delete related data using where clauses
59
+ if (ticketIds.length > 0) {
60
+ await payload.delete({
61
+ collection: slugs.ticketMessages as any,
62
+ where: { ticket: { in: ticketIds } },
63
+ overrideAccess: true,
64
+ })
65
+
66
+ await payload.delete({
67
+ collection: slugs.ticketActivityLog as any,
68
+ where: { ticket: { in: ticketIds } },
69
+ overrideAccess: true,
70
+ })
71
+
72
+ await payload.delete({
73
+ collection: slugs.timeEntries as any,
74
+ where: { ticket: { in: ticketIds } },
75
+ overrideAccess: true,
76
+ })
77
+
78
+ // Delete all tickets
79
+ await payload.delete({
80
+ collection: slugs.tickets as any,
81
+ where: { client: { equals: clientId } },
82
+ overrideAccess: true,
83
+ })
84
+ }
85
+
86
+ // 3. Batch delete satisfaction surveys
87
+ await payload.delete({
88
+ collection: slugs.satisfactionSurveys as any,
89
+ where: { client: { equals: clientId } },
90
+ overrideAccess: true,
91
+ })
92
+
93
+ // 4. Batch delete chat messages
94
+ await payload.delete({
95
+ collection: slugs.chatMessages as any,
96
+ where: { client: { equals: clientId } },
97
+ overrideAccess: true,
98
+ })
99
+
100
+ // 5. Delete the client account
101
+ await payload.delete({
102
+ collection: slugs.supportClients as any,
103
+ id: clientId,
104
+ overrideAccess: true,
105
+ })
106
+
107
+ const headers = new Headers({ 'Content-Type': 'application/json' })
108
+ const secure = process.env.NODE_ENV === 'production'
109
+ headers.append(
110
+ 'Set-Cookie',
111
+ `payload-token=; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=0`,
112
+ )
113
+
114
+ return new Response(
115
+ JSON.stringify({
116
+ deleted: true,
117
+ message: 'Votre compte et toutes vos données ont été supprimés définitivement.',
118
+ }),
119
+ { status: 200, headers },
120
+ )
121
+ } catch (err) {
122
+ const authResponse = handleAuthError(err)
123
+ if (authResponse) return authResponse
124
+ console.error('[delete-account] Error:', err)
125
+ return Response.json({ error: 'Erreur interne' }, { status: 500 })
126
+ }
127
+ },
128
+ }
129
+ }
@@ -0,0 +1,109 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+
4
+ /**
5
+ * GET /api/support/email-stats?days=7
6
+ * Aggregates EmailLogs data for the tracking dashboard. Admin-only.
7
+ */
8
+ export function createEmailStatsEndpoint(slugs: CollectionSlugs): Endpoint {
9
+ return {
10
+ path: '/support/email-stats',
11
+ method: 'get',
12
+ handler: async (req) => {
13
+ try {
14
+ const payload = req.payload
15
+
16
+ if (!req.user) {
17
+ return Response.json({ error: 'Unauthorized' }, { status: 401 })
18
+ }
19
+
20
+ const url = new URL(req.url!)
21
+ const days = Math.min(Number(url.searchParams.get('days')) || 7, 365)
22
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
23
+
24
+ const allDocs: Array<{
25
+ status: string
26
+ processingTimeMs?: number | null
27
+ action?: string | null
28
+ createdAt: string
29
+ }> = []
30
+ const MAX_PAGES = 50
31
+ let page = 1
32
+ let hasMore = true
33
+
34
+ while (hasMore && page <= MAX_PAGES) {
35
+ const result = await payload.find({
36
+ collection: slugs.emailLogs as any,
37
+ where: { createdAt: { greater_than: cutoff } },
38
+ sort: '-createdAt',
39
+ limit: 500,
40
+ page,
41
+ depth: 0,
42
+ overrideAccess: true,
43
+ select: {
44
+ status: true,
45
+ processingTimeMs: true,
46
+ action: true,
47
+ createdAt: true,
48
+ },
49
+ })
50
+
51
+ allDocs.push(
52
+ ...result.docs.map((d: any) => ({
53
+ status: d.status,
54
+ processingTimeMs: d.processingTimeMs,
55
+ action: d.action,
56
+ createdAt: d.createdAt,
57
+ })),
58
+ )
59
+
60
+ hasMore = result.hasNextPage
61
+ page++
62
+ }
63
+
64
+ const total = allDocs.length
65
+ const success = allDocs.filter((d) => d.status === 'success').length
66
+ const errors = allDocs.filter((d) => d.status === 'error').length
67
+ const ignored = allDocs.filter((d) => d.status === 'ignored').length
68
+ const successRate = total > 0 ? Math.round((success / total) * 1000) / 10 : 0
69
+
70
+ const withTime = allDocs.filter((d) => typeof d.processingTimeMs === 'number' && d.processingTimeMs > 0)
71
+ const avgProcessingTime = withTime.length > 0
72
+ ? Math.round(withTime.reduce((sum, d) => sum + (d.processingTimeMs || 0), 0) / withTime.length)
73
+ : 0
74
+
75
+ // Daily breakdown
76
+ const dailyMap = new Map<string, { success: number; error: number; ignored: number }>()
77
+ for (const doc of allDocs) {
78
+ const day = doc.createdAt.slice(0, 10)
79
+ const entry = dailyMap.get(day) || { success: 0, error: 0, ignored: 0 }
80
+ if (doc.status === 'success') entry.success++
81
+ else if (doc.status === 'error') entry.error++
82
+ else if (doc.status === 'ignored') entry.ignored++
83
+ dailyMap.set(day, entry)
84
+ }
85
+
86
+ // Action breakdown
87
+ const actionMap = new Map<string, number>()
88
+ for (const doc of allDocs) {
89
+ const action = doc.action || 'unknown'
90
+ actionMap.set(action, (actionMap.get(action) || 0) + 1)
91
+ }
92
+
93
+ return Response.json({
94
+ total,
95
+ success,
96
+ errors,
97
+ ignored,
98
+ successRate,
99
+ avgProcessingTime,
100
+ daily: Object.fromEntries(dailyMap),
101
+ actions: Object.fromEntries(actionMap),
102
+ })
103
+ } catch (err) {
104
+ console.error('[email-stats] Error:', err)
105
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
106
+ }
107
+ },
108
+ }
109
+ }