@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
@@ -13,11 +13,24 @@ import { dbFind } from './db'
13
13
  *
14
14
  * Returns an empty array when the client has no accessible tickets — callers MUST
15
15
  * treat that as "deny" (e.g. filter on a sentinel id) rather than "allow all".
16
+ *
17
+ * `mode` selects which of the two scopes is wanted:
18
+ * - `'read'` (default) — owned tickets + every ticket collaborated on, whatever
19
+ * the role. This is the historical behaviour and what `access.read` needs.
20
+ * - `'write'` — owned tickets + collaborated tickets whose row carries
21
+ * `role === 'collaborator'`. The invitation endpoint offers two roles and the
22
+ * invitation email promises "lecteur (consultation)" for `viewer`, but nothing
23
+ * ever read the field back: a viewer could post into the thread and trigger the
24
+ * whole notification chain. Rows with no explicit role are treated as viewers
25
+ * (the collection's own `defaultValue`), i.e. read-only — fail closed.
16
26
  */
27
+ export type TicketAccessMode = 'read' | 'write'
28
+
17
29
  export async function resolveAccessibleTicketIds(
18
30
  payload: Payload,
19
31
  slugs: CollectionSlugs,
20
32
  clientId: number | string,
33
+ mode: TicketAccessMode = 'read',
21
34
  ): Promise<Array<number | string>> {
22
35
  const ids = new Set<number | string>()
23
36
 
@@ -41,7 +54,9 @@ export async function resolveAccessibleTicketIds(
41
54
  overrideAccess: true,
42
55
  })
43
56
  for (const r of collab.docs) {
44
- const row = r as { ticket?: number | string | { id?: number | string } }
57
+ const row = r as { ticket?: number | string | { id?: number | string }; role?: string }
58
+ // `viewer` (and any row without an explicit role) grants read only.
59
+ if (mode === 'write' && row.role !== 'collaborator') continue
45
60
  const tid = typeof row.ticket === 'object' ? row.ticket?.id : row.ticket
46
61
  if (tid !== undefined && tid !== null) ids.add(tid)
47
62
  }
@@ -0,0 +1,80 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto'
2
+
3
+ /**
4
+ * Proof that the password step already succeeded for this email address.
5
+ *
6
+ * `POST /support/2fa {action:'send'}` used to accept an email and nothing else.
7
+ * Its only guard was a limiter keyed on that email — 3 sends per hour — so an
8
+ * ANONYMOUS caller who knew a victim's address (they circulate in ticket threads
9
+ * and email copies) could burn the quota in three requests and leave the victim
10
+ * unable to obtain the code their own login now demands. Since the OAuth path
11
+ * enforces 2FA as well, that was a complete, renewable account lockout, plus an
12
+ * outbound-email amplifier and a way to overwrite a code already in flight.
13
+ *
14
+ * The fix keys the consumable resource to something the ATTACKER cannot produce:
15
+ * a short-lived signature issued only by a successful password (or Google)
16
+ * authentication. Same HMAC-over-PAYLOAD_SECRET construction as the tracking
17
+ * pixel, no storage, no new dependency.
18
+ *
19
+ * Format: `<expiry-ms>.<hex hmac>`. The signature covers the email AND the
20
+ * expiry, so neither can be swapped without invalidating the token, and a token
21
+ * issued for one account is useless for another.
22
+ */
23
+
24
+ /** Matches the 10-minute validity of the code the challenge lets you request. */
25
+ export const TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1000
26
+
27
+ function challengeSecret(): string {
28
+ const secret = process.env.PAYLOAD_SECRET
29
+ if (!secret) {
30
+ // Fail closed: the codes themselves are hashed with this secret, so 2FA is
31
+ // already inoperable without it — never fall back to a source-visible value.
32
+ throw new Error(
33
+ '[support][2fa] PAYLOAD_SECRET is not set — refusing to issue a 2FA challenge with an insecure fallback secret',
34
+ )
35
+ }
36
+ return secret
37
+ }
38
+
39
+ function normalizeEmail(email: string): string {
40
+ return String(email).trim().toLowerCase()
41
+ }
42
+
43
+ function sign(email: string, expiresAt: number): string {
44
+ return createHmac('sha256', challengeSecret())
45
+ .update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`)
46
+ .digest('hex')
47
+ }
48
+
49
+ /** Issued by the login endpoints once credentials have been verified. */
50
+ export function issueTwoFactorChallenge(email: string, now: number = Date.now()): string {
51
+ const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS
52
+ return `${expiresAt}.${sign(email, expiresAt)}`
53
+ }
54
+
55
+ /** Constant-time; false on any malformed, expired or foreign token. */
56
+ export function verifyTwoFactorChallenge(
57
+ email: unknown,
58
+ token: unknown,
59
+ now: number = Date.now(),
60
+ ): boolean {
61
+ if (typeof email !== 'string' || !email || typeof token !== 'string') return false
62
+ const separator = token.indexOf('.')
63
+ if (separator <= 0) return false
64
+
65
+ const expiresAt = Number(token.slice(0, separator))
66
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false
67
+
68
+ const received = token.slice(separator + 1)
69
+ if (!/^[0-9a-f]{64}$/i.test(received)) return false
70
+
71
+ let expected: string
72
+ try {
73
+ expected = sign(email, expiresAt)
74
+ } catch {
75
+ return false // no secret configured — fail closed
76
+ }
77
+ const a = Buffer.from(expected, 'hex')
78
+ const b = Buffer.from(received.toLowerCase(), 'hex')
79
+ return a.length === b.length && timingSafeEqual(a, b)
80
+ }
@@ -0,0 +1,230 @@
1
+ import { lookup } from 'dns/promises'
2
+
3
+ /**
4
+ * SSRF guards for every URL the SERVER follows on behalf of a user-supplied
5
+ * value (today: outbound webhook endpoints).
6
+ *
7
+ * `webhook-endpoints.url` is a plain `text` field with no validation, writable by
8
+ * any member of the staff collection, and `_sendToEndpoint` used to `fetch()` it
9
+ * with the default redirect policy. Pointing it at `http://169.254.169.254/…` or
10
+ * an internal service turned every ticket event into a blind SSRF, with the HTTP
11
+ * status echoed back into the admin through `lastStatus`.
12
+ *
13
+ * Three layers, because any one of them alone is bypassable:
14
+ * 1. `validateWebhookUrl` at WRITE time — rejects the scheme and the literal
15
+ * private / loopback / link-local hosts before the row is saved.
16
+ * 2. `assertPublicHost` at SEND time — resolves the name and re-checks the
17
+ * resolved addresses, so a DNS record flipped to 127.0.0.1 after the row was
18
+ * validated is caught. It FAILS CLOSED: a resolution that errors, returns
19
+ * nothing, or outruns `LOOKUP_TIMEOUT_MS` is treated as "do not connect".
20
+ * Note the limit, honestly stated: `fetch` resolves the name a SECOND time,
21
+ * so this narrows the rebinding window rather than closing it — closing it
22
+ * would mean connecting to the address we validated (a custom dispatcher
23
+ * lookup), which is a dependency this plugin does not take.
24
+ * 3. `safeFetch` follows redirects MANUALLY, re-running (1) and (2) on every
25
+ * hop: a public host answering `302 Location: http://127.0.0.1:8080/` would
26
+ * otherwise walk straight through the first two layers.
27
+ */
28
+
29
+ /** Set `SUPPORT_ALLOW_INSECURE_WEBHOOKS=1` to allow plain http (local dev only). */
30
+ function httpAllowed(): boolean {
31
+ return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === '1'
32
+ }
33
+
34
+ const BLOCKED_HOST_SUFFIXES = ['.local', '.localhost', '.internal', '.home.arpa']
35
+
36
+ /** Dotted-quad only — no octal/hex shorthand, which `new URL()` normalises away anyway. */
37
+ function parseIPv4(host: string): number[] | null {
38
+ const parts = host.split('.')
39
+ if (parts.length !== 4) return null
40
+ const octets: number[] = []
41
+ for (const part of parts) {
42
+ if (!/^\d{1,3}$/.test(part)) return null
43
+ const n = Number(part)
44
+ if (n > 255) return null
45
+ octets.push(n)
46
+ }
47
+ return octets
48
+ }
49
+
50
+ function isPrivateIPv4(octets: number[]): boolean {
51
+ const [a, b] = octets
52
+ if (a === 0) return true // "this network" / 0.0.0.0
53
+ if (a === 10) return true // 10/8
54
+ if (a === 127) return true // loopback
55
+ if (a === 169 && b === 254) return true // link-local, incl. cloud metadata
56
+ if (a === 172 && b >= 16 && b <= 31) return true // 172.16/12
57
+ if (a === 192 && b === 168) return true // 192.168/16
58
+ if (a === 192 && b === 0) return true // 192.0.0/24 + 192.0.2/24
59
+ if (a === 198 && (b === 18 || b === 19)) return true // benchmarking
60
+ if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64/10
61
+ if (a >= 224) return true // multicast + reserved + broadcast
62
+ return false
63
+ }
64
+
65
+ /**
66
+ * Handles the IPv4-mapped and IPv4-compatible forms — `::ffff:10.0.0.1` and
67
+ * `::ffff:a00:1` both reach 10.0.0.1 and both must be blocked.
68
+ */
69
+ function isPrivateIPv6(host: string): boolean {
70
+ const lower = host.toLowerCase()
71
+ if (lower === '::' || lower === '::1') return true
72
+ if (lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb')) return true // fe80::/10
73
+ if (lower.startsWith('fc') || lower.startsWith('fd')) return true // fc00::/7 unique-local
74
+ if (lower.startsWith('ff')) return true // multicast
75
+
76
+ // IPv4-mapped / -compatible, dotted form: ::ffff:10.0.0.1
77
+ const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/)
78
+ if (dotted) {
79
+ const octets = parseIPv4(dotted[1])
80
+ return octets ? isPrivateIPv4(octets) : true
81
+ }
82
+
83
+ // IPv4-mapped, hex form: ::ffff:a00:1
84
+ const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
85
+ if (hex) {
86
+ const high = parseInt(hex[1], 16)
87
+ const low = parseInt(hex[2], 16)
88
+ return isPrivateIPv4([high >> 8, high & 0xff, low >> 8, low & 0xff])
89
+ }
90
+
91
+ return false
92
+ }
93
+
94
+ /** Strips the brackets WHATWG URL keeps around an IPv6 literal. */
95
+ function normalizeHost(hostname: string): string {
96
+ const host = hostname.trim().toLowerCase()
97
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
98
+ }
99
+
100
+ /**
101
+ * True when the host is a literal address (or a name) that must never be
102
+ * reached from the server. Names that are not literals are NOT blocked here —
103
+ * they are resolved and re-checked by `assertPublicHost`.
104
+ */
105
+ export function isBlockedHost(hostname: string): boolean {
106
+ const host = normalizeHost(hostname)
107
+ if (!host) return true
108
+ if (host === 'localhost') return true
109
+ if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true
110
+
111
+ const v4 = parseIPv4(host)
112
+ if (v4) return isPrivateIPv4(v4)
113
+ if (host.includes(':')) return isPrivateIPv6(host)
114
+ return false
115
+ }
116
+
117
+ export interface UrlValidationResult {
118
+ ok: boolean
119
+ /** Machine-readable reason, also used as the field validation message. */
120
+ reason?: 'invalid_url' | 'scheme_not_allowed' | 'private_host'
121
+ url?: URL
122
+ }
123
+
124
+ /** Scheme + literal-host validation. Cheap, synchronous, safe to run in a field `validate`. */
125
+ export function validateWebhookUrl(raw: unknown): UrlValidationResult {
126
+ if (typeof raw !== 'string' || !raw.trim()) return { ok: false, reason: 'invalid_url' }
127
+ let url: URL
128
+ try {
129
+ url = new URL(raw.trim())
130
+ } catch {
131
+ return { ok: false, reason: 'invalid_url' }
132
+ }
133
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && httpAllowed())) {
134
+ return { ok: false, reason: 'scheme_not_allowed' }
135
+ }
136
+ if (isBlockedHost(url.hostname)) return { ok: false, reason: 'private_host' }
137
+ return { ok: true, url }
138
+ }
139
+
140
+ /** French messages for the collection field — mirrors the rest of the admin UI. */
141
+ export const WEBHOOK_URL_MESSAGES: Record<NonNullable<UrlValidationResult['reason']>, string> = {
142
+ invalid_url: 'URL invalide.',
143
+ scheme_not_allowed: 'Seules les URL https:// sont acceptées.',
144
+ private_host: 'Les adresses privées, loopback et link-local sont interdites (SSRF).',
145
+ }
146
+
147
+ /**
148
+ * Resolve the name and reject unless EVERY resolved address is public.
149
+ *
150
+ * FAIL-CLOSED on anything that is not a clean answer. The previous version
151
+ * returned `true` on its own timeout, on an empty answer and on every error, on
152
+ * the theory that "`fetch` would fail the same way". A slow resolver is not a
153
+ * failing resolver: an attacker-run authoritative server answering just after
154
+ * the deadline got this guard to wave the request through, and `fetch` then did
155
+ * its OWN resolution — no deadline — and reached 127.0.0.1. So a timeout, a
156
+ * temporary resolver error (EAI_AGAIN/EAI_FAIL) and an empty answer all block.
157
+ *
158
+ * The ONE tolerated case is a definitive "this name does not exist"
159
+ * (ENOTFOUND / ENODATA): there is no address for `fetch` to reach either, so
160
+ * blocking would only swap one failure message for another.
161
+ *
162
+ * The deadline is generous on purpose — with fail-closed semantics it now costs
163
+ * a legitimate delivery when it fires, and a resolver needing more than 5s is
164
+ * broken, not slow. It still bounds the delivery: the `fetch` that follows has
165
+ * its own 10s abort.
166
+ */
167
+ const LOOKUP_TIMEOUT_MS = 5000
168
+ const LOOKUP_TIMED_OUT = Symbol('lookup-timed-out')
169
+ /** Definitive "no such host" — nothing for `fetch` to connect to either. */
170
+ const NONEXISTENT_HOST_CODES = new Set(['ENOTFOUND', 'ENODATA', 'NOTFOUND'])
171
+
172
+ export async function assertPublicHost(hostname: string): Promise<boolean> {
173
+ const host = normalizeHost(hostname)
174
+ if (isBlockedHost(host)) return false
175
+ if (parseIPv4(host) || host.includes(':')) return true // already a literal, checked above
176
+ try {
177
+ // Bounded: a stalled resolver must not hold a webhook delivery open.
178
+ const addresses = await Promise.race([
179
+ lookup(host, { all: true }),
180
+ new Promise<typeof LOOKUP_TIMED_OUT>((resolve) =>
181
+ setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.(),
182
+ ),
183
+ ])
184
+ if (addresses === LOOKUP_TIMED_OUT) return false
185
+ // `[].every()` is `true`: an empty answer must not read as "all public".
186
+ if (!Array.isArray(addresses) || addresses.length === 0) return false
187
+ return addresses.every((entry) => !isBlockedHost(entry.address))
188
+ } catch (error) {
189
+ const code = (error as { code?: string } | null)?.code
190
+ return typeof code === 'string' && NONEXISTENT_HOST_CODES.has(code)
191
+ }
192
+ }
193
+
194
+ export class BlockedRequestError extends Error {
195
+ constructor(public readonly reason: NonNullable<UrlValidationResult['reason']> | 'too_many_redirects') {
196
+ super(`Blocked outbound request: ${reason}`)
197
+ this.name = 'BlockedRequestError'
198
+ }
199
+ }
200
+
201
+ const MAX_REDIRECTS = 3
202
+
203
+ /**
204
+ * `fetch` with SSRF guards on the initial URL AND on every redirect target.
205
+ *
206
+ * `redirect: 'manual'` is the point: the default policy follows 3xx inside
207
+ * undici, where no guard of ours can see the new target.
208
+ */
209
+ export async function safeFetch(rawUrl: string, init: RequestInit): Promise<Response> {
210
+ let current = rawUrl
211
+ let body: BodyInit | null | undefined = init.body
212
+
213
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
214
+ const validation = validateWebhookUrl(current)
215
+ if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || 'invalid_url')
216
+ if (!(await assertPublicHost(validation.url.hostname))) throw new BlockedRequestError('private_host')
217
+
218
+ const response = await fetch(current, { ...init, body, redirect: 'manual' })
219
+ if (response.status < 300 || response.status > 399) return response
220
+
221
+ const location = response.headers.get('location')
222
+ if (!location) return response
223
+
224
+ current = new URL(location, current).toString()
225
+ // 303 (and, in practice, 301/302 on a POST) turns the follow-up into a GET.
226
+ if (response.status === 303) body = undefined
227
+ }
228
+
229
+ throw new BlockedRequestError('too_many_redirects')
230
+ }
@@ -2,8 +2,18 @@ import crypto from 'crypto'
2
2
  import type { BasePayload } from 'payload'
