@consilioweb/payload-support 2.0.1 → 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 (215) hide show
  1. package/README.md +636 -402
  2. package/dist/components/RichTextEditor/index.d.ts +19 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.d.ts +6 -0
  4. package/dist/components/TicketConversation/SkeletonText.d.ts +4 -0
  5. package/dist/components/TicketConversation/components/AISummaryPanel.d.ts +14 -0
  6. package/dist/components/TicketConversation/components/ActionPanels.d.ts +47 -0
  7. package/dist/components/TicketConversation/components/ActivityLog.d.ts +5 -0
  8. package/dist/components/TicketConversation/components/ClientBar.d.ts +5 -0
  9. package/dist/components/TicketConversation/components/ClientHistory.d.ts +24 -0
  10. package/dist/components/TicketConversation/components/CodeBlock.d.ts +26 -0
  11. package/dist/components/TicketConversation/components/CodeBlockInserter.d.ts +12 -0
  12. package/dist/components/TicketConversation/components/QuickActions.d.ts +32 -0
  13. package/dist/components/TicketConversation/components/TicketHeader.d.ts +19 -0
  14. package/dist/components/TicketConversation/components/TimeTrackingPanel.d.ts +24 -0
  15. package/dist/components/TicketConversation/config.d.ts +10 -0
  16. package/dist/components/TicketConversation/config.js +1 -40
  17. package/dist/components/TicketConversation/constants.d.ts +56 -0
  18. package/dist/components/TicketConversation/context.d.ts +53 -0
  19. package/dist/components/TicketConversation/hooks/useAI.d.ts +21 -0
  20. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.d.ts +3 -0
  21. package/dist/components/TicketConversation/hooks/useFeatures.d.ts +23 -0
  22. package/dist/components/TicketConversation/hooks/useFeatures.js +61 -0
  23. package/dist/components/TicketConversation/hooks/useMessageActions.d.ts +20 -0
  24. package/dist/components/TicketConversation/hooks/useReply.d.ts +27 -0
  25. package/dist/components/TicketConversation/hooks/useTicketActions.d.ts +58 -0
  26. package/dist/components/TicketConversation/hooks/useTimeTracking.d.ts +18 -0
  27. package/dist/components/TicketConversation/hooks/useTranslation.d.ts +33 -0
  28. package/dist/components/TicketConversation/index.d.ts +4 -0
  29. package/dist/components/TicketConversation/index.js +2 -2
  30. package/dist/components/TicketConversation/types.d.ts +56 -0
  31. package/dist/components/TicketConversation/utils.d.ts +2 -0
  32. package/dist/index.cjs +392 -320
  33. package/dist/index.d.cts +100 -3
  34. package/dist/index.d.ts +100 -3
  35. package/dist/index.js +388 -321
  36. package/dist/utils/features.d.ts +123 -0
  37. package/dist/utils/features.js +147 -0
  38. package/dist/views/BillingView/client.d.ts +2 -0
  39. package/dist/views/BillingView/index.d.ts +4 -0
  40. package/dist/views/ChatView/client.d.ts +2 -0
  41. package/dist/views/ChatView/client.js +9 -9
  42. package/dist/views/ChatView/index.d.ts +4 -0
  43. package/dist/views/CrmView/client.d.ts +2 -0
  44. package/dist/views/CrmView/client.js +6 -9
  45. package/dist/views/CrmView/index.d.ts +4 -0
  46. package/dist/views/EmailTrackingView/client.d.ts +2 -0
  47. package/dist/views/EmailTrackingView/index.d.ts +4 -0
  48. package/dist/views/ImportConversationView/client.d.ts +2 -0
  49. package/dist/views/ImportConversationView/index.d.ts +4 -0
  50. package/dist/views/LogsView/client.d.ts +2 -0
  51. package/dist/views/LogsView/index.d.ts +4 -0
  52. package/dist/views/NewTicketView/client.d.ts +2 -0
  53. package/dist/views/NewTicketView/index.d.ts +4 -0
  54. package/dist/views/PendingEmailsView/client.d.ts +2 -0
  55. package/dist/views/PendingEmailsView/index.d.ts +4 -0
  56. package/dist/views/SupportDashboardView/client.d.ts +2 -0
  57. package/dist/views/SupportDashboardView/index.d.ts +4 -0
  58. package/dist/views/TicketDetailView/NextActionItem.d.ts +6 -0
  59. package/dist/views/TicketDetailView/RewriteDropdown.d.ts +7 -0
  60. package/dist/views/TicketDetailView/client.d.ts +2 -0
  61. package/dist/views/TicketDetailView/client.js +2 -2
  62. package/dist/views/TicketDetailView/constants.d.ts +13 -0
  63. package/dist/views/TicketDetailView/helpers.d.ts +2 -0
  64. package/dist/views/TicketDetailView/index.d.ts +4 -0
  65. package/dist/views/TicketDetailView/types.d.ts +40 -0
  66. package/dist/views/TicketInboxView/client.d.ts +2 -0
  67. package/dist/views/TicketInboxView/client.js +5 -5
  68. package/dist/views/TicketInboxView/index.d.ts +4 -0
  69. package/dist/views/TicketingSettingsView/client.d.ts +2 -0
  70. package/dist/views/TicketingSettingsView/client.js +5 -4
  71. package/dist/views/TicketingSettingsView/index.d.ts +4 -0
  72. package/dist/views/TimeDashboardView/client.d.ts +2 -0
  73. package/dist/views/TimeDashboardView/index.d.ts +4 -0
  74. package/dist/views/shared/AdminViewHeader.d.ts +13 -0
  75. package/dist/views/shared/ErrorBoundary.d.ts +17 -0
  76. package/dist/views/shared/Skeleton.d.ts +21 -0
  77. package/dist/views/shared/StatusPill.d.ts +8 -0
  78. package/dist/views/shared/adminTokens.d.ts +19 -0
  79. package/dist/views/shared/config.d.ts +8 -0
  80. package/dist/views/shared/config.js +1 -40
  81. package/dist/views/shared/dateLocale.d.ts +5 -0
  82. package/dist/views/shared/index.d.ts +12 -0
  83. package/dist/views/shared/index.js +2 -1
  84. package/dist/views/shared/sla.d.ts +14 -0
  85. package/dist/views.d.ts +13 -13
  86. package/package.json +16 -31
  87. package/src/collections/SupportClients.ts +29 -0
  88. package/src/collections/TicketCollaborators.ts +6 -1
  89. package/src/collections/TicketMessages.ts +46 -24
  90. package/src/collections/Tickets.ts +117 -79
  91. package/src/collections/TimeEntries.ts +57 -32
  92. package/src/components/TicketConversation/config.ts +16 -80
  93. package/src/components/TicketConversation/hooks/useFeatures.ts +109 -0
  94. package/src/components/TicketConversation/index.tsx +2 -2
  95. package/src/endpoints/ai.ts +2 -1
  96. package/src/endpoints/client-intelligence.ts +2 -1
  97. package/src/endpoints/process-scheduled.ts +13 -8
  98. package/src/endpoints/round-robin-config.ts +30 -20
  99. package/src/endpoints/settings.ts +28 -77
  100. package/src/index.ts +6 -2
  101. package/src/portal/LiveChat.tsx +33 -22
  102. package/src/portal/auth/tickets/detail/TicketPolling.tsx +5 -1
  103. package/src/types/lucide-react.d.ts +10 -2
  104. package/src/utils/aiAgent.ts +2 -1
  105. package/src/utils/aiProvider.ts +21 -0
  106. package/src/utils/features.ts +297 -0
  107. package/src/utils/fireWebhooks.ts +6 -0
  108. package/src/utils/generateTicketSynthesis.ts +2 -1
  109. package/src/utils/index.ts +4 -2
  110. package/src/utils/readSettings.ts +82 -9
  111. package/src/utils/webhookDispatcher.ts +19 -7
  112. package/src/views/ChatView/client.tsx +13 -9
  113. package/src/views/CrmView/client.tsx +10 -11
  114. package/src/views/TicketDetailView/client.tsx +2 -2
  115. package/src/views/TicketInboxView/client.tsx +10 -5
  116. package/src/views/TicketingSettingsView/client.tsx +8 -6
  117. package/src/views/shared/config.ts +14 -80
  118. package/src/views/shared/index.ts +3 -1
  119. package/dist/components/RichTextEditor/index.cjs +0 -279
  120. package/dist/components/TicketConversation/RewriteDropdown.cjs +0 -98
  121. package/dist/components/TicketConversation/SkeletonText.cjs +0 -21
  122. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +0 -85
  123. package/dist/components/TicketConversation/components/ActionPanels.cjs +0 -193
  124. package/dist/components/TicketConversation/components/ActivityLog.cjs +0 -24
  125. package/dist/components/TicketConversation/components/ClientBar.cjs +0 -43
  126. package/dist/components/TicketConversation/components/ClientHistory.cjs +0 -138
  127. package/dist/components/TicketConversation/components/CodeBlock.cjs +0 -171
  128. package/dist/components/TicketConversation/components/CodeBlockInserter.cjs +0 -156
  129. package/dist/components/TicketConversation/components/QuickActions.cjs +0 -87
  130. package/dist/components/TicketConversation/components/TicketHeader.cjs +0 -93
  131. package/dist/components/TicketConversation/components/TimeTrackingPanel.cjs +0 -134
  132. package/dist/components/TicketConversation/config.cjs +0 -44
  133. package/dist/components/TicketConversation/constants.cjs +0 -127
  134. package/dist/components/TicketConversation/context.cjs +0 -13
  135. package/dist/components/TicketConversation/hooks/useAI.cjs +0 -196
  136. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +0 -20
  137. package/dist/components/TicketConversation/hooks/useMessageActions.cjs +0 -136
  138. package/dist/components/TicketConversation/hooks/useReply.cjs +0 -188
  139. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +0 -290
  140. package/dist/components/TicketConversation/hooks/useTimeTracking.cjs +0 -123
  141. package/dist/components/TicketConversation/hooks/useTranslation.cjs +0 -101
  142. package/dist/components/TicketConversation/index.cjs +0 -1114
  143. package/dist/components/TicketConversation/types.cjs +0 -2
  144. package/dist/components/TicketConversation/utils.cjs +0 -27
  145. package/dist/views/BillingView/client.cjs +0 -469
  146. package/dist/views/BillingView/index.cjs +0 -34
  147. package/dist/views/ChatView/client.cjs +0 -301
  148. package/dist/views/ChatView/index.cjs +0 -34
  149. package/dist/views/CrmView/client.cjs +0 -458
  150. package/dist/views/CrmView/index.cjs +0 -34
  151. package/dist/views/EmailTrackingView/client.cjs +0 -171
  152. package/dist/views/EmailTrackingView/index.cjs +0 -34
  153. package/dist/views/ImportConversationView/client.cjs +0 -238
  154. package/dist/views/ImportConversationView/index.cjs +0 -34
  155. package/dist/views/LogsView/client.cjs +0 -148
  156. package/dist/views/LogsView/index.cjs +0 -32
  157. package/dist/views/NewTicketView/client.cjs +0 -213
  158. package/dist/views/NewTicketView/index.cjs +0 -32
  159. package/dist/views/PendingEmailsView/client.cjs +0 -471
  160. package/dist/views/PendingEmailsView/index.cjs +0 -34
  161. package/dist/views/SupportDashboardView/client.cjs +0 -437
  162. package/dist/views/SupportDashboardView/index.cjs +0 -34
  163. package/dist/views/TicketDetailView/NextActionItem.cjs +0 -40
  164. package/dist/views/TicketDetailView/RewriteDropdown.cjs +0 -80
  165. package/dist/views/TicketDetailView/client.cjs +0 -1412
  166. package/dist/views/TicketDetailView/constants.cjs +0 -20
  167. package/dist/views/TicketDetailView/helpers.cjs +0 -23
  168. package/dist/views/TicketDetailView/index.cjs +0 -34
  169. package/dist/views/TicketDetailView/types.cjs +0 -2
  170. package/dist/views/TicketInboxView/client.cjs +0 -359
  171. package/dist/views/TicketInboxView/index.cjs +0 -32
  172. package/dist/views/TicketingSettingsView/client.cjs +0 -740
  173. package/dist/views/TicketingSettingsView/index.cjs +0 -34
  174. package/dist/views/TimeDashboardView/client.cjs +0 -180
  175. package/dist/views/TimeDashboardView/index.cjs +0 -34
  176. package/dist/views/shared/AdminViewHeader.cjs +0 -68
  177. package/dist/views/shared/ErrorBoundary.cjs +0 -55
  178. package/dist/views/shared/Skeleton.cjs +0 -77
  179. package/dist/views/shared/StatusPill.cjs +0 -47
  180. package/dist/views/shared/adminTokens.cjs +0 -34
  181. package/dist/views/shared/config.cjs +0 -44
  182. package/dist/views/shared/dateLocale.cjs +0 -5
  183. package/dist/views/shared/index.cjs +0 -72
  184. package/dist/views/shared/sla.cjs +0 -37
  185. package/dist/views.cjs +0 -86
  186. package/dist/views.d.cts +0 -13
  187. package/src/__tests__/aiSummaryRendering.test.ts +0 -21
  188. package/src/__tests__/authResponses.test.ts +0 -69
  189. package/src/__tests__/capabilities.test.ts +0 -71
  190. package/src/__tests__/generateTrackingToken.test.ts +0 -67
  191. package/src/__tests__/integration/ai-agent.test.ts +0 -46
  192. package/src/__tests__/integration/auth.test.ts +0 -43
  193. package/src/__tests__/integration/automation-rules.test.ts +0 -68
  194. package/src/__tests__/integration/buildTestPayload.ts +0 -52
  195. package/src/__tests__/integration/channels.test.ts +0 -52
  196. package/src/__tests__/integration/core-behaviors.test.ts +0 -81
  197. package/src/__tests__/integration/dashboard-volume.test.ts +0 -31
  198. package/src/__tests__/integration/digest.test.ts +0 -48
  199. package/src/__tests__/integration/invoice-pdf.test.ts +0 -30
  200. package/src/__tests__/integration/isolation.test.ts +0 -81
  201. package/src/__tests__/integration/mentions-invoice.test.ts +0 -50
  202. package/src/__tests__/integration/nps.test.ts +0 -37
  203. package/src/__tests__/integration/per-team-sla.test.ts +0 -44
  204. package/src/__tests__/integration/push.test.ts +0 -75
  205. package/src/__tests__/integration/sanitization.test.ts +0 -35
  206. package/src/__tests__/integration/sla-pause.test.ts +0 -39
  207. package/src/__tests__/integration/smoke.test.ts +0 -26
  208. package/src/__tests__/integration/snooze.test.ts +0 -68
  209. package/src/__tests__/integration/teams.test.ts +0 -38
  210. package/src/__tests__/rateLimiter.test.ts +0 -83
  211. package/src/__tests__/sanitizeHtml.test.ts +0 -165
  212. package/src/__tests__/sla.test.ts +0 -258
  213. package/src/__tests__/trackOpenEndpoint.test.ts +0 -54
  214. package/src/__tests__/translations.test.ts +0 -67
  215. package/src/__tests__/webhookSecurity.test.ts +0 -58
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Ticketing feature flags — single source of truth (client + server).
3
+ *
4
+ * Flags are stored SERVER-side, in the `support-settings` row of
5
+ * `payload-preferences` (see `readSettings.ts` and `endpoints/settings.ts`).
6
+ * A flag toggled on one machine therefore applies to every browser and to
7
+ * server-side code (hooks, cron endpoints) alike.
8
+ *
9
+ * `localStorage` is still used, but demoted to a plain CACHE: it lets admin
10
+ * views paint synchronously on first render and keeps them usable when the
11
+ * settings endpoint is unreachable. It is never authoritative.
12
+ *
13
+ * Two flags are *projections* of settings that already lived server-side long
14
+ * before this module, and are deliberately NOT stored twice:
15
+ * - `autoClose` ← `settings.autoClose.enabled`
16
+ * - `autoCloseDays` ← `settings.autoClose.daysBeforeClose`
17
+ * The `autoClose` block stays authoritative; the flags are recomputed on read
18
+ * and dropped on write. See `projectAutoClose` / `stripProjectedFeatures`.
19
+ */
20
+
21
+ /**
22
+ * Feature flags for the ticketing module.
23
+ * Each feature can be enabled/disabled by the admin.
24
+ * When disabled, the corresponding UI section is hidden entirely.
25
+ */
26
+ export interface TicketingFeatures {
27
+ /** Time tracking: timer, manual entries, billing */
28
+ timeTracking: boolean
29
+ /** AI features: sentiment, synthesis, suggestion, rewrite */
30
+ ai: boolean
31
+ /** Satisfaction surveys: CSAT rating after resolution */
32
+ satisfaction: boolean
33
+ /** Live chat integration: chat → ticket conversion */
34
+ chat: boolean
35
+ /** Email tracking: pixel tracking, open/sent status per message */
36
+ emailTracking: boolean
37
+ /** Canned responses: quick reply templates */
38
+ canned: boolean
39
+ /** Ticket merge: combine two tickets into one */
40
+ merge: boolean
41
+ /** Snooze: temporarily hide a ticket */
42
+ snooze: boolean
43
+ /** External messages: add messages received outside the system */
44
+ externalMessages: boolean
45
+ /** Client history: past tickets, projects, notes sidebar */
46
+ clientHistory: boolean
47
+ /** Activity log: audit trail of actions on the ticket */
48
+ activityLog: boolean
49
+ /** Split ticket: extract a message into a new ticket */
50
+ splitTicket: boolean
51
+ /** Scheduled replies: send a message at a future date */
52
+ scheduledReplies: boolean
53
+ /** Auto-close: automatically resolve inactive tickets (projection of settings.autoClose.enabled) */
54
+ autoClose: boolean
55
+ /** Auto-close delay in days (projection of settings.autoClose.daysBeforeClose) */
56
+ autoCloseDays: number
57
+ /** Round-robin: distribute new tickets evenly among agents */
58
+ roundRobin: boolean
59
+ }
60
+
61
+ /** Default features — all enabled except round-robin */
62
+ export const DEFAULT_TICKETING_FEATURES: TicketingFeatures = {
63
+ timeTracking: true,
64
+ ai: true,
65
+ satisfaction: true,
66
+ chat: true,
67
+ emailTracking: true,
68
+ canned: true,
69
+ merge: true,
70
+ snooze: true,
71
+ externalMessages: true,
72
+ clientHistory: true,
73
+ activityLog: true,
74
+ splitTicket: true,
75
+ scheduledReplies: true,
76
+ autoClose: true,
77
+ autoCloseDays: 7,
78
+ roundRobin: false,
79
+ }
80
+
81
+ type BooleanFeatureKey = Exclude<keyof TicketingFeatures, 'autoCloseDays'>
82
+
83
+ const BOOLEAN_FEATURE_KEYS: readonly BooleanFeatureKey[] = [
84
+ 'timeTracking',
85
+ 'ai',
86
+ 'satisfaction',
87
+ 'chat',
88
+ 'emailTracking',
89
+ 'canned',
90
+ 'merge',
91
+ 'snooze',
92
+ 'externalMessages',
93
+ 'clientHistory',
94
+ 'activityLog',
95
+ 'splitTicket',
96
+ 'scheduledReplies',
97
+ 'autoClose',
98
+ 'roundRobin',
99
+ ]
100
+
101
+ /** Flags derived from another settings block — never persisted inside `features`. */
102
+ const PROJECTED_FEATURE_KEYS = ['autoClose', 'autoCloseDays'] as const
103
+
104
+ /**
105
+ * Coerce arbitrary input (stale localStorage, hand-edited preference row, older
106
+ * plugin version) into a complete, well-typed feature set. Unknown keys are
107
+ * dropped and wrongly-typed values fall back to their default.
108
+ */
109
+ export function normalizeFeatures(raw: unknown): TicketingFeatures {
110
+ const out = { ...DEFAULT_TICKETING_FEATURES }
111
+ if (!raw || typeof raw !== 'object') return out
112
+
113
+ const src = raw as Record<string, unknown>
114
+ for (const key of BOOLEAN_FEATURE_KEYS) {
115
+ if (typeof src[key] === 'boolean') out[key] = src[key] as boolean
116
+ }
117
+
118
+ const days = src.autoCloseDays
119
+ if (typeof days === 'number' && Number.isFinite(days) && days >= 1) {
120
+ out.autoCloseDays = Math.floor(days)
121
+ }
122
+
123
+ return out
124
+ }
125
+
126
+ /** Shape of the `autoClose` settings block the two projected flags mirror. */
127
+ export interface AutoCloseProjectionSource {
128
+ enabled?: boolean
129
+ daysBeforeClose?: number
130
+ }
131
+
132
+ /** Recompute the projected flags from the authoritative `autoClose` block. */
133
+ export function projectAutoClose(
134
+ features: TicketingFeatures,
135
+ autoClose: AutoCloseProjectionSource | undefined,
136
+ ): TicketingFeatures {
137
+ if (!autoClose) return features
138
+ return {
139
+ ...features,
140
+ autoClose: typeof autoClose.enabled === 'boolean' ? autoClose.enabled : features.autoClose,
141
+ autoCloseDays:
142
+ typeof autoClose.daysBeforeClose === 'number' && autoClose.daysBeforeClose >= 1
143
+ ? Math.floor(autoClose.daysBeforeClose)
144
+ : features.autoCloseDays,
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Drop the projected flags before persisting, so `features` and the `autoClose`
150
+ * block can never disagree.
151
+ */
152
+ export function stripProjectedFeatures(
153
+ features: TicketingFeatures,
154
+ ): Omit<TicketingFeatures, (typeof PROJECTED_FEATURE_KEYS)[number]> {
155
+ const out = { ...features } as Record<string, unknown>
156
+ for (const key of PROJECTED_FEATURE_KEYS) delete out[key]
157
+ return out as Omit<TicketingFeatures, (typeof PROJECTED_FEATURE_KEYS)[number]>
158
+ }
159
+
160
+ // ─── Client-side cache ───────────────────────────────────
161
+
162
+ /** localStorage key. Was the source of truth before 2.1 — now only a cache. */
163
+ export const FEATURES_CACHE_KEY = 'ticketing_features'
164
+ /** Set once the pre-2.1 localStorage values have been pushed to the server. */
165
+ export const FEATURES_ADOPTED_KEY = 'ticketing_features_adopted'
166
+ /** Admin-only settings endpoint backing the flags. */
167
+ export const FEATURES_ENDPOINT = '/api/support/settings'
168
+
169
+ /** localStorage can throw (Safari private mode, disabled storage) — never let it bubble. */
170
+ function storage(): Storage | null {
171
+ try {
172
+ if (typeof window === 'undefined' || !window.localStorage) return null
173
+ return window.localStorage
174
+ } catch {
175
+ return null
176
+ }
177
+ }
178
+
179
+ /** Read the cached flags, or `null` when nothing usable is cached. */
180
+ export function readFeaturesCache(): TicketingFeatures | null {
181
+ const store = storage()
182
+ if (!store) return null
183
+ try {
184
+ const stored = store.getItem(FEATURES_CACHE_KEY)
185
+ if (!stored) return null
186
+ return normalizeFeatures(JSON.parse(stored))
187
+ } catch {
188
+ return null
189
+ }
190
+ }
191
+
192
+ /** Refresh the cache with the values the server just confirmed. */
193
+ export function writeFeaturesCache(features: TicketingFeatures): void {
194
+ const store = storage()
195
+ if (!store) return
196
+ try {
197
+ store.setItem(FEATURES_CACHE_KEY, JSON.stringify(features))
198
+ } catch {
199
+ /* quota exceeded / storage disabled — the server copy still stands */
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Synchronous read used for first paint. Returns the cache when present, the
205
+ * defaults otherwise. Callers must still refresh from the server — see
206
+ * `useFeatures`.
207
+ */
208
+ export function getFeatures(): TicketingFeatures {
209
+ return readFeaturesCache() ?? { ...DEFAULT_TICKETING_FEATURES }
210
+ }
211
+
212
+ // ─── One-shot migration of pre-2.1 localStorage ──────────
213
+
214
+ /** True once this browser's legacy flags have been handed over to the server. */
215
+ export function hasAdoptedLegacyFeatures(): boolean {
216
+ const store = storage()
217
+ if (!store) return true // nothing to migrate without storage
218
+ try {
219
+ return store.getItem(FEATURES_ADOPTED_KEY) === '1'
220
+ } catch {
221
+ return true
222
+ }
223
+ }
224
+
225
+ export function markLegacyFeaturesAdopted(): void {
226
+ const store = storage()
227
+ if (!store) return
228
+ try {
229
+ store.setItem(FEATURES_ADOPTED_KEY, '1')
230
+ } catch {
231
+ /* ignore */
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Flags this browser configured before the server became authoritative, or
237
+ * `null` when there is nothing to migrate (no local values, or already done).
238
+ */
239
+ export function readLegacyFeatures(): TicketingFeatures | null {
240
+ if (hasAdoptedLegacyFeatures()) return null
241
+ return readFeaturesCache()
242
+ }
243
+
244
+ // ─── Server round-trips ──────────────────────────────────
245
+
246
+ export interface FetchedFeatures {
247
+ features: TicketingFeatures
248
+ /** False when the server has never had feature flags saved (fresh install). */
249
+ configured: boolean
250
+ /** True when the values come from the cache/defaults because the call failed. */
251
+ offline: boolean
252
+ }
253
+
254
+ /**
255
+ * Read the flags from the server. Falls back to the local cache, then to the
256
+ * defaults: a failed settings call must never leave a ticket screen unusable.
257
+ */
258
+ export async function fetchFeatures(signal?: AbortSignal): Promise<FetchedFeatures> {
259
+ try {
260
+ const res = await fetch(FEATURES_ENDPOINT, { credentials: 'include', signal })
261
+ if (res.ok) {
262
+ const data = (await res.json()) as {
263
+ features?: unknown
264
+ featuresConfigured?: boolean
265
+ autoClose?: AutoCloseProjectionSource
266
+ }
267
+ const features = projectAutoClose(normalizeFeatures(data.features), data.autoClose)
268
+ writeFeaturesCache(features)
269
+ return { features, configured: data.featuresConfigured === true, offline: false }
270
+ }
271
+ } catch {
272
+ /* network error / aborted — fall through to the cache */
273
+ }
274
+ return { features: getFeatures(), configured: false, offline: true }
275
+ }
276
+
277
+ /**
278
+ * Persist the flags server-side, then refresh the local cache.
279
+ *
280
+ * @returns true when the server accepted the write.
281
+ */
282
+ export async function saveFeatures(features: TicketingFeatures): Promise<boolean> {
283
+ const normalized = normalizeFeatures(features)
284
+ try {
285
+ const res = await fetch(FEATURES_ENDPOINT, {
286
+ method: 'POST',
287
+ headers: { 'Content-Type': 'application/json' },
288
+ credentials: 'include',
289
+ body: JSON.stringify({ features: normalized }),
290
+ })
291
+ if (!res.ok) return false
292
+ writeFeaturesCache(normalized)
293
+ return true
294
+ } catch {
295
+ return false
296
+ }
297
+ }
@@ -6,6 +6,12 @@ import { dbFind, dbUpdate } from './db'
6
6
  * Fire all active webhooks matching the given event.
7
7
  * Uses fire-and-forget pattern (Promise.allSettled) so hook callers are never blocked.
8
8
  * Each webhook request has a 10-second timeout.
9
+ *
10
+ * @deprecated Use `dispatchWebhook` instead. This transport sends the endpoint
11
+ * secret IN CLEAR in the `X-Webhook-Secret` header, which contradicts the
12
+ * description of the `secret` field (HMAC-SHA256 signature) and defeats the point
13
+ * of signing. No hook in the plugin calls it any more — it stays exported only
14
+ * because it is part of the public surface, and will be removed in the next major.
9
15
  */
10
16
  export async function fireWebhooks(
11
17
  payload: Payload,
@@ -2,12 +2,13 @@ import type { Payload } from 'payload'
2
2
  import type { CollectionSlugs } from './slugs'
3
3
  import { readSupportSettings, type SupportSettings } from './readSettings'
4
4
  import { dbFindByID, dbUpdate, dbFind } from './db'
5
+ import { resolveOllamaBaseUrl } from './aiProvider'
5
6
 
6
7
  function getClient(aiSettings: SupportSettings['ai']) {
7
8
  // eslint-disable-next-line @typescript-eslint/no-require-imports
8
9
  const Anthropic = require('@anthropic-ai/sdk').default
9
10
  if (aiSettings.provider === 'ollama') {
10
- const baseURL = process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1'
11
+ const baseURL = resolveOllamaBaseUrl()
11
12
  return new Anthropic({ apiKey: 'ollama', baseURL })
12
13
  }
13
14
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
@@ -1,5 +1,7 @@
1
- export { readSupportSettings, DEFAULT_SETTINGS } from './readSettings'
2
- export type { SupportSettings } from './readSettings'
1
+ export { readSupportSettings, readSupportSettingsState, mergeSupportSettings, invalidateSupportSettingsCache, DEFAULT_SETTINGS } from './readSettings'
2
+ export type { SupportSettings, SupportSettingsState } from './readSettings'
3
+ export { DEFAULT_TICKETING_FEATURES, normalizeFeatures, projectAutoClose, stripProjectedFeatures } from './features'
4
+ export type { TicketingFeatures } from './features'
3
5
  export { resolveSlugs, DEFAULT_SLUGS } from './slugs'
4
6
  export type { CollectionSlugs } from './slugs'
5
7
  export { RateLimiter } from './rateLimiter'
@@ -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),
@@ -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({