@consilioweb/payload-support 0.16.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 (193) 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/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 +1604 -671
  13. package/dist/index.d.cts +3 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +1602 -671
  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 +23 -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/sanitization.test.ts +35 -0
  71. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  72. package/src/__tests__/integration/smoke.test.ts +26 -0
  73. package/src/__tests__/integration/snooze.test.ts +68 -0
  74. package/src/__tests__/integration/teams.test.ts +38 -0
  75. package/src/__tests__/rateLimiter.test.ts +89 -0
  76. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  77. package/src/__tests__/sla.test.ts +258 -0
  78. package/src/collections/AutomationRules.ts +66 -0
  79. package/src/collections/NotificationQueue.ts +30 -0
  80. package/src/collections/SatisfactionSurveys.ts +11 -2
  81. package/src/collections/SupportClients.ts +58 -1
  82. package/src/collections/SupportTeam.ts +27 -0
  83. package/src/collections/TicketActivityLog.ts +9 -4
  84. package/src/collections/TicketMessages.ts +129 -31
  85. package/src/collections/Tickets.ts +31 -73
  86. package/src/collections/index.ts +3 -0
  87. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  88. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  89. package/src/components/TicketConversation/constants.ts +29 -0
  90. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  91. package/src/components/TicketConversation/index.tsx +4 -152
  92. package/src/endpoints/admin-chat-stream.ts +3 -4
  93. package/src/endpoints/admin-chat.ts +11 -20
  94. package/src/endpoints/admin-stats.ts +70 -32
  95. package/src/endpoints/ai-agent.ts +36 -0
  96. package/src/endpoints/apply-macro.ts +8 -14
  97. package/src/endpoints/auth-2fa.ts +12 -12
  98. package/src/endpoints/auto-close.ts +6 -10
  99. package/src/endpoints/billing.ts +17 -4
  100. package/src/endpoints/bulk-action.ts +7 -12
  101. package/src/endpoints/channels.ts +89 -0
  102. package/src/endpoints/chat-stream.ts +3 -4
  103. package/src/endpoints/chat.ts +7 -12
  104. package/src/endpoints/chatbot.ts +2 -2
  105. package/src/endpoints/client-intelligence.ts +4 -4
  106. package/src/endpoints/delete-account.ts +9 -16
  107. package/src/endpoints/email-stats.ts +2 -2
  108. package/src/endpoints/escalate.ts +2 -2
  109. package/src/endpoints/export-csv.ts +2 -2
  110. package/src/endpoints/export-data.ts +5 -8
  111. package/src/endpoints/import-conversation.ts +6 -10
  112. package/src/endpoints/index.ts +18 -1
  113. package/src/endpoints/invite-collaborator.ts +30 -15
  114. package/src/endpoints/invoice.ts +127 -0
  115. package/src/endpoints/kb-search.ts +2 -2
  116. package/src/endpoints/login.ts +12 -4
  117. package/src/endpoints/merge-clients.ts +14 -19
  118. package/src/endpoints/merge-tickets.ts +11 -18
  119. package/src/endpoints/oauth-google.ts +86 -39
  120. package/src/endpoints/pending-emails-process.ts +15 -25
  121. package/src/endpoints/process-digests.ts +93 -0
  122. package/src/endpoints/process-scheduled.ts +6 -10
  123. package/src/endpoints/process-snooze.ts +78 -0
  124. package/src/endpoints/purge-logs.ts +2 -2
  125. package/src/endpoints/resend-notification.ts +3 -4
  126. package/src/endpoints/round-robin-config.ts +3 -4
  127. package/src/endpoints/satisfaction.ts +21 -14
  128. package/src/endpoints/search.ts +5 -8
  129. package/src/endpoints/seed-kb.ts +3 -4
  130. package/src/endpoints/send-reminder.ts +6 -9
  131. package/src/endpoints/settings.ts +5 -2
  132. package/src/endpoints/signature.ts +3 -4
  133. package/src/endpoints/sla-check.ts +2 -2
  134. package/src/endpoints/split-ticket.ts +7 -12
  135. package/src/endpoints/statuses.ts +2 -2
  136. package/src/endpoints/ticket-feedback.ts +4 -6
  137. package/src/endpoints/ticket-synthesis.ts +2 -2
  138. package/src/endpoints/track-open.ts +16 -28
  139. package/src/endpoints/transfer-ticket.ts +21 -9
  140. package/src/endpoints/user-prefs.ts +3 -4
  141. package/src/hooks/automationRules.ts +84 -0
  142. package/src/hooks/checkSLA.ts +48 -16
  143. package/src/plugin.ts +6 -0
  144. package/src/portal/LiveChat.tsx +2 -63
  145. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  146. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  147. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  148. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  149. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  150. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  151. package/src/portal/helpers.ts +46 -0
  152. package/src/portal/login/page.tsx +3 -8
  153. package/src/portal/types.ts +15 -0
  154. package/src/types/lucide-react.d.ts +34 -0
  155. package/src/types/payload-config.d.ts +10 -0
  156. package/src/utils/adminNotification.ts +2 -2
  157. package/src/utils/aiAgent.ts +147 -0
  158. package/src/utils/auth.ts +5 -4
  159. package/src/utils/channels.ts +36 -0
  160. package/src/utils/db.ts +73 -0
  161. package/src/utils/emailTemplate.ts +8 -1
  162. package/src/utils/fireWebhooks.ts +4 -6
  163. package/src/utils/generateTicketSynthesis.ts +7 -12
  164. package/src/utils/invoicePdf.ts +88 -0
  165. package/src/utils/notificationQueue.ts +36 -0
  166. package/src/utils/readSettings.ts +22 -6
  167. package/src/utils/sanitizeHtml.ts +57 -0
  168. package/src/utils/slugs.ts +6 -0
  169. package/src/utils/teamAccess.ts +27 -0
  170. package/src/utils/ticketAccess.ts +53 -0
  171. package/src/utils/webhookDispatcher.ts +4 -6
  172. package/src/views/BillingView/client.tsx +49 -37
  173. package/src/views/ChatView/client.tsx +4 -3
  174. package/src/views/CrmView/client.tsx +7 -7
  175. package/src/views/EmailTrackingView/client.tsx +7 -6
  176. package/src/views/ImportConversationView/client.tsx +4 -4
  177. package/src/views/LogsView/client.tsx +3 -2
  178. package/src/views/NewTicketView/client.tsx +1 -1
  179. package/src/views/PendingEmailsView/client.tsx +7 -6
  180. package/src/views/SupportDashboardView/client.tsx +21 -21
  181. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  182. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  183. package/src/views/TicketDetailView/client.tsx +54 -179
  184. package/src/views/TicketDetailView/constants.ts +23 -0
  185. package/src/views/TicketDetailView/helpers.ts +17 -0
  186. package/src/views/TicketDetailView/types.ts +10 -0
  187. package/src/views/TicketInboxView/client.tsx +16 -10
  188. package/src/views/TicketingSettingsView/client.tsx +90 -87
  189. package/src/views/TimeDashboardView/client.tsx +2 -2
  190. package/src/views/shared/StatusPill.tsx +24 -12
  191. package/src/views/shared/dateLocale.ts +5 -0
  192. package/src/views/shared/locales/en.json +708 -0
  193. package/src/views/shared/locales/fr.json +708 -0
