@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.js CHANGED
@@ -1,4 +1,8 @@
1
- import crypto3, { randomBytes, createHmac } from 'crypto';
1
+ import crypto3, { createHmac, randomBytes } from 'crypto';
2
+ import PDFDocument from 'pdfkit';
3
+ import { APIError, getFieldsToSign, jwtSign } from 'payload';
4
+ import webpush from 'web-push';
5
+ import sanitizeHtml from 'sanitize-html';
2
6
 
3
7
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
4
8
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
@@ -58,6 +62,10 @@ var DEFAULT_SLUGS = {
58
62
  macros: "macros",
59
63
  ticketStatuses: "ticket-statuses",
60
64
  ticketFeedback: "ticket-feedback",
65
+ notificationQueue: "notification-queue",
66
+ automationRules: "automation-rules",
67
+ supportTeams: "support-teams",
68
+ pushSubscriptions: "push-subscriptions",
61
69
  users: "users",
62
70
  media: "media"
63
71
  };
@@ -89,6 +97,26 @@ function handleAuthError(error) {
89
97
  return null;
90
98
  }
91
99
 
100
+ // src/utils/db.ts
101
+ function dbFind(payload, slug, options = {}) {
102
+ return payload.find({ collection: slug, ...options });
103
+ }
104
+ function dbFindByID(payload, slug, options) {
105
+ return payload.findByID({ collection: slug, ...options });
106
+ }
107
+ function dbCreate(payload, slug, options) {
108
+ return payload.create({ collection: slug, ...options });
109
+ }
110
+ function dbUpdate(payload, slug, options) {
111
+ return payload.update({ collection: slug, ...options });
112
+ }
113
+ function dbCount(payload, slug, options = {}) {
114
+ return payload.count({ collection: slug, ...options });
115
+ }
116
+ function dbDelete(payload, slug, options) {
117
+ return payload.delete({ collection: slug, ...options });
118
+ }
119
+
92
120
  // src/utils/readSettings.ts
93
121
  var PREF_KEY = "support-settings";
94
122
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
@@ -102,10 +130,18 @@ var DEFAULT_USER_PREFS = {
102
130
  locale: "fr",
103
131
  signature: ""
104
132
  };
