@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.cjs CHANGED
@@ -1,10 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  var crypto3 = require('crypto');
4
+ var PDFDocument = require('pdfkit');
5
+ var payload = require('payload');
6
+ var sanitizeHtml = require('sanitize-html');
4
7
 
5
8
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
6
9
 
7
10
  var crypto3__default = /*#__PURE__*/_interopDefault(crypto3);
11
+ var PDFDocument__default = /*#__PURE__*/_interopDefault(PDFDocument);
12
+ var sanitizeHtml__default = /*#__PURE__*/_interopDefault(sanitizeHtml);
8
13
 
9
14
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
10
15
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
@@ -64,6 +69,9 @@ var DEFAULT_SLUGS = {
64
69
  macros: "macros",
65
70
  ticketStatuses: "ticket-statuses",
66
71
  ticketFeedback: "ticket-feedback",
72
+ notificationQueue: "notification-queue",
73
+ automationRules: "automation-rules",
74
+ supportTeams: "support-teams",
67
75
  users: "users",
68
76
  media: "media"
69
77
  };
@@ -95,6 +103,26 @@ function handleAuthError(error) {
95
103
  return null;
96
104
  }
97
105
 
106
+ // src/utils/db.ts
107
+ function dbFind(payload, slug, options = {}) {
108
+ return payload.find({ collection: slug, ...options });
109
+ }
110
+ function dbFindByID(payload, slug, options) {
111
+ return payload.findByID({ collection: slug, ...options });
112
+ }
113
+ function dbCreate(payload, slug, options) {
114
+ return payload.create({ collection: slug, ...options });
115
+ }
116
+ function dbUpdate(payload, slug, options) {
117
+ return payload.update({ collection: slug, ...options });
118
+ }
119
+ function dbCount(payload, slug, options = {}) {
120
+ return payload.count({ collection: slug, ...options });
121
+ }
122
+ function dbDelete(payload, slug, options) {
123
+ return payload.delete({ collection: slug, ...options });
124
+ }
125
+
98
126
  // src/utils/readSettings.ts
99
127
  var PREF_KEY = "support-settings";
100
128
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
@@ -108,10 +136,18 @@ var DEFAULT_USER_PREFS = {
108
136
  locale: "fr",
109
137
  signature: ""
110
138
  };
