@consilioweb/payload-support 2.0.1 → 4.0.0

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 (246) hide show
  1. package/README.md +670 -402
  2. package/dist/components/RichTextEditor/index.d.ts +19 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.d.ts +6 -0
  4. package/dist/components/TicketConversation/SkeletonText.d.ts +4 -0
  5. package/dist/components/TicketConversation/components/AISummaryPanel.d.ts +14 -0
  6. package/dist/components/TicketConversation/components/ActionPanels.d.ts +47 -0
  7. package/dist/components/TicketConversation/components/ActivityLog.d.ts +5 -0
  8. package/dist/components/TicketConversation/components/ClientBar.d.ts +5 -0
  9. package/dist/components/TicketConversation/components/ClientHistory.d.ts +24 -0
  10. package/dist/components/TicketConversation/components/CodeBlock.d.ts +26 -0
  11. package/dist/components/TicketConversation/components/CodeBlockInserter.d.ts +12 -0
  12. package/dist/components/TicketConversation/components/QuickActions.d.ts +32 -0
  13. package/dist/components/TicketConversation/components/TicketHeader.d.ts +19 -0
  14. package/dist/components/TicketConversation/components/TimeTrackingPanel.d.ts +24 -0
  15. package/dist/components/TicketConversation/config.d.ts +10 -0
  16. package/dist/components/TicketConversation/config.js +1 -40
  17. package/dist/components/TicketConversation/constants.d.ts +56 -0
  18. package/dist/components/TicketConversation/context.d.ts +53 -0
  19. package/dist/components/TicketConversation/hooks/useAI.d.ts +21 -0
  20. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.d.ts +3 -0
  21. package/dist/components/TicketConversation/hooks/useFeatures.d.ts +23 -0
  22. package/dist/components/TicketConversation/hooks/useFeatures.js +61 -0
  23. package/dist/components/TicketConversation/hooks/useMessageActions.d.ts +20 -0
  24. package/dist/components/TicketConversation/hooks/useReply.d.ts +27 -0
  25. package/dist/components/TicketConversation/hooks/useTicketActions.d.ts +58 -0
  26. package/dist/components/TicketConversation/hooks/useTimeTracking.d.ts +18 -0
  27. package/dist/components/TicketConversation/hooks/useTranslation.d.ts +33 -0
  28. package/dist/components/TicketConversation/index.d.ts +4 -0
  29. package/dist/components/TicketConversation/index.js +2 -2
  30. package/dist/components/TicketConversation/types.d.ts +56 -0
  31. package/dist/components/TicketConversation/utils.d.ts +2 -0
  32. package/dist/index.cjs +940 -404
  33. package/dist/index.d.cts +119 -7
  34. package/dist/index.d.ts +119 -7
  35. package/dist/index.js +936 -405
  36. package/dist/utils/features.d.ts +123 -0
  37. package/dist/utils/features.js +147 -0
  38. package/dist/views/BillingView/client.d.ts +2 -0
  39. package/dist/views/BillingView/index.d.ts +4 -0
  40. package/dist/views/ChatView/client.d.ts +2 -0
  41. package/dist/views/ChatView/client.js +9 -9
  42. package/dist/views/ChatView/index.d.ts +4 -0
  43. package/dist/views/CrmView/client.d.ts +2 -0
  44. package/dist/views/CrmView/client.js +6 -9
  45. package/dist/views/CrmView/index.d.ts +4 -0
  46. package/dist/views/EmailTrackingView/client.d.ts +2 -0
  47. package/dist/views/EmailTrackingView/index.d.ts +4 -0
  48. package/dist/views/ImportConversationView/client.d.ts +2 -0
  49. package/dist/views/ImportConversationView/index.d.ts +4 -0
  50. package/dist/views/LogsView/client.d.ts +2 -0
  51. package/dist/views/LogsView/index.d.ts +4 -0
  52. package/dist/views/NewTicketView/client.d.ts +2 -0
  53. package/dist/views/NewTicketView/index.d.ts +4 -0
  54. package/dist/views/PendingEmailsView/client.d.ts +2 -0
  55. package/dist/views/PendingEmailsView/index.d.ts +4 -0
  56. package/dist/views/SupportDashboardView/client.d.ts +2 -0
  57. package/dist/views/SupportDashboardView/index.d.ts +4 -0
  58. package/dist/views/TicketDetailView/NextActionItem.d.ts +6 -0
  59. package/dist/views/TicketDetailView/RewriteDropdown.d.ts +7 -0
  60. package/dist/views/TicketDetailView/client.d.ts +2 -0
  61. package/dist/views/TicketDetailView/client.js +2 -2
  62. package/dist/views/TicketDetailView/constants.d.ts +13 -0
  63. package/dist/views/TicketDetailView/helpers.d.ts +2 -0
  64. package/dist/views/TicketDetailView/index.d.ts +4 -0
  65. package/dist/views/TicketDetailView/types.d.ts +40 -0
  66. package/dist/views/TicketInboxView/client.d.ts +2 -0
  67. package/dist/views/TicketInboxView/client.js +5 -5
  68. package/dist/views/TicketInboxView/index.d.ts +4 -0
  69. package/dist/views/TicketingSettingsView/client.d.ts +2 -0
  70. package/dist/views/TicketingSettingsView/client.js +5 -4
  71. package/dist/views/TicketingSettingsView/index.d.ts +4 -0
  72. package/dist/views/TimeDashboardView/client.d.ts +2 -0
  73. package/dist/views/TimeDashboardView/index.d.ts +4 -0
  74. package/dist/views/shared/AdminViewHeader.d.ts +13 -0
  75. package/dist/views/shared/ErrorBoundary.d.ts +17 -0
  76. package/dist/views/shared/Skeleton.d.ts +21 -0
  77. package/dist/views/shared/StatusPill.d.ts +8 -0
  78. package/dist/views/shared/adminTokens.d.ts +19 -0
  79. package/dist/views/shared/config.d.ts +8 -0
  80. package/dist/views/shared/config.js +1 -40
  81. package/dist/views/shared/dateLocale.d.ts +5 -0
  82. package/dist/views/shared/index.d.ts +12 -0
  83. package/dist/views/shared/index.js +2 -1
  84. package/dist/views/shared/sla.d.ts +14 -0
  85. package/dist/views.d.ts +13 -13
  86. package/package.json +16 -31
  87. package/src/collections/ChatMessages.ts +59 -2
  88. package/src/collections/ClientSummaries.ts +10 -4
  89. package/src/collections/SupportClients.ts +29 -0
  90. package/src/collections/TicketCollaborators.ts +6 -1
  91. package/src/collections/TicketMessages.ts +49 -24
  92. package/src/collections/Tickets.ts +117 -79
  93. package/src/collections/TimeEntries.ts +57 -32
  94. package/src/collections/WebhookEndpoints.ts +44 -2
  95. package/src/components/TicketConversation/config.ts +16 -80
  96. package/src/components/TicketConversation/hooks/useFeatures.ts +109 -0
  97. package/src/components/TicketConversation/index.tsx +2 -2
  98. package/src/endpoints/admin-chat.ts +3 -3
  99. package/src/endpoints/ai-agent.ts +3 -3
  100. package/src/endpoints/ai.ts +5 -4
  101. package/src/endpoints/auth-2fa.ts +16 -4
  102. package/src/endpoints/capabilities.ts +6 -6
  103. package/src/endpoints/chat.ts +7 -5
  104. package/src/endpoints/chatbot.ts +48 -2
  105. package/src/endpoints/client-intelligence.ts +6 -5
  106. package/src/endpoints/email-stats.ts +19 -3
  107. package/src/endpoints/import-conversation.ts +1 -1
  108. package/src/endpoints/index.ts +1 -1
  109. package/src/endpoints/invite-collaborator.ts +29 -3
  110. package/src/endpoints/login.ts +15 -2
  111. package/src/endpoints/oauth-google.ts +130 -8
  112. package/src/endpoints/process-scheduled.ts +13 -8
  113. package/src/endpoints/resend-notification.ts +3 -3
  114. package/src/endpoints/round-robin-config.ts +30 -20
  115. package/src/endpoints/send-reminder.ts +3 -3
  116. package/src/endpoints/settings.ts +28 -77
  117. package/src/endpoints/signature.ts +9 -2
  118. package/src/endpoints/ticket-synthesis.ts +3 -3
  119. package/src/endpoints/transfer-ticket.ts +28 -3
  120. package/src/endpoints/typing.ts +117 -14
  121. package/src/endpoints/user-prefs.ts +5 -2
  122. package/src/index.ts +6 -2
  123. package/src/plugin.ts +12 -0
  124. package/src/portal/LiveChat.tsx +33 -22
  125. package/src/portal/auth/layout.tsx +19 -1
  126. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  127. package/src/portal/auth/tickets/detail/TicketPolling.tsx +5 -1
  128. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  129. package/src/portal/login/page.tsx +11 -3
  130. package/src/types/lucide-react.d.ts +10 -2
  131. package/src/utils/aiAgent.ts +2 -1
  132. package/src/utils/aiProvider.ts +21 -0
  133. package/src/utils/features.ts +297 -0
  134. package/src/utils/fireWebhooks.ts +10 -1
  135. package/src/utils/generateTicketSynthesis.ts +2 -1
  136. package/src/utils/index.ts +4 -2
  137. package/src/utils/rateLimiter.ts +39 -5
  138. package/src/utils/readSettings.ts +199 -16
  139. package/src/utils/ticketAccess.ts +16 -1
  140. package/src/utils/twoFactorChallenge.ts +80 -0
  141. package/src/utils/urlSafety.ts +230 -0
  142. package/src/utils/webhookDispatcher.ts +24 -8
  143. package/src/views/ChatView/client.tsx +13 -9
  144. package/src/views/CrmView/client.tsx +10 -11
  145. package/src/views/TicketDetailView/client.tsx +2 -2
  146. package/src/views/TicketInboxView/client.tsx +10 -5
  147. package/src/views/TicketingSettingsView/client.tsx +8 -6
  148. package/src/views/shared/config.ts +14 -80
  149. package/src/views/shared/index.ts +3 -1
  150. package/dist/components/RichTextEditor/index.cjs +0 -279
  151. package/dist/components/TicketConversation/RewriteDropdown.cjs +0 -98
  152. package/dist/components/TicketConversation/SkeletonText.cjs +0 -21
  153. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +0 -85
  154. package/dist/components/TicketConversation/components/ActionPanels.cjs +0 -193
  155. package/dist/components/TicketConversation/components/ActivityLog.cjs +0 -24
  156. package/dist/components/TicketConversation/components/ClientBar.cjs +0 -43
  157. package/dist/components/TicketConversation/components/ClientHistory.cjs +0 -138
  158. package/dist/components/TicketConversation/components/CodeBlock.cjs +0 -171
  159. package/dist/components/TicketConversation/components/CodeBlockInserter.cjs +0 -156
  160. package/dist/components/TicketConversation/components/QuickActions.cjs +0 -87
  161. package/dist/components/TicketConversation/components/TicketHeader.cjs +0 -93
  162. package/dist/components/TicketConversation/components/TimeTrackingPanel.cjs +0 -134
  163. package/dist/components/TicketConversation/config.cjs +0 -44
  164. package/dist/components/TicketConversation/constants.cjs +0 -127
  165. package/dist/components/TicketConversation/context.cjs +0 -13
  166. package/dist/components/TicketConversation/hooks/useAI.cjs +0 -196
  167. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +0 -20
  168. package/dist/components/TicketConversation/hooks/useMessageActions.cjs +0 -136
  169. package/dist/components/TicketConversation/hooks/useReply.cjs +0 -188
  170. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +0 -290
  171. package/dist/components/TicketConversation/hooks/useTimeTracking.cjs +0 -123
  172. package/dist/components/TicketConversation/hooks/useTranslation.cjs +0 -101
  173. package/dist/components/TicketConversation/index.cjs +0 -1114
  174. package/dist/components/TicketConversation/types.cjs +0 -2
  175. package/dist/components/TicketConversation/utils.cjs +0 -27
  176. package/dist/views/BillingView/client.cjs +0 -469
  177. package/dist/views/BillingView/index.cjs +0 -34
  178. package/dist/views/ChatView/client.cjs +0 -301
  179. package/dist/views/ChatView/index.cjs +0 -34
  180. package/dist/views/CrmView/client.cjs +0 -458
  181. package/dist/views/CrmView/index.cjs +0 -34
  182. package/dist/views/EmailTrackingView/client.cjs +0 -171
  183. package/dist/views/EmailTrackingView/index.cjs +0 -34
  184. package/dist/views/ImportConversationView/client.cjs +0 -238
  185. package/dist/views/ImportConversationView/index.cjs +0 -34
  186. package/dist/views/LogsView/client.cjs +0 -148
  187. package/dist/views/LogsView/index.cjs +0 -32
  188. package/dist/views/NewTicketView/client.cjs +0 -213
  189. package/dist/views/NewTicketView/index.cjs +0 -32
  190. package/dist/views/PendingEmailsView/client.cjs +0 -471
  191. package/dist/views/PendingEmailsView/index.cjs +0 -34
  192. package/dist/views/SupportDashboardView/client.cjs +0 -437
  193. package/dist/views/SupportDashboardView/index.cjs +0 -34
  194. package/dist/views/TicketDetailView/NextActionItem.cjs +0 -40
  195. package/dist/views/TicketDetailView/RewriteDropdown.cjs +0 -80
  196. package/dist/views/TicketDetailView/client.cjs +0 -1412
  197. package/dist/views/TicketDetailView/constants.cjs +0 -20
  198. package/dist/views/TicketDetailView/helpers.cjs +0 -23
  199. package/dist/views/TicketDetailView/index.cjs +0 -34
  200. package/dist/views/TicketDetailView/types.cjs +0 -2
  201. package/dist/views/TicketInboxView/client.cjs +0 -359
  202. package/dist/views/TicketInboxView/index.cjs +0 -32
  203. package/dist/views/TicketingSettingsView/client.cjs +0 -740
  204. package/dist/views/TicketingSettingsView/index.cjs +0 -34
  205. package/dist/views/TimeDashboardView/client.cjs +0 -180
  206. package/dist/views/TimeDashboardView/index.cjs +0 -34
  207. package/dist/views/shared/AdminViewHeader.cjs +0 -68
  208. package/dist/views/shared/ErrorBoundary.cjs +0 -55
  209. package/dist/views/shared/Skeleton.cjs +0 -77
  210. package/dist/views/shared/StatusPill.cjs +0 -47
  211. package/dist/views/shared/adminTokens.cjs +0 -34
  212. package/dist/views/shared/config.cjs +0 -44
  213. package/dist/views/shared/dateLocale.cjs +0 -5
  214. package/dist/views/shared/index.cjs +0 -72
  215. package/dist/views/shared/sla.cjs +0 -37
  216. package/dist/views.cjs +0 -86
  217. package/dist/views.d.cts +0 -13
  218. package/src/__tests__/aiSummaryRendering.test.ts +0 -21
  219. package/src/__tests__/authResponses.test.ts +0 -69
  220. package/src/__tests__/capabilities.test.ts +0 -71
  221. package/src/__tests__/generateTrackingToken.test.ts +0 -67
  222. package/src/__tests__/integration/ai-agent.test.ts +0 -46
  223. package/src/__tests__/integration/auth.test.ts +0 -43
  224. package/src/__tests__/integration/automation-rules.test.ts +0 -68
  225. package/src/__tests__/integration/buildTestPayload.ts +0 -52
  226. package/src/__tests__/integration/channels.test.ts +0 -52
  227. package/src/__tests__/integration/core-behaviors.test.ts +0 -81
  228. package/src/__tests__/integration/dashboard-volume.test.ts +0 -31
  229. package/src/__tests__/integration/digest.test.ts +0 -48
  230. package/src/__tests__/integration/invoice-pdf.test.ts +0 -30
  231. package/src/__tests__/integration/isolation.test.ts +0 -81
  232. package/src/__tests__/integration/mentions-invoice.test.ts +0 -50
  233. package/src/__tests__/integration/nps.test.ts +0 -37
  234. package/src/__tests__/integration/per-team-sla.test.ts +0 -44
  235. package/src/__tests__/integration/push.test.ts +0 -75
  236. package/src/__tests__/integration/sanitization.test.ts +0 -35
  237. package/src/__tests__/integration/sla-pause.test.ts +0 -39
  238. package/src/__tests__/integration/smoke.test.ts +0 -26
  239. package/src/__tests__/integration/snooze.test.ts +0 -68
  240. package/src/__tests__/integration/teams.test.ts +0 -38
  241. package/src/__tests__/rateLimiter.test.ts +0 -83
  242. package/src/__tests__/sanitizeHtml.test.ts +0 -165
  243. package/src/__tests__/sla.test.ts +0 -258
  244. package/src/__tests__/trackOpenEndpoint.test.ts +0 -54
  245. package/src/__tests__/translations.test.ts +0 -67
  246. package/src/__tests__/webhookSecurity.test.ts +0 -58
