@consilioweb/payload-support 0.16.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 (193) 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/constants.cjs +27 -0
  7. package/dist/components/TicketConversation/constants.js +26 -1
  8. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  9. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  10. package/dist/components/TicketConversation/index.cjs +10 -147
  11. package/dist/components/TicketConversation/index.js +4 -141
  12. package/dist/index.cjs +1604 -671
  13. package/dist/index.d.cts +3 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +1602 -671
  16. package/dist/views/BillingView/client.cjs +40 -54
  17. package/dist/views/BillingView/client.js +40 -54
  18. package/dist/views/ChatView/client.cjs +4 -3
  19. package/dist/views/ChatView/client.js +4 -3
  20. package/dist/views/CrmView/client.cjs +7 -7
  21. package/dist/views/CrmView/client.js +7 -7
  22. package/dist/views/EmailTrackingView/client.cjs +6 -5
  23. package/dist/views/EmailTrackingView/client.js +6 -5
  24. package/dist/views/ImportConversationView/client.cjs +4 -4
  25. package/dist/views/ImportConversationView/client.js +4 -4
  26. package/dist/views/LogsView/client.cjs +3 -2
  27. package/dist/views/LogsView/client.js +3 -2
  28. package/dist/views/NewTicketView/client.cjs +1 -1
  29. package/dist/views/NewTicketView/client.js +1 -1
  30. package/dist/views/PendingEmailsView/client.cjs +7 -6
  31. package/dist/views/PendingEmailsView/client.js +7 -6
  32. package/dist/views/SupportDashboardView/client.cjs +19 -22
  33. package/dist/views/SupportDashboardView/client.js +19 -22
  34. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  35. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  36. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  37. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  38. package/dist/views/TicketDetailView/client.cjs +59 -190
  39. package/dist/views/TicketDetailView/client.js +52 -183
  40. package/dist/views/TicketDetailView/constants.cjs +20 -0
  41. package/dist/views/TicketDetailView/constants.js +16 -0
  42. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  43. package/dist/views/TicketDetailView/helpers.js +20 -0
  44. package/dist/views/TicketDetailView/types.cjs +2 -0
  45. package/dist/views/TicketDetailView/types.js +1 -0
  46. package/dist/views/TicketInboxView/client.cjs +14 -10
  47. package/dist/views/TicketInboxView/client.js +14 -10
  48. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  49. package/dist/views/TicketingSettingsView/client.js +92 -88
  50. package/dist/views/TimeDashboardView/client.cjs +2 -2
  51. package/dist/views/TimeDashboardView/client.js +2 -2
  52. package/dist/views/shared/StatusPill.cjs +23 -12
  53. package/dist/views/shared/StatusPill.js +23 -12
  54. package/dist/views/shared/dateLocale.cjs +5 -0
  55. package/dist/views/shared/dateLocale.js +3 -0
  56. package/package.json +23 -15
  57. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  58. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  59. package/src/__tests__/integration/auth.test.ts +43 -0
  60. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  61. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  62. package/src/__tests__/integration/channels.test.ts +52 -0
  63. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  64. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  65. package/src/__tests__/integration/digest.test.ts +48 -0
  66. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  67. package/src/__tests__/integration/isolation.test.ts +81 -0
  68. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  69. package/src/__tests__/integration/nps.test.ts +37 -0
  70. package/src/__tests__/integration/sanitization.test.ts +35 -0
  71. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  72. package/src/__tests__/integration/smoke.test.ts +26 -0
  73. package/src/__tests__/integration/snooze.test.ts +68 -0
  74. package/src/__tests__/integration/teams.test.ts +38 -0
  75. package/src/__tests__/rateLimiter.test.ts +89 -0
  76. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  77. package/src/__tests__/sla.test.ts +258 -0
  78. package/src/collections/AutomationRules.ts +66 -0
  79. package/src/collections/NotificationQueue.ts +30 -0
  80. package/src/collections/SatisfactionSurveys.ts +11 -2
  81. package/src/collections/SupportClients.ts +58 -1
  82. package/src/collections/SupportTeam.ts +27 -0
  83. package/src/collections/TicketActivityLog.ts +9 -4
  84. package/src/collections/TicketMessages.ts +129 -31
  85. package/src/collections/Tickets.ts +31 -73
  86. package/src/collections/index.ts +3 -0
  87. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  88. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  89. package/src/components/TicketConversation/constants.ts +29 -0
  90. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  91. package/src/components/TicketConversation/index.tsx +4 -152
  92. package/src/endpoints/admin-chat-stream.ts +3 -4
  93. package/src/endpoints/admin-chat.ts +11 -20
  94. package/src/endpoints/admin-stats.ts +70 -32
  95. package/src/endpoints/ai-agent.ts +36 -0
  96. package/src/endpoints/apply-macro.ts +8 -14
  97. package/src/endpoints/auth-2fa.ts +12 -12
  98. package/src/endpoints/auto-close.ts +6 -10
  99. package/src/endpoints/billing.ts +17 -4
  100. package/src/endpoints/bulk-action.ts +7 -12
  101. package/src/endpoints/channels.ts +89 -0
  102. package/src/endpoints/chat-stream.ts +3 -4
  103. package/src/endpoints/chat.ts +7 -12
  104. package/src/endpoints/chatbot.ts +2 -2
  105. package/src/endpoints/client-intelligence.ts +4 -4
  106. package/src/endpoints/delete-account.ts +9 -16
  107. package/src/endpoints/email-stats.ts +2 -2
  108. package/src/endpoints/escalate.ts +2 -2
  109. package/src/endpoints/export-csv.ts +2 -2
  110. package/src/endpoints/export-data.ts +5 -8
  111. package/src/endpoints/import-conversation.ts +6 -10
  112. package/src/endpoints/index.ts +18 -1
  113. package/src/endpoints/invite-collaborator.ts +30 -15
  114. package/src/endpoints/invoice.ts +127 -0
  115. package/src/endpoints/kb-search.ts +2 -2
  116. package/src/endpoints/login.ts +12 -4
  117. package/src/endpoints/merge-clients.ts +14 -19
  118. package/src/endpoints/merge-tickets.ts +11 -18
  119. package/src/endpoints/oauth-google.ts +86 -39
  120. package/src/endpoints/pending-emails-process.ts +15 -25
  121. package/src/endpoints/process-digests.ts +93 -0
  122. package/src/endpoints/process-scheduled.ts +6 -10
  123. package/src/endpoints/process-snooze.ts +78 -0
  124. package/src/endpoints/purge-logs.ts +2 -2
  125. package/src/endpoints/resend-notification.ts +3 -4
  126. package/src/endpoints/round-robin-config.ts +3 -4
  127. package/src/endpoints/satisfaction.ts +21 -14
  128. package/src/endpoints/search.ts +5 -8
  129. package/src/endpoints/seed-kb.ts +3 -4
  130. package/src/endpoints/send-reminder.ts +6 -9
  131. package/src/endpoints/settings.ts +5 -2
  132. package/src/endpoints/signature.ts +3 -4
  133. package/src/endpoints/sla-check.ts +2 -2
  134. package/src/endpoints/split-ticket.ts +7 -12
  135. package/src/endpoints/statuses.ts +2 -2
  136. package/src/endpoints/ticket-feedback.ts +4 -6
  137. package/src/endpoints/ticket-synthesis.ts +2 -2
  138. package/src/endpoints/track-open.ts +16 -28
  139. package/src/endpoints/transfer-ticket.ts +21 -9
  140. package/src/endpoints/user-prefs.ts +3 -4
  141. package/src/hooks/automationRules.ts +84 -0
  142. package/src/hooks/checkSLA.ts +48 -16
  143. package/src/plugin.ts +6 -0
  144. package/src/portal/LiveChat.tsx +2 -63
  145. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  146. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  147. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  148. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  149. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  150. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  151. package/src/portal/helpers.ts +46 -0
  152. package/src/portal/login/page.tsx +3 -8
  153. package/src/portal/types.ts +15 -0
  154. package/src/types/lucide-react.d.ts +34 -0
  155. package/src/types/payload-config.d.ts +10 -0
  156. package/src/utils/adminNotification.ts +2 -2
  157. package/src/utils/aiAgent.ts +147 -0
  158. package/src/utils/auth.ts +5 -4
  159. package/src/utils/channels.ts +36 -0
  160. package/src/utils/db.ts +73 -0
  161. package/src/utils/emailTemplate.ts +8 -1
  162. package/src/utils/fireWebhooks.ts +4 -6
  163. package/src/utils/generateTicketSynthesis.ts +7 -12
  164. package/src/utils/invoicePdf.ts +88 -0
  165. package/src/utils/notificationQueue.ts +36 -0
  166. package/src/utils/readSettings.ts +22 -6
  167. package/src/utils/sanitizeHtml.ts +57 -0
  168. package/src/utils/slugs.ts +6 -0
  169. package/src/utils/teamAccess.ts +27 -0
  170. package/src/utils/ticketAccess.ts +53 -0
  171. package/src/utils/webhookDispatcher.ts +4 -6
  172. package/src/views/BillingView/client.tsx +49 -37
  173. package/src/views/ChatView/client.tsx +4 -3
  174. package/src/views/CrmView/client.tsx +7 -7
  175. package/src/views/EmailTrackingView/client.tsx +7 -6
  176. package/src/views/ImportConversationView/client.tsx +4 -4
  177. package/src/views/LogsView/client.tsx +3 -2
  178. package/src/views/NewTicketView/client.tsx +1 -1
  179. package/src/views/PendingEmailsView/client.tsx +7 -6
  180. package/src/views/SupportDashboardView/client.tsx +21 -21
  181. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  182. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  183. package/src/views/TicketDetailView/client.tsx +54 -179
  184. package/src/views/TicketDetailView/constants.ts +23 -0
  185. package/src/views/TicketDetailView/helpers.ts +17 -0
  186. package/src/views/TicketDetailView/types.ts +10 -0
  187. package/src/views/TicketInboxView/client.tsx +16 -10
  188. package/src/views/TicketingSettingsView/client.tsx +90 -87
  189. package/src/views/TimeDashboardView/client.tsx +2 -2
  190. package/src/views/shared/StatusPill.tsx +24 -12
  191. package/src/views/shared/dateLocale.ts +5 -0
  192. package/src/views/shared/locales/en.json +708 -0
  193. 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 }) => {
@@ -90,30 +94,51 @@ function createNotifyClient(slugs: CollectionSlugs): CollectionAfterChangeHook {
90
94
 
91
95
  const replyPreview = (doc.body || '').replace(/\s+/g, ' ').trim().slice(0, 90)
92
96
 
93
- await req.payload.sendEmail({
94
- to: client.email,
95
- ...(supportEmail ? { replyTo: supportEmail } : {}),
96
- ...(internalBcc ? { bcc: internalBcc } : {}),
97
- subject: `Re: [${ticketNumber}] ${subject}`,
98
- html: emailWrapper(`Nouvelle reponse — ${ticketNumber}`, [
99
- emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,`),
100
- emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em>.`),
101
- rawContent,
102
- emailButton('Consulter le ticket', portalUrl),
103
- emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
104
- emailTrackingPixel(ticketId, doc.id),
105
- ].join(''), {
106
- kind: 'reply',
107
- preheader: replyPreview ? `${replyPreview}…` : `Nouvelle reponse a votre ticket ${ticketNumber}`,
108
- }),
109
- })
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
+ }
110
109
 
