@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
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireClient, handleAuthError } from '../utils/auth'
4
+ import { dbFindByID, dbFind, dbCreate } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * POST /api/support/satisfaction
@@ -17,13 +18,21 @@ export function createSatisfactionEndpoint(slugs: CollectionSlugs): Endpoint {
17
18
  requireClient(req, slugs)
18
19
 
19
20
  const body = await req.json!()
20
- const { ticketId, rating, comment } = body
21
+ const { ticketId, rating, nps, comment } = body
21
22
 
22
- if (!ticketId || !rating || !Number.isInteger(rating) || rating < 1 || rating > 5) {
23
- return Response.json(
24
- { error: 'ticketId et rating (entier 1-5) sont requis.' },
25
- { status: 400 },
26
- )
23
+ if (!ticketId) {
24
+ return Response.json({ error: 'ticketId est requis.' }, { status: 400 })
25
+ }
26
+ const hasRating = rating !== undefined && rating !== null
27
+ const hasNps = nps !== undefined && nps !== null
28
+ if (!hasRating && !hasNps) {
29
+ return Response.json({ error: 'Un rating (1-5) et/ou un nps (0-10) est requis.' }, { status: 400 })
30
+ }
31
+ if (hasRating && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
32
+ return Response.json({ error: 'rating doit être un entier 1-5.' }, { status: 400 })
33
+ }
34
+ if (hasNps && (!Number.isInteger(nps) || nps < 0 || nps > 10)) {
35
+ return Response.json({ error: 'nps doit être un entier 0-10.' }, { status: 400 })
27
36
  }
28
37
 
29
38
  if (comment && typeof comment === 'string' && comment.length > 5000) {
@@ -34,13 +43,12 @@ export function createSatisfactionEndpoint(slugs: CollectionSlugs): Endpoint {
34
43
  }
35
44
 
36
45
  // Verify ticket belongs to client and is resolved
37
- const ticket = await payload.findByID({
38
- collection: slugs.tickets as any,
46
+ const ticket = await dbFindByID(payload, slugs.tickets, {
39
47
  id: ticketId,
40
48
  depth: 0,
41
49
  overrideAccess: false,
42
50
  user: req.user,
43
- }) as any
51
+ })
44
52
 
45
53
  if (!ticket) {
46
54
  return Response.json({ error: 'Ticket introuvable.' }, { status: 404 })
@@ -54,8 +62,7 @@ export function createSatisfactionEndpoint(slugs: CollectionSlugs): Endpoint {
54
62
  }
55
63
 
56
64
  // Check if survey already exists
57
- const existing = await payload.find({
58
- collection: slugs.satisfactionSurveys as any,
65
+ const existing = await dbFind(payload, slugs.satisfactionSurveys, {
59
66
  where: { ticket: { equals: ticketId } },
60
67
  limit: 1,
61
68
  depth: 0,
@@ -69,13 +76,13 @@ export function createSatisfactionEndpoint(slugs: CollectionSlugs): Endpoint {
69
76
  )
70
77
  }
71
78
 
72
- const survey = await payload.create({
73
- collection: slugs.satisfactionSurveys as any,
79
+ const survey = await dbCreate(payload, slugs.satisfactionSurveys, {
74
80
  data: {
75
81
  source: 'ticket',
76
82
  ticket: ticketId,
77
83
  client: req.user.id,
78
- rating: Math.round(rating),
84
+ ...(hasRating ? { rating: Math.round(rating) } : {}),
85
+ ...(hasNps ? { nps: Math.round(nps) } : {}),
79
86
  ...(comment ? { comment: comment.trim() } : {}),
80
87
  },
81
88
  overrideAccess: true,
@@ -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 } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * GET /api/support/search?q=term
@@ -24,8 +25,7 @@ export function createSearchEndpoint(slugs: CollectionSlugs): Endpoint {
24
25
  }
25
26
 
26
27
  const [ticketsRes, clientsRes, messagesRes, articlesRes] = await Promise.all([
27
- payload.find({
28
- collection: slugs.tickets as any,
28
+ dbFind(payload, slugs.tickets, {
29
29
  where: {
30
30
  or: [
31
31
  { ticketNumber: { contains: q } },
@@ -37,8 +37,7 @@ export function createSearchEndpoint(slugs: CollectionSlugs): Endpoint {
37
37
  depth: 1,
38
38
  overrideAccess: true,
39
39
  }),
40
- payload.find({
41
- collection: slugs.supportClients as any,
40
+ dbFind(payload, slugs.supportClients, {
42
41
  where: {
43
42
  or: [
44
43
  { firstName: { contains: q } },
@@ -51,16 +50,14 @@ export function createSearchEndpoint(slugs: CollectionSlugs): Endpoint {
51
50
  depth: 0,
52
51
  overrideAccess: true,
53
52
  }),
54
- payload.find({
55
- collection: slugs.ticketMessages as any,
53
+ dbFind(payload, slugs.ticketMessages, {
56
54
  where: { body: { contains: q } },
57
55
  sort: '-createdAt',
58
56
  limit: 5,
59
57
  depth: 1,
60
58
  overrideAccess: true,
61
59
  }),
62
- payload.find({
63
- collection: slugs.knowledgeBase as any,
60
+ dbFind(payload, slugs.knowledgeBase, {
64
61
  where: { title: { contains: q } },
65
62
  limit: 5,
66
63
  depth: 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, dbCreate } from '../utils/db'
4
5
 
5
6
  const KB_ARTICLES = [
6
7
  {
@@ -95,8 +96,7 @@ export function createSeedKbEndpoint(slugs: CollectionSlugs): Endpoint {
95
96
  let skipped = 0
96
97
 
97
98
  for (const article of KB_ARTICLES) {
98
- const existing = await payload.find({
99
- collection: slugs.knowledgeBase as any,
99
+ const existing = await dbFind(payload, slugs.knowledgeBase, {
100
100
  where: { slug: { equals: article.slug } },
101
101
  limit: 1,
102
102
  depth: 0,
@@ -126,8 +126,7 @@ export function createSeedKbEndpoint(slugs: CollectionSlugs): Endpoint {
126
126
  },
127
127
  }
128
128
 
129
- await payload.create({
130
- collection: slugs.knowledgeBase as any,
129
+ await dbCreate(payload, slugs.knowledgeBase, {
131
130
  data: {
132
131
  title: article.title,
133
132
  slug: article.slug,
@@ -4,6 +4,7 @@ import { requireAdmin, handleAuthError } from '../utils/auth'
4
4
  import { RateLimiter } from '../utils/rateLimiter'
5
5
  import { escapeHtml, emailWrapper, emailParagraph, emailButton } from '../utils/emailTemplate'
6
6
  import { readSupportSettings } from '../utils/readSettings'
7
+ import { dbFindByID, dbFind, dbCreate, dbUpdate } from '../utils/db'
7
8
 
8
9
  // Manual reminders are a deliberate admin action, so the cap is generous —
9
10
  // it only exists to stop a runaway script, not normal usage.
@@ -71,12 +72,11 @@ export function createSendReminderEndpoint(slugs: CollectionSlugs): Endpoint {
71
72
  ? Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.round(rawHours)))
72
73
  : 24
73
74
 
74
- const ticket = await payload.findByID({
75
- collection: slugs.tickets as any,
75
+ const ticket = await dbFindByID(payload, slugs.tickets, {
76
76
  id: ticketId,
77
77
  depth: 1,
78
78
  overrideAccess: true,
79
- }) as any
79
+ })
80
80
 
81
81
  if (!ticket) {
82
82
  return Response.json({ error: 'Ticket introuvable' }, { status: 404 })
@@ -102,8 +102,7 @@ export function createSendReminderEndpoint(slugs: CollectionSlugs): Endpoint {
102
102
  // response then to ticket creation.
103
103
  let waitingSince: string | undefined = ticket.firstResponseAt || ticket.createdAt
104
104
  try {
105
- const lastAdminMsg = await payload.find({
106
- collection: slugs.ticketMessages as any,
105
+ const lastAdminMsg = await dbFind(payload, slugs.ticketMessages, {
107
106
  where: {
108
107
  and: [
109
108
  { ticket: { equals: ticketId } },
@@ -147,8 +146,7 @@ export function createSendReminderEndpoint(slugs: CollectionSlugs): Endpoint {
147
146
  })
148
147
 
149
148
  // Internal note for the conversation timeline (never sent to the client).
150
- await payload.create({
151
- collection: slugs.ticketMessages as any,
149
+ await dbCreate(payload, slugs.ticketMessages, {
152
150
  data: {
153
151
  ticket: ticketId,
154
152
  body: `Relance envoyée à ${client.email}. Fermeture automatique programmée le ${deadlineLabel} sans réponse du client.`,
@@ -162,8 +160,7 @@ export function createSendReminderEndpoint(slugs: CollectionSlugs): Endpoint {
162
160
  // Arm the ticket. `autoCloseRemindedAt: now` keeps the day-based cron
163
161
  // reminder from firing a second time; `autoCloseScheduledAt` is the hard
164
162
  // 24h-style deadline the cron close step honours.
165
- await payload.update({
166
- collection: slugs.tickets as any,
163
+ await dbUpdate(payload, slugs.tickets, {
167
164
  id: ticketId,
168
165
  data: {
169
166
  status: 'waiting_client',
@@ -1,6 +1,8 @@
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 { invalidateSupportSettingsCache } from '../utils/readSettings'
5
+ import { dbFind } from '../utils/db'
4
6
 
5
7
  const PREF_KEY = 'support-settings'
6
8
 
@@ -63,8 +65,7 @@ export function createSettingsGetEndpoint(slugs: CollectionSlugs): Endpoint {
63
65
 
64
66
  requireAdmin(req, slugs)
65
67
 
66
- const prefs = await payload.find({
67
- collection: 'payload-preferences' as any,
68
+ const prefs = await dbFind(payload, 'payload-preferences', {
68
69
  where: { key: { equals: PREF_KEY } },
69
70
  limit: 1,
70
71
  depth: 0,
@@ -132,6 +133,8 @@ export function createSettingsPostEndpoint(slugs: CollectionSlugs): Endpoint {
132
133
  },
133
134
  })
134
135
 
136
+ invalidateSupportSettingsCache()
137
+
135
138
  return Response.json(merged)
136
139
  } catch (error) {
137
140
  const authResponse = handleAuthError(error)
@@ -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 } from '../utils/db'
4
5
 
5
6
  const PREF_KEY = 'email-signature'
6
7
 
@@ -17,8 +18,7 @@ export function createSignatureGetEndpoint(slugs: CollectionSlugs): Endpoint {
17
18
 
18
19
  requireAdmin(req, slugs)
19
20
 
20
- const prefs = await payload.find({
21
- collection: 'payload-preferences' as any,
21
+ const prefs = await dbFind(payload, 'payload-preferences', {
22
22
  where: { key: { equals: `${PREF_KEY}-${req.user.id}` } },
23
23
  limit: 1,
24
24
  depth: 0,
@@ -56,8 +56,7 @@ export function createSignaturePostEndpoint(slugs: CollectionSlugs): Endpoint {
56
56
  const { signature } = (await req.json!()) as { signature: string }
57
57
  const key = `${PREF_KEY}-${req.user.id}`
58
58
 
59
- const existing = await payload.find({
60
- collection: 'payload-preferences' as any,
59
+ const existing = await dbFind(payload, 'payload-preferences', {
61
60
  where: { key: { equals: key } },
62
61
  limit: 1,
63
62
  depth: 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 } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * GET /api/support/sla-check
@@ -19,8 +20,7 @@ export function createSlaCheckEndpoint(slugs: CollectionSlugs): Endpoint {
19
20
  const now = new Date()
20
21
  const nowISO = now.toISOString()
21
22
 
22
- const { docs: tickets } = await payload.find({
23
- collection: slugs.tickets as any,
23
+ const { docs: tickets } = await dbFind(payload, slugs.tickets, {
24
24
  where: {
25
25
  and: [
26
26
  { status: { in: ['open', 'waiting_client'] } },
@@ -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, dbCreate, dbUpdate } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * POST /api/support/split-ticket
@@ -21,8 +22,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
21
22
  return Response.json({ error: 'messageId required' }, { status: 400 })
22
23
  }
23
24
 
24
- const message = await payload.findByID({
25
- collection: slugs.ticketMessages as any,
25
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
26
26
  id: messageId,
27
27
  depth: 1,
28
28
  overrideAccess: true,
@@ -42,8 +42,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
42
42
  : sourceTicket.client
43
43
 
44
44
  // Create new ticket
45
- const newTicket = await payload.create({
46
- collection: slugs.tickets as any,
45
+ const newTicket = await dbCreate(payload, slugs.tickets, {
47
46
  data: {
48
47
  subject: subject || `Split: ${sourceTicket.subject}`,
49
48
  client: clientId,
@@ -61,8 +60,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
61
60
  file: typeof a.file === 'object' ? a.file.id : a.file,
62
61
  })) || []
63
62
 
64
- await payload.create({
65
- collection: slugs.ticketMessages as any,
63
+ await dbCreate(payload, slugs.ticketMessages, {
66
64
  data: {
67
65
  ticket: newTicket.id,
68
66
  body: message.body,
@@ -77,8 +75,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
77
75
  })
78
76
 
79
77
  // Add system note on source ticket
80
- await payload.create({
81
- collection: slugs.ticketMessages as any,
78
+ await dbCreate(payload, slugs.ticketMessages, {
82
79
  data: {
83
80
  ticket: sourceTicket.id,
84
81
  body: `Message extrait vers le ticket ${newTicket.ticketNumber}`,
@@ -95,8 +92,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
95
92
  : []
96
93
 
97
94
  if (!existingRelated.includes(newTicket.id)) {
98
- await payload.update({
99
- collection: slugs.tickets as any,
95
+ await dbUpdate(payload, slugs.tickets, {
100
96
  id: sourceTicket.id,
101
97
  data: { relatedTickets: [...existingRelated, newTicket.id] },
102
98
  overrideAccess: true,
@@ -104,8 +100,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
104
100
  }
105
101
 
106
102
  // Log activity
107
- await payload.create({
108
- collection: slugs.ticketActivityLog as any,
103
+ await dbCreate(payload, slugs.ticketActivityLog, {
109
104
  data: {
110
105
  ticket: sourceTicket.id,
111
106
  action: 'split',
@@ -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
  /**
5
6
  * GET /api/support/statuses
@@ -17,8 +18,7 @@ export function createStatusesEndpoint(slugs: CollectionSlugs): Endpoint {
17
18
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
18
19
  }
19
20
 
20
- const { docs } = await payload.find({
21
- collection: slugs.ticketStatuses as any,
21
+ const { docs } = await dbFind(payload, slugs.ticketStatuses, {
22
22
  sort: 'sortOrder',
23
23
  limit: 100,
24
24
  depth: 0,
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireClient, handleAuthError } from '../utils/auth'
4
+ import { dbFind, dbFindByID, dbCreate } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * POST /api/support/tickets/:id/feedback
@@ -46,8 +47,7 @@ export function createTicketFeedbackEndpoint(slugs: CollectionSlugs): Endpoint {
46
47
  }
47
48
 
48
49
  // Find ticket and check it belongs to the requesting client
49
- const ticket = (await payload.findByID({
50
- collection: slugs.tickets as any,
50
+ const ticket = (await dbFindByID(payload, slugs.tickets, {
51
51
  id: ticketId,
52
52
  depth: 0,
53
53
  overrideAccess: true,
@@ -66,8 +66,7 @@ export function createTicketFeedbackEndpoint(slugs: CollectionSlugs): Endpoint {
66
66
  }
67
67
 
68
68
  // Check no feedback already exists for this ticket
69
- const existing = await payload.find({
70
- collection: 'ticket-feedback' as any,
69
+ const existing = await dbFind(payload, 'ticket-feedback', {
71
70
  where: { ticket: { equals: ticketId } },
72
71
  limit: 1,
73
72
  depth: 0,
@@ -80,8 +79,7 @@ export function createTicketFeedbackEndpoint(slugs: CollectionSlugs): Endpoint {
80
79
  )
81
80
  }
82
81
 
83
- const feedback = await payload.create({
84
- collection: 'ticket-feedback' as any,
82
+ const feedback = await dbCreate(payload, 'ticket-feedback', {
85
83
  data: {
86
84
  ticket: ticketId,
87
85
  client: req.user.id,
@@ -2,6 +2,7 @@ import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
4
  import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
5
+ import { dbFindByID } from '../utils/db'
5
6
 
6
7
  /**
7
8
  * POST /api/support/ticket-synthesis?ticketId=X[&force=true]
@@ -38,8 +39,7 @@ export function createTicketSynthesisEndpoint(slugs: CollectionSlugs): Endpoint
38
39
  }
39
40
 
40
41
  if (!force) {
41
- const existing = await payload.findByID({
42
- collection: slugs.tickets as any,
42
+ const existing = await dbFindByID(payload, slugs.tickets, {
43
43
  id: ticketId,
44
44
  depth: 0,
45
45
  overrideAccess: true,
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { createHmac } from 'crypto'
4
+ import { dbFindByID, dbUpdate, dbCreate } from '../utils/db'
4
5
 
5
6
  // 1x1 transparent GIF (43 bytes)
6
7
  const TRANSPARENT_GIF = Buffer.from(
@@ -34,23 +35,16 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
34
35
  const parsedId = ticketId ? Number(ticketId) : NaN
35
36
  const parsedMsgId = messageId ? Number(messageId) : NaN
36
37
 
37
- // Validate HMAC signature
38
+ // Open-tracking REQUIRES a valid HMAC signature. Without one we never
39
+ // process (no DB writes) — this prevents enumeration/abuse of t/m params
40
+ // and the silent "tracking writes without auth" hole.
38
41
  const secret = process.env.PAYLOAD_SECRET || ''
39
- if (secret && ticketId && messageId && sig) {
40
- const expected = generateTrackingToken(ticketId, messageId, secret)
41
- if (sig !== expected) {
42
- // Return transparent GIF silently (don't leak information)
43
- return new Response(TRANSPARENT_GIF, {
44
- status: 200,
45
- headers: {
46
- 'Content-Type': 'image/gif',
47
- 'Content-Length': String(TRANSPARENT_GIF.length),
48
- 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
49
- },
50
- })
51
- }
52
- } else if (secret && (!sig || !ticketId || !messageId)) {
53
- // Missing signature param: return GIF but don't process
42
+ const validSig =
43
+ !!secret && !!ticketId && !!messageId && !!sig &&
44
+ sig === generateTrackingToken(ticketId, messageId, secret)
45
+
46
+ if (!validSig) {
47
+ // Return transparent GIF silently (don't leak information, don't process)
54
48
  return new Response(TRANSPARENT_GIF, {
55
49
  status: 200,
56
50
  headers: {
@@ -65,8 +59,7 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
65
59
  try {
66
60
  const payload = req.payload
67
61
 
68
- const ticket = await payload.findByID({
69
- collection: slugs.tickets as any,
62
+ const ticket = await dbFindByID(payload, slugs.tickets, {
70
63
  id: parsedId,
71
64
  depth: 0,
72
65
  overrideAccess: true,
@@ -78,8 +71,7 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
78
71
  const fiveMinAgo = Date.now() - 5 * 60 * 1000
79
72
 
80
73
  if (lastRead < fiveMinAgo) {
81
- await payload.update({
82
- collection: slugs.tickets as any,
74
+ await dbUpdate(payload, slugs.tickets, {
83
75
  id: parsedId,
84
76
  data: { lastClientReadAt: new Date().toISOString() },
85
77
  overrideAccess: true,
@@ -89,8 +81,7 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
89
81
 
90
82
  // Track at message level
91
83
  if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
92
- const msg = await payload.findByID({
93
- collection: slugs.ticketMessages as any,
84
+ const msg = await dbFindByID(payload, slugs.ticketMessages, {
94
85
  id: parsedMsgId,
95
86
  depth: 0,
96
87
  overrideAccess: true,
@@ -98,15 +89,13 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
98
89
  }) as any
99
90
 
100
91
  if (msg && !msg.emailOpenedAt) {
101
- await payload.update({
102
- collection: slugs.ticketMessages as any,
92
+ await dbUpdate(payload, slugs.ticketMessages, {
103
93
  id: parsedMsgId,
104
94
  data: { emailOpenedAt: new Date().toISOString() },
105
95
  overrideAccess: true,
106
96
  })
107
97
 
108
- const ticketInfo = await payload.findByID({
109
- collection: slugs.tickets as any,
98
+ const ticketInfo = await dbFindByID(payload, slugs.tickets, {
110
99
  id: parsedId,
111
100
  depth: 1,
112
101
  overrideAccess: true,
@@ -119,8 +108,7 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
119
108
 
120
109
  // Try to create admin notification (collection may not exist)
121
110
  try {
122
- await payload.create({
123
- collection: 'admin-notifications' as any,
111
+ await dbCreate(payload, 'admin-notifications', {
124
112
  data: {
125
113
  title: `Email ouvert — ${ticketInfo?.ticketNumber || 'TK-????'}`,
126
114
  message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || 'ticket'}"`,
@@ -3,6 +3,8 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { handleAuthError, AuthError } from '../utils/auth'
4
4
  import { escapeHtml, emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
+ import { RateLimiter } from '../utils/rateLimiter'
7
+ import { dbFindByID, dbFind, dbCreate, dbCount } from '../utils/db'
6
8
 
7
9
  // Simple RFC-5322 style sanity check — same level of strictness as the
8
10
  // portal forms used elsewhere in the plugin. Server-side validation only;
@@ -11,6 +13,11 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
11
13
 
12
14
  const MAX_TRANSFERS_PER_DAY = 5
13
15
 
16
+ // In-memory fail-closed rate limiter — enforced even when the persistent
17
+ // emailLogs-based check is unavailable (collection disabled). Prevents the
18
+ // endpoint from becoming an unbounded outbound mailer.
19
+ const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_DAY)
20
+
14
21
  /**
15
22
  * POST /api/support/tickets/:id/transfer
16
23
  *
@@ -56,12 +63,11 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
56
63
  const payload = req.payload
57
64
 
58
65
  // Load ticket (ownership check happens here too)
59
- const ticket = await payload.findByID({
60
- collection: slugs.tickets as any,
66
+ const ticket = await dbFindByID(payload, slugs.tickets, {
61
67
  id: ticketId,
62
68
  depth: 1,
63
69
  overrideAccess: true,
64
- }) as any
70
+ })
65
71
  if (!ticket) return Response.json({ error: 'Ticket introuvable' }, { status: 404 })
66
72
 
67
73
  const isAdmin = req.user.collection === slugs.users
@@ -74,11 +80,19 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
74
80
  return Response.json({ error: 'Forbidden' }, { status: 403 })
75
81
  }
76
82
 
83
+ // Fail-closed in-memory rate limit (per user + ticket): enforced even
84
+ // when the persistent emailLogs check below cannot run.
85
+ if (transferLimiter.check(`${req.user.id}:${ticketId}`)) {
86
+ return Response.json(
87
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
88
+ { status: 429 },
89
+ )
90
+ }
91
+
77
92
  // Rate limit: count transfers in last 24h for this ticket
78
93
  try {
79
94
  const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
80
- const existing = await payload.count({
81
- collection: slugs.emailLogs as any,
95
+ const existing = await dbCount(payload, slugs.emailLogs, {
82
96
  where: {
83
97
  and: [
84
98
  { action: { equals: 'transfer' } },
@@ -99,8 +113,7 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
99
113
  }
100
114
 
101
115
  // Fetch non-internal messages, ordered chronologically
102
- const messages = await payload.find({
103
- collection: slugs.ticketMessages as any,
116
+ const messages = await dbFind(payload, slugs.ticketMessages, {
104
117
  where: {
105
118
  and: [
106
119
  { ticket: { equals: ticketId } },
@@ -206,8 +219,7 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
206
219
 
207
220
  // Log the transfer (best-effort)
208
221
  try {
209
- await payload.create({
210
- collection: slugs.emailLogs as any,
222
+ await dbCreate(payload, slugs.emailLogs, {
211
223
  data: {
212
224
  status: 'success',
213
225
  action: 'transfer',
@@ -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 } from '../utils/db'
4
5
 
5
6
  const PREF_KEY_PREFIX = 'support-user-prefs'
6
7
 
@@ -29,8 +30,7 @@ export function createUserPrefsGetEndpoint(slugs: CollectionSlugs): Endpoint {
29
30
 
30
31
  const key = `${PREF_KEY_PREFIX}-${req.user!.id}`
31
32
 
32
- const prefs = await payload.find({
33
- collection: 'payload-preferences' as any,
33
+ const prefs = await dbFind(payload, 'payload-preferences', {
34
34
  where: { key: { equals: key } },
35
35
  limit: 1,
36
36
  depth: 0,
@@ -74,8 +74,7 @@ export function createUserPrefsPostEndpoint(slugs: CollectionSlugs): Endpoint {
74
74
  const key = `${PREF_KEY_PREFIX}-${req.user!.id}`
75
75
 
76
76
  // Read existing prefs to merge
77
- const existing = await payload.find({
78
- collection: 'payload-preferences' as any,
77
+ const existing = await dbFind(payload, 'payload-preferences', {
79
78
  where: { key: { equals: key } },
80
79
  limit: 1,
81
80
  depth: 0,