@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,704 @@
1
+ import type {
2
+ CollectionConfig,
3
+ CollectionBeforeChangeHook,
4
+ CollectionAfterChangeHook,
5
+ CollectionBeforeDeleteHook,
6
+ Field,
7
+ } from 'payload'
8
+ import type { CollectionSlugs } from '../utils/slugs'
9
+ import { escapeHtml } from '../utils/emailTemplate'
10
+ import { fireWebhooks } from '../utils/fireWebhooks'
11
+ import { createAdminNotification } from '../utils/adminNotification'
12
+ import { dispatchWebhook } from '../utils/webhookDispatcher'
13
+ import { readSupportSettings } from '../utils/readSettings'
14
+ import { createTicketStatusEmail } from '../hooks/ticketStatusEmail'
15
+ import { createAssignSlaDeadlines, createCheckSlaOnResolve } from '../hooks/checkSLA'
16
+
17
+ // ─── Hooks ───────────────────────────────────────────────
18
+
19
+ function createAssignTicketNumber(slugs: CollectionSlugs): CollectionBeforeChangeHook {
20
+ return async ({ data, operation, req }) => {
21
+ if (operation === 'create') {
22
+ // Retry loop to handle unique constraint collisions (e.g. concurrent inserts in SQLite).
23
+ let retries = 3
24
+ while (retries > 0) {
25
+ try {
26
+ const countResult = await req.payload.count({
27
+ collection: slugs.tickets,
28
+ overrideAccess: true,
29
+ })
30
+ const baseNumber = countResult.totalDocs + 1
31
+ data.ticketNumber = `TK-${String(baseNumber).padStart(4, '0')}`
32
+
33
+ // Double-check: if this number already exists, append a timestamp suffix
34
+ const existing = await req.payload.find({
35
+ collection: slugs.tickets,
36
+ where: { ticketNumber: { equals: data.ticketNumber } },
37
+ limit: 1,
38
+ depth: 0,
39
+ overrideAccess: true,
40
+ })
41
+ if (existing.docs.length > 0) {
42
+ const suffix = Date.now() % 10000
43
+ data.ticketNumber = `TK-${String(baseNumber + suffix).padStart(4, '0')}`
44
+ }
45
+ break
46
+ } catch (error: any) {
47
+ if (
48
+ retries > 1 &&
49
+ (error?.message?.includes('UNIQUE') ||
50
+ error?.message?.includes('unique') ||
51
+ error?.code === 'SQLITE_CONSTRAINT')
52
+ ) {
53
+ retries--
54
+ continue
55
+ }
56
+ throw error
57
+ }
58
+ }
59
+ }
60
+ return data
61
+ }
62
+ }
63
+
64
+ function createAssignClientOnCreate(slugs: CollectionSlugs): CollectionBeforeChangeHook {
65
+ return async ({ data, operation, req }) => {
66
+ if (operation === 'create' && req.user?.collection === slugs.supportClients && !data.client) {
67
+ data.client = req.user.id
68
+ }
69
+ return data
70
+ }
71
+ }
72
+
73
+ const autoPaidAt: CollectionBeforeChangeHook = async ({ data, operation, originalDoc }) => {
74
+ if (operation !== 'update') return data
75
+ if (data.paymentStatus === 'paid' && originalDoc?.paymentStatus !== 'paid' && !data.paidAt) {
76
+ data.paidAt = new Date().toISOString()
77
+ }
78
+ return data
79
+ }
80
+
81
+ function createRestrictClientUpdates(slugs: CollectionSlugs): CollectionBeforeChangeHook {
82
+ return async ({ data, operation, req, originalDoc }) => {
83
+ if (operation !== 'update') return data
84
+ if (req.user?.collection !== slugs.supportClients) return data
85
+ const allowedStatuses = ['open', 'resolved']
86
+ const newData: Record<string, unknown> = {}
87
+ if (data.status && allowedStatuses.includes(data.status as string)) {
88
+ newData.status = data.status
89
+ }
90
+ return { ...originalDoc, ...newData }
91
+ }
92
+ }
93
+
94
+ function createAutoAssignAdmin(slugs: CollectionSlugs): CollectionBeforeChangeHook {
95
+ return async ({ data, operation, req }) => {
96
+ if (operation === 'create' && !data.assignedTo) {
97
+ const { payload } = req
98
+ let roundRobinEnabled = false
99
+ try {
100
+ const prefs = await payload.find({
101
+ collection: 'payload-preferences',
102
+ where: { key: { equals: 'support-round-robin' } },
103
+ limit: 1,
104
+ depth: 0,
105
+ overrideAccess: true,
106
+ })
107
+ if (prefs.docs.length > 0) {
108
+ roundRobinEnabled = (prefs.docs[0].value as { enabled?: boolean })?.enabled === true
109
+ }
110
+ } catch (err) { console.warn('[support] Failed to read round-robin preferences:', err) }
111
+
112
+ const admins = await payload.find({
113
+ collection: slugs.users,
114
+ limit: 100,
115
+ depth: 0,
116
+ overrideAccess: true,
117
+ })
118
+ if (admins.docs.length === 0) return data
119
+
120
+ if (roundRobinEnabled && admins.docs.length > 1) {
121
+ const counts = await Promise.all(
122
+ admins.docs.map(async (admin) => {
123
+ const result = await payload.count({
124
+ collection: slugs.tickets,
125
+ where: { assignedTo: { equals: admin.id }, status: { in: ['open', 'waiting_client'] } },
126
+ overrideAccess: true,
127
+ })
128
+ return { id: admin.id, count: result.totalDocs }
129
+ }),
130
+ )
131
+ counts.sort((a, b) => a.count - b.count)
132
+ data.assignedTo = counts[0].id
133
+ } else {
134
+ data.assignedTo = admins.docs[0].id
135
+ }
136
+ }
137
+ return data
138
+ }
139
+ }
140
+
141
+ function createTrackSLA(slugs: CollectionSlugs): CollectionAfterChangeHook {
142
+ return async ({ doc, previousDoc, operation, req }) => {
143
+ if (operation !== 'update') return doc
144
+ const statusChanged = previousDoc?.status !== doc.status
145
+ if (statusChanged && doc.status === 'resolved' && !doc.resolvedAt) {
146
+ await req.payload.update({
147
+ collection: slugs.tickets,
148
+ id: doc.id,
149
+ data: { resolvedAt: new Date().toISOString() },
150
+ overrideAccess: true,
151
+ })
152
+ }
153
+ return doc
154
+ }
155
+ }
156
+
157
+ function createLogTicketActivity(slugs: CollectionSlugs): CollectionAfterChangeHook {
158
+ return async ({ doc, previousDoc, operation, req }) => {
159
+ if (operation !== 'update' || !previousDoc) return doc
160
+ const changes: { field: string; oldValue: string; newValue: string }[] = []
161
+ const trackedFields = ['status', 'priority', 'category', 'assignedTo'] as const
162
+ const displayVal = (val: unknown): string => {
163
+ if (val === null || val === undefined || val === '') return '(vide)'
164
+ if (typeof val === 'object') {
165
+ const obj = val as Record<string, unknown>
166
+ return String(obj.email || obj.name || obj.title || obj.id || JSON.stringify(val))
167
+ }
168
+ return String(val)
169
+ }
170
+ for (const field of trackedFields) {
171
+ const oldVal = previousDoc[field]
172
+ const newVal = doc[field]
173
+ const oldId = typeof oldVal === 'object' && oldVal !== null ? (oldVal as Record<string, unknown>).id : oldVal
174
+ const newId = typeof newVal === 'object' && newVal !== null ? (newVal as Record<string, unknown>).id : newVal
175
+ if (oldId !== newId) {
176
+ changes.push({ field, oldValue: displayVal(oldVal), newValue: displayVal(newVal) })
177
+ }
178
+ }
179
+ if (changes.length === 0) return doc
180
+ const actorType = req.user?.collection === slugs.users ? 'admin' : 'client'
181
+ for (const change of changes) {
182
+ try {
183
+ await req.payload.create({
184
+ collection: slugs.ticketActivityLog,
185
+ data: {
186
+ ticket: doc.id,
187
+ action: `${change.field}_changed`,
188
+ detail: `${change.field}: ${change.oldValue || '(vide)'} → ${change.newValue}`,
189
+ actorType,
190
+ actorEmail: req.user?.email || 'system',
191
+ },
192
+ overrideAccess: true,
193
+ })
194
+ } catch (err) {
195
+ console.error('[support] Failed to log activity:', err)
196
+ }
197
+ }
198
+ return doc
199
+ }
200
+ }
201
+
202
+ function createNotifyOnAssignment(slugs: CollectionSlugs): CollectionAfterChangeHook {
203
+ return async ({ doc, previousDoc, operation, req }) => {
204
+ if (operation !== 'update' || !previousDoc) return doc
205
+ const oldAssigned = typeof previousDoc.assignedTo === 'object' ? previousDoc.assignedTo?.id : previousDoc.assignedTo
206
+ const newAssigned = typeof doc.assignedTo === 'object' ? doc.assignedTo?.id : doc.assignedTo
207
+ if (!newAssigned || oldAssigned === newAssigned) return doc
208
+ try {
209
+ const assignee = typeof doc.assignedTo === 'object' && doc.assignedTo?.email
210
+ ? doc.assignedTo
211
+ : await req.payload.findByID({ collection: slugs.users, id: newAssigned, depth: 0, overrideAccess: true })
212
+ if (!assignee?.email) return doc
213
+ const settings = await readSupportSettings(req.payload)
214
+ const ticketNumber = doc.ticketNumber || 'TK-????'
215
+ const subject = doc.subject || 'Support'
216
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
217
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || ''
218
+ await req.payload.sendEmail({
219
+ to: assignee.email,
220
+ ...(replyTo ? { replyTo } : {}),
221
+ subject: `Ticket assigne : [${ticketNumber}] ${subject}`,
222
+ html: `<p>Le ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em> — vous a ete assigne.</p><p><a href="${baseUrl}/admin/collections/${slugs.tickets}/${doc.id}">Ouvrir le ticket</a></p>`,
223
+ })
224
+ } catch (err) {
225
+ console.error('[support] Failed to notify on assignment:', err)
226
+ }
227
+ return doc
228
+ }
229
+ }
230
+
231
+ function createNotifyClientOnResolve(slugs: CollectionSlugs): CollectionAfterChangeHook {
232
+ return async ({ doc, previousDoc, operation, req }) => {
233
+ if (operation !== 'update' || !previousDoc) return doc
234
+ if (previousDoc.status === doc.status || doc.status !== 'resolved') return doc
235
+ try {
236
+ const client = typeof doc.client === 'object' ? doc.client : null
237
+ const clientId = typeof doc.client === 'number' ? doc.client : client?.id
238
+ const clientData = client?.email ? client : (clientId ? await req.payload.findByID({
239
+ collection: slugs.supportClients,
240
+ id: clientId,
241
+ depth: 0,
242
+ overrideAccess: true,
243
+ }) : null)
244
+ if (!clientData?.email || clientData.notifyOnStatusChange === false) return doc
245
+ const settings = await readSupportSettings(req.payload)
246
+ const ticketNumber = doc.ticketNumber || 'TK-????'
247
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
248
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || ''
249
+ await req.payload.sendEmail({
250
+ to: clientData.email,
251
+ ...(replyTo ? { replyTo } : {}),
252
+ subject: `[${ticketNumber}] Ticket resolu`,
253
+ html: `<p>Bonjour ${escapeHtml(clientData.firstName || '')},</p><p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> a ete resolu.</p><p><a href="${baseUrl}/support/tickets/${doc.id}">Consulter le ticket</a></p>`,
254
+ })
255
+ } catch (err) {
256
+ console.error('[support] Failed to notify client on resolve:', err)
257
+ }
258
+ return doc
259
+ }
260
+ }
261
+
262
+ function createAutoCalculateSLA(slugs: CollectionSlugs): CollectionAfterChangeHook {
263
+ return async ({ doc, operation, req }) => {
264
+ if (operation !== 'create') return doc
265
+ try {
266
+ const { payload } = req
267
+ const ticketPriority = doc.priority || 'normal'
268
+
269
+ // Try to find a SLA policy matching the ticket's priority first
270
+ let policy: any = null
271
+ const byPriority = await payload.find({
272
+ collection: slugs.slaPolicies as any,
273
+ where: { priority: { equals: ticketPriority } },
274
+ limit: 1,
275
+ depth: 0,
276
+ overrideAccess: true,
277
+ })
278
+ if (byPriority.docs.length > 0) {
279
+ policy = byPriority.docs[0]
280
+ } else {
281
+ // Fall back to the default policy
282
+ const defaults = await payload.find({
283
+ collection: slugs.slaPolicies as any,
284
+ where: { isDefault: { equals: true } },
285
+ limit: 1,
286
+ depth: 0,
287
+ overrideAccess: true,
288
+ })
289
+ if (defaults.docs.length > 0) {
290
+ policy = defaults.docs[0]
291
+ }
292
+ }
293
+
294
+ if (!policy) return doc
295
+
296
+ // SLA fields are in minutes (firstResponseTime, resolutionTime)
297
+ const now = new Date()
298
+ const firstResponseMinutes = policy.firstResponseTime || 240 // default 4h
299
+ const resolutionMinutes = policy.resolutionTime || 1440 // default 24h
300
+ const firstResponseDue = new Date(now.getTime() + firstResponseMinutes * 60000)
301
+ const resolutionDue = new Date(now.getTime() + resolutionMinutes * 60000)
302
+
303
+ await payload.update({
304
+ collection: slugs.tickets as any,
305
+ id: doc.id,
306
+ data: {
307
+ slaPolicy: policy.id,
308
+ slaFirstResponseDue: firstResponseDue.toISOString(),
309
+ slaResolutionDue: resolutionDue.toISOString(),
310
+ },
311
+ overrideAccess: true,
312
+ })
313
+ } catch (err) {
314
+ console.error('[support] Failed to auto-calculate SLA:', err)
315
+ }
316
+ return doc
317
+ }
318
+ }
319
+
320
+ function createFireTicketWebhooks(slugs: CollectionSlugs): CollectionAfterChangeHook {
321
+ return async ({ doc, previousDoc, operation, req }) => {
322
+ const { payload } = req
323
+
324
+ if (operation === 'create') {
325
+ // ticket_created
326
+ fireWebhooks(payload, slugs, 'ticket_created', {
327
+ id: doc.id,
328
+ ticketNumber: doc.ticketNumber,
329
+ subject: doc.subject,
330
+ status: doc.status,
331
+ priority: doc.priority,
332
+ category: doc.category,
333
+ })
334
+ return doc
335
+ }
336
+
337
+ if (operation === 'update' && previousDoc) {
338
+ // ticket_resolved
339
+ if (previousDoc.status !== doc.status && doc.status === 'resolved') {
340
+ fireWebhooks(payload, slugs, 'ticket_resolved', {
341
+ id: doc.id,
342
+ ticketNumber: doc.ticketNumber,
343
+ subject: doc.subject,
344
+ previousStatus: previousDoc.status,
345
+ })
346
+ }
347
+
348
+ // ticket_assigned
349
+ const oldAssigned = typeof previousDoc.assignedTo === 'object' ? previousDoc.assignedTo?.id : previousDoc.assignedTo
350
+ const newAssigned = typeof doc.assignedTo === 'object' ? doc.assignedTo?.id : doc.assignedTo
351
+ if (newAssigned && oldAssigned !== newAssigned) {
352
+ fireWebhooks(payload, slugs, 'ticket_assigned', {
353
+ id: doc.id,
354
+ ticketNumber: doc.ticketNumber,
355
+ subject: doc.subject,
356
+ assignedTo: newAssigned,
357
+ })
358
+ }
359
+ }
360
+
361
+ return doc
362
+ }
363
+ }
364
+
365
+ function createNotifyAdminOnNewTicket(slugs: CollectionSlugs, notificationSlug: string): CollectionAfterChangeHook {
366
+ return async ({ doc, operation, req }) => {
367
+ if (operation !== 'create') return doc
368
+
369
+ try {
370
+ const ticketNumber = doc.ticketNumber || 'TK-????'
371
+ const subject = doc.subject || 'Support'
372
+
373
+ await createAdminNotification(req.payload, {
374
+ title: `Nouveau ticket : ${ticketNumber}`,
375
+ message: `${ticketNumber} — ${subject}`,
376
+ type: 'new_ticket',
377
+ link: `/admin/collections/${slugs.tickets}/${doc.id}`,
378
+ }, notificationSlug)
379
+ } catch (err) {
380
+ console.error('[support] Failed to create admin notification on new ticket:', err)
381
+ }
382
+
383
+ return doc
384
+ }
385
+ }
386
+
387
+ function createDispatchWebhookOnTicket(slugs: CollectionSlugs): CollectionAfterChangeHook {
388
+ return async ({ doc, previousDoc, operation, req }) => {
389
+ if (operation === 'create') {
390
+ dispatchWebhook(
391
+ { ticketId: doc.id, ticketNumber: doc.ticketNumber, subject: doc.subject },
392
+ 'ticket_created',
393
+ req.payload,
394
+ slugs,
395
+ )
396
+ }
397
+
398
+ if (operation === 'update' && previousDoc?.status !== doc.status && doc.status === 'resolved') {
399
+ dispatchWebhook(
400
+ { ticketId: doc.id, ticketNumber: doc.ticketNumber, subject: doc.subject },
401
+ 'ticket_resolved',
402
+ req.payload,
403
+ slugs,
404
+ )
405
+ }
406
+
407
+ return doc
408
+ }
409
+ }
410
+
411
+ function createCascadeDelete(slugs: CollectionSlugs): CollectionBeforeDeleteHook {
412
+ return async ({ id, req }) => {
413
+ const collections = [slugs.ticketMessages, slugs.ticketActivityLog, slugs.timeEntries, slugs.satisfactionSurveys]
414
+ for (const slug of collections) {
415
+ await req.payload.delete({
416
+ collection: slug as any,
417
+ where: { ticket: { equals: id } },
418
+ overrideAccess: true,
419
+ })
420
+ }
421
+ }
422
+ }
423
+
424
+ // ─── Collection factory ──────────────────────────────────
425
+
426
+ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
427
+ conversationComponent?: string
428
+ projectCollectionSlug?: string
429
+ documentsCollectionSlug?: string
430
+ notificationSlug?: string
431
+ }): CollectionConfig {
432
+ const notificationSlug = options?.notificationSlug || 'admin-notifications'
433
+
434
+ // Build dynamic fields
435
+ const dynamicFields: Field[] = []
436
+
437
+ // Conversation UI field at the top
438
+ dynamicFields.push({
439
+ name: 'conversation',
440
+ type: 'ui',
441
+ admin: {
442
+ components: {
443
+ Field: options?.conversationComponent || '@consilioweb/payload-support/src/components/TicketConversation',
444
+ },
445
+ },
446
+ })
447
+
448
+ // Project relationship (only if configured)
449
+ if (options?.projectCollectionSlug) {
450
+ dynamicFields.push({
451
+ name: 'project',
452
+ type: 'relationship',
453
+ relationTo: options.projectCollectionSlug,
454
+ label: 'Projet',
455
+ admin: { position: 'sidebar' },
456
+ })
457
+ }
458
+
459
+ // Billing collapsible fields
460
+ const billingFields: Field[] = [
461
+ {
462
+ type: 'row',
463
+ fields: [
464
+ ...(options?.documentsCollectionSlug ? [
465
+ {
466
+ name: 'quote',
467
+ type: 'upload' as const,
468
+ relationTo: options.documentsCollectionSlug,
469
+ label: 'Devis',
470
+ admin: { width: '50%' },
471
+ },
472
+ {
473
+ name: 'invoice',
474
+ type: 'upload' as const,
475
+ relationTo: options.documentsCollectionSlug,
476
+ label: 'Facture',
477
+ admin: { width: '50%' },
478
+ },
479
+ ] : []),
480
+ ],
481
+ },
482
+ {
483
+ type: 'row',
484
+ fields: [
485
+ {
486
+ name: 'paymentStatus',
487
+ type: 'select',
488
+ label: 'Statut de paiement',
489
+ defaultValue: 'unpaid',
490
+ options: [
491
+ { label: 'Non payé', value: 'unpaid' },
492
+ { label: 'Paiement partiel', value: 'partial' },
493
+ { label: 'Payé', value: 'paid' },
494
+ ],
495
+ access: { update: ({ req }) => req.user?.collection === 'users' },
496
+ admin: { width: '50%', condition: (data) => !!data?.invoice },
497
+ },
498
+ {
499
+ name: 'paidAt',
500
+ type: 'date',
501
+ label: 'Paye le',
502
+ admin: { width: '33%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } },
503
+ },
504
+ {
505
+ name: 'billedAmount',
506
+ type: 'number',
507
+ label: 'Montant facture (EUR)',
508
+ admin: { width: '33%' },
509
+ },
510
+ ],
511
+ },
512
+ ]
513
+
514
+ return {
515
+ slug: slugs.tickets,
516
+ labels: { singular: 'Ticket', plural: 'Tickets' },
517
+ admin: {
518
+ useAsTitle: 'subject',
519
+ group: 'Support',
520
+ defaultColumns: ['ticketNumber', 'subject', 'status', 'priority', 'category', 'client', 'updatedAt'],
521
+ listSearchableFields: ['ticketNumber', 'subject'],
522
+ },
523
+ fields: [
524
+ // Conversation UI field first
525
+ ...dynamicFields,
526
+ { name: 'subject', type: 'text', required: true, label: 'Sujet' },
527
+ {
528
+ type: 'row',
529
+ fields: [
530
+ {
531
+ name: 'status', type: 'select', defaultValue: 'open', label: 'Statut',
532
+ options: [
533
+ { label: 'Ouvert', value: 'open' },
534
+ { label: 'En attente client', value: 'waiting_client' },
535
+ { label: 'Resolu', value: 'resolved' },
536
+ ],
537
+ admin: { width: '50%' },
538
+ },
539
+ {
540
+ name: 'priority', type: 'select', defaultValue: 'normal', label: 'Priorite',
541
+ options: [
542
+ { label: 'Basse', value: 'low' },
543
+ { label: 'Normale', value: 'normal' },
544
+ { label: 'Haute', value: 'high' },
545
+ { label: 'Urgente', value: 'urgent' },
546
+ ],
547
+ admin: { width: '50%' },
548
+ },
549
+ ],
550
+ },
551
+ {
552
+ type: 'row',
553
+ fields: [
554
+ { name: 'client', type: 'relationship', relationTo: slugs.supportClients, required: true, label: 'Client', admin: { width: '50%' } },
555
+ ],
556
+ },
557
+ // Billing collapsible
558
+ {
559
+ type: 'collapsible', label: 'Facturation',
560
+ admin: { initCollapsed: true },
561
+ fields: billingFields,
562
+ },
563
+ // SLA & Delais
564
+ {
565
+ type: 'collapsible', label: 'SLA & Delais',
566
+ admin: { initCollapsed: true },
567
+ fields: [
568
+ {
569
+ type: 'row',
570
+ fields: [
571
+ { name: 'firstResponseAt', type: 'date', label: 'Premiere reponse', admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
572
+ { name: 'resolvedAt', type: 'date', label: 'Date de resolution', admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
573
+ ],
574
+ },
575
+ {
576
+ type: 'row',
577
+ fields: [
578
+ { name: 'slaFirstResponseDue', type: 'date', label: 'Echeance 1ere reponse (SLA)', admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
579
+ { name: 'slaResolutionDue', type: 'date', label: 'Echeance resolution (SLA)', admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
580
+ ],
581
+ },
582
+ {
583
+ type: 'row',
584
+ fields: [
585
+ { name: 'slaFirstResponseBreached', type: 'checkbox', label: '1ere reponse depassee', admin: { readOnly: true, width: '50%' } },
586
+ { name: 'slaResolutionBreached', type: 'checkbox', label: 'Resolution depassee', admin: { readOnly: true, width: '50%' } },
587
+ ],
588
+ },
589
+ ],
590
+ },
591
+ // Systeme
592
+ {
593
+ type: 'collapsible', label: 'Systeme',
594
+ admin: { initCollapsed: true },
595
+ fields: [
596
+ { name: 'chatSession', type: 'text', label: 'Session Chat', admin: { readOnly: true } },
597
+ {
598
+ type: 'row',
599
+ fields: [
600
+ { name: 'lastClientReadAt', type: 'date', label: 'Derniere lecture client', admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
601
+ { name: 'lastAdminReadAt', type: 'date', label: 'Derniere lecture admin', admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
602
+ { name: 'lastClientMessageAt', type: 'date', label: 'Dernier message client', admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
603
+ ],
604
+ },
605
+ { name: 'mergedInto', type: 'relationship', relationTo: slugs.tickets, label: 'Fusionne dans', admin: { readOnly: true } },
606
+ { name: 'autoCloseRemindedAt', type: 'date', label: 'Rappel auto-close envoye', admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
607
+ ],
608
+ },
609
+ // Sidebar
610
+ { name: 'ticketNumber', type: 'text', unique: true, label: 'N° Ticket', admin: { readOnly: true, position: 'sidebar' } },
611
+ { name: 'slaPolicy', type: 'relationship', relationTo: slugs.slaPolicies, label: 'Politique SLA', admin: { position: 'sidebar' } },
612
+ {
613
+ name: 'category', type: 'select', label: 'Categorie',
614
+ options: [
615
+ { label: 'Bug / Dysfonctionnement', value: 'bug' },
616
+ { label: 'Modification de contenu', value: 'content' },
617
+ { label: 'Nouvelle fonctionnalite', value: 'feature' },
618
+ { label: 'Question / Aide', value: 'question' },
619
+ { label: 'Hebergement / Domaine', value: 'hosting' },
620
+ ],
621
+ admin: { position: 'sidebar' },
622
+ },
623
+ { name: 'assignedTo', type: 'relationship', relationTo: slugs.users, label: 'Assigne a', admin: { position: 'sidebar' } },
624
+ {
625
+ name: 'source', type: 'select', defaultValue: 'portal', label: 'Source',
626
+ options: [
627
+ { label: 'Portail client', value: 'portal' },
628
+ { label: 'Chat en direct', value: 'live-chat' },
629
+ { label: 'Email', value: 'email' },
630
+ { label: 'Admin', value: 'admin' },
631
+ ],
632
+ admin: { position: 'sidebar' },
633
+ },
634
+ {
635
+ name: 'tags', type: 'select', hasMany: true, label: 'Tags',
636
+ options: [
637
+ { label: 'Urgent client', value: 'urgent-client' },
638
+ { label: 'Facturable', value: 'facturable' },
639
+ { label: 'Bug critique', value: 'bug-critique' },
640
+ { label: 'En attente info', value: 'attente-info' },
641
+ { label: 'Evolution', value: 'evolution' },
642
+ { label: 'Maintenance', value: 'maintenance' },
643
+ { label: 'Design', value: 'design' },
644
+ { label: 'SEO', value: 'seo' },
645
+ { label: 'SMS', value: 'sms' },
646
+ { label: 'WhatsApp', value: 'whatsapp' },
647
+ ],
648
+ admin: { position: 'sidebar' },
649
+ },
650
+ { name: 'relatedTickets', type: 'relationship', relationTo: slugs.tickets, hasMany: true, label: 'Tickets lies', admin: { position: 'sidebar' } },
651
+ { name: 'snoozeUntil', type: 'date', label: 'Snooze jusqu\'au', admin: { position: 'sidebar', date: { pickerAppearance: 'dayAndTime', displayFormat: 'dd/MM/yyyy HH:mm' } } },
652
+ // Billing sidebar
653
+ { name: 'billable', type: 'checkbox', defaultValue: true, label: 'Facturable', admin: { position: 'sidebar' } },
654
+ { name: 'showTimeToClient', type: 'checkbox', defaultValue: true, label: 'Afficher le temps au client', admin: { position: 'sidebar' } },
655
+ { name: 'totalTimeMinutes', type: 'number', defaultValue: 0, label: 'Temps total (minutes)', admin: { readOnly: true, position: 'sidebar' } },
656
+ ],
657
+ hooks: {
658
+ beforeChange: [
659
+ createAssignTicketNumber(slugs),
660
+ createAssignClientOnCreate(slugs),
661
+ createAutoAssignAdmin(slugs),
662
+ autoPaidAt,
663
+ createRestrictClientUpdates(slugs),
664
+ ],
665
+ afterChange: [
666
+ createTrackSLA(slugs),
667
+ createAutoCalculateSLA(slugs),
668
+ createAssignSlaDeadlines(slugs, notificationSlug),
669
+ createCheckSlaOnResolve(slugs, notificationSlug),
670
+ createLogTicketActivity(slugs),
671
+ createNotifyOnAssignment(slugs),
672
+ createNotifyClientOnResolve(slugs),
673
+ createTicketStatusEmail(slugs),
674
+ createNotifyAdminOnNewTicket(slugs, notificationSlug),
675
+ createFireTicketWebhooks(slugs),
676
+ createDispatchWebhookOnTicket(slugs),
677
+ ],
678
+ beforeDelete: [createCascadeDelete(slugs)],
679
+ },
680
+ access: {
681
+ create: ({ req }) => {
682
+ if (req.user?.collection === slugs.users) return true
683
+ if (req.user?.collection === slugs.supportClients) return true
684
+ return false
685
+ },
686
+ read: ({ req }) => {
687
+ if (req.user?.collection === slugs.users) return true
688
+ if (req.user?.collection === slugs.supportClients) {
689
+ return { client: { equals: req.user.id } }
690
+ }
691
+ return false
692
+ },
693
+ update: ({ req }) => {
694
+ if (req.user?.collection === slugs.users) return true
695
+ if (req.user?.collection === slugs.supportClients) {
696
+ return { client: { equals: req.user.id } }
697
+ }
698
+ return false
699
+ },
700
+ delete: ({ req }) => req.user?.collection === slugs.users,
701
+ },
702
+ timestamps: true,
703
+ }
704
+ }