@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,59 +1,21 @@
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 { invalidateSupportSettingsCache } from '../utils/readSettings'
5
- import { dbFind } from '../utils/db'
6
-
7
- const PREF_KEY = 'support-settings'
8
-
9
- interface SupportSettings {
10
- email: {
11
- fromAddress: string
12
- fromName: string
13
- replyToAddress: string
14
- }
15
- ai: {
16
- provider: 'anthropic' | 'openai' | 'gemini' | 'ollama'
17
- model: string
18
- enableSentiment: boolean
19
- enableSynthesis: boolean
20
- enableSuggestion: boolean
21
- enableRewrite: boolean
22
- }
23
- sla: {
24
- firstResponseMinutes: number
25
- resolutionMinutes: number
26
- businessHoursOnly: boolean
27
- escalationEmail: string
28
- }
29
- autoClose: {
30
- enabled: boolean
31
- daysBeforeClose: number
32
- reminderDaysBefore: number
33
- }
34
- }
35
-
36
- const DEFAULT_SUPPORT_SETTINGS: SupportSettings = {
37
- email: { fromAddress: '', fromName: 'Support', replyToAddress: '' },
38
- ai: {
39
- provider: 'anthropic',
40
- model: 'claude-haiku-4-5-20251001',
41
- enableSentiment: true,
42
- enableSynthesis: true,
43
- enableSuggestion: true,
44
- enableRewrite: true,
45
- },
46
- sla: {
47
- firstResponseMinutes: 120,
48
- resolutionMinutes: 1440,
49
- businessHoursOnly: true,
50
- escalationEmail: '',
51
- },
52
- autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 },
53
- }
4
+ import {
5
+ SUPPORT_SETTINGS_PREF_KEY as PREF_KEY,
6
+ invalidateSupportSettingsCache,
7
+ mergeSupportSettings,
8
+ readSupportSettingsState,
9
+ type SupportSettings,
10
+ } from '../utils/readSettings'
11
+ import { stripProjectedFeatures } from '../utils/features'
54
12
 
55
13
  /**
56
14
  * GET /api/support/settings — Read support settings
15
+ *
16
+ * `featuresConfigured` tells the admin UI whether the server has ever had
17
+ * feature flags saved; a client still holding pre-2.1 `localStorage` flags uses
18
+ * it to seed the server once instead of losing its configuration.
57
19
  */
58
20
  export function createSettingsGetEndpoint(slugs: CollectionSlugs): Endpoint {
59
21
  return {
@@ -65,25 +27,9 @@ export function createSettingsGetEndpoint(slugs: CollectionSlugs): Endpoint {
65
27
 
66
28
  requireAdmin(req, slugs)
67
29
 
68
- const prefs = await dbFind(payload, 'payload-preferences', {
69
- where: { key: { equals: PREF_KEY } },
70
- limit: 1,
71
- depth: 0,
72
- overrideAccess: true,
73
- })
74
-
75
- let settings = { ...DEFAULT_SUPPORT_SETTINGS }
76
- if (prefs.docs.length > 0) {
77
- const stored = prefs.docs[0].value as Partial<SupportSettings>
78
- settings = {
79
- email: { ...DEFAULT_SUPPORT_SETTINGS.email, ...stored.email },
80
- ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...stored.ai },
81
- sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...stored.sla },
82
- autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...stored.autoClose },
83
- }
84
- }
30
+ const { settings, featuresConfigured } = await readSupportSettingsState(payload)
85
31
 
