@consilioweb/payload-support 0.16.0 → 1.1.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 (199) hide show
  1. package/README.md +357 -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 +1763 -677
  13. package/dist/index.d.cts +4 -0
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.js +1760 -677
  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 +28 -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/per-team-sla.test.ts +44 -0
  71. package/src/__tests__/integration/push.test.ts +75 -0
  72. package/src/__tests__/integration/sanitization.test.ts +35 -0
  73. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  74. package/src/__tests__/integration/smoke.test.ts +26 -0
  75. package/src/__tests__/integration/snooze.test.ts +68 -0
  76. package/src/__tests__/integration/teams.test.ts +38 -0
  77. package/src/__tests__/rateLimiter.test.ts +89 -0
  78. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  79. package/src/__tests__/sla.test.ts +258 -0
  80. package/src/collections/AutomationRules.ts +66 -0
  81. package/src/collections/NotificationQueue.ts +30 -0
  82. package/src/collections/PushSubscription.ts +28 -0
  83. package/src/collections/SatisfactionSurveys.ts +11 -2
  84. package/src/collections/SlaPolicies.ts +10 -0
  85. package/src/collections/SupportClients.ts +58 -1
  86. package/src/collections/SupportTeam.ts +27 -0
  87. package/src/collections/TicketActivityLog.ts +9 -4
  88. package/src/collections/TicketMessages.ts +140 -31
  89. package/src/collections/Tickets.ts +31 -73
  90. package/src/collections/index.ts +4 -0
  91. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  92. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  93. package/src/components/TicketConversation/constants.ts +29 -0
  94. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  95. package/src/components/TicketConversation/index.tsx +4 -152
  96. package/src/endpoints/admin-chat-stream.ts +3 -4
  97. package/src/endpoints/admin-chat.ts +11 -20
  98. package/src/endpoints/admin-stats.ts +77 -35
  99. package/src/endpoints/ai-agent.ts +36 -0
  100. package/src/endpoints/apply-macro.ts +8 -14
  101. package/src/endpoints/auth-2fa.ts +12 -12
  102. package/src/endpoints/auto-close.ts +6 -10
  103. package/src/endpoints/billing.ts +17 -4
  104. package/src/endpoints/bulk-action.ts +7 -12
  105. package/src/endpoints/channels.ts +89 -0
  106. package/src/endpoints/chat-stream.ts +3 -4
  107. package/src/endpoints/chat.ts +7 -12
  108. package/src/endpoints/chatbot.ts +2 -2
  109. package/src/endpoints/client-intelligence.ts +4 -4
  110. package/src/endpoints/delete-account.ts +9 -16
  111. package/src/endpoints/email-stats.ts +2 -2
  112. package/src/endpoints/escalate.ts +2 -2
  113. package/src/endpoints/export-csv.ts +2 -2
  114. package/src/endpoints/export-data.ts +5 -8
  115. package/src/endpoints/import-conversation.ts +6 -10
  116. package/src/endpoints/index.ts +22 -1
  117. package/src/endpoints/invite-collaborator.ts +30 -15
  118. package/src/endpoints/invoice.ts +127 -0
  119. package/src/endpoints/kb-search.ts +2 -2
  120. package/src/endpoints/login.ts +12 -4
  121. package/src/endpoints/merge-clients.ts +14 -19
  122. package/src/endpoints/merge-tickets.ts +11 -18
  123. package/src/endpoints/oauth-google.ts +86 -39
  124. package/src/endpoints/pending-emails-process.ts +15 -25
  125. package/src/endpoints/process-digests.ts +93 -0
  126. package/src/endpoints/process-scheduled.ts +6 -10
  127. package/src/endpoints/process-snooze.ts +78 -0
  128. package/src/endpoints/purge-logs.ts +2 -2
  129. package/src/endpoints/push.ts +49 -0
  130. package/src/endpoints/resend-notification.ts +3 -4
  131. package/src/endpoints/round-robin-config.ts +3 -4
  132. package/src/endpoints/satisfaction.ts +21 -14
  133. package/src/endpoints/search.ts +5 -8
  134. package/src/endpoints/seed-kb.ts +3 -4
  135. package/src/endpoints/send-reminder.ts +6 -9
  136. package/src/endpoints/settings.ts +5 -2
  137. package/src/endpoints/signature.ts +3 -4
  138. package/src/endpoints/sla-check.ts +2 -2
  139. package/src/endpoints/split-ticket.ts +7 -12
  140. package/src/endpoints/statuses.ts +2 -2
  141. package/src/endpoints/ticket-feedback.ts +4 -6
  142. package/src/endpoints/ticket-synthesis.ts +2 -2
  143. package/src/endpoints/track-open.ts +16 -28
  144. package/src/endpoints/transfer-ticket.ts +21 -9
  145. package/src/endpoints/user-prefs.ts +3 -4
  146. package/src/hooks/automationRules.ts +84 -0
  147. package/src/hooks/checkSLA.ts +63 -17
  148. package/src/plugin.ts +8 -0
  149. package/src/portal/LiveChat.tsx +2 -63
  150. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  151. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  152. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  153. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  154. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  155. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  156. package/src/portal/helpers.ts +46 -0
  157. package/src/portal/login/page.tsx +3 -8
  158. package/src/portal/types.ts +15 -0
  159. package/src/types/lucide-react.d.ts +34 -0
  160. package/src/types/payload-config.d.ts +10 -0
  161. package/src/utils/adminNotification.ts +2 -2
  162. package/src/utils/aiAgent.ts +147 -0
  163. package/src/utils/auth.ts +5 -4
  164. package/src/utils/channels.ts +36 -0
  165. package/src/utils/db.ts +73 -0
  166. package/src/utils/emailTemplate.ts +8 -1
  167. package/src/utils/fireWebhooks.ts +4 -6
  168. package/src/utils/generateTicketSynthesis.ts +7 -12
  169. package/src/utils/invoicePdf.ts +88 -0
  170. package/src/utils/notificationQueue.ts +36 -0
  171. package/src/utils/push.ts +63 -0
  172. package/src/utils/readSettings.ts +22 -6
  173. package/src/utils/sanitizeHtml.ts +57 -0
  174. package/src/utils/slugs.ts +8 -0
  175. package/src/utils/teamAccess.ts +27 -0
  176. package/src/utils/ticketAccess.ts +53 -0
  177. package/src/utils/webhookDispatcher.ts +4 -6
  178. package/src/views/BillingView/client.tsx +49 -37
  179. package/src/views/ChatView/client.tsx +4 -3
  180. package/src/views/CrmView/client.tsx +7 -7
  181. package/src/views/EmailTrackingView/client.tsx +7 -6
  182. package/src/views/ImportConversationView/client.tsx +4 -4
  183. package/src/views/LogsView/client.tsx +3 -2
  184. package/src/views/NewTicketView/client.tsx +1 -1
  185. package/src/views/PendingEmailsView/client.tsx +7 -6
  186. package/src/views/SupportDashboardView/client.tsx +21 -21
  187. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  188. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  189. package/src/views/TicketDetailView/client.tsx +54 -179
  190. package/src/views/TicketDetailView/constants.ts +23 -0
  191. package/src/views/TicketDetailView/helpers.ts +17 -0
  192. package/src/views/TicketDetailView/types.ts +10 -0
  193. package/src/views/TicketInboxView/client.tsx +16 -10
  194. package/src/views/TicketingSettingsView/client.tsx +90 -87
  195. package/src/views/TimeDashboardView/client.tsx +2 -2
  196. package/src/views/shared/StatusPill.tsx +24 -12
  197. package/src/views/shared/dateLocale.ts +5 -0
  198. package/src/views/shared/locales/en.json +708 -0
  199. package/src/views/shared/locales/fr.json +708 -0
package/dist/index.cjs CHANGED
@@ -1,10 +1,17 @@
1
1
  'use strict';
2
2
 
3
3
  var crypto3 = require('crypto');
4
+ var PDFDocument = require('pdfkit');
5
+ var payload = require('payload');
6
+ var webpush = require('web-push');
7
+ var sanitizeHtml = require('sanitize-html');
4
8
 
5
9
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
6
10
 
7
11
  var crypto3__default = /*#__PURE__*/_interopDefault(crypto3);
12
+ var PDFDocument__default = /*#__PURE__*/_interopDefault(PDFDocument);
13
+ var webpush__default = /*#__PURE__*/_interopDefault(webpush);
14
+ var sanitizeHtml__default = /*#__PURE__*/_interopDefault(sanitizeHtml);
8
15
 
9
16
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
10
17
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
@@ -64,6 +71,10 @@ var DEFAULT_SLUGS = {
64
71
  macros: "macros",
65
72
  ticketStatuses: "ticket-statuses",
66
73
  ticketFeedback: "ticket-feedback",
74
+ notificationQueue: "notification-queue",
75
+ automationRules: "automation-rules",
76
+ supportTeams: "support-teams",
77
+ pushSubscriptions: "push-subscriptions",
67
78
  users: "users",
68
79
  media: "media"
69
80
  };
@@ -95,6 +106,26 @@ function handleAuthError(error) {
95
106
  return null;
96
107
  }
97
108
 
109
+ // src/utils/db.ts
110
+ function dbFind(payload, slug, options = {}) {
111
+ return payload.find({ collection: slug, ...options });
112
+ }
113
+ function dbFindByID(payload, slug, options) {
114
+ return payload.findByID({ collection: slug, ...options });
115
+ }
116
+ function dbCreate(payload, slug, options) {
117
+ return payload.create({ collection: slug, ...options });
118
+ }
119
+ function dbUpdate(payload, slug, options) {
120
+ return payload.update({ collection: slug, ...options });
121
+ }
122
+ function dbCount(payload, slug, options = {}) {
123
+ return payload.count({ collection: slug, ...options });
124
+ }
125
+ function dbDelete(payload, slug, options) {
126
+ return payload.delete({ collection: slug, ...options });
127
+ }
128
+
98
129
  // src/utils/readSettings.ts
99
130
  var PREF_KEY = "support-settings";
100
131
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
@@ -108,10 +139,18 @@ var DEFAULT_USER_PREFS = {
108
139
  locale: "fr",
109
140
  signature: ""
110
141
  };
