@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
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
- import crypto3, { randomBytes, createHmac } from 'crypto';
1
+ import crypto3, { createHmac, randomBytes } from 'crypto';
2
+ import PDFDocument from 'pdfkit';
3
+ import { APIError, getFieldsToSign, jwtSign } from 'payload';
4
+ import sanitizeHtml from 'sanitize-html';
2
5
 
3
6
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
4
7
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
@@ -58,6 +61,9 @@ var DEFAULT_SLUGS = {
58
61
  macros: "macros",
59
62
  ticketStatuses: "ticket-statuses",
60
63
  ticketFeedback: "ticket-feedback",
64
+ notificationQueue: "notification-queue",
65
+ automationRules: "automation-rules",
66
+ supportTeams: "support-teams",
61
67
  users: "users",
62
68
  media: "media"
63
69
  };
@@ -89,6 +95,26 @@ function handleAuthError(error) {
89
95
  return null;
90
96
  }
91
97
 
98
+ // src/utils/db.ts
99
+ function dbFind(payload, slug, options = {}) {
100
+ return payload.find({ collection: slug, ...options });
101
+ }
102
+ function dbFindByID(payload, slug, options) {
103
+ return payload.findByID({ collection: slug, ...options });
104
+ }
105
+ function dbCreate(payload, slug, options) {
106
+ return payload.create({ collection: slug, ...options });
107
+ }
108
+ function dbUpdate(payload, slug, options) {
109
+ return payload.update({ collection: slug, ...options });
110
+ }
111
+ function dbCount(payload, slug, options = {}) {
112
+ return payload.count({ collection: slug, ...options });
113
+ }
114
+ function dbDelete(payload, slug, options) {
115
+ return payload.delete({ collection: slug, ...options });
116
+ }
117
+
92
118
  // src/utils/readSettings.ts
93
119
  var PREF_KEY = "support-settings";
94
120
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
@@ -102,10 +128,18 @@ var DEFAULT_USER_PREFS = {
102
128
  locale: "fr",
103
129
  signature: ""
104
130
  };
