@consilioweb/payload-support 0.16.0 → 1.1.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 (199) hide show
  1. package/README.md +357 -435
  2. package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
  4. package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
  5. package/dist/components/TicketConversation/SkeletonText.js +20 -0
  6. package/dist/components/TicketConversation/constants.cjs +27 -0
  7. package/dist/components/TicketConversation/constants.js +26 -1
  8. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  9. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  10. package/dist/components/TicketConversation/index.cjs +10 -147
  11. package/dist/components/TicketConversation/index.js +4 -141
  12. package/dist/index.cjs +1763 -677
  13. package/dist/index.d.cts +4 -0
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.js +1760 -677
  16. package/dist/views/BillingView/client.cjs +40 -54
  17. package/dist/views/BillingView/client.js +40 -54
  18. package/dist/views/ChatView/client.cjs +4 -3
  19. package/dist/views/ChatView/client.js +4 -3
  20. package/dist/views/CrmView/client.cjs +7 -7
  21. package/dist/views/CrmView/client.js +7 -7
  22. package/dist/views/EmailTrackingView/client.cjs +6 -5
  23. package/dist/views/EmailTrackingView/client.js +6 -5
  24. package/dist/views/ImportConversationView/client.cjs +4 -4
  25. package/dist/views/ImportConversationView/client.js +4 -4
  26. package/dist/views/LogsView/client.cjs +3 -2
  27. package/dist/views/LogsView/client.js +3 -2
  28. package/dist/views/NewTicketView/client.cjs +1 -1
  29. package/dist/views/NewTicketView/client.js +1 -1
  30. package/dist/views/PendingEmailsView/client.cjs +7 -6
  31. package/dist/views/PendingEmailsView/client.js +7 -6
  32. package/dist/views/SupportDashboardView/client.cjs +19 -22
  33. package/dist/views/SupportDashboardView/client.js +19 -22
  34. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  35. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  36. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  37. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  38. package/dist/views/TicketDetailView/client.cjs +59 -190
  39. package/dist/views/TicketDetailView/client.js +52 -183
  40. package/dist/views/TicketDetailView/constants.cjs +20 -0
  41. package/dist/views/TicketDetailView/constants.js +16 -0
  42. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  43. package/dist/views/TicketDetailView/helpers.js +20 -0
  44. package/dist/views/TicketDetailView/types.cjs +2 -0
  45. package/dist/views/TicketDetailView/types.js +1 -0
  46. package/dist/views/TicketInboxView/client.cjs +14 -10
  47. package/dist/views/TicketInboxView/client.js +14 -10
  48. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  49. package/dist/views/TicketingSettingsView/client.js +92 -88
  50. package/dist/views/TimeDashboardView/client.cjs +2 -2
  51. package/dist/views/TimeDashboardView/client.js +2 -2
  52. package/dist/views/shared/StatusPill.cjs +23 -12
  53. package/dist/views/shared/StatusPill.js +23 -12
  54. package/dist/views/shared/dateLocale.cjs +5 -0
  55. package/dist/views/shared/dateLocale.js +3 -0
  56. package/package.json +28 -15
  57. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  58. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  59. package/src/__tests__/integration/auth.test.ts +43 -0
  60. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  61. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  62. package/src/__tests__/integration/channels.test.ts +52 -0
  63. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  64. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  65. package/src/__tests__/integration/digest.test.ts +48 -0
  66. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  67. package/src/__tests__/integration/isolation.test.ts +81 -0
  68. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  69. package/src/__tests__/integration/nps.test.ts +37 -0
  70. package/src/__tests__/integration/per-team-sla.test.ts +44 -0
  71. package/src/__tests__/integration/push.test.ts +75 -0
  72. package/src/__tests__/integration/sanitization.test.ts +35 -0
  73. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  74. package/src/__tests__/integration/smoke.test.ts +26 -0
  75. package/src/__tests__/integration/snooze.test.ts +68 -0
  76. package/src/__tests__/integration/teams.test.ts +38 -0
  77. package/src/__tests__/rateLimiter.test.ts +89 -0
  78. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  79. package/src/__tests__/sla.test.ts +258 -0
  80. package/src/collections/AutomationRules.ts +66 -0
  81. package/src/collections/NotificationQueue.ts +30 -0
  82. package/src/collections/PushSubscription.ts +28 -0
  83. package/src/collections/SatisfactionSurveys.ts +11 -2
  84. package/src/collections/SlaPolicies.ts +10 -0
  85. package/src/collections/SupportClients.ts +58 -1
  86. package/src/collections/SupportTeam.ts +27 -0
  87. package/src/collections/TicketActivityLog.ts +9 -4
  88. package/src/collections/TicketMessages.ts +140 -31
  89. package/src/collections/Tickets.ts +31 -73
  90. package/src/collections/index.ts +4 -0
  91. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  92. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  93. package/src/components/TicketConversation/constants.ts +29 -0
  94. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  95. package/src/components/TicketConversation/index.tsx +4 -152
  96. package/src/endpoints/admin-chat-stream.ts +3 -4
  97. package/src/endpoints/admin-chat.ts +11 -20
  98. package/src/endpoints/admin-stats.ts +77 -35
  99. package/src/endpoints/ai-agent.ts +36 -0
  100. package/src/endpoints/apply-macro.ts +8 -14
  101. package/src/endpoints/auth-2fa.ts +12 -12
  102. package/src/endpoints/auto-close.ts +6 -10
  103. package/src/endpoints/billing.ts +17 -4
  104. package/src/endpoints/bulk-action.ts +7 -12
  105. package/src/endpoints/channels.ts +89 -0
  106. package/src/endpoints/chat-stream.ts +3 -4
  107. package/src/endpoints/chat.ts +7 -12
  108. package/src/endpoints/chatbot.ts +2 -2
  109. package/src/endpoints/client-intelligence.ts +4 -4
  110. package/src/endpoints/delete-account.ts +9 -16
  111. package/src/endpoints/email-stats.ts +2 -2
  112. package/src/endpoints/escalate.ts +2 -2
  113. package/src/endpoints/export-csv.ts +2 -2
  114. package/src/endpoints/export-data.ts +5 -8
  115. package/src/endpoints/import-conversation.ts +6 -10
  116. package/src/endpoints/index.ts +22 -1
  117. package/src/endpoints/invite-collaborator.ts +30 -15
  118. package/src/endpoints/invoice.ts +127 -0
  119. package/src/endpoints/kb-search.ts +2 -2
  120. package/src/endpoints/login.ts +12 -4
  121. package/src/endpoints/merge-clients.ts +14 -19
  122. package/src/endpoints/merge-tickets.ts +11 -18
  123. package/src/endpoints/oauth-google.ts +86 -39
  124. package/src/endpoints/pending-emails-process.ts +15 -25
  125. package/src/endpoints/process-digests.ts +93 -0
  126. package/src/endpoints/process-scheduled.ts +6 -10
  127. package/src/endpoints/process-snooze.ts +78 -0
  128. package/src/endpoints/purge-logs.ts +2 -2
  129. package/src/endpoints/push.ts +49 -0
  130. package/src/endpoints/resend-notification.ts +3 -4
  131. package/src/endpoints/round-robin-config.ts +3 -4
  132. package/src/endpoints/satisfaction.ts +21 -14
  133. package/src/endpoints/search.ts +5 -8
  134. package/src/endpoints/seed-kb.ts +3 -4
  135. package/src/endpoints/send-reminder.ts +6 -9
  136. package/src/endpoints/settings.ts +5 -2
  137. package/src/endpoints/signature.ts +3 -4
  138. package/src/endpoints/sla-check.ts +2 -2
  139. package/src/endpoints/split-ticket.ts +7 -12
  140. package/src/endpoints/statuses.ts +2 -2
  141. package/src/endpoints/ticket-feedback.ts +4 -6
  142. package/src/endpoints/ticket-synthesis.ts +2 -2
  143. package/src/endpoints/track-open.ts +16 -28
  144. package/src/endpoints/transfer-ticket.ts +21 -9
  145. package/src/endpoints/user-prefs.ts +3 -4
  146. package/src/hooks/automationRules.ts +84 -0
  147. package/src/hooks/checkSLA.ts +63 -17
  148. package/src/plugin.ts +8 -0
  149. package/src/portal/LiveChat.tsx +2 -63
  150. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  151. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  152. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  153. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  154. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  155. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  156. package/src/portal/helpers.ts +46 -0
  157. package/src/portal/login/page.tsx +3 -8
  158. package/src/portal/types.ts +15 -0
  159. package/src/types/lucide-react.d.ts +34 -0
  160. package/src/types/payload-config.d.ts +10 -0
  161. package/src/utils/adminNotification.ts +2 -2
  162. package/src/utils/aiAgent.ts +147 -0
  163. package/src/utils/auth.ts +5 -4
  164. package/src/utils/channels.ts +36 -0
  165. package/src/utils/db.ts +73 -0
  166. package/src/utils/emailTemplate.ts +8 -1
  167. package/src/utils/fireWebhooks.ts +4 -6
  168. package/src/utils/generateTicketSynthesis.ts +7 -12
  169. package/src/utils/invoicePdf.ts +88 -0
  170. package/src/utils/notificationQueue.ts +36 -0
  171. package/src/utils/push.ts +63 -0
  172. package/src/utils/readSettings.ts +22 -6
  173. package/src/utils/sanitizeHtml.ts +57 -0
  174. package/src/utils/slugs.ts +8 -0
  175. package/src/utils/teamAccess.ts +27 -0
  176. package/src/utils/ticketAccess.ts +53 -0
  177. package/src/utils/webhookDispatcher.ts +4 -6
  178. package/src/views/BillingView/client.tsx +49 -37
  179. package/src/views/ChatView/client.tsx +4 -3
  180. package/src/views/CrmView/client.tsx +7 -7
  181. package/src/views/EmailTrackingView/client.tsx +7 -6
  182. package/src/views/ImportConversationView/client.tsx +4 -4
  183. package/src/views/LogsView/client.tsx +3 -2
  184. package/src/views/NewTicketView/client.tsx +1 -1
  185. package/src/views/PendingEmailsView/client.tsx +7 -6
  186. package/src/views/SupportDashboardView/client.tsx +21 -21
  187. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  188. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  189. package/src/views/TicketDetailView/client.tsx +54 -179
  190. package/src/views/TicketDetailView/constants.ts +23 -0
  191. package/src/views/TicketDetailView/helpers.ts +17 -0
  192. package/src/views/TicketDetailView/types.ts +10 -0
  193. package/src/views/TicketInboxView/client.tsx +16 -10
  194. package/src/views/TicketingSettingsView/client.tsx +90 -87
  195. package/src/views/TimeDashboardView/client.tsx +2 -2
  196. package/src/views/shared/StatusPill.tsx +24 -12
  197. package/src/views/shared/dateLocale.ts +5 -0
  198. package/src/views/shared/locales/en.json +708 -0
  199. package/src/views/shared/locales/fr.json +708 -0
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { dbFind, dbCount } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * GET /api/support/admin-stats
@@ -17,13 +18,16 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
17
18
 
