@consilioweb/payload-support 2.0.1 → 4.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 (246) hide show
  1. package/README.md +670 -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 +940 -404
  33. package/dist/index.d.cts +119 -7
  34. package/dist/index.d.ts +119 -7
  35. package/dist/index.js +936 -405
  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/ChatMessages.ts +59 -2
  88. package/src/collections/ClientSummaries.ts +10 -4
  89. package/src/collections/SupportClients.ts +29 -0
  90. package/src/collections/TicketCollaborators.ts +6 -1
  91. package/src/collections/TicketMessages.ts +49 -24
  92. package/src/collections/Tickets.ts +117 -79
  93. package/src/collections/TimeEntries.ts +57 -32
  94. package/src/collections/WebhookEndpoints.ts +44 -2
  95. package/src/components/TicketConversation/config.ts +16 -80
  96. package/src/components/TicketConversation/hooks/useFeatures.ts +109 -0
  97. package/src/components/TicketConversation/index.tsx +2 -2
  98. package/src/endpoints/admin-chat.ts +3 -3
  99. package/src/endpoints/ai-agent.ts +3 -3
  100. package/src/endpoints/ai.ts +5 -4
  101. package/src/endpoints/auth-2fa.ts +16 -4
  102. package/src/endpoints/capabilities.ts +6 -6
  103. package/src/endpoints/chat.ts +7 -5
  104. package/src/endpoints/chatbot.ts +48 -2
  105. package/src/endpoints/client-intelligence.ts +6 -5
  106. package/src/endpoints/email-stats.ts +19 -3
  107. package/src/endpoints/import-conversation.ts +1 -1
  108. package/src/endpoints/index.ts +1 -1
  109. package/src/endpoints/invite-collaborator.ts +29 -3
  110. package/src/endpoints/login.ts +15 -2
  111. package/src/endpoints/oauth-google.ts +130 -8
  112. package/src/endpoints/process-scheduled.ts +13 -8
  113. package/src/endpoints/resend-notification.ts +3 -3
  114. package/src/endpoints/round-robin-config.ts +30 -20
  115. package/src/endpoints/send-reminder.ts +3 -3
  116. package/src/endpoints/settings.ts +28 -77
  117. package/src/endpoints/signature.ts +9 -2
  118. package/src/endpoints/ticket-synthesis.ts +3 -3
  119. package/src/endpoints/transfer-ticket.ts +28 -3
  120. package/src/endpoints/typing.ts +117 -14
  121. package/src/endpoints/user-prefs.ts +5 -2
  122. package/src/index.ts +6 -2
  123. package/src/plugin.ts +12 -0
  124. package/src/portal/LiveChat.tsx +33 -22
  125. package/src/portal/auth/layout.tsx +19 -1
  126. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  127. package/src/portal/auth/tickets/detail/TicketPolling.tsx +5 -1
  128. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  129. package/src/portal/login/page.tsx +11 -3
  130. package/src/types/lucide-react.d.ts +10 -2
  131. package/src/utils/aiAgent.ts +2 -1
  132. package/src/utils/aiProvider.ts +21 -0
  133. package/src/utils/features.ts +297 -0
  134. package/src/utils/fireWebhooks.ts +10 -1
  135. package/src/utils/generateTicketSynthesis.ts +2 -1
  136. package/src/utils/index.ts +4 -2
  137. package/src/utils/rateLimiter.ts +39 -5
  138. package/src/utils/readSettings.ts +199 -16
  139. package/src/utils/ticketAccess.ts +16 -1
  140. package/src/utils/twoFactorChallenge.ts +80 -0
  141. package/src/utils/urlSafety.ts +230 -0
  142. package/src/utils/webhookDispatcher.ts +24 -8
  143. package/src/views/ChatView/client.tsx +13 -9
  144. package/src/views/CrmView/client.tsx +10 -11
  145. package/src/views/TicketDetailView/client.tsx +2 -2
  146. package/src/views/TicketInboxView/client.tsx +10 -5
  147. package/src/views/TicketingSettingsView/client.tsx +8 -6
  148. package/src/views/shared/config.ts +14 -80
  149. package/src/views/shared/index.ts +3 -1
  150. package/dist/components/RichTextEditor/index.cjs +0 -279
  151. package/dist/components/TicketConversation/RewriteDropdown.cjs +0 -98
  152. package/dist/components/TicketConversation/SkeletonText.cjs +0 -21
  153. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +0 -85
  154. package/dist/components/TicketConversation/components/ActionPanels.cjs +0 -193
  155. package/dist/components/TicketConversation/components/ActivityLog.cjs +0 -24
  156. package/dist/components/TicketConversation/components/ClientBar.cjs +0 -43
  157. package/dist/components/TicketConversation/components/ClientHistory.cjs +0 -138
  158. package/dist/components/TicketConversation/components/CodeBlock.cjs +0 -171
  159. package/dist/components/TicketConversation/components/CodeBlockInserter.cjs +0 -156
  160. package/dist/components/TicketConversation/components/QuickActions.cjs +0 -87
  161. package/dist/components/TicketConversation/components/TicketHeader.cjs +0 -93
  162. package/dist/components/TicketConversation/components/TimeTrackingPanel.cjs +0 -134
  163. package/dist/components/TicketConversation/config.cjs +0 -44
  164. package/dist/components/TicketConversation/constants.cjs +0 -127
  165. package/dist/components/TicketConversation/context.cjs +0 -13
  166. package/dist/components/TicketConversation/hooks/useAI.cjs +0 -196
  167. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +0 -20
  168. package/dist/components/TicketConversation/hooks/useMessageActions.cjs +0 -136
  169. package/dist/components/TicketConversation/hooks/useReply.cjs +0 -188
  170. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +0 -290
  171. package/dist/components/TicketConversation/hooks/useTimeTracking.cjs +0 -123
  172. package/dist/components/TicketConversation/hooks/useTranslation.cjs +0 -101
  173. package/dist/components/TicketConversation/index.cjs +0 -1114
  174. package/dist/components/TicketConversation/types.cjs +0 -2
  175. package/dist/components/TicketConversation/utils.cjs +0 -27
  176. package/dist/views/BillingView/client.cjs +0 -469
  177. package/dist/views/BillingView/index.cjs +0 -34
  178. package/dist/views/ChatView/client.cjs +0 -301
  179. package/dist/views/ChatView/index.cjs +0 -34
  180. package/dist/views/CrmView/client.cjs +0 -458
  181. package/dist/views/CrmView/index.cjs +0 -34
  182. package/dist/views/EmailTrackingView/client.cjs +0 -171
  183. package/dist/views/EmailTrackingView/index.cjs +0 -34
  184. package/dist/views/ImportConversationView/client.cjs +0 -238
  185. package/dist/views/ImportConversationView/index.cjs +0 -34
  186. package/dist/views/LogsView/client.cjs +0 -148
  187. package/dist/views/LogsView/index.cjs +0 -32
  188. package/dist/views/NewTicketView/client.cjs +0 -213
  189. package/dist/views/NewTicketView/index.cjs +0 -32
  190. package/dist/views/PendingEmailsView/client.cjs +0 -471
  191. package/dist/views/PendingEmailsView/index.cjs +0 -34
  192. package/dist/views/SupportDashboardView/client.cjs +0 -437
  193. package/dist/views/SupportDashboardView/index.cjs +0 -34
  194. package/dist/views/TicketDetailView/NextActionItem.cjs +0 -40
  195. package/dist/views/TicketDetailView/RewriteDropdown.cjs +0 -80
  196. package/dist/views/TicketDetailView/client.cjs +0 -1412
  197. package/dist/views/TicketDetailView/constants.cjs +0 -20
  198. package/dist/views/TicketDetailView/helpers.cjs +0 -23
  199. package/dist/views/TicketDetailView/index.cjs +0 -34
  200. package/dist/views/TicketDetailView/types.cjs +0 -2
  201. package/dist/views/TicketInboxView/client.cjs +0 -359
  202. package/dist/views/TicketInboxView/index.cjs +0 -32
  203. package/dist/views/TicketingSettingsView/client.cjs +0 -740
  204. package/dist/views/TicketingSettingsView/index.cjs +0 -34
  205. package/dist/views/TimeDashboardView/client.cjs +0 -180
  206. package/dist/views/TimeDashboardView/index.cjs +0 -34
  207. package/dist/views/shared/AdminViewHeader.cjs +0 -68
  208. package/dist/views/shared/ErrorBoundary.cjs +0 -55
  209. package/dist/views/shared/Skeleton.cjs +0 -77
  210. package/dist/views/shared/StatusPill.cjs +0 -47
  211. package/dist/views/shared/adminTokens.cjs +0 -34
  212. package/dist/views/shared/config.cjs +0 -44
  213. package/dist/views/shared/dateLocale.cjs +0 -5
  214. package/dist/views/shared/index.cjs +0 -72
  215. package/dist/views/shared/sla.cjs +0 -37
  216. package/dist/views.cjs +0 -86
  217. package/dist/views.d.cts +0 -13
  218. package/src/__tests__/aiSummaryRendering.test.ts +0 -21
  219. package/src/__tests__/authResponses.test.ts +0 -69
  220. package/src/__tests__/capabilities.test.ts +0 -71
  221. package/src/__tests__/generateTrackingToken.test.ts +0 -67
  222. package/src/__tests__/integration/ai-agent.test.ts +0 -46
  223. package/src/__tests__/integration/auth.test.ts +0 -43
  224. package/src/__tests__/integration/automation-rules.test.ts +0 -68
  225. package/src/__tests__/integration/buildTestPayload.ts +0 -52
  226. package/src/__tests__/integration/channels.test.ts +0 -52
  227. package/src/__tests__/integration/core-behaviors.test.ts +0 -81
  228. package/src/__tests__/integration/dashboard-volume.test.ts +0 -31
  229. package/src/__tests__/integration/digest.test.ts +0 -48
  230. package/src/__tests__/integration/invoice-pdf.test.ts +0 -30
  231. package/src/__tests__/integration/isolation.test.ts +0 -81
  232. package/src/__tests__/integration/mentions-invoice.test.ts +0 -50
  233. package/src/__tests__/integration/nps.test.ts +0 -37
  234. package/src/__tests__/integration/per-team-sla.test.ts +0 -44
  235. package/src/__tests__/integration/push.test.ts +0 -75
  236. package/src/__tests__/integration/sanitization.test.ts +0 -35
  237. package/src/__tests__/integration/sla-pause.test.ts +0 -39
  238. package/src/__tests__/integration/smoke.test.ts +0 -26
  239. package/src/__tests__/integration/snooze.test.ts +0 -68
  240. package/src/__tests__/integration/teams.test.ts +0 -38
  241. package/src/__tests__/rateLimiter.test.ts +0 -83
  242. package/src/__tests__/sanitizeHtml.test.ts +0 -165
  243. package/src/__tests__/sla.test.ts +0 -258
  244. package/src/__tests__/trackOpenEndpoint.test.ts +0 -54
  245. package/src/__tests__/translations.test.ts +0 -67
  246. 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
