@consilioweb/payload-support 0.15.0 → 1.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 (202) hide show
  1. package/README.md +347 -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/components/ActionPanels.cjs +72 -0
  7. package/dist/components/TicketConversation/components/ActionPanels.js +73 -2
  8. package/dist/components/TicketConversation/components/QuickActions.cjs +22 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +22 -1
  10. package/dist/components/TicketConversation/constants.cjs +27 -0
  11. package/dist/components/TicketConversation/constants.js +26 -1
  12. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  13. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  14. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +59 -0
  15. package/dist/components/TicketConversation/hooks/useTicketActions.js +59 -0
  16. package/dist/components/TicketConversation/index.cjs +42 -149
  17. package/dist/components/TicketConversation/index.js +37 -144
  18. package/dist/index.cjs +1835 -741
  19. package/dist/index.d.cts +3 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +1833 -741
  22. package/dist/views/BillingView/client.cjs +40 -54
  23. package/dist/views/BillingView/client.js +40 -54
  24. package/dist/views/ChatView/client.cjs +4 -3
  25. package/dist/views/ChatView/client.js +4 -3
  26. package/dist/views/CrmView/client.cjs +7 -7
  27. package/dist/views/CrmView/client.js +7 -7
  28. package/dist/views/EmailTrackingView/client.cjs +6 -5
  29. package/dist/views/EmailTrackingView/client.js +6 -5
  30. package/dist/views/ImportConversationView/client.cjs +4 -4
  31. package/dist/views/ImportConversationView/client.js +4 -4
  32. package/dist/views/LogsView/client.cjs +3 -2
  33. package/dist/views/LogsView/client.js +3 -2
  34. package/dist/views/NewTicketView/client.cjs +1 -1
  35. package/dist/views/NewTicketView/client.js +1 -1
  36. package/dist/views/PendingEmailsView/client.cjs +7 -6
  37. package/dist/views/PendingEmailsView/client.js +7 -6
  38. package/dist/views/SupportDashboardView/client.cjs +19 -22
  39. package/dist/views/SupportDashboardView/client.js +19 -22
  40. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  41. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  42. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  43. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  44. package/dist/views/TicketDetailView/client.cjs +59 -190
  45. package/dist/views/TicketDetailView/client.js +52 -183
  46. package/dist/views/TicketDetailView/constants.cjs +20 -0
  47. package/dist/views/TicketDetailView/constants.js +16 -0
  48. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  49. package/dist/views/TicketDetailView/helpers.js +20 -0
  50. package/dist/views/TicketDetailView/types.cjs +2 -0
  51. package/dist/views/TicketDetailView/types.js +1 -0
  52. package/dist/views/TicketInboxView/client.cjs +14 -10
  53. package/dist/views/TicketInboxView/client.js +14 -10
  54. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  55. package/dist/views/TicketingSettingsView/client.js +92 -88
  56. package/dist/views/TimeDashboardView/client.cjs +2 -2
  57. package/dist/views/TimeDashboardView/client.js +2 -2
  58. package/dist/views/shared/StatusPill.cjs +23 -12
  59. package/dist/views/shared/StatusPill.js +23 -12
  60. package/dist/views/shared/dateLocale.cjs +5 -0
  61. package/dist/views/shared/dateLocale.js +3 -0
  62. package/package.json +23 -15
  63. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  64. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  65. package/src/__tests__/integration/auth.test.ts +43 -0
  66. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  67. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  68. package/src/__tests__/integration/channels.test.ts +52 -0
  69. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  70. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  71. package/src/__tests__/integration/digest.test.ts +48 -0
  72. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  73. package/src/__tests__/integration/isolation.test.ts +81 -0
  74. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  75. package/src/__tests__/integration/nps.test.ts +37 -0
  76. package/src/__tests__/integration/sanitization.test.ts +35 -0
  77. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  78. package/src/__tests__/integration/smoke.test.ts +26 -0
  79. package/src/__tests__/integration/snooze.test.ts +68 -0
  80. package/src/__tests__/integration/teams.test.ts +38 -0
  81. package/src/__tests__/rateLimiter.test.ts +89 -0
  82. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  83. package/src/__tests__/sla.test.ts +258 -0
  84. package/src/collections/AutomationRules.ts +66 -0
  85. package/src/collections/NotificationQueue.ts +30 -0
  86. package/src/collections/SatisfactionSurveys.ts +11 -2
  87. package/src/collections/SupportClients.ts +58 -1
  88. package/src/collections/SupportTeam.ts +27 -0
  89. package/src/collections/TicketActivityLog.ts +9 -4
  90. package/src/collections/TicketMessages.ts +137 -31
  91. package/src/collections/Tickets.ts +31 -72
  92. package/src/collections/index.ts +3 -0
  93. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  94. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  95. package/src/components/TicketConversation/components/ActionPanels.tsx +82 -1
  96. package/src/components/TicketConversation/components/QuickActions.tsx +25 -0
  97. package/src/components/TicketConversation/constants.ts +29 -0
  98. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  99. package/src/components/TicketConversation/hooks/useTicketActions.ts +51 -0
  100. package/src/components/TicketConversation/index.tsx +29 -157
  101. package/src/endpoints/admin-chat-stream.ts +3 -4
  102. package/src/endpoints/admin-chat.ts +11 -20
  103. package/src/endpoints/admin-stats.ts +70 -32
  104. package/src/endpoints/ai-agent.ts +36 -0
  105. package/src/endpoints/apply-macro.ts +8 -14
  106. package/src/endpoints/auth-2fa.ts +12 -12
  107. package/src/endpoints/auto-close.ts +38 -18
  108. package/src/endpoints/billing.ts +17 -4
  109. package/src/endpoints/bulk-action.ts +7 -12
  110. package/src/endpoints/channels.ts +89 -0
  111. package/src/endpoints/chat-stream.ts +3 -4
  112. package/src/endpoints/chat.ts +7 -12
  113. package/src/endpoints/chatbot.ts +2 -2
  114. package/src/endpoints/client-intelligence.ts +4 -4
  115. package/src/endpoints/delete-account.ts +9 -16
  116. package/src/endpoints/email-stats.ts +2 -2
  117. package/src/endpoints/escalate.ts +2 -2
  118. package/src/endpoints/export-csv.ts +2 -2
  119. package/src/endpoints/export-data.ts +5 -8
  120. package/src/endpoints/import-conversation.ts +6 -10
  121. package/src/endpoints/index.ts +24 -2
  122. package/src/endpoints/invite-collaborator.ts +30 -15
  123. package/src/endpoints/invoice.ts +127 -0
  124. package/src/endpoints/kb-search.ts +2 -2
  125. package/src/endpoints/login.ts +12 -4
  126. package/src/endpoints/merge-clients.ts +14 -19
  127. package/src/endpoints/merge-tickets.ts +11 -18
  128. package/src/endpoints/oauth-google.ts +86 -39
  129. package/src/endpoints/pending-emails-process.ts +15 -25
  130. package/src/endpoints/process-digests.ts +93 -0
  131. package/src/endpoints/process-scheduled.ts +6 -10
  132. package/src/endpoints/process-snooze.ts +78 -0
  133. package/src/endpoints/purge-logs.ts +2 -2
  134. package/src/endpoints/resend-notification.ts +3 -4
  135. package/src/endpoints/round-robin-config.ts +3 -4
  136. package/src/endpoints/satisfaction.ts +21 -14
  137. package/src/endpoints/search.ts +5 -8
  138. package/src/endpoints/seed-kb.ts +3 -4
  139. package/src/endpoints/send-reminder.ts +186 -0
  140. package/src/endpoints/settings.ts +5 -2
  141. package/src/endpoints/signature.ts +3 -4
  142. package/src/endpoints/sla-check.ts +2 -2
  143. package/src/endpoints/split-ticket.ts +7 -12
  144. package/src/endpoints/statuses.ts +2 -2
  145. package/src/endpoints/ticket-feedback.ts +4 -6
  146. package/src/endpoints/ticket-synthesis.ts +2 -2
  147. package/src/endpoints/track-open.ts +16 -28
  148. package/src/endpoints/transfer-ticket.ts +21 -9
  149. package/src/endpoints/user-prefs.ts +3 -4
  150. package/src/hooks/automationRules.ts +84 -0
  151. package/src/hooks/checkSLA.ts +48 -16
  152. package/src/plugin.ts +6 -0
  153. package/src/portal/LiveChat.tsx +2 -63
  154. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  155. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  156. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  157. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  158. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  159. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  160. package/src/portal/helpers.ts +46 -0
  161. package/src/portal/login/page.tsx +3 -8
  162. package/src/portal/types.ts +15 -0
  163. package/src/types/lucide-react.d.ts +34 -0
  164. package/src/types/payload-config.d.ts +10 -0
  165. package/src/utils/adminNotification.ts +2 -2
  166. package/src/utils/aiAgent.ts +147 -0
  167. package/src/utils/auth.ts +5 -4
  168. package/src/utils/channels.ts +36 -0
  169. package/src/utils/db.ts +73 -0
  170. package/src/utils/emailTemplate.ts +8 -1
  171. package/src/utils/fireWebhooks.ts +4 -6
  172. package/src/utils/generateTicketSynthesis.ts +7 -12
  173. package/src/utils/invoicePdf.ts +88 -0
  174. package/src/utils/notificationQueue.ts +36 -0
  175. package/src/utils/readSettings.ts +22 -6
  176. package/src/utils/sanitizeHtml.ts +57 -0
  177. package/src/utils/slugs.ts +6 -0
  178. package/src/utils/teamAccess.ts +27 -0
  179. package/src/utils/ticketAccess.ts +53 -0
  180. package/src/utils/webhookDispatcher.ts +4 -6
  181. package/src/views/BillingView/client.tsx +49 -37
  182. package/src/views/ChatView/client.tsx +4 -3
  183. package/src/views/CrmView/client.tsx +7 -7
  184. package/src/views/EmailTrackingView/client.tsx +7 -6
  185. package/src/views/ImportConversationView/client.tsx +4 -4
  186. package/src/views/LogsView/client.tsx +3 -2
  187. package/src/views/NewTicketView/client.tsx +1 -1
  188. package/src/views/PendingEmailsView/client.tsx +7 -6
  189. package/src/views/SupportDashboardView/client.tsx +21 -21
  190. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  191. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  192. package/src/views/TicketDetailView/client.tsx +54 -179
  193. package/src/views/TicketDetailView/constants.ts +23 -0
  194. package/src/views/TicketDetailView/helpers.ts +17 -0
  195. package/src/views/TicketDetailView/types.ts +10 -0
  196. package/src/views/TicketInboxView/client.tsx +16 -10
  197. package/src/views/TicketingSettingsView/client.tsx +90 -87
  198. package/src/views/TimeDashboardView/client.tsx +2 -2
  199. package/src/views/shared/StatusPill.tsx +24 -12
  200. package/src/views/shared/dateLocale.ts +5 -0
  201. package/src/views/shared/locales/en.json +708 -0
  202. 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