131
+ var settingsCache = null;
132
+ var SETTINGS_TTL_MS = 6e4;
133
+ function invalidateSupportSettingsCache() {
134
+ settingsCache = null;
135
+ }
105
136
  async function readSupportSettings(payload) {
137
+ if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
138
+ return settingsCache.value;
139
+ }
140
+ let value = { ...DEFAULT_SETTINGS };
106
141
  try {
107
- const prefs = await payload.find({
108
- collection: "payload-preferences",
142
+ const prefs = await dbFind(payload, "payload-preferences", {
109
143
  where: { key: { equals: PREF_KEY } },
110
144
  limit: 1,
111
145
  depth: 0,
@@ -113,7 +147,7 @@ async function readSupportSettings(payload) {
113
147
  });
114
148
  if (prefs.docs.length > 0) {
115
149
  const stored = prefs.docs[0].value;
116
- return {
150
+ value = {
117
151
  email: { ...DEFAULT_SETTINGS.email, ...stored.email },
118
152
  ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
119
153
  sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
@@ -122,13 +156,13 @@ async function readSupportSettings(payload) {
122
156
  }
123
157
  } catch {
124
158
  }
125
- return { ...DEFAULT_SETTINGS };
159
+ settingsCache = { value, ts: Date.now() };
160
+ return value;
126
161
  }
127
162
  async function readUserPrefs(payload, userId) {
128
163
  try {
129
164
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
130
- const prefs = await payload.find({
131
- collection: "payload-preferences",
165
+ const prefs = await dbFind(payload, "payload-preferences", {
132
166
  where: { key: { equals: key } },
133
167
  limit: 1,
134
168
  depth: 0,
@@ -314,8 +348,130 @@ ${text}`;
314
348
  };
315
349
  }
316
350
 
317
- // src/endpoints/client-intelligence.ts
351
+ // src/utils/aiAgent.ts
352
+ async function applyAgentDecision(payload, slugs, ticketId, decision) {
353
+ if (decision.action === "reply" && decision.reply && decision.reply.trim()) {
354
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
355
+ data: {
356
+ ticket: ticketId,
357
+ body: decision.reply.trim(),
358
+ authorType: "admin",
359
+ isInternal: false,
360
+ fromAlias: "Assistant IA"
361
+ },
362
+ overrideAccess: true
363
+ });
364
+ return { acted: "reply", messageId: msg.id, confidence: decision.confidence };
365
+ }
366
+ await dbCreate(payload, slugs.ticketMessages, {
367
+ data: {
368
+ ticket: ticketId,
369
+ body: `[Agent IA] Escalade vers un humain. Raison : ${decision.reason || "non d\xE9termin\xE9e"} (confiance ${Math.round((decision.confidence ?? 0) * 100)} %).`,
370
+ authorType: "admin",
371
+ isInternal: true,
372
+ skipNotification: true
373
+ },
374
+ overrideAccess: true
375
+ });
376
+ await dbUpdate(payload, slugs.tickets, { id: ticketId, data: { status: "escalated" }, overrideAccess: true });
377
+ return { acted: "escalate", confidence: decision.confidence };
378
+ }
318
379
  function getClient2(aiSettings) {
380
+ const Anthropic = __require("@anthropic-ai/sdk").default;
381
+ if (aiSettings.provider === "ollama") {
382
+ return new Anthropic({ apiKey: "ollama", baseURL: process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1" });
383
+ }
384
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
385
+ }
386
+ function stripHtml(s) {
387
+ return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
388
+ }
389
+ function parseDecision(text) {
390
+ try {
391
+ const match = text.match(/\{[\s\S]*\}/);
392
+ if (match) {
393
+ const obj = JSON.parse(match[0]);
394
+ return {
395
+ action: obj.action === "reply" ? "reply" : "escalate",
396
+ reply: typeof obj.reply === "string" ? obj.reply : void 0,
397
+ reason: typeof obj.reason === "string" ? obj.reason : void 0,
398
+ confidence: typeof obj.confidence === "number" ? obj.confidence : 0
399
+ };
400
+ }
401
+ } catch {
402
+ }
403
+ return { action: "escalate", reason: "R\xE9ponse IA non interpr\xE9table", confidence: 0 };
404
+ }
405
+ async function runAiAgent(payload, slugs, ticketId, opts) {
406
+ const settings = await readSupportSettings(payload);
407
+ const threshold = opts?.confidenceThreshold ?? 0.7;
408
+ const ticket = await dbFindByID(payload, slugs.tickets, { id: ticketId, depth: 0, overrideAccess: true });
409
+ const messages = await dbFind(payload, slugs.ticketMessages, { where: { ticket: { equals: ticketId } }, sort: "createdAt", limit: 50, depth: 0, overrideAccess: true });
410
+ const kb = await dbFind(payload, slugs.knowledgeBase, { where: { published: { equals: true } }, limit: 20, depth: 0, overrideAccess: true });
411
+ const conversation = messages.docs.map((m) => `${m.authorType}: ${m.body || ""}`).join("\n");
412
+ const kbText = kb.docs.map((a) => `# ${a.title || ""}
413
+ ${stripHtml(String(a.content ?? ""))}`).join("\n\n").slice(0, 8e3);
414
+ const prompt = `Tu es un agent de support IA. \xC0 partir du ticket et de la base de connaissances ci-dessous, d\xE9cide :
415
+ - "reply" si tu peux r\xE9pondre avec certitude UNIQUEMENT \xE0 partir de la base de connaissances ;
416
+ - "escalate" sinon (question hors KB, ambigu\xEB, sensible, ou n\xE9cessitant une action humaine).
417
+ R\xE9ponds en JSON STRICT, sans texte autour : {"action":"reply"|"escalate","reply":"<r\xE9ponse au client si reply>","reason":"<courte raison>","confidence":<0 \xE0 1>}.
418
+
419
+ TICKET : ${ticket.subject || ""}
420
+ CONVERSATION :
421
+ ${conversation}
422
+
423
+ BASE DE CONNAISSANCES :
424
+ ${kbText || "(vide)"}`;
425
+ let decision;
426
+ try {
427
+ const client = getClient2(settings.ai);
428
+ const resp = await client.messages.create({
429
+ model: settings.ai.model || "claude-haiku-4-5-20251001",
430
+ max_tokens: 1024,
431
+ messages: [{ role: "user", content: prompt }]
432
+ });
433
+ const text = resp?.content?.[0]?.text || "";
434
+ decision = parseDecision(text);
435
+ } catch (err) {
436
+ console.error("[support] AI agent decision failed:", err);
437
+ decision = { action: "escalate", reason: "Erreur du fournisseur IA", confidence: 0 };
438
+ }
439
+ const finalDecision = decision.action === "reply" && (decision.confidence ?? 0) >= threshold ? decision : { action: "escalate", reason: decision.reason || "Confiance insuffisante pour une r\xE9ponse automatique", confidence: decision.confidence };
440
+ return applyAgentDecision(payload, slugs, ticketId, finalDecision);
441
+ }
442
+
443
+ // src/endpoints/ai-agent.ts
444
+ function createAiAgentEndpoint(slugs) {
445
+ return {
446
+ path: "/support/ai-agent",
447
+ method: "post",
448
+ handler: async (req) => {
449
+ try {
450
+ requireAdmin(req, slugs);
451
+ let body = {};
452
+ try {
453
+ body = await req.json();
454
+ } catch {
455
+ }
456
+ if (!body.ticketId) {
457
+ return Response.json({ error: "ticketId requis." }, { status: 400 });
458
+ }
459
+ const result = await runAiAgent(req.payload, slugs, body.ticketId, {
460
+ confidenceThreshold: body.confidenceThreshold
461
+ });
462
+ return Response.json({ ok: true, ...result });
463
+ } catch (error) {
464
+ const authResponse = handleAuthError(error);
465
+ if (authResponse) return authResponse;
466
+ console.error("[ai-agent] Error:", error);
467
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
468
+ }
469
+ }
470
+ };
471
+ }
472
+
473
+ // src/endpoints/client-intelligence.ts
474
+ function getClient3(aiSettings) {
319
475
  const Anthropic = __require("@anthropic-ai/sdk").default;
320
476
  if (aiSettings.provider === "ollama") {
321
477
  const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
@@ -469,7 +625,7 @@ R\xE9ponds en JSON strict (pas de markdown, pas de commentaires) avec cette stru
469
625
  }
470
626
 
471
627
  Sois factuel. Ne d\xE9passe pas 5 items par tableau. R\xE9ponds UNIQUEMENT avec le JSON.`;
472
- const anthropic = getClient2(aiSettings);
628
+ const anthropic = getClient3(aiSettings);
473
629
  const model = getModel2(aiSettings);
474
630
  const res = await anthropic.messages.create({
475
631
  model,
@@ -533,8 +689,7 @@ function createSearchEndpoint(slugs) {
533
689
  return Response.json({ tickets: [], clients: [], messages: [], articles: [] });
534
690
  }
535
691
  const [ticketsRes, clientsRes, messagesRes, articlesRes] = await Promise.all([
536
- payload.find({
537
- collection: slugs.tickets,
692
+ dbFind(payload, slugs.tickets, {
538
693
  where: {
539
694
  or: [
540
695
  { ticketNumber: { contains: q } },
@@ -546,8 +701,7 @@ function createSearchEndpoint(slugs) {
546
701
  depth: 1,
547
702
  overrideAccess: true
548
703
  }),
549
- payload.find({
550
- collection: slugs.supportClients,
704
+ dbFind(payload, slugs.supportClients, {
551
705
  where: {
552
706
  or: [
553
707
  { firstName: { contains: q } },
@@ -560,16 +714,14 @@ function createSearchEndpoint(slugs) {
560
714
  depth: 0,
561
715
  overrideAccess: true
562
716
  }),
563
- payload.find({
564
- collection: slugs.ticketMessages,
717
+ dbFind(payload, slugs.ticketMessages, {
565
718
  where: { body: { contains: q } },
566
719
  sort: "-createdAt",
567
720
  limit: 5,
568
721
  depth: 1,
569
722
  overrideAccess: true
570
723
  }),
571
- payload.find({
572
- collection: slugs.knowledgeBase,
724
+ dbFind(payload, slugs.knowledgeBase, {
573
725
  where: { title: { contains: q } },
574
726
  limit: 5,
575
727
  depth: 0,
@@ -669,8 +821,7 @@ function createKbSearchEndpoint(slugs) {
669
821
  orClauses.push({ category: { contains: t } });
670
822
  }
671
823
  orClauses.push({ title: { contains: q } });
672
- const res = await payload.find({
673
- collection: slugs.knowledgeBase,
824
+ const res = await dbFind(payload, slugs.knowledgeBase, {
674
825
  where: {
675
826
  and: [
676
827
  { published: { equals: true } },
@@ -729,16 +880,14 @@ function createBulkActionEndpoint(slugs) {
729
880
  try {
730
881
  switch (action) {
731
882
  case "close":
732
- await payload.update({
733
- collection: slugs.tickets,
883
+ await dbUpdate(payload, slugs.tickets, {
734
884
  id: ticketId,
735
885
  data: { status: "resolved", resolvedAt: (/* @__PURE__ */ new Date()).toISOString() },
736
886
  overrideAccess: true
737
887
  });
738
888
  break;
739
889
  case "reopen":
740
- await payload.update({
741
- collection: slugs.tickets,
890
+ await dbUpdate(payload, slugs.tickets, {
742
891
  id: ticketId,
743
892
  data: { status: "open" },
744
893
  overrideAccess: true
@@ -746,8 +895,7 @@ function createBulkActionEndpoint(slugs) {
746
895
  break;
747
896
  case "assign":
748
897
  if (value) {
749
- await payload.update({
750
- collection: slugs.tickets,
898
+ await dbUpdate(payload, slugs.tickets, {
751
899
  id: ticketId,
752
900
  data: { assignedTo: Number(value) },
753
901
  overrideAccess: true
@@ -756,8 +904,7 @@ function createBulkActionEndpoint(slugs) {
756
904
  break;
757
905
  case "set_priority":
758
906
  if (value) {
759
- await payload.update({
760
- collection: slugs.tickets,
907
+ await dbUpdate(payload, slugs.tickets, {
761
908
  id: ticketId,
762
909
  data: { priority: String(value) },
763
910
  overrideAccess: true
@@ -766,8 +913,7 @@ function createBulkActionEndpoint(slugs) {
766
913
  break;
767
914
  case "set_category":
768
915
  if (value) {
769
- await payload.update({
770
- collection: slugs.tickets,
916
+ await dbUpdate(payload, slugs.tickets, {
771
917
  id: ticketId,
772
918
  data: { category: String(value) },
773
919
  overrideAccess: true
@@ -775,8 +921,7 @@ function createBulkActionEndpoint(slugs) {
775
921
  }
776
922
  break;
777
923
  case "delete":
778
- await payload.delete({
779
- collection: slugs.tickets,
924
+ await dbDelete(payload, slugs.tickets, {
780
925
  id: ticketId,
781
926
  overrideAccess: true
782
927
  });
@@ -815,8 +960,8 @@ function createMergeTicketsEndpoint(slugs) {
815
960
  return Response.json({ error: "Impossible de fusionner un ticket avec lui-m\xEAme" }, { status: 400 });
816
961
  }
817
962
  const [source, target] = await Promise.all([
818
- payload.findByID({ collection: slugs.tickets, id: sourceTicketId, depth: 0, overrideAccess: true }),
819
- payload.findByID({ collection: slugs.tickets, id: targetTicketId, depth: 0, overrideAccess: true })
963
+ dbFindByID(payload, slugs.tickets, { id: sourceTicketId, depth: 0, overrideAccess: true }),
964
+ dbFindByID(payload, slugs.tickets, { id: targetTicketId, depth: 0, overrideAccess: true })
820
965
  ]);
821
966
  if (!source) return Response.json({ error: "Ticket source introuvable" }, { status: 404 });
822
967
  if (!target) return Response.json({ error: "Ticket cible introuvable" }, { status: 404 });
@@ -825,39 +970,34 @@ function createMergeTicketsEndpoint(slugs) {
825
970
  if (sourceClient !== targetClient) {
826
971
  return Response.json({ error: "Les deux tickets doivent appartenir au m\xEAme client" }, { status: 400 });
827
972
  }
828
- const messages = await payload.find({
829
- collection: slugs.ticketMessages,
973
+ const messages = await dbFind(payload, slugs.ticketMessages, {
830
974
  where: { ticket: { equals: sourceTicketId } },
831
975
  limit: 500,
832
976
  depth: 0,
833
977
  overrideAccess: true
834
978
  });
835
979
  for (const msg of messages.docs) {
836
- await payload.update({
837
- collection: slugs.ticketMessages,
980
+ await dbUpdate(payload, slugs.ticketMessages, {
838
981
  id: msg.id,
839
982
  data: { ticket: targetTicketId },
840
983
  overrideAccess: true
841
984
  });
842
985
  }
843
- const timeEntries = await payload.find({
844
- collection: slugs.timeEntries,
986
+ const timeEntries = await dbFind(payload, slugs.timeEntries, {
845
987
  where: { ticket: { equals: sourceTicketId } },
846
988
  limit: 500,
847
989
  depth: 0,
848
990
  overrideAccess: true
849
991
  });
850
992
  for (const entry of timeEntries.docs) {
851
- await payload.update({
852
- collection: slugs.timeEntries,
993
+ await dbUpdate(payload, slugs.timeEntries, {
853
994
  id: entry.id,
854
995
  data: { ticket: targetTicketId },
855
996
  overrideAccess: true
856
997
  });
857
998
  }
858
999
  const sourceNumber = source.ticketNumber || `#${sourceTicketId}`;
859
- await payload.create({
860
- collection: slugs.ticketMessages,
1000
+ await dbCreate(payload, slugs.ticketMessages, {
861
1001
  data: {
862
1002
  ticket: targetTicketId,
863
1003
  body: `Messages fusionn\xE9s depuis ${sourceNumber} (${messages.totalDocs} messages, ${timeEntries.totalDocs} entr\xE9es de temps)`,
@@ -867,13 +1007,11 @@ function createMergeTicketsEndpoint(slugs) {
867
1007
  },
868
1008
  overrideAccess: true
869
1009
  });
870
- await payload.delete({
871
- collection: slugs.tickets,
1010
+ await dbDelete(payload, slugs.tickets, {
872
1011
  id: sourceTicketId,
873
1012
  overrideAccess: true
874
1013
  });
875
- const allTimeEntries = await payload.find({
876
- collection: slugs.timeEntries,
1014
+ const allTimeEntries = await dbFind(payload, slugs.timeEntries, {
877
1015
  where: { ticket: { equals: targetTicketId } },
878
1016
  limit: 500,
879
1017
  depth: 0,
@@ -883,8 +1021,7 @@ function createMergeTicketsEndpoint(slugs) {
883
1021
  (sum, e) => sum + (e.duration || 0),
884
1022
  0
885
1023
  );
886
- await payload.update({
887
- collection: slugs.tickets,
1024
+ await dbUpdate(payload, slugs.tickets, {
888
1025
  id: targetTicketId,
889
1026
  data: { totalTimeMinutes: totalMinutes },
890
1027
  overrideAccess: true
@@ -919,8 +1056,7 @@ function createSplitTicketEndpoint(slugs) {
919
1056
  if (!messageId) {
920
1057
  return Response.json({ error: "messageId required" }, { status: 400 });
921
1058
  }
922
- const message = await payload.findByID({
923
- collection: slugs.ticketMessages,
1059
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
924
1060
  id: messageId,
925
1061
  depth: 1,
926
1062
  overrideAccess: true
@@ -933,8 +1069,7 @@ function createSplitTicketEndpoint(slugs) {
933
1069
  return Response.json({ error: "Cannot resolve source ticket" }, { status: 400 });
934
1070
  }
935
1071
  const clientId = typeof sourceTicket.client === "object" ? sourceTicket.client.id : sourceTicket.client;
936
- const newTicket = await payload.create({
937
- collection: slugs.tickets,
1072
+ const newTicket = await dbCreate(payload, slugs.tickets, {
938
1073
  data: {
939
1074
  subject: subject || `Split: ${sourceTicket.subject}`,
940
1075
  client: clientId,
@@ -949,8 +1084,7 @@ function createSplitTicketEndpoint(slugs) {
949
1084
  const attachments = message.attachments?.map((a) => ({
950
1085
  file: typeof a.file === "object" ? a.file.id : a.file
951
1086
  })) || [];
952
- await payload.create({
953
- collection: slugs.ticketMessages,
1087
+ await dbCreate(payload, slugs.ticketMessages, {
954
1088
  data: {
955
1089
  ticket: newTicket.id,
956
1090
  body: message.body,
@@ -963,8 +1097,7 @@ function createSplitTicketEndpoint(slugs) {
963
1097
  },
964
1098
  overrideAccess: true
965
1099
  });
966
- await payload.create({
967
- collection: slugs.ticketMessages,
1100
+ await dbCreate(payload, slugs.ticketMessages, {
968
1101
  data: {
969
1102
  ticket: sourceTicket.id,
970
1103
  body: `Message extrait vers le ticket ${newTicket.ticketNumber}`,
@@ -976,15 +1109,13 @@ function createSplitTicketEndpoint(slugs) {
976
1109
  });
977
1110
  const existingRelated = Array.isArray(sourceTicket.relatedTickets) ? sourceTicket.relatedTickets.map((t) => typeof t === "object" ? t.id : t) : [];
978
1111
  if (!existingRelated.includes(newTicket.id)) {
979
- await payload.update({
980
- collection: slugs.tickets,
1112
+ await dbUpdate(payload, slugs.tickets, {
981
1113
  id: sourceTicket.id,
982
1114
  data: { relatedTickets: [...existingRelated, newTicket.id] },
983
1115
  overrideAccess: true
984
1116
  });
985
1117
  }
986
- await payload.create({
987
- collection: slugs.ticketActivityLog,
1118
+ await dbCreate(payload, slugs.ticketActivityLog, {
988
1119
  data: {
989
1120
  ticket: sourceTicket.id,
990
1121
  action: "split",
@@ -1199,8 +1330,7 @@ function createSettingsGetEndpoint(slugs) {
1199
1330
  try {
1200
1331
  const payload = req.payload;
1201
1332
  requireAdmin(req, slugs);
1202
- const prefs = await payload.find({
1203
- collection: "payload-preferences",
1333
+ const prefs = await dbFind(payload, "payload-preferences", {
1204
1334
  where: { key: { equals: PREF_KEY2 } },
1205
1335
  limit: 1,
1206
1336
  depth: 0,
@@ -1257,6 +1387,7 @@ function createSettingsPostEndpoint(slugs) {
1257
1387
  ]
1258
1388
  }
1259
1389
  });
1390
+ invalidateSupportSettingsCache();
1260
1391
  return Response.json(merged);
1261
1392
  } catch (error) {
1262
1393
  const authResponse = handleAuthError(error);
@@ -1278,8 +1409,7 @@ function createSignatureGetEndpoint(slugs) {
1278
1409
  try {
1279
1410
  const payload = req.payload;
1280
1411
  requireAdmin(req, slugs);
1281
- const prefs = await payload.find({
1282
- collection: "payload-preferences",
1412
+ const prefs = await dbFind(payload, "payload-preferences", {
1283
1413
  where: { key: { equals: `${PREF_KEY3}-${req.user.id}` } },
1284
1414
  limit: 1,
1285
1415
  depth: 0,
@@ -1306,8 +1436,7 @@ function createSignaturePostEndpoint(slugs) {
1306
1436
  requireAdmin(req, slugs);
1307
1437
  const { signature } = await req.json();
1308
1438
  const key = `${PREF_KEY3}-${req.user.id}`;
1309
- const existing = await payload.find({
1310
- collection: "payload-preferences",
1439
+ const existing = await dbFind(payload, "payload-preferences", {
1311
1440
  where: { key: { equals: key } },
1312
1441
  limit: 1,
1313
1442
  depth: 0,
@@ -1350,8 +1479,7 @@ function createRoundRobinConfigGetEndpoint(slugs) {
1350
1479
  try {
1351
1480
  const payload = req.payload;
1352
1481
  requireAdmin(req, slugs);
1353
- const prefs = await payload.find({
1354
- collection: "payload-preferences",
1482
+ const prefs = await dbFind(payload, "payload-preferences", {
1355
1483
  where: { key: { equals: PREF_KEY4 } },
1356
1484
  limit: 1,
1357
1485
  depth: 0,
@@ -1377,8 +1505,7 @@ function createRoundRobinConfigPostEndpoint(slugs) {
1377
1505
  const payload = req.payload;
1378
1506
  requireAdmin(req, slugs);
1379
1507
  const { enabled } = await req.json();
1380
- const existing = await payload.find({
1381
- collection: "payload-preferences",
1508
+ const existing = await dbFind(payload, "payload-preferences", {
1382
1509
  where: { key: { equals: PREF_KEY4 } },
1383
1510
  limit: 1,
1384
1511
  depth: 0,
@@ -1422,8 +1549,7 @@ function createSlaCheckEndpoint(slugs) {
1422
1549
  requireAdmin(req, slugs);
1423
1550
  const now = /* @__PURE__ */ new Date();
1424
1551
  const nowISO = now.toISOString();
1425
- const { docs: tickets } = await payload.find({
1426
- collection: slugs.tickets,
1552
+ const { docs: tickets } = await dbFind(payload, slugs.tickets, {
1427
1553
  where: {
1428
1554
  and: [
1429
1555
  { status: { in: ["open", "waiting_client"] } },
@@ -1517,6 +1643,107 @@ function createSlaCheckEndpoint(slugs) {
1517
1643
  }
1518
1644
  };
1519
1645
  }
1646
+ var TRANSPARENT_GIF = Buffer.from(
1647
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
1648
+ "base64"
1649
+ );
1650
+ function generateTrackingToken(ticketId, messageId, secret) {
1651
+ return createHmac("sha256", secret).update(`${ticketId}:${messageId}`).digest("hex").substring(0, 16);
1652
+ }
1653
+ function createTrackOpenEndpoint(slugs) {
1654
+ return {
1655
+ path: "/support/track-open",
1656
+ method: "get",
1657
+ handler: async (req) => {
1658
+ const url = new URL(req.url);
1659
+ const ticketId = url.searchParams.get("t");
1660
+ const messageId = url.searchParams.get("m");
1661
+ const sig = url.searchParams.get("sig");
1662
+ const parsedId = ticketId ? Number(ticketId) : NaN;
1663
+ const parsedMsgId = messageId ? Number(messageId) : NaN;
1664
+ const secret = process.env.PAYLOAD_SECRET || "";
1665
+ const validSig = !!secret && !!ticketId && !!messageId && !!sig && sig === generateTrackingToken(ticketId, messageId, secret);
1666
+ if (!validSig) {
1667
+ return new Response(TRANSPARENT_GIF, {
1668
+ status: 200,
1669
+ headers: {
1670
+ "Content-Type": "image/gif",
1671
+ "Content-Length": String(TRANSPARENT_GIF.length),
1672
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
1673
+ }
1674
+ });
1675
+ }
1676
+ if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
1677
+ try {
1678
+ const payload = req.payload;
1679
+ const ticket = await dbFindByID(payload, slugs.tickets, {
1680
+ id: parsedId,
1681
+ depth: 0,
1682
+ overrideAccess: true,
1683
+ select: { lastClientReadAt: true }
1684
+ });
1685
+ if (ticket) {
1686
+ const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0;
1687
+ const fiveMinAgo = Date.now() - 5 * 60 * 1e3;
1688
+ if (lastRead < fiveMinAgo) {
1689
+ await dbUpdate(payload, slugs.tickets, {
1690
+ id: parsedId,
1691
+ data: { lastClientReadAt: (/* @__PURE__ */ new Date()).toISOString() },
1692
+ overrideAccess: true
1693
+ });
1694
+ }
1695
+ }
1696
+ if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
1697
+ const msg = await dbFindByID(payload, slugs.ticketMessages, {
1698
+ id: parsedMsgId,
1699
+ depth: 0,
1700
+ overrideAccess: true,
1701
+ select: { emailOpenedAt: true }
1702
+ });
1703
+ if (msg && !msg.emailOpenedAt) {
1704
+ await dbUpdate(payload, slugs.ticketMessages, {
1705
+ id: parsedMsgId,
1706
+ data: { emailOpenedAt: (/* @__PURE__ */ new Date()).toISOString() },
1707
+ overrideAccess: true
1708
+ });
1709
+ const ticketInfo = await dbFindByID(payload, slugs.tickets, {
1710
+ id: parsedId,
1711
+ depth: 1,
1712
+ overrideAccess: true,
1713
+ select: { ticketNumber: true, subject: true, client: true }
1714
+ });
1715
+ const clientName = typeof ticketInfo?.client === "object" ? ticketInfo.client?.firstName || "Client" : "Client";
1716
+ try {
1717
+ await dbCreate(payload, "admin-notifications", {
1718
+ data: {
1719
+ title: `Email ouvert \u2014 ${ticketInfo?.ticketNumber || "TK-????"}`,
1720
+ message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || "ticket"}"`,
1721
+ type: "email_opened",
1722
+ link: `/admin/ticket?id=${parsedId}`
1723
+ },
1724
+ overrideAccess: true
1725
+ });
1726
+ } catch {
1727
+ }
1728
+ }
1729
+ }
1730
+ } catch (err) {
1731
+ console.error("[track-open] Error:", err);
1732
+ }
1733
+ }
1734
+ return new Response(TRANSPARENT_GIF, {
1735
+ status: 200,
1736
+ headers: {
1737
+ "Content-Type": "image/gif",
1738
+ "Content-Length": String(TRANSPARENT_GIF.length),
1739
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
1740
+ "Pragma": "no-cache",
1741
+ "Expires": "0"
1742
+ }
1743
+ });
1744
+ }
1745
+ };
1746
+ }
1520
1747
 
1521
1748
  // src/utils/emailTemplate.ts
1522
1749
  var COLORS = {
@@ -1553,7 +1780,10 @@ function escapeHtml(str) {
1553
1780
  }
1554
1781
  function emailTrackingPixel(ticketId, messageId, baseUrl) {
1555
1782
  const url = process.env.NEXT_PUBLIC_SERVER_URL || "";
1556
- const params = `t=${ticketId}${messageId ? `&m=${messageId}` : ""}`;
1783
+ const secret = process.env.PAYLOAD_SECRET || "";
1784
+ if (!secret || messageId === void 0 || messageId === null) return "";
1785
+ const sig = generateTrackingToken(String(ticketId), String(messageId), secret);
1786
+ const params = `t=${ticketId}&m=${messageId}&sig=${sig}`;
1557
1787
  return `<img src="${url}/api/support/track-open?${params}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`;
1558
1788
  }
1559
1789
  function emailRichContent(html, config) {
@@ -1818,8 +2048,7 @@ function createAutoCloseEndpoint(slugs) {
1818
2048
  const CLOSE_AFTER_REMIND_DAYS = settings.autoClose.reminderDaysBefore;
1819
2049
  const results = { reminded: 0, closed: 0, errors: 0 };
1820
2050
  const remindCutoff = new Date(now.getTime() - REMIND_AFTER_DAYS * 24 * 60 * 60 * 1e3);
1821
- const ticketsToRemind = await payload.find({
1822
- collection: slugs.tickets,
2051
+ const ticketsToRemind = await dbFind(payload, slugs.tickets, {
1823
2052
  where: {
1824
2053
  and: [
1825
2054
  { status: { equals: "waiting_client" } },
@@ -1850,8 +2079,7 @@ function createAutoCloseEndpoint(slugs) {
1850
2079
  <p><a href="${portalUrl}">R\xE9pondre au ticket</a></p>
1851
2080
  </div>`
1852
2081
  });
1853
- await payload.update({
1854
- collection: slugs.tickets,
2082
+ await dbUpdate(payload, slugs.tickets, {
1855
2083
  id: t.id,
1856
2084
  data: { autoCloseRemindedAt: now.toISOString() },
1857
2085
  overrideAccess: true
@@ -1863,8 +2091,7 @@ function createAutoCloseEndpoint(slugs) {
1863
2091
  }
1864
2092
  }
1865
2093
  const closeCutoff = new Date(now.getTime() - CLOSE_AFTER_REMIND_DAYS * 24 * 60 * 60 * 1e3);
1866
- const ticketsToClose = await payload.find({
1867
- collection: slugs.tickets,
2094
+ const ticketsToClose = await dbFind(payload, slugs.tickets, {
1868
2095
  where: {
1869
2096
  and: [
1870
2097
  { status: { equals: "waiting_client" } },
@@ -1899,8 +2126,7 @@ function createAutoCloseEndpoint(slugs) {
1899
2126
  const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS;
1900
2127
  const viaSchedule = !!t.autoCloseScheduledAt && new Date(t.autoCloseScheduledAt).getTime() <= now.getTime();
1901
2128
  const noteBody = viaSchedule ? "Ticket r\xE9solu automatiquement \u2014 relance manuelle rest\xE9e sans r\xE9ponse du client" : `Ticket r\xE9solu automatiquement \u2014 sans r\xE9ponse client depuis ${closeTotalDays} jours`;
1902
- await payload.create({
1903
- collection: slugs.ticketMessages,
2129
+ await dbCreate(payload, slugs.ticketMessages, {
1904
2130
  data: {
1905
2131
  ticket: t.id,
1906
2132
  body: noteBody,
@@ -1910,8 +2136,7 @@ function createAutoCloseEndpoint(slugs) {
1910
2136
  },
1911
2137
  overrideAccess: true
1912
2138
  });
1913
- await payload.update({
1914
- collection: slugs.tickets,
2139
+ await dbUpdate(payload, slugs.tickets, {
1915
2140
  id: t.id,
1916
2141
  // Clear the armed deadline so a later manual reopen can't be
1917
2142
  // closed instantly by a stale timestamp.
@@ -2034,8 +2259,7 @@ function createSendReminderEndpoint(slugs) {
2034
2259
  }
2035
2260
  const rawHours = Number(body.hours);
2036
2261
  const hours = Number.isFinite(rawHours) ? Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.round(rawHours))) : 24;
2037
- const ticket = await payload.findByID({
2038
- collection: slugs.tickets,
2262
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2039
2263
  id: ticketId,
2040
2264
  depth: 1,
2041
2265
  overrideAccess: true
@@ -2057,8 +2281,7 @@ function createSendReminderEndpoint(slugs) {
2057
2281
  const replyTo = settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || "";
2058
2282
  let waitingSince = ticket.firstResponseAt || ticket.createdAt;
2059
2283
  try {
2060
- const lastAdminMsg = await payload.find({
2061
- collection: slugs.ticketMessages,
2284
+ const lastAdminMsg = await dbFind(payload, slugs.ticketMessages, {
2062
2285
  where: {
2063
2286
  and: [
2064
2287
  { ticket: { equals: ticketId } },
@@ -2099,8 +2322,7 @@ function createSendReminderEndpoint(slugs) {
2099
2322
  preheader: `Sans r\xE9ponse de votre part, votre ticket ${ticketNumber} sera cl\xF4tur\xE9 le ${deadlineLabel}.`
2100
2323
  })
2101
2324
  });
2102
- await payload.create({
2103
- collection: slugs.ticketMessages,
2325
+ await dbCreate(payload, slugs.ticketMessages, {
2104
2326
  data: {
2105
2327
  ticket: ticketId,
2106
2328
  body: `Relance envoy\xE9e \xE0 ${client.email}. Fermeture automatique programm\xE9e le ${deadlineLabel} sans r\xE9ponse du client.`,
@@ -2110,8 +2332,7 @@ function createSendReminderEndpoint(slugs) {
2110
2332
  },
2111
2333
  overrideAccess: true
2112
2334
  });
2113
- await payload.update({
2114
- collection: slugs.tickets,
2335
+ await dbUpdate(payload, slugs.tickets, {
2115
2336
  id: ticketId,
2116
2337
  data: {
2117
2338
  status: "waiting_client",
@@ -2146,8 +2367,7 @@ function createStatusesEndpoint(slugs) {
2146
2367
  if (!req.user) {
2147
2368
  return Response.json({ error: "Unauthorized" }, { status: 401 });
2148
2369
  }
2149
- const { docs } = await payload.find({
2150
- collection: slugs.ticketStatuses,
2370
+ const { docs } = await dbFind(payload, slugs.ticketStatuses, {
2151
2371
  sort: "sortOrder",
2152
2372
  limit: 100,
2153
2373
  depth: 0,
@@ -2186,16 +2406,14 @@ function createApplyMacroEndpoint(slugs) {
2186
2406
  if (!macroId || !ticketId) {
2187
2407
  return Response.json({ error: "macroId and ticketId are required" }, { status: 400 });
2188
2408
  }
2189
- const macro = await payload.findByID({
2190
- collection: slugs.macros,
2409
+ const macro = await dbFindByID(payload, slugs.macros, {
2191
2410
  id: macroId,
2192
2411
  depth: 0,
2193
2412
  overrideAccess: true
2194
2413
  });
2195
2414
  if (!macro) return Response.json({ error: "Macro not found" }, { status: 404 });
2196
2415
  if (!macro.isActive) return Response.json({ error: "Macro is disabled" }, { status: 400 });
2197
- const ticket = await payload.findByID({
2198
- collection: slugs.tickets,
2416
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2199
2417
  id: ticketId,
2200
2418
  depth: 0,
2201
2419
  overrideAccess: true
@@ -2207,8 +2425,7 @@ function createApplyMacroEndpoint(slugs) {
2207
2425
  try {
2208
2426
  switch (action.type) {
2209
2427
  case "set_status":
2210
- await payload.update({
2211
- collection: slugs.tickets,
2428
+ await dbUpdate(payload, slugs.tickets, {
2212
2429
  id: ticketId,
2213
2430
  data: { status: action.value },
2214
2431
  overrideAccess: true
@@ -2216,8 +2433,7 @@ function createApplyMacroEndpoint(slugs) {
2216
2433
  appliedActions.push({ type: action.type, value: action.value, success: true });
2217
2434
  break;
2218
2435
  case "set_priority":
2219
- await payload.update({
2220
- collection: slugs.tickets,
2436
+ await dbUpdate(payload, slugs.tickets, {
2221
2437
  id: ticketId,
2222
2438
  data: { priority: action.value },
2223
2439
  overrideAccess: true
@@ -2229,8 +2445,7 @@ function createApplyMacroEndpoint(slugs) {
2229
2445
  if (!currentTags.includes(action.value)) {
2230
2446
  currentTags.push(action.value);
2231
2447
  }
2232
- await payload.update({
2233
- collection: slugs.tickets,
2448
+ await dbUpdate(payload, slugs.tickets, {
2234
2449
  id: ticketId,
2235
2450
  data: { tags: currentTags },
2236
2451
  overrideAccess: true
@@ -2239,8 +2454,7 @@ function createApplyMacroEndpoint(slugs) {
2239
2454
  break;
2240
2455
  }
2241
2456
  case "send_reply":
2242
- await payload.create({
2243
- collection: slugs.ticketMessages,
2457
+ await dbCreate(payload, slugs.ticketMessages, {
2244
2458
  data: {
2245
2459
  ticket: ticketId,
2246
2460
  body: action.value,
@@ -2257,8 +2471,7 @@ function createApplyMacroEndpoint(slugs) {
2257
2471
  appliedActions.push({ type: action.type, value: action.value, success: false, error: "Invalid user ID" });
2258
2472
  break;
2259
2473
  }
2260
- await payload.update({
2261
- collection: slugs.tickets,
2474
+ await dbUpdate(payload, slugs.tickets, {
2262
2475
  id: ticketId,
2263
2476
  data: { assignedTo: userId },
2264
2477
  overrideAccess: true
@@ -2310,8 +2523,7 @@ function createPurgeLogsEndpoint(slugs) {
2310
2523
  return Response.json({ error: "Invalid collection. Use email-logs or auth-logs." }, { status: 400 });
2311
2524
  }
2312
2525
  const cutoff = days > 0 ? new Date(Date.now() - days * 864e5).toISOString() : null;
2313
- const result = await payload.delete({
2314
- collection: allowedCollections[collection],
2526
+ const result = await dbDelete(payload, allowedCollections[collection], {
2315
2527
  where: cutoff ? { createdAt: { less_than: cutoff } } : { id: { exists: true } },
2316
2528
  overrideAccess: true
2317
2529
  });
@@ -2354,8 +2566,7 @@ function createChatbotEndpoint(slugs) {
2354
2566
  return Response.json({ error: "Question too short" }, { status: 400 });
2355
2567
  }
2356
2568
  const payload = req.payload;
2357
- const articles = await payload.find({
2358
- collection: slugs.knowledgeBase,
2569
+ const articles = await dbFind(payload, slugs.knowledgeBase, {
2359
2570
  where: { published: { equals: true } },
2360
2571
  limit: 100,
2361
2572
  depth: 0,
@@ -2445,8 +2656,7 @@ function createChatGetEndpoint(slugs) {
2445
2656
  if (after) {
2446
2657
  where.createdAt = { greater_than: after };
2447
2658
  }
2448
- const messages = await payload.find({
2449
- collection: slugs.chatMessages,
2659
+ const messages = await dbFind(payload, slugs.chatMessages, {
2450
2660
  where,
2451
2661
  sort: "createdAt",
2452
2662
  limit: 100,
@@ -2489,8 +2699,7 @@ function createChatPostEndpoint(slugs) {
2489
2699
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
2490
2700
  }
2491
2701
  const sessionId = `chat_${crypto3.randomBytes(16).toString("hex")}`;
2492
- const systemMsg = await payload.create({
2493
- collection: slugs.chatMessages,
2702
+ const systemMsg = await dbCreate(payload, slugs.chatMessages, {
2494
2703
  data: {
2495
2704
  session: sessionId,
2496
2705
  client: req.user.id,
@@ -2510,8 +2719,7 @@ function createChatPostEndpoint(slugs) {
2510
2719
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2511
2720
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2512
2721
  }
2513
- const existing = await payload.find({
2514
- collection: slugs.chatMessages,
2722
+ const existing = await dbFind(payload, slugs.chatMessages, {
2515
2723
  where: { session: { equals: session }, client: { equals: req.user.id } },
2516
2724
  limit: 1,
2517
2725
  overrideAccess: true
@@ -2519,8 +2727,7 @@ function createChatPostEndpoint(slugs) {
2519
2727
  if (existing.docs.length === 0) {
2520
2728
  return Response.json({ error: "Session invalide" }, { status: 403 });
2521
2729
  }
2522
- const newMsg = await payload.create({
2523
- collection: slugs.chatMessages,
2730
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2524
2731
  data: {
2525
2732
  session,
2526
2733
  client: req.user.id,
@@ -2533,8 +2740,7 @@ function createChatPostEndpoint(slugs) {
2533
2740
  return Response.json({ message: newMsg });
2534
2741
  }
2535
2742
  if (action === "close" && session) {
2536
- const existing = await payload.find({
2537
- collection: slugs.chatMessages,
2743
+ const existing = await dbFind(payload, slugs.chatMessages, {
2538
2744
  where: { session: { equals: session }, client: { equals: req.user.id } },
2539
2745
  limit: 1,
2540
2746
  overrideAccess: true
@@ -2542,8 +2748,7 @@ function createChatPostEndpoint(slugs) {
2542
2748
  if (existing.docs.length === 0) {
2543
2749
  return Response.json({ error: "Session invalide" }, { status: 403 });
2544
2750
  }
2545
- await payload.create({
2546
- collection: slugs.chatMessages,
2751
+ await dbCreate(payload, slugs.chatMessages, {
2547
2752
  data: {
2548
2753
  session,
2549
2754
  client: req.user.id,
@@ -2585,8 +2790,7 @@ function createChatStreamEndpoint(slugs) {
2585
2790
  if (!sessionId) {
2586
2791
  return Response.json({ error: "Missing session parameter" }, { status: 400 });
2587
2792
  }
2588
- const existing = await req.payload.find({
2589
- collection: slugs.chatMessages,
2793
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
2590
2794
  where: {
2591
2795
  session: { equals: sessionId },
2592
2796
  client: { equals: userId }
@@ -2608,8 +2812,7 @@ function createChatStreamEndpoint(slugs) {
2608
2812
  );
2609
2813
  const interval = setInterval(async () => {
2610
2814
  try {
2611
- const messages = await req.payload.find({
2612
- collection: slugs.chatMessages,
2815
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2613
2816
  where: {
2614
2817
  session: { equals: sessionId },
2615
2818
  createdAt: { greater_than: lastCheck }
@@ -2690,8 +2893,7 @@ function createAdminChatGetEndpoint(slugs) {
2690
2893
  if (session) {
2691
2894
  const where = { session: { equals: session } };
2692
2895
  if (after) where.createdAt = { greater_than: after };
2693
- const messages = await payload.find({
2694
- collection: slugs.chatMessages,
2896
+ const messages = await dbFind(payload, slugs.chatMessages, {
2695
2897
  where,
2696
2898
  sort: "createdAt",
2697
2899
  limit: 200,
@@ -2705,8 +2907,7 @@ function createAdminChatGetEndpoint(slugs) {
2705
2907
  }
2706
2908
  });
2707
2909
  }
2708
- const recentMessages = await payload.find({
2709
- collection: slugs.chatMessages,
2910
+ const recentMessages = await dbFind(payload, slugs.chatMessages, {
2710
2911
  sort: "-createdAt",
2711
2912
  limit: 50,
2712
2913
  depth: 1,
@@ -2769,8 +2970,7 @@ function createAdminChatPostEndpoint(slugs) {
2769
2970
  if (!session) {
2770
2971
  return Response.json({ error: "Session requise" }, { status: 400 });
2771
2972
  }
2772
- const sessionMsg = await payload.find({
2773
- collection: slugs.chatMessages,
2973
+ const sessionMsg = await dbFind(payload, slugs.chatMessages, {
2774
2974
  where: { session: { equals: session } },
2775
2975
  limit: 1,
2776
2976
  depth: 0,
@@ -2788,8 +2988,7 @@ function createAdminChatPostEndpoint(slugs) {
2788
2988
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2789
2989
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2790
2990
  }
2791
- const newMsg = await payload.create({
2792
- collection: slugs.chatMessages,
2991
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2793
2992
  data: {
2794
2993
  session,
2795
2994
  client: clientId,
@@ -2801,8 +3000,7 @@ function createAdminChatPostEndpoint(slugs) {
2801
3000
  overrideAccess: true
2802
3001
  });
2803
3002
  try {
2804
- const linkedTicket = await payload.find({
2805
- collection: slugs.tickets,
3003
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2806
3004
  where: { chatSession: { equals: session } },
2807
3005
  limit: 1,
2808
3006
  depth: 0,
@@ -2810,8 +3008,7 @@ function createAdminChatPostEndpoint(slugs) {
2810
3008
  });
2811
3009
  if (linkedTicket.docs.length > 0) {
2812
3010
  const ticketId = linkedTicket.docs[0].id;
2813
- await payload.create({
2814
- collection: slugs.ticketMessages,
3011
+ await dbCreate(payload, slugs.ticketMessages, {
2815
3012
  data: {
2816
3013
  ticket: ticketId,
2817
3014
  body: trimmedMessage,
@@ -2820,8 +3017,7 @@ function createAdminChatPostEndpoint(slugs) {
2820
3017
  },
2821
3018
  overrideAccess: true
2822
3019
  });
2823
- await payload.update({
2824
- collection: slugs.chatMessages,
3020
+ await dbUpdate(payload, slugs.chatMessages, {
2825
3021
  id: newMsg.id,
2826
3022
  data: { ticket: ticketId },
2827
3023
  overrideAccess: true
@@ -2833,8 +3029,7 @@ function createAdminChatPostEndpoint(slugs) {
2833
3029
  return Response.json({ message: newMsg });
2834
3030
  }
2835
3031
  if (action === "close") {
2836
- await payload.create({
2837
- collection: slugs.chatMessages,
3032
+ await dbCreate(payload, slugs.chatMessages, {
2838
3033
  data: {
2839
3034
  session,
2840
3035
  client: clientId,
@@ -2845,16 +3040,14 @@ function createAdminChatPostEndpoint(slugs) {
2845
3040
  overrideAccess: true
2846
3041
  });
2847
3042
  try {
2848
- const linkedTicket = await payload.find({
2849
- collection: slugs.tickets,
3043
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2850
3044
  where: { chatSession: { equals: session } },
2851
3045
  limit: 1,
2852
3046
  depth: 0,
2853
3047
  overrideAccess: true
2854
3048
  });
2855
3049
  if (linkedTicket.docs.length > 0) {
2856
- await payload.create({
2857
- collection: slugs.ticketMessages,
3050
+ await dbCreate(payload, slugs.ticketMessages, {
2858
3051
  data: {
2859
3052
  ticket: linkedTicket.docs[0].id,
2860
3053
  body: "Session de chat termin\xE9e par un agent.",
@@ -2917,8 +3110,7 @@ function createSessionStream(req, slugs, sessionId) {
2917
3110
  );
2918
3111
  const interval = setInterval(async () => {
2919
3112
  try {
2920
- const messages = await req.payload.find({
2921
- collection: slugs.chatMessages,
3113
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2922
3114
  where: {
2923
3115
  session: { equals: sessionId },
2924
3116
  createdAt: { greater_than: lastCheck }
@@ -2991,8 +3183,7 @@ function createSessionListStream(req, slugs) {
2991
3183
  );
2992
3184
  const interval = setInterval(async () => {
2993
3185
  try {
2994
- const recentMessages = await req.payload.find({
2995
- collection: slugs.chatMessages,
3186
+ const recentMessages = await dbFind(req.payload, slugs.chatMessages, {
2996
3187
  sort: "-createdAt",
2997
3188
  limit: 50,
2998
3189
  depth: 1,
@@ -3079,8 +3270,7 @@ function createAdminStatsEndpoint(slugs) {
3079
3270
  const statuses = ["open", "waiting_client", "resolved"];
3080
3271
  const statusCounts = await Promise.all(
3081
3272
  statuses.map(async (status) => {
3082
- const result = await payload.count({
3083
- collection: slugs.tickets,
3273
+ const result = await dbCount(payload, slugs.tickets, {
3084
3274
  where: { status: { equals: status } },
3085
3275
  overrideAccess: true
3086
3276
  });
@@ -3092,8 +3282,7 @@ function createAdminStatsEndpoint(slugs) {
3092
3282
  const priorities = ["low", "normal", "high", "urgent"];
3093
3283
  const priorityCounts = await Promise.all(
3094
3284
  priorities.map(async (priority) => {
3095
- const result = await payload.count({
3096
- collection: slugs.tickets,
3285
+ const result = await dbCount(payload, slugs.tickets, {
3097
3286
  where: { priority: { equals: priority } },
3098
3287
  overrideAccess: true
3099
3288
  });
@@ -3104,8 +3293,7 @@ function createAdminStatsEndpoint(slugs) {
3104
3293
  const categories = ["bug", "content", "feature", "question", "hosting"];
3105
3294
  const categoryCounts = await Promise.all(
3106
3295
  categories.map(async (category) => {
3107
- const result = await payload.count({
3108
- collection: slugs.tickets,
3296
+ const result = await dbCount(payload, slugs.tickets, {
3109
3297
  where: { category: { equals: category } },
3110
3298
  overrideAccess: true
3111
3299
  });
@@ -3119,17 +3307,34 @@ function createAdminStatsEndpoint(slugs) {
3119
3307
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
3120
3308
  const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
3121
3309
  const [created7, created30] = await Promise.all([
3122
- payload.count({
3123
- collection: slugs.tickets,
3310
+ dbCount(payload, slugs.tickets, {
3124
3311
  where: { createdAt: { greater_than_equal: sevenDaysAgo.toISOString() } },
3125
3312
  overrideAccess: true
3126
3313
  }),
3127
- payload.count({
3128
- collection: slugs.tickets,
3314
+ dbCount(payload, slugs.tickets, {
3129
3315
  where: { createdAt: { greater_than_equal: thirtyDaysAgo.toISOString() } },
3130
3316
  overrideAccess: true
3131
3317
  })
3132
3318
  ]);
3319
+ const volumeByDay = await Promise.all(
3320
+ Array.from({ length: 7 }, (_, i) => i).map(async (offset) => {
3321
+ const dayStart = new Date(now);
3322
+ dayStart.setHours(0, 0, 0, 0);
3323
+ dayStart.setDate(dayStart.getDate() - (6 - offset));
3324
+ const dayEnd = new Date(dayStart);
3325
+ dayEnd.setDate(dayEnd.getDate() + 1);
3326
+ const r = await dbCount(payload, slugs.tickets, {
3327
+ where: {
3328
+ and: [
3329
+ { createdAt: { greater_than_equal: dayStart.toISOString() } },
3330
+ { createdAt: { less_than: dayEnd.toISOString() } }
3331
+ ]
3332
+ },
3333
+ overrideAccess: true
3334
+ });
3335
+ return { date: dayStart.toISOString(), count: r.totalDocs };
3336
+ })
3337
+ );
3133
3338
  const PAGE_SIZE2 = 100;
3134
3339
  const MAX_PAGES2 = 50;
3135
3340
  let totalResponseTimeMs = 0;
@@ -3140,8 +3345,7 @@ function createAdminStatsEndpoint(slugs) {
3140
3345
  let page = 1;
3141
3346
  let hasMore = true;
3142
3347
  while (hasMore && page <= MAX_PAGES2) {
3143
- const batch = await payload.find({
3144
- collection: slugs.tickets,
3348
+ const batch = await dbFind(payload, slugs.tickets, {
3145
3349
  limit: PAGE_SIZE2,
3146
3350
  page,
3147
3351
  depth: 0,
@@ -3174,43 +3378,62 @@ function createAdminStatsEndpoint(slugs) {
3174
3378
  hasMore = batch.hasNextPage ?? false;
3175
3379
  page++;
3176
3380
  }
3177
- const surveyCount = await payload.count({
3178
- collection: slugs.satisfactionSurveys,
3381
+ const surveyCount = await dbCount(payload, slugs.satisfactionSurveys, {
3179
3382
  overrideAccess: true
3180
3383
  });
3181
3384
  let satisfactionAvg = 0;
3385
+ let satisfactionCount = 0;
3386
+ let npsScore = null;
3387
+ let npsCount = 0;
3182
3388
  if (surveyCount.totalDocs > 0) {
3183
- let totalRating = 0;
3389
+ let ratingSum = 0;
3390
+ let ratingN = 0;
3391
+ let promoters = 0;
3392
+ let detractors = 0;
3393
+ let npsN = 0;
3184
3394
  let surveyPage = 1;
3185
3395
  let surveyHasMore = true;
3186
3396
  while (surveyHasMore && surveyPage <= MAX_PAGES2) {
3187
- const batch = await payload.find({
3188
- collection: slugs.satisfactionSurveys,
3397
+ const batch = await dbFind(payload, slugs.satisfactionSurveys, {
3189
3398
  limit: PAGE_SIZE2,
3190
3399
  page: surveyPage,
3191
3400
  depth: 0,
3192
3401
  overrideAccess: true,
3193
- select: { rating: true }
3402
+ select: { rating: true, nps: true }
3194
3403
  });
3195
3404
  for (const s of batch.docs) {
3196
- totalRating += s.rating || 0;
3405
+ const r = s.rating;
3406
+ if (typeof r === "number" && r > 0) {
3407
+ ratingSum += r;
3408
+ ratingN++;
3409
+ }
3410
+ const n = s.nps;
3411
+ if (typeof n === "number") {
3412
+ npsN++;
3413
+ if (n >= 9) promoters++;
3414
+ else if (n <= 6) detractors++;
3415
+ }
3197
3416
  }
3198
3417
  surveyHasMore = batch.hasNextPage ?? false;
3199
3418
  surveyPage++;
3200
3419
  }
3201
- satisfactionAvg = Math.round(totalRating / surveyCount.totalDocs * 10) / 10;
3420
+ satisfactionAvg = ratingN > 0 ? Math.round(ratingSum / ratingN * 10) / 10 : 0;
3421
+ satisfactionCount = ratingN;
3422
+ if (npsN > 0) {
3423
+ npsScore = Math.round((promoters - detractors) / npsN * 100);
3424
+ npsCount = npsN;
3425
+ }
3202
3426
  }
3203
- const [clientCount, pendingEmailsCount] = await Promise.all([
3204
- payload.count({
3205
- collection: slugs.supportClients,
3206
- overrideAccess: true
3207
- }),
3208
- payload.count({
3209
- collection: slugs.pendingEmails,
3427
+ const clientCount = await dbCount(payload, slugs.supportClients, { overrideAccess: true });
3428
+ let pendingEmailsTotal = 0;
3429
+ try {
3430
+ const pe = await dbCount(payload, slugs.pendingEmails, {
3210
3431
  where: { status: { equals: "pending" } },
3211
3432
  overrideAccess: true
3212
- })
3213
- ]);
3433
+ });
3434
+ pendingEmailsTotal = pe.totalDocs;
3435
+ } catch {
3436
+ }
3214
3437
  return new Response(JSON.stringify({
3215
3438
  total,
3216
3439
  byStatus,
@@ -3218,13 +3441,16 @@ function createAdminStatsEndpoint(slugs) {
3218
3441
  byCategory,
3219
3442
  createdLast7Days: created7.totalDocs,
3220
3443
  createdLast30Days: created30.totalDocs,
3444
+ volumeByDay,
3221
3445
  avgResponseTimeHours: responseTimeCount > 0 ? Math.round(totalResponseTimeMs / responseTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3222
3446
  avgResolutionTimeHours: resolutionTimeCount > 0 ? Math.round(totalResolutionTimeMs / resolutionTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3223
3447
  totalTimeMinutes,
3224
3448
  satisfactionAvg,
3225
- satisfactionCount: surveyCount.totalDocs,
3449
+ satisfactionCount,
3450
+ npsScore,
3451
+ npsCount,
3226
3452
  clientCount: clientCount.totalDocs,
3227
- pendingEmailsCount: pendingEmailsCount.totalDocs
3453
+ pendingEmailsCount: pendingEmailsTotal
3228
3454
  }), {
3229
3455
  headers: {
3230
3456
  "Content-Type": "application/json",
@@ -3294,13 +3520,26 @@ function createBillingEndpoint(slugs) {
3294
3520
  let ticketPage = 1;
3295
3521
  let ticketHasMore = true;
3296
3522
  while (ticketHasMore && ticketPage <= MAX_PAGES) {
3297
- const batch = await payload.find({
3298
- collection: slugs.tickets,
3523
+ const batch = await dbFind(payload, slugs.tickets, {
3299
3524
  where: ticketWhere,
3300
3525
  limit: PAGE_SIZE,
3301
3526
  page: ticketPage,
3527
+ // depth 2 is required: we read ticket.project.client.company below.
3302
3528
  depth: 2,
3303
- overrideAccess: true
3529
+ overrideAccess: true,
3530
+ // Bound the payload to only the fields the report consumes (skips body,
3531
+ // richText, SLA fields, etc.) while keeping project/client populated.
3532
+ select: {
3533
+ ticketNumber: true,
3534
+ subject: true,
3535
+ status: true,
3536
+ billedAmount: true,
3537
+ aiSummary: true,
3538
+ aiSummaryGeneratedAt: true,
3539
+ aiSummaryStatus: true,
3540
+ project: true,
3541
+ client: true
3542
+ }
3304
3543
  });
3305
3544
  allTickets.push(...batch.docs);
3306
3545
  ticketHasMore = batch.hasNextPage ?? false;
@@ -3310,8 +3549,7 @@ function createBillingEndpoint(slugs) {
3310
3549
  let entryPage = 1;
3311
3550
  let entryHasMore = true;
3312
3551
  while (entryHasMore && entryPage <= MAX_PAGES) {
3313
- const batch = await payload.find({
3314
- collection: slugs.timeEntries,
3552
+ const batch = await dbFind(payload, slugs.timeEntries, {
3315
3553
  where: {
3316
3554
  and: [
3317
3555
  { date: { greater_than_equal: from } },
@@ -3409,43 +3647,205 @@ function createBillingEndpoint(slugs) {
3409
3647
  }
3410
3648
  };
3411
3649
  }
3412
-
3413
- // src/utils/generateTicketSynthesis.ts
3414
- function getClient3(aiSettings) {
3415
- const Anthropic = __require("@anthropic-ai/sdk").default;
3416
- if (aiSettings.provider === "ollama") {
3417
- const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
3418
- return new Anthropic({ apiKey: "ollama", baseURL });
3419
- }
3420
- return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
3421
- }
3422
- function getModel3(aiSettings) {
3423
- return aiSettings.model || "claude-haiku-4-5-20251001";
3424
- }
3425
- async function generateTicketSynthesis(args) {
3426
- const { payload, slugs, ticketId } = args;
3427
- const settings = await readSupportSettings(payload);
3428
- if (settings.ai.enableSynthesis === false) {
3429
- return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "skipped", reason: "synthesis disabled" };
3430
- }
3431
- const ticket = await payload.findByID({
3432
- collection: slugs.tickets,
3433
- id: ticketId,
3434
- depth: 1,
3435
- overrideAccess: true
3650
+ var fmtAmount = (n) => `${n.toFixed(2)} EUR`;
3651
+ var fmtTime = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3652
+ function generateInvoicePdf(data) {
3653
+ return new Promise((resolve, reject) => {
3654
+ try {
3655
+ const doc = new PDFDocument({ size: "A4", margin: 50 });
3656
+ const chunks = [];
3657
+ doc.on("data", (c) => chunks.push(c));
3658
+ doc.on("end", () => resolve(Buffer.concat(chunks)));
3659
+ doc.on("error", reject);
3660
+ doc.fillColor("#0f172a").fontSize(22).text("Invoice / Pre-billing");
3661
+ doc.moveDown(0.2);
3662
+ doc.fillColor("#64748b").fontSize(10).text(`Period: ${data.from} -> ${data.to}${data.projectId ? ` \xB7 Project #${data.projectId}` : ""}`);
3663
+ doc.moveDown(1);
3664
+ const X = { ticket: 50, subject: 130, client: 320, time: 430, amount: 500 };
3665
+ const drawRow = (r, opts) => {
3666
+ const y = doc.y;
3667
+ doc.fontSize(opts?.header ? 9 : 10).fillColor(opts?.header ? "#475569" : "#0f172a");
3668
+ if (opts?.total) doc.font("Helvetica-Bold");
3669
+ doc.text(r.ticket, X.ticket, y, { width: X.subject - X.ticket - 6 });
3670
+ doc.text(r.subject, X.subject, y, { width: X.client - X.subject - 6 });
3671
+ doc.text(r.client, X.client, y, { width: X.time - X.client - 6 });
3672
+ doc.text(r.time, X.time, y, { width: X.amount - X.time - 6, align: "right" });
3673
+ doc.text(r.amount, X.amount, y, { width: 545 - X.amount, align: "right" });
3674
+ doc.font("Helvetica");
3675
+ doc.moveDown(0.4);
3676
+ };
3677
+ drawRow({ ticket: "Ticket", subject: "Subject", client: "Client", time: "Time", amount: "Amount" }, { header: true });
3678
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#e2e8f0").stroke();
3679
+ doc.moveDown(0.3);
3680
+ for (const l of data.lines) {
3681
+ if (doc.y > 760) doc.addPage();
3682
+ drawRow({
3683
+ ticket: l.ticketNumber || "",
3684
+ subject: l.subject || "",
3685
+ client: l.client || "",
3686
+ time: fmtTime(l.minutes),
3687
+ amount: fmtAmount(l.amount)
3688
+ });
3689
+ }
3690
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#0f172a").stroke();
3691
+ doc.moveDown(0.3);
3692
+ drawRow({
3693
+ ticket: "",
3694
+ subject: `Total (${data.lines.length} ticket${data.lines.length > 1 ? "s" : ""})`,
3695
+ client: "",
3696
+ time: fmtTime(data.totalMinutes),
3697
+ amount: fmtAmount(data.totalAmount)
3698
+ }, { total: true });
3699
+ doc.end();
3700
+ } catch (err) {
3701
+ reject(err);
3702
+ }
3436
3703
  });
3437
- if (!ticket) {
3438
- return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: "ticket not found" };
3439
- }
3440
- await payload.update({
3441
- collection: slugs.tickets,
3442
- id: ticketId,
3443
- data: { aiSummaryStatus: "pending" },
3704
+ }
3705
+
3706
+ // src/endpoints/invoice.ts
3707
+ function createInvoiceEndpoint(slugs) {
3708
+ return {
3709
+ path: "/support/billing/invoice",
3710
+ method: "get",
3711
+ handler: async (req) => {
3712
+ try {
3713
+ requireAdmin(req, slugs);
3714
+ const payload = req.payload;
3715
+ const url = new URL(req.url);
3716
+ const from = url.searchParams.get("from");
3717
+ const to = url.searchParams.get("to");
3718
+ const projectId = url.searchParams.get("projectId");
3719
+ const format = url.searchParams.get("format") === "pdf" ? "pdf" : "html";
3720
+ if (!from || !to) {
3721
+ return Response.json({ error: "Param\xE8tres from et to requis." }, { status: 400 });
3722
+ }
3723
+ const toExclusive = new Date(to);
3724
+ toExclusive.setDate(toExclusive.getDate() + 1);
3725
+ const toIso = toExclusive.toISOString();
3726
+ const where = {
3727
+ and: [
3728
+ { billable: { equals: true } },
3729
+ ...projectId ? [{ project: { equals: Number(projectId) } }] : [],
3730
+ {
3731
+ or: [
3732
+ { and: [{ updatedAt: { greater_than_equal: from } }, { updatedAt: { less_than: toIso } }] },
3733
+ { and: [{ createdAt: { greater_than_equal: from } }, { createdAt: { less_than: toIso } }] },
3734
+ { and: [{ resolvedAt: { greater_than_equal: from } }, { resolvedAt: { less_than: toIso } }] }
3735
+ ]
3736
+ }
3737
+ ]
3738
+ };
3739
+ const tickets = [];
3740
+ let page = 1;
3741
+ let hasMore = true;
3742
+ while (hasMore && page <= 50) {
3743
+ const batch = await dbFind(payload, slugs.tickets, {
3744
+ where,
3745
+ limit: 500,
3746
+ page,
3747
+ depth: 2,
3748
+ overrideAccess: true,
3749
+ select: { ticketNumber: true, subject: true, billedAmount: true, totalTimeMinutes: true, project: true, client: true }
3750
+ });
3751
+ tickets.push(...batch.docs);
3752
+ hasMore = batch.hasNextPage ?? false;
3753
+ page++;
3754
+ }
3755
+ let totalAmount = 0;
3756
+ let totalMinutes = 0;
3757
+ const lines = tickets.map((t) => {
3758
+ const amount = typeof t.billedAmount === "number" ? t.billedAmount : 0;
3759
+ const minutes = typeof t.totalTimeMinutes === "number" ? t.totalTimeMinutes : 0;
3760
+ totalAmount += amount;
3761
+ totalMinutes += minutes;
3762
+ const client = typeof t.client === "object" && t.client ? t.client.company || "" : "";
3763
+ return { ticketNumber: String(t.ticketNumber || ""), subject: String(t.subject || ""), client, minutes, amount };
3764
+ });
3765
+ if (format === "pdf") {
3766
+ const pdf = await generateInvoicePdf({ from, to, projectId, lines, totalMinutes, totalAmount });
3767
+ return new Response(new Uint8Array(pdf), {
3768
+ status: 200,
3769
+ headers: {
3770
+ "Content-Type": "application/pdf",
3771
+ "Content-Disposition": `inline; filename="invoice-${from}_${to}.pdf"`
3772
+ }
3773
+ });
3774
+ }
3775
+ const fmtAmount2 = (n) => `${n.toFixed(2)} \u20AC`;
3776
+ const fmtTime2 = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3777
+ const rows = lines.map((l) => `<tr>
3778
+ <td>${escapeHtml(l.ticketNumber)}</td>
3779
+ <td>${escapeHtml(l.subject)}</td>
3780
+ <td>${escapeHtml(l.client)}</td>
3781
+ <td style="text-align:right;">${fmtTime2(l.minutes)}</td>
3782
+ <td style="text-align:right;">${fmtAmount2(l.amount)}</td>
3783
+ </tr>`).join("");
3784
+ const html = `<!doctype html>
3785
+ <html lang="fr"><head><meta charset="utf-8"><title>Facture ${escapeHtml(from)} \u2192 ${escapeHtml(to)}</title>
3786
+ <style>
3787
+ body { font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 820px; margin: 32px auto; padding: 0 24px; }
3788
+ h1 { font-size: 22px; margin: 0 0 4px; }
3789
+ .period { color: #64748b; margin-bottom: 24px; }
3790
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
3791
+ th, td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; text-align: left; }
3792
+ th { background: #f8fafc; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #475569; }
3793
+ tfoot td { font-weight: 700; border-top: 2px solid #0f172a; border-bottom: none; }
3794
+ @media print { body { margin: 0; } }
3795
+ </style></head>
3796
+ <body>
3797
+ <h1>Facture / Pr\xE9-facture</h1>
3798
+ <div class="period">P\xE9riode : ${escapeHtml(from)} \u2192 ${escapeHtml(to)}${projectId ? ` \xB7 Projet #${escapeHtml(projectId)}` : ""}</div>
3799
+ <table>
3800
+ <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>
3801
+ <tbody>${rows || '<tr><td colspan="5" style="color:#94a3b8;">Aucun ticket facturable sur la p\xE9riode.</td></tr>'}</tbody>
3802
+ <tfoot><tr><td colspan="3">Total (${lines.length} ticket${lines.length > 1 ? "s" : ""})</td><td style="text-align:right;">${fmtTime2(totalMinutes)}</td><td style="text-align:right;">${fmtAmount2(totalAmount)}</td></tr></tfoot>
3803
+ </table>
3804
+ </body></html>`;
3805
+ return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
3806
+ } catch (error) {
3807
+ const authResponse = handleAuthError(error);
3808
+ if (authResponse) return authResponse;
3809
+ console.error("[invoice] Error:", error);
3810
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
3811
+ }
3812
+ }
3813
+ };
3814
+ }
3815
+
3816
+ // src/utils/generateTicketSynthesis.ts
3817
+ function getClient4(aiSettings) {
3818
+ const Anthropic = __require("@anthropic-ai/sdk").default;
3819
+ if (aiSettings.provider === "ollama") {
3820
+ const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
3821
+ return new Anthropic({ apiKey: "ollama", baseURL });
3822
+ }
3823
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
3824
+ }
3825
+ function getModel3(aiSettings) {
3826
+ return aiSettings.model || "claude-haiku-4-5-20251001";
3827
+ }
3828
+ async function generateTicketSynthesis(args) {
3829
+ const { payload, slugs, ticketId } = args;
3830
+ const settings = await readSupportSettings(payload);
3831
+ if (settings.ai.enableSynthesis === false) {
3832
+ return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "skipped", reason: "synthesis disabled" };
3833
+ }
3834
+ const ticket = await dbFindByID(payload, slugs.tickets, {
3835
+ id: ticketId,
3836
+ depth: 1,
3837
+ overrideAccess: true
3838
+ });
3839
+ if (!ticket) {
3840
+ return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: "ticket not found" };
3841
+ }
3842
+ await dbUpdate(payload, slugs.tickets, {
3843
+ id: ticketId,
3844
+ data: { aiSummaryStatus: "pending" },
3444
3845
  overrideAccess: true
3445
3846
  }).catch(() => {
3446
3847
  });
3447
- const messagesResult = await payload.find({
3448
- collection: slugs.ticketMessages,
3848
+ const messagesResult = await dbFind(payload, slugs.ticketMessages, {
3449
3849
  where: { ticket: { equals: ticketId } },
3450
3850
  sort: "createdAt",
3451
3851
  limit: 500,
@@ -3455,8 +3855,7 @@ async function generateTicketSynthesis(args) {
3455
3855
  const messages = messagesResult.docs;
3456
3856
  if (messages.length === 0) {
3457
3857
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3458
- await payload.update({
3459
- collection: slugs.tickets,
3858
+ await dbUpdate(payload, slugs.tickets, {
3460
3859
  id: ticketId,
3461
3860
  data: {
3462
3861
  aiSummary: "(Aucun message dans ce ticket)",
@@ -3501,7 +3900,7 @@ Regles strictes :
3501
3900
  - Si le ticket n'a pas abouti, decris quand meme le travail d'analyse realise
3502
3901
 
3503
3902
  Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3504
- const anthropic = getClient3(settings.ai);
3903
+ const anthropic = getClient4(settings.ai);
3505
3904
  const model = getModel3(settings.ai);
3506
3905
  try {
3507
3906
  const res = await anthropic.messages.create({
@@ -3511,8 +3910,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3511
3910
  });
3512
3911
  const summary = res.content[0]?.type === "text" ? res.content[0].text.trim() : "";
3513
3912
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3514
- await payload.update({
3515
- collection: slugs.tickets,
3913
+ await dbUpdate(payload, slugs.tickets, {
3516
3914
  id: ticketId,
3517
3915
  data: {
3518
3916
  aiSummary: summary,
@@ -3524,8 +3922,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3524
3922
  return { summary, generatedAt, status: "done" };
3525
3923
  } catch (err) {
3526
3924
  const message = err instanceof Error ? err.message : String(err);
3527
- await payload.update({
3528
- collection: slugs.tickets,
3925
+ await dbUpdate(payload, slugs.tickets, {
3529
3926
  id: ticketId,
3530
3927
  data: { aiSummaryStatus: "error" },
3531
3928
  overrideAccess: true
@@ -3555,8 +3952,7 @@ function createTicketSynthesisEndpoint(slugs) {
3555
3952
  return Response.json({ error: "ticketId must be a number" }, { status: 400 });
3556
3953
  }
3557
3954
  if (!force) {
3558
- const existing = await payload.findByID({
3559
- collection: slugs.tickets,
3955
+ const existing = await dbFindByID(payload, slugs.tickets, {
3560
3956
  id: ticketId,
3561
3957
  depth: 0,
3562
3958
  overrideAccess: true
@@ -3600,8 +3996,7 @@ function createEmailStatsEndpoint(slugs) {
3600
3996
  let page = 1;
3601
3997
  let hasMore = true;
3602
3998
  while (hasMore && page <= MAX_PAGES2) {
3603
- const result = await payload.find({
3604
- collection: slugs.emailLogs,
3999
+ const result = await dbFind(payload, slugs.emailLogs, {
3605
4000
  where: { createdAt: { greater_than: cutoff } },
3606
4001
  sort: "-createdAt",
3607
4002
  limit: 500,
@@ -3675,12 +4070,20 @@ function createSatisfactionEndpoint(slugs) {
3675
4070
  const payload = req.payload;
3676
4071
  requireClient(req, slugs);
3677
4072
  const body = await req.json();
3678
- const { ticketId, rating, comment } = body;
3679
- if (!ticketId || !rating || !Number.isInteger(rating) || rating < 1 || rating > 5) {
3680
- return Response.json(
3681
- { error: "ticketId et rating (entier 1-5) sont requis." },
3682
- { status: 400 }
3683
- );
4073
+ const { ticketId, rating, nps, comment } = body;
4074
+ if (!ticketId) {
4075
+ return Response.json({ error: "ticketId est requis." }, { status: 400 });
4076
+ }
4077
+ const hasRating = rating !== void 0 && rating !== null;
4078
+ const hasNps = nps !== void 0 && nps !== null;
4079
+ if (!hasRating && !hasNps) {
4080
+ return Response.json({ error: "Un rating (1-5) et/ou un nps (0-10) est requis." }, { status: 400 });
4081
+ }
4082
+ if (hasRating && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
4083
+ return Response.json({ error: "rating doit \xEAtre un entier 1-5." }, { status: 400 });
4084
+ }
4085
+ if (hasNps && (!Number.isInteger(nps) || nps < 0 || nps > 10)) {
4086
+ return Response.json({ error: "nps doit \xEAtre un entier 0-10." }, { status: 400 });
3684
4087
  }
3685
4088
  if (comment && typeof comment === "string" && comment.length > 5e3) {
3686
4089
  return Response.json(
@@ -3688,8 +4091,7 @@ function createSatisfactionEndpoint(slugs) {
3688
4091
  { status: 400 }
3689
4092
  );
3690
4093
  }
3691
- const ticket = await payload.findByID({
3692
- collection: slugs.tickets,
4094
+ const ticket = await dbFindByID(payload, slugs.tickets, {
3693
4095
  id: ticketId,
3694
4096
  depth: 0,
3695
4097
  overrideAccess: false,
@@ -3704,8 +4106,7 @@ function createSatisfactionEndpoint(slugs) {
3704
4106
  { status: 400 }
3705
4107
  );
3706
4108
  }
3707
- const existing = await payload.find({
3708
- collection: slugs.satisfactionSurveys,
4109
+ const existing = await dbFind(payload, slugs.satisfactionSurveys, {
3709
4110
  where: { ticket: { equals: ticketId } },
3710
4111
  limit: 1,
3711
4112
  depth: 0,
@@ -3717,13 +4118,13 @@ function createSatisfactionEndpoint(slugs) {
3717
4118
  { status: 409 }
3718
4119
  );
3719
4120
  }
3720
- const survey = await payload.create({
3721
- collection: slugs.satisfactionSurveys,
4121
+ const survey = await dbCreate(payload, slugs.satisfactionSurveys, {
3722
4122
  data: {
3723
4123
  source: "ticket",
3724
4124
  ticket: ticketId,
3725
4125
  client: req.user.id,
3726
- rating: Math.round(rating),
4126
+ ...hasRating ? { rating: Math.round(rating) } : {},
4127
+ ...hasNps ? { nps: Math.round(nps) } : {},
3727
4128
  ...comment ? { comment: comment.trim() } : {}
3728
4129
  },
3729
4130
  overrideAccess: true
@@ -3738,124 +4139,6 @@ function createSatisfactionEndpoint(slugs) {
3738
4139
  }
3739
4140
  };
3740
4141
  }
3741
- var TRANSPARENT_GIF = Buffer.from(
3742
- "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
3743
- "base64"
3744
- );
3745
- function generateTrackingToken(ticketId, messageId, secret) {
3746
- return createHmac("sha256", secret).update(`${ticketId}:${messageId}`).digest("hex").substring(0, 16);
3747
- }
3748
- function createTrackOpenEndpoint(slugs) {
3749
- return {
3750
- path: "/support/track-open",
3751
- method: "get",
3752
- handler: async (req) => {
3753
- const url = new URL(req.url);
3754
- const ticketId = url.searchParams.get("t");
3755
- const messageId = url.searchParams.get("m");
3756
- const sig = url.searchParams.get("sig");
3757
- const parsedId = ticketId ? Number(ticketId) : NaN;
3758
- const parsedMsgId = messageId ? Number(messageId) : NaN;
3759
- const secret = process.env.PAYLOAD_SECRET || "";
3760
- if (secret && ticketId && messageId && sig) {
3761
- const expected = generateTrackingToken(ticketId, messageId, secret);
3762
- if (sig !== expected) {
3763
- return new Response(TRANSPARENT_GIF, {
3764
- status: 200,
3765
- headers: {
3766
- "Content-Type": "image/gif",
3767
- "Content-Length": String(TRANSPARENT_GIF.length),
3768
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
3769
- }
3770
- });
3771
- }
3772
- } else if (secret && (!sig || !ticketId || !messageId)) {
3773
- return new Response(TRANSPARENT_GIF, {
3774
- status: 200,
3775
- headers: {
3776
- "Content-Type": "image/gif",
3777
- "Content-Length": String(TRANSPARENT_GIF.length),
3778
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
3779
- }
3780
- });
3781
- }
3782
- if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
3783
- try {
3784
- const payload = req.payload;
3785
- const ticket = await payload.findByID({
3786
- collection: slugs.tickets,
3787
- id: parsedId,
3788
- depth: 0,
3789
- overrideAccess: true,
3790
- select: { lastClientReadAt: true }
3791
- });
3792
- if (ticket) {
3793
- const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0;
3794
- const fiveMinAgo = Date.now() - 5 * 60 * 1e3;
3795
- if (lastRead < fiveMinAgo) {
3796
- await payload.update({
3797
- collection: slugs.tickets,
3798
- id: parsedId,
3799
- data: { lastClientReadAt: (/* @__PURE__ */ new Date()).toISOString() },
3800
- overrideAccess: true
3801
- });
3802
- }
3803
- }
3804
- if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
3805
- const msg = await payload.findByID({
3806
- collection: slugs.ticketMessages,
3807
- id: parsedMsgId,
3808
- depth: 0,
3809
- overrideAccess: true,
3810
- select: { emailOpenedAt: true }
3811
- });
3812
- if (msg && !msg.emailOpenedAt) {
3813
- await payload.update({
3814
- collection: slugs.ticketMessages,
3815
- id: parsedMsgId,
3816
- data: { emailOpenedAt: (/* @__PURE__ */ new Date()).toISOString() },
3817
- overrideAccess: true
3818
- });
3819
- const ticketInfo = await payload.findByID({
3820
- collection: slugs.tickets,
3821
- id: parsedId,
3822
- depth: 1,
3823
- overrideAccess: true,
3824
- select: { ticketNumber: true, subject: true, client: true }
3825
- });
3826
- const clientName = typeof ticketInfo?.client === "object" ? ticketInfo.client?.firstName || "Client" : "Client";
3827
- try {
3828
- await payload.create({
3829
- collection: "admin-notifications",
3830
- data: {
3831
- title: `Email ouvert \u2014 ${ticketInfo?.ticketNumber || "TK-????"}`,
3832
- message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || "ticket"}"`,
3833
- type: "email_opened",
3834
- link: `/admin/ticket?id=${parsedId}`
3835
- },
3836
- overrideAccess: true
3837
- });
3838
- } catch {
3839
- }
3840
- }
3841
- }
3842
- } catch (err) {
3843
- console.error("[track-open] Error:", err);
3844
- }
3845
- }
3846
- return new Response(TRANSPARENT_GIF, {
3847
- status: 200,
3848
- headers: {
3849
- "Content-Type": "image/gif",
3850
- "Content-Length": String(TRANSPARENT_GIF.length),
3851
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
3852
- "Pragma": "no-cache",
3853
- "Expires": "0"
3854
- }
3855
- });
3856
- }
3857
- };
3858
- }
3859
4142
 
3860
4143
  // src/endpoints/export-csv.ts
3861
4144
  function createExportCsvEndpoint(slugs) {
@@ -3871,8 +4154,7 @@ function createExportCsvEndpoint(slugs) {
3871
4154
  let hasMore = true;
3872
4155
  const allDocs = [];
3873
4156
  while (hasMore) {
3874
- const batch = await payload.find({
3875
- collection: slugs.tickets,
4157
+ const batch = await dbFind(payload, slugs.tickets, {
3876
4158
  limit: PAGE_SIZE2,
3877
4159
  page,
3878
4160
  depth: 1,
@@ -3948,21 +4230,18 @@ function createExportDataEndpoint(slugs) {
3948
4230
  const payload = req.payload;
3949
4231
  requireClient(req, slugs);
3950
4232
  const [clientData, ticketsResult, messagesResult, surveysResult] = await Promise.all([
3951
- payload.findByID({
3952
- collection: slugs.supportClients,
4233
+ dbFindByID(payload, slugs.supportClients, {
3953
4234
  id: req.user.id,
3954
4235
  depth: 0,
3955
4236
  overrideAccess: true
3956
4237
  }),
3957
- payload.find({
3958
- collection: slugs.tickets,
4238
+ dbFind(payload, slugs.tickets, {
3959
4239
  where: { client: { equals: req.user.id } },
3960
4240
  limit: 1e3,
3961
4241
  depth: 0,
3962
4242
  overrideAccess: true
3963
4243
  }),
3964
- payload.find({
3965
- collection: slugs.ticketMessages,
4244
+ dbFind(payload, slugs.ticketMessages, {
3966
4245
  where: {
3967
4246
  "ticket.client": { equals: req.user.id },
3968
4247
  authorType: { equals: "client" }
@@ -3971,8 +4250,7 @@ function createExportDataEndpoint(slugs) {
3971
4250
  depth: 0,
3972
4251
  overrideAccess: true
3973
4252
  }),
3974
- payload.find({
3975
- collection: slugs.satisfactionSurveys,
4253
+ dbFind(payload, slugs.satisfactionSurveys, {
3976
4254
  where: { client: { equals: req.user.id } },
3977
4255
  limit: 500,
3978
4256
  depth: 0,
@@ -4046,8 +4324,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4046
4324
  if (!["create_ticket", "add_to_ticket", "ignore"].includes(action)) {
4047
4325
  return Response.json({ error: "Invalid action" }, { status: 400 });
4048
4326
  }
4049
- const pendingEmail = await payload.findByID({
4050
- collection: slugs.pendingEmails,
4327
+ const pendingEmail = await dbFindByID(payload, slugs.pendingEmails, {
4051
4328
  id: Number(id),
4052
4329
  depth: 1,
4053
4330
  overrideAccess: true
@@ -4061,14 +4338,12 @@ function createPendingEmailsProcessEndpoint(slugs) {
4061
4338
  let clientId = overrideClientId || (typeof pendingEmail.client === "object" ? pendingEmail.client?.id : pendingEmail.client);
4062
4339
  let clientDoc = typeof pendingEmail.client === "object" ? pendingEmail.client : null;
4063
4340
  if (overrideClientId && overrideClientId !== clientDoc?.id) {
4064
- clientDoc = await payload.findByID({
4065
- collection: slugs.supportClients,
4341
+ clientDoc = await dbFindByID(payload, slugs.supportClients, {
4066
4342
  id: overrideClientId,
4067
4343
  depth: 0,
4068
4344
  overrideAccess: true
4069
4345
  });
4070
- await payload.update({
4071
- collection: slugs.pendingEmails,
4346
+ await dbUpdate(payload, slugs.pendingEmails, {
4072
4347
  id: Number(id),
4073
4348
  data: { client: overrideClientId },
4074
4349
  overrideAccess: true
@@ -4085,8 +4360,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4085
4360
  file: typeof a.file === "object" ? a.file.id : a.file
4086
4361
  })) || [];
4087
4362
  if (action === "ignore") {
4088
- await payload.update({
4089
- collection: slugs.pendingEmails,
4363
+ await dbUpdate(payload, slugs.pendingEmails, {
4090
4364
  id: Number(id),
4091
4365
  data: {
4092
4366
  status: "ignored",
@@ -4098,8 +4372,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4098
4372
  return Response.json({ action: "ignored", pendingEmailId: Number(id) });
4099
4373
  }
4100
4374
  if (action === "create_ticket") {
4101
- const newTicket = await payload.create({
4102
- collection: slugs.tickets,
4375
+ const newTicket = await dbCreate(payload, slugs.tickets, {
4103
4376
  data: {
4104
4377
  subject: pendingEmail.subject,
4105
4378
  client: clientId,
@@ -4110,8 +4383,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4110
4383
  },
4111
4384
  overrideAccess: true
4112
4385
  });
4113
- await payload.create({
4114
- collection: slugs.ticketMessages,
4386
+ await dbCreate(payload, slugs.ticketMessages, {
4115
4387
  data: {
4116
4388
  ticket: newTicket.id,
4117
4389
  body: pendingEmail.body,
@@ -4138,8 +4410,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4138
4410
  } catch (err) {
4139
4411
  console.error("[pending-email-process] Failed to send notification:", err);
4140
4412
  }
4141
- await payload.update({
4142
- collection: slugs.pendingEmails,
4413
+ await dbUpdate(payload, slugs.pendingEmails, {
4143
4414
  id: Number(id),
4144
4415
  data: {
4145
4416
  status: "processed",
@@ -4159,8 +4430,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4159
4430
  if (!ticketId) {
4160
4431
  return Response.json({ error: "ticketId is required for add_to_ticket" }, { status: 400 });
4161
4432
  }
4162
- const targetTicket = await payload.findByID({
4163
- collection: slugs.tickets,
4433
+ const targetTicket = await dbFindByID(payload, slugs.tickets, {
4164
4434
  id: ticketId,
4165
4435
  depth: 0,
4166
4436
  overrideAccess: true
@@ -4168,8 +4438,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4168
4438
  if (!targetTicket) {
4169
4439
  return Response.json({ error: "Target ticket not found" }, { status: 404 });
4170
4440
  }
4171
- await payload.create({
4172
- collection: slugs.ticketMessages,
4441
+ await dbCreate(payload, slugs.ticketMessages, {
4173
4442
  data: {
4174
4443
  ticket: ticketId,
4175
4444
  body: pendingEmail.body,
@@ -4181,15 +4450,13 @@ function createPendingEmailsProcessEndpoint(slugs) {
4181
4450
  overrideAccess: true
4182
4451
  });
4183
4452
  if (targetTicket.status === "resolved") {
4184
- await payload.update({
4185
- collection: slugs.tickets,
4453
+ await dbUpdate(payload, slugs.tickets, {
4186
4454
  id: ticketId,
4187
4455
  data: { status: "open" },
4188
4456
  overrideAccess: true
4189
4457
  });
4190
4458
  }
4191
- await payload.update({
4192
- collection: slugs.pendingEmails,
4459
+ await dbUpdate(payload, slugs.pendingEmails, {
4193
4460
  id: Number(id),
4194
4461
  data: {
4195
4462
  status: "processed",
@@ -4242,8 +4509,7 @@ function createResendNotificationEndpoint(slugs) {
4242
4509
  if (!messageId) {
4243
4510
  return Response.json({ error: "messageId requis" }, { status: 400 });
4244
4511
  }
4245
- const message = await payload.findByID({
4246
- collection: slugs.ticketMessages,
4512
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
4247
4513
  id: messageId,
4248
4514
  depth: 0,
4249
4515
  overrideAccess: true
@@ -4252,8 +4518,7 @@ function createResendNotificationEndpoint(slugs) {
4252
4518
  return Response.json({ error: "Message introuvable" }, { status: 404 });
4253
4519
  }
4254
4520
  const ticketId = typeof message.ticket === "object" ? message.ticket.id : message.ticket;
4255
- const ticket = await payload.findByID({
4256
- collection: slugs.tickets,
4521
+ const ticket = await dbFindByID(payload, slugs.tickets, {
4257
4522
  id: ticketId,
4258
4523
  depth: 1,
4259
4524
  overrideAccess: true
@@ -4379,8 +4644,7 @@ function createSeedKbEndpoint(slugs) {
4379
4644
  let created = 0;
4380
4645
  let skipped = 0;
4381
4646
  for (const article of KB_ARTICLES) {
4382
- const existing = await payload.find({
4383
- collection: slugs.knowledgeBase,
4647
+ const existing = await dbFind(payload, slugs.knowledgeBase, {
4384
4648
  where: { slug: { equals: article.slug } },
4385
4649
  limit: 1,
4386
4650
  depth: 0,
@@ -4407,8 +4671,7 @@ function createSeedKbEndpoint(slugs) {
4407
4671
  version: 1
4408
4672
  }
4409
4673
  };
4410
- await payload.create({
4411
- collection: slugs.knowledgeBase,
4674
+ await dbCreate(payload, slugs.knowledgeBase, {
4412
4675
  data: {
4413
4676
  title: article.title,
4414
4677
  slug: article.slug,
@@ -4463,8 +4726,7 @@ function createLoginEndpoint(slugs) {
4463
4726
  collection: slugs.supportClients,
4464
4727
  data: { email, password }
4465
4728
  });
4466
- payload.create({
4467
- collection: slugs.authLogs,
4729
+ dbCreate(payload, slugs.authLogs, {
4468
4730
  data: { email, success: true, action: "login", ipAddress: ip, userAgent },
4469
4731
  overrideAccess: true
4470
4732
  }).catch(() => {
@@ -4488,12 +4750,14 @@ function createLoginEndpoint(slugs) {
4488
4750
  );
4489
4751
  } catch (err) {
4490
4752
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
4753
+ if (errorMessage.includes("2FA_REQUIRED")) {
4754
+ return Response.json({ requires2FA: true }, { status: 200 });
4755
+ }
4491
4756
  let errorReason = "Identifiants incorrects";
4492
4757
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
4493
4758
  errorReason = "Compte verrouill\xE9 (trop de tentatives)";
4494
4759
  }
4495
- payload.create({
4496
- collection: slugs.authLogs,
4760
+ dbCreate(payload, slugs.authLogs, {
4497
4761
  data: { email, success: false, action: "login", errorReason, ipAddress: ip, userAgent },
4498
4762
  overrideAccess: true
4499
4763
  }).catch(() => {
@@ -4514,7 +4778,10 @@ function generateSecureCode() {
4514
4778
  return String(num);
4515
4779
  }
4516
4780
  function hashCode(code) {
4517
- const secret = process.env.PAYLOAD_SECRET || "payload-support-2fa";
4781
+ const secret = process.env.PAYLOAD_SECRET;
4782
+ if (!secret) {
4783
+ throw new Error("[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to operate 2FA with an insecure fallback secret");
4784
+ }
4518
4785
  return createHmac("sha256", secret).update(code).digest("hex");
4519
4786
  }
4520
4787
  function createAuth2faEndpoint(slugs) {
@@ -4539,8 +4806,7 @@ function createAuth2faEndpoint(slugs) {
4539
4806
  if (sendLimiter.check(email)) {
4540
4807
  return Response.json(genericSendResponse);
4541
4808
  }
4542
- const clients = await payload.find({
4543
- collection: slugs.supportClients,
4809
+ const clients = await dbFind(payload, slugs.supportClients, {
4544
4810
  where: { email: { equals: email } },
4545
4811
  limit: 1,
4546
4812
  depth: 0,
@@ -4553,8 +4819,7 @@ function createAuth2faEndpoint(slugs) {
4553
4819
  const plainCode = generateSecureCode();
4554
4820
  const twoFactorCode = hashCode(plainCode);
4555
4821
  const twoFactorExpiry = new Date(Date.now() + 10 * 60 * 1e3).toISOString();
4556
- await payload.update({
4557
- collection: slugs.supportClients,
4822
+ await dbUpdate(payload, slugs.supportClients, {
4558
4823
  id: client.id,
4559
4824
  data: { twoFactorCode, twoFactorExpiry },
4560
4825
  overrideAccess: true
@@ -4585,8 +4850,7 @@ function createAuth2faEndpoint(slugs) {
4585
4850
  { status: 429 }
4586
4851
  );
4587
4852
  }
4588
- const clients = await payload.find({
4589
- collection: slugs.supportClients,
4853
+ const clients = await dbFind(payload, slugs.supportClients, {
4590
4854
  where: { email: { equals: email } },
4591
4855
  limit: 1,
4592
4856
  depth: 0,
@@ -4602,8 +4866,7 @@ function createAuth2faEndpoint(slugs) {
4602
4866
  return Response.json({ error: "Aucun code en attente" }, { status: 400 });
4603
4867
  }
4604
4868
  if (/* @__PURE__ */ new Date() > new Date(storedExpiry)) {
4605
- await payload.update({
4606
- collection: slugs.supportClients,
4869
+ await dbUpdate(payload, slugs.supportClients, {
4607
4870
  id: client.id,
4608
4871
  data: { twoFactorCode: "", twoFactorExpiry: "" },
4609
4872
  overrideAccess: true
@@ -4617,10 +4880,9 @@ function createAuth2faEndpoint(slugs) {
4617
4880
  if (submittedBuffer.length !== storedBuffer.length || !crypto3.timingSafeEqual(submittedBuffer, storedBuffer)) {
4618
4881
  return Response.json({ error: "Code incorrect" }, { status: 400 });
4619
4882
  }
4620
- await payload.update({
4621
- collection: slugs.supportClients,
4883
+ await dbUpdate(payload, slugs.supportClients, {
4622
4884
  id: client.id,
4623
- data: { twoFactorCode: "", twoFactorExpiry: "" },
4885
+ data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
4624
4886
  overrideAccess: true
4625
4887
  });
4626
4888
  verifyLimiter.reset(email);
@@ -4694,15 +4956,33 @@ function createOAuthGoogleEndpoint(slugs, options) {
4694
4956
  if (!profile.email) {
4695
4957
  return Response.json({ error: "no_email" }, { status: 400 });
4696
4958
  }
4959
+ if (profile.verified_email !== true) {
4960
+ return Response.json({ error: "email_not_verified" }, { status: 403 });
4961
+ }
4697
4962
  const payload = req.payload;
4698
- const existing = await payload.find({
4699
- collection: slugs.supportClients,
4700
- where: { email: { equals: profile.email } },
4963
+ const byGoogle = await dbFind(payload, slugs.supportClients, {
4964
+ where: { googleId: { equals: profile.id } },
4701
4965
  limit: 1,
4702
4966
  depth: 0,
4703
4967
  overrideAccess: true
4704
4968
  });
4705
- let clientDoc = existing.docs[0];
4969
+ let clientDoc = byGoogle.docs[0];
4970
+ if (!clientDoc) {
4971
+ const byEmail = await dbFind(payload, slugs.supportClients, {
4972
+ where: { email: { equals: profile.email } },
4973
+ limit: 1,
4974
+ depth: 0,
4975
+ overrideAccess: true
4976
+ });
4977
+ clientDoc = byEmail.docs[0];
4978
+ if (clientDoc && profile.id) {
4979
+ await dbUpdate(payload, slugs.supportClients, {
4980
+ id: clientDoc.id,
4981
+ data: { googleId: profile.id },
4982
+ overrideAccess: true
4983
+ });
4984
+ }
4985
+ }
4706
4986
  if (!clientDoc) {
4707
4987
  const allowedDomains = options?.allowedEmailDomains;
4708
4988
  if (allowedDomains && allowedDomains.length > 0) {
@@ -4720,43 +5000,64 @@ function createOAuthGoogleEndpoint(slugs, options) {
4720
5000
  const autoPassword = crypto3.randomBytes(48).toString("base64url");
4721
5001
  const fullName = profile.name || profile.email.split("@")[0];
4722
5002
  const nameParts = fullName.split(" ");
4723
- clientDoc = await payload.create({
4724
- collection: slugs.supportClients,
5003
+ clientDoc = await dbCreate(payload, slugs.supportClients, {
4725
5004
  data: {
4726
5005
  email: profile.email,
4727
5006
  firstName: nameParts[0] || fullName,
4728
5007
  lastName: nameParts.slice(1).join(" ") || "-",
4729
5008
  company: fullName,
5009
+ googleId: profile.id,
4730
5010
  password: autoPassword
4731
5011
  },
4732
5012
  overrideAccess: true
4733
5013
  });
4734
5014
  }
4735
- const tempPassword = crypto3.randomBytes(48).toString("base64url");
4736
- await payload.update({
4737
- collection: slugs.supportClients,
4738
- id: clientDoc.id,
4739
- data: { password: tempPassword },
4740
- overrideAccess: true
4741
- });
4742
- const loginResult = await payload.login({
4743
- collection: slugs.supportClients,
4744
- data: { email: profile.email, password: tempPassword }
4745
- });
4746
- const postLoginPassword = crypto3.randomBytes(48).toString("base64url");
4747
- await payload.update({
4748
- collection: slugs.supportClients,
4749
- id: clientDoc.id,
4750
- data: { password: postLoginPassword },
4751
- overrideAccess: true
4752
- });
4753
- if (!loginResult.token) {
4754
- return Response.json({ error: "login_failed" }, { status: 400 });
5015
+ const secret = process.env.PAYLOAD_SECRET;
5016
+ if (!secret) {
5017
+ return Response.json({ error: "server_misconfigured" }, { status: 500 });
4755
5018
  }
4756
- return Response.json({
4757
- token: loginResult.token,
4758
- user: loginResult.user,
4759
- exp: loginResult.exp
5019
+ const collectionConfig = payload.collections[slugs.supportClients].config;
5020
+ const tokenExpiration = collectionConfig.auth?.tokenExpiration ?? 7200;
5021
+ let sid;
5022
+ if (collectionConfig.auth?.useSessions) {
5023
+ sid = crypto3.randomUUID();
5024
+ const now = /* @__PURE__ */ new Date();
5025
+ const expiresAt = new Date(now.getTime() + tokenExpiration * 1e3);
5026
+ const fresh = await dbFindByID(payload, slugs.supportClients, {
5027
+ id: clientDoc.id,
5028
+ depth: 0,
5029
+ overrideAccess: true,
5030
+ showHiddenFields: true
5031
+ });
5032
+ const kept = Array.isArray(fresh?.sessions) ? fresh.sessions.filter((s) => new Date(s.expiresAt) > now) : [];
5033
+ await payload.db.updateOne({
5034
+ collection: slugs.supportClients,
5035
+ id: clientDoc.id,
5036
+ data: {
5037
+ sessions: [
5038
+ ...kept,
5039
+ { id: sid, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString() }
5040
+ ]
5041
+ },
5042
+ returning: false
5043
+ });
5044
+ }
5045
+ const fieldsToSign = getFieldsToSign({
5046
+ collectionConfig,
5047
+ email: clientDoc.email,
5048
+ sid,
5049
+ user: { ...clientDoc, collection: slugs.supportClients }
5050
+ });
5051
+ const { token, exp } = await jwtSign({ fieldsToSign, secret, tokenExpiration });
5052
+ const headers = new Headers({ "Content-Type": "application/json" });
5053
+ const cookieSecure = process.env.NODE_ENV === "production";
5054
+ headers.append(
5055
+ "Set-Cookie",
5056
+ `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5057
+ );
5058
+ return new Response(JSON.stringify({ token, user: clientDoc, exp }), {
5059
+ status: 200,
5060
+ headers
4760
5061
  });
4761
5062
  }
4762
5063
  return Response.json({ error: "Action invalide" }, { status: 400 });
@@ -4797,8 +5098,7 @@ function createDeleteAccountEndpoint(slugs) {
4797
5098
  );
4798
5099
  }
4799
5100
  const clientId = req.user.id;
4800
- const tickets = await payload.find({
4801
- collection: slugs.tickets,
5101
+ const tickets = await dbFind(payload, slugs.tickets, {
4802
5102
  where: { client: { equals: clientId } },
4803
5103
  limit: 1e4,
4804
5104
  depth: 0,
@@ -4807,39 +5107,32 @@ function createDeleteAccountEndpoint(slugs) {
4807
5107
  });
4808
5108
  const ticketIds = tickets.docs.map((t) => t.id);
4809
5109
  if (ticketIds.length > 0) {
4810
- await payload.delete({
4811
- collection: slugs.ticketMessages,
5110
+ await dbDelete(payload, slugs.ticketMessages, {
4812
5111
  where: { ticket: { in: ticketIds } },
4813
5112
  overrideAccess: true
4814
5113
  });
4815
- await payload.delete({
4816
- collection: slugs.ticketActivityLog,
5114
+ await dbDelete(payload, slugs.ticketActivityLog, {
4817
5115
  where: { ticket: { in: ticketIds } },
4818
5116
  overrideAccess: true
4819
5117
  });
4820
- await payload.delete({
4821
- collection: slugs.timeEntries,
5118
+ await dbDelete(payload, slugs.timeEntries, {
4822
5119
  where: { ticket: { in: ticketIds } },
4823
5120
  overrideAccess: true
4824
5121
  });
4825
- await payload.delete({
4826
- collection: slugs.tickets,
5122
+ await dbDelete(payload, slugs.tickets, {
4827
5123
  where: { client: { equals: clientId } },
4828
5124
  overrideAccess: true
4829
5125
  });
4830
5126
  }
4831
- await payload.delete({
4832
- collection: slugs.satisfactionSurveys,
5127
+ await dbDelete(payload, slugs.satisfactionSurveys, {
4833
5128
  where: { client: { equals: clientId } },
4834
5129
  overrideAccess: true
4835
5130
  });
4836
- await payload.delete({
4837
- collection: slugs.chatMessages,
5131
+ await dbDelete(payload, slugs.chatMessages, {
4838
5132
  where: { client: { equals: clientId } },
4839
5133
  overrideAccess: true
4840
5134
  });
4841
- await payload.delete({
4842
- collection: slugs.supportClients,
5135
+ await dbDelete(payload, slugs.supportClients, {
4843
5136
  id: clientId,
4844
5137
  overrideAccess: true
4845
5138
  });
@@ -4880,8 +5173,8 @@ function createMergeClientsEndpoint(slugs) {
4880
5173
  return Response.json({ error: "sourceId and targetId are required and must be different" }, { status: 400 });
4881
5174
  }
4882
5175
  const [source, target] = await Promise.all([
4883
- payload.findByID({ collection: slugs.supportClients, id: sourceId, depth: 0, overrideAccess: true }),
4884
- payload.findByID({ collection: slugs.supportClients, id: targetId, depth: 0, overrideAccess: true })
5176
+ dbFindByID(payload, slugs.supportClients, { id: sourceId, depth: 0, overrideAccess: true }),
5177
+ dbFindByID(payload, slugs.supportClients, { id: targetId, depth: 0, overrideAccess: true })
4885
5178
  ]);
4886
5179
  if (!source || !target) {
4887
5180
  return Response.json({ error: "Source or target client not found" }, { status: 404 });
@@ -4893,63 +5186,57 @@ function createMergeClientsEndpoint(slugs) {
4893
5186
  pendingEmails: 0,
4894
5187
  satisfactionSurveys: 0
4895
5188
  };
4896
- const tickets = await payload.find({
4897
- collection: slugs.tickets,
5189
+ const tickets = await dbFind(payload, slugs.tickets, {
4898
5190
  where: { client: { equals: sourceId } },
4899
5191
  limit: 500,
4900
5192
  depth: 0,
4901
5193
  overrideAccess: true
4902
5194
  });
4903
5195
  for (const ticket of tickets.docs) {
4904
- await payload.update({ collection: slugs.tickets, id: ticket.id, data: { client: targetId }, overrideAccess: true });
5196
+ await dbUpdate(payload, slugs.tickets, { id: ticket.id, data: { client: targetId }, overrideAccess: true });
4905
5197
  results.tickets++;
4906
5198
  }
4907
- const messages = await payload.find({
4908
- collection: slugs.ticketMessages,
5199
+ const messages = await dbFind(payload, slugs.ticketMessages, {
4909
5200
  where: { authorClient: { equals: sourceId } },
4910
5201
  limit: 1e3,
4911
5202
  depth: 0,
4912
5203
  overrideAccess: true
4913
5204
  });
4914
5205
  for (const msg of messages.docs) {
4915
- await payload.update({ collection: slugs.ticketMessages, id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
5206
+ await dbUpdate(payload, slugs.ticketMessages, { id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
4916
5207
  results.ticketMessages++;
4917
5208
  }
4918
- const chats = await payload.find({
4919
- collection: slugs.chatMessages,
5209
+ const chats = await dbFind(payload, slugs.chatMessages, {
4920
5210
  where: { client: { equals: sourceId } },
4921
5211
  limit: 1e3,
4922
5212
  depth: 0,
4923
5213
  overrideAccess: true
4924
5214
  });
4925
5215
  for (const chat of chats.docs) {
4926
- await payload.update({ collection: slugs.chatMessages, id: chat.id, data: { client: targetId }, overrideAccess: true });
5216
+ await dbUpdate(payload, slugs.chatMessages, { id: chat.id, data: { client: targetId }, overrideAccess: true });
4927
5217
  results.chatMessages++;
4928
5218
  }
4929
- const pendingEmails = await payload.find({
4930
- collection: slugs.pendingEmails,
5219
+ const pendingEmails = await dbFind(payload, slugs.pendingEmails, {
4931
5220
  where: { client: { equals: sourceId } },
4932
5221
  limit: 500,
4933
5222
  depth: 0,
4934
5223
  overrideAccess: true
4935
5224
  });
4936
5225
  for (const pe of pendingEmails.docs) {
4937
- await payload.update({ collection: slugs.pendingEmails, id: pe.id, data: { client: targetId }, overrideAccess: true });
5226
+ await dbUpdate(payload, slugs.pendingEmails, { id: pe.id, data: { client: targetId }, overrideAccess: true });
4938
5227
  results.pendingEmails++;
4939
5228
  }
4940
- const surveys = await payload.find({
4941
- collection: slugs.satisfactionSurveys,
5229
+ const surveys = await dbFind(payload, slugs.satisfactionSurveys, {
4942
5230
  where: { client: { equals: sourceId } },
4943
5231
  limit: 500,
4944
5232
  depth: 0,
4945
5233
  overrideAccess: true
4946
5234
  });
4947
5235
  for (const survey of surveys.docs) {
4948
- await payload.update({ collection: slugs.satisfactionSurveys, id: survey.id, data: { client: targetId }, overrideAccess: true });
5236
+ await dbUpdate(payload, slugs.satisfactionSurveys, { id: survey.id, data: { client: targetId }, overrideAccess: true });
4949
5237
  results.satisfactionSurveys++;
4950
5238
  }
4951
- await payload.delete({
4952
- collection: slugs.supportClients,
5239
+ await dbDelete(payload, slugs.supportClients, {
4953
5240
  id: sourceId,
4954
5241
  overrideAccess: true
4955
5242
  });
@@ -5143,8 +5430,7 @@ function createImportConversationEndpoint(slugs) {
5143
5430
  if (blockedEmails.includes(conversation.client.email)) {
5144
5431
  return Response.json({ error: "Cannot create ticket from system email address" }, { status: 400 });
5145
5432
  }
5146
- const clientResult = await payload.find({
5147
- collection: slugs.supportClients,
5433
+ const clientResult = await dbFind(payload, slugs.supportClients, {
5148
5434
  where: { email: { equals: conversation.client.email } },
5149
5435
  limit: 1,
5150
5436
  depth: 0,
@@ -5155,8 +5441,7 @@ function createImportConversationEndpoint(slugs) {
5155
5441
  if (!client) {
5156
5442
  const nameParts = conversation.client.name.split(" ");
5157
5443
  const randomPassword = crypto3.randomBytes(48).toString("base64url");
5158
- client = await payload.create({
5159
- collection: slugs.supportClients,
5444
+ client = await dbCreate(payload, slugs.supportClients, {
5160
5445
  data: {
5161
5446
  email: conversation.client.email,
5162
5447
  password: randomPassword,
@@ -5168,15 +5453,13 @@ function createImportConversationEndpoint(slugs) {
5168
5453
  });
5169
5454
  isNewClient = true;
5170
5455
  }
5171
- const adminUsers = await payload.find({
5172
- collection: slugs.users,
5456
+ const adminUsers = await dbFind(payload, slugs.users, {
5173
5457
  limit: 1,
5174
5458
  depth: 0,
5175
5459
  overrideAccess: true
5176
5460
  });
5177
5461
  const adminUserId = adminUsers.docs[0]?.id;
5178
- const ticket = await payload.create({
5179
- collection: slugs.tickets,
5462
+ const ticket = await dbCreate(payload, slugs.tickets, {
5180
5463
  data: {
5181
5464
  subject: conversation.subject,
5182
5465
  client: client.id,
@@ -5189,8 +5472,7 @@ function createImportConversationEndpoint(slugs) {
5189
5472
  let importedCount = 0;
5190
5473
  for (const msg of conversation.messages) {
5191
5474
  const isAdmin = msg.from === "admin";
5192
- await payload.create({
5193
- collection: slugs.ticketMessages,
5475
+ await dbCreate(payload, slugs.ticketMessages, {
5194
5476
  data: {
5195
5477
  ticket: ticket.id,
5196
5478
  body: msg.content,
@@ -5226,8 +5508,7 @@ function createImportConversationEndpoint(slugs) {
5226
5508
  // src/utils/fireWebhooks.ts
5227
5509
  async function fireWebhooks(payload, slugs, event, data) {
5228
5510
  try {
5229
- const endpoints = await payload.find({
5230
- collection: slugs.webhookEndpoints,
5511
+ const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
5231
5512
  where: {
5232
5513
  active: { equals: true },
5233
5514
  events: { contains: event }
@@ -5251,8 +5532,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5251
5532
  signal: AbortSignal.timeout(1e4)
5252
5533
  });
5253
5534
  try {
5254
- await payload.update({
5255
- collection: slugs.webhookEndpoints,
5535
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5256
5536
  id: endpoint.id,
5257
5537
  data: {
5258
5538
  lastTriggeredAt: timestamp,
@@ -5265,8 +5545,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5265
5545
  } catch (error) {
5266
5546
  console.warn(`[support] Webhook delivery failed: ${endpoint.url}`, error);
5267
5547
  try {
5268
- await payload.update({
5269
- collection: slugs.webhookEndpoints,
5548
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5270
5549
  id: endpoint.id,
5271
5550
  data: {
5272
5551
  lastTriggeredAt: timestamp,
@@ -5301,8 +5580,7 @@ function createProcessScheduledEndpoint(slugs) {
5301
5580
  const settings = await readSupportSettings(payload);
5302
5581
  const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5303
5582
  const results = { processed: 0, errors: 0 };
5304
- const scheduled = await payload.find({
5305
- collection: slugs.ticketMessages,
5583
+ const scheduled = await dbFind(payload, slugs.ticketMessages, {
5306
5584
  where: {
5307
5585
  and: [
5308
5586
  { scheduledAt: { less_than_equal: now.toISOString() } },
@@ -5322,8 +5600,7 @@ function createProcessScheduledEndpoint(slugs) {
5322
5600
  try {
5323
5601
  const msg = message;
5324
5602
  const ticketId = typeof msg.ticket === "object" ? msg.ticket.id : msg.ticket;
5325
- const ticket = await payload.findByID({
5326
- collection: slugs.tickets,
5603
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5327
5604
  id: ticketId,
5328
5605
  depth: 1,
5329
5606
  overrideAccess: true
@@ -5346,8 +5623,7 @@ function createProcessScheduledEndpoint(slugs) {
5346
5623
  subject: `Re: [${ticketNumber}] ${subject}`,
5347
5624
  html: `<p>Bonjour ${escapeHtml(client.firstName || "")},</p><p>Notre \xE9quipe a r\xE9pondu \xE0 votre ticket <strong>${escapeHtml(ticketNumber)}</strong>.</p><blockquote style="border-left:4px solid #2563eb;padding:12px;margin:16px 0;background:#f8fafc;">${escapeHtml(preview)}</blockquote><p><a href="${baseUrl}/support/tickets/${ticketId}">Consulter le ticket</a></p>`
5348
5625
  });
5349
- await payload.update({
5350
- collection: slugs.ticketMessages,
5626
+ await dbUpdate(payload, slugs.ticketMessages, {
5351
5627
  id: msg.id,
5352
5628
  data: {
5353
5629
  scheduledSent: true,
@@ -5357,16 +5633,14 @@ function createProcessScheduledEndpoint(slugs) {
5357
5633
  overrideAccess: true
5358
5634
  });
5359
5635
  } else {
5360
- await payload.update({
5361
- collection: slugs.ticketMessages,
5636
+ await dbUpdate(payload, slugs.ticketMessages, {
5362
5637
  id: msg.id,
5363
5638
  data: { scheduledSent: true },
5364
5639
  overrideAccess: true
5365
5640
  });
5366
5641
  }
5367
5642
  if (msg.authorType === "admin" && !msg.isInternal) {
5368
- await payload.update({
5369
- collection: slugs.tickets,
5643
+ await dbUpdate(payload, slugs.tickets, {
5370
5644
  id: ticketId,
5371
5645
  data: { status: "waiting_client" },
5372
5646
  overrideAccess: true
@@ -5398,6 +5672,208 @@ function createProcessScheduledEndpoint(slugs) {
5398
5672
  };
5399
5673
  }
5400
5674
 
5675
+ // src/endpoints/process-snooze.ts
5676
+ function createProcessSnoozeEndpoint(slugs) {
5677
+ return {
5678
+ path: "/support/process-snooze",
5679
+ method: "post",
5680
+ handler: async (req) => {
5681
+ const secret = req.headers.get("x-cron-secret");
5682
+ const expectedSecret = process.env.CRON_SECRET;
5683
+ if (!expectedSecret || secret !== expectedSecret) {
5684
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5685
+ }
5686
+ try {
5687
+ const payload = req.payload;
5688
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
5689
+ const results = { processed: 0, errors: 0 };
5690
+ const due = await dbFind(payload, slugs.tickets, {
5691
+ where: {
5692
+ and: [
5693
+ { snoozeUntil: { exists: true } },
5694
+ { snoozeUntil: { less_than_equal: nowIso } }
5695
+ ]
5696
+ },
5697
+ limit: 500,
5698
+ depth: 0,
5699
+ overrideAccess: true,
5700
+ select: { id: true, ticketNumber: true }
5701
+ });
5702
+ for (const ticket of due.docs) {
5703
+ try {
5704
+ await dbUpdate(payload, slugs.tickets, {
5705
+ id: ticket.id,
5706
+ data: { snoozeUntil: null },
5707
+ overrideAccess: true
5708
+ });
5709
+ try {
5710
+ await dbCreate(payload, slugs.ticketActivityLog, {
5711
+ data: {
5712
+ ticket: ticket.id,
5713
+ action: "snooze_expired",
5714
+ detail: "Snooze \xE9chu \u2014 ticket r\xE9activ\xE9",
5715
+ actorType: "system"
5716
+ },
5717
+ overrideAccess: true
5718
+ });
5719
+ } catch {
5720
+ }
5721
+ results.processed++;
5722
+ } catch (err) {
5723
+ console.error("[support] Failed to wake snoozed ticket:", err);
5724
+ results.errors++;
5725
+ }
5726
+ }
5727
+ return Response.json({ ok: true, ...results });
5728
+ } catch (err) {
5729
+ console.error("[process-snooze] Error:", err);
5730
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5731
+ }
5732
+ }
5733
+ };
5734
+ }
5735
+
5736
+ // src/endpoints/process-digests.ts
5737
+ function createProcessDigestsEndpoint(slugs) {
5738
+ return {
5739
+ path: "/support/process-digests",
5740
+ method: "post",
5741
+ handler: async (req) => {
5742
+ const secret = req.headers.get("x-cron-secret");
5743
+ if (!process.env.CRON_SECRET || secret !== process.env.CRON_SECRET) {
5744
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5745
+ }
5746
+ try {
5747
+ const payload = req.payload;
5748
+ let body = {};
5749
+ try {
5750
+ body = await req.json();
5751
+ } catch {
5752
+ }
5753
+ const frequency = body.frequency === "weekly" ? "weekly" : "daily";
5754
+ const settings = await readSupportSettings(payload);
5755
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5756
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
5757
+ const itemsByClient = /* @__PURE__ */ new Map();
5758
+ let page = 1;
5759
+ let hasMore = true;
5760
+ while (hasMore && page <= 50) {
5761
+ const batch = await dbFind(payload, slugs.notificationQueue, { limit: 200, page, depth: 0, overrideAccess: true, sort: "createdAt" });
5762
+ for (const it of batch.docs) {
5763
+ const raw = it;
5764
+ const clientId = typeof raw.client === "object" ? raw.client?.id : raw.client;
5765
+ if (clientId === void 0 || clientId === null) continue;
5766
+ const key = String(clientId);
5767
+ if (!itemsByClient.has(key)) itemsByClient.set(key, []);
5768
+ itemsByClient.get(key).push({ id: raw.id, title: raw.title, message: raw.message });
5769
+ }
5770
+ hasMore = batch.hasNextPage ?? false;
5771
+ page++;
5772
+ }
5773
+ const results = { clients: 0, items: 0, errors: 0 };
5774
+ for (const [clientId, items] of itemsByClient) {
5775
+ try {
5776
+ const client = await dbFindByID(payload, slugs.supportClients, { id: clientId, depth: 0, overrideAccess: true });
5777
+ if (!client || client.notificationFrequency !== frequency) continue;
5778
+ if (client.email) {
5779
+ const rows = items.map((i) => `<tr><td style="padding:8px 0;font-size:14px;color:#1e293b;border-bottom:1px solid #f1f5f9;"><strong>${escapeHtml(i.title || "")}</strong><br/><span style="color:#64748b;font-size:13px;">${escapeHtml(i.message || "")}</span></td></tr>`).join("");
5780
+ const html = emailWrapper("Recapitulatif de vos tickets", [
5781
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
5782
+ emailParagraph(`Voici le recapitulatif de l'activite sur vos tickets (${items.length} notification${items.length > 1 ? "s" : ""}) :`),
5783
+ `<table cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table>`,
5784
+ ...baseUrl ? [emailParagraph(`<a href="${baseUrl}/support/dashboard">Acceder a votre espace support</a>`)] : []
5785
+ ].join(""), { kind: "system" });
5786
+ await payload.sendEmail({ to: client.email, ...replyTo ? { replyTo } : {}, subject: `Recapitulatif support (${items.length})`, html });
5787
+ }
5788
+ for (const i of items) {
5789
+ try {
5790
+ await dbDelete(payload, slugs.notificationQueue, { id: i.id, overrideAccess: true });
5791
+ } catch {
5792
+ }
5793
+ }
5794
+ results.clients++;
5795
+ results.items += items.length;
5796
+ } catch (err) {
5797
+ console.error("[process-digests] client error:", err);
5798
+ results.errors++;
5799
+ }
5800
+ }
5801
+ return Response.json({ ok: true, frequency, ...results });
5802
+ } catch (err) {
5803
+ console.error("[process-digests] Error:", err);
5804
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5805
+ }
5806
+ }
5807
+ };
5808
+ }
5809
+ var PROVIDERS = ["whatsapp", "messenger"];
5810
+ function createChannelsWebhookEndpoint(slugs) {
5811
+ return {
5812
+ path: "/support/channels/webhook",
5813
+ method: "post",
5814
+ handler: async (req) => {
5815
+ const secret = req.headers.get("x-channel-secret");
5816
+ if (!process.env.CHANNELS_WEBHOOK_SECRET || secret !== process.env.CHANNELS_WEBHOOK_SECRET) {
5817
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5818
+ }
5819
+ let body;
5820
+ try {
5821
+ body = await req.json();
5822
+ } catch {
5823
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5824
+ }
5825
+ const provider = body.provider;
5826
+ const from = (body.from || "").trim();
5827
+ const text = (body.text || "").trim();
5828
+ if (!PROVIDERS.includes(provider) || !from || !text) {
5829
+ return Response.json({ error: "provider, from et text requis." }, { status: 400 });
5830
+ }
5831
+ try {
5832
+ const payload = req.payload;
5833
+ const email = `${from}@${provider}.channel`;
5834
+ const existing = await dbFind(payload, slugs.supportClients, { where: { email: { equals: email } }, limit: 1, depth: 0, overrideAccess: true });
5835
+ let clientId;
5836
+ if (existing.docs.length > 0) {
5837
+ clientId = existing.docs[0].id;
5838
+ } else {
5839
+ const created = await dbCreate(payload, slugs.supportClients, {
5840
+ data: { email, firstName: body.name || provider, lastName: from.slice(0, 40), company: provider, password: randomBytes(16).toString("hex") },
5841
+ overrideAccess: true
5842
+ });
5843
+ clientId = created.id;
5844
+ }
5845
+ const open = await dbFind(payload, slugs.tickets, {
5846
+ where: { and: [{ client: { equals: clientId } }, { source: { equals: provider } }, { status: { in: ["open", "waiting_client"] } }] },
5847
+ sort: "-createdAt",
5848
+ limit: 1,
5849
+ depth: 0,
5850
+ overrideAccess: true
5851
+ });
5852
+ let ticketId;
5853
+ let createdTicket = false;
5854
+ if (open.docs.length > 0) {
5855
+ ticketId = open.docs[0].id;
5856
+ } else {
5857
+ const t = await dbCreate(payload, slugs.tickets, {
5858
+ data: { subject: text.slice(0, 80), client: clientId, status: "open", priority: "normal", source: provider },
5859
+ overrideAccess: true
5860
+ });
5861
+ ticketId = t.id;
5862
+ createdTicket = true;
5863
+ }
5864
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
5865
+ data: { ticket: ticketId, body: text, authorType: "client", authorClient: clientId },
5866
+ overrideAccess: true
5867
+ });
5868
+ return Response.json({ ok: true, ticketId, createdTicket, messageId: msg.id });
5869
+ } catch (err) {
5870
+ console.error("[channels] Error:", err);
5871
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5872
+ }
5873
+ }
5874
+ };
5875
+ }
5876
+
5401
5877
  // src/endpoints/user-prefs.ts
5402
5878
  var PREF_KEY_PREFIX = "support-user-prefs";
5403
5879
  var DEFAULT_USER_PREFS2 = {
@@ -5413,8 +5889,7 @@ function createUserPrefsGetEndpoint(slugs) {
5413
5889
  const payload = req.payload;
5414
5890
  requireAdmin(req, slugs);
5415
5891
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5416
- const prefs = await payload.find({
5417
- collection: "payload-preferences",
5892
+ const prefs = await dbFind(payload, "payload-preferences", {
5418
5893
  where: { key: { equals: key } },
5419
5894
  limit: 1,
5420
5895
  depth: 0,
@@ -5448,8 +5923,7 @@ function createUserPrefsPostEndpoint(slugs) {
5448
5923
  requireAdmin(req, slugs);
5449
5924
  const body = await req.json();
5450
5925
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5451
- const existing = await payload.find({
5452
- collection: "payload-preferences",
5926
+ const existing = await dbFind(payload, "payload-preferences", {
5453
5927
  where: { key: { equals: key } },
5454
5928
  limit: 1,
5455
5929
  depth: 0,
@@ -5510,8 +5984,7 @@ function createEscalateEndpoint(slugs) {
5510
5984
  if (Number.isNaN(ticketId)) {
5511
5985
  return Response.json({ error: "invalid-id" }, { status: 400 });
5512
5986
  }
5513
- const ticket = await req.payload.update({
5514
- collection: slugs.tickets,
5987
+ const ticket = await dbUpdate(req.payload, slugs.tickets, {
5515
5988
  id: ticketId,
5516
5989
  data: { status: "escalated" },
5517
5990
  overrideAccess: true
@@ -5559,8 +6032,7 @@ function createTicketFeedbackEndpoint(slugs) {
5559
6032
  { status: 400 }
5560
6033
  );
5561
6034
  }
5562
- const ticket = await payload.findByID({
5563
- collection: slugs.tickets,
6035
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5564
6036
  id: ticketId,
5565
6037
  depth: 0,
5566
6038
  overrideAccess: true
@@ -5572,8 +6044,7 @@ function createTicketFeedbackEndpoint(slugs) {
5572
6044
  if (ticketClientId !== req.user.id) {
5573
6045
  return Response.json({ error: "forbidden" }, { status: 403 });
5574
6046
  }
5575
- const existing = await payload.find({
5576
- collection: "ticket-feedback",
6047
+ const existing = await dbFind(payload, "ticket-feedback", {
5577
6048
  where: { ticket: { equals: ticketId } },
5578
6049
  limit: 1,
5579
6050
  depth: 0,
@@ -5585,8 +6056,7 @@ function createTicketFeedbackEndpoint(slugs) {
5585
6056
  { status: 409 }
5586
6057
  );
5587
6058
  }
5588
- const feedback = await payload.create({
5589
- collection: "ticket-feedback",
6059
+ const feedback = await dbCreate(payload, "ticket-feedback", {
5590
6060
  data: {
5591
6061
  ticket: ticketId,
5592
6062
  client: req.user.id,
@@ -5610,6 +6080,7 @@ function createTicketFeedbackEndpoint(slugs) {
5610
6080
  // src/endpoints/transfer-ticket.ts
5611
6081
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5612
6082
  var MAX_TRANSFERS_PER_DAY = 5;
6083
+ var transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY);
5613
6084
  function createTransferTicketEndpoint(slugs) {
5614
6085
  return {
5615
6086
  path: "/support/tickets/:id/transfer",
@@ -5634,8 +6105,7 @@ function createTransferTicketEndpoint(slugs) {
5634
6105
  const includeAttachments = body.includeAttachments !== false;
5635
6106
  const customMessage = typeof body.message === "string" ? body.message.slice(0, 1e3) : "";
5636
6107
  const payload = req.payload;
5637
- const ticket = await payload.findByID({
5638
- collection: slugs.tickets,
6108
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5639
6109
  id: ticketId,
5640
6110
  depth: 1,
5641
6111
  overrideAccess: true
@@ -5646,10 +6116,15 @@ function createTransferTicketEndpoint(slugs) {
5646
6116
  if (!isAdmin && !isOwner) {
5647
6117
  return Response.json({ error: "Forbidden" }, { status: 403 });
5648
6118
  }
6119
+ if (transferLimiter.check(`${req.user.id}:${ticketId}`)) {
6120
+ return Response.json(
6121
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6122
+ { status: 429 }
6123
+ );
6124
+ }
5649
6125
  try {
5650
6126
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
5651
- const existing = await payload.count({
5652
- collection: slugs.emailLogs,
6127
+ const existing = await dbCount(payload, slugs.emailLogs, {
5653
6128
  where: {
5654
6129
  and: [
5655
6130
  { action: { equals: "transfer" } },
@@ -5667,8 +6142,7 @@ function createTransferTicketEndpoint(slugs) {
5667
6142
  }
5668
6143
  } catch {
5669
6144
  }
5670
- const messages = await payload.find({
5671
- collection: slugs.ticketMessages,
6145
+ const messages = await dbFind(payload, slugs.ticketMessages, {
5672
6146
  where: {
5673
6147
  and: [
5674
6148
  { ticket: { equals: ticketId } },
@@ -5748,8 +6222,7 @@ function createTransferTicketEndpoint(slugs) {
5748
6222
  html
5749
6223
  });
5750
6224
  try {
5751
- await payload.create({
5752
- collection: slugs.emailLogs,
6225
+ await dbCreate(payload, slugs.emailLogs, {
5753
6226
  data: {
5754
6227
  status: "success",
5755
6228
  action: "transfer",
@@ -5789,6 +6262,8 @@ function statusToLabel(status) {
5789
6262
  }
5790
6263
  }
5791
6264
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6265
+ var MAX_COLLABORATORS_PER_TICKET = 20;
6266
+ var inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10);
5792
6267
  function createInviteCollaboratorEndpoint(slugs) {
5793
6268
  return {
5794
6269
  path: "/support/tickets/:id/invite",
@@ -5812,8 +6287,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5812
6287
  }
5813
6288
  const role = body.role === "collaborator" ? "collaborator" : "viewer";
5814
6289
  const payload = req.payload;
5815
- const ticket = await payload.findByID({
5816
- collection: slugs.tickets,
6290
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5817
6291
  id: ticketId,
5818
6292
  depth: 1,
5819
6293
  overrideAccess: true
@@ -5825,13 +6299,22 @@ function createInviteCollaboratorEndpoint(slugs) {
5825
6299
  if (!isAdmin && !isOwner) {
5826
6300
  return Response.json({ error: "Forbidden" }, { status: 403 });
5827
6301
  }
6302
+ if (inviteLimiter.check(String(req.user.id))) {
6303
+ return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6304
+ }
6305
+ const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
6306
+ if (collabCount.totalDocs >= MAX_COLLABORATORS_PER_TICKET) {
6307
+ return Response.json(
6308
+ { error: `Limite de ${MAX_COLLABORATORS_PER_TICKET} collaborateurs atteinte sur ce ticket` },
6309
+ { status: 429 }
6310
+ );
6311
+ }
5828
6312
  const ownerEmail = typeof ticket.client === "object" ? ticket.client?.email : void 0;
5829
6313
  if (ownerEmail && ownerEmail.toLowerCase() === email) {
5830
6314
  return Response.json({ error: "Ce client est d\xE9j\xE0 propri\xE9taire du ticket" }, { status: 400 });
5831
6315
  }
5832
6316
  let inviteeId = null;
5833
- const existing = await payload.find({
5834
- collection: slugs.supportClients,
6317
+ const existing = await dbFind(payload, slugs.supportClients, {
5835
6318
  where: { email: { equals: email } },
5836
6319
  limit: 1,
5837
6320
  depth: 0,
@@ -5841,8 +6324,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5841
6324
  inviteeId = existing.docs[0].id;
5842
6325
  } else {
5843
6326
  const tempPassword = randomBytes(16).toString("hex");
5844
- const created = await payload.create({
5845
- collection: slugs.supportClients,
6327
+ const created = await dbCreate(payload, slugs.supportClients, {
5846
6328
  data: {
5847
6329
  email,
5848
6330
  firstName: "Invit\xE9",
@@ -5859,8 +6341,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5859
6341
  if (!inviteeId) {
5860
6342
  return Response.json({ error: "Impossible de cr\xE9er le client invit\xE9" }, { status: 500 });
5861
6343
  }
5862
- const dupe = await payload.find({
5863
- collection: "ticket-collaborators",
6344
+ const dupe = await dbFind(payload, "ticket-collaborators", {
5864
6345
  where: {
5865
6346
  and: [
5866
6347
  { ticket: { equals: ticketId } },
@@ -5873,15 +6354,13 @@ function createInviteCollaboratorEndpoint(slugs) {
5873
6354
  });
5874
6355
  const invitationToken = randomBytes(24).toString("hex");
5875
6356
  if (dupe.docs.length > 0) {
5876
- await payload.update({
5877
- collection: "ticket-collaborators",
6357
+ await dbUpdate(payload, "ticket-collaborators", {
5878
6358
  id: dupe.docs[0].id,
5879
6359
  data: { role, invitationToken },
5880
6360
  overrideAccess: true
5881
6361
  });
5882
6362
  } else {
5883
- await payload.create({
5884
- collection: "ticket-collaborators",
6363
+ await dbCreate(payload, "ticket-collaborators", {
5885
6364
  data: {
5886
6365
  ticket: ticketId,
5887
6366
  client: inviteeId,
@@ -5924,8 +6403,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5924
6403
  )
5925
6404
  });
5926
6405
  try {
5927
- await payload.create({
5928
- collection: slugs.emailLogs,
6406
+ await dbCreate(payload, slugs.emailLogs, {
5929
6407
  data: {
5930
6408
  status: "success",
5931
6409
  action: "invite",
@@ -5978,6 +6456,7 @@ function createSupportEndpoints(slugs, options) {
5978
6456
  ];
5979
6457
  if (!f || f.ai !== false) {
5980
6458
  endpoints.push(createAiEndpoint(slugs));
6459
+ endpoints.push(createAiAgentEndpoint(slugs));
5981
6460
  endpoints.push(...createClientIntelligenceEndpoint(slugs));
5982
6461
  endpoints.push(createTicketSynthesisEndpoint(slugs));
5983
6462
  }
@@ -6008,21 +6487,26 @@ function createSupportEndpoints(slugs, options) {
6008
6487
  endpoints.push(createAdminChatGetEndpoint(slugs), createAdminChatPostEndpoint(slugs));
6009
6488
  endpoints.push(createAdminChatStreamEndpoint(slugs));
6010
6489
  }
6011
- if (!f || f.timeTracking !== false) endpoints.push(createBillingEndpoint(slugs));
6490
+ if (!f || f.timeTracking !== false) {
6491
+ endpoints.push(createBillingEndpoint(slugs));
6492
+ endpoints.push(createInvoiceEndpoint(slugs));
6493
+ }
6012
6494
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
6013
6495
  if (!f || f.emailTracking !== false) {
6014
6496
  endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
6015
6497
  }
6016
6498
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
6017
6499
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
6500
+ if (!f || f.snooze !== false) endpoints.push(createProcessSnoozeEndpoint(slugs));
6501
+ endpoints.push(createProcessDigestsEndpoint(slugs));
6502
+ endpoints.push(createChannelsWebhookEndpoint(slugs));
6018
6503
  return endpoints;
6019
6504
  }
6020
6505
 
6021
6506
  // src/utils/adminNotification.ts
6022
6507
  async function createAdminNotification(payload, data, collectionSlug = "admin-notifications") {
6023
6508
  try {
6024
- await payload.create({
6025
- collection: collectionSlug,
6509
+ await dbCreate(payload, collectionSlug, {
6026
6510
  data: {
6027
6511
  title: data.title,
6028
6512
  message: data.message,
@@ -6042,8 +6526,7 @@ function dispatchWebhook(data, event, payload, slugs) {
6042
6526
  }
6043
6527
  async function _dispatch(data, event, payload, slugs) {
6044
6528
  try {
6045
- const { docs: endpoints } = await payload.find({
6046
- collection: slugs.webhookEndpoints,
6529
+ const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
6047
6530
  where: {
6048
6531
  and: [
6049
6532
  { active: { equals: true } },
@@ -6080,8 +6563,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
6080
6563
  signal: AbortSignal.timeout(1e4)
6081
6564
  // 10s timeout
6082
6565
  });
6083
- await payload.update({
6084
- collection: slugs.webhookEndpoints,
6566
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6085
6567
  id: endpoint.id,
6086
6568
  data: {
6087
6569
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6095,8 +6577,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
6095
6577
  } catch (err) {
6096
6578
  console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
6097
6579
  try {
6098
- await payload.update({
6099
- collection: slugs.webhookEndpoints,
6580
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6100
6581
  id: endpoint.id,
6101
6582
  data: {
6102
6583
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6330,8 +6811,7 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6330
6811
  if (typeof policyRef === "object" && policyRef.firstResponseTime) {
6331
6812
  return policyRef;
6332
6813
  }
6333
- return await payload.findByID({
6334
- collection: slugs.slaPolicies,
6814
+ return await dbFindByID(payload, slugs.slaPolicies, {
6335
6815
  id: policyId,
6336
6816
  depth: 0,
6337
6817
  overrideAccess: true
@@ -6342,8 +6822,7 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6342
6822
  }
6343
6823
  const priority = field(ticket, "priority") || "normal";
6344
6824
  try {
6345
- const defaults = await payload.find({
6346
- collection: slugs.slaPolicies,
6825
+ const defaults = await dbFind(payload, slugs.slaPolicies, {
6347
6826
  where: {
6348
6827
  and: [
6349
6828
  { isDefault: { equals: true } },
@@ -6380,8 +6859,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6380
6859
  if (typeof policyRef === "object" && policyRef.escalateOnBreach !== void 0) {
6381
6860
  policy = policyRef;
6382
6861
  } else {
6383
- policy = await payload.findByID({
6384
- collection: slugs.slaPolicies,
6862
+ policy = await dbFindByID(payload, slugs.slaPolicies, {
6385
6863
  id: policyId,
6386
6864
  depth: 0,
6387
6865
  overrideAccess: true
@@ -6394,8 +6872,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6394
6872
  try {
6395
6873
  const escalateToId = typeof policy.escalateTo === "object" ? policy.escalateTo.id : policy.escalateTo;
6396
6874
  if (!escalateToId) return;
6397
- const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await payload.findByID({
6398
- collection: slugs.users,
6875
+ const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await dbFindByID(payload, slugs.users, {
6399
6876
  id: escalateToId,
6400
6877
  depth: 0,
6401
6878
  overrideAccess: true
@@ -6443,8 +6920,7 @@ function createAssignSlaDeadlines(slugs, notificationSlug = "admin-notifications
6443
6920
  if (!field(doc, "slaPolicy") && policy.id) {
6444
6921
  updateData.slaPolicy = policy.id;
6445
6922
  }
6446
- await payload.update({
6447
- collection: slugs.tickets,
6923
+ await dbUpdate(payload, slugs.tickets, {
6448
6924
  id: doc.id,
6449
6925
  data: updateData,
6450
6926
  overrideAccess: true
@@ -6467,8 +6943,7 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6467
6943
  const now = /* @__PURE__ */ new Date();
6468
6944
  const deadline = new Date(doc.slaResolutionDue);
6469
6945
  const breached = now > deadline;
6470
- await payload.update({
6471
- collection: slugs.tickets,
6946
+ await dbUpdate(payload, slugs.tickets, {
6472
6947
  id: doc.id,
6473
6948
  data: { slaResolutionBreached: breached },
6474
6949
  overrideAccess: true
@@ -6482,6 +6957,35 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6482
6957
  return doc;
6483
6958
  };
6484
6959
  }
6960
+ function createPauseSlaOnHold(slugs) {
6961
+ return async ({ doc, previousDoc, operation, req }) => {
6962
+ if (operation !== "update" || !previousDoc) return doc;
6963
+ if (previousDoc.status === doc.status) return doc;
6964
+ if (!doc.slaResolutionDue) return doc;
6965
+ const wasWaiting = previousDoc.status === "waiting_client";
6966
+ const isWaiting = doc.status === "waiting_client";
6967
+ try {
6968
+ if (!wasWaiting && isWaiting && !doc.slaPausedAt && doc.status !== "resolved") {
6969
+ await req.payload.update({
6970
+ collection: slugs.tickets,
6971
+ id: doc.id,
6972
+ data: { slaPausedAt: (/* @__PURE__ */ new Date()).toISOString() },
6973
+ overrideAccess: true
6974
+ });
6975
+ } else if (wasWaiting && !isWaiting && doc.slaPausedAt) {
6976
+ const pausedMs = Date.now() - new Date(doc.slaPausedAt).getTime();
6977
+ const data = { slaPausedAt: null };
6978
+ if (pausedMs > 0) {
6979
+ data.slaResolutionDue = new Date(new Date(doc.slaResolutionDue).getTime() + pausedMs).toISOString();
6980
+ }
6981
+ await req.payload.update({ collection: slugs.tickets, id: doc.id, data, overrideAccess: true });
6982
+ }
6983
+ } catch (err) {
6984
+ console.error("[sla] Failed to pause/resume SLA on hold:", err);
6985
+ }
6986
+ return doc;
6987
+ };
6988
+ }
6485
6989
  function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications") {
6486
6990
  return async ({ doc, operation, req }) => {
6487
6991
  if (operation !== "create") return doc;
@@ -6489,8 +6993,7 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6489
6993
  try {
6490
6994
  const { payload } = req;
6491
6995
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
6492
- const ticket = await payload.findByID({
6493
- collection: slugs.tickets,
6996
+ const ticket = await dbFindByID(payload, slugs.tickets, {
6494
6997
  id: ticketId,
6495
6998
  depth: 1,
6496
6999
  overrideAccess: true
@@ -6501,8 +7004,7 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6501
7004
  const now = /* @__PURE__ */ new Date();
6502
7005
  const deadline = new Date(ticket.slaFirstResponseDue);
6503
7006
  const breached = now > deadline;
6504
- await payload.update({
6505
- collection: slugs.tickets,
7007
+ await dbUpdate(payload, slugs.tickets, {
6506
7008
  id: ticketId,
6507
7009
  data: { slaFirstResponseBreached: breached },
6508
7010
  overrideAccess: true
@@ -6517,6 +7019,108 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6517
7019
  };
6518
7020
  }
6519
7021
 
7022
+ // src/hooks/automationRules.ts
7023
+ function evaluateConditions(doc, rule) {
7024
+ const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
7025
+ if (conds.length === 0) return true;
7026
+ const results = conds.map((c) => {
7027
+ const fieldVal = String(doc[c.field] ?? "");
7028
+ const v = String(c.value ?? "");
7029
+ switch (c.operator) {
7030
+ case "equals":
7031
+ return fieldVal === v;
7032
+ case "not_equals":
7033
+ return fieldVal !== v;
7034
+ case "contains":
7035
+ return fieldVal.toLowerCase().includes(v.toLowerCase());
7036
+ default:
7037
+ return false;
7038
+ }
7039
+ });
7040
+ return rule.matchType === "any" ? results.some(Boolean) : results.every(Boolean);
7041
+ }
7042
+ function createApplyAutomationRules(slugs) {
7043
+ return async ({ doc, previousDoc, operation, req }) => {
7044
+ if (req.context?.skipAutomation) return doc;
7045
+ const events = [];
7046
+ if (operation === "create") events.push("ticket_created");
7047
+ if (operation === "update") {
7048
+ events.push("ticket_updated");
7049
+ if (previousDoc && previousDoc.status !== doc.status) events.push("ticket_status_changed");
7050
+ }
7051
+ if (events.length === 0) return doc;
7052
+ try {
7053
+ const rules = await dbFind(req.payload, slugs.automationRules, {
7054
+ where: { and: [{ enabled: { equals: true } }, { event: { in: events } }] },
7055
+ sort: "order",
7056
+ limit: 200,
7057
+ depth: 0,
7058
+ overrideAccess: true
7059
+ });
7060
+ const changes = {};
7061
+ const tags = Array.isArray(doc.tags) ? [...doc.tags] : [];
7062
+ let tagsTouched = false;
7063
+ for (const rule of rules.docs) {
7064
+ if (!evaluateConditions(doc, rule)) continue;
7065
+ for (const a of Array.isArray(rule.actions) ? rule.actions : []) {
7066
+ if (!a.value) continue;
7067
+ switch (a.type) {
7068
+ case "set_status":
7069
+ changes.status = a.value;
7070
+ break;
7071
+ case "set_priority":
7072
+ changes.priority = a.value;
7073
+ break;
7074
+ case "set_category":
7075
+ changes.category = a.value;
7076
+ break;
7077
+ case "assign":
7078
+ changes.assignedTo = a.value;
7079
+ break;
7080
+ case "add_tag":
7081
+ if (!tags.includes(a.value)) {
7082
+ tags.push(a.value);
7083
+ tagsTouched = true;
7084
+ }
7085
+ break;
7086
+ }
7087
+ }
7088
+ }
7089
+ if (tagsTouched) changes.tags = tags;
7090
+ if (Object.keys(changes).length > 0) {
7091
+ await req.payload.update({
7092
+ collection: slugs.tickets,
7093
+ id: doc.id,
7094
+ data: changes,
7095
+ overrideAccess: true,
7096
+ context: { skipAutomation: true }
7097
+ });
7098
+ }
7099
+ } catch (err) {
7100
+ console.error("[support] Failed to apply automation rules:", err);
7101
+ }
7102
+ return doc;
7103
+ };
7104
+ }
7105
+
7106
+ // src/utils/teamAccess.ts
7107
+ function isTeamScopingEnabled() {
7108
+ return process.env.SUPPORT_TEAM_SCOPING === "1" || process.env.SUPPORT_TEAM_SCOPING === "true";
7109
+ }
7110
+ async function resolveAgentTeamIds(payload, slugs, userId) {
7111
+ try {
7112
+ const teams = await dbFind(payload, slugs.supportTeams, {
7113
+ where: { members: { in: [userId] } },
7114
+ limit: 200,
7115
+ depth: 0,
7116
+ overrideAccess: true
7117
+ });
7118
+ return teams.docs.map((t) => t.id);
7119
+ } catch {
7120
+ return [];
7121
+ }
7122
+ }
7123
+
6520
7124
  // src/collections/Tickets.ts
6521
7125
  function createAssignTicketNumber(slugs) {
6522
7126
  return async ({ data, operation, req }) => {
@@ -6779,56 +7383,6 @@ function createNotifyClientOnResolve(slugs) {
6779
7383
  return doc;
6780
7384
  };
6781
7385
  }
6782
- function createAutoCalculateSLA(slugs) {
6783
- return async ({ doc, operation, req }) => {
6784
- if (operation !== "create") return doc;
6785
- try {
6786
- const { payload } = req;
6787
- const ticketPriority = doc.priority || "normal";
6788
- let policy = null;
6789
- const byPriority = await payload.find({
6790
- collection: slugs.slaPolicies,
6791
- where: { priority: { equals: ticketPriority } },
6792
- limit: 1,
6793
- depth: 0,
6794
- overrideAccess: true
6795
- });
6796
- if (byPriority.docs.length > 0) {
6797
- policy = byPriority.docs[0];
6798
- } else {
6799
- const defaults = await payload.find({
6800
- collection: slugs.slaPolicies,
6801
- where: { isDefault: { equals: true } },
6802
- limit: 1,
6803
- depth: 0,
6804
- overrideAccess: true
6805
- });
6806
- if (defaults.docs.length > 0) {
6807
- policy = defaults.docs[0];
6808
- }
6809
- }
6810
- if (!policy) return doc;
6811
- const now = /* @__PURE__ */ new Date();
6812
- const firstResponseMinutes = policy.firstResponseTime || 240;
6813
- const resolutionMinutes = policy.resolutionTime || 1440;
6814
- const firstResponseDue = new Date(now.getTime() + firstResponseMinutes * 6e4);
6815
- const resolutionDue = new Date(now.getTime() + resolutionMinutes * 6e4);
6816
- await payload.update({
6817
- collection: slugs.tickets,
6818
- id: doc.id,
6819
- data: {
6820
- slaPolicy: policy.id,
6821
- slaFirstResponseDue: firstResponseDue.toISOString(),
6822
- slaResolutionDue: resolutionDue.toISOString()
6823
- },
6824
- overrideAccess: true
6825
- });
6826
- } catch (err) {
6827
- console.error("[support] Failed to auto-calculate SLA:", err);
6828
- }
6829
- return doc;
6830
- };
6831
- }
6832
7386
  function createFireTicketWebhooks(slugs) {
6833
7387
  return async ({ doc, previousDoc, operation, req }) => {
6834
7388
  const { payload } = req;
@@ -6920,8 +7474,7 @@ function createCascadeDelete(slugs) {
6920
7474
  return async ({ id, req }) => {
6921
7475
  const collections = [slugs.ticketMessages, slugs.ticketActivityLog, slugs.timeEntries, slugs.satisfactionSurveys];
6922
7476
  for (const slug of collections) {
6923
- await req.payload.delete({
6924
- collection: slug,
7477
+ await dbDelete(req.payload, slug, {
6925
7478
  where: { ticket: { equals: id } },
6926
7479
  overrideAccess: true
6927
7480
  });
@@ -7046,6 +7599,7 @@ function createTicketsCollection(slugs, options) {
7046
7599
  {
7047
7600
  name: "status",
7048
7601
  type: "select",
7602
+ index: true,
7049
7603
  defaultValue: "open",
7050
7604
  label: "Statut",
7051
7605
  options: [
@@ -7060,6 +7614,7 @@ function createTicketsCollection(slugs, options) {
7060
7614
  {
7061
7615
  name: "priority",
7062
7616
  type: "select",
7617
+ index: true,
7063
7618
  defaultValue: "normal",
7064
7619
  label: "Priorite",
7065
7620
  options: [
@@ -7075,7 +7630,7 @@ function createTicketsCollection(slugs, options) {
7075
7630
  {
7076
7631
  type: "row",
7077
7632
  fields: [
7078
- { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, label: "Client", admin: { width: "50%" } }
7633
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client", admin: { width: "50%" } }
7079
7634
  ]
7080
7635
  },
7081
7636
  // Billing collapsible
@@ -7144,15 +7699,16 @@ function createTicketsCollection(slugs, options) {
7144
7699
  {
7145
7700
  type: "row",
7146
7701
  fields: [
7147
- { name: "slaFirstResponseDue", type: "date", label: "Echeance 1ere reponse (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7148
- { name: "slaResolutionDue", type: "date", label: "Echeance resolution (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
7702
+ { name: "slaFirstResponseDue", type: "date", index: true, label: "Echeance 1ere reponse (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7703
+ { name: "slaResolutionDue", type: "date", index: true, label: "Echeance resolution (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
7149
7704
  ]
7150
7705
  },
7151
7706
  {
7152
7707
  type: "row",
7153
7708
  fields: [
7154
7709
  { name: "slaFirstResponseBreached", type: "checkbox", label: "1ere reponse depassee", admin: { readOnly: true, width: "50%" } },
7155
- { name: "slaResolutionBreached", type: "checkbox", label: "Resolution depassee", admin: { readOnly: true, width: "50%" } }
7710
+ { name: "slaResolutionBreached", type: "checkbox", label: "Resolution depassee", admin: { readOnly: true, width: "50%" } },
7711
+ { name: "slaPausedAt", type: "date", label: "SLA en pause depuis", admin: { hidden: true } }
7156
7712
  ]
7157
7713
  }
7158
7714
  ]
@@ -7174,7 +7730,7 @@ function createTicketsCollection(slugs, options) {
7174
7730
  },
7175
7731
  { name: "mergedInto", type: "relationship", relationTo: slugs.tickets, label: "Fusionne dans", admin: { readOnly: true } },
7176
7732
  { name: "autoCloseRemindedAt", type: "date", label: "Rappel auto-close envoye", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7177
- { name: "autoCloseScheduledAt", type: "date", label: "Fermeture auto programmee", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" }, description: "Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client." } }
7733
+ { name: "autoCloseScheduledAt", type: "date", index: true, label: "Fermeture auto programmee", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" }, description: "Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client." } }
7178
7734
  ]
7179
7735
  },
7180
7736
  // Sidebar
@@ -7183,6 +7739,7 @@ function createTicketsCollection(slugs, options) {
7183
7739
  {
7184
7740
  name: "category",
7185
7741
  type: "select",
7742
+ index: true,
7186
7743
  label: "Categorie",
7187
7744
  options: [
7188
7745
  { label: "Bug / Dysfonctionnement", value: "bug" },
@@ -7193,7 +7750,8 @@ function createTicketsCollection(slugs, options) {
7193
7750
  ],
7194
7751
  admin: { position: "sidebar" }
7195
7752
  },
7196
- { name: "assignedTo", type: "relationship", relationTo: slugs.users, label: "Assigne a", admin: { position: "sidebar" } },
7753
+ { name: "assignedTo", type: "relationship", relationTo: slugs.users, index: true, label: "Assigne a", admin: { position: "sidebar" } },
7754
+ { name: "team", type: "relationship", relationTo: slugs.supportTeams, index: true, label: "Equipe", admin: { position: "sidebar" } },
7197
7755
  {
7198
7756
  name: "source",
7199
7757
  type: "select",
@@ -7203,7 +7761,9 @@ function createTicketsCollection(slugs, options) {
7203
7761
  { label: "Portail client", value: "portal" },
7204
7762
  { label: "Chat en direct", value: "live-chat" },
7205
7763
  { label: "Email", value: "email" },
7206
- { label: "Admin", value: "admin" }
7764
+ { label: "Admin", value: "admin" },
7765
+ { label: "WhatsApp", value: "whatsapp" },
7766
+ { label: "Messenger", value: "messenger" }
7207
7767
  ],
7208
7768
  admin: { position: "sidebar" }
7209
7769
  },
@@ -7254,8 +7814,8 @@ function createTicketsCollection(slugs, options) {
7254
7814
  afterChange: [
7255
7815
  createTrackSLA(slugs),
7256
7816
  createTrackAiSummaryOnResolve(slugs),
7257
- createAutoCalculateSLA(slugs),
7258
7817
  createAssignSlaDeadlines(slugs, notificationSlug),
7818
+ createPauseSlaOnHold(slugs),
7259
7819
  createCheckSlaOnResolve(slugs, notificationSlug),
7260
7820
  createLogTicketActivity(slugs),
7261
7821
  createNotifyOnAssignment(slugs),
@@ -7263,7 +7823,8 @@ function createTicketsCollection(slugs, options) {
7263
7823
  createTicketStatusEmail(slugs),
7264
7824
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
7265
7825
  createFireTicketWebhooks(slugs),
7266
- createDispatchWebhookOnTicket(slugs)
7826
+ createDispatchWebhookOnTicket(slugs),
7827
+ createApplyAutomationRules(slugs)
7267
7828
  ],
7268
7829
  beforeDelete: [createCascadeDelete(slugs)]
7269
7830
  },
@@ -7274,12 +7835,20 @@ function createTicketsCollection(slugs, options) {
7274
7835
  return false;
7275
7836
  },
7276
7837
  read: async ({ req }) => {
7277
- if (req.user?.collection === slugs.users) return true;
7838
+ if (req.user?.collection === slugs.users) {
7839
+ if (!isTeamScopingEnabled()) return true;
7840
+ const teamIds = await resolveAgentTeamIds(req.payload, slugs, req.user.id);
7841
+ return {
7842
+ or: [
7843
+ { team: { in: teamIds.length > 0 ? teamIds : [-1] } },
7844
+ { team: { exists: false } }
7845
+ ]
7846
+ };
7847
+ }
7278
7848
  if (req.user?.collection === slugs.supportClients) {
7279
7849
  let collabTicketIds = [];
7280
7850
  try {
7281
- const rows = await req.payload.find({
7282
- collection: "ticket-collaborators",
7851
+ const rows = await dbFind(req.payload, "ticket-collaborators", {
7283
7852
  where: { client: { equals: req.user.id } },
7284
7853
  limit: 1e3,
7285
7854
  depth: 0,
@@ -7314,6 +7883,125 @@ function createTicketsCollection(slugs, options) {
7314
7883
  };
7315
7884
  }
7316
7885
 
7886
+ // src/utils/ticketAccess.ts
7887
+ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
7888
+ const ids = /* @__PURE__ */ new Set();
7889
+ try {
7890
+ const owned = await dbFind(payload, slugs.tickets, {
7891
+ where: { client: { equals: clientId } },
7892
+ limit: 5e3,
7893
+ depth: 0,
7894
+ overrideAccess: true
7895
+ });
7896
+ for (const t of owned.docs) ids.add(t.id);
7897
+ } catch {
7898
+ }
7899
+ try {
7900
+ const collab = await dbFind(payload, "ticket-collaborators", {
7901
+ where: { client: { equals: clientId } },
7902
+ limit: 1e3,
7903
+ depth: 0,
7904
+ overrideAccess: true
7905
+ });
7906
+ for (const r of collab.docs) {
7907
+ const row = r;
7908
+ const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
7909
+ if (tid !== void 0 && tid !== null) ids.add(tid);
7910
+ }
7911
+ } catch {
7912
+ }
7913
+ return Array.from(ids);
7914
+ }
7915
+ var OPTIONS = {
7916
+ allowedTags: [
7917
+ "p",
7918
+ "br",
7919
+ "b",
7920
+ "strong",
7921
+ "i",
7922
+ "em",
7923
+ "u",
7924
+ "s",
7925
+ "strike",
7926
+ "del",
7927
+ "ins",
7928
+ "mark",
7929
+ "a",
7930
+ "ul",
7931
+ "ol",
7932
+ "li",
7933
+ "blockquote",
7934
+ "code",
7935
+ "pre",
7936
+ "kbd",
7937
+ "span",
7938
+ "div",
7939
+ "h1",
7940
+ "h2",
7941
+ "h3",
7942
+ "h4",
7943
+ "h5",
7944
+ "h6",
7945
+ "hr",
7946
+ "img",
7947
+ "table",
7948
+ "thead",
7949
+ "tbody",
7950
+ "tr",
7951
+ "th",
7952
+ "td"
7953
+ ],
7954
+ allowedAttributes: {
7955
+ a: ["href", "title", "target", "rel"],
7956
+ img: ["src", "alt", "title", "width", "height"],
7957
+ code: ["class"],
7958
+ pre: ["class"],
7959
+ span: ["class"],
7960
+ "*": ["style"]
7961
+ },
7962
+ allowedSchemes: ["http", "https", "mailto", "tel"],
7963
+ allowedSchemesByTag: { img: ["http", "https", "data"] },
7964
+ allowProtocolRelative: false,
7965
+ disallowedTagsMode: "discard",
7966
+ // Force safe rel on links opening a new tab (defense against tabnabbing).
7967
+ transformTags: {
7968
+ a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer nofollow" }, true)
7969
+ },
7970
+ // Only allow CSS that cannot carry a script payload.
7971
+ allowedStyles: {
7972
+ "*": {
7973
+ color: [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
7974
+ "background-color": [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
7975
+ "text-align": [/^(left|right|center|justify)$/],
7976
+ "font-weight": [/^(normal|bold|[1-9]00)$/],
7977
+ "font-style": [/^(normal|italic)$/],
7978
+ "text-decoration": [/^(underline|line-through|none)$/]
7979
+ }
7980
+ }
7981
+ };
7982
+ function sanitizeMessageHtml(html) {
7983
+ if (!html) return html;
7984
+ return sanitizeHtml(html, OPTIONS);
7985
+ }
7986
+
7987
+ // src/utils/notificationQueue.ts
7988
+ async function queueClientNotification(payload, slugs, item) {
7989
+ try {
7990
+ await dbCreate(payload, slugs.notificationQueue, {
7991
+ data: {
7992
+ client: item.client,
7993
+ type: item.type,
7994
+ title: item.title,
7995
+ message: item.message,
7996
+ ...item.ticket ? { ticket: item.ticket } : {}
7997
+ },
7998
+ overrideAccess: true
7999
+ });
8000
+ } catch (err) {
8001
+ console.error("[support] Failed to queue client notification:", err);
8002
+ }
8003
+ }
8004
+
7317
8005
  // src/collections/TicketMessages.ts
7318
8006
  function createAssignAuthor(slugs) {
7319
8007
  return async ({ data, operation, req }) => {
@@ -7376,29 +8064,46 @@ function createNotifyClient(slugs) {
7376
8064
  const internalBcc = process.env.SUPPORT_CLIENT_BCC || void 0;
7377
8065
  const rawContent = doc.bodyHtml ? emailRichContent(doc.bodyHtml) : emailQuote(doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body);
7378
8066
  const replyPreview = (doc.body || "").replace(/\s+/g, " ").trim().slice(0, 90);
7379
- await req.payload.sendEmail({
7380
- to: client.email,
7381
- ...supportEmail ? { replyTo: supportEmail } : {},
7382
- ...internalBcc ? { bcc: internalBcc } : {},
7383
- subject: `Re: [${ticketNumber}] ${subject}`,
7384
- html: emailWrapper(`Nouvelle reponse \u2014 ${ticketNumber}`, [
7385
- emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
7386
- emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`),
7387
- rawContent,
7388
- emailButton("Consulter le ticket", portalUrl),
7389
- emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
7390
- emailTrackingPixel(ticketId, doc.id)
7391
- ].join(""), {
7392
- kind: "reply",
7393
- preheader: replyPreview ? `${replyPreview}\u2026` : `Nouvelle reponse a votre ticket ${ticketNumber}`
7394
- })
7395
- });
7396
- await req.payload.update({
7397
- collection: slugs.ticketMessages,
7398
- id: doc.id,
7399
- data: { emailSentAt: (/* @__PURE__ */ new Date()).toISOString(), emailSentTo: client.email },
7400
- overrideAccess: true
7401
- });
8067
+ const freq = client.notificationFrequency;
8068
+ if (freq === "daily" || freq === "weekly") {
8069
+ await queueClientNotification(req.payload, slugs, {
8070
+ client: client.id,
8071
+ type: "reply",
8072
+ title: `Nouvelle reponse \u2014 ${ticketNumber}`,
8073
+ message: replyPreview || `Reponse a votre ticket ${ticketNumber}`,
8074
+ ticket: ticketId
8075
+ });
8076
+ return doc;
8077
+ }
8078
+ void (async () => {
8079
+ try {
8080
+ await req.payload.sendEmail({
8081
+ to: client.email,
8082
+ ...supportEmail ? { replyTo: supportEmail } : {},
8083
+ ...internalBcc ? { bcc: internalBcc } : {},
8084
+ subject: `Re: [${ticketNumber}] ${subject}`,
8085
+ html: emailWrapper(`Nouvelle reponse \u2014 ${ticketNumber}`, [
8086
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
8087
+ emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`),
8088
+ rawContent,
8089
+ emailButton("Consulter le ticket", portalUrl),
8090
+ emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
8091
+ emailTrackingPixel(ticketId, doc.id)
8092
+ ].join(""), {
8093
+ kind: "reply",
8094
+ preheader: replyPreview ? `${replyPreview}\u2026` : `Nouvelle reponse a votre ticket ${ticketNumber}`
8095
+ })
8096
+ });
8097
+ await req.payload.update({
8098
+ collection: slugs.ticketMessages,
8099
+ id: doc.id,
8100
+ data: { emailSentAt: (/* @__PURE__ */ new Date()).toISOString(), emailSentTo: client.email },
8101
+ overrideAccess: true
8102
+ });
8103
+ } catch (err) {
8104
+ console.error("[support] Failed to notify client (async):", err);
8105
+ }
8106
+ })();
7402
8107
  } catch (err) {
7403
8108
  console.error("[support] Failed to notify client:", err);
7404
8109
  }
@@ -7436,8 +8141,7 @@ function createSyncTicketReplyToChat(slugs) {
7436
8141
  if (!ticket?.chatSession) return doc;
7437
8142
  if (doc.skipNotification) return doc;
7438
8143
  const clientId = typeof ticket.client === "object" ? ticket.client.id : ticket.client;
7439
- await payload.create({
7440
- collection: slugs.chatMessages,
8144
+ await dbCreate(payload, slugs.chatMessages, {
7441
8145
  data: {
7442
8146
  session: ticket.chatSession,
7443
8147
  client: clientId,
@@ -7554,6 +8258,57 @@ function createDispatchWebhookOnReply(slugs) {
7554
8258
  return doc;
7555
8259
  };
7556
8260
  }
8261
+ function createSanitizeMessageHtml() {
8262
+ return ({ data }) => {
8263
+ if (data && typeof data.bodyHtml === "string" && data.bodyHtml) {
8264
+ data.bodyHtml = sanitizeMessageHtml(data.bodyHtml);
8265
+ }
8266
+ return data;
8267
+ };
8268
+ }
8269
+ var MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
8270
+ function createResolveMentions(slugs) {
8271
+ return async ({ data, operation, req }) => {
8272
+ if (operation !== "create") return data;
8273
+ const body = typeof data?.body === "string" ? data.body : "";
8274
+ const emails = Array.from(new Set([...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase())));
8275
+ if (emails.length === 0) return data;
8276
+ try {
8277
+ const users = await dbFind(req.payload, slugs.users, { where: { email: { in: emails } }, limit: 50, depth: 0, overrideAccess: true });
8278
+ const ids = users.docs.map((u) => u.id);
8279
+ if (ids.length > 0) data.mentions = ids;
8280
+ } catch {
8281
+ }
8282
+ return data;
8283
+ };
8284
+ }
8285
+ function createNotifyMentions(slugs) {
8286
+ return async ({ doc, operation, req }) => {
8287
+ if (operation !== "create") return doc;
8288
+ const mentions = Array.isArray(doc.mentions) ? doc.mentions : [];
8289
+ if (mentions.length === 0) return doc;
8290
+ const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
8291
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
8292
+ for (const m of mentions) {
8293
+ const userId = typeof m === "object" ? m.id : m;
8294
+ try {
8295
+ const user = await dbFindByID(req.payload, slugs.users, { id: userId, depth: 0, overrideAccess: true });
8296
+ if (!user?.email) continue;
8297
+ await req.payload.sendEmail({
8298
+ to: user.email,
8299
+ subject: `Vous avez ete mentionne \u2014 ticket #${ticketId}`,
8300
+ html: emailWrapper("Mention", [
8301
+ emailParagraph(`Vous avez ete mentionne dans un message du ticket <strong>#${escapeHtml(String(ticketId))}</strong>.`),
8302
+ ...baseUrl ? [emailButton("Ouvrir le ticket", `${baseUrl}/admin/collections/${slugs.tickets}/${ticketId}`, "dark")] : []
8303
+ ].join(""), { kind: "system" })
8304
+ });
8305
+ } catch (err) {
8306
+ console.error("[support] Failed to notify mention:", err);
8307
+ }
8308
+ }
8309
+ return doc;
8310
+ };
8311
+ }
7557
8312
  function createTicketMessagesCollection(slugs, options) {
7558
8313
  const notificationSlug = options?.notificationSlug || "admin-notifications";
7559
8314
  return {
@@ -7561,7 +8316,7 @@ function createTicketMessagesCollection(slugs, options) {
7561
8316
  labels: { singular: "Message", plural: "Messages" },
7562
8317
  admin: { hidden: true, group: "Support", defaultColumns: ["ticket", "authorType", "createdAt"] },
7563
8318
  fields: [
7564
- { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, label: "Ticket" },
8319
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, index: true, label: "Ticket" },
7565
8320
  { name: "body", type: "textarea", required: true, label: "Message" },
7566
8321
  { name: "bodyHtml", type: "textarea", label: "Message HTML", admin: { hidden: true } },
7567
8322
  {
@@ -7570,6 +8325,7 @@ function createTicketMessagesCollection(slugs, options) {
7570
8325
  {
7571
8326
  name: "authorType",
7572
8327
  type: "select",
8328
+ index: true,
7573
8329
  label: "Type d'auteur",
7574
8330
  defaultValue: "admin",
7575
8331
  options: [
@@ -7605,8 +8361,9 @@ function createTicketMessagesCollection(slugs, options) {
7605
8361
  },
7606
8362
  { name: "isInternal", type: "checkbox", defaultValue: false, label: "Note interne", admin: { position: "sidebar" } },
7607
8363
  { name: "isSolution", type: "checkbox", defaultValue: false, label: "Reponse solution", admin: { position: "sidebar" } },
8364
+ { name: "mentions", type: "relationship", relationTo: slugs.users, hasMany: true, label: "Agents mentionnes", admin: { hidden: true } },
7608
8365
  { name: "skipNotification", type: "checkbox", defaultValue: false, label: "Sans notification", admin: { position: "sidebar", condition: (data) => data?.skipNotification === true } },
7609
- { name: "scheduledAt", type: "date", label: "Programme pour", admin: { date: { pickerAppearance: "dayAndTime" }, position: "sidebar", condition: (data) => !!data?.scheduledAt } },
8366
+ { name: "scheduledAt", type: "date", index: true, label: "Programme pour", admin: { date: { pickerAppearance: "dayAndTime" }, position: "sidebar", condition: (data) => !!data?.scheduledAt } },
7610
8367
  { name: "scheduledSent", type: "checkbox", defaultValue: false, admin: { hidden: true } },
7611
8368
  { name: "editedAt", type: "date", label: "Modifie le", admin: { hidden: true } },
7612
8369
  { name: "deletedAt", type: "date", label: "Supprime le", admin: { hidden: true } },
@@ -7615,7 +8372,7 @@ function createTicketMessagesCollection(slugs, options) {
7615
8372
  { name: "emailOpenedAt", type: "date", label: "Email ouvert le", admin: { hidden: true } }
7616
8373
  ],
7617
8374
  hooks: {
7618
- beforeChange: [createAssignAuthor(slugs)],
8375
+ beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
7619
8376
  afterChange: [
7620
8377
  createAutoUpdateStatus(slugs),
7621
8378
  createNotifyClient(slugs),
@@ -7624,17 +8381,19 @@ function createTicketMessagesCollection(slugs, options) {
7624
8381
  createSyncTicketReplyToChat(slugs),
7625
8382
  createNotifyAdminOnClientMessage(slugs, notificationSlug),
7626
8383
  createFireMessageWebhooks(slugs),
7627
- createDispatchWebhookOnReply(slugs)
8384
+ createDispatchWebhookOnReply(slugs),
8385
+ createNotifyMentions(slugs)
7628
8386
  ]
7629
8387
  },
7630
8388
  access: {
7631
8389
  create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
7632
- read: ({ req }) => {
8390
+ read: async ({ req }) => {
7633
8391
  if (req.user?.collection === slugs.users) return true;
7634
8392
  if (req.user?.collection === slugs.supportClients) {
8393
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
7635
8394
  return {
7636
8395
  and: [
7637
- { "ticket.client": { equals: req.user.id } },
8396
+ { ticket: { in: ids.length > 0 ? ids : [-1] } },
7638
8397
  { isInternal: { equals: false } },
7639
8398
  { or: [{ scheduledAt: { exists: false } }, { scheduledSent: { equals: true } }] }
7640
8399
  ]
@@ -7654,8 +8413,6 @@ function createTicketMessagesCollection(slugs, options) {
7654
8413
  timestamps: true
7655
8414
  };
7656
8415
  }
7657
-
7658
- // src/collections/SupportClients.ts
7659
8416
  function createSendInvitationOnCreate(slugs) {
7660
8417
  return async ({ doc, operation, req }) => {
7661
8418
  if (operation !== "create") return doc;
@@ -7695,6 +8452,26 @@ function createSendInvitationOnCreate(slugs) {
7695
8452
  return doc;
7696
8453
  };
7697
8454
  }
8455
+ var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
8456
+ function createEnforce2FA(slugs) {
8457
+ return async ({ req, user }) => {
8458
+ if (!user?.twoFactorEnabled) return user;
8459
+ const raw = user.twoFactorVerifiedAt;
8460
+ const verifiedAt = raw ? new Date(raw).getTime() : 0;
8461
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
8462
+ throw new APIError("2FA_REQUIRED", 401);
8463
+ }
8464
+ await req.payload.update({
8465
+ collection: slugs.supportClients,
8466
+ id: user.id,
8467
+ data: { twoFactorVerifiedAt: null },
8468
+ req,
8469
+ overrideAccess: true
8470
+ });
8471
+ user.twoFactorVerifiedAt = null;
8472
+ return user;
8473
+ };
8474
+ }
7698
8475
  function createSupportClientsCollection(slugs) {
7699
8476
  return {
7700
8477
  slug: slugs.supportClients,
@@ -7823,6 +8600,10 @@ function createSupportClientsCollection(slugs) {
7823
8600
  name: "opportunities",
7824
8601
  type: "textarea",
7825
8602
  label: "Opportunites commerciales",
8603
+ access: {
8604
+ // Admin-only: never serialize commercial notes onto the client's own doc.
8605
+ read: ({ req }) => req.user?.collection === slugs.users
8606
+ },
7826
8607
  admin: {
7827
8608
  description: "Notes commerciales (visible uniquement par les admins)"
7828
8609
  }
@@ -7866,6 +8647,19 @@ function createSupportClientsCollection(slugs) {
7866
8647
  type: "date",
7867
8648
  admin: { hidden: true }
7868
8649
  },
8650
+ {
8651
+ // Set by /support/2fa "verify"; gates login via the beforeLogin hook.
8652
+ name: "twoFactorVerifiedAt",
8653
+ type: "date",
8654
+ admin: { hidden: true }
8655
+ },
8656
+ {
8657
+ // Google OAuth subject id — accounts are linked by this, not email alone.
8658
+ name: "googleId",
8659
+ type: "text",
8660
+ index: true,
8661
+ admin: { hidden: true }
8662
+ },
7869
8663
  {
7870
8664
  name: "notifyOnReply",
7871
8665
  type: "checkbox",
@@ -7932,6 +8726,10 @@ function createSupportClientsCollection(slugs) {
7932
8726
  name: "notes",
7933
8727
  type: "textarea",
7934
8728
  label: "Notes internes",
8729
+ access: {
8730
+ // Admin-only: never serialize internal notes onto the client's own doc.
8731
+ read: ({ req }) => req.user?.collection === slugs.users
8732
+ },
7935
8733
  admin: {
7936
8734
  description: "Visible uniquement par les admins",
7937
8735
  position: "sidebar"
@@ -7939,6 +8737,7 @@ function createSupportClientsCollection(slugs) {
7939
8737
  }
7940
8738
  ],
7941
8739
  hooks: {
8740
+ beforeLogin: [createEnforce2FA(slugs)],
7942
8741
  afterChange: [createSendInvitationOnCreate(slugs)]
7943
8742
  },
7944
8743
  access: {
@@ -8152,6 +8951,7 @@ function createTicketActivityLogCollection(slugs) {
8152
8951
  type: "relationship",
8153
8952
  relationTo: slugs.tickets,
8154
8953
  required: true,
8954
+ index: true,
8155
8955
  label: "Ticket"
8156
8956
  },
8157
8957
  {
@@ -8197,10 +8997,11 @@ function createTicketActivityLogCollection(slugs) {
8197
8997
  access: {
8198
8998
  create: () => false,
8199
8999
  // Created only by hooks via overrideAccess
8200
- read: ({ req }) => {
9000
+ read: async ({ req }) => {
8201
9001
  if (req.user?.collection === slugs.users) return true;
8202
9002
  if (req.user?.collection === slugs.supportClients) {
8203
- return { "ticket.client": { equals: req.user.id } };
9003
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
9004
+ return { ticket: { in: ids.length > 0 ? ids : [-1] } };
8204
9005
  }
8205
9006
  return false;
8206
9007
  },
@@ -8269,12 +9070,21 @@ function createSatisfactionSurveysCollection(slugs) {
8269
9070
  {
8270
9071
  name: "rating",
8271
9072
  type: "number",
8272
- required: true,
8273
9073
  min: 1,
8274
9074
  max: 5,
8275
9075
  label: "Note (1-5)",
8276
9076
  admin: {
8277
- description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait"
9077
+ description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait (CSAT)"
9078
+ }
9079
+ },
9080
+ {
9081
+ name: "nps",
9082
+ type: "number",
9083
+ min: 0,
9084
+ max: 10,
9085
+ label: "NPS (0-10)",
9086
+ admin: {
9087
+ description: "\xAB Recommanderiez-vous ? \xBB \u2014 promoteur 9-10, passif 7-8, d\xE9tracteur 0-6"
8278
9088
  }
8279
9089
  },
8280
9090
  {
@@ -9520,6 +10330,124 @@ function createTicketCollaboratorsCollection(slugs) {
9520
10330
  };
9521
10331
  }
9522
10332
 
10333
+ // src/collections/NotificationQueue.ts
10334
+ function createNotificationQueueCollection(slugs) {
10335
+ return {
10336
+ slug: slugs.notificationQueue,
10337
+ labels: { singular: "Notification en file", plural: "Notifications en file" },
10338
+ admin: { hidden: true, group: "Support", defaultColumns: ["client", "type", "title", "createdAt"] },
10339
+ access: {
10340
+ // Internal: written/drained only via overrideAccess (hooks + cron).
10341
+ create: () => false,
10342
+ update: () => false,
10343
+ read: ({ req }) => req.user?.collection === slugs.users,
10344
+ delete: ({ req }) => req.user?.collection === slugs.users
10345
+ },
10346
+ fields: [
10347
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client" },
10348
+ { name: "type", type: "text", label: "Type" },
10349
+ { name: "title", type: "text", label: "Titre" },
10350
+ { name: "message", type: "textarea", label: "Message" },
10351
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, label: "Ticket" }
10352
+ ],
10353
+ timestamps: true
10354
+ };
10355
+ }
10356
+
10357
+ // src/collections/AutomationRules.ts
10358
+ var CONDITION_FIELDS = ["status", "priority", "category", "source", "subject"];
10359
+ var ACTION_TYPES = [
10360
+ { label: "D\xE9finir le statut", value: "set_status" },
10361
+ { label: "D\xE9finir la priorit\xE9", value: "set_priority" },
10362
+ { label: "D\xE9finir la cat\xE9gorie", value: "set_category" },
10363
+ { label: "Assigner \xE0 (id agent)", value: "assign" },
10364
+ { label: "Ajouter un tag", value: "add_tag" }
10365
+ ];
10366
+ function createAutomationRulesCollection(slugs) {
10367
+ return {
10368
+ slug: slugs.automationRules,
10369
+ labels: { singular: "R\xE8gle d'automatisation", plural: "R\xE8gles d'automatisation" },
10370
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "event", "enabled", "order"] },
10371
+ access: {
10372
+ read: ({ req }) => req.user?.collection === slugs.users,
10373
+ create: ({ req }) => req.user?.collection === slugs.users,
10374
+ update: ({ req }) => req.user?.collection === slugs.users,
10375
+ delete: ({ req }) => req.user?.collection === slugs.users
10376
+ },
10377
+ fields: [
10378
+ { name: "name", type: "text", required: true, label: "Nom" },
10379
+ { name: "enabled", type: "checkbox", defaultValue: true, label: "Active", index: true },
10380
+ {
10381
+ name: "event",
10382
+ type: "select",
10383
+ defaultValue: "ticket_created",
10384
+ index: true,
10385
+ label: "D\xE9clencheur",
10386
+ options: [
10387
+ { label: "Ticket cr\xE9\xE9", value: "ticket_created" },
10388
+ { label: "Ticket mis \xE0 jour", value: "ticket_updated" },
10389
+ { label: "Changement de statut", value: "ticket_status_changed" }
10390
+ ]
10391
+ },
10392
+ {
10393
+ name: "matchType",
10394
+ type: "select",
10395
+ defaultValue: "all",
10396
+ label: "Correspondance",
10397
+ options: [{ label: "Toutes les conditions (ET)", value: "all" }, { label: "Au moins une (OU)", value: "any" }]
10398
+ },
10399
+ { name: "order", type: "number", defaultValue: 0, label: "Ordre d'\xE9valuation" },
10400
+ {
10401
+ name: "conditions",
10402
+ type: "array",
10403
+ label: "Conditions",
10404
+ fields: [
10405
+ { name: "field", type: "select", required: true, options: CONDITION_FIELDS.map((v) => ({ label: v, value: v })) },
10406
+ {
10407
+ name: "operator",
10408
+ type: "select",
10409
+ defaultValue: "equals",
10410
+ required: true,
10411
+ options: [{ label: "= \xE9gal", value: "equals" }, { label: "\u2260 diff\xE9rent", value: "not_equals" }, { label: "contient", value: "contains" }]
10412
+ },
10413
+ { name: "value", type: "text", required: true }
10414
+ ]
10415
+ },
10416
+ {
10417
+ name: "actions",
10418
+ type: "array",
10419
+ label: "Actions",
10420
+ fields: [
10421
+ { name: "type", type: "select", required: true, options: ACTION_TYPES },
10422
+ { name: "value", type: "text", required: true }
10423
+ ]
10424
+ }
10425
+ ],
10426
+ timestamps: true
10427
+ };
10428
+ }
10429
+
10430
+ // src/collections/SupportTeam.ts
10431
+ function createSupportTeamCollection(slugs) {
10432
+ return {
10433
+ slug: slugs.supportTeams,
10434
+ labels: { singular: "\xC9quipe support", plural: "\xC9quipes support" },
10435
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "members"] },
10436
+ access: {
10437
+ read: ({ req }) => req.user?.collection === slugs.users,
10438
+ create: ({ req }) => req.user?.collection === slugs.users,
10439
+ update: ({ req }) => req.user?.collection === slugs.users,
10440
+ delete: ({ req }) => req.user?.collection === slugs.users
10441
+ },
10442
+ fields: [
10443
+ { name: "name", type: "text", required: true, label: "Nom" },
10444
+ { name: "description", type: "textarea", label: "Description" },
10445
+ { name: "members", type: "relationship", relationTo: slugs.users, hasMany: true, index: true, label: "Membres (agents)" }
10446
+ ],
10447
+ timestamps: true
10448
+ };
10449
+ }
10450
+
9523
10451
  // src/plugin.ts
9524
10452
  function viewConfig(component, path) {
9525
10453
  return { Component: component, path };
@@ -9555,7 +10483,10 @@ function supportPlugin(config) {
9555
10483
  createSatisfactionSurveysCollection(slugs),
9556
10484
  createKnowledgeBaseCollection(slugs),
9557
10485
  createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
9558
- createTicketCollaboratorsCollection(slugs)
10486
+ createTicketCollaboratorsCollection(slugs),
10487
+ createNotificationQueueCollection(slugs),
10488
+ createAutomationRulesCollection(slugs),
10489
+ createSupportTeamCollection(slugs)
9559
10490
  ];
9560
10491
  if (features.authLogs !== false) supportCollections.push(createAuthLogsCollection(slugs));
9561
10492
  if (features.timeTracking !== false) supportCollections.push(createTimeEntriesCollection(slugs));