3
3
  import type { CollectionSlugs } from './slugs'
4
4
  import { dbFind, dbUpdate } from './db'
5
+ import { safeFetch } from './urlSafety'
5
6
 
6
- type WebhookEvent = 'ticket_created' | 'ticket_resolved' | 'ticket_replied' | 'sla_breached'
7
+ /**
8
+ * Outbound webhook events. Must stay in sync with the `events` options of the
9
+ * webhook-endpoints collection.
10
+ */
11
+ export type WebhookEvent =
12
+ | 'ticket_created'
13
+ | 'ticket_resolved'
14
+ | 'ticket_replied'
15
+ | 'ticket_assigned'
16
+ | 'sla_breached'
7
17
 
8
18
  /**
9
19
  * Dispatch outbound webhooks for a given event.
@@ -21,9 +31,12 @@ export function dispatchWebhook(
21
31
  event: WebhookEvent,
22
32
  payload: BasePayload,
23
33
  slugs: CollectionSlugs,
24
- ): void {
25
- // Fire and forget do not await
26
- void _dispatch(data, event, payload, slugs)
34
+ ): Promise<void> {
35
+ // Fire and forget for callers (hooks never await it); the promise is returned so
36
+ // tests and any caller that wants to — can wait for delivery to settle.
37
+ const done = _dispatch(data, event, payload, slugs)
38
+ void done
39
+ return done
27
40
  }
28
41
 
29
42
  async function _dispatch(
@@ -49,9 +62,9 @@ async function _dispatch(
49
62
 
50
63
  const body = JSON.stringify({ event, data, timestamp: new Date().toISOString() })
51
64
 
52
- for (const endpoint of endpoints) {
53
- void _sendToEndpoint(endpoint as any, body, payload, slugs)
54
- }
65
+ await Promise.allSettled(
66
+ endpoints.map((endpoint) => _sendToEndpoint(endpoint as any, body, payload, slugs)),
67
+ )
55
68
  } catch (err) {
56
69
  console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err)
57
70
  }
@@ -78,7 +91,10 @@ async function _sendToEndpoint(
78
91
  headers['X-Webhook-Signature'] = signature
79
92
  }
80
93
 
81
- const response = await fetch(endpoint.url, {
94
+ // safeFetch: rejects non-https and private / loopback / link-local targets,
95
+ // re-resolves the name before connecting (DNS rebinding) and re-checks every
96
+ // redirect hop instead of letting undici follow a 302 into the private range.
97
+ const response = await safeFetch(endpoint.url, {
82
98
  method: 'POST',
83
99
  headers,
84
100
  body,
@@ -40,6 +40,11 @@ export const ChatViewClient: React.FC = () => {
40
40
  const sessionsPollTimeout = useRef<NodeJS.Timeout>(undefined)
41
41
  const messagesPollInterval = useRef(3000)
42
42
  const messagesPollTimeout = useRef<NodeJS.Timeout>(undefined)
43
+ // Serialized snapshot of the last sessions payload. The change detection MUST
44
+ // live outside the setSessions updater: React skips the updater entirely on the
45
+ // eager-state path, so a flag assigned inside it stops being written after the
46
+ // first render and the adaptive backoff then climbs to its ceiling and stays there.
47
+ const sessionsSnapshotRef = useRef<string>('')
43
48
 
44
49
  // Fetch sessions list
45
50
  const [sessionExpired, setSessionExpired] = useState(false)
@@ -50,15 +55,14 @@ export const ChatViewClient: React.FC = () => {
50
55
  if (res.status === 401 || res.status === 403) { setSessionExpired(true); return }
51
56
  if (res.ok) {
52
57
  const data = await res.json()
53
- setSessions((prev) => {
54
- const newActive = data.active || []
55
- const newClosed = data.closed || []
56
- if (JSON.stringify(prev.active) !== JSON.stringify(newActive) || JSON.stringify(prev.closed) !== JSON.stringify(newClosed)) {
57
- hadChanges = true
58
- return { active: newActive, closed: newClosed }
59
- }
60
- return prev
61
- })
58
+ const newActive = data.active || []
59
+ const newClosed = data.closed || []
60
+ const snapshot = JSON.stringify([newActive, newClosed])
61
+ if (snapshot !== sessionsSnapshotRef.current) {
62
+ sessionsSnapshotRef.current = snapshot
63
+ hadChanges = true
64
+ setSessions({ active: newActive, closed: newClosed })
65
+ }
62
66
  }
63
67
  } catch { /* ignore */ }