111
- await req.payload.update({
112
- collection: slugs.ticketMessages,
113
- id: doc.id,
114
- data: { emailSentAt: new Date().toISOString(), emailSentTo: client.email },
115
- overrideAccess: true,
116
- })
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
+ })()
117
142
  } catch (err) {
118
143
  console.error('[support] Failed to notify client:', err)
119
144
  }
@@ -161,8 +186,7 @@ function createSyncTicketReplyToChat(slugs: CollectionSlugs): CollectionAfterCha
161
186
  const clientId = typeof ticket.client === 'object' ? (ticket.client as { id: number | string }).id : ticket.client
162
187
 
163
188
  // Create a chat message so the client sees it in the widget
164
- await payload.create({
165
- collection: slugs.chatMessages as any,
189
+ await dbCreate(payload, slugs.chatMessages, {
166
190
  data: {
167
191
  session: ticket.chatSession,
168
192
  client: clientId,
@@ -302,6 +326,72 @@ function createDispatchWebhookOnReply(slugs: CollectionSlugs): CollectionAfterCh
302
326
  }
303
327
  }
304
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
+
305
395
  export function createTicketMessagesCollection(slugs: CollectionSlugs, options?: {
306
396
  notificationSlug?: string
307
397
  }): CollectionConfig {
@@ -312,14 +402,14 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
312
402
  labels: { singular: 'Message', plural: 'Messages' },
313
403
  admin: { hidden: true, group: 'Support', defaultColumns: ['ticket', 'authorType', 'createdAt'] },
314
404
  fields: [
315
- { 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' },
316
406
  { name: 'body', type: 'textarea', required: true, label: 'Message' },
317
407
  { name: 'bodyHtml', type: 'textarea', label: 'Message HTML', admin: { hidden: true } },
318
408
  {
319
409
  type: 'row',
320
410
  fields: [
321
411
  {
322
- name: 'authorType', type: 'select', label: 'Type d\'auteur', defaultValue: 'admin',
412
+ name: 'authorType', type: 'select', index: true, label: 'Type d\'auteur', defaultValue: 'admin',
323
413
  options: [
324
414
  { label: 'Client', value: 'client' },
325
415
  { label: 'Support', value: 'admin' },
@@ -348,8 +438,9 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
348
438
  },
349
439
  { name: 'isInternal', type: 'checkbox', defaultValue: false, label: 'Note interne', admin: { position: 'sidebar' } },
350
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 } },
351
442
  { name: 'skipNotification', type: 'checkbox', defaultValue: false, label: 'Sans notification', admin: { position: 'sidebar', condition: (data) => data?.skipNotification === true } },
352
- { 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 } },
353
444
  { name: 'scheduledSent', type: 'checkbox', defaultValue: false, admin: { hidden: true } },
354
445
  { name: 'editedAt', type: 'date', label: 'Modifie le', admin: { hidden: true } },
355
446
  { name: 'deletedAt', type: 'date', label: 'Supprime le', admin: { hidden: true } },
@@ -358,7 +449,7 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
358
449
  { name: 'emailOpenedAt', type: 'date', label: 'Email ouvert le', admin: { hidden: true } },
359
450
  ],
360
451
  hooks: {
361
- beforeChange: [createAssignAuthor(slugs)],
452
+ beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
362
453
  afterChange: [
363
454
  createAutoUpdateStatus(slugs),
364
455
  createNotifyClient(slugs),
@@ -368,16 +459,23 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
368
459
  createNotifyAdminOnClientMessage(slugs, notificationSlug),
369
460
  createFireMessageWebhooks(slugs),
370
461
  createDispatchWebhookOnReply(slugs),
462
+ createNotifyMentions(slugs),
371
463
  ],
372
464
  },
373
465
  access: {
374
466
  create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
375
- read: ({ req }) => {
467
+ read: async ({ req }) => {
376
468
  if (req.user?.collection === slugs.users) return true
377
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)
378
476
  return {
379
477
  and: [
380
- { 'ticket.client': { equals: req.user.id } } as Where,
478
+ { ticket: { in: ids.length > 0 ? ids : [-1] } } as Where,
381
479
  { isInternal: { equals: false } } as Where,
382
480
  { or: [{ scheduledAt: { exists: false } } as Where, { scheduledSent: { equals: true } } as Where] } as Where,
383
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,14 +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' } } },
742
- { name: 'autoCloseScheduledAt', type: 'date', 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.' } },
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.' } },
743
688
  ],
744
689
  },
745
690
  // Sidebar
746
691
  { name: 'ticketNumber', type: 'text', unique: true, label: 'N° Ticket', admin: { position: 'sidebar' } },
747
692
  { name: 'slaPolicy', type: 'relationship', relationTo: slugs.slaPolicies, label: 'Politique SLA', admin: { position: 'sidebar' } },
748
693
  {
749
- name: 'category', type: 'select', label: 'Categorie',
694
+ name: 'category', type: 'select', index: true, label: 'Categorie',
750
695
  options: [
751
696
  { label: 'Bug / Dysfonctionnement', value: 'bug' },
752
697
  { label: 'Modification de contenu', value: 'content' },
@@ -756,7 +701,8 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
756
701
  ],
757
702
  admin: { position: 'sidebar' },
758
703
  },
759
- { 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' } },
760
706
  {
761
707
  name: 'source', type: 'select', defaultValue: 'portal', label: 'Source',
762
708
  options: [
@@ -764,6 +710,8 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
764
710
  { label: 'Chat en direct', value: 'live-chat' },
765
711
  { label: 'Email', value: 'email' },
766
712
  { label: 'Admin', value: 'admin' },
713
+ { label: 'WhatsApp', value: 'whatsapp' },
714
+ { label: 'Messenger', value: 'messenger' },
767
715
  ],
768
716
  admin: { position: 'sidebar' },
769
717
  },
@@ -811,8 +759,8 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
811
759
  afterChange: [
812
760
  createTrackSLA(slugs),
813
761
  createTrackAiSummaryOnResolve(slugs),
814
- createAutoCalculateSLA(slugs),
815
762
  createAssignSlaDeadlines(slugs, notificationSlug),
763
+ createPauseSlaOnHold(slugs),
816
764
  createCheckSlaOnResolve(slugs, notificationSlug),
817
765
  createLogTicketActivity(slugs),
818
766
  createNotifyOnAssignment(slugs),
@@ -821,6 +769,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
821
769
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
822
770
  createFireTicketWebhooks(slugs),
823
771
  createDispatchWebhookOnTicket(slugs),
772
+ createApplyAutomationRules(slugs),
824
773
  ],
825
774
  beforeDelete: [createCascadeDelete(slugs)],
826
775
  },
@@ -831,15 +780,24 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
831
780
  return false
832
781
  },
833
782
  read: async ({ req }) => {
834
- 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
+ }
835
794
  if (req.user?.collection === slugs.supportClients) {
836
795
  // Owner OR collaborator (via ticket-collaborators table).
837
796
  // We resolve the collaborator ticket-ids upfront so we keep a single Where
838
797
  // expression — Payload's access fns support async returns.
839
798
  let collabTicketIds: Array<number | string> = []
840
799
  try {
841
- const rows = await req.payload.find({
842
- collection: 'ticket-collaborators' as any,
800
+ const rows = await dbFind(req.payload, 'ticket-collaborators', {
843
801
  where: { client: { equals: req.user.id } },
844
802
  limit: 1000,
845
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
+ }
@@ -1,5 +1,34 @@
1
1
  import type React from 'react'
2
2
 
3
+ // ── Rewrite style dropdown options ──────────────────────────────────────
4
+ export const REWRITE_STYLES = [
5
+ { id: 'auto', label: '✏️ Auto', desc: 'Garde le ton actuel' },
6
+ { id: 'tutoyer', label: '👋 Tutoyer', desc: 'Passe en tu' },
7
+ { id: 'vouvoyer', label: '🎩 Vouvoyer', desc: 'Passe en vous' },
8
+ { id: 'formel', label: '💼 Formel', desc: 'Ton professionnel' },
9
+ { id: 'amical', label: '😊 Amical', desc: 'Ton chaleureux' },
10
+ { id: 'court', label: '⚡ Court', desc: 'Version concise' },
11
+ ]
12
+
13
+ // Inline layout styles
14
+ export const layoutStyles = {
15
+ root: { padding: '12px 0' } as React.CSSProperties,
16
+ twoColumns: {
17
+ display: 'grid',
18
+ gridTemplateColumns: '1fr 320px',
19
+ gap: '16px',
20
+ alignItems: 'start',
21
+ } as React.CSSProperties,
22
+ mainColumn: { minWidth: 0 } as React.CSSProperties,
23
+ sideColumn: {
24
+ display: 'flex',
25
+ flexDirection: 'column' as const,
26
+ gap: '12px',
27
+ position: 'sticky' as const,
28
+ top: '80px',
29
+ } as React.CSSProperties,
30
+ }
31
+
3
32
  export const statusLabels: Record<string, { label: string; bg: string; color: string }> = {
4
33
  open: { label: 'Ouvert', bg: '#dbeafe', color: '#1e40af' },
5
34
  waiting_client: { label: 'Attente', bg: '#fef3c7', color: '#92400e' },
@@ -0,0 +1,21 @@
1
+ import { useState, useEffect } from 'react'
2
+
3
+ // Replacement for @payloadcms/ui's useDocumentInfo — avoids importing
4
+ // @payloadcms/ui (which has chunk splits that break webpack resolution
5
+ // when this bundle is consumed from node_modules).
6
+ // Extracts the document id from the admin URL pattern:
7
+ // /admin/collections/:slug/:id or /admin/globals/:slug
8
+ export function useDocumentIdFromUrl(): { id: string | number | undefined } {
9
+ const [id, setId] = useState<string | number | undefined>(undefined)
10
+ useEffect(() => {
11
+ const match = window.location.pathname.match(
12
+ /\/admin\/collections\/[^/]+\/([^/?#]+)/
13
+ )
14
+ if (match && match[1] !== 'create') {
15
+ const raw = match[1]
16
+ const num = Number(raw)
17
+ setId(Number.isFinite(num) && String(num) === raw ? num : raw)
18
+ }
19
+ }, [])
20
+ return { id }
21
+ }