+ }
@@ -1,11 +1,18 @@
1
1
  import type { Payload } from 'payload'
2
2
  import type { CollectionSlugs } from './slugs'
3
3
  import { dbFind, dbUpdate } from './db'
4
+ import { safeFetch } from './urlSafety'
4
5
 
5
6
  /**
6
7
  * Fire all active webhooks matching the given event.
7
8
  * Uses fire-and-forget pattern (Promise.allSettled) so hook callers are never blocked.
8
9
  * Each webhook request has a 10-second timeout.
10
+ *
11
+ * @deprecated Use `dispatchWebhook` instead. This transport sends the endpoint
12
+ * secret IN CLEAR in the `X-Webhook-Secret` header, which contradicts the
13
+ * description of the `secret` field (HMAC-SHA256 signature) and defeats the point
14
+ * of signing. No hook in the plugin calls it any more — it stays exported only
15
+ * because it is part of the public surface, and will be removed in the next major.
9
16
  */
10
17
  export async function fireWebhooks(
11
18
  payload: Payload,
@@ -33,7 +40,9 @@ export async function fireWebhooks(
33
40
  await Promise.allSettled(
34
41
  endpoints.docs.map(async (endpoint: any) => {
35
42
  try {
36
- const res = await fetch(endpoint.url, {
43
+ // Same SSRF guards as `dispatchWebhook` — this transport is deprecated
44
+ // but still exported, so it must not stay an open door.
45
+ const res = await safeFetch(endpoint.url, {
37
46
  method: 'POST',
38
47
  headers: {
39
48
  'Content-Type': 'application/json',
@@ -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'
@@ -119,27 +119,61 @@ export class PayloadRateLimitStore implements RateLimitStore {
119
119
  }
120
120
  }
121
121
 
122
+ /**
123
+ * Identity part of a rate-limit key for an authenticated caller.
124
+ *
125
+ * Ids are per-collection sequences: support-client #7 and agent #7 are different
126
+ * people with the same `id`. Keying a limiter on `String(user.id)` alone let one
127
+ * consume the other's budget, so the auth collection travels with the id.
128
+ */
129
+ export function principalRateKey(
130
+ user: { id?: unknown; collection?: unknown } | null | undefined,
131
+ ): string {
132
+ if (!user || user.id === undefined || user.id === null) return 'anonymous'
133
+ const collection = typeof user.collection === 'string' && user.collection ? user.collection : 'unknown'
134
+ return `${collection}:${String(user.id)}`
135
+ }
136
+
122
137
  export class RateLimiter {
123
138
  private readonly store: RateLimitStore
124
-
139
+ private readonly prefix: string
140
+
141
+ /**
142
+ * @param namespace Endpoint-scoped prefix for every key this limiter writes.
143
+ * The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
144
+ * all endpoints), and the raw keys collide across endpoints: `ip` was used
145
+ * by both the login and the chatbot limiter — 10 forged chatbot requests
146
+ * locked a victim out of the portal for 15 minutes — and `String(user.id)`
147
+ * by five different endpoints. Always pass one; it is optional only because
148
+ * `RateLimiter` is part of the published API surface.
149
+ */
125
150
  constructor(
126
151
  private readonly windowMs: number,
127
152
  private readonly maxRequests: number,
128
153
  store?: RateLimitStore,
154
+ namespace?: string,
129
155
  ) {
130
156
  this.store = store ?? new MemoryRateLimitStore()
157
+ this.prefix = namespace ? `${namespace}:` : ''
158
+ }
159
+
160
+ /** The key actually written to the store. Exposed for assertions in tests. */
161
+ scopedKey(key: string): string {
162
+ return `${this.prefix}${key}`
131
163
  }
132
164
 
133
165
  async check(key: string, context?: unknown): Promise<boolean> {
166
+ const scoped = this.scopedKey(key)
134
167
  const entry = context === undefined
135
- ? await this.store.increment(key, this.windowMs)
136
- : await this.store.increment(key, this.windowMs, context)
168
+ ? await this.store.increment(scoped, this.windowMs)
169
+ : await this.store.increment(scoped, this.windowMs, context)
137
170
  return entry.count > this.maxRequests
138
171
  }
139
172
 
140
173
  async reset(key: string, context?: unknown): Promise<void> {
141
- if (context === undefined) await this.store.reset(key)
142
- else await this.store.reset(key, context)
174
+ const scoped = this.scopedKey(key)
175
+ if (context === undefined) await this.store.reset(scoped)
176
+ else await this.store.reset(scoped, context)
143
177
  }
144
178
  }
145
179
  import { commitTransaction, initTransaction, killTransaction, type PayloadRequest } from 'payload'
@@ -1,14 +1,69 @@
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'
15
+
16
+ /**
17
+ * Key under which `plugin.ts` publishes the resolved staff collection slug on
18
+ * `config.custom`. It is the ONE source of truth shared by the read path (here)
19
+ * and the write path (`requireAdmin` → `slugs.users`).
20
+ */
21
+ export const SUPPORT_STAFF_SLUG_CONFIG_KEY = 'supportStaffCollection'
22
+
23
+ /**
24
+ * Owner scope for every `payload-preferences` row this plugin reads back.
25
+ *
26
+ * `payload-preferences` is writable by ANY authenticated principal, whatever its
27
+ * auth collection: Payload's `POST /api/payload-preferences/:key` handler only
28
+ * checks `!!req.user` before upserting the row. Reading a plugin-wide row by key
29
+ * alone therefore lets a front-office user (or a support-client) plant their own
30
+ * row and have the whole plugin read it — replyTo addresses, SLA escalation
31
+ * address, AI provider, feature flags.
32
+ *
33
+ * Every read below is constrained to `user.relationTo = <staff collection>`, the
34
+ * same scope the WRITE path already uses (endpoints/settings.ts, signature.ts,
35
+ * user-prefs.ts all upsert with `req.user.collection`, and all three are guarded
36
+ * by `requireAdmin`, which compares against `slugs.users`).
37
+ *
38
+ * TWO SOURCES OF TRUTH WERE THE BUG: this used to resolve the scope from
39
+ * `config.admin.user`, which Payload defaults to the FIRST auth collection of
40
+ * the app when the integrator did not declare it (config/sanitize.js). On a host
41
+ * whose first auth collection is the front office and whose staff collection is
42
+ * declared through `collectionSlugs.users`, the read scope named the front
43
+ * office while the write scope named the staff — and the poisoning this scope
44
+ * was added to defeat was open again. So the plugin now PUBLISHES the resolved
45
+ * `slugs.users` on `config.custom` (see plugin.ts) and reads it back here.
46
+ * `config.admin.user` remains a last-resort fallback for callers using these
47
+ * helpers outside of a plugin-built config; there is no plugin deployment in
48
+ * which it is consulted.
49
+ */
50
+ export function resolveStaffPrefSlug(payload: Payload, staffSlug?: string): string {
51
+ if (staffSlug) return staffSlug
52
+ const config = (payload as unknown as {
53
+ config?: { admin?: { user?: string }; custom?: Record<string, unknown> }
54
+ }).config
55
+ const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY]
56
+ if (typeof registered === 'string' && registered) return registered
57
+ return config?.admin?.user || 'users'
58
+ }
6
59
 
7
60
  export interface SupportSettings {
8
61
  email: { fromAddress: string; fromName: string; replyToAddress: string }
9
62
  ai: { provider: string; model: string; enableSentiment: boolean; enableSynthesis: boolean; enableSuggestion: boolean; enableRewrite: boolean }
10
63
  sla: { firstResponseMinutes: number; resolutionMinutes: number; businessHoursOnly: boolean; escalationEmail: string }
11
64
  autoClose: { enabled: boolean; daysBeforeClose: number; reminderDaysBefore: number }
65
+ /** Ticketing feature flags — server-authoritative since 2.1 (used to be per-browser localStorage). */
66
+ features: TicketingFeatures
12
67
  }
13
68
 
14
69
  export interface UserPrefs {
@@ -21,6 +76,7 @@ export const DEFAULT_SETTINGS: SupportSettings = {
21
76
  ai: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
22
77
  sla: { firstResponseMinutes: 120, resolutionMinutes: 1440, businessHoursOnly: true, escalationEmail: '' },
23
78
  autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 },
79
+ features: { ...DEFAULT_TICKETING_FEATURES },
24
80
  }
25
81
 
26
82
  export const DEFAULT_USER_PREFS: UserPrefs = {
@@ -32,43 +88,170 @@ export const DEFAULT_USER_PREFS: UserPrefs = {
32
88
  // (the afterChange hook chain). The settings doc changes rarely, so a short TTL
33
89
  // + explicit invalidation on save (endpoints/settings.ts) avoids redundant DB
34
90
  // reads of the same `payload-preferences` row.
35
- let settingsCache: { value: SupportSettings; ts: number } | null = null
91
+ // Keyed by the STAFF SLUG the read was scoped to: a single shared slot would
92
+ // serve one install's (or one test's) settings to another whose scope differs.
93
+ // Bounded — the key comes from config, never from a request — but capped anyway
94
+ // so an integrator calling the exported helper with arbitrary slugs cannot grow
95
+ // it without limit.
96
+ const settingsCache = new Map<string, { value: SupportSettingsState; ts: number }>()
36
97
  const SETTINGS_TTL_MS = 60_000
98
+ const SETTINGS_CACHE_MAX = 8
99
+
100
+ /** Rate-limits the "settings row exists but is not staff-owned" warning, per scope. */
101
+ const warnedForeignSettingsRow = new Set<string>()
102
+
103
+ export interface SupportSettingsState {
104
+ settings: SupportSettings
105
+ /**
106
+ * False when the preference row carries no `features` object yet — a
107
+ * pre-2.1 install, or a fresh one. Clients use it to decide whether their
108
+ * legacy `localStorage` flags should seed the server.
109
+ */
110
+ featuresConfigured: boolean
111
+ }
37
112
 
38
113
  /** Invalidate the settings cache — call right after writing support settings. */
39
114
  export function invalidateSupportSettingsCache(): void {
40
- settingsCache = null
115
+ settingsCache.clear()
116
+ warnedForeignSettingsRow.clear()
41
117
  }
42
118
 
43
- export async function readSupportSettings(payload: Payload): Promise<SupportSettings> {
44
- if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
45
- return settingsCache.value
119
+ /**
120
+ * Merge a stored preference value onto the defaults.
121
+ * Exported for the settings endpoint, which must not re-implement the merge.
122
+ */
123
+ export function mergeSupportSettings(
124
+ stored: Partial<SupportSettings> | undefined | null,
125
+ base: SupportSettings = DEFAULT_SETTINGS,
126
+ ): SupportSettings {
127
+ const autoClose = { ...base.autoClose, ...stored?.autoClose }
128
+ return {
129
+ email: { ...base.email, ...stored?.email },
130
+ ai: { ...base.ai, ...stored?.ai },
131
+ sla: { ...base.sla, ...stored?.sla },
132
+ autoClose,
133
+ // `autoClose` / `autoCloseDays` are projections of the block above, so they
134
+ // are recomputed here rather than trusted from whatever was persisted.
135
+ features: projectAutoClose(
136
+ normalizeFeatures({ ...base.features, ...(stored?.features as object | undefined) }),
137
+ autoClose,
138
+ ),
139
+ }
140
+ }
141
+
142
+ export async function readSupportSettingsState(
143
+ payload: Payload,
144
+ staffSlug?: string,
145
+ ): Promise<SupportSettingsState> {
146
+ const staff = resolveStaffPrefSlug(payload, staffSlug)
147
+ const cached = settingsCache.get(staff)
148
+ if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
149
+ return cached.value
150
+ }
151
+ let value: SupportSettingsState = {
152
+ settings: mergeSupportSettings(null),
153
+ featuresConfigured: false,
46
154
  }
47
- let value: SupportSettings = { ...DEFAULT_SETTINGS }
48
155
  try {
49
156
  const prefs = await dbFind(payload, 'payload-preferences', {
50
- where: { key: { equals: PREF_KEY } },
157
+ // Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
158
+ // security boundary: without it any authenticated principal can plant a
159
+ // `support-settings` row and own the plugin's server settings.
160
+ where: { key: { equals: PREF_KEY }, 'user.relationTo': { equals: staff } },
161
+ // The upsert is scoped per admin user, so several rows can share the key.
162
+ // Sorting makes "last write wins" deterministic instead of arbitrary.
163
+ sort: '-updatedAt',
51
164
  limit: 1, depth: 0, overrideAccess: true,
52
165
  })
53
166
  if (prefs.docs.length > 0) {
54
167
  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 },
168
+ const featuresConfigured = !!stored.features && typeof stored.features === 'object'
169
+ const settings = mergeSupportSettings(stored)
170
+
171
+ // Pre-2.1 installs enabled round-robin through its own preference row.
172
+ // Honour it until the first save writes `features` — after that the
173
+ // legacy row is ignored, and both write paths land in `features`.
174
+ if (!featuresConfigured) {
175
+ settings.features.roundRobin = await readLegacyRoundRobin(payload, staff)
60
176
  }
177
+
178
+ value = { settings, featuresConfigured }
179
+ } else {
180
+ await warnOnForeignSettingsRow(payload, staff)
61
181
  }
62
182
  } catch { /* fallback to defaults */ }
63
- settingsCache = { value, ts: Date.now() }
183
+ if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear()
184
+ settingsCache.set(staff, { value, ts: Date.now() })
64
185
  return value
65
186
  }
66
187
 
67
- export async function readUserPrefs(payload: Payload, userId: string | number): Promise<UserPrefs> {
188
+ /**
189
+ * The scoped read came back empty. That is normal on a fresh install, but it is
190
+ * also what an operator sees when the settings row was written under another
191
+ * owner than the resolved staff collection: the row exists, is ignored,
192
+ * and the whole plugin silently runs on the defaults — a `try/catch` away from
193
+ * any signal. So: if a row with this key exists under ANOTHER owner, say so.
194
+ *
195
+ * Same message covers the other case, deliberately: a `support-settings` row
196
+ * owned by a non-staff principal is exactly the poisoning attempt this scope was
197
+ * added to defeat, and the operator should hear about it. Once per cache window
198
+ * at most (the extra query only runs when nothing was found).
199
+ */
200
+ async function warnOnForeignSettingsRow(payload: Payload, staff: string): Promise<void> {
201
+ if (warnedForeignSettingsRow.has(staff)) return
202
+ try {
203
+ const any = await dbFind(payload, 'payload-preferences', {
204
+ where: { key: { equals: PREF_KEY } },
205
+ limit: 1, depth: 0, overrideAccess: true,
206
+ })
207
+ if (any.docs.length === 0) return // fresh install — nothing to report
208
+ if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear()
209
+ warnedForeignSettingsRow.add(staff)
210
+ console.warn(
211
+ `[support] A "${PREF_KEY}" preference row exists but none is owned by the "${staff}" collection: ` +
212
+ 'the plugin is running on its DEFAULT settings. Either the staff auth collection differs from ' +
213
+ '`admin.user`, or the row was written by a principal that is not staff — in which case it is ' +
214
+ 'ignored on purpose.',
215
+ )
216
+ } catch {
217
+ /* diagnostic only — never let it change the outcome */
218
+ }
219
+ }
220
+
221
+ export async function readSupportSettings(
222
+ payload: Payload,
223
+ staffSlug?: string,
224
+ ): Promise<SupportSettings> {
225
+ return (await readSupportSettingsState(payload, staffSlug)).settings
226
+ }
227
+
228
+ async function readLegacyRoundRobin(payload: Payload, staff: string): Promise<boolean> {
229
+ try {
230
+ const prefs = await dbFind(payload, 'payload-preferences', {
231
+ where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, 'user.relationTo': { equals: staff } },
232
+ limit: 1, depth: 0, overrideAccess: true,
233
+ })
234
+ if (prefs.docs.length > 0) {
235
+ return (prefs.docs[0].value as { enabled?: boolean })?.enabled === true
236
+ }
237
+ } catch { /* ignore — defaults to disabled */ }
238
+ return DEFAULT_TICKETING_FEATURES.roundRobin
239
+ }
240
+
241
+ export async function readUserPrefs(
242
+ payload: Payload,
243
+ userId: string | number,
244
+ staffSlug?: string,
245
+ ): Promise<UserPrefs> {
68
246
  try {
69
247
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`
248
+ // Ids collide across auth collections: a support-client with id 7 would
249
+ // otherwise own `support-user-prefs-7`, the row read back for agent 7.
70
250
  const prefs = await dbFind(payload, 'payload-preferences', {
71
- where: { key: { equals: key } },
251
+ where: {
252
+ key: { equals: key },
253
+ 'user.relationTo': { equals: resolveStaffPrefSlug(payload, staffSlug) },
254
+ },
72
255
  limit: 1, depth: 0, overrideAccess: true,
73
256
  })
74
257
  if (prefs.docs.length > 0) {