@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
@@ -3,6 +3,8 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { handleAuthError, AuthError } from '../utils/auth'
4
4
  import { escapeHtml, emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
+ import { RateLimiter } from '../utils/rateLimiter'
7
+ import { dbFindByID, dbFind, dbCreate, dbCount } from '../utils/db'
6
8
 
7
9
  // Simple RFC-5322 style sanity check — same level of strictness as the
8
10
  // portal forms used elsewhere in the plugin. Server-side validation only;
@@ -11,6 +13,11 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
11
13
 
12
14
  const MAX_TRANSFERS_PER_DAY = 5
13
15
 
16
+ // In-memory fail-closed rate limiter — enforced even when the persistent
17
+ // emailLogs-based check is unavailable (collection disabled). Prevents the
18
+ // endpoint from becoming an unbounded outbound mailer.
19
+ const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_DAY)
20
+
14
21
  /**
15
22
  * POST /api/support/tickets/:id/transfer
16
23
  *
@@ -56,12 +63,11 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
56
63
  const payload = req.payload
57
64
 
58
65
  // Load ticket (ownership check happens here too)
59
- const ticket = await payload.findByID({
60
- collection: slugs.tickets as any,
66
+ const ticket = await dbFindByID(payload, slugs.tickets, {
61
67
  id: ticketId,
62
68
  depth: 1,
63
69
  overrideAccess: true,
64
- }) as any
70
+ })
65
71
  if (!ticket) return Response.json({ error: 'Ticket introuvable' }, { status: 404 })
66
72
 
67
73
  const isAdmin = req.user.collection === slugs.users
@@ -74,11 +80,19 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
74
80
  return Response.json({ error: 'Forbidden' }, { status: 403 })
75
81
  }
76
82
 
83
+ // Fail-closed in-memory rate limit (per user + ticket): enforced even
84
+ // when the persistent emailLogs check below cannot run.
85
+ if (transferLimiter.check(`${req.user.id}:${ticketId}`)) {
86
+ return Response.json(
87
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
88
+ { status: 429 },
89
+ )
90
+ }
91
+
77
92
  // Rate limit: count transfers in last 24h for this ticket
78
93
  try {
79
94
  const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
80
- const existing = await payload.count({
81
- collection: slugs.emailLogs as any,
95
+ const existing = await dbCount(payload, slugs.emailLogs, {
82
96
  where: {
83
97
  and: [
84
98
  { action: { equals: 'transfer' } },
@@ -99,8 +113,7 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
99
113
  }
100
114
 
101
115
  // Fetch non-internal messages, ordered chronologically
102
- const messages = await payload.find({
103
- collection: slugs.ticketMessages as any,
116
+ const messages = await dbFind(payload, slugs.ticketMessages, {
104
117
  where: {
105
118
  and: [
106
119
  { ticket: { equals: ticketId } },
@@ -206,8 +219,7 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
206
219
 
207
220
  // Log the transfer (best-effort)
208
221
  try {
209
- await payload.create({
210
- collection: slugs.emailLogs as any,
222
+ await dbCreate(payload, slugs.emailLogs, {
211
223
  data: {
212
224
  status: 'success',
213
225
  action: 'transfer',
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { dbFind } from '../utils/db'
4
5
 
5
6
  const PREF_KEY_PREFIX = 'support-user-prefs'
6
7
 
@@ -29,8 +30,7 @@ export function createUserPrefsGetEndpoint(slugs: CollectionSlugs): Endpoint {
29
30
 
30
31
  const key = `${PREF_KEY_PREFIX}-${req.user!.id}`
31
32
 
32
- const prefs = await payload.find({
33
- collection: 'payload-preferences' as any,
33
+ const prefs = await dbFind(payload, 'payload-preferences', {
34
34
  where: { key: { equals: key } },
35
35
  limit: 1,
36
36
  depth: 0,
@@ -74,8 +74,7 @@ export function createUserPrefsPostEndpoint(slugs: CollectionSlugs): Endpoint {
74
74
  const key = `${PREF_KEY_PREFIX}-${req.user!.id}`
75
75
 
76
76
  // Read existing prefs to merge
77
- const existing = await payload.find({
78
- collection: 'payload-preferences' as any,
77
+ const existing = await dbFind(payload, 'payload-preferences', {
79
78
  where: { key: { equals: key } },
80
79
  limit: 1,
81
80
  depth: 0,
@@ -0,0 +1,84 @@
1
+ import type { CollectionAfterChangeHook } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { dbFind } from '../utils/db'
4
+
5
+ interface Condition { field?: string; operator?: string; value?: string }
6
+ interface Action { type?: string; value?: string }
7
+ interface Rule { matchType?: string; conditions?: Condition[]; actions?: Action[] }
8
+
9
+ function evaluateConditions(doc: Record<string, unknown>, rule: Rule): boolean {
10
+ const conds = Array.isArray(rule.conditions) ? rule.conditions : []
11
+ if (conds.length === 0) return true
12
+ const results = conds.map((c) => {
13
+ const fieldVal = String(doc[c.field as string] ?? '')
14
+ const v = String(c.value ?? '')
15
+ switch (c.operator) {
16
+ case 'equals': return fieldVal === v
17
+ case 'not_equals': return fieldVal !== v
18
+ case 'contains': return fieldVal.toLowerCase().includes(v.toLowerCase())
19
+ default: return false
20
+ }
21
+ })
22
+ return rule.matchType === 'any' ? results.some(Boolean) : results.every(Boolean)
23
+ }
24
+
25
+ /**
26
+ * Evaluate enabled automation rules against a ticket and apply their actions.
27
+ * Runs as afterChange on the Tickets collection. All matched actions are applied
28
+ * in a SINGLE update flagged `skipAutomation` to avoid re-triggering (no loop).
29
+ */
30
+ export function createApplyAutomationRules(slugs: CollectionSlugs): CollectionAfterChangeHook {
31
+ return async ({ doc, previousDoc, operation, req }) => {
32
+ if (req.context?.skipAutomation) return doc
33
+
34
+ const events: string[] = []
35
+ if (operation === 'create') events.push('ticket_created')
36
+ if (operation === 'update') {
37
+ events.push('ticket_updated')
38
+ if (previousDoc && previousDoc.status !== doc.status) events.push('ticket_status_changed')
39
+ }
40
+ if (events.length === 0) return doc
41
+
42
+ try {
43
+ const rules = await dbFind<Rule & { actions?: Action[] }>(req.payload, slugs.automationRules, {
44
+ where: { and: [{ enabled: { equals: true } }, { event: { in: events } }] },
45
+ sort: 'order',
46
+ limit: 200,
47
+ depth: 0,
48
+ overrideAccess: true,
49
+ })
50
+
51
+ const changes: Record<string, unknown> = {}
52
+ const tags: string[] = Array.isArray(doc.tags) ? [...(doc.tags as string[])] : []
53
+ let tagsTouched = false
54
+
55
+ for (const rule of rules.docs) {
56
+ if (!evaluateConditions(doc as Record<string, unknown>, rule)) continue
57
+ for (const a of Array.isArray(rule.actions) ? rule.actions : []) {
58
+ if (!a.value) continue
59
+ switch (a.type) {
60
+ case 'set_status': changes.status = a.value; break
61
+ case 'set_priority': changes.priority = a.value; break
62
+ case 'set_category': changes.category = a.value; break
63
+ case 'assign': changes.assignedTo = a.value; break
64
+ case 'add_tag': if (!tags.includes(a.value)) { tags.push(a.value); tagsTouched = true } break
65
+ }
66
+ }
67
+ }
68
+
69
+ if (tagsTouched) changes.tags = tags
70
+ if (Object.keys(changes).length > 0) {
71
+ await req.payload.update({
72
+ collection: slugs.tickets,
73
+ id: doc.id,
74
+ data: changes,
75
+ overrideAccess: true,
76
+ context: { skipAutomation: true },
77
+ })
78
+ }
79
+ } catch (err) {
80
+ console.error('[support] Failed to apply automation rules:', err)
81
+ }
82
+ return doc
83
+ }
84
+ }
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { createAdminNotification } from '../utils/adminNotification'
4
4
  import { emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
+ import { dbFindByID, dbFind, dbUpdate } from '../utils/db'
6
7
 
7
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
9
  type AnyDoc = any
@@ -145,8 +146,7 @@ async function resolveSlaPolicy(
145
146
  if (typeof policyRef === 'object' && (policyRef as AnyDoc).firstResponseTime) {
146
147
  return policyRef
147
148
  }
148
- return await payload.findByID({
149
- collection: slugs.slaPolicies as any,
149
+ return await dbFindByID(payload, slugs.slaPolicies, {
150
150
  id: policyId as number,
151
151
  depth: 0,
152
152
  overrideAccess: true,
@@ -160,8 +160,7 @@ async function resolveSlaPolicy(
160
160
  // Look for a default policy matching this ticket's priority
161
161
  const priority = field(ticket, 'priority') || 'normal'
162
162
  try {
163
- const defaults = await payload.find({
164
- collection: slugs.slaPolicies as any,
163
+ const defaults = await dbFind(payload, slugs.slaPolicies, {
165
164
  where: {
166
165
  and: [
167
166
  { isDefault: { equals: true } },
@@ -218,8 +217,7 @@ async function handleSlaBreach(
218
217
  if (typeof policyRef === 'object' && (policyRef as AnyDoc).escalateOnBreach !== undefined) {
219
218
  policy = policyRef
220
219
  } else {
221
- policy = await payload.findByID({
222
- collection: slugs.slaPolicies as any,
220
+ policy = await dbFindByID(payload, slugs.slaPolicies, {
223
221
  id: policyId as number,
224
222
  depth: 0,
225
223
  overrideAccess: true,
@@ -240,8 +238,7 @@ async function handleSlaBreach(
240
238
 
241
239
  const escalateUser = typeof policy.escalateTo === 'object' && (policy.escalateTo as AnyDoc).email
242
240
  ? policy.escalateTo
243
- : await payload.findByID({
244
- collection: slugs.users as any,
241
+ : await dbFindByID(payload, slugs.users, {
245
242
  id: escalateToId as number,
246
243
  depth: 0,
247
244
  overrideAccess: true,
@@ -312,8 +309,7 @@ export function createAssignSlaDeadlines(slugs: CollectionSlugs, notificationSlu
312
309
  updateData.slaPolicy = policy.id
313
310
  }
314
311
 
315
- await payload.update({
316
- collection: slugs.tickets as any,
312
+ await dbUpdate(payload, slugs.tickets, {
317
313
  id: doc.id,
318
314
  data: updateData,
319
315
  overrideAccess: true,
@@ -346,8 +342,7 @@ export function createCheckSlaOnResolve(slugs: CollectionSlugs, notificationSlug
346
342
  const deadline = new Date(doc.slaResolutionDue as string)
347
343
  const breached = now > deadline
348
344
 
349
- await payload.update({
350
- collection: slugs.tickets as any,
345
+ await dbUpdate(payload, slugs.tickets, {
351
346
  id: doc.id,
352
347
  data: { slaResolutionBreached: breached },
353
348
  overrideAccess: true,
@@ -364,6 +359,45 @@ export function createCheckSlaOnResolve(slugs: CollectionSlugs, notificationSlug
364
359
  }
365
360
  }
366
361
 
362
+ /**
363
+ * SLA pause-on-hold: time spent in `waiting_client` should not count against the
364
+ * resolution SLA (the agent is waiting on the customer). Entering hold stamps
365
+ * `slaPausedAt`; leaving it pushes `slaResolutionDue` forward by the paused span.
366
+ */
367
+ export function createPauseSlaOnHold(slugs: CollectionSlugs): CollectionAfterChangeHook {
368
+ return async ({ doc, previousDoc, operation, req }) => {
369
+ if (operation !== 'update' || !previousDoc) return doc
370
+ if (previousDoc.status === doc.status) return doc
371
+ if (!doc.slaResolutionDue) return doc
372
+
373
+ const wasWaiting = previousDoc.status === 'waiting_client'
374
+ const isWaiting = doc.status === 'waiting_client'
375
+
376
+ try {
377
+ if (!wasWaiting && isWaiting && !doc.slaPausedAt && doc.status !== 'resolved') {
378
+ // Entering hold — stop the resolution clock.
379
+ await req.payload.update({
380
+ collection: slugs.tickets,
381
+ id: doc.id,
382
+ data: { slaPausedAt: new Date().toISOString() },
383
+ overrideAccess: true,
384
+ })
385
+ } else if (wasWaiting && !isWaiting && doc.slaPausedAt) {
386
+ // Leaving hold — extend the deadline by however long we were paused.
387
+ const pausedMs = Date.now() - new Date(doc.slaPausedAt as string).getTime()
388
+ const data: Record<string, unknown> = { slaPausedAt: null }
389
+ if (pausedMs > 0) {
390
+ data.slaResolutionDue = new Date(new Date(doc.slaResolutionDue as string).getTime() + pausedMs).toISOString()
391
+ }
392
+ await req.payload.update({ collection: slugs.tickets, id: doc.id, data, overrideAccess: true })
393
+ }
394
+ } catch (err) {
395
+ console.error('[sla] Failed to pause/resume SLA on hold:', err)
396
+ }
397
+ return doc
398
+ }
399
+ }
400
+
367
401
  /**
368
402
  * Factory: Check SLA breach on first admin response.
369
403
  * Runs as afterChange on ticket-messages collection.
@@ -377,8 +411,7 @@ export function createCheckSlaOnReply(slugs: CollectionSlugs, notificationSlug =
377
411
  const { payload } = req
378
412
  const ticketId = typeof doc.ticket === 'object' ? (doc.ticket as AnyDoc).id : doc.ticket
379
413
 
380
- const ticket = await payload.findByID({
381
- collection: slugs.tickets as any,
414
+ const ticket = await dbFindByID(payload, slugs.tickets, {
382
415
  id: ticketId as number,
383
416
  depth: 1,
384
417
  overrideAccess: true,
@@ -394,8 +427,7 @@ export function createCheckSlaOnReply(slugs: CollectionSlugs, notificationSlug =
394
427
  const deadline = new Date(ticket.slaFirstResponseDue as string)
395
428
  const breached = now > deadline
396
429
 
397
- await payload.update({
398
- collection: slugs.tickets as any,
430
+ await dbUpdate(payload, slugs.tickets, {
399
431
  id: ticketId as number,
400
432
  data: { slaFirstResponseBreached: breached },
401
433
  overrideAccess: true,
package/src/plugin.ts CHANGED
@@ -23,6 +23,9 @@ import {
23
23
  createClientSummariesCollection,
24
24
  createTicketFeedbackCollection,
25
25
  createTicketCollaboratorsCollection,
26
+ createNotificationQueueCollection,
27
+ createAutomationRulesCollection,
28
+ createSupportTeamCollection,
26
29
  } from './collections'
27
30
 
28
31
  function viewConfig(component: string, path: string): AdminViewConfig {
@@ -90,6 +93,9 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
90
93
  createKnowledgeBaseCollection(slugs),
91
94
  createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
92
95
  createTicketCollaboratorsCollection(slugs),
96
+ createNotificationQueueCollection(slugs),
97
+ createAutomationRulesCollection(slugs),
98
+ createSupportTeamCollection(slugs),
93
99
  ]
94
100
 
95
101
  // Auth logs (conditional)
@@ -2,69 +2,8 @@
2
2
 
3
3
  import React, { useState, useEffect, useRef, useCallback } from 'react'
4
4
  import { MessageCircle, Send, X, Minimize2, ArrowLeft } from 'lucide-react'
5
-
6
- // ─── Types ───────────────────────────────────────────────────
7
-
8
- interface ChatMessage {
9
- id: number | string
10
- body: string
11
- senderType: 'client' | 'agent' | 'system'
12
- createdAt: string
13
- }
14
-
15
- interface LiveChatSession {
16
- clientToken: string
17
- email: string
18
- name: string
19
- sessionId?: string
20
- }
21
-
22
- type Screen = 'closed' | 'identify' | 'chat' | 'rating'
23
-
24
- // ─── LocalStorage keys ──────────────────────────────────────
25
-
26
- const LS_KEY = 'support-livechat'
27
-
28
- function loadSession(): LiveChatSession | null {
29
- if (typeof window === 'undefined') return null
30
- try {
31
- const raw = localStorage.getItem(LS_KEY)
32
- if (!raw) return null
33
- const session = JSON.parse(raw) as LiveChatSession
34
-
35
- // Validate token expiry (24h) — extract timestamp from token
36
- if (session.clientToken) {
37
- const parts = session.clientToken.split('_')
38
- if (parts.length === 4) {
39
- const timestamp = parseInt(parts[2], 10)
40
- if (!isNaN(timestamp) && Date.now() - timestamp > 24 * 60 * 60 * 1000) {
41
- localStorage.removeItem(LS_KEY)
42
- return null
43
- }
44
- }
45
- }
46
-
47
- return session
48
- } catch {
49
- return null
50
- }
51
- }
52
-
53
- function saveSession(session: LiveChatSession): void {
54
- try {
55
- localStorage.setItem(LS_KEY, JSON.stringify(session))
56
- } catch {
57
- // localStorage full or disabled
58
- }
59
- }
60
-
61
- function clearSession(): void {
62
- try {
63
- localStorage.removeItem(LS_KEY)
64
- } catch {
65
- // Ignore
66
- }
67
- }
5
+ import type { ChatMessage, LiveChatSession, Screen } from './types'
6
+ import { loadSession, saveSession, clearSession } from './helpers'
68
7
 
69
8
  // ─── Component ───────────────────────────────────────────────
70
9
 
@@ -7,6 +7,7 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
7
7
  const router = useRouter()
8
8
  const [loading, setLoading] = useState(false)
9
9
  const [confirming, setConfirming] = useState(false)
10
+ const [error, setError] = useState('')
10
11
 
11
12
  const handleClose = async () => {
12
13
  if (!confirming) {
@@ -15,6 +16,7 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
15
16
  }
16
17
 
17
18
  setLoading(true)
19
+ setError('')
18
20
  try {
19
21
  const res = await fetch(`/api/tickets/${ticketId}`, {
20
22
  method: 'PATCH',
@@ -25,9 +27,12 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
25
27
 
26
28
  if (res.ok) {
27
29
  router.refresh()
30
+ } else {
31
+ setError('Impossible de fermer le ticket. Veuillez réessayer.')
28
32
  }
29
33
  } catch (err) {
30
34
  console.warn('[CloseTicketButton] Failed to close ticket:', err)
35
+ setError('Erreur de connexion. Veuillez réessayer.')
31
36
  } finally {
32
37
  setLoading(false)
33
38
  setConfirming(false)
@@ -35,6 +40,10 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
35
40
  }
36
41
 
37
42
  return (
43
+ <div className="flex flex-col items-end gap-1">
44
+ {error && (
45
+ <p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
46
+ )}
38
47
  <button
39
48
  onClick={handleClose}
40
49
  disabled={loading}
@@ -70,5 +79,6 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
70
79
  </>
71
80
  )}
72
81
  </button>
82
+ </div>
73
83
  )
74
84
  }
@@ -12,25 +12,33 @@ export function MarkSolutionButton({
12
12
  }) {
13
13
  const router = useRouter()
14
14
  const [loading, setLoading] = useState(false)
15
+ const [error, setError] = useState('')
15
16
 
16
17
  const handleToggle = async () => {
17
18
  setLoading(true)
19
+ setError('')
18
20
  try {
19
- await fetch(`/api/ticket-messages/${messageId}`, {
21
+ const res = await fetch(`/api/ticket-messages/${messageId}`, {
20
22
  method: 'PATCH',
21
23
  headers: { 'Content-Type': 'application/json' },
22
24
  credentials: 'include',
23
25
  body: JSON.stringify({ isSolution: !isSolution }),
24
26
  })
25
- router.refresh()
27
+ if (res.ok) {
28
+ router.refresh()
29
+ } else {
30
+ setError('Échec. Veuillez réessayer.')
31
+ }
26
32
  } catch (err) {
27
33
  console.warn('[MarkSolutionButton] Failed to toggle solution:', err)
34
+ setError('Erreur de connexion.')
28
35
  } finally {
29
36
  setLoading(false)
30
37
  }
31
38
  }
32
39
 
33
40
  return (
41
+ <div className="flex flex-col items-start gap-0.5">
34
42
  <button
35
43
  onClick={handleToggle}
36
44
  disabled={loading}
@@ -46,5 +54,9 @@ export function MarkSolutionButton({
46
54
  </svg>
47
55
  {isSolution ? 'Solution' : 'Marquer comme solution'}
48
56
  </button>
57
+ {error && (
58
+ <p className="text-[10px] font-medium text-red-500 dark:text-red-400">{error}</p>
59
+ )}
60
+ </div>
49
61
  )
50
62
  }
@@ -19,6 +19,7 @@ export function MessageActions({
19
19
  const [editText, setEditText] = useState(body)
20
20
  const [saving, setSaving] = useState(false)
21
21
  const [confirmDelete, setConfirmDelete] = useState(false)
22
+ const [error, setError] = useState('')
22
23
 
23
24
  const elapsed = Date.now() - new Date(createdAt).getTime()
24
25
  const canEdit = elapsed < EDIT_WINDOW_MS
@@ -33,6 +34,7 @@ export function MessageActions({
33
34
  return
34
35
  }
35
36
  setSaving(true)
37
+ setError('')
36
38
  try {
37
39
  const res = await fetch(`/api/ticket-messages/${messageId}`, {
38
40
  method: 'PATCH',
@@ -46,13 +48,18 @@ export function MessageActions({
46
48
  if (res.ok) {
47
49
  setEditing(false)
48
50
  router.refresh()
51
+ } else {
52
+ setError('Impossible de sauvegarder. Veuillez réessayer.')
49
53
  }
50
- } catch { /* ignore */ }
54
+ } catch {
55
+ setError('Erreur de connexion. Veuillez réessayer.')
56
+ }
51
57
  setSaving(false)
52
58
  }
53
59
 
54
60
  const handleDelete = async () => {
55
61
  setSaving(true)
62
+ setError('')
56
63
  try {
57
64
  const res = await fetch(`/api/ticket-messages/${messageId}`, {
58
65
  method: 'PATCH',
@@ -67,8 +74,12 @@ export function MessageActions({
67
74
  if (res.ok) {
68
75
  setConfirmDelete(false)
69
76
  router.refresh()
77
+ } else {
78
+ setError('Impossible de supprimer. Veuillez réessayer.')
70
79
  }
71
- } catch { /* ignore */ }
80
+ } catch {
81
+ setError('Erreur de connexion. Veuillez réessayer.')
82
+ }
72
83
  setSaving(false)
73
84
  }
74
85
 
@@ -90,33 +101,41 @@ export function MessageActions({
90
101
  {saving ? 'Enregistrement...' : 'Enregistrer'}
91
102
  </button>
92
103
  <button
93
- onClick={() => { setEditing(false); setEditText(body) }}
104
+ onClick={() => { setEditing(false); setEditText(body); setError('') }}
94
105
  className="rounded-md border border-slate-300 dark:border-slate-600 px-3 py-1 text-xs font-semibold text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800"
95
106
  >
96
107
  Annuler
97
108
  </button>
98
109
  </div>
110
+ {error && (
111
+ <p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
112
+ )}
99
113
  </div>
100
114
  )
101
115
  }
102
116
 
103
117
  if (confirmDelete) {
104
118
  return (
105
- <div className="mt-2 flex items-center gap-2">
106
- <span className="text-xs text-red-500">Supprimer ce message ?</span>
107
- <button
108
- onClick={handleDelete}
109
- disabled={saving}
110
- className="rounded-md bg-red-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50"
111
- >
112
- {saving ? '...' : 'Confirmer'}
113
- </button>
114
- <button
115
- onClick={() => setConfirmDelete(false)}
116
- className="rounded-md border border-slate-300 dark:border-slate-600 px-2.5 py-1 text-xs text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800"
117
- >
118
- Annuler
119
- </button>
119
+ <div className="mt-2 space-y-1.5">
120
+ <div className="flex items-center gap-2">
121
+ <span className="text-xs text-red-500">Supprimer ce message ?</span>
122
+ <button
123
+ onClick={handleDelete}
124
+ disabled={saving}
125
+ className="rounded-md bg-red-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50"
126
+ >
127
+ {saving ? '...' : 'Confirmer'}
128
+ </button>
129
+ <button
130
+ onClick={() => { setConfirmDelete(false); setError('') }}
131
+ className="rounded-md border border-slate-300 dark:border-slate-600 px-2.5 py-1 text-xs text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800"
132
+ >
133
+ Annuler
134
+ </button>
135
+ </div>
136
+ {error && (
137
+ <p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
138
+ )}
120
139
  </div>
121
140
  )
122
141
  }
@@ -7,6 +7,7 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
7
7
  const router = useRouter()
8
8
  const [loading, setLoading] = useState(false)
9
9
  const [confirming, setConfirming] = useState(false)
10
+ const [error, setError] = useState('')
10
11
 
11
12
  const handleReopen = async () => {
12
13
  if (!confirming) {
@@ -15,6 +16,7 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
15
16
  }
16
17
 
17
18
  setLoading(true)
19
+ setError('')
18
20
  try {
19
21
  const res = await fetch(`/api/tickets/${ticketId}`, {
20
22
  method: 'PATCH',
@@ -25,9 +27,12 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
25
27
 
26
28
  if (res.ok) {
27
29
  router.refresh()
30
+ } else {
31
+ setError('Impossible de rouvrir le ticket. Veuillez réessayer.')
28
32
  }
29
33
  } catch (err) {
30
34
  console.warn('[ReopenTicketButton] Failed to reopen ticket:', err)
35
+ setError('Erreur de connexion. Veuillez réessayer.')
31
36
  } finally {
32
37
  setLoading(false)
33
38
  setConfirming(false)
@@ -35,6 +40,10 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
35
40
  }
36
41
 
37
42
  return (
43
+ <div className="flex flex-col items-end gap-1">
44
+ {error && (
45
+ <p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
46
+ )}
38
47
  <button
39
48
  onClick={handleReopen}
40
49
  disabled={loading}
@@ -70,5 +79,6 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
70
79
  </>
71
80
  )}
72
81
  </button>
82
+ </div>
73
83
  )
74
84
  }