@consilioweb/payload-support 0.15.0 → 1.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 (202) hide show
  1. package/README.md +347 -435
  2. package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
  4. package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
  5. package/dist/components/TicketConversation/SkeletonText.js +20 -0
  6. package/dist/components/TicketConversation/components/ActionPanels.cjs +72 -0
  7. package/dist/components/TicketConversation/components/ActionPanels.js +73 -2
  8. package/dist/components/TicketConversation/components/QuickActions.cjs +22 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +22 -1
  10. package/dist/components/TicketConversation/constants.cjs +27 -0
  11. package/dist/components/TicketConversation/constants.js +26 -1
  12. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  13. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  14. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +59 -0
  15. package/dist/components/TicketConversation/hooks/useTicketActions.js +59 -0
  16. package/dist/components/TicketConversation/index.cjs +42 -149
  17. package/dist/components/TicketConversation/index.js +37 -144
  18. package/dist/index.cjs +1835 -741
  19. package/dist/index.d.cts +3 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +1833 -741
  22. package/dist/views/BillingView/client.cjs +40 -54
  23. package/dist/views/BillingView/client.js +40 -54
  24. package/dist/views/ChatView/client.cjs +4 -3
  25. package/dist/views/ChatView/client.js +4 -3
  26. package/dist/views/CrmView/client.cjs +7 -7
  27. package/dist/views/CrmView/client.js +7 -7
  28. package/dist/views/EmailTrackingView/client.cjs +6 -5
  29. package/dist/views/EmailTrackingView/client.js +6 -5
  30. package/dist/views/ImportConversationView/client.cjs +4 -4
  31. package/dist/views/ImportConversationView/client.js +4 -4
  32. package/dist/views/LogsView/client.cjs +3 -2
  33. package/dist/views/LogsView/client.js +3 -2
  34. package/dist/views/NewTicketView/client.cjs +1 -1
  35. package/dist/views/NewTicketView/client.js +1 -1
  36. package/dist/views/PendingEmailsView/client.cjs +7 -6
  37. package/dist/views/PendingEmailsView/client.js +7 -6
  38. package/dist/views/SupportDashboardView/client.cjs +19 -22
  39. package/dist/views/SupportDashboardView/client.js +19 -22
  40. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  41. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  42. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  43. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  44. package/dist/views/TicketDetailView/client.cjs +59 -190
  45. package/dist/views/TicketDetailView/client.js +52 -183
  46. package/dist/views/TicketDetailView/constants.cjs +20 -0
  47. package/dist/views/TicketDetailView/constants.js +16 -0
  48. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  49. package/dist/views/TicketDetailView/helpers.js +20 -0
  50. package/dist/views/TicketDetailView/types.cjs +2 -0
  51. package/dist/views/TicketDetailView/types.js +1 -0
  52. package/dist/views/TicketInboxView/client.cjs +14 -10
  53. package/dist/views/TicketInboxView/client.js +14 -10
  54. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  55. package/dist/views/TicketingSettingsView/client.js +92 -88
  56. package/dist/views/TimeDashboardView/client.cjs +2 -2
  57. package/dist/views/TimeDashboardView/client.js +2 -2
  58. package/dist/views/shared/StatusPill.cjs +23 -12
  59. package/dist/views/shared/StatusPill.js +23 -12
  60. package/dist/views/shared/dateLocale.cjs +5 -0
  61. package/dist/views/shared/dateLocale.js +3 -0
  62. package/package.json +23 -15
  63. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  64. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  65. package/src/__tests__/integration/auth.test.ts +43 -0
  66. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  67. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  68. package/src/__tests__/integration/channels.test.ts +52 -0
  69. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  70. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  71. package/src/__tests__/integration/digest.test.ts +48 -0
  72. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  73. package/src/__tests__/integration/isolation.test.ts +81 -0
  74. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  75. package/src/__tests__/integration/nps.test.ts +37 -0
  76. package/src/__tests__/integration/sanitization.test.ts +35 -0
  77. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  78. package/src/__tests__/integration/smoke.test.ts +26 -0
  79. package/src/__tests__/integration/snooze.test.ts +68 -0
  80. package/src/__tests__/integration/teams.test.ts +38 -0
  81. package/src/__tests__/rateLimiter.test.ts +89 -0
  82. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  83. package/src/__tests__/sla.test.ts +258 -0
  84. package/src/collections/AutomationRules.ts +66 -0
  85. package/src/collections/NotificationQueue.ts +30 -0
  86. package/src/collections/SatisfactionSurveys.ts +11 -2
  87. package/src/collections/SupportClients.ts +58 -1
  88. package/src/collections/SupportTeam.ts +27 -0
  89. package/src/collections/TicketActivityLog.ts +9 -4
  90. package/src/collections/TicketMessages.ts +137 -31
  91. package/src/collections/Tickets.ts +31 -72
  92. package/src/collections/index.ts +3 -0
  93. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  94. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  95. package/src/components/TicketConversation/components/ActionPanels.tsx +82 -1
  96. package/src/components/TicketConversation/components/QuickActions.tsx +25 -0
  97. package/src/components/TicketConversation/constants.ts +29 -0
  98. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  99. package/src/components/TicketConversation/hooks/useTicketActions.ts +51 -0
  100. package/src/components/TicketConversation/index.tsx +29 -157
  101. package/src/endpoints/admin-chat-stream.ts +3 -4
  102. package/src/endpoints/admin-chat.ts +11 -20
  103. package/src/endpoints/admin-stats.ts +70 -32
  104. package/src/endpoints/ai-agent.ts +36 -0
  105. package/src/endpoints/apply-macro.ts +8 -14
  106. package/src/endpoints/auth-2fa.ts +12 -12
  107. package/src/endpoints/auto-close.ts +38 -18
  108. package/src/endpoints/billing.ts +17 -4
  109. package/src/endpoints/bulk-action.ts +7 -12
  110. package/src/endpoints/channels.ts +89 -0
  111. package/src/endpoints/chat-stream.ts +3 -4
  112. package/src/endpoints/chat.ts +7 -12
  113. package/src/endpoints/chatbot.ts +2 -2
  114. package/src/endpoints/client-intelligence.ts +4 -4
  115. package/src/endpoints/delete-account.ts +9 -16
  116. package/src/endpoints/email-stats.ts +2 -2
  117. package/src/endpoints/escalate.ts +2 -2
  118. package/src/endpoints/export-csv.ts +2 -2
  119. package/src/endpoints/export-data.ts +5 -8
  120. package/src/endpoints/import-conversation.ts +6 -10
  121. package/src/endpoints/index.ts +24 -2
  122. package/src/endpoints/invite-collaborator.ts +30 -15
  123. package/src/endpoints/invoice.ts +127 -0
  124. package/src/endpoints/kb-search.ts +2 -2
  125. package/src/endpoints/login.ts +12 -4
  126. package/src/endpoints/merge-clients.ts +14 -19
  127. package/src/endpoints/merge-tickets.ts +11 -18
  128. package/src/endpoints/oauth-google.ts +86 -39
  129. package/src/endpoints/pending-emails-process.ts +15 -25
  130. package/src/endpoints/process-digests.ts +93 -0
  131. package/src/endpoints/process-scheduled.ts +6 -10
  132. package/src/endpoints/process-snooze.ts +78 -0
  133. package/src/endpoints/purge-logs.ts +2 -2
  134. package/src/endpoints/resend-notification.ts +3 -4
  135. package/src/endpoints/round-robin-config.ts +3 -4
  136. package/src/endpoints/satisfaction.ts +21 -14
  137. package/src/endpoints/search.ts +5 -8
  138. package/src/endpoints/seed-kb.ts +3 -4
  139. package/src/endpoints/send-reminder.ts +186 -0
  140. package/src/endpoints/settings.ts +5 -2
  141. package/src/endpoints/signature.ts +3 -4
  142. package/src/endpoints/sla-check.ts +2 -2
  143. package/src/endpoints/split-ticket.ts +7 -12
  144. package/src/endpoints/statuses.ts +2 -2
  145. package/src/endpoints/ticket-feedback.ts +4 -6
  146. package/src/endpoints/ticket-synthesis.ts +2 -2
  147. package/src/endpoints/track-open.ts +16 -28
  148. package/src/endpoints/transfer-ticket.ts +21 -9
  149. package/src/endpoints/user-prefs.ts +3 -4
  150. package/src/hooks/automationRules.ts +84 -0
  151. package/src/hooks/checkSLA.ts +48 -16
  152. package/src/plugin.ts +6 -0
  153. package/src/portal/LiveChat.tsx +2 -63
  154. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  155. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  156. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  157. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  158. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  159. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  160. package/src/portal/helpers.ts +46 -0
  161. package/src/portal/login/page.tsx +3 -8
  162. package/src/portal/types.ts +15 -0
  163. package/src/types/lucide-react.d.ts +34 -0
  164. package/src/types/payload-config.d.ts +10 -0
  165. package/src/utils/adminNotification.ts +2 -2
  166. package/src/utils/aiAgent.ts +147 -0
  167. package/src/utils/auth.ts +5 -4
  168. package/src/utils/channels.ts +36 -0
  169. package/src/utils/db.ts +73 -0
  170. package/src/utils/emailTemplate.ts +8 -1
  171. package/src/utils/fireWebhooks.ts +4 -6
  172. package/src/utils/generateTicketSynthesis.ts +7 -12
  173. package/src/utils/invoicePdf.ts +88 -0
  174. package/src/utils/notificationQueue.ts +36 -0
  175. package/src/utils/readSettings.ts +22 -6
  176. package/src/utils/sanitizeHtml.ts +57 -0
  177. package/src/utils/slugs.ts +6 -0
  178. package/src/utils/teamAccess.ts +27 -0
  179. package/src/utils/ticketAccess.ts +53 -0
  180. package/src/utils/webhookDispatcher.ts +4 -6
  181. package/src/views/BillingView/client.tsx +49 -37
  182. package/src/views/ChatView/client.tsx +4 -3
  183. package/src/views/CrmView/client.tsx +7 -7
  184. package/src/views/EmailTrackingView/client.tsx +7 -6
  185. package/src/views/ImportConversationView/client.tsx +4 -4
  186. package/src/views/LogsView/client.tsx +3 -2
  187. package/src/views/NewTicketView/client.tsx +1 -1
  188. package/src/views/PendingEmailsView/client.tsx +7 -6
  189. package/src/views/SupportDashboardView/client.tsx +21 -21
  190. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  191. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  192. package/src/views/TicketDetailView/client.tsx +54 -179
  193. package/src/views/TicketDetailView/constants.ts +23 -0
  194. package/src/views/TicketDetailView/helpers.ts +17 -0
  195. package/src/views/TicketDetailView/types.ts +10 -0
  196. package/src/views/TicketInboxView/client.tsx +16 -10
  197. package/src/views/TicketingSettingsView/client.tsx +90 -87
  198. package/src/views/TimeDashboardView/client.tsx +2 -2
  199. package/src/views/shared/StatusPill.tsx +24 -12
  200. package/src/views/shared/dateLocale.ts +5 -0
  201. package/src/views/shared/locales/en.json +708 -0
  202. package/src/views/shared/locales/fr.json +708 -0