64
68
  setLoading(false)
@@ -144,26 +144,25 @@ export const CrmClient: React.FC = () => {
144
144
  const fetchDetail = useCallback(async (clientId: number) => {
145
145
  setDetailLoading(true)
146
146
  try {
147
- const [clientRes, ticketsRes, projectsRes, timeRes] = await Promise.all([
147
+ const [clientRes, ticketsRes, projectsRes] = await Promise.all([
148
148
  fetch(`/api/support-clients/${clientId}?depth=0`),
149
149
  fetch(`/api/tickets?where[client][equals]=${clientId}&sort=-createdAt&limit=20&depth=0`),
150
150
  fetch(`/api/projects?where[client][equals]=${clientId}&depth=0`),
151
- fetch(`/api/time-entries?limit=0&depth=0`),
152
151
  ])
153
152
 
154
153
  const client = clientRes.ok ? await clientRes.json() : null
155
154
  const tickets = ticketsRes.ok ? (await ticketsRes.json()).docs || [] : []
156
155
  const projects = projectsRes.ok ? (await projectsRes.json()).docs || [] : []
157
156
 
158
- // Get time entries for this client's tickets
159
- const ticketIds = tickets.map((t: { id: number }) => t.id)
160
- let totalTimeMinutes = 0
161
- if (timeRes.ok) {
162
- const timeData = await timeRes.json()
163
- totalTimeMinutes = timeData.docs
164
- .filter((e: { ticket: number }) => ticketIds.includes(e.ticket))
165
- .reduce((sum: number, e: { duration: number }) => sum + (e.duration || 0), 0)
166
- }
157
+ // Sum the per-ticket rollup maintained by the TimeEntries afterChange /
158
+ // afterDelete hooks. This used to download the ENTIRE time-entries table
159
+ // (via limit=0, which does not mean "no document") only to keep the rows whose
160
+ // ticket is one of the 20 listed here — same result, O(1) payload instead of
161
+ // O(all time entries ever recorded).
162
+ const totalTimeMinutes = tickets.reduce(
163
+ (sum: number, t: { totalTimeMinutes?: number }) => sum + (t.totalTimeMinutes || 0),
164
+ 0,
165
+ )
167
166
 
168
167
  const openTickets = tickets.filter((t: { status: string }) =>
169
168
  ['open', 'waiting_client'].includes(t.status),
@@ -6,7 +6,7 @@ import Link from 'next/link'
6
6
  import { RichTextEditor, type RichTextEditorHandle } from '../../components/RichTextEditor/index'
7
7
  import { hasCodeBlocks, MessageWithCodeBlocks, CodeBlockRendererHtml } from '../../components/TicketConversation/components/CodeBlock'
8
8
  import { CodeBlockInserter } from '../../components/TicketConversation/components/CodeBlockInserter'
9
- import { getFeatures } from '../shared/config'
9
+ import { useFeatures } from '../../components/TicketConversation/hooks/useFeatures'
10
10
  import { computeSlaState, formatSlaRemaining } from '../shared/sla'
11
11
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
12
12
  import { DATE_LOCALE } from '../shared/dateLocale'
@@ -21,7 +21,7 @@ export const TicketDetailClient: React.FC = () => {
21
21
  const { t } = useTranslation()
22
22
  const searchParams = useSearchParams()
23
23
  const ticketId = searchParams.get('id')
24
- const features = getFeatures()
24
+ const { features } = useFeatures()
25
25
  const editorRef = useRef<RichTextEditorHandle>(null)
26
26
  const threadEndRef = useRef<HTMLDivElement>(null)
27
27
  const dropdownRef = useRef<HTMLDivElement>(null)
@@ -165,12 +165,17 @@ export const TicketInboxClient: React.FC = () => {
165
165
  try {
166
166
  // Exclude snoozed tickets from the counts too (mirrors the list query).
167
167
  const sn = `where[and][0][or][0][snoozeUntil][exists]=false&where[and][0][or][1][snoozeUntil][less_than_equal]=${encodeURIComponent(new Date().toISOString())}`
168
+ // `/count` is a real SQL COUNT. The previous limit=0 idiom did NOT mean
169
+ // "no document": Payload disables pagination for limit=0, skips the count
170
+ // query and derives totalDocs from the rows it just materialised — so each
171
+ // of these five counters used to read the whole tickets table (relations
172
+ // included) just to display a badge.
168
173
  const [all, openRes, waiting, resolved, breach] = await Promise.all([
169
- fetch(`/api/tickets?limit=0&depth=0&${sn}`, { credentials: 'include' }),
170
- fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=open&${sn}`, { credentials: 'include' }),
171
- fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=waiting_client&${sn}`, { credentials: 'include' }),
172
- fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=resolved&${sn}`, { credentials: 'include' }),
173
- fetch(`/api/tickets?limit=0&depth=0&where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&${sn}`, { credentials: 'include' }),
174
+ fetch(`/api/tickets/count?${sn}`, { credentials: 'include' }),
175
+ fetch(`/api/tickets/count?where[status][equals]=open&${sn}`, { credentials: 'include' }),
176
+ fetch(`/api/tickets/count?where[status][equals]=waiting_client&${sn}`, { credentials: 'include' }),
177
+ fetch(`/api/tickets/count?where[status][equals]=resolved&${sn}`, { credentials: 'include' }),
178
+ fetch(`/api/tickets/count?where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&${sn}`, { credentials: 'include' }),
174
179
  ])
175
180
  const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()])
176
181
  setCounts({
@@ -4,7 +4,8 @@ import React, { useState, useEffect } from 'react'
4
4
  import { Settings, Mail, Bot, Clock, Timer, Globe, FileSignature } from 'lucide-react'
5
5
  import { V, btnStyle } from '../shared/adminTokens'
6
6
  import { AdminViewHeader } from '../shared/AdminViewHeader'
7
- import { getFeatures, saveFeatures, DEFAULT_FEATURES, type TicketingFeatures } from '../../components/TicketConversation/config'
7
+ import { DEFAULT_FEATURES, type TicketingFeatures } from '../../components/TicketConversation/config'
8
+ import { useFeatures } from '../../components/TicketConversation/hooks/useFeatures'
8
9
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
9
10
  import ts from '../../styles/TicketingSettings.module.scss'
10
11
 
@@ -301,7 +302,7 @@ export const TicketingSettingsClient: React.FC = () => {
301
302
  const { t } = useTranslation()
302
303
  const FEATURE_LIST = React.useMemo(() => getFeatureList(t), [t])
303
304
  const CATEGORIES = React.useMemo(() => getCategories(t), [t])
304
- const [features, setFeatures] = useState<TicketingFeatures>(() => getFeatures())
305
+ const { features, setFeatures, save: saveFeatures } = useFeatures()
305
306
  const [settings, setSettings] = useState<AllSettings>(DEFAULT_SETTINGS)
306
307
  const [signature, setSignature] = useState('')
307
308
  const [saved, setSaved] = useState(false)
@@ -355,15 +356,16 @@ export const TicketingSettingsClient: React.FC = () => {
355
356
 
356
357
  const handleSave = async () => {
357
358
  setSaving(true)
358
- // Save features to localStorage (UI-only flags)
359
- saveFeatures(features)
360
- // Save global settings + per-user prefs
359
+ // Feature flags and the other blocks share one preference row, so the two
360
+ // writes are sequenced: running them in parallel would make the second
361
+ // request merge onto settings read before the first one landed.
362
+ const featuresOk = await saveFeatures(features)
361
363
  const [settingsOk, prefsOk] = await Promise.all([
362
364
  saveSettingsToAPI(settings),
363
365
  saveUserPrefs({ locale: settings.locale.language, signature }),
364
366
  ])
365
367
  setSaving(false)
366
- if (settingsOk && prefsOk) {
368
+ if (featuresOk && settingsOk && prefsOk) {
367
369
  setSaved(true)
368
370
  setTimeout(() => setSaved(false), 3000)
369
371
  }
@@ -1,82 +1,16 @@
1
1
  /**
2
- * Feature flags for the ticketing module.
3
- * Each feature can be enabled/disabled by the admin.
4
- * When disabled, the corresponding UI section is hidden entirely.
2
+ * Ticketing feature flags re-export of the shared implementation.
3
+ *
4
+ * See `utils/features.ts`. This module is kept so that
5
+ * `views/shared/index.ts` and existing view imports keep resolving.
5
6
  */
6
- export interface TicketingFeatures {
7
- /** Time tracking: timer, manual entries, billing */
8
- timeTracking: boolean
9
- /** AI features: sentiment, synthesis, suggestion, rewrite */
10
- ai: boolean
11
- /** Satisfaction surveys: CSAT rating after resolution */
12
- satisfaction: boolean
13
- /** Live chat integration: chat -> ticket conversion */
14
- chat: boolean
15
- /** Email tracking: pixel tracking, open/sent status per message */
16
- emailTracking: boolean
17
- /** Canned responses: quick reply templates */
18
- canned: boolean
19
- /** Ticket merge: combine two tickets into one */
20
- merge: boolean
21
- /** Snooze: temporarily hide a ticket */
22
- snooze: boolean
23
- /** External messages: add messages received outside the system */
24
- externalMessages: boolean
25
- /** Client history: past tickets, projects, notes sidebar */
26
- clientHistory: boolean
27
- /** Activity log: audit trail of actions on the ticket */
28
- activityLog: boolean
29
- /** Split ticket: extract a message into a new ticket */
30
- splitTicket: boolean
31
- /** Scheduled replies: send a message at a future date */
32
- scheduledReplies: boolean
33
- /** Auto-close: automatically resolve inactive tickets */
34
- autoClose: boolean
35
- /** Auto-close delay in days (used by auto-close cron) */
36
- autoCloseDays: number
37
- /** Round-robin: distribute new tickets evenly among agents */
38
- roundRobin: boolean
39
- }
40
-
41
- /** Default features -- all enabled */
42
- export const DEFAULT_FEATURES: TicketingFeatures = {
43
- timeTracking: true,
44
- ai: true,
45
- satisfaction: true,
46
- chat: true,
47
- emailTracking: true,
48
- canned: true,
49
- merge: true,
50
- snooze: true,
51
- externalMessages: true,
52
- clientHistory: true,
53
- activityLog: true,
54
- splitTicket: true,
55
- scheduledReplies: true,
56
- autoClose: true,
57
- autoCloseDays: 7,
58
- roundRobin: false,
59
- }
60
-
61
- const STORAGE_KEY = 'ticketing_features'
62
-
63
- /** Read features from localStorage (falls back to defaults) */
64
- export function getFeatures(): TicketingFeatures {
65
- if (typeof window === 'undefined') return DEFAULT_FEATURES
66
- try {
67
- const stored = localStorage.getItem(STORAGE_KEY)
68
- if (stored) {
69
- const parsed = JSON.parse(stored)
70
- return { ...DEFAULT_FEATURES, ...parsed }
71
- }
72
- } catch { /* ignore */ }
73
- return DEFAULT_FEATURES
74
- }
75
-
76
- /** Save features to localStorage */
77
- export function saveFeatures(features: TicketingFeatures): void {
78
- if (typeof window === 'undefined') return
79
- try {
80
- localStorage.setItem(STORAGE_KEY, JSON.stringify(features))
81
- } catch { /* ignore */ }
82
- }
7
+ export {
8
+ DEFAULT_TICKETING_FEATURES,
9
+ DEFAULT_TICKETING_FEATURES as DEFAULT_FEATURES,
10
+ FEATURES_CACHE_KEY,
11
+ fetchFeatures,
12
+ getFeatures,
13
+ normalizeFeatures,
14
+ saveFeatures,
15
+ } from '../../utils/features'
16
+ export type { TicketingFeatures } from '../../utils/features'