@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,45 +1,69 @@
1
- import type { CollectionConfig, CollectionAfterChangeHook } from 'payload'
1
+ import type { CollectionConfig, CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
2
+ import type { Payload } from 'payload'
2
3
  import type { CollectionSlugs } from '../utils/slugs'
3
4
 
4
5
  // ─── Hooks ───────────────────────────────────────────────
5
6
 
6
- function createRecalculateTicketTime(slugs: CollectionSlugs): CollectionAfterChangeHook {
7
- return async ({ doc, req }) => {
8
- if (!doc.ticket) return
7
+ async function recalculateTicketTime(
8
+ payload: Payload,
9
+ slugs: CollectionSlugs,
10
+ ticketId: number | string,
11
+ ): Promise<void> {
12
+ // Sum all time entries for this ticket, paginating to avoid loading all at once
13
+ let totalMinutes = 0
14
+ let page = 1
15
+ let hasMore = true
9
16
 
10
- const { payload } = req
11
- const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
17
+ while (hasMore) {
18
+ const entries = await payload.find({
19
+ collection: slugs.timeEntries,
20
+ where: { ticket: { equals: ticketId } },
21
+ limit: 100,
22
+ page,
23
+ depth: 0,
24
+ overrideAccess: true,
25
+ select: { duration: true },
26
+ })
12
27
 
13
- // Sum all time entries for this ticket, paginating to avoid loading all at once
14
- let totalMinutes = 0
15
- let page = 1
16
- let hasMore = true
28
+ for (const entry of entries.docs) {
29
+ totalMinutes += ((entry.duration as number) || 0)
30
+ }
17
31
 
18
- while (hasMore) {
19
- const entries = await payload.find({
20
- collection: slugs.timeEntries,
21
- where: { ticket: { equals: ticketId } },
22
- limit: 100,
23
- page,
24
- depth: 0,
25
- overrideAccess: true,
26
- select: { duration: true },
27
- })
32
+ hasMore = entries.hasNextPage ?? false
33
+ page++
34
+ }
28
35
 
29
- for (const entry of entries.docs) {
30
- totalMinutes += ((entry.duration as number) || 0)
31
- }
36
+ await payload.update({
37
+ collection: slugs.tickets,
38
+ id: ticketId,
39
+ data: { totalTimeMinutes: totalMinutes },
40
+ overrideAccess: true,
41
+ })
42
+ }
32
43
 
33
- hasMore = entries.hasNextPage ?? false
34
- page++
35
- }
44
+ function createRecalculateTicketTime(slugs: CollectionSlugs): CollectionAfterChangeHook {
45
+ return async ({ doc, req }) => {
46
+ if (!doc.ticket) return
47
+ const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
48
+ await recalculateTicketTime(req.payload, slugs, ticketId)
49
+ }
50
+ }
36
51
 
37
- await payload.update({
38
- collection: slugs.tickets,
39
- id: ticketId,
40
- data: { totalTimeMinutes: totalMinutes },
41
- overrideAccess: true,
42
- })
52
+ /**
53
+ * Deleting a time entry has to re-roll the ticket total too. Without this the
54
+ * rollup only ever grows: `tickets.totalTimeMinutes` is what the invoice endpoint
55
+ * and the CRM view bill on, so a removed entry stayed billed forever.
56
+ */
57
+ function createRecalculateTicketTimeOnDelete(slugs: CollectionSlugs): CollectionAfterDeleteHook {
58
+ return async ({ doc, req }) => {
59
+ if (!doc?.ticket) return
60
+ const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
61
+ try {
62
+ await recalculateTicketTime(req.payload, slugs, ticketId)
63
+ } catch (err) {
64
+ // The entry is already gone; never fail the delete on the rollup refresh.
65
+ console.error('[support] Failed to recalculate ticket time after delete:', err)
66
+ }
43
67
  }
44
68
  }
45
69
 
@@ -105,6 +129,7 @@ export function createTimeEntriesCollection(slugs: CollectionSlugs): CollectionC
105
129
  ],
106
130
  hooks: {
107
131
  afterChange: [createRecalculateTicketTime(slugs)],
132
+ afterDelete: [createRecalculateTicketTimeOnDelete(slugs)],
108
133
  },
109
134
  access: {
110
135
  create: ({ req }) => req.user?.collection === slugs.users,
@@ -1,82 +1,18 @@
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
+ * The flags used to be defined (twice, byte-for-byte) here and in
5
+ * `views/shared/config.ts`, each reading `localStorage` directly. The single
6
+ * implementation now lives in `utils/features.ts` and is backed by the server;
7
+ * both modules are kept as import sites so existing paths keep working.
5
8
  */
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
- }
9
+ export {
10
+ DEFAULT_TICKETING_FEATURES,
11
+ DEFAULT_TICKETING_FEATURES as DEFAULT_FEATURES,
12
+ FEATURES_CACHE_KEY,
13
+ fetchFeatures,
14
+ getFeatures,
15
+ normalizeFeatures,
16
+ saveFeatures,
17
+ } from '../../utils/features'
18
+ export type { TicketingFeatures } from '../../utils/features'
@@ -0,0 +1,109 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import {
5
+ fetchFeatures,
6
+ getFeatures,
7
+ hasAdoptedLegacyFeatures,
8
+ markLegacyFeaturesAdopted,
9
+ readLegacyFeatures,
10
+ saveFeatures as persistFeatures,
11
+ writeFeaturesCache,
12
+ type TicketingFeatures,
13
+ } from '../../../utils/features'
14
+
15
+ export interface UseFeaturesResult {
16
+ /** Current flags — cached values on first paint, server values once loaded. */
17
+ features: TicketingFeatures
18
+ /** True until the first server round-trip settles. */
19
+ loading: boolean
20
+ /** True when the server could not be reached and cached/default values are shown. */
21
+ offline: boolean
22
+ /** Replace the flags locally without persisting (for a settings form). */
23
+ setFeatures: (features: TicketingFeatures) => void
24
+ /** Persist the flags server-side. Resolves to false when the write failed. */
25
+ save: (features: TicketingFeatures) => Promise<boolean>
26
+ }
27
+
28
+ /**
29
+ * Read the ticketing feature flags from the server, with the local cache as a
30
+ * fallback so a screen never blocks on the settings call.
31
+ *
32
+ * Also performs the one-shot migration of pre-2.1 installs: when this browser
33
+ * still holds flags in `localStorage` and the server has never had any saved,
34
+ * the local values are pushed up once and become the shared truth. Afterwards
35
+ * the server always wins and `localStorage` is only a cache.
36
+ */
37
+ export function useFeatures(): UseFeaturesResult {
38
+ const [features, setFeatures] = useState<TicketingFeatures>(() => getFeatures())
39
+ const [loading, setLoading] = useState(true)
40
+ const [offline, setOffline] = useState(false)
41
+ // Migration must run once per mount at most, even under StrictMode double-effects.
42
+ const migrationAttempted = useRef(false)
43
+ // Set as soon as the caller edits the flags, so a slow first load cannot
44
+ // overwrite a toggle the admin just made in the settings form.
45
+ const dirty = useRef(false)
46
+
47
+ useEffect(() => {
48
+ const controller = new AbortController()
49
+ let cancelled = false
50
+
51
+ void (async () => {
52
+ const result = await fetchFeatures(controller.signal)
53
+ if (cancelled) return
54
+
55
+ // Fresh server + legacy browser values => adopt them instead of silently
56
+ // resetting the admin's configuration to the defaults.
57
+ if (!result.offline && !result.configured && !migrationAttempted.current && !dirty.current) {
58
+ migrationAttempted.current = true
59
+ const legacy = readLegacyFeatures()
60
+ if (legacy) {
61
+ const ok = await persistFeatures(legacy)
62
+ if (cancelled) return
63
+ if (ok) {
64
+ markLegacyFeaturesAdopted()
65
+ setFeatures(legacy)
66
+ setLoading(false)
67
+ return
68
+ }
69
+ }
70
+ }
71
+
72
+ if (!result.offline && !hasAdoptedLegacyFeatures()) {
73
+ // Server already configured (by this admin elsewhere, or another admin):
74
+ // the server wins, and there is nothing left to migrate here.
75
+ markLegacyFeaturesAdopted()
76
+ }
77
+
78
+ if (!dirty.current) setFeatures(result.features)
79
+ setOffline(result.offline)
80
+ setLoading(false)
81
+ })()
82
+
83
+ return () => {
84
+ cancelled = true
85
+ controller.abort()
86
+ }
87
+ }, [])
88
+
89
+ const setLocalFeatures = useCallback((next: TicketingFeatures) => {
90
+ dirty.current = true
91
+ setFeatures(next)
92
+ // Keep the cache aligned with what is on screen, so a reload before saving
93
+ // does not flash the previous values.
94
+ writeFeaturesCache(next)
95
+ }, [])
96
+
97
+ const save = useCallback(async (next: TicketingFeatures) => {
98
+ const ok = await persistFeatures(next)
99
+ if (ok) {
100
+ markLegacyFeaturesAdopted()
101
+ dirty.current = false
102
+ setFeatures(next)
103
+ setOffline(false)
104
+ }
105
+ return ok
106
+ }, [])
107
+
108
+ return { features, loading, offline, setFeatures: setLocalFeatures, save }
109
+ }
@@ -1,22 +1,56 @@
1
1
  import { useState, useCallback, useEffect } from 'react'
2
2
  import frTranslations from '../locales/fr.json'
3
3
  import enTranslations from '../locales/en.json'
4
+ import frViewTranslations from '../../../views/shared/locales/fr.json'
5
+ import enViewTranslations from '../../../views/shared/locales/en.json'
4
6
 
5
7
  type Locale = 'fr' | 'en'
6
8
 
7
9
  const LOCALE_STORAGE_KEY = 'support_locale'
8
10
  const DEFAULT_LOCALE: Locale = 'fr'
9
11
 
12
+ function mergeTranslations(
13
+ base: Record<string, unknown>,
14
+ supplement: Record<string, unknown>,
15
+ ): Record<string, unknown> {
16
+ const merged = { ...base }
17
+
18
+ for (const [key, value] of Object.entries(supplement)) {
19
+ const current = merged[key]
20
+
21
+ if (
22
+ current != null
23
+ && value != null
24
+ && typeof current === 'object'
25
+ && typeof value === 'object'
26
+ && !Array.isArray(current)
27
+ && !Array.isArray(value)
28
+ ) {
29
+ merged[key] = mergeTranslations(
30
+ current as Record<string, unknown>,
31
+ value as Record<string, unknown>,
32
+ )
33
+ continue
34
+ }
35
+
36
+ if (current === undefined || (typeof current !== 'object' && typeof value === 'object')) {
37
+ merged[key] = value
38
+ }
39
+ }
40
+
41
+ return merged
42
+ }
43
+
10
44
  const translations: Record<Locale, Record<string, unknown>> = {
11
- fr: frTranslations,
12
- en: enTranslations,
45
+ fr: mergeTranslations(frTranslations, frViewTranslations),
46
+ en: mergeTranslations(enTranslations, enViewTranslations),
13
47
  }
14
48
 
15
49
  /**
16
50
  * Resolve a dot-notation key from a nested object.
17
51
  * e.g. getNestedValue(obj, 'ticket.status.open') => 'Ouvert'
18
52
  */
19
- function getNestedValue(obj: Record<string, unknown>, path: string): string {
53
+ export function getNestedValue(obj: Record<string, unknown>, path: string): string {
20
54
  const keys = path.split('.')
21
55
  let current: unknown = obj
22
56
 
@@ -113,4 +147,4 @@ export function useTranslation() {
113
147
  }
114
148
 
115
149
  export type { Locale }
116
- export { DEFAULT_LOCALE, LOCALE_STORAGE_KEY }
150
+ export { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, translations }
@@ -21,14 +21,14 @@ import { useTicketActions } from './hooks/useTicketActions'
21
21
  import { useReply } from './hooks/useReply'
22
22
  import { useAI } from './hooks/useAI'
23
23
  import { useDocumentIdFromUrl } from './hooks/useDocumentIdFromUrl'
24
- import { getFeatures, type TicketingFeatures } from './config'
24
+ import { useFeatures } from './hooks/useFeatures'
25
25
  import { SkeletonText } from './SkeletonText'
26
26
  import { RewriteDropdown } from './RewriteDropdown'
27
27
  import '../../styles/theme.css'
28
28
 
29
29
  const TicketConversation: React.FC = () => {
30
30
  const { id } = useDocumentIdFromUrl()
31
- const [features] = useState<TicketingFeatures>(() => getFeatures())
31
+ const { features } = useFeatures()
32
32
  const [messages, setMessages] = useState<Message[]>([])
33
33
  const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([])
34
34
  const [client, setClient] = useState<ClientInfo | null>(null)
@@ -3,12 +3,13 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
4
  import { readSupportSettings, type SupportSettings } from '../utils/readSettings'
5
5
  import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
6
+ import { resolveOllamaBaseUrl } from '../utils/aiProvider'
6
7
 
7
8
  async function getClient(aiSettings: SupportSettings['ai']) {
8
9
  const moduleName = '@anthropic-ai/sdk'
9
10
  const { default: Anthropic } = await import(moduleName)
10
11
  if (aiSettings.provider === 'ollama') {
11
- const baseURL = process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1'
12
+ const baseURL = resolveOllamaBaseUrl()
12
13
  return new Anthropic({ apiKey: 'ollama', baseURL })
13
14
  }
14
15
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
@@ -3,12 +3,13 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
4
  import { readSupportSettings, type SupportSettings } from '../utils/readSettings'
5
5
  import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
6
+ import { resolveOllamaBaseUrl } from '../utils/aiProvider'
6
7
 
7
8
  async function getClient(aiSettings: SupportSettings['ai']) {
8
9
  const moduleName = '@anthropic-ai/sdk'
9
10
  const { default: Anthropic } = await import(moduleName)
10
11
  if (aiSettings.provider === 'ollama') {
11
- const baseURL = process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1'
12
+ const baseURL = resolveOllamaBaseUrl()
12
13
  return new Anthropic({ apiKey: 'ollama', baseURL })
13
14
  }
14
15
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
@@ -1,7 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { escapeHtml } from '../utils/emailTemplate'
4
- import { fireWebhooks } from '../utils/fireWebhooks'
4
+ import { dispatchWebhook } from '../utils/webhookDispatcher'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
6
  import { dbFind, dbFindByID, dbUpdate } from '../utils/db'
7
7
  import { verifySecret } from '../utils/webhookSecurity'
@@ -112,13 +112,18 @@ export function createProcessScheduledEndpoint(slugs: CollectionSlugs): Endpoint
112
112
  }
113
113
 
114
114
  // Fire webhook for the now-sent reply
115
- fireWebhooks(payload, slugs, 'ticket_replied', {
116
- ticketId,
117
- messageId: msg.id,
118
- authorType: msg.authorType,
119
- scheduled: true,
120
- body: msg.body?.length > 500 ? msg.body.slice(0, 500) + '...' : msg.body,
121
- })
115
+ dispatchWebhook(
116
+ {
117
+ ticketId,
118
+ messageId: msg.id,
119
+ authorType: msg.authorType,
120
+ scheduled: true,
121
+ body: msg.body?.length > 500 ? msg.body.slice(0, 500) + '...' : msg.body,
122
+ },
123
+ 'ticket_replied',
124
+ payload,
125
+ slugs,
126
+ )
122
127
 
123
128
  results.processed++
124
129
  } catch (err) {
@@ -1,9 +1,24 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
- import { dbFind } from '../utils/db'
4
+ import {
5
+ SUPPORT_SETTINGS_PREF_KEY as PREF_KEY,
6
+ invalidateSupportSettingsCache,
7
+ mergeSupportSettings,
8
+ readSupportSettingsState,
9
+ } from '../utils/readSettings'
10
+ import { stripProjectedFeatures } from '../utils/features'
5
11
 
6
- const PREF_KEY = 'support-round-robin'
12
+ /**
13
+ * Round-robin used to live in its own `support-round-robin` preference row,
14
+ * while the admin UI toggled a `roundRobin` flag in `localStorage` — the two
15
+ * never met, so the toggle had no effect on assignment.
16
+ *
17
+ * Both endpoints below now read and write `settings.features.roundRobin`, the
18
+ * same value `/api/support/settings` exposes and `Tickets`' auto-assign hook
19
+ * reads. The legacy row is still honoured on read until the first save (see
20
+ * `readSupportSettingsState`), so an install that had it enabled keeps it.
21
+ */
7
22
 
8
23
  /**
9
24
  * GET /api/support/round-robin-config — Get round-robin enabled status
@@ -18,18 +33,9 @@ export function createRoundRobinConfigGetEndpoint(slugs: CollectionSlugs): Endpo
18
33
 
19
34
  requireAdmin(req, slugs)
20
35
 
21
- const prefs = await dbFind(payload, 'payload-preferences', {
22
- where: { key: { equals: PREF_KEY } },
23
- limit: 1,
24
- depth: 0,
25
- overrideAccess: true,
26
- })
27
-
28
- const enabled = prefs.docs.length > 0
29
- ? (prefs.docs[0].value as { enabled?: boolean })?.enabled === true
30
- : false
36
+ const { settings } = await readSupportSettingsState(payload)
31
37
 
32
- return Response.json({ enabled })
38
+ return Response.json({ enabled: settings.features.roundRobin })
33
39
  } catch (error) {
34
40
  const authResponse = handleAuthError(error)
35
41
  if (authResponse) return authResponse
@@ -55,19 +61,21 @@ export function createRoundRobinConfigPostEndpoint(slugs: CollectionSlugs): Endp
55
61
 
56
62
  const { enabled } = (await req.json!()) as { enabled: boolean }
57
63
 
58
- const existing = await dbFind(payload, 'payload-preferences', {
59
- where: { key: { equals: PREF_KEY } },
60
- limit: 1,
61
- depth: 0,
62
- overrideAccess: true,
63
- })
64
+ const { settings: current } = await readSupportSettingsState(payload)
65
+ const merged = mergeSupportSettings(
66
+ { features: { ...current.features, roundRobin: !!enabled } },
67
+ current,
68
+ )
64
69
 
65
70
  await payload.db.upsert({
66
71
  collection: 'payload-preferences',
67
72
  data: {
68
73
  key: PREF_KEY,
69
74
  user: { relationTo: req.user!.collection, value: req.user!.id },
70
- value: { enabled: !!enabled },
75
+ value: {
76
+ ...merged,
77
+ features: stripProjectedFeatures(merged.features),
78
+ } as unknown as Record<string, unknown>,
71
79
  },
72
80
  req: { payload, user: req.user } as any,
73
81
  where: {
@@ -79,6 +87,8 @@ export function createRoundRobinConfigPostEndpoint(slugs: CollectionSlugs): Endp
79
87
  },
80
88
  })
81
89
 
90
+ invalidateSupportSettingsCache()
91
+
82
92
  return Response.json({ enabled: !!enabled })
83
93
  } catch (error) {
84
94
  const authResponse = handleAuthError(error)