@consilioweb/payload-support 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +656 -355
  2. package/dist/components/RichTextEditor/index.d.ts +19 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.d.ts +6 -0
  4. package/dist/components/TicketConversation/SkeletonText.d.ts +4 -0
  5. package/dist/components/TicketConversation/components/AISummaryPanel.d.ts +14 -0
  6. package/dist/components/TicketConversation/components/ActionPanels.d.ts +47 -0
  7. package/dist/components/TicketConversation/components/ActivityLog.d.ts +5 -0
  8. package/dist/components/TicketConversation/components/ClientBar.d.ts +5 -0
  9. package/dist/components/TicketConversation/components/ClientHistory.d.ts +24 -0
  10. package/dist/components/TicketConversation/components/CodeBlock.d.ts +26 -0
  11. package/dist/components/TicketConversation/components/CodeBlockInserter.d.ts +12 -0
  12. package/dist/components/TicketConversation/components/QuickActions.d.ts +32 -0
  13. package/dist/components/TicketConversation/components/TicketHeader.d.ts +19 -0
  14. package/dist/components/TicketConversation/components/TimeTrackingPanel.d.ts +24 -0
  15. package/dist/components/TicketConversation/config.d.ts +10 -0
  16. package/dist/components/TicketConversation/config.js +1 -40
  17. package/dist/components/TicketConversation/constants.d.ts +56 -0
  18. package/dist/components/TicketConversation/context.d.ts +53 -0
  19. package/dist/components/TicketConversation/hooks/useAI.d.ts +21 -0
  20. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.d.ts +3 -0
  21. package/dist/components/TicketConversation/hooks/useFeatures.d.ts +23 -0
  22. package/dist/components/TicketConversation/hooks/useFeatures.js +61 -0
  23. package/dist/components/TicketConversation/hooks/useMessageActions.d.ts +20 -0
  24. package/dist/components/TicketConversation/hooks/useReply.d.ts +27 -0
  25. package/dist/components/TicketConversation/hooks/useTicketActions.d.ts +58 -0
  26. package/dist/components/TicketConversation/hooks/useTimeTracking.d.ts +18 -0
  27. package/dist/components/TicketConversation/hooks/useTranslation.d.ts +33 -0
  28. package/dist/components/TicketConversation/hooks/useTranslation.js +22 -3
  29. package/dist/components/TicketConversation/index.d.ts +4 -0
  30. package/dist/components/TicketConversation/index.js +2 -2
  31. package/dist/components/TicketConversation/types.d.ts +56 -0
  32. package/dist/components/TicketConversation/utils.d.ts +2 -0
  33. package/dist/index.cjs +392 -320
  34. package/dist/index.d.cts +100 -3
  35. package/dist/index.d.ts +100 -3
  36. package/dist/index.js +388 -321
  37. package/dist/utils/features.d.ts +123 -0
  38. package/dist/utils/features.js +147 -0
  39. package/dist/views/BillingView/client.d.ts +2 -0
  40. package/dist/views/BillingView/index.d.ts +4 -0
  41. package/dist/views/ChatView/client.d.ts +2 -0
  42. package/dist/views/ChatView/client.js +9 -9
  43. package/dist/views/ChatView/index.d.ts +4 -0
  44. package/dist/views/CrmView/client.d.ts +2 -0
  45. package/dist/views/CrmView/client.js +6 -9
  46. package/dist/views/CrmView/index.d.ts +4 -0
  47. package/dist/views/EmailTrackingView/client.d.ts +2 -0
  48. package/dist/views/EmailTrackingView/index.d.ts +4 -0
  49. package/dist/views/ImportConversationView/client.d.ts +2 -0
  50. package/dist/views/ImportConversationView/index.d.ts +4 -0
  51. package/dist/views/LogsView/client.d.ts +2 -0
  52. package/dist/views/LogsView/index.d.ts +4 -0
  53. package/dist/views/NewTicketView/client.d.ts +2 -0
  54. package/dist/views/NewTicketView/index.d.ts +4 -0
  55. package/dist/views/PendingEmailsView/client.d.ts +2 -0
  56. package/dist/views/PendingEmailsView/index.d.ts +4 -0
  57. package/dist/views/SupportDashboardView/client.d.ts +2 -0
  58. package/dist/views/SupportDashboardView/client.js +1 -1
  59. package/dist/views/SupportDashboardView/index.d.ts +4 -0
  60. package/dist/views/TicketDetailView/NextActionItem.d.ts +6 -0
  61. package/dist/views/TicketDetailView/RewriteDropdown.d.ts +7 -0
  62. package/dist/views/TicketDetailView/client.d.ts +2 -0
  63. package/dist/views/TicketDetailView/client.js +2 -2
  64. package/dist/views/TicketDetailView/constants.d.ts +13 -0
  65. package/dist/views/TicketDetailView/helpers.d.ts +2 -0
  66. package/dist/views/TicketDetailView/index.d.ts +4 -0
  67. package/dist/views/TicketDetailView/types.d.ts +40 -0
  68. package/dist/views/TicketInboxView/client.d.ts +2 -0
  69. package/dist/views/TicketInboxView/client.js +5 -5
  70. package/dist/views/TicketInboxView/index.d.ts +4 -0
  71. package/dist/views/TicketingSettingsView/client.d.ts +2 -0
  72. package/dist/views/TicketingSettingsView/client.js +6 -5
  73. package/dist/views/TicketingSettingsView/index.d.ts +4 -0
  74. package/dist/views/TimeDashboardView/client.d.ts +2 -0
  75. package/dist/views/TimeDashboardView/index.d.ts +4 -0
  76. package/dist/views/shared/AdminViewHeader.d.ts +13 -0
  77. package/dist/views/shared/ErrorBoundary.d.ts +17 -0
  78. package/dist/views/shared/Skeleton.d.ts +21 -0
  79. package/dist/views/shared/StatusPill.d.ts +8 -0
  80. package/dist/views/shared/adminTokens.d.ts +19 -0
  81. package/dist/views/shared/config.d.ts +8 -0
  82. package/dist/views/shared/config.js +1 -40
  83. package/dist/views/shared/dateLocale.d.ts +5 -0
  84. package/dist/views/shared/index.d.ts +12 -0
  85. package/dist/views/shared/index.js +2 -1
  86. package/dist/views/shared/locales/en.json +708 -0
  87. package/dist/views/shared/locales/fr.json +708 -0
  88. package/dist/views/shared/sla.d.ts +14 -0
  89. package/dist/views.d.ts +13 -13
  90. package/package.json +26 -41
  91. package/src/collections/SupportClients.ts +29 -0
  92. package/src/collections/TicketCollaborators.ts +6 -1
  93. package/src/collections/TicketMessages.ts +46 -24
  94. package/src/collections/Tickets.ts +117 -79
  95. package/src/collections/TimeEntries.ts +57 -32
  96. package/src/components/TicketConversation/config.ts +16 -80
  97. package/src/components/TicketConversation/hooks/useFeatures.ts +109 -0
  98. package/src/components/TicketConversation/hooks/useTranslation.ts +38 -4
  99. package/src/components/TicketConversation/index.tsx +2 -2
  100. package/src/endpoints/ai.ts +2 -1
  101. package/src/endpoints/client-intelligence.ts +2 -1
  102. package/src/endpoints/process-scheduled.ts +13 -8
  103. package/src/endpoints/round-robin-config.ts +30 -20
  104. package/src/endpoints/settings.ts +28 -77
  105. package/src/index.ts +6 -2
  106. package/src/portal/LiveChat.tsx +33 -22
  107. package/src/portal/auth/tickets/detail/TicketPolling.tsx +5 -1
  108. package/src/types/lucide-react.d.ts +10 -2
  109. package/src/utils/aiAgent.ts +2 -1
  110. package/src/utils/aiProvider.ts +21 -0
  111. package/src/utils/features.ts +297 -0
  112. package/src/utils/fireWebhooks.ts +6 -0
  113. package/src/utils/generateTicketSynthesis.ts +2 -1
  114. package/src/utils/index.ts +4 -2
  115. package/src/utils/readSettings.ts +82 -9
  116. package/src/utils/webhookDispatcher.ts +19 -7
  117. package/src/views/ChatView/client.tsx +13 -9
  118. package/src/views/CrmView/client.tsx +10 -11
  119. package/src/views/SupportDashboardView/client.tsx +1 -1
  120. package/src/views/TicketDetailView/client.tsx +2 -2
  121. package/src/views/TicketInboxView/client.tsx +10 -5
  122. package/src/views/TicketingSettingsView/client.tsx +9 -7
  123. package/src/views/shared/config.ts +14 -80
  124. package/src/views/shared/index.ts +3 -1
  125. package/src/views/shared/locales/en.json +2 -2
  126. package/src/views/shared/locales/fr.json +2 -2
  127. package/dist/components/RichTextEditor/index.cjs +0 -279
  128. package/dist/components/TicketConversation/RewriteDropdown.cjs +0 -98
  129. package/dist/components/TicketConversation/SkeletonText.cjs +0 -21
  130. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +0 -85
  131. package/dist/components/TicketConversation/components/ActionPanels.cjs +0 -193
  132. package/dist/components/TicketConversation/components/ActivityLog.cjs +0 -24
  133. package/dist/components/TicketConversation/components/ClientBar.cjs +0 -43
  134. package/dist/components/TicketConversation/components/ClientHistory.cjs +0 -138
  135. package/dist/components/TicketConversation/components/CodeBlock.cjs +0 -171
  136. package/dist/components/TicketConversation/components/CodeBlockInserter.cjs +0 -156
  137. package/dist/components/TicketConversation/components/QuickActions.cjs +0 -87
  138. package/dist/components/TicketConversation/components/TicketHeader.cjs +0 -93
  139. package/dist/components/TicketConversation/components/TimeTrackingPanel.cjs +0 -134
  140. package/dist/components/TicketConversation/config.cjs +0 -44
  141. package/dist/components/TicketConversation/constants.cjs +0 -127
  142. package/dist/components/TicketConversation/context.cjs +0 -13
  143. package/dist/components/TicketConversation/hooks/useAI.cjs +0 -196
  144. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +0 -20
  145. package/dist/components/TicketConversation/hooks/useMessageActions.cjs +0 -136
  146. package/dist/components/TicketConversation/hooks/useReply.cjs +0 -188
  147. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +0 -290
  148. package/dist/components/TicketConversation/hooks/useTimeTracking.cjs +0 -123
  149. package/dist/components/TicketConversation/hooks/useTranslation.cjs +0 -78
  150. package/dist/components/TicketConversation/index.cjs +0 -1114
  151. package/dist/components/TicketConversation/types.cjs +0 -2
  152. package/dist/components/TicketConversation/utils.cjs +0 -27
  153. package/dist/views/BillingView/client.cjs +0 -469
  154. package/dist/views/BillingView/index.cjs +0 -34
  155. package/dist/views/ChatView/client.cjs +0 -301
  156. package/dist/views/ChatView/index.cjs +0 -34
  157. package/dist/views/CrmView/client.cjs +0 -458
  158. package/dist/views/CrmView/index.cjs +0 -34
  159. package/dist/views/EmailTrackingView/client.cjs +0 -171
  160. package/dist/views/EmailTrackingView/index.cjs +0 -34
  161. package/dist/views/ImportConversationView/client.cjs +0 -238
  162. package/dist/views/ImportConversationView/index.cjs +0 -34
  163. package/dist/views/LogsView/client.cjs +0 -148
  164. package/dist/views/LogsView/index.cjs +0 -32
  165. package/dist/views/NewTicketView/client.cjs +0 -213
  166. package/dist/views/NewTicketView/index.cjs +0 -32
  167. package/dist/views/PendingEmailsView/client.cjs +0 -471
  168. package/dist/views/PendingEmailsView/index.cjs +0 -34
  169. package/dist/views/SupportDashboardView/client.cjs +0 -437
  170. package/dist/views/SupportDashboardView/index.cjs +0 -34
  171. package/dist/views/TicketDetailView/NextActionItem.cjs +0 -40
  172. package/dist/views/TicketDetailView/RewriteDropdown.cjs +0 -80
  173. package/dist/views/TicketDetailView/client.cjs +0 -1412
  174. package/dist/views/TicketDetailView/constants.cjs +0 -20
  175. package/dist/views/TicketDetailView/helpers.cjs +0 -23
  176. package/dist/views/TicketDetailView/index.cjs +0 -34
  177. package/dist/views/TicketDetailView/types.cjs +0 -2
  178. package/dist/views/TicketInboxView/client.cjs +0 -359
  179. package/dist/views/TicketInboxView/index.cjs +0 -32
  180. package/dist/views/TicketingSettingsView/client.cjs +0 -740
  181. package/dist/views/TicketingSettingsView/index.cjs +0 -34
  182. package/dist/views/TimeDashboardView/client.cjs +0 -180
  183. package/dist/views/TimeDashboardView/index.cjs +0 -34
  184. package/dist/views/shared/AdminViewHeader.cjs +0 -68
  185. package/dist/views/shared/ErrorBoundary.cjs +0 -55
  186. package/dist/views/shared/Skeleton.cjs +0 -77
  187. package/dist/views/shared/StatusPill.cjs +0 -47
  188. package/dist/views/shared/adminTokens.cjs +0 -34
  189. package/dist/views/shared/config.cjs +0 -44
  190. package/dist/views/shared/dateLocale.cjs +0 -5
  191. package/dist/views/shared/index.cjs +0 -72
  192. package/dist/views/shared/sla.cjs +0 -37
  193. package/dist/views.cjs +0 -86
  194. package/dist/views.d.cts +0 -13
  195. package/src/__tests__/aiSummaryRendering.test.ts +0 -21
  196. package/src/__tests__/authResponses.test.ts +0 -69
  197. package/src/__tests__/capabilities.test.ts +0 -71
  198. package/src/__tests__/generateTrackingToken.test.ts +0 -67
  199. package/src/__tests__/integration/ai-agent.test.ts +0 -46
  200. package/src/__tests__/integration/auth.test.ts +0 -43
  201. package/src/__tests__/integration/automation-rules.test.ts +0 -68
  202. package/src/__tests__/integration/buildTestPayload.ts +0 -52
  203. package/src/__tests__/integration/channels.test.ts +0 -52
  204. package/src/__tests__/integration/core-behaviors.test.ts +0 -81
  205. package/src/__tests__/integration/dashboard-volume.test.ts +0 -31
  206. package/src/__tests__/integration/digest.test.ts +0 -48
  207. package/src/__tests__/integration/invoice-pdf.test.ts +0 -30
  208. package/src/__tests__/integration/isolation.test.ts +0 -81
  209. package/src/__tests__/integration/mentions-invoice.test.ts +0 -50
  210. package/src/__tests__/integration/nps.test.ts +0 -37
  211. package/src/__tests__/integration/per-team-sla.test.ts +0 -44
  212. package/src/__tests__/integration/push.test.ts +0 -75
  213. package/src/__tests__/integration/sanitization.test.ts +0 -35
  214. package/src/__tests__/integration/sla-pause.test.ts +0 -39
  215. package/src/__tests__/integration/smoke.test.ts +0 -26
  216. package/src/__tests__/integration/snooze.test.ts +0 -68
  217. package/src/__tests__/integration/teams.test.ts +0 -38
  218. package/src/__tests__/rateLimiter.test.ts +0 -83
  219. package/src/__tests__/sanitizeHtml.test.ts +0 -165
  220. package/src/__tests__/sla.test.ts +0 -258
  221. package/src/__tests__/trackOpenEndpoint.test.ts +0 -54
  222. package/src/__tests__/webhookSecurity.test.ts +0 -58