@@ -6,6 +6,10 @@ import { createAdminNotification } from '../utils/adminNotification'
6
6
  import { dispatchWebhook } from '../utils/webhookDispatcher'
7
7
  import { readSupportSettings } from '../utils/readSettings'
8
8
  import { createCheckSlaOnReply } from '../hooks/checkSLA'
9
+ import { resolveAccessibleTicketIds } from '../utils/ticketAccess'
10
+ import { sanitizeMessageHtml } from '../utils/sanitizeHtml'
11
+ import { queueClientNotification } from '../utils/notificationQueue'
12
+ import { dbFind, dbCreate, dbFindByID } from '../utils/db'
9
13
 
10
14
  function createAssignAuthor(slugs: CollectionSlugs): CollectionBeforeChangeHook {
11
15
  return async ({ data, operation, req }) => {
@@ -30,8 +34,16 @@ function createAutoUpdateStatus(slugs: CollectionSlugs): CollectionAfterChangeHo
30
34
  if (!doc.isInternal) {
31
35
  if (doc.authorType === 'admin') {
32
36
  updateData.status = 'waiting_client'
37
+ // A fresh public reply starts a new waiting window: drop any pending
38
+ // auto-close arming (manual deadline + day-based reminder anchor) so
39
+ // reminders re-arm cleanly from here.
40
+ updateData.autoCloseScheduledAt = null
41
+ updateData.autoCloseRemindedAt = null
33
42
  } else if (doc.authorType === 'client' || doc.authorType === 'email') {
34
43
  updateData.lastClientMessageAt = new Date().toISOString()
44
+ // The client answered — cancel any pending automatic closure.
45
+ updateData.autoCloseScheduledAt = null
46
+ updateData.autoCloseRemindedAt = null
35
47
  if (ticket.status && ['waiting_client', 'resolved'].includes(ticket.status as string)) {
36
48
  updateData.status = 'open'
37
49
  }
@@ -82,30 +94,51 @@ function createNotifyClient(slugs: CollectionSlugs): CollectionAfterChangeHook {
82
94
 
83
95
  const replyPreview = (doc.body || '').replace(/\s+/g, ' ').trim().slice(0, 90)
84
96
 
85
- await req.payload.sendEmail({
86
- to: client.email,
87
- ...(supportEmail ? { replyTo: supportEmail } : {}),
88
- ...(internalBcc ? { bcc: internalBcc } : {}),
89
- subject: `Re: [${ticketNumber}] ${subject}`,
90
- html: emailWrapper(`Nouvelle reponse — ${ticketNumber}`, [
91
- emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,`),
92
- emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em>.`),
93
- rawContent,
94
- emailButton('Consulter le ticket', portalUrl),
95
- emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
96
- emailTrackingPixel(ticketId, doc.id),
97
- ].join(''), {
98
- kind: 'reply',
99
- preheader: replyPreview ? `${replyPreview}…` : `Nouvelle reponse a votre ticket ${ticketNumber}`,
100
- }),
101
- })
97
+ // Digest: clients on daily/weekly frequency are queued, not emailed now.
98
+ const freq = (client as { notificationFrequency?: string }).notificationFrequency
99
+ if (freq === 'daily' || freq === 'weekly') {
100
+ await queueClientNotification(req.payload, slugs, {
101
+ client: (client as { id: number | string }).id,
102
+ type: 'reply',
103
+ title: `Nouvelle reponse ${ticketNumber}`,
104
+ message: replyPreview || `Reponse a votre ticket ${ticketNumber}`,
105
+ ticket: ticketId,
106
+ })
107
+ return doc
108
+ }
102
109
 
103
- await req.payload.update({
104
- collection: slugs.ticketMessages,
105
- id: doc.id,
106
- data: { emailSentAt: new Date().toISOString(), emailSentTo: client.email },
107
- overrideAccess: true,
108
- })
110
+ // Fire-and-forget: don't block the reply mutation on the SMTP round-trip
111
+ // (200-500ms). The email + its emailSentAt bookkeeping run detached.
112
+ void (async () => {
113
+ try {
114
+ await req.payload.sendEmail({
115
+ to: client.email,
116
+ ...(supportEmail ? { replyTo: supportEmail } : {}),
117
+ ...(internalBcc ? { bcc: internalBcc } : {}),
118
+ subject: `Re: [${ticketNumber}] ${subject}`,
119
+ html: emailWrapper(`Nouvelle reponse — ${ticketNumber}`, [
120
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,`),
121
+ emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em>.`),
122
+ rawContent,
123
+ emailButton('Consulter le ticket', portalUrl),
124
+ emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
125
+ emailTrackingPixel(ticketId, doc.id),
126
+ ].join(''), {
127
+ kind: 'reply',
128
+ preheader: replyPreview ? `${replyPreview}…` : `Nouvelle reponse a votre ticket ${ticketNumber}`,
129
+ }),
130
+ })
131
+
132
+ await req.payload.update({
133
+ collection: slugs.ticketMessages,
134
+ id: doc.id,
135
+ data: { emailSentAt: new Date().toISOString(), emailSentTo: client.email },
136
+ overrideAccess: true,
137
+ })
138
+ } catch (err) {
139
+ console.error('[support] Failed to notify client (async):', err)
140
+ }
141
+ })()
109
142
  } catch (err) {
110
143
  console.error('[support] Failed to notify client:', err)
111
144
  }
@@ -153,8 +186,7 @@ function createSyncTicketReplyToChat(slugs: CollectionSlugs): CollectionAfterCha
153
186
  const clientId = typeof ticket.client === 'object' ? (ticket.client as { id: number | string }).id : ticket.client
154
187
 
155
188
  // Create a chat message so the client sees it in the widget
156
- await payload.create({
157
- collection: slugs.chatMessages as any,
189
+ await dbCreate(payload, slugs.chatMessages, {
158
190
  data: {
159
191
  session: ticket.chatSession,
160
192
  client: clientId,
@@ -294,6 +326,72 @@ function createDispatchWebhookOnReply(slugs: CollectionSlugs): CollectionAfterCh
294
326
  }
295
327
  }
296
328
 
329
+ /**
330
+ * Sanitize submitted rich HTML before persistence. `bodyHtml` is rendered via
331
+ * dangerouslySetInnerHTML in the admin & portal, so unsanitized HTML is a
332
+ * stored-XSS vector (→ admin session theft → cross-client access). Runs for
333
+ * every writer (client AND admin) — neither needs to store scripts.
334
+ */
335
+ function createSanitizeMessageHtml(): CollectionBeforeChangeHook {
336
+ return ({ data }) => {
337
+ if (data && typeof data.bodyHtml === 'string' && data.bodyHtml) {
338
+ data.bodyHtml = sanitizeMessageHtml(data.bodyHtml)
339
+ }
340
+ return data
341
+ }
342
+ }
343
+
344
+ // Matches `@someone@example.com` mentions inside a message body.
345
+ const MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g
346
+
347
+ /**
348
+ * Resolve `@email` mentions in the message body to agent (users) IDs, stored on
349
+ * the `mentions` field so the UI can highlight them and we can notify them.
350
+ */
351
+ function createResolveMentions(slugs: CollectionSlugs): CollectionBeforeChangeHook {
352
+ return async ({ data, operation, req }) => {
353
+ if (operation !== 'create') return data
354
+ const body = typeof data?.body === 'string' ? data.body : ''
355
+ const emails = Array.from(new Set([...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase())))
356
+ if (emails.length === 0) return data
357
+ try {
358
+ const users = await dbFind(req.payload, slugs.users, { where: { email: { in: emails } }, limit: 50, depth: 0, overrideAccess: true })
359
+ const ids = users.docs.map((u) => (u as { id: number | string }).id)
360
+ if (ids.length > 0) data.mentions = ids
361
+ } catch { /* users lookup is best-effort */ }
362
+ return data
363
+ }
364
+ }
365
+
366
+ /** Email the agents mentioned in a freshly-created message. */
367
+ function createNotifyMentions(slugs: CollectionSlugs): CollectionAfterChangeHook {
368
+ return async ({ doc, operation, req }) => {
369
+ if (operation !== 'create') return doc
370
+ const mentions: Array<number | string | { id: number | string }> = Array.isArray(doc.mentions) ? doc.mentions : []
371
+ if (mentions.length === 0) return doc
372
+ const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
373
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
374
+ for (const m of mentions) {
375
+ const userId = typeof m === 'object' ? m.id : m
376
+ try {
377
+ const user = await dbFindByID(req.payload, slugs.users, { id: userId, depth: 0, overrideAccess: true })
378
+ if (!user?.email) continue
379
+ await req.payload.sendEmail({
380
+ to: user.email,
381
+ subject: `Vous avez ete mentionne — ticket #${ticketId}`,
382
+ html: emailWrapper('Mention', [
383
+ emailParagraph(`Vous avez ete mentionne dans un message du ticket <strong>#${escapeHtml(String(ticketId))}</strong>.`),
384
+ ...(baseUrl ? [emailButton('Ouvrir le ticket', `${baseUrl}/admin/collections/${slugs.tickets}/${ticketId}`, 'dark')] : []),
385
+ ].join(''), { kind: 'system' }),
386
+ })
387
+ } catch (err) {
388
+ console.error('[support] Failed to notify mention:', err)
389
+ }
390
+ }
391
+ return doc
392
+ }
393
+ }
394
+
297
395
  export function createTicketMessagesCollection(slugs: CollectionSlugs, options?: {
298
396
  notificationSlug?: string
299
397
  }): CollectionConfig {
@@ -304,14 +402,14 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
304
402
  labels: { singular: 'Message', plural: 'Messages' },
305
403
  admin: { hidden: true, group: 'Support', defaultColumns: ['ticket', 'authorType', 'createdAt'] },
306
404
  fields: [
307
- { name: 'ticket', type: 'relationship', relationTo: slugs.tickets, required: true, label: 'Ticket' },
405
+ { name: 'ticket', type: 'relationship', relationTo: slugs.tickets, required: true, index: true, label: 'Ticket' },
308
406
  { name: 'body', type: 'textarea', required: true, label: 'Message' },
309
407
  { name: 'bodyHtml', type: 'textarea', label: 'Message HTML', admin: { hidden: true } },
310
408
  {
311
409
  type: 'row',
312
410
  fields: [
313
411
  {
314
- name: 'authorType', type: 'select', label: 'Type d\'auteur', defaultValue: 'admin',
412
+ name: 'authorType', type: 'select', index: true, label: 'Type d\'auteur', defaultValue: 'admin',
315
413
  options: [
316
414
  { label: 'Client', value: 'client' },
317
415
  { label: 'Support', value: 'admin' },
@@ -340,8 +438,9 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
340
438
  },
341
439
  { name: 'isInternal', type: 'checkbox', defaultValue: false, label: 'Note interne', admin: { position: 'sidebar' } },
342
440
  { name: 'isSolution', type: 'checkbox', defaultValue: false, label: 'Reponse solution', admin: { position: 'sidebar' } },
441
+ { name: 'mentions', type: 'relationship', relationTo: slugs.users, hasMany: true, label: 'Agents mentionnes', admin: { hidden: true } },
343
442
  { name: 'skipNotification', type: 'checkbox', defaultValue: false, label: 'Sans notification', admin: { position: 'sidebar', condition: (data) => data?.skipNotification === true } },
344
- { name: 'scheduledAt', type: 'date', label: 'Programme pour', admin: { date: { pickerAppearance: 'dayAndTime' }, position: 'sidebar', condition: (data) => !!data?.scheduledAt } },
443
+ { name: 'scheduledAt', type: 'date', index: true, label: 'Programme pour', admin: { date: { pickerAppearance: 'dayAndTime' }, position: 'sidebar', condition: (data) => !!data?.scheduledAt } },
345
444
  { name: 'scheduledSent', type: 'checkbox', defaultValue: false, admin: { hidden: true } },
346
445
  { name: 'editedAt', type: 'date', label: 'Modifie le', admin: { hidden: true } },
347
446
  { name: 'deletedAt', type: 'date', label: 'Supprime le', admin: { hidden: true } },
@@ -350,7 +449,7 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
350
449
  { name: 'emailOpenedAt', type: 'date', label: 'Email ouvert le', admin: { hidden: true } },
351
450
  ],
352
451
  hooks: {
353
- beforeChange: [createAssignAuthor(slugs)],
452
+ beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
354
453
  afterChange: [
355
454
  createAutoUpdateStatus(slugs),
356
455
  createNotifyClient(slugs),
@@ -360,16 +459,23 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
360
459
  createNotifyAdminOnClientMessage(slugs, notificationSlug),
361
460
  createFireMessageWebhooks(slugs),
362
461
  createDispatchWebhookOnReply(slugs),
462
+ createNotifyMentions(slugs),
363
463
  ],
364
464
  },
365
465
  access: {
366
466
  create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
367
- read: ({ req }) => {
467
+ read: async ({ req }) => {
368
468
  if (req.user?.collection === slugs.users) return true
369
469
  if (req.user?.collection === slugs.supportClients) {
470
+ // Constrain to tickets the client owns or collaborates on, then filter
471
+ // messages by `ticket IN (...)`. We do NOT use a nested relationship
472
+ // filter ('ticket.client') as the security boundary — its behaviour
473
+ // through the SQLite adapter is not guaranteed. Empty set → sentinel
474
+ // id so the IN matches nothing (fail closed).
475
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id)
370
476
  return {
371
477
  and: [
372
- { 'ticket.client': { equals: req.user.id } } as Where,
478
+ { ticket: { in: ids.length > 0 ? ids : [-1] } } as Where,
373
479
  { isInternal: { equals: false } } as Where,
374
480
  { or: [{ scheduledAt: { exists: false } } as Where, { scheduledSent: { equals: true } } as Where] } as Where,
375
481
  ],
@@ -13,8 +13,11 @@ import { createAdminNotification } from '../utils/adminNotification'
13
13
  import { dispatchWebhook } from '../utils/webhookDispatcher'
14
14
  import { readSupportSettings } from '../utils/readSettings'
15
15
  import { createTicketStatusEmail } from '../hooks/ticketStatusEmail'
16
- import { createAssignSlaDeadlines, createCheckSlaOnResolve } from '../hooks/checkSLA'
16
+ import { createAssignSlaDeadlines, createCheckSlaOnResolve, createPauseSlaOnHold } from '../hooks/checkSLA'
17
+ import { createApplyAutomationRules } from '../hooks/automationRules'
18
+ import { isTeamScopingEnabled, resolveAgentTeamIds } from '../utils/teamAccess'
17
19
  import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
20
+ import { dbFind, dbCreate, dbDelete } from '../utils/db'
18
21
 
19
22
  // ─── Hooks ───────────────────────────────────────────────
20
23
 
@@ -314,64 +317,6 @@ function createNotifyClientOnResolve(slugs: CollectionSlugs): CollectionAfterCha
314
317
  }
315
318
  }
316
319
 
317
- function createAutoCalculateSLA(slugs: CollectionSlugs): CollectionAfterChangeHook {
318
- return async ({ doc, operation, req }) => {
319
- if (operation !== 'create') return doc
320
- try {
321
- const { payload } = req
322
- const ticketPriority = doc.priority || 'normal'
323
-
324
- // Try to find a SLA policy matching the ticket's priority first
325
- let policy: any = null
326
- const byPriority = await payload.find({
327
- collection: slugs.slaPolicies as any,
328
- where: { priority: { equals: ticketPriority } },
329
- limit: 1,
330
- depth: 0,
331
- overrideAccess: true,
332
- })
333
- if (byPriority.docs.length > 0) {
334
- policy = byPriority.docs[0]
335
- } else {
336
- // Fall back to the default policy
337
- const defaults = await payload.find({
338
- collection: slugs.slaPolicies as any,
339
- where: { isDefault: { equals: true } },
340
- limit: 1,
341
- depth: 0,
342
- overrideAccess: true,
343
- })
344
- if (defaults.docs.length > 0) {
345
- policy = defaults.docs[0]
346
- }
347
- }
348
-
349
- if (!policy) return doc
350
-
351
- // SLA fields are in minutes (firstResponseTime, resolutionTime)
352
- const now = new Date()
353
- const firstResponseMinutes = policy.firstResponseTime || 240 // default 4h
354
- const resolutionMinutes = policy.resolutionTime || 1440 // default 24h
355
- const firstResponseDue = new Date(now.getTime() + firstResponseMinutes * 60000)
356
- const resolutionDue = new Date(now.getTime() + resolutionMinutes * 60000)
357
-
358
- await payload.update({
359
- collection: slugs.tickets as any,
360
- id: doc.id,
361
- data: {
362
- slaPolicy: policy.id,
363
- slaFirstResponseDue: firstResponseDue.toISOString(),
364
- slaResolutionDue: resolutionDue.toISOString(),
365
- },
366
- overrideAccess: true,
367
- })
368
- } catch (err) {
369
- console.error('[support] Failed to auto-calculate SLA:', err)
370
- }
371
- return doc
372
- }
373
- }
374
-
375
320
  function createFireTicketWebhooks(slugs: CollectionSlugs): CollectionAfterChangeHook {
376
321
  return async ({ doc, previousDoc, operation, req }) => {
377
322
  const { payload } = req
@@ -478,8 +423,7 @@ function createCascadeDelete(slugs: CollectionSlugs): CollectionBeforeDeleteHook
478
423
  return async ({ id, req }) => {
479
424
  const collections = [slugs.ticketMessages, slugs.ticketActivityLog, slugs.timeEntries, slugs.satisfactionSurveys]
480
425
  for (const slug of collections) {
481
- await req.payload.delete({
482
- collection: slug as any,
426
+ await dbDelete(req.payload, slug, {
483
427
  where: { ticket: { equals: id } },
484
428
  overrideAccess: true,
485
429
  })
@@ -619,7 +563,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
619
563
  type: 'row',
620
564
  fields: [
621
565
  {
622
- name: 'status', type: 'select', defaultValue: 'open', label: 'Statut',
566
+ name: 'status', type: 'select', index: true, defaultValue: 'open', label: 'Statut',
623
567
  options: [
624
568
  { label: 'Ouvert', value: 'open' },
625
569
  { label: 'En attente client', value: 'waiting_client' },
@@ -630,7 +574,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
630
574
  admin: { width: '50%' },
631
575
  },
632
576
  {
633
- name: 'priority', type: 'select', defaultValue: 'normal', label: 'Priorite',
577
+ name: 'priority', type: 'select', index: true, defaultValue: 'normal', label: 'Priorite',
634
578
  options: [
635
579
  { label: 'Basse', value: 'low' },
636
580
  { label: 'Normale', value: 'normal' },
@@ -644,7 +588,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
644
588
  {
645
589
  type: 'row',
646
590
  fields: [
647
- { name: 'client', type: 'relationship', relationTo: slugs.supportClients, required: true, label: 'Client', admin: { width: '50%' } },
591
+ { name: 'client', type: 'relationship', relationTo: slugs.supportClients, required: true, index: true, label: 'Client', admin: { width: '50%' } },
648
592
  ],
649
593
  },
650
594
  // Billing collapsible
@@ -710,8 +654,8 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
710
654
  {
711
655
  type: 'row',
712
656
  fields: [
713
- { name: 'slaFirstResponseDue', type: 'date', label: 'Echeance 1ere reponse (SLA)', admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
714
- { name: 'slaResolutionDue', type: 'date', label: 'Echeance resolution (SLA)', admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
657
+ { name: 'slaFirstResponseDue', type: 'date', index: true, label: 'Echeance 1ere reponse (SLA)', admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
658
+ { name: 'slaResolutionDue', type: 'date', index: true, label: 'Echeance resolution (SLA)', admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
715
659
  ],
716
660
  },
717
661
  {
@@ -719,6 +663,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
719
663
  fields: [
720
664
  { name: 'slaFirstResponseBreached', type: 'checkbox', label: '1ere reponse depassee', admin: { readOnly: true, width: '50%' } },
721
665
  { name: 'slaResolutionBreached', type: 'checkbox', label: 'Resolution depassee', admin: { readOnly: true, width: '50%' } },
666
+ { name: 'slaPausedAt', type: 'date', label: 'SLA en pause depuis', admin: { hidden: true } },
722
667
  ],
723
668
  },
724
669
  ],
@@ -739,13 +684,14 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
739
684
  },
740
685
  { name: 'mergedInto', type: 'relationship', relationTo: slugs.tickets, label: 'Fusionne dans', admin: { readOnly: true } },
741
686
  { name: 'autoCloseRemindedAt', type: 'date', label: 'Rappel auto-close envoye', admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
687
+ { name: 'autoCloseScheduledAt', type: 'date', index: true, label: 'Fermeture auto programmee', admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' }, description: 'Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client.' } },
742
688
  ],
743
689
  },
744
690
  // Sidebar
745
691
  { name: 'ticketNumber', type: 'text', unique: true, label: 'N° Ticket', admin: { position: 'sidebar' } },
746
692
  { name: 'slaPolicy', type: 'relationship', relationTo: slugs.slaPolicies, label: 'Politique SLA', admin: { position: 'sidebar' } },
747
693
  {
748
- name: 'category', type: 'select', label: 'Categorie',
694
+ name: 'category', type: 'select', index: true, label: 'Categorie',
749
695
  options: [
750
696
  { label: 'Bug / Dysfonctionnement', value: 'bug' },
751
697
  { label: 'Modification de contenu', value: 'content' },
@@ -755,7 +701,8 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
755
701
  ],
756
702
  admin: { position: 'sidebar' },
757
703
  },
758
- { name: 'assignedTo', type: 'relationship', relationTo: slugs.users, label: 'Assigne a', admin: { position: 'sidebar' } },
704
+ { name: 'assignedTo', type: 'relationship', relationTo: slugs.users, index: true, label: 'Assigne a', admin: { position: 'sidebar' } },
705
+ { name: 'team', type: 'relationship', relationTo: slugs.supportTeams, index: true, label: 'Equipe', admin: { position: 'sidebar' } },
759
706
  {
760
707
  name: 'source', type: 'select', defaultValue: 'portal', label: 'Source',
761
708
  options: [
@@ -763,6 +710,8 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
763
710
  { label: 'Chat en direct', value: 'live-chat' },
764
711
  { label: 'Email', value: 'email' },
765
712
  { label: 'Admin', value: 'admin' },
713
+ { label: 'WhatsApp', value: 'whatsapp' },
714
+ { label: 'Messenger', value: 'messenger' },
766
715
  ],
767
716
  admin: { position: 'sidebar' },
768
717
  },
@@ -810,8 +759,8 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
810
759
  afterChange: [
811
760
  createTrackSLA(slugs),
812
761
  createTrackAiSummaryOnResolve(slugs),
813
- createAutoCalculateSLA(slugs),
814
762
  createAssignSlaDeadlines(slugs, notificationSlug),
763
+ createPauseSlaOnHold(slugs),
815
764
  createCheckSlaOnResolve(slugs, notificationSlug),
816
765
  createLogTicketActivity(slugs),
817
766
  createNotifyOnAssignment(slugs),
@@ -820,6 +769,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
820
769
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
821
770
  createFireTicketWebhooks(slugs),
822
771
  createDispatchWebhookOnTicket(slugs),
772
+ createApplyAutomationRules(slugs),
823
773
  ],
824
774
  beforeDelete: [createCascadeDelete(slugs)],
825
775
  },
@@ -830,15 +780,24 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
830
780
  return false
831
781
  },
832
782
  read: async ({ req }) => {
833
- if (req.user?.collection === slugs.users) return true
783
+ if (req.user?.collection === slugs.users) {
784
+ if (!isTeamScopingEnabled()) return true
785
+ // Team scoping (opt-in): agents see their teams' tickets + team-less ones.
786
+ const teamIds = await resolveAgentTeamIds(req.payload, slugs, req.user.id)
787
+ return {
788
+ or: [
789
+ { team: { in: teamIds.length > 0 ? teamIds : [-1] } },
790
+ { team: { exists: false } },
791
+ ],
792
+ } as Where
793
+ }
834
794
  if (req.user?.collection === slugs.supportClients) {
835
795
  // Owner OR collaborator (via ticket-collaborators table).
836
796
  // We resolve the collaborator ticket-ids upfront so we keep a single Where
837
797
  // expression — Payload's access fns support async returns.
838
798
  let collabTicketIds: Array<number | string> = []
839
799
  try {
840
- const rows = await req.payload.find({
841
- collection: 'ticket-collaborators' as any,
800
+ const rows = await dbFind(req.payload, 'ticket-collaborators', {
842
801
  where: { client: { equals: req.user.id } },
843
802
  limit: 1000,
844
803
  depth: 0,
@@ -17,3 +17,6 @@ export { createTicketStatusesCollection } from './TicketStatuses'
17
17
  export { createClientSummariesCollection } from './ClientSummaries'
18
18
  export { createTicketFeedbackCollection } from './TicketFeedback'
19
19
  export { createTicketCollaboratorsCollection } from './TicketCollaborators'
20
+ export { createNotificationQueueCollection } from './NotificationQueue'
21
+ export { createAutomationRulesCollection } from './AutomationRules'
22
+ export { createSupportTeamCollection } from './SupportTeam'
@@ -0,0 +1,86 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+ import { REWRITE_STYLES, s } from './constants'
5
+
6
+ export const RewriteDropdown: React.FC<{
7
+ disabled: boolean
8
+ loading: boolean
9
+ onSelect: (style: string) => void
10
+ }> = ({ disabled, loading, onSelect }) => {
11
+ const [open, setOpen] = useState(false)
12
+ const ref = React.useRef<HTMLDivElement>(null)
13
+
14
+ React.useEffect(() => {
15
+ if (!open) return
16
+ const close = (e: MouseEvent) => {
17
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
18
+ }
19
+ document.addEventListener('mousedown', close)
20
+ return () => document.removeEventListener('mousedown', close)
21
+ }, [open])
22
+
23
+ return (
24
+ <div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
25
+ <button
26
+ type="button"
27
+ onClick={() => setOpen(!open)}
28
+ disabled={disabled}
29
+ style={{
30
+ ...s.outlineBtn('#0891b2', disabled),
31
+ fontSize: '11px',
32
+ padding: '3px 10px',
33
+ borderRadius: '14px',
34
+ display: 'flex',
35
+ alignItems: 'center',
36
+ gap: '4px',
37
+ }}
38
+ >
39
+ {loading ? 'Reformulation...' : '✏️ Reformuler'}
40
+ {!loading && <span style={{ fontSize: 9, opacity: 0.7 }}>▼</span>}
41
+ </button>
42
+ {open && !disabled && !loading && (
43
+ <div
44
+ style={{
45
+ position: 'absolute',
46
+ bottom: '100%',
47
+ left: 0,
48
+ marginBottom: 4,
49
+ background: 'var(--theme-elevation-0, #fff)',
50
+ border: '1px solid var(--theme-elevation-200, #e5e7eb)',
51
+ borderRadius: 8,
52
+ boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
53
+ zIndex: 100,
54
+ minWidth: 180,
55
+ overflow: 'hidden',
56
+ }}
57
+ >
58
+ {REWRITE_STYLES.map((style) => (
59
+ <button
60
+ key={style.id}
61
+ type="button"
62
+ onClick={() => { setOpen(false); onSelect(style.id) }}
63
+ style={{
64
+ display: 'flex',
65
+ flexDirection: 'column',
66
+ width: '100%',
67
+ padding: '8px 12px',
68
+ border: 'none',
69
+ background: 'transparent',
70
+ cursor: 'pointer',
71
+ textAlign: 'left',
72
+ borderBottom: '1px solid var(--theme-elevation-100, #f3f4f6)',
73
+ transition: 'background 120ms',
74
+ }}
75
+ onMouseEnter={(e) => { (e.target as HTMLElement).style.background = 'var(--theme-elevation-50, #f9fafb)' }}
76
+ onMouseLeave={(e) => { (e.target as HTMLElement).style.background = 'transparent' }}
77
+ >
78
+ <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--theme-text, #111)' }}>{style.label}</span>
79
+ <span style={{ fontSize: 10, color: 'var(--theme-elevation-500, #6b7280)' }}>{style.desc}</span>
80
+ </button>
81
+ ))}
82
+ </div>
83
+ )}
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,23 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+
5
+ // Inline skeleton replacement (no external dependency)
6
+ export function SkeletonText({ lines = 3 }: { lines?: number }) {
7
+ return (
8
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '12px 0' }}>
9
+ {Array.from({ length: lines }).map((_, i) => (
10
+ <div
11
+ key={i}
12
+ style={{
13
+ height: '14px',
14
+ borderRadius: '4px',
15
+ backgroundColor: '#e2e8f0',
16
+ width: i === lines - 1 ? '60%' : '100%',
17
+ animation: 'pulse 1.5s ease-in-out infinite',
18
+ }}
19
+ />
20
+ ))}
21
+ </div>
22
+ )
23
+ }