142
+ var settingsCache = null;
143
+ var SETTINGS_TTL_MS = 6e4;
144
+ function invalidateSupportSettingsCache() {
145
+ settingsCache = null;
146
+ }
111
147
  async function readSupportSettings(payload) {
148
+ if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
149
+ return settingsCache.value;
150
+ }
151
+ let value = { ...DEFAULT_SETTINGS };
112
152
  try {
113
- const prefs = await payload.find({
114
- collection: "payload-preferences",
153
+ const prefs = await dbFind(payload, "payload-preferences", {
115
154
  where: { key: { equals: PREF_KEY } },
116
155
  limit: 1,
117
156
  depth: 0,
@@ -119,7 +158,7 @@ async function readSupportSettings(payload) {
119
158
  });
120
159
  if (prefs.docs.length > 0) {
121
160
  const stored = prefs.docs[0].value;
122
- return {
161
+ value = {
123
162
  email: { ...DEFAULT_SETTINGS.email, ...stored.email },
124
163
  ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
125
164
  sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
@@ -128,13 +167,13 @@ async function readSupportSettings(payload) {
128
167
  }
129
168
  } catch {
130
169
  }
131
- return { ...DEFAULT_SETTINGS };
170
+ settingsCache = { value, ts: Date.now() };
171
+ return value;
132
172
  }
133
173
  async function readUserPrefs(payload, userId) {
134
174
  try {
135
175
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
136
- const prefs = await payload.find({
137
- collection: "payload-preferences",
176
+ const prefs = await dbFind(payload, "payload-preferences", {
138
177
  where: { key: { equals: key } },
139
178
  limit: 1,
140
179
  depth: 0,
@@ -320,8 +359,130 @@ ${text}`;
320
359
  };
321
360
  }
322
361
 
323
- // src/endpoints/client-intelligence.ts
362
+ // src/utils/aiAgent.ts
363
+ async function applyAgentDecision(payload, slugs, ticketId, decision) {
364
+ if (decision.action === "reply" && decision.reply && decision.reply.trim()) {
365
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
366
+ data: {
367
+ ticket: ticketId,
368
+ body: decision.reply.trim(),
369
+ authorType: "admin",
370
+ isInternal: false,
371
+ fromAlias: "Assistant IA"
372
+ },
373
+ overrideAccess: true
374
+ });
375
+ return { acted: "reply", messageId: msg.id, confidence: decision.confidence };
376
+ }
377
+ await dbCreate(payload, slugs.ticketMessages, {
378
+ data: {
379
+ ticket: ticketId,
380
+ body: `[Agent IA] Escalade vers un humain. Raison : ${decision.reason || "non d\xE9termin\xE9e"} (confiance ${Math.round((decision.confidence ?? 0) * 100)} %).`,
381
+ authorType: "admin",
382
+ isInternal: true,
383
+ skipNotification: true
384
+ },
385
+ overrideAccess: true
386
+ });
387
+ await dbUpdate(payload, slugs.tickets, { id: ticketId, data: { status: "escalated" }, overrideAccess: true });
388
+ return { acted: "escalate", confidence: decision.confidence };
389
+ }
324
390
  function getClient2(aiSettings) {
391
+ const Anthropic = __require("@anthropic-ai/sdk").default;
392
+ if (aiSettings.provider === "ollama") {
393
+ return new Anthropic({ apiKey: "ollama", baseURL: process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1" });
394
+ }
395
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
396
+ }
397
+ function stripHtml(s) {
398
+ return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
399
+ }
400
+ function parseDecision(text) {
401
+ try {
402
+ const match = text.match(/\{[\s\S]*\}/);
403
+ if (match) {
404
+ const obj = JSON.parse(match[0]);
405
+ return {
406
+ action: obj.action === "reply" ? "reply" : "escalate",
407
+ reply: typeof obj.reply === "string" ? obj.reply : void 0,
408
+ reason: typeof obj.reason === "string" ? obj.reason : void 0,
409
+ confidence: typeof obj.confidence === "number" ? obj.confidence : 0
410
+ };
411
+ }
412
+ } catch {
413
+ }
414
+ return { action: "escalate", reason: "R\xE9ponse IA non interpr\xE9table", confidence: 0 };
415
+ }
416
+ async function runAiAgent(payload, slugs, ticketId, opts) {
417
+ const settings = await readSupportSettings(payload);
418
+ const threshold = opts?.confidenceThreshold ?? 0.7;
419
+ const ticket = await dbFindByID(payload, slugs.tickets, { id: ticketId, depth: 0, overrideAccess: true });
420
+ const messages = await dbFind(payload, slugs.ticketMessages, { where: { ticket: { equals: ticketId } }, sort: "createdAt", limit: 50, depth: 0, overrideAccess: true });
421
+ const kb = await dbFind(payload, slugs.knowledgeBase, { where: { published: { equals: true } }, limit: 20, depth: 0, overrideAccess: true });
422
+ const conversation = messages.docs.map((m) => `${m.authorType}: ${m.body || ""}`).join("\n");
423
+ const kbText = kb.docs.map((a) => `# ${a.title || ""}
424
+ ${stripHtml(String(a.content ?? ""))}`).join("\n\n").slice(0, 8e3);
425
+ const prompt = `Tu es un agent de support IA. \xC0 partir du ticket et de la base de connaissances ci-dessous, d\xE9cide :
426
+ - "reply" si tu peux r\xE9pondre avec certitude UNIQUEMENT \xE0 partir de la base de connaissances ;
427
+ - "escalate" sinon (question hors KB, ambigu\xEB, sensible, ou n\xE9cessitant une action humaine).
428
+ 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>}.
429
+
430
+ TICKET : ${ticket.subject || ""}
431
+ CONVERSATION :
432
+ ${conversation}
433
+
434
+ BASE DE CONNAISSANCES :
435
+ ${kbText || "(vide)"}`;
436
+ let decision;
437
+ try {
438
+ const client = getClient2(settings.ai);
439
+ const resp = await client.messages.create({
440
+ model: settings.ai.model || "claude-haiku-4-5-20251001",
441
+ max_tokens: 1024,
442
+ messages: [{ role: "user", content: prompt }]
443
+ });
444
+ const text = resp?.content?.[0]?.text || "";
445
+ decision = parseDecision(text);
446
+ } catch (err) {
447
+ console.error("[support] AI agent decision failed:", err);
448
+ decision = { action: "escalate", reason: "Erreur du fournisseur IA", confidence: 0 };
449
+ }
450
+ 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 };
451
+ return applyAgentDecision(payload, slugs, ticketId, finalDecision);
452
+ }
453
+
454
+ // src/endpoints/ai-agent.ts
455
+ function createAiAgentEndpoint(slugs) {
456
+ return {
457
+ path: "/support/ai-agent",
458
+ method: "post",
459
+ handler: async (req) => {
460
+ try {
461
+ requireAdmin(req, slugs);
462
+ let body = {};
463
+ try {
464
+ body = await req.json();
465
+ } catch {
466
+ }
467
+ if (!body.ticketId) {
468
+ return Response.json({ error: "ticketId requis." }, { status: 400 });
469
+ }
470
+ const result = await runAiAgent(req.payload, slugs, body.ticketId, {
471
+ confidenceThreshold: body.confidenceThreshold
472
+ });
473
+ return Response.json({ ok: true, ...result });
474
+ } catch (error) {
475
+ const authResponse = handleAuthError(error);
476
+ if (authResponse) return authResponse;
477
+ console.error("[ai-agent] Error:", error);
478
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
479
+ }
480
+ }
481
+ };
482
+ }
483
+
484
+ // src/endpoints/client-intelligence.ts
485
+ function getClient3(aiSettings) {
325
486
  const Anthropic = __require("@anthropic-ai/sdk").default;
326
487
  if (aiSettings.provider === "ollama") {
327
488
  const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
@@ -475,7 +636,7 @@ R\xE9ponds en JSON strict (pas de markdown, pas de commentaires) avec cette stru
475
636
  }
476
637
 
477
638
  Sois factuel. Ne d\xE9passe pas 5 items par tableau. R\xE9ponds UNIQUEMENT avec le JSON.`;
478
- const anthropic = getClient2(aiSettings);
639
+ const anthropic = getClient3(aiSettings);
479
640
  const model = getModel2(aiSettings);
480
641
  const res = await anthropic.messages.create({
481
642
  model,
@@ -539,8 +700,7 @@ function createSearchEndpoint(slugs) {
539
700
  return Response.json({ tickets: [], clients: [], messages: [], articles: [] });
540
701
  }
541
702
  const [ticketsRes, clientsRes, messagesRes, articlesRes] = await Promise.all([
542
- payload.find({
543
- collection: slugs.tickets,
703
+ dbFind(payload, slugs.tickets, {
544
704
  where: {
545
705
  or: [
546
706
  { ticketNumber: { contains: q } },
@@ -552,8 +712,7 @@ function createSearchEndpoint(slugs) {
552
712
  depth: 1,
553
713
  overrideAccess: true
554
714
  }),
555
- payload.find({
556
- collection: slugs.supportClients,
715
+ dbFind(payload, slugs.supportClients, {
557
716
  where: {
558
717
  or: [
559
718
  { firstName: { contains: q } },
@@ -566,16 +725,14 @@ function createSearchEndpoint(slugs) {
566
725
  depth: 0,
567
726
  overrideAccess: true
568
727
  }),
569
- payload.find({
570
- collection: slugs.ticketMessages,
728
+ dbFind(payload, slugs.ticketMessages, {
571
729
  where: { body: { contains: q } },
572
730
  sort: "-createdAt",
573
731
  limit: 5,
574
732
  depth: 1,
575
733
  overrideAccess: true
576
734
  }),
577
- payload.find({
578
- collection: slugs.knowledgeBase,
735
+ dbFind(payload, slugs.knowledgeBase, {
579
736
  where: { title: { contains: q } },
580
737
  limit: 5,
581
738
  depth: 0,
@@ -675,8 +832,7 @@ function createKbSearchEndpoint(slugs) {
675
832
  orClauses.push({ category: { contains: t } });
676
833
  }
677
834
  orClauses.push({ title: { contains: q } });
678
- const res = await payload.find({
679
- collection: slugs.knowledgeBase,
835
+ const res = await dbFind(payload, slugs.knowledgeBase, {
680
836
  where: {
681
837
  and: [
682
838
  { published: { equals: true } },
@@ -735,16 +891,14 @@ function createBulkActionEndpoint(slugs) {
735
891
  try {
736
892
  switch (action) {
737
893
  case "close":
738
- await payload.update({
739
- collection: slugs.tickets,
894
+ await dbUpdate(payload, slugs.tickets, {
740
895
  id: ticketId,
741
896
  data: { status: "resolved", resolvedAt: (/* @__PURE__ */ new Date()).toISOString() },
742
897
  overrideAccess: true
743
898
  });
744
899
  break;
745
900
  case "reopen":
746
- await payload.update({
747
- collection: slugs.tickets,
901
+ await dbUpdate(payload, slugs.tickets, {
748
902
  id: ticketId,
749
903
  data: { status: "open" },
750
904
  overrideAccess: true
@@ -752,8 +906,7 @@ function createBulkActionEndpoint(slugs) {
752
906
  break;
753
907
  case "assign":
754
908
  if (value) {
755
- await payload.update({
756
- collection: slugs.tickets,
909
+ await dbUpdate(payload, slugs.tickets, {
757
910
  id: ticketId,
758
911
  data: { assignedTo: Number(value) },
759
912
  overrideAccess: true
@@ -762,8 +915,7 @@ function createBulkActionEndpoint(slugs) {
762
915
  break;
763
916
  case "set_priority":
764
917
  if (value) {
765
- await payload.update({
766
- collection: slugs.tickets,
918
+ await dbUpdate(payload, slugs.tickets, {
767
919
  id: ticketId,
768
920
  data: { priority: String(value) },
769
921
  overrideAccess: true
@@ -772,8 +924,7 @@ function createBulkActionEndpoint(slugs) {
772
924
  break;
773
925
  case "set_category":
774
926
  if (value) {
775
- await payload.update({
776
- collection: slugs.tickets,
927
+ await dbUpdate(payload, slugs.tickets, {
777
928
  id: ticketId,
778
929
  data: { category: String(value) },
779
930
  overrideAccess: true
@@ -781,8 +932,7 @@ function createBulkActionEndpoint(slugs) {
781
932
  }
782
933
  break;
783
934
  case "delete":
784
- await payload.delete({
785
- collection: slugs.tickets,
935
+ await dbDelete(payload, slugs.tickets, {
786
936
  id: ticketId,
787
937
  overrideAccess: true
788
938
  });
@@ -821,8 +971,8 @@ function createMergeTicketsEndpoint(slugs) {
821
971
  return Response.json({ error: "Impossible de fusionner un ticket avec lui-m\xEAme" }, { status: 400 });
822
972
  }
823
973
  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 })
974
+ dbFindByID(payload, slugs.tickets, { id: sourceTicketId, depth: 0, overrideAccess: true }),
975
+ dbFindByID(payload, slugs.tickets, { id: targetTicketId, depth: 0, overrideAccess: true })
826
976
  ]);
827
977
  if (!source) return Response.json({ error: "Ticket source introuvable" }, { status: 404 });
828
978
  if (!target) return Response.json({ error: "Ticket cible introuvable" }, { status: 404 });
@@ -831,39 +981,34 @@ function createMergeTicketsEndpoint(slugs) {
831
981
  if (sourceClient !== targetClient) {
832
982
  return Response.json({ error: "Les deux tickets doivent appartenir au m\xEAme client" }, { status: 400 });
833
983
  }
834
- const messages = await payload.find({
835
- collection: slugs.ticketMessages,
984
+ const messages = await dbFind(payload, slugs.ticketMessages, {
836
985
  where: { ticket: { equals: sourceTicketId } },
837
986
  limit: 500,
838
987
  depth: 0,
839
988
  overrideAccess: true
840
989
  });
841
990
  for (const msg of messages.docs) {
842
- await payload.update({
843
- collection: slugs.ticketMessages,
991
+ await dbUpdate(payload, slugs.ticketMessages, {
844
992
  id: msg.id,
845
993
  data: { ticket: targetTicketId },
846
994
  overrideAccess: true
847
995
  });
848
996
  }
849
- const timeEntries = await payload.find({
850
- collection: slugs.timeEntries,
997
+ const timeEntries = await dbFind(payload, slugs.timeEntries, {
851
998
  where: { ticket: { equals: sourceTicketId } },
852
999
  limit: 500,
853
1000
  depth: 0,
854
1001
  overrideAccess: true
855
1002
  });
856
1003
  for (const entry of timeEntries.docs) {
857
- await payload.update({
858
- collection: slugs.timeEntries,
1004
+ await dbUpdate(payload, slugs.timeEntries, {
859
1005
  id: entry.id,
860
1006
  data: { ticket: targetTicketId },
861
1007
  overrideAccess: true
862
1008
  });
863
1009
  }
864
1010
  const sourceNumber = source.ticketNumber || `#${sourceTicketId}`;
865
- await payload.create({
866
- collection: slugs.ticketMessages,
1011
+ await dbCreate(payload, slugs.ticketMessages, {
867
1012
  data: {
868
1013
  ticket: targetTicketId,
869
1014
  body: `Messages fusionn\xE9s depuis ${sourceNumber} (${messages.totalDocs} messages, ${timeEntries.totalDocs} entr\xE9es de temps)`,
@@ -873,13 +1018,11 @@ function createMergeTicketsEndpoint(slugs) {
873
1018
  },
874
1019
  overrideAccess: true
875
1020
  });
876
- await payload.delete({
877
- collection: slugs.tickets,
1021
+ await dbDelete(payload, slugs.tickets, {
878
1022
  id: sourceTicketId,
879
1023
  overrideAccess: true
880
1024
  });
881
- const allTimeEntries = await payload.find({
882
- collection: slugs.timeEntries,
1025
+ const allTimeEntries = await dbFind(payload, slugs.timeEntries, {
883
1026
  where: { ticket: { equals: targetTicketId } },
884
1027
  limit: 500,
885
1028
  depth: 0,
@@ -889,8 +1032,7 @@ function createMergeTicketsEndpoint(slugs) {
889
1032
  (sum, e) => sum + (e.duration || 0),
890
1033
  0
891
1034
  );
892
- await payload.update({
893
- collection: slugs.tickets,
1035
+ await dbUpdate(payload, slugs.tickets, {
894
1036
  id: targetTicketId,
895
1037
  data: { totalTimeMinutes: totalMinutes },
896
1038
  overrideAccess: true
@@ -925,8 +1067,7 @@ function createSplitTicketEndpoint(slugs) {
925
1067
  if (!messageId) {
926
1068
  return Response.json({ error: "messageId required" }, { status: 400 });
927
1069
  }
928
- const message = await payload.findByID({
929
- collection: slugs.ticketMessages,
1070
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
930
1071
  id: messageId,
931
1072
  depth: 1,
932
1073
  overrideAccess: true
@@ -939,8 +1080,7 @@ function createSplitTicketEndpoint(slugs) {
939
1080
  return Response.json({ error: "Cannot resolve source ticket" }, { status: 400 });
940
1081
  }
941
1082
  const clientId = typeof sourceTicket.client === "object" ? sourceTicket.client.id : sourceTicket.client;
942
- const newTicket = await payload.create({
943
- collection: slugs.tickets,
1083
+ const newTicket = await dbCreate(payload, slugs.tickets, {
944
1084
  data: {
945
1085
  subject: subject || `Split: ${sourceTicket.subject}`,
946
1086
  client: clientId,
@@ -955,8 +1095,7 @@ function createSplitTicketEndpoint(slugs) {
955
1095
  const attachments = message.attachments?.map((a) => ({
956
1096
  file: typeof a.file === "object" ? a.file.id : a.file
957
1097
  })) || [];
958
- await payload.create({
959
- collection: slugs.ticketMessages,
1098
+ await dbCreate(payload, slugs.ticketMessages, {
960
1099
  data: {
961
1100
  ticket: newTicket.id,
962
1101
  body: message.body,
@@ -969,8 +1108,7 @@ function createSplitTicketEndpoint(slugs) {
969
1108
  },
970
1109
  overrideAccess: true
971
1110
  });
972
- await payload.create({
973
- collection: slugs.ticketMessages,
1111
+ await dbCreate(payload, slugs.ticketMessages, {
974
1112
  data: {
975
1113
  ticket: sourceTicket.id,
976
1114
  body: `Message extrait vers le ticket ${newTicket.ticketNumber}`,
@@ -982,15 +1120,13 @@ function createSplitTicketEndpoint(slugs) {
982
1120
  });
983
1121
  const existingRelated = Array.isArray(sourceTicket.relatedTickets) ? sourceTicket.relatedTickets.map((t) => typeof t === "object" ? t.id : t) : [];
984
1122
  if (!existingRelated.includes(newTicket.id)) {
985
- await payload.update({
986
- collection: slugs.tickets,
1123
+ await dbUpdate(payload, slugs.tickets, {
987
1124
  id: sourceTicket.id,
988
1125
  data: { relatedTickets: [...existingRelated, newTicket.id] },
989
1126
  overrideAccess: true
990
1127
  });
991
1128
  }
992
- await payload.create({
993
- collection: slugs.ticketActivityLog,
1129
+ await dbCreate(payload, slugs.ticketActivityLog, {
994
1130
  data: {
995
1131
  ticket: sourceTicket.id,
996
1132
  action: "split",
@@ -1205,8 +1341,7 @@ function createSettingsGetEndpoint(slugs) {
1205
1341
  try {
1206
1342
  const payload = req.payload;
1207
1343
  requireAdmin(req, slugs);
1208
- const prefs = await payload.find({
1209
- collection: "payload-preferences",
1344
+ const prefs = await dbFind(payload, "payload-preferences", {
1210
1345
  where: { key: { equals: PREF_KEY2 } },
1211
1346
  limit: 1,
1212
1347
  depth: 0,
@@ -1263,6 +1398,7 @@ function createSettingsPostEndpoint(slugs) {
1263
1398
  ]
1264
1399
  }
1265
1400
  });
1401
+ invalidateSupportSettingsCache();
1266
1402
  return Response.json(merged);
1267
1403
  } catch (error) {
1268
1404
  const authResponse = handleAuthError(error);
@@ -1284,8 +1420,7 @@ function createSignatureGetEndpoint(slugs) {
1284
1420
  try {
1285
1421
  const payload = req.payload;
1286
1422
  requireAdmin(req, slugs);
1287
- const prefs = await payload.find({
1288
- collection: "payload-preferences",
1423
+ const prefs = await dbFind(payload, "payload-preferences", {
1289
1424
  where: { key: { equals: `${PREF_KEY3}-${req.user.id}` } },
1290
1425
  limit: 1,
1291
1426
  depth: 0,
@@ -1312,8 +1447,7 @@ function createSignaturePostEndpoint(slugs) {
1312
1447
  requireAdmin(req, slugs);
1313
1448
  const { signature } = await req.json();
1314
1449
  const key = `${PREF_KEY3}-${req.user.id}`;
1315
- const existing = await payload.find({
1316
- collection: "payload-preferences",
1450
+ const existing = await dbFind(payload, "payload-preferences", {
1317
1451
  where: { key: { equals: key } },
1318
1452
  limit: 1,
1319
1453
  depth: 0,
@@ -1356,8 +1490,7 @@ function createRoundRobinConfigGetEndpoint(slugs) {
1356
1490
  try {
1357
1491
  const payload = req.payload;
1358
1492
  requireAdmin(req, slugs);
1359
- const prefs = await payload.find({
1360
- collection: "payload-preferences",
1493
+ const prefs = await dbFind(payload, "payload-preferences", {
1361
1494
  where: { key: { equals: PREF_KEY4 } },
1362
1495
  limit: 1,
1363
1496
  depth: 0,
@@ -1383,8 +1516,7 @@ function createRoundRobinConfigPostEndpoint(slugs) {
1383
1516
  const payload = req.payload;
1384
1517
  requireAdmin(req, slugs);
1385
1518
  const { enabled } = await req.json();
1386
- const existing = await payload.find({
1387
- collection: "payload-preferences",
1519
+ const existing = await dbFind(payload, "payload-preferences", {
1388
1520
  where: { key: { equals: PREF_KEY4 } },
1389
1521
  limit: 1,
1390
1522
  depth: 0,
@@ -1428,8 +1560,7 @@ function createSlaCheckEndpoint(slugs) {
1428
1560
  requireAdmin(req, slugs);
1429
1561
  const now = /* @__PURE__ */ new Date();
1430
1562
  const nowISO = now.toISOString();
1431
- const { docs: tickets } = await payload.find({
1432
- collection: slugs.tickets,
1563
+ const { docs: tickets } = await dbFind(payload, slugs.tickets, {
1433
1564
  where: {
1434
1565
  and: [
1435
1566
  { status: { in: ["open", "waiting_client"] } },
@@ -1523,6 +1654,107 @@ function createSlaCheckEndpoint(slugs) {
1523
1654
  }
1524
1655
  };
1525
1656
  }
1657
+ var TRANSPARENT_GIF = Buffer.from(
1658
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
1659
+ "base64"
1660
+ );
1661
+ function generateTrackingToken(ticketId, messageId, secret) {
1662
+ return crypto3.createHmac("sha256", secret).update(`${ticketId}:${messageId}`).digest("hex").substring(0, 16);
1663
+ }
1664
+ function createTrackOpenEndpoint(slugs) {
1665
+ return {
1666
+ path: "/support/track-open",
1667
+ method: "get",
1668
+ handler: async (req) => {
1669
+ const url = new URL(req.url);
1670
+ const ticketId = url.searchParams.get("t");
1671
+ const messageId = url.searchParams.get("m");
1672
+ const sig = url.searchParams.get("sig");
1673
+ const parsedId = ticketId ? Number(ticketId) : NaN;
1674
+ const parsedMsgId = messageId ? Number(messageId) : NaN;
1675
+ const secret = process.env.PAYLOAD_SECRET || "";
1676
+ const validSig = !!secret && !!ticketId && !!messageId && !!sig && sig === generateTrackingToken(ticketId, messageId, secret);
1677
+ if (!validSig) {
1678
+ return new Response(TRANSPARENT_GIF, {
1679
+ status: 200,
1680
+ headers: {
1681
+ "Content-Type": "image/gif",
1682
+ "Content-Length": String(TRANSPARENT_GIF.length),
1683
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
1684
+ }
1685
+ });
1686
+ }
1687
+ if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
1688
+ try {
1689
+ const payload = req.payload;
1690
+ const ticket = await dbFindByID(payload, slugs.tickets, {
1691
+ id: parsedId,
1692
+ depth: 0,
1693
+ overrideAccess: true,
1694
+ select: { lastClientReadAt: true }
1695
+ });
1696
+ if (ticket) {
1697
+ const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0;
1698
+ const fiveMinAgo = Date.now() - 5 * 60 * 1e3;
1699
+ if (lastRead < fiveMinAgo) {
1700
+ await dbUpdate(payload, slugs.tickets, {
1701
+ id: parsedId,
1702
+ data: { lastClientReadAt: (/* @__PURE__ */ new Date()).toISOString() },
1703
+ overrideAccess: true
1704
+ });
1705
+ }
1706
+ }
1707
+ if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
1708
+ const msg = await dbFindByID(payload, slugs.ticketMessages, {
1709
+ id: parsedMsgId,
1710
+ depth: 0,
1711
+ overrideAccess: true,
1712
+ select: { emailOpenedAt: true }
1713
+ });
1714
+ if (msg && !msg.emailOpenedAt) {
1715
+ await dbUpdate(payload, slugs.ticketMessages, {
1716
+ id: parsedMsgId,
1717
+ data: { emailOpenedAt: (/* @__PURE__ */ new Date()).toISOString() },
1718
+ overrideAccess: true
1719
+ });
1720
+ const ticketInfo = await dbFindByID(payload, slugs.tickets, {
1721
+ id: parsedId,
1722
+ depth: 1,
1723
+ overrideAccess: true,
1724
+ select: { ticketNumber: true, subject: true, client: true }
1725
+ });
1726
+ const clientName = typeof ticketInfo?.client === "object" ? ticketInfo.client?.firstName || "Client" : "Client";
1727
+ try {
1728
+ await dbCreate(payload, "admin-notifications", {
1729
+ data: {
1730
+ title: `Email ouvert \u2014 ${ticketInfo?.ticketNumber || "TK-????"}`,
1731
+ message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || "ticket"}"`,
1732
+ type: "email_opened",
1733
+ link: `/admin/ticket?id=${parsedId}`
1734
+ },
1735
+ overrideAccess: true
1736
+ });
1737
+ } catch {
1738
+ }
1739
+ }
1740
+ }
1741
+ } catch (err) {
1742
+ console.error("[track-open] Error:", err);
1743
+ }
1744
+ }
1745
+ return new Response(TRANSPARENT_GIF, {
1746
+ status: 200,
1747
+ headers: {
1748
+ "Content-Type": "image/gif",
1749
+ "Content-Length": String(TRANSPARENT_GIF.length),
1750
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
1751
+ "Pragma": "no-cache",
1752
+ "Expires": "0"
1753
+ }
1754
+ });
1755
+ }
1756
+ };
1757
+ }
1526
1758
 
1527
1759
  // src/utils/emailTemplate.ts
1528
1760
  var COLORS = {
@@ -1559,7 +1791,10 @@ function escapeHtml(str) {
1559
1791
  }
1560
1792
  function emailTrackingPixel(ticketId, messageId, baseUrl) {
1561
1793
  const url = process.env.NEXT_PUBLIC_SERVER_URL || "";
1562
- const params = `t=${ticketId}${messageId ? `&m=${messageId}` : ""}`;
1794
+ const secret = process.env.PAYLOAD_SECRET || "";
1795
+ if (!secret || messageId === void 0 || messageId === null) return "";
1796
+ const sig = generateTrackingToken(String(ticketId), String(messageId), secret);
1797
+ const params = `t=${ticketId}&m=${messageId}&sig=${sig}`;
1563
1798
  return `<img src="${url}/api/support/track-open?${params}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`;
1564
1799
  }
1565
1800
  function emailRichContent(html, config) {
@@ -1824,8 +2059,7 @@ function createAutoCloseEndpoint(slugs) {
1824
2059
  const CLOSE_AFTER_REMIND_DAYS = settings.autoClose.reminderDaysBefore;
1825
2060
  const results = { reminded: 0, closed: 0, errors: 0 };
1826
2061
  const remindCutoff = new Date(now.getTime() - REMIND_AFTER_DAYS * 24 * 60 * 60 * 1e3);
1827
- const ticketsToRemind = await payload.find({
1828
- collection: slugs.tickets,
2062
+ const ticketsToRemind = await dbFind(payload, slugs.tickets, {
1829
2063
  where: {
1830
2064
  and: [
1831
2065
  { status: { equals: "waiting_client" } },
@@ -1856,8 +2090,7 @@ function createAutoCloseEndpoint(slugs) {
1856
2090
  <p><a href="${portalUrl}">R\xE9pondre au ticket</a></p>
1857
2091
  </div>`
1858
2092
  });
1859
- await payload.update({
1860
- collection: slugs.tickets,
2093
+ await dbUpdate(payload, slugs.tickets, {
1861
2094
  id: t.id,
1862
2095
  data: { autoCloseRemindedAt: now.toISOString() },
1863
2096
  overrideAccess: true
@@ -1869,8 +2102,7 @@ function createAutoCloseEndpoint(slugs) {
1869
2102
  }
1870
2103
  }
1871
2104
  const closeCutoff = new Date(now.getTime() - CLOSE_AFTER_REMIND_DAYS * 24 * 60 * 60 * 1e3);
1872
- const ticketsToClose = await payload.find({
1873
- collection: slugs.tickets,
2105
+ const ticketsToClose = await dbFind(payload, slugs.tickets, {
1874
2106
  where: {
1875
2107
  and: [
1876
2108
  { status: { equals: "waiting_client" } },
@@ -1905,8 +2137,7 @@ function createAutoCloseEndpoint(slugs) {
1905
2137
  const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS;
1906
2138
  const viaSchedule = !!t.autoCloseScheduledAt && new Date(t.autoCloseScheduledAt).getTime() <= now.getTime();
1907
2139
  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,
2140
+ await dbCreate(payload, slugs.ticketMessages, {
1910
2141
  data: {
1911
2142
  ticket: t.id,
1912
2143
  body: noteBody,
@@ -1916,8 +2147,7 @@ function createAutoCloseEndpoint(slugs) {
1916
2147
  },
1917
2148
  overrideAccess: true
1918
2149
  });
1919
- await payload.update({
1920
- collection: slugs.tickets,
2150
+ await dbUpdate(payload, slugs.tickets, {
1921
2151
  id: t.id,
1922
2152
  // Clear the armed deadline so a later manual reopen can't be
1923
2153
  // closed instantly by a stale timestamp.
@@ -2040,8 +2270,7 @@ function createSendReminderEndpoint(slugs) {
2040
2270
  }
2041
2271
  const rawHours = Number(body.hours);
2042
2272
  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,
2273
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2045
2274
  id: ticketId,
2046
2275
  depth: 1,
2047
2276
  overrideAccess: true
@@ -2063,8 +2292,7 @@ function createSendReminderEndpoint(slugs) {
2063
2292
  const replyTo = settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || "";
2064
2293
  let waitingSince = ticket.firstResponseAt || ticket.createdAt;
2065
2294
  try {
2066
- const lastAdminMsg = await payload.find({
2067
- collection: slugs.ticketMessages,
2295
+ const lastAdminMsg = await dbFind(payload, slugs.ticketMessages, {
2068
2296
  where: {
2069
2297
  and: [
2070
2298
  { ticket: { equals: ticketId } },
@@ -2105,8 +2333,7 @@ function createSendReminderEndpoint(slugs) {
2105
2333
  preheader: `Sans r\xE9ponse de votre part, votre ticket ${ticketNumber} sera cl\xF4tur\xE9 le ${deadlineLabel}.`
2106
2334
  })
2107
2335
  });
2108
- await payload.create({
2109
- collection: slugs.ticketMessages,
2336
+ await dbCreate(payload, slugs.ticketMessages, {
2110
2337
  data: {
2111
2338
  ticket: ticketId,
2112
2339
  body: `Relance envoy\xE9e \xE0 ${client.email}. Fermeture automatique programm\xE9e le ${deadlineLabel} sans r\xE9ponse du client.`,
@@ -2116,8 +2343,7 @@ function createSendReminderEndpoint(slugs) {
2116
2343
  },
2117
2344
  overrideAccess: true
2118
2345
  });
2119
- await payload.update({
2120
- collection: slugs.tickets,
2346
+ await dbUpdate(payload, slugs.tickets, {
2121
2347
  id: ticketId,
2122
2348
  data: {
2123
2349
  status: "waiting_client",
@@ -2152,8 +2378,7 @@ function createStatusesEndpoint(slugs) {
2152
2378
  if (!req.user) {
2153
2379
  return Response.json({ error: "Unauthorized" }, { status: 401 });
2154
2380
  }
2155
- const { docs } = await payload.find({
2156
- collection: slugs.ticketStatuses,
2381
+ const { docs } = await dbFind(payload, slugs.ticketStatuses, {
2157
2382
  sort: "sortOrder",
2158
2383
  limit: 100,
2159
2384
  depth: 0,
@@ -2192,16 +2417,14 @@ function createApplyMacroEndpoint(slugs) {
2192
2417
  if (!macroId || !ticketId) {
2193
2418
  return Response.json({ error: "macroId and ticketId are required" }, { status: 400 });
2194
2419
  }
2195
- const macro = await payload.findByID({
2196
- collection: slugs.macros,
2420
+ const macro = await dbFindByID(payload, slugs.macros, {
2197
2421
  id: macroId,
2198
2422
  depth: 0,
2199
2423
  overrideAccess: true
2200
2424
  });
2201
2425
  if (!macro) return Response.json({ error: "Macro not found" }, { status: 404 });
2202
2426
  if (!macro.isActive) return Response.json({ error: "Macro is disabled" }, { status: 400 });
2203
- const ticket = await payload.findByID({
2204
- collection: slugs.tickets,
2427
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2205
2428
  id: ticketId,
2206
2429
  depth: 0,
2207
2430
  overrideAccess: true
@@ -2213,8 +2436,7 @@ function createApplyMacroEndpoint(slugs) {
2213
2436
  try {
2214
2437
  switch (action.type) {
2215
2438
  case "set_status":
2216
- await payload.update({
2217
- collection: slugs.tickets,
2439
+ await dbUpdate(payload, slugs.tickets, {
2218
2440
  id: ticketId,
2219
2441
  data: { status: action.value },
2220
2442
  overrideAccess: true
@@ -2222,8 +2444,7 @@ function createApplyMacroEndpoint(slugs) {
2222
2444
  appliedActions.push({ type: action.type, value: action.value, success: true });
2223
2445
  break;
2224
2446
  case "set_priority":
2225
- await payload.update({
2226
- collection: slugs.tickets,
2447
+ await dbUpdate(payload, slugs.tickets, {
2227
2448
  id: ticketId,
2228
2449
  data: { priority: action.value },
2229
2450
  overrideAccess: true
@@ -2235,8 +2456,7 @@ function createApplyMacroEndpoint(slugs) {
2235
2456
  if (!currentTags.includes(action.value)) {
2236
2457
  currentTags.push(action.value);
2237
2458
  }
2238
- await payload.update({
2239
- collection: slugs.tickets,
2459
+ await dbUpdate(payload, slugs.tickets, {
2240
2460
  id: ticketId,
2241
2461
  data: { tags: currentTags },
2242
2462
  overrideAccess: true
@@ -2245,8 +2465,7 @@ function createApplyMacroEndpoint(slugs) {
2245
2465
  break;
2246
2466
  }
2247
2467
  case "send_reply":
2248
- await payload.create({
2249
- collection: slugs.ticketMessages,
2468
+ await dbCreate(payload, slugs.ticketMessages, {
2250
2469
  data: {
2251
2470
  ticket: ticketId,
2252
2471
  body: action.value,
@@ -2263,8 +2482,7 @@ function createApplyMacroEndpoint(slugs) {
2263
2482
  appliedActions.push({ type: action.type, value: action.value, success: false, error: "Invalid user ID" });
2264
2483
  break;
2265
2484
  }
2266
- await payload.update({
2267
- collection: slugs.tickets,
2485
+ await dbUpdate(payload, slugs.tickets, {
2268
2486
  id: ticketId,
2269
2487
  data: { assignedTo: userId },
2270
2488
  overrideAccess: true
@@ -2316,8 +2534,7 @@ function createPurgeLogsEndpoint(slugs) {
2316
2534
  return Response.json({ error: "Invalid collection. Use email-logs or auth-logs." }, { status: 400 });
2317
2535
  }
2318
2536
  const cutoff = days > 0 ? new Date(Date.now() - days * 864e5).toISOString() : null;
2319
- const result = await payload.delete({
2320
- collection: allowedCollections[collection],
2537
+ const result = await dbDelete(payload, allowedCollections[collection], {
2321
2538
  where: cutoff ? { createdAt: { less_than: cutoff } } : { id: { exists: true } },
2322
2539
  overrideAccess: true
2323
2540
  });
@@ -2360,8 +2577,7 @@ function createChatbotEndpoint(slugs) {
2360
2577
  return Response.json({ error: "Question too short" }, { status: 400 });
2361
2578
  }
2362
2579
  const payload = req.payload;
2363
- const articles = await payload.find({
2364
- collection: slugs.knowledgeBase,
2580
+ const articles = await dbFind(payload, slugs.knowledgeBase, {
2365
2581
  where: { published: { equals: true } },
2366
2582
  limit: 100,
2367
2583
  depth: 0,
@@ -2451,8 +2667,7 @@ function createChatGetEndpoint(slugs) {
2451
2667
  if (after) {
2452
2668
  where.createdAt = { greater_than: after };
2453
2669
  }
2454
- const messages = await payload.find({
2455
- collection: slugs.chatMessages,
2670
+ const messages = await dbFind(payload, slugs.chatMessages, {
2456
2671
  where,
2457
2672
  sort: "createdAt",
2458
2673
  limit: 100,
@@ -2495,8 +2710,7 @@ function createChatPostEndpoint(slugs) {
2495
2710
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
2496
2711
  }
2497
2712
  const sessionId = `chat_${crypto3__default.default.randomBytes(16).toString("hex")}`;
2498
- const systemMsg = await payload.create({
2499
- collection: slugs.chatMessages,
2713
+ const systemMsg = await dbCreate(payload, slugs.chatMessages, {
2500
2714
  data: {
2501
2715
  session: sessionId,
2502
2716
  client: req.user.id,
@@ -2516,8 +2730,7 @@ function createChatPostEndpoint(slugs) {
2516
2730
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2517
2731
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2518
2732
  }
2519
- const existing = await payload.find({
2520
- collection: slugs.chatMessages,
2733
+ const existing = await dbFind(payload, slugs.chatMessages, {
2521
2734
  where: { session: { equals: session }, client: { equals: req.user.id } },
2522
2735
  limit: 1,
2523
2736
  overrideAccess: true
@@ -2525,8 +2738,7 @@ function createChatPostEndpoint(slugs) {
2525
2738
  if (existing.docs.length === 0) {
2526
2739
  return Response.json({ error: "Session invalide" }, { status: 403 });
2527
2740
  }
2528
- const newMsg = await payload.create({
2529
- collection: slugs.chatMessages,
2741
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2530
2742
  data: {
2531
2743
  session,
2532
2744
  client: req.user.id,
@@ -2539,8 +2751,7 @@ function createChatPostEndpoint(slugs) {
2539
2751
  return Response.json({ message: newMsg });
2540
2752
  }
2541
2753
  if (action === "close" && session) {
2542
- const existing = await payload.find({
2543
- collection: slugs.chatMessages,
2754
+ const existing = await dbFind(payload, slugs.chatMessages, {
2544
2755
  where: { session: { equals: session }, client: { equals: req.user.id } },
2545
2756
  limit: 1,
2546
2757
  overrideAccess: true
@@ -2548,8 +2759,7 @@ function createChatPostEndpoint(slugs) {
2548
2759
  if (existing.docs.length === 0) {
2549
2760
  return Response.json({ error: "Session invalide" }, { status: 403 });
2550
2761
  }
2551
- await payload.create({
2552
- collection: slugs.chatMessages,
2762
+ await dbCreate(payload, slugs.chatMessages, {
2553
2763
  data: {
2554
2764
  session,
2555
2765
  client: req.user.id,
@@ -2591,8 +2801,7 @@ function createChatStreamEndpoint(slugs) {
2591
2801
  if (!sessionId) {
2592
2802
  return Response.json({ error: "Missing session parameter" }, { status: 400 });
2593
2803
  }
2594
- const existing = await req.payload.find({
2595
- collection: slugs.chatMessages,
2804
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
2596
2805
  where: {
2597
2806
  session: { equals: sessionId },
2598
2807
  client: { equals: userId }
@@ -2614,8 +2823,7 @@ function createChatStreamEndpoint(slugs) {
2614
2823
  );
2615
2824
  const interval = setInterval(async () => {
2616
2825
  try {
2617
- const messages = await req.payload.find({
2618
- collection: slugs.chatMessages,
2826
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2619
2827
  where: {
2620
2828
  session: { equals: sessionId },
2621
2829
  createdAt: { greater_than: lastCheck }
@@ -2696,8 +2904,7 @@ function createAdminChatGetEndpoint(slugs) {
2696
2904
  if (session) {
2697
2905
  const where = { session: { equals: session } };
2698
2906
  if (after) where.createdAt = { greater_than: after };
2699
- const messages = await payload.find({
2700
- collection: slugs.chatMessages,
2907
+ const messages = await dbFind(payload, slugs.chatMessages, {
2701
2908
  where,
2702
2909
  sort: "createdAt",
2703
2910
  limit: 200,
@@ -2711,8 +2918,7 @@ function createAdminChatGetEndpoint(slugs) {
2711
2918
  }
2712
2919
  });
2713
2920
  }
2714
- const recentMessages = await payload.find({
2715
- collection: slugs.chatMessages,
2921
+ const recentMessages = await dbFind(payload, slugs.chatMessages, {
2716
2922
  sort: "-createdAt",
2717
2923
  limit: 50,
2718
2924
  depth: 1,
@@ -2775,8 +2981,7 @@ function createAdminChatPostEndpoint(slugs) {
2775
2981
  if (!session) {
2776
2982
  return Response.json({ error: "Session requise" }, { status: 400 });
2777
2983
  }
2778
- const sessionMsg = await payload.find({
2779
- collection: slugs.chatMessages,
2984
+ const sessionMsg = await dbFind(payload, slugs.chatMessages, {
2780
2985
  where: { session: { equals: session } },
2781
2986
  limit: 1,
2782
2987
  depth: 0,
@@ -2794,8 +2999,7 @@ function createAdminChatPostEndpoint(slugs) {
2794
2999
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2795
3000
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2796
3001
  }
2797
- const newMsg = await payload.create({
2798
- collection: slugs.chatMessages,
3002
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2799
3003
  data: {
2800
3004
  session,
2801
3005
  client: clientId,
@@ -2807,8 +3011,7 @@ function createAdminChatPostEndpoint(slugs) {
2807
3011
  overrideAccess: true
2808
3012
  });
2809
3013
  try {
2810
- const linkedTicket = await payload.find({
2811
- collection: slugs.tickets,
3014
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2812
3015
  where: { chatSession: { equals: session } },
2813
3016
  limit: 1,
2814
3017
  depth: 0,
@@ -2816,8 +3019,7 @@ function createAdminChatPostEndpoint(slugs) {
2816
3019
  });
2817
3020
  if (linkedTicket.docs.length > 0) {
2818
3021
  const ticketId = linkedTicket.docs[0].id;
2819
- await payload.create({
2820
- collection: slugs.ticketMessages,
3022
+ await dbCreate(payload, slugs.ticketMessages, {
2821
3023
  data: {
2822
3024
  ticket: ticketId,
2823
3025
  body: trimmedMessage,
@@ -2826,8 +3028,7 @@ function createAdminChatPostEndpoint(slugs) {
2826
3028
  },
2827
3029
  overrideAccess: true
2828
3030
  });
2829
- await payload.update({
2830
- collection: slugs.chatMessages,
3031
+ await dbUpdate(payload, slugs.chatMessages, {
2831
3032
  id: newMsg.id,
2832
3033
  data: { ticket: ticketId },
2833
3034
  overrideAccess: true
@@ -2839,8 +3040,7 @@ function createAdminChatPostEndpoint(slugs) {
2839
3040
  return Response.json({ message: newMsg });
2840
3041
  }
2841
3042
  if (action === "close") {
2842
- await payload.create({
2843
- collection: slugs.chatMessages,
3043
+ await dbCreate(payload, slugs.chatMessages, {
2844
3044
  data: {
2845
3045
  session,
2846
3046
  client: clientId,
@@ -2851,16 +3051,14 @@ function createAdminChatPostEndpoint(slugs) {
2851
3051
  overrideAccess: true
2852
3052
  });
2853
3053
  try {
2854
- const linkedTicket = await payload.find({
2855
- collection: slugs.tickets,
3054
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2856
3055
  where: { chatSession: { equals: session } },
2857
3056
  limit: 1,
2858
3057
  depth: 0,
2859
3058
  overrideAccess: true
2860
3059
  });
2861
3060
  if (linkedTicket.docs.length > 0) {
2862
- await payload.create({
2863
- collection: slugs.ticketMessages,
3061
+ await dbCreate(payload, slugs.ticketMessages, {
2864
3062
  data: {
2865
3063
  ticket: linkedTicket.docs[0].id,
2866
3064
  body: "Session de chat termin\xE9e par un agent.",
@@ -2923,8 +3121,7 @@ function createSessionStream(req, slugs, sessionId) {
2923
3121
  );
2924
3122
  const interval = setInterval(async () => {
2925
3123
  try {
2926
- const messages = await req.payload.find({
2927
- collection: slugs.chatMessages,
3124
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2928
3125
  where: {
2929
3126
  session: { equals: sessionId },
2930
3127
  createdAt: { greater_than: lastCheck }
@@ -2997,8 +3194,7 @@ function createSessionListStream(req, slugs) {
2997
3194
  );
2998
3195
  const interval = setInterval(async () => {
2999
3196
  try {
3000
- const recentMessages = await req.payload.find({
3001
- collection: slugs.chatMessages,
3197
+ const recentMessages = await dbFind(req.payload, slugs.chatMessages, {
3002
3198
  sort: "-createdAt",
3003
3199
  limit: 50,
3004
3200
  depth: 1,
@@ -3082,12 +3278,13 @@ function createAdminStatsEndpoint(slugs) {
3082
3278
  try {
3083
3279
  const payload = req.payload;
3084
3280
  requireAdmin(req, slugs);
3281
+ const teamId = new URL(req.url).searchParams.get("teamId");
3282
+ const teamAnd = teamId ? [{ team: { equals: Number(teamId) } }] : [];
3085
3283
  const statuses = ["open", "waiting_client", "resolved"];
3086
3284
  const statusCounts = await Promise.all(
3087
3285
  statuses.map(async (status) => {
3088
- const result = await payload.count({
3089
- collection: slugs.tickets,
3090
- where: { status: { equals: status } },
3286
+ const result = await dbCount(payload, slugs.tickets, {
3287
+ where: { and: [{ status: { equals: status } }, ...teamAnd] },
3091
3288
  overrideAccess: true
3092
3289
  });
3093
3290
  return [status, result.totalDocs];
@@ -3098,9 +3295,8 @@ function createAdminStatsEndpoint(slugs) {
3098
3295
  const priorities = ["low", "normal", "high", "urgent"];
3099
3296
  const priorityCounts = await Promise.all(
3100
3297
  priorities.map(async (priority) => {
3101
- const result = await payload.count({
3102
- collection: slugs.tickets,
3103
- where: { priority: { equals: priority } },
3298
+ const result = await dbCount(payload, slugs.tickets, {
3299
+ where: { and: [{ priority: { equals: priority } }, ...teamAnd] },
3104
3300
  overrideAccess: true
3105
3301
  });
3106
3302
  return [priority, result.totalDocs];
@@ -3110,9 +3306,8 @@ function createAdminStatsEndpoint(slugs) {
3110
3306
  const categories = ["bug", "content", "feature", "question", "hosting"];
3111
3307
  const categoryCounts = await Promise.all(
3112
3308
  categories.map(async (category) => {
3113
- const result = await payload.count({
3114
- collection: slugs.tickets,
3115
- where: { category: { equals: category } },
3309
+ const result = await dbCount(payload, slugs.tickets, {
3310
+ where: { and: [{ category: { equals: category } }, ...teamAnd] },
3116
3311
  overrideAccess: true
3117
3312
  });
3118
3313
  return [category, result.totalDocs];
@@ -3125,17 +3320,34 @@ function createAdminStatsEndpoint(slugs) {
3125
3320
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
3126
3321
  const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
3127
3322
  const [created7, created30] = await Promise.all([
3128
- payload.count({
3129
- collection: slugs.tickets,
3323
+ dbCount(payload, slugs.tickets, {
3130
3324
  where: { createdAt: { greater_than_equal: sevenDaysAgo.toISOString() } },
3131
3325
  overrideAccess: true
3132
3326
  }),
3133
- payload.count({
3134
- collection: slugs.tickets,
3327
+ dbCount(payload, slugs.tickets, {
3135
3328
  where: { createdAt: { greater_than_equal: thirtyDaysAgo.toISOString() } },
3136
3329
  overrideAccess: true
3137
3330
  })
3138
3331
  ]);
3332
+ const volumeByDay = await Promise.all(
3333
+ Array.from({ length: 7 }, (_, i) => i).map(async (offset) => {
3334
+ const dayStart = new Date(now);
3335
+ dayStart.setHours(0, 0, 0, 0);
3336
+ dayStart.setDate(dayStart.getDate() - (6 - offset));
3337
+ const dayEnd = new Date(dayStart);
3338
+ dayEnd.setDate(dayEnd.getDate() + 1);
3339
+ const r = await dbCount(payload, slugs.tickets, {
3340
+ where: {
3341
+ and: [
3342
+ { createdAt: { greater_than_equal: dayStart.toISOString() } },
3343
+ { createdAt: { less_than: dayEnd.toISOString() } }
3344
+ ]
3345
+ },
3346
+ overrideAccess: true
3347
+ });
3348
+ return { date: dayStart.toISOString(), count: r.totalDocs };
3349
+ })
3350
+ );
3139
3351
  const PAGE_SIZE2 = 100;
3140
3352
  const MAX_PAGES2 = 50;
3141
3353
  let totalResponseTimeMs = 0;
@@ -3146,8 +3358,7 @@ function createAdminStatsEndpoint(slugs) {
3146
3358
  let page = 1;
3147
3359
  let hasMore = true;
3148
3360
  while (hasMore && page <= MAX_PAGES2) {
3149
- const batch = await payload.find({
3150
- collection: slugs.tickets,
3361
+ const batch = await dbFind(payload, slugs.tickets, {
3151
3362
  limit: PAGE_SIZE2,
3152
3363
  page,
3153
3364
  depth: 0,
@@ -3180,43 +3391,62 @@ function createAdminStatsEndpoint(slugs) {
3180
3391
  hasMore = batch.hasNextPage ?? false;
3181
3392
  page++;
3182
3393
  }
3183
- const surveyCount = await payload.count({
3184
- collection: slugs.satisfactionSurveys,
3394
+ const surveyCount = await dbCount(payload, slugs.satisfactionSurveys, {
3185
3395
  overrideAccess: true
3186
3396
  });
3187
3397
  let satisfactionAvg = 0;
3398
+ let satisfactionCount = 0;
3399
+ let npsScore = null;
3400
+ let npsCount = 0;
3188
3401
  if (surveyCount.totalDocs > 0) {
3189
- let totalRating = 0;
3402
+ let ratingSum = 0;
3403
+ let ratingN = 0;
3404
+ let promoters = 0;
3405
+ let detractors = 0;
3406
+ let npsN = 0;
3190
3407
  let surveyPage = 1;
3191
3408
  let surveyHasMore = true;
3192
3409
  while (surveyHasMore && surveyPage <= MAX_PAGES2) {
3193
- const batch = await payload.find({
3194
- collection: slugs.satisfactionSurveys,
3410
+ const batch = await dbFind(payload, slugs.satisfactionSurveys, {
3195
3411
  limit: PAGE_SIZE2,
3196
3412
  page: surveyPage,
3197
3413
  depth: 0,
3198
3414
  overrideAccess: true,
3199
- select: { rating: true }
3415
+ select: { rating: true, nps: true }
3200
3416
  });
3201
3417
  for (const s of batch.docs) {
3202
- totalRating += s.rating || 0;
3418
+ const r = s.rating;
3419
+ if (typeof r === "number" && r > 0) {
3420
+ ratingSum += r;
3421
+ ratingN++;
3422
+ }
3423
+ const n = s.nps;
3424
+ if (typeof n === "number") {
3425
+ npsN++;
3426
+ if (n >= 9) promoters++;
3427
+ else if (n <= 6) detractors++;
3428
+ }
3203
3429
  }
3204
3430
  surveyHasMore = batch.hasNextPage ?? false;
3205
3431
  surveyPage++;
3206
3432
  }
3207
- satisfactionAvg = Math.round(totalRating / surveyCount.totalDocs * 10) / 10;
3433
+ satisfactionAvg = ratingN > 0 ? Math.round(ratingSum / ratingN * 10) / 10 : 0;
3434
+ satisfactionCount = ratingN;
3435
+ if (npsN > 0) {
3436
+ npsScore = Math.round((promoters - detractors) / npsN * 100);
3437
+ npsCount = npsN;
3438
+ }
3208
3439
  }
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,
3440
+ const clientCount = await dbCount(payload, slugs.supportClients, { overrideAccess: true });
3441
+ let pendingEmailsTotal = 0;
3442
+ try {
3443
+ const pe = await dbCount(payload, slugs.pendingEmails, {
3216
3444
  where: { status: { equals: "pending" } },
3217
3445
  overrideAccess: true
3218
- })
3219
- ]);
3446
+ });
3447
+ pendingEmailsTotal = pe.totalDocs;
3448
+ } catch {
3449
+ }
3220
3450
  return new Response(JSON.stringify({
3221
3451
  total,
3222
3452
  byStatus,
@@ -3224,13 +3454,16 @@ function createAdminStatsEndpoint(slugs) {
3224
3454
  byCategory,
3225
3455
  createdLast7Days: created7.totalDocs,
3226
3456
  createdLast30Days: created30.totalDocs,
3457
+ volumeByDay,
3227
3458
  avgResponseTimeHours: responseTimeCount > 0 ? Math.round(totalResponseTimeMs / responseTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3228
3459
  avgResolutionTimeHours: resolutionTimeCount > 0 ? Math.round(totalResolutionTimeMs / resolutionTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3229
3460
  totalTimeMinutes,
3230
3461
  satisfactionAvg,
3231
- satisfactionCount: surveyCount.totalDocs,
3462
+ satisfactionCount,
3463
+ npsScore,
3464
+ npsCount,
3232
3465
  clientCount: clientCount.totalDocs,
3233
- pendingEmailsCount: pendingEmailsCount.totalDocs
3466
+ pendingEmailsCount: pendingEmailsTotal
3234
3467
  }), {
3235
3468
  headers: {
3236
3469
  "Content-Type": "application/json",
@@ -3300,13 +3533,26 @@ function createBillingEndpoint(slugs) {
3300
3533
  let ticketPage = 1;
3301
3534
  let ticketHasMore = true;
3302
3535
  while (ticketHasMore && ticketPage <= MAX_PAGES) {
3303
- const batch = await payload.find({
3304
- collection: slugs.tickets,
3536
+ const batch = await dbFind(payload, slugs.tickets, {
3305
3537
  where: ticketWhere,
3306
3538
  limit: PAGE_SIZE,
3307
3539
  page: ticketPage,
3540
+ // depth 2 is required: we read ticket.project.client.company below.
3308
3541
  depth: 2,
3309
- overrideAccess: true
3542
+ overrideAccess: true,
3543
+ // Bound the payload to only the fields the report consumes (skips body,
3544
+ // richText, SLA fields, etc.) while keeping project/client populated.
3545
+ select: {
3546
+ ticketNumber: true,
3547
+ subject: true,
3548
+ status: true,
3549
+ billedAmount: true,
3550
+ aiSummary: true,
3551
+ aiSummaryGeneratedAt: true,
3552
+ aiSummaryStatus: true,
3553
+ project: true,
3554
+ client: true
3555
+ }
3310
3556
  });
3311
3557
  allTickets.push(...batch.docs);
3312
3558
  ticketHasMore = batch.hasNextPage ?? false;
@@ -3316,8 +3562,7 @@ function createBillingEndpoint(slugs) {
3316
3562
  let entryPage = 1;
3317
3563
  let entryHasMore = true;
3318
3564
  while (entryHasMore && entryPage <= MAX_PAGES) {
3319
- const batch = await payload.find({
3320
- collection: slugs.timeEntries,
3565
+ const batch = await dbFind(payload, slugs.timeEntries, {
3321
3566
  where: {
3322
3567
  and: [
3323
3568
  { date: { greater_than_equal: from } },
@@ -3415,43 +3660,205 @@ function createBillingEndpoint(slugs) {
3415
3660
  }
3416
3661
  };
3417
3662
  }
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
3663
+ var fmtAmount = (n) => `${n.toFixed(2)} EUR`;
3664
+ var fmtTime = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3665
+ function generateInvoicePdf(data) {
3666
+ return new Promise((resolve, reject) => {
3667
+ try {
3668
+ const doc = new PDFDocument__default.default({ size: "A4", margin: 50 });
3669
+ const chunks = [];
3670
+ doc.on("data", (c) => chunks.push(c));
3671
+ doc.on("end", () => resolve(Buffer.concat(chunks)));
3672
+ doc.on("error", reject);
3673
+ doc.fillColor("#0f172a").fontSize(22).text("Invoice / Pre-billing");
3674
+ doc.moveDown(0.2);
3675
+ doc.fillColor("#64748b").fontSize(10).text(`Period: ${data.from} -> ${data.to}${data.projectId ? ` \xB7 Project #${data.projectId}` : ""}`);
3676
+ doc.moveDown(1);
3677
+ const X = { ticket: 50, subject: 130, client: 320, time: 430, amount: 500 };
3678
+ const drawRow = (r, opts) => {
3679
+ const y = doc.y;
3680
+ doc.fontSize(opts?.header ? 9 : 10).fillColor(opts?.header ? "#475569" : "#0f172a");
3681
+ if (opts?.total) doc.font("Helvetica-Bold");
3682
+ doc.text(r.ticket, X.ticket, y, { width: X.subject - X.ticket - 6 });
3683
+ doc.text(r.subject, X.subject, y, { width: X.client - X.subject - 6 });
3684
+ doc.text(r.client, X.client, y, { width: X.time - X.client - 6 });
3685
+ doc.text(r.time, X.time, y, { width: X.amount - X.time - 6, align: "right" });
3686
+ doc.text(r.amount, X.amount, y, { width: 545 - X.amount, align: "right" });
3687
+ doc.font("Helvetica");
3688
+ doc.moveDown(0.4);
3689
+ };
3690
+ drawRow({ ticket: "Ticket", subject: "Subject", client: "Client", time: "Time", amount: "Amount" }, { header: true });
3691
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#e2e8f0").stroke();
3692
+ doc.moveDown(0.3);
3693
+ for (const l of data.lines) {
3694
+ if (doc.y > 760) doc.addPage();
3695
+ drawRow({
3696
+ ticket: l.ticketNumber || "",
3697
+ subject: l.subject || "",
3698
+ client: l.client || "",
3699
+ time: fmtTime(l.minutes),
3700
+ amount: fmtAmount(l.amount)
3701
+ });
3702
+ }
3703
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#0f172a").stroke();
3704
+ doc.moveDown(0.3);
3705
+ drawRow({
3706
+ ticket: "",
3707
+ subject: `Total (${data.lines.length} ticket${data.lines.length > 1 ? "s" : ""})`,
3708
+ client: "",
3709
+ time: fmtTime(data.totalMinutes),
3710
+ amount: fmtAmount(data.totalAmount)
3711
+ }, { total: true });
3712
+ doc.end();
3713
+ } catch (err) {
3714
+ reject(err);
3715
+ }
3442
3716
  });
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" },
3717
+ }
3718
+
3719
+ // src/endpoints/invoice.ts
3720
+ function createInvoiceEndpoint(slugs) {
3721
+ return {
3722
+ path: "/support/billing/invoice",
3723
+ method: "get",
3724
+ handler: async (req) => {
3725
+ try {
3726
+ requireAdmin(req, slugs);
3727
+ const payload = req.payload;
3728
+ const url = new URL(req.url);
3729
+ const from = url.searchParams.get("from");
3730
+ const to = url.searchParams.get("to");
3731
+ const projectId = url.searchParams.get("projectId");
3732
+ const format = url.searchParams.get("format") === "pdf" ? "pdf" : "html";
3733
+ if (!from || !to) {
3734
+ return Response.json({ error: "Param\xE8tres from et to requis." }, { status: 400 });
3735
+ }
3736
+ const toExclusive = new Date(to);
3737
+ toExclusive.setDate(toExclusive.getDate() + 1);
3738
+ const toIso = toExclusive.toISOString();
3739
+ const where = {
3740
+ and: [
3741
+ { billable: { equals: true } },
3742
+ ...projectId ? [{ project: { equals: Number(projectId) } }] : [],
3743
+ {
3744
+ or: [
3745
+ { and: [{ updatedAt: { greater_than_equal: from } }, { updatedAt: { less_than: toIso } }] },
3746
+ { and: [{ createdAt: { greater_than_equal: from } }, { createdAt: { less_than: toIso } }] },
3747
+ { and: [{ resolvedAt: { greater_than_equal: from } }, { resolvedAt: { less_than: toIso } }] }
3748
+ ]
3749
+ }
3750
+ ]
3751
+ };
3752
+ const tickets = [];
3753
+ let page = 1;
3754
+ let hasMore = true;
3755
+ while (hasMore && page <= 50) {
3756
+ const batch = await dbFind(payload, slugs.tickets, {
3757
+ where,
3758
+ limit: 500,
3759
+ page,
3760
+ depth: 2,
3761
+ overrideAccess: true,
3762
+ select: { ticketNumber: true, subject: true, billedAmount: true, totalTimeMinutes: true, project: true, client: true }
3763
+ });
3764
+ tickets.push(...batch.docs);
3765
+ hasMore = batch.hasNextPage ?? false;
3766
+ page++;
3767
+ }
3768
+ let totalAmount = 0;
3769
+ let totalMinutes = 0;
3770
+ const lines = tickets.map((t) => {
3771
+ const amount = typeof t.billedAmount === "number" ? t.billedAmount : 0;
3772
+ const minutes = typeof t.totalTimeMinutes === "number" ? t.totalTimeMinutes : 0;
3773
+ totalAmount += amount;
3774
+ totalMinutes += minutes;
3775
+ const client = typeof t.client === "object" && t.client ? t.client.company || "" : "";
3776
+ return { ticketNumber: String(t.ticketNumber || ""), subject: String(t.subject || ""), client, minutes, amount };
3777
+ });
3778
+ if (format === "pdf") {
3779
+ const pdf = await generateInvoicePdf({ from, to, projectId, lines, totalMinutes, totalAmount });
3780
+ return new Response(new Uint8Array(pdf), {
3781
+ status: 200,
3782
+ headers: {
3783
+ "Content-Type": "application/pdf",
3784
+ "Content-Disposition": `inline; filename="invoice-${from}_${to}.pdf"`
3785
+ }
3786
+ });
3787
+ }
3788
+ const fmtAmount2 = (n) => `${n.toFixed(2)} \u20AC`;
3789
+ const fmtTime2 = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3790
+ const rows = lines.map((l) => `<tr>
3791
+ <td>${escapeHtml(l.ticketNumber)}</td>
3792
+ <td>${escapeHtml(l.subject)}</td>
3793
+ <td>${escapeHtml(l.client)}</td>
3794
+ <td style="text-align:right;">${fmtTime2(l.minutes)}</td>
3795
+ <td style="text-align:right;">${fmtAmount2(l.amount)}</td>
3796
+ </tr>`).join("");
3797
+ const html = `<!doctype html>
3798
+ <html lang="fr"><head><meta charset="utf-8"><title>Facture ${escapeHtml(from)} \u2192 ${escapeHtml(to)}</title>
3799
+ <style>
3800
+ body { font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 820px; margin: 32px auto; padding: 0 24px; }
3801
+ h1 { font-size: 22px; margin: 0 0 4px; }
3802
+ .period { color: #64748b; margin-bottom: 24px; }
3803
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
3804
+ th, td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; text-align: left; }
3805
+ th { background: #f8fafc; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #475569; }
3806
+ tfoot td { font-weight: 700; border-top: 2px solid #0f172a; border-bottom: none; }
3807
+ @media print { body { margin: 0; } }
3808
+ </style></head>
3809
+ <body>
3810
+ <h1>Facture / Pr\xE9-facture</h1>
3811
+ <div class="period">P\xE9riode : ${escapeHtml(from)} \u2192 ${escapeHtml(to)}${projectId ? ` \xB7 Projet #${escapeHtml(projectId)}` : ""}</div>
3812
+ <table>
3813
+ <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>
3814
+ <tbody>${rows || '<tr><td colspan="5" style="color:#94a3b8;">Aucun ticket facturable sur la p\xE9riode.</td></tr>'}</tbody>
3815
+ <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>
3816
+ </table>
3817
+ </body></html>`;
3818
+ return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
3819
+ } catch (error) {
3820
+ const authResponse = handleAuthError(error);
3821
+ if (authResponse) return authResponse;
3822
+ console.error("[invoice] Error:", error);
3823
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
3824
+ }
3825
+ }
3826
+ };
3827
+ }
3828
+
3829
+ // src/utils/generateTicketSynthesis.ts
3830
+ function getClient4(aiSettings) {
3831
+ const Anthropic = __require("@anthropic-ai/sdk").default;
3832
+ if (aiSettings.provider === "ollama") {
3833
+ const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
3834
+ return new Anthropic({ apiKey: "ollama", baseURL });
3835
+ }
3836
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
3837
+ }
3838
+ function getModel3(aiSettings) {
3839
+ return aiSettings.model || "claude-haiku-4-5-20251001";
3840
+ }
3841
+ async function generateTicketSynthesis(args) {
3842
+ const { payload, slugs, ticketId } = args;
3843
+ const settings = await readSupportSettings(payload);
3844
+ if (settings.ai.enableSynthesis === false) {
3845
+ return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "skipped", reason: "synthesis disabled" };
3846
+ }
3847
+ const ticket = await dbFindByID(payload, slugs.tickets, {
3848
+ id: ticketId,
3849
+ depth: 1,
3850
+ overrideAccess: true
3851
+ });
3852
+ if (!ticket) {
3853
+ return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: "ticket not found" };
3854
+ }
3855
+ await dbUpdate(payload, slugs.tickets, {
3856
+ id: ticketId,
3857
+ data: { aiSummaryStatus: "pending" },
3450
3858
  overrideAccess: true
3451
3859
  }).catch(() => {
3452
3860
  });
3453
- const messagesResult = await payload.find({
3454
- collection: slugs.ticketMessages,
3861
+ const messagesResult = await dbFind(payload, slugs.ticketMessages, {
3455
3862
  where: { ticket: { equals: ticketId } },
3456
3863
  sort: "createdAt",
3457
3864
  limit: 500,
@@ -3461,8 +3868,7 @@ async function generateTicketSynthesis(args) {
3461
3868
  const messages = messagesResult.docs;
3462
3869
  if (messages.length === 0) {
3463
3870
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3464
- await payload.update({
3465
- collection: slugs.tickets,
3871
+ await dbUpdate(payload, slugs.tickets, {
3466
3872
  id: ticketId,
3467
3873
  data: {
3468
3874
  aiSummary: "(Aucun message dans ce ticket)",
@@ -3507,7 +3913,7 @@ Regles strictes :
3507
3913
  - Si le ticket n'a pas abouti, decris quand meme le travail d'analyse realise
3508
3914
 
3509
3915
  Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3510
- const anthropic = getClient3(settings.ai);
3916
+ const anthropic = getClient4(settings.ai);
3511
3917
  const model = getModel3(settings.ai);
3512
3918
  try {
3513
3919
  const res = await anthropic.messages.create({
@@ -3517,8 +3923,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3517
3923
  });
3518
3924
  const summary = res.content[0]?.type === "text" ? res.content[0].text.trim() : "";
3519
3925
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3520
- await payload.update({
3521
- collection: slugs.tickets,
3926
+ await dbUpdate(payload, slugs.tickets, {
3522
3927
  id: ticketId,
3523
3928
  data: {
3524
3929
  aiSummary: summary,
@@ -3530,8 +3935,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3530
3935
  return { summary, generatedAt, status: "done" };
3531
3936
  } catch (err) {
3532
3937
  const message = err instanceof Error ? err.message : String(err);
3533
- await payload.update({
3534
- collection: slugs.tickets,
3938
+ await dbUpdate(payload, slugs.tickets, {
3535
3939
  id: ticketId,
3536
3940
  data: { aiSummaryStatus: "error" },
3537
3941
  overrideAccess: true
@@ -3561,8 +3965,7 @@ function createTicketSynthesisEndpoint(slugs) {
3561
3965
  return Response.json({ error: "ticketId must be a number" }, { status: 400 });
3562
3966
  }
3563
3967
  if (!force) {
3564
- const existing = await payload.findByID({
3565
- collection: slugs.tickets,
3968
+ const existing = await dbFindByID(payload, slugs.tickets, {
3566
3969
  id: ticketId,
3567
3970
  depth: 0,
3568
3971
  overrideAccess: true
@@ -3606,8 +4009,7 @@ function createEmailStatsEndpoint(slugs) {
3606
4009
  let page = 1;
3607
4010
  let hasMore = true;
3608
4011
  while (hasMore && page <= MAX_PAGES2) {
3609
- const result = await payload.find({
3610
- collection: slugs.emailLogs,
4012
+ const result = await dbFind(payload, slugs.emailLogs, {
3611
4013
  where: { createdAt: { greater_than: cutoff } },
3612
4014
  sort: "-createdAt",
3613
4015
  limit: 500,
@@ -3681,12 +4083,20 @@ function createSatisfactionEndpoint(slugs) {
3681
4083
  const payload = req.payload;
3682
4084
  requireClient(req, slugs);
3683
4085
  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
- );
4086
+ const { ticketId, rating, nps, comment } = body;
4087
+ if (!ticketId) {
4088
+ return Response.json({ error: "ticketId est requis." }, { status: 400 });
4089
+ }
4090
+ const hasRating = rating !== void 0 && rating !== null;
4091
+ const hasNps = nps !== void 0 && nps !== null;
4092
+ if (!hasRating && !hasNps) {
4093
+ return Response.json({ error: "Un rating (1-5) et/ou un nps (0-10) est requis." }, { status: 400 });
4094
+ }
4095
+ if (hasRating && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
4096
+ return Response.json({ error: "rating doit \xEAtre un entier 1-5." }, { status: 400 });
4097
+ }
4098
+ if (hasNps && (!Number.isInteger(nps) || nps < 0 || nps > 10)) {
4099
+ return Response.json({ error: "nps doit \xEAtre un entier 0-10." }, { status: 400 });
3690
4100
  }
3691
4101
  if (comment && typeof comment === "string" && comment.length > 5e3) {
3692
4102
  return Response.json(
@@ -3694,8 +4104,7 @@ function createSatisfactionEndpoint(slugs) {
3694
4104
  { status: 400 }
3695
4105
  );
3696
4106
  }
3697
- const ticket = await payload.findByID({
3698
- collection: slugs.tickets,
4107
+ const ticket = await dbFindByID(payload, slugs.tickets, {
3699
4108
  id: ticketId,
3700
4109
  depth: 0,
3701
4110
  overrideAccess: false,
@@ -3710,8 +4119,7 @@ function createSatisfactionEndpoint(slugs) {
3710
4119
  { status: 400 }
3711
4120
  );
3712
4121
  }
3713
- const existing = await payload.find({
3714
- collection: slugs.satisfactionSurveys,
4122
+ const existing = await dbFind(payload, slugs.satisfactionSurveys, {
3715
4123
  where: { ticket: { equals: ticketId } },
3716
4124
  limit: 1,
3717
4125
  depth: 0,
@@ -3723,13 +4131,13 @@ function createSatisfactionEndpoint(slugs) {
3723
4131
  { status: 409 }
3724
4132
  );
3725
4133
  }
3726
- const survey = await payload.create({
3727
- collection: slugs.satisfactionSurveys,
4134
+ const survey = await dbCreate(payload, slugs.satisfactionSurveys, {
3728
4135
  data: {
3729
4136
  source: "ticket",
3730
4137
  ticket: ticketId,
3731
4138
  client: req.user.id,
3732
- rating: Math.round(rating),
4139
+ ...hasRating ? { rating: Math.round(rating) } : {},
4140
+ ...hasNps ? { nps: Math.round(nps) } : {},
3733
4141
  ...comment ? { comment: comment.trim() } : {}
3734
4142
  },
3735
4143
  overrideAccess: true
@@ -3744,124 +4152,6 @@ function createSatisfactionEndpoint(slugs) {
3744
4152
  }
3745
4153
  };
3746
4154
  }
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
4155
 
3866
4156
  // src/endpoints/export-csv.ts
3867
4157
  function createExportCsvEndpoint(slugs) {
@@ -3877,8 +4167,7 @@ function createExportCsvEndpoint(slugs) {
3877
4167
  let hasMore = true;
3878
4168
  const allDocs = [];
3879
4169
  while (hasMore) {
3880
- const batch = await payload.find({
3881
- collection: slugs.tickets,
4170
+ const batch = await dbFind(payload, slugs.tickets, {
3882
4171
  limit: PAGE_SIZE2,
3883
4172
  page,
3884
4173
  depth: 1,
@@ -3954,21 +4243,18 @@ function createExportDataEndpoint(slugs) {
3954
4243
  const payload = req.payload;
3955
4244
  requireClient(req, slugs);
3956
4245
  const [clientData, ticketsResult, messagesResult, surveysResult] = await Promise.all([
3957
- payload.findByID({
3958
- collection: slugs.supportClients,
4246
+ dbFindByID(payload, slugs.supportClients, {
3959
4247
  id: req.user.id,
3960
4248
  depth: 0,
3961
4249
  overrideAccess: true
3962
4250
  }),
3963
- payload.find({
3964
- collection: slugs.tickets,
4251
+ dbFind(payload, slugs.tickets, {
3965
4252
  where: { client: { equals: req.user.id } },
3966
4253
  limit: 1e3,
3967
4254
  depth: 0,
3968
4255
  overrideAccess: true
3969
4256
  }),
3970
- payload.find({
3971
- collection: slugs.ticketMessages,
4257
+ dbFind(payload, slugs.ticketMessages, {
3972
4258
  where: {
3973
4259
  "ticket.client": { equals: req.user.id },
3974
4260
  authorType: { equals: "client" }
@@ -3977,8 +4263,7 @@ function createExportDataEndpoint(slugs) {
3977
4263
  depth: 0,
3978
4264
  overrideAccess: true
3979
4265
  }),
3980
- payload.find({
3981
- collection: slugs.satisfactionSurveys,
4266
+ dbFind(payload, slugs.satisfactionSurveys, {
3982
4267
  where: { client: { equals: req.user.id } },
3983
4268
  limit: 500,
3984
4269
  depth: 0,
@@ -4052,8 +4337,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4052
4337
  if (!["create_ticket", "add_to_ticket", "ignore"].includes(action)) {
4053
4338
  return Response.json({ error: "Invalid action" }, { status: 400 });
4054
4339
  }
4055
- const pendingEmail = await payload.findByID({
4056
- collection: slugs.pendingEmails,
4340
+ const pendingEmail = await dbFindByID(payload, slugs.pendingEmails, {
4057
4341
  id: Number(id),
4058
4342
  depth: 1,
4059
4343
  overrideAccess: true
@@ -4067,14 +4351,12 @@ function createPendingEmailsProcessEndpoint(slugs) {
4067
4351
  let clientId = overrideClientId || (typeof pendingEmail.client === "object" ? pendingEmail.client?.id : pendingEmail.client);
4068
4352
  let clientDoc = typeof pendingEmail.client === "object" ? pendingEmail.client : null;
4069
4353
  if (overrideClientId && overrideClientId !== clientDoc?.id) {
4070
- clientDoc = await payload.findByID({
4071
- collection: slugs.supportClients,
4354
+ clientDoc = await dbFindByID(payload, slugs.supportClients, {
4072
4355
  id: overrideClientId,
4073
4356
  depth: 0,
4074
4357
  overrideAccess: true
4075
4358
  });
4076
- await payload.update({
4077
- collection: slugs.pendingEmails,
4359
+ await dbUpdate(payload, slugs.pendingEmails, {
4078
4360
  id: Number(id),
4079
4361
  data: { client: overrideClientId },
4080
4362
  overrideAccess: true
@@ -4091,8 +4373,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4091
4373
  file: typeof a.file === "object" ? a.file.id : a.file
4092
4374
  })) || [];
4093
4375
  if (action === "ignore") {
4094
- await payload.update({
4095
- collection: slugs.pendingEmails,
4376
+ await dbUpdate(payload, slugs.pendingEmails, {
4096
4377
  id: Number(id),
4097
4378
  data: {
4098
4379
  status: "ignored",
@@ -4104,8 +4385,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4104
4385
  return Response.json({ action: "ignored", pendingEmailId: Number(id) });
4105
4386
  }
4106
4387
  if (action === "create_ticket") {
4107
- const newTicket = await payload.create({
4108
- collection: slugs.tickets,
4388
+ const newTicket = await dbCreate(payload, slugs.tickets, {
4109
4389
  data: {
4110
4390
  subject: pendingEmail.subject,
4111
4391
  client: clientId,
@@ -4116,8 +4396,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4116
4396
  },
4117
4397
  overrideAccess: true
4118
4398
  });
4119
- await payload.create({
4120
- collection: slugs.ticketMessages,
4399
+ await dbCreate(payload, slugs.ticketMessages, {
4121
4400
  data: {
4122
4401
  ticket: newTicket.id,
4123
4402
  body: pendingEmail.body,
@@ -4144,8 +4423,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4144
4423
  } catch (err) {
4145
4424
  console.error("[pending-email-process] Failed to send notification:", err);
4146
4425
  }
4147
- await payload.update({
4148
- collection: slugs.pendingEmails,
4426
+ await dbUpdate(payload, slugs.pendingEmails, {
4149
4427
  id: Number(id),
4150
4428
  data: {
4151
4429
  status: "processed",
@@ -4165,8 +4443,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4165
4443
  if (!ticketId) {
4166
4444
  return Response.json({ error: "ticketId is required for add_to_ticket" }, { status: 400 });
4167
4445
  }
4168
- const targetTicket = await payload.findByID({
4169
- collection: slugs.tickets,
4446
+ const targetTicket = await dbFindByID(payload, slugs.tickets, {
4170
4447
  id: ticketId,
4171
4448
  depth: 0,
4172
4449
  overrideAccess: true
@@ -4174,8 +4451,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4174
4451
  if (!targetTicket) {
4175
4452
  return Response.json({ error: "Target ticket not found" }, { status: 404 });
4176
4453
  }
4177
- await payload.create({
4178
- collection: slugs.ticketMessages,
4454
+ await dbCreate(payload, slugs.ticketMessages, {
4179
4455
  data: {
4180
4456
  ticket: ticketId,
4181
4457
  body: pendingEmail.body,
@@ -4187,15 +4463,13 @@ function createPendingEmailsProcessEndpoint(slugs) {
4187
4463
  overrideAccess: true
4188
4464
  });
4189
4465
  if (targetTicket.status === "resolved") {
4190
- await payload.update({
4191
- collection: slugs.tickets,
4466
+ await dbUpdate(payload, slugs.tickets, {
4192
4467
  id: ticketId,
4193
4468
  data: { status: "open" },
4194
4469
  overrideAccess: true
4195
4470
  });
4196
4471
  }
4197
- await payload.update({
4198
- collection: slugs.pendingEmails,
4472
+ await dbUpdate(payload, slugs.pendingEmails, {
4199
4473
  id: Number(id),
4200
4474
  data: {
4201
4475
  status: "processed",
@@ -4248,8 +4522,7 @@ function createResendNotificationEndpoint(slugs) {
4248
4522
  if (!messageId) {
4249
4523
  return Response.json({ error: "messageId requis" }, { status: 400 });
4250
4524
  }
4251
- const message = await payload.findByID({
4252
- collection: slugs.ticketMessages,
4525
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
4253
4526
  id: messageId,
4254
4527
  depth: 0,
4255
4528
  overrideAccess: true
@@ -4258,8 +4531,7 @@ function createResendNotificationEndpoint(slugs) {
4258
4531
  return Response.json({ error: "Message introuvable" }, { status: 404 });
4259
4532
  }
4260
4533
  const ticketId = typeof message.ticket === "object" ? message.ticket.id : message.ticket;
4261
- const ticket = await payload.findByID({
4262
- collection: slugs.tickets,
4534
+ const ticket = await dbFindByID(payload, slugs.tickets, {
4263
4535
  id: ticketId,
4264
4536
  depth: 1,
4265
4537
  overrideAccess: true
@@ -4385,8 +4657,7 @@ function createSeedKbEndpoint(slugs) {
4385
4657
  let created = 0;
4386
4658
  let skipped = 0;
4387
4659
  for (const article of KB_ARTICLES) {
4388
- const existing = await payload.find({
4389
- collection: slugs.knowledgeBase,
4660
+ const existing = await dbFind(payload, slugs.knowledgeBase, {
4390
4661
  where: { slug: { equals: article.slug } },
4391
4662
  limit: 1,
4392
4663
  depth: 0,
@@ -4413,8 +4684,7 @@ function createSeedKbEndpoint(slugs) {
4413
4684
  version: 1
4414
4685
  }
4415
4686
  };
4416
- await payload.create({
4417
- collection: slugs.knowledgeBase,
4687
+ await dbCreate(payload, slugs.knowledgeBase, {
4418
4688
  data: {
4419
4689
  title: article.title,
4420
4690
  slug: article.slug,
@@ -4469,8 +4739,7 @@ function createLoginEndpoint(slugs) {
4469
4739
  collection: slugs.supportClients,
4470
4740
  data: { email, password }
4471
4741
  });
4472
- payload.create({
4473
- collection: slugs.authLogs,
4742
+ dbCreate(payload, slugs.authLogs, {
4474
4743
  data: { email, success: true, action: "login", ipAddress: ip, userAgent },
4475
4744
  overrideAccess: true
4476
4745
  }).catch(() => {
@@ -4494,12 +4763,14 @@ function createLoginEndpoint(slugs) {
4494
4763
  );
4495
4764
  } catch (err) {
4496
4765
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
4766
+ if (errorMessage.includes("2FA_REQUIRED")) {
4767
+ return Response.json({ requires2FA: true }, { status: 200 });
4768
+ }
4497
4769
  let errorReason = "Identifiants incorrects";
4498
4770
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
4499
4771
  errorReason = "Compte verrouill\xE9 (trop de tentatives)";
4500
4772
  }
4501
- payload.create({
4502
- collection: slugs.authLogs,
4773
+ dbCreate(payload, slugs.authLogs, {
4503
4774
  data: { email, success: false, action: "login", errorReason, ipAddress: ip, userAgent },
4504
4775
  overrideAccess: true
4505
4776
  }).catch(() => {
@@ -4520,7 +4791,10 @@ function generateSecureCode() {
4520
4791
  return String(num);
4521
4792
  }
4522
4793
  function hashCode(code) {
4523
- const secret = process.env.PAYLOAD_SECRET || "payload-support-2fa";
4794
+ const secret = process.env.PAYLOAD_SECRET;
4795
+ if (!secret) {
4796
+ throw new Error("[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to operate 2FA with an insecure fallback secret");
4797
+ }
4524
4798
  return crypto3.createHmac("sha256", secret).update(code).digest("hex");
4525
4799
  }
4526
4800
  function createAuth2faEndpoint(slugs) {
@@ -4545,8 +4819,7 @@ function createAuth2faEndpoint(slugs) {
4545
4819
  if (sendLimiter.check(email)) {
4546
4820
  return Response.json(genericSendResponse);
4547
4821
  }
4548
- const clients = await payload.find({
4549
- collection: slugs.supportClients,
4822
+ const clients = await dbFind(payload, slugs.supportClients, {
4550
4823
  where: { email: { equals: email } },
4551
4824
  limit: 1,
4552
4825
  depth: 0,
@@ -4559,8 +4832,7 @@ function createAuth2faEndpoint(slugs) {
4559
4832
  const plainCode = generateSecureCode();
4560
4833
  const twoFactorCode = hashCode(plainCode);
4561
4834
  const twoFactorExpiry = new Date(Date.now() + 10 * 60 * 1e3).toISOString();
4562
- await payload.update({
4563
- collection: slugs.supportClients,
4835
+ await dbUpdate(payload, slugs.supportClients, {
4564
4836
  id: client.id,
4565
4837
  data: { twoFactorCode, twoFactorExpiry },
4566
4838
  overrideAccess: true
@@ -4591,8 +4863,7 @@ function createAuth2faEndpoint(slugs) {
4591
4863
  { status: 429 }
4592
4864
  );
4593
4865
  }
4594
- const clients = await payload.find({
4595
- collection: slugs.supportClients,
4866
+ const clients = await dbFind(payload, slugs.supportClients, {
4596
4867
  where: { email: { equals: email } },
4597
4868
  limit: 1,
4598
4869
  depth: 0,
@@ -4608,8 +4879,7 @@ function createAuth2faEndpoint(slugs) {
4608
4879
  return Response.json({ error: "Aucun code en attente" }, { status: 400 });
4609
4880
  }
4610
4881
  if (/* @__PURE__ */ new Date() > new Date(storedExpiry)) {
4611
- await payload.update({
4612
- collection: slugs.supportClients,
4882
+ await dbUpdate(payload, slugs.supportClients, {
4613
4883
  id: client.id,
4614
4884
  data: { twoFactorCode: "", twoFactorExpiry: "" },
4615
4885
  overrideAccess: true
@@ -4623,10 +4893,9 @@ function createAuth2faEndpoint(slugs) {
4623
4893
  if (submittedBuffer.length !== storedBuffer.length || !crypto3__default.default.timingSafeEqual(submittedBuffer, storedBuffer)) {
4624
4894
  return Response.json({ error: "Code incorrect" }, { status: 400 });
4625
4895
  }
4626
- await payload.update({
4627
- collection: slugs.supportClients,
4896
+ await dbUpdate(payload, slugs.supportClients, {
4628
4897
  id: client.id,
4629
- data: { twoFactorCode: "", twoFactorExpiry: "" },
4898
+ data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
4630
4899
  overrideAccess: true
4631
4900
  });
4632
4901
  verifyLimiter.reset(email);
@@ -4700,15 +4969,33 @@ function createOAuthGoogleEndpoint(slugs, options) {
4700
4969
  if (!profile.email) {
4701
4970
  return Response.json({ error: "no_email" }, { status: 400 });
4702
4971
  }
4703
- const payload = req.payload;
4704
- const existing = await payload.find({
4705
- collection: slugs.supportClients,
4706
- where: { email: { equals: profile.email } },
4972
+ if (profile.verified_email !== true) {
4973
+ return Response.json({ error: "email_not_verified" }, { status: 403 });
4974
+ }
4975
+ const payload$1 = req.payload;
4976
+ const byGoogle = await dbFind(payload$1, slugs.supportClients, {
4977
+ where: { googleId: { equals: profile.id } },
4707
4978
  limit: 1,
4708
4979
  depth: 0,
4709
4980
  overrideAccess: true
4710
4981
  });
4711
- let clientDoc = existing.docs[0];
4982
+ let clientDoc = byGoogle.docs[0];
4983
+ if (!clientDoc) {
4984
+ const byEmail = await dbFind(payload$1, slugs.supportClients, {
4985
+ where: { email: { equals: profile.email } },
4986
+ limit: 1,
4987
+ depth: 0,
4988
+ overrideAccess: true
4989
+ });
4990
+ clientDoc = byEmail.docs[0];
4991
+ if (clientDoc && profile.id) {
4992
+ await dbUpdate(payload$1, slugs.supportClients, {
4993
+ id: clientDoc.id,
4994
+ data: { googleId: profile.id },
4995
+ overrideAccess: true
4996
+ });
4997
+ }
4998
+ }
4712
4999
  if (!clientDoc) {
4713
5000
  const allowedDomains = options?.allowedEmailDomains;
4714
5001
  if (allowedDomains && allowedDomains.length > 0) {
@@ -4726,43 +5013,64 @@ function createOAuthGoogleEndpoint(slugs, options) {
4726
5013
  const autoPassword = crypto3__default.default.randomBytes(48).toString("base64url");
4727
5014
  const fullName = profile.name || profile.email.split("@")[0];
4728
5015
  const nameParts = fullName.split(" ");
4729
- clientDoc = await payload.create({
4730
- collection: slugs.supportClients,
5016
+ clientDoc = await dbCreate(payload$1, slugs.supportClients, {
4731
5017
  data: {
4732
5018
  email: profile.email,
4733
5019
  firstName: nameParts[0] || fullName,
4734
5020
  lastName: nameParts.slice(1).join(" ") || "-",
4735
5021
  company: fullName,
5022
+ googleId: profile.id,
4736
5023
  password: autoPassword
4737
5024
  },
4738
5025
  overrideAccess: true
4739
5026
  });
4740
5027
  }
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 });
5028
+ const secret = process.env.PAYLOAD_SECRET;
5029
+ if (!secret) {
5030
+ return Response.json({ error: "server_misconfigured" }, { status: 500 });
4761
5031
  }
4762
- return Response.json({
4763
- token: loginResult.token,
4764
- user: loginResult.user,
4765
- exp: loginResult.exp
5032
+ const collectionConfig = payload$1.collections[slugs.supportClients].config;
5033
+ const tokenExpiration = collectionConfig.auth?.tokenExpiration ?? 7200;
5034
+ let sid;
5035
+ if (collectionConfig.auth?.useSessions) {
5036
+ sid = crypto3__default.default.randomUUID();
5037
+ const now = /* @__PURE__ */ new Date();
5038
+ const expiresAt = new Date(now.getTime() + tokenExpiration * 1e3);
5039
+ const fresh = await dbFindByID(payload$1, slugs.supportClients, {
5040
+ id: clientDoc.id,
5041
+ depth: 0,
5042
+ overrideAccess: true,
5043
+ showHiddenFields: true
5044
+ });
5045
+ const kept = Array.isArray(fresh?.sessions) ? fresh.sessions.filter((s) => new Date(s.expiresAt) > now) : [];
5046
+ await payload$1.db.updateOne({
5047
+ collection: slugs.supportClients,
5048
+ id: clientDoc.id,
5049
+ data: {
5050
+ sessions: [
5051
+ ...kept,
5052
+ { id: sid, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString() }
5053
+ ]
5054
+ },
5055
+ returning: false
5056
+ });
5057
+ }
5058
+ const fieldsToSign = payload.getFieldsToSign({
5059
+ collectionConfig,
5060
+ email: clientDoc.email,
5061
+ sid,
5062
+ user: { ...clientDoc, collection: slugs.supportClients }
5063
+ });
5064
+ const { token, exp } = await payload.jwtSign({ fieldsToSign, secret, tokenExpiration });
5065
+ const headers = new Headers({ "Content-Type": "application/json" });
5066
+ const cookieSecure = process.env.NODE_ENV === "production";
5067
+ headers.append(
5068
+ "Set-Cookie",
5069
+ `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5070
+ );
5071
+ return new Response(JSON.stringify({ token, user: clientDoc, exp }), {
5072
+ status: 200,
5073
+ headers
4766
5074
  });
4767
5075
  }
4768
5076
  return Response.json({ error: "Action invalide" }, { status: 400 });
@@ -4803,8 +5111,7 @@ function createDeleteAccountEndpoint(slugs) {
4803
5111
  );
4804
5112
  }
4805
5113
  const clientId = req.user.id;
4806
- const tickets = await payload.find({
4807
- collection: slugs.tickets,
5114
+ const tickets = await dbFind(payload, slugs.tickets, {
4808
5115
  where: { client: { equals: clientId } },
4809
5116
  limit: 1e4,
4810
5117
  depth: 0,
@@ -4813,39 +5120,32 @@ function createDeleteAccountEndpoint(slugs) {
4813
5120
  });
4814
5121
  const ticketIds = tickets.docs.map((t) => t.id);
4815
5122
  if (ticketIds.length > 0) {
4816
- await payload.delete({
4817
- collection: slugs.ticketMessages,
5123
+ await dbDelete(payload, slugs.ticketMessages, {
4818
5124
  where: { ticket: { in: ticketIds } },
4819
5125
  overrideAccess: true
4820
5126
  });
4821
- await payload.delete({
4822
- collection: slugs.ticketActivityLog,
5127
+ await dbDelete(payload, slugs.ticketActivityLog, {
4823
5128
  where: { ticket: { in: ticketIds } },
4824
5129
  overrideAccess: true
4825
5130
  });
4826
- await payload.delete({
4827
- collection: slugs.timeEntries,
5131
+ await dbDelete(payload, slugs.timeEntries, {
4828
5132
  where: { ticket: { in: ticketIds } },
4829
5133
  overrideAccess: true
4830
5134
  });
4831
- await payload.delete({
4832
- collection: slugs.tickets,
5135
+ await dbDelete(payload, slugs.tickets, {
4833
5136
  where: { client: { equals: clientId } },
4834
5137
  overrideAccess: true
4835
5138
  });
4836
5139
  }
4837
- await payload.delete({
4838
- collection: slugs.satisfactionSurveys,
5140
+ await dbDelete(payload, slugs.satisfactionSurveys, {
4839
5141
  where: { client: { equals: clientId } },
4840
5142
  overrideAccess: true
4841
5143
  });
4842
- await payload.delete({
4843
- collection: slugs.chatMessages,
5144
+ await dbDelete(payload, slugs.chatMessages, {
4844
5145
  where: { client: { equals: clientId } },
4845
5146
  overrideAccess: true
4846
5147
  });
4847
- await payload.delete({
4848
- collection: slugs.supportClients,
5148
+ await dbDelete(payload, slugs.supportClients, {
4849
5149
  id: clientId,
4850
5150
  overrideAccess: true
4851
5151
  });
@@ -4886,8 +5186,8 @@ function createMergeClientsEndpoint(slugs) {
4886
5186
  return Response.json({ error: "sourceId and targetId are required and must be different" }, { status: 400 });
4887
5187
  }
4888
5188
  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 })
5189
+ dbFindByID(payload, slugs.supportClients, { id: sourceId, depth: 0, overrideAccess: true }),
5190
+ dbFindByID(payload, slugs.supportClients, { id: targetId, depth: 0, overrideAccess: true })
4891
5191
  ]);
4892
5192
  if (!source || !target) {
4893
5193
  return Response.json({ error: "Source or target client not found" }, { status: 404 });
@@ -4899,63 +5199,57 @@ function createMergeClientsEndpoint(slugs) {
4899
5199
  pendingEmails: 0,
4900
5200
  satisfactionSurveys: 0
4901
5201
  };
4902
- const tickets = await payload.find({
4903
- collection: slugs.tickets,
5202
+ const tickets = await dbFind(payload, slugs.tickets, {
4904
5203
  where: { client: { equals: sourceId } },
4905
5204
  limit: 500,
4906
5205
  depth: 0,
4907
5206
  overrideAccess: true
4908
5207
  });
4909
5208
  for (const ticket of tickets.docs) {
4910
- await payload.update({ collection: slugs.tickets, id: ticket.id, data: { client: targetId }, overrideAccess: true });
5209
+ await dbUpdate(payload, slugs.tickets, { id: ticket.id, data: { client: targetId }, overrideAccess: true });
4911
5210
  results.tickets++;
4912
5211
  }
4913
- const messages = await payload.find({
4914
- collection: slugs.ticketMessages,
5212
+ const messages = await dbFind(payload, slugs.ticketMessages, {
4915
5213
  where: { authorClient: { equals: sourceId } },
4916
5214
  limit: 1e3,
4917
5215
  depth: 0,
4918
5216
  overrideAccess: true
4919
5217
  });
4920
5218
  for (const msg of messages.docs) {
4921
- await payload.update({ collection: slugs.ticketMessages, id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
5219
+ await dbUpdate(payload, slugs.ticketMessages, { id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
4922
5220
  results.ticketMessages++;
4923
5221
  }
4924
- const chats = await payload.find({
4925
- collection: slugs.chatMessages,
5222
+ const chats = await dbFind(payload, slugs.chatMessages, {
4926
5223
  where: { client: { equals: sourceId } },
4927
5224
  limit: 1e3,
4928
5225
  depth: 0,
4929
5226
  overrideAccess: true
4930
5227
  });
4931
5228
  for (const chat of chats.docs) {
4932
- await payload.update({ collection: slugs.chatMessages, id: chat.id, data: { client: targetId }, overrideAccess: true });
5229
+ await dbUpdate(payload, slugs.chatMessages, { id: chat.id, data: { client: targetId }, overrideAccess: true });
4933
5230
  results.chatMessages++;
4934
5231
  }
4935
- const pendingEmails = await payload.find({
4936
- collection: slugs.pendingEmails,
5232
+ const pendingEmails = await dbFind(payload, slugs.pendingEmails, {
4937
5233
  where: { client: { equals: sourceId } },
4938
5234
  limit: 500,
4939
5235
  depth: 0,
4940
5236
  overrideAccess: true
4941
5237
  });
4942
5238
  for (const pe of pendingEmails.docs) {
4943
- await payload.update({ collection: slugs.pendingEmails, id: pe.id, data: { client: targetId }, overrideAccess: true });
5239
+ await dbUpdate(payload, slugs.pendingEmails, { id: pe.id, data: { client: targetId }, overrideAccess: true });
4944
5240
  results.pendingEmails++;
4945
5241
  }
4946
- const surveys = await payload.find({
4947
- collection: slugs.satisfactionSurveys,
5242
+ const surveys = await dbFind(payload, slugs.satisfactionSurveys, {
4948
5243
  where: { client: { equals: sourceId } },
4949
5244
  limit: 500,
4950
5245
  depth: 0,
4951
5246
  overrideAccess: true
4952
5247
  });
4953
5248
  for (const survey of surveys.docs) {
4954
- await payload.update({ collection: slugs.satisfactionSurveys, id: survey.id, data: { client: targetId }, overrideAccess: true });
5249
+ await dbUpdate(payload, slugs.satisfactionSurveys, { id: survey.id, data: { client: targetId }, overrideAccess: true });
4955
5250
  results.satisfactionSurveys++;
4956
5251
  }
4957
- await payload.delete({
4958
- collection: slugs.supportClients,
5252
+ await dbDelete(payload, slugs.supportClients, {
4959
5253
  id: sourceId,
4960
5254
  overrideAccess: true
4961
5255
  });
@@ -5149,8 +5443,7 @@ function createImportConversationEndpoint(slugs) {
5149
5443
  if (blockedEmails.includes(conversation.client.email)) {
5150
5444
  return Response.json({ error: "Cannot create ticket from system email address" }, { status: 400 });
5151
5445
  }
5152
- const clientResult = await payload.find({
5153
- collection: slugs.supportClients,
5446
+ const clientResult = await dbFind(payload, slugs.supportClients, {
5154
5447
  where: { email: { equals: conversation.client.email } },
5155
5448
  limit: 1,
5156
5449
  depth: 0,
@@ -5161,8 +5454,7 @@ function createImportConversationEndpoint(slugs) {
5161
5454
  if (!client) {
5162
5455
  const nameParts = conversation.client.name.split(" ");
5163
5456
  const randomPassword = crypto3__default.default.randomBytes(48).toString("base64url");
5164
- client = await payload.create({
5165
- collection: slugs.supportClients,
5457
+ client = await dbCreate(payload, slugs.supportClients, {
5166
5458
  data: {
5167
5459
  email: conversation.client.email,
5168
5460
  password: randomPassword,
@@ -5174,15 +5466,13 @@ function createImportConversationEndpoint(slugs) {
5174
5466
  });
5175
5467
  isNewClient = true;
5176
5468
  }
5177
- const adminUsers = await payload.find({
5178
- collection: slugs.users,
5469
+ const adminUsers = await dbFind(payload, slugs.users, {
5179
5470
  limit: 1,
5180
5471
  depth: 0,
5181
5472
  overrideAccess: true
5182
5473
  });
5183
5474
  const adminUserId = adminUsers.docs[0]?.id;
5184
- const ticket = await payload.create({
5185
- collection: slugs.tickets,
5475
+ const ticket = await dbCreate(payload, slugs.tickets, {
5186
5476
  data: {
5187
5477
  subject: conversation.subject,
5188
5478
  client: client.id,
@@ -5195,8 +5485,7 @@ function createImportConversationEndpoint(slugs) {
5195
5485
  let importedCount = 0;
5196
5486
  for (const msg of conversation.messages) {
5197
5487
  const isAdmin = msg.from === "admin";
5198
- await payload.create({
5199
- collection: slugs.ticketMessages,
5488
+ await dbCreate(payload, slugs.ticketMessages, {
5200
5489
  data: {
5201
5490
  ticket: ticket.id,
5202
5491
  body: msg.content,
@@ -5232,8 +5521,7 @@ function createImportConversationEndpoint(slugs) {
5232
5521
  // src/utils/fireWebhooks.ts
5233
5522
  async function fireWebhooks(payload, slugs, event, data) {
5234
5523
  try {
5235
- const endpoints = await payload.find({
5236
- collection: slugs.webhookEndpoints,
5524
+ const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
5237
5525
  where: {
5238
5526
  active: { equals: true },
5239
5527
  events: { contains: event }
@@ -5257,8 +5545,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5257
5545
  signal: AbortSignal.timeout(1e4)
5258
5546
  });
5259
5547
  try {
5260
- await payload.update({
5261
- collection: slugs.webhookEndpoints,
5548
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5262
5549
  id: endpoint.id,
5263
5550
  data: {
5264
5551
  lastTriggeredAt: timestamp,
@@ -5271,8 +5558,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5271
5558
  } catch (error) {
5272
5559
  console.warn(`[support] Webhook delivery failed: ${endpoint.url}`, error);
5273
5560
  try {
5274
- await payload.update({
5275
- collection: slugs.webhookEndpoints,
5561
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5276
5562
  id: endpoint.id,
5277
5563
  data: {
5278
5564
  lastTriggeredAt: timestamp,
@@ -5307,8 +5593,7 @@ function createProcessScheduledEndpoint(slugs) {
5307
5593
  const settings = await readSupportSettings(payload);
5308
5594
  const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5309
5595
  const results = { processed: 0, errors: 0 };
5310
- const scheduled = await payload.find({
5311
- collection: slugs.ticketMessages,
5596
+ const scheduled = await dbFind(payload, slugs.ticketMessages, {
5312
5597
  where: {
5313
5598
  and: [
5314
5599
  { scheduledAt: { less_than_equal: now.toISOString() } },
@@ -5328,8 +5613,7 @@ function createProcessScheduledEndpoint(slugs) {
5328
5613
  try {
5329
5614
  const msg = message;
5330
5615
  const ticketId = typeof msg.ticket === "object" ? msg.ticket.id : msg.ticket;
5331
- const ticket = await payload.findByID({
5332
- collection: slugs.tickets,
5616
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5333
5617
  id: ticketId,
5334
5618
  depth: 1,
5335
5619
  overrideAccess: true
@@ -5352,8 +5636,7 @@ function createProcessScheduledEndpoint(slugs) {
5352
5636
  subject: `Re: [${ticketNumber}] ${subject}`,
5353
5637
  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
5638
  });
5355
- await payload.update({
5356
- collection: slugs.ticketMessages,
5639
+ await dbUpdate(payload, slugs.ticketMessages, {
5357
5640
  id: msg.id,
5358
5641
  data: {
5359
5642
  scheduledSent: true,
@@ -5363,16 +5646,14 @@ function createProcessScheduledEndpoint(slugs) {
5363
5646
  overrideAccess: true
5364
5647
  });
5365
5648
  } else {
5366
- await payload.update({
5367
- collection: slugs.ticketMessages,
5649
+ await dbUpdate(payload, slugs.ticketMessages, {
5368
5650
  id: msg.id,
5369
5651
  data: { scheduledSent: true },
5370
5652
  overrideAccess: true
5371
5653
  });
5372
5654
  }
5373
5655
  if (msg.authorType === "admin" && !msg.isInternal) {
5374
- await payload.update({
5375
- collection: slugs.tickets,
5656
+ await dbUpdate(payload, slugs.tickets, {
5376
5657
  id: ticketId,
5377
5658
  data: { status: "waiting_client" },
5378
5659
  overrideAccess: true
@@ -5404,6 +5685,296 @@ function createProcessScheduledEndpoint(slugs) {
5404
5685
  };
5405
5686
  }
5406
5687
 
5688
+ // src/endpoints/process-snooze.ts
5689
+ function createProcessSnoozeEndpoint(slugs) {
5690
+ return {
5691
+ path: "/support/process-snooze",
5692
+ method: "post",
5693
+ handler: async (req) => {
5694
+ const secret = req.headers.get("x-cron-secret");
5695
+ const expectedSecret = process.env.CRON_SECRET;
5696
+ if (!expectedSecret || secret !== expectedSecret) {
5697
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5698
+ }
5699
+ try {
5700
+ const payload = req.payload;
5701
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
5702
+ const results = { processed: 0, errors: 0 };
5703
+ const due = await dbFind(payload, slugs.tickets, {
5704
+ where: {
5705
+ and: [
5706
+ { snoozeUntil: { exists: true } },
5707
+ { snoozeUntil: { less_than_equal: nowIso } }
5708
+ ]
5709
+ },
5710
+ limit: 500,
5711
+ depth: 0,
5712
+ overrideAccess: true,
5713
+ select: { id: true, ticketNumber: true }
5714
+ });
5715
+ for (const ticket of due.docs) {
5716
+ try {
5717
+ await dbUpdate(payload, slugs.tickets, {
5718
+ id: ticket.id,
5719
+ data: { snoozeUntil: null },
5720
+ overrideAccess: true
5721
+ });
5722
+ try {
5723
+ await dbCreate(payload, slugs.ticketActivityLog, {
5724
+ data: {
5725
+ ticket: ticket.id,
5726
+ action: "snooze_expired",
5727
+ detail: "Snooze \xE9chu \u2014 ticket r\xE9activ\xE9",
5728
+ actorType: "system"
5729
+ },
5730
+ overrideAccess: true
5731
+ });
5732
+ } catch {
5733
+ }
5734
+ results.processed++;
5735
+ } catch (err) {
5736
+ console.error("[support] Failed to wake snoozed ticket:", err);
5737
+ results.errors++;
5738
+ }
5739
+ }
5740
+ return Response.json({ ok: true, ...results });
5741
+ } catch (err) {
5742
+ console.error("[process-snooze] Error:", err);
5743
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5744
+ }
5745
+ }
5746
+ };
5747
+ }
5748
+
5749
+ // src/endpoints/process-digests.ts
5750
+ function createProcessDigestsEndpoint(slugs) {
5751
+ return {
5752
+ path: "/support/process-digests",
5753
+ method: "post",
5754
+ handler: async (req) => {
5755
+ const secret = req.headers.get("x-cron-secret");
5756
+ if (!process.env.CRON_SECRET || secret !== process.env.CRON_SECRET) {
5757
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5758
+ }
5759
+ try {
5760
+ const payload = req.payload;
5761
+ let body = {};
5762
+ try {
5763
+ body = await req.json();
5764
+ } catch {
5765
+ }
5766
+ const frequency = body.frequency === "weekly" ? "weekly" : "daily";
5767
+ const settings = await readSupportSettings(payload);
5768
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5769
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
5770
+ const itemsByClient = /* @__PURE__ */ new Map();
5771
+ let page = 1;
5772
+ let hasMore = true;
5773
+ while (hasMore && page <= 50) {
5774
+ const batch = await dbFind(payload, slugs.notificationQueue, { limit: 200, page, depth: 0, overrideAccess: true, sort: "createdAt" });
5775
+ for (const it of batch.docs) {
5776
+ const raw = it;
5777
+ const clientId = typeof raw.client === "object" ? raw.client?.id : raw.client;
5778
+ if (clientId === void 0 || clientId === null) continue;
5779
+ const key = String(clientId);
5780
+ if (!itemsByClient.has(key)) itemsByClient.set(key, []);
5781
+ itemsByClient.get(key).push({ id: raw.id, title: raw.title, message: raw.message });
5782
+ }
5783
+ hasMore = batch.hasNextPage ?? false;
5784
+ page++;
5785
+ }
5786
+ const results = { clients: 0, items: 0, errors: 0 };
5787
+ for (const [clientId, items] of itemsByClient) {
5788
+ try {
5789
+ const client = await dbFindByID(payload, slugs.supportClients, { id: clientId, depth: 0, overrideAccess: true });
5790
+ if (!client || client.notificationFrequency !== frequency) continue;
5791
+ if (client.email) {
5792
+ 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("");
5793
+ const html = emailWrapper("Recapitulatif de vos tickets", [
5794
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
5795
+ emailParagraph(`Voici le recapitulatif de l'activite sur vos tickets (${items.length} notification${items.length > 1 ? "s" : ""}) :`),
5796
+ `<table cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table>`,
5797
+ ...baseUrl ? [emailParagraph(`<a href="${baseUrl}/support/dashboard">Acceder a votre espace support</a>`)] : []
5798
+ ].join(""), { kind: "system" });
5799
+ await payload.sendEmail({ to: client.email, ...replyTo ? { replyTo } : {}, subject: `Recapitulatif support (${items.length})`, html });
5800
+ }
5801
+ for (const i of items) {
5802
+ try {
5803
+ await dbDelete(payload, slugs.notificationQueue, { id: i.id, overrideAccess: true });
5804
+ } catch {
5805
+ }
5806
+ }
5807
+ results.clients++;
5808
+ results.items += items.length;
5809
+ } catch (err) {
5810
+ console.error("[process-digests] client error:", err);
5811
+ results.errors++;
5812
+ }
5813
+ }
5814
+ return Response.json({ ok: true, frequency, ...results });
5815
+ } catch (err) {
5816
+ console.error("[process-digests] Error:", err);
5817
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5818
+ }
5819
+ }
5820
+ };
5821
+ }
5822
+ var PROVIDERS = ["whatsapp", "messenger"];
5823
+ function createChannelsWebhookEndpoint(slugs) {
5824
+ return {
5825
+ path: "/support/channels/webhook",
5826
+ method: "post",
5827
+ handler: async (req) => {
5828
+ const secret = req.headers.get("x-channel-secret");
5829
+ if (!process.env.CHANNELS_WEBHOOK_SECRET || secret !== process.env.CHANNELS_WEBHOOK_SECRET) {
5830
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5831
+ }
5832
+ let body;
5833
+ try {
5834
+ body = await req.json();
5835
+ } catch {
5836
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5837
+ }
5838
+ const provider = body.provider;
5839
+ const from = (body.from || "").trim();
5840
+ const text = (body.text || "").trim();
5841
+ if (!PROVIDERS.includes(provider) || !from || !text) {
5842
+ return Response.json({ error: "provider, from et text requis." }, { status: 400 });
5843
+ }
5844
+ try {
5845
+ const payload = req.payload;
5846
+ const email = `${from}@${provider}.channel`;
5847
+ const existing = await dbFind(payload, slugs.supportClients, { where: { email: { equals: email } }, limit: 1, depth: 0, overrideAccess: true });
5848
+ let clientId;
5849
+ if (existing.docs.length > 0) {
5850
+ clientId = existing.docs[0].id;
5851
+ } else {
5852
+ const created = await dbCreate(payload, slugs.supportClients, {
5853
+ data: { email, firstName: body.name || provider, lastName: from.slice(0, 40), company: provider, password: crypto3.randomBytes(16).toString("hex") },
5854
+ overrideAccess: true
5855
+ });
5856
+ clientId = created.id;
5857
+ }
5858
+ const open = await dbFind(payload, slugs.tickets, {
5859
+ where: { and: [{ client: { equals: clientId } }, { source: { equals: provider } }, { status: { in: ["open", "waiting_client"] } }] },
5860
+ sort: "-createdAt",
5861
+ limit: 1,
5862
+ depth: 0,
5863
+ overrideAccess: true
5864
+ });
5865
+ let ticketId;
5866
+ let createdTicket = false;
5867
+ if (open.docs.length > 0) {
5868
+ ticketId = open.docs[0].id;
5869
+ } else {
5870
+ const t = await dbCreate(payload, slugs.tickets, {
5871
+ data: { subject: text.slice(0, 80), client: clientId, status: "open", priority: "normal", source: provider },
5872
+ overrideAccess: true
5873
+ });
5874
+ ticketId = t.id;
5875
+ createdTicket = true;
5876
+ }
5877
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
5878
+ data: { ticket: ticketId, body: text, authorType: "client", authorClient: clientId },
5879
+ overrideAccess: true
5880
+ });
5881
+ return Response.json({ ok: true, ticketId, createdTicket, messageId: msg.id });
5882
+ } catch (err) {
5883
+ console.error("[channels] Error:", err);
5884
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5885
+ }
5886
+ }
5887
+ };
5888
+ }
5889
+ var configured = false;
5890
+ function ensureVapid() {
5891
+ if (configured) return true;
5892
+ const publicKey = process.env.VAPID_PUBLIC_KEY;
5893
+ const privateKey = process.env.VAPID_PRIVATE_KEY;
5894
+ if (!publicKey || !privateKey) return false;
5895
+ webpush__default.default.setVapidDetails(process.env.VAPID_SUBJECT || "mailto:support@example.com", publicKey, privateKey);
5896
+ configured = true;
5897
+ return true;
5898
+ }
5899
+ function getVapidPublicKey() {
5900
+ return process.env.VAPID_PUBLIC_KEY || null;
5901
+ }
5902
+ async function sendPushToUser(payload, slugs, userId, notification) {
5903
+ if (!ensureVapid()) return { sent: 0 };
5904
+ let sent = 0;
5905
+ try {
5906
+ const subs = await dbFind(payload, slugs.pushSubscriptions, { where: { user: { equals: userId } }, limit: 100, depth: 0, overrideAccess: true });
5907
+ for (const s of subs.docs) {
5908
+ const row = s;
5909
+ if (!row.endpoint || !row.p256dh || !row.auth) continue;
5910
+ try {
5911
+ await webpush__default.default.sendNotification(
5912
+ { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
5913
+ JSON.stringify(notification)
5914
+ );
5915
+ sent++;
5916
+ } catch (err) {
5917
+ const code = err?.statusCode;
5918
+ if (code === 404 || code === 410) {
5919
+ try {
5920
+ await dbDelete(payload, slugs.pushSubscriptions, { id: row.id, overrideAccess: true });
5921
+ } catch {
5922
+ }
5923
+ }
5924
+ }
5925
+ }
5926
+ } catch (err) {
5927
+ console.error("[support] Push send failed:", err);
5928
+ }
5929
+ return { sent };
5930
+ }
5931
+
5932
+ // src/endpoints/push.ts
5933
+ function createVapidKeyEndpoint() {
5934
+ return {
5935
+ path: "/support/push/vapid-public-key",
5936
+ method: "get",
5937
+ handler: async () => Response.json({ publicKey: getVapidPublicKey() })
5938
+ };
5939
+ }
5940
+ function createPushSubscribeEndpoint(slugs) {
5941
+ return {
5942
+ path: "/support/push/subscribe",
5943
+ method: "post",
5944
+ handler: async (req) => {
5945
+ try {
5946
+ requireAdmin(req, slugs);
5947
+ let body;
5948
+ try {
5949
+ body = await req.json();
5950
+ } catch {
5951
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5952
+ }
5953
+ const sub = body?.subscription;
5954
+ const endpoint = sub?.endpoint;
5955
+ const p256dh = sub?.keys?.p256dh;
5956
+ const auth = sub?.keys?.auth;
5957
+ if (!endpoint || !p256dh || !auth) {
5958
+ return Response.json({ error: "subscription invalide (endpoint + keys requis)." }, { status: 400 });
5959
+ }
5960
+ const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: req.headers.get("user-agent") || "" };
5961
+ const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true });
5962
+ if (existing.docs.length > 0) {
5963
+ await dbUpdate(req.payload, slugs.pushSubscriptions, { id: existing.docs[0].id, data, overrideAccess: true });
5964
+ } else {
5965
+ await dbCreate(req.payload, slugs.pushSubscriptions, { data, overrideAccess: true });
5966
+ }
5967
+ return Response.json({ ok: true });
5968
+ } catch (error) {
5969
+ const authResponse = handleAuthError(error);
5970
+ if (authResponse) return authResponse;
5971
+ console.error("[push] Error:", error);
5972
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5973
+ }
5974
+ }
5975
+ };
5976
+ }
5977
+
5407
5978
  // src/endpoints/user-prefs.ts
5408
5979
  var PREF_KEY_PREFIX = "support-user-prefs";
5409
5980
  var DEFAULT_USER_PREFS2 = {
@@ -5419,8 +5990,7 @@ function createUserPrefsGetEndpoint(slugs) {
5419
5990
  const payload = req.payload;
5420
5991
  requireAdmin(req, slugs);
5421
5992
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5422
- const prefs = await payload.find({
5423
- collection: "payload-preferences",
5993
+ const prefs = await dbFind(payload, "payload-preferences", {
5424
5994
  where: { key: { equals: key } },
5425
5995
  limit: 1,
5426
5996
  depth: 0,
@@ -5454,8 +6024,7 @@ function createUserPrefsPostEndpoint(slugs) {
5454
6024
  requireAdmin(req, slugs);
5455
6025
  const body = await req.json();
5456
6026
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5457
- const existing = await payload.find({
5458
- collection: "payload-preferences",
6027
+ const existing = await dbFind(payload, "payload-preferences", {
5459
6028
  where: { key: { equals: key } },
5460
6029
  limit: 1,
5461
6030
  depth: 0,
@@ -5516,8 +6085,7 @@ function createEscalateEndpoint(slugs) {
5516
6085
  if (Number.isNaN(ticketId)) {
5517
6086
  return Response.json({ error: "invalid-id" }, { status: 400 });
5518
6087
  }
5519
- const ticket = await req.payload.update({
5520
- collection: slugs.tickets,
6088
+ const ticket = await dbUpdate(req.payload, slugs.tickets, {
5521
6089
  id: ticketId,
5522
6090
  data: { status: "escalated" },
5523
6091
  overrideAccess: true
@@ -5565,8 +6133,7 @@ function createTicketFeedbackEndpoint(slugs) {
5565
6133
  { status: 400 }
5566
6134
  );
5567
6135
  }
5568
- const ticket = await payload.findByID({
5569
- collection: slugs.tickets,
6136
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5570
6137
  id: ticketId,
5571
6138
  depth: 0,
5572
6139
  overrideAccess: true
@@ -5578,8 +6145,7 @@ function createTicketFeedbackEndpoint(slugs) {
5578
6145
  if (ticketClientId !== req.user.id) {
5579
6146
  return Response.json({ error: "forbidden" }, { status: 403 });
5580
6147
  }
5581
- const existing = await payload.find({
5582
- collection: "ticket-feedback",
6148
+ const existing = await dbFind(payload, "ticket-feedback", {
5583
6149
  where: { ticket: { equals: ticketId } },
5584
6150
  limit: 1,
5585
6151
  depth: 0,
@@ -5591,8 +6157,7 @@ function createTicketFeedbackEndpoint(slugs) {
5591
6157
  { status: 409 }
5592
6158
  );
5593
6159
  }
5594
- const feedback = await payload.create({
5595
- collection: "ticket-feedback",
6160
+ const feedback = await dbCreate(payload, "ticket-feedback", {
5596
6161
  data: {
5597
6162
  ticket: ticketId,
5598
6163
  client: req.user.id,
@@ -5616,6 +6181,7 @@ function createTicketFeedbackEndpoint(slugs) {
5616
6181
  // src/endpoints/transfer-ticket.ts
5617
6182
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5618
6183
  var MAX_TRANSFERS_PER_DAY = 5;
6184
+ var transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY);
5619
6185
  function createTransferTicketEndpoint(slugs) {
5620
6186
  return {
5621
6187
  path: "/support/tickets/:id/transfer",
@@ -5640,8 +6206,7 @@ function createTransferTicketEndpoint(slugs) {
5640
6206
  const includeAttachments = body.includeAttachments !== false;
5641
6207
  const customMessage = typeof body.message === "string" ? body.message.slice(0, 1e3) : "";
5642
6208
  const payload = req.payload;
5643
- const ticket = await payload.findByID({
5644
- collection: slugs.tickets,
6209
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5645
6210
  id: ticketId,
5646
6211
  depth: 1,
5647
6212
  overrideAccess: true
@@ -5652,10 +6217,15 @@ function createTransferTicketEndpoint(slugs) {
5652
6217
  if (!isAdmin && !isOwner) {
5653
6218
  return Response.json({ error: "Forbidden" }, { status: 403 });
5654
6219
  }
6220
+ if (transferLimiter.check(`${req.user.id}:${ticketId}`)) {
6221
+ return Response.json(
6222
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6223
+ { status: 429 }
6224
+ );
6225
+ }
5655
6226
  try {
5656
6227
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
5657
- const existing = await payload.count({
5658
- collection: slugs.emailLogs,
6228
+ const existing = await dbCount(payload, slugs.emailLogs, {
5659
6229
  where: {
5660
6230
  and: [
5661
6231
  { action: { equals: "transfer" } },
@@ -5673,8 +6243,7 @@ function createTransferTicketEndpoint(slugs) {
5673
6243
  }
5674
6244
  } catch {
5675
6245
  }
5676
- const messages = await payload.find({
5677
- collection: slugs.ticketMessages,
6246
+ const messages = await dbFind(payload, slugs.ticketMessages, {
5678
6247
  where: {
5679
6248
  and: [
5680
6249
  { ticket: { equals: ticketId } },
@@ -5754,8 +6323,7 @@ function createTransferTicketEndpoint(slugs) {
5754
6323
  html
5755
6324
  });
5756
6325
  try {
5757
- await payload.create({
5758
- collection: slugs.emailLogs,
6326
+ await dbCreate(payload, slugs.emailLogs, {
5759
6327
  data: {
5760
6328
  status: "success",
5761
6329
  action: "transfer",
@@ -5795,6 +6363,8 @@ function statusToLabel(status) {
5795
6363
  }
5796
6364
  }
5797
6365
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6366
+ var MAX_COLLABORATORS_PER_TICKET = 20;
6367
+ var inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10);
5798
6368
  function createInviteCollaboratorEndpoint(slugs) {
5799
6369
  return {
5800
6370
  path: "/support/tickets/:id/invite",
@@ -5818,8 +6388,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5818
6388
  }
5819
6389
  const role = body.role === "collaborator" ? "collaborator" : "viewer";
5820
6390
  const payload = req.payload;
5821
- const ticket = await payload.findByID({
5822
- collection: slugs.tickets,
6391
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5823
6392
  id: ticketId,
5824
6393
  depth: 1,
5825
6394
  overrideAccess: true
@@ -5831,13 +6400,22 @@ function createInviteCollaboratorEndpoint(slugs) {
5831
6400
  if (!isAdmin && !isOwner) {
5832
6401
  return Response.json({ error: "Forbidden" }, { status: 403 });
5833
6402
  }
6403
+ if (inviteLimiter.check(String(req.user.id))) {
6404
+ return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6405
+ }
6406
+ const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
6407
+ if (collabCount.totalDocs >= MAX_COLLABORATORS_PER_TICKET) {
6408
+ return Response.json(
6409
+ { error: `Limite de ${MAX_COLLABORATORS_PER_TICKET} collaborateurs atteinte sur ce ticket` },
6410
+ { status: 429 }
6411
+ );
6412
+ }
5834
6413
  const ownerEmail = typeof ticket.client === "object" ? ticket.client?.email : void 0;
5835
6414
  if (ownerEmail && ownerEmail.toLowerCase() === email) {
5836
6415
  return Response.json({ error: "Ce client est d\xE9j\xE0 propri\xE9taire du ticket" }, { status: 400 });
5837
6416
  }
5838
6417
  let inviteeId = null;
5839
- const existing = await payload.find({
5840
- collection: slugs.supportClients,
6418
+ const existing = await dbFind(payload, slugs.supportClients, {
5841
6419
  where: { email: { equals: email } },
5842
6420
  limit: 1,
5843
6421
  depth: 0,
@@ -5847,8 +6425,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5847
6425
  inviteeId = existing.docs[0].id;
5848
6426
  } else {
5849
6427
  const tempPassword = crypto3.randomBytes(16).toString("hex");
5850
- const created = await payload.create({
5851
- collection: slugs.supportClients,
6428
+ const created = await dbCreate(payload, slugs.supportClients, {
5852
6429
  data: {
5853
6430
  email,
5854
6431
  firstName: "Invit\xE9",
@@ -5865,8 +6442,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5865
6442
  if (!inviteeId) {
5866
6443
  return Response.json({ error: "Impossible de cr\xE9er le client invit\xE9" }, { status: 500 });
5867
6444
  }
5868
- const dupe = await payload.find({
5869
- collection: "ticket-collaborators",
6445
+ const dupe = await dbFind(payload, "ticket-collaborators", {
5870
6446
  where: {
5871
6447
  and: [
5872
6448
  { ticket: { equals: ticketId } },
@@ -5879,15 +6455,13 @@ function createInviteCollaboratorEndpoint(slugs) {
5879
6455
  });
5880
6456
  const invitationToken = crypto3.randomBytes(24).toString("hex");
5881
6457
  if (dupe.docs.length > 0) {
5882
- await payload.update({
5883
- collection: "ticket-collaborators",
6458
+ await dbUpdate(payload, "ticket-collaborators", {
5884
6459
  id: dupe.docs[0].id,
5885
6460
  data: { role, invitationToken },
5886
6461
  overrideAccess: true
5887
6462
  });
5888
6463
  } else {
5889
- await payload.create({
5890
- collection: "ticket-collaborators",
6464
+ await dbCreate(payload, "ticket-collaborators", {
5891
6465
  data: {
5892
6466
  ticket: ticketId,
5893
6467
  client: inviteeId,
@@ -5930,8 +6504,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5930
6504
  )
5931
6505
  });
5932
6506
  try {
5933
- await payload.create({
5934
- collection: slugs.emailLogs,
6507
+ await dbCreate(payload, slugs.emailLogs, {
5935
6508
  data: {
5936
6509
  status: "success",
5937
6510
  action: "invite",
@@ -5984,6 +6557,7 @@ function createSupportEndpoints(slugs, options) {
5984
6557
  ];
5985
6558
  if (!f || f.ai !== false) {
5986
6559
  endpoints.push(createAiEndpoint(slugs));
6560
+ endpoints.push(createAiAgentEndpoint(slugs));
5987
6561
  endpoints.push(...createClientIntelligenceEndpoint(slugs));
5988
6562
  endpoints.push(createTicketSynthesisEndpoint(slugs));
5989
6563
  }
@@ -6014,21 +6588,28 @@ function createSupportEndpoints(slugs, options) {
6014
6588
  endpoints.push(createAdminChatGetEndpoint(slugs), createAdminChatPostEndpoint(slugs));
6015
6589
  endpoints.push(createAdminChatStreamEndpoint(slugs));
6016
6590
  }
6017
- if (!f || f.timeTracking !== false) endpoints.push(createBillingEndpoint(slugs));
6591
+ if (!f || f.timeTracking !== false) {
6592
+ endpoints.push(createBillingEndpoint(slugs));
6593
+ endpoints.push(createInvoiceEndpoint(slugs));
6594
+ }
6018
6595
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
6019
6596
  if (!f || f.emailTracking !== false) {
6020
6597
  endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
6021
6598
  }
6022
6599
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
6023
6600
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
6601
+ if (!f || f.snooze !== false) endpoints.push(createProcessSnoozeEndpoint(slugs));
6602
+ endpoints.push(createProcessDigestsEndpoint(slugs));
6603
+ endpoints.push(createChannelsWebhookEndpoint(slugs));
6604
+ endpoints.push(createVapidKeyEndpoint());
6605
+ endpoints.push(createPushSubscribeEndpoint(slugs));
6024
6606
  return endpoints;
6025
6607
  }
6026
6608
 
6027
6609
  // src/utils/adminNotification.ts
6028
6610
  async function createAdminNotification(payload, data, collectionSlug = "admin-notifications") {
6029
6611
  try {
6030
- await payload.create({
6031
- collection: collectionSlug,
6612
+ await dbCreate(payload, collectionSlug, {
6032
6613
  data: {
6033
6614
  title: data.title,
6034
6615
  message: data.message,
@@ -6048,8 +6629,7 @@ function dispatchWebhook(data, event, payload, slugs) {
6048
6629
  }
6049
6630
  async function _dispatch(data, event, payload, slugs) {
6050
6631
  try {
6051
- const { docs: endpoints } = await payload.find({
6052
- collection: slugs.webhookEndpoints,
6632
+ const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
6053
6633
  where: {
6054
6634
  and: [
6055
6635
  { active: { equals: true } },
@@ -6086,8 +6666,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
6086
6666
  signal: AbortSignal.timeout(1e4)
6087
6667
  // 10s timeout
6088
6668
  });
6089
- await payload.update({
6090
- collection: slugs.webhookEndpoints,
6669
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6091
6670
  id: endpoint.id,
6092
6671
  data: {
6093
6672
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6101,8 +6680,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
6101
6680
  } catch (err) {
6102
6681
  console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
6103
6682
  try {
6104
- await payload.update({
6105
- collection: slugs.webhookEndpoints,
6683
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6106
6684
  id: endpoint.id,
6107
6685
  data: {
6108
6686
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6336,8 +6914,7 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6336
6914
  if (typeof policyRef === "object" && policyRef.firstResponseTime) {
6337
6915
  return policyRef;
6338
6916
  }
6339
- return await payload.findByID({
6340
- collection: slugs.slaPolicies,
6917
+ return await dbFindByID(payload, slugs.slaPolicies, {
6341
6918
  id: policyId,
6342
6919
  depth: 0,
6343
6920
  overrideAccess: true
@@ -6347,9 +6924,22 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6347
6924
  }
6348
6925
  }
6349
6926
  const priority = field(ticket, "priority") || "normal";
6927
+ const teamRef = field(ticket, "team");
6928
+ const teamId = typeof teamRef === "object" && teamRef !== null ? teamRef.id : teamRef;
6929
+ if (teamId) {
6930
+ try {
6931
+ const teamPolicies = await dbFind(payload, slugs.slaPolicies, {
6932
+ where: { and: [{ team: { equals: teamId } }, { priority: { equals: priority } }] },
6933
+ limit: 1,
6934
+ depth: 0,
6935
+ overrideAccess: true
6936
+ });
6937
+ if (teamPolicies.docs.length > 0) return teamPolicies.docs[0];
6938
+ } catch {
6939
+ }
6940
+ }
6350
6941
  try {
6351
- const defaults = await payload.find({
6352
- collection: slugs.slaPolicies,
6942
+ const defaults = await dbFind(payload, slugs.slaPolicies, {
6353
6943
  where: {
6354
6944
  and: [
6355
6945
  { isDefault: { equals: true } },
@@ -6386,8 +6976,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6386
6976
  if (typeof policyRef === "object" && policyRef.escalateOnBreach !== void 0) {
6387
6977
  policy = policyRef;
6388
6978
  } else {
6389
- policy = await payload.findByID({
6390
- collection: slugs.slaPolicies,
6979
+ policy = await dbFindByID(payload, slugs.slaPolicies, {
6391
6980
  id: policyId,
6392
6981
  depth: 0,
6393
6982
  overrideAccess: true
@@ -6400,8 +6989,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6400
6989
  try {
6401
6990
  const escalateToId = typeof policy.escalateTo === "object" ? policy.escalateTo.id : policy.escalateTo;
6402
6991
  if (!escalateToId) return;
6403
- const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await payload.findByID({
6404
- collection: slugs.users,
6992
+ const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await dbFindByID(payload, slugs.users, {
6405
6993
  id: escalateToId,
6406
6994
  depth: 0,
6407
6995
  overrideAccess: true
@@ -6449,8 +7037,7 @@ function createAssignSlaDeadlines(slugs, notificationSlug = "admin-notifications
6449
7037
  if (!field(doc, "slaPolicy") && policy.id) {
6450
7038
  updateData.slaPolicy = policy.id;
6451
7039
  }
6452
- await payload.update({
6453
- collection: slugs.tickets,
7040
+ await dbUpdate(payload, slugs.tickets, {
6454
7041
  id: doc.id,
6455
7042
  data: updateData,
6456
7043
  overrideAccess: true
@@ -6473,8 +7060,7 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6473
7060
  const now = /* @__PURE__ */ new Date();
6474
7061
  const deadline = new Date(doc.slaResolutionDue);
6475
7062
  const breached = now > deadline;
6476
- await payload.update({
6477
- collection: slugs.tickets,
7063
+ await dbUpdate(payload, slugs.tickets, {
6478
7064
  id: doc.id,
6479
7065
  data: { slaResolutionBreached: breached },
6480
7066
  overrideAccess: true
@@ -6488,6 +7074,35 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6488
7074
  return doc;
6489
7075
  };
6490
7076
  }
7077
+ function createPauseSlaOnHold(slugs) {
7078
+ return async ({ doc, previousDoc, operation, req }) => {
7079
+ if (operation !== "update" || !previousDoc) return doc;
7080
+ if (previousDoc.status === doc.status) return doc;
7081
+ if (!doc.slaResolutionDue) return doc;
7082
+ const wasWaiting = previousDoc.status === "waiting_client";
7083
+ const isWaiting = doc.status === "waiting_client";
7084
+ try {
7085
+ if (!wasWaiting && isWaiting && !doc.slaPausedAt && doc.status !== "resolved") {
7086
+ await req.payload.update({
7087
+ collection: slugs.tickets,
7088
+ id: doc.id,
7089
+ data: { slaPausedAt: (/* @__PURE__ */ new Date()).toISOString() },
7090
+ overrideAccess: true
7091
+ });
7092
+ } else if (wasWaiting && !isWaiting && doc.slaPausedAt) {
7093
+ const pausedMs = Date.now() - new Date(doc.slaPausedAt).getTime();
7094
+ const data = { slaPausedAt: null };
7095
+ if (pausedMs > 0) {
7096
+ data.slaResolutionDue = new Date(new Date(doc.slaResolutionDue).getTime() + pausedMs).toISOString();
7097
+ }
7098
+ await req.payload.update({ collection: slugs.tickets, id: doc.id, data, overrideAccess: true });
7099
+ }
7100
+ } catch (err) {
7101
+ console.error("[sla] Failed to pause/resume SLA on hold:", err);
7102
+ }
7103
+ return doc;
7104
+ };
7105
+ }
6491
7106
  function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications") {
6492
7107
  return async ({ doc, operation, req }) => {
6493
7108
  if (operation !== "create") return doc;
@@ -6495,8 +7110,7 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6495
7110
  try {
6496
7111
  const { payload } = req;
6497
7112
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
6498
- const ticket = await payload.findByID({
6499
- collection: slugs.tickets,
7113
+ const ticket = await dbFindByID(payload, slugs.tickets, {
6500
7114
  id: ticketId,
6501
7115
  depth: 1,
6502
7116
  overrideAccess: true
@@ -6507,22 +7121,123 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6507
7121
  const now = /* @__PURE__ */ new Date();
6508
7122
  const deadline = new Date(ticket.slaFirstResponseDue);
6509
7123
  const breached = now > deadline;
6510
- await payload.update({
6511
- collection: slugs.tickets,
7124
+ await dbUpdate(payload, slugs.tickets, {
6512
7125
  id: ticketId,
6513
7126
  data: { slaFirstResponseBreached: breached },
6514
7127
  overrideAccess: true
6515
7128
  });
6516
- if (breached) {
6517
- await handleSlaBreach(payload, ticket, "first_response", slugs, notificationSlug);
7129
+ if (breached) {
7130
+ await handleSlaBreach(payload, ticket, "first_response", slugs, notificationSlug);
7131
+ }
7132
+ } catch (err) {
7133
+ console.error("[sla] Failed to check SLA on reply:", err);
7134
+ }
7135
+ return doc;
7136
+ };
7137
+ }
7138
+
7139
+ // src/hooks/automationRules.ts
7140
+ function evaluateConditions(doc, rule) {
7141
+ const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
7142
+ if (conds.length === 0) return true;
7143
+ const results = conds.map((c) => {
7144
+ const fieldVal = String(doc[c.field] ?? "");
7145
+ const v = String(c.value ?? "");
7146
+ switch (c.operator) {
7147
+ case "equals":
7148
+ return fieldVal === v;
7149
+ case "not_equals":
7150
+ return fieldVal !== v;
7151
+ case "contains":
7152
+ return fieldVal.toLowerCase().includes(v.toLowerCase());
7153
+ default:
7154
+ return false;
7155
+ }
7156
+ });
7157
+ return rule.matchType === "any" ? results.some(Boolean) : results.every(Boolean);
7158
+ }
7159
+ function createApplyAutomationRules(slugs) {
7160
+ return async ({ doc, previousDoc, operation, req }) => {
7161
+ if (req.context?.skipAutomation) return doc;
7162
+ const events = [];
7163
+ if (operation === "create") events.push("ticket_created");
7164
+ if (operation === "update") {
7165
+ events.push("ticket_updated");
7166
+ if (previousDoc && previousDoc.status !== doc.status) events.push("ticket_status_changed");
7167
+ }
7168
+ if (events.length === 0) return doc;
7169
+ try {
7170
+ const rules = await dbFind(req.payload, slugs.automationRules, {
7171
+ where: { and: [{ enabled: { equals: true } }, { event: { in: events } }] },
7172
+ sort: "order",
7173
+ limit: 200,
7174
+ depth: 0,
7175
+ overrideAccess: true
7176
+ });
7177
+ const changes = {};
7178
+ const tags = Array.isArray(doc.tags) ? [...doc.tags] : [];
7179
+ let tagsTouched = false;
7180
+ for (const rule of rules.docs) {
7181
+ if (!evaluateConditions(doc, rule)) continue;
7182
+ for (const a of Array.isArray(rule.actions) ? rule.actions : []) {
7183
+ if (!a.value) continue;
7184
+ switch (a.type) {
7185
+ case "set_status":
7186
+ changes.status = a.value;
7187
+ break;
7188
+ case "set_priority":
7189
+ changes.priority = a.value;
7190
+ break;
7191
+ case "set_category":
7192
+ changes.category = a.value;
7193
+ break;
7194
+ case "assign":
7195
+ changes.assignedTo = a.value;
7196
+ break;
7197
+ case "add_tag":
7198
+ if (!tags.includes(a.value)) {
7199
+ tags.push(a.value);
7200
+ tagsTouched = true;
7201
+ }
7202
+ break;
7203
+ }
7204
+ }
7205
+ }
7206
+ if (tagsTouched) changes.tags = tags;
7207
+ if (Object.keys(changes).length > 0) {
7208
+ await req.payload.update({
7209
+ collection: slugs.tickets,
7210
+ id: doc.id,
7211
+ data: changes,
7212
+ overrideAccess: true,
7213
+ context: { skipAutomation: true }
7214
+ });
6518
7215
  }
6519
7216
  } catch (err) {
6520
- console.error("[sla] Failed to check SLA on reply:", err);
7217
+ console.error("[support] Failed to apply automation rules:", err);
6521
7218
  }
6522
7219
  return doc;
6523
7220
  };
6524
7221
  }
6525
7222
 
7223
+ // src/utils/teamAccess.ts
7224
+ function isTeamScopingEnabled() {
7225
+ return process.env.SUPPORT_TEAM_SCOPING === "1" || process.env.SUPPORT_TEAM_SCOPING === "true";
7226
+ }
7227
+ async function resolveAgentTeamIds(payload, slugs, userId) {
7228
+ try {
7229
+ const teams = await dbFind(payload, slugs.supportTeams, {
7230
+ where: { members: { in: [userId] } },
7231
+ limit: 200,
7232
+ depth: 0,
7233
+ overrideAccess: true
7234
+ });
7235
+ return teams.docs.map((t) => t.id);
7236
+ } catch {
7237
+ return [];
7238
+ }
7239
+ }
7240
+
6526
7241
  // src/collections/Tickets.ts
6527
7242
  function createAssignTicketNumber(slugs) {
6528
7243
  return async ({ data, operation, req }) => {
@@ -6785,56 +7500,6 @@ function createNotifyClientOnResolve(slugs) {
6785
7500
  return doc;
6786
7501
  };
6787
7502
  }
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
7503
  function createFireTicketWebhooks(slugs) {
6839
7504
  return async ({ doc, previousDoc, operation, req }) => {
6840
7505
  const { payload } = req;
@@ -6926,8 +7591,7 @@ function createCascadeDelete(slugs) {
6926
7591
  return async ({ id, req }) => {
6927
7592
  const collections = [slugs.ticketMessages, slugs.ticketActivityLog, slugs.timeEntries, slugs.satisfactionSurveys];
6928
7593
  for (const slug of collections) {
6929
- await req.payload.delete({
6930
- collection: slug,
7594
+ await dbDelete(req.payload, slug, {
6931
7595
  where: { ticket: { equals: id } },
6932
7596
  overrideAccess: true
6933
7597
  });
@@ -7052,6 +7716,7 @@ function createTicketsCollection(slugs, options) {
7052
7716
  {
7053
7717
  name: "status",
7054
7718
  type: "select",
7719
+ index: true,
7055
7720
  defaultValue: "open",
7056
7721
  label: "Statut",
7057
7722
  options: [
@@ -7066,6 +7731,7 @@ function createTicketsCollection(slugs, options) {
7066
7731
  {
7067
7732
  name: "priority",
7068
7733
  type: "select",
7734
+ index: true,
7069
7735
  defaultValue: "normal",
7070
7736
  label: "Priorite",
7071
7737
  options: [
@@ -7081,7 +7747,7 @@ function createTicketsCollection(slugs, options) {
7081
7747
  {
7082
7748
  type: "row",
7083
7749
  fields: [
7084
- { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, label: "Client", admin: { width: "50%" } }
7750
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client", admin: { width: "50%" } }
7085
7751
  ]
7086
7752
  },
7087
7753
  // Billing collapsible
@@ -7150,15 +7816,16 @@ function createTicketsCollection(slugs, options) {
7150
7816
  {
7151
7817
  type: "row",
7152
7818
  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" } } }
7819
+ { name: "slaFirstResponseDue", type: "date", index: true, label: "Echeance 1ere reponse (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7820
+ { name: "slaResolutionDue", type: "date", index: true, label: "Echeance resolution (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
7155
7821
  ]
7156
7822
  },
7157
7823
  {
7158
7824
  type: "row",
7159
7825
  fields: [
7160
7826
  { 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%" } }
7827
+ { name: "slaResolutionBreached", type: "checkbox", label: "Resolution depassee", admin: { readOnly: true, width: "50%" } },
7828
+ { name: "slaPausedAt", type: "date", label: "SLA en pause depuis", admin: { hidden: true } }
7162
7829
  ]
7163
7830
  }
7164
7831
  ]
@@ -7180,7 +7847,7 @@ function createTicketsCollection(slugs, options) {
7180
7847
  },
7181
7848
  { name: "mergedInto", type: "relationship", relationTo: slugs.tickets, label: "Fusionne dans", admin: { readOnly: true } },
7182
7849
  { 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." } }
7850
+ { 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
7851
  ]
7185
7852
  },
7186
7853
  // Sidebar
@@ -7189,6 +7856,7 @@ function createTicketsCollection(slugs, options) {
7189
7856
  {
7190
7857
  name: "category",
7191
7858
  type: "select",
7859
+ index: true,
7192
7860
  label: "Categorie",
7193
7861
  options: [
7194
7862
  { label: "Bug / Dysfonctionnement", value: "bug" },
@@ -7199,7 +7867,8 @@ function createTicketsCollection(slugs, options) {
7199
7867
  ],
7200
7868
  admin: { position: "sidebar" }
7201
7869
  },
7202
- { name: "assignedTo", type: "relationship", relationTo: slugs.users, label: "Assigne a", admin: { position: "sidebar" } },
7870
+ { name: "assignedTo", type: "relationship", relationTo: slugs.users, index: true, label: "Assigne a", admin: { position: "sidebar" } },
7871
+ { name: "team", type: "relationship", relationTo: slugs.supportTeams, index: true, label: "Equipe", admin: { position: "sidebar" } },
7203
7872
  {
7204
7873
  name: "source",
7205
7874
  type: "select",
@@ -7209,7 +7878,9 @@ function createTicketsCollection(slugs, options) {
7209
7878
  { label: "Portail client", value: "portal" },
7210
7879
  { label: "Chat en direct", value: "live-chat" },
7211
7880
  { label: "Email", value: "email" },
7212
- { label: "Admin", value: "admin" }
7881
+ { label: "Admin", value: "admin" },
7882
+ { label: "WhatsApp", value: "whatsapp" },
7883
+ { label: "Messenger", value: "messenger" }
7213
7884
  ],
7214
7885
  admin: { position: "sidebar" }
7215
7886
  },
@@ -7260,8 +7931,8 @@ function createTicketsCollection(slugs, options) {
7260
7931
  afterChange: [
7261
7932
  createTrackSLA(slugs),
7262
7933
  createTrackAiSummaryOnResolve(slugs),
7263
- createAutoCalculateSLA(slugs),
7264
7934
  createAssignSlaDeadlines(slugs, notificationSlug),
7935
+ createPauseSlaOnHold(slugs),
7265
7936
  createCheckSlaOnResolve(slugs, notificationSlug),
7266
7937
  createLogTicketActivity(slugs),
7267
7938
  createNotifyOnAssignment(slugs),
@@ -7269,7 +7940,8 @@ function createTicketsCollection(slugs, options) {
7269
7940
  createTicketStatusEmail(slugs),
7270
7941
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
7271
7942
  createFireTicketWebhooks(slugs),
7272
- createDispatchWebhookOnTicket(slugs)
7943
+ createDispatchWebhookOnTicket(slugs),
7944
+ createApplyAutomationRules(slugs)
7273
7945
  ],
7274
7946
  beforeDelete: [createCascadeDelete(slugs)]
7275
7947
  },
@@ -7280,12 +7952,20 @@ function createTicketsCollection(slugs, options) {
7280
7952
  return false;
7281
7953
  },
7282
7954
  read: async ({ req }) => {
7283
- if (req.user?.collection === slugs.users) return true;
7955
+ if (req.user?.collection === slugs.users) {
7956
+ if (!isTeamScopingEnabled()) return true;
7957
+ const teamIds = await resolveAgentTeamIds(req.payload, slugs, req.user.id);
7958
+ return {
7959
+ or: [
7960
+ { team: { in: teamIds.length > 0 ? teamIds : [-1] } },
7961
+ { team: { exists: false } }
7962
+ ]
7963
+ };
7964
+ }
7284
7965
  if (req.user?.collection === slugs.supportClients) {
7285
7966
  let collabTicketIds = [];
7286
7967
  try {
7287
- const rows = await req.payload.find({
7288
- collection: "ticket-collaborators",
7968
+ const rows = await dbFind(req.payload, "ticket-collaborators", {
7289
7969
  where: { client: { equals: req.user.id } },
7290
7970
  limit: 1e3,
7291
7971
  depth: 0,
@@ -7320,6 +8000,125 @@ function createTicketsCollection(slugs, options) {
7320
8000
  };
7321
8001
  }
7322
8002
 
8003
+ // src/utils/ticketAccess.ts
8004
+ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
8005
+ const ids = /* @__PURE__ */ new Set();
8006
+ try {
8007
+ const owned = await dbFind(payload, slugs.tickets, {
8008
+ where: { client: { equals: clientId } },
8009
+ limit: 5e3,
8010
+ depth: 0,
8011
+ overrideAccess: true
8012
+ });
8013
+ for (const t of owned.docs) ids.add(t.id);
8014
+ } catch {
8015
+ }
8016
+ try {
8017
+ const collab = await dbFind(payload, "ticket-collaborators", {
8018
+ where: { client: { equals: clientId } },
8019
+ limit: 1e3,
8020
+ depth: 0,
8021
+ overrideAccess: true
8022
+ });
8023
+ for (const r of collab.docs) {
8024
+ const row = r;
8025
+ const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
8026
+ if (tid !== void 0 && tid !== null) ids.add(tid);
8027
+ }
8028
+ } catch {
8029
+ }
8030
+ return Array.from(ids);
8031
+ }
8032
+ var OPTIONS = {
8033
+ allowedTags: [
8034
+ "p",
8035
+ "br",
8036
+ "b",
8037
+ "strong",
8038
+ "i",
8039
+ "em",
8040
+ "u",
8041
+ "s",
8042
+ "strike",
8043
+ "del",
8044
+ "ins",
8045
+ "mark",
8046
+ "a",
8047
+ "ul",
8048
+ "ol",
8049
+ "li",
8050
+ "blockquote",
8051
+ "code",
8052
+ "pre",
8053
+ "kbd",
8054
+ "span",
8055
+ "div",
8056
+ "h1",
8057
+ "h2",
8058
+ "h3",
8059
+ "h4",
8060
+ "h5",
8061
+ "h6",
8062
+ "hr",
8063
+ "img",
8064
+ "table",
8065
+ "thead",
8066
+ "tbody",
8067
+ "tr",
8068
+ "th",
8069
+ "td"
8070
+ ],
8071
+ allowedAttributes: {
8072
+ a: ["href", "title", "target", "rel"],
8073
+ img: ["src", "alt", "title", "width", "height"],
8074
+ code: ["class"],
8075
+ pre: ["class"],
8076
+ span: ["class"],
8077
+ "*": ["style"]
8078
+ },
8079
+ allowedSchemes: ["http", "https", "mailto", "tel"],
8080
+ allowedSchemesByTag: { img: ["http", "https", "data"] },
8081
+ allowProtocolRelative: false,
8082
+ disallowedTagsMode: "discard",
8083
+ // Force safe rel on links opening a new tab (defense against tabnabbing).
8084
+ transformTags: {
8085
+ a: sanitizeHtml__default.default.simpleTransform("a", { rel: "noopener noreferrer nofollow" }, true)
8086
+ },
8087
+ // Only allow CSS that cannot carry a script payload.
8088
+ allowedStyles: {
8089
+ "*": {
8090
+ color: [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
8091
+ "background-color": [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
8092
+ "text-align": [/^(left|right|center|justify)$/],
8093
+ "font-weight": [/^(normal|bold|[1-9]00)$/],
8094
+ "font-style": [/^(normal|italic)$/],
8095
+ "text-decoration": [/^(underline|line-through|none)$/]
8096
+ }
8097
+ }
8098
+ };
8099
+ function sanitizeMessageHtml(html) {
8100
+ if (!html) return html;
8101
+ return sanitizeHtml__default.default(html, OPTIONS);
8102
+ }
8103
+
8104
+ // src/utils/notificationQueue.ts
8105
+ async function queueClientNotification(payload, slugs, item) {
8106
+ try {
8107
+ await dbCreate(payload, slugs.notificationQueue, {
8108
+ data: {
8109
+ client: item.client,
8110
+ type: item.type,
8111
+ title: item.title,
8112
+ message: item.message,
8113
+ ...item.ticket ? { ticket: item.ticket } : {}
8114
+ },
8115
+ overrideAccess: true
8116
+ });
8117
+ } catch (err) {
8118
+ console.error("[support] Failed to queue client notification:", err);
8119
+ }
8120
+ }
8121
+
7323
8122
  // src/collections/TicketMessages.ts
7324
8123
  function createAssignAuthor(slugs) {
7325
8124
  return async ({ data, operation, req }) => {
@@ -7382,29 +8181,46 @@ function createNotifyClient(slugs) {
7382
8181
  const internalBcc = process.env.SUPPORT_CLIENT_BCC || void 0;
7383
8182
  const rawContent = doc.bodyHtml ? emailRichContent(doc.bodyHtml) : emailQuote(doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body);
7384
8183
  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
- });
8184
+ const freq = client.notificationFrequency;
8185
+ if (freq === "daily" || freq === "weekly") {
8186
+ await queueClientNotification(req.payload, slugs, {
8187
+ client: client.id,
8188
+ type: "reply",
8189
+ title: `Nouvelle reponse \u2014 ${ticketNumber}`,
8190
+ message: replyPreview || `Reponse a votre ticket ${ticketNumber}`,
8191
+ ticket: ticketId
8192
+ });
8193
+ return doc;
8194
+ }
8195
+ void (async () => {
8196
+ try {
8197
+ await req.payload.sendEmail({
8198
+ to: client.email,
8199
+ ...supportEmail ? { replyTo: supportEmail } : {},
8200
+ ...internalBcc ? { bcc: internalBcc } : {},
8201
+ subject: `Re: [${ticketNumber}] ${subject}`,
8202
+ html: emailWrapper(`Nouvelle reponse \u2014 ${ticketNumber}`, [
8203
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
8204
+ emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`),
8205
+ rawContent,
8206
+ emailButton("Consulter le ticket", portalUrl),
8207
+ emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
8208
+ emailTrackingPixel(ticketId, doc.id)
8209
+ ].join(""), {
8210
+ kind: "reply",
8211
+ preheader: replyPreview ? `${replyPreview}\u2026` : `Nouvelle reponse a votre ticket ${ticketNumber}`
8212
+ })
8213
+ });
8214
+ await req.payload.update({
8215
+ collection: slugs.ticketMessages,
8216
+ id: doc.id,
8217
+ data: { emailSentAt: (/* @__PURE__ */ new Date()).toISOString(), emailSentTo: client.email },
8218
+ overrideAccess: true
8219
+ });
8220
+ } catch (err) {
8221
+ console.error("[support] Failed to notify client (async):", err);
8222
+ }
8223
+ })();
7408
8224
  } catch (err) {
7409
8225
  console.error("[support] Failed to notify client:", err);
7410
8226
  }
@@ -7442,8 +8258,7 @@ function createSyncTicketReplyToChat(slugs) {
7442
8258
  if (!ticket?.chatSession) return doc;
7443
8259
  if (doc.skipNotification) return doc;
7444
8260
  const clientId = typeof ticket.client === "object" ? ticket.client.id : ticket.client;
7445
- await payload.create({
7446
- collection: slugs.chatMessages,
8261
+ await dbCreate(payload, slugs.chatMessages, {
7447
8262
  data: {
7448
8263
  session: ticket.chatSession,
7449
8264
  client: clientId,
@@ -7505,6 +8320,15 @@ function createNotifyAdminOnClientMessage(slugs, notificationSlug) {
7505
8320
  }
7506
8321
  const preview = doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body;
7507
8322
  const headerTitle = isNewTicket ? `Nouveau ticket ${ticketNumber}` : `Nouveau message \u2014 ${ticketNumber}`;
8323
+ const assignedId = assignedAdmin?.id ?? (ticket.assignedTo && typeof ticket.assignedTo !== "object" ? ticket.assignedTo : void 0);
8324
+ if (assignedId) {
8325
+ void sendPushToUser(payload, slugs, assignedId, {
8326
+ title: headerTitle,
8327
+ body: `${clientName}: ${preview || subject}`,
8328
+ url: `/admin/collections/${slugs.tickets}/${ticketId}`
8329
+ }).catch(() => {
8330
+ });
8331
+ }
7508
8332
  if (primaryEmail) {
7509
8333
  await payload.sendEmail({
7510
8334
  // INTERNAL email (to the support team) — recipients are the contact
@@ -7560,6 +8384,57 @@ function createDispatchWebhookOnReply(slugs) {
7560
8384
  return doc;
7561
8385
  };
7562
8386
  }
8387
+ function createSanitizeMessageHtml() {
8388
+ return ({ data }) => {
8389
+ if (data && typeof data.bodyHtml === "string" && data.bodyHtml) {
8390
+ data.bodyHtml = sanitizeMessageHtml(data.bodyHtml);
8391
+ }
8392
+ return data;
8393
+ };
8394
+ }
8395
+ var MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
8396
+ function createResolveMentions(slugs) {
8397
+ return async ({ data, operation, req }) => {
8398
+ if (operation !== "create") return data;
8399
+ const body = typeof data?.body === "string" ? data.body : "";
8400
+ const emails = Array.from(new Set([...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase())));
8401
+ if (emails.length === 0) return data;
8402
+ try {
8403
+ const users = await dbFind(req.payload, slugs.users, { where: { email: { in: emails } }, limit: 50, depth: 0, overrideAccess: true });
8404
+ const ids = users.docs.map((u) => u.id);
8405
+ if (ids.length > 0) data.mentions = ids;
8406
+ } catch {
8407
+ }
8408
+ return data;
8409
+ };
8410
+ }
8411
+ function createNotifyMentions(slugs) {
8412
+ return async ({ doc, operation, req }) => {
8413
+ if (operation !== "create") return doc;
8414
+ const mentions = Array.isArray(doc.mentions) ? doc.mentions : [];
8415
+ if (mentions.length === 0) return doc;
8416
+ const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
8417
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
8418
+ for (const m of mentions) {
8419
+ const userId = typeof m === "object" ? m.id : m;
8420
+ try {
8421
+ const user = await dbFindByID(req.payload, slugs.users, { id: userId, depth: 0, overrideAccess: true });
8422
+ if (!user?.email) continue;
8423
+ await req.payload.sendEmail({
8424
+ to: user.email,
8425
+ subject: `Vous avez ete mentionne \u2014 ticket #${ticketId}`,
8426
+ html: emailWrapper("Mention", [
8427
+ emailParagraph(`Vous avez ete mentionne dans un message du ticket <strong>#${escapeHtml(String(ticketId))}</strong>.`),
8428
+ ...baseUrl ? [emailButton("Ouvrir le ticket", `${baseUrl}/admin/collections/${slugs.tickets}/${ticketId}`, "dark")] : []
8429
+ ].join(""), { kind: "system" })
8430
+ });
8431
+ } catch (err) {
8432
+ console.error("[support] Failed to notify mention:", err);
8433
+ }
8434
+ }
8435
+ return doc;
8436
+ };
8437
+ }
7563
8438
  function createTicketMessagesCollection(slugs, options) {
7564
8439
  const notificationSlug = options?.notificationSlug || "admin-notifications";
7565
8440
  return {
@@ -7567,7 +8442,7 @@ function createTicketMessagesCollection(slugs, options) {
7567
8442
  labels: { singular: "Message", plural: "Messages" },
7568
8443
  admin: { hidden: true, group: "Support", defaultColumns: ["ticket", "authorType", "createdAt"] },
7569
8444
  fields: [
7570
- { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, label: "Ticket" },
8445
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, index: true, label: "Ticket" },
7571
8446
  { name: "body", type: "textarea", required: true, label: "Message" },
7572
8447
  { name: "bodyHtml", type: "textarea", label: "Message HTML", admin: { hidden: true } },
7573
8448
  {
@@ -7576,6 +8451,7 @@ function createTicketMessagesCollection(slugs, options) {
7576
8451
  {
7577
8452
  name: "authorType",
7578
8453
  type: "select",
8454
+ index: true,
7579
8455
  label: "Type d'auteur",
7580
8456
  defaultValue: "admin",
7581
8457
  options: [
@@ -7611,8 +8487,9 @@ function createTicketMessagesCollection(slugs, options) {
7611
8487
  },
7612
8488
  { name: "isInternal", type: "checkbox", defaultValue: false, label: "Note interne", admin: { position: "sidebar" } },
7613
8489
  { name: "isSolution", type: "checkbox", defaultValue: false, label: "Reponse solution", admin: { position: "sidebar" } },
8490
+ { name: "mentions", type: "relationship", relationTo: slugs.users, hasMany: true, label: "Agents mentionnes", admin: { hidden: true } },
7614
8491
  { 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 } },
8492
+ { name: "scheduledAt", type: "date", index: true, label: "Programme pour", admin: { date: { pickerAppearance: "dayAndTime" }, position: "sidebar", condition: (data) => !!data?.scheduledAt } },
7616
8493
  { name: "scheduledSent", type: "checkbox", defaultValue: false, admin: { hidden: true } },
7617
8494
  { name: "editedAt", type: "date", label: "Modifie le", admin: { hidden: true } },
7618
8495
  { name: "deletedAt", type: "date", label: "Supprime le", admin: { hidden: true } },
@@ -7621,7 +8498,7 @@ function createTicketMessagesCollection(slugs, options) {
7621
8498
  { name: "emailOpenedAt", type: "date", label: "Email ouvert le", admin: { hidden: true } }
7622
8499
  ],
7623
8500
  hooks: {
7624
- beforeChange: [createAssignAuthor(slugs)],
8501
+ beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
7625
8502
  afterChange: [
7626
8503
  createAutoUpdateStatus(slugs),
7627
8504
  createNotifyClient(slugs),
@@ -7630,17 +8507,19 @@ function createTicketMessagesCollection(slugs, options) {
7630
8507
  createSyncTicketReplyToChat(slugs),
7631
8508
  createNotifyAdminOnClientMessage(slugs, notificationSlug),
7632
8509
  createFireMessageWebhooks(slugs),
7633
- createDispatchWebhookOnReply(slugs)
8510
+ createDispatchWebhookOnReply(slugs),
8511
+ createNotifyMentions(slugs)
7634
8512
  ]
7635
8513
  },
7636
8514
  access: {
7637
8515
  create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
7638
- read: ({ req }) => {
8516
+ read: async ({ req }) => {
7639
8517
  if (req.user?.collection === slugs.users) return true;
7640
8518
  if (req.user?.collection === slugs.supportClients) {
8519
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
7641
8520
  return {
7642
8521
  and: [
7643
- { "ticket.client": { equals: req.user.id } },
8522
+ { ticket: { in: ids.length > 0 ? ids : [-1] } },
7644
8523
  { isInternal: { equals: false } },
7645
8524
  { or: [{ scheduledAt: { exists: false } }, { scheduledSent: { equals: true } }] }
7646
8525
  ]
@@ -7660,8 +8539,6 @@ function createTicketMessagesCollection(slugs, options) {
7660
8539
  timestamps: true
7661
8540
  };
7662
8541
  }
7663
-
7664
- // src/collections/SupportClients.ts
7665
8542
  function createSendInvitationOnCreate(slugs) {
7666
8543
  return async ({ doc, operation, req }) => {
7667
8544
  if (operation !== "create") return doc;
@@ -7701,6 +8578,26 @@ function createSendInvitationOnCreate(slugs) {
7701
8578
  return doc;
7702
8579
  };
7703
8580
  }
8581
+ var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
8582
+ function createEnforce2FA(slugs) {
8583
+ return async ({ req, user }) => {
8584
+ if (!user?.twoFactorEnabled) return user;
8585
+ const raw = user.twoFactorVerifiedAt;
8586
+ const verifiedAt = raw ? new Date(raw).getTime() : 0;
8587
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
8588
+ throw new payload.APIError("2FA_REQUIRED", 401);
8589
+ }
8590
+ await req.payload.update({
8591
+ collection: slugs.supportClients,
8592
+ id: user.id,
8593
+ data: { twoFactorVerifiedAt: null },
8594
+ req,
8595
+ overrideAccess: true
8596
+ });
8597
+ user.twoFactorVerifiedAt = null;
8598
+ return user;
8599
+ };
8600
+ }
7704
8601
  function createSupportClientsCollection(slugs) {
7705
8602
  return {
7706
8603
  slug: slugs.supportClients,
@@ -7829,6 +8726,10 @@ function createSupportClientsCollection(slugs) {
7829
8726
  name: "opportunities",
7830
8727
  type: "textarea",
7831
8728
  label: "Opportunites commerciales",
8729
+ access: {
8730
+ // Admin-only: never serialize commercial notes onto the client's own doc.
8731
+ read: ({ req }) => req.user?.collection === slugs.users
8732
+ },
7832
8733
  admin: {
7833
8734
  description: "Notes commerciales (visible uniquement par les admins)"
7834
8735
  }
@@ -7872,6 +8773,19 @@ function createSupportClientsCollection(slugs) {
7872
8773
  type: "date",
7873
8774
  admin: { hidden: true }
7874
8775
  },
8776
+ {
8777
+ // Set by /support/2fa "verify"; gates login via the beforeLogin hook.
8778
+ name: "twoFactorVerifiedAt",
8779
+ type: "date",
8780
+ admin: { hidden: true }
8781
+ },
8782
+ {
8783
+ // Google OAuth subject id — accounts are linked by this, not email alone.
8784
+ name: "googleId",
8785
+ type: "text",
8786
+ index: true,
8787
+ admin: { hidden: true }
8788
+ },
7875
8789
  {
7876
8790
  name: "notifyOnReply",
7877
8791
  type: "checkbox",
@@ -7938,6 +8852,10 @@ function createSupportClientsCollection(slugs) {
7938
8852
  name: "notes",
7939
8853
  type: "textarea",
7940
8854
  label: "Notes internes",
8855
+ access: {
8856
+ // Admin-only: never serialize internal notes onto the client's own doc.
8857
+ read: ({ req }) => req.user?.collection === slugs.users
8858
+ },
7941
8859
  admin: {
7942
8860
  description: "Visible uniquement par les admins",
7943
8861
  position: "sidebar"
@@ -7945,6 +8863,7 @@ function createSupportClientsCollection(slugs) {
7945
8863
  }
7946
8864
  ],
7947
8865
  hooks: {
8866
+ beforeLogin: [createEnforce2FA(slugs)],
7948
8867
  afterChange: [createSendInvitationOnCreate(slugs)]
7949
8868
  },
7950
8869
  access: {
@@ -8158,6 +9077,7 @@ function createTicketActivityLogCollection(slugs) {
8158
9077
  type: "relationship",
8159
9078
  relationTo: slugs.tickets,
8160
9079
  required: true,
9080
+ index: true,
8161
9081
  label: "Ticket"
8162
9082
  },
8163
9083
  {
@@ -8203,10 +9123,11 @@ function createTicketActivityLogCollection(slugs) {
8203
9123
  access: {
8204
9124
  create: () => false,
8205
9125
  // Created only by hooks via overrideAccess
8206
- read: ({ req }) => {
9126
+ read: async ({ req }) => {
8207
9127
  if (req.user?.collection === slugs.users) return true;
8208
9128
  if (req.user?.collection === slugs.supportClients) {
8209
- return { "ticket.client": { equals: req.user.id } };
9129
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
9130
+ return { ticket: { in: ids.length > 0 ? ids : [-1] } };
8210
9131
  }
8211
9132
  return false;
8212
9133
  },
@@ -8275,12 +9196,21 @@ function createSatisfactionSurveysCollection(slugs) {
8275
9196
  {
8276
9197
  name: "rating",
8277
9198
  type: "number",
8278
- required: true,
8279
9199
  min: 1,
8280
9200
  max: 5,
8281
9201
  label: "Note (1-5)",
8282
9202
  admin: {
8283
- description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait"
9203
+ description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait (CSAT)"
9204
+ }
9205
+ },
9206
+ {
9207
+ name: "nps",
9208
+ type: "number",
9209
+ min: 0,
9210
+ max: 10,
9211
+ label: "NPS (0-10)",
9212
+ admin: {
9213
+ description: "\xAB Recommanderiez-vous ? \xBB \u2014 promoteur 9-10, passif 7-8, d\xE9tracteur 0-6"
8284
9214
  }
8285
9215
  },
8286
9216
  {
@@ -9002,6 +9932,16 @@ function createSlaPoliciesCollection(slugs) {
9002
9932
  admin: {
9003
9933
  description: "Utiliser cette politique pour les tickets sans SLA sp\xE9cifique (par priorit\xE9)"
9004
9934
  }
9935
+ },
9936
+ {
9937
+ name: "team",
9938
+ type: "relationship",
9939
+ relationTo: slugs.supportTeams,
9940
+ index: true,
9941
+ label: "\xC9quipe (optionnel)",
9942
+ admin: {
9943
+ description: "Limiter cette politique \xE0 une \xE9quipe \u2014 prioritaire sur la politique par d\xE9faut"
9944
+ }
9005
9945
  }
9006
9946
  ],
9007
9947
  access: {
@@ -9526,6 +10466,148 @@ function createTicketCollaboratorsCollection(slugs) {
9526
10466
  };
9527
10467
  }
9528
10468
 
10469
+ // src/collections/NotificationQueue.ts
10470
+ function createNotificationQueueCollection(slugs) {
10471
+ return {
10472
+ slug: slugs.notificationQueue,
10473
+ labels: { singular: "Notification en file", plural: "Notifications en file" },
10474
+ admin: { hidden: true, group: "Support", defaultColumns: ["client", "type", "title", "createdAt"] },
10475
+ access: {
10476
+ // Internal: written/drained only via overrideAccess (hooks + cron).
10477
+ create: () => false,
10478
+ update: () => false,
10479
+ read: ({ req }) => req.user?.collection === slugs.users,
10480
+ delete: ({ req }) => req.user?.collection === slugs.users
10481
+ },
10482
+ fields: [
10483
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client" },
10484
+ { name: "type", type: "text", label: "Type" },
10485
+ { name: "title", type: "text", label: "Titre" },
10486
+ { name: "message", type: "textarea", label: "Message" },
10487
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, label: "Ticket" }
10488
+ ],
10489
+ timestamps: true
10490
+ };
10491
+ }
10492
+
10493
+ // src/collections/AutomationRules.ts
10494
+ var CONDITION_FIELDS = ["status", "priority", "category", "source", "subject"];
10495
+ var ACTION_TYPES = [
10496
+ { label: "D\xE9finir le statut", value: "set_status" },
10497
+ { label: "D\xE9finir la priorit\xE9", value: "set_priority" },
10498
+ { label: "D\xE9finir la cat\xE9gorie", value: "set_category" },
10499
+ { label: "Assigner \xE0 (id agent)", value: "assign" },
10500
+ { label: "Ajouter un tag", value: "add_tag" }
10501
+ ];
10502
+ function createAutomationRulesCollection(slugs) {
10503
+ return {
10504
+ slug: slugs.automationRules,
10505
+ labels: { singular: "R\xE8gle d'automatisation", plural: "R\xE8gles d'automatisation" },
10506
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "event", "enabled", "order"] },
10507
+ access: {
10508
+ read: ({ req }) => req.user?.collection === slugs.users,
10509
+ create: ({ req }) => req.user?.collection === slugs.users,
10510
+ update: ({ req }) => req.user?.collection === slugs.users,
10511
+ delete: ({ req }) => req.user?.collection === slugs.users
10512
+ },
10513
+ fields: [
10514
+ { name: "name", type: "text", required: true, label: "Nom" },
10515
+ { name: "enabled", type: "checkbox", defaultValue: true, label: "Active", index: true },
10516
+ {
10517
+ name: "event",
10518
+ type: "select",
10519
+ defaultValue: "ticket_created",
10520
+ index: true,
10521
+ label: "D\xE9clencheur",
10522
+ options: [
10523
+ { label: "Ticket cr\xE9\xE9", value: "ticket_created" },
10524
+ { label: "Ticket mis \xE0 jour", value: "ticket_updated" },
10525
+ { label: "Changement de statut", value: "ticket_status_changed" }
10526
+ ]
10527
+ },
10528
+ {
10529
+ name: "matchType",
10530
+ type: "select",
10531
+ defaultValue: "all",
10532
+ label: "Correspondance",
10533
+ options: [{ label: "Toutes les conditions (ET)", value: "all" }, { label: "Au moins une (OU)", value: "any" }]
10534
+ },
10535
+ { name: "order", type: "number", defaultValue: 0, label: "Ordre d'\xE9valuation" },
10536
+ {
10537
+ name: "conditions",
10538
+ type: "array",
10539
+ label: "Conditions",
10540
+ fields: [
10541
+ { name: "field", type: "select", required: true, options: CONDITION_FIELDS.map((v) => ({ label: v, value: v })) },
10542
+ {
10543
+ name: "operator",
10544
+ type: "select",
10545
+ defaultValue: "equals",
10546
+ required: true,
10547
+ options: [{ label: "= \xE9gal", value: "equals" }, { label: "\u2260 diff\xE9rent", value: "not_equals" }, { label: "contient", value: "contains" }]
10548
+ },
10549
+ { name: "value", type: "text", required: true }
10550
+ ]
10551
+ },
10552
+ {
10553
+ name: "actions",
10554
+ type: "array",
10555
+ label: "Actions",
10556
+ fields: [
10557
+ { name: "type", type: "select", required: true, options: ACTION_TYPES },
10558
+ { name: "value", type: "text", required: true }
10559
+ ]
10560
+ }
10561
+ ],
10562
+ timestamps: true
10563
+ };
10564
+ }
10565
+
10566
+ // src/collections/SupportTeam.ts
10567
+ function createSupportTeamCollection(slugs) {
10568
+ return {
10569
+ slug: slugs.supportTeams,
10570
+ labels: { singular: "\xC9quipe support", plural: "\xC9quipes support" },
10571
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "members"] },
10572
+ access: {
10573
+ read: ({ req }) => req.user?.collection === slugs.users,
10574
+ create: ({ req }) => req.user?.collection === slugs.users,
10575
+ update: ({ req }) => req.user?.collection === slugs.users,
10576
+ delete: ({ req }) => req.user?.collection === slugs.users
10577
+ },
10578
+ fields: [
10579
+ { name: "name", type: "text", required: true, label: "Nom" },
10580
+ { name: "description", type: "textarea", label: "Description" },
10581
+ { name: "members", type: "relationship", relationTo: slugs.users, hasMany: true, index: true, label: "Membres (agents)" }
10582
+ ],
10583
+ timestamps: true
10584
+ };
10585
+ }
10586
+
10587
+ // src/collections/PushSubscription.ts
10588
+ function createPushSubscriptionCollection(slugs) {
10589
+ return {
10590
+ slug: slugs.pushSubscriptions,
10591
+ labels: { singular: "Abonnement push", plural: "Abonnements push" },
10592
+ admin: { hidden: true, group: "Support", defaultColumns: ["user", "endpoint", "createdAt"] },
10593
+ access: {
10594
+ create: () => false,
10595
+ // written via the subscribe endpoint (overrideAccess)
10596
+ read: ({ req }) => req.user?.collection === slugs.users,
10597
+ update: () => false,
10598
+ delete: ({ req }) => req.user?.collection === slugs.users
10599
+ },
10600
+ fields: [
10601
+ { name: "user", type: "relationship", relationTo: slugs.users, index: true, label: "Agent" },
10602
+ { name: "endpoint", type: "text", required: true, index: true, label: "Endpoint" },
10603
+ { name: "p256dh", type: "text", label: "Cl\xE9 p256dh" },
10604
+ { name: "auth", type: "text", label: "Cl\xE9 auth" },
10605
+ { name: "userAgent", type: "text", label: "User-Agent" }
10606
+ ],
10607
+ timestamps: true
10608
+ };
10609
+ }
10610
+
9529
10611
  // src/plugin.ts
9530
10612
  function viewConfig(component, path) {
9531
10613
  return { Component: component, path };
@@ -9561,7 +10643,11 @@ function supportPlugin(config) {
9561
10643
  createSatisfactionSurveysCollection(slugs),
9562
10644
  createKnowledgeBaseCollection(slugs),
9563
10645
  createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
9564
- createTicketCollaboratorsCollection(slugs)
10646
+ createTicketCollaboratorsCollection(slugs),
10647
+ createNotificationQueueCollection(slugs),
10648
+ createAutomationRulesCollection(slugs),
10649
+ createSupportTeamCollection(slugs),
10650
+ createPushSubscriptionCollection(slugs)
9565
10651
  ];
9566
10652
  if (features.authLogs !== false) supportCollections.push(createAuthLogsCollection(slugs));
9567
10653
  if (features.timeTracking !== false) supportCollections.push(createTimeEntriesCollection(slugs));