@@ -0,0 +1,14 @@
1
+ export type SlaState = 'ok' | 'warn' | 'breach' | 'none' | 'met';
2
+ export interface SlaInput {
3
+ slaFirstResponseDue?: string | null;
4
+ slaFirstResponseBreached?: boolean | null;
5
+ firstResponseAt?: string | null;
6
+ slaResolutionDue?: string | null;
7
+ slaResolutionBreached?: boolean | null;
8
+ }
9
+ export declare function computeSlaState(t: SlaInput, now?: Date): {
10
+ state: SlaState;
11
+ remainingMs: number | null;
12
+ due: string | null;
13
+ };
14
+ export declare function formatSlaRemaining(remainingMs: number | null): string;
package/dist/views.d.ts CHANGED
@@ -1,13 +1,13 @@
1
- export { default as TicketInboxView } from './views/TicketInboxView/index';
2
- export { default as TicketDetailView } from './views/TicketDetailView/index';
3
- export { default as SupportDashboardView } from './views/SupportDashboardView/index';
4
- export { default as NewTicketView } from './views/NewTicketView/index';
5
- export { default as TicketingSettingsView } from './views/TicketingSettingsView/index';
6
- export { default as LogsView } from './views/LogsView/index';
7
- export { default as ChatView } from './views/ChatView/index';
8
- export { default as CrmView } from './views/CrmView/index';
9
- export { default as PendingEmailsView } from './views/PendingEmailsView/index';
10
- export { default as EmailTrackingView } from './views/EmailTrackingView/index';
11
- export { default as BillingView } from './views/BillingView/index';
12
- export { default as TimeDashboardView } from './views/TimeDashboardView/index';
13
- export { default as ImportConversationView } from './views/ImportConversationView/index';
1
+ export { default as TicketInboxView } from './views/TicketInboxView/index.js';
2
+ export { default as TicketDetailView } from './views/TicketDetailView/index.js';
3
+ export { default as SupportDashboardView } from './views/SupportDashboardView/index.js';
4
+ export { default as NewTicketView } from './views/NewTicketView/index.js';
5
+ export { default as TicketingSettingsView } from './views/TicketingSettingsView/index.js';
6
+ export { default as LogsView } from './views/LogsView/index.js';
7
+ export { default as ChatView } from './views/ChatView/index.js';
8
+ export { default as CrmView } from './views/CrmView/index.js';
9
+ export { default as PendingEmailsView } from './views/PendingEmailsView/index.js';
10
+ export { default as EmailTrackingView } from './views/EmailTrackingView/index.js';
11
+ export { default as BillingView } from './views/BillingView/index.js';
12
+ export { default as TimeDashboardView } from './views/TimeDashboardView/index.js';
13
+ export { default as ImportConversationView } from './views/ImportConversationView/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -31,14 +31,11 @@
31
31
  "import": {
32
32
  "types": "./dist/views.d.ts",
33
33
  "default": "./dist/views.js"
34
- },
35
- "require": {
36
- "types": "./dist/views.d.cts",
37
- "default": "./dist/views.cjs"
38
34
  }