18
19
  requireAdmin(req, slugs)
19
20
 
21
+ // Optional per-team dashboard scoping (?teamId=) — applied to ticket counts.
22
+ const teamId = new URL(req.url!).searchParams.get('teamId')
23
+ const teamAnd = teamId ? [{ team: { equals: Number(teamId) } }] : []
24
+
20
25
  // ── Status counts via individual count queries ──
21
26
  const statuses = ['open', 'waiting_client', 'resolved'] as const
22
27
  const statusCounts = await Promise.all(
23
28
  statuses.map(async (status) => {
24
- const result = await payload.count({
25
- collection: slugs.tickets as any,
26
- where: { status: { equals: status } },
29
+ const result = await dbCount(payload, slugs.tickets, {
30
+ where: { and: [{ status: { equals: status } }, ...teamAnd] },
27
31
  overrideAccess: true,
28
32
  })
29
33
  return [status, result.totalDocs] as const
@@ -36,9 +40,8 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
36
40
  const priorities = ['low', 'normal', 'high', 'urgent'] as const
37
41
  const priorityCounts = await Promise.all(
38
42
  priorities.map(async (priority) => {
39
- const result = await payload.count({
40
- collection: slugs.tickets as any,
41
- where: { priority: { equals: priority } },
43
+ const result = await dbCount(payload, slugs.tickets, {
44
+ where: { and: [{ priority: { equals: priority } }, ...teamAnd] },
42
45
  overrideAccess: true,
43
46
  })
44
47
  return [priority, result.totalDocs] as const
@@ -50,9 +53,8 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
50
53
  const categories = ['bug', 'content', 'feature', 'question', 'hosting'] as const
51
54
  const categoryCounts = await Promise.all(
52
55
  categories.map(async (category) => {
53
- const result = await payload.count({
54
- collection: slugs.tickets as any,
55
- where: { category: { equals: category } },
56
+ const result = await dbCount(payload, slugs.tickets, {
57
+ where: { and: [{ category: { equals: category } }, ...teamAnd] },
56
58
  overrideAccess: true,
57
59
  })
58
60
  return [category, result.totalDocs] as const
@@ -68,18 +70,38 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
68
70
  const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
69
71
 
70
72
  const [created7, created30] = await Promise.all([
71
- payload.count({
72
- collection: slugs.tickets as any,
73
+ dbCount(payload, slugs.tickets, {
73
74
  where: { createdAt: { greater_than_equal: sevenDaysAgo.toISOString() } },
74
75
  overrideAccess: true,
75
76
  }),
76
- payload.count({
77
- collection: slugs.tickets as any,
77
+ dbCount(payload, slugs.tickets, {
78
78
  where: { createdAt: { greater_than_equal: thirtyDaysAgo.toISOString() } },
79
79
  overrideAccess: true,
80
80
  }),
81
81
  ])
82
82
 
83
+ // Real per-day ticket volume for the last 7 days (chronological), replacing
84
+ // the previous synthetic distribution in the dashboard chart.
85
+ const volumeByDay = await Promise.all(
86
+ Array.from({ length: 7 }, (_, i) => i).map(async (offset) => {
87
+ const dayStart = new Date(now)
88
+ dayStart.setHours(0, 0, 0, 0)
89
+ dayStart.setDate(dayStart.getDate() - (6 - offset))
90
+ const dayEnd = new Date(dayStart)
91
+ dayEnd.setDate(dayEnd.getDate() + 1)
92
+ const r = await dbCount(payload, slugs.tickets, {
93
+ where: {
94
+ and: [
95
+ { createdAt: { greater_than_equal: dayStart.toISOString() } },
96
+ { createdAt: { less_than: dayEnd.toISOString() } },
97
+ ],
98
+ },
99
+ overrideAccess: true,
100
+ })
101
+ return { date: dayStart.toISOString(), count: r.totalDocs }
102
+ }),
103
+ )
104
+
83
105
  // ── Averages: paginate with limit:100, only fetch needed fields ──
84
106
  const PAGE_SIZE = 100
85
107
  const MAX_PAGES = 50
@@ -93,8 +115,7 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
93
115
  let hasMore = true
94
116
 
95
117
  while (hasMore && page <= MAX_PAGES) {
96
- const batch = await payload.find({
97
- collection: slugs.tickets as any,
118
+ const batch = await dbFind(payload, slugs.tickets, {
98
119
  limit: PAGE_SIZE,
99
120
  page,
100
121
  depth: 0,
@@ -124,46 +145,64 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
124
145
  page++
125
146
  }
126
147
 
127
- // ── Satisfaction: use count + paginated average ──
128
- const surveyCount = await payload.count({
129
- collection: slugs.satisfactionSurveys as any,
148
+ // ── Satisfaction (CSAT) + NPS: count + paginated aggregation ──
149
+ const surveyCount = await dbCount(payload, slugs.satisfactionSurveys, {
130
150
  overrideAccess: true,
131
151
  })
132
152
 
133
153
  let satisfactionAvg = 0
154
+ let satisfactionCount = 0
155
+ let npsScore: number | null = null
156
+ let npsCount = 0
134
157
  if (surveyCount.totalDocs > 0) {
135
- let totalRating = 0
158
+ let ratingSum = 0
159
+ let ratingN = 0
160
+ let promoters = 0
161
+ let detractors = 0
162
+ let npsN = 0
136
163
  let surveyPage = 1
137
164
  let surveyHasMore = true
138
165
  while (surveyHasMore && surveyPage <= MAX_PAGES) {
139
- const batch = await payload.find({
140
- collection: slugs.satisfactionSurveys as any,
166
+ const batch = await dbFind(payload, slugs.satisfactionSurveys, {
141
167
  limit: PAGE_SIZE,
142
168
  page: surveyPage,
143
169
  depth: 0,
144
170
  overrideAccess: true,
145
- select: { rating: true },
171
+ select: { rating: true, nps: true },
146
172
  })
147
173
  for (const s of batch.docs) {
148
- totalRating += ((s as any).rating || 0)
174
+ const r = (s as { rating?: number }).rating
175
+ if (typeof r === 'number' && r > 0) { ratingSum += r; ratingN++ }
176
+ const n = (s as { nps?: number }).nps
177
+ if (typeof n === 'number') {
178
+ npsN++
179
+ if (n >= 9) promoters++
180
+ else if (n <= 6) detractors++
181
+ }
149
182
  }
150
183
  surveyHasMore = batch.hasNextPage ?? false
151
184
  surveyPage++
152
185
  }
153
- satisfactionAvg = Math.round((totalRating / surveyCount.totalDocs) * 10) / 10
186
+ satisfactionAvg = ratingN > 0 ? Math.round((ratingSum / ratingN) * 10) / 10 : 0
187
+ satisfactionCount = ratingN
188
+ if (npsN > 0) {
189
+ // NPS = %promoters - %detractors, on a -100..100 scale.
190
+ npsScore = Math.round(((promoters - detractors) / npsN) * 100)
191
+ npsCount = npsN
192
+ }
154
193
  }
155
194
 
156
- const [clientCount, pendingEmailsCount] = await Promise.all([
157
- payload.count({
158
- collection: slugs.supportClients as any,
159
- overrideAccess: true,
160
- }),
161
- payload.count({
162
- collection: slugs.pendingEmails as any,
195
+ const clientCount = await dbCount(payload, slugs.supportClients, { overrideAccess: true })
196
+ // pendingEmails is an optional feature — tolerate its collection being absent
197
+ // (otherwise the whole stats endpoint 500s on installs without it).
198
+ let pendingEmailsTotal = 0
199
+ try {
200
+ const pe = await dbCount(payload, slugs.pendingEmails, {
163
201
  where: { status: { equals: 'pending' } },
164
202
  overrideAccess: true,
165
- }),
166
- ])
203
+ })
204
+ pendingEmailsTotal = pe.totalDocs
205
+ } catch { /* pendingEmails feature disabled — count stays 0 */ }
167
206
 
168
207
  return new Response(JSON.stringify({
169
208
  total,
@@ -172,6 +211,7 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
172
211
  byCategory,
173
212
  createdLast7Days: created7.totalDocs,
174
213
  createdLast30Days: created30.totalDocs,
214
+ volumeByDay,
175
215
  avgResponseTimeHours: responseTimeCount > 0
176
216
  ? Math.round((totalResponseTimeMs / responseTimeCount / (1000 * 60 * 60)) * 10) / 10
177
217
  : null,
@@ -180,9 +220,11 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
180
220
  : null,
181
221
  totalTimeMinutes,
182
222
  satisfactionAvg,
183
- satisfactionCount: surveyCount.totalDocs,
223
+ satisfactionCount,
224
+ npsScore,
225
+ npsCount,
184
226
  clientCount: clientCount.totalDocs,
185
- pendingEmailsCount: pendingEmailsCount.totalDocs,
227
+ pendingEmailsCount: pendingEmailsTotal,
186
228
  }), {
187
229
  headers: {
188
230
  'Content-Type': 'application/json',
@@ -0,0 +1,36 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { runAiAgent } from '../utils/aiAgent'
5
+
6
+ /**
7
+ * POST /api/support/ai-agent body: { ticketId, confidenceThreshold? }
8
+ *
9
+ * Runs the autonomous AI agent on a ticket: it either posts a KB-grounded answer
10
+ * (when confident) or escalates to a human with an internal note. Admin-only.
11
+ */
12
+ export function createAiAgentEndpoint(slugs: CollectionSlugs): Endpoint {
13
+ return {
14
+ path: '/support/ai-agent',
15
+ method: 'post',
16
+ handler: async (req) => {
17
+ try {
18
+ requireAdmin(req, slugs)
19
+ let body: { ticketId?: number | string; confidenceThreshold?: number } = {}
20
+ try { body = (await req.json!()) as typeof body } catch { /* no body */ }
21
+ if (!body.ticketId) {
22
+ return Response.json({ error: 'ticketId requis.' }, { status: 400 })
23
+ }
24
+ const result = await runAiAgent(req.payload, slugs, body.ticketId, {
25
+ confidenceThreshold: body.confidenceThreshold,
26
+ })
27
+ return Response.json({ ok: true, ...result })
28
+ } catch (error) {
29
+ const authResponse = handleAuthError(error)
30
+ if (authResponse) return authResponse
31
+ console.error('[ai-agent] Error:', error)
32
+ return Response.json({ error: 'Erreur interne' }, { status: 500 })
33
+ }
34
+ },
35
+ }
36
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { dbFindByID, dbUpdate, dbCreate } from '../utils/db'
4
5
 
5
6
  interface MacroAction {
6
7
  type: 'set_status' | 'set_priority' | 'add_tag' | 'send_reply' | 'assign'
@@ -28,8 +29,7 @@ export function createApplyMacroEndpoint(slugs: CollectionSlugs): Endpoint {
28
29
  return Response.json({ error: 'macroId and ticketId are required' }, { status: 400 })
29
30
  }
30
31
 
31
- const macro = await payload.findByID({
32
- collection: slugs.macros as any,
32
+ const macro = await dbFindByID(payload, slugs.macros, {
33
33
  id: macroId,
34
34
  depth: 0,
35
35
  overrideAccess: true,
@@ -38,8 +38,7 @@ export function createApplyMacroEndpoint(slugs: CollectionSlugs): Endpoint {
38
38
  if (!macro) return Response.json({ error: 'Macro not found' }, { status: 404 })
39
39
  if (!macro.isActive) return Response.json({ error: 'Macro is disabled' }, { status: 400 })
40
40
 
41
- const ticket = await payload.findByID({
42
- collection: slugs.tickets as any,
41
+ const ticket = await dbFindByID(payload, slugs.tickets, {
43
42
  id: ticketId,
44
43
  depth: 0,
45
44
  overrideAccess: true,
@@ -54,8 +53,7 @@ export function createApplyMacroEndpoint(slugs: CollectionSlugs): Endpoint {
54
53
  try {
55
54
  switch (action.type) {
56
55
  case 'set_status':
57
- await payload.update({
58
- collection: slugs.tickets as any,
56
+ await dbUpdate(payload, slugs.tickets, {
59
57
  id: ticketId,
60
58
  data: { status: action.value },
61
59
  overrideAccess: true,
@@ -64,8 +62,7 @@ export function createApplyMacroEndpoint(slugs: CollectionSlugs): Endpoint {
64
62
  break
65
63
 
66
64
  case 'set_priority':
67
- await payload.update({
68
- collection: slugs.tickets as any,
65
+ await dbUpdate(payload, slugs.tickets, {
69
66
  id: ticketId,
70
67
  data: { priority: action.value },
71
68
  overrideAccess: true,
@@ -78,8 +75,7 @@ export function createApplyMacroEndpoint(slugs: CollectionSlugs): Endpoint {
78
75
  if (!currentTags.includes(action.value)) {
79
76
  currentTags.push(action.value)
80
77
  }
81
- await payload.update({
82
- collection: slugs.tickets as any,
78
+ await dbUpdate(payload, slugs.tickets, {
83
79
  id: ticketId,
84
80
  data: { tags: currentTags },
85
81
  overrideAccess: true,
@@ -89,8 +85,7 @@ export function createApplyMacroEndpoint(slugs: CollectionSlugs): Endpoint {
89
85
  }
90
86
 
91
87
  case 'send_reply':
92
- await payload.create({
93
- collection: slugs.ticketMessages as any,
88
+ await dbCreate(payload, slugs.ticketMessages, {
94
89
  data: {
95
90
  ticket: ticketId,
96
91
  body: action.value,
@@ -108,8 +103,7 @@ export function createApplyMacroEndpoint(slugs: CollectionSlugs): Endpoint {
108
103
  appliedActions.push({ type: action.type, value: action.value, success: false, error: 'Invalid user ID' })
109
104
  break
110
105
  }
111
- await payload.update({
112
- collection: slugs.tickets as any,
106
+ await dbUpdate(payload, slugs.tickets, {
113
107
  id: ticketId,
114
108
  data: { assignedTo: userId },
115
109
  overrideAccess: true,
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import crypto, { createHmac } from 'crypto'
4
4
  import { RateLimiter } from '../utils/rateLimiter'
5
5
  import { escapeHtml } from '../utils/emailTemplate'
6
+ import { dbFind, dbUpdate } from '../utils/db'
6
7
 
7
8
  const sendLimiter = new RateLimiter(60 * 60 * 1000, 3) // 3 per hour
8
9
  const verifyLimiter = new RateLimiter(15 * 60 * 1000, 5) // 5 per 15 min
@@ -14,7 +15,11 @@ function generateSecureCode(): string {
14
15
  }
15
16
 
16
17
  function hashCode(code: string): string {
17
- const secret = process.env.PAYLOAD_SECRET || 'payload-support-2fa'
18
+ const secret = process.env.PAYLOAD_SECRET
19
+ if (!secret) {
20
+ // Fail closed: never hash 2FA codes with an insecure, source-visible fallback.
21
+ throw new Error('[support][2fa] PAYLOAD_SECRET is not set — refusing to operate 2FA with an insecure fallback secret')
22
+ }
18
23
  return createHmac('sha256', secret).update(code).digest('hex')
19
24
  }
20
25
 
@@ -48,8 +53,7 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs): Endpoint {
48
53
  return Response.json(genericSendResponse)
49
54
  }
50
55
 
51
- const clients = await payload.find({
52
- collection: slugs.supportClients as any,
56
+ const clients = await dbFind(payload, slugs.supportClients, {
53
57
  where: { email: { equals: email } },
54
58
  limit: 1,
55
59
  depth: 0,
@@ -65,8 +69,7 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs): Endpoint {
65
69
  const twoFactorCode = hashCode(plainCode)
66
70
  const twoFactorExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString()
67
71
 
68
- await payload.update({
69
- collection: slugs.supportClients as any,
72
+ await dbUpdate(payload, slugs.supportClients, {
70
73
  id: client.id,
71
74
  data: { twoFactorCode, twoFactorExpiry },
72
75
  overrideAccess: true,
@@ -102,8 +105,7 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs): Endpoint {
102
105
  )
103
106
  }
104
107
 
105
- const clients = await payload.find({
106
- collection: slugs.supportClients as any,
108
+ const clients = await dbFind(payload, slugs.supportClients, {
107
109
  where: { email: { equals: email } },
108
110
  limit: 1,
109
111
  depth: 0,
@@ -123,8 +125,7 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs): Endpoint {
123
125
  }
124
126
 
125
127
  if (new Date() > new Date(storedExpiry)) {
126
- await payload.update({
127
- collection: slugs.supportClients as any,
128
+ await dbUpdate(payload, slugs.supportClients, {
128
129
  id: client.id,
129
130
  data: { twoFactorCode: '', twoFactorExpiry: '' },
130
131
  overrideAccess: true,
@@ -141,10 +142,9 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs): Endpoint {
141
142
  return Response.json({ error: 'Code incorrect' }, { status: 400 })
142
143
  }
143
144
 
144
- await payload.update({
145
- collection: slugs.supportClients as any,
145
+ await dbUpdate(payload, slugs.supportClients, {
146
146
  id: client.id,
147
- data: { twoFactorCode: '', twoFactorExpiry: '' },
147
+ data: { twoFactorCode: '', twoFactorExpiry: '', twoFactorVerifiedAt: new Date().toISOString() },
148
148
  overrideAccess: true,
149
149
  })
150
150
 
@@ -2,6 +2,7 @@ import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { escapeHtml } from '../utils/emailTemplate'
4
4
  import { readSupportSettings } from '../utils/readSettings'
5
+ import { dbFind, dbCreate, dbUpdate } from '../utils/db'
5
6
 
6
7
  /**
7
8
  * GET /api/support/auto-close
@@ -40,8 +41,7 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
40
41
  // Step 1: Find tickets needing a reminder
41
42
  const remindCutoff = new Date(now.getTime() - REMIND_AFTER_DAYS * 24 * 60 * 60 * 1000)
42
43
 
43
- const ticketsToRemind = await payload.find({
44
- collection: slugs.tickets as any,
44
+ const ticketsToRemind = await dbFind(payload, slugs.tickets, {
45
45
  where: {
46
46
  and: [
47
47
  { status: { equals: 'waiting_client' } },
@@ -76,8 +76,7 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
76
76
  </div>`,
77
77
  })
78
78
 
79
- await payload.update({
80
- collection: slugs.tickets as any,
79
+ await dbUpdate(payload, slugs.tickets, {
81
80
  id: t.id,
82
81
  data: { autoCloseRemindedAt: now.toISOString() },
83
82
  overrideAccess: true,
@@ -99,8 +98,7 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
99
98
  // the past means no reply was received.
100
99
  const closeCutoff = new Date(now.getTime() - CLOSE_AFTER_REMIND_DAYS * 24 * 60 * 60 * 1000)
101
100
 
102
- const ticketsToClose = await payload.find({
103
- collection: slugs.tickets as any,
101
+ const ticketsToClose = await dbFind(payload, slugs.tickets, {
104
102
  where: {
105
103
  and: [
106
104
  { status: { equals: 'waiting_client' } },
@@ -142,8 +140,7 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
142
140
  ? 'Ticket résolu automatiquement — relance manuelle restée sans réponse du client'
143
141
  : `Ticket résolu automatiquement — sans réponse client depuis ${closeTotalDays} jours`
144
142
 
145
- await payload.create({
146
- collection: slugs.ticketMessages as any,
143
+ await dbCreate(payload, slugs.ticketMessages, {
147
144
  data: {
148
145
  ticket: t.id,
149
146
  body: noteBody,
@@ -154,8 +151,7 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
154
151
  overrideAccess: true,
155
152
  })
156
153
 
157
- await payload.update({
158
- collection: slugs.tickets as any,
154
+ await dbUpdate(payload, slugs.tickets, {
159
155
  id: t.id,
160
156
  // Clear the armed deadline so a later manual reopen can't be
161
157
  // closed instantly by a stale timestamp.
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint, Where } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { dbFind } from '../utils/db'
4
5
 
5
6
  const PAGE_SIZE = 500
6
7
  const MAX_PAGES = 50
@@ -75,13 +76,26 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
75
76
  let ticketHasMore = true
76
77
 
77
78
  while (ticketHasMore && ticketPage <= MAX_PAGES) {
78
- const batch = await payload.find({
79
- collection: slugs.tickets as any,
79
+ const batch = await dbFind(payload, slugs.tickets, {
80
80
  where: ticketWhere,
81
81
  limit: PAGE_SIZE,
82
82
  page: ticketPage,
83
+ // depth 2 is required: we read ticket.project.client.company below.
83
84
  depth: 2,
84
85
  overrideAccess: true,
86
+ // Bound the payload to only the fields the report consumes (skips body,
87
+ // richText, SLA fields, etc.) while keeping project/client populated.
88
+ select: {
89
+ ticketNumber: true,
90
+ subject: true,
91
+ status: true,
92
+ billedAmount: true,
93
+ aiSummary: true,
94
+ aiSummaryGeneratedAt: true,
95
+ aiSummaryStatus: true,
96
+ project: true,
97
+ client: true,
98
+ },
85
99
  })
86
100
  allTickets.push(...batch.docs as any[])
87
101
  ticketHasMore = batch.hasNextPage ?? false
@@ -94,8 +108,7 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
94
108
  let entryHasMore = true
95
109
 
96
110
  while (entryHasMore && entryPage <= MAX_PAGES) {
97
- const batch = await payload.find({
98
- collection: slugs.timeEntries as any,
111
+ const batch = await dbFind(payload, slugs.timeEntries, {
99
112
  where: {
100
113
  and: [
101
114
  { date: { greater_than_equal: from } },
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { dbUpdate, dbDelete } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * POST /api/support/bulk-action
@@ -32,16 +33,14 @@ export function createBulkActionEndpoint(slugs: CollectionSlugs): Endpoint {
32
33
  try {
33
34
  switch (action) {
34
35
  case 'close':
35
- await payload.update({
36
- collection: slugs.tickets as any,
36
+ await dbUpdate(payload, slugs.tickets, {
37
37
  id: ticketId,
38
38
  data: { status: 'resolved', resolvedAt: new Date().toISOString() },
39
39
  overrideAccess: true,
40
40
  })
41
41
  break
42
42
  case 'reopen':
43
- await payload.update({
44
- collection: slugs.tickets as any,
43
+ await dbUpdate(payload, slugs.tickets, {
45
44
  id: ticketId,
46
45
  data: { status: 'open' },
47
46
  overrideAccess: true,
@@ -49,8 +48,7 @@ export function createBulkActionEndpoint(slugs: CollectionSlugs): Endpoint {
49
48
  break
50
49
  case 'assign':
51
50
  if (value) {
52
- await payload.update({
53
- collection: slugs.tickets as any,
51
+ await dbUpdate(payload, slugs.tickets, {
54
52
  id: ticketId,
55
53
  data: { assignedTo: Number(value) },
56
54
  overrideAccess: true,
@@ -59,8 +57,7 @@ export function createBulkActionEndpoint(slugs: CollectionSlugs): Endpoint {
59
57
  break
60
58
  case 'set_priority':
61
59
  if (value) {
62
- await payload.update({
63
- collection: slugs.tickets as any,
60
+ await dbUpdate(payload, slugs.tickets, {
64
61
  id: ticketId,
65
62
  data: { priority: String(value) },
66
63
  overrideAccess: true,
@@ -69,8 +66,7 @@ export function createBulkActionEndpoint(slugs: CollectionSlugs): Endpoint {
69
66
  break
70
67
  case 'set_category':
71
68
  if (value) {
72
- await payload.update({
73
- collection: slugs.tickets as any,
69
+ await dbUpdate(payload, slugs.tickets, {
74
70
  id: ticketId,
75
71
  data: { category: String(value) },
76
72
  overrideAccess: true,
@@ -78,8 +74,7 @@ export function createBulkActionEndpoint(slugs: CollectionSlugs): Endpoint {
78
74
  }
79
75
  break
80
76
  case 'delete':
81
- await payload.delete({
82
- collection: slugs.tickets as any,
77
+ await dbDelete(payload, slugs.tickets, {
83
78
  id: ticketId,
84
79
  overrideAccess: true,
85
80
  })
@@ -0,0 +1,89 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { dbFind, dbCreate } from '../utils/db'
4
+ import { randomBytes } from 'crypto'
5
+
6
+ const PROVIDERS = ['whatsapp', 'messenger'] as const
7
+ type Provider = (typeof PROVIDERS)[number]
8
+
9
+ /**
10
+ * POST /api/support/channels/webhook
11
+ *
12
+ * Inbound social-channel webhook (WhatsApp / Messenger). Accepts a NORMALIZED
13
+ * payload `{ provider, from, name?, text }` — a thin adapter maps the provider's
14
+ * raw webhook (Meta X-Hub-Signature-256 etc.) to this shape at the edge. Creates
15
+ * or appends the client's open ticket for that channel.
16
+ *
17
+ * Protected by the `x-channel-secret` header (`CHANNELS_WEBHOOK_SECRET`).
18
+ */
19
+ export function createChannelsWebhookEndpoint(slugs: CollectionSlugs): Endpoint {
20
+ return {
21
+ path: '/support/channels/webhook',
22
+ method: 'post',
23
+ handler: async (req) => {
24
+ const secret = req.headers.get('x-channel-secret')
25
+ if (!process.env.CHANNELS_WEBHOOK_SECRET || secret !== process.env.CHANNELS_WEBHOOK_SECRET) {
26
+ return Response.json({ error: 'Non autorisé' }, { status: 401 })
27
+ }
28
+
29
+ let body: { provider?: string; from?: string; name?: string; text?: string }
30
+ try {
31
+ body = (await req.json!()) as typeof body
32
+ } catch {
33
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
34
+ }
35
+
36
+ const provider = body.provider as Provider
37
+ const from = (body.from || '').trim()
38
+ const text = (body.text || '').trim()
39
+ if (!PROVIDERS.includes(provider) || !from || !text) {
40
+ return Response.json({ error: 'provider, from et text requis.' }, { status: 400 })
41
+ }
42
+
43
+ try {
44
+ const payload = req.payload
45
+ // Resolve (or create) the channel client by a synthetic, stable identity.
46
+ const email = `${from}@${provider}.channel`
47
+ const existing = await dbFind(payload, slugs.supportClients, { where: { email: { equals: email } }, limit: 1, depth: 0, overrideAccess: true })
48
+ let clientId: number | string
49
+ if (existing.docs.length > 0) {
50
+ clientId = (existing.docs[0] as { id: number | string }).id
51
+ } else {
52
+ const created = await dbCreate(payload, slugs.supportClients, {
53
+ data: { email, firstName: body.name || provider, lastName: from.slice(0, 40), company: provider, password: randomBytes(16).toString('hex') },
54
+ overrideAccess: true,
55
+ })
56
+ clientId = (created as { id: number | string }).id
57
+ }
58
+
59
+ // Find an open ticket for this client + channel, else open a new one.
60
+ const open = await dbFind(payload, slugs.tickets, {
61
+ where: { and: [{ client: { equals: clientId } }, { source: { equals: provider } }, { status: { in: ['open', 'waiting_client'] } }] },
62
+ sort: '-createdAt', limit: 1, depth: 0, overrideAccess: true,
63
+ })
64
+ let ticketId: number | string
65
+ let createdTicket = false
66
+ if (open.docs.length > 0) {
67
+ ticketId = (open.docs[0] as { id: number | string }).id
68
+ } else {
69
+ const t = await dbCreate(payload, slugs.tickets, {
70
+ data: { subject: text.slice(0, 80), client: clientId, status: 'open', priority: 'normal', source: provider },
71
+ overrideAccess: true,
72
+ })
73
+ ticketId = (t as { id: number | string }).id
74
+ createdTicket = true
75
+ }
76
+
77
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
78
+ data: { ticket: ticketId, body: text, authorType: 'client', authorClient: clientId },
79
+ overrideAccess: true,
80
+ })
81
+
82
+ return Response.json({ ok: true, ticketId, createdTicket, messageId: (msg as { id: number | string }).id })
83
+ } catch (err) {
84
+ console.error('[channels] Error:', err)
85
+ return Response.json({ error: 'Erreur interne' }, { status: 500 })
86
+ }
87
+ },
88
+ }
89
+ }