@@ -21,8 +22,7 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
21
22
  const statuses = ['open', 'waiting_client', 'resolved'] as const
22
23
  const statusCounts = await Promise.all(
23
24
  statuses.map(async (status) => {
24
- const result = await payload.count({
25
- collection: slugs.tickets as any,
25
+ const result = await dbCount(payload, slugs.tickets, {
26
26
  where: { status: { equals: status } },
27
27
  overrideAccess: true,
28
28
  })
@@ -36,8 +36,7 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
36
36
  const priorities = ['low', 'normal', 'high', 'urgent'] as const
37
37
  const priorityCounts = await Promise.all(
38
38
  priorities.map(async (priority) => {
39
- const result = await payload.count({
40
- collection: slugs.tickets as any,
39
+ const result = await dbCount(payload, slugs.tickets, {
41
40
  where: { priority: { equals: priority } },
42
41
  overrideAccess: true,
43
42
  })
@@ -50,8 +49,7 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
50
49
  const categories = ['bug', 'content', 'feature', 'question', 'hosting'] as const
51
50
  const categoryCounts = await Promise.all(
52
51
  categories.map(async (category) => {
53
- const result = await payload.count({
54
- collection: slugs.tickets as any,
52
+ const result = await dbCount(payload, slugs.tickets, {
55
53
  where: { category: { equals: category } },
56
54
  overrideAccess: true,
57
55
  })
@@ -68,18 +66,38 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
68
66
  const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
69
67
 
70
68
  const [created7, created30] = await Promise.all([
71
- payload.count({
72
- collection: slugs.tickets as any,
69
+ dbCount(payload, slugs.tickets, {
73
70
  where: { createdAt: { greater_than_equal: sevenDaysAgo.toISOString() } },
74
71
  overrideAccess: true,
75
72
  }),
76
- payload.count({
77
- collection: slugs.tickets as any,
73
+ dbCount(payload, slugs.tickets, {
78
74
  where: { createdAt: { greater_than_equal: thirtyDaysAgo.toISOString() } },
79
75
  overrideAccess: true,
80
76
  }),
81
77
  ])
82
78
 
79
+ // Real per-day ticket volume for the last 7 days (chronological), replacing
80
+ // the previous synthetic distribution in the dashboard chart.
81
+ const volumeByDay = await Promise.all(
82
+ Array.from({ length: 7 }, (_, i) => i).map(async (offset) => {
83
+ const dayStart = new Date(now)
84
+ dayStart.setHours(0, 0, 0, 0)
85
+ dayStart.setDate(dayStart.getDate() - (6 - offset))
86
+ const dayEnd = new Date(dayStart)
87
+ dayEnd.setDate(dayEnd.getDate() + 1)
88
+ const r = await dbCount(payload, slugs.tickets, {
89
+ where: {
90
+ and: [
91
+ { createdAt: { greater_than_equal: dayStart.toISOString() } },
92
+ { createdAt: { less_than: dayEnd.toISOString() } },
93
+ ],
94
+ },
95
+ overrideAccess: true,
96
+ })
97
+ return { date: dayStart.toISOString(), count: r.totalDocs }
98
+ }),
99
+ )
100
+
83
101
  // ── Averages: paginate with limit:100, only fetch needed fields ──
84
102
  const PAGE_SIZE = 100
85
103
  const MAX_PAGES = 50
@@ -93,8 +111,7 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
93
111
  let hasMore = true
94
112
 
95
113
  while (hasMore && page <= MAX_PAGES) {
96
- const batch = await payload.find({
97
- collection: slugs.tickets as any,
114
+ const batch = await dbFind(payload, slugs.tickets, {
98
115
  limit: PAGE_SIZE,
99
116
  page,
100
117
  depth: 0,
@@ -124,46 +141,64 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
124
141
  page++
125
142
  }
126
143
 
127
- // ── Satisfaction: use count + paginated average ──
128
- const surveyCount = await payload.count({
129
- collection: slugs.satisfactionSurveys as any,
144
+ // ── Satisfaction (CSAT) + NPS: count + paginated aggregation ──
145
+ const surveyCount = await dbCount(payload, slugs.satisfactionSurveys, {
130
146
  overrideAccess: true,
131
147
  })
132
148
 
133
149
  let satisfactionAvg = 0
150
+ let satisfactionCount = 0
151
+ let npsScore: number | null = null
152
+ let npsCount = 0
134
153
  if (surveyCount.totalDocs > 0) {
135
- let totalRating = 0
154
+ let ratingSum = 0
155
+ let ratingN = 0
156
+ let promoters = 0
157
+ let detractors = 0
158
+ let npsN = 0
136
159
  let surveyPage = 1
137
160
  let surveyHasMore = true
138
161
  while (surveyHasMore && surveyPage <= MAX_PAGES) {
139
- const batch = await payload.find({
140
- collection: slugs.satisfactionSurveys as any,
162
+ const batch = await dbFind(payload, slugs.satisfactionSurveys, {
141
163
  limit: PAGE_SIZE,
142
164
  page: surveyPage,
143
165
  depth: 0,
144
166
  overrideAccess: true,
145
- select: { rating: true },
167
+ select: { rating: true, nps: true },
146
168
  })
147
169
  for (const s of batch.docs) {
148
- totalRating += ((s as any).rating || 0)
170
+ const r = (s as { rating?: number }).rating
171
+ if (typeof r === 'number' && r > 0) { ratingSum += r; ratingN++ }
172
+ const n = (s as { nps?: number }).nps
173
+ if (typeof n === 'number') {
174
+ npsN++
175
+ if (n >= 9) promoters++
176
+ else if (n <= 6) detractors++
177
+ }
149
178
  }
150
179
  surveyHasMore = batch.hasNextPage ?? false
151
180
  surveyPage++
152
181
  }
153
- satisfactionAvg = Math.round((totalRating / surveyCount.totalDocs) * 10) / 10
182
+ satisfactionAvg = ratingN > 0 ? Math.round((ratingSum / ratingN) * 10) / 10 : 0
183
+ satisfactionCount = ratingN
184
+ if (npsN > 0) {
185
+ // NPS = %promoters - %detractors, on a -100..100 scale.
186
+ npsScore = Math.round(((promoters - detractors) / npsN) * 100)
187
+ npsCount = npsN
188
+ }
154
189
  }
155
190
 
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,
191
+ const clientCount = await dbCount(payload, slugs.supportClients, { overrideAccess: true })
192
+ // pendingEmails is an optional feature — tolerate its collection being absent
193
+ // (otherwise the whole stats endpoint 500s on installs without it).
194
+ let pendingEmailsTotal = 0
195
+ try {
196
+ const pe = await dbCount(payload, slugs.pendingEmails, {
163
197
  where: { status: { equals: 'pending' } },
164
198
  overrideAccess: true,
165
- }),
166
- ])
199
+ })
200
+ pendingEmailsTotal = pe.totalDocs
201
+ } catch { /* pendingEmails feature disabled — count stays 0 */ }
167
202
 
168
203
  return new Response(JSON.stringify({
169
204
  total,
@@ -172,6 +207,7 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
172
207
  byCategory,
173
208
  createdLast7Days: created7.totalDocs,
174
209
  createdLast30Days: created30.totalDocs,
210
+ volumeByDay,
175
211
  avgResponseTimeHours: responseTimeCount > 0
176
212
  ? Math.round((totalResponseTimeMs / responseTimeCount / (1000 * 60 * 60)) * 10) / 10
177
213
  : null,
@@ -180,9 +216,11 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
180
216
  : null,
181
217
  totalTimeMinutes,
182
218
  satisfactionAvg,
183
- satisfactionCount: surveyCount.totalDocs,
219
+ satisfactionCount,
220
+ npsScore,
221
+ npsCount,
184
222
  clientCount: clientCount.totalDocs,
185
- pendingEmailsCount: pendingEmailsCount.totalDocs,
223
+ pendingEmailsCount: pendingEmailsTotal,
186
224
  }), {
187
225
  headers: {
188
226
  '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,
@@ -90,19 +89,33 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
90
89
  }
91
90
  }
92
91
 
93
- // Step 2: Close tickets that were reminded > CLOSE_AFTER_REMIND_DAYS ago
92
+ // Step 2: Close tickets that are due. Two independent paths:
93
+ // - day-based: reminded > CLOSE_AFTER_REMIND_DAYS ago and the client
94
+ // stayed silent (the legacy automatic flow).
95
+ // - explicit deadline: a manual reminder armed `autoCloseScheduledAt`
96
+ // (e.g. "close in 24h"). When the client replies, that field is
97
+ // cleared and the status flips to `open`, so a still-set deadline in
98
+ // the past means no reply was received.
94
99
  const closeCutoff = new Date(now.getTime() - CLOSE_AFTER_REMIND_DAYS * 24 * 60 * 60 * 1000)
95
100
 
96
- const ticketsToClose = await payload.find({
97
- collection: slugs.tickets as any,
101
+ const ticketsToClose = await dbFind(payload, slugs.tickets, {
98
102
  where: {
99
103
  and: [
100
104
  { status: { equals: 'waiting_client' } },
101
- { autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
102
105
  {
103
106
  or: [
104
- { lastClientMessageAt: { exists: false } },
105
- { lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } },
107
+ {
108
+ and: [
109
+ { autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
110
+ {
111
+ or: [
112
+ { lastClientMessageAt: { exists: false } },
113
+ { lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } },
114
+ ],
115
+ },
116
+ ],
117
+ },
118
+ { autoCloseScheduledAt: { less_than_equal: now.toISOString() } },
106
119
  ],
107
120
  },
108
121
  ],
@@ -119,12 +132,18 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
119
132
  const ticketNumber = t.ticketNumber || 'TK-????'
120
133
  const subject = t.subject || 'Support'
121
134
  const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS
122
-
123
- await payload.create({
124
- collection: slugs.ticketMessages as any,
135
+ // Was this ticket closed via a manual reminder's explicit deadline,
136
+ // or via the legacy day-based flow? Drives the wording below.
137
+ const viaSchedule = !!t.autoCloseScheduledAt
138
+ && new Date(t.autoCloseScheduledAt).getTime() <= now.getTime()
139
+ const noteBody = viaSchedule
140
+ ? 'Ticket résolu automatiquement — relance manuelle restée sans réponse du client'
141
+ : `Ticket résolu automatiquement — sans réponse client depuis ${closeTotalDays} jours`
142
+
143
+ await dbCreate(payload, slugs.ticketMessages, {
125
144
  data: {
126
145
  ticket: t.id,
127
- body: `Ticket résolu automatiquement — sans réponse client depuis ${closeTotalDays} jours`,
146
+ body: noteBody,
128
147
  authorType: 'admin',
129
148
  isInternal: true,
130
149
  skipNotification: true,
@@ -132,10 +151,11 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
132
151
  overrideAccess: true,
133
152
  })
134
153
 
135
- await payload.update({
136
- collection: slugs.tickets as any,
154
+ await dbUpdate(payload, slugs.tickets, {
137
155
  id: t.id,
138
- data: { status: 'resolved' },
156
+ // Clear the armed deadline so a later manual reopen can't be
157
+ // closed instantly by a stale timestamp.
158
+ data: { status: 'resolved', autoCloseScheduledAt: null },
139
159
  overrideAccess: true,
140
160
  })
141
161
 
@@ -147,7 +167,7 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
147
167
  subject: `[${ticketNumber}] Ticket résolu — ${subject}`,
148
168
  html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
149
169
  <p>Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,</p>
150
- <p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em> — a été résolu automatiquement après ${closeTotalDays} jours sans réponse.</p>
170
+ <p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em> — a été résolu automatiquement ${viaSchedule ? 'faute de réponse de votre part' : `après ${closeTotalDays} jours sans réponse`}.</p>
151
171
  <p>Si vous avez encore besoin d'aide, n'hésitez pas à rouvrir ce ticket ou à en créer un nouveau.</p>
152
172
  <p><a href="${portalUrl}">Consulter le ticket</a></p>
153
173
  </div>`,
@@ -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
  })