@@ -2,6 +2,7 @@ import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { RateLimiter } from '../utils/rateLimiter'
4
4
  import { readSupportSettings } from '../utils/readSettings'
5
+ import { dbFind, dbCreate } from '../utils/db'
5
6
  import crypto from 'crypto'
6
7
 
7
8
  interface ParsedConversation {
@@ -215,8 +216,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs): Endpoi
215
216
  }
216
217
 
217
218
  // Find or create support client
218
- const clientResult = await payload.find({
219
- collection: slugs.supportClients as any,
219
+ const clientResult = await dbFind(payload, slugs.supportClients, {
220
220
  where: { email: { equals: conversation.client.email } },
221
221
  limit: 1,
222
222
  depth: 0,
@@ -230,8 +230,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs): Endpoi
230
230
  const nameParts = conversation.client.name.split(' ')
231
231
  const randomPassword = crypto.randomBytes(48).toString('base64url')
232
232
 
233
- client = await payload.create({
234
- collection: slugs.supportClients as any,
233
+ client = await dbCreate(payload, slugs.supportClients, {
235
234
  data: {
236
235
  email: conversation.client.email,
237
236
  password: randomPassword,
@@ -245,8 +244,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs): Endpoi
245
244
  }
246
245
 
247
246
  // Find admin user for authorAdmin
248
- const adminUsers = await payload.find({
249
- collection: slugs.users as any,
247
+ const adminUsers = await dbFind(payload, slugs.users, {
250
248
  limit: 1,
251
249
  depth: 0,
252
250
  overrideAccess: true,
@@ -254,8 +252,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs): Endpoi
254
252
  const adminUserId = adminUsers.docs[0]?.id
255
253
 
256
254
  // Create ticket
257
- const ticket = await payload.create({
258
- collection: slugs.tickets as any,
255
+ const ticket = await dbCreate(payload, slugs.tickets, {
259
256
  data: {
260
257
  subject: conversation.subject,
261
258
  client: client.id,
@@ -271,8 +268,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs): Endpoi
271
268
  for (const msg of conversation.messages) {
272
269
  const isAdmin = msg.from === 'admin'
273
270
 
274
- await payload.create({
275
- collection: slugs.ticketMessages as any,
271
+ await dbCreate(payload, slugs.ticketMessages, {
276
272
  data: {
277
273
  ticket: ticket.id,
278
274
  body: msg.content,
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import type { SupportFeatures } from '../types'
4
4
 
5
5
  import { createAiEndpoint } from './ai'
6
+ import { createAiAgentEndpoint } from './ai-agent'
6
7
  import { createClientIntelligenceEndpoint } from './client-intelligence'
7
8
  import { createSearchEndpoint } from './search'
8
9
  import { createKbSearchEndpoint } from './kb-search'
@@ -27,6 +28,7 @@ import { createAdminChatGetEndpoint, createAdminChatPostEndpoint } from './admin
27
28
  import { createAdminChatStreamEndpoint } from './admin-chat-stream'
28
29
  import { createAdminStatsEndpoint } from './admin-stats'
29
30
  import { createBillingEndpoint } from './billing'
31
+ import { createInvoiceEndpoint } from './invoice'
30
32
  import { createTicketSynthesisEndpoint } from './ticket-synthesis'
31
33
  import { createEmailStatsEndpoint } from './email-stats'
32
34
  import { createSatisfactionEndpoint } from './satisfaction'
@@ -43,6 +45,9 @@ import { createDeleteAccountEndpoint } from './delete-account'
43
45
  import { createMergeClientsEndpoint } from './merge-clients'
44
46
  import { createImportConversationEndpoint } from './import-conversation'
45
47
  import { createProcessScheduledEndpoint } from './process-scheduled'
48
+ import { createProcessSnoozeEndpoint } from './process-snooze'
49
+ import { createProcessDigestsEndpoint } from './process-digests'
50
+ import { createChannelsWebhookEndpoint } from './channels'
46
51
  import { createUserPrefsGetEndpoint, createUserPrefsPostEndpoint } from './user-prefs'
47
52
  import { createEscalateEndpoint } from './escalate'
48
53
  import { createTicketFeedbackEndpoint } from './ticket-feedback'
@@ -51,6 +56,7 @@ import { createInviteCollaboratorEndpoint } from './invite-collaborator'
51
56
 
52
57
  // Re-export all individual endpoint creators
53
58
  export { createAiEndpoint } from './ai'
59
+ export { createAiAgentEndpoint } from './ai-agent'
54
60
  export { createSearchEndpoint } from './search'
55
61
  export { createKbSearchEndpoint } from './kb-search'
56
62
  export { createBulkActionEndpoint } from './bulk-action'
@@ -74,6 +80,7 @@ export { createAdminChatGetEndpoint, createAdminChatPostEndpoint } from './admin
74
80
  export { createAdminChatStreamEndpoint } from './admin-chat-stream'
75
81
  export { createAdminStatsEndpoint } from './admin-stats'
76
82
  export { createBillingEndpoint } from './billing'
83
+ export { createInvoiceEndpoint } from './invoice'
77
84
  export { createTicketSynthesisEndpoint } from './ticket-synthesis'
78
85
  export { createEmailStatsEndpoint } from './email-stats'
79
86
  export { createSatisfactionEndpoint } from './satisfaction'
@@ -90,6 +97,9 @@ export { createDeleteAccountEndpoint } from './delete-account'
90
97
  export { createMergeClientsEndpoint } from './merge-clients'
91
98
  export { createImportConversationEndpoint } from './import-conversation'
92
99
  export { createProcessScheduledEndpoint } from './process-scheduled'
100
+ export { createProcessSnoozeEndpoint } from './process-snooze'
101
+ export { createProcessDigestsEndpoint } from './process-digests'
102
+ export { createChannelsWebhookEndpoint } from './channels'
93
103
  export { createUserPrefsGetEndpoint, createUserPrefsPostEndpoint } from './user-prefs'
94
104
  export type { UserPrefs } from './user-prefs'
95
105
  export { createEscalateEndpoint } from './escalate'
@@ -139,6 +149,7 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
139
149
  // Conditional endpoints based on feature flags
140
150
  if (!f || f.ai !== false) {
141
151
  endpoints.push(createAiEndpoint(slugs))
152
+ endpoints.push(createAiAgentEndpoint(slugs))
142
153
  endpoints.push(...createClientIntelligenceEndpoint(slugs))
143
154
  endpoints.push(createTicketSynthesisEndpoint(slugs))
144
155
  }
@@ -169,13 +180,19 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
169
180
  endpoints.push(createAdminChatGetEndpoint(slugs), createAdminChatPostEndpoint(slugs))
170
181
  endpoints.push(createAdminChatStreamEndpoint(slugs))
171
182
  }
172
- if (!f || f.timeTracking !== false) endpoints.push(createBillingEndpoint(slugs))
183
+ if (!f || f.timeTracking !== false) {
184
+ endpoints.push(createBillingEndpoint(slugs))
185
+ endpoints.push(createInvoiceEndpoint(slugs))
186
+ }
173
187
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs))
174
188
  if (!f || f.emailTracking !== false) {
175
189
  endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs))
176
190
  }
177
191
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs))
178
192
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs))
193
+ if (!f || f.snooze !== false) endpoints.push(createProcessSnoozeEndpoint(slugs))
194
+ endpoints.push(createProcessDigestsEndpoint(slugs))
195
+ endpoints.push(createChannelsWebhookEndpoint(slugs))
179
196
 
180
197
  return endpoints
181
198
  }
@@ -4,9 +4,16 @@ import { handleAuthError, AuthError } from '../utils/auth'
4
4
  import { escapeHtml, emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
6
  import { randomBytes } from 'crypto'
7
+ import { RateLimiter } from '../utils/rateLimiter'
8
+ import { dbFindByID, dbFind, dbCreate, dbUpdate, dbCount } from '../utils/db'
7
9
 
8
10
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
9
11
 
12
+ // Anti-abuse limits: an invite can create a placeholder account AND send an
13
+ // email, so cap both the volume per user and the collaborators per ticket.
14
+ const MAX_COLLABORATORS_PER_TICKET = 20
15
+ const inviteLimiter = new RateLimiter(60 * 60 * 1000, 10)
16
+
10
17
  /**
11
18
  * POST /api/support/tickets/:id/invite
12
19
  *
@@ -52,12 +59,11 @@ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoi
52
59
 
53
60
  const payload = req.payload
54
61
 
55
- const ticket = await payload.findByID({
56
- collection: slugs.tickets as any,
62
+ const ticket = await dbFindByID(payload, slugs.tickets, {
57
63
  id: ticketId,
58
64
  depth: 1,
59
65
  overrideAccess: true,
60
- }) as any
66
+ })
61
67
  if (!ticket) return Response.json({ error: 'Ticket introuvable' }, { status: 404 })
62
68
 
63
69
  const isAdmin = req.user.collection === slugs.users
@@ -68,6 +74,21 @@ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoi
68
74
  return Response.json({ error: 'Forbidden' }, { status: 403 })
69
75
  }
70
76
 
77
+ // Anti-abuse: cap invite volume per user (in-memory, fail-closed).
78
+ if (inviteLimiter.check(String(req.user.id))) {
79
+ return Response.json({ error: 'Trop d\'invitations. Réessayez plus tard.' }, { status: 429 })
80
+ }
81
+
82
+ // Cap collaborators per ticket to prevent mass placeholder-account creation.
83
+ const collabCount = await dbCount(payload, 'ticket-collaborators', { where: { ticket: { equals: ticketId } }, overrideAccess: true })
84
+ .catch(() => ({ totalDocs: 0 }))
85
+ if (collabCount.totalDocs >= MAX_COLLABORATORS_PER_TICKET) {
86
+ return Response.json(
87
+ { error: `Limite de ${MAX_COLLABORATORS_PER_TICKET} collaborateurs atteinte sur ce ticket` },
88
+ { status: 429 },
89
+ )
90
+ }
91
+
71
92
  // Prevent inviting the primary owner (no-op)
72
93
  const ownerEmail =
73
94
  typeof ticket.client === 'object' ? (ticket.client?.email as string | undefined) : undefined
@@ -77,8 +98,7 @@ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoi
77
98
 
78
99
  // Find-or-create the invited support-client
79
100
  let inviteeId: number | string | null = null
80
- const existing = await payload.find({
81
- collection: slugs.supportClients as any,
101
+ const existing = await dbFind(payload, slugs.supportClients, {
82
102
  where: { email: { equals: email } },
83
103
  limit: 1,
84
104
  depth: 0,
@@ -90,8 +110,7 @@ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoi
90
110
  // Create a placeholder client; password is random — the invitee will reset
91
111
  // via the forgotPassword flow triggered by the SupportClients afterChange hook.
92
112
  const tempPassword = randomBytes(16).toString('hex')
93
- const created = await payload.create({
94
- collection: slugs.supportClients as any,
113
+ const created = await dbCreate(payload, slugs.supportClients, {
95
114
  data: {
96
115
  email,
97
116
  firstName: 'Invité',
@@ -111,8 +130,7 @@ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoi
111
130
  }
112
131
 
113
132
  // Upsert ticket-collaborator row (one per (ticket, client))
114
- const dupe = await payload.find({
115
- collection: 'ticket-collaborators' as any,
133
+ const dupe = await dbFind(payload, 'ticket-collaborators', {
116
134
  where: {
117
135
  and: [
118
136
  { ticket: { equals: ticketId } },
@@ -127,15 +145,13 @@ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoi
127
145
  const invitationToken = randomBytes(24).toString('hex')
128
146
 
129
147
  if (dupe.docs.length > 0) {
130
- await payload.update({
131
- collection: 'ticket-collaborators' as any,
148
+ await dbUpdate(payload, 'ticket-collaborators', {
132
149
  id: (dupe.docs[0] as any).id,
133
150
  data: { role, invitationToken },
134
151
  overrideAccess: true,
135
152
  })
136
153
  } else {
137
- await payload.create({
138
- collection: 'ticket-collaborators' as any,
154
+ await dbCreate(payload, 'ticket-collaborators', {
139
155
  data: {
140
156
  ticket: ticketId,
141
157
  client: inviteeId,
@@ -187,8 +203,7 @@ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoi
187
203
 
188
204
  // Best-effort email log
189
205
  try {
190
- await payload.create({
191
- collection: slugs.emailLogs as any,
206
+ await dbCreate(payload, slugs.emailLogs, {
192
207
  data: {
193
208
  status: 'success',
194
209
  action: 'invite',
@@ -0,0 +1,127 @@
1
+ import type { Endpoint, Where } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { dbFind } from '../utils/db'
5
+ import { escapeHtml } from '../utils/emailTemplate'
6
+ import { generateInvoicePdf, type InvoiceLine } from '../utils/invoicePdf'
7
+
8
+ /**
9
+ * GET /api/support/billing/invoice?from=YYYY-MM-DD&to=YYYY-MM-DD&projectId=...&format=html|pdf
10
+ *
11
+ * Admin-only. Renders an invoice for the billable tickets active in the period.
12
+ * `format=pdf` returns a real binary PDF (pdfkit); otherwise a self-contained,
13
+ * print-ready HTML document. Defaults to HTML.
14
+ */
15
+ export function createInvoiceEndpoint(slugs: CollectionSlugs): Endpoint {
16
+ return {
17
+ path: '/support/billing/invoice',
18
+ method: 'get',
19
+ handler: async (req) => {
20
+ try {
21
+ requireAdmin(req, slugs)
22
+ const payload = req.payload
23
+ const url = new URL(req.url!)
24
+ const from = url.searchParams.get('from')
25
+ const to = url.searchParams.get('to')
26
+ const projectId = url.searchParams.get('projectId')
27
+ const format = url.searchParams.get('format') === 'pdf' ? 'pdf' : 'html'
28
+ if (!from || !to) {
29
+ return Response.json({ error: 'Paramètres from et to requis.' }, { status: 400 })
30
+ }
31
+
32
+ const toExclusive = new Date(to)
33
+ toExclusive.setDate(toExclusive.getDate() + 1)
34
+ const toIso = toExclusive.toISOString()
35
+
36
+ const where: Where = {
37
+ and: [
38
+ { billable: { equals: true } },
39
+ ...(projectId ? [{ project: { equals: Number(projectId) } } as Where] : []),
40
+ {
41
+ or: [
42
+ { and: [{ updatedAt: { greater_than_equal: from } }, { updatedAt: { less_than: toIso } }] },
43
+ { and: [{ createdAt: { greater_than_equal: from } }, { createdAt: { less_than: toIso } }] },
44
+ { and: [{ resolvedAt: { greater_than_equal: from } }, { resolvedAt: { less_than: toIso } }] },
45
+ ],
46
+ },
47
+ ],
48
+ }
49
+
50
+ const tickets: Array<Record<string, unknown>> = []
51
+ let page = 1
52
+ let hasMore = true
53
+ while (hasMore && page <= 50) {
54
+ const batch = await dbFind(payload, slugs.tickets, {
55
+ where, limit: 500, page, depth: 2, overrideAccess: true,
56
+ select: { ticketNumber: true, subject: true, billedAmount: true, totalTimeMinutes: true, project: true, client: true },
57
+ })
58
+ tickets.push(...(batch.docs as Array<Record<string, unknown>>))
59
+ hasMore = batch.hasNextPage ?? false
60
+ page++
61
+ }
62
+
63
+ // Shared structured lines (used by both HTML and PDF renderers).
64
+ let totalAmount = 0
65
+ let totalMinutes = 0
66
+ const lines: InvoiceLine[] = tickets.map((t) => {
67
+ const amount = typeof t.billedAmount === 'number' ? t.billedAmount : 0
68
+ const minutes = typeof t.totalTimeMinutes === 'number' ? t.totalTimeMinutes : 0
69
+ totalAmount += amount
70
+ totalMinutes += minutes
71
+ const client = typeof t.client === 'object' && t.client ? (t.client as { company?: string }).company || '' : ''
72
+ return { ticketNumber: String(t.ticketNumber || ''), subject: String(t.subject || ''), client, minutes, amount }
73
+ })
74
+
75
+ if (format === 'pdf') {
76
+ const pdf = await generateInvoicePdf({ from, to, projectId, lines, totalMinutes, totalAmount })
77
+ return new Response(new Uint8Array(pdf), {
78
+ status: 200,
79
+ headers: {
80
+ 'Content-Type': 'application/pdf',
81
+ 'Content-Disposition': `inline; filename="invoice-${from}_${to}.pdf"`,
82
+ },
83
+ })
84
+ }
85
+
86
+ const fmtAmount = (n: number) => `${n.toFixed(2)} €`
87
+ const fmtTime = (min: number) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}`
88
+ const rows = lines.map((l) => `<tr>
89
+ <td>${escapeHtml(l.ticketNumber)}</td>
90
+ <td>${escapeHtml(l.subject)}</td>
91
+ <td>${escapeHtml(l.client)}</td>
92
+ <td style="text-align:right;">${fmtTime(l.minutes)}</td>
93
+ <td style="text-align:right;">${fmtAmount(l.amount)}</td>
94
+ </tr>`).join('')
95
+
96
+ const html = `<!doctype html>
97
+ <html lang="fr"><head><meta charset="utf-8"><title>Facture ${escapeHtml(from)} → ${escapeHtml(to)}</title>
98
+ <style>
99
+ body { font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 820px; margin: 32px auto; padding: 0 24px; }
100
+ h1 { font-size: 22px; margin: 0 0 4px; }
101
+ .period { color: #64748b; margin-bottom: 24px; }
102
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
103
+ th, td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; text-align: left; }
104
+ th { background: #f8fafc; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #475569; }
105
+ tfoot td { font-weight: 700; border-top: 2px solid #0f172a; border-bottom: none; }
106
+ @media print { body { margin: 0; } }
107
+ </style></head>
108
+ <body>
109
+ <h1>Facture / Pré-facture</h1>
110
+ <div class="period">Période : ${escapeHtml(from)} → ${escapeHtml(to)}${projectId ? ` · Projet #${escapeHtml(projectId)}` : ''}</div>
111
+ <table>
112
+ <thead><tr><th>Ticket</th><th>Sujet</th><th>Client</th><th style="text-align:right;">Temps</th><th style="text-align:right;">Montant</th></tr></thead>
113
+ <tbody>${rows || '<tr><td colspan="5" style="color:#94a3b8;">Aucun ticket facturable sur la période.</td></tr>'}</tbody>
114
+ <tfoot><tr><td colspan="3">Total (${lines.length} ticket${lines.length > 1 ? 's' : ''})</td><td style="text-align:right;">${fmtTime(totalMinutes)}</td><td style="text-align:right;">${fmtAmount(totalAmount)}</td></tr></tfoot>
115
+ </table>
116
+ </body></html>`
117
+
118
+ return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } })
119
+ } catch (error) {
120
+ const authResponse = handleAuthError(error)
121
+ if (authResponse) return authResponse
122
+ console.error('[invoice] Error:', error)
123
+ return Response.json({ error: 'Erreur interne' }, { status: 500 })
124
+ }
125
+ },
126
+ }
127
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
+ import { dbFind } from '../utils/db'
3
4
 
4
5
  // Combining diacritics range used to strip accents after NFD normalization.
5
6
  const DIACRITICS = /[̀-ͯ]/g
@@ -107,8 +108,7 @@ export function createKbSearchEndpoint(slugs: CollectionSlugs): Endpoint {
107
108
  // Always include the raw query for short single-word matches.
108
109
  orClauses.push({ title: { contains: q } })
109
110
 
110
- const res = await payload.find({
111
- collection: slugs.knowledgeBase as any,
111
+ const res = await dbFind(payload, slugs.knowledgeBase, {
112
112
  where: {
113
113
  and: [
114
114
  { published: { equals: true } },
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { RateLimiter } from '../utils/rateLimiter'
4
+ import { dbCreate } from '../utils/db'
4
5
 
5
6
  const loginLimiter = new RateLimiter(15 * 60_000, 10) // 10 per 15 min
6
7
 
@@ -13,6 +14,9 @@ export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
13
14
  path: '/support/login',
14
15
  method: 'post',
15
16
  handler: async (req) => {
17
+ // NOTE: x-forwarded-for is spoofable unless this app sits strictly behind a
18
+ // trusted proxy that rewrites it. This IP rate-limit is a SECONDARY defense;
19
+ // the primary brute-force control is Payload's account lock (maxLoginAttempts).
16
20
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
17
21
 
18
22
  if (loginLimiter.check(ip)) {
@@ -43,8 +47,7 @@ export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
43
47
  })
44
48
 
45
49
  // Log successful login (fire-and-forget)
46
- payload.create({
47
- collection: slugs.authLogs as any,
50
+ dbCreate(payload, slugs.authLogs, {
48
51
  data: { email, success: true, action: 'login', ipAddress: ip, userAgent },
49
52
  overrideAccess: true,
50
53
  }).catch(() => {})
@@ -71,13 +74,18 @@ export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
71
74
  } catch (err: unknown) {
72
75
  const errorMessage = err instanceof Error ? err.message : 'Erreur inconnue'
73
76
 
77
+ // 2FA gate: password was correct but a fresh 2FA verification is required.
78
+ // No session/cookie is issued (Payload rolled it back on the throw).
79
+ if (errorMessage.includes('2FA_REQUIRED')) {
80
+ return Response.json({ requires2FA: true }, { status: 200 })
81
+ }
82
+
74
83
  let errorReason = 'Identifiants incorrects'
75
84
  if (errorMessage.includes('locked') || errorMessage.includes('verrouillé') || errorMessage.includes('Too many')) {
76
85
  errorReason = 'Compte verrouillé (trop de tentatives)'
77
86
  }
78
87
 
79
- payload.create({
80
- collection: slugs.authLogs as any,
88
+ dbCreate(payload, slugs.authLogs, {
81
89
  data: { email, success: false, action: 'login', errorReason, ipAddress: ip, userAgent },
82
90
  overrideAccess: true,
83
91
  }).catch(() => {})
@@ -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, dbFindByID, dbUpdate, dbDelete } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * POST /api/support/merge-clients
@@ -23,8 +24,8 @@ export function createMergeClientsEndpoint(slugs: CollectionSlugs): Endpoint {
23
24
  }
24
25
 
25
26
  const [source, target] = await Promise.all([
26
- payload.findByID({ collection: slugs.supportClients as any, id: sourceId, depth: 0, overrideAccess: true }),
27
- payload.findByID({ collection: slugs.supportClients as any, id: targetId, depth: 0, overrideAccess: true }),
27
+ dbFindByID(payload, slugs.supportClients, { id: sourceId, depth: 0, overrideAccess: true }),
28
+ dbFindByID(payload, slugs.supportClients, { id: targetId, depth: 0, overrideAccess: true }),
28
29
  ]) as [any, any]
29
30
 
30
31
  if (!source || !target) {
@@ -40,73 +41,67 @@ export function createMergeClientsEndpoint(slugs: CollectionSlugs): Endpoint {
40
41
  }
41
42
 
42
43
  // 1. Transfer tickets
43
- const tickets = await payload.find({
44
- collection: slugs.tickets as any,
44
+ const tickets = await dbFind(payload, slugs.tickets, {
45
45
  where: { client: { equals: sourceId } },
46
46
  limit: 500,
47
47
  depth: 0,
48
48
  overrideAccess: true,
49
49
  })
50
50
  for (const ticket of tickets.docs) {
51
- await payload.update({ collection: slugs.tickets as any, id: ticket.id, data: { client: targetId }, overrideAccess: true })
51
+ await dbUpdate(payload, slugs.tickets, { id: ticket.id, data: { client: targetId }, overrideAccess: true })
52
52
  results.tickets++
53
53
  }
54
54
 
55
55
  // 2. Transfer ticket messages (authorClient)
56
- const messages = await payload.find({
57
- collection: slugs.ticketMessages as any,
56
+ const messages = await dbFind(payload, slugs.ticketMessages, {
58
57
  where: { authorClient: { equals: sourceId } },
59
58
  limit: 1000,
60
59
  depth: 0,
61
60
  overrideAccess: true,
62
61
  })
63
62
  for (const msg of messages.docs) {
64
- await payload.update({ collection: slugs.ticketMessages as any, id: msg.id, data: { authorClient: targetId }, overrideAccess: true })
63
+ await dbUpdate(payload, slugs.ticketMessages, { id: msg.id, data: { authorClient: targetId }, overrideAccess: true })
65
64
  results.ticketMessages++
66
65
  }
67
66
 
68
67
  // 3. Transfer chat messages
69
- const chats = await payload.find({
70
- collection: slugs.chatMessages as any,
68
+ const chats = await dbFind(payload, slugs.chatMessages, {
71
69
  where: { client: { equals: sourceId } },
72
70
  limit: 1000,
73
71
  depth: 0,
74
72
  overrideAccess: true,
75
73
  })
76
74
  for (const chat of chats.docs) {
77
- await payload.update({ collection: slugs.chatMessages as any, id: chat.id, data: { client: targetId }, overrideAccess: true })
75
+ await dbUpdate(payload, slugs.chatMessages, { id: chat.id, data: { client: targetId }, overrideAccess: true })
78
76
  results.chatMessages++
79
77
  }
80
78
 
81
79
  // 4. Transfer pending emails
82
- const pendingEmails = await payload.find({
83
- collection: slugs.pendingEmails as any,
80
+ const pendingEmails = await dbFind(payload, slugs.pendingEmails, {
84
81
  where: { client: { equals: sourceId } },
85
82
  limit: 500,
86
83
  depth: 0,
87
84
  overrideAccess: true,
88
85
  })
89
86
  for (const pe of pendingEmails.docs) {
90
- await payload.update({ collection: slugs.pendingEmails as any, id: pe.id, data: { client: targetId }, overrideAccess: true })
87
+ await dbUpdate(payload, slugs.pendingEmails, { id: pe.id, data: { client: targetId }, overrideAccess: true })
91
88
  results.pendingEmails++
92
89
  }
93
90
 
94
91
  // 5. Transfer satisfaction surveys
95
- const surveys = await payload.find({
96
- collection: slugs.satisfactionSurveys as any,
92
+ const surveys = await dbFind(payload, slugs.satisfactionSurveys, {
97
93
  where: { client: { equals: sourceId } },
98
94
  limit: 500,
99
95
  depth: 0,
100
96
  overrideAccess: true,
101
97
  })
102
98
  for (const survey of surveys.docs) {
103
- await payload.update({ collection: slugs.satisfactionSurveys as any, id: survey.id, data: { client: targetId }, overrideAccess: true })
99
+ await dbUpdate(payload, slugs.satisfactionSurveys, { id: survey.id, data: { client: targetId }, overrideAccess: true })
104
100
  results.satisfactionSurveys++
105
101
  }
106
102
 
107
103
  // 6. Delete source client
108
- await payload.delete({
109
- collection: slugs.supportClients as any,
104
+ await dbDelete(payload, slugs.supportClients, {
110
105
  id: sourceId,
111
106
  overrideAccess: true,
112
107
  })
@@ -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, dbFindByID, dbCreate, dbUpdate, dbDelete } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * POST /api/support/merge-tickets
@@ -27,8 +28,8 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
27
28
  }
28
29
 
29
30
  const [source, target] = await Promise.all([
30
- payload.findByID({ collection: slugs.tickets as any, id: sourceTicketId, depth: 0, overrideAccess: true }),
31
- payload.findByID({ collection: slugs.tickets as any, id: targetTicketId, depth: 0, overrideAccess: true }),
31
+ dbFindByID(payload, slugs.tickets, { id: sourceTicketId, depth: 0, overrideAccess: true }),
32
+ dbFindByID(payload, slugs.tickets, { id: targetTicketId, depth: 0, overrideAccess: true }),
32
33
  ])
33
34
 
34
35
  if (!source) return Response.json({ error: 'Ticket source introuvable' }, { status: 404 })
@@ -42,8 +43,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
42
43
  }
43
44
 
44
45
  // Move all messages from source to target
45
- const messages = await payload.find({
46
- collection: slugs.ticketMessages as any,
46
+ const messages = await dbFind(payload, slugs.ticketMessages, {
47
47
  where: { ticket: { equals: sourceTicketId } },
48
48
  limit: 500,
49
49
  depth: 0,
@@ -51,8 +51,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
51
51
  })
52
52
 
53
53
  for (const msg of messages.docs) {
54
- await payload.update({
55
- collection: slugs.ticketMessages as any,
54
+ await dbUpdate(payload, slugs.ticketMessages, {
56
55
  id: msg.id,
57
56
  data: { ticket: targetTicketId },
58
57
  overrideAccess: true,
@@ -60,8 +59,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
60
59
  }
61
60
 
62
61
  // Move time entries
63
- const timeEntries = await payload.find({
64
- collection: slugs.timeEntries as any,
62
+ const timeEntries = await dbFind(payload, slugs.timeEntries, {
65
63
  where: { ticket: { equals: sourceTicketId } },
66
64
  limit: 500,
67
65
  depth: 0,
@@ -69,8 +67,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
69
67
  })
70
68
 
71
69
  for (const entry of timeEntries.docs) {
72
- await payload.update({
73
- collection: slugs.timeEntries as any,
70
+ await dbUpdate(payload, slugs.timeEntries, {
74
71
  id: entry.id,
75
72
  data: { ticket: targetTicketId },
76
73
  overrideAccess: true,
@@ -79,8 +76,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
79
76
 
80
77
  // Add internal note to target
81
78
  const sourceNumber = (source as any).ticketNumber || `#${sourceTicketId}`
82
- await payload.create({
83
- collection: slugs.ticketMessages as any,
79
+ await dbCreate(payload, slugs.ticketMessages, {
84
80
  data: {
85
81
  ticket: targetTicketId,
86
82
  body: `Messages fusionnés depuis ${sourceNumber} (${messages.totalDocs} messages, ${timeEntries.totalDocs} entrées de temps)`,
@@ -92,15 +88,13 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
92
88
  })
93
89
 
94
90
  // Delete source ticket
95
- await payload.delete({
96
- collection: slugs.tickets as any,
91
+ await dbDelete(payload, slugs.tickets, {
97
92
  id: sourceTicketId,
98
93
  overrideAccess: true,
99
94
  })
100
95
 
101
96
  // Recalculate totalTimeMinutes on target
102
- const allTimeEntries = await payload.find({
103
- collection: slugs.timeEntries as any,
97
+ const allTimeEntries = await dbFind(payload, slugs.timeEntries, {
104
98
  where: { ticket: { equals: targetTicketId } },
105
99
  limit: 500,
106
100
  depth: 0,
@@ -112,8 +106,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
112
106
  0,
113
107
  )
114
108
 
115
- await payload.update({
116
- collection: slugs.tickets as any,
109
+ await dbUpdate(payload, slugs.tickets, {
117
110
  id: targetTicketId,
118
111
  data: { totalTimeMinutes: totalMinutes },
119
112
  overrideAccess: true,