@@ -3,13 +3,13 @@ import type {
3
3
  CollectionBeforeChangeHook,
4
4
  CollectionAfterChangeHook,
5
5
  CollectionBeforeDeleteHook,
6
+ CollectionBeforeValidateHook,
6
7
  Field,
7
8
  Where,
8
9
  } from 'payload'
9
10
  import type { CollectionSlugs } from '../utils/slugs'
10
11
  import type { SupportCapabilities } from '../types'
11
12
  import { escapeHtml, emailWrapper, emailParagraph, emailButton } from '../utils/emailTemplate'
12
- import { fireWebhooks } from '../utils/fireWebhooks'
13
13
  import { createAdminNotification } from '../utils/adminNotification'
14
14
  import { dispatchWebhook } from '../utils/webhookDispatcher'
15
15
  import { readSupportSettings } from '../utils/readSettings'
@@ -96,13 +96,52 @@ function createAssignTicketNumber(
96
96
 
97
97
  function createAssignClientOnCreate(slugs: CollectionSlugs): CollectionBeforeChangeHook {
98
98
  return async ({ data, operation, req }) => {
99
- if (operation === 'create' && req.user?.collection === slugs.supportClients && !data.client) {
99
+ // Unconditional, NOT `if (!data.client)`: a support-client posting an explicit
100
+ // `client` id would otherwise open a ticket in someone else's name.
101
+ if (operation === 'create' && req.user?.collection === slugs.supportClients) {
100
102
  data.client = req.user.id
101
103
  }
102
104
  return data
103
105
  }
104
106
  }
105
107
 
108
+ /**
109
+ * Fields a support-client is allowed to submit when opening a ticket from the
110
+ * portal. Everything else (billing, SLA, assignment, counters, AI bookkeeping,
111
+ * `client`, `source`) is server-owned.
112
+ */
113
+ const CLIENT_CREATABLE_TICKET_FIELDS = ['subject', 'category', 'priority', 'project'] as const
114
+
115
+ /**
116
+ * Allow-list the payload of a client-initiated ticket creation.
117
+ *
118
+ * `access.create` grants any authenticated support-client the right to create a
119
+ * ticket, and Payload happily persists every field it is handed — including
120
+ * `paymentStatus: 'paid'`, `billable: false`, `assignedTo` or `slaPolicy`, all of
121
+ * which the billing endpoints read back as trusted input.
122
+ *
123
+ * Runs as `beforeValidate` so the later `beforeChange` hooks (ticket number
124
+ * allocation, round-robin assignment) can still set the server-owned fields they
125
+ * own. Update is already covered by `createRestrictClientUpdates`.
126
+ */
127
+ function createRestrictClientCreate(slugs: CollectionSlugs): CollectionBeforeValidateHook {
128
+ return async ({ data, operation, req }) => {
129
+ if (operation !== 'create') return data
130
+ if (req.user?.collection !== slugs.supportClients) return data
131
+
132
+ const incoming = (data ?? {}) as Record<string, unknown>
133
+ const sanitized: Record<string, unknown> = {}
134
+ for (const field of CLIENT_CREATABLE_TICKET_FIELDS) {
135
+ if (incoming[field] !== undefined) sanitized[field] = incoming[field]
136
+ }
137
+ // Clients open tickets; they do not get to pick the initial workflow state.
138
+ sanitized.status = 'open'
139
+ sanitized.client = req.user.id
140
+ sanitized.source = 'portal'
141
+ return sanitized
142
+ }
143
+ }
144
+
106
145
  const autoPaidAt: CollectionBeforeChangeHook = async ({ data, operation, originalDoc }) => {
107
146
  if (operation !== 'update') return data
108
147
  if (data.paymentStatus === 'paid' && originalDoc?.paymentStatus !== 'paid' && !data.paidAt) {
@@ -133,16 +172,10 @@ function createAutoAssignAdmin(slugs: CollectionSlugs): CollectionBeforeChangeHo
133
172
  const { payload } = req
134
173
  let roundRobinEnabled = false
135
174
  try {
136
- const prefs = await payload.find({
137
- collection: 'payload-preferences',
138
- where: { key: { equals: 'support-round-robin' } },
139
- limit: 1,
140
- depth: 0,
141
- overrideAccess: true,
142
- })
143
- if (prefs.docs.length > 0) {
144
- roundRobinEnabled = (prefs.docs[0].value as { enabled?: boolean })?.enabled === true
145
- }
175
+ // Single source of truth: the `features` block of the support settings,
176
+ // which also falls back to the legacy `support-round-robin` row.
177
+ const settings = await readSupportSettings(payload)
178
+ roundRobinEnabled = settings.features.roundRobin === true
146
179
  } catch (err) { console.warn('[support] Failed to read round-robin preferences:', err) }
147
180
 
148
181
  const admins = await payload.find({
@@ -384,62 +417,6 @@ function createNotifyClientOnResolve(slugs: CollectionSlugs): CollectionAfterCha
384
417
  }
385
418
  }
386
419
 
387
- function createFireTicketWebhooks(slugs: CollectionSlugs): CollectionAfterChangeHook {
388
- return async ({ doc, previousDoc, operation, req }) => {
389
- const { payload } = req
390
-
391
- if (operation === 'create') {
392
- // ticket_created
393
- fireWebhooks(payload, slugs, 'ticket_created', {
394
- id: doc.id,
395
- ticketNumber: doc.ticketNumber,
396
- subject: doc.subject,
397
- status: doc.status,
398
- priority: doc.priority,
399
- category: doc.category,
400
- })
401
- return doc
402
- }
403
-
404
- if (operation === 'update' && previousDoc) {
405
- // ticket_resolved
406
- if (previousDoc.status !== doc.status && doc.status === 'resolved') {
407
- fireWebhooks(payload, slugs, 'ticket_resolved', {
408
- id: doc.id,
409
- ticketNumber: doc.ticketNumber,
410
- subject: doc.subject,
411
- previousStatus: previousDoc.status,
412
- })
413
-
414
- // Invalidate client summary cache so it refreshes on next view
415
- const clientId = typeof doc.client === 'object' ? doc.client?.id : doc.client
416
- if (clientId) {
417
- payload.update({
418
- collection: 'client-summaries',
419
- where: { client: { equals: clientId } },
420
- data: { generatedAt: new Date(0).toISOString() }, // Force cache expiry
421
- overrideAccess: true,
422
- }).catch(() => { /* silent — collection might not exist yet */ })
423
- }
424
- }
425
-
426
- // ticket_assigned
427
- const oldAssigned = typeof previousDoc.assignedTo === 'object' ? previousDoc.assignedTo?.id : previousDoc.assignedTo
428
- const newAssigned = typeof doc.assignedTo === 'object' ? doc.assignedTo?.id : doc.assignedTo
429
- if (newAssigned && oldAssigned !== newAssigned) {
430
- fireWebhooks(payload, slugs, 'ticket_assigned', {
431
- id: doc.id,
432
- ticketNumber: doc.ticketNumber,
433
- subject: doc.subject,
434
- assignedTo: newAssigned,
435
- })
436
- }
437
- }
438
-
439
- return doc
440
- }
441
- }
442
-
443
420
  function createNotifyAdminOnNewTicket(slugs: CollectionSlugs, notificationSlug: string): CollectionAfterChangeHook {
444
421
  return async ({ doc, operation, req }) => {
445
422
  if (operation !== 'create') return doc
@@ -462,24 +439,82 @@ function createNotifyAdminOnNewTicket(slugs: CollectionSlugs, notificationSlug:
462
439
  }
463
440
  }
464
441
 
442
+ /**
443
+ * Single outbound-webhook dispatcher for tickets.
444
+ *
445
+ * Until 2.x two dispatchers were registered side by side on these very hooks with
446
+ * identical guards, so every subscriber received TWO POSTs per event, with two
447
+ * incompatible body shapes and two auth schemes — the legacy one shipping the
448
+ * endpoint secret in clear in `X-Webhook-Secret`, which defeated the HMAC of the
449
+ * other. Only the HMAC path remains; its payloads absorb the legacy fields
450
+ * (`id`, `status`, `priority`, `category`, `previousStatus`, `assignedTo`) so
451
+ * consumers of either shape keep working.
452
+ */
465
453
  function createDispatchWebhookOnTicket(slugs: CollectionSlugs): CollectionAfterChangeHook {
466
454
  return async ({ doc, previousDoc, operation, req }) => {
455
+ const { payload } = req
456
+
467
457
  if (operation === 'create') {
468
458
  dispatchWebhook(
469
- { ticketId: doc.id, ticketNumber: doc.ticketNumber, subject: doc.subject },
459
+ {
460
+ ticketId: doc.id,
461
+ id: doc.id,
462
+ ticketNumber: doc.ticketNumber,
463
+ subject: doc.subject,
464
+ status: doc.status,
465
+ priority: doc.priority,
466
+ category: doc.category,
467
+ },
470
468
  'ticket_created',
471
- req.payload,
469
+ payload,
472
470
  slugs,
473
471
  )
472
+ return doc
474
473
  }
475
474
 
476
- if (operation === 'update' && previousDoc?.status !== doc.status && doc.status === 'resolved') {
477
- dispatchWebhook(
478
- { ticketId: doc.id, ticketNumber: doc.ticketNumber, subject: doc.subject },
479
- 'ticket_resolved',
480
- req.payload,
481
- slugs,
482
- )
475
+ if (operation === 'update' && previousDoc) {
476
+ if (previousDoc.status !== doc.status && doc.status === 'resolved') {
477
+ dispatchWebhook(
478
+ {
479
+ ticketId: doc.id,
480
+ id: doc.id,
481
+ ticketNumber: doc.ticketNumber,
482
+ subject: doc.subject,
483
+ previousStatus: previousDoc.status,
484
+ },
485
+ 'ticket_resolved',
486
+ payload,
487
+ slugs,
488
+ )
489
+
490
+ // Invalidate client summary cache so it refreshes on next view
491
+ const clientId = typeof doc.client === 'object' ? doc.client?.id : doc.client
492
+ if (clientId) {
493
+ payload.update({
494
+ collection: 'client-summaries',
495
+ where: { client: { equals: clientId } },
496
+ data: { generatedAt: new Date(0).toISOString() }, // Force cache expiry
497
+ overrideAccess: true,
498
+ }).catch(() => { /* silent — collection might not exist yet */ })
499
+ }
500
+ }
501
+
502
+ const oldAssigned = typeof previousDoc.assignedTo === 'object' ? previousDoc.assignedTo?.id : previousDoc.assignedTo
503
+ const newAssigned = typeof doc.assignedTo === 'object' ? doc.assignedTo?.id : doc.assignedTo
504
+ if (newAssigned && oldAssigned !== newAssigned) {
505
+ dispatchWebhook(
506
+ {
507
+ ticketId: doc.id,
508
+ id: doc.id,
509
+ ticketNumber: doc.ticketNumber,
510
+ subject: doc.subject,
511
+ assignedTo: newAssigned,
512
+ },
513
+ 'ticket_assigned',
514
+ payload,
515
+ slugs,
516
+ )
517
+ }
483
518
  }
484
519
 
485
520
  return doc
@@ -626,7 +661,10 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
626
661
  { label: 'Paiement partiel', value: 'partial' },
627
662
  { label: 'Payé', value: 'paid' },
628
663
  ],
629
- access: { update: ({ req }) => req.user?.collection === 'users' },
664
+ // `slugs.users`, not the literal 'users': the users collection slug is
665
+ // configurable, and a hard-coded one silently opens this field to everybody
666
+ // on a host that renamed it.
667
+ access: { update: ({ req }) => req.user?.collection === slugs.users },
630
668
  admin: { width: '50%', condition: (data) => !!data?.invoice },
631
669
  },
632
670
  {
@@ -890,6 +928,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
890
928
  { name: 'totalTimeMinutes', type: 'number', defaultValue: 0, label: 'Temps total (minutes)', admin: { readOnly: true, position: 'sidebar' } },
891
929
  ],
892
930
  hooks: {
931
+ beforeValidate: [createRestrictClientCreate(slugs)],
893
932
  beforeChange: [
894
933
  createAssignTicketNumber(slugs, options?.ticketNumber),
895
934
  createAssignClientOnCreate(slugs),
@@ -910,7 +949,6 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
910
949
  createNotifyClientOnResolve(slugs),
911
950
  createTicketStatusEmail(slugs),
912
951
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
913
- createFireTicketWebhooks(slugs),
914
952
  createDispatchWebhookOnTicket(slugs),
915
953
  createApplyAutomationRules(slugs),
916
954
  ],
@@ -1,45 +1,69 @@
1
- import type { CollectionConfig, CollectionAfterChangeHook } from 'payload'
1
+ import type { CollectionConfig, CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
2
+ import type { Payload } from 'payload'
2
3
  import type { CollectionSlugs } from '../utils/slugs'
3
4
 
4
5
  // ─── Hooks ───────────────────────────────────────────────
5
6
 
6
- function createRecalculateTicketTime(slugs: CollectionSlugs): CollectionAfterChangeHook {
7
- return async ({ doc, req }) => {
8
- if (!doc.ticket) return
7
+ async function recalculateTicketTime(
8
+ payload: Payload,
9
+ slugs: CollectionSlugs,
10
+ ticketId: number | string,
11
+ ): Promise<void> {
12
+ // Sum all time entries for this ticket, paginating to avoid loading all at once
13
+ let totalMinutes = 0
14
+ let page = 1
15
+ let hasMore = true
9
16
 
10
- const { payload } = req
11
- const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
17
+ while (hasMore) {
18
+ const entries = await payload.find({
19
+ collection: slugs.timeEntries,
20
+ where: { ticket: { equals: ticketId } },
21
+ limit: 100,
22
+ page,
23
+ depth: 0,
24
+ overrideAccess: true,
25
+ select: { duration: true },
26
+ })
12
27
 
13
- // Sum all time entries for this ticket, paginating to avoid loading all at once
14
- let totalMinutes = 0
15
- let page = 1
16
- let hasMore = true
28
+ for (const entry of entries.docs) {
29
+ totalMinutes += ((entry.duration as number) || 0)
30
+ }
17
31
 
18
- while (hasMore) {
19
- const entries = await payload.find({
20
- collection: slugs.timeEntries,
21
- where: { ticket: { equals: ticketId } },
22
- limit: 100,
23
- page,
24
- depth: 0,
25
- overrideAccess: true,
26
- select: { duration: true },
27
- })
32
+ hasMore = entries.hasNextPage ?? false
33
+ page++
34
+ }
28
35
 
29
- for (const entry of entries.docs) {
30
- totalMinutes += ((entry.duration as number) || 0)
31
- }
36
+ await payload.update({
37
+ collection: slugs.tickets,
38
+ id: ticketId,
39
+ data: { totalTimeMinutes: totalMinutes },
40
+ overrideAccess: true,
41
+ })
42
+ }
32
43
 
33
- hasMore = entries.hasNextPage ?? false
34
- page++
35
- }
44
+ function createRecalculateTicketTime(slugs: CollectionSlugs): CollectionAfterChangeHook {
45
+ return async ({ doc, req }) => {
46
+ if (!doc.ticket) return
47
+ const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
48
+ await recalculateTicketTime(req.payload, slugs, ticketId)
49
+ }
50
+ }
36
51
 
37
- await payload.update({
38
- collection: slugs.tickets,
39
- id: ticketId,
40
- data: { totalTimeMinutes: totalMinutes },
41
- overrideAccess: true,
42
- })
52
+ /**
53
+ * Deleting a time entry has to re-roll the ticket total too. Without this the
54
+ * rollup only ever grows: `tickets.totalTimeMinutes` is what the invoice endpoint
55
+ * and the CRM view bill on, so a removed entry stayed billed forever.
56
+ */
57
+ function createRecalculateTicketTimeOnDelete(slugs: CollectionSlugs): CollectionAfterDeleteHook {
58
+ return async ({ doc, req }) => {
59
+ if (!doc?.ticket) return
60
+ const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
61
+ try {
62
+ await recalculateTicketTime(req.payload, slugs, ticketId)
63
+ } catch (err) {
64
+ // The entry is already gone; never fail the delete on the rollup refresh.
65
+ console.error('[support] Failed to recalculate ticket time after delete:', err)
66
+ }
43
67
  }
44
68
  }
45
69
 
@@ -105,6 +129,7 @@ export function createTimeEntriesCollection(slugs: CollectionSlugs): CollectionC
105
129
  ],
106
130
  hooks: {
107
131
  afterChange: [createRecalculateTicketTime(slugs)],
132
+ afterDelete: [createRecalculateTicketTimeOnDelete(slugs)],
108
133
  },
109
134
  access: {
110
135
  create: ({ req }) => req.user?.collection === slugs.users,
@@ -1,5 +1,41 @@
1
- import type { CollectionConfig } from 'payload'
1
+ import { APIError } from 'payload'
2
+ import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
2
3
  import type { CollectionSlugs } from '../utils/slugs'
4
+ import { WEBHOOK_URL_MESSAGES, validateWebhookUrl } from '../utils/urlSafety'
5
+
6
+ /**
7
+ * First SSRF layer: the value is fetched by the SERVER on every ticket event, so
8
+ * a loopback / private / link-local target (or a non-https scheme) must never be
9
+ * SAVED. The send path re-checks the resolved address and every redirect hop —
10
+ * see `utils/urlSafety.ts`.
11
+ *
12
+ * Why a collection hook rather than a field `validate`: Payload re-validates the
13
+ * MERGED document on every write, so a field-level guard also fires on partial
14
+ * updates that never mention `url`. A row saved before this guard existed (or
15
+ * seeded through `payload.db`, which runs no hooks) then became totally
16
+ * immutable — impossible to rename, to re-scope its events, even to set
17
+ * `active: false` — and the dispatcher's own bookkeeping write (`lastStatus: 0`,
18
+ * issued inside a `catch` that swallows errors) failed silently, leaving a dead
19
+ * endpoint looking healthy in the admin.
20
+ *
21
+ * So only a URL actually being SET or CHANGED is checked. Leaving an existing
22
+ * bad value alone costs nothing: the send path refuses to call it anyway.
23
+ */
24
+ function createValidateWebhookUrl(): CollectionBeforeValidateHook {
25
+ return ({ data, operation, originalDoc }) => {
26
+ const incoming = (data as { url?: unknown } | undefined)?.url
27
+ if (incoming === undefined || incoming === null) return data
28
+
29
+ const previous = (originalDoc as { url?: unknown } | undefined)?.url
30
+ if (operation === 'update' && incoming === previous) return data
31
+
32
+ const result = validateWebhookUrl(incoming)
33
+ if (!result.ok) {
34
+ throw new APIError(WEBHOOK_URL_MESSAGES[result.reason || 'invalid_url'], 400)
35
+ }
36
+ return data
37
+ }
38
+ }
3
39
 
4
40
  // ─── Collection factory ──────────────────────────────────
5
41
 
@@ -36,8 +72,11 @@ export function createWebhookEndpointsCollection(slugs: CollectionSlugs): Collec
36
72
  type: 'text',
37
73
  required: true,
38
74
  label: 'URL',
75
+ // The SSRF check lives in the collection `beforeValidate` above, NOT in a
76
+ // field `validate`: the latter re-runs on the merged document and would
77
+ // freeze every pre-existing row on any unrelated edit.
39
78
  admin: {
40
- description: 'URL du webhook à appeler (POST)',
79
+ description: 'URL https:// du webhook à appeler (POST). Les adresses privées et loopback sont refusées.',
41
80
  },
42
81
  },
43
82
  {
@@ -91,6 +130,9 @@ export function createWebhookEndpointsCollection(slugs: CollectionSlugs): Collec
91
130
  },
92
131
  },
93
132
  ],
133
+ hooks: {
134
+ beforeValidate: [createValidateWebhookUrl()],
135
+ },
94
136
  timestamps: true,
95
137
  }
96
138
  }
@@ -1,82 +1,18 @@
1
1
  /**
2
- * Feature flags for the ticketing module.
3
- * Each feature can be enabled/disabled by the admin.
4
- * When disabled, the corresponding UI section is hidden entirely.
2
+ * Ticketing feature flags re-export of the shared implementation.
3
+ *
4
+ * The flags used to be defined (twice, byte-for-byte) here and in
5
+ * `views/shared/config.ts`, each reading `localStorage` directly. The single
6
+ * implementation now lives in `utils/features.ts` and is backed by the server;
7
+ * both modules are kept as import sites so existing paths keep working.
5
8
  */
6
- export interface TicketingFeatures {
7
- /** Time tracking: timer, manual entries, billing */
8
- timeTracking: boolean
9
- /** AI features: sentiment, synthesis, suggestion, rewrite */
10
- ai: boolean
11
- /** Satisfaction surveys: CSAT rating after resolution */
12
- satisfaction: boolean
13
- /** Live chat integration: chat → ticket conversion */
14
- chat: boolean
15
- /** Email tracking: pixel tracking, open/sent status per message */
16
- emailTracking: boolean
17
- /** Canned responses: quick reply templates */
18
- canned: boolean
19
- /** Ticket merge: combine two tickets into one */
20
- merge: boolean
21
- /** Snooze: temporarily hide a ticket */
22
- snooze: boolean
23
- /** External messages: add messages received outside the system */
24
- externalMessages: boolean
25
- /** Client history: past tickets, projects, notes sidebar */
26
- clientHistory: boolean
27
- /** Activity log: audit trail of actions on the ticket */
28
- activityLog: boolean
29
- /** Split ticket: extract a message into a new ticket */
30
- splitTicket: boolean
31
- /** Scheduled replies: send a message at a future date */
32
- scheduledReplies: boolean
33
- /** Auto-close: automatically resolve inactive tickets */
34
- autoClose: boolean
35
- /** Auto-close delay in days (used by auto-close cron) */
36
- autoCloseDays: number
37
- /** Round-robin: distribute new tickets evenly among agents */
38
- roundRobin: boolean
39
- }
40
-
41
- /** Default features — all enabled */
42
- export const DEFAULT_FEATURES: TicketingFeatures = {
43
- timeTracking: true,
44
- ai: true,
45
- satisfaction: true,
46
- chat: true,
47
- emailTracking: true,
48
- canned: true,
49
- merge: true,
50
- snooze: true,
51
- externalMessages: true,
52
- clientHistory: true,
53
- activityLog: true,
54
- splitTicket: true,
55
- scheduledReplies: true,
56
- autoClose: true,
57
- autoCloseDays: 7,
58
- roundRobin: false,
59
- }
60
-
61
- const STORAGE_KEY = 'ticketing_features'
62
-
63
- /** Read features from localStorage (falls back to defaults) */
64
- export function getFeatures(): TicketingFeatures {
65
- if (typeof window === 'undefined') return DEFAULT_FEATURES
66
- try {
67
- const stored = localStorage.getItem(STORAGE_KEY)
68
- if (stored) {
69
- const parsed = JSON.parse(stored)
70
- return { ...DEFAULT_FEATURES, ...parsed }
71
- }
72
- } catch { /* ignore */ }
73
- return DEFAULT_FEATURES
74
- }
75
-
76
- /** Save features to localStorage */
77
- export function saveFeatures(features: TicketingFeatures): void {
78
- if (typeof window === 'undefined') return
79
- try {
80
- localStorage.setItem(STORAGE_KEY, JSON.stringify(features))
81
- } catch { /* ignore */ }
82
- }
9
+ export {
10
+ DEFAULT_TICKETING_FEATURES,
11
+ DEFAULT_TICKETING_FEATURES as DEFAULT_FEATURES,
12
+ FEATURES_CACHE_KEY,
13
+ fetchFeatures,
14
+ getFeatures,
15
+ normalizeFeatures,
16
+ saveFeatures,
17
+ } from '../../utils/features'
18
+ export type { TicketingFeatures } from '../../utils/features'
@@ -0,0 +1,109 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import {
5
+ fetchFeatures,
6
+ getFeatures,
7
+ hasAdoptedLegacyFeatures,
8
+ markLegacyFeaturesAdopted,
9
+ readLegacyFeatures,
10
+ saveFeatures as persistFeatures,
11
+ writeFeaturesCache,
12
+ type TicketingFeatures,
13
+ } from '../../../utils/features'
14
+
15
+ export interface UseFeaturesResult {
16
+ /** Current flags — cached values on first paint, server values once loaded. */
17
+ features: TicketingFeatures
18
+ /** True until the first server round-trip settles. */
19
+ loading: boolean
20
+ /** True when the server could not be reached and cached/default values are shown. */
21
+ offline: boolean
22
+ /** Replace the flags locally without persisting (for a settings form). */
23
+ setFeatures: (features: TicketingFeatures) => void
24
+ /** Persist the flags server-side. Resolves to false when the write failed. */
25
+ save: (features: TicketingFeatures) => Promise<boolean>
26
+ }
27
+
28
+ /**
29
+ * Read the ticketing feature flags from the server, with the local cache as a
30
+ * fallback so a screen never blocks on the settings call.
31
+ *
32
+ * Also performs the one-shot migration of pre-2.1 installs: when this browser
33
+ * still holds flags in `localStorage` and the server has never had any saved,
34
+ * the local values are pushed up once and become the shared truth. Afterwards
35
+ * the server always wins and `localStorage` is only a cache.
36
+ */
37
+ export function useFeatures(): UseFeaturesResult {
38
+ const [features, setFeatures] = useState<TicketingFeatures>(() => getFeatures())
39
+ const [loading, setLoading] = useState(true)
40
+ const [offline, setOffline] = useState(false)
41
+ // Migration must run once per mount at most, even under StrictMode double-effects.
42
+ const migrationAttempted = useRef(false)
43
+ // Set as soon as the caller edits the flags, so a slow first load cannot
44
+ // overwrite a toggle the admin just made in the settings form.
45
+ const dirty = useRef(false)
46
+
47
+ useEffect(() => {
48
+ const controller = new AbortController()
49
+ let cancelled = false
50
+
51
+ void (async () => {
52
+ const result = await fetchFeatures(controller.signal)
53
+ if (cancelled) return
54
+
55
+ // Fresh server + legacy browser values => adopt them instead of silently
56
+ // resetting the admin's configuration to the defaults.
57
+ if (!result.offline && !result.configured && !migrationAttempted.current && !dirty.current) {
58
+ migrationAttempted.current = true
59
+ const legacy = readLegacyFeatures()
60
+ if (legacy) {
61
+ const ok = await persistFeatures(legacy)
62
+ if (cancelled) return
63
+ if (ok) {
64
+ markLegacyFeaturesAdopted()
65
+ setFeatures(legacy)
66
+ setLoading(false)
67
+ return
68
+ }
69
+ }
70
+ }
71
+
72
+ if (!result.offline && !hasAdoptedLegacyFeatures()) {
73
+ // Server already configured (by this admin elsewhere, or another admin):
74
+ // the server wins, and there is nothing left to migrate here.
75
+ markLegacyFeaturesAdopted()
76
+ }
77
+
78
+ if (!dirty.current) setFeatures(result.features)
79
+ setOffline(result.offline)
80
+ setLoading(false)
81
+ })()
82
+
83
+ return () => {
84
+ cancelled = true
85
+ controller.abort()
86
+ }
87
+ }, [])
88
+
89
+ const setLocalFeatures = useCallback((next: TicketingFeatures) => {
90
+ dirty.current = true
91
+ setFeatures(next)
92
+ // Keep the cache aligned with what is on screen, so a reload before saving
93
+ // does not flash the previous values.
94
+ writeFeaturesCache(next)
95
+ }, [])
96
+
97
+ const save = useCallback(async (next: TicketingFeatures) => {
98
+ const ok = await persistFeatures(next)
99
+ if (ok) {
100
+ markLegacyFeaturesAdopted()
101
+ dirty.current = false
102
+ setFeatures(next)
103
+ setOffline(false)
104
+ }
105
+ return ok
106
+ }, [])
107
+
108
+ return { features, loading, offline, setFeatures: setLocalFeatures, save }
109
+ }
@@ -21,14 +21,14 @@ import { useTicketActions } from './hooks/useTicketActions'
21
21
  import { useReply } from './hooks/useReply'
22
22
  import { useAI } from './hooks/useAI'
23
23
  import { useDocumentIdFromUrl } from './hooks/useDocumentIdFromUrl'
24
- import { getFeatures, type TicketingFeatures } from './config'
24
+ import { useFeatures } from './hooks/useFeatures'
25
25
  import { SkeletonText } from './SkeletonText'
26
26
  import { RewriteDropdown } from './RewriteDropdown'
27
27
  import '../../styles/theme.css'
28
28
 
29
29
  const TicketConversation: React.FC = () => {
30
30
  const { id } = useDocumentIdFromUrl()
31
- const [features] = useState<TicketingFeatures>(() => getFeatures())
31
+ const { features } = useFeatures()
32
32
  const [messages, setMessages] = useState<Message[]>([])
33
33
  const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([])
34
34
  const [client, setClient] = useState<ClientInfo | null>(null)