86
- return Response.json(settings)
32
+ return Response.json({ ...settings, featuresConfigured })
87
33
  } catch (error) {
88
34
  const authResponse = handleAuthError(error)
89
35
  if (authResponse) return authResponse
@@ -95,7 +41,11 @@ export function createSettingsGetEndpoint(slugs: CollectionSlugs): Endpoint {
95
41
  }
96
42
 
97
43
  /**
98
- * POST /api/support/settings — Save all support settings (admin-only)
44
+ * POST /api/support/settings — Save support settings (admin-only)
45
+ *
46
+ * The body is merged onto the CURRENT settings, not onto the defaults: the
47
+ * feature-flag screen posts `{ features }` alone, and that must not reset the
48
+ * email / AI / SLA blocks.
99
49
  */
100
50
  export function createSettingsPostEndpoint(slugs: CollectionSlugs): Endpoint {
101
51
  return {
@@ -109,19 +59,20 @@ export function createSettingsPostEndpoint(slugs: CollectionSlugs): Endpoint {
109
59
 
110
60
  const body = (await req.json!()) as Partial<SupportSettings>
111
61
 
112
- const merged: SupportSettings = {
113
- email: { ...DEFAULT_SUPPORT_SETTINGS.email, ...body.email },
114
- ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...body.ai },
115
- sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...body.sla },
116
- autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...body.autoClose },
117
- }
62
+ const { settings: current } = await readSupportSettingsState(payload)
63
+ const merged = mergeSupportSettings(body, current)
118
64
 
119
65
  await payload.db.upsert({
120
66
  collection: 'payload-preferences',
121
67
  data: {
122
68
  key: PREF_KEY,
123
69
  user: { relationTo: req.user!.collection, value: req.user!.id },
124
- value: merged as unknown as Record<string, unknown>,
70
+ value: {
71
+ ...merged,
72
+ // Persist the flags without the two projected ones, so `features`
73
+ // and the `autoClose` block can never drift apart.
74
+ features: stripProjectedFeatures(merged.features),
75
+ } as unknown as Record<string, unknown>,
125
76
  },
126
77
  req: { payload, user: req.user } as any,
127
78
  where: {
@@ -135,7 +86,7 @@ export function createSettingsPostEndpoint(slugs: CollectionSlugs): Endpoint {
135
86
 
136
87
  invalidateSupportSettingsCache()
137
88
 
138
- return Response.json(merged)
89
+ return Response.json({ ...merged, featuresConfigured: true })
139
90
  } catch (error) {
140
91
  const authResponse = handleAuthError(error)
141
92
  if (authResponse) return authResponse
package/src/index.ts CHANGED
@@ -21,8 +21,12 @@ export { DEFAULT_FEATURES } from './types'
21
21
  // Utils
22
22
  export { resolveSlugs, DEFAULT_SLUGS } from './utils/slugs'
23
23
  export type { CollectionSlugs } from './utils/slugs'
24
- export { readSupportSettings, readUserPrefs, DEFAULT_SETTINGS, DEFAULT_USER_PREFS } from './utils/readSettings'
25
- export type { SupportSettings, UserPrefs } from './utils/readSettings'
24
+ export { readSupportSettings, readSupportSettingsState, readUserPrefs, DEFAULT_SETTINGS, DEFAULT_USER_PREFS } from './utils/readSettings'
25
+ export type { SupportSettings, SupportSettingsState, UserPrefs } from './utils/readSettings'
26
+ // Runtime feature flags (server-authoritative, see utils/features.ts). Distinct
27
+ // from the build-time `SupportFeatures` passed to `supportPlugin()`.
28
+ export { DEFAULT_TICKETING_FEATURES, normalizeFeatures, projectAutoClose, stripProjectedFeatures } from './utils/features'
29
+ export type { TicketingFeatures } from './utils/features'
26
30
  export { MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter } from './utils/rateLimiter'
27
31
  export type { RateLimitEntry, RateLimitStore } from './utils/rateLimiter'
28
32
  export { DEFAULT_INBOUND_EMAIL_LIMITS, validateInboundEmailPayload, verifySecret } from './utils/webhookSecurity'
@@ -29,6 +29,13 @@ export function LiveChat() {
29
29
  const lastFetchRef = useRef<string | null>(null)
30
30
  const inputRef = useRef<HTMLInputElement>(null)
31
31
  const pollIntervalRef = useRef<ReturnType<typeof setTimeout> | null>(null)
32
+ // Mirror of `messages`. The poll decision (and the unread badge) MUST be taken
33
+ // outside the setMessages updater: React skips the updater on the eager-state
34
+ // path, so a flag assigned inside it is only ever written on the very first
35
+ // update — after which `hasNewMessages` stays false, the adaptive backoff climbs
36
+ // to its 30 s ceiling and never comes back down, mid-conversation. Same pattern
37
+ // as ChatWidget.tsx.
38
+ const messagesRef = useRef<ChatMessage[]>([])
32
39
 
33
40
  // Load session from localStorage on mount
34
41
  useEffect(() => {
@@ -42,6 +49,7 @@ export function LiveChat() {
42
49
 
43
50
  // Auto-scroll to bottom when messages change
44
51
  useEffect(() => {
52
+ messagesRef.current = messages
45
53
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
46
54
  }, [messages])
47
55
 
@@ -61,31 +69,32 @@ export function LiveChat() {
61
69
 
62
70
  const data = await res.json()
63
71
  if (data.messages?.length > 0) {
64
- let hadNewMessages = false
65
- setMessages((prev) => {
66
- const existingIds = new Set(prev.map((m) => String(m.id)))
67
- const newMsgs = data.messages.filter(
68
- (m: ChatMessage) => !existingIds.has(String(m.id)),
69
- )
70
- if (newMsgs.length === 0) return prev
71
-
72
- hadNewMessages = true
73
-
74
- // Count agent messages as unread if chat panel is closed
75
- if (screen === 'closed') {
76
- const agentNewMsgs = newMsgs.filter(
77
- (m: ChatMessage) => m.senderType === 'agent',
78
- )
79
- if (agentNewMsgs.length > 0) {
80
- setUnreadCount((prev) => prev + agentNewMsgs.length)
81
- }
72
+ const existingIds = new Set(messagesRef.current.map((m) => String(m.id)))
73
+ const newMsgs: ChatMessage[] = data.messages.filter(
74
+ (m: ChatMessage) => !existingIds.has(String(m.id)),
75
+ )
76
+ lastFetchRef.current = data.messages[data.messages.length - 1].createdAt
77
+ if (newMsgs.length === 0) return false
78
+
79
+ // Count agent messages as unread if chat panel is closed. Kept OUT of the
80
+ // updater: nesting a setState there also double-counted the badge under
81
+ // StrictMode's double invocation in dev.
82
+ if (screen === 'closed') {
83
+ const agentNewMsgs = newMsgs.filter((m) => m.senderType === 'agent')
84
+ if (agentNewMsgs.length > 0) {
85
+ setUnreadCount((prev) => prev + agentNewMsgs.length)
82
86
  }
87
+ }
83
88
 
84
- return [...prev, ...newMsgs]
89
+ setMessages((prev) => {
90
+ // Re-dedupe against the authoritative state: the mirror can lag by one
91
+ // commit if a message was appended optimistically in the meantime.
92
+ const prevIds = new Set(prev.map((m) => String(m.id)))
93
+ const trulyNew = newMsgs.filter((m) => !prevIds.has(String(m.id)))
94
+ if (trulyNew.length === 0) return prev
95
+ return [...prev, ...trulyNew]
85
96
  })
86
- lastFetchRef.current =
87
- data.messages[data.messages.length - 1].createdAt
88
- return hadNewMessages
97
+ return true
89
98
  }
90
99
  return false
91
100
  } catch {
@@ -127,6 +136,8 @@ export function LiveChat() {
127
136
  // If we have a session, fetch messages
128
137
  if (session.sessionId) {
129
138
  lastFetchRef.current = null
139
+ // Synchronous too: pollMessages() runs before the mirroring effect commits.
140
+ messagesRef.current = []
130
141
  setMessages([])
131
142
  pollMessages()
132
143
  }
@@ -30,8 +30,12 @@ export function TicketPolling({
30
30
 
31
31
  const interval = setInterval(async () => {
32
32
  try {
33
+ // `/count` returns a single number. The previous limit=0 idiom did NOT return
34
+ // zero documents — Payload disables pagination for limit=0 and derives
35
+ // totalDocs from the rows it materialised, so every open portal tab used to
36
+ // re-download its whole conversation every 30 s to read one counter.
33
37
  const res = await fetch(
34
- `/api/ticket-messages?where[ticket][equals]=${ticketId}&limit=0&depth=0`,
38
+ `/api/ticket-messages/count?where[ticket][equals]=${ticketId}`,
35
39
  { credentials: 'include' },
36
40
  )
37
41
  if (res.status === 401 || res.status === 403) {
@@ -1,7 +1,15 @@
1
1
  /**
2
2
  * Ambient stub for 'lucide-react'.
3
- * lucide-react is an optional peer dependency — the host application installs it.
4
- * This stub satisfies TypeScript when the plugin is type-checked in isolation.
3
+ *
4
+ * lucide-react is a REQUIRED peer dependency three unconditionally registered
5
+ * components import it statically, so a host without it fails at build time.
6
+ * It is deliberately not a devDependency here; this stub is what lets the plugin
7
+ * type-check in isolation without pulling the icon set into the repo.
8
+ *
9
+ * Keep the icon list below in sync with the actual imports:
10
+ * src/portal/LiveChat.tsx
11
+ * src/views/PendingEmailsView/client.tsx
12
+ * src/views/TicketingSettingsView/client.tsx
5
13
  */
6
14
  declare module 'lucide-react' {
7
15
  import type { FC, SVGProps } from 'react'
@@ -2,6 +2,7 @@ import type { Payload } from 'payload'
2
2
  import type { CollectionSlugs } from './slugs'
3
3
  import { readSupportSettings, type SupportSettings } from './readSettings'
4
4
  import { dbFind, dbFindByID, dbCreate, dbUpdate } from './db'
5
+ import { resolveOllamaBaseUrl } from './aiProvider'
5
6
 
6
7
  export interface AgentDecision {
7
8
  action: 'reply' | 'escalate'
@@ -61,7 +62,7 @@ export async function applyAgentDecision(
61
62
  function getClient(aiSettings: SupportSettings['ai']) {
62
63
  const Anthropic = require('@anthropic-ai/sdk').default
63
64
  if (aiSettings.provider === 'ollama') {
64
- return new Anthropic({ apiKey: 'ollama', baseURL: process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1' })
65
+ return new Anthropic({ apiKey: 'ollama', baseURL: resolveOllamaBaseUrl() })
65
66
  }
66
67
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
67
68
  }
@@ -0,0 +1,21 @@
1
+ import { APIError } from 'payload'
2
+
3
+ /**
4
+ * Resolve the base URL of the OpenAI-compatible gateway used by the `ollama`
5
+ * provider.
6
+ *
7
+ * There is deliberately NO default here. An integrator who selects `ollama` does
8
+ * so precisely to keep ticket content off third-party infrastructure; a silent
9
+ * fallback to any hosted endpoint would ship the whole conversation — subject,
10
+ * message bodies, client name and company — to a server the operator never chose,
11
+ * and would do so invisibly because the AI answer still comes back normally.
12
+ *
13
+ * Fail loudly instead: a missing `OLLAMA_API_URL` is a configuration error.
14
+ */
15
+ export function resolveOllamaBaseUrl(): string {
16
+ const baseURL = process.env.OLLAMA_API_URL
17
+ if (!baseURL) {
18
+ throw new APIError("provider 'ollama' requires OLLAMA_API_URL", 500)
19
+ }
20
+ return baseURL
21
+ }
@@ -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'