133
+ var settingsCache = null;
134
+ var SETTINGS_TTL_MS = 6e4;
135
+ function invalidateSupportSettingsCache() {
136
+ settingsCache = null;
137
+ }
105
138
  async function readSupportSettings(payload) {
139
+ if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
140
+ return settingsCache.value;
141
+ }
142
+ let value = { ...DEFAULT_SETTINGS };
106
143
  try {
107
- const prefs = await payload.find({
108
- collection: "payload-preferences",
144
+ const prefs = await dbFind(payload, "payload-preferences", {
109
145
  where: { key: { equals: PREF_KEY } },
110
146
  limit: 1,
111
147
  depth: 0,
@@ -113,7 +149,7 @@ async function readSupportSettings(payload) {
113
149
  });
114
150
  if (prefs.docs.length > 0) {
115
151
  const stored = prefs.docs[0].value;
116
- return {
152
+ value = {
117
153
  email: { ...DEFAULT_SETTINGS.email, ...stored.email },
118
154
  ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
119
155
  sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
@@ -122,13 +158,13 @@ async function readSupportSettings(payload) {
122
158
  }
123
159
  } catch {
124
160
  }
125
- return { ...DEFAULT_SETTINGS };
161
+ settingsCache = { value, ts: Date.now() };
162
+ return value;
126
163
  }
127
164
  async function readUserPrefs(payload, userId) {
128
165
  try {
129
166
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
130
- const prefs = await payload.find({
131
- collection: "payload-preferences",
167
+ const prefs = await dbFind(payload, "payload-preferences", {
132
168
  where: { key: { equals: key } },
133
169
  limit: 1,
134
170
  depth: 0,
@@ -314,8 +350,130 @@ ${text}`;
314
350
  };
315
351
  }
316
352
 
317
- // src/endpoints/client-intelligence.ts
353
+ // src/utils/aiAgent.ts
354
+ async function applyAgentDecision(payload, slugs, ticketId, decision) {
355
+ if (decision.action === "reply" && decision.reply && decision.reply.trim()) {
356
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
357
+ data: {
358
+ ticket: ticketId,
359
+ body: decision.reply.trim(),
360
+ authorType: "admin",
361
+ isInternal: false,
362
+ fromAlias: "Assistant IA"
363
+ },
364
+ overrideAccess: true
365
+ });
366
+ return { acted: "reply", messageId: msg.id, confidence: decision.confidence };
367
+ }
368
+ await dbCreate(payload, slugs.ticketMessages, {
369
+ data: {
370
+ ticket: ticketId,
371
+ body: `[Agent IA] Escalade vers un humain. Raison : ${decision.reason || "non d\xE9termin\xE9e"} (confiance ${Math.round((decision.confidence ?? 0) * 100)} %).`,
372
+ authorType: "admin",
373
+ isInternal: true,
374
+ skipNotification: true
375
+ },
376
+ overrideAccess: true
377
+ });
378
+ await dbUpdate(payload, slugs.tickets, { id: ticketId, data: { status: "escalated" }, overrideAccess: true });
379
+ return { acted: "escalate", confidence: decision.confidence };
380
+ }
318
381
  function getClient2(aiSettings) {
382
+ const Anthropic = __require("@anthropic-ai/sdk").default;
383
+ if (aiSettings.provider === "ollama") {
384
+ return new Anthropic({ apiKey: "ollama", baseURL: process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1" });
385
+ }
386
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
387
+ }
388
+ function stripHtml(s) {
389
+ return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
390
+ }
391
+ function parseDecision(text) {
392
+ try {
393
+ const match = text.match(/\{[\s\S]*\}/);
394
+ if (match) {
395
+ const obj = JSON.parse(match[0]);
396
+ return {
397
+ action: obj.action === "reply" ? "reply" : "escalate",
398
+ reply: typeof obj.reply === "string" ? obj.reply : void 0,
399
+ reason: typeof obj.reason === "string" ? obj.reason : void 0,
400
+ confidence: typeof obj.confidence === "number" ? obj.confidence : 0
401
+ };
402
+ }
403
+ } catch {
404
+ }
405
+ return { action: "escalate", reason: "R\xE9ponse IA non interpr\xE9table", confidence: 0 };
406
+ }
407
+ async function runAiAgent(payload, slugs, ticketId, opts) {
408
+ const settings = await readSupportSettings(payload);
409
+ const threshold = opts?.confidenceThreshold ?? 0.7;
410
+ const ticket = await dbFindByID(payload, slugs.tickets, { id: ticketId, depth: 0, overrideAccess: true });
411
+ const messages = await dbFind(payload, slugs.ticketMessages, { where: { ticket: { equals: ticketId } }, sort: "createdAt", limit: 50, depth: 0, overrideAccess: true });
412
+ const kb = await dbFind(payload, slugs.knowledgeBase, { where: { published: { equals: true } }, limit: 20, depth: 0, overrideAccess: true });
413
+ const conversation = messages.docs.map((m) => `${m.authorType}: ${m.body || ""}`).join("\n");
414
+ const kbText = kb.docs.map((a) => `# ${a.title || ""}
415
+ ${stripHtml(String(a.content ?? ""))}`).join("\n\n").slice(0, 8e3);
416
+ const prompt = `Tu es un agent de support IA. \xC0 partir du ticket et de la base de connaissances ci-dessous, d\xE9cide :
417
+ - "reply" si tu peux r\xE9pondre avec certitude UNIQUEMENT \xE0 partir de la base de connaissances ;
418
+ - "escalate" sinon (question hors KB, ambigu\xEB, sensible, ou n\xE9cessitant une action humaine).
419
+ 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>}.
420
+
421
+ TICKET : ${ticket.subject || ""}
422
+ CONVERSATION :
423
+ ${conversation}
424
+
425
+ BASE DE CONNAISSANCES :
426
+ ${kbText || "(vide)"}`;
427
+ let decision;
428
+ try {
429
+ const client = getClient2(settings.ai);
430
+ const resp = await client.messages.create({
431
+ model: settings.ai.model || "claude-haiku-4-5-20251001",
432
+ max_tokens: 1024,
433
+ messages: [{ role: "user", content: prompt }]
434
+ });
435
+ const text = resp?.content?.[0]?.text || "";
436
+ decision = parseDecision(text);
437
+ } catch (err) {
438
+ console.error("[support] AI agent decision failed:", err);
439
+ decision = { action: "escalate", reason: "Erreur du fournisseur IA", confidence: 0 };
440
+ }
441
+ 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 };
442
+ return applyAgentDecision(payload, slugs, ticketId, finalDecision);
443
+ }
444
+
445
+ // src/endpoints/ai-agent.ts
446
+ function createAiAgentEndpoint(slugs) {
447
+ return {
448
+ path: "/support/ai-agent",
449
+ method: "post",
450
+ handler: async (req) => {
451
+ try {
452
+ requireAdmin(req, slugs);
453
+ let body = {};
454
+ try {
455
+ body = await req.json();
456
+ } catch {
457
+ }
458
+ if (!body.ticketId) {
459
+ return Response.json({ error: "ticketId requis." }, { status: 400 });
460
+ }
461
+ const result = await runAiAgent(req.payload, slugs, body.ticketId, {
462
+ confidenceThreshold: body.confidenceThreshold
463
+ });
464
+ return Response.json({ ok: true, ...result });
465
+ } catch (error) {
466
+ const authResponse = handleAuthError(error);
467
+ if (authResponse) return authResponse;
468
+ console.error("[ai-agent] Error:", error);
469
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
470
+ }
471
+ }
472
+ };
473
+ }
474
+
475
+ // src/endpoints/client-intelligence.ts
476
+ function getClient3(aiSettings) {
319
477
  const Anthropic = __require("@anthropic-ai/sdk").default;
320
478
  if (aiSettings.provider === "ollama") {
321
479
  const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
@@ -469,7 +627,7 @@ R\xE9ponds en JSON strict (pas de markdown, pas de commentaires) avec cette stru
469
627
  }
470
628
 
471
629
  Sois factuel. Ne d\xE9passe pas 5 items par tableau. R\xE9ponds UNIQUEMENT avec le JSON.`;
472
- const anthropic = getClient2(aiSettings);
630
+ const anthropic = getClient3(aiSettings);
473
631
  const model = getModel2(aiSettings);
474
632
  const res = await anthropic.messages.create({
475
633
  model,
@@ -533,8 +691,7 @@ function createSearchEndpoint(slugs) {
533
691
  return Response.json({ tickets: [], clients: [], messages: [], articles: [] });
534
692
  }
535
693
  const [ticketsRes, clientsRes, messagesRes, articlesRes] = await Promise.all([
536
- payload.find({
537
- collection: slugs.tickets,
694
+ dbFind(payload, slugs.tickets, {
538
695
  where: {
539
696
  or: [
540
697
  { ticketNumber: { contains: q } },
@@ -546,8 +703,7 @@ function createSearchEndpoint(slugs) {
546
703
  depth: 1,
547
704
  overrideAccess: true
548
705
  }),
549
- payload.find({
550
- collection: slugs.supportClients,
706
+ dbFind(payload, slugs.supportClients, {
551
707
  where: {
552
708
  or: [
553
709
  { firstName: { contains: q } },
@@ -560,16 +716,14 @@ function createSearchEndpoint(slugs) {
560
716
  depth: 0,
561
717
  overrideAccess: true
562
718
  }),
563
- payload.find({
564
- collection: slugs.ticketMessages,
719
+ dbFind(payload, slugs.ticketMessages, {
565
720
  where: { body: { contains: q } },
566
721
  sort: "-createdAt",
567
722
  limit: 5,
568
723
  depth: 1,
569
724
  overrideAccess: true
570
725
  }),
571
- payload.find({
572
- collection: slugs.knowledgeBase,
726
+ dbFind(payload, slugs.knowledgeBase, {
573
727
  where: { title: { contains: q } },
574
728
  limit: 5,
575
729
  depth: 0,
@@ -669,8 +823,7 @@ function createKbSearchEndpoint(slugs) {
669
823
  orClauses.push({ category: { contains: t } });
670
824
  }
671
825
  orClauses.push({ title: { contains: q } });
672
- const res = await payload.find({
673
- collection: slugs.knowledgeBase,
826
+ const res = await dbFind(payload, slugs.knowledgeBase, {
674
827
  where: {
675
828
  and: [
676
829
  { published: { equals: true } },
@@ -729,16 +882,14 @@ function createBulkActionEndpoint(slugs) {
729
882
  try {
730
883
  switch (action) {
731
884
  case "close":
732
- await payload.update({
733
- collection: slugs.tickets,
885
+ await dbUpdate(payload, slugs.tickets, {
734
886
  id: ticketId,
735
887
  data: { status: "resolved", resolvedAt: (/* @__PURE__ */ new Date()).toISOString() },
736
888
  overrideAccess: true
737
889
  });
738
890
  break;
739
891
  case "reopen":
740
- await payload.update({
741
- collection: slugs.tickets,
892
+ await dbUpdate(payload, slugs.tickets, {
742
893
  id: ticketId,
743
894
  data: { status: "open" },
744
895
  overrideAccess: true
@@ -746,8 +897,7 @@ function createBulkActionEndpoint(slugs) {
746
897
  break;
747
898
  case "assign":
748
899
  if (value) {
749
- await payload.update({
750
- collection: slugs.tickets,
900
+ await dbUpdate(payload, slugs.tickets, {
751
901
  id: ticketId,
752
902
  data: { assignedTo: Number(value) },
753
903
  overrideAccess: true
@@ -756,8 +906,7 @@ function createBulkActionEndpoint(slugs) {
756
906
  break;
757
907
  case "set_priority":
758
908
  if (value) {
759
- await payload.update({
760
- collection: slugs.tickets,
909
+ await dbUpdate(payload, slugs.tickets, {
761
910
  id: ticketId,
762
911
  data: { priority: String(value) },
763
912
  overrideAccess: true
@@ -766,8 +915,7 @@ function createBulkActionEndpoint(slugs) {
766
915
  break;
767
916
  case "set_category":
768
917
  if (value) {
769
- await payload.update({
770
- collection: slugs.tickets,
918
+ await dbUpdate(payload, slugs.tickets, {
771
919
  id: ticketId,
772
920
  data: { category: String(value) },
773
921
  overrideAccess: true
@@ -775,8 +923,7 @@ function createBulkActionEndpoint(slugs) {
775
923
  }
776
924
  break;
777
925
  case "delete":
778
- await payload.delete({
779
- collection: slugs.tickets,
926
+ await dbDelete(payload, slugs.tickets, {
780
927
  id: ticketId,
781
928
  overrideAccess: true
782
929
  });
@@ -815,8 +962,8 @@ function createMergeTicketsEndpoint(slugs) {
815
962
  return Response.json({ error: "Impossible de fusionner un ticket avec lui-m\xEAme" }, { status: 400 });
816
963
  }
817
964
  const [source, target] = await Promise.all([
818
- payload.findByID({ collection: slugs.tickets, id: sourceTicketId, depth: 0, overrideAccess: true }),
819
- payload.findByID({ collection: slugs.tickets, id: targetTicketId, depth: 0, overrideAccess: true })
965
+ dbFindByID(payload, slugs.tickets, { id: sourceTicketId, depth: 0, overrideAccess: true }),
966
+ dbFindByID(payload, slugs.tickets, { id: targetTicketId, depth: 0, overrideAccess: true })
820
967
  ]);
821
968
  if (!source) return Response.json({ error: "Ticket source introuvable" }, { status: 404 });
822
969
  if (!target) return Response.json({ error: "Ticket cible introuvable" }, { status: 404 });
@@ -825,39 +972,34 @@ function createMergeTicketsEndpoint(slugs) {
825
972
  if (sourceClient !== targetClient) {
826
973
  return Response.json({ error: "Les deux tickets doivent appartenir au m\xEAme client" }, { status: 400 });
827
974
  }
828
- const messages = await payload.find({
829
- collection: slugs.ticketMessages,
975
+ const messages = await dbFind(payload, slugs.ticketMessages, {
830
976
  where: { ticket: { equals: sourceTicketId } },
831
977
  limit: 500,
832
978
  depth: 0,
833
979
  overrideAccess: true
834
980
  });
835
981
  for (const msg of messages.docs) {
836
- await payload.update({
837
- collection: slugs.ticketMessages,
982
+ await dbUpdate(payload, slugs.ticketMessages, {
838
983
  id: msg.id,
839
984
  data: { ticket: targetTicketId },
840
985
  overrideAccess: true
841
986
  });
842
987
  }
843
- const timeEntries = await payload.find({
844
- collection: slugs.timeEntries,
988
+ const timeEntries = await dbFind(payload, slugs.timeEntries, {
845
989
  where: { ticket: { equals: sourceTicketId } },
846
990
  limit: 500,
847
991
  depth: 0,
848
992
  overrideAccess: true
849
993
  });
850
994
  for (const entry of timeEntries.docs) {
851
- await payload.update({
852
- collection: slugs.timeEntries,
995
+ await dbUpdate(payload, slugs.timeEntries, {
853
996
  id: entry.id,
854
997
  data: { ticket: targetTicketId },
855
998
  overrideAccess: true
856
999
  });
857
1000
  }
858
1001
  const sourceNumber = source.ticketNumber || `#${sourceTicketId}`;
859
- await payload.create({
860
- collection: slugs.ticketMessages,
1002
+ await dbCreate(payload, slugs.ticketMessages, {
861
1003
  data: {
862
1004
  ticket: targetTicketId,
863
1005
  body: `Messages fusionn\xE9s depuis ${sourceNumber} (${messages.totalDocs} messages, ${timeEntries.totalDocs} entr\xE9es de temps)`,
@@ -867,13 +1009,11 @@ function createMergeTicketsEndpoint(slugs) {
867
1009
  },
868
1010
  overrideAccess: true
869
1011
  });
870
- await payload.delete({
871
- collection: slugs.tickets,
1012
+ await dbDelete(payload, slugs.tickets, {
872
1013
  id: sourceTicketId,
873
1014
  overrideAccess: true
874
1015
  });
875
- const allTimeEntries = await payload.find({
876
- collection: slugs.timeEntries,
1016
+ const allTimeEntries = await dbFind(payload, slugs.timeEntries, {
877
1017
  where: { ticket: { equals: targetTicketId } },
878
1018
  limit: 500,
879
1019
  depth: 0,
@@ -883,8 +1023,7 @@ function createMergeTicketsEndpoint(slugs) {
883
1023
  (sum, e) => sum + (e.duration || 0),
884
1024
  0
885
1025
  );
886
- await payload.update({
887
- collection: slugs.tickets,
1026
+ await dbUpdate(payload, slugs.tickets, {
888
1027
  id: targetTicketId,
889
1028
  data: { totalTimeMinutes: totalMinutes },
890
1029
  overrideAccess: true
@@ -919,8 +1058,7 @@ function createSplitTicketEndpoint(slugs) {
919
1058
  if (!messageId) {
920
1059
  return Response.json({ error: "messageId required" }, { status: 400 });
921
1060
  }
922
- const message = await payload.findByID({
923
- collection: slugs.ticketMessages,
1061
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
924
1062
  id: messageId,
925
1063
  depth: 1,
926
1064
  overrideAccess: true
@@ -933,8 +1071,7 @@ function createSplitTicketEndpoint(slugs) {
933
1071
  return Response.json({ error: "Cannot resolve source ticket" }, { status: 400 });
934
1072
  }
935
1073
  const clientId = typeof sourceTicket.client === "object" ? sourceTicket.client.id : sourceTicket.client;
936
- const newTicket = await payload.create({
937
- collection: slugs.tickets,
1074
+ const newTicket = await dbCreate(payload, slugs.tickets, {
938
1075
  data: {
939
1076
  subject: subject || `Split: ${sourceTicket.subject}`,
940
1077
  client: clientId,
@@ -949,8 +1086,7 @@ function createSplitTicketEndpoint(slugs) {
949
1086
  const attachments = message.attachments?.map((a) => ({
950
1087
  file: typeof a.file === "object" ? a.file.id : a.file
951
1088
  })) || [];
952
- await payload.create({
953
- collection: slugs.ticketMessages,
1089
+ await dbCreate(payload, slugs.ticketMessages, {
954
1090
  data: {
955
1091
  ticket: newTicket.id,
956
1092
  body: message.body,
@@ -963,8 +1099,7 @@ function createSplitTicketEndpoint(slugs) {
963
1099
  },
964
1100
  overrideAccess: true
965
1101
  });
966
- await payload.create({
967
- collection: slugs.ticketMessages,
1102
+ await dbCreate(payload, slugs.ticketMessages, {
968
1103
  data: {
969
1104
  ticket: sourceTicket.id,
970
1105
  body: `Message extrait vers le ticket ${newTicket.ticketNumber}`,
@@ -976,15 +1111,13 @@ function createSplitTicketEndpoint(slugs) {
976
1111
  });
977
1112
  const existingRelated = Array.isArray(sourceTicket.relatedTickets) ? sourceTicket.relatedTickets.map((t) => typeof t === "object" ? t.id : t) : [];
978
1113
  if (!existingRelated.includes(newTicket.id)) {
979
- await payload.update({
980
- collection: slugs.tickets,
1114
+ await dbUpdate(payload, slugs.tickets, {
981
1115
  id: sourceTicket.id,
982
1116
  data: { relatedTickets: [...existingRelated, newTicket.id] },
983
1117
  overrideAccess: true
984
1118
  });
985
1119
  }
986
- await payload.create({
987
- collection: slugs.ticketActivityLog,
1120
+ await dbCreate(payload, slugs.ticketActivityLog, {
988
1121
  data: {
989
1122
  ticket: sourceTicket.id,
990
1123
  action: "split",
@@ -1199,8 +1332,7 @@ function createSettingsGetEndpoint(slugs) {
1199
1332
  try {
1200
1333
  const payload = req.payload;
1201
1334
  requireAdmin(req, slugs);
1202
- const prefs = await payload.find({
1203
- collection: "payload-preferences",
1335
+ const prefs = await dbFind(payload, "payload-preferences", {
1204
1336
  where: { key: { equals: PREF_KEY2 } },
1205
1337
  limit: 1,
1206
1338
  depth: 0,
@@ -1257,6 +1389,7 @@ function createSettingsPostEndpoint(slugs) {
1257
1389
  ]
1258
1390
  }
1259
1391
  });
1392
+ invalidateSupportSettingsCache();
1260
1393
  return Response.json(merged);
1261
1394
  } catch (error) {
1262
1395
  const authResponse = handleAuthError(error);
@@ -1278,8 +1411,7 @@ function createSignatureGetEndpoint(slugs) {
1278
1411
  try {
1279
1412
  const payload = req.payload;
1280
1413
  requireAdmin(req, slugs);
1281
- const prefs = await payload.find({
1282
- collection: "payload-preferences",
1414
+ const prefs = await dbFind(payload, "payload-preferences", {
1283
1415
  where: { key: { equals: `${PREF_KEY3}-${req.user.id}` } },
1284
1416
  limit: 1,
1285
1417
  depth: 0,
@@ -1306,8 +1438,7 @@ function createSignaturePostEndpoint(slugs) {
1306
1438
  requireAdmin(req, slugs);
1307
1439
  const { signature } = await req.json();
1308
1440
  const key = `${PREF_KEY3}-${req.user.id}`;
1309
- const existing = await payload.find({
1310
- collection: "payload-preferences",
1441
+ const existing = await dbFind(payload, "payload-preferences", {
1311
1442
  where: { key: { equals: key } },
1312
1443
  limit: 1,
1313
1444
  depth: 0,
@@ -1350,8 +1481,7 @@ function createRoundRobinConfigGetEndpoint(slugs) {
1350
1481
  try {
1351
1482
  const payload = req.payload;
1352
1483
  requireAdmin(req, slugs);
1353
- const prefs = await payload.find({
1354
- collection: "payload-preferences",
1484
+ const prefs = await dbFind(payload, "payload-preferences", {
1355
1485
  where: { key: { equals: PREF_KEY4 } },
1356
1486
  limit: 1,
1357
1487
  depth: 0,
@@ -1377,8 +1507,7 @@ function createRoundRobinConfigPostEndpoint(slugs) {
1377
1507
  const payload = req.payload;
1378
1508
  requireAdmin(req, slugs);
1379
1509
  const { enabled } = await req.json();
1380
- const existing = await payload.find({
1381
- collection: "payload-preferences",
1510
+ const existing = await dbFind(payload, "payload-preferences", {
1382
1511
  where: { key: { equals: PREF_KEY4 } },
1383
1512
  limit: 1,
1384
1513
  depth: 0,
@@ -1422,8 +1551,7 @@ function createSlaCheckEndpoint(slugs) {
1422
1551
  requireAdmin(req, slugs);
1423
1552
  const now = /* @__PURE__ */ new Date();
1424
1553
  const nowISO = now.toISOString();
1425
- const { docs: tickets } = await payload.find({
1426
- collection: slugs.tickets,
1554
+ const { docs: tickets } = await dbFind(payload, slugs.tickets, {
1427
1555
  where: {
1428
1556
  and: [
1429
1557
  { status: { in: ["open", "waiting_client"] } },
@@ -1517,6 +1645,107 @@ function createSlaCheckEndpoint(slugs) {
1517
1645
  }
1518
1646
  };
1519
1647
  }
1648
+ var TRANSPARENT_GIF = Buffer.from(
1649
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
1650
+ "base64"
1651
+ );
1652
+ function generateTrackingToken(ticketId, messageId, secret) {
1653
+ return createHmac("sha256", secret).update(`${ticketId}:${messageId}`).digest("hex").substring(0, 16);
1654
+ }
1655
+ function createTrackOpenEndpoint(slugs) {
1656
+ return {
1657
+ path: "/support/track-open",
1658
+ method: "get",
1659
+ handler: async (req) => {
1660
+ const url = new URL(req.url);
1661
+ const ticketId = url.searchParams.get("t");
1662
+ const messageId = url.searchParams.get("m");
1663
+ const sig = url.searchParams.get("sig");
1664
+ const parsedId = ticketId ? Number(ticketId) : NaN;
1665
+ const parsedMsgId = messageId ? Number(messageId) : NaN;
1666
+ const secret = process.env.PAYLOAD_SECRET || "";
1667
+ const validSig = !!secret && !!ticketId && !!messageId && !!sig && sig === generateTrackingToken(ticketId, messageId, secret);
1668
+ if (!validSig) {
1669
+ return new Response(TRANSPARENT_GIF, {
1670
+ status: 200,
1671
+ headers: {
1672
+ "Content-Type": "image/gif",
1673
+ "Content-Length": String(TRANSPARENT_GIF.length),
1674
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
1675
+ }
1676
+ });
1677
+ }
1678
+ if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
1679
+ try {
1680
+ const payload = req.payload;
1681
+ const ticket = await dbFindByID(payload, slugs.tickets, {
1682
+ id: parsedId,
1683
+ depth: 0,
1684
+ overrideAccess: true,
1685
+ select: { lastClientReadAt: true }
1686
+ });
1687
+ if (ticket) {
1688
+ const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0;
1689
+ const fiveMinAgo = Date.now() - 5 * 60 * 1e3;
1690
+ if (lastRead < fiveMinAgo) {
1691
+ await dbUpdate(payload, slugs.tickets, {
1692
+ id: parsedId,
1693
+ data: { lastClientReadAt: (/* @__PURE__ */ new Date()).toISOString() },
1694
+ overrideAccess: true
1695
+ });
1696
+ }
1697
+ }
1698
+ if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
1699
+ const msg = await dbFindByID(payload, slugs.ticketMessages, {
1700
+ id: parsedMsgId,
1701
+ depth: 0,
1702
+ overrideAccess: true,
1703
+ select: { emailOpenedAt: true }
1704
+ });
1705
+ if (msg && !msg.emailOpenedAt) {
1706
+ await dbUpdate(payload, slugs.ticketMessages, {
1707
+ id: parsedMsgId,
1708
+ data: { emailOpenedAt: (/* @__PURE__ */ new Date()).toISOString() },
1709
+ overrideAccess: true
1710
+ });
1711
+ const ticketInfo = await dbFindByID(payload, slugs.tickets, {
1712
+ id: parsedId,
1713
+ depth: 1,
1714
+ overrideAccess: true,
1715
+ select: { ticketNumber: true, subject: true, client: true }
1716
+ });
1717
+ const clientName = typeof ticketInfo?.client === "object" ? ticketInfo.client?.firstName || "Client" : "Client";
1718
+ try {
1719
+ await dbCreate(payload, "admin-notifications", {
1720
+ data: {
1721
+ title: `Email ouvert \u2014 ${ticketInfo?.ticketNumber || "TK-????"}`,
1722
+ message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || "ticket"}"`,
1723
+ type: "email_opened",
1724
+ link: `/admin/ticket?id=${parsedId}`
1725
+ },
1726
+ overrideAccess: true
1727
+ });
1728
+ } catch {
1729
+ }
1730
+ }
1731
+ }
1732
+ } catch (err) {
1733
+ console.error("[track-open] Error:", err);
1734
+ }
1735
+ }
1736
+ return new Response(TRANSPARENT_GIF, {
1737
+ status: 200,
1738
+ headers: {
1739
+ "Content-Type": "image/gif",
1740
+ "Content-Length": String(TRANSPARENT_GIF.length),
1741
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
1742
+ "Pragma": "no-cache",
1743
+ "Expires": "0"
1744
+ }
1745
+ });
1746
+ }
1747
+ };
1748
+ }
1520
1749
 
1521
1750
  // src/utils/emailTemplate.ts
1522
1751
  var COLORS = {
@@ -1553,7 +1782,10 @@ function escapeHtml(str) {
1553
1782
  }
1554
1783
  function emailTrackingPixel(ticketId, messageId, baseUrl) {
1555
1784
  const url = process.env.NEXT_PUBLIC_SERVER_URL || "";
1556
- const params = `t=${ticketId}${messageId ? `&m=${messageId}` : ""}`;
1785
+ const secret = process.env.PAYLOAD_SECRET || "";
1786
+ if (!secret || messageId === void 0 || messageId === null) return "";
1787
+ const sig = generateTrackingToken(String(ticketId), String(messageId), secret);
1788
+ const params = `t=${ticketId}&m=${messageId}&sig=${sig}`;
1557
1789
  return `<img src="${url}/api/support/track-open?${params}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`;
1558
1790
  }
1559
1791
  function emailRichContent(html, config) {
@@ -1818,8 +2050,7 @@ function createAutoCloseEndpoint(slugs) {
1818
2050
  const CLOSE_AFTER_REMIND_DAYS = settings.autoClose.reminderDaysBefore;
1819
2051
  const results = { reminded: 0, closed: 0, errors: 0 };
1820
2052
  const remindCutoff = new Date(now.getTime() - REMIND_AFTER_DAYS * 24 * 60 * 60 * 1e3);
1821
- const ticketsToRemind = await payload.find({
1822
- collection: slugs.tickets,
2053
+ const ticketsToRemind = await dbFind(payload, slugs.tickets, {
1823
2054
  where: {
1824
2055
  and: [
1825
2056
  { status: { equals: "waiting_client" } },
@@ -1850,8 +2081,7 @@ function createAutoCloseEndpoint(slugs) {
1850
2081
  <p><a href="${portalUrl}">R\xE9pondre au ticket</a></p>
1851
2082
  </div>`
1852
2083
  });
1853
- await payload.update({
1854
- collection: slugs.tickets,
2084
+ await dbUpdate(payload, slugs.tickets, {
1855
2085
  id: t.id,
1856
2086
  data: { autoCloseRemindedAt: now.toISOString() },
1857
2087
  overrideAccess: true
@@ -1863,8 +2093,7 @@ function createAutoCloseEndpoint(slugs) {
1863
2093
  }
1864
2094
  }
1865
2095
  const closeCutoff = new Date(now.getTime() - CLOSE_AFTER_REMIND_DAYS * 24 * 60 * 60 * 1e3);
1866
- const ticketsToClose = await payload.find({
1867
- collection: slugs.tickets,
2096
+ const ticketsToClose = await dbFind(payload, slugs.tickets, {
1868
2097
  where: {
1869
2098
  and: [
1870
2099
  { status: { equals: "waiting_client" } },
@@ -1899,8 +2128,7 @@ function createAutoCloseEndpoint(slugs) {
1899
2128
  const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS;
1900
2129
  const viaSchedule = !!t.autoCloseScheduledAt && new Date(t.autoCloseScheduledAt).getTime() <= now.getTime();
1901
2130
  const noteBody = viaSchedule ? "Ticket r\xE9solu automatiquement \u2014 relance manuelle rest\xE9e sans r\xE9ponse du client" : `Ticket r\xE9solu automatiquement \u2014 sans r\xE9ponse client depuis ${closeTotalDays} jours`;
1902
- await payload.create({
1903
- collection: slugs.ticketMessages,
2131
+ await dbCreate(payload, slugs.ticketMessages, {
1904
2132
  data: {
1905
2133
  ticket: t.id,
1906
2134
  body: noteBody,
@@ -1910,8 +2138,7 @@ function createAutoCloseEndpoint(slugs) {
1910
2138
  },
1911
2139
  overrideAccess: true
1912
2140
  });
1913
- await payload.update({
1914
- collection: slugs.tickets,
2141
+ await dbUpdate(payload, slugs.tickets, {
1915
2142
  id: t.id,
1916
2143
  // Clear the armed deadline so a later manual reopen can't be
1917
2144
  // closed instantly by a stale timestamp.
@@ -2034,8 +2261,7 @@ function createSendReminderEndpoint(slugs) {
2034
2261
  }
2035
2262
  const rawHours = Number(body.hours);
2036
2263
  const hours = Number.isFinite(rawHours) ? Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.round(rawHours))) : 24;
2037
- const ticket = await payload.findByID({
2038
- collection: slugs.tickets,
2264
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2039
2265
  id: ticketId,
2040
2266
  depth: 1,
2041
2267
  overrideAccess: true
@@ -2057,8 +2283,7 @@ function createSendReminderEndpoint(slugs) {
2057
2283
  const replyTo = settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || "";
2058
2284
  let waitingSince = ticket.firstResponseAt || ticket.createdAt;
2059
2285
  try {
2060
- const lastAdminMsg = await payload.find({
2061
- collection: slugs.ticketMessages,
2286
+ const lastAdminMsg = await dbFind(payload, slugs.ticketMessages, {
2062
2287
  where: {
2063
2288
  and: [
2064
2289
  { ticket: { equals: ticketId } },
@@ -2099,8 +2324,7 @@ function createSendReminderEndpoint(slugs) {
2099
2324
  preheader: `Sans r\xE9ponse de votre part, votre ticket ${ticketNumber} sera cl\xF4tur\xE9 le ${deadlineLabel}.`
2100
2325
  })
2101
2326
  });
2102
- await payload.create({
2103
- collection: slugs.ticketMessages,
2327
+ await dbCreate(payload, slugs.ticketMessages, {
2104
2328
  data: {
2105
2329
  ticket: ticketId,
2106
2330
  body: `Relance envoy\xE9e \xE0 ${client.email}. Fermeture automatique programm\xE9e le ${deadlineLabel} sans r\xE9ponse du client.`,
@@ -2110,8 +2334,7 @@ function createSendReminderEndpoint(slugs) {
2110
2334
  },
2111
2335
  overrideAccess: true
2112
2336
  });
2113
- await payload.update({
2114
- collection: slugs.tickets,
2337
+ await dbUpdate(payload, slugs.tickets, {
2115
2338
  id: ticketId,
2116
2339
  data: {
2117
2340
  status: "waiting_client",
@@ -2146,8 +2369,7 @@ function createStatusesEndpoint(slugs) {
2146
2369
  if (!req.user) {
2147
2370
  return Response.json({ error: "Unauthorized" }, { status: 401 });
2148
2371
  }
2149
- const { docs } = await payload.find({
2150
- collection: slugs.ticketStatuses,
2372
+ const { docs } = await dbFind(payload, slugs.ticketStatuses, {
2151
2373
  sort: "sortOrder",
2152
2374
  limit: 100,
2153
2375
  depth: 0,
@@ -2186,16 +2408,14 @@ function createApplyMacroEndpoint(slugs) {
2186
2408
  if (!macroId || !ticketId) {
2187
2409
  return Response.json({ error: "macroId and ticketId are required" }, { status: 400 });
2188
2410
  }
2189
- const macro = await payload.findByID({
2190
- collection: slugs.macros,
2411
+ const macro = await dbFindByID(payload, slugs.macros, {
2191
2412
  id: macroId,
2192
2413
  depth: 0,
2193
2414
  overrideAccess: true
2194
2415
  });
2195
2416
  if (!macro) return Response.json({ error: "Macro not found" }, { status: 404 });
2196
2417
  if (!macro.isActive) return Response.json({ error: "Macro is disabled" }, { status: 400 });
2197
- const ticket = await payload.findByID({
2198
- collection: slugs.tickets,
2418
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2199
2419
  id: ticketId,
2200
2420
  depth: 0,
2201
2421
  overrideAccess: true
@@ -2207,8 +2427,7 @@ function createApplyMacroEndpoint(slugs) {
2207
2427
  try {
2208
2428
  switch (action.type) {
2209
2429
  case "set_status":
2210
- await payload.update({
2211
- collection: slugs.tickets,
2430
+ await dbUpdate(payload, slugs.tickets, {
2212
2431
  id: ticketId,
2213
2432
  data: { status: action.value },
2214
2433
  overrideAccess: true
@@ -2216,8 +2435,7 @@ function createApplyMacroEndpoint(slugs) {
2216
2435
  appliedActions.push({ type: action.type, value: action.value, success: true });
2217
2436
  break;
2218
2437
  case "set_priority":
2219
- await payload.update({
2220
- collection: slugs.tickets,
2438
+ await dbUpdate(payload, slugs.tickets, {
2221
2439
  id: ticketId,
2222
2440
  data: { priority: action.value },
2223
2441
  overrideAccess: true
@@ -2229,8 +2447,7 @@ function createApplyMacroEndpoint(slugs) {
2229
2447
  if (!currentTags.includes(action.value)) {
2230
2448
  currentTags.push(action.value);
2231
2449
  }
2232
- await payload.update({
2233
- collection: slugs.tickets,
2450
+ await dbUpdate(payload, slugs.tickets, {
2234
2451
  id: ticketId,
2235
2452
  data: { tags: currentTags },
2236
2453
  overrideAccess: true
@@ -2239,8 +2456,7 @@ function createApplyMacroEndpoint(slugs) {
2239
2456
  break;
2240
2457
  }
2241
2458
  case "send_reply":
2242
- await payload.create({
2243
- collection: slugs.ticketMessages,
2459
+ await dbCreate(payload, slugs.ticketMessages, {
2244
2460
  data: {
2245
2461
  ticket: ticketId,
2246
2462
  body: action.value,
@@ -2257,8 +2473,7 @@ function createApplyMacroEndpoint(slugs) {
2257
2473
  appliedActions.push({ type: action.type, value: action.value, success: false, error: "Invalid user ID" });
2258
2474
  break;
2259
2475
  }
2260
- await payload.update({
2261
- collection: slugs.tickets,
2476
+ await dbUpdate(payload, slugs.tickets, {
2262
2477
  id: ticketId,
2263
2478
  data: { assignedTo: userId },
2264
2479
  overrideAccess: true
@@ -2310,8 +2525,7 @@ function createPurgeLogsEndpoint(slugs) {
2310
2525
  return Response.json({ error: "Invalid collection. Use email-logs or auth-logs." }, { status: 400 });
2311
2526
  }
2312
2527
  const cutoff = days > 0 ? new Date(Date.now() - days * 864e5).toISOString() : null;
2313
- const result = await payload.delete({
2314
- collection: allowedCollections[collection],
2528
+ const result = await dbDelete(payload, allowedCollections[collection], {
2315
2529
  where: cutoff ? { createdAt: { less_than: cutoff } } : { id: { exists: true } },
2316
2530
  overrideAccess: true
2317
2531
  });
@@ -2354,8 +2568,7 @@ function createChatbotEndpoint(slugs) {
2354
2568
  return Response.json({ error: "Question too short" }, { status: 400 });
2355
2569
  }
2356
2570
  const payload = req.payload;
2357
- const articles = await payload.find({
2358
- collection: slugs.knowledgeBase,
2571
+ const articles = await dbFind(payload, slugs.knowledgeBase, {
2359
2572
  where: { published: { equals: true } },
2360
2573
  limit: 100,
2361
2574
  depth: 0,
@@ -2445,8 +2658,7 @@ function createChatGetEndpoint(slugs) {
2445
2658
  if (after) {
2446
2659
  where.createdAt = { greater_than: after };
2447
2660
  }
2448
- const messages = await payload.find({
2449
- collection: slugs.chatMessages,
2661
+ const messages = await dbFind(payload, slugs.chatMessages, {
2450
2662
  where,
2451
2663
  sort: "createdAt",
2452
2664
  limit: 100,
@@ -2489,8 +2701,7 @@ function createChatPostEndpoint(slugs) {
2489
2701
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
2490
2702
  }
2491
2703
  const sessionId = `chat_${crypto3.randomBytes(16).toString("hex")}`;
2492
- const systemMsg = await payload.create({
2493
- collection: slugs.chatMessages,
2704
+ const systemMsg = await dbCreate(payload, slugs.chatMessages, {
2494
2705
  data: {
2495
2706
  session: sessionId,
2496
2707
  client: req.user.id,
@@ -2510,8 +2721,7 @@ function createChatPostEndpoint(slugs) {
2510
2721
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2511
2722
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2512
2723
  }
2513
- const existing = await payload.find({
2514
- collection: slugs.chatMessages,
2724
+ const existing = await dbFind(payload, slugs.chatMessages, {
2515
2725
  where: { session: { equals: session }, client: { equals: req.user.id } },
2516
2726
  limit: 1,
2517
2727
  overrideAccess: true
@@ -2519,8 +2729,7 @@ function createChatPostEndpoint(slugs) {
2519
2729
  if (existing.docs.length === 0) {
2520
2730
  return Response.json({ error: "Session invalide" }, { status: 403 });
2521
2731
  }
2522
- const newMsg = await payload.create({
2523
- collection: slugs.chatMessages,
2732
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2524
2733
  data: {
2525
2734
  session,
2526
2735
  client: req.user.id,
@@ -2533,8 +2742,7 @@ function createChatPostEndpoint(slugs) {
2533
2742
  return Response.json({ message: newMsg });
2534
2743
  }
2535
2744
  if (action === "close" && session) {
2536
- const existing = await payload.find({
2537
- collection: slugs.chatMessages,
2745
+ const existing = await dbFind(payload, slugs.chatMessages, {
2538
2746
  where: { session: { equals: session }, client: { equals: req.user.id } },
2539
2747
  limit: 1,
2540
2748
  overrideAccess: true
@@ -2542,8 +2750,7 @@ function createChatPostEndpoint(slugs) {
2542
2750
  if (existing.docs.length === 0) {
2543
2751
  return Response.json({ error: "Session invalide" }, { status: 403 });
2544
2752
  }
2545
- await payload.create({
2546
- collection: slugs.chatMessages,
2753
+ await dbCreate(payload, slugs.chatMessages, {
2547
2754
  data: {
2548
2755
  session,
2549
2756
  client: req.user.id,
@@ -2585,8 +2792,7 @@ function createChatStreamEndpoint(slugs) {
2585
2792
  if (!sessionId) {
2586
2793
  return Response.json({ error: "Missing session parameter" }, { status: 400 });
2587
2794
  }
2588
- const existing = await req.payload.find({
2589
- collection: slugs.chatMessages,
2795
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
2590
2796
  where: {
2591
2797
  session: { equals: sessionId },
2592
2798
  client: { equals: userId }
@@ -2608,8 +2814,7 @@ function createChatStreamEndpoint(slugs) {
2608
2814
  );
2609
2815
  const interval = setInterval(async () => {
2610
2816
  try {
2611
- const messages = await req.payload.find({
2612
- collection: slugs.chatMessages,
2817
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2613
2818
  where: {
2614
2819
  session: { equals: sessionId },
2615
2820
  createdAt: { greater_than: lastCheck }
@@ -2690,8 +2895,7 @@ function createAdminChatGetEndpoint(slugs) {
2690
2895
  if (session) {
2691
2896
  const where = { session: { equals: session } };
2692
2897
  if (after) where.createdAt = { greater_than: after };
2693
- const messages = await payload.find({
2694
- collection: slugs.chatMessages,
2898
+ const messages = await dbFind(payload, slugs.chatMessages, {
2695
2899
  where,
2696
2900
  sort: "createdAt",
2697
2901
  limit: 200,
@@ -2705,8 +2909,7 @@ function createAdminChatGetEndpoint(slugs) {
2705
2909
  }
2706
2910
  });
2707
2911
  }
2708
- const recentMessages = await payload.find({
2709
- collection: slugs.chatMessages,
2912
+ const recentMessages = await dbFind(payload, slugs.chatMessages, {
2710
2913
  sort: "-createdAt",
2711
2914
  limit: 50,
2712
2915
  depth: 1,
@@ -2769,8 +2972,7 @@ function createAdminChatPostEndpoint(slugs) {
2769
2972
  if (!session) {
2770
2973
  return Response.json({ error: "Session requise" }, { status: 400 });
2771
2974
  }
2772
- const sessionMsg = await payload.find({
2773
- collection: slugs.chatMessages,
2975
+ const sessionMsg = await dbFind(payload, slugs.chatMessages, {
2774
2976
  where: { session: { equals: session } },
2775
2977
  limit: 1,
2776
2978
  depth: 0,
@@ -2788,8 +2990,7 @@ function createAdminChatPostEndpoint(slugs) {
2788
2990
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2789
2991
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2790
2992
  }
2791
- const newMsg = await payload.create({
2792
- collection: slugs.chatMessages,
2993
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2793
2994
  data: {
2794
2995
  session,
2795
2996
  client: clientId,
@@ -2801,8 +3002,7 @@ function createAdminChatPostEndpoint(slugs) {
2801
3002
  overrideAccess: true
2802
3003
  });
2803
3004
  try {
2804
- const linkedTicket = await payload.find({
2805
- collection: slugs.tickets,
3005
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2806
3006
  where: { chatSession: { equals: session } },
2807
3007
  limit: 1,
2808
3008
  depth: 0,
@@ -2810,8 +3010,7 @@ function createAdminChatPostEndpoint(slugs) {
2810
3010
  });
2811
3011
  if (linkedTicket.docs.length > 0) {
2812
3012
  const ticketId = linkedTicket.docs[0].id;
2813
- await payload.create({
2814
- collection: slugs.ticketMessages,
3013
+ await dbCreate(payload, slugs.ticketMessages, {
2815
3014
  data: {
2816
3015
  ticket: ticketId,
2817
3016
  body: trimmedMessage,
@@ -2820,8 +3019,7 @@ function createAdminChatPostEndpoint(slugs) {
2820
3019
  },
2821
3020
  overrideAccess: true
2822
3021
  });
2823
- await payload.update({
2824
- collection: slugs.chatMessages,
3022
+ await dbUpdate(payload, slugs.chatMessages, {
2825
3023
  id: newMsg.id,
2826
3024
  data: { ticket: ticketId },
2827
3025
  overrideAccess: true
@@ -2833,8 +3031,7 @@ function createAdminChatPostEndpoint(slugs) {
2833
3031
  return Response.json({ message: newMsg });
2834
3032
  }
2835
3033
  if (action === "close") {
2836
- await payload.create({
2837
- collection: slugs.chatMessages,
3034
+ await dbCreate(payload, slugs.chatMessages, {
2838
3035
  data: {
2839
3036
  session,
2840
3037
  client: clientId,
@@ -2845,16 +3042,14 @@ function createAdminChatPostEndpoint(slugs) {
2845
3042
  overrideAccess: true
2846
3043
  });
2847
3044
  try {
2848
- const linkedTicket = await payload.find({
2849
- collection: slugs.tickets,
3045
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2850
3046
  where: { chatSession: { equals: session } },
2851
3047
  limit: 1,
2852
3048
  depth: 0,
2853
3049
  overrideAccess: true
2854
3050
  });
2855
3051
  if (linkedTicket.docs.length > 0) {
2856
- await payload.create({
2857
- collection: slugs.ticketMessages,
3052
+ await dbCreate(payload, slugs.ticketMessages, {
2858
3053
  data: {
2859
3054
  ticket: linkedTicket.docs[0].id,
2860
3055
  body: "Session de chat termin\xE9e par un agent.",
@@ -2917,8 +3112,7 @@ function createSessionStream(req, slugs, sessionId) {
2917
3112
  );
2918
3113
  const interval = setInterval(async () => {
2919
3114
  try {
2920
- const messages = await req.payload.find({
2921
- collection: slugs.chatMessages,
3115
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2922
3116
  where: {
2923
3117
  session: { equals: sessionId },
2924
3118
  createdAt: { greater_than: lastCheck }
@@ -2991,8 +3185,7 @@ function createSessionListStream(req, slugs) {
2991
3185
  );
2992
3186
  const interval = setInterval(async () => {
2993
3187
  try {
2994
- const recentMessages = await req.payload.find({
2995
- collection: slugs.chatMessages,
3188
+ const recentMessages = await dbFind(req.payload, slugs.chatMessages, {
2996
3189
  sort: "-createdAt",
2997
3190
  limit: 50,
2998
3191
  depth: 1,
@@ -3076,12 +3269,13 @@ function createAdminStatsEndpoint(slugs) {
3076
3269
  try {
3077
3270
  const payload = req.payload;
3078
3271
  requireAdmin(req, slugs);
3272
+ const teamId = new URL(req.url).searchParams.get("teamId");
3273
+ const teamAnd = teamId ? [{ team: { equals: Number(teamId) } }] : [];
3079
3274
  const statuses = ["open", "waiting_client", "resolved"];
3080
3275
  const statusCounts = await Promise.all(
3081
3276
  statuses.map(async (status) => {
3082
- const result = await payload.count({
3083
- collection: slugs.tickets,
3084
- where: { status: { equals: status } },
3277
+ const result = await dbCount(payload, slugs.tickets, {
3278
+ where: { and: [{ status: { equals: status } }, ...teamAnd] },
3085
3279
  overrideAccess: true
3086
3280
  });
3087
3281
  return [status, result.totalDocs];
@@ -3092,9 +3286,8 @@ function createAdminStatsEndpoint(slugs) {
3092
3286
  const priorities = ["low", "normal", "high", "urgent"];
3093
3287
  const priorityCounts = await Promise.all(
3094
3288
  priorities.map(async (priority) => {
3095
- const result = await payload.count({
3096
- collection: slugs.tickets,
3097
- where: { priority: { equals: priority } },
3289
+ const result = await dbCount(payload, slugs.tickets, {
3290
+ where: { and: [{ priority: { equals: priority } }, ...teamAnd] },
3098
3291
  overrideAccess: true
3099
3292
  });
3100
3293
  return [priority, result.totalDocs];
@@ -3104,9 +3297,8 @@ function createAdminStatsEndpoint(slugs) {
3104
3297
  const categories = ["bug", "content", "feature", "question", "hosting"];
3105
3298
  const categoryCounts = await Promise.all(
3106
3299
  categories.map(async (category) => {
3107
- const result = await payload.count({
3108
- collection: slugs.tickets,
3109
- where: { category: { equals: category } },
3300
+ const result = await dbCount(payload, slugs.tickets, {
3301
+ where: { and: [{ category: { equals: category } }, ...teamAnd] },
3110
3302
  overrideAccess: true
3111
3303
  });
3112
3304
  return [category, result.totalDocs];
@@ -3119,17 +3311,34 @@ function createAdminStatsEndpoint(slugs) {
3119
3311
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
3120
3312
  const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
3121
3313
  const [created7, created30] = await Promise.all([
3122
- payload.count({
3123
- collection: slugs.tickets,
3314
+ dbCount(payload, slugs.tickets, {
3124
3315
  where: { createdAt: { greater_than_equal: sevenDaysAgo.toISOString() } },
3125
3316
  overrideAccess: true
3126
3317
  }),
3127
- payload.count({
3128
- collection: slugs.tickets,
3318
+ dbCount(payload, slugs.tickets, {
3129
3319
  where: { createdAt: { greater_than_equal: thirtyDaysAgo.toISOString() } },
3130
3320
  overrideAccess: true
3131
3321
  })
3132
3322
  ]);
3323
+ const volumeByDay = await Promise.all(
3324
+ Array.from({ length: 7 }, (_, i) => i).map(async (offset) => {
3325
+ const dayStart = new Date(now);
3326
+ dayStart.setHours(0, 0, 0, 0);
3327
+ dayStart.setDate(dayStart.getDate() - (6 - offset));
3328
+ const dayEnd = new Date(dayStart);
3329
+ dayEnd.setDate(dayEnd.getDate() + 1);
3330
+ const r = await dbCount(payload, slugs.tickets, {
3331
+ where: {
3332
+ and: [
3333
+ { createdAt: { greater_than_equal: dayStart.toISOString() } },
3334
+ { createdAt: { less_than: dayEnd.toISOString() } }
3335
+ ]
3336
+ },
3337
+ overrideAccess: true
3338
+ });
3339
+ return { date: dayStart.toISOString(), count: r.totalDocs };
3340
+ })
3341
+ );
3133
3342
  const PAGE_SIZE2 = 100;
3134
3343
  const MAX_PAGES2 = 50;
3135
3344
  let totalResponseTimeMs = 0;
@@ -3140,8 +3349,7 @@ function createAdminStatsEndpoint(slugs) {
3140
3349
  let page = 1;
3141
3350
  let hasMore = true;
3142
3351
  while (hasMore && page <= MAX_PAGES2) {
3143
- const batch = await payload.find({
3144
- collection: slugs.tickets,
3352
+ const batch = await dbFind(payload, slugs.tickets, {
3145
3353
  limit: PAGE_SIZE2,
3146
3354
  page,
3147
3355
  depth: 0,
@@ -3174,43 +3382,62 @@ function createAdminStatsEndpoint(slugs) {
3174
3382
  hasMore = batch.hasNextPage ?? false;
3175
3383
  page++;
3176
3384
  }
3177
- const surveyCount = await payload.count({
3178
- collection: slugs.satisfactionSurveys,
3385
+ const surveyCount = await dbCount(payload, slugs.satisfactionSurveys, {
3179
3386
  overrideAccess: true
3180
3387
  });
3181
3388
  let satisfactionAvg = 0;
3389
+ let satisfactionCount = 0;
3390
+ let npsScore = null;
3391
+ let npsCount = 0;
3182
3392
  if (surveyCount.totalDocs > 0) {
3183
- let totalRating = 0;
3393
+ let ratingSum = 0;
3394
+ let ratingN = 0;
3395
+ let promoters = 0;
3396
+ let detractors = 0;
3397
+ let npsN = 0;
3184
3398
  let surveyPage = 1;
3185
3399
  let surveyHasMore = true;
3186
3400
  while (surveyHasMore && surveyPage <= MAX_PAGES2) {
3187
- const batch = await payload.find({
3188
- collection: slugs.satisfactionSurveys,
3401
+ const batch = await dbFind(payload, slugs.satisfactionSurveys, {
3189
3402
  limit: PAGE_SIZE2,
3190
3403
  page: surveyPage,
3191
3404
  depth: 0,
3192
3405
  overrideAccess: true,
3193
- select: { rating: true }
3406
+ select: { rating: true, nps: true }
3194
3407
  });
3195
3408
  for (const s of batch.docs) {
3196
- totalRating += s.rating || 0;
3409
+ const r = s.rating;
3410
+ if (typeof r === "number" && r > 0) {
3411
+ ratingSum += r;
3412
+ ratingN++;
3413
+ }
3414
+ const n = s.nps;
3415
+ if (typeof n === "number") {
3416
+ npsN++;
3417
+ if (n >= 9) promoters++;
3418
+ else if (n <= 6) detractors++;
3419
+ }
3197
3420
  }
3198
3421
  surveyHasMore = batch.hasNextPage ?? false;
3199
3422
  surveyPage++;
3200
3423
  }
3201
- satisfactionAvg = Math.round(totalRating / surveyCount.totalDocs * 10) / 10;
3424
+ satisfactionAvg = ratingN > 0 ? Math.round(ratingSum / ratingN * 10) / 10 : 0;
3425
+ satisfactionCount = ratingN;
3426
+ if (npsN > 0) {
3427
+ npsScore = Math.round((promoters - detractors) / npsN * 100);
3428
+ npsCount = npsN;
3429
+ }
3202
3430
  }
3203
- const [clientCount, pendingEmailsCount] = await Promise.all([
3204
- payload.count({
3205
- collection: slugs.supportClients,
3206
- overrideAccess: true
3207
- }),
3208
- payload.count({
3209
- collection: slugs.pendingEmails,
3431
+ const clientCount = await dbCount(payload, slugs.supportClients, { overrideAccess: true });
3432
+ let pendingEmailsTotal = 0;
3433
+ try {
3434
+ const pe = await dbCount(payload, slugs.pendingEmails, {
3210
3435
  where: { status: { equals: "pending" } },
3211
3436
  overrideAccess: true
3212
- })
3213
- ]);
3437
+ });
3438
+ pendingEmailsTotal = pe.totalDocs;
3439
+ } catch {
3440
+ }
3214
3441
  return new Response(JSON.stringify({
3215
3442
  total,
3216
3443
  byStatus,
@@ -3218,13 +3445,16 @@ function createAdminStatsEndpoint(slugs) {
3218
3445
  byCategory,
3219
3446
  createdLast7Days: created7.totalDocs,
3220
3447
  createdLast30Days: created30.totalDocs,
3448
+ volumeByDay,
3221
3449
  avgResponseTimeHours: responseTimeCount > 0 ? Math.round(totalResponseTimeMs / responseTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3222
3450
  avgResolutionTimeHours: resolutionTimeCount > 0 ? Math.round(totalResolutionTimeMs / resolutionTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3223
3451
  totalTimeMinutes,
3224
3452
  satisfactionAvg,
3225
- satisfactionCount: surveyCount.totalDocs,
3453
+ satisfactionCount,
3454
+ npsScore,
3455
+ npsCount,
3226
3456
  clientCount: clientCount.totalDocs,
3227
- pendingEmailsCount: pendingEmailsCount.totalDocs
3457
+ pendingEmailsCount: pendingEmailsTotal
3228
3458
  }), {
3229
3459
  headers: {
3230
3460
  "Content-Type": "application/json",
@@ -3294,13 +3524,26 @@ function createBillingEndpoint(slugs) {
3294
3524
  let ticketPage = 1;
3295
3525
  let ticketHasMore = true;
3296
3526
  while (ticketHasMore && ticketPage <= MAX_PAGES) {
3297
- const batch = await payload.find({
3298
- collection: slugs.tickets,
3527
+ const batch = await dbFind(payload, slugs.tickets, {
3299
3528
  where: ticketWhere,
3300
3529
  limit: PAGE_SIZE,
3301
3530
  page: ticketPage,
3531
+ // depth 2 is required: we read ticket.project.client.company below.
3302
3532
  depth: 2,
3303
- overrideAccess: true
3533
+ overrideAccess: true,
3534
+ // Bound the payload to only the fields the report consumes (skips body,
3535
+ // richText, SLA fields, etc.) while keeping project/client populated.
3536
+ select: {
3537
+ ticketNumber: true,
3538
+ subject: true,
3539
+ status: true,
3540
+ billedAmount: true,
3541
+ aiSummary: true,
3542
+ aiSummaryGeneratedAt: true,
3543
+ aiSummaryStatus: true,
3544
+ project: true,
3545
+ client: true
3546
+ }
3304
3547
  });
3305
3548
  allTickets.push(...batch.docs);
3306
3549
  ticketHasMore = batch.hasNextPage ?? false;
@@ -3310,8 +3553,7 @@ function createBillingEndpoint(slugs) {
3310
3553
  let entryPage = 1;
3311
3554
  let entryHasMore = true;
3312
3555
  while (entryHasMore && entryPage <= MAX_PAGES) {
3313
- const batch = await payload.find({
3314
- collection: slugs.timeEntries,
3556
+ const batch = await dbFind(payload, slugs.timeEntries, {
3315
3557
  where: {
3316
3558
  and: [
3317
3559
  { date: { greater_than_equal: from } },
@@ -3409,43 +3651,205 @@ function createBillingEndpoint(slugs) {
3409
3651
  }
3410
3652
  };
3411
3653
  }
3412
-
3413
- // src/utils/generateTicketSynthesis.ts
3414
- function getClient3(aiSettings) {
3415
- const Anthropic = __require("@anthropic-ai/sdk").default;
3416
- if (aiSettings.provider === "ollama") {
3417
- const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
3418
- return new Anthropic({ apiKey: "ollama", baseURL });
3419
- }
3420
- return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
3421
- }
3422
- function getModel3(aiSettings) {
3423
- return aiSettings.model || "claude-haiku-4-5-20251001";
3424
- }
3425
- async function generateTicketSynthesis(args) {
3426
- const { payload, slugs, ticketId } = args;
3427
- const settings = await readSupportSettings(payload);
3428
- if (settings.ai.enableSynthesis === false) {
3429
- return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "skipped", reason: "synthesis disabled" };
3430
- }
3431
- const ticket = await payload.findByID({
3432
- collection: slugs.tickets,
3433
- id: ticketId,
3434
- depth: 1,
3435
- overrideAccess: true
3654
+ var fmtAmount = (n) => `${n.toFixed(2)} EUR`;
3655
+ var fmtTime = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3656
+ function generateInvoicePdf(data) {
3657
+ return new Promise((resolve, reject) => {
3658
+ try {
3659
+ const doc = new PDFDocument({ size: "A4", margin: 50 });
3660
+ const chunks = [];
3661
+ doc.on("data", (c) => chunks.push(c));
3662
+ doc.on("end", () => resolve(Buffer.concat(chunks)));
3663
+ doc.on("error", reject);
3664
+ doc.fillColor("#0f172a").fontSize(22).text("Invoice / Pre-billing");
3665
+ doc.moveDown(0.2);
3666
+ doc.fillColor("#64748b").fontSize(10).text(`Period: ${data.from} -> ${data.to}${data.projectId ? ` \xB7 Project #${data.projectId}` : ""}`);
3667
+ doc.moveDown(1);
3668
+ const X = { ticket: 50, subject: 130, client: 320, time: 430, amount: 500 };
3669
+ const drawRow = (r, opts) => {
3670
+ const y = doc.y;
3671
+ doc.fontSize(opts?.header ? 9 : 10).fillColor(opts?.header ? "#475569" : "#0f172a");
3672
+ if (opts?.total) doc.font("Helvetica-Bold");
3673
+ doc.text(r.ticket, X.ticket, y, { width: X.subject - X.ticket - 6 });
3674
+ doc.text(r.subject, X.subject, y, { width: X.client - X.subject - 6 });
3675
+ doc.text(r.client, X.client, y, { width: X.time - X.client - 6 });
3676
+ doc.text(r.time, X.time, y, { width: X.amount - X.time - 6, align: "right" });
3677
+ doc.text(r.amount, X.amount, y, { width: 545 - X.amount, align: "right" });
3678
+ doc.font("Helvetica");
3679
+ doc.moveDown(0.4);
3680
+ };
3681
+ drawRow({ ticket: "Ticket", subject: "Subject", client: "Client", time: "Time", amount: "Amount" }, { header: true });
3682
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#e2e8f0").stroke();
3683
+ doc.moveDown(0.3);
3684
+ for (const l of data.lines) {
3685
+ if (doc.y > 760) doc.addPage();
3686
+ drawRow({
3687
+ ticket: l.ticketNumber || "",
3688
+ subject: l.subject || "",
3689
+ client: l.client || "",
3690
+ time: fmtTime(l.minutes),
3691
+ amount: fmtAmount(l.amount)
3692
+ });
3693
+ }
3694
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#0f172a").stroke();
3695
+ doc.moveDown(0.3);
3696
+ drawRow({
3697
+ ticket: "",
3698
+ subject: `Total (${data.lines.length} ticket${data.lines.length > 1 ? "s" : ""})`,
3699
+ client: "",
3700
+ time: fmtTime(data.totalMinutes),
3701
+ amount: fmtAmount(data.totalAmount)
3702
+ }, { total: true });
3703
+ doc.end();
3704
+ } catch (err) {
3705
+ reject(err);
3706
+ }
3436
3707
  });
3437
- if (!ticket) {
3438
- return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: "ticket not found" };
3439
- }
3440
- await payload.update({
3441
- collection: slugs.tickets,
3442
- id: ticketId,
3443
- data: { aiSummaryStatus: "pending" },
3708
+ }
3709
+
3710
+ // src/endpoints/invoice.ts
3711
+ function createInvoiceEndpoint(slugs) {
3712
+ return {
3713
+ path: "/support/billing/invoice",
3714
+ method: "get",
3715
+ handler: async (req) => {
3716
+ try {
3717
+ requireAdmin(req, slugs);
3718
+ const payload = req.payload;
3719
+ const url = new URL(req.url);
3720
+ const from = url.searchParams.get("from");
3721
+ const to = url.searchParams.get("to");
3722
+ const projectId = url.searchParams.get("projectId");
3723
+ const format = url.searchParams.get("format") === "pdf" ? "pdf" : "html";
3724
+ if (!from || !to) {
3725
+ return Response.json({ error: "Param\xE8tres from et to requis." }, { status: 400 });
3726
+ }
3727
+ const toExclusive = new Date(to);
3728
+ toExclusive.setDate(toExclusive.getDate() + 1);
3729
+ const toIso = toExclusive.toISOString();
3730
+ const where = {
3731
+ and: [
3732
+ { billable: { equals: true } },
3733
+ ...projectId ? [{ project: { equals: Number(projectId) } }] : [],
3734
+ {
3735
+ or: [
3736
+ { and: [{ updatedAt: { greater_than_equal: from } }, { updatedAt: { less_than: toIso } }] },
3737
+ { and: [{ createdAt: { greater_than_equal: from } }, { createdAt: { less_than: toIso } }] },
3738
+ { and: [{ resolvedAt: { greater_than_equal: from } }, { resolvedAt: { less_than: toIso } }] }
3739
+ ]
3740
+ }
3741
+ ]
3742
+ };
3743
+ const tickets = [];
3744
+ let page = 1;
3745
+ let hasMore = true;
3746
+ while (hasMore && page <= 50) {
3747
+ const batch = await dbFind(payload, slugs.tickets, {
3748
+ where,
3749
+ limit: 500,
3750
+ page,
3751
+ depth: 2,
3752
+ overrideAccess: true,
3753
+ select: { ticketNumber: true, subject: true, billedAmount: true, totalTimeMinutes: true, project: true, client: true }
3754
+ });
3755
+ tickets.push(...batch.docs);
3756
+ hasMore = batch.hasNextPage ?? false;
3757
+ page++;
3758
+ }
3759
+ let totalAmount = 0;
3760
+ let totalMinutes = 0;
3761
+ const lines = tickets.map((t) => {
3762
+ const amount = typeof t.billedAmount === "number" ? t.billedAmount : 0;
3763
+ const minutes = typeof t.totalTimeMinutes === "number" ? t.totalTimeMinutes : 0;
3764
+ totalAmount += amount;
3765
+ totalMinutes += minutes;
3766
+ const client = typeof t.client === "object" && t.client ? t.client.company || "" : "";
3767
+ return { ticketNumber: String(t.ticketNumber || ""), subject: String(t.subject || ""), client, minutes, amount };
3768
+ });
3769
+ if (format === "pdf") {
3770
+ const pdf = await generateInvoicePdf({ from, to, projectId, lines, totalMinutes, totalAmount });
3771
+ return new Response(new Uint8Array(pdf), {
3772
+ status: 200,
3773
+ headers: {
3774
+ "Content-Type": "application/pdf",
3775
+ "Content-Disposition": `inline; filename="invoice-${from}_${to}.pdf"`
3776
+ }
3777
+ });
3778
+ }
3779
+ const fmtAmount2 = (n) => `${n.toFixed(2)} \u20AC`;
3780
+ const fmtTime2 = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3781
+ const rows = lines.map((l) => `<tr>
3782
+ <td>${escapeHtml(l.ticketNumber)}</td>
3783
+ <td>${escapeHtml(l.subject)}</td>
3784
+ <td>${escapeHtml(l.client)}</td>
3785
+ <td style="text-align:right;">${fmtTime2(l.minutes)}</td>
3786
+ <td style="text-align:right;">${fmtAmount2(l.amount)}</td>
3787
+ </tr>`).join("");
3788
+ const html = `<!doctype html>
3789
+ <html lang="fr"><head><meta charset="utf-8"><title>Facture ${escapeHtml(from)} \u2192 ${escapeHtml(to)}</title>
3790
+ <style>
3791
+ body { font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 820px; margin: 32px auto; padding: 0 24px; }
3792
+ h1 { font-size: 22px; margin: 0 0 4px; }
3793
+ .period { color: #64748b; margin-bottom: 24px; }
3794
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
3795
+ th, td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; text-align: left; }
3796
+ th { background: #f8fafc; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #475569; }
3797
+ tfoot td { font-weight: 700; border-top: 2px solid #0f172a; border-bottom: none; }
3798
+ @media print { body { margin: 0; } }
3799
+ </style></head>
3800
+ <body>
3801
+ <h1>Facture / Pr\xE9-facture</h1>
3802
+ <div class="period">P\xE9riode : ${escapeHtml(from)} \u2192 ${escapeHtml(to)}${projectId ? ` \xB7 Projet #${escapeHtml(projectId)}` : ""}</div>
3803
+ <table>
3804
+ <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>
3805
+ <tbody>${rows || '<tr><td colspan="5" style="color:#94a3b8;">Aucun ticket facturable sur la p\xE9riode.</td></tr>'}</tbody>
3806
+ <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>
3807
+ </table>
3808
+ </body></html>`;
3809
+ return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
3810
+ } catch (error) {
3811
+ const authResponse = handleAuthError(error);
3812
+ if (authResponse) return authResponse;
3813
+ console.error("[invoice] Error:", error);
3814
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
3815
+ }
3816
+ }
3817
+ };
3818
+ }
3819
+
3820
+ // src/utils/generateTicketSynthesis.ts
3821
+ function getClient4(aiSettings) {
3822
+ const Anthropic = __require("@anthropic-ai/sdk").default;
3823
+ if (aiSettings.provider === "ollama") {
3824
+ const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
3825
+ return new Anthropic({ apiKey: "ollama", baseURL });
3826
+ }
3827
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
3828
+ }
3829
+ function getModel3(aiSettings) {
3830
+ return aiSettings.model || "claude-haiku-4-5-20251001";
3831
+ }
3832
+ async function generateTicketSynthesis(args) {
3833
+ const { payload, slugs, ticketId } = args;
3834
+ const settings = await readSupportSettings(payload);
3835
+ if (settings.ai.enableSynthesis === false) {
3836
+ return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "skipped", reason: "synthesis disabled" };
3837
+ }
3838
+ const ticket = await dbFindByID(payload, slugs.tickets, {
3839
+ id: ticketId,
3840
+ depth: 1,
3841
+ overrideAccess: true
3842
+ });
3843
+ if (!ticket) {
3844
+ return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: "ticket not found" };
3845
+ }
3846
+ await dbUpdate(payload, slugs.tickets, {
3847
+ id: ticketId,
3848
+ data: { aiSummaryStatus: "pending" },
3444
3849
  overrideAccess: true
3445
3850
  }).catch(() => {
3446
3851
  });
3447
- const messagesResult = await payload.find({
3448
- collection: slugs.ticketMessages,
3852
+ const messagesResult = await dbFind(payload, slugs.ticketMessages, {
3449
3853
  where: { ticket: { equals: ticketId } },
3450
3854
  sort: "createdAt",
3451
3855
  limit: 500,
@@ -3455,8 +3859,7 @@ async function generateTicketSynthesis(args) {
3455
3859
  const messages = messagesResult.docs;
3456
3860
  if (messages.length === 0) {
3457
3861
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3458
- await payload.update({
3459
- collection: slugs.tickets,
3862
+ await dbUpdate(payload, slugs.tickets, {
3460
3863
  id: ticketId,
3461
3864
  data: {
3462
3865
  aiSummary: "(Aucun message dans ce ticket)",
@@ -3501,7 +3904,7 @@ Regles strictes :
3501
3904
  - Si le ticket n'a pas abouti, decris quand meme le travail d'analyse realise
3502
3905
 
3503
3906
  Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3504
- const anthropic = getClient3(settings.ai);
3907
+ const anthropic = getClient4(settings.ai);
3505
3908
  const model = getModel3(settings.ai);
3506
3909
  try {
3507
3910
  const res = await anthropic.messages.create({
@@ -3511,8 +3914,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3511
3914
  });
3512
3915
  const summary = res.content[0]?.type === "text" ? res.content[0].text.trim() : "";
3513
3916
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3514
- await payload.update({
3515
- collection: slugs.tickets,
3917
+ await dbUpdate(payload, slugs.tickets, {
3516
3918
  id: ticketId,
3517
3919
  data: {
3518
3920
  aiSummary: summary,
@@ -3524,8 +3926,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3524
3926
  return { summary, generatedAt, status: "done" };
3525
3927
  } catch (err) {
3526
3928
  const message = err instanceof Error ? err.message : String(err);
3527
- await payload.update({
3528
- collection: slugs.tickets,
3929
+ await dbUpdate(payload, slugs.tickets, {
3529
3930
  id: ticketId,
3530
3931
  data: { aiSummaryStatus: "error" },
3531
3932
  overrideAccess: true
@@ -3555,8 +3956,7 @@ function createTicketSynthesisEndpoint(slugs) {
3555
3956
  return Response.json({ error: "ticketId must be a number" }, { status: 400 });
3556
3957
  }
3557
3958
  if (!force) {
3558
- const existing = await payload.findByID({
3559
- collection: slugs.tickets,
3959
+ const existing = await dbFindByID(payload, slugs.tickets, {
3560
3960
  id: ticketId,
3561
3961
  depth: 0,
3562
3962
  overrideAccess: true
@@ -3600,8 +4000,7 @@ function createEmailStatsEndpoint(slugs) {
3600
4000
  let page = 1;
3601
4001
  let hasMore = true;
3602
4002
  while (hasMore && page <= MAX_PAGES2) {
3603
- const result = await payload.find({
3604
- collection: slugs.emailLogs,
4003
+ const result = await dbFind(payload, slugs.emailLogs, {
3605
4004
  where: { createdAt: { greater_than: cutoff } },
3606
4005
  sort: "-createdAt",
3607
4006
  limit: 500,
@@ -3675,12 +4074,20 @@ function createSatisfactionEndpoint(slugs) {
3675
4074
  const payload = req.payload;
3676
4075
  requireClient(req, slugs);
3677
4076
  const body = await req.json();
3678
- const { ticketId, rating, comment } = body;
3679
- if (!ticketId || !rating || !Number.isInteger(rating) || rating < 1 || rating > 5) {
3680
- return Response.json(
3681
- { error: "ticketId et rating (entier 1-5) sont requis." },
3682
- { status: 400 }
3683
- );
4077
+ const { ticketId, rating, nps, comment } = body;
4078
+ if (!ticketId) {
4079
+ return Response.json({ error: "ticketId est requis." }, { status: 400 });
4080
+ }
4081
+ const hasRating = rating !== void 0 && rating !== null;
4082
+ const hasNps = nps !== void 0 && nps !== null;
4083
+ if (!hasRating && !hasNps) {
4084
+ return Response.json({ error: "Un rating (1-5) et/ou un nps (0-10) est requis." }, { status: 400 });
4085
+ }
4086
+ if (hasRating && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
4087
+ return Response.json({ error: "rating doit \xEAtre un entier 1-5." }, { status: 400 });
4088
+ }
4089
+ if (hasNps && (!Number.isInteger(nps) || nps < 0 || nps > 10)) {
4090
+ return Response.json({ error: "nps doit \xEAtre un entier 0-10." }, { status: 400 });
3684
4091
  }
3685
4092
  if (comment && typeof comment === "string" && comment.length > 5e3) {
3686
4093
  return Response.json(
@@ -3688,8 +4095,7 @@ function createSatisfactionEndpoint(slugs) {
3688
4095
  { status: 400 }
3689
4096
  );
3690
4097
  }
3691
- const ticket = await payload.findByID({
3692
- collection: slugs.tickets,
4098
+ const ticket = await dbFindByID(payload, slugs.tickets, {
3693
4099
  id: ticketId,
3694
4100
  depth: 0,
3695
4101
  overrideAccess: false,
@@ -3704,8 +4110,7 @@ function createSatisfactionEndpoint(slugs) {
3704
4110
  { status: 400 }
3705
4111
  );
3706
4112
  }
3707
- const existing = await payload.find({
3708
- collection: slugs.satisfactionSurveys,
4113
+ const existing = await dbFind(payload, slugs.satisfactionSurveys, {
3709
4114
  where: { ticket: { equals: ticketId } },
3710
4115
  limit: 1,
3711
4116
  depth: 0,
@@ -3717,13 +4122,13 @@ function createSatisfactionEndpoint(slugs) {
3717
4122
  { status: 409 }
3718
4123
  );
3719
4124
  }
3720
- const survey = await payload.create({
3721
- collection: slugs.satisfactionSurveys,
4125
+ const survey = await dbCreate(payload, slugs.satisfactionSurveys, {
3722
4126
  data: {
3723
4127
  source: "ticket",
3724
4128
  ticket: ticketId,
3725
4129
  client: req.user.id,
3726
- rating: Math.round(rating),
4130
+ ...hasRating ? { rating: Math.round(rating) } : {},
4131
+ ...hasNps ? { nps: Math.round(nps) } : {},
3727
4132
  ...comment ? { comment: comment.trim() } : {}
3728
4133
  },
3729
4134
  overrideAccess: true
@@ -3738,124 +4143,6 @@ function createSatisfactionEndpoint(slugs) {
3738
4143
  }
3739
4144
  };
3740
4145
  }
3741
- var TRANSPARENT_GIF = Buffer.from(
3742
- "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
3743
- "base64"
3744
- );
3745
- function generateTrackingToken(ticketId, messageId, secret) {
3746
- return createHmac("sha256", secret).update(`${ticketId}:${messageId}`).digest("hex").substring(0, 16);
3747
- }
3748
- function createTrackOpenEndpoint(slugs) {
3749
- return {
3750
- path: "/support/track-open",
3751
- method: "get",
3752
- handler: async (req) => {
3753
- const url = new URL(req.url);
3754
- const ticketId = url.searchParams.get("t");
3755
- const messageId = url.searchParams.get("m");
3756
- const sig = url.searchParams.get("sig");
3757
- const parsedId = ticketId ? Number(ticketId) : NaN;
3758
- const parsedMsgId = messageId ? Number(messageId) : NaN;
3759
- const secret = process.env.PAYLOAD_SECRET || "";
3760
- if (secret && ticketId && messageId && sig) {
3761
- const expected = generateTrackingToken(ticketId, messageId, secret);
3762
- if (sig !== expected) {
3763
- return new Response(TRANSPARENT_GIF, {
3764
- status: 200,
3765
- headers: {
3766
- "Content-Type": "image/gif",
3767
- "Content-Length": String(TRANSPARENT_GIF.length),
3768
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
3769
- }
3770
- });
3771
- }
3772
- } else if (secret && (!sig || !ticketId || !messageId)) {
3773
- return new Response(TRANSPARENT_GIF, {
3774
- status: 200,
3775
- headers: {
3776
- "Content-Type": "image/gif",
3777
- "Content-Length": String(TRANSPARENT_GIF.length),
3778
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
3779
- }
3780
- });
3781
- }
3782
- if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
3783
- try {
3784
- const payload = req.payload;
3785
- const ticket = await payload.findByID({
3786
- collection: slugs.tickets,
3787
- id: parsedId,
3788
- depth: 0,
3789
- overrideAccess: true,
3790
- select: { lastClientReadAt: true }
3791
- });
3792
- if (ticket) {
3793
- const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0;
3794
- const fiveMinAgo = Date.now() - 5 * 60 * 1e3;
3795
- if (lastRead < fiveMinAgo) {
3796
- await payload.update({
3797
- collection: slugs.tickets,
3798
- id: parsedId,
3799
- data: { lastClientReadAt: (/* @__PURE__ */ new Date()).toISOString() },
3800
- overrideAccess: true
3801
- });
3802
- }
3803
- }
3804
- if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
3805
- const msg = await payload.findByID({
3806
- collection: slugs.ticketMessages,
3807
- id: parsedMsgId,
3808
- depth: 0,
3809
- overrideAccess: true,
3810
- select: { emailOpenedAt: true }
3811
- });
3812
- if (msg && !msg.emailOpenedAt) {
3813
- await payload.update({
3814
- collection: slugs.ticketMessages,
3815
- id: parsedMsgId,
3816
- data: { emailOpenedAt: (/* @__PURE__ */ new Date()).toISOString() },
3817
- overrideAccess: true
3818
- });
3819
- const ticketInfo = await payload.findByID({
3820
- collection: slugs.tickets,
3821
- id: parsedId,
3822
- depth: 1,
3823
- overrideAccess: true,
3824
- select: { ticketNumber: true, subject: true, client: true }
3825
- });
3826
- const clientName = typeof ticketInfo?.client === "object" ? ticketInfo.client?.firstName || "Client" : "Client";
3827
- try {
3828
- await payload.create({
3829
- collection: "admin-notifications",
3830
- data: {
3831
- title: `Email ouvert \u2014 ${ticketInfo?.ticketNumber || "TK-????"}`,
3832
- message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || "ticket"}"`,
3833
- type: "email_opened",
3834
- link: `/admin/ticket?id=${parsedId}`
3835
- },
3836
- overrideAccess: true
3837
- });
3838
- } catch {
3839
- }
3840
- }
3841
- }
3842
- } catch (err) {
3843
- console.error("[track-open] Error:", err);
3844
- }
3845
- }
3846
- return new Response(TRANSPARENT_GIF, {
3847
- status: 200,
3848
- headers: {
3849
- "Content-Type": "image/gif",
3850
- "Content-Length": String(TRANSPARENT_GIF.length),
3851
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
3852
- "Pragma": "no-cache",
3853
- "Expires": "0"
3854
- }
3855
- });
3856
- }
3857
- };
3858
- }
3859
4146
 
3860
4147
  // src/endpoints/export-csv.ts
3861
4148
  function createExportCsvEndpoint(slugs) {
@@ -3871,8 +4158,7 @@ function createExportCsvEndpoint(slugs) {
3871
4158
  let hasMore = true;
3872
4159
  const allDocs = [];
3873
4160
  while (hasMore) {
3874
- const batch = await payload.find({
3875
- collection: slugs.tickets,
4161
+ const batch = await dbFind(payload, slugs.tickets, {
3876
4162
  limit: PAGE_SIZE2,
3877
4163
  page,
3878
4164
  depth: 1,
@@ -3948,21 +4234,18 @@ function createExportDataEndpoint(slugs) {
3948
4234
  const payload = req.payload;
3949
4235
  requireClient(req, slugs);
3950
4236
  const [clientData, ticketsResult, messagesResult, surveysResult] = await Promise.all([
3951
- payload.findByID({
3952
- collection: slugs.supportClients,
4237
+ dbFindByID(payload, slugs.supportClients, {
3953
4238
  id: req.user.id,
3954
4239
  depth: 0,
3955
4240
  overrideAccess: true
3956
4241
  }),
3957
- payload.find({
3958
- collection: slugs.tickets,
4242
+ dbFind(payload, slugs.tickets, {
3959
4243
  where: { client: { equals: req.user.id } },
3960
4244
  limit: 1e3,
3961
4245
  depth: 0,
3962
4246
  overrideAccess: true
3963
4247
  }),
3964
- payload.find({
3965
- collection: slugs.ticketMessages,
4248
+ dbFind(payload, slugs.ticketMessages, {
3966
4249
  where: {
3967
4250
  "ticket.client": { equals: req.user.id },
3968
4251
  authorType: { equals: "client" }
@@ -3971,8 +4254,7 @@ function createExportDataEndpoint(slugs) {
3971
4254
  depth: 0,
3972
4255
  overrideAccess: true
3973
4256
  }),
3974
- payload.find({
3975
- collection: slugs.satisfactionSurveys,
4257
+ dbFind(payload, slugs.satisfactionSurveys, {
3976
4258
  where: { client: { equals: req.user.id } },
3977
4259
  limit: 500,
3978
4260
  depth: 0,
@@ -4046,8 +4328,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4046
4328
  if (!["create_ticket", "add_to_ticket", "ignore"].includes(action)) {
4047
4329
  return Response.json({ error: "Invalid action" }, { status: 400 });
4048
4330
  }
4049
- const pendingEmail = await payload.findByID({
4050
- collection: slugs.pendingEmails,
4331
+ const pendingEmail = await dbFindByID(payload, slugs.pendingEmails, {
4051
4332
  id: Number(id),
4052
4333
  depth: 1,
4053
4334
  overrideAccess: true
@@ -4061,14 +4342,12 @@ function createPendingEmailsProcessEndpoint(slugs) {
4061
4342
  let clientId = overrideClientId || (typeof pendingEmail.client === "object" ? pendingEmail.client?.id : pendingEmail.client);
4062
4343
  let clientDoc = typeof pendingEmail.client === "object" ? pendingEmail.client : null;
4063
4344
  if (overrideClientId && overrideClientId !== clientDoc?.id) {
4064
- clientDoc = await payload.findByID({
4065
- collection: slugs.supportClients,
4345
+ clientDoc = await dbFindByID(payload, slugs.supportClients, {
4066
4346
  id: overrideClientId,
4067
4347
  depth: 0,
4068
4348
  overrideAccess: true
4069
4349
  });
4070
- await payload.update({
4071
- collection: slugs.pendingEmails,
4350
+ await dbUpdate(payload, slugs.pendingEmails, {
4072
4351
  id: Number(id),
4073
4352
  data: { client: overrideClientId },
4074
4353
  overrideAccess: true
@@ -4085,8 +4364,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4085
4364
  file: typeof a.file === "object" ? a.file.id : a.file
4086
4365
  })) || [];
4087
4366
  if (action === "ignore") {
4088
- await payload.update({
4089
- collection: slugs.pendingEmails,
4367
+ await dbUpdate(payload, slugs.pendingEmails, {
4090
4368
  id: Number(id),
4091
4369
  data: {
4092
4370
  status: "ignored",
@@ -4098,8 +4376,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4098
4376
  return Response.json({ action: "ignored", pendingEmailId: Number(id) });
4099
4377
  }
4100
4378
  if (action === "create_ticket") {
4101
- const newTicket = await payload.create({
4102
- collection: slugs.tickets,
4379
+ const newTicket = await dbCreate(payload, slugs.tickets, {
4103
4380
  data: {
4104
4381
  subject: pendingEmail.subject,
4105
4382
  client: clientId,
@@ -4110,8 +4387,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4110
4387
  },
4111
4388
  overrideAccess: true
4112
4389
  });
4113
- await payload.create({
4114
- collection: slugs.ticketMessages,
4390
+ await dbCreate(payload, slugs.ticketMessages, {
4115
4391
  data: {
4116
4392
  ticket: newTicket.id,
4117
4393
  body: pendingEmail.body,
@@ -4138,8 +4414,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4138
4414
  } catch (err) {
4139
4415
  console.error("[pending-email-process] Failed to send notification:", err);
4140
4416
  }
4141
- await payload.update({
4142
- collection: slugs.pendingEmails,
4417
+ await dbUpdate(payload, slugs.pendingEmails, {
4143
4418
  id: Number(id),
4144
4419
  data: {
4145
4420
  status: "processed",
@@ -4159,8 +4434,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4159
4434
  if (!ticketId) {
4160
4435
  return Response.json({ error: "ticketId is required for add_to_ticket" }, { status: 400 });
4161
4436
  }
4162
- const targetTicket = await payload.findByID({
4163
- collection: slugs.tickets,
4437
+ const targetTicket = await dbFindByID(payload, slugs.tickets, {
4164
4438
  id: ticketId,
4165
4439
  depth: 0,
4166
4440
  overrideAccess: true
@@ -4168,8 +4442,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4168
4442
  if (!targetTicket) {
4169
4443
  return Response.json({ error: "Target ticket not found" }, { status: 404 });
4170
4444
  }
4171
- await payload.create({
4172
- collection: slugs.ticketMessages,
4445
+ await dbCreate(payload, slugs.ticketMessages, {
4173
4446
  data: {
4174
4447
  ticket: ticketId,
4175
4448
  body: pendingEmail.body,
@@ -4181,15 +4454,13 @@ function createPendingEmailsProcessEndpoint(slugs) {
4181
4454
  overrideAccess: true
4182
4455
  });
4183
4456
  if (targetTicket.status === "resolved") {
4184
- await payload.update({
4185
- collection: slugs.tickets,
4457
+ await dbUpdate(payload, slugs.tickets, {
4186
4458
  id: ticketId,
4187
4459
  data: { status: "open" },
4188
4460
  overrideAccess: true
4189
4461
  });
4190
4462
  }
4191
- await payload.update({
4192
- collection: slugs.pendingEmails,
4463
+ await dbUpdate(payload, slugs.pendingEmails, {
4193
4464
  id: Number(id),
4194
4465
  data: {
4195
4466
  status: "processed",
@@ -4242,8 +4513,7 @@ function createResendNotificationEndpoint(slugs) {
4242
4513
  if (!messageId) {
4243
4514
  return Response.json({ error: "messageId requis" }, { status: 400 });
4244
4515
  }
4245
- const message = await payload.findByID({
4246
- collection: slugs.ticketMessages,
4516
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
4247
4517
  id: messageId,
4248
4518
  depth: 0,
4249
4519
  overrideAccess: true
@@ -4252,8 +4522,7 @@ function createResendNotificationEndpoint(slugs) {
4252
4522
  return Response.json({ error: "Message introuvable" }, { status: 404 });
4253
4523
  }
4254
4524
  const ticketId = typeof message.ticket === "object" ? message.ticket.id : message.ticket;
4255
- const ticket = await payload.findByID({
4256
- collection: slugs.tickets,
4525
+ const ticket = await dbFindByID(payload, slugs.tickets, {
4257
4526
  id: ticketId,
4258
4527
  depth: 1,
4259
4528
  overrideAccess: true
@@ -4379,8 +4648,7 @@ function createSeedKbEndpoint(slugs) {
4379
4648
  let created = 0;
4380
4649
  let skipped = 0;
4381
4650
  for (const article of KB_ARTICLES) {
4382
- const existing = await payload.find({
4383
- collection: slugs.knowledgeBase,
4651
+ const existing = await dbFind(payload, slugs.knowledgeBase, {
4384
4652
  where: { slug: { equals: article.slug } },
4385
4653
  limit: 1,
4386
4654
  depth: 0,
@@ -4407,8 +4675,7 @@ function createSeedKbEndpoint(slugs) {
4407
4675
  version: 1
4408
4676
  }
4409
4677
  };
4410
- await payload.create({
4411
- collection: slugs.knowledgeBase,
4678
+ await dbCreate(payload, slugs.knowledgeBase, {
4412
4679
  data: {
4413
4680
  title: article.title,
4414
4681
  slug: article.slug,
@@ -4463,8 +4730,7 @@ function createLoginEndpoint(slugs) {
4463
4730
  collection: slugs.supportClients,
4464
4731
  data: { email, password }
4465
4732
  });
4466
- payload.create({
4467
- collection: slugs.authLogs,
4733
+ dbCreate(payload, slugs.authLogs, {
4468
4734
  data: { email, success: true, action: "login", ipAddress: ip, userAgent },
4469
4735
  overrideAccess: true
4470
4736
  }).catch(() => {
@@ -4488,12 +4754,14 @@ function createLoginEndpoint(slugs) {
4488
4754
  );
4489
4755
  } catch (err) {
4490
4756
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
4757
+ if (errorMessage.includes("2FA_REQUIRED")) {
4758
+ return Response.json({ requires2FA: true }, { status: 200 });
4759
+ }
4491
4760
  let errorReason = "Identifiants incorrects";
4492
4761
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
4493
4762
  errorReason = "Compte verrouill\xE9 (trop de tentatives)";
4494
4763
  }
4495
- payload.create({
4496
- collection: slugs.authLogs,
4764
+ dbCreate(payload, slugs.authLogs, {
4497
4765
  data: { email, success: false, action: "login", errorReason, ipAddress: ip, userAgent },
4498
4766
  overrideAccess: true
4499
4767
  }).catch(() => {
@@ -4514,7 +4782,10 @@ function generateSecureCode() {
4514
4782
  return String(num);
4515
4783
  }
4516
4784
  function hashCode(code) {
4517
- const secret = process.env.PAYLOAD_SECRET || "payload-support-2fa";
4785
+ const secret = process.env.PAYLOAD_SECRET;
4786
+ if (!secret) {
4787
+ throw new Error("[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to operate 2FA with an insecure fallback secret");
4788
+ }
4518
4789
  return createHmac("sha256", secret).update(code).digest("hex");
4519
4790
  }
4520
4791
  function createAuth2faEndpoint(slugs) {
@@ -4539,8 +4810,7 @@ function createAuth2faEndpoint(slugs) {
4539
4810
  if (sendLimiter.check(email)) {
4540
4811
  return Response.json(genericSendResponse);
4541
4812
  }
4542
- const clients = await payload.find({
4543
- collection: slugs.supportClients,
4813
+ const clients = await dbFind(payload, slugs.supportClients, {
4544
4814
  where: { email: { equals: email } },
4545
4815
  limit: 1,
4546
4816
  depth: 0,
@@ -4553,8 +4823,7 @@ function createAuth2faEndpoint(slugs) {
4553
4823
  const plainCode = generateSecureCode();
4554
4824
  const twoFactorCode = hashCode(plainCode);
4555
4825
  const twoFactorExpiry = new Date(Date.now() + 10 * 60 * 1e3).toISOString();
4556
- await payload.update({
4557
- collection: slugs.supportClients,
4826
+ await dbUpdate(payload, slugs.supportClients, {
4558
4827
  id: client.id,
4559
4828
  data: { twoFactorCode, twoFactorExpiry },
4560
4829
  overrideAccess: true
@@ -4585,8 +4854,7 @@ function createAuth2faEndpoint(slugs) {
4585
4854
  { status: 429 }
4586
4855
  );
4587
4856
  }
4588
- const clients = await payload.find({
4589
- collection: slugs.supportClients,
4857
+ const clients = await dbFind(payload, slugs.supportClients, {
4590
4858
  where: { email: { equals: email } },
4591
4859
  limit: 1,
4592
4860
  depth: 0,
@@ -4602,8 +4870,7 @@ function createAuth2faEndpoint(slugs) {
4602
4870
  return Response.json({ error: "Aucun code en attente" }, { status: 400 });
4603
4871
  }
4604
4872
  if (/* @__PURE__ */ new Date() > new Date(storedExpiry)) {
4605
- await payload.update({
4606
- collection: slugs.supportClients,
4873
+ await dbUpdate(payload, slugs.supportClients, {
4607
4874
  id: client.id,
4608
4875
  data: { twoFactorCode: "", twoFactorExpiry: "" },
4609
4876
  overrideAccess: true
@@ -4617,10 +4884,9 @@ function createAuth2faEndpoint(slugs) {
4617
4884
  if (submittedBuffer.length !== storedBuffer.length || !crypto3.timingSafeEqual(submittedBuffer, storedBuffer)) {
4618
4885
  return Response.json({ error: "Code incorrect" }, { status: 400 });
4619
4886
  }
4620
- await payload.update({
4621
- collection: slugs.supportClients,
4887
+ await dbUpdate(payload, slugs.supportClients, {
4622
4888
  id: client.id,
4623
- data: { twoFactorCode: "", twoFactorExpiry: "" },
4889
+ data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
4624
4890
  overrideAccess: true
4625
4891
  });
4626
4892
  verifyLimiter.reset(email);
@@ -4694,15 +4960,33 @@ function createOAuthGoogleEndpoint(slugs, options) {
4694
4960
  if (!profile.email) {
4695
4961
  return Response.json({ error: "no_email" }, { status: 400 });
4696
4962
  }
4963
+ if (profile.verified_email !== true) {
4964
+ return Response.json({ error: "email_not_verified" }, { status: 403 });
4965
+ }
4697
4966
  const payload = req.payload;
4698
- const existing = await payload.find({
4699
- collection: slugs.supportClients,
4700
- where: { email: { equals: profile.email } },
4967
+ const byGoogle = await dbFind(payload, slugs.supportClients, {
4968
+ where: { googleId: { equals: profile.id } },
4701
4969
  limit: 1,
4702
4970
  depth: 0,
4703
4971
  overrideAccess: true
4704
4972
  });
4705
- let clientDoc = existing.docs[0];
4973
+ let clientDoc = byGoogle.docs[0];
4974
+ if (!clientDoc) {
4975
+ const byEmail = await dbFind(payload, slugs.supportClients, {
4976
+ where: { email: { equals: profile.email } },
4977
+ limit: 1,
4978
+ depth: 0,
4979
+ overrideAccess: true
4980
+ });
4981
+ clientDoc = byEmail.docs[0];
4982
+ if (clientDoc && profile.id) {
4983
+ await dbUpdate(payload, slugs.supportClients, {
4984
+ id: clientDoc.id,
4985
+ data: { googleId: profile.id },
4986
+ overrideAccess: true
4987
+ });
4988
+ }
4989
+ }
4706
4990
  if (!clientDoc) {
4707
4991
  const allowedDomains = options?.allowedEmailDomains;
4708
4992
  if (allowedDomains && allowedDomains.length > 0) {
@@ -4720,43 +5004,64 @@ function createOAuthGoogleEndpoint(slugs, options) {
4720
5004
  const autoPassword = crypto3.randomBytes(48).toString("base64url");
4721
5005
  const fullName = profile.name || profile.email.split("@")[0];
4722
5006
  const nameParts = fullName.split(" ");
4723
- clientDoc = await payload.create({
4724
- collection: slugs.supportClients,
5007
+ clientDoc = await dbCreate(payload, slugs.supportClients, {
4725
5008
  data: {
4726
5009
  email: profile.email,
4727
5010
  firstName: nameParts[0] || fullName,
4728
5011
  lastName: nameParts.slice(1).join(" ") || "-",
4729
5012
  company: fullName,
5013
+ googleId: profile.id,
4730
5014
  password: autoPassword
4731
5015
  },
4732
5016
  overrideAccess: true
4733
5017
  });
4734
5018
  }
4735
- const tempPassword = crypto3.randomBytes(48).toString("base64url");
4736
- await payload.update({
4737
- collection: slugs.supportClients,
4738
- id: clientDoc.id,
4739
- data: { password: tempPassword },
4740
- overrideAccess: true
4741
- });
4742
- const loginResult = await payload.login({
4743
- collection: slugs.supportClients,
4744
- data: { email: profile.email, password: tempPassword }
4745
- });
4746
- const postLoginPassword = crypto3.randomBytes(48).toString("base64url");
4747
- await payload.update({
4748
- collection: slugs.supportClients,
4749
- id: clientDoc.id,
4750
- data: { password: postLoginPassword },
4751
- overrideAccess: true
4752
- });
4753
- if (!loginResult.token) {
4754
- return Response.json({ error: "login_failed" }, { status: 400 });
5019
+ const secret = process.env.PAYLOAD_SECRET;
5020
+ if (!secret) {
5021
+ return Response.json({ error: "server_misconfigured" }, { status: 500 });
4755
5022
  }
4756
- return Response.json({
4757
- token: loginResult.token,
4758
- user: loginResult.user,
4759
- exp: loginResult.exp
5023
+ const collectionConfig = payload.collections[slugs.supportClients].config;
5024
+ const tokenExpiration = collectionConfig.auth?.tokenExpiration ?? 7200;
5025
+ let sid;
5026
+ if (collectionConfig.auth?.useSessions) {
5027
+ sid = crypto3.randomUUID();
5028
+ const now = /* @__PURE__ */ new Date();
5029
+ const expiresAt = new Date(now.getTime() + tokenExpiration * 1e3);
5030
+ const fresh = await dbFindByID(payload, slugs.supportClients, {
5031
+ id: clientDoc.id,
5032
+ depth: 0,
5033
+ overrideAccess: true,
5034
+ showHiddenFields: true
5035
+ });
5036
+ const kept = Array.isArray(fresh?.sessions) ? fresh.sessions.filter((s) => new Date(s.expiresAt) > now) : [];
5037
+ await payload.db.updateOne({
5038
+ collection: slugs.supportClients,
5039
+ id: clientDoc.id,
5040
+ data: {
5041
+ sessions: [
5042
+ ...kept,
5043
+ { id: sid, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString() }
5044
+ ]
5045
+ },
5046
+ returning: false
5047
+ });
5048
+ }
5049
+ const fieldsToSign = getFieldsToSign({
5050
+ collectionConfig,
5051
+ email: clientDoc.email,
5052
+ sid,
5053
+ user: { ...clientDoc, collection: slugs.supportClients }
5054
+ });
5055
+ const { token, exp } = await jwtSign({ fieldsToSign, secret, tokenExpiration });
5056
+ const headers = new Headers({ "Content-Type": "application/json" });
5057
+ const cookieSecure = process.env.NODE_ENV === "production";
5058
+ headers.append(
5059
+ "Set-Cookie",
5060
+ `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5061
+ );
5062
+ return new Response(JSON.stringify({ token, user: clientDoc, exp }), {
5063
+ status: 200,
5064
+ headers
4760
5065
  });
4761
5066
  }
4762
5067
  return Response.json({ error: "Action invalide" }, { status: 400 });
@@ -4797,8 +5102,7 @@ function createDeleteAccountEndpoint(slugs) {
4797
5102
  );
4798
5103
  }
4799
5104
  const clientId = req.user.id;
4800
- const tickets = await payload.find({
4801
- collection: slugs.tickets,
5105
+ const tickets = await dbFind(payload, slugs.tickets, {
4802
5106
  where: { client: { equals: clientId } },
4803
5107
  limit: 1e4,
4804
5108
  depth: 0,
@@ -4807,39 +5111,32 @@ function createDeleteAccountEndpoint(slugs) {
4807
5111
  });
4808
5112
  const ticketIds = tickets.docs.map((t) => t.id);
4809
5113
  if (ticketIds.length > 0) {
4810
- await payload.delete({
4811
- collection: slugs.ticketMessages,
5114
+ await dbDelete(payload, slugs.ticketMessages, {
4812
5115
  where: { ticket: { in: ticketIds } },
4813
5116
  overrideAccess: true
4814
5117
  });
4815
- await payload.delete({
4816
- collection: slugs.ticketActivityLog,
5118
+ await dbDelete(payload, slugs.ticketActivityLog, {
4817
5119
  where: { ticket: { in: ticketIds } },
4818
5120
  overrideAccess: true
4819
5121
  });
4820
- await payload.delete({
4821
- collection: slugs.timeEntries,
5122
+ await dbDelete(payload, slugs.timeEntries, {
4822
5123
  where: { ticket: { in: ticketIds } },
4823
5124
  overrideAccess: true
4824
5125
  });
4825
- await payload.delete({
4826
- collection: slugs.tickets,
5126
+ await dbDelete(payload, slugs.tickets, {
4827
5127
  where: { client: { equals: clientId } },
4828
5128
  overrideAccess: true
4829
5129
  });
4830
5130
  }
4831
- await payload.delete({
4832
- collection: slugs.satisfactionSurveys,
5131
+ await dbDelete(payload, slugs.satisfactionSurveys, {
4833
5132
  where: { client: { equals: clientId } },
4834
5133
  overrideAccess: true
4835
5134
  });
4836
- await payload.delete({
4837
- collection: slugs.chatMessages,
5135
+ await dbDelete(payload, slugs.chatMessages, {
4838
5136
  where: { client: { equals: clientId } },
4839
5137
  overrideAccess: true
4840
5138
  });
4841
- await payload.delete({
4842
- collection: slugs.supportClients,
5139
+ await dbDelete(payload, slugs.supportClients, {
4843
5140
  id: clientId,
4844
5141
  overrideAccess: true
4845
5142
  });
@@ -4880,8 +5177,8 @@ function createMergeClientsEndpoint(slugs) {
4880
5177
  return Response.json({ error: "sourceId and targetId are required and must be different" }, { status: 400 });
4881
5178
  }
4882
5179
  const [source, target] = await Promise.all([
4883
- payload.findByID({ collection: slugs.supportClients, id: sourceId, depth: 0, overrideAccess: true }),
4884
- payload.findByID({ collection: slugs.supportClients, id: targetId, depth: 0, overrideAccess: true })
5180
+ dbFindByID(payload, slugs.supportClients, { id: sourceId, depth: 0, overrideAccess: true }),
5181
+ dbFindByID(payload, slugs.supportClients, { id: targetId, depth: 0, overrideAccess: true })
4885
5182
  ]);
4886
5183
  if (!source || !target) {
4887
5184
  return Response.json({ error: "Source or target client not found" }, { status: 404 });
@@ -4893,63 +5190,57 @@ function createMergeClientsEndpoint(slugs) {
4893
5190
  pendingEmails: 0,
4894
5191
  satisfactionSurveys: 0
4895
5192
  };
4896
- const tickets = await payload.find({
4897
- collection: slugs.tickets,
5193
+ const tickets = await dbFind(payload, slugs.tickets, {
4898
5194
  where: { client: { equals: sourceId } },
4899
5195
  limit: 500,
4900
5196
  depth: 0,
4901
5197
  overrideAccess: true
4902
5198
  });
4903
5199
  for (const ticket of tickets.docs) {
4904
- await payload.update({ collection: slugs.tickets, id: ticket.id, data: { client: targetId }, overrideAccess: true });
5200
+ await dbUpdate(payload, slugs.tickets, { id: ticket.id, data: { client: targetId }, overrideAccess: true });
4905
5201
  results.tickets++;
4906
5202
  }
4907
- const messages = await payload.find({
4908
- collection: slugs.ticketMessages,
5203
+ const messages = await dbFind(payload, slugs.ticketMessages, {
4909
5204
  where: { authorClient: { equals: sourceId } },
4910
5205
  limit: 1e3,
4911
5206
  depth: 0,
4912
5207
  overrideAccess: true
4913
5208
  });
4914
5209
  for (const msg of messages.docs) {
4915
- await payload.update({ collection: slugs.ticketMessages, id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
5210
+ await dbUpdate(payload, slugs.ticketMessages, { id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
4916
5211
  results.ticketMessages++;
4917
5212
  }
4918
- const chats = await payload.find({
4919
- collection: slugs.chatMessages,
5213
+ const chats = await dbFind(payload, slugs.chatMessages, {
4920
5214
  where: { client: { equals: sourceId } },
4921
5215
  limit: 1e3,
4922
5216
  depth: 0,
4923
5217
  overrideAccess: true
4924
5218
  });
4925
5219
  for (const chat of chats.docs) {
4926
- await payload.update({ collection: slugs.chatMessages, id: chat.id, data: { client: targetId }, overrideAccess: true });
5220
+ await dbUpdate(payload, slugs.chatMessages, { id: chat.id, data: { client: targetId }, overrideAccess: true });
4927
5221
  results.chatMessages++;
4928
5222
  }
4929
- const pendingEmails = await payload.find({
4930
- collection: slugs.pendingEmails,
5223
+ const pendingEmails = await dbFind(payload, slugs.pendingEmails, {
4931
5224
  where: { client: { equals: sourceId } },
4932
5225
  limit: 500,
4933
5226
  depth: 0,
4934
5227
  overrideAccess: true
4935
5228
  });
4936
5229
  for (const pe of pendingEmails.docs) {
4937
- await payload.update({ collection: slugs.pendingEmails, id: pe.id, data: { client: targetId }, overrideAccess: true });
5230
+ await dbUpdate(payload, slugs.pendingEmails, { id: pe.id, data: { client: targetId }, overrideAccess: true });
4938
5231
  results.pendingEmails++;
4939
5232
  }
4940
- const surveys = await payload.find({
4941
- collection: slugs.satisfactionSurveys,
5233
+ const surveys = await dbFind(payload, slugs.satisfactionSurveys, {
4942
5234
  where: { client: { equals: sourceId } },
4943
5235
  limit: 500,
4944
5236
  depth: 0,
4945
5237
  overrideAccess: true
4946
5238
  });
4947
5239
  for (const survey of surveys.docs) {
4948
- await payload.update({ collection: slugs.satisfactionSurveys, id: survey.id, data: { client: targetId }, overrideAccess: true });
5240
+ await dbUpdate(payload, slugs.satisfactionSurveys, { id: survey.id, data: { client: targetId }, overrideAccess: true });
4949
5241
  results.satisfactionSurveys++;
4950
5242
  }
4951
- await payload.delete({
4952
- collection: slugs.supportClients,
5243
+ await dbDelete(payload, slugs.supportClients, {
4953
5244
  id: sourceId,
4954
5245
  overrideAccess: true
4955
5246
  });
@@ -5143,8 +5434,7 @@ function createImportConversationEndpoint(slugs) {
5143
5434
  if (blockedEmails.includes(conversation.client.email)) {
5144
5435
  return Response.json({ error: "Cannot create ticket from system email address" }, { status: 400 });
5145
5436
  }
5146
- const clientResult = await payload.find({
5147
- collection: slugs.supportClients,
5437
+ const clientResult = await dbFind(payload, slugs.supportClients, {
5148
5438
  where: { email: { equals: conversation.client.email } },
5149
5439
  limit: 1,
5150
5440
  depth: 0,
@@ -5155,8 +5445,7 @@ function createImportConversationEndpoint(slugs) {
5155
5445
  if (!client) {
5156
5446
  const nameParts = conversation.client.name.split(" ");
5157
5447
  const randomPassword = crypto3.randomBytes(48).toString("base64url");
5158
- client = await payload.create({
5159
- collection: slugs.supportClients,
5448
+ client = await dbCreate(payload, slugs.supportClients, {
5160
5449
  data: {
5161
5450
  email: conversation.client.email,
5162
5451
  password: randomPassword,
@@ -5168,15 +5457,13 @@ function createImportConversationEndpoint(slugs) {
5168
5457
  });
5169
5458
  isNewClient = true;
5170
5459
  }
5171
- const adminUsers = await payload.find({
5172
- collection: slugs.users,
5460
+ const adminUsers = await dbFind(payload, slugs.users, {
5173
5461
  limit: 1,
5174
5462
  depth: 0,
5175
5463
  overrideAccess: true
5176
5464
  });
5177
5465
  const adminUserId = adminUsers.docs[0]?.id;
5178
- const ticket = await payload.create({
5179
- collection: slugs.tickets,
5466
+ const ticket = await dbCreate(payload, slugs.tickets, {
5180
5467
  data: {
5181
5468
  subject: conversation.subject,
5182
5469
  client: client.id,
@@ -5189,8 +5476,7 @@ function createImportConversationEndpoint(slugs) {
5189
5476
  let importedCount = 0;
5190
5477
  for (const msg of conversation.messages) {
5191
5478
  const isAdmin = msg.from === "admin";
5192
- await payload.create({
5193
- collection: slugs.ticketMessages,
5479
+ await dbCreate(payload, slugs.ticketMessages, {
5194
5480
  data: {
5195
5481
  ticket: ticket.id,
5196
5482
  body: msg.content,
@@ -5226,8 +5512,7 @@ function createImportConversationEndpoint(slugs) {
5226
5512
  // src/utils/fireWebhooks.ts
5227
5513
  async function fireWebhooks(payload, slugs, event, data) {
5228
5514
  try {
5229
- const endpoints = await payload.find({
5230
- collection: slugs.webhookEndpoints,
5515
+ const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
5231
5516
  where: {
5232
5517
  active: { equals: true },
5233
5518
  events: { contains: event }
@@ -5251,8 +5536,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5251
5536
  signal: AbortSignal.timeout(1e4)
5252
5537
  });
5253
5538
  try {
5254
- await payload.update({
5255
- collection: slugs.webhookEndpoints,
5539
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5256
5540
  id: endpoint.id,
5257
5541
  data: {
5258
5542
  lastTriggeredAt: timestamp,
@@ -5265,8 +5549,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5265
5549
  } catch (error) {
5266
5550
  console.warn(`[support] Webhook delivery failed: ${endpoint.url}`, error);
5267
5551
  try {
5268
- await payload.update({
5269
- collection: slugs.webhookEndpoints,
5552
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5270
5553
  id: endpoint.id,
5271
5554
  data: {
5272
5555
  lastTriggeredAt: timestamp,
@@ -5301,8 +5584,7 @@ function createProcessScheduledEndpoint(slugs) {
5301
5584
  const settings = await readSupportSettings(payload);
5302
5585
  const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5303
5586
  const results = { processed: 0, errors: 0 };
5304
- const scheduled = await payload.find({
5305
- collection: slugs.ticketMessages,
5587
+ const scheduled = await dbFind(payload, slugs.ticketMessages, {
5306
5588
  where: {
5307
5589
  and: [
5308
5590
  { scheduledAt: { less_than_equal: now.toISOString() } },
@@ -5322,8 +5604,7 @@ function createProcessScheduledEndpoint(slugs) {
5322
5604
  try {
5323
5605
  const msg = message;
5324
5606
  const ticketId = typeof msg.ticket === "object" ? msg.ticket.id : msg.ticket;
5325
- const ticket = await payload.findByID({
5326
- collection: slugs.tickets,
5607
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5327
5608
  id: ticketId,
5328
5609
  depth: 1,
5329
5610
  overrideAccess: true
@@ -5346,8 +5627,7 @@ function createProcessScheduledEndpoint(slugs) {
5346
5627
  subject: `Re: [${ticketNumber}] ${subject}`,
5347
5628
  html: `<p>Bonjour ${escapeHtml(client.firstName || "")},</p><p>Notre \xE9quipe a r\xE9pondu \xE0 votre ticket <strong>${escapeHtml(ticketNumber)}</strong>.</p><blockquote style="border-left:4px solid #2563eb;padding:12px;margin:16px 0;background:#f8fafc;">${escapeHtml(preview)}</blockquote><p><a href="${baseUrl}/support/tickets/${ticketId}">Consulter le ticket</a></p>`
5348
5629
  });
5349
- await payload.update({
5350
- collection: slugs.ticketMessages,
5630
+ await dbUpdate(payload, slugs.ticketMessages, {
5351
5631
  id: msg.id,
5352
5632
  data: {
5353
5633
  scheduledSent: true,
@@ -5357,16 +5637,14 @@ function createProcessScheduledEndpoint(slugs) {
5357
5637
  overrideAccess: true
5358
5638
  });
5359
5639
  } else {
5360
- await payload.update({
5361
- collection: slugs.ticketMessages,
5640
+ await dbUpdate(payload, slugs.ticketMessages, {
5362
5641
  id: msg.id,
5363
5642
  data: { scheduledSent: true },
5364
5643
  overrideAccess: true
5365
5644
  });
5366
5645
  }
5367
5646
  if (msg.authorType === "admin" && !msg.isInternal) {
5368
- await payload.update({
5369
- collection: slugs.tickets,
5647
+ await dbUpdate(payload, slugs.tickets, {
5370
5648
  id: ticketId,
5371
5649
  data: { status: "waiting_client" },
5372
5650
  overrideAccess: true
@@ -5398,6 +5676,296 @@ function createProcessScheduledEndpoint(slugs) {
5398
5676
  };
5399
5677
  }
5400
5678
 
5679
+ // src/endpoints/process-snooze.ts
5680
+ function createProcessSnoozeEndpoint(slugs) {
5681
+ return {
5682
+ path: "/support/process-snooze",
5683
+ method: "post",
5684
+ handler: async (req) => {
5685
+ const secret = req.headers.get("x-cron-secret");
5686
+ const expectedSecret = process.env.CRON_SECRET;
5687
+ if (!expectedSecret || secret !== expectedSecret) {
5688
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5689
+ }
5690
+ try {
5691
+ const payload = req.payload;
5692
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
5693
+ const results = { processed: 0, errors: 0 };
5694
+ const due = await dbFind(payload, slugs.tickets, {
5695
+ where: {
5696
+ and: [
5697
+ { snoozeUntil: { exists: true } },
5698
+ { snoozeUntil: { less_than_equal: nowIso } }
5699
+ ]
5700
+ },
5701
+ limit: 500,
5702
+ depth: 0,
5703
+ overrideAccess: true,
5704
+ select: { id: true, ticketNumber: true }
5705
+ });
5706
+ for (const ticket of due.docs) {
5707
+ try {
5708
+ await dbUpdate(payload, slugs.tickets, {
5709
+ id: ticket.id,
5710
+ data: { snoozeUntil: null },
5711
+ overrideAccess: true
5712
+ });
5713
+ try {
5714
+ await dbCreate(payload, slugs.ticketActivityLog, {
5715
+ data: {
5716
+ ticket: ticket.id,
5717
+ action: "snooze_expired",
5718
+ detail: "Snooze \xE9chu \u2014 ticket r\xE9activ\xE9",
5719
+ actorType: "system"
5720
+ },
5721
+ overrideAccess: true
5722
+ });
5723
+ } catch {
5724
+ }
5725
+ results.processed++;
5726
+ } catch (err) {
5727
+ console.error("[support] Failed to wake snoozed ticket:", err);
5728
+ results.errors++;
5729
+ }
5730
+ }
5731
+ return Response.json({ ok: true, ...results });
5732
+ } catch (err) {
5733
+ console.error("[process-snooze] Error:", err);
5734
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5735
+ }
5736
+ }
5737
+ };
5738
+ }
5739
+
5740
+ // src/endpoints/process-digests.ts
5741
+ function createProcessDigestsEndpoint(slugs) {
5742
+ return {
5743
+ path: "/support/process-digests",
5744
+ method: "post",
5745
+ handler: async (req) => {
5746
+ const secret = req.headers.get("x-cron-secret");
5747
+ if (!process.env.CRON_SECRET || secret !== process.env.CRON_SECRET) {
5748
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5749
+ }
5750
+ try {
5751
+ const payload = req.payload;
5752
+ let body = {};
5753
+ try {
5754
+ body = await req.json();
5755
+ } catch {
5756
+ }
5757
+ const frequency = body.frequency === "weekly" ? "weekly" : "daily";
5758
+ const settings = await readSupportSettings(payload);
5759
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5760
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
5761
+ const itemsByClient = /* @__PURE__ */ new Map();
5762
+ let page = 1;
5763
+ let hasMore = true;
5764
+ while (hasMore && page <= 50) {
5765
+ const batch = await dbFind(payload, slugs.notificationQueue, { limit: 200, page, depth: 0, overrideAccess: true, sort: "createdAt" });
5766
+ for (const it of batch.docs) {
5767
+ const raw = it;
5768
+ const clientId = typeof raw.client === "object" ? raw.client?.id : raw.client;
5769
+ if (clientId === void 0 || clientId === null) continue;
5770
+ const key = String(clientId);
5771
+ if (!itemsByClient.has(key)) itemsByClient.set(key, []);
5772
+ itemsByClient.get(key).push({ id: raw.id, title: raw.title, message: raw.message });
5773
+ }
5774
+ hasMore = batch.hasNextPage ?? false;
5775
+ page++;
5776
+ }
5777
+ const results = { clients: 0, items: 0, errors: 0 };
5778
+ for (const [clientId, items] of itemsByClient) {
5779
+ try {
5780
+ const client = await dbFindByID(payload, slugs.supportClients, { id: clientId, depth: 0, overrideAccess: true });
5781
+ if (!client || client.notificationFrequency !== frequency) continue;
5782
+ if (client.email) {
5783
+ 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("");
5784
+ const html = emailWrapper("Recapitulatif de vos tickets", [
5785
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
5786
+ emailParagraph(`Voici le recapitulatif de l'activite sur vos tickets (${items.length} notification${items.length > 1 ? "s" : ""}) :`),
5787
+ `<table cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table>`,
5788
+ ...baseUrl ? [emailParagraph(`<a href="${baseUrl}/support/dashboard">Acceder a votre espace support</a>`)] : []
5789
+ ].join(""), { kind: "system" });
5790
+ await payload.sendEmail({ to: client.email, ...replyTo ? { replyTo } : {}, subject: `Recapitulatif support (${items.length})`, html });
5791
+ }
5792
+ for (const i of items) {
5793
+ try {
5794
+ await dbDelete(payload, slugs.notificationQueue, { id: i.id, overrideAccess: true });
5795
+ } catch {
5796
+ }
5797
+ }
5798
+ results.clients++;
5799
+ results.items += items.length;
5800
+ } catch (err) {
5801
+ console.error("[process-digests] client error:", err);
5802
+ results.errors++;
5803
+ }
5804
+ }
5805
+ return Response.json({ ok: true, frequency, ...results });
5806
+ } catch (err) {
5807
+ console.error("[process-digests] Error:", err);
5808
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5809
+ }
5810
+ }
5811
+ };
5812
+ }
5813
+ var PROVIDERS = ["whatsapp", "messenger"];
5814
+ function createChannelsWebhookEndpoint(slugs) {
5815
+ return {
5816
+ path: "/support/channels/webhook",
5817
+ method: "post",
5818
+ handler: async (req) => {
5819
+ const secret = req.headers.get("x-channel-secret");
5820
+ if (!process.env.CHANNELS_WEBHOOK_SECRET || secret !== process.env.CHANNELS_WEBHOOK_SECRET) {
5821
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5822
+ }
5823
+ let body;
5824
+ try {
5825
+ body = await req.json();
5826
+ } catch {
5827
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5828
+ }
5829
+ const provider = body.provider;
5830
+ const from = (body.from || "").trim();
5831
+ const text = (body.text || "").trim();
5832
+ if (!PROVIDERS.includes(provider) || !from || !text) {
5833
+ return Response.json({ error: "provider, from et text requis." }, { status: 400 });
5834
+ }
5835
+ try {
5836
+ const payload = req.payload;
5837
+ const email = `${from}@${provider}.channel`;
5838
+ const existing = await dbFind(payload, slugs.supportClients, { where: { email: { equals: email } }, limit: 1, depth: 0, overrideAccess: true });
5839
+ let clientId;
5840
+ if (existing.docs.length > 0) {
5841
+ clientId = existing.docs[0].id;
5842
+ } else {
5843
+ const created = await dbCreate(payload, slugs.supportClients, {
5844
+ data: { email, firstName: body.name || provider, lastName: from.slice(0, 40), company: provider, password: randomBytes(16).toString("hex") },
5845
+ overrideAccess: true
5846
+ });
5847
+ clientId = created.id;
5848
+ }
5849
+ const open = await dbFind(payload, slugs.tickets, {
5850
+ where: { and: [{ client: { equals: clientId } }, { source: { equals: provider } }, { status: { in: ["open", "waiting_client"] } }] },
5851
+ sort: "-createdAt",
5852
+ limit: 1,
5853
+ depth: 0,
5854
+ overrideAccess: true
5855
+ });
5856
+ let ticketId;
5857
+ let createdTicket = false;
5858
+ if (open.docs.length > 0) {
5859
+ ticketId = open.docs[0].id;
5860
+ } else {
5861
+ const t = await dbCreate(payload, slugs.tickets, {
5862
+ data: { subject: text.slice(0, 80), client: clientId, status: "open", priority: "normal", source: provider },
5863
+ overrideAccess: true
5864
+ });
5865
+ ticketId = t.id;
5866
+ createdTicket = true;
5867
+ }
5868
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
5869
+ data: { ticket: ticketId, body: text, authorType: "client", authorClient: clientId },
5870
+ overrideAccess: true
5871
+ });
5872
+ return Response.json({ ok: true, ticketId, createdTicket, messageId: msg.id });
5873
+ } catch (err) {
5874
+ console.error("[channels] Error:", err);
5875
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5876
+ }
5877
+ }
5878
+ };
5879
+ }
5880
+ var configured = false;
5881
+ function ensureVapid() {
5882
+ if (configured) return true;
5883
+ const publicKey = process.env.VAPID_PUBLIC_KEY;
5884
+ const privateKey = process.env.VAPID_PRIVATE_KEY;
5885
+ if (!publicKey || !privateKey) return false;
5886
+ webpush.setVapidDetails(process.env.VAPID_SUBJECT || "mailto:support@example.com", publicKey, privateKey);
5887
+ configured = true;
5888
+ return true;
5889
+ }
5890
+ function getVapidPublicKey() {
5891
+ return process.env.VAPID_PUBLIC_KEY || null;
5892
+ }
5893
+ async function sendPushToUser(payload, slugs, userId, notification) {
5894
+ if (!ensureVapid()) return { sent: 0 };
5895
+ let sent = 0;
5896
+ try {
5897
+ const subs = await dbFind(payload, slugs.pushSubscriptions, { where: { user: { equals: userId } }, limit: 100, depth: 0, overrideAccess: true });
5898
+ for (const s of subs.docs) {
5899
+ const row = s;
5900
+ if (!row.endpoint || !row.p256dh || !row.auth) continue;
5901
+ try {
5902
+ await webpush.sendNotification(
5903
+ { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
5904
+ JSON.stringify(notification)
5905
+ );
5906
+ sent++;
5907
+ } catch (err) {
5908
+ const code = err?.statusCode;
5909
+ if (code === 404 || code === 410) {
5910
+ try {
5911
+ await dbDelete(payload, slugs.pushSubscriptions, { id: row.id, overrideAccess: true });
5912
+ } catch {
5913
+ }
5914
+ }
5915
+ }
5916
+ }
5917
+ } catch (err) {
5918
+ console.error("[support] Push send failed:", err);
5919
+ }
5920
+ return { sent };
5921
+ }
5922
+
5923
+ // src/endpoints/push.ts
5924
+ function createVapidKeyEndpoint() {
5925
+ return {
5926
+ path: "/support/push/vapid-public-key",
5927
+ method: "get",
5928
+ handler: async () => Response.json({ publicKey: getVapidPublicKey() })
5929
+ };
5930
+ }
5931
+ function createPushSubscribeEndpoint(slugs) {
5932
+ return {
5933
+ path: "/support/push/subscribe",
5934
+ method: "post",
5935
+ handler: async (req) => {
5936
+ try {
5937
+ requireAdmin(req, slugs);
5938
+ let body;
5939
+ try {
5940
+ body = await req.json();
5941
+ } catch {
5942
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5943
+ }
5944
+ const sub = body?.subscription;
5945
+ const endpoint = sub?.endpoint;
5946
+ const p256dh = sub?.keys?.p256dh;
5947
+ const auth = sub?.keys?.auth;
5948
+ if (!endpoint || !p256dh || !auth) {
5949
+ return Response.json({ error: "subscription invalide (endpoint + keys requis)." }, { status: 400 });
5950
+ }
5951
+ const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: req.headers.get("user-agent") || "" };
5952
+ const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true });
5953
+ if (existing.docs.length > 0) {
5954
+ await dbUpdate(req.payload, slugs.pushSubscriptions, { id: existing.docs[0].id, data, overrideAccess: true });
5955
+ } else {
5956
+ await dbCreate(req.payload, slugs.pushSubscriptions, { data, overrideAccess: true });
5957
+ }
5958
+ return Response.json({ ok: true });
5959
+ } catch (error) {
5960
+ const authResponse = handleAuthError(error);
5961
+ if (authResponse) return authResponse;
5962
+ console.error("[push] Error:", error);
5963
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5964
+ }
5965
+ }
5966
+ };
5967
+ }
5968
+
5401
5969
  // src/endpoints/user-prefs.ts
5402
5970
  var PREF_KEY_PREFIX = "support-user-prefs";
5403
5971
  var DEFAULT_USER_PREFS2 = {
@@ -5413,8 +5981,7 @@ function createUserPrefsGetEndpoint(slugs) {
5413
5981
  const payload = req.payload;
5414
5982
  requireAdmin(req, slugs);
5415
5983
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5416
- const prefs = await payload.find({
5417
- collection: "payload-preferences",
5984
+ const prefs = await dbFind(payload, "payload-preferences", {
5418
5985
  where: { key: { equals: key } },
5419
5986
  limit: 1,
5420
5987
  depth: 0,
@@ -5448,8 +6015,7 @@ function createUserPrefsPostEndpoint(slugs) {
5448
6015
  requireAdmin(req, slugs);
5449
6016
  const body = await req.json();
5450
6017
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5451
- const existing = await payload.find({
5452
- collection: "payload-preferences",
6018
+ const existing = await dbFind(payload, "payload-preferences", {
5453
6019
  where: { key: { equals: key } },
5454
6020
  limit: 1,
5455
6021
  depth: 0,
@@ -5510,8 +6076,7 @@ function createEscalateEndpoint(slugs) {
5510
6076
  if (Number.isNaN(ticketId)) {
5511
6077
  return Response.json({ error: "invalid-id" }, { status: 400 });
5512
6078
  }
5513
- const ticket = await req.payload.update({
5514
- collection: slugs.tickets,
6079
+ const ticket = await dbUpdate(req.payload, slugs.tickets, {
5515
6080
  id: ticketId,
5516
6081
  data: { status: "escalated" },
5517
6082
  overrideAccess: true
@@ -5559,8 +6124,7 @@ function createTicketFeedbackEndpoint(slugs) {
5559
6124
  { status: 400 }
5560
6125
  );
5561
6126
  }
5562
- const ticket = await payload.findByID({
5563
- collection: slugs.tickets,
6127
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5564
6128
  id: ticketId,
5565
6129
  depth: 0,
5566
6130
  overrideAccess: true
@@ -5572,8 +6136,7 @@ function createTicketFeedbackEndpoint(slugs) {
5572
6136
  if (ticketClientId !== req.user.id) {
5573
6137
  return Response.json({ error: "forbidden" }, { status: 403 });
5574
6138
  }
5575
- const existing = await payload.find({
5576
- collection: "ticket-feedback",
6139
+ const existing = await dbFind(payload, "ticket-feedback", {
5577
6140
  where: { ticket: { equals: ticketId } },
5578
6141
  limit: 1,
5579
6142
  depth: 0,
@@ -5585,8 +6148,7 @@ function createTicketFeedbackEndpoint(slugs) {
5585
6148
  { status: 409 }
5586
6149
  );
5587
6150
  }
5588
- const feedback = await payload.create({
5589
- collection: "ticket-feedback",
6151
+ const feedback = await dbCreate(payload, "ticket-feedback", {
5590
6152
  data: {
5591
6153
  ticket: ticketId,
5592
6154
  client: req.user.id,
@@ -5610,6 +6172,7 @@ function createTicketFeedbackEndpoint(slugs) {
5610
6172
  // src/endpoints/transfer-ticket.ts
5611
6173
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5612
6174
  var MAX_TRANSFERS_PER_DAY = 5;
6175
+ var transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY);
5613
6176
  function createTransferTicketEndpoint(slugs) {
5614
6177
  return {
5615
6178
  path: "/support/tickets/:id/transfer",
@@ -5634,8 +6197,7 @@ function createTransferTicketEndpoint(slugs) {
5634
6197
  const includeAttachments = body.includeAttachments !== false;
5635
6198
  const customMessage = typeof body.message === "string" ? body.message.slice(0, 1e3) : "";
5636
6199
  const payload = req.payload;
5637
- const ticket = await payload.findByID({
5638
- collection: slugs.tickets,
6200
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5639
6201
  id: ticketId,
5640
6202
  depth: 1,
5641
6203
  overrideAccess: true
@@ -5646,10 +6208,15 @@ function createTransferTicketEndpoint(slugs) {
5646
6208
  if (!isAdmin && !isOwner) {
5647
6209
  return Response.json({ error: "Forbidden" }, { status: 403 });
5648
6210
  }
6211
+ if (transferLimiter.check(`${req.user.id}:${ticketId}`)) {
6212
+ return Response.json(
6213
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6214
+ { status: 429 }
6215
+ );
6216
+ }
5649
6217
  try {
5650
6218
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
5651
- const existing = await payload.count({
5652
- collection: slugs.emailLogs,
6219
+ const existing = await dbCount(payload, slugs.emailLogs, {
5653
6220
  where: {
5654
6221
  and: [
5655
6222
  { action: { equals: "transfer" } },
@@ -5667,8 +6234,7 @@ function createTransferTicketEndpoint(slugs) {
5667
6234
  }
5668
6235
  } catch {
5669
6236
  }
5670
- const messages = await payload.find({
5671
- collection: slugs.ticketMessages,
6237
+ const messages = await dbFind(payload, slugs.ticketMessages, {
5672
6238
  where: {
5673
6239
  and: [
5674
6240
  { ticket: { equals: ticketId } },
@@ -5748,8 +6314,7 @@ function createTransferTicketEndpoint(slugs) {
5748
6314
  html
5749
6315
  });
5750
6316
  try {
5751
- await payload.create({
5752
- collection: slugs.emailLogs,
6317
+ await dbCreate(payload, slugs.emailLogs, {
5753
6318
  data: {
5754
6319
  status: "success",
5755
6320
  action: "transfer",
@@ -5789,6 +6354,8 @@ function statusToLabel(status) {
5789
6354
  }
5790
6355
  }
5791
6356
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6357
+ var MAX_COLLABORATORS_PER_TICKET = 20;
6358
+ var inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10);
5792
6359
  function createInviteCollaboratorEndpoint(slugs) {
5793
6360
  return {
5794
6361
  path: "/support/tickets/:id/invite",
@@ -5812,8 +6379,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5812
6379
  }
5813
6380
  const role = body.role === "collaborator" ? "collaborator" : "viewer";
5814
6381
  const payload = req.payload;
5815
- const ticket = await payload.findByID({
5816
- collection: slugs.tickets,
6382
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5817
6383
  id: ticketId,
5818
6384
  depth: 1,
5819
6385
  overrideAccess: true
@@ -5825,13 +6391,22 @@ function createInviteCollaboratorEndpoint(slugs) {
5825
6391
  if (!isAdmin && !isOwner) {
5826
6392
  return Response.json({ error: "Forbidden" }, { status: 403 });
5827
6393
  }
6394
+ if (inviteLimiter.check(String(req.user.id))) {
6395
+ return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6396
+ }
6397
+ const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
6398
+ if (collabCount.totalDocs >= MAX_COLLABORATORS_PER_TICKET) {
6399
+ return Response.json(
6400
+ { error: `Limite de ${MAX_COLLABORATORS_PER_TICKET} collaborateurs atteinte sur ce ticket` },
6401
+ { status: 429 }
6402
+ );
6403
+ }
5828
6404
  const ownerEmail = typeof ticket.client === "object" ? ticket.client?.email : void 0;
5829
6405
  if (ownerEmail && ownerEmail.toLowerCase() === email) {
5830
6406
  return Response.json({ error: "Ce client est d\xE9j\xE0 propri\xE9taire du ticket" }, { status: 400 });
5831
6407
  }
5832
6408
  let inviteeId = null;
5833
- const existing = await payload.find({
5834
- collection: slugs.supportClients,
6409
+ const existing = await dbFind(payload, slugs.supportClients, {
5835
6410
  where: { email: { equals: email } },
5836
6411
  limit: 1,
5837
6412
  depth: 0,
@@ -5841,8 +6416,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5841
6416
  inviteeId = existing.docs[0].id;
5842
6417
  } else {
5843
6418
  const tempPassword = randomBytes(16).toString("hex");
5844
- const created = await payload.create({
5845
- collection: slugs.supportClients,
6419
+ const created = await dbCreate(payload, slugs.supportClients, {
5846
6420
  data: {
5847
6421
  email,
5848
6422
  firstName: "Invit\xE9",
@@ -5859,8 +6433,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5859
6433
  if (!inviteeId) {
5860
6434
  return Response.json({ error: "Impossible de cr\xE9er le client invit\xE9" }, { status: 500 });
5861
6435
  }
5862
- const dupe = await payload.find({
5863
- collection: "ticket-collaborators",
6436
+ const dupe = await dbFind(payload, "ticket-collaborators", {
5864
6437
  where: {
5865
6438
  and: [
5866
6439
  { ticket: { equals: ticketId } },
@@ -5873,15 +6446,13 @@ function createInviteCollaboratorEndpoint(slugs) {
5873
6446
  });
5874
6447
  const invitationToken = randomBytes(24).toString("hex");
5875
6448
  if (dupe.docs.length > 0) {
5876
- await payload.update({
5877
- collection: "ticket-collaborators",
6449
+ await dbUpdate(payload, "ticket-collaborators", {
5878
6450
  id: dupe.docs[0].id,
5879
6451
  data: { role, invitationToken },
5880
6452
  overrideAccess: true
5881
6453
  });
5882
6454
  } else {
5883
- await payload.create({
5884
- collection: "ticket-collaborators",
6455
+ await dbCreate(payload, "ticket-collaborators", {
5885
6456
  data: {
5886
6457
  ticket: ticketId,
5887
6458
  client: inviteeId,
@@ -5924,8 +6495,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5924
6495
  )
5925
6496
  });
5926
6497
  try {
5927
- await payload.create({
5928
- collection: slugs.emailLogs,
6498
+ await dbCreate(payload, slugs.emailLogs, {
5929
6499
  data: {
5930
6500
  status: "success",
5931
6501
  action: "invite",
@@ -5978,6 +6548,7 @@ function createSupportEndpoints(slugs, options) {
5978
6548
  ];
5979
6549
  if (!f || f.ai !== false) {
5980
6550
  endpoints.push(createAiEndpoint(slugs));
6551
+ endpoints.push(createAiAgentEndpoint(slugs));
5981
6552
  endpoints.push(...createClientIntelligenceEndpoint(slugs));
5982
6553
  endpoints.push(createTicketSynthesisEndpoint(slugs));
5983
6554
  }
@@ -6008,21 +6579,28 @@ function createSupportEndpoints(slugs, options) {
6008
6579
  endpoints.push(createAdminChatGetEndpoint(slugs), createAdminChatPostEndpoint(slugs));
6009
6580
  endpoints.push(createAdminChatStreamEndpoint(slugs));
6010
6581
  }
6011
- if (!f || f.timeTracking !== false) endpoints.push(createBillingEndpoint(slugs));
6582
+ if (!f || f.timeTracking !== false) {
6583
+ endpoints.push(createBillingEndpoint(slugs));
6584
+ endpoints.push(createInvoiceEndpoint(slugs));
6585
+ }
6012
6586
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
6013
6587
  if (!f || f.emailTracking !== false) {
6014
6588
  endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
6015
6589
  }
6016
6590
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
6017
6591
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
6592
+ if (!f || f.snooze !== false) endpoints.push(createProcessSnoozeEndpoint(slugs));
6593
+ endpoints.push(createProcessDigestsEndpoint(slugs));
6594
+ endpoints.push(createChannelsWebhookEndpoint(slugs));
6595
+ endpoints.push(createVapidKeyEndpoint());
6596
+ endpoints.push(createPushSubscribeEndpoint(slugs));
6018
6597
  return endpoints;
6019
6598
  }
6020
6599
 
6021
6600
  // src/utils/adminNotification.ts
6022
6601
  async function createAdminNotification(payload, data, collectionSlug = "admin-notifications") {
6023
6602
  try {
6024
- await payload.create({
6025
- collection: collectionSlug,
6603
+ await dbCreate(payload, collectionSlug, {
6026
6604
  data: {
6027
6605
  title: data.title,
6028
6606
  message: data.message,
@@ -6042,8 +6620,7 @@ function dispatchWebhook(data, event, payload, slugs) {
6042
6620
  }
6043
6621
  async function _dispatch(data, event, payload, slugs) {
6044
6622
  try {
6045
- const { docs: endpoints } = await payload.find({
6046
- collection: slugs.webhookEndpoints,
6623
+ const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
6047
6624
  where: {
6048
6625
  and: [
6049
6626
  { active: { equals: true } },
@@ -6080,8 +6657,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
6080
6657
  signal: AbortSignal.timeout(1e4)
6081
6658
  // 10s timeout
6082
6659
  });
6083
- await payload.update({
6084
- collection: slugs.webhookEndpoints,
6660
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6085
6661
  id: endpoint.id,
6086
6662
  data: {
6087
6663
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6095,8 +6671,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
6095
6671
  } catch (err) {
6096
6672
  console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
6097
6673
  try {
6098
- await payload.update({
6099
- collection: slugs.webhookEndpoints,
6674
+ await dbUpdate(payload, slugs.webhookEndpoints, {
6100
6675
  id: endpoint.id,
6101
6676
  data: {
6102
6677
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6330,8 +6905,7 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6330
6905
  if (typeof policyRef === "object" && policyRef.firstResponseTime) {
6331
6906
  return policyRef;
6332
6907
  }
6333
- return await payload.findByID({
6334
- collection: slugs.slaPolicies,
6908
+ return await dbFindByID(payload, slugs.slaPolicies, {
6335
6909
  id: policyId,
6336
6910
  depth: 0,
6337
6911
  overrideAccess: true
@@ -6341,9 +6915,22 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6341
6915
  }
6342
6916
  }
6343
6917
  const priority = field(ticket, "priority") || "normal";
6918
+ const teamRef = field(ticket, "team");
6919
+ const teamId = typeof teamRef === "object" && teamRef !== null ? teamRef.id : teamRef;
6920
+ if (teamId) {
6921
+ try {
6922
+ const teamPolicies = await dbFind(payload, slugs.slaPolicies, {
6923
+ where: { and: [{ team: { equals: teamId } }, { priority: { equals: priority } }] },
6924
+ limit: 1,
6925
+ depth: 0,
6926
+ overrideAccess: true
6927
+ });
6928
+ if (teamPolicies.docs.length > 0) return teamPolicies.docs[0];
6929
+ } catch {
6930
+ }
6931
+ }
6344
6932
  try {
6345
- const defaults = await payload.find({
6346
- collection: slugs.slaPolicies,
6933
+ const defaults = await dbFind(payload, slugs.slaPolicies, {
6347
6934
  where: {
6348
6935
  and: [
6349
6936
  { isDefault: { equals: true } },
@@ -6380,8 +6967,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6380
6967
  if (typeof policyRef === "object" && policyRef.escalateOnBreach !== void 0) {
6381
6968
  policy = policyRef;
6382
6969
  } else {
6383
- policy = await payload.findByID({
6384
- collection: slugs.slaPolicies,
6970
+ policy = await dbFindByID(payload, slugs.slaPolicies, {
6385
6971
  id: policyId,
6386
6972
  depth: 0,
6387
6973
  overrideAccess: true
@@ -6394,8 +6980,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6394
6980
  try {
6395
6981
  const escalateToId = typeof policy.escalateTo === "object" ? policy.escalateTo.id : policy.escalateTo;
6396
6982
  if (!escalateToId) return;
6397
- const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await payload.findByID({
6398
- collection: slugs.users,
6983
+ const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await dbFindByID(payload, slugs.users, {
6399
6984
  id: escalateToId,
6400
6985
  depth: 0,
6401
6986
  overrideAccess: true
@@ -6443,8 +7028,7 @@ function createAssignSlaDeadlines(slugs, notificationSlug = "admin-notifications
6443
7028
  if (!field(doc, "slaPolicy") && policy.id) {
6444
7029
  updateData.slaPolicy = policy.id;
6445
7030
  }
6446
- await payload.update({
6447
- collection: slugs.tickets,
7031
+ await dbUpdate(payload, slugs.tickets, {
6448
7032
  id: doc.id,
6449
7033
  data: updateData,
6450
7034
  overrideAccess: true
@@ -6467,8 +7051,7 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6467
7051
  const now = /* @__PURE__ */ new Date();
6468
7052
  const deadline = new Date(doc.slaResolutionDue);
6469
7053
  const breached = now > deadline;
6470
- await payload.update({
6471
- collection: slugs.tickets,
7054
+ await dbUpdate(payload, slugs.tickets, {
6472
7055
  id: doc.id,
6473
7056
  data: { slaResolutionBreached: breached },
6474
7057
  overrideAccess: true
@@ -6482,6 +7065,35 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6482
7065
  return doc;
6483
7066
  };
6484
7067
  }
7068
+ function createPauseSlaOnHold(slugs) {
7069
+ return async ({ doc, previousDoc, operation, req }) => {
7070
+ if (operation !== "update" || !previousDoc) return doc;
7071
+ if (previousDoc.status === doc.status) return doc;
7072
+ if (!doc.slaResolutionDue) return doc;
7073
+ const wasWaiting = previousDoc.status === "waiting_client";
7074
+ const isWaiting = doc.status === "waiting_client";
7075
+ try {
7076
+ if (!wasWaiting && isWaiting && !doc.slaPausedAt && doc.status !== "resolved") {
7077
+ await req.payload.update({
7078
+ collection: slugs.tickets,
7079
+ id: doc.id,
7080
+ data: { slaPausedAt: (/* @__PURE__ */ new Date()).toISOString() },
7081
+ overrideAccess: true
7082
+ });
7083
+ } else if (wasWaiting && !isWaiting && doc.slaPausedAt) {
7084
+ const pausedMs = Date.now() - new Date(doc.slaPausedAt).getTime();
7085
+ const data = { slaPausedAt: null };
7086
+ if (pausedMs > 0) {
7087
+ data.slaResolutionDue = new Date(new Date(doc.slaResolutionDue).getTime() + pausedMs).toISOString();
7088
+ }
7089
+ await req.payload.update({ collection: slugs.tickets, id: doc.id, data, overrideAccess: true });
7090
+ }
7091
+ } catch (err) {
7092
+ console.error("[sla] Failed to pause/resume SLA on hold:", err);
7093
+ }
7094
+ return doc;
7095
+ };
7096
+ }
6485
7097
  function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications") {
6486
7098
  return async ({ doc, operation, req }) => {
6487
7099
  if (operation !== "create") return doc;
@@ -6489,8 +7101,7 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6489
7101
  try {
6490
7102
  const { payload } = req;
6491
7103
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
6492
- const ticket = await payload.findByID({
6493
- collection: slugs.tickets,
7104
+ const ticket = await dbFindByID(payload, slugs.tickets, {
6494
7105
  id: ticketId,
6495
7106
  depth: 1,
6496
7107
  overrideAccess: true
@@ -6501,22 +7112,123 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6501
7112
  const now = /* @__PURE__ */ new Date();
6502
7113
  const deadline = new Date(ticket.slaFirstResponseDue);
6503
7114
  const breached = now > deadline;
6504
- await payload.update({
6505
- collection: slugs.tickets,
7115
+ await dbUpdate(payload, slugs.tickets, {
6506
7116
  id: ticketId,
6507
7117
  data: { slaFirstResponseBreached: breached },
6508
7118
  overrideAccess: true
6509
7119
  });
6510
- if (breached) {
6511
- await handleSlaBreach(payload, ticket, "first_response", slugs, notificationSlug);
7120
+ if (breached) {
7121
+ await handleSlaBreach(payload, ticket, "first_response", slugs, notificationSlug);
7122
+ }
7123
+ } catch (err) {
7124
+ console.error("[sla] Failed to check SLA on reply:", err);
7125
+ }
7126
+ return doc;
7127
+ };
7128
+ }
7129
+
7130
+ // src/hooks/automationRules.ts
7131
+ function evaluateConditions(doc, rule) {
7132
+ const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
7133
+ if (conds.length === 0) return true;
7134
+ const results = conds.map((c) => {
7135
+ const fieldVal = String(doc[c.field] ?? "");
7136
+ const v = String(c.value ?? "");
7137
+ switch (c.operator) {
7138
+ case "equals":
7139
+ return fieldVal === v;
7140
+ case "not_equals":
7141
+ return fieldVal !== v;
7142
+ case "contains":
7143
+ return fieldVal.toLowerCase().includes(v.toLowerCase());
7144
+ default:
7145
+ return false;
7146
+ }
7147
+ });
7148
+ return rule.matchType === "any" ? results.some(Boolean) : results.every(Boolean);
7149
+ }
7150
+ function createApplyAutomationRules(slugs) {
7151
+ return async ({ doc, previousDoc, operation, req }) => {
7152
+ if (req.context?.skipAutomation) return doc;
7153
+ const events = [];
7154
+ if (operation === "create") events.push("ticket_created");
7155
+ if (operation === "update") {
7156
+ events.push("ticket_updated");
7157
+ if (previousDoc && previousDoc.status !== doc.status) events.push("ticket_status_changed");
7158
+ }
7159
+ if (events.length === 0) return doc;
7160
+ try {
7161
+ const rules = await dbFind(req.payload, slugs.automationRules, {
7162
+ where: { and: [{ enabled: { equals: true } }, { event: { in: events } }] },
7163
+ sort: "order",
7164
+ limit: 200,
7165
+ depth: 0,
7166
+ overrideAccess: true
7167
+ });
7168
+ const changes = {};
7169
+ const tags = Array.isArray(doc.tags) ? [...doc.tags] : [];
7170
+ let tagsTouched = false;
7171
+ for (const rule of rules.docs) {
7172
+ if (!evaluateConditions(doc, rule)) continue;
7173
+ for (const a of Array.isArray(rule.actions) ? rule.actions : []) {
7174
+ if (!a.value) continue;
7175
+ switch (a.type) {
7176
+ case "set_status":
7177
+ changes.status = a.value;
7178
+ break;
7179
+ case "set_priority":
7180
+ changes.priority = a.value;
7181
+ break;
7182
+ case "set_category":
7183
+ changes.category = a.value;
7184
+ break;
7185
+ case "assign":
7186
+ changes.assignedTo = a.value;
7187
+ break;
7188
+ case "add_tag":
7189
+ if (!tags.includes(a.value)) {
7190
+ tags.push(a.value);
7191
+ tagsTouched = true;
7192
+ }
7193
+ break;
7194
+ }
7195
+ }
7196
+ }
7197
+ if (tagsTouched) changes.tags = tags;
7198
+ if (Object.keys(changes).length > 0) {
7199
+ await req.payload.update({
7200
+ collection: slugs.tickets,
7201
+ id: doc.id,
7202
+ data: changes,
7203
+ overrideAccess: true,
7204
+ context: { skipAutomation: true }
7205
+ });
6512
7206
  }
6513
7207
  } catch (err) {
6514
- console.error("[sla] Failed to check SLA on reply:", err);
7208
+ console.error("[support] Failed to apply automation rules:", err);
6515
7209
  }
6516
7210
  return doc;
6517
7211
  };
6518
7212
  }
6519
7213
 
7214
+ // src/utils/teamAccess.ts
7215
+ function isTeamScopingEnabled() {
7216
+ return process.env.SUPPORT_TEAM_SCOPING === "1" || process.env.SUPPORT_TEAM_SCOPING === "true";
7217
+ }
7218
+ async function resolveAgentTeamIds(payload, slugs, userId) {
7219
+ try {
7220
+ const teams = await dbFind(payload, slugs.supportTeams, {
7221
+ where: { members: { in: [userId] } },
7222
+ limit: 200,
7223
+ depth: 0,
7224
+ overrideAccess: true
7225
+ });
7226
+ return teams.docs.map((t) => t.id);
7227
+ } catch {
7228
+ return [];
7229
+ }
7230
+ }
7231
+
6520
7232
  // src/collections/Tickets.ts
6521
7233
  function createAssignTicketNumber(slugs) {
6522
7234
  return async ({ data, operation, req }) => {
@@ -6779,56 +7491,6 @@ function createNotifyClientOnResolve(slugs) {
6779
7491
  return doc;
6780
7492
  };
6781
7493
  }
6782
- function createAutoCalculateSLA(slugs) {
6783
- return async ({ doc, operation, req }) => {
6784
- if (operation !== "create") return doc;
6785
- try {
6786
- const { payload } = req;
6787
- const ticketPriority = doc.priority || "normal";
6788
- let policy = null;
6789
- const byPriority = await payload.find({
6790
- collection: slugs.slaPolicies,
6791
- where: { priority: { equals: ticketPriority } },
6792
- limit: 1,
6793
- depth: 0,
6794
- overrideAccess: true
6795
- });
6796
- if (byPriority.docs.length > 0) {
6797
- policy = byPriority.docs[0];
6798
- } else {
6799
- const defaults = await payload.find({
6800
- collection: slugs.slaPolicies,
6801
- where: { isDefault: { equals: true } },
6802
- limit: 1,
6803
- depth: 0,
6804
- overrideAccess: true
6805
- });
6806
- if (defaults.docs.length > 0) {
6807
- policy = defaults.docs[0];
6808
- }
6809
- }
6810
- if (!policy) return doc;
6811
- const now = /* @__PURE__ */ new Date();
6812
- const firstResponseMinutes = policy.firstResponseTime || 240;
6813
- const resolutionMinutes = policy.resolutionTime || 1440;
6814
- const firstResponseDue = new Date(now.getTime() + firstResponseMinutes * 6e4);
6815
- const resolutionDue = new Date(now.getTime() + resolutionMinutes * 6e4);
6816
- await payload.update({
6817
- collection: slugs.tickets,
6818
- id: doc.id,
6819
- data: {
6820
- slaPolicy: policy.id,
6821
- slaFirstResponseDue: firstResponseDue.toISOString(),
6822
- slaResolutionDue: resolutionDue.toISOString()
6823
- },
6824
- overrideAccess: true
6825
- });
6826
- } catch (err) {
6827
- console.error("[support] Failed to auto-calculate SLA:", err);
6828
- }
6829
- return doc;
6830
- };
6831
- }
6832
7494
  function createFireTicketWebhooks(slugs) {
6833
7495
  return async ({ doc, previousDoc, operation, req }) => {
6834
7496
  const { payload } = req;
@@ -6920,8 +7582,7 @@ function createCascadeDelete(slugs) {
6920
7582
  return async ({ id, req }) => {
6921
7583
  const collections = [slugs.ticketMessages, slugs.ticketActivityLog, slugs.timeEntries, slugs.satisfactionSurveys];
6922
7584
  for (const slug of collections) {
6923
- await req.payload.delete({
6924
- collection: slug,
7585
+ await dbDelete(req.payload, slug, {
6925
7586
  where: { ticket: { equals: id } },
6926
7587
  overrideAccess: true
6927
7588
  });
@@ -7046,6 +7707,7 @@ function createTicketsCollection(slugs, options) {
7046
7707
  {
7047
7708
  name: "status",
7048
7709
  type: "select",
7710
+ index: true,
7049
7711
  defaultValue: "open",
7050
7712
  label: "Statut",
7051
7713
  options: [
@@ -7060,6 +7722,7 @@ function createTicketsCollection(slugs, options) {
7060
7722
  {
7061
7723
  name: "priority",
7062
7724
  type: "select",
7725
+ index: true,
7063
7726
  defaultValue: "normal",
7064
7727
  label: "Priorite",
7065
7728
  options: [
@@ -7075,7 +7738,7 @@ function createTicketsCollection(slugs, options) {
7075
7738
  {
7076
7739
  type: "row",
7077
7740
  fields: [
7078
- { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, label: "Client", admin: { width: "50%" } }
7741
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client", admin: { width: "50%" } }
7079
7742
  ]
7080
7743
  },
7081
7744
  // Billing collapsible
@@ -7144,15 +7807,16 @@ function createTicketsCollection(slugs, options) {
7144
7807
  {
7145
7808
  type: "row",
7146
7809
  fields: [
7147
- { name: "slaFirstResponseDue", type: "date", label: "Echeance 1ere reponse (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7148
- { name: "slaResolutionDue", type: "date", label: "Echeance resolution (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
7810
+ { name: "slaFirstResponseDue", type: "date", index: true, label: "Echeance 1ere reponse (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7811
+ { name: "slaResolutionDue", type: "date", index: true, label: "Echeance resolution (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
7149
7812
  ]
7150
7813
  },
7151
7814
  {
7152
7815
  type: "row",
7153
7816
  fields: [
7154
7817
  { name: "slaFirstResponseBreached", type: "checkbox", label: "1ere reponse depassee", admin: { readOnly: true, width: "50%" } },
7155
- { name: "slaResolutionBreached", type: "checkbox", label: "Resolution depassee", admin: { readOnly: true, width: "50%" } }
7818
+ { name: "slaResolutionBreached", type: "checkbox", label: "Resolution depassee", admin: { readOnly: true, width: "50%" } },
7819
+ { name: "slaPausedAt", type: "date", label: "SLA en pause depuis", admin: { hidden: true } }
7156
7820
  ]
7157
7821
  }
7158
7822
  ]
@@ -7174,7 +7838,7 @@ function createTicketsCollection(slugs, options) {
7174
7838
  },
7175
7839
  { name: "mergedInto", type: "relationship", relationTo: slugs.tickets, label: "Fusionne dans", admin: { readOnly: true } },
7176
7840
  { name: "autoCloseRemindedAt", type: "date", label: "Rappel auto-close envoye", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7177
- { name: "autoCloseScheduledAt", type: "date", label: "Fermeture auto programmee", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" }, description: "Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client." } }
7841
+ { name: "autoCloseScheduledAt", type: "date", index: true, label: "Fermeture auto programmee", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" }, description: "Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client." } }
7178
7842
  ]
7179
7843
  },
7180
7844
  // Sidebar
@@ -7183,6 +7847,7 @@ function createTicketsCollection(slugs, options) {
7183
7847
  {
7184
7848
  name: "category",
7185
7849
  type: "select",
7850
+ index: true,
7186
7851
  label: "Categorie",
7187
7852
  options: [
7188
7853
  { label: "Bug / Dysfonctionnement", value: "bug" },
@@ -7193,7 +7858,8 @@ function createTicketsCollection(slugs, options) {
7193
7858
  ],
7194
7859
  admin: { position: "sidebar" }
7195
7860
  },
7196
- { name: "assignedTo", type: "relationship", relationTo: slugs.users, label: "Assigne a", admin: { position: "sidebar" } },
7861
+ { name: "assignedTo", type: "relationship", relationTo: slugs.users, index: true, label: "Assigne a", admin: { position: "sidebar" } },
7862
+ { name: "team", type: "relationship", relationTo: slugs.supportTeams, index: true, label: "Equipe", admin: { position: "sidebar" } },
7197
7863
  {
7198
7864
  name: "source",
7199
7865
  type: "select",
@@ -7203,7 +7869,9 @@ function createTicketsCollection(slugs, options) {
7203
7869
  { label: "Portail client", value: "portal" },
7204
7870
  { label: "Chat en direct", value: "live-chat" },
7205
7871
  { label: "Email", value: "email" },
7206
- { label: "Admin", value: "admin" }
7872
+ { label: "Admin", value: "admin" },
7873
+ { label: "WhatsApp", value: "whatsapp" },
7874
+ { label: "Messenger", value: "messenger" }
7207
7875
  ],
7208
7876
  admin: { position: "sidebar" }
7209
7877
  },
@@ -7254,8 +7922,8 @@ function createTicketsCollection(slugs, options) {
7254
7922
  afterChange: [
7255
7923
  createTrackSLA(slugs),
7256
7924
  createTrackAiSummaryOnResolve(slugs),
7257
- createAutoCalculateSLA(slugs),
7258
7925
  createAssignSlaDeadlines(slugs, notificationSlug),
7926
+ createPauseSlaOnHold(slugs),
7259
7927
  createCheckSlaOnResolve(slugs, notificationSlug),
7260
7928
  createLogTicketActivity(slugs),
7261
7929
  createNotifyOnAssignment(slugs),
@@ -7263,7 +7931,8 @@ function createTicketsCollection(slugs, options) {
7263
7931
  createTicketStatusEmail(slugs),
7264
7932
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
7265
7933
  createFireTicketWebhooks(slugs),
7266
- createDispatchWebhookOnTicket(slugs)
7934
+ createDispatchWebhookOnTicket(slugs),
7935
+ createApplyAutomationRules(slugs)
7267
7936
  ],
7268
7937
  beforeDelete: [createCascadeDelete(slugs)]
7269
7938
  },
@@ -7274,12 +7943,20 @@ function createTicketsCollection(slugs, options) {
7274
7943
  return false;
7275
7944
  },
7276
7945
  read: async ({ req }) => {
7277
- if (req.user?.collection === slugs.users) return true;
7946
+ if (req.user?.collection === slugs.users) {
7947
+ if (!isTeamScopingEnabled()) return true;
7948
+ const teamIds = await resolveAgentTeamIds(req.payload, slugs, req.user.id);
7949
+ return {
7950
+ or: [
7951
+ { team: { in: teamIds.length > 0 ? teamIds : [-1] } },
7952
+ { team: { exists: false } }
7953
+ ]
7954
+ };
7955
+ }
7278
7956
  if (req.user?.collection === slugs.supportClients) {
7279
7957
  let collabTicketIds = [];
7280
7958
  try {
7281
- const rows = await req.payload.find({
7282
- collection: "ticket-collaborators",
7959
+ const rows = await dbFind(req.payload, "ticket-collaborators", {
7283
7960
  where: { client: { equals: req.user.id } },
7284
7961
  limit: 1e3,
7285
7962
  depth: 0,
@@ -7314,6 +7991,125 @@ function createTicketsCollection(slugs, options) {
7314
7991
  };
7315
7992
  }
7316
7993
 
7994
+ // src/utils/ticketAccess.ts
7995
+ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
7996
+ const ids = /* @__PURE__ */ new Set();
7997
+ try {
7998
+ const owned = await dbFind(payload, slugs.tickets, {
7999
+ where: { client: { equals: clientId } },
8000
+ limit: 5e3,
8001
+ depth: 0,
8002
+ overrideAccess: true
8003
+ });
8004
+ for (const t of owned.docs) ids.add(t.id);
8005
+ } catch {
8006
+ }
8007
+ try {
8008
+ const collab = await dbFind(payload, "ticket-collaborators", {
8009
+ where: { client: { equals: clientId } },
8010
+ limit: 1e3,
8011
+ depth: 0,
8012
+ overrideAccess: true
8013
+ });
8014
+ for (const r of collab.docs) {
8015
+ const row = r;
8016
+ const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
8017
+ if (tid !== void 0 && tid !== null) ids.add(tid);
8018
+ }
8019
+ } catch {
8020
+ }
8021
+ return Array.from(ids);
8022
+ }
8023
+ var OPTIONS = {
8024
+ allowedTags: [
8025
+ "p",
8026
+ "br",
8027
+ "b",
8028
+ "strong",
8029
+ "i",
8030
+ "em",
8031
+ "u",
8032
+ "s",
8033
+ "strike",
8034
+ "del",
8035
+ "ins",
8036
+ "mark",
8037
+ "a",
8038
+ "ul",
8039
+ "ol",
8040
+ "li",
8041
+ "blockquote",
8042
+ "code",
8043
+ "pre",
8044
+ "kbd",
8045
+ "span",
8046
+ "div",
8047
+ "h1",
8048
+ "h2",
8049
+ "h3",
8050
+ "h4",
8051
+ "h5",
8052
+ "h6",
8053
+ "hr",
8054
+ "img",
8055
+ "table",
8056
+ "thead",
8057
+ "tbody",
8058
+ "tr",
8059
+ "th",
8060
+ "td"
8061
+ ],
8062
+ allowedAttributes: {
8063
+ a: ["href", "title", "target", "rel"],
8064
+ img: ["src", "alt", "title", "width", "height"],
8065
+ code: ["class"],
8066
+ pre: ["class"],
8067
+ span: ["class"],
8068
+ "*": ["style"]
8069
+ },
8070
+ allowedSchemes: ["http", "https", "mailto", "tel"],
8071
+ allowedSchemesByTag: { img: ["http", "https", "data"] },
8072
+ allowProtocolRelative: false,
8073
+ disallowedTagsMode: "discard",
8074
+ // Force safe rel on links opening a new tab (defense against tabnabbing).
8075
+ transformTags: {
8076
+ a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer nofollow" }, true)
8077
+ },
8078
+ // Only allow CSS that cannot carry a script payload.
8079
+ allowedStyles: {
8080
+ "*": {
8081
+ color: [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
8082
+ "background-color": [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
8083
+ "text-align": [/^(left|right|center|justify)$/],
8084
+ "font-weight": [/^(normal|bold|[1-9]00)$/],
8085
+ "font-style": [/^(normal|italic)$/],
8086
+ "text-decoration": [/^(underline|line-through|none)$/]
8087
+ }
8088
+ }
8089
+ };
8090
+ function sanitizeMessageHtml(html) {
8091
+ if (!html) return html;
8092
+ return sanitizeHtml(html, OPTIONS);
8093
+ }
8094
+
8095
+ // src/utils/notificationQueue.ts
8096
+ async function queueClientNotification(payload, slugs, item) {
8097
+ try {
8098
+ await dbCreate(payload, slugs.notificationQueue, {
8099
+ data: {
8100
+ client: item.client,
8101
+ type: item.type,
8102
+ title: item.title,
8103
+ message: item.message,
8104
+ ...item.ticket ? { ticket: item.ticket } : {}
8105
+ },
8106
+ overrideAccess: true
8107
+ });
8108
+ } catch (err) {
8109
+ console.error("[support] Failed to queue client notification:", err);
8110
+ }
8111
+ }
8112
+
7317
8113
  // src/collections/TicketMessages.ts
7318
8114
  function createAssignAuthor(slugs) {
7319
8115
  return async ({ data, operation, req }) => {
@@ -7376,29 +8172,46 @@ function createNotifyClient(slugs) {
7376
8172
  const internalBcc = process.env.SUPPORT_CLIENT_BCC || void 0;
7377
8173
  const rawContent = doc.bodyHtml ? emailRichContent(doc.bodyHtml) : emailQuote(doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body);
7378
8174
  const replyPreview = (doc.body || "").replace(/\s+/g, " ").trim().slice(0, 90);
7379
- await req.payload.sendEmail({
7380
- to: client.email,
7381
- ...supportEmail ? { replyTo: supportEmail } : {},
7382
- ...internalBcc ? { bcc: internalBcc } : {},
7383
- subject: `Re: [${ticketNumber}] ${subject}`,
7384
- html: emailWrapper(`Nouvelle reponse \u2014 ${ticketNumber}`, [
7385
- emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
7386
- emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`),
7387
- rawContent,
7388
- emailButton("Consulter le ticket", portalUrl),
7389
- emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
7390
- emailTrackingPixel(ticketId, doc.id)
7391
- ].join(""), {
7392
- kind: "reply",
7393
- preheader: replyPreview ? `${replyPreview}\u2026` : `Nouvelle reponse a votre ticket ${ticketNumber}`
7394
- })
7395
- });
7396
- await req.payload.update({
7397
- collection: slugs.ticketMessages,
7398
- id: doc.id,
7399
- data: { emailSentAt: (/* @__PURE__ */ new Date()).toISOString(), emailSentTo: client.email },
7400
- overrideAccess: true
7401
- });
8175
+ const freq = client.notificationFrequency;
8176
+ if (freq === "daily" || freq === "weekly") {
8177
+ await queueClientNotification(req.payload, slugs, {
8178
+ client: client.id,
8179
+ type: "reply",
8180
+ title: `Nouvelle reponse \u2014 ${ticketNumber}`,
8181
+ message: replyPreview || `Reponse a votre ticket ${ticketNumber}`,
8182
+ ticket: ticketId
8183
+ });
8184
+ return doc;
8185
+ }
8186
+ void (async () => {
8187
+ try {
8188
+ await req.payload.sendEmail({
8189
+ to: client.email,
8190
+ ...supportEmail ? { replyTo: supportEmail } : {},
8191
+ ...internalBcc ? { bcc: internalBcc } : {},
8192
+ subject: `Re: [${ticketNumber}] ${subject}`,
8193
+ html: emailWrapper(`Nouvelle reponse \u2014 ${ticketNumber}`, [
8194
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
8195
+ emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`),
8196
+ rawContent,
8197
+ emailButton("Consulter le ticket", portalUrl),
8198
+ emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
8199
+ emailTrackingPixel(ticketId, doc.id)
8200
+ ].join(""), {
8201
+ kind: "reply",
8202
+ preheader: replyPreview ? `${replyPreview}\u2026` : `Nouvelle reponse a votre ticket ${ticketNumber}`
8203
+ })
8204
+ });
8205
+ await req.payload.update({
8206
+ collection: slugs.ticketMessages,
8207
+ id: doc.id,
8208
+ data: { emailSentAt: (/* @__PURE__ */ new Date()).toISOString(), emailSentTo: client.email },
8209
+ overrideAccess: true
8210
+ });
8211
+ } catch (err) {
8212
+ console.error("[support] Failed to notify client (async):", err);
8213
+ }
8214
+ })();
7402
8215
  } catch (err) {
7403
8216
  console.error("[support] Failed to notify client:", err);
7404
8217
  }
@@ -7436,8 +8249,7 @@ function createSyncTicketReplyToChat(slugs) {
7436
8249
  if (!ticket?.chatSession) return doc;
7437
8250
  if (doc.skipNotification) return doc;
7438
8251
  const clientId = typeof ticket.client === "object" ? ticket.client.id : ticket.client;
7439
- await payload.create({
7440
- collection: slugs.chatMessages,
8252
+ await dbCreate(payload, slugs.chatMessages, {
7441
8253
  data: {
7442
8254
  session: ticket.chatSession,
7443
8255
  client: clientId,
@@ -7499,6 +8311,15 @@ function createNotifyAdminOnClientMessage(slugs, notificationSlug) {
7499
8311
  }
7500
8312
  const preview = doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body;
7501
8313
  const headerTitle = isNewTicket ? `Nouveau ticket ${ticketNumber}` : `Nouveau message \u2014 ${ticketNumber}`;
8314
+ const assignedId = assignedAdmin?.id ?? (ticket.assignedTo && typeof ticket.assignedTo !== "object" ? ticket.assignedTo : void 0);
8315
+ if (assignedId) {
8316
+ void sendPushToUser(payload, slugs, assignedId, {
8317
+ title: headerTitle,
8318
+ body: `${clientName}: ${preview || subject}`,
8319
+ url: `/admin/collections/${slugs.tickets}/${ticketId}`
8320
+ }).catch(() => {
8321
+ });
8322
+ }
7502
8323
  if (primaryEmail) {
7503
8324
  await payload.sendEmail({
7504
8325
  // INTERNAL email (to the support team) — recipients are the contact
@@ -7554,6 +8375,57 @@ function createDispatchWebhookOnReply(slugs) {
7554
8375
  return doc;
7555
8376
  };
7556
8377
  }
8378
+ function createSanitizeMessageHtml() {
8379
+ return ({ data }) => {
8380
+ if (data && typeof data.bodyHtml === "string" && data.bodyHtml) {
8381
+ data.bodyHtml = sanitizeMessageHtml(data.bodyHtml);
8382
+ }
8383
+ return data;
8384
+ };
8385
+ }
8386
+ var MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
8387
+ function createResolveMentions(slugs) {
8388
+ return async ({ data, operation, req }) => {
8389
+ if (operation !== "create") return data;
8390
+ const body = typeof data?.body === "string" ? data.body : "";
8391
+ const emails = Array.from(new Set([...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase())));
8392
+ if (emails.length === 0) return data;
8393
+ try {
8394
+ const users = await dbFind(req.payload, slugs.users, { where: { email: { in: emails } }, limit: 50, depth: 0, overrideAccess: true });
8395
+ const ids = users.docs.map((u) => u.id);
8396
+ if (ids.length > 0) data.mentions = ids;
8397
+ } catch {
8398
+ }
8399
+ return data;
8400
+ };
8401
+ }
8402
+ function createNotifyMentions(slugs) {
8403
+ return async ({ doc, operation, req }) => {
8404
+ if (operation !== "create") return doc;
8405
+ const mentions = Array.isArray(doc.mentions) ? doc.mentions : [];
8406
+ if (mentions.length === 0) return doc;
8407
+ const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
8408
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
8409
+ for (const m of mentions) {
8410
+ const userId = typeof m === "object" ? m.id : m;
8411
+ try {
8412
+ const user = await dbFindByID(req.payload, slugs.users, { id: userId, depth: 0, overrideAccess: true });
8413
+ if (!user?.email) continue;
8414
+ await req.payload.sendEmail({
8415
+ to: user.email,
8416
+ subject: `Vous avez ete mentionne \u2014 ticket #${ticketId}`,
8417
+ html: emailWrapper("Mention", [
8418
+ emailParagraph(`Vous avez ete mentionne dans un message du ticket <strong>#${escapeHtml(String(ticketId))}</strong>.`),
8419
+ ...baseUrl ? [emailButton("Ouvrir le ticket", `${baseUrl}/admin/collections/${slugs.tickets}/${ticketId}`, "dark")] : []
8420
+ ].join(""), { kind: "system" })
8421
+ });
8422
+ } catch (err) {
8423
+ console.error("[support] Failed to notify mention:", err);
8424
+ }
8425
+ }
8426
+ return doc;
8427
+ };
8428
+ }
7557
8429
  function createTicketMessagesCollection(slugs, options) {
7558
8430
  const notificationSlug = options?.notificationSlug || "admin-notifications";
7559
8431
  return {
@@ -7561,7 +8433,7 @@ function createTicketMessagesCollection(slugs, options) {
7561
8433
  labels: { singular: "Message", plural: "Messages" },
7562
8434
  admin: { hidden: true, group: "Support", defaultColumns: ["ticket", "authorType", "createdAt"] },
7563
8435
  fields: [
7564
- { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, label: "Ticket" },
8436
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, index: true, label: "Ticket" },
7565
8437
  { name: "body", type: "textarea", required: true, label: "Message" },
7566
8438
  { name: "bodyHtml", type: "textarea", label: "Message HTML", admin: { hidden: true } },
7567
8439
  {
@@ -7570,6 +8442,7 @@ function createTicketMessagesCollection(slugs, options) {
7570
8442
  {
7571
8443
  name: "authorType",
7572
8444
  type: "select",
8445
+ index: true,
7573
8446
  label: "Type d'auteur",
7574
8447
  defaultValue: "admin",
7575
8448
  options: [
@@ -7605,8 +8478,9 @@ function createTicketMessagesCollection(slugs, options) {
7605
8478
  },
7606
8479
  { name: "isInternal", type: "checkbox", defaultValue: false, label: "Note interne", admin: { position: "sidebar" } },
7607
8480
  { name: "isSolution", type: "checkbox", defaultValue: false, label: "Reponse solution", admin: { position: "sidebar" } },
8481
+ { name: "mentions", type: "relationship", relationTo: slugs.users, hasMany: true, label: "Agents mentionnes", admin: { hidden: true } },
7608
8482
  { name: "skipNotification", type: "checkbox", defaultValue: false, label: "Sans notification", admin: { position: "sidebar", condition: (data) => data?.skipNotification === true } },
7609
- { name: "scheduledAt", type: "date", label: "Programme pour", admin: { date: { pickerAppearance: "dayAndTime" }, position: "sidebar", condition: (data) => !!data?.scheduledAt } },
8483
+ { name: "scheduledAt", type: "date", index: true, label: "Programme pour", admin: { date: { pickerAppearance: "dayAndTime" }, position: "sidebar", condition: (data) => !!data?.scheduledAt } },
7610
8484
  { name: "scheduledSent", type: "checkbox", defaultValue: false, admin: { hidden: true } },
7611
8485
  { name: "editedAt", type: "date", label: "Modifie le", admin: { hidden: true } },
7612
8486
  { name: "deletedAt", type: "date", label: "Supprime le", admin: { hidden: true } },
@@ -7615,7 +8489,7 @@ function createTicketMessagesCollection(slugs, options) {
7615
8489
  { name: "emailOpenedAt", type: "date", label: "Email ouvert le", admin: { hidden: true } }
7616
8490
  ],
7617
8491
  hooks: {
7618
- beforeChange: [createAssignAuthor(slugs)],
8492
+ beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
7619
8493
  afterChange: [
7620
8494
  createAutoUpdateStatus(slugs),
7621
8495
  createNotifyClient(slugs),
@@ -7624,17 +8498,19 @@ function createTicketMessagesCollection(slugs, options) {
7624
8498
  createSyncTicketReplyToChat(slugs),
7625
8499
  createNotifyAdminOnClientMessage(slugs, notificationSlug),
7626
8500
  createFireMessageWebhooks(slugs),
7627
- createDispatchWebhookOnReply(slugs)
8501
+ createDispatchWebhookOnReply(slugs),
8502
+ createNotifyMentions(slugs)
7628
8503
  ]
7629
8504
  },
7630
8505
  access: {
7631
8506
  create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
7632
- read: ({ req }) => {
8507
+ read: async ({ req }) => {
7633
8508
  if (req.user?.collection === slugs.users) return true;
7634
8509
  if (req.user?.collection === slugs.supportClients) {
8510
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
7635
8511
  return {
7636
8512
  and: [
7637
- { "ticket.client": { equals: req.user.id } },
8513
+ { ticket: { in: ids.length > 0 ? ids : [-1] } },
7638
8514
  { isInternal: { equals: false } },
7639
8515
  { or: [{ scheduledAt: { exists: false } }, { scheduledSent: { equals: true } }] }
7640
8516
  ]
@@ -7654,8 +8530,6 @@ function createTicketMessagesCollection(slugs, options) {
7654
8530
  timestamps: true
7655
8531
  };
7656
8532
  }
7657
-
7658
- // src/collections/SupportClients.ts
7659
8533
  function createSendInvitationOnCreate(slugs) {
7660
8534
  return async ({ doc, operation, req }) => {
7661
8535
  if (operation !== "create") return doc;
@@ -7695,6 +8569,26 @@ function createSendInvitationOnCreate(slugs) {
7695
8569
  return doc;
7696
8570
  };
7697
8571
  }
8572
+ var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
8573
+ function createEnforce2FA(slugs) {
8574
+ return async ({ req, user }) => {
8575
+ if (!user?.twoFactorEnabled) return user;
8576
+ const raw = user.twoFactorVerifiedAt;
8577
+ const verifiedAt = raw ? new Date(raw).getTime() : 0;
8578
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
8579
+ throw new APIError("2FA_REQUIRED", 401);
8580
+ }
8581
+ await req.payload.update({
8582
+ collection: slugs.supportClients,
8583
+ id: user.id,
8584
+ data: { twoFactorVerifiedAt: null },
8585
+ req,
8586
+ overrideAccess: true
8587
+ });
8588
+ user.twoFactorVerifiedAt = null;
8589
+ return user;
8590
+ };
8591
+ }
7698
8592
  function createSupportClientsCollection(slugs) {
7699
8593
  return {
7700
8594
  slug: slugs.supportClients,
@@ -7823,6 +8717,10 @@ function createSupportClientsCollection(slugs) {
7823
8717
  name: "opportunities",
7824
8718
  type: "textarea",
7825
8719
  label: "Opportunites commerciales",
8720
+ access: {
8721
+ // Admin-only: never serialize commercial notes onto the client's own doc.
8722
+ read: ({ req }) => req.user?.collection === slugs.users
8723
+ },
7826
8724
  admin: {
7827
8725
  description: "Notes commerciales (visible uniquement par les admins)"
7828
8726
  }
@@ -7866,6 +8764,19 @@ function createSupportClientsCollection(slugs) {
7866
8764
  type: "date",
7867
8765
  admin: { hidden: true }
7868
8766
  },
8767
+ {
8768
+ // Set by /support/2fa "verify"; gates login via the beforeLogin hook.
8769
+ name: "twoFactorVerifiedAt",
8770
+ type: "date",
8771
+ admin: { hidden: true }
8772
+ },
8773
+ {
8774
+ // Google OAuth subject id — accounts are linked by this, not email alone.
8775
+ name: "googleId",
8776
+ type: "text",
8777
+ index: true,
8778
+ admin: { hidden: true }
8779
+ },
7869
8780
  {
7870
8781
  name: "notifyOnReply",
7871
8782
  type: "checkbox",
@@ -7932,6 +8843,10 @@ function createSupportClientsCollection(slugs) {
7932
8843
  name: "notes",
7933
8844
  type: "textarea",
7934
8845
  label: "Notes internes",
8846
+ access: {
8847
+ // Admin-only: never serialize internal notes onto the client's own doc.
8848
+ read: ({ req }) => req.user?.collection === slugs.users
8849
+ },
7935
8850
  admin: {
7936
8851
  description: "Visible uniquement par les admins",
7937
8852
  position: "sidebar"
@@ -7939,6 +8854,7 @@ function createSupportClientsCollection(slugs) {
7939
8854
  }
7940
8855
  ],
7941
8856
  hooks: {
8857
+ beforeLogin: [createEnforce2FA(slugs)],
7942
8858
  afterChange: [createSendInvitationOnCreate(slugs)]
7943
8859
  },
7944
8860
  access: {
@@ -8152,6 +9068,7 @@ function createTicketActivityLogCollection(slugs) {
8152
9068
  type: "relationship",
8153
9069
  relationTo: slugs.tickets,
8154
9070
  required: true,
9071
+ index: true,
8155
9072
  label: "Ticket"
8156
9073
  },
8157
9074
  {
@@ -8197,10 +9114,11 @@ function createTicketActivityLogCollection(slugs) {
8197
9114
  access: {
8198
9115
  create: () => false,
8199
9116
  // Created only by hooks via overrideAccess
8200
- read: ({ req }) => {
9117
+ read: async ({ req }) => {
8201
9118
  if (req.user?.collection === slugs.users) return true;
8202
9119
  if (req.user?.collection === slugs.supportClients) {
8203
- return { "ticket.client": { equals: req.user.id } };
9120
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
9121
+ return { ticket: { in: ids.length > 0 ? ids : [-1] } };
8204
9122
  }
8205
9123
  return false;
8206
9124
  },
@@ -8269,12 +9187,21 @@ function createSatisfactionSurveysCollection(slugs) {
8269
9187
  {
8270
9188
  name: "rating",
8271
9189
  type: "number",
8272
- required: true,
8273
9190
  min: 1,
8274
9191
  max: 5,
8275
9192
  label: "Note (1-5)",
8276
9193
  admin: {
8277
- description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait"
9194
+ description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait (CSAT)"
9195
+ }
9196
+ },
9197
+ {
9198
+ name: "nps",
9199
+ type: "number",
9200
+ min: 0,
9201
+ max: 10,
9202
+ label: "NPS (0-10)",
9203
+ admin: {
9204
+ description: "\xAB Recommanderiez-vous ? \xBB \u2014 promoteur 9-10, passif 7-8, d\xE9tracteur 0-6"
8278
9205
  }
8279
9206
  },
8280
9207
  {
@@ -8996,6 +9923,16 @@ function createSlaPoliciesCollection(slugs) {
8996
9923
  admin: {
8997
9924
  description: "Utiliser cette politique pour les tickets sans SLA sp\xE9cifique (par priorit\xE9)"
8998
9925
  }
9926
+ },
9927
+ {
9928
+ name: "team",
9929
+ type: "relationship",
9930
+ relationTo: slugs.supportTeams,
9931
+ index: true,
9932
+ label: "\xC9quipe (optionnel)",
9933
+ admin: {
9934
+ description: "Limiter cette politique \xE0 une \xE9quipe \u2014 prioritaire sur la politique par d\xE9faut"
9935
+ }
8999
9936
  }
9000
9937
  ],
9001
9938
  access: {
@@ -9520,6 +10457,148 @@ function createTicketCollaboratorsCollection(slugs) {
9520
10457
  };
9521
10458
  }
9522
10459
 
10460
+ // src/collections/NotificationQueue.ts
10461
+ function createNotificationQueueCollection(slugs) {
10462
+ return {
10463
+ slug: slugs.notificationQueue,
10464
+ labels: { singular: "Notification en file", plural: "Notifications en file" },
10465
+ admin: { hidden: true, group: "Support", defaultColumns: ["client", "type", "title", "createdAt"] },
10466
+ access: {
10467
+ // Internal: written/drained only via overrideAccess (hooks + cron).
10468
+ create: () => false,
10469
+ update: () => false,
10470
+ read: ({ req }) => req.user?.collection === slugs.users,
10471
+ delete: ({ req }) => req.user?.collection === slugs.users
10472
+ },
10473
+ fields: [
10474
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client" },
10475
+ { name: "type", type: "text", label: "Type" },
10476
+ { name: "title", type: "text", label: "Titre" },
10477
+ { name: "message", type: "textarea", label: "Message" },
10478
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, label: "Ticket" }
10479
+ ],
10480
+ timestamps: true
10481
+ };
10482
+ }
10483
+
10484
+ // src/collections/AutomationRules.ts
10485
+ var CONDITION_FIELDS = ["status", "priority", "category", "source", "subject"];
10486
+ var ACTION_TYPES = [
10487
+ { label: "D\xE9finir le statut", value: "set_status" },
10488
+ { label: "D\xE9finir la priorit\xE9", value: "set_priority" },
10489
+ { label: "D\xE9finir la cat\xE9gorie", value: "set_category" },
10490
+ { label: "Assigner \xE0 (id agent)", value: "assign" },
10491
+ { label: "Ajouter un tag", value: "add_tag" }
10492
+ ];
10493
+ function createAutomationRulesCollection(slugs) {
10494
+ return {
10495
+ slug: slugs.automationRules,
10496
+ labels: { singular: "R\xE8gle d'automatisation", plural: "R\xE8gles d'automatisation" },
10497
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "event", "enabled", "order"] },
10498
+ access: {
10499
+ read: ({ req }) => req.user?.collection === slugs.users,
10500
+ create: ({ req }) => req.user?.collection === slugs.users,
10501
+ update: ({ req }) => req.user?.collection === slugs.users,
10502
+ delete: ({ req }) => req.user?.collection === slugs.users
10503
+ },
10504
+ fields: [
10505
+ { name: "name", type: "text", required: true, label: "Nom" },
10506
+ { name: "enabled", type: "checkbox", defaultValue: true, label: "Active", index: true },
10507
+ {
10508
+ name: "event",
10509
+ type: "select",
10510
+ defaultValue: "ticket_created",
10511
+ index: true,
10512
+ label: "D\xE9clencheur",
10513
+ options: [
10514
+ { label: "Ticket cr\xE9\xE9", value: "ticket_created" },
10515
+ { label: "Ticket mis \xE0 jour", value: "ticket_updated" },
10516
+ { label: "Changement de statut", value: "ticket_status_changed" }
10517
+ ]
10518
+ },
10519
+ {
10520
+ name: "matchType",
10521
+ type: "select",
10522
+ defaultValue: "all",
10523
+ label: "Correspondance",
10524
+ options: [{ label: "Toutes les conditions (ET)", value: "all" }, { label: "Au moins une (OU)", value: "any" }]
10525
+ },
10526
+ { name: "order", type: "number", defaultValue: 0, label: "Ordre d'\xE9valuation" },
10527
+ {
10528
+ name: "conditions",
10529
+ type: "array",
10530
+ label: "Conditions",
10531
+ fields: [
10532
+ { name: "field", type: "select", required: true, options: CONDITION_FIELDS.map((v) => ({ label: v, value: v })) },
10533
+ {
10534
+ name: "operator",
10535
+ type: "select",
10536
+ defaultValue: "equals",
10537
+ required: true,
10538
+ options: [{ label: "= \xE9gal", value: "equals" }, { label: "\u2260 diff\xE9rent", value: "not_equals" }, { label: "contient", value: "contains" }]
10539
+ },
10540
+ { name: "value", type: "text", required: true }
10541
+ ]
10542
+ },
10543
+ {
10544
+ name: "actions",
10545
+ type: "array",
10546
+ label: "Actions",
10547
+ fields: [
10548
+ { name: "type", type: "select", required: true, options: ACTION_TYPES },
10549
+ { name: "value", type: "text", required: true }
10550
+ ]
10551
+ }
10552
+ ],
10553
+ timestamps: true
10554
+ };
10555
+ }
10556
+
10557
+ // src/collections/SupportTeam.ts
10558
+ function createSupportTeamCollection(slugs) {
10559
+ return {
10560
+ slug: slugs.supportTeams,
10561
+ labels: { singular: "\xC9quipe support", plural: "\xC9quipes support" },
10562
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "members"] },
10563
+ access: {
10564
+ read: ({ req }) => req.user?.collection === slugs.users,
10565
+ create: ({ req }) => req.user?.collection === slugs.users,
10566
+ update: ({ req }) => req.user?.collection === slugs.users,
10567
+ delete: ({ req }) => req.user?.collection === slugs.users
10568
+ },
10569
+ fields: [
10570
+ { name: "name", type: "text", required: true, label: "Nom" },
10571
+ { name: "description", type: "textarea", label: "Description" },
10572
+ { name: "members", type: "relationship", relationTo: slugs.users, hasMany: true, index: true, label: "Membres (agents)" }
10573
+ ],
10574
+ timestamps: true
10575
+ };
10576
+ }
10577
+
10578
+ // src/collections/PushSubscription.ts
10579
+ function createPushSubscriptionCollection(slugs) {
10580
+ return {
10581
+ slug: slugs.pushSubscriptions,
10582
+ labels: { singular: "Abonnement push", plural: "Abonnements push" },
10583
+ admin: { hidden: true, group: "Support", defaultColumns: ["user", "endpoint", "createdAt"] },
10584
+ access: {
10585
+ create: () => false,
10586
+ // written via the subscribe endpoint (overrideAccess)
10587
+ read: ({ req }) => req.user?.collection === slugs.users,
10588
+ update: () => false,
10589
+ delete: ({ req }) => req.user?.collection === slugs.users
10590
+ },
10591
+ fields: [
10592
+ { name: "user", type: "relationship", relationTo: slugs.users, index: true, label: "Agent" },
10593
+ { name: "endpoint", type: "text", required: true, index: true, label: "Endpoint" },
10594
+ { name: "p256dh", type: "text", label: "Cl\xE9 p256dh" },
10595
+ { name: "auth", type: "text", label: "Cl\xE9 auth" },
10596
+ { name: "userAgent", type: "text", label: "User-Agent" }
10597
+ ],
10598
+ timestamps: true
10599
+ };
10600
+ }
10601
+
9523
10602
  // src/plugin.ts
9524
10603
  function viewConfig(component, path) {
9525
10604
  return { Component: component, path };
@@ -9555,7 +10634,11 @@ function supportPlugin(config) {
9555
10634
  createSatisfactionSurveysCollection(slugs),
9556
10635
  createKnowledgeBaseCollection(slugs),
9557
10636
  createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
9558
- createTicketCollaboratorsCollection(slugs)
10637
+ createTicketCollaboratorsCollection(slugs),
10638
+ createNotificationQueueCollection(slugs),
10639
+ createAutomationRulesCollection(slugs),
10640
+ createSupportTeamCollection(slugs),
10641
+ createPushSubscriptionCollection(slugs)
9559
10642
  ];
9560
10643
  if (features.authLogs !== false) supportCollections.push(createAuthLogsCollection(slugs));
9561
10644
  if (features.timeTracking !== false) supportCollections.push(createTimeEntriesCollection(slugs));