139
+ var settingsCache = null;
140
+ var SETTINGS_TTL_MS = 6e4;
141
+ function invalidateSupportSettingsCache() {
142
+ settingsCache = null;
143
+ }
111
144
  async function readSupportSettings(payload) {
145
+ if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
146
+ return settingsCache.value;
147
+ }
148
+ let value = { ...DEFAULT_SETTINGS };
112
149
  try {
113
- const prefs = await payload.find({
114
- collection: "payload-preferences",
150
+ const prefs = await dbFind(payload, "payload-preferences", {
115
151
  where: { key: { equals: PREF_KEY } },
116
152
  limit: 1,
117
153
  depth: 0,
@@ -119,7 +155,7 @@ async function readSupportSettings(payload) {
119
155
  });
120
156
  if (prefs.docs.length > 0) {
121
157
  const stored = prefs.docs[0].value;
122
- return {
158
+ value = {
123
159
  email: { ...DEFAULT_SETTINGS.email, ...stored.email },
124
160
  ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
125
161
  sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
@@ -128,13 +164,13 @@ async function readSupportSettings(payload) {
128
164
  }
129
165
  } catch {
130
166
  }
131
- return { ...DEFAULT_SETTINGS };
167
+ settingsCache = { value, ts: Date.now() };
168
+ return value;
132
169
  }
133
170
  async function readUserPrefs(payload, userId) {
134
171
  try {
135
172
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
136
- const prefs = await payload.find({
137
- collection: "payload-preferences",
173
+ const prefs = await dbFind(payload, "payload-preferences", {
138
174
  where: { key: { equals: key } },
139
175
  limit: 1,
140
176
  depth: 0,
@@ -320,8 +356,130 @@ ${text}`;
320
356
  };
321
357
  }
322
358
 
323
- // src/endpoints/client-intelligence.ts
359
+ // src/utils/aiAgent.ts
360
+ async function applyAgentDecision(payload, slugs, ticketId, decision) {
361
+ if (decision.action === "reply" && decision.reply && decision.reply.trim()) {
362
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
363
+ data: {
364
+ ticket: ticketId,
365
+ body: decision.reply.trim(),
366
+ authorType: "admin",
367
+ isInternal: false,
368
+ fromAlias: "Assistant IA"
369
+ },
370
+ overrideAccess: true
371
+ });
372
+ return { acted: "reply", messageId: msg.id, confidence: decision.confidence };
373
+ }
374
+ await dbCreate(payload, slugs.ticketMessages, {
375
+ data: {
376
+ ticket: ticketId,
377
+ body: `[Agent IA] Escalade vers un humain. Raison : ${decision.reason || "non d\xE9termin\xE9e"} (confiance ${Math.round((decision.confidence ?? 0) * 100)} %).`,
378
+ authorType: "admin",
379
+ isInternal: true,
380
+ skipNotification: true
381
+ },
382
+ overrideAccess: true
383
+ });
384
+ await dbUpdate(payload, slugs.tickets, { id: ticketId, data: { status: "escalated" }, overrideAccess: true });
385
+ return { acted: "escalate", confidence: decision.confidence };
386
+ }
324
387
  function getClient2(aiSettings) {
388
+ const Anthropic = __require("@anthropic-ai/sdk").default;
389
+ if (aiSettings.provider === "ollama") {
390
+ return new Anthropic({ apiKey: "ollama", baseURL: process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1" });
391
+ }
392
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
393
+ }
394
+ function stripHtml(s) {
395
+ return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
396
+ }
397
+ function parseDecision(text) {
398
+ try {
399
+ const match = text.match(/\{[\s\S]*\}/);
400
+ if (match) {
401
+ const obj = JSON.parse(match[0]);
402
+ return {
403
+ action: obj.action === "reply" ? "reply" : "escalate",
404
+ reply: typeof obj.reply === "string" ? obj.reply : void 0,
405
+ reason: typeof obj.reason === "string" ? obj.reason : void 0,
406
+ confidence: typeof obj.confidence === "number" ? obj.confidence : 0
407
+ };
408
+ }
409
+ } catch {
410
+ }
411
+ return { action: "escalate", reason: "R\xE9ponse IA non interpr\xE9table", confidence: 0 };
412
+ }
413
+ async function runAiAgent(payload, slugs, ticketId, opts) {
414
+ const settings = await readSupportSettings(payload);
415
+ const threshold = opts?.confidenceThreshold ?? 0.7;
416
+ const ticket = await dbFindByID(payload, slugs.tickets, { id: ticketId, depth: 0, overrideAccess: true });
417
+ const messages = await dbFind(payload, slugs.ticketMessages, { where: { ticket: { equals: ticketId } }, sort: "createdAt", limit: 50, depth: 0, overrideAccess: true });
418
+ const kb = await dbFind(payload, slugs.knowledgeBase, { where: { published: { equals: true } }, limit: 20, depth: 0, overrideAccess: true });
419
+ const conversation = messages.docs.map((m) => `${m.authorType}: ${m.body || ""}`).join("\n");
420
+ const kbText = kb.docs.map((a) => `# ${a.title || ""}
421
+ ${stripHtml(String(a.content ?? ""))}`).join("\n\n").slice(0, 8e3);
422
+ const prompt = `Tu es un agent de support IA. \xC0 partir du ticket et de la base de connaissances ci-dessous, d\xE9cide :
423
+ - "reply" si tu peux r\xE9pondre avec certitude UNIQUEMENT \xE0 partir de la base de connaissances ;
424
+ - "escalate" sinon (question hors KB, ambigu\xEB, sensible, ou n\xE9cessitant une action humaine).
425
+ 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>}.
426
+
427
+ TICKET : ${ticket.subject || ""}
428
+ CONVERSATION :
429
+ ${conversation}
430
+
431
+ BASE DE CONNAISSANCES :
432
+ ${kbText || "(vide)"}`;
433
+ let decision;
434
+ try {
435
+ const client = getClient2(settings.ai);
436
+ const resp = await client.messages.create({
437
+ model: settings.ai.model || "claude-haiku-4-5-20251001",
438
+ max_tokens: 1024,
439
+ messages: [{ role: "user", content: prompt }]
440
+ });
441
+ const text = resp?.content?.[0]?.text || "";
442
+ decision = parseDecision(text);
443
+ } catch (err) {
444
+ console.error("[support] AI agent decision failed:", err);
445
+ decision = { action: "escalate", reason: "Erreur du fournisseur IA", confidence: 0 };
446
+ }
447
+ 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 };
448
+ return applyAgentDecision(payload, slugs, ticketId, finalDecision);
449
+ }
450
+
451
+ // src/endpoints/ai-agent.ts
452
+ function createAiAgentEndpoint(slugs) {
453
+ return {
454
+ path: "/support/ai-agent",
455
+ method: "post",
456
+ handler: async (req) => {
457
+ try {
458
+ requireAdmin(req, slugs);
459
+ let body = {};
460
+ try {
461
+ body = await req.json();
462
+ } catch {
463
+ }
464
+ if (!body.ticketId) {
465
+ return Response.json({ error: "ticketId requis." }, { status: 400 });
466
+ }
467
+ const result = await runAiAgent(req.payload, slugs, body.ticketId, {
468
+ confidenceThreshold: body.confidenceThreshold
469
+ });
470
+ return Response.json({ ok: true, ...result });
471
+ } catch (error) {
472
+ const authResponse = handleAuthError(error);
473
+ if (authResponse) return authResponse;
474
+ console.error("[ai-agent] Error:", error);
475
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
476
+ }
477
+ }
478
+ };
479
+ }
480
+
481
+ // src/endpoints/client-intelligence.ts
482
+ function getClient3(aiSettings) {
325
483
  const Anthropic = __require("@anthropic-ai/sdk").default;
326
484
  if (aiSettings.provider === "ollama") {
327
485
  const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
@@ -475,7 +633,7 @@ R\xE9ponds en JSON strict (pas de markdown, pas de commentaires) avec cette stru
475
633
  }
476
634
 
477
635
  Sois factuel. Ne d\xE9passe pas 5 items par tableau. R\xE9ponds UNIQUEMENT avec le JSON.`;
478
- const anthropic = getClient2(aiSettings);
636
+ const anthropic = getClient3(aiSettings);
479
637
  const model = getModel2(aiSettings);
480
638
  const res = await anthropic.messages.create({
481
639
  model,
@@ -539,8 +697,7 @@ function createSearchEndpoint(slugs) {
539
697
  return Response.json({ tickets: [], clients: [], messages: [], articles: [] });
540
698
  }
541
699
  const [ticketsRes, clientsRes, messagesRes, articlesRes] = await Promise.all([
542
- payload.find({
543
- collection: slugs.tickets,
700
+ dbFind(payload, slugs.tickets, {
544
701
  where: {
545
702
  or: [
546
703
  { ticketNumber: { contains: q } },
@@ -552,8 +709,7 @@ function createSearchEndpoint(slugs) {
552
709
  depth: 1,
553
710
  overrideAccess: true
554
711
  }),
555
- payload.find({
556
- collection: slugs.supportClients,
712
+ dbFind(payload, slugs.supportClients, {
557
713
  where: {
558
714
  or: [
559
715
  { firstName: { contains: q } },
@@ -566,16 +722,14 @@ function createSearchEndpoint(slugs) {
566
722
  depth: 0,
567
723
  overrideAccess: true
568
724
  }),
569
- payload.find({
570
- collection: slugs.ticketMessages,
725
+ dbFind(payload, slugs.ticketMessages, {
571
726
  where: { body: { contains: q } },
572
727
  sort: "-createdAt",
573
728
  limit: 5,
574
729
  depth: 1,
575
730
  overrideAccess: true
576
731
  }),
577
- payload.find({
578
- collection: slugs.knowledgeBase,
732
+ dbFind(payload, slugs.knowledgeBase, {
579
733
  where: { title: { contains: q } },
580
734
  limit: 5,
581
735
  depth: 0,
@@ -675,8 +829,7 @@ function createKbSearchEndpoint(slugs) {
675
829
  orClauses.push({ category: { contains: t } });
676
830
  }
677
831
  orClauses.push({ title: { contains: q } });
678
- const res = await payload.find({
679
- collection: slugs.knowledgeBase,
832
+ const res = await dbFind(payload, slugs.knowledgeBase, {
680
833
  where: {
681
834
  and: [
682
835
  { published: { equals: true } },
@@ -735,16 +888,14 @@ function createBulkActionEndpoint(slugs) {
735
888
  try {
736
889
  switch (action) {
737
890
  case "close":
738
- await payload.update({
739
- collection: slugs.tickets,
891
+ await dbUpdate(payload, slugs.tickets, {
740
892
  id: ticketId,
741
893
  data: { status: "resolved", resolvedAt: (/* @__PURE__ */ new Date()).toISOString() },
742
894
  overrideAccess: true
743
895
  });
744
896
  break;
745
897
  case "reopen":
746
- await payload.update({
747
- collection: slugs.tickets,
898
+ await dbUpdate(payload, slugs.tickets, {
748
899
  id: ticketId,
749
900
  data: { status: "open" },
750
901
  overrideAccess: true
@@ -752,8 +903,7 @@ function createBulkActionEndpoint(slugs) {
752
903
  break;
753
904
  case "assign":
754
905
  if (value) {
755
- await payload.update({
756
- collection: slugs.tickets,
906
+ await dbUpdate(payload, slugs.tickets, {
757
907
  id: ticketId,
758
908
  data: { assignedTo: Number(value) },
759
909
  overrideAccess: true
@@ -762,8 +912,7 @@ function createBulkActionEndpoint(slugs) {
762
912
  break;
763
913
  case "set_priority":
764
914
  if (value) {
765
- await payload.update({
766
- collection: slugs.tickets,
915
+ await dbUpdate(payload, slugs.tickets, {
767
916
  id: ticketId,
768
917
  data: { priority: String(value) },
769
918
  overrideAccess: true
@@ -772,8 +921,7 @@ function createBulkActionEndpoint(slugs) {
772
921
  break;
773
922
  case "set_category":
774
923
  if (value) {
775
- await payload.update({
776
- collection: slugs.tickets,
924
+ await dbUpdate(payload, slugs.tickets, {
777
925
  id: ticketId,
778
926
  data: { category: String(value) },
779
927
  overrideAccess: true
@@ -781,8 +929,7 @@ function createBulkActionEndpoint(slugs) {
781
929
  }
782
930
  break;
783
931
  case "delete":
784
- await payload.delete({
785
- collection: slugs.tickets,
932
+ await dbDelete(payload, slugs.tickets, {
786
933
  id: ticketId,
787
934
  overrideAccess: true
788
935
  });
@@ -821,8 +968,8 @@ function createMergeTicketsEndpoint(slugs) {
821
968
  return Response.json({ error: "Impossible de fusionner un ticket avec lui-m\xEAme" }, { status: 400 });
822
969
  }
823
970
  const [source, target] = await Promise.all([
824
- payload.findByID({ collection: slugs.tickets, id: sourceTicketId, depth: 0, overrideAccess: true }),
825
- payload.findByID({ collection: slugs.tickets, id: targetTicketId, depth: 0, overrideAccess: true })
971
+ dbFindByID(payload, slugs.tickets, { id: sourceTicketId, depth: 0, overrideAccess: true }),
972
+ dbFindByID(payload, slugs.tickets, { id: targetTicketId, depth: 0, overrideAccess: true })
826
973
  ]);
827
974
  if (!source) return Response.json({ error: "Ticket source introuvable" }, { status: 404 });
828
975
  if (!target) return Response.json({ error: "Ticket cible introuvable" }, { status: 404 });
@@ -831,39 +978,34 @@ function createMergeTicketsEndpoint(slugs) {
831
978
  if (sourceClient !== targetClient) {
832
979
  return Response.json({ error: "Les deux tickets doivent appartenir au m\xEAme client" }, { status: 400 });
833
980
  }
834
- const messages = await payload.find({
835
- collection: slugs.ticketMessages,
981
+ const messages = await dbFind(payload, slugs.ticketMessages, {
836
982
  where: { ticket: { equals: sourceTicketId } },
837
983
  limit: 500,
838
984
  depth: 0,
839
985
  overrideAccess: true
840
986
  });
841
987
  for (const msg of messages.docs) {
842
- await payload.update({
843
- collection: slugs.ticketMessages,
988
+ await dbUpdate(payload, slugs.ticketMessages, {
844
989
  id: msg.id,
845
990
  data: { ticket: targetTicketId },
846
991
  overrideAccess: true
847
992
  });
848
993
  }
849
- const timeEntries = await payload.find({
850
- collection: slugs.timeEntries,
994
+ const timeEntries = await dbFind(payload, slugs.timeEntries, {
851
995
  where: { ticket: { equals: sourceTicketId } },
852
996
  limit: 500,
853
997
  depth: 0,
854
998
  overrideAccess: true
855
999
  });
856
1000
  for (const entry of timeEntries.docs) {
857
- await payload.update({
858
- collection: slugs.timeEntries,
1001
+ await dbUpdate(payload, slugs.timeEntries, {
859
1002
  id: entry.id,
860
1003
  data: { ticket: targetTicketId },
861
1004
  overrideAccess: true
862
1005
  });
863
1006
  }
864
1007
  const sourceNumber = source.ticketNumber || `#${sourceTicketId}`;
865
- await payload.create({
866
- collection: slugs.ticketMessages,
1008
+ await dbCreate(payload, slugs.ticketMessages, {
867
1009
  data: {
868
1010
  ticket: targetTicketId,
869
1011
  body: `Messages fusionn\xE9s depuis ${sourceNumber} (${messages.totalDocs} messages, ${timeEntries.totalDocs} entr\xE9es de temps)`,
@@ -873,13 +1015,11 @@ function createMergeTicketsEndpoint(slugs) {
873
1015
  },
874
1016
  overrideAccess: true
875
1017
  });
876
- await payload.delete({
877
- collection: slugs.tickets,
1018
+ await dbDelete(payload, slugs.tickets, {
878
1019
  id: sourceTicketId,
879
1020
  overrideAccess: true
880
1021
  });
881
- const allTimeEntries = await payload.find({
882
- collection: slugs.timeEntries,
1022
+ const allTimeEntries = await dbFind(payload, slugs.timeEntries, {
883
1023
  where: { ticket: { equals: targetTicketId } },
884
1024
  limit: 500,
885
1025
  depth: 0,
@@ -889,8 +1029,7 @@ function createMergeTicketsEndpoint(slugs) {
889
1029
  (sum, e) => sum + (e.duration || 0),
890
1030
  0
891
1031
  );
892
- await payload.update({
893
- collection: slugs.tickets,
1032
+ await dbUpdate(payload, slugs.tickets, {
894
1033
  id: targetTicketId,
895
1034
  data: { totalTimeMinutes: totalMinutes },
896
1035
  overrideAccess: true
@@ -925,8 +1064,7 @@ function createSplitTicketEndpoint(slugs) {
925
1064
  if (!messageId) {
926
1065
  return Response.json({ error: "messageId required" }, { status: 400 });
927
1066
  }
928
- const message = await payload.findByID({
929
- collection: slugs.ticketMessages,
1067
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
930
1068
  id: messageId,
931
1069
  depth: 1,
932
1070
  overrideAccess: true
@@ -939,8 +1077,7 @@ function createSplitTicketEndpoint(slugs) {
939
1077
  return Response.json({ error: "Cannot resolve source ticket" }, { status: 400 });
940
1078
  }
941
1079
  const clientId = typeof sourceTicket.client === "object" ? sourceTicket.client.id : sourceTicket.client;
942
- const newTicket = await payload.create({
943
- collection: slugs.tickets,
1080
+ const newTicket = await dbCreate(payload, slugs.tickets, {
944
1081
  data: {
945
1082
  subject: subject || `Split: ${sourceTicket.subject}`,
946
1083
  client: clientId,
@@ -955,8 +1092,7 @@ function createSplitTicketEndpoint(slugs) {
955
1092
  const attachments = message.attachments?.map((a) => ({
956
1093
  file: typeof a.file === "object" ? a.file.id : a.file
957
1094
  })) || [];
958
- await payload.create({
959
- collection: slugs.ticketMessages,
1095
+ await dbCreate(payload, slugs.ticketMessages, {
960
1096
  data: {
961
1097
  ticket: newTicket.id,
962
1098
  body: message.body,
@@ -969,8 +1105,7 @@ function createSplitTicketEndpoint(slugs) {
969
1105
  },
970
1106
  overrideAccess: true
971
1107
  });
972
- await payload.create({
973
- collection: slugs.ticketMessages,
1108
+ await dbCreate(payload, slugs.ticketMessages, {
974
1109
  data: {
975
1110
  ticket: sourceTicket.id,
976
1111
  body: `Message extrait vers le ticket ${newTicket.ticketNumber}`,
@@ -982,15 +1117,13 @@ function createSplitTicketEndpoint(slugs) {
982
1117
  });
983
1118
  const existingRelated = Array.isArray(sourceTicket.relatedTickets) ? sourceTicket.relatedTickets.map((t) => typeof t === "object" ? t.id : t) : [];
984
1119
  if (!existingRelated.includes(newTicket.id)) {
985
- await payload.update({
986
- collection: slugs.tickets,
1120
+ await dbUpdate(payload, slugs.tickets, {
987
1121
  id: sourceTicket.id,
988
1122
  data: { relatedTickets: [...existingRelated, newTicket.id] },
989
1123
  overrideAccess: true
990
1124
  });
991
1125
  }
992
- await payload.create({
993
- collection: slugs.ticketActivityLog,
1126
+ await dbCreate(payload, slugs.ticketActivityLog, {
994
1127
  data: {
995
1128
  ticket: sourceTicket.id,
996
1129
  action: "split",
@@ -1205,8 +1338,7 @@ function createSettingsGetEndpoint(slugs) {
1205
1338
  try {
1206
1339
  const payload = req.payload;
1207
1340
  requireAdmin(req, slugs);
1208
- const prefs = await payload.find({
1209
- collection: "payload-preferences",
1341
+ const prefs = await dbFind(payload, "payload-preferences", {
1210
1342
  where: { key: { equals: PREF_KEY2 } },
1211
1343
  limit: 1,
1212
1344
  depth: 0,
@@ -1263,6 +1395,7 @@ function createSettingsPostEndpoint(slugs) {
1263
1395
  ]
1264
1396
  }
1265
1397
  });
1398
+ invalidateSupportSettingsCache();
1266
1399
  return Response.json(merged);
1267
1400
  } catch (error) {
1268
1401
  const authResponse = handleAuthError(error);
@@ -1284,8 +1417,7 @@ function createSignatureGetEndpoint(slugs) {
1284
1417
  try {
1285
1418
  const payload = req.payload;
1286
1419
  requireAdmin(req, slugs);
1287
- const prefs = await payload.find({
1288
- collection: "payload-preferences",
1420
+ const prefs = await dbFind(payload, "payload-preferences", {
1289
1421
  where: { key: { equals: `${PREF_KEY3}-${req.user.id}` } },
1290
1422
  limit: 1,
1291
1423
  depth: 0,
@@ -1312,8 +1444,7 @@ function createSignaturePostEndpoint(slugs) {
1312
1444
  requireAdmin(req, slugs);
1313
1445
  const { signature } = await req.json();
1314
1446
  const key = `${PREF_KEY3}-${req.user.id}`;
1315
- const existing = await payload.find({
1316
- collection: "payload-preferences",
1447
+ const existing = await dbFind(payload, "payload-preferences", {
1317
1448
  where: { key: { equals: key } },
1318
1449
  limit: 1,
1319
1450
  depth: 0,
@@ -1356,8 +1487,7 @@ function createRoundRobinConfigGetEndpoint(slugs) {
1356
1487
  try {
1357
1488
  const payload = req.payload;
1358
1489
  requireAdmin(req, slugs);
1359
- const prefs = await payload.find({
1360
- collection: "payload-preferences",
1490
+ const prefs = await dbFind(payload, "payload-preferences", {
1361
1491
  where: { key: { equals: PREF_KEY4 } },
1362
1492
  limit: 1,
1363
1493
  depth: 0,
@@ -1383,8 +1513,7 @@ function createRoundRobinConfigPostEndpoint(slugs) {
1383
1513
  const payload = req.payload;
1384
1514
  requireAdmin(req, slugs);
1385
1515
  const { enabled } = await req.json();
1386
- const existing = await payload.find({
1387
- collection: "payload-preferences",
1516
+ const existing = await dbFind(payload, "payload-preferences", {
1388
1517
  where: { key: { equals: PREF_KEY4 } },
1389
1518
  limit: 1,
1390
1519
  depth: 0,
@@ -1428,8 +1557,7 @@ function createSlaCheckEndpoint(slugs) {
1428
1557
  requireAdmin(req, slugs);
1429
1558
  const now = /* @__PURE__ */ new Date();
1430
1559
  const nowISO = now.toISOString();
1431
- const { docs: tickets } = await payload.find({
1432
- collection: slugs.tickets,
1560
+ const { docs: tickets } = await dbFind(payload, slugs.tickets, {
1433
1561
  where: {
1434
1562
  and: [
1435
1563
  { status: { in: ["open", "waiting_client"] } },
@@ -1523,6 +1651,107 @@ function createSlaCheckEndpoint(slugs) {
1523
1651
  }
1524
1652
  };
1525
1653
  }
1654
+ var TRANSPARENT_GIF = Buffer.from(
1655
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
1656
+ "base64"
1657
+ );
1658
+ function generateTrackingToken(ticketId, messageId, secret) {
1659
+ return crypto3.createHmac("sha256", secret).update(`${ticketId}:${messageId}`).digest("hex").substring(0, 16);
1660
+ }
1661
+ function createTrackOpenEndpoint(slugs) {
1662
+ return {
1663
+ path: "/support/track-open",
1664
+ method: "get",
1665
+ handler: async (req) => {
1666
+ const url = new URL(req.url);
1667
+ const ticketId = url.searchParams.get("t");
1668
+ const messageId = url.searchParams.get("m");
1669
+ const sig = url.searchParams.get("sig");
1670
+ const parsedId = ticketId ? Number(ticketId) : NaN;
1671
+ const parsedMsgId = messageId ? Number(messageId) : NaN;
1672
+ const secret = process.env.PAYLOAD_SECRET || "";
1673
+ const validSig = !!secret && !!ticketId && !!messageId && !!sig && sig === generateTrackingToken(ticketId, messageId, secret);
1674
+ if (!validSig) {
1675
+ return new Response(TRANSPARENT_GIF, {
1676
+ status: 200,
1677
+ headers: {
1678
+ "Content-Type": "image/gif",
1679
+ "Content-Length": String(TRANSPARENT_GIF.length),
1680
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
1681
+ }
1682
+ });
1683
+ }
1684
+ if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
1685
+ try {
1686
+ const payload = req.payload;
1687
+ const ticket = await dbFindByID(payload, slugs.tickets, {
1688
+ id: parsedId,
1689
+ depth: 0,
1690
+ overrideAccess: true,
1691
+ select: { lastClientReadAt: true }
1692
+ });
1693
+ if (ticket) {
1694
+ const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0;
1695
+ const fiveMinAgo = Date.now() - 5 * 60 * 1e3;
1696
+ if (lastRead < fiveMinAgo) {
1697
+ await dbUpdate(payload, slugs.tickets, {
1698
+ id: parsedId,
1699
+ data: { lastClientReadAt: (/* @__PURE__ */ new Date()).toISOString() },
1700
+ overrideAccess: true
1701
+ });
1702
+ }
1703
+ }
1704
+ if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
1705
+ const msg = await dbFindByID(payload, slugs.ticketMessages, {
1706
+ id: parsedMsgId,
1707
+ depth: 0,
1708
+ overrideAccess: true,
1709
+ select: { emailOpenedAt: true }
1710
+ });
1711
+ if (msg && !msg.emailOpenedAt) {
1712
+ await dbUpdate(payload, slugs.ticketMessages, {
1713
+ id: parsedMsgId,
1714
+ data: { emailOpenedAt: (/* @__PURE__ */ new Date()).toISOString() },
1715
+ overrideAccess: true
1716
+ });
1717
+ const ticketInfo = await dbFindByID(payload, slugs.tickets, {
1718
+ id: parsedId,
1719
+ depth: 1,
1720
+ overrideAccess: true,
1721
+ select: { ticketNumber: true, subject: true, client: true }
1722
+ });
1723
+ const clientName = typeof ticketInfo?.client === "object" ? ticketInfo.client?.firstName || "Client" : "Client";
1724
+ try {
1725
+ await dbCreate(payload, "admin-notifications", {
1726
+ data: {
1727
+ title: `Email ouvert \u2014 ${ticketInfo?.ticketNumber || "TK-????"}`,
1728
+ message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || "ticket"}"`,
1729
+ type: "email_opened",
1730
+ link: `/admin/ticket?id=${parsedId}`
1731
+ },
1732
+ overrideAccess: true
1733
+ });
1734
+ } catch {
1735
+ }
1736
+ }
1737
+ }
1738
+ } catch (err) {
1739
+ console.error("[track-open] Error:", err);
1740
+ }
1741
+ }
1742
+ return new Response(TRANSPARENT_GIF, {
1743
+ status: 200,
1744
+ headers: {
1745
+ "Content-Type": "image/gif",
1746
+ "Content-Length": String(TRANSPARENT_GIF.length),
1747
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
1748
+ "Pragma": "no-cache",
1749
+ "Expires": "0"
1750
+ }
1751
+ });
1752
+ }
1753
+ };
1754
+ }
1526
1755
 
1527
1756
  // src/utils/emailTemplate.ts
1528
1757
  var COLORS = {
@@ -1559,7 +1788,10 @@ function escapeHtml(str) {
1559
1788
  }
1560
1789
  function emailTrackingPixel(ticketId, messageId, baseUrl) {
1561
1790
  const url = process.env.NEXT_PUBLIC_SERVER_URL || "";
1562
- const params = `t=${ticketId}${messageId ? `&m=${messageId}` : ""}`;
1791
+ const secret = process.env.PAYLOAD_SECRET || "";
1792
+ if (!secret || messageId === void 0 || messageId === null) return "";
1793
+ const sig = generateTrackingToken(String(ticketId), String(messageId), secret);
1794
+ const params = `t=${ticketId}&m=${messageId}&sig=${sig}`;
1563
1795
  return `<img src="${url}/api/support/track-open?${params}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`;
1564
1796
  }
1565
1797
  function emailRichContent(html, config) {
@@ -1824,8 +2056,7 @@ function createAutoCloseEndpoint(slugs) {
1824
2056
  const CLOSE_AFTER_REMIND_DAYS = settings.autoClose.reminderDaysBefore;
1825
2057
  const results = { reminded: 0, closed: 0, errors: 0 };
1826
2058
  const remindCutoff = new Date(now.getTime() - REMIND_AFTER_DAYS * 24 * 60 * 60 * 1e3);
1827
- const ticketsToRemind = await payload.find({
1828
- collection: slugs.tickets,
2059
+ const ticketsToRemind = await dbFind(payload, slugs.tickets, {
1829
2060
  where: {
1830
2061
  and: [
1831
2062
  { status: { equals: "waiting_client" } },
@@ -1856,8 +2087,7 @@ function createAutoCloseEndpoint(slugs) {
1856
2087
  <p><a href="${portalUrl}">R\xE9pondre au ticket</a></p>
1857
2088
  </div>`
1858
2089
  });
1859
- await payload.update({
1860
- collection: slugs.tickets,
2090
+ await dbUpdate(payload, slugs.tickets, {
1861
2091
  id: t.id,
1862
2092
  data: { autoCloseRemindedAt: now.toISOString() },
1863
2093
  overrideAccess: true
@@ -1869,8 +2099,7 @@ function createAutoCloseEndpoint(slugs) {
1869
2099
  }
1870
2100
  }
1871
2101
  const closeCutoff = new Date(now.getTime() - CLOSE_AFTER_REMIND_DAYS * 24 * 60 * 60 * 1e3);
1872
- const ticketsToClose = await payload.find({
1873
- collection: slugs.tickets,
2102
+ const ticketsToClose = await dbFind(payload, slugs.tickets, {
1874
2103
  where: {
1875
2104
  and: [
1876
2105
  { status: { equals: "waiting_client" } },
@@ -1905,8 +2134,7 @@ function createAutoCloseEndpoint(slugs) {
1905
2134
  const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS;
1906
2135
  const viaSchedule = !!t.autoCloseScheduledAt && new Date(t.autoCloseScheduledAt).getTime() <= now.getTime();
1907
2136
  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`;
1908
- await payload.create({
1909
- collection: slugs.ticketMessages,
2137
+ await dbCreate(payload, slugs.ticketMessages, {
1910
2138
  data: {
1911
2139
  ticket: t.id,
1912
2140
  body: noteBody,
@@ -1916,8 +2144,7 @@ function createAutoCloseEndpoint(slugs) {
1916
2144
  },
1917
2145
  overrideAccess: true
1918
2146
  });
1919
- await payload.update({
1920
- collection: slugs.tickets,
2147
+ await dbUpdate(payload, slugs.tickets, {
1921
2148
  id: t.id,
1922
2149
  // Clear the armed deadline so a later manual reopen can't be
1923
2150
  // closed instantly by a stale timestamp.
@@ -2040,8 +2267,7 @@ function createSendReminderEndpoint(slugs) {
2040
2267
  }
2041
2268
  const rawHours = Number(body.hours);
2042
2269
  const hours = Number.isFinite(rawHours) ? Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.round(rawHours))) : 24;
2043
- const ticket = await payload.findByID({
2044
- collection: slugs.tickets,
2270
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2045
2271
  id: ticketId,
2046
2272
  depth: 1,
2047
2273
  overrideAccess: true
@@ -2063,8 +2289,7 @@ function createSendReminderEndpoint(slugs) {
2063
2289
  const replyTo = settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || "";
2064
2290
  let waitingSince = ticket.firstResponseAt || ticket.createdAt;
2065
2291
  try {
2066
- const lastAdminMsg = await payload.find({
2067
- collection: slugs.ticketMessages,
2292
+ const lastAdminMsg = await dbFind(payload, slugs.ticketMessages, {
2068
2293
  where: {
2069
2294
  and: [
2070
2295
  { ticket: { equals: ticketId } },
@@ -2105,8 +2330,7 @@ function createSendReminderEndpoint(slugs) {
2105
2330
  preheader: `Sans r\xE9ponse de votre part, votre ticket ${ticketNumber} sera cl\xF4tur\xE9 le ${deadlineLabel}.`
2106
2331
  })
2107
2332
  });
2108
- await payload.create({
2109
- collection: slugs.ticketMessages,
2333
+ await dbCreate(payload, slugs.ticketMessages, {
2110
2334
  data: {
2111
2335
  ticket: ticketId,
2112
2336
  body: `Relance envoy\xE9e \xE0 ${client.email}. Fermeture automatique programm\xE9e le ${deadlineLabel} sans r\xE9ponse du client.`,
@@ -2116,8 +2340,7 @@ function createSendReminderEndpoint(slugs) {
2116
2340
  },
2117
2341
  overrideAccess: true
2118
2342
  });
2119
- await payload.update({
2120
- collection: slugs.tickets,
2343
+ await dbUpdate(payload, slugs.tickets, {
2121
2344
  id: ticketId,
2122
2345
  data: {
2123
2346
  status: "waiting_client",
@@ -2152,8 +2375,7 @@ function createStatusesEndpoint(slugs) {
2152
2375
  if (!req.user) {
2153
2376
  return Response.json({ error: "Unauthorized" }, { status: 401 });
2154
2377
  }
2155
- const { docs } = await payload.find({
2156
- collection: slugs.ticketStatuses,
2378
+ const { docs } = await dbFind(payload, slugs.ticketStatuses, {
2157
2379
  sort: "sortOrder",
2158
2380
  limit: 100,
2159
2381
  depth: 0,
@@ -2192,16 +2414,14 @@ function createApplyMacroEndpoint(slugs) {
2192
2414
  if (!macroId || !ticketId) {
2193
2415
  return Response.json({ error: "macroId and ticketId are required" }, { status: 400 });
2194
2416
  }
2195
- const macro = await payload.findByID({
2196
- collection: slugs.macros,
2417
+ const macro = await dbFindByID(payload, slugs.macros, {
2197
2418
  id: macroId,
2198
2419
  depth: 0,
2199
2420
  overrideAccess: true
2200
2421
  });
2201
2422
  if (!macro) return Response.json({ error: "Macro not found" }, { status: 404 });
2202
2423
  if (!macro.isActive) return Response.json({ error: "Macro is disabled" }, { status: 400 });
2203
- const ticket = await payload.findByID({
2204
- collection: slugs.tickets,
2424
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2205
2425
  id: ticketId,
2206
2426
  depth: 0,
2207
2427
  overrideAccess: true
@@ -2213,8 +2433,7 @@ function createApplyMacroEndpoint(slugs) {
2213
2433
  try {
2214
2434
  switch (action.type) {
2215
2435
  case "set_status":
2216
- await payload.update({
2217
- collection: slugs.tickets,
2436
+ await dbUpdate(payload, slugs.tickets, {
2218
2437
  id: ticketId,
2219
2438
  data: { status: action.value },
2220
2439
  overrideAccess: true
@@ -2222,8 +2441,7 @@ function createApplyMacroEndpoint(slugs) {
2222
2441
  appliedActions.push({ type: action.type, value: action.value, success: true });
2223
2442
  break;
2224
2443
  case "set_priority":
2225
- await payload.update({
2226
- collection: slugs.tickets,
2444
+ await dbUpdate(payload, slugs.tickets, {
2227
2445
  id: ticketId,
2228
2446
  data: { priority: action.value },
2229
2447
  overrideAccess: true
@@ -2235,8 +2453,7 @@ function createApplyMacroEndpoint(slugs) {
2235
2453
  if (!currentTags.includes(action.value)) {
2236
2454
  currentTags.push(action.value);
2237
2455
  }
2238
- await payload.update({
2239
- collection: slugs.tickets,
2456
+ await dbUpdate(payload, slugs.tickets, {
2240
2457
  id: ticketId,
2241
2458
  data: { tags: currentTags },
2242
2459
  overrideAccess: true
@@ -2245,8 +2462,7 @@ function createApplyMacroEndpoint(slugs) {
2245
2462
  break;
2246
2463
  }
2247
2464
  case "send_reply":
2248
- await payload.create({
2249
- collection: slugs.ticketMessages,
2465
+ await dbCreate(payload, slugs.ticketMessages, {
2250
2466
  data: {
2251
2467
  ticket: ticketId,
2252
2468
  body: action.value,
@@ -2263,8 +2479,7 @@ function createApplyMacroEndpoint(slugs) {
2263
2479
  appliedActions.push({ type: action.type, value: action.value, success: false, error: "Invalid user ID" });
2264
2480
  break;
2265
2481
  }
2266
- await payload.update({
2267
- collection: slugs.tickets,
2482
+ await dbUpdate(payload, slugs.tickets, {
2268
2483
  id: ticketId,
2269
2484
  data: { assignedTo: userId },
2270
2485
  overrideAccess: true
@@ -2316,8 +2531,7 @@ function createPurgeLogsEndpoint(slugs) {
2316
2531
  return Response.json({ error: "Invalid collection. Use email-logs or auth-logs." }, { status: 400 });
2317
2532
  }
2318
2533
  const cutoff = days > 0 ? new Date(Date.now() - days * 864e5).toISOString() : null;
2319
- const result = await payload.delete({
2320
- collection: allowedCollections[collection],
2534
+ const result = await dbDelete(payload, allowedCollections[collection], {
2321
2535
  where: cutoff ? { createdAt: { less_than: cutoff } } : { id: { exists: true } },
2322
2536
  overrideAccess: true
2323
2537
  });
@@ -2360,8 +2574,7 @@ function createChatbotEndpoint(slugs) {
2360
2574
  return Response.json({ error: "Question too short" }, { status: 400 });
2361
2575
  }
2362
2576
  const payload = req.payload;
2363
- const articles = await payload.find({
2364
- collection: slugs.knowledgeBase,
2577
+ const articles = await dbFind(payload, slugs.knowledgeBase, {
2365
2578
  where: { published: { equals: true } },
2366
2579
  limit: 100,
2367
2580
  depth: 0,
@@ -2451,8 +2664,7 @@ function createChatGetEndpoint(slugs) {
2451
2664
  if (after) {
2452
2665
  where.createdAt = { greater_than: after };
2453
2666
  }
2454
- const messages = await payload.find({
2455
- collection: slugs.chatMessages,
2667
+ const messages = await dbFind(payload, slugs.chatMessages, {
2456
2668
  where,
2457
2669
  sort: "createdAt",
2458
2670
  limit: 100,
@@ -2495,8 +2707,7 @@ function createChatPostEndpoint(slugs) {
2495
2707
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
2496
2708
  }
2497
2709
  const sessionId = `chat_${crypto3__default.default.randomBytes(16).toString("hex")}`;
2498
- const systemMsg = await payload.create({
2499
- collection: slugs.chatMessages,
2710
+ const systemMsg = await dbCreate(payload, slugs.chatMessages, {
2500
2711
  data: {
2501
2712
  session: sessionId,
2502
2713
  client: req.user.id,
@@ -2516,8 +2727,7 @@ function createChatPostEndpoint(slugs) {
2516
2727
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2517
2728
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2518
2729
  }
2519
- const existing = await payload.find({
2520
- collection: slugs.chatMessages,
2730
+ const existing = await dbFind(payload, slugs.chatMessages, {
2521
2731
  where: { session: { equals: session }, client: { equals: req.user.id } },
2522
2732
  limit: 1,
2523
2733
  overrideAccess: true
@@ -2525,8 +2735,7 @@ function createChatPostEndpoint(slugs) {
2525
2735
  if (existing.docs.length === 0) {
2526
2736
  return Response.json({ error: "Session invalide" }, { status: 403 });
2527
2737
  }
2528
- const newMsg = await payload.create({
2529
- collection: slugs.chatMessages,
2738
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2530
2739
  data: {
2531
2740
  session,
2532
2741
  client: req.user.id,
@@ -2539,8 +2748,7 @@ function createChatPostEndpoint(slugs) {
2539
2748
  return Response.json({ message: newMsg });
2540
2749
  }
2541
2750
  if (action === "close" && session) {
2542
- const existing = await payload.find({
2543
- collection: slugs.chatMessages,
2751
+ const existing = await dbFind(payload, slugs.chatMessages, {
2544
2752
  where: { session: { equals: session }, client: { equals: req.user.id } },
2545
2753
  limit: 1,
2546
2754
  overrideAccess: true
@@ -2548,8 +2756,7 @@ function createChatPostEndpoint(slugs) {
2548
2756
  if (existing.docs.length === 0) {
2549
2757
  return Response.json({ error: "Session invalide" }, { status: 403 });
2550
2758
  }
2551
- await payload.create({
2552
- collection: slugs.chatMessages,
2759
+ await dbCreate(payload, slugs.chatMessages, {
2553
2760
  data: {
2554
2761
  session,
2555
2762
  client: req.user.id,
@@ -2591,8 +2798,7 @@ function createChatStreamEndpoint(slugs) {
2591
2798
  if (!sessionId) {
2592
2799
  return Response.json({ error: "Missing session parameter" }, { status: 400 });
2593
2800
  }
2594
- const existing = await req.payload.find({
2595
- collection: slugs.chatMessages,
2801
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
2596
2802
  where: {
2597
2803
  session: { equals: sessionId },
2598
2804
  client: { equals: userId }
@@ -2614,8 +2820,7 @@ function createChatStreamEndpoint(slugs) {
2614
2820
  );
2615
2821
  const interval = setInterval(async () => {
2616
2822
  try {
2617
- const messages = await req.payload.find({
2618
- collection: slugs.chatMessages,
2823
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2619
2824
  where: {
2620
2825
  session: { equals: sessionId },
2621
2826
  createdAt: { greater_than: lastCheck }
@@ -2696,8 +2901,7 @@ function createAdminChatGetEndpoint(slugs) {
2696
2901
  if (session) {
2697
2902
  const where = { session: { equals: session } };
2698
2903
  if (after) where.createdAt = { greater_than: after };
2699
- const messages = await payload.find({
2700
- collection: slugs.chatMessages,
2904
+ const messages = await dbFind(payload, slugs.chatMessages, {
2701
2905
  where,
2702
2906
  sort: "createdAt",
2703
2907
  limit: 200,
@@ -2711,8 +2915,7 @@ function createAdminChatGetEndpoint(slugs) {
2711
2915
  }
2712
2916
  });
2713
2917
  }
2714
- const recentMessages = await payload.find({
2715
- collection: slugs.chatMessages,
2918
+ const recentMessages = await dbFind(payload, slugs.chatMessages, {
2716
2919
  sort: "-createdAt",
2717
2920
  limit: 50,
2718
2921
  depth: 1,
@@ -2775,8 +2978,7 @@ function createAdminChatPostEndpoint(slugs) {
2775
2978
  if (!session) {
2776
2979
  return Response.json({ error: "Session requise" }, { status: 400 });
2777
2980
  }
2778
- const sessionMsg = await payload.find({
2779
- collection: slugs.chatMessages,
2981
+ const sessionMsg = await dbFind(payload, slugs.chatMessages, {
2780
2982
  where: { session: { equals: session } },
2781
2983
  limit: 1,
2782
2984
  depth: 0,
@@ -2794,8 +2996,7 @@ function createAdminChatPostEndpoint(slugs) {
2794
2996
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2795
2997
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2796
2998
  }
2797
- const newMsg = await payload.create({
2798
- collection: slugs.chatMessages,
2999
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2799
3000
  data: {
2800
3001
  session,
2801
3002
  client: clientId,
@@ -2807,8 +3008,7 @@ function createAdminChatPostEndpoint(slugs) {
2807
3008
  overrideAccess: true
2808
3009
  });
2809
3010
  try {
2810
- const linkedTicket = await payload.find({
2811
- collection: slugs.tickets,
3011
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2812
3012
  where: { chatSession: { equals: session } },
2813
3013
  limit: 1,
2814
3014
  depth: 0,
@@ -2816,8 +3016,7 @@ function createAdminChatPostEndpoint(slugs) {
2816
3016
  });
2817
3017
  if (linkedTicket.docs.length > 0) {
2818
3018
  const ticketId = linkedTicket.docs[0].id;
2819
- await payload.create({
2820
- collection: slugs.ticketMessages,
3019
+ await dbCreate(payload, slugs.ticketMessages, {
2821
3020
  data: {
2822
3021
  ticket: ticketId,
2823
3022
  body: trimmedMessage,
@@ -2826,8 +3025,7 @@ function createAdminChatPostEndpoint(slugs) {
2826
3025
  },
2827
3026
  overrideAccess: true
2828
3027
  });
2829
- await payload.update({
2830
- collection: slugs.chatMessages,
3028
+ await dbUpdate(payload, slugs.chatMessages, {
2831
3029
  id: newMsg.id,
2832
3030
  data: { ticket: ticketId },
2833
3031
  overrideAccess: true
@@ -2839,8 +3037,7 @@ function createAdminChatPostEndpoint(slugs) {
2839
3037
  return Response.json({ message: newMsg });
2840
3038
  }
2841
3039
  if (action === "close") {
2842
- await payload.create({
2843
- collection: slugs.chatMessages,
3040
+ await dbCreate(payload, slugs.chatMessages, {
2844
3041
  data: {
2845
3042
  session,
2846
3043
  client: clientId,
@@ -2851,16 +3048,14 @@ function createAdminChatPostEndpoint(slugs) {
2851
3048
  overrideAccess: true
2852
3049
  });
2853
3050
  try {
2854
- const linkedTicket = await payload.find({
2855
- collection: slugs.tickets,
3051
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2856
3052
  where: { chatSession: { equals: session } },
2857
3053
  limit: 1,
2858
3054
  depth: 0,
2859
3055
  overrideAccess: true
2860
3056
  });
2861
3057
  if (linkedTicket.docs.length > 0) {
2862
- await payload.create({
2863
- collection: slugs.ticketMessages,
3058
+ await dbCreate(payload, slugs.ticketMessages, {
2864
3059
  data: {
2865
3060
  ticket: linkedTicket.docs[0].id,
2866
3061
  body: "Session de chat termin\xE9e par un agent.",
@@ -2923,8 +3118,7 @@ function createSessionStream(req, slugs, sessionId) {
2923
3118
  );
2924
3119
  const interval = setInterval(async () => {
2925
3120
  try {
2926
- const messages = await req.payload.find({
2927
- collection: slugs.chatMessages,
3121
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2928
3122
  where: {
2929
3123
  session: { equals: sessionId },
2930
3124
  createdAt: { greater_than: lastCheck }
@@ -2997,8 +3191,7 @@ function createSessionListStream(req, slugs) {
2997
3191
  );
2998
3192
  const interval = setInterval(async () => {
2999
3193
  try {
3000
- const recentMessages = await req.payload.find({
3001
- collection: slugs.chatMessages,
3194
+ const recentMessages = await dbFind(req.payload, slugs.chatMessages, {
3002
3195
  sort: "-createdAt",
3003
3196
  limit: 50,
3004
3197
  depth: 1,
@@ -3085,8 +3278,7 @@ function createAdminStatsEndpoint(slugs) {
3085
3278
  const statuses = ["open", "waiting_client", "resolved"];
3086
3279
  const statusCounts = await Promise.all(
3087
3280
  statuses.map(async (status) => {
3088
- const result = await payload.count({
3089
- collection: slugs.tickets,
3281
+ const result = await dbCount(payload, slugs.tickets, {
3090
3282
  where: { status: { equals: status } },
3091
3283
  overrideAccess: true
3092
3284
  });
@@ -3098,8 +3290,7 @@ function createAdminStatsEndpoint(slugs) {
3098
3290
  const priorities = ["low", "normal", "high", "urgent"];
3099
3291
  const priorityCounts = await Promise.all(
3100
3292
  priorities.map(async (priority) => {
3101
- const result = await payload.count({
3102
- collection: slugs.tickets,
3293
+ const result = await dbCount(payload, slugs.tickets, {
3103
3294
  where: { priority: { equals: priority } },
3104
3295
  overrideAccess: true
3105
3296
  });
@@ -3110,8 +3301,7 @@ function createAdminStatsEndpoint(slugs) {
3110
3301
  const categories = ["bug", "content", "feature", "question", "hosting"];
3111
3302
  const categoryCounts = await Promise.all(
3112
3303
  categories.map(async (category) => {
3113
- const result = await payload.count({
3114
- collection: slugs.tickets,
3304
+ const result = await dbCount(payload, slugs.tickets, {
3115
3305
  where: { category: { equals: category } },
3116
3306
  overrideAccess: true
3117
3307
  });
@@ -3125,17 +3315,34 @@ function createAdminStatsEndpoint(slugs) {
3125
3315
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
3126
3316
  const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
3127
3317
  const [created7, created30] = await Promise.all([
3128
- payload.count({
3129
- collection: slugs.tickets,
3318
+ dbCount(payload, slugs.tickets, {
3130
3319
  where: { createdAt: { greater_than_equal: sevenDaysAgo.toISOString() } },
3131
3320
  overrideAccess: true
3132
3321
  }),
3133
- payload.count({
3134
- collection: slugs.tickets,
3322
+ dbCount(payload, slugs.tickets, {
3135
3323
  where: { createdAt: { greater_than_equal: thirtyDaysAgo.toISOString() } },
3136
3324
  overrideAccess: true
3137
3325
  })
3138
3326
  ]);
3327
+ const volumeByDay = await Promise.all(
3328
+ Array.from({ length: 7 }, (_, i) => i).map(async (offset) => {
3329
+ const dayStart = new Date(now);
3330
+ dayStart.setHours(0, 0, 0, 0);
3331
+ dayStart.setDate(dayStart.getDate() - (6 - offset));
3332
+ const dayEnd = new Date(dayStart);
3333
+ dayEnd.setDate(dayEnd.getDate() + 1);
3334
+ const r = await dbCount(payload, slugs.tickets, {
3335
+ where: {
3336
+ and: [
3337
+ { createdAt: { greater_than_equal: dayStart.toISOString() } },
3338
+ { createdAt: { less_than: dayEnd.toISOString() } }
3339
+ ]
3340
+ },
3341
+ overrideAccess: true
3342
+ });
3343
+ return { date: dayStart.toISOString(), count: r.totalDocs };
3344
+ })
3345
+ );
3139
3346
  const PAGE_SIZE2 = 100;
3140
3347
  const MAX_PAGES2 = 50;
3141
3348
  let totalResponseTimeMs = 0;
@@ -3146,8 +3353,7 @@ function createAdminStatsEndpoint(slugs) {
3146
3353
  let page = 1;
3147
3354
  let hasMore = true;
3148
3355
  while (hasMore && page <= MAX_PAGES2) {
3149
- const batch = await payload.find({
3150
- collection: slugs.tickets,
3356
+ const batch = await dbFind(payload, slugs.tickets, {
3151
3357
  limit: PAGE_SIZE2,
3152
3358
  page,
3153
3359
  depth: 0,
@@ -3180,43 +3386,62 @@ function createAdminStatsEndpoint(slugs) {
3180
3386
  hasMore = batch.hasNextPage ?? false;
3181
3387
  page++;
3182
3388
  }
3183
- const surveyCount = await payload.count({
3184
- collection: slugs.satisfactionSurveys,
3389
+ const surveyCount = await dbCount(payload, slugs.satisfactionSurveys, {
3185
3390
  overrideAccess: true
3186
3391
  });
3187
3392
  let satisfactionAvg = 0;
3393
+ let satisfactionCount = 0;
3394
+ let npsScore = null;
3395
+ let npsCount = 0;
3188
3396
  if (surveyCount.totalDocs > 0) {
3189
- let totalRating = 0;
3397
+ let ratingSum = 0;
3398
+ let ratingN = 0;
3399
+ let promoters = 0;
3400
+ let detractors = 0;
3401
+ let npsN = 0;
3190
3402
  let surveyPage = 1;
3191
3403
  let surveyHasMore = true;
3192
3404
  while (surveyHasMore && surveyPage <= MAX_PAGES2) {
3193
- const batch = await payload.find({
3194
- collection: slugs.satisfactionSurveys,
3405
+ const batch = await dbFind(payload, slugs.satisfactionSurveys, {
3195
3406
  limit: PAGE_SIZE2,
3196
3407
  page: surveyPage,
3197
3408
  depth: 0,
3198
3409
  overrideAccess: true,
3199
- select: { rating: true }
3410
+ select: { rating: true, nps: true }
3200
3411
  });
3201
3412
  for (const s of batch.docs) {
3202
- totalRating += s.rating || 0;
3413
+ const r = s.rating;
3414
+ if (typeof r === "number" && r > 0) {
3415
+ ratingSum += r;
3416
+ ratingN++;
3417
+ }
3418
+ const n = s.nps;
3419
+ if (typeof n === "number") {
3420
+ npsN++;
3421
+ if (n >= 9) promoters++;
3422
+ else if (n <= 6) detractors++;
3423
+ }
3203
3424
  }
3204
3425
  surveyHasMore = batch.hasNextPage ?? false;
3205
3426
  surveyPage++;
3206
3427
  }
3207
- satisfactionAvg = Math.round(totalRating / surveyCount.totalDocs * 10) / 10;
3428
+ satisfactionAvg = ratingN > 0 ? Math.round(ratingSum / ratingN * 10) / 10 : 0;
3429
+ satisfactionCount = ratingN;
3430
+ if (npsN > 0) {
3431
+ npsScore = Math.round((promoters - detractors) / npsN * 100);
3432
+ npsCount = npsN;
3433
+ }
3208
3434
  }
3209
- const [clientCount, pendingEmailsCount] = await Promise.all([
3210
- payload.count({
3211
- collection: slugs.supportClients,
3212
- overrideAccess: true
3213
- }),
3214
- payload.count({
3215
- collection: slugs.pendingEmails,
3435
+ const clientCount = await dbCount(payload, slugs.supportClients, { overrideAccess: true });
3436
+ let pendingEmailsTotal = 0;
3437
+ try {
3438
+ const pe = await dbCount(payload, slugs.pendingEmails, {
3216
3439
  where: { status: { equals: "pending" } },
3217
3440
  overrideAccess: true
3218
- })
3219
- ]);
3441
+ });
3442
+ pendingEmailsTotal = pe.totalDocs;
3443
+ } catch {
3444
+ }
3220
3445
  return new Response(JSON.stringify({
3221
3446
  total,
3222
3447
  byStatus,
@@ -3224,13 +3449,16 @@ function createAdminStatsEndpoint(slugs) {
3224
3449
  byCategory,
3225
3450
  createdLast7Days: created7.totalDocs,
3226
3451
  createdLast30Days: created30.totalDocs,
3452
+ volumeByDay,
3227
3453
  avgResponseTimeHours: responseTimeCount > 0 ? Math.round(totalResponseTimeMs / responseTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3228
3454
  avgResolutionTimeHours: resolutionTimeCount > 0 ? Math.round(totalResolutionTimeMs / resolutionTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3229
3455
  totalTimeMinutes,
3230
3456
  satisfactionAvg,
3231
- satisfactionCount: surveyCount.totalDocs,
3457
+ satisfactionCount,
3458
+ npsScore,
3459
+ npsCount,
3232
3460
  clientCount: clientCount.totalDocs,
3233
- pendingEmailsCount: pendingEmailsCount.totalDocs
3461
+ pendingEmailsCount: pendingEmailsTotal
3234
3462
  }), {
3235
3463
  headers: {
3236
3464
  "Content-Type": "application/json",
@@ -3300,13 +3528,26 @@ function createBillingEndpoint(slugs) {
3300
3528
  let ticketPage = 1;
3301
3529
  let ticketHasMore = true;
3302
3530
  while (ticketHasMore && ticketPage <= MAX_PAGES) {
3303
- const batch = await payload.find({
3304
- collection: slugs.tickets,
3531
+ const batch = await dbFind(payload, slugs.tickets, {
3305
3532
  where: ticketWhere,
3306
3533
  limit: PAGE_SIZE,
3307
3534
  page: ticketPage,
3535
+ // depth 2 is required: we read ticket.project.client.company below.
3308
3536
  depth: 2,
3309
- overrideAccess: true
3537
+ overrideAccess: true,
3538
+ // Bound the payload to only the fields the report consumes (skips body,
3539
+ // richText, SLA fields, etc.) while keeping project/client populated.
3540
+ select: {
3541
+ ticketNumber: true,
3542
+ subject: true,
3543
+ status: true,
3544
+ billedAmount: true,
3545
+ aiSummary: true,
3546
+ aiSummaryGeneratedAt: true,
3547
+ aiSummaryStatus: true,
3548
+ project: true,
3549
+ client: true
3550
+ }
3310
3551
  });
3311
3552
  allTickets.push(...batch.docs);
3312
3553
  ticketHasMore = batch.hasNextPage ?? false;
@@ -3316,8 +3557,7 @@ function createBillingEndpoint(slugs) {
3316
3557
  let entryPage = 1;
3317
3558
  let entryHasMore = true;
3318
3559
  while (entryHasMore && entryPage <= MAX_PAGES) {
3319
- const batch = await payload.find({
3320
- collection: slugs.timeEntries,
3560
+ const batch = await dbFind(payload, slugs.timeEntries, {
3321
3561
  where: {
3322
3562
  and: [
3323
3563
  { date: { greater_than_equal: from } },
@@ -3415,43 +3655,205 @@ function createBillingEndpoint(slugs) {
3415
3655
  }
3416
3656
  };
3417
3657
  }
3418
-
3419
- // src/utils/generateTicketSynthesis.ts
3420
- function getClient3(aiSettings) {
3421
- const Anthropic = __require("@anthropic-ai/sdk").default;
3422
- if (aiSettings.provider === "ollama") {
3423
- const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
3424
- return new Anthropic({ apiKey: "ollama", baseURL });
3425
- }
3426
- return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
3427
- }
3428
- function getModel3(aiSettings) {
3429
- return aiSettings.model || "claude-haiku-4-5-20251001";
3430
- }
3431
- async function generateTicketSynthesis(args) {
3432
- const { payload, slugs, ticketId } = args;
3433
- const settings = await readSupportSettings(payload);
3434
- if (settings.ai.enableSynthesis === false) {
3435
- return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "skipped", reason: "synthesis disabled" };
3436
- }
3437
- const ticket = await payload.findByID({
3438
- collection: slugs.tickets,
3439
- id: ticketId,
3440
- depth: 1,
3441
- overrideAccess: true
3658
+ var fmtAmount = (n) => `${n.toFixed(2)} EUR`;
3659
+ var fmtTime = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3660
+ function generateInvoicePdf(data) {
3661
+ return new Promise((resolve, reject) => {
3662
+ try {
3663
+ const doc = new PDFDocument__default.default({ size: "A4", margin: 50 });
3664
+ const chunks = [];
3665
+ doc.on("data", (c) => chunks.push(c));
3666
+ doc.on("end", () => resolve(Buffer.concat(chunks)));
3667
+ doc.on("error", reject);
3668
+ doc.fillColor("#0f172a").fontSize(22).text("Invoice / Pre-billing");
3669
+ doc.moveDown(0.2);
3670
+ doc.fillColor("#64748b").fontSize(10).text(`Period: ${data.from} -> ${data.to}${data.projectId ? ` \xB7 Project #${data.projectId}` : ""}`);
3671
+ doc.moveDown(1);
3672
+ const X = { ticket: 50, subject: 130, client: 320, time: 430, amount: 500 };
3673
+ const drawRow = (r, opts) => {
3674
+ const y = doc.y;
3675
+ doc.fontSize(opts?.header ? 9 : 10).fillColor(opts?.header ? "#475569" : "#0f172a");
3676
+ if (opts?.total) doc.font("Helvetica-Bold");
3677
+ doc.text(r.ticket, X.ticket, y, { width: X.subject - X.ticket - 6 });
3678
+ doc.text(r.subject, X.subject, y, { width: X.client - X.subject - 6 });
3679
+ doc.text(r.client, X.client, y, { width: X.time - X.client - 6 });
3680
+ doc.text(r.time, X.time, y, { width: X.amount - X.time - 6, align: "right" });
3681
+ doc.text(r.amount, X.amount, y, { width: 545 - X.amount, align: "right" });
3682
+ doc.font("Helvetica");
3683
+ doc.moveDown(0.4);
3684
+ };
3685
+ drawRow({ ticket: "Ticket", subject: "Subject", client: "Client", time: "Time", amount: "Amount" }, { header: true });
3686
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#e2e8f0").stroke();
3687
+ doc.moveDown(0.3);
3688
+ for (const l of data.lines) {
3689
+ if (doc.y > 760) doc.addPage();
3690
+ drawRow({
3691
+ ticket: l.ticketNumber || "",
3692
+ subject: l.subject || "",
3693
+ client: l.client || "",
3694
+ time: fmtTime(l.minutes),
3695
+ amount: fmtAmount(l.amount)
3696
+ });
3697
+ }
3698
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#0f172a").stroke();
3699
+ doc.moveDown(0.3);
3700
+ drawRow({
3701
+ ticket: "",
3702
+ subject: `Total (${data.lines.length} ticket${data.lines.length > 1 ? "s" : ""})`,
3703
+ client: "",
3704
+ time: fmtTime(data.totalMinutes),
3705
+ amount: fmtAmount(data.totalAmount)
3706
+ }, { total: true });
3707
+ doc.end();
3708
+ } catch (err) {
3709
+ reject(err);
3710
+ }
3442
3711
  });
3443
- if (!ticket) {
3444
- return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: "ticket not found" };
3445
- }
3446
- await payload.update({
3447
- collection: slugs.tickets,
3448
- id: ticketId,
3449
- data: { aiSummaryStatus: "pending" },
3712
+ }
3713
+
3714
+ // src/endpoints/invoice.ts
3715
+ function createInvoiceEndpoint(slugs) {
3716
+ return {
3717
+ path: "/support/billing/invoice",
3718
+ method: "get",
3719
+ handler: async (req) => {
3720
+ try {
3721
+ requireAdmin(req, slugs);
3722
+ const payload = req.payload;
3723
+ const url = new URL(req.url);
3724
+ const from = url.searchParams.get("from");
3725
+ const to = url.searchParams.get("to");
3726
+ const projectId = url.searchParams.get("projectId");
3727
+ const format = url.searchParams.get("format") === "pdf" ? "pdf" : "html";
3728
+ if (!from || !to) {
3729
+ return Response.json({ error: "Param\xE8tres from et to requis." }, { status: 400 });
3730
+ }
3731
+ const toExclusive = new Date(to);
3732
+ toExclusive.setDate(toExclusive.getDate() + 1);
3733
+ const toIso = toExclusive.toISOString();
3734
+ const where = {
3735
+ and: [
3736
+ { billable: { equals: true } },
3737
+ ...projectId ? [{ project: { equals: Number(projectId) } }] : [],
3738
+ {
3739
+ or: [
3740
+ { and: [{ updatedAt: { greater_than_equal: from } }, { updatedAt: { less_than: toIso } }] },
3741
+ { and: [{ createdAt: { greater_than_equal: from } }, { createdAt: { less_than: toIso } }] },
3742
+ { and: [{ resolvedAt: { greater_than_equal: from } }, { resolvedAt: { less_than: toIso } }] }
3743
+ ]
3744
+ }
3745
+ ]
3746
+ };
3747
+ const tickets = [];
3748
+ let page = 1;
3749
+ let hasMore = true;
3750
+ while (hasMore && page <= 50) {
3751
+ const batch = await dbFind(payload, slugs.tickets, {
3752
+ where,
3753
+ limit: 500,
3754
+ page,
3755
+ depth: 2,
3756
+ overrideAccess: true,
3757
+ select: { ticketNumber: true, subject: true, billedAmount: true, totalTimeMinutes: true, project: true, client: true }
3758
+ });
3759
+ tickets.push(...batch.docs);
3760
+ hasMore = batch.hasNextPage ?? false;
3761
+ page++;
3762
+ }
3763
+ let totalAmount = 0;
3764
+ let totalMinutes = 0;
3765
+ const lines = tickets.map((t) => {
3766
+ const amount = typeof t.billedAmount === "number" ? t.billedAmount : 0;
3767
+ const minutes = typeof t.totalTimeMinutes === "number" ? t.totalTimeMinutes : 0;
3768
+ totalAmount += amount;
3769
+ totalMinutes += minutes;
3770
+ const client = typeof t.client === "object" && t.client ? t.client.company || "" : "";
3771
+ return { ticketNumber: String(t.ticketNumber || ""), subject: String(t.subject || ""), client, minutes, amount };
3772
+ });
3773
+ if (format === "pdf") {
3774
+ const pdf = await generateInvoicePdf({ from, to, projectId, lines, totalMinutes, totalAmount });
3775
+ return new Response(new Uint8Array(pdf), {
3776
+ status: 200,
3777
+ headers: {
3778
+ "Content-Type": "application/pdf",
3779
+ "Content-Disposition": `inline; filename="invoice-${from}_${to}.pdf"`
3780
+ }
3781
+ });
3782
+ }
3783
+ const fmtAmount2 = (n) => `${n.toFixed(2)} \u20AC`;
3784
+ const fmtTime2 = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3785
+ const rows = lines.map((l) => `<tr>
3786
+ <td>${escapeHtml(l.ticketNumber)}</td>
3787
+ <td>${escapeHtml(l.subject)}</td>
3788
+ <td>${escapeHtml(l.client)}</td>
3789
+ <td style="text-align:right;">${fmtTime2(l.minutes)}</td>
3790
+ <td style="text-align:right;">${fmtAmount2(l.amount)}</td>
3791
+ </tr>`).join("");
3792
+ const html = `<!doctype html>
3793
+ <html lang="fr"><head><meta charset="utf-8"><title>Facture ${escapeHtml(from)} \u2192 ${escapeHtml(to)}</title>
3794
+ <style>
3795
+ body { font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 820px; margin: 32px auto; padding: 0 24px; }
3796
+ h1 { font-size: 22px; margin: 0 0 4px; }
3797
+ .period { color: #64748b; margin-bottom: 24px; }
3798
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
3799
+ th, td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; text-align: left; }
3800
+ th { background: #f8fafc; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #475569; }
3801
+ tfoot td { font-weight: 700; border-top: 2px solid #0f172a; border-bottom: none; }
3802
+ @media print { body { margin: 0; } }
3803
+ </style></head>
3804
+ <body>
3805
+ <h1>Facture / Pr\xE9-facture</h1>
3806
+ <div class="period">P\xE9riode : ${escapeHtml(from)} \u2192 ${escapeHtml(to)}${projectId ? ` \xB7 Projet #${escapeHtml(projectId)}` : ""}</div>
3807
+ <table>
3808
+ <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>
3809
+ <tbody>${rows || '<tr><td colspan="5" style="color:#94a3b8;">Aucun ticket facturable sur la p\xE9riode.</td></tr>'}</tbody>
3810
+ <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>
3811
+ </table>
3812
+ </body></html>`;
3813
+ return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
3814
+ } catch (error) {
3815
+ const authResponse = handleAuthError(error);
3816
+ if (authResponse) return authResponse;
3817
+ console.error("[invoice] Error:", error);
3818
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
3819
+ }
3820
+ }
3821
+ };
3822
+ }
3823
+
3824
+ // src/utils/generateTicketSynthesis.ts
3825
+ function getClient4(aiSettings) {
3826
+ const Anthropic = __require("@anthropic-ai/sdk").default;
3827
+ if (aiSettings.provider === "ollama") {
3828
+ const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
3829
+ return new Anthropic({ apiKey: "ollama", baseURL });
3830
+ }
3831
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
3832
+ }
3833
+ function getModel3(aiSettings) {
3834
+ return aiSettings.model || "claude-haiku-4-5-20251001";
3835
+ }
3836
+ async function generateTicketSynthesis(args) {
3837
+ const { payload, slugs, ticketId } = args;
3838
+ const settings = await readSupportSettings(payload);
3839
+ if (settings.ai.enableSynthesis === false) {
3840
+ return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "skipped", reason: "synthesis disabled" };
3841
+ }
3842
+ const ticket = await dbFindByID(payload, slugs.tickets, {
3843
+ id: ticketId,
3844
+ depth: 1,
3845
+ overrideAccess: true
3846
+ });
3847
+ if (!ticket) {
3848
+ return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: "ticket not found" };
3849
+ }
3850
+ await dbUpdate(payload, slugs.tickets, {
3851
+ id: ticketId,
3852
+ data: { aiSummaryStatus: "pending" },
3450
3853
  overrideAccess: true
3451
3854
  }).catch(() => {
3452
3855
  });
3453
- const messagesResult = await payload.find({
3454
- collection: slugs.ticketMessages,
3856
+ const messagesResult = await dbFind(payload, slugs.ticketMessages, {
3455
3857
  where: { ticket: { equals: ticketId } },
3456
3858
  sort: "createdAt",
3457
3859
  limit: 500,
@@ -3461,8 +3863,7 @@ async function generateTicketSynthesis(args) {
3461
3863
  const messages = messagesResult.docs;
3462
3864
  if (messages.length === 0) {
3463
3865
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3464
- await payload.update({
3465
- collection: slugs.tickets,
3866
+ await dbUpdate(payload, slugs.tickets, {
3466
3867
  id: ticketId,
3467
3868
  data: {
3468
3869
  aiSummary: "(Aucun message dans ce ticket)",
@@ -3507,7 +3908,7 @@ Regles strictes :
3507
3908
  - Si le ticket n'a pas abouti, decris quand meme le travail d'analyse realise
3508
3909
 
3509
3910
  Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3510
- const anthropic = getClient3(settings.ai);
3911
+ const anthropic = getClient4(settings.ai);
3511
3912
  const model = getModel3(settings.ai);
3512
3913
  try {
3513
3914
  const res = await anthropic.messages.create({
@@ -3517,8 +3918,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3517
3918
  });
3518
3919
  const summary = res.content[0]?.type === "text" ? res.content[0].text.trim() : "";
3519
3920
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3520
- await payload.update({
3521
- collection: slugs.tickets,
3921
+ await dbUpdate(payload, slugs.tickets, {
3522
3922
  id: ticketId,
3523
3923
  data: {
3524
3924
  aiSummary: summary,
@@ -3530,8 +3930,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3530
3930
  return { summary, generatedAt, status: "done" };
3531
3931
  } catch (err) {
3532
3932
  const message = err instanceof Error ? err.message : String(err);
3533
- await payload.update({
3534
- collection: slugs.tickets,
3933
+ await dbUpdate(payload, slugs.tickets, {
3535
3934
  id: ticketId,
3536
3935
  data: { aiSummaryStatus: "error" },
3537
3936
  overrideAccess: true
@@ -3561,8 +3960,7 @@ function createTicketSynthesisEndpoint(slugs) {
3561
3960
  return Response.json({ error: "ticketId must be a number" }, { status: 400 });
3562
3961
  }
3563
3962
  if (!force) {
3564
- const existing = await payload.findByID({
3565
- collection: slugs.tickets,
3963
+ const existing = await dbFindByID(payload, slugs.tickets, {
3566
3964
  id: ticketId,
3567
3965
  depth: 0,
3568
3966
  overrideAccess: true
@@ -3606,8 +4004,7 @@ function createEmailStatsEndpoint(slugs) {
3606
4004
  let page = 1;
3607
4005
  let hasMore = true;
3608
4006
  while (hasMore && page <= MAX_PAGES2) {
3609
- const result = await payload.find({
3610
- collection: slugs.emailLogs,
4007
+ const result = await dbFind(payload, slugs.emailLogs, {
3611
4008
  where: { createdAt: { greater_than: cutoff } },
3612
4009
  sort: "-createdAt",
3613
4010
  limit: 500,
@@ -3681,12 +4078,20 @@ function createSatisfactionEndpoint(slugs) {
3681
4078
  const payload = req.payload;
3682
4079
  requireClient(req, slugs);
3683
4080
  const body = await req.json();
3684
- const { ticketId, rating, comment } = body;
3685
- if (!ticketId || !rating || !Number.isInteger(rating) || rating < 1 || rating > 5) {
3686
- return Response.json(
3687
- { error: "ticketId et rating (entier 1-5) sont requis." },
3688
- { status: 400 }
3689
- );
4081
+ const { ticketId, rating, nps, comment } = body;
4082
+ if (!ticketId) {
4083
+ return Response.json({ error: "ticketId est requis." }, { status: 400 });
4084
+ }
4085
+ const hasRating = rating !== void 0 && rating !== null;
4086
+ const hasNps = nps !== void 0 && nps !== null;
4087
+ if (!hasRating && !hasNps) {
4088
+ return Response.json({ error: "Un rating (1-5) et/ou un nps (0-10) est requis." }, { status: 400 });
4089
+ }
4090
+ if (hasRating && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
4091
+ return Response.json({ error: "rating doit \xEAtre un entier 1-5." }, { status: 400 });
4092
+ }
4093
+ if (hasNps && (!Number.isInteger(nps) || nps < 0 || nps > 10)) {
4094
+ return Response.json({ error: "nps doit \xEAtre un entier 0-10." }, { status: 400 });
3690
4095
  }
3691
4096
  if (comment && typeof comment === "string" && comment.length > 5e3) {
3692
4097
  return Response.json(
@@ -3694,8 +4099,7 @@ function createSatisfactionEndpoint(slugs) {
3694
4099
  { status: 400 }
3695
4100
  );
3696
4101
  }
3697
- const ticket = await payload.findByID({
3698
- collection: slugs.tickets,
4102
+ const ticket = await dbFindByID(payload, slugs.tickets, {
3699
4103
  id: ticketId,
3700
4104
  depth: 0,
3701
4105
  overrideAccess: false,
@@ -3710,8 +4114,7 @@ function createSatisfactionEndpoint(slugs) {
3710
4114
  { status: 400 }
3711
4115
  );
3712
4116
  }
3713
- const existing = await payload.find({
3714
- collection: slugs.satisfactionSurveys,
4117
+ const existing = await dbFind(payload, slugs.satisfactionSurveys, {
3715
4118
  where: { ticket: { equals: ticketId } },
3716
4119
  limit: 1,
3717
4120
  depth: 0,
@@ -3723,13 +4126,13 @@ function createSatisfactionEndpoint(slugs) {
3723
4126
  { status: 409 }
3724
4127
  );
3725
4128
  }
3726
- const survey = await payload.create({
3727
- collection: slugs.satisfactionSurveys,
4129
+ const survey = await dbCreate(payload, slugs.satisfactionSurveys, {
3728
4130
  data: {
3729
4131
  source: "ticket",
3730
4132
  ticket: ticketId,
3731
4133
  client: req.user.id,
3732
- rating: Math.round(rating),
4134
+ ...hasRating ? { rating: Math.round(rating) } : {},
4135
+ ...hasNps ? { nps: Math.round(nps) } : {},
3733
4136
  ...comment ? { comment: comment.trim() } : {}
3734
4137
  },
3735
4138
  overrideAccess: true
@@ -3744,124 +4147,6 @@ function createSatisfactionEndpoint(slugs) {
3744
4147
  }
3745
4148
  };
3746
4149
  }
3747
- var TRANSPARENT_GIF = Buffer.from(
3748
- "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
3749
- "base64"
3750
- );
3751
- function generateTrackingToken(ticketId, messageId, secret) {
3752
- return crypto3.createHmac("sha256", secret).update(`${ticketId}:${messageId}`).digest("hex").substring(0, 16);
3753
- }
3754
- function createTrackOpenEndpoint(slugs) {
3755
- return {
3756
- path: "/support/track-open",
3757
- method: "get",
3758
- handler: async (req) => {
3759
- const url = new URL(req.url);
3760
- const ticketId = url.searchParams.get("t");
3761
- const messageId = url.searchParams.get("m");
3762
- const sig = url.searchParams.get("sig");
3763
- const parsedId = ticketId ? Number(ticketId) : NaN;
3764
- const parsedMsgId = messageId ? Number(messageId) : NaN;
3765
- const secret = process.env.PAYLOAD_SECRET || "";
3766
- if (secret && ticketId && messageId && sig) {
3767
- const expected = generateTrackingToken(ticketId, messageId, secret);
3768
- if (sig !== expected) {
3769
- return new Response(TRANSPARENT_GIF, {
3770
- status: 200,
3771
- headers: {
3772
- "Content-Type": "image/gif",
3773
- "Content-Length": String(TRANSPARENT_GIF.length),
3774
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
3775
- }
3776
- });
3777
- }
3778
- } else if (secret && (!sig || !ticketId || !messageId)) {
3779
- return new Response(TRANSPARENT_GIF, {
3780
- status: 200,
3781
- headers: {
3782
- "Content-Type": "image/gif",
3783
- "Content-Length": String(TRANSPARENT_GIF.length),
3784
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
3785
- }
3786
- });
3787
- }
3788
- if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
3789
- try {
3790
- const payload = req.payload;
3791
- const ticket = await payload.findByID({
3792
- collection: slugs.tickets,
3793
- id: parsedId,
3794
- depth: 0,
3795
- overrideAccess: true,
3796
- select: { lastClientReadAt: true }
3797
- });
3798
- if (ticket) {
3799
- const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0;
3800
- const fiveMinAgo = Date.now() - 5 * 60 * 1e3;
3801
- if (lastRead < fiveMinAgo) {
3802
- await payload.update({
3803
- collection: slugs.tickets,
3804
- id: parsedId,
3805
- data: { lastClientReadAt: (/* @__PURE__ */ new Date()).toISOString() },
3806
- overrideAccess: true
3807
- });
3808
- }
3809
- }
3810
- if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
3811
- const msg = await payload.findByID({
3812
- collection: slugs.ticketMessages,
3813
- id: parsedMsgId,
3814
- depth: 0,
3815
- overrideAccess: true,
3816
- select: { emailOpenedAt: true }
3817
- });
3818
- if (msg && !msg.emailOpenedAt) {
3819
- await payload.update({
3820
- collection: slugs.ticketMessages,
3821
- id: parsedMsgId,
3822
- data: { emailOpenedAt: (/* @__PURE__ */ new Date()).toISOString() },
3823
- overrideAccess: true
3824
- });
3825
- const ticketInfo = await payload.findByID({
3826
- collection: slugs.tickets,
3827
- id: parsedId,
3828
- depth: 1,
3829
- overrideAccess: true,
3830
- select: { ticketNumber: true, subject: true, client: true }
3831
- });
3832
- const clientName = typeof ticketInfo?.client === "object" ? ticketInfo.client?.firstName || "Client" : "Client";
3833
- try {
3834
- await payload.create({
3835
- collection: "admin-notifications",
3836
- data: {
3837
- title: `Email ouvert \u2014 ${ticketInfo?.ticketNumber || "TK-????"}`,
3838
- message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || "ticket"}"`,
3839
- type: "email_opened",
3840
- link: `/admin/ticket?id=${parsedId}`
3841
- },
3842
- overrideAccess: true
3843
- });
3844
- } catch {
3845
- }
3846
- }
3847
- }
3848
- } catch (err) {
3849
- console.error("[track-open] Error:", err);
3850
- }
3851
- }
3852
- return new Response(TRANSPARENT_GIF, {
3853
- status: 200,
3854
- headers: {
3855
- "Content-Type": "image/gif",
3856
- "Content-Length": String(TRANSPARENT_GIF.length),
3857
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
3858
- "Pragma": "no-cache",
3859
- "Expires": "0"
3860
- }
3861
- });
3862
- }
3863
- };
3864
- }
3865
4150
 
3866
4151
  // src/endpoints/export-csv.ts
3867
4152
  function createExportCsvEndpoint(slugs) {
@@ -3877,8 +4162,7 @@ function createExportCsvEndpoint(slugs) {
3877
4162
  let hasMore = true;
3878
4163
  const allDocs = [];
3879
4164
  while (hasMore) {
3880
- const batch = await payload.find({
3881
- collection: slugs.tickets,
4165
+ const batch = await dbFind(payload, slugs.tickets, {
3882
4166
  limit: PAGE_SIZE2,
3883
4167
  page,
3884
4168
  depth: 1,
@@ -3954,21 +4238,18 @@ function createExportDataEndpoint(slugs) {
3954
4238
  const payload = req.payload;
3955
4239
  requireClient(req, slugs);
3956
4240
  const [clientData, ticketsResult, messagesResult, surveysResult] = await Promise.all([
3957
- payload.findByID({
3958
- collection: slugs.supportClients,
4241
+ dbFindByID(payload, slugs.supportClients, {
3959
4242
  id: req.user.id,
3960
4243
  depth: 0,
3961
4244
  overrideAccess: true
3962
4245
  }),
3963
- payload.find({
3964
- collection: slugs.tickets,
4246
+ dbFind(payload, slugs.tickets, {
3965
4247
  where: { client: { equals: req.user.id } },
3966
4248
  limit: 1e3,
3967
4249
  depth: 0,
3968
4250
  overrideAccess: true
3969
4251
  }),
3970
- payload.find({
3971
- collection: slugs.ticketMessages,
4252
+ dbFind(payload, slugs.ticketMessages, {
3972
4253
  where: {
3973
4254
  "ticket.client": { equals: req.user.id },
3974
4255
  authorType: { equals: "client" }
@@ -3977,8 +4258,7 @@ function createExportDataEndpoint(slugs) {
3977
4258
  depth: 0,
3978
4259
  overrideAccess: true
3979
4260
  }),
3980
- payload.find({
3981
- collection: slugs.satisfactionSurveys,
4261
+ dbFind(payload, slugs.satisfactionSurveys, {
3982
4262
  where: { client: { equals: req.user.id } },
3983
4263
  limit: 500,
3984
4264
  depth: 0,
@@ -4052,8 +4332,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4052
4332
  if (!["create_ticket", "add_to_ticket", "ignore"].includes(action)) {
4053
4333
  return Response.json({ error: "Invalid action" }, { status: 400 });
4054
4334
  }
4055
- const pendingEmail = await payload.findByID({
4056
- collection: slugs.pendingEmails,
4335
+ const pendingEmail = await dbFindByID(payload, slugs.pendingEmails, {
4057
4336
  id: Number(id),
4058
4337
  depth: 1,
4059
4338
  overrideAccess: true
@@ -4067,14 +4346,12 @@ function createPendingEmailsProcessEndpoint(slugs) {
4067
4346
  let clientId = overrideClientId || (typeof pendingEmail.client === "object" ? pendingEmail.client?.id : pendingEmail.client);
4068
4347
  let clientDoc = typeof pendingEmail.client === "object" ? pendingEmail.client : null;
4069
4348
  if (overrideClientId && overrideClientId !== clientDoc?.id) {
4070
- clientDoc = await payload.findByID({
4071
- collection: slugs.supportClients,
4349
+ clientDoc = await dbFindByID(payload, slugs.supportClients, {
4072
4350
  id: overrideClientId,
4073
4351
  depth: 0,
4074
4352
  overrideAccess: true
4075
4353
  });
4076
- await payload.update({
4077
- collection: slugs.pendingEmails,
4354
+ await dbUpdate(payload, slugs.pendingEmails, {
4078
4355
  id: Number(id),
4079
4356
  data: { client: overrideClientId },
4080
4357
  overrideAccess: true
@@ -4091,8 +4368,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4091
4368
  file: typeof a.file === "object" ? a.file.id : a.file
4092
4369
  })) || [];
4093
4370
  if (action === "ignore") {
4094
- await payload.update({
4095
- collection: slugs.pendingEmails,
4371
+ await dbUpdate(payload, slugs.pendingEmails, {
4096
4372
  id: Number(id),
4097
4373
  data: {
4098
4374
  status: "ignored",
@@ -4104,8 +4380,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4104
4380
  return Response.json({ action: "ignored", pendingEmailId: Number(id) });
4105
4381
  }
4106
4382
  if (action === "create_ticket") {
4107
- const newTicket = await payload.create({
4108
- collection: slugs.tickets,
4383
+ const newTicket = await dbCreate(payload, slugs.tickets, {
4109
4384
  data: {
4110
4385
  subject: pendingEmail.subject,
4111
4386
  client: clientId,
@@ -4116,8 +4391,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4116
4391
  },
4117
4392
  overrideAccess: true
4118
4393
  });
4119
- await payload.create({
4120
- collection: slugs.ticketMessages,
4394
+ await dbCreate(payload, slugs.ticketMessages, {
4121
4395
  data: {
4122
4396
  ticket: newTicket.id,
4123
4397
  body: pendingEmail.body,
@@ -4144,8 +4418,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4144
4418
  } catch (err) {
4145
4419
  console.error("[pending-email-process] Failed to send notification:", err);
4146
4420
  }
4147
- await payload.update({
4148
- collection: slugs.pendingEmails,
4421
+ await dbUpdate(payload, slugs.pendingEmails, {
4149
4422
  id: Number(id),
4150
4423
  data: {
4151
4424
  status: "processed",
@@ -4165,8 +4438,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4165
4438
  if (!ticketId) {
4166
4439
  return Response.json({ error: "ticketId is required for add_to_ticket" }, { status: 400 });
4167
4440
  }
4168
- const targetTicket = await payload.findByID({
4169
- collection: slugs.tickets,
4441
+ const targetTicket = await dbFindByID(payload, slugs.tickets, {
4170
4442
  id: ticketId,
4171
4443
  depth: 0,
4172
4444
  overrideAccess: true
@@ -4174,8 +4446,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4174
4446
  if (!targetTicket) {
4175
4447
  return Response.json({ error: "Target ticket not found" }, { status: 404 });
4176
4448
  }
4177
- await payload.create({
4178
- collection: slugs.ticketMessages,
4449
+ await dbCreate(payload, slugs.ticketMessages, {
4179
4450
  data: {
4180
4451
  ticket: ticketId,
4181
4452
  body: pendingEmail.body,
@@ -4187,15 +4458,13 @@ function createPendingEmailsProcessEndpoint(slugs) {
4187
4458
  overrideAccess: true
4188
4459
  });
4189
4460
  if (targetTicket.status === "resolved") {
4190
- await payload.update({
4191
- collection: slugs.tickets,
4461
+ await dbUpdate(payload, slugs.tickets, {
4192
4462
  id: ticketId,
4193
4463
  data: { status: "open" },
4194
4464
  overrideAccess: true
4195
4465
  });
4196
4466
  }
4197
- await payload.update({
4198
- collection: slugs.pendingEmails,
4467
+ await dbUpdate(payload, slugs.pendingEmails, {
4199
4468
  id: Number(id),
4200
4469
  data: {
4201
4470
  status: "processed",
@@ -4248,8 +4517,7 @@ function createResendNotificationEndpoint(slugs) {
4248
4517
  if (!messageId) {
4249
4518
  return Response.json({ error: "messageId requis" }, { status: 400 });
4250
4519
  }
4251
- const message = await payload.findByID({
4252
- collection: slugs.ticketMessages,
4520
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
4253
4521
  id: messageId,
4254
4522
  depth: 0,
4255
4523
  overrideAccess: true
@@ -4258,8 +4526,7 @@ function createResendNotificationEndpoint(slugs) {
4258
4526
  return Response.json({ error: "Message introuvable" }, { status: 404 });
4259
4527
  }
4260
4528
  const ticketId = typeof message.ticket === "object" ? message.ticket.id : message.ticket;
4261
- const ticket = await payload.findByID({
4262
- collection: slugs.tickets,
4529
+ const ticket = await dbFindByID(payload, slugs.tickets, {
4263
4530
  id: ticketId,
4264
4531
  depth: 1,
4265
4532
  overrideAccess: true
@@ -4385,8 +4652,7 @@ function createSeedKbEndpoint(slugs) {
4385
4652
  let created = 0;
4386
4653
  let skipped = 0;
4387
4654
  for (const article of KB_ARTICLES) {
4388
- const existing = await payload.find({
4389
- collection: slugs.knowledgeBase,
4655
+ const existing = await dbFind(payload, slugs.knowledgeBase, {
4390
4656
  where: { slug: { equals: article.slug } },
4391
4657
  limit: 1,
4392
4658
  depth: 0,
@@ -4413,8 +4679,7 @@ function createSeedKbEndpoint(slugs) {
4413
4679
  version: 1
4414
4680
  }
4415
4681
  };
4416
- await payload.create({
4417
- collection: slugs.knowledgeBase,
4682
+ await dbCreate(payload, slugs.knowledgeBase, {
4418
4683
  data: {
4419
4684
  title: article.title,
4420
4685
  slug: article.slug,
@@ -4469,8 +4734,7 @@ function createLoginEndpoint(slugs) {
4469
4734
  collection: slugs.supportClients,
4470
4735
  data: { email, password }
4471
4736
  });
4472
- payload.create({
4473
- collection: slugs.authLogs,
4737
+ dbCreate(payload, slugs.authLogs, {
4474
4738
  data: { email, success: true, action: "login", ipAddress: ip, userAgent },
4475
4739
  overrideAccess: true
4476
4740
  }).catch(() => {
@@ -4494,12 +4758,14 @@ function createLoginEndpoint(slugs) {
4494
4758
  );
4495
4759
  } catch (err) {
4496
4760
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
4761
+ if (errorMessage.includes("2FA_REQUIRED")) {
4762
+ return Response.json({ requires2FA: true }, { status: 200 });
4763
+ }
4497
4764
  let errorReason = "Identifiants incorrects";
4498
4765
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
4499
4766
  errorReason = "Compte verrouill\xE9 (trop de tentatives)";
4500
4767
  }
4501
- payload.create({
4502
- collection: slugs.authLogs,
4768
+ dbCreate(payload, slugs.authLogs, {
4503
4769
  data: { email, success: false, action: "login", errorReason, ipAddress: ip, userAgent },
4504
4770
  overrideAccess: true
4505
4771
  }).catch(() => {
@@ -4520,7 +4786,10 @@ function generateSecureCode() {
4520
4786
  return String(num);
4521
4787
  }
4522
4788
  function hashCode(code) {
4523
- const secret = process.env.PAYLOAD_SECRET || "payload-support-2fa";
4789
+ const secret = process.env.PAYLOAD_SECRET;
4790
+ if (!secret) {
4791
+ throw new Error("[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to operate 2FA with an insecure fallback secret");
4792
+ }
4524
4793
  return crypto3.createHmac("sha256", secret).update(code).digest("hex");
4525
4794
  }
4526
4795
  function createAuth2faEndpoint(slugs) {
@@ -4545,8 +4814,7 @@ function createAuth2faEndpoint(slugs) {
4545
4814
  if (sendLimiter.check(email)) {
4546
4815
  return Response.json(genericSendResponse);
4547
4816
  }
4548
- const clients = await payload.find({
4549
- collection: slugs.supportClients,
4817
+ const clients = await dbFind(payload, slugs.supportClients, {
4550
4818
  where: { email: { equals: email } },
4551
4819
  limit: 1,
4552
4820
  depth: 0,
@@ -4559,8 +4827,7 @@ function createAuth2faEndpoint(slugs) {
4559
4827
  const plainCode = generateSecureCode();
4560
4828
  const twoFactorCode = hashCode(plainCode);
4561
4829
  const twoFactorExpiry = new Date(Date.now() + 10 * 60 * 1e3).toISOString();
4562
- await payload.update({
4563
- collection: slugs.supportClients,
4830
+ await dbUpdate(payload, slugs.supportClients, {
4564
4831
  id: client.id,
4565
4832
  data: { twoFactorCode, twoFactorExpiry },
4566
4833
  overrideAccess: true
@@ -4591,8 +4858,7 @@ function createAuth2faEndpoint(slugs) {
4591
4858
  { status: 429 }
4592
4859
  );
4593
4860
  }
4594
- const clients = await payload.find({
4595
- collection: slugs.supportClients,
4861
+ const clients = await dbFind(payload, slugs.supportClients, {
4596
4862
  where: { email: { equals: email } },
4597
4863
  limit: 1,
4598
4864
  depth: 0,
@@ -4608,8 +4874,7 @@ function createAuth2faEndpoint(slugs) {
4608
4874
  return Response.json({ error: "Aucun code en attente" }, { status: 400 });
4609
4875
  }
4610
4876
  if (/* @__PURE__ */ new Date() > new Date(storedExpiry)) {
4611
- await payload.update({
4612
- collection: slugs.supportClients,
4877
+ await dbUpdate(payload, slugs.supportClients, {
4613
4878
  id: client.id,
4614
4879
  data: { twoFactorCode: "", twoFactorExpiry: "" },
4615
4880
  overrideAccess: true
@@ -4623,10 +4888,9 @@ function createAuth2faEndpoint(slugs) {
4623
4888
  if (submittedBuffer.length !== storedBuffer.length || !crypto3__default.default.timingSafeEqual(submittedBuffer, storedBuffer)) {
4624
4889
  return Response.json({ error: "Code incorrect" }, { status: 400 });
4625
4890
  }
4626
- await payload.update({
4627
- collection: slugs.supportClients,
4891
+ await dbUpdate(payload, slugs.supportClients, {
4628
4892
  id: client.id,
4629
- data: { twoFactorCode: "", twoFactorExpiry: "" },
4893
+ data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
4630
4894
  overrideAccess: true
4631
4895
  });
4632
4896
  verifyLimiter.reset(email);
@@ -4700,15 +4964,33 @@ function createOAuthGoogleEndpoint(slugs, options) {
4700
4964
  if (!profile.email) {
4701
4965
  return Response.json({ error: "no_email" }, { status: 400 });
4702
4966
  }
4703
- const payload = req.payload;
4704
- const existing = await payload.find({
4705
- collection: slugs.supportClients,
4706
- where: { email: { equals: profile.email } },
4967
+ if (profile.verified_email !== true) {
4968
+ return Response.json({ error: "email_not_verified" }, { status: 403 });
4969
+ }
4970
+ const payload$1 = req.payload;
4971
+ const byGoogle = await dbFind(payload$1, slugs.supportClients, {
4972
+ where: { googleId: { equals: profile.id } },
4707
4973
  limit: 1,
4708
4974
  depth: 0,
4709
4975
  overrideAccess: true
4710
4976
  });
4711
- let clientDoc = existing.docs[0];
4977
+ let clientDoc = byGoogle.docs[0];
4978
+ if (!clientDoc) {
4979
+ const byEmail = await dbFind(payload$1, slugs.supportClients, {
4980
+ where: { email: { equals: profile.email } },
4981
+ limit: 1,
4982
+ depth: 0,
4983
+ overrideAccess: true
4984
+ });
4985
+ clientDoc = byEmail.docs[0];
4986
+ if (clientDoc && profile.id) {
4987
+ await dbUpdate(payload$1, slugs.supportClients, {
4988
+ id: clientDoc.id,
4989
+ data: { googleId: profile.id },
4990
+ overrideAccess: true
4991
+ });
4992
+ }
4993
+ }
4712
4994
  if (!clientDoc) {
4713
4995
  const allowedDomains = options?.allowedEmailDomains;
4714
4996
  if (allowedDomains && allowedDomains.length > 0) {
@@ -4726,43 +5008,64 @@ function createOAuthGoogleEndpoint(slugs, options) {
4726
5008
  const autoPassword = crypto3__default.default.randomBytes(48).toString("base64url");
4727
5009
  const fullName = profile.name || profile.email.split("@")[0];
4728
5010
  const nameParts = fullName.split(" ");
4729
- clientDoc = await payload.create({
4730
- collection: slugs.supportClients,
5011
+ clientDoc = await dbCreate(payload$1, slugs.supportClients, {
4731
5012
  data: {
4732
5013
  email: profile.email,
4733
5014
  firstName: nameParts[0] || fullName,
4734
5015
  lastName: nameParts.slice(1).join(" ") || "-",
4735
5016
  company: fullName,
5017
+ googleId: profile.id,
4736
5018
  password: autoPassword
4737
5019
  },
4738
5020
  overrideAccess: true
4739
5021
  });
4740
5022
  }
4741
- const tempPassword = crypto3__default.default.randomBytes(48).toString("base64url");
4742
- await payload.update({
4743
- collection: slugs.supportClients,
4744
- id: clientDoc.id,
4745
- data: { password: tempPassword },
4746
- overrideAccess: true
4747
- });
4748
- const loginResult = await payload.login({
4749
- collection: slugs.supportClients,
4750
- data: { email: profile.email, password: tempPassword }
4751
- });
4752
- const postLoginPassword = crypto3__default.default.randomBytes(48).toString("base64url");
4753
- await payload.update({
4754
- collection: slugs.supportClients,
4755
- id: clientDoc.id,
4756
- data: { password: postLoginPassword },
4757
- overrideAccess: true
4758
- });
4759
- if (!loginResult.token) {
4760
- return Response.json({ error: "login_failed" }, { status: 400 });
5023
+ const secret = process.env.PAYLOAD_SECRET;
5024
+ if (!secret) {
5025
+ return Response.json({ error: "server_misconfigured" }, { status: 500 });
4761
5026
  }
4762
- return Response.json({
4763
- token: loginResult.token,
4764
- user: loginResult.user,
4765
- exp: loginResult.exp
5027
+ const collectionConfig = payload$1.collections[slugs.supportClients].config;
5028
+ const tokenExpiration = collectionConfig.auth?.tokenExpiration ?? 7200;
5029
+ let sid;
5030
+ if (collectionConfig.auth?.useSessions) {
5031
+ sid = crypto3__default.default.randomUUID();
5032
+ const now = /* @__PURE__ */ new Date();
5033
+ const expiresAt = new Date(now.getTime() + tokenExpiration * 1e3);
5034
+ const fresh = await dbFindByID(payload$1, slugs.supportClients, {
5035
+ id: clientDoc.id,
5036
+ depth: 0,
5037
+ overrideAccess: true,
5038
+ showHiddenFields: true
5039
+ });
5040
+ const kept = Array.isArray(fresh?.sessions) ? fresh.sessions.filter((s) => new Date(s.expiresAt) > now) : [];
5041
+ await payload$1.db.updateOne({
5042
+ collection: slugs.supportClients,
5043
+ id: clientDoc.id,
5044
+ data: {
5045
+ sessions: [
5046
+ ...kept,
5047
+ { id: sid, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString() }
5048
+ ]
5049
+ },
5050
+ returning: false
5051
+ });
5052
+ }
5053
+ const fieldsToSign = payload.getFieldsToSign({
5054
+ collectionConfig,
5055
+ email: clientDoc.email,
5056
+ sid,
5057
+ user: { ...clientDoc, collection: slugs.supportClients }
5058
+ });
5059
+ const { token, exp } = await payload.jwtSign({ fieldsToSign, secret, tokenExpiration });
5060
+ const headers = new Headers({ "Content-Type": "application/json" });
5061
+ const cookieSecure = process.env.NODE_ENV === "production";
5062
+ headers.append(
5063
+ "Set-Cookie",
5064
+ `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5065
+ );
5066
+ return new Response(JSON.stringify({ token, user: clientDoc, exp }), {
5067
+ status: 200,
5068
+ headers
4766
5069
  });
4767
5070
  }
4768
5071
  return Response.json({ error: "Action invalide" }, { status: 400 });
@@ -4803,8 +5106,7 @@ function createDeleteAccountEndpoint(slugs) {
4803
5106
  );
4804
5107
  }
4805
5108
  const clientId = req.user.id;
4806
- const tickets = await payload.find({
4807
- collection: slugs.tickets,
5109
+ const tickets = await dbFind(payload, slugs.tickets, {
4808
5110
  where: { client: { equals: clientId } },
4809
5111
  limit: 1e4,
4810
5112
  depth: 0,
@@ -4813,39 +5115,32 @@ function createDeleteAccountEndpoint(slugs) {
4813
5115
  });
4814
5116
  const ticketIds = tickets.docs.map((t) => t.id);
4815
5117
  if (ticketIds.length > 0) {
4816
- await payload.delete({
4817
- collection: slugs.ticketMessages,
5118
+ await dbDelete(payload, slugs.ticketMessages, {
4818
5119
  where: { ticket: { in: ticketIds } },
4819
5120
  overrideAccess: true
4820
5121
  });
4821
- await payload.delete({
4822
- collection: slugs.ticketActivityLog,
5122
+ await dbDelete(payload, slugs.ticketActivityLog, {
4823
5123
  where: { ticket: { in: ticketIds } },
4824
5124
  overrideAccess: true
4825
5125
  });
4826
- await payload.delete({
4827
- collection: slugs.timeEntries,
5126
+ await dbDelete(payload, slugs.timeEntries, {
4828
5127
  where: { ticket: { in: ticketIds } },
4829
5128
  overrideAccess: true
4830
5129
  });
4831
- await payload.delete({
4832
- collection: slugs.tickets,
5130
+ await dbDelete(payload, slugs.tickets, {
4833
5131
  where: { client: { equals: clientId } },
4834
5132
  overrideAccess: true
4835
5133
  });
4836
5134
  }
4837
- await payload.delete({
4838
- collection: slugs.satisfactionSurveys,
5135
+ await dbDelete(payload, slugs.satisfactionSurveys, {
4839
5136
  where: { client: { equals: clientId } },
4840
5137
  overrideAccess: true
4841
5138
  });
4842
- await payload.delete({
4843
- collection: slugs.chatMessages,
5139
+ await dbDelete(payload, slugs.chatMessages, {
4844
5140
  where: { client: { equals: clientId } },
4845
5141
  overrideAccess: true
4846
5142
  });
4847
- await payload.delete({
4848
- collection: slugs.supportClients,
5143
+ await dbDelete(payload, slugs.supportClients, {
4849
5144
  id: clientId,
4850
5145
  overrideAccess: true
4851
5146
  });
@@ -4886,8 +5181,8 @@ function createMergeClientsEndpoint(slugs) {
4886
5181
  return Response.json({ error: "sourceId and targetId are required and must be different" }, { status: 400 });
4887
5182
  }
4888
5183
  const [source, target] = await Promise.all([
4889
- payload.findByID({ collection: slugs.supportClients, id: sourceId, depth: 0, overrideAccess: true }),
4890
- payload.findByID({ collection: slugs.supportClients, id: targetId, depth: 0, overrideAccess: true })
5184
+ dbFindByID(payload, slugs.supportClients, { id: sourceId, depth: 0, overrideAccess: true }),
5185
+ dbFindByID(payload, slugs.supportClients, { id: targetId, depth: 0, overrideAccess: true })
4891
5186
  ]);
4892
5187
  if (!source || !target) {
4893
5188
  return Response.json({ error: "Source or target client not found" }, { status: 404 });
@@ -4899,63 +5194,57 @@ function createMergeClientsEndpoint(slugs) {
4899
5194
  pendingEmails: 0,
4900
5195
  satisfactionSurveys: 0
4901
5196
  };
4902
- const tickets = await payload.find({
4903
- collection: slugs.tickets,
5197
+ const tickets = await dbFind(payload, slugs.tickets, {
4904
5198
  where: { client: { equals: sourceId } },
4905
5199
  limit: 500,
4906
5200
  depth: 0,
4907
5201
  overrideAccess: true
4908
5202
  });
4909
5203
  for (const ticket of tickets.docs) {
4910
- await payload.update({ collection: slugs.tickets, id: ticket.id, data: { client: targetId }, overrideAccess: true });
5204
+ await dbUpdate(payload, slugs.tickets, { id: ticket.id, data: { client: targetId }, overrideAccess: true });
4911
5205
  results.tickets++;
4912
5206
  }
4913
- const messages = await payload.find({
4914
- collection: slugs.ticketMessages,
5207
+ const messages = await dbFind(payload, slugs.ticketMessages, {
4915
5208
  where: { authorClient: { equals: sourceId } },
4916
5209
  limit: 1e3,
4917
5210
  depth: 0,
4918
5211
  overrideAccess: true
4919
5212
  });
4920
5213
  for (const msg of messages.docs) {
4921
- await payload.update({ collection: slugs.ticketMessages, id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
5214
+ await dbUpdate(payload, slugs.ticketMessages, { id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
4922
5215
  results.ticketMessages++;
4923
5216
  }
4924
- const chats = await payload.find({
4925
- collection: slugs.chatMessages,
5217
+ const chats = await dbFind(payload, slugs.chatMessages, {
4926
5218
  where: { client: { equals: sourceId } },
4927
5219
  limit: 1e3,
4928
5220
  depth: 0,
4929
5221
  overrideAccess: true
4930
5222
  });
4931
5223
  for (const chat of chats.docs) {
4932
- await payload.update({ collection: slugs.chatMessages, id: chat.id, data: { client: targetId }, overrideAccess: true });
5224
+ await dbUpdate(payload, slugs.chatMessages, { id: chat.id, data: { client: targetId }, overrideAccess: true });
4933
5225
  results.chatMessages++;
4934
5226
  }
4935
- const pendingEmails = await payload.find({
4936
- collection: slugs.pendingEmails,
5227
+ const pendingEmails = await dbFind(payload, slugs.pendingEmails, {
4937
5228
  where: { client: { equals: sourceId } },
4938
5229
  limit: 500,
4939
5230
  depth: 0,
4940
5231
  overrideAccess: true
4941
5232
  });
4942
5233
  for (const pe of pendingEmails.docs) {
4943
- await payload.update({ collection: slugs.pendingEmails, id: pe.id, data: { client: targetId }, overrideAccess: true });
5234
+ await dbUpdate(payload, slugs.pendingEmails, { id: pe.id, data: { client: targetId }, overrideAccess: true });
4944
5235
  results.pendingEmails++;
4945
5236
  }
4946
- const surveys = await payload.find({
4947
- collection: slugs.satisfactionSurveys,
5237
+ const surveys = await dbFind(payload, slugs.satisfactionSurveys, {
4948
5238
  where: { client: { equals: sourceId } },
4949
5239
  limit: 500,
4950
5240
  depth: 0,
4951
5241
  overrideAccess: true
4952
5242
  });
4953
5243
  for (const survey of surveys.docs) {
4954
- await payload.update({ collection: slugs.satisfactionSurveys, id: survey.id, data: { client: targetId }, overrideAccess: true });
5244
+ await dbUpdate(payload, slugs.satisfactionSurveys, { id: survey.id, data: { client: targetId }, overrideAccess: true });
4955
5245
  results.satisfactionSurveys++;
4956
5246
  }
4957
- await payload.delete({
4958
- collection: slugs.supportClients,
5247
+ await dbDelete(payload, slugs.supportClients, {
4959
5248
  id: sourceId,
4960
5249
  overrideAccess: true
4961
5250
  });
@@ -5149,8 +5438,7 @@ function createImportConversationEndpoint(slugs) {
5149
5438
  if (blockedEmails.includes(conversation.client.email)) {
5150
5439
  return Response.json({ error: "Cannot create ticket from system email address" }, { status: 400 });
5151
5440
  }
5152
- const clientResult = await payload.find({
5153
- collection: slugs.supportClients,
5441
+ const clientResult = await dbFind(payload, slugs.supportClients, {
5154
5442
  where: { email: { equals: conversation.client.email } },
5155
5443
  limit: 1,
5156
5444
  depth: 0,
@@ -5161,8 +5449,7 @@ function createImportConversationEndpoint(slugs) {
5161
5449
  if (!client) {
5162
5450
  const nameParts = conversation.client.name.split(" ");
5163
5451
  const randomPassword = crypto3__default.default.randomBytes(48).toString("base64url");
5164
- client = await payload.create({
5165
- collection: slugs.supportClients,
5452
+ client = await dbCreate(payload, slugs.supportClients, {
5166
5453
  data: {
5167
5454
  email: conversation.client.email,
5168
5455
  password: randomPassword,
@@ -5174,15 +5461,13 @@ function createImportConversationEndpoint(slugs) {
5174
5461
  });
5175
5462
  isNewClient = true;
5176
5463
  }
5177
- const adminUsers = await payload.find({
5178
- collection: slugs.users,
5464
+ const adminUsers = await dbFind(payload, slugs.users, {
5179
5465
  limit: 1,
5180
5466
  depth: 0,
5181
5467
  overrideAccess: true
5182
5468
  });
5183
5469
  const adminUserId = adminUsers.docs[0]?.id;
5184
- const ticket = await payload.create({
5185
- collection: slugs.tickets,
5470
+ const ticket = await dbCreate(payload, slugs.tickets, {
5186
5471
  data: {
5187
5472
  subject: conversation.subject,
5188
5473
  client: client.id,
@@ -5195,8 +5480,7 @@ function createImportConversationEndpoint(slugs) {
5195
5480
  let importedCount = 0;
5196
5481
  for (const msg of conversation.messages) {
5197
5482
  const isAdmin = msg.from === "admin";
5198
- await payload.create({
5199
- collection: slugs.ticketMessages,
5483
+ await dbCreate(payload, slugs.ticketMessages, {
5200
5484
  data: {
5201
5485
  ticket: ticket.id,
5202
5486
  body: msg.content,
@@ -5232,8 +5516,7 @@ function createImportConversationEndpoint(slugs) {
5232
5516
  // src/utils/fireWebhooks.ts
5233
5517
  async function fireWebhooks(payload, slugs, event, data) {
5234
5518
  try {
5235
- const endpoints = await payload.find({
5236
- collection: slugs.webhookEndpoints,
5519
+ const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
5237
5520
  where: {
5238
5521
  active: { equals: true },
5239
5522
  events: { contains: event }
@@ -5257,8 +5540,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5257
5540
  signal: AbortSignal.timeout(1e4)
5258
5541
  });
5259
5542
  try {
5260
- await payload.update({
5261
- collection: slugs.webhookEndpoints,
5543
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5262
5544
  id: endpoint.id,
5263
5545
  data: {
5264
5546
  lastTriggeredAt: timestamp,
@@ -5271,8 +5553,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5271
5553
  } catch (error) {
5272
5554
  console.warn(`[support] Webhook delivery failed: ${endpoint.url}`, error);
5273
5555
  try {
5274
- await payload.update({
5275
- collection: slugs.webhookEndpoints,
5556
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5276
5557
  id: endpoint.id,
5277
5558
  data: {
5278
5559
  lastTriggeredAt: timestamp,
@@ -5307,8 +5588,7 @@ function createProcessScheduledEndpoint(slugs) {
5307
5588
  const settings = await readSupportSettings(payload);
5308
5589
  const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5309
5590
  const results = { processed: 0, errors: 0 };
5310
- const scheduled = await payload.find({
5311
- collection: slugs.ticketMessages,
5591
+ const scheduled = await dbFind(payload, slugs.ticketMessages, {
5312
5592
  where: {
5313
5593
  and: [
5314
5594
  { scheduledAt: { less_than_equal: now.toISOString() } },
@@ -5328,8 +5608,7 @@ function createProcessScheduledEndpoint(slugs) {
5328
5608
  try {
5329
5609
  const msg = message;
5330
5610
  const ticketId = typeof msg.ticket === "object" ? msg.ticket.id : msg.ticket;
5331
- const ticket = await payload.findByID({
5332
- collection: slugs.tickets,
5611
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5333
5612
  id: ticketId,
5334
5613
  depth: 1,
5335
5614
  overrideAccess: true
@@ -5352,8 +5631,7 @@ function createProcessScheduledEndpoint(slugs) {
5352
5631
  subject: `Re: [${ticketNumber}] ${subject}`,
5353
5632
  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>`
5354
5633
  });
5355
- await payload.update({
5356
- collection: slugs.ticketMessages,
5634
+ await dbUpdate(payload, slugs.ticketMessages, {
5357
5635
  id: msg.id,
5358
5636
  data: {
5359
5637
  scheduledSent: true,
@@ -5363,16 +5641,14 @@ function createProcessScheduledEndpoint(slugs) {
5363
5641
  overrideAccess: true
5364
5642
  });
5365
5643
  } else {
5366
- await payload.update({
5367
- collection: slugs.ticketMessages,
5644
+ await dbUpdate(payload, slugs.ticketMessages, {
5368
5645
  id: msg.id,
5369
5646
  data: { scheduledSent: true },
5370
5647
  overrideAccess: true
5371
5648
  });
5372
5649
  }
5373
5650
  if (msg.authorType === "admin" && !msg.isInternal) {
5374
- await payload.update({
5375
- collection: slugs.tickets,
5651
+ await dbUpdate(payload, slugs.tickets, {
5376
5652
  id: ticketId,
5377
5653
  data: { status: "waiting_client" },
5378
5654
  overrideAccess: true
@@ -5404,6 +5680,208 @@ function createProcessScheduledEndpoint(slugs) {
5404
5680
  };
5405
5681
  }
5406
5682
 
5683
+ // src/endpoints/process-snooze.ts
5684
+ function createProcessSnoozeEndpoint(slugs) {
5685
+ return {
5686
+ path: "/support/process-snooze",
5687
+ method: "post",
5688
+ handler: async (req) => {
5689
+ const secret = req.headers.get("x-cron-secret");
5690
+ const expectedSecret = process.env.CRON_SECRET;
5691
+ if (!expectedSecret || secret !== expectedSecret) {
5692
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5693
+ }
5694
+ try {
5695
+ const payload = req.payload;
5696
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
5697
+ const results = { processed: 0, errors: 0 };
5698
+ const due = await dbFind(payload, slugs.tickets, {
5699
+ where: {
5700
+ and: [
5701
+ { snoozeUntil: { exists: true } },
5702
+ { snoozeUntil: { less_than_equal: nowIso } }
5703
+ ]
5704
+ },
5705
+ limit: 500,
5706
+ depth: 0,
5707
+ overrideAccess: true,
5708
+ select: { id: true, ticketNumber: true }
5709
+ });
5710
+ for (const ticket of due.docs) {
5711
+ try {
5712
+ await dbUpdate(payload, slugs.tickets, {
5713
+ id: ticket.id,
5714
+ data: { snoozeUntil: null },
5715
+ overrideAccess: true
5716
+ });
5717
+ try {
5718
+ await dbCreate(payload, slugs.ticketActivityLog, {
5719
+ data: {
5720
+ ticket: ticket.id,
5721
+ action: "snooze_expired",
5722
+ detail: "Snooze \xE9chu \u2014 ticket r\xE9activ\xE9",
5723
+ actorType: "system"
5724
+ },
5725
+ overrideAccess: true
5726
+ });
5727
+ } catch {
5728
+ }
5729
+ results.processed++;
5730
+ } catch (err) {
5731
+ console.error("[support] Failed to wake snoozed ticket:", err);
5732
+ results.errors++;
5733
+ }
5734
+ }
5735
+ return Response.json({ ok: true, ...results });
5736
+ } catch (err) {
5737
+ console.error("[process-snooze] Error:", err);
5738
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5739
+ }
5740
+ }
5741
+ };
5742
+ }
5743
+
5744
+ // src/endpoints/process-digests.ts
5745
+ function createProcessDigestsEndpoint(slugs) {
5746
+ return {
5747
+ path: "/support/process-digests",
5748
+ method: "post",
5749
+ handler: async (req) => {
5750
+ const secret = req.headers.get("x-cron-secret");
5751
+ if (!process.env.CRON_SECRET || secret !== process.env.CRON_SECRET) {
5752
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5753
+ }
5754
+ try {
5755
+ const payload = req.payload;
5756
+ let body = {};
5757
+ try {
5758
+ body = await req.json();
5759
+ } catch {
5760
+ }
5761
+ const frequency = body.frequency === "weekly" ? "weekly" : "daily";
5762
+ const settings = await readSupportSettings(payload);
5763
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5764
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
5765
+ const itemsByClient = /* @__PURE__ */ new Map();
5766
+ let page = 1;
5767
+ let hasMore = true;
5768
+ while (hasMore && page <= 50) {
5769
+ const batch = await dbFind(payload, slugs.notificationQueue, { limit: 200, page, depth: 0, overrideAccess: true, sort: "createdAt" });
5770
+ for (const it of batch.docs) {
5771
+ const raw = it;
5772
+ const clientId = typeof raw.client === "object" ? raw.client?.id : raw.client;
5773
+ if (clientId === void 0 || clientId === null) continue;
5774
+ const key = String(clientId);
5775
+ if (!itemsByClient.has(key)) itemsByClient.set(key, []);
5776
+ itemsByClient.get(key).push({ id: raw.id, title: raw.title, message: raw.message });
5777
+ }
5778
+ hasMore = batch.hasNextPage ?? false;
5779
+ page++;
5780
+ }
5781
+ const results = { clients: 0, items: 0, errors: 0 };
5782
+ for (const [clientId, items] of itemsByClient) {
5783
+ try {
5784
+ const client = await dbFindByID(payload, slugs.supportClients, { id: clientId, depth: 0, overrideAccess: true });
5785
+ if (!client || client.notificationFrequency !== frequency) continue;
5786
+ if (client.email) {
5787
+ 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("");
5788
+ const html = emailWrapper("Recapitulatif de vos tickets", [
5789
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
5790
+ emailParagraph(`Voici le recapitulatif de l'activite sur vos tickets (${items.length} notification${items.length > 1 ? "s" : ""}) :`),
5791
+ `<table cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table>`,
5792
+ ...baseUrl ? [emailParagraph(`<a href="${baseUrl}/support/dashboard">Acceder a votre espace support</a>`)] : []
5793
+ ].join(""), { kind: "system" });
5794
+ await payload.sendEmail({ to: client.email, ...replyTo ? { replyTo } : {}, subject: `Recapitulatif support (${items.length})`, html });
5795
+ }
5796
+ for (const i of items) {
5797
+ try {
5798
+ await dbDelete(payload, slugs.notificationQueue, { id: i.id, overrideAccess: true });
5799
+ } catch {
5800
+ }
5801
+ }
5802
+ results.clients++;
5803
+ results.items += items.length;
5804
+ } catch (err) {
5805
+ console.error("[process-digests] client error:", err);
5806
+ results.errors++;
5807
+ }
5808
+ }
5809
+ return Response.json({ ok: true, frequency, ...results });
5810
+ } catch (err) {
5811
+ console.error("[process-digests] Error:", err);
5812
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5813
+ }
5814
+ }
5815
+ };
5816
+ }
5817
+ var PROVIDERS = ["whatsapp", "messenger"];
5818
+ function createChannelsWebhookEndpoint(slugs) {
5819
+ return {
5820
+ path: "/support/channels/webhook",
5821
+ method: "post",
5822
+ handler: async (req) => {
5823
+ const secret = req.headers.get("x-channel-secret");
5824
+ if (!process.env.CHANNELS_WEBHOOK_SECRET || secret !== process.env.CHANNELS_WEBHOOK_SECRET) {
5825
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5826
+ }
5827
+ let body;
5828
+ try {
5829
+ body = await req.json();
5830
+ } catch {
5831
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5832
+ }
5833
+ const provider = body.provider;
5834
+ const from = (body.from || "").trim();
5835
+ const text = (body.text || "").trim();
5836
+ if (!PROVIDERS.includes(provider) || !from || !text) {
5837
+ return Response.json({ error: "provider, from et text requis." }, { status: 400 });
5838
+ }
5839
+ try {
5840
+ const payload = req.payload;
5841
+ const email = `${from}@${provider}.channel`;
5842
+ const existing = await dbFind(payload, slugs.supportClients, { where: { email: { equals: email } }, limit: 1, depth: 0, overrideAccess: true });
5843
+ let clientId;
5844
+ if (existing.docs.length > 0) {
5845
+ clientId = existing.docs[0].id;
5846
+ } else {
5847
+ const created = await dbCreate(payload, slugs.supportClients, {
5848
+ data: { email, firstName: body.name || provider, lastName: from.slice(0, 40), company: provider, password: crypto3.randomBytes(16).toString("hex") },
5849
+ overrideAccess: true
5850
+ });
5851
+ clientId = created.id;
5852
+ }
5853
+ const open = await dbFind(payload, slugs.tickets, {
5854
+ where: { and: [{ client: { equals: clientId } }, { source: { equals: provider } }, { status: { in: ["open", "waiting_client"] } }] },
5855
+ sort: "-createdAt",
5856
+ limit: 1,
5857
+ depth: 0,
5858
+ overrideAccess: true
5859
+ });
5860
+ let ticketId;
5861
+ let createdTicket = false;
5862
+ if (open.docs.length > 0) {
5863
+ ticketId = open.docs[0].id;
5864
+ } else {
5865
+ const t = await dbCreate(payload, slugs.tickets, {
5866
+ data: { subject: text.slice(0, 80), client: clientId, status: "open", priority: "normal", source: provider },
5867
+ overrideAccess: true
5868
+ });
5869
+ ticketId = t.id;
5870
+ createdTicket = true;
5871
+ }
5872
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
5873
+ data: { ticket: ticketId, body: text, authorType: "client", authorClient: clientId },
5874
+ overrideAccess: true
5875
+ });
5876
+ return Response.json({ ok: true, ticketId, createdTicket, messageId: msg.id });
5877
+ } catch (err) {
5878
+ console.error("[channels] Error:", err);
5879
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5880
+ }
5881
+ }
5882
+ };
5883
+ }
5884
+
5407
5885
  // src/endpoints/user-prefs.ts
5408
5886
  var PREF_KEY_PREFIX = "support-user-prefs";
5409
5887
  var DEFAULT_USER_PREFS2 = {
@@ -5419,8 +5897,7 @@ function createUserPrefsGetEndpoint(slugs) {
5419
5897
  const payload = req.payload;
5420
5898
  requireAdmin(req, slugs);
5421
5899
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5422
- const prefs = await payload.find({
5423
- collection: "payload-preferences",
5900
+ const prefs = await dbFind(payload, "payload-preferences", {
5424
5901
  where: { key: { equals: key } },
5425
5902
  limit: 1,
5426
5903
  depth: 0,
@@ -5454,8 +5931,7 @@ function createUserPrefsPostEndpoint(slugs) {
5454
5931
  requireAdmin(req, slugs);
5455
5932
  const body = await req.json();
5456
5933
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5457
- const existing = await payload.find({
5458
- collection: "payload-preferences",
5934
+ const existing = await dbFind(payload, "payload-preferences", {
5459
5935
  where: { key: { equals: key } },
5460
5936
  limit: 1,
5461
5937
  depth: 0,
@@ -5516,8 +5992,7 @@ function createEscalateEndpoint(slugs) {
5516
5992
  if (Number.isNaN(ticketId)) {
5517
5993
  return Response.json({ error: "invalid-id" }, { status: 400 });
5518
5994
  }
5519
- const ticket = await req.payload.update({
5520
- collection: slugs.tickets,
5995
+ const ticket = await dbUpdate(req.payload, slugs.tickets, {
5521
5996
  id: ticketId,
5522
5997
  data: { status: "escalated" },
5523
5998
  overrideAccess: true
@@ -5565,8 +6040,7 @@ function createTicketFeedbackEndpoint(slugs) {
5565
6040
  { status: 400 }
5566
6041
  );
5567
6042
  }
5568
- const ticket = await payload.findByID({
5569
- collection: slugs.tickets,
6043
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5570
6044
  id: ticketId,
5571
6045
  depth: 0,
5572
6046
  overrideAccess: true
@@ -5578,8 +6052,7 @@ function createTicketFeedbackEndpoint(slugs) {
5578
6052
  if (ticketClientId !== req.user.id) {
5579
6053
  return Response.json({ error: "forbidden" }, { status: 403 });
5580
6054
  }
5581
- const existing = await payload.find({
5582
- collection: "ticket-feedback",
6055
+ const existing = await dbFind(payload, "ticket-feedback", {
5583
6056
  where: { ticket: { equals: ticketId } },
5584
6057
  limit: 1,
5585
6058
  depth: 0,
@@ -5591,8 +6064,7 @@ function createTicketFeedbackEndpoint(slugs) {
5591
6064
  { status: 409 }
5592
6065
  );
5593
6066
  }
5594
- const feedback = await payload.create({
5595
- collection: "ticket-feedback",
6067
+ const feedback = await dbCreate(payload, "ticket-feedback", {
5596
6068
  data: {
5597
6069
  ticket: ticketId,
5598
6070
  client: req.user.id,
@@ -5616,6 +6088,7 @@ function createTicketFeedbackEndpoint(slugs) {
5616
6088
  // src/endpoints/transfer-ticket.ts
5617
6089
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5618
6090
  var MAX_TRANSFERS_PER_DAY = 5;
6091
+ var transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY);
5619
6092
  function createTransferTicketEndpoint(slugs) {
5620
6093
  return {
5621
6094
  path: "/support/tickets/:id/transfer",
@@ -5640,8 +6113,7 @@ function createTransferTicketEndpoint(slugs) {
5640
6113
  const includeAttachments = body.includeAttachments !== false;
5641
6114
  const customMessage = typeof body.message === "string" ? body.message.slice(0, 1e3) : "";
5642
6115
  const payload = req.payload;
5643
- const ticket = await payload.findByID({
5644
- collection: slugs.tickets,
6116
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5645
6117
  id: ticketId,
5646
6118
  depth: 1,
5647
6119
  overrideAccess: true
@@ -5652,10 +6124,15 @@ function createTransferTicketEndpoint(slugs) {
5652
6124
  if (!isAdmin && !isOwner) {
5653
6125
  return Response.json({ error: "Forbidden" }, { status: 403 });
5654
6126
  }
6127
+ if (transferLimiter.check(`${req.user.id}:${ticketId}`)) {
6128
+ return Response.json(
6129
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6130
+ { status: 429 }
6131
+ );
6132
+ }
5655
6133
  try {
5656
6134
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
5657
- const existing = await payload.count({
5658
- collection: slugs.emailLogs,
6135
+ const existing = await dbCount(payload, slugs.emailLogs, {
5659
6136
  where: {
5660
6137
  and: [
5661
6138
  { action: { equals: "transfer" } },
@@ -5673,8 +6150,7 @@ function createTransferTicketEndpoint(slugs) {
5673
6150
  }
5674
6151
  } catch {
5675
6152
  }
5676
- const messages = await payload.find({
5677
- collection: slugs.ticketMessages,
6153
+ const messages = await dbFind(payload, slugs.ticketMessages, {
5678
6154
  where: {
5679
6155
  and: [
5680
6156
  { ticket: { equals: ticketId } },
@@ -5754,8 +6230,7 @@ function createTransferTicketEndpoint(slugs) {
5754
6230
  html
5755
6231
  });
5756
6232
  try {
5757
- await payload.create({
5758
- collection: slugs.emailLogs,
6233
+ await dbCreate(payload, slugs.emailLogs, {
5759
6234
  data: {
5760
6235
  status: "success",
5761
6236
  action: "transfer",
@@ -5795,6 +6270,8 @@ function statusToLabel(status) {
5795
6270
  }
5796
6271
  }
5797
6272
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6273
+ var MAX_COLLABORATORS_PER_TICKET = 20;
6274
+ var inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10);
5798
6275
  function createInviteCollaboratorEndpoint(slugs) {
5799
6276
  return {
5800
6277
  path: "/support/tickets/:id/invite",
@@ -5818,8 +6295,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5818
6295
  }
5819
6296
  const role = body.role === "collaborator" ? "collaborator" : "viewer";
5820
6297
  const payload = req.payload;
5821
- const ticket = await payload.findByID({
5822
- collection: slugs.tickets,
6298
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5823
6299
  id: ticketId,
5824
6300
  depth: 1,
5825
6301
  overrideAccess: true
@@ -5831,13 +6307,22 @@ function createInviteCollaboratorEndpoint(slugs) {
5831
6307
  if (!isAdmin && !isOwner) {
5832
6308
  return Response.json({ error: "Forbidden" }, { status: 403 });
5833
6309
  }
6310
+ if (inviteLimiter.check(String(req.user.id))) {
6311
+ return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6312
+ }
6313
+ const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
6314
+ if (collabCount.totalDocs >= MAX_COLLABORATORS_PER_TICKET) {
6315
+ return Response.json(
6316
+ { error: `Limite de ${MAX_COLLABORATORS_PER_TICKET} collaborateurs atteinte sur ce ticket` },
6317
+ { status: 429 }
6318
+ );
6319
+ }
5834
6320
  const ownerEmail = typeof ticket.client === "object" ? ticket.client?.email : void 0;
5835
6321
  if (ownerEmail && ownerEmail.toLowerCase() === email) {
5836
6322
  return Response.json({ error: "Ce client est d\xE9j\xE0 propri\xE9taire du ticket" }, { status: 400 });
5837
6323
  }
5838
6324
  let inviteeId = null;
5839
- const existing = await payload.find({
5840
- collection: slugs.supportClients,
6325
+ const existing = await dbFind(payload, slugs.supportClients, {
5841
6326
  where: { email: { equals: email } },
5842
6327
  limit: 1,
5843
6328
  depth: 0,
@@ -5847,8 +6332,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5847
6332
  inviteeId = existing.docs[0].id;
5848
6333
  } else {
5849
6334
  const tempPassword = crypto3.randomBytes(16).toString("hex");
5850
- const created = await payload.create({
5851
- collection: slugs.supportClients,
6335
+ const created = await dbCreate(payload, slugs.supportClients, {
5852
6336
  data: {
5853
6337
  email,
5854
6338
  firstName: "Invit\xE9",
@@ -5865,8 +6349,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5865
6349
  if (!inviteeId) {
5866
6350
  return Response.json({ error: "Impossible de cr\xE9er le client invit\xE9" }, { status: 500 });
5867
6351
  }
5868
- const dupe = await payload.find({
5869
- collection: "ticket-collaborators",
6352
+ const dupe = await dbFind(payload, "ticket-collaborators", {
5870
6353
  where: {
5871
6354
  and: [
5872
6355
  { ticket: { equals: ticketId } },
@@ -5879,15 +6362,13 @@ function createInviteCollaboratorEndpoint(slugs) {
5879
6362
  });
5880
6363
  const invitationToken = crypto3.randomBytes(24).toString("hex");
5881
6364
  if (dupe.docs.length > 0) {
5882
- await payload.update({
5883
- collection: "ticket-collaborators",
6365
+ await dbUpdate(payload, "ticket-collaborators", {
5884
6366
  id: dupe.docs[0].id,
5885
6367
  data: { role, invitationToken },
5886
6368
  overrideAccess: true
5887
6369
  });
5888
6370
  } else {
5889
- await payload.create({
5890
- collection: "ticket-collaborators",
6371
+ await dbCreate(payload, "ticket-collaborators", {
5891
6372
  data: {
5892
6373
  ticket: ticketId,
5893
6374
  client: inviteeId,
@@ -5930,8 +6411,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5930
6411
  )
5931
6412
  });
5932
6413
  try {
5933
- await payload.create({
5934
- collection: slugs.emailLogs,
6414
+ await dbCreate(payload, slugs.emailLogs, {
5935
6415
  data: {
5936
6416
  status: "success",
5937
6417
  action: "invite",
@@ -5984,6 +6464,7 @@ function createSupportEndpoints(slugs, options) {
5984
6464
  ];
5985
6465
  if (!f || f.ai !== false) {
5986
6466
  endpoints.push(createAiEndpoint(slugs));
6467
+ endpoints.push(createAiAgentEndpoint(slugs));
5987
6468
  endpoints.push(...createClientIntelligenceEndpoint(slugs));
5988
6469
  endpoints.push(createTicketSynthesisEndpoint(slugs));
5989
6470
  }
@@ -6014,21 +6495,26 @@ function createSupportEndpoints(slugs, options) {
6014
6495
  endpoints.push(createAdminChatGetEndpoint(slugs), createAdminChatPostEndpoint(slugs));
6015
6496
  endpoints.push(createAdminChatStreamEndpoint(slugs));
6016
6497
  }
6017
- if (!f || f.timeTracking !== false) endpoints.push(createBillingEndpoint(slugs));
6498
+ if (!f || f.timeTracking !== false) {
6499
+ endpoints.push(createBillingEndpoint(slugs));
6500
+ endpoints.push(createInvoiceEndpoint(slugs));
6501
+ }
6018
6502
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
6019
6503
  if (!f || f.emailTracking !== false) {
6020
6504
  endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
6021
6505
  }
6022
6506
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
6023
6507
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
6508
+ if (!f || f.snooze !== false) endpoints.push(createProcessSnoozeEndpoint(slugs));
6509
+ endpoints.push(createProcessDigestsEndpoint(slugs));
6510
+ endpoints.push(createChannelsWebhookEndpoint(slugs));
6024
6511
  return endpoints;
6025
6512
  }
6026
6513
 
6027
6514
  // src/utils/adminNotification.ts
6028
6515
  async function createAdminNotification(payload, data, collectionSlug = "admin-notifications") {
6029
6516
  try {
6030
- await payload.create({
6031
- collection: collectionSlug,
6517
+ await dbCreate(payload, collectionSlug, {
6032
6518
  data: {
6033
6519
  title: data.title,
6034
6520
  message: data.message,
@@ -6048,8 +6534,7 @@ function dispatchWebhook(data, event, payload, slugs) {
6048
6534
  }
6049
6535
  async function _dispatch(data, event, payload, slugs) {
6050
6536
  try {
6051
- const { docs: endpoints } = await payload.find({
6052
- collection: slugs.webhookEndpoints,
6537
+ const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
6053
6538
  where: {
6054
6539
  and: [
6055
6540
  { active: { equals: true } },
@@ -6086,8 +6571,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
6086
6571
  signal: AbortSignal.timeout(1e4)
6087
6572
  // 10s timeout
6088
6573
  });
6089
- await payload.update({
6090
- collection: slugs.webhookEndpoints,
6574
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6091
6575
  id: endpoint.id,
6092
6576
  data: {
6093
6577
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6101,8 +6585,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
6101
6585
  } catch (err) {
6102
6586
  console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
6103
6587
  try {
6104
- await payload.update({
6105
- collection: slugs.webhookEndpoints,
6588
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6106
6589
  id: endpoint.id,
6107
6590
  data: {
6108
6591
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6336,8 +6819,7 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6336
6819
  if (typeof policyRef === "object" && policyRef.firstResponseTime) {
6337
6820
  return policyRef;
6338
6821
  }
6339
- return await payload.findByID({
6340
- collection: slugs.slaPolicies,
6822
+ return await dbFindByID(payload, slugs.slaPolicies, {
6341
6823
  id: policyId,
6342
6824
  depth: 0,
6343
6825
  overrideAccess: true
@@ -6348,8 +6830,7 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6348
6830
  }
6349
6831
  const priority = field(ticket, "priority") || "normal";
6350
6832
  try {
6351
- const defaults = await payload.find({
6352
- collection: slugs.slaPolicies,
6833
+ const defaults = await dbFind(payload, slugs.slaPolicies, {
6353
6834
  where: {
6354
6835
  and: [
6355
6836
  { isDefault: { equals: true } },
@@ -6386,8 +6867,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6386
6867
  if (typeof policyRef === "object" && policyRef.escalateOnBreach !== void 0) {
6387
6868
  policy = policyRef;
6388
6869
  } else {
6389
- policy = await payload.findByID({
6390
- collection: slugs.slaPolicies,
6870
+ policy = await dbFindByID(payload, slugs.slaPolicies, {
6391
6871
  id: policyId,
6392
6872
  depth: 0,
6393
6873
  overrideAccess: true
@@ -6400,8 +6880,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6400
6880
  try {
6401
6881
  const escalateToId = typeof policy.escalateTo === "object" ? policy.escalateTo.id : policy.escalateTo;
6402
6882
  if (!escalateToId) return;
6403
- const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await payload.findByID({
6404
- collection: slugs.users,
6883
+ const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await dbFindByID(payload, slugs.users, {
6405
6884
  id: escalateToId,
6406
6885
  depth: 0,
6407
6886
  overrideAccess: true
@@ -6449,8 +6928,7 @@ function createAssignSlaDeadlines(slugs, notificationSlug = "admin-notifications
6449
6928
  if (!field(doc, "slaPolicy") && policy.id) {
6450
6929
  updateData.slaPolicy = policy.id;
6451
6930
  }
6452
- await payload.update({
6453
- collection: slugs.tickets,
6931
+ await dbUpdate(payload, slugs.tickets, {
6454
6932
  id: doc.id,
6455
6933
  data: updateData,
6456
6934
  overrideAccess: true
@@ -6473,8 +6951,7 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6473
6951
  const now = /* @__PURE__ */ new Date();
6474
6952
  const deadline = new Date(doc.slaResolutionDue);
6475
6953
  const breached = now > deadline;
6476
- await payload.update({
6477
- collection: slugs.tickets,
6954
+ await dbUpdate(payload, slugs.tickets, {
6478
6955
  id: doc.id,
6479
6956
  data: { slaResolutionBreached: breached },
6480
6957
  overrideAccess: true
@@ -6488,6 +6965,35 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6488
6965
  return doc;
6489
6966
  };
6490
6967
  }
6968
+ function createPauseSlaOnHold(slugs) {
6969
+ return async ({ doc, previousDoc, operation, req }) => {
6970
+ if (operation !== "update" || !previousDoc) return doc;
6971
+ if (previousDoc.status === doc.status) return doc;
6972
+ if (!doc.slaResolutionDue) return doc;
6973
+ const wasWaiting = previousDoc.status === "waiting_client";
6974
+ const isWaiting = doc.status === "waiting_client";
6975
+ try {
6976
+ if (!wasWaiting && isWaiting && !doc.slaPausedAt && doc.status !== "resolved") {
6977
+ await req.payload.update({
6978
+ collection: slugs.tickets,
6979
+ id: doc.id,
6980
+ data: { slaPausedAt: (/* @__PURE__ */ new Date()).toISOString() },
6981
+ overrideAccess: true
6982
+ });
6983
+ } else if (wasWaiting && !isWaiting && doc.slaPausedAt) {
6984
+ const pausedMs = Date.now() - new Date(doc.slaPausedAt).getTime();
6985
+ const data = { slaPausedAt: null };
6986
+ if (pausedMs > 0) {
6987
+ data.slaResolutionDue = new Date(new Date(doc.slaResolutionDue).getTime() + pausedMs).toISOString();
6988
+ }
6989
+ await req.payload.update({ collection: slugs.tickets, id: doc.id, data, overrideAccess: true });
6990
+ }
6991
+ } catch (err) {
6992
+ console.error("[sla] Failed to pause/resume SLA on hold:", err);
6993
+ }
6994
+ return doc;
6995
+ };
6996
+ }
6491
6997
  function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications") {
6492
6998
  return async ({ doc, operation, req }) => {
6493
6999
  if (operation !== "create") return doc;
@@ -6495,8 +7001,7 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6495
7001
  try {
6496
7002
  const { payload } = req;
6497
7003
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
6498
- const ticket = await payload.findByID({
6499
- collection: slugs.tickets,
7004
+ const ticket = await dbFindByID(payload, slugs.tickets, {
6500
7005
  id: ticketId,
6501
7006
  depth: 1,
6502
7007
  overrideAccess: true
@@ -6507,8 +7012,7 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6507
7012
  const now = /* @__PURE__ */ new Date();
6508
7013
  const deadline = new Date(ticket.slaFirstResponseDue);
6509
7014
  const breached = now > deadline;
6510
- await payload.update({
6511
- collection: slugs.tickets,
7015
+ await dbUpdate(payload, slugs.tickets, {
6512
7016
  id: ticketId,
6513
7017
  data: { slaFirstResponseBreached: breached },
6514
7018
  overrideAccess: true
@@ -6523,6 +7027,108 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6523
7027
  };
6524
7028
  }
6525
7029
 
7030
+ // src/hooks/automationRules.ts
7031
+ function evaluateConditions(doc, rule) {
7032
+ const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
7033
+ if (conds.length === 0) return true;
7034
+ const results = conds.map((c) => {
7035
+ const fieldVal = String(doc[c.field] ?? "");
7036
+ const v = String(c.value ?? "");
7037
+ switch (c.operator) {
7038
+ case "equals":
7039
+ return fieldVal === v;
7040
+ case "not_equals":
7041
+ return fieldVal !== v;
7042
+ case "contains":
7043
+ return fieldVal.toLowerCase().includes(v.toLowerCase());
7044
+ default:
7045
+ return false;
7046
+ }
7047
+ });
7048
+ return rule.matchType === "any" ? results.some(Boolean) : results.every(Boolean);
7049
+ }
7050
+ function createApplyAutomationRules(slugs) {
7051
+ return async ({ doc, previousDoc, operation, req }) => {
7052
+ if (req.context?.skipAutomation) return doc;
7053
+ const events = [];
7054
+ if (operation === "create") events.push("ticket_created");
7055
+ if (operation === "update") {
7056
+ events.push("ticket_updated");
7057
+ if (previousDoc && previousDoc.status !== doc.status) events.push("ticket_status_changed");
7058
+ }
7059
+ if (events.length === 0) return doc;
7060
+ try {
7061
+ const rules = await dbFind(req.payload, slugs.automationRules, {
7062
+ where: { and: [{ enabled: { equals: true } }, { event: { in: events } }] },
7063
+ sort: "order",
7064
+ limit: 200,
7065
+ depth: 0,
7066
+ overrideAccess: true
7067
+ });
7068
+ const changes = {};
7069
+ const tags = Array.isArray(doc.tags) ? [...doc.tags] : [];
7070
+ let tagsTouched = false;
7071
+ for (const rule of rules.docs) {
7072
+ if (!evaluateConditions(doc, rule)) continue;
7073
+ for (const a of Array.isArray(rule.actions) ? rule.actions : []) {
7074
+ if (!a.value) continue;
7075
+ switch (a.type) {
7076
+ case "set_status":
7077
+ changes.status = a.value;
7078
+ break;
7079
+ case "set_priority":
7080
+ changes.priority = a.value;
7081
+ break;
7082
+ case "set_category":
7083
+ changes.category = a.value;
7084
+ break;
7085
+ case "assign":
7086
+ changes.assignedTo = a.value;
7087
+ break;
7088
+ case "add_tag":
7089
+ if (!tags.includes(a.value)) {
7090
+ tags.push(a.value);
7091
+ tagsTouched = true;
7092
+ }
7093
+ break;
7094
+ }
7095
+ }
7096
+ }
7097
+ if (tagsTouched) changes.tags = tags;
7098
+ if (Object.keys(changes).length > 0) {
7099
+ await req.payload.update({
7100
+ collection: slugs.tickets,
7101
+ id: doc.id,
7102
+ data: changes,
7103
+ overrideAccess: true,
7104
+ context: { skipAutomation: true }
7105
+ });
7106
+ }
7107
+ } catch (err) {
7108
+ console.error("[support] Failed to apply automation rules:", err);
7109
+ }
7110
+ return doc;
7111
+ };
7112
+ }
7113
+
7114
+ // src/utils/teamAccess.ts
7115
+ function isTeamScopingEnabled() {
7116
+ return process.env.SUPPORT_TEAM_SCOPING === "1" || process.env.SUPPORT_TEAM_SCOPING === "true";
7117
+ }
7118
+ async function resolveAgentTeamIds(payload, slugs, userId) {
7119
+ try {
7120
+ const teams = await dbFind(payload, slugs.supportTeams, {
7121
+ where: { members: { in: [userId] } },
7122
+ limit: 200,
7123
+ depth: 0,
7124
+ overrideAccess: true
7125
+ });
7126
+ return teams.docs.map((t) => t.id);
7127
+ } catch {
7128
+ return [];
7129
+ }
7130
+ }
7131
+
6526
7132
  // src/collections/Tickets.ts
6527
7133
  function createAssignTicketNumber(slugs) {
6528
7134
  return async ({ data, operation, req }) => {
@@ -6785,56 +7391,6 @@ function createNotifyClientOnResolve(slugs) {
6785
7391
  return doc;
6786
7392
  };
6787
7393
  }
6788
- function createAutoCalculateSLA(slugs) {
6789
- return async ({ doc, operation, req }) => {
6790
- if (operation !== "create") return doc;
6791
- try {
6792
- const { payload } = req;
6793
- const ticketPriority = doc.priority || "normal";
6794
- let policy = null;
6795
- const byPriority = await payload.find({
6796
- collection: slugs.slaPolicies,
6797
- where: { priority: { equals: ticketPriority } },
6798
- limit: 1,
6799
- depth: 0,
6800
- overrideAccess: true
6801
- });
6802
- if (byPriority.docs.length > 0) {
6803
- policy = byPriority.docs[0];
6804
- } else {
6805
- const defaults = await payload.find({
6806
- collection: slugs.slaPolicies,
6807
- where: { isDefault: { equals: true } },
6808
- limit: 1,
6809
- depth: 0,
6810
- overrideAccess: true
6811
- });
6812
- if (defaults.docs.length > 0) {
6813
- policy = defaults.docs[0];
6814
- }
6815
- }
6816
- if (!policy) return doc;
6817
- const now = /* @__PURE__ */ new Date();
6818
- const firstResponseMinutes = policy.firstResponseTime || 240;
6819
- const resolutionMinutes = policy.resolutionTime || 1440;
6820
- const firstResponseDue = new Date(now.getTime() + firstResponseMinutes * 6e4);
6821
- const resolutionDue = new Date(now.getTime() + resolutionMinutes * 6e4);
6822
- await payload.update({
6823
- collection: slugs.tickets,
6824
- id: doc.id,
6825
- data: {
6826
- slaPolicy: policy.id,
6827
- slaFirstResponseDue: firstResponseDue.toISOString(),
6828
- slaResolutionDue: resolutionDue.toISOString()
6829
- },
6830
- overrideAccess: true
6831
- });
6832
- } catch (err) {
6833
- console.error("[support] Failed to auto-calculate SLA:", err);
6834
- }
6835
- return doc;
6836
- };
6837
- }
6838
7394
  function createFireTicketWebhooks(slugs) {
6839
7395
  return async ({ doc, previousDoc, operation, req }) => {
6840
7396
  const { payload } = req;
@@ -6926,8 +7482,7 @@ function createCascadeDelete(slugs) {
6926
7482
  return async ({ id, req }) => {
6927
7483
  const collections = [slugs.ticketMessages, slugs.ticketActivityLog, slugs.timeEntries, slugs.satisfactionSurveys];
6928
7484
  for (const slug of collections) {
6929
- await req.payload.delete({
6930
- collection: slug,
7485
+ await dbDelete(req.payload, slug, {
6931
7486
  where: { ticket: { equals: id } },
6932
7487
  overrideAccess: true
6933
7488
  });
@@ -7052,6 +7607,7 @@ function createTicketsCollection(slugs, options) {
7052
7607
  {
7053
7608
  name: "status",
7054
7609
  type: "select",
7610
+ index: true,
7055
7611
  defaultValue: "open",
7056
7612
  label: "Statut",
7057
7613
  options: [
@@ -7066,6 +7622,7 @@ function createTicketsCollection(slugs, options) {
7066
7622
  {
7067
7623
  name: "priority",
7068
7624
  type: "select",
7625
+ index: true,
7069
7626
  defaultValue: "normal",
7070
7627
  label: "Priorite",
7071
7628
  options: [
@@ -7081,7 +7638,7 @@ function createTicketsCollection(slugs, options) {
7081
7638
  {
7082
7639
  type: "row",
7083
7640
  fields: [
7084
- { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, label: "Client", admin: { width: "50%" } }
7641
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client", admin: { width: "50%" } }
7085
7642
  ]
7086
7643
  },
7087
7644
  // Billing collapsible
@@ -7150,15 +7707,16 @@ function createTicketsCollection(slugs, options) {
7150
7707
  {
7151
7708
  type: "row",
7152
7709
  fields: [
7153
- { name: "slaFirstResponseDue", type: "date", label: "Echeance 1ere reponse (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7154
- { name: "slaResolutionDue", type: "date", label: "Echeance resolution (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
7710
+ { name: "slaFirstResponseDue", type: "date", index: true, label: "Echeance 1ere reponse (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7711
+ { name: "slaResolutionDue", type: "date", index: true, label: "Echeance resolution (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
7155
7712
  ]
7156
7713
  },
7157
7714
  {
7158
7715
  type: "row",
7159
7716
  fields: [
7160
7717
  { name: "slaFirstResponseBreached", type: "checkbox", label: "1ere reponse depassee", admin: { readOnly: true, width: "50%" } },
7161
- { name: "slaResolutionBreached", type: "checkbox", label: "Resolution depassee", admin: { readOnly: true, width: "50%" } }
7718
+ { name: "slaResolutionBreached", type: "checkbox", label: "Resolution depassee", admin: { readOnly: true, width: "50%" } },
7719
+ { name: "slaPausedAt", type: "date", label: "SLA en pause depuis", admin: { hidden: true } }
7162
7720
  ]
7163
7721
  }
7164
7722
  ]
@@ -7180,7 +7738,7 @@ function createTicketsCollection(slugs, options) {
7180
7738
  },
7181
7739
  { name: "mergedInto", type: "relationship", relationTo: slugs.tickets, label: "Fusionne dans", admin: { readOnly: true } },
7182
7740
  { name: "autoCloseRemindedAt", type: "date", label: "Rappel auto-close envoye", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7183
- { 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." } }
7741
+ { 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." } }
7184
7742
  ]
7185
7743
  },
7186
7744
  // Sidebar
@@ -7189,6 +7747,7 @@ function createTicketsCollection(slugs, options) {
7189
7747
  {
7190
7748
  name: "category",
7191
7749
  type: "select",
7750
+ index: true,
7192
7751
  label: "Categorie",
7193
7752
  options: [
7194
7753
  { label: "Bug / Dysfonctionnement", value: "bug" },
@@ -7199,7 +7758,8 @@ function createTicketsCollection(slugs, options) {
7199
7758
  ],
7200
7759
  admin: { position: "sidebar" }
7201
7760
  },
7202
- { name: "assignedTo", type: "relationship", relationTo: slugs.users, label: "Assigne a", admin: { position: "sidebar" } },
7761
+ { name: "assignedTo", type: "relationship", relationTo: slugs.users, index: true, label: "Assigne a", admin: { position: "sidebar" } },
7762
+ { name: "team", type: "relationship", relationTo: slugs.supportTeams, index: true, label: "Equipe", admin: { position: "sidebar" } },
7203
7763
  {
7204
7764
  name: "source",
7205
7765
  type: "select",
@@ -7209,7 +7769,9 @@ function createTicketsCollection(slugs, options) {
7209
7769
  { label: "Portail client", value: "portal" },
7210
7770
  { label: "Chat en direct", value: "live-chat" },
7211
7771
  { label: "Email", value: "email" },
7212
- { label: "Admin", value: "admin" }
7772
+ { label: "Admin", value: "admin" },
7773
+ { label: "WhatsApp", value: "whatsapp" },
7774
+ { label: "Messenger", value: "messenger" }
7213
7775
  ],
7214
7776
  admin: { position: "sidebar" }
7215
7777
  },
@@ -7260,8 +7822,8 @@ function createTicketsCollection(slugs, options) {
7260
7822
  afterChange: [
7261
7823
  createTrackSLA(slugs),
7262
7824
  createTrackAiSummaryOnResolve(slugs),
7263
- createAutoCalculateSLA(slugs),
7264
7825
  createAssignSlaDeadlines(slugs, notificationSlug),
7826
+ createPauseSlaOnHold(slugs),
7265
7827
  createCheckSlaOnResolve(slugs, notificationSlug),
7266
7828
  createLogTicketActivity(slugs),
7267
7829
  createNotifyOnAssignment(slugs),
@@ -7269,7 +7831,8 @@ function createTicketsCollection(slugs, options) {
7269
7831
  createTicketStatusEmail(slugs),
7270
7832
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
7271
7833
  createFireTicketWebhooks(slugs),
7272
- createDispatchWebhookOnTicket(slugs)
7834
+ createDispatchWebhookOnTicket(slugs),
7835
+ createApplyAutomationRules(slugs)
7273
7836
  ],
7274
7837
  beforeDelete: [createCascadeDelete(slugs)]
7275
7838
  },
@@ -7280,12 +7843,20 @@ function createTicketsCollection(slugs, options) {
7280
7843
  return false;
7281
7844
  },
7282
7845
  read: async ({ req }) => {
7283
- if (req.user?.collection === slugs.users) return true;
7846
+ if (req.user?.collection === slugs.users) {
7847
+ if (!isTeamScopingEnabled()) return true;
7848
+ const teamIds = await resolveAgentTeamIds(req.payload, slugs, req.user.id);
7849
+ return {
7850
+ or: [
7851
+ { team: { in: teamIds.length > 0 ? teamIds : [-1] } },
7852
+ { team: { exists: false } }
7853
+ ]
7854
+ };
7855
+ }
7284
7856
  if (req.user?.collection === slugs.supportClients) {
7285
7857
  let collabTicketIds = [];
7286
7858
  try {
7287
- const rows = await req.payload.find({
7288
- collection: "ticket-collaborators",
7859
+ const rows = await dbFind(req.payload, "ticket-collaborators", {
7289
7860
  where: { client: { equals: req.user.id } },
7290
7861
  limit: 1e3,
7291
7862
  depth: 0,
@@ -7320,6 +7891,125 @@ function createTicketsCollection(slugs, options) {
7320
7891
  };
7321
7892
  }
7322
7893
 
7894
+ // src/utils/ticketAccess.ts
7895
+ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
7896
+ const ids = /* @__PURE__ */ new Set();
7897
+ try {
7898
+ const owned = await dbFind(payload, slugs.tickets, {
7899
+ where: { client: { equals: clientId } },
7900
+ limit: 5e3,
7901
+ depth: 0,
7902
+ overrideAccess: true
7903
+ });
7904
+ for (const t of owned.docs) ids.add(t.id);
7905
+ } catch {
7906
+ }
7907
+ try {
7908
+ const collab = await dbFind(payload, "ticket-collaborators", {
7909
+ where: { client: { equals: clientId } },
7910
+ limit: 1e3,
7911
+ depth: 0,
7912
+ overrideAccess: true
7913
+ });
7914
+ for (const r of collab.docs) {
7915
+ const row = r;
7916
+ const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
7917
+ if (tid !== void 0 && tid !== null) ids.add(tid);
7918
+ }
7919
+ } catch {
7920
+ }
7921
+ return Array.from(ids);
7922
+ }
7923
+ var OPTIONS = {
7924
+ allowedTags: [
7925
+ "p",
7926
+ "br",
7927
+ "b",
7928
+ "strong",
7929
+ "i",
7930
+ "em",
7931
+ "u",
7932
+ "s",
7933
+ "strike",
7934
+ "del",
7935
+ "ins",
7936
+ "mark",
7937
+ "a",
7938
+ "ul",
7939
+ "ol",
7940
+ "li",
7941
+ "blockquote",
7942
+ "code",
7943
+ "pre",
7944
+ "kbd",
7945
+ "span",
7946
+ "div",
7947
+ "h1",
7948
+ "h2",
7949
+ "h3",
7950
+ "h4",
7951
+ "h5",
7952
+ "h6",
7953
+ "hr",
7954
+ "img",
7955
+ "table",
7956
+ "thead",
7957
+ "tbody",
7958
+ "tr",
7959
+ "th",
7960
+ "td"
7961
+ ],
7962
+ allowedAttributes: {
7963
+ a: ["href", "title", "target", "rel"],
7964
+ img: ["src", "alt", "title", "width", "height"],
7965
+ code: ["class"],
7966
+ pre: ["class"],
7967
+ span: ["class"],
7968
+ "*": ["style"]
7969
+ },
7970
+ allowedSchemes: ["http", "https", "mailto", "tel"],
7971
+ allowedSchemesByTag: { img: ["http", "https", "data"] },
7972
+ allowProtocolRelative: false,
7973
+ disallowedTagsMode: "discard",
7974
+ // Force safe rel on links opening a new tab (defense against tabnabbing).
7975
+ transformTags: {
7976
+ a: sanitizeHtml__default.default.simpleTransform("a", { rel: "noopener noreferrer nofollow" }, true)
7977
+ },
7978
+ // Only allow CSS that cannot carry a script payload.
7979
+ allowedStyles: {
7980
+ "*": {
7981
+ color: [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
7982
+ "background-color": [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
7983
+ "text-align": [/^(left|right|center|justify)$/],
7984
+ "font-weight": [/^(normal|bold|[1-9]00)$/],
7985
+ "font-style": [/^(normal|italic)$/],
7986
+ "text-decoration": [/^(underline|line-through|none)$/]
7987
+ }
7988
+ }
7989
+ };
7990
+ function sanitizeMessageHtml(html) {
7991
+ if (!html) return html;
7992
+ return sanitizeHtml__default.default(html, OPTIONS);
7993
+ }
7994
+
7995
+ // src/utils/notificationQueue.ts
7996
+ async function queueClientNotification(payload, slugs, item) {
7997
+ try {
7998
+ await dbCreate(payload, slugs.notificationQueue, {
7999
+ data: {
8000
+ client: item.client,
8001
+ type: item.type,
8002
+ title: item.title,
8003
+ message: item.message,
8004
+ ...item.ticket ? { ticket: item.ticket } : {}
8005
+ },
8006
+ overrideAccess: true
8007
+ });
8008
+ } catch (err) {
8009
+ console.error("[support] Failed to queue client notification:", err);
8010
+ }
8011
+ }
8012
+
7323
8013
  // src/collections/TicketMessages.ts
7324
8014
  function createAssignAuthor(slugs) {
7325
8015
  return async ({ data, operation, req }) => {
@@ -7382,29 +8072,46 @@ function createNotifyClient(slugs) {
7382
8072
  const internalBcc = process.env.SUPPORT_CLIENT_BCC || void 0;
7383
8073
  const rawContent = doc.bodyHtml ? emailRichContent(doc.bodyHtml) : emailQuote(doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body);
7384
8074
  const replyPreview = (doc.body || "").replace(/\s+/g, " ").trim().slice(0, 90);
7385
- await req.payload.sendEmail({
7386
- to: client.email,
7387
- ...supportEmail ? { replyTo: supportEmail } : {},
7388
- ...internalBcc ? { bcc: internalBcc } : {},
7389
- subject: `Re: [${ticketNumber}] ${subject}`,
7390
- html: emailWrapper(`Nouvelle reponse \u2014 ${ticketNumber}`, [
7391
- emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
7392
- emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`),
7393
- rawContent,
7394
- emailButton("Consulter le ticket", portalUrl),
7395
- emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
7396
- emailTrackingPixel(ticketId, doc.id)
7397
- ].join(""), {
7398
- kind: "reply",
7399
- preheader: replyPreview ? `${replyPreview}\u2026` : `Nouvelle reponse a votre ticket ${ticketNumber}`
7400
- })
7401
- });
7402
- await req.payload.update({
7403
- collection: slugs.ticketMessages,
7404
- id: doc.id,
7405
- data: { emailSentAt: (/* @__PURE__ */ new Date()).toISOString(), emailSentTo: client.email },
7406
- overrideAccess: true
7407
- });
8075
+ const freq = client.notificationFrequency;
8076
+ if (freq === "daily" || freq === "weekly") {
8077
+ await queueClientNotification(req.payload, slugs, {
8078
+ client: client.id,
8079
+ type: "reply",
8080
+ title: `Nouvelle reponse \u2014 ${ticketNumber}`,
8081
+ message: replyPreview || `Reponse a votre ticket ${ticketNumber}`,
8082
+ ticket: ticketId
8083
+ });
8084
+ return doc;
8085
+ }
8086
+ void (async () => {
8087
+ try {
8088
+ await req.payload.sendEmail({
8089
+ to: client.email,
8090
+ ...supportEmail ? { replyTo: supportEmail } : {},
8091
+ ...internalBcc ? { bcc: internalBcc } : {},
8092
+ subject: `Re: [${ticketNumber}] ${subject}`,
8093
+ html: emailWrapper(`Nouvelle reponse \u2014 ${ticketNumber}`, [
8094
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
8095
+ emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`),
8096
+ rawContent,
8097
+ emailButton("Consulter le ticket", portalUrl),
8098
+ emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
8099
+ emailTrackingPixel(ticketId, doc.id)
8100
+ ].join(""), {
8101
+ kind: "reply",
8102
+ preheader: replyPreview ? `${replyPreview}\u2026` : `Nouvelle reponse a votre ticket ${ticketNumber}`
8103
+ })
8104
+ });
8105
+ await req.payload.update({
8106
+ collection: slugs.ticketMessages,
8107
+ id: doc.id,
8108
+ data: { emailSentAt: (/* @__PURE__ */ new Date()).toISOString(), emailSentTo: client.email },
8109
+ overrideAccess: true
8110
+ });
8111
+ } catch (err) {
8112
+ console.error("[support] Failed to notify client (async):", err);
8113
+ }
8114
+ })();
7408
8115
  } catch (err) {
7409
8116
  console.error("[support] Failed to notify client:", err);
7410
8117
  }
@@ -7442,8 +8149,7 @@ function createSyncTicketReplyToChat(slugs) {
7442
8149
  if (!ticket?.chatSession) return doc;
7443
8150
  if (doc.skipNotification) return doc;
7444
8151
  const clientId = typeof ticket.client === "object" ? ticket.client.id : ticket.client;
7445
- await payload.create({
7446
- collection: slugs.chatMessages,
8152
+ await dbCreate(payload, slugs.chatMessages, {
7447
8153
  data: {
7448
8154
  session: ticket.chatSession,
7449
8155
  client: clientId,
@@ -7560,6 +8266,57 @@ function createDispatchWebhookOnReply(slugs) {
7560
8266
  return doc;
7561
8267
  };
7562
8268
  }
8269
+ function createSanitizeMessageHtml() {
8270
+ return ({ data }) => {
8271
+ if (data && typeof data.bodyHtml === "string" && data.bodyHtml) {
8272
+ data.bodyHtml = sanitizeMessageHtml(data.bodyHtml);
8273
+ }
8274
+ return data;
8275
+ };
8276
+ }
8277
+ var MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
8278
+ function createResolveMentions(slugs) {
8279
+ return async ({ data, operation, req }) => {
8280
+ if (operation !== "create") return data;
8281
+ const body = typeof data?.body === "string" ? data.body : "";
8282
+ const emails = Array.from(new Set([...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase())));
8283
+ if (emails.length === 0) return data;
8284
+ try {
8285
+ const users = await dbFind(req.payload, slugs.users, { where: { email: { in: emails } }, limit: 50, depth: 0, overrideAccess: true });
8286
+ const ids = users.docs.map((u) => u.id);
8287
+ if (ids.length > 0) data.mentions = ids;
8288
+ } catch {
8289
+ }
8290
+ return data;
8291
+ };
8292
+ }
8293
+ function createNotifyMentions(slugs) {
8294
+ return async ({ doc, operation, req }) => {
8295
+ if (operation !== "create") return doc;
8296
+ const mentions = Array.isArray(doc.mentions) ? doc.mentions : [];
8297
+ if (mentions.length === 0) return doc;
8298
+ const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
8299
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
8300
+ for (const m of mentions) {
8301
+ const userId = typeof m === "object" ? m.id : m;
8302
+ try {
8303
+ const user = await dbFindByID(req.payload, slugs.users, { id: userId, depth: 0, overrideAccess: true });
8304
+ if (!user?.email) continue;
8305
+ await req.payload.sendEmail({
8306
+ to: user.email,
8307
+ subject: `Vous avez ete mentionne \u2014 ticket #${ticketId}`,
8308
+ html: emailWrapper("Mention", [
8309
+ emailParagraph(`Vous avez ete mentionne dans un message du ticket <strong>#${escapeHtml(String(ticketId))}</strong>.`),
8310
+ ...baseUrl ? [emailButton("Ouvrir le ticket", `${baseUrl}/admin/collections/${slugs.tickets}/${ticketId}`, "dark")] : []
8311
+ ].join(""), { kind: "system" })
8312
+ });
8313
+ } catch (err) {
8314
+ console.error("[support] Failed to notify mention:", err);
8315
+ }
8316
+ }
8317
+ return doc;
8318
+ };
8319
+ }
7563
8320
  function createTicketMessagesCollection(slugs, options) {
7564
8321
  const notificationSlug = options?.notificationSlug || "admin-notifications";
7565
8322
  return {
@@ -7567,7 +8324,7 @@ function createTicketMessagesCollection(slugs, options) {
7567
8324
  labels: { singular: "Message", plural: "Messages" },
7568
8325
  admin: { hidden: true, group: "Support", defaultColumns: ["ticket", "authorType", "createdAt"] },
7569
8326
  fields: [
7570
- { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, label: "Ticket" },
8327
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, index: true, label: "Ticket" },
7571
8328
  { name: "body", type: "textarea", required: true, label: "Message" },
7572
8329
  { name: "bodyHtml", type: "textarea", label: "Message HTML", admin: { hidden: true } },
7573
8330
  {
@@ -7576,6 +8333,7 @@ function createTicketMessagesCollection(slugs, options) {
7576
8333
  {
7577
8334
  name: "authorType",
7578
8335
  type: "select",
8336
+ index: true,
7579
8337
  label: "Type d'auteur",
7580
8338
  defaultValue: "admin",
7581
8339
  options: [
@@ -7611,8 +8369,9 @@ function createTicketMessagesCollection(slugs, options) {
7611
8369
  },
7612
8370
  { name: "isInternal", type: "checkbox", defaultValue: false, label: "Note interne", admin: { position: "sidebar" } },
7613
8371
  { name: "isSolution", type: "checkbox", defaultValue: false, label: "Reponse solution", admin: { position: "sidebar" } },
8372
+ { name: "mentions", type: "relationship", relationTo: slugs.users, hasMany: true, label: "Agents mentionnes", admin: { hidden: true } },
7614
8373
  { name: "skipNotification", type: "checkbox", defaultValue: false, label: "Sans notification", admin: { position: "sidebar", condition: (data) => data?.skipNotification === true } },
7615
- { name: "scheduledAt", type: "date", label: "Programme pour", admin: { date: { pickerAppearance: "dayAndTime" }, position: "sidebar", condition: (data) => !!data?.scheduledAt } },
8374
+ { name: "scheduledAt", type: "date", index: true, label: "Programme pour", admin: { date: { pickerAppearance: "dayAndTime" }, position: "sidebar", condition: (data) => !!data?.scheduledAt } },
7616
8375
  { name: "scheduledSent", type: "checkbox", defaultValue: false, admin: { hidden: true } },
7617
8376
  { name: "editedAt", type: "date", label: "Modifie le", admin: { hidden: true } },
7618
8377
  { name: "deletedAt", type: "date", label: "Supprime le", admin: { hidden: true } },
@@ -7621,7 +8380,7 @@ function createTicketMessagesCollection(slugs, options) {
7621
8380
  { name: "emailOpenedAt", type: "date", label: "Email ouvert le", admin: { hidden: true } }
7622
8381
  ],
7623
8382
  hooks: {
7624
- beforeChange: [createAssignAuthor(slugs)],
8383
+ beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
7625
8384
  afterChange: [
7626
8385
  createAutoUpdateStatus(slugs),
7627
8386
  createNotifyClient(slugs),
@@ -7630,17 +8389,19 @@ function createTicketMessagesCollection(slugs, options) {
7630
8389
  createSyncTicketReplyToChat(slugs),
7631
8390
  createNotifyAdminOnClientMessage(slugs, notificationSlug),
7632
8391
  createFireMessageWebhooks(slugs),
7633
- createDispatchWebhookOnReply(slugs)
8392
+ createDispatchWebhookOnReply(slugs),
8393
+ createNotifyMentions(slugs)
7634
8394
  ]
7635
8395
  },
7636
8396
  access: {
7637
8397
  create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
7638
- read: ({ req }) => {
8398
+ read: async ({ req }) => {
7639
8399
  if (req.user?.collection === slugs.users) return true;
7640
8400
  if (req.user?.collection === slugs.supportClients) {
8401
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
7641
8402
  return {
7642
8403
  and: [
7643
- { "ticket.client": { equals: req.user.id } },
8404
+ { ticket: { in: ids.length > 0 ? ids : [-1] } },
7644
8405
  { isInternal: { equals: false } },
7645
8406
  { or: [{ scheduledAt: { exists: false } }, { scheduledSent: { equals: true } }] }
7646
8407
  ]
@@ -7660,8 +8421,6 @@ function createTicketMessagesCollection(slugs, options) {
7660
8421
  timestamps: true
7661
8422
  };
7662
8423
  }
7663
-
7664
- // src/collections/SupportClients.ts
7665
8424
  function createSendInvitationOnCreate(slugs) {
7666
8425
  return async ({ doc, operation, req }) => {
7667
8426
  if (operation !== "create") return doc;
@@ -7701,6 +8460,26 @@ function createSendInvitationOnCreate(slugs) {
7701
8460
  return doc;
7702
8461
  };
7703
8462
  }
8463
+ var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
8464
+ function createEnforce2FA(slugs) {
8465
+ return async ({ req, user }) => {
8466
+ if (!user?.twoFactorEnabled) return user;
8467
+ const raw = user.twoFactorVerifiedAt;
8468
+ const verifiedAt = raw ? new Date(raw).getTime() : 0;
8469
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
8470
+ throw new payload.APIError("2FA_REQUIRED", 401);
8471
+ }
8472
+ await req.payload.update({
8473
+ collection: slugs.supportClients,
8474
+ id: user.id,
8475
+ data: { twoFactorVerifiedAt: null },
8476
+ req,
8477
+ overrideAccess: true
8478
+ });
8479
+ user.twoFactorVerifiedAt = null;
8480
+ return user;
8481
+ };
8482
+ }
7704
8483
  function createSupportClientsCollection(slugs) {
7705
8484
  return {
7706
8485
  slug: slugs.supportClients,
@@ -7829,6 +8608,10 @@ function createSupportClientsCollection(slugs) {
7829
8608
  name: "opportunities",
7830
8609
  type: "textarea",
7831
8610
  label: "Opportunites commerciales",
8611
+ access: {
8612
+ // Admin-only: never serialize commercial notes onto the client's own doc.
8613
+ read: ({ req }) => req.user?.collection === slugs.users
8614
+ },
7832
8615
  admin: {
7833
8616
  description: "Notes commerciales (visible uniquement par les admins)"
7834
8617
  }
@@ -7872,6 +8655,19 @@ function createSupportClientsCollection(slugs) {
7872
8655
  type: "date",
7873
8656
  admin: { hidden: true }
7874
8657
  },
8658
+ {
8659
+ // Set by /support/2fa "verify"; gates login via the beforeLogin hook.
8660
+ name: "twoFactorVerifiedAt",
8661
+ type: "date",
8662
+ admin: { hidden: true }
8663
+ },
8664
+ {
8665
+ // Google OAuth subject id — accounts are linked by this, not email alone.
8666
+ name: "googleId",
8667
+ type: "text",
8668
+ index: true,
8669
+ admin: { hidden: true }
8670
+ },
7875
8671
  {
7876
8672
  name: "notifyOnReply",
7877
8673
  type: "checkbox",
@@ -7938,6 +8734,10 @@ function createSupportClientsCollection(slugs) {
7938
8734
  name: "notes",
7939
8735
  type: "textarea",
7940
8736
  label: "Notes internes",
8737
+ access: {
8738
+ // Admin-only: never serialize internal notes onto the client's own doc.
8739
+ read: ({ req }) => req.user?.collection === slugs.users
8740
+ },
7941
8741
  admin: {
7942
8742
  description: "Visible uniquement par les admins",
7943
8743
  position: "sidebar"
@@ -7945,6 +8745,7 @@ function createSupportClientsCollection(slugs) {
7945
8745
  }
7946
8746
  ],
7947
8747
  hooks: {
8748
+ beforeLogin: [createEnforce2FA(slugs)],
7948
8749
  afterChange: [createSendInvitationOnCreate(slugs)]
7949
8750
  },
7950
8751
  access: {
@@ -8158,6 +8959,7 @@ function createTicketActivityLogCollection(slugs) {
8158
8959
  type: "relationship",
8159
8960
  relationTo: slugs.tickets,
8160
8961
  required: true,
8962
+ index: true,
8161
8963
  label: "Ticket"
8162
8964
  },
8163
8965
  {
@@ -8203,10 +9005,11 @@ function createTicketActivityLogCollection(slugs) {
8203
9005
  access: {
8204
9006
  create: () => false,
8205
9007
  // Created only by hooks via overrideAccess
8206
- read: ({ req }) => {
9008
+ read: async ({ req }) => {
8207
9009
  if (req.user?.collection === slugs.users) return true;
8208
9010
  if (req.user?.collection === slugs.supportClients) {
8209
- return { "ticket.client": { equals: req.user.id } };
9011
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
9012
+ return { ticket: { in: ids.length > 0 ? ids : [-1] } };
8210
9013
  }
8211
9014
  return false;
8212
9015
  },
@@ -8275,12 +9078,21 @@ function createSatisfactionSurveysCollection(slugs) {
8275
9078
  {
8276
9079
  name: "rating",
8277
9080
  type: "number",
8278
- required: true,
8279
9081
  min: 1,
8280
9082
  max: 5,
8281
9083
  label: "Note (1-5)",
8282
9084
  admin: {
8283
- description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait"
9085
+ description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait (CSAT)"
9086
+ }
9087
+ },
9088
+ {
9089
+ name: "nps",
9090
+ type: "number",
9091
+ min: 0,
9092
+ max: 10,
9093
+ label: "NPS (0-10)",
9094
+ admin: {
9095
+ description: "\xAB Recommanderiez-vous ? \xBB \u2014 promoteur 9-10, passif 7-8, d\xE9tracteur 0-6"
8284
9096
  }
8285
9097
  },
8286
9098
  {
@@ -9526,6 +10338,124 @@ function createTicketCollaboratorsCollection(slugs) {
9526
10338
  };
9527
10339
  }
9528
10340
 
10341
+ // src/collections/NotificationQueue.ts
10342
+ function createNotificationQueueCollection(slugs) {
10343
+ return {
10344
+ slug: slugs.notificationQueue,
10345
+ labels: { singular: "Notification en file", plural: "Notifications en file" },
10346
+ admin: { hidden: true, group: "Support", defaultColumns: ["client", "type", "title", "createdAt"] },
10347
+ access: {
10348
+ // Internal: written/drained only via overrideAccess (hooks + cron).
10349
+ create: () => false,
10350
+ update: () => false,
10351
+ read: ({ req }) => req.user?.collection === slugs.users,
10352
+ delete: ({ req }) => req.user?.collection === slugs.users
10353
+ },
10354
+ fields: [
10355
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client" },
10356
+ { name: "type", type: "text", label: "Type" },
10357
+ { name: "title", type: "text", label: "Titre" },
10358
+ { name: "message", type: "textarea", label: "Message" },
10359
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, label: "Ticket" }
10360
+ ],
10361
+ timestamps: true
10362
+ };
10363
+ }
10364
+
10365
+ // src/collections/AutomationRules.ts
10366
+ var CONDITION_FIELDS = ["status", "priority", "category", "source", "subject"];
10367
+ var ACTION_TYPES = [
10368
+ { label: "D\xE9finir le statut", value: "set_status" },
10369
+ { label: "D\xE9finir la priorit\xE9", value: "set_priority" },
10370
+ { label: "D\xE9finir la cat\xE9gorie", value: "set_category" },
10371
+ { label: "Assigner \xE0 (id agent)", value: "assign" },
10372
+ { label: "Ajouter un tag", value: "add_tag" }
10373
+ ];
10374
+ function createAutomationRulesCollection(slugs) {
10375
+ return {
10376
+ slug: slugs.automationRules,
10377
+ labels: { singular: "R\xE8gle d'automatisation", plural: "R\xE8gles d'automatisation" },
10378
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "event", "enabled", "order"] },
10379
+ access: {
10380
+ read: ({ req }) => req.user?.collection === slugs.users,
10381
+ create: ({ req }) => req.user?.collection === slugs.users,
10382
+ update: ({ req }) => req.user?.collection === slugs.users,
10383
+ delete: ({ req }) => req.user?.collection === slugs.users
10384
+ },
10385
+ fields: [
10386
+ { name: "name", type: "text", required: true, label: "Nom" },
10387
+ { name: "enabled", type: "checkbox", defaultValue: true, label: "Active", index: true },
10388
+ {
10389
+ name: "event",
10390
+ type: "select",
10391
+ defaultValue: "ticket_created",
10392
+ index: true,
10393
+ label: "D\xE9clencheur",
10394
+ options: [
10395
+ { label: "Ticket cr\xE9\xE9", value: "ticket_created" },
10396
+ { label: "Ticket mis \xE0 jour", value: "ticket_updated" },
10397
+ { label: "Changement de statut", value: "ticket_status_changed" }
10398
+ ]
10399
+ },
10400
+ {
10401
+ name: "matchType",
10402
+ type: "select",
10403
+ defaultValue: "all",
10404
+ label: "Correspondance",
10405
+ options: [{ label: "Toutes les conditions (ET)", value: "all" }, { label: "Au moins une (OU)", value: "any" }]
10406
+ },
10407
+ { name: "order", type: "number", defaultValue: 0, label: "Ordre d'\xE9valuation" },
10408
+ {
10409
+ name: "conditions",
10410
+ type: "array",
10411
+ label: "Conditions",
10412
+ fields: [
10413
+ { name: "field", type: "select", required: true, options: CONDITION_FIELDS.map((v) => ({ label: v, value: v })) },
10414
+ {
10415
+ name: "operator",
10416
+ type: "select",
10417
+ defaultValue: "equals",
10418
+ required: true,
10419
+ options: [{ label: "= \xE9gal", value: "equals" }, { label: "\u2260 diff\xE9rent", value: "not_equals" }, { label: "contient", value: "contains" }]
10420
+ },
10421
+ { name: "value", type: "text", required: true }
10422
+ ]
10423
+ },
10424
+ {
10425
+ name: "actions",
10426
+ type: "array",
10427
+ label: "Actions",
10428
+ fields: [
10429
+ { name: "type", type: "select", required: true, options: ACTION_TYPES },
10430
+ { name: "value", type: "text", required: true }
10431
+ ]
10432
+ }
10433
+ ],
10434
+ timestamps: true
10435
+ };
10436
+ }
10437
+
10438
+ // src/collections/SupportTeam.ts
10439
+ function createSupportTeamCollection(slugs) {
10440
+ return {
10441
+ slug: slugs.supportTeams,
10442
+ labels: { singular: "\xC9quipe support", plural: "\xC9quipes support" },
10443
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "members"] },
10444
+ access: {
10445
+ read: ({ req }) => req.user?.collection === slugs.users,
10446
+ create: ({ req }) => req.user?.collection === slugs.users,
10447
+ update: ({ req }) => req.user?.collection === slugs.users,
10448
+ delete: ({ req }) => req.user?.collection === slugs.users
10449
+ },
10450
+ fields: [
10451
+ { name: "name", type: "text", required: true, label: "Nom" },
10452
+ { name: "description", type: "textarea", label: "Description" },
10453
+ { name: "members", type: "relationship", relationTo: slugs.users, hasMany: true, index: true, label: "Membres (agents)" }
10454
+ ],
10455
+ timestamps: true
10456
+ };
10457
+ }
10458
+
9529
10459
  // src/plugin.ts
9530
10460
  function viewConfig(component, path) {
9531
10461
  return { Component: component, path };
@@ -9561,7 +10491,10 @@ function supportPlugin(config) {
9561
10491
  createSatisfactionSurveysCollection(slugs),
9562
10492
  createKnowledgeBaseCollection(slugs),
9563
10493
  createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
9564
- createTicketCollaboratorsCollection(slugs)
10494
+ createTicketCollaboratorsCollection(slugs),
10495
+ createNotificationQueueCollection(slugs),
10496
+ createAutomationRulesCollection(slugs),
10497
+ createSupportTeamCollection(slugs)
9565
10498
  ];
9566
10499
  if (features.authLogs !== false) supportCollections.push(createAuthLogsCollection(slugs));
9567
10500
  if (features.timeTracking !== false) supportCollections.push(createTimeEntriesCollection(slugs));