@consilioweb/payload-support 2.0.0 → 3.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 (222) hide show
  1. package/README.md +656 -355
  2. package/dist/components/RichTextEditor/index.d.ts +19 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.d.ts +6 -0
  4. package/dist/components/TicketConversation/SkeletonText.d.ts +4 -0
  5. package/dist/components/TicketConversation/components/AISummaryPanel.d.ts +14 -0
  6. package/dist/components/TicketConversation/components/ActionPanels.d.ts +47 -0
  7. package/dist/components/TicketConversation/components/ActivityLog.d.ts +5 -0
  8. package/dist/components/TicketConversation/components/ClientBar.d.ts +5 -0
  9. package/dist/components/TicketConversation/components/ClientHistory.d.ts +24 -0
  10. package/dist/components/TicketConversation/components/CodeBlock.d.ts +26 -0
  11. package/dist/components/TicketConversation/components/CodeBlockInserter.d.ts +12 -0
  12. package/dist/components/TicketConversation/components/QuickActions.d.ts +32 -0
  13. package/dist/components/TicketConversation/components/TicketHeader.d.ts +19 -0
  14. package/dist/components/TicketConversation/components/TimeTrackingPanel.d.ts +24 -0
  15. package/dist/components/TicketConversation/config.d.ts +10 -0
  16. package/dist/components/TicketConversation/config.js +1 -40
  17. package/dist/components/TicketConversation/constants.d.ts +56 -0
  18. package/dist/components/TicketConversation/context.d.ts +53 -0
  19. package/dist/components/TicketConversation/hooks/useAI.d.ts +21 -0
  20. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.d.ts +3 -0
  21. package/dist/components/TicketConversation/hooks/useFeatures.d.ts +23 -0
  22. package/dist/components/TicketConversation/hooks/useFeatures.js +61 -0
  23. package/dist/components/TicketConversation/hooks/useMessageActions.d.ts +20 -0
  24. package/dist/components/TicketConversation/hooks/useReply.d.ts +27 -0
  25. package/dist/components/TicketConversation/hooks/useTicketActions.d.ts +58 -0
  26. package/dist/components/TicketConversation/hooks/useTimeTracking.d.ts +18 -0
  27. package/dist/components/TicketConversation/hooks/useTranslation.d.ts +33 -0
  28. package/dist/components/TicketConversation/hooks/useTranslation.js +22 -3
  29. package/dist/components/TicketConversation/index.d.ts +4 -0
  30. package/dist/components/TicketConversation/index.js +2 -2
  31. package/dist/components/TicketConversation/types.d.ts +56 -0
  32. package/dist/components/TicketConversation/utils.d.ts +2 -0
  33. package/dist/index.cjs +392 -320
  34. package/dist/index.d.cts +100 -3
  35. package/dist/index.d.ts +100 -3
  36. package/dist/index.js +388 -321
  37. package/dist/utils/features.d.ts +123 -0
  38. package/dist/utils/features.js +147 -0
  39. package/dist/views/BillingView/client.d.ts +2 -0
  40. package/dist/views/BillingView/index.d.ts +4 -0
  41. package/dist/views/ChatView/client.d.ts +2 -0
  42. package/dist/views/ChatView/client.js +9 -9
  43. package/dist/views/ChatView/index.d.ts +4 -0
  44. package/dist/views/CrmView/client.d.ts +2 -0
  45. package/dist/views/CrmView/client.js +6 -9
  46. package/dist/views/CrmView/index.d.ts +4 -0
  47. package/dist/views/EmailTrackingView/client.d.ts +2 -0
  48. package/dist/views/EmailTrackingView/index.d.ts +4 -0
  49. package/dist/views/ImportConversationView/client.d.ts +2 -0
  50. package/dist/views/ImportConversationView/index.d.ts +4 -0
  51. package/dist/views/LogsView/client.d.ts +2 -0
  52. package/dist/views/LogsView/index.d.ts +4 -0
  53. package/dist/views/NewTicketView/client.d.ts +2 -0
  54. package/dist/views/NewTicketView/index.d.ts +4 -0
  55. package/dist/views/PendingEmailsView/client.d.ts +2 -0
  56. package/dist/views/PendingEmailsView/index.d.ts +4 -0
  57. package/dist/views/SupportDashboardView/client.d.ts +2 -0
  58. package/dist/views/SupportDashboardView/client.js +1 -1
  59. package/dist/views/SupportDashboardView/index.d.ts +4 -0
  60. package/dist/views/TicketDetailView/NextActionItem.d.ts +6 -0
  61. package/dist/views/TicketDetailView/RewriteDropdown.d.ts +7 -0
  62. package/dist/views/TicketDetailView/client.d.ts +2 -0
  63. package/dist/views/TicketDetailView/client.js +2 -2
  64. package/dist/views/TicketDetailView/constants.d.ts +13 -0
  65. package/dist/views/TicketDetailView/helpers.d.ts +2 -0
  66. package/dist/views/TicketDetailView/index.d.ts +4 -0
  67. package/dist/views/TicketDetailView/types.d.ts +40 -0
  68. package/dist/views/TicketInboxView/client.d.ts +2 -0
  69. package/dist/views/TicketInboxView/client.js +5 -5
  70. package/dist/views/TicketInboxView/index.d.ts +4 -0
  71. package/dist/views/TicketingSettingsView/client.d.ts +2 -0
  72. package/dist/views/TicketingSettingsView/client.js +6 -5
  73. package/dist/views/TicketingSettingsView/index.d.ts +4 -0
  74. package/dist/views/TimeDashboardView/client.d.ts +2 -0
  75. package/dist/views/TimeDashboardView/index.d.ts +4 -0
  76. package/dist/views/shared/AdminViewHeader.d.ts +13 -0
  77. package/dist/views/shared/ErrorBoundary.d.ts +17 -0
  78. package/dist/views/shared/Skeleton.d.ts +21 -0
  79. package/dist/views/shared/StatusPill.d.ts +8 -0
  80. package/dist/views/shared/adminTokens.d.ts +19 -0
  81. package/dist/views/shared/config.d.ts +8 -0
  82. package/dist/views/shared/config.js +1 -40
  83. package/dist/views/shared/dateLocale.d.ts +5 -0
  84. package/dist/views/shared/index.d.ts +12 -0
  85. package/dist/views/shared/index.js +2 -1
  86. package/dist/views/shared/locales/en.json +708 -0
  87. package/dist/views/shared/locales/fr.json +708 -0
  88. package/dist/views/shared/sla.d.ts +14 -0
  89. package/dist/views.d.ts +13 -13
  90. package/package.json +26 -41
  91. package/src/collections/SupportClients.ts +29 -0
  92. package/src/collections/TicketCollaborators.ts +6 -1
  93. package/src/collections/TicketMessages.ts +46 -24
  94. package/src/collections/Tickets.ts +117 -79
  95. package/src/collections/TimeEntries.ts +57 -32
  96. package/src/components/TicketConversation/config.ts +16 -80
  97. package/src/components/TicketConversation/hooks/useFeatures.ts +109 -0
  98. package/src/components/TicketConversation/hooks/useTranslation.ts +38 -4
  99. package/src/components/TicketConversation/index.tsx +2 -2
  100. package/src/endpoints/ai.ts +2 -1
  101. package/src/endpoints/client-intelligence.ts +2 -1
  102. package/src/endpoints/process-scheduled.ts +13 -8
  103. package/src/endpoints/round-robin-config.ts +30 -20
  104. package/src/endpoints/settings.ts +28 -77
  105. package/src/index.ts +6 -2
  106. package/src/portal/LiveChat.tsx +33 -22
  107. package/src/portal/auth/tickets/detail/TicketPolling.tsx +5 -1
  108. package/src/types/lucide-react.d.ts +10 -2
  109. package/src/utils/aiAgent.ts +2 -1
  110. package/src/utils/aiProvider.ts +21 -0
  111. package/src/utils/features.ts +297 -0
  112. package/src/utils/fireWebhooks.ts +6 -0
  113. package/src/utils/generateTicketSynthesis.ts +2 -1
  114. package/src/utils/index.ts +4 -2
  115. package/src/utils/readSettings.ts +82 -9
  116. package/src/utils/webhookDispatcher.ts +19 -7
  117. package/src/views/ChatView/client.tsx +13 -9
  118. package/src/views/CrmView/client.tsx +10 -11
  119. package/src/views/SupportDashboardView/client.tsx +1 -1
  120. package/src/views/TicketDetailView/client.tsx +2 -2
  121. package/src/views/TicketInboxView/client.tsx +10 -5
  122. package/src/views/TicketingSettingsView/client.tsx +9 -7
  123. package/src/views/shared/config.ts +14 -80
  124. package/src/views/shared/index.ts +3 -1
  125. package/src/views/shared/locales/en.json +2 -2
  126. package/src/views/shared/locales/fr.json +2 -2
  127. package/dist/components/RichTextEditor/index.cjs +0 -279
  128. package/dist/components/TicketConversation/RewriteDropdown.cjs +0 -98
  129. package/dist/components/TicketConversation/SkeletonText.cjs +0 -21
  130. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +0 -85
  131. package/dist/components/TicketConversation/components/ActionPanels.cjs +0 -193
  132. package/dist/components/TicketConversation/components/ActivityLog.cjs +0 -24
  133. package/dist/components/TicketConversation/components/ClientBar.cjs +0 -43
  134. package/dist/components/TicketConversation/components/ClientHistory.cjs +0 -138
  135. package/dist/components/TicketConversation/components/CodeBlock.cjs +0 -171
  136. package/dist/components/TicketConversation/components/CodeBlockInserter.cjs +0 -156
  137. package/dist/components/TicketConversation/components/QuickActions.cjs +0 -87
  138. package/dist/components/TicketConversation/components/TicketHeader.cjs +0 -93
  139. package/dist/components/TicketConversation/components/TimeTrackingPanel.cjs +0 -134
  140. package/dist/components/TicketConversation/config.cjs +0 -44
  141. package/dist/components/TicketConversation/constants.cjs +0 -127
  142. package/dist/components/TicketConversation/context.cjs +0 -13
  143. package/dist/components/TicketConversation/hooks/useAI.cjs +0 -196
  144. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +0 -20
  145. package/dist/components/TicketConversation/hooks/useMessageActions.cjs +0 -136
  146. package/dist/components/TicketConversation/hooks/useReply.cjs +0 -188
  147. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +0 -290
  148. package/dist/components/TicketConversation/hooks/useTimeTracking.cjs +0 -123
  149. package/dist/components/TicketConversation/hooks/useTranslation.cjs +0 -78
  150. package/dist/components/TicketConversation/index.cjs +0 -1114
  151. package/dist/components/TicketConversation/types.cjs +0 -2
  152. package/dist/components/TicketConversation/utils.cjs +0 -27
  153. package/dist/views/BillingView/client.cjs +0 -469
  154. package/dist/views/BillingView/index.cjs +0 -34
  155. package/dist/views/ChatView/client.cjs +0 -301
  156. package/dist/views/ChatView/index.cjs +0 -34
  157. package/dist/views/CrmView/client.cjs +0 -458
  158. package/dist/views/CrmView/index.cjs +0 -34
  159. package/dist/views/EmailTrackingView/client.cjs +0 -171
  160. package/dist/views/EmailTrackingView/index.cjs +0 -34
  161. package/dist/views/ImportConversationView/client.cjs +0 -238
  162. package/dist/views/ImportConversationView/index.cjs +0 -34
  163. package/dist/views/LogsView/client.cjs +0 -148
  164. package/dist/views/LogsView/index.cjs +0 -32
  165. package/dist/views/NewTicketView/client.cjs +0 -213
  166. package/dist/views/NewTicketView/index.cjs +0 -32
  167. package/dist/views/PendingEmailsView/client.cjs +0 -471
  168. package/dist/views/PendingEmailsView/index.cjs +0 -34
  169. package/dist/views/SupportDashboardView/client.cjs +0 -437
  170. package/dist/views/SupportDashboardView/index.cjs +0 -34
  171. package/dist/views/TicketDetailView/NextActionItem.cjs +0 -40
  172. package/dist/views/TicketDetailView/RewriteDropdown.cjs +0 -80
  173. package/dist/views/TicketDetailView/client.cjs +0 -1412
  174. package/dist/views/TicketDetailView/constants.cjs +0 -20
  175. package/dist/views/TicketDetailView/helpers.cjs +0 -23
  176. package/dist/views/TicketDetailView/index.cjs +0 -34
  177. package/dist/views/TicketDetailView/types.cjs +0 -2
  178. package/dist/views/TicketInboxView/client.cjs +0 -359
  179. package/dist/views/TicketInboxView/index.cjs +0 -32
  180. package/dist/views/TicketingSettingsView/client.cjs +0 -740
  181. package/dist/views/TicketingSettingsView/index.cjs +0 -34
  182. package/dist/views/TimeDashboardView/client.cjs +0 -180
  183. package/dist/views/TimeDashboardView/index.cjs +0 -34
  184. package/dist/views/shared/AdminViewHeader.cjs +0 -68
  185. package/dist/views/shared/ErrorBoundary.cjs +0 -55
  186. package/dist/views/shared/Skeleton.cjs +0 -77
  187. package/dist/views/shared/StatusPill.cjs +0 -47
  188. package/dist/views/shared/adminTokens.cjs +0 -34
  189. package/dist/views/shared/config.cjs +0 -44
  190. package/dist/views/shared/dateLocale.cjs +0 -5
  191. package/dist/views/shared/index.cjs +0 -72
  192. package/dist/views/shared/sla.cjs +0 -37
  193. package/dist/views.cjs +0 -86
  194. package/dist/views.d.cts +0 -13
  195. package/src/__tests__/aiSummaryRendering.test.ts +0 -21
  196. package/src/__tests__/authResponses.test.ts +0 -69
  197. package/src/__tests__/capabilities.test.ts +0 -71
  198. package/src/__tests__/generateTrackingToken.test.ts +0 -67
  199. package/src/__tests__/integration/ai-agent.test.ts +0 -46
  200. package/src/__tests__/integration/auth.test.ts +0 -43
  201. package/src/__tests__/integration/automation-rules.test.ts +0 -68
  202. package/src/__tests__/integration/buildTestPayload.ts +0 -52
  203. package/src/__tests__/integration/channels.test.ts +0 -52
  204. package/src/__tests__/integration/core-behaviors.test.ts +0 -81
  205. package/src/__tests__/integration/dashboard-volume.test.ts +0 -31
  206. package/src/__tests__/integration/digest.test.ts +0 -48
  207. package/src/__tests__/integration/invoice-pdf.test.ts +0 -30
  208. package/src/__tests__/integration/isolation.test.ts +0 -81
  209. package/src/__tests__/integration/mentions-invoice.test.ts +0 -50
  210. package/src/__tests__/integration/nps.test.ts +0 -37
  211. package/src/__tests__/integration/per-team-sla.test.ts +0 -44
  212. package/src/__tests__/integration/push.test.ts +0 -75
  213. package/src/__tests__/integration/sanitization.test.ts +0 -35
  214. package/src/__tests__/integration/sla-pause.test.ts +0 -39
  215. package/src/__tests__/integration/smoke.test.ts +0 -26
  216. package/src/__tests__/integration/snooze.test.ts +0 -68
  217. package/src/__tests__/integration/teams.test.ts +0 -38
  218. package/src/__tests__/rateLimiter.test.ts +0 -83
  219. package/src/__tests__/sanitizeHtml.test.ts +0 -165
  220. package/src/__tests__/sla.test.ts +0 -258
  221. package/src/__tests__/trackOpenEndpoint.test.ts +0 -54
  222. package/src/__tests__/webhookSecurity.test.ts +0 -58