39
35
  },
40
36
  "./components/TicketConversation": {
41
37
  "import": {
38
+ "types": "./dist/components/TicketConversation/index.d.ts",
42
39
  "default": "./dist/components/TicketConversation/index.js"
43
40
  }
44
41
  }
@@ -46,18 +43,12 @@
46
43
  "files": [
47
44
  "dist",
48
45
  "src",
46
+ "!src/__tests__",
47
+ "!src/**/*.test.ts",
48
+ "!src/**/*.test.tsx",
49
49
  "README.md",
50
50
  "LICENSE"
51
51
  ],
52
- "scripts": {
53
- "build": "tsup",
54
- "typecheck": "tsc --noEmit",
55
- "prepublishOnly": "npm run build",
56
- "test": "vitest run",
57
- "test:watch": "vitest",
58
- "test:e2e": "playwright test",
59
- "test:e2e:ui": "playwright test --ui"
60
- },
61
52
  "keywords": [
62
53
  "payload",
63
54
  "payload-cms",
@@ -94,43 +85,28 @@
94
85
  "author": "ConsilioWEB <contact@consilioweb.fr> (https://consilioweb.fr)",
95
86
  "license": "MIT",
96
87
  "peerDependencies": {
97
- "@payloadcms/next": "^3.0.0",
98
- "@payloadcms/translations": "^3.0.0",
99
- "@payloadcms/ui": "^3.0.0",
88
+ "@payloadcms/next": "^3.37.0",
100
89
  "lucide-react": ">=0.300.0",
101
- "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
102
- "payload": "^3.0.0",
103
- "react": "^18.0.0 || ^19.0.0",
104
- "react-dom": "^18.0.0 || ^19.0.0"
105
- },
106
- "peerDependenciesMeta": {
107
- "@payloadcms/ui": {
108
- "optional": true
109
- },
110
- "@payloadcms/translations": {
111
- "optional": true
112
- },
113
- "@payloadcms/next": {
114
- "optional": true
115
- },
116
- "lucide-react": {
117
- "optional": true
118
- },
119
- "next": {
120
- "optional": true
121
- }
90
+ "next": "^15.2.9 || ^16.0.0",
91
+ "payload": "^3.37.0",
92
+ "react": "^19.0.0",
93
+ "react-dom": "^19.0.0"
122
94
  },
123
95
  "engines": {
124
- "node": ">=18"
96
+ "node": ">=20.9.0"
97
+ },
98
+ "publishConfig": {
99
+ "access": "public",
100
+ "provenance": true
125
101
  },
126
102
  "devDependencies": {
127
103
  "@payloadcms/db-sqlite": "^3.86.0",
128
- "@playwright/test": "^1.49.0",
129
104
  "@payloadcms/graphql": "^3.86.0",
130
105
  "@payloadcms/next": "^3.86.0",
131
106
  "@payloadcms/richtext-lexical": "^3.86.0",
132
107
  "@payloadcms/translations": "^3.86.0",
133
108
  "@payloadcms/ui": "^3.86.0",
109
+ "@playwright/test": "^1.49.0",
134
110
  "@types/pdfkit": "^0.17.6",
135
111
  "@types/react": "^19.0.0",
136
112
  "@types/sanitize-html": "^2.16.1",
@@ -140,6 +116,7 @@
140
116
  "payload": "^3.86.0",
141
117
  "react": "^19.2.7",
142
118
  "react-dom": "^19.2.7",
119
+ "sharp": "0.34.5",
143
120
  "tsup": "^8.0.0",
144
121
  "typescript": "^5.5.0",
145
122
  "vitest": "^4.1.9"
@@ -148,5 +125,13 @@
148
125
  "pdfkit": "^0.19.1",
149
126
  "sanitize-html": "^2.17.5",
150
127
  "web-push": "^3.6.7"
128
+ },
129
+ "scripts": {
130
+ "build": "tsup && tsc -p tsconfig.types.json && node scripts/copy-subpath-types.mjs",
131
+ "typecheck": "tsc --noEmit",
132
+ "test": "vitest run",
133
+ "test:watch": "vitest",
134
+ "test:e2e": "playwright test",
135
+ "test:e2e:ui": "playwright test --ui"
151
136
  }
152
- }
137
+ }
@@ -91,12 +91,33 @@ function createEnforce2FA(slugs: CollectionSlugs): CollectionBeforeLoginHook {
91
91
  }
