@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,224 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { escapeHtml } from '../utils/emailTemplate'
4
+ import { requireAdmin, handleAuthError } from '../utils/auth'
5
+ import { readSupportSettings } from '../utils/readSettings'
6
+
7
+ /**
8
+ * POST /api/support/pending-emails/:id/process
9
+ * Admin-only endpoint to process a pending email.
10
+ */
11
+ export function createPendingEmailsProcessEndpoint(slugs: CollectionSlugs): Endpoint {
12
+ return {
13
+ path: '/support/pending-emails/:id/process',
14
+ method: 'post',
15
+ handler: async (req) => {
16
+ try {
17
+ const payload = req.payload
18
+
19
+ requireAdmin(req, slugs)
20
+
21
+ const id = req.routeParams?.id as string
22
+ const body = await req.json!()
23
+ const { action, ticketId, clientId: overrideClientId } = body as {
24
+ action: string
25
+ ticketId?: number
26
+ clientId?: number
27
+ }
28
+
29
+ if (!['create_ticket', 'add_to_ticket', 'ignore'].includes(action)) {
30
+ return Response.json({ error: 'Invalid action' }, { status: 400 })
31
+ }
32
+
33
+ const pendingEmail = await payload.findByID({
34
+ collection: slugs.pendingEmails as any,
35
+ id: Number(id),
36
+ depth: 1,
37
+ overrideAccess: true,
38
+ }) as any
39
+
40
+ if (!pendingEmail) {
41
+ return Response.json({ error: 'Pending email not found' }, { status: 404 })
42
+ }
43
+
44
+ if (pendingEmail.status !== 'pending') {
45
+ return Response.json({ error: 'Email already processed' }, { status: 409 })
46
+ }
47
+
48
+ let clientId = overrideClientId || (typeof pendingEmail.client === 'object' ? pendingEmail.client?.id : pendingEmail.client)
49
+ let clientDoc = typeof pendingEmail.client === 'object' ? pendingEmail.client : null
50
+
51
+ if (overrideClientId && overrideClientId !== clientDoc?.id) {
52
+ clientDoc = await payload.findByID({
53
+ collection: slugs.supportClients as any,
54
+ id: overrideClientId,
55
+ depth: 0,
56
+ overrideAccess: true,
57
+ })
58
+
59
+ await payload.update({
60
+ collection: slugs.pendingEmails as any,
61
+ id: Number(id),
62
+ data: { client: overrideClientId },
63
+ overrideAccess: true,
64
+ })
65
+ }
66
+
67
+ const settings = await readSupportSettings(payload)
68
+ const clientEmail = clientDoc?.email || pendingEmail.senderEmail || ''
69
+ const clientName = clientDoc?.firstName || pendingEmail.senderName || clientEmail
70
+ const portalUrl = `${process.env.NEXT_PUBLIC_SERVER_URL || ''}/support/dashboard`
71
+
72
+ if (!clientId && action !== 'ignore') {
73
+ return Response.json({ error: 'No client associated with this pending email' }, { status: 400 })
74
+ }
75
+
76
+ const attachments = (pendingEmail.attachments as Array<{ file: { id: number } | number }> | undefined)?.map((a: any) => ({
77
+ file: typeof a.file === 'object' ? a.file.id : a.file,
78
+ })) || []
79
+
80
+ if (action === 'ignore') {
81
+ await payload.update({
82
+ collection: slugs.pendingEmails as any,
83
+ id: Number(id),
84
+ data: {
85
+ status: 'ignored',
86
+ processedAction: 'ignored',
87
+ processedAt: new Date().toISOString(),
88
+ },
89
+ overrideAccess: true,
90
+ })
91
+ return Response.json({ action: 'ignored', pendingEmailId: Number(id) })
92
+ }
93
+
94
+ if (action === 'create_ticket') {
95
+ const newTicket = await payload.create({
96
+ collection: slugs.tickets as any,
97
+ data: {
98
+ subject: pendingEmail.subject,
99
+ client: clientId!,
100
+ status: 'open',
101
+ priority: 'normal',
102
+ category: 'question',
103
+ source: 'email',
104
+ },
105
+ overrideAccess: true,
106
+ }) as any
107
+
108
+ await payload.create({
109
+ collection: slugs.ticketMessages as any,
110
+ data: {
111
+ ticket: newTicket.id,
112
+ body: pendingEmail.body,
113
+ authorType: 'email',
114
+ authorClient: clientId,
115
+ isInternal: false,
116
+ ...(attachments.length > 0 && { attachments }),
117
+ },
118
+ overrideAccess: true,
119
+ })
120
+
121
+ // Send confirmation email
122
+ try {
123
+ await payload.sendEmail({
124
+ to: clientEmail,
125
+ replyTo: settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || '',
126
+ subject: `[${newTicket.ticketNumber}] ${pendingEmail.subject}`,
127
+ html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
128
+ <p>Bonjour <strong>${escapeHtml(clientName)}</strong>,</p>
129
+ <p>Votre demande a été enregistrée sous le numéro <strong>${newTicket.ticketNumber}</strong>.</p>
130
+ <p>Sujet : ${escapeHtml(pendingEmail.subject)}</p>
131
+ <p><a href="${portalUrl}">Accéder à mon espace</a></p>
132
+ <p style="font-size: 14px; color: #666;">Vous pouvez aussi répondre directement à cet email.</p>
133
+ </div>`,
134
+ })
135
+ } catch (err) {
136
+ console.error('[pending-email-process] Failed to send notification:', err)
137
+ }
138
+
139
+ await payload.update({
140
+ collection: slugs.pendingEmails as any,
141
+ id: Number(id),
142
+ data: {
143
+ status: 'processed',
144
+ processedAction: 'ticket_created',
145
+ processedTicket: newTicket.id,
146
+ processedAt: new Date().toISOString(),
147
+ },
148
+ overrideAccess: true,
149
+ })
150
+
151
+ return Response.json({
152
+ action: 'ticket_created',
153
+ ticketNumber: newTicket.ticketNumber,
154
+ ticketId: newTicket.id,
155
+ })
156
+ }
157
+
158
+ if (action === 'add_to_ticket') {
159
+ if (!ticketId) {
160
+ return Response.json({ error: 'ticketId is required for add_to_ticket' }, { status: 400 })
161
+ }
162
+
163
+ const targetTicket = await payload.findByID({
164
+ collection: slugs.tickets as any,
165
+ id: ticketId,
166
+ depth: 0,
167
+ overrideAccess: true,
168
+ }) as any
169
+
170
+ if (!targetTicket) {
171
+ return Response.json({ error: 'Target ticket not found' }, { status: 404 })
172
+ }
173
+
174
+ await payload.create({
175
+ collection: slugs.ticketMessages as any,
176
+ data: {
177
+ ticket: ticketId,
178
+ body: pendingEmail.body,
179
+ authorType: 'email',
180
+ authorClient: clientId,
181
+ isInternal: false,
182
+ ...(attachments.length > 0 && { attachments }),
183
+ },
184
+ overrideAccess: true,
185
+ })
186
+
187
+ if (targetTicket.status === 'resolved') {
188
+ await payload.update({
189
+ collection: slugs.tickets as any,
190
+ id: ticketId,
191
+ data: { status: 'open' },
192
+ overrideAccess: true,
193
+ })
194
+ }
195
+
196
+ await payload.update({
197
+ collection: slugs.pendingEmails as any,
198
+ id: Number(id),
199
+ data: {
200
+ status: 'processed',
201
+ processedAction: 'message_added',
202
+ processedTicket: ticketId,
203
+ processedAt: new Date().toISOString(),
204
+ },
205
+ overrideAccess: true,
206
+ })
207
+
208
+ return Response.json({
209
+ action: 'message_added',
210
+ ticketNumber: targetTicket.ticketNumber,
211
+ ticketId: targetTicket.id,
212
+ })
213
+ }
214
+
215
+ return Response.json({ error: 'Invalid action' }, { status: 400 })
216
+ } catch (error) {
217
+ const authResponse = handleAuthError(error)
218
+ if (authResponse) return authResponse
219
+ console.error('[pending-email-process] Error:', error)
220
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
221
+ }
222
+ },
223
+ }
224
+ }
@@ -0,0 +1,104 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+
5
+ // In-memory presence state: ticketId -> Map<userId, { name, email, timestamp }>
6
+ const presenceState = new Map<string, Map<string | number, { name: string; email: string; ts: number }>>()
7
+
8
+ const PRESENCE_TTL = 30_000 // 30 seconds
9
+
10
+ function cleanExpired(ticketId: string) {
11
+ const viewers = presenceState.get(ticketId)
12
+ if (!viewers) return
13
+ const now = Date.now()
14
+ for (const [userId, entry] of viewers) {
15
+ if (now - entry.ts > PRESENCE_TTL) {
16
+ viewers.delete(userId)
17
+ }
18
+ }
19
+ if (viewers.size === 0) presenceState.delete(ticketId)
20
+ }
21
+
22
+ /**
23
+ * POST /api/support/presence — Register or remove presence on a ticket
24
+ */
25
+ export function createPresencePostEndpoint(slugs: CollectionSlugs): Endpoint {
26
+ return {
27
+ path: '/support/presence',
28
+ method: 'post',
29
+ handler: async (req) => {
30
+ try {
31
+ requireAdmin(req, slugs)
32
+
33
+ const { ticketId, action } = (await req.json!()) as { ticketId: number; action: 'join' | 'leave' }
34
+ if (!ticketId || !action) {
35
+ return Response.json({ error: 'ticketId and action required' }, { status: 400 })
36
+ }
37
+
38
+ const key = String(ticketId)
39
+
40
+ if (action === 'join') {
41
+ if (!presenceState.has(key)) {
42
+ presenceState.set(key, new Map())
43
+ }
44
+ presenceState.get(key)!.set(req.user.id, {
45
+ name: (req.user as any).firstName || req.user.email || 'Admin',
46
+ email: req.user.email || '',
47
+ ts: Date.now(),
48
+ })
49
+ } else if (action === 'leave') {
50
+ presenceState.get(key)?.delete(req.user.id)
51
+ if (presenceState.get(key)?.size === 0) presenceState.delete(key)
52
+ }
53
+
54
+ return Response.json({ ok: true })
55
+ } catch (error) {
56
+ const authResponse = handleAuthError(error)
57
+ if (authResponse) return authResponse
58
+ console.warn('[presence] POST error:', error)
59
+ return Response.json({ error: 'Error' }, { status: 500 })
60
+ }
61
+ },
62
+ }
63
+ }
64
+
65
+ /**
66
+ * GET /api/support/presence?ticketId=123 — Get list of admins viewing this ticket
67
+ */
68
+ export function createPresenceGetEndpoint(slugs: CollectionSlugs): Endpoint {
69
+ return {
70
+ path: '/support/presence',
71
+ method: 'get',
72
+ handler: async (req) => {
73
+ try {
74
+ requireAdmin(req, slugs)
75
+
76
+ const url = new URL(req.url!)
77
+ const ticketId = url.searchParams.get('ticketId')
78
+ if (!ticketId) {
79
+ return Response.json({ error: 'ticketId required' }, { status: 400 })
80
+ }
81
+
82
+ cleanExpired(ticketId)
83
+ const viewers = presenceState.get(ticketId)
84
+
85
+ if (!viewers || viewers.size === 0) {
86
+ return Response.json({ viewers: [] })
87
+ }
88
+
89
+ const result: { name: string; email: string }[] = []
90
+ for (const [userId, entry] of viewers) {
91
+ if (userId !== req.user.id) {
92
+ result.push({ name: entry.name, email: entry.email })
93
+ }
94
+ }
95
+
96
+ return Response.json({ viewers: result })
97
+ } catch (error) {
98
+ const authResponse = handleAuthError(error)
99
+ if (authResponse) return authResponse
100
+ return Response.json({ viewers: [] })
101
+ }
102
+ },
103
+ }
104
+ }
@@ -0,0 +1,144 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { escapeHtml } from '../utils/emailTemplate'
4
+ import { fireWebhooks } from '../utils/fireWebhooks'
5
+ import { readSupportSettings } from '../utils/readSettings'
6
+
7
+ /**
8
+ * POST /api/support/process-scheduled
9
+ * Process all scheduled replies whose scheduledAt has passed and scheduledSent is false.
10
+ * Protected by x-cron-secret header (same as auto-close).
11
+ */
12
+ export function createProcessScheduledEndpoint(slugs: CollectionSlugs): Endpoint {
13
+ return {
14
+ path: '/support/process-scheduled',
15
+ method: 'post',
16
+ handler: async (req) => {
17
+ const secret = req.headers.get('x-cron-secret')
18
+ const expectedSecret = process.env.CRON_SECRET
19
+
20
+ if (!expectedSecret || secret !== expectedSecret) {
21
+ return Response.json({ error: 'Non autorisé' }, { status: 401 })
22
+ }
23
+
24
+ try {
25
+ const payload = req.payload
26
+ const now = new Date()
27
+ const settings = await readSupportSettings(payload)
28
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || ''
29
+ const results = { processed: 0, errors: 0 }
30
+
31
+ // Find all messages where scheduledAt is in the past and scheduledSent is not true
32
+ const scheduled = await payload.find({
33
+ collection: slugs.ticketMessages as any,
34
+ where: {
35
+ and: [
36
+ { scheduledAt: { less_than_equal: now.toISOString() } },
37
+ {
38
+ or: [
39
+ { scheduledSent: { equals: false } },
40
+ { scheduledSent: { exists: false } },
41
+ ],
42
+ },
43
+ ],
44
+ },
45
+ limit: 100,
46
+ depth: 0,
47
+ overrideAccess: true,
48
+ })
49
+
50
+ for (const message of scheduled.docs) {
51
+ try {
52
+ const msg = message as any
53
+ const ticketId = typeof msg.ticket === 'object' ? msg.ticket.id : msg.ticket
54
+
55
+ // Fetch the ticket with client data
56
+ const ticket = await payload.findByID({
57
+ collection: slugs.tickets as any,
58
+ id: ticketId,
59
+ depth: 1,
60
+ overrideAccess: true,
61
+ })
62
+
63
+ if (!ticket) {
64
+ console.warn(`[process-scheduled] Ticket ${ticketId} not found for message ${msg.id}`)
65
+ results.errors++
66
+ continue
67
+ }
68
+
69
+ const t = ticket as any
70
+ const client = typeof t.client === 'object' ? t.client : null
71
+
72
+ // Send email notification to client (same logic as createNotifyClient hook)
73
+ if (client?.email && msg.authorType === 'admin' && !msg.isInternal) {
74
+ const ticketNumber = t.ticketNumber || 'TK-????'
75
+ const subject = t.subject || 'Support'
76
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
77
+ const preview = msg.body?.length > 500 ? msg.body.slice(0, 500) + '...' : msg.body
78
+
79
+ await payload.sendEmail({
80
+ to: client.email,
81
+ ...(replyTo ? { replyTo } : {}),
82
+ subject: `Re: [${ticketNumber}] ${subject}`,
83
+ html: `<p>Bonjour ${escapeHtml(client.firstName || '')},</p><p>Notre équipe a répondu à votre ticket <strong>${escapeHtml(ticketNumber)}</strong>.</p><blockquote style="border-left:4px solid #2563eb;padding:12px;margin:16px 0;background:#f8fafc;">${escapeHtml(preview)}</blockquote><p><a href="${baseUrl}/support/tickets/${ticketId}">Consulter le ticket</a></p>`,
84
+ })
85
+
86
+ // Record email delivery info
87
+ await payload.update({
88
+ collection: slugs.ticketMessages as any,
89
+ id: msg.id,
90
+ data: {
91
+ scheduledSent: true,
92
+ emailSentAt: now.toISOString(),
93
+ emailSentTo: client.email,
94
+ },
95
+ overrideAccess: true,
96
+ })
97
+ } else {
98
+ // No email needed (internal note or no client email), just mark as sent
99
+ await payload.update({
100
+ collection: slugs.ticketMessages as any,
101
+ id: msg.id,
102
+ data: { scheduledSent: true },
103
+ overrideAccess: true,
104
+ })
105
+ }
106
+
107
+ // Update ticket status if this is an admin reply
108
+ if (msg.authorType === 'admin' && !msg.isInternal) {
109
+ await payload.update({
110
+ collection: slugs.tickets as any,
111
+ id: ticketId,
112
+ data: { status: 'waiting_client' },
113
+ overrideAccess: true,
114
+ })
115
+ }
116
+
117
+ // Fire webhook for the now-sent reply
118
+ fireWebhooks(payload, slugs, 'ticket_replied', {
119
+ ticketId,
120
+ messageId: msg.id,
121
+ authorType: msg.authorType,
122
+ scheduled: true,
123
+ body: msg.body?.length > 500 ? msg.body.slice(0, 500) + '...' : msg.body,
124
+ })
125
+
126
+ results.processed++
127
+ } catch (err) {
128
+ console.error(`[process-scheduled] Error processing message ${(message as any).id}:`, err)
129
+ results.errors++
130
+ }
131
+ }
132
+
133
+ return Response.json({
134
+ success: true,
135
+ ...results,
136
+ timestamp: now.toISOString(),
137
+ })
138
+ } catch (error) {
139
+ console.error('[process-scheduled] Error:', error)
140
+ return Response.json({ error: 'Erreur interne' }, { status: 500 })
141
+ }
142
+ },
143
+ }
144
+ }
@@ -0,0 +1,58 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+
5
+ /**
6
+ * DELETE /api/support/purge-logs?collection=email-logs&days=30
7
+ * Purge old logs older than X days. Admin-only.
8
+ */
9
+ export function createPurgeLogsEndpoint(slugs: CollectionSlugs): Endpoint {
10
+ return {
11
+ path: '/support/purge-logs',
12
+ method: 'delete',
13
+ handler: async (req) => {
14
+ try {
15
+ const payload = req.payload
16
+
17
+ requireAdmin(req, slugs)
18
+
19
+ const url = new URL(req.url!)
20
+ const collection = url.searchParams.get('collection')
21
+ const days = Number(url.searchParams.get('days') || '0')
22
+
23
+ // Map collection param to slug
24
+ const allowedCollections: Record<string, string> = {
25
+ 'email-logs': slugs.emailLogs,
26
+ 'auth-logs': slugs.authLogs,
27
+ }
28
+
29
+ if (!collection || !allowedCollections[collection]) {
30
+ return Response.json({ error: 'Invalid collection. Use email-logs or auth-logs.' }, { status: 400 })
31
+ }
32
+
33
+ const cutoff = days > 0 ? new Date(Date.now() - days * 86400000).toISOString() : null
34
+
35
+ const result = await payload.delete({
36
+ collection: allowedCollections[collection] as any,
37
+ where: cutoff
38
+ ? { createdAt: { less_than: cutoff } }
39
+ : { id: { exists: true } },
40
+ overrideAccess: true,
41
+ })
42
+
43
+ const count = Array.isArray(result.docs) ? result.docs.length : 0
44
+
45
+ return Response.json({
46
+ purged: count,
47
+ collection,
48
+ days: days || 'all',
49
+ })
50
+ } catch (error) {
51
+ const authResponse = handleAuthError(error)
52
+ if (authResponse) return authResponse
53
+ console.error('[purge-logs] Error:', error)
54
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
55
+ }
56
+ },
57
+ }
58
+ }
@@ -0,0 +1,99 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { RateLimiter } from '../utils/rateLimiter'
5
+ import { escapeHtml } from '../utils/emailTemplate'
6
+ import { readSupportSettings } from '../utils/readSettings'
7
+
8
+ const resendLimiter = new RateLimiter(60 * 60 * 1000, 10) // 10 per hour
9
+
10
+ /**
11
+ * POST /api/support/resend-notification
12
+ * Resend the email notification for a specific ticket message. Admin-only.
13
+ */
14
+ export function createResendNotificationEndpoint(slugs: CollectionSlugs): Endpoint {
15
+ return {
16
+ path: '/support/resend-notification',
17
+ method: 'post',
18
+ handler: async (req) => {
19
+ try {
20
+ const payload = req.payload
21
+
22
+ requireAdmin(req, slugs)
23
+
24
+ if (resendLimiter.check(String(req.user.id))) {
25
+ return Response.json(
26
+ { error: 'Trop de renvois. Réessayez dans une heure.' },
27
+ { status: 429 },
28
+ )
29
+ }
30
+
31
+ let body: { messageId?: string | number }
32
+ try {
33
+ body = await req.json!()
34
+ } catch {
35
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
36
+ }
37
+ const { messageId } = body
38
+
39
+ if (!messageId) {
40
+ return Response.json({ error: 'messageId requis' }, { status: 400 })
41
+ }
42
+
43
+ const message = await payload.findByID({
44
+ collection: slugs.ticketMessages as any,
45
+ id: messageId,
46
+ depth: 0,
47
+ overrideAccess: true,
48
+ }) as any
49
+
50
+ if (!message) {
51
+ return Response.json({ error: 'Message introuvable' }, { status: 404 })
52
+ }
53
+
54
+ const ticketId = typeof message.ticket === 'object' ? message.ticket.id : message.ticket
55
+ const ticket = await payload.findByID({
56
+ collection: slugs.tickets as any,
57
+ id: ticketId,
58
+ depth: 1,
59
+ overrideAccess: true,
60
+ }) as any
61
+
62
+ if (!ticket) {
63
+ return Response.json({ error: 'Ticket introuvable' }, { status: 404 })
64
+ }
65
+
66
+ const client = typeof ticket.client === 'object' ? ticket.client : null
67
+ if (!client?.email) {
68
+ return Response.json({ error: 'Client sans email' }, { status: 400 })
69
+ }
70
+
71
+ const settings = await readSupportSettings(payload)
72
+ const ticketNumber = ticket.ticketNumber || 'TK-????'
73
+ const subject = ticket.subject || 'Support'
74
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
75
+ const portalUrl = `${baseUrl}/support/tickets/${ticketId}`
76
+ const preview = message.body?.length > 500 ? message.body.slice(0, 500) + '...' : message.body
77
+
78
+ await payload.sendEmail({
79
+ to: client.email,
80
+ replyTo: settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || '',
81
+ subject: `Re: [${ticketNumber}] ${subject}`,
82
+ html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
83
+ <p>Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,</p>
84
+ <p>Notre équipe a apporté une réponse à votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em>.</p>
85
+ <blockquote style="border-left: 4px solid #ccc; padding: 8px 16px; margin: 16px 0; color: #555;">${escapeHtml(preview)}</blockquote>
86
+ <p><a href="${portalUrl}">Consulter le ticket</a></p>
87
+ </div>`,
88
+ })
89
+
90
+ return Response.json({ success: true })
91
+ } catch (error) {
92
+ const authResponse = handleAuthError(error)
93
+ if (authResponse) return authResponse
94
+ console.error('[resend-notification] Error:', error)
95
+ return Response.json({ error: 'Erreur interne' }, { status: 500 })
96
+ }
97
+ },
98
+ }
99
+ }