@@ -1,14 +1,25 @@
1
1
  import type { Payload } from 'payload'
2
2
  import { dbFind } from './db'
3
+ import {
4
+ DEFAULT_TICKETING_FEATURES,
5
+ normalizeFeatures,
6
+ projectAutoClose,
7
+ type TicketingFeatures,
8
+ } from './features'
3
9
 
4
- const PREF_KEY = 'support-settings'
10
+ export const SUPPORT_SETTINGS_PREF_KEY = 'support-settings'
11
+ const PREF_KEY = SUPPORT_SETTINGS_PREF_KEY
5
12
  const USER_PREFS_KEY_PREFIX = 'support-user-prefs'
13
+ /** Pre-2.1 standalone round-robin row — read as a fallback, then folded into `features`. */
14
+ const LEGACY_ROUND_ROBIN_KEY = 'support-round-robin'
6
15
 
7
16
  export interface SupportSettings {
8
17
  email: { fromAddress: string; fromName: string; replyToAddress: string }
9
18
  ai: { provider: string; model: string; enableSentiment: boolean; enableSynthesis: boolean; enableSuggestion: boolean; enableRewrite: boolean }
10
19
  sla: { firstResponseMinutes: number; resolutionMinutes: number; businessHoursOnly: boolean; escalationEmail: string }
11
20
  autoClose: { enabled: boolean; daysBeforeClose: number; reminderDaysBefore: number }
21
+ /** Ticketing feature flags — server-authoritative since 2.1 (used to be per-browser localStorage). */
22
+ features: TicketingFeatures
12
23
  }
13
24
 
14
25
  export interface UserPrefs {
@@ -21,6 +32,7 @@ export const DEFAULT_SETTINGS: SupportSettings = {
21
32
  ai: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
22
33
  sla: { firstResponseMinutes: 120, resolutionMinutes: 1440, businessHoursOnly: true, escalationEmail: '' },
23
34
  autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 },
35
+ features: { ...DEFAULT_TICKETING_FEATURES },
24
36
  }
25
37
 
26
38
  export const DEFAULT_USER_PREFS: UserPrefs = {
@@ -32,38 +44,99 @@ export const DEFAULT_USER_PREFS: UserPrefs = {
32
44
  // (the afterChange hook chain). The settings doc changes rarely, so a short TTL
33
45
  // + explicit invalidation on save (endpoints/settings.ts) avoids redundant DB
34
46
  // reads of the same `payload-preferences` row.
35
- let settingsCache: { value: SupportSettings; ts: number } | null = null
47
+ let settingsCache: { value: SupportSettingsState; ts: number } | null = null
36
48
  const SETTINGS_TTL_MS = 60_000
37
49
 
50
+ export interface SupportSettingsState {
51
+ settings: SupportSettings
52
+ /**
53
+ * False when the preference row carries no `features` object yet — a
54
+ * pre-2.1 install, or a fresh one. Clients use it to decide whether their
55
+ * legacy `localStorage` flags should seed the server.
56
+ */
57
+ featuresConfigured: boolean
58
+ }
59
+
38
60
  /** Invalidate the settings cache — call right after writing support settings. */
39
61
  export function invalidateSupportSettingsCache(): void {
40
62
  settingsCache = null
41
63
  }
42
64
 
43
- export async function readSupportSettings(payload: Payload): Promise<SupportSettings> {
65
+ /**
66
+ * Merge a stored preference value onto the defaults.
67
+ * Exported for the settings endpoint, which must not re-implement the merge.
68
+ */
69
+ export function mergeSupportSettings(
70
+ stored: Partial<SupportSettings> | undefined | null,
71
+ base: SupportSettings = DEFAULT_SETTINGS,
72
+ ): SupportSettings {
73
+ const autoClose = { ...base.autoClose, ...stored?.autoClose }
74
+ return {
75
+ email: { ...base.email, ...stored?.email },
76
+ ai: { ...base.ai, ...stored?.ai },
77
+ sla: { ...base.sla, ...stored?.sla },
78
+ autoClose,
79
+ // `autoClose` / `autoCloseDays` are projections of the block above, so they
80
+ // are recomputed here rather than trusted from whatever was persisted.
81
+ features: projectAutoClose(
82
+ normalizeFeatures({ ...base.features, ...(stored?.features as object | undefined) }),
83
+ autoClose,
84
+ ),
85
+ }
86
+ }
87
+
88
+ export async function readSupportSettingsState(payload: Payload): Promise<SupportSettingsState> {
44
89
  if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
45
90
  return settingsCache.value
46
91
  }
47
- let value: SupportSettings = { ...DEFAULT_SETTINGS }
92
+ let value: SupportSettingsState = {
93
+ settings: mergeSupportSettings(null),
94
+ featuresConfigured: false,
95
+ }
48
96
  try {
49
97
  const prefs = await dbFind(payload, 'payload-preferences', {
50
98
  where: { key: { equals: PREF_KEY } },
99
+ // The upsert is scoped per admin user, so several rows can share the key.
100
+ // Sorting makes "last write wins" deterministic instead of arbitrary.
101
+ sort: '-updatedAt',
51
102
  limit: 1, depth: 0, overrideAccess: true,
52
103
  })
53
104
  if (prefs.docs.length > 0) {
54
105
  const stored = prefs.docs[0].value as Partial<SupportSettings>
55
- value = {
56
- email: { ...DEFAULT_SETTINGS.email, ...stored.email },
57
- ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
58
- sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
59
- autoClose: { ...DEFAULT_SETTINGS.autoClose, ...stored.autoClose },
106
+ const featuresConfigured = !!stored.features && typeof stored.features === 'object'
107
+ const settings = mergeSupportSettings(stored)
108
+
109
+ // Pre-2.1 installs enabled round-robin through its own preference row.
110
+ // Honour it until the first save writes `features` — after that the
111
+ // legacy row is ignored, and both write paths land in `features`.
112
+ if (!featuresConfigured) {
113
+ settings.features.roundRobin = await readLegacyRoundRobin(payload)
60
114
  }
115
+
116
+ value = { settings, featuresConfigured }
61
117
  }
62
118
  } catch { /* fallback to defaults */ }
63
119
  settingsCache = { value, ts: Date.now() }
64
120
  return value
65
121
  }
66
122
 
123
+ export async function readSupportSettings(payload: Payload): Promise<SupportSettings> {
124
+ return (await readSupportSettingsState(payload)).settings
125
+ }
126
+
127
+ async function readLegacyRoundRobin(payload: Payload): Promise<boolean> {
128
+ try {
129
+ const prefs = await dbFind(payload, 'payload-preferences', {
130
+ where: { key: { equals: LEGACY_ROUND_ROBIN_KEY } },
131
+ limit: 1, depth: 0, overrideAccess: true,
132
+ })
133
+ if (prefs.docs.length > 0) {
134
+ return (prefs.docs[0].value as { enabled?: boolean })?.enabled === true
135
+ }
136
+ } catch { /* ignore — defaults to disabled */ }
137
+ return DEFAULT_TICKETING_FEATURES.roundRobin
138
+ }
139
+
67
140
  export async function readUserPrefs(payload: Payload, userId: string | number): Promise<UserPrefs> {
68
141
  try {
69
142
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`
@@ -3,7 +3,16 @@ import type { BasePayload } from 'payload'
3
3
  import type { CollectionSlugs } from './slugs'
4
4
  import { dbFind, dbUpdate } from './db'
5
5
 
6
- type WebhookEvent = 'ticket_created' | 'ticket_resolved' | 'ticket_replied' | 'sla_breached'
6
+ /**
7
+ * Outbound webhook events. Must stay in sync with the `events` options of the
8
+ * webhook-endpoints collection.
9
+ */
10
+ export type WebhookEvent =
11
+ | 'ticket_created'
12
+ | 'ticket_resolved'
13
+ | 'ticket_replied'
14
+ | 'ticket_assigned'
15
+ | 'sla_breached'
7
16
 
8
17
  /**
9
18
  * Dispatch outbound webhooks for a given event.
@@ -21,9 +30,12 @@ export function dispatchWebhook(
21
30
  event: WebhookEvent,
22
31
  payload: BasePayload,
23
32
  slugs: CollectionSlugs,
24
- ): void {
25
- // Fire and forget do not await
26
- void _dispatch(data, event, payload, slugs)
33
+ ): Promise<void> {
34
+ // Fire and forget for callers (hooks never await it); the promise is returned so
35
+ // tests and any caller that wants to — can wait for delivery to settle.
36
+ const done = _dispatch(data, event, payload, slugs)
37
+ void done
38
+ return done
27
39
  }
28
40
 
29
41
  async function _dispatch(
@@ -49,9 +61,9 @@ async function _dispatch(
49
61
 
50
62
  const body = JSON.stringify({ event, data, timestamp: new Date().toISOString() })
51
63
 
52
- for (const endpoint of endpoints) {
53
- void _sendToEndpoint(endpoint as any, body, payload, slugs)
54
- }
64
+ await Promise.allSettled(
65
+ endpoints.map((endpoint) => _sendToEndpoint(endpoint as any, body, payload, slugs)),
66
+ )
55
67
  } catch (err) {
56
68
  console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err)
57
69
  }
@@ -40,6 +40,11 @@ export const ChatViewClient: React.FC = () => {
40
40
  const sessionsPollTimeout = useRef<NodeJS.Timeout>(undefined)
41
41
  const messagesPollInterval = useRef(3000)
42
42
  const messagesPollTimeout = useRef<NodeJS.Timeout>(undefined)
43
+ // Serialized snapshot of the last sessions payload. The change detection MUST
44
+ // live outside the setSessions updater: React skips the updater entirely on the
45
+ // eager-state path, so a flag assigned inside it stops being written after the
46
+ // first render and the adaptive backoff then climbs to its ceiling and stays there.
47
+ const sessionsSnapshotRef = useRef<string>('')
43
48
 
44
49
  // Fetch sessions list
45
50
  const [sessionExpired, setSessionExpired] = useState(false)
@@ -50,15 +55,14 @@ export const ChatViewClient: React.FC = () => {
50
55
  if (res.status === 401 || res.status === 403) { setSessionExpired(true); return }
51
56
  if (res.ok) {
52
57
  const data = await res.json()
53
- setSessions((prev) => {
54
- const newActive = data.active || []
55
- const newClosed = data.closed || []
56
- if (JSON.stringify(prev.active) !== JSON.stringify(newActive) || JSON.stringify(prev.closed) !== JSON.stringify(newClosed)) {
57
- hadChanges = true
58
- return { active: newActive, closed: newClosed }
59
- }
60
- return prev
61
- })
58
+ const newActive = data.active || []
59
+ const newClosed = data.closed || []
60
+ const snapshot = JSON.stringify([newActive, newClosed])
61
+ if (snapshot !== sessionsSnapshotRef.current) {
62
+ sessionsSnapshotRef.current = snapshot
63
+ hadChanges = true
64
+ setSessions({ active: newActive, closed: newClosed })
65
+ }
62
66
  }
63
67
  } catch { /* ignore */ }
64
68
  setLoading(false)
@@ -144,26 +144,25 @@ export const CrmClient: React.FC = () => {
144
144
  const fetchDetail = useCallback(async (clientId: number) => {
145
145
  setDetailLoading(true)
146
146
  try {
147
- const [clientRes, ticketsRes, projectsRes, timeRes] = await Promise.all([
147
+ const [clientRes, ticketsRes, projectsRes] = await Promise.all([
148
148
  fetch(`/api/support-clients/${clientId}?depth=0`),
149
149
  fetch(`/api/tickets?where[client][equals]=${clientId}&sort=-createdAt&limit=20&depth=0`),
150
150
  fetch(`/api/projects?where[client][equals]=${clientId}&depth=0`),
151
- fetch(`/api/time-entries?limit=0&depth=0`),
152
151
  ])
153
152
 
154
153
  const client = clientRes.ok ? await clientRes.json() : null
155
154
  const tickets = ticketsRes.ok ? (await ticketsRes.json()).docs || [] : []
156
155
  const projects = projectsRes.ok ? (await projectsRes.json()).docs || [] : []
157
156
 
158
- // Get time entries for this client's tickets
159
- const ticketIds = tickets.map((t: { id: number }) => t.id)
160
- let totalTimeMinutes = 0
161
- if (timeRes.ok) {
162
- const timeData = await timeRes.json()
163
- totalTimeMinutes = timeData.docs
164
- .filter((e: { ticket: number }) => ticketIds.includes(e.ticket))
165
- .reduce((sum: number, e: { duration: number }) => sum + (e.duration || 0), 0)
166
- }
157
+ // Sum the per-ticket rollup maintained by the TimeEntries afterChange /
158
+ // afterDelete hooks. This used to download the ENTIRE time-entries table
159
+ // (via limit=0, which does not mean "no document") only to keep the rows whose
160
+ // ticket is one of the 20 listed here — same result, O(1) payload instead of
161
+ // O(all time entries ever recorded).
162
+ const totalTimeMinutes = tickets.reduce(
163
+ (sum: number, t: { totalTimeMinutes?: number }) => sum + (t.totalTimeMinutes || 0),
164
+ 0,
165
+ )
167
166
 
168
167
  const openTickets = tickets.filter((t: { status: string }) =>
169
168
  ['open', 'waiting_client'].includes(t.status),
@@ -540,7 +540,7 @@ export const SupportDashboardClient: React.FC = () => {
540
540
 
541
541
  {/* CSAT ring */}
542
542
  <div className={styles.panel}>
543
- <h2 className={styles.panelTitle}>{t('dashboard.csat')}</h2>
543
+ <h2 className={styles.panelTitle}>{t('dashboard.csatTitle')}</h2>
544
544
  <CSATRing score={stats.satisfactionAvg} count={stats.satisfactionCount} />
545
545
  </div>
546
546
  </div>
@@ -6,7 +6,7 @@ import Link from 'next/link'
6
6
  import { RichTextEditor, type RichTextEditorHandle } from '../../components/RichTextEditor/index'
7
7
  import { hasCodeBlocks, MessageWithCodeBlocks, CodeBlockRendererHtml } from '../../components/TicketConversation/components/CodeBlock'
8
8
  import { CodeBlockInserter } from '../../components/TicketConversation/components/CodeBlockInserter'
9
- import { getFeatures } from '../shared/config'
9
+ import { useFeatures } from '../../components/TicketConversation/hooks/useFeatures'
10
10
  import { computeSlaState, formatSlaRemaining } from '../shared/sla'
11
11
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
12
12
  import { DATE_LOCALE } from '../shared/dateLocale'
@@ -21,7 +21,7 @@ export const TicketDetailClient: React.FC = () => {
21
21
  const { t } = useTranslation()
22
22
  const searchParams = useSearchParams()
23
23
  const ticketId = searchParams.get('id')
24
- const features = getFeatures()
24
+ const { features } = useFeatures()
25
25
  const editorRef = useRef<RichTextEditorHandle>(null)
26
26
  const threadEndRef = useRef<HTMLDivElement>(null)
27
27
  const dropdownRef = useRef<HTMLDivElement>(null)
@@ -165,12 +165,17 @@ export const TicketInboxClient: React.FC = () => {
165
165
  try {
166
166
  // Exclude snoozed tickets from the counts too (mirrors the list query).
167
167
  const sn = `where[and][0][or][0][snoozeUntil][exists]=false&where[and][0][or][1][snoozeUntil][less_than_equal]=${encodeURIComponent(new Date().toISOString())}`
168
+ // `/count` is a real SQL COUNT. The previous limit=0 idiom did NOT mean
169
+ // "no document": Payload disables pagination for limit=0, skips the count
170
+ // query and derives totalDocs from the rows it just materialised — so each
171
+ // of these five counters used to read the whole tickets table (relations
172
+ // included) just to display a badge.
168
173
  const [all, openRes, waiting, resolved, breach] = await Promise.all([
169
- fetch(`/api/tickets?limit=0&depth=0&${sn}`, { credentials: 'include' }),
170
- fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=open&${sn}`, { credentials: 'include' }),
171
- fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=waiting_client&${sn}`, { credentials: 'include' }),
172
- fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=resolved&${sn}`, { credentials: 'include' }),
173
- fetch(`/api/tickets?limit=0&depth=0&where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&${sn}`, { credentials: 'include' }),
174
+ fetch(`/api/tickets/count?${sn}`, { credentials: 'include' }),
175
+ fetch(`/api/tickets/count?where[status][equals]=open&${sn}`, { credentials: 'include' }),
176
+ fetch(`/api/tickets/count?where[status][equals]=waiting_client&${sn}`, { credentials: 'include' }),
177
+ fetch(`/api/tickets/count?where[status][equals]=resolved&${sn}`, { credentials: 'include' }),
178
+ fetch(`/api/tickets/count?where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&${sn}`, { credentials: 'include' }),
174
179
  ])
175
180
  const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()])
176
181
  setCounts({
@@ -4,7 +4,8 @@ import React, { useState, useEffect } from 'react'
4
4
  import { Settings, Mail, Bot, Clock, Timer, Globe, FileSignature } from 'lucide-react'
5
5
  import { V, btnStyle } from '../shared/adminTokens'
6
6
  import { AdminViewHeader } from '../shared/AdminViewHeader'
7
- import { getFeatures, saveFeatures, DEFAULT_FEATURES, type TicketingFeatures } from '../../components/TicketConversation/config'
7
+ import { DEFAULT_FEATURES, type TicketingFeatures } from '../../components/TicketConversation/config'
8
+ import { useFeatures } from '../../components/TicketConversation/hooks/useFeatures'
8
9
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
9
10
  import ts from '../../styles/TicketingSettings.module.scss'
10
11
 
@@ -301,7 +302,7 @@ export const TicketingSettingsClient: React.FC = () => {
301
302
  const { t } = useTranslation()
302
303
  const FEATURE_LIST = React.useMemo(() => getFeatureList(t), [t])
303
304
  const CATEGORIES = React.useMemo(() => getCategories(t), [t])
304
- const [features, setFeatures] = useState<TicketingFeatures>(() => getFeatures())
305
+ const { features, setFeatures, save: saveFeatures } = useFeatures()
305
306
  const [settings, setSettings] = useState<AllSettings>(DEFAULT_SETTINGS)
306
307
  const [signature, setSignature] = useState('')
307
308
  const [saved, setSaved] = useState(false)
@@ -355,15 +356,16 @@ export const TicketingSettingsClient: React.FC = () => {
355
356
 
356
357
  const handleSave = async () => {
357
358
  setSaving(true)
358
- // Save features to localStorage (UI-only flags)
359
- saveFeatures(features)
360
- // Save global settings + per-user prefs
359
+ // Feature flags and the other blocks share one preference row, so the two
360
+ // writes are sequenced: running them in parallel would make the second
361
+ // request merge onto settings read before the first one landed.
362
+ const featuresOk = await saveFeatures(features)
361
363
  const [settingsOk, prefsOk] = await Promise.all([
362
364
  saveSettingsToAPI(settings),
363
365
  saveUserPrefs({ locale: settings.locale.language, signature }),
364
366
  ])
365
367
  setSaving(false)
366
- if (settingsOk && prefsOk) {
368
+ if (featuresOk && settingsOk && prefsOk) {
367
369
  setSaved(true)
368
370
  setTimeout(() => setSaved(false), 3000)
369
371
  }
@@ -413,7 +415,7 @@ export const TicketingSettingsClient: React.FC = () => {
413
415
  * SECTION 1 — Feature Flags
414
416
  * ======================================== */}
415
417
  <CollapsibleSection
416
- title={t('settingsView.features')}
418
+ title={t('settingsView.featuresTitle')}
417
419
  icon={<Settings size={16} />}
418
420
  color={V.blue}
419
421
  badge={
@@ -1,82 +1,16 @@
1
1
  /**
2
- * Feature flags for the ticketing module.
3
- * Each feature can be enabled/disabled by the admin.
4
- * When disabled, the corresponding UI section is hidden entirely.
2
+ * Ticketing feature flags re-export of the shared implementation.
3
+ *
4
+ * See `utils/features.ts`. This module is kept so that
5
+ * `views/shared/index.ts` and existing view imports keep resolving.
5
6
  */
6
- export interface TicketingFeatures {
7
- /** Time tracking: timer, manual entries, billing */
8
- timeTracking: boolean
9
- /** AI features: sentiment, synthesis, suggestion, rewrite */
10
- ai: boolean
11
- /** Satisfaction surveys: CSAT rating after resolution */
12
- satisfaction: boolean
13
- /** Live chat integration: chat -> ticket conversion */
14
- chat: boolean
15
- /** Email tracking: pixel tracking, open/sent status per message */
16
- emailTracking: boolean
17
- /** Canned responses: quick reply templates */
18
- canned: boolean
19
- /** Ticket merge: combine two tickets into one */
20
- merge: boolean
21
- /** Snooze: temporarily hide a ticket */
22
- snooze: boolean
23
- /** External messages: add messages received outside the system */
24
- externalMessages: boolean
25
- /** Client history: past tickets, projects, notes sidebar */
26
- clientHistory: boolean
27
- /** Activity log: audit trail of actions on the ticket */
28
- activityLog: boolean
29
- /** Split ticket: extract a message into a new ticket */
30
- splitTicket: boolean
31
- /** Scheduled replies: send a message at a future date */
32
- scheduledReplies: boolean
33
- /** Auto-close: automatically resolve inactive tickets */
34
- autoClose: boolean
35
- /** Auto-close delay in days (used by auto-close cron) */
36
- autoCloseDays: number
37
- /** Round-robin: distribute new tickets evenly among agents */
38
- roundRobin: boolean
39
- }
40
-
41
- /** Default features -- all enabled */
42
- export const DEFAULT_FEATURES: TicketingFeatures = {
43
- timeTracking: true,
44
- ai: true,
45
- satisfaction: true,
46
- chat: true,
47
- emailTracking: true,
48
- canned: true,
49
- merge: true,
50
- snooze: true,
51
- externalMessages: true,
52
- clientHistory: true,
53
- activityLog: true,
54
- splitTicket: true,
55
- scheduledReplies: true,
56
- autoClose: true,
57
- autoCloseDays: 7,
58
- roundRobin: false,
59
- }
60
-
61
- const STORAGE_KEY = 'ticketing_features'
62
-
63
- /** Read features from localStorage (falls back to defaults) */
64
- export function getFeatures(): TicketingFeatures {
65
- if (typeof window === 'undefined') return DEFAULT_FEATURES
66
- try {
67
- const stored = localStorage.getItem(STORAGE_KEY)
68
- if (stored) {
69
- const parsed = JSON.parse(stored)
70
- return { ...DEFAULT_FEATURES, ...parsed }
71
- }
72
- } catch { /* ignore */ }
73
- return DEFAULT_FEATURES
74
- }
75
-
76
- /** Save features to localStorage */
77
- export function saveFeatures(features: TicketingFeatures): void {
78
- if (typeof window === 'undefined') return
79
- try {
80
- localStorage.setItem(STORAGE_KEY, JSON.stringify(features))
81
- } catch { /* ignore */ }
82
- }
7
+ export {
8
+ DEFAULT_TICKETING_FEATURES,
9
+ DEFAULT_TICKETING_FEATURES as DEFAULT_FEATURES,
10
+ FEATURES_CACHE_KEY,
11
+ fetchFeatures,
12
+ getFeatures,
13
+ normalizeFeatures,
14
+ saveFeatures,
15
+ } from '../../utils/features'
16
+ export type { TicketingFeatures } from '../../utils/features'
@@ -2,8 +2,10 @@ export { AdminErrorBoundary } from './ErrorBoundary'
2
2
  export { V, btnStyle } from './adminTokens'
3
3
  export { AdminViewHeader } from './AdminViewHeader'
4
4
  export { Skeleton, SkeletonText, SkeletonCard, SkeletonTable, SkeletonDashboard } from './Skeleton'
5
- export { getFeatures, saveFeatures, DEFAULT_FEATURES } from './config'
5
+ export { getFeatures, saveFeatures, fetchFeatures, normalizeFeatures, DEFAULT_FEATURES } from './config'
6
6
  export type { TicketingFeatures } from './config'
7
+ export { useFeatures } from '../../components/TicketConversation/hooks/useFeatures'
8
+ export type { UseFeaturesResult } from '../../components/TicketConversation/hooks/useFeatures'
7
9
  export { StatusPill } from './StatusPill'
8
10
  export type { StatusPillProps, TicketStatusVariant } from './StatusPill'
9
11
  export { computeSlaState, formatSlaRemaining } from './sla'
@@ -266,7 +266,7 @@
266
266
  "activeTickets": "Active tickets",
267
267
  "noActiveTickets": "No active tickets",
268
268
  "volume7days": "Volume — last 7 days",
269
- "csat": "Client satisfaction (CSAT)",
269
+ "csatTitle": "Client satisfaction (CSAT)",
270
270
  "newTicketAction": "+ New ticket",
271
271
  "pendingEmails": "Pending emails",
272
272
  "crm": "CRM Clients",
@@ -599,7 +599,7 @@
599
599
  "settingsView": {
600
600
  "configTitle": "Support module configuration",
601
601
  "intro": "Manage features, email configuration, AI and support module preferences.",
602
- "features": "Features",
602
+ "featuresTitle": "Features",
603
603
  "featuresDescription": "Enable or disable support module features.",
604
604
  "reset": "Reset",
605
605
  "saved": "✓ Saved",
@@ -266,7 +266,7 @@
266
266
  "activeTickets": "Tickets actifs",
267
267
  "noActiveTickets": "Aucun ticket actif",
268
268
  "volume7days": "Volume — 7 derniers jours",
269
- "csat": "Satisfaction client (CSAT)",
269
+ "csatTitle": "Satisfaction client (CSAT)",
270
270
  "newTicketAction": "+ Nouveau ticket",
271
271
  "pendingEmails": "Emails en attente",
272
272
  "crm": "CRM Clients",
@@ -599,7 +599,7 @@
599
599
  "settingsView": {
600
600
  "configTitle": "Configuration du module Support",
601
601
  "intro": "Gérez les fonctionnalités, la configuration email, l'IA et les préférences du module de support.",
602
- "features": "Fonctionnalités",
602
+ "featuresTitle": "Fonctionnalités",
603
603
  "featuresDescription": "Activez ou désactivez les fonctionnalités du module de support.",
604
604
  "reset": "Réinitialiser",
605
605
  "saved": "✓ Sauvegardé",