92
92
  }
93
93
 
94
+ /**
95
+ * Field-level write guard for the commercial / security fields.
96
+ *
97
+ * `access.update` on this collection intentionally lets a support-client PATCH
98
+ * their OWN document (the portal profile page writes firstName, phone, the
99
+ * notification toggles and the 2FA switch). Payload persists every other field of
100
+ * that same PATCH too, so without a field guard a client could self-promote
101
+ * (`tier`), rewrite the sales notes, or — worst — set `googleId`, which the Google
102
+ * OAuth callback resolves BEFORE the email and would hand them another account.
103
+ *
104
+ * Internal writers (2FA endpoints, OAuth linking, beforeLogin) all go through
105
+ * `overrideAccess: true`, which bypasses field access, so they are unaffected.
106
+ * `twoFactorEnabled` is deliberately NOT locked: it is a legitimate self-service
107
+ * toggle on the portal profile page.
108
+ */
109
+ function createAdminOnlyFieldUpdate(slugs: CollectionSlugs) {
110
+ return ({ req }: { req: { user?: { collection?: string } | null } }) =>
111
+ req.user?.collection === slugs.users
112
+ }
113
+
94
114
  // ─── Collection factory ──────────────────────────────────
95
115
 
96
116
  export function createSupportClientsCollection(
97
117
  slugs: CollectionSlugs,
98
118
  options?: { capabilities?: SupportCapabilities },
99
119
  ): CollectionConfig {
120
+ const adminOnlyFieldUpdate = createAdminOnlyFieldUpdate(slugs)
100
121
  return {
101
122
  slug: slugs.supportClients,
102
123
  labels: {
@@ -226,6 +247,7 @@ export function createSupportClientsCollection(
226
247
  access: {
227
248
  // Admin-only: never serialize commercial notes onto the client's own doc.
228
249
  read: ({ req }) => req.user?.collection === slugs.users,
250
+ update: adminOnlyFieldUpdate,
229
251
  },
230
252
  admin: {
231
253
  description: 'Notes commerciales (visible uniquement par les admins)',
@@ -235,6 +257,7 @@ export function createSupportClientsCollection(
235
257
  name: 'tier',
236
258
  type: 'select',
237
259
  label: 'Plan',
260
+ access: { update: adminOnlyFieldUpdate },
238
261
  options: [
239
262
  { label: 'Free', value: 'free' },
240
263
  { label: 'Basic', value: 'basic' },
@@ -248,6 +271,7 @@ export function createSupportClientsCollection(
248
271
  type: 'relationship',
249
272
  relationTo: slugs.users,
250
273
  label: 'Agent referent',
274
+ access: { update: adminOnlyFieldUpdate },
251
275
  admin: { position: 'sidebar' },
252
276
  },
253
277
  {
@@ -263,17 +287,20 @@ export function createSupportClientsCollection(
263
287
  {
264
288
  name: 'twoFactorCode',
265
289
  type: 'text',
290
+ access: { update: adminOnlyFieldUpdate },
266
291
  admin: { hidden: true },
267
292
  },
268
293
  {
269
294
  name: 'twoFactorExpiry',
270
295
  type: 'date',
296
+ access: { update: adminOnlyFieldUpdate },
271
297
  admin: { hidden: true },
272
298
  },
273
299
  {
274
300
  // Set by /support/2fa "verify"; gates login via the beforeLogin hook.
275
301
  name: 'twoFactorVerifiedAt',
276
302
  type: 'date',
303
+ access: { update: adminOnlyFieldUpdate },
277
304
  admin: { hidden: true },
278
305
  },
279
306
  {
@@ -281,6 +308,7 @@ export function createSupportClientsCollection(
281
308
  name: 'googleId',
282
309
  type: 'text',
283
310
  index: true,
311
+ access: { update: adminOnlyFieldUpdate },
284
312
  admin: { hidden: true },
285
313
  },
286
314
  {
@@ -352,6 +380,7 @@ export function createSupportClientsCollection(
352
380
  access: {
353
381
  // Admin-only: never serialize internal notes onto the client's own doc.
354
382
  read: ({ req }) => req.user?.collection === slugs.users,
383
+ update: adminOnlyFieldUpdate,
355
384
  },
356
385
  admin: {
357
386
  description: 'Visible uniquement par les admins',
@@ -32,7 +32,12 @@ export function createTicketCollaboratorsCollection(slugs: CollectionSlugs): Col
32
32
  }
33
33
  return false
34
34
  },
35
- create: ({ req }) => !!req.user,
35
+ // Staff only. This collection is the source of truth for
36
+ // resolveAccessibleTicketIds(), which widens `read` on tickets, messages
37
+ // and the activity log — so letting any authenticated principal POST here
38
+ // let a support client grant themselves access to any ticket by id.
39
+ // The /invite endpoint is unaffected: it writes with overrideAccess: true.
40
+ create: ({ req }) => req.user?.collection === slugs.users,
36
41
  update: () => false,
37
42
  delete: ({ req }) => req.user?.collection === slugs.users,
38
43
  },
@@ -1,8 +1,8 @@
1
1
  import type { CollectionConfig, CollectionBeforeChangeHook, CollectionAfterChangeHook, Where } from 'payload'
2
+ import { APIError } from 'payload'
2
3
  import type { CollectionSlugs } from '../utils/slugs'
3
4
  import type { SupportCapabilities } from '../types'
4
5
  import { escapeHtml, emailWrapper, emailButton, emailQuote, emailParagraph, emailRichContent, emailTrackingPixel } from '../utils/emailTemplate'
5
- import { fireWebhooks } from '../utils/fireWebhooks'
6
6
  import { createAdminNotification } from '../utils/adminNotification'
7
7
  import { dispatchWebhook } from '../utils/webhookDispatcher'
8
8
  import { readSupportSettings } from '../utils/readSettings'
@@ -13,6 +13,38 @@ import { queueClientNotification } from '../utils/notificationQueue'
13
13
  import { sendPushToUser } from '../utils/push'
14
14
  import { dbFind, dbCreate, dbFindByID } from '../utils/db'
15
15
 
16
+ /**
17
+ * Cross-tenant write guard for support-clients.
18
+ *
19
+ * `access.create` only checks WHICH collection the actor belongs to, so any
20
+ * authenticated support-client could POST a message onto an arbitrary ticket id.
21
+ * That is not just a data-integrity issue: the `afterChange` notification hooks
22
+ * would then mail the injected body to the real ticket owner from the legitimate
23
+ * support address (phishing over a trusted channel).
24
+ *
25
+ * We reuse `resolveAccessibleTicketIds()` — the same boundary `access.read` uses —
26
+ * so read and write scopes cannot drift apart. Staff writes and every internal
27
+ * `overrideAccess` write are untouched: they carry no `support-clients` user.
28
+ */
29
+ function createRestrictClientTicketTarget(slugs: CollectionSlugs): CollectionBeforeChangeHook {
30
+ return async ({ data, operation, req, originalDoc }) => {
31
+ if (req.user?.collection !== slugs.supportClients) return data
32
+
33
+ const raw = (data as { ticket?: unknown }).ticket
34
+ ?? (operation === 'update' ? (originalDoc as { ticket?: unknown } | undefined)?.ticket : undefined)
35
+ const targetId = raw && typeof raw === 'object' ? (raw as { id?: unknown }).id : raw
36
+ if (targetId === undefined || targetId === null || targetId === '') {
37
+ throw new APIError('Ticket cible requis.', 400)
38
+ }
39
+
40
+ const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id)
41
+ if (!accessible.some((id) => String(id) === String(targetId))) {
42
+ throw new APIError('Ticket inaccessible.', 403)
43
+ }
44
+ return data
45
+ }
46
+ }
47
+
16
48
  function createAssignAuthor(slugs: CollectionSlugs): CollectionBeforeChangeHook {
17
49
  return async ({ data, operation, req }) => {
18
50
  if (operation === 'create' && req.user?.collection === slugs.supportClients) {
@@ -300,26 +332,12 @@ function createNotifyAdminOnClientMessage(slugs: CollectionSlugs, notificationSl
300
332
  }
301
333
  }
302
334
 
303
- function createFireMessageWebhooks(slugs: CollectionSlugs): CollectionAfterChangeHook {
304
- return async ({ doc, operation, req }) => {
305
- if (operation !== 'create') return doc
306
- // Don't fire for scheduled messages that haven't been sent yet
307
- if (doc.scheduledAt && !doc.scheduledSent) return doc
308
- // Don't fire for internal notes
309
- if (doc.isInternal) return doc
310
-
311
- const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
312
- fireWebhooks(req.payload, slugs, 'ticket_replied', {
313
- ticketId,
314
- messageId: doc.id,
315
- authorType: doc.authorType,
316
- body: doc.body?.length > 500 ? doc.body.slice(0, 500) + '...' : doc.body,
317
- })
318
-
319
- return doc
320
- }
321
- }
322
-
335
+ /**
336
+ * Single outbound-webhook dispatcher for replies. See the matching comment in
337
+ * Tickets.ts: a second, unsigned dispatcher used to fire on this exact hook with
338
+ * the same guards, doubling every delivery. The `body` excerpt of the legacy
339
+ * payload is carried over so existing consumers do not regress.
340
+ */
323
341
  function createDispatchWebhookOnReply(slugs: CollectionSlugs): CollectionAfterChangeHook {
324
342
  return async ({ doc, operation, req }) => {
325
343
  if (operation !== 'create') return doc
@@ -328,7 +346,12 @@ function createDispatchWebhookOnReply(slugs: CollectionSlugs): CollectionAfterCh
328
346
 
329
347
  const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
330
348
  dispatchWebhook(
331
- { ticketId, messageId: doc.id, authorType: doc.authorType },
349
+ {
350
+ ticketId,
351
+ messageId: doc.id,
352
+ authorType: doc.authorType,
353
+ body: doc.body?.length > 500 ? doc.body.slice(0, 500) + '...' : doc.body,
354
+ },
332
355
  'ticket_replied',
333
356
  req.payload,
334
357
  slugs,
@@ -534,7 +557,7 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
534
557
  ] : []),
535
558
  ],
536
559
  hooks: {
537
- beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
560
+ beforeChange: [createRestrictClientTicketTarget(slugs), createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
538
561
  afterChange: [
539
562
  ...(options?.capabilities?.sms ? [createSmsNotification(slugs, options.capabilities.sms)] : []),
540
563
  createAutoUpdateStatus(slugs),
@@ -543,7 +566,6 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
543
566
  createCheckSlaOnReply(slugs, notificationSlug),
544
567
  createSyncTicketReplyToChat(slugs),
545
568
  createNotifyAdminOnClientMessage(slugs, notificationSlug),
546
- createFireMessageWebhooks(slugs),
547
569
  createDispatchWebhookOnReply(slugs),
548
570
  createNotifyMentions(slugs),
549
571
  ...(options?.capabilities?.threadCleanup ? [createThreadCleanup(options.capabilities.threadCleanup)] : []),
@@ -3,13 +3,13 @@ import type {
3
3
  CollectionBeforeChangeHook,
4
4
  CollectionAfterChangeHook,
5
5
  CollectionBeforeDeleteHook,
6
+ CollectionBeforeValidateHook,
6
7
  Field,
7
8
  Where,
8
9
  } from 'payload'
9
10
  import type { CollectionSlugs } from '../utils/slugs'
10
11
  import type { SupportCapabilities } from '../types'
11
12
  import { escapeHtml, emailWrapper, emailParagraph, emailButton } from '../utils/emailTemplate'
12
- import { fireWebhooks } from '../utils/fireWebhooks'
13
13
  import { createAdminNotification } from '../utils/adminNotification'
14
14
  import { dispatchWebhook } from '../utils/webhookDispatcher'
15
15
  import { readSupportSettings } from '../utils/readSettings'
@@ -96,13 +96,52 @@ function createAssignTicketNumber(
96
96
 
97
97
  function createAssignClientOnCreate(slugs: CollectionSlugs): CollectionBeforeChangeHook {
98
98
  return async ({ data, operation, req }) => {
99
- if (operation === 'create' && req.user?.collection === slugs.supportClients && !data.client) {
99
+ // Unconditional, NOT `if (!data.client)`: a support-client posting an explicit
100
+ // `client` id would otherwise open a ticket in someone else's name.
101
+ if (operation === 'create' && req.user?.collection === slugs.supportClients) {
100
102
  data.client = req.user.id
101
103
  }
102
104
  return data
103
105
  }
104
106
  }
105
107
 
108
+ /**
109
+ * Fields a support-client is allowed to submit when opening a ticket from the
110
+ * portal. Everything else (billing, SLA, assignment, counters, AI bookkeeping,
111
+ * `client`, `source`) is server-owned.
112
+ */
113
+ const CLIENT_CREATABLE_TICKET_FIELDS = ['subject', 'category', 'priority', 'project'] as const
114
+
115
+ /**
116
+ * Allow-list the payload of a client-initiated ticket creation.
117
+ *
118
+ * `access.create` grants any authenticated support-client the right to create a
119
+ * ticket, and Payload happily persists every field it is handed — including
120
+ * `paymentStatus: 'paid'`, `billable: false`, `assignedTo` or `slaPolicy`, all of
121
+ * which the billing endpoints read back as trusted input.
122
+ *
123
+ * Runs as `beforeValidate` so the later `beforeChange` hooks (ticket number
124
+ * allocation, round-robin assignment) can still set the server-owned fields they
125
+ * own. Update is already covered by `createRestrictClientUpdates`.
126
+ */
127
+ function createRestrictClientCreate(slugs: CollectionSlugs): CollectionBeforeValidateHook {
128
+ return async ({ data, operation, req }) => {
129
+ if (operation !== 'create') return data
130
+ if (req.user?.collection !== slugs.supportClients) return data
131
+
132
+ const incoming = (data ?? {}) as Record<string, unknown>
133
+ const sanitized: Record<string, unknown> = {}
134
+ for (const field of CLIENT_CREATABLE_TICKET_FIELDS) {
135
+ if (incoming[field] !== undefined) sanitized[field] = incoming[field]
136
+ }
137
+ // Clients open tickets; they do not get to pick the initial workflow state.
138
+ sanitized.status = 'open'
139
+ sanitized.client = req.user.id
140
+ sanitized.source = 'portal'
141
+ return sanitized
142
+ }
143
+ }
144
+
106
145
  const autoPaidAt: CollectionBeforeChangeHook = async ({ data, operation, originalDoc }) => {
107
146
  if (operation !== 'update') return data
108
147
  if (data.paymentStatus === 'paid' && originalDoc?.paymentStatus !== 'paid' && !data.paidAt) {
@@ -133,16 +172,10 @@ function createAutoAssignAdmin(slugs: CollectionSlugs): CollectionBeforeChangeHo
133
172
  const { payload } = req
134
173
  let roundRobinEnabled = false
135
174
  try {
136
- const prefs = await payload.find({
137
- collection: 'payload-preferences',
138
- where: { key: { equals: 'support-round-robin' } },
139
- limit: 1,
140
- depth: 0,
141
- overrideAccess: true,
142
- })
143
- if (prefs.docs.length > 0) {
144
- roundRobinEnabled = (prefs.docs[0].value as { enabled?: boolean })?.enabled === true
145
- }
175
+ // Single source of truth: the `features` block of the support settings,
176
+ // which also falls back to the legacy `support-round-robin` row.
177
+ const settings = await readSupportSettings(payload)
178
+ roundRobinEnabled = settings.features.roundRobin === true
146
179
  } catch (err) { console.warn('[support] Failed to read round-robin preferences:', err) }
147
180
 
148
181
  const admins = await payload.find({
@@ -384,62 +417,6 @@ function createNotifyClientOnResolve(slugs: CollectionSlugs): CollectionAfterCha
384
417
  }
385
418
  }
386
419
 
387
- function createFireTicketWebhooks(slugs: CollectionSlugs): CollectionAfterChangeHook {
388
- return async ({ doc, previousDoc, operation, req }) => {
389
- const { payload } = req
390
-
391
- if (operation === 'create') {
392
- // ticket_created
393
- fireWebhooks(payload, slugs, 'ticket_created', {
394
- id: doc.id,
395
- ticketNumber: doc.ticketNumber,
396
- subject: doc.subject,
397
- status: doc.status,
398
- priority: doc.priority,
399
- category: doc.category,
400
- })
401
- return doc
402
- }
403
-
404
- if (operation === 'update' && previousDoc) {
405
- // ticket_resolved
406
- if (previousDoc.status !== doc.status && doc.status === 'resolved') {
407
- fireWebhooks(payload, slugs, 'ticket_resolved', {
408
- id: doc.id,
409
- ticketNumber: doc.ticketNumber,
410
- subject: doc.subject,
411
- previousStatus: previousDoc.status,
412
- })
413
-
414
- // Invalidate client summary cache so it refreshes on next view
415
- const clientId = typeof doc.client === 'object' ? doc.client?.id : doc.client
416
- if (clientId) {
417
- payload.update({
418
- collection: 'client-summaries',
419
- where: { client: { equals: clientId } },
420
- data: { generatedAt: new Date(0).toISOString() }, // Force cache expiry
421
- overrideAccess: true,
422
- }).catch(() => { /* silent — collection might not exist yet */ })
423
- }
424
- }
425
-
426
- // ticket_assigned
427
- const oldAssigned = typeof previousDoc.assignedTo === 'object' ? previousDoc.assignedTo?.id : previousDoc.assignedTo
428
- const newAssigned = typeof doc.assignedTo === 'object' ? doc.assignedTo?.id : doc.assignedTo
429
- if (newAssigned && oldAssigned !== newAssigned) {
430
- fireWebhooks(payload, slugs, 'ticket_assigned', {
431
- id: doc.id,
432
- ticketNumber: doc.ticketNumber,
433
- subject: doc.subject,
434
- assignedTo: newAssigned,
435
- })
436
- }
437
- }
438
-
439
- return doc
440
- }
441
- }
442
-
443
420
  function createNotifyAdminOnNewTicket(slugs: CollectionSlugs, notificationSlug: string): CollectionAfterChangeHook {
444
421
  return async ({ doc, operation, req }) => {
445
422
  if (operation !== 'create') return doc
@@ -462,24 +439,82 @@ function createNotifyAdminOnNewTicket(slugs: CollectionSlugs, notificationSlug:
462
439
  }
463
440
  }
464
441
 
442
+ /**
443
+ * Single outbound-webhook dispatcher for tickets.
444
+ *
445
+ * Until 2.x two dispatchers were registered side by side on these very hooks with
446
+ * identical guards, so every subscriber received TWO POSTs per event, with two
447
+ * incompatible body shapes and two auth schemes — the legacy one shipping the
448
+ * endpoint secret in clear in `X-Webhook-Secret`, which defeated the HMAC of the
449
+ * other. Only the HMAC path remains; its payloads absorb the legacy fields
450
+ * (`id`, `status`, `priority`, `category`, `previousStatus`, `assignedTo`) so
451
+ * consumers of either shape keep working.
452
+ */
465
453
  function createDispatchWebhookOnTicket(slugs: CollectionSlugs): CollectionAfterChangeHook {
466
454
  return async ({ doc, previousDoc, operation, req }) => {
455
+ const { payload } = req
456
+
467
457
  if (operation === 'create') {
468
458
  dispatchWebhook(
469
- { ticketId: doc.id, ticketNumber: doc.ticketNumber, subject: doc.subject },
459
+ {
460
+ ticketId: doc.id,
461
+ id: doc.id,
462
+ ticketNumber: doc.ticketNumber,
463
+ subject: doc.subject,
464
+ status: doc.status,
465
+ priority: doc.priority,
466
+ category: doc.category,
467
+ },
470
468
  'ticket_created',
471
- req.payload,
469
+ payload,
472
470
  slugs,
473
471
  )
472
+ return doc
474
473
  }
475
474
 
476
- if (operation === 'update' && previousDoc?.status !== doc.status && doc.status === 'resolved') {
477
- dispatchWebhook(
478
- { ticketId: doc.id, ticketNumber: doc.ticketNumber, subject: doc.subject },
479
- 'ticket_resolved',
480
- req.payload,
481
- slugs,
482
- )
475
+ if (operation === 'update' && previousDoc) {
476
+ if (previousDoc.status !== doc.status && doc.status === 'resolved') {
477
+ dispatchWebhook(
478
+ {
479
+ ticketId: doc.id,
480
+ id: doc.id,
481
+ ticketNumber: doc.ticketNumber,
482
+ subject: doc.subject,
483
+ previousStatus: previousDoc.status,
484
+ },
485
+ 'ticket_resolved',
486
+ payload,
487
+ slugs,
488
+ )
489
+
490
+ // Invalidate client summary cache so it refreshes on next view
491
+ const clientId = typeof doc.client === 'object' ? doc.client?.id : doc.client
492
+ if (clientId) {
493
+ payload.update({
494
+ collection: 'client-summaries',
495
+ where: { client: { equals: clientId } },
496
+ data: { generatedAt: new Date(0).toISOString() }, // Force cache expiry
497
+ overrideAccess: true,
498
+ }).catch(() => { /* silent — collection might not exist yet */ })
499
+ }
500
+ }
501
+
502
+ const oldAssigned = typeof previousDoc.assignedTo === 'object' ? previousDoc.assignedTo?.id : previousDoc.assignedTo
503
+ const newAssigned = typeof doc.assignedTo === 'object' ? doc.assignedTo?.id : doc.assignedTo
504
+ if (newAssigned && oldAssigned !== newAssigned) {
505
+ dispatchWebhook(
506
+ {
507
+ ticketId: doc.id,
508
+ id: doc.id,
509
+ ticketNumber: doc.ticketNumber,
510
+ subject: doc.subject,
511
+ assignedTo: newAssigned,
512
+ },
513
+ 'ticket_assigned',
514
+ payload,
515
+ slugs,
516
+ )
517
+ }
483
518
  }
484
519
 
485
520
  return doc
@@ -626,7 +661,10 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
626
661
  { label: 'Paiement partiel', value: 'partial' },
627
662
  { label: 'Payé', value: 'paid' },
628
663
  ],
629
- access: { update: ({ req }) => req.user?.collection === 'users' },
664
+ // `slugs.users`, not the literal 'users': the users collection slug is
665
+ // configurable, and a hard-coded one silently opens this field to everybody
666
+ // on a host that renamed it.
667
+ access: { update: ({ req }) => req.user?.collection === slugs.users },
630
668
  admin: { width: '50%', condition: (data) => !!data?.invoice },
631
669
  },
632
670
  {
@@ -890,6 +928,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
890
928
  { name: 'totalTimeMinutes', type: 'number', defaultValue: 0, label: 'Temps total (minutes)', admin: { readOnly: true, position: 'sidebar' } },
891
929
  ],
892
930
  hooks: {
931
+ beforeValidate: [createRestrictClientCreate(slugs)],
893
932
  beforeChange: [
894
933
  createAssignTicketNumber(slugs, options?.ticketNumber),
895
934
  createAssignClientOnCreate(slugs),
@@ -910,7 +949,6 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
910
949
  createNotifyClientOnResolve(slugs),
911
950
  createTicketStatusEmail(slugs),
912
951
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
913
- createFireTicketWebhooks(slugs),
914
952
  createDispatchWebhookOnTicket(slugs),
915
953
  createApplyAutomationRules(slugs),
916
954
  ],