@consilioweb/payload-support 0.15.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +347 -435
  2. package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
  4. package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
  5. package/dist/components/TicketConversation/SkeletonText.js +20 -0
  6. package/dist/components/TicketConversation/components/ActionPanels.cjs +72 -0
  7. package/dist/components/TicketConversation/components/ActionPanels.js +73 -2
  8. package/dist/components/TicketConversation/components/QuickActions.cjs +22 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +22 -1
  10. package/dist/components/TicketConversation/constants.cjs +27 -0
  11. package/dist/components/TicketConversation/constants.js +26 -1
  12. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  13. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  14. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +59 -0
  15. package/dist/components/TicketConversation/hooks/useTicketActions.js +59 -0
  16. package/dist/components/TicketConversation/index.cjs +42 -149
  17. package/dist/components/TicketConversation/index.js +37 -144
  18. package/dist/index.cjs +1835 -741
  19. package/dist/index.d.cts +3 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +1833 -741
  22. package/dist/views/BillingView/client.cjs +40 -54
  23. package/dist/views/BillingView/client.js +40 -54
  24. package/dist/views/ChatView/client.cjs +4 -3
  25. package/dist/views/ChatView/client.js +4 -3
  26. package/dist/views/CrmView/client.cjs +7 -7
  27. package/dist/views/CrmView/client.js +7 -7
  28. package/dist/views/EmailTrackingView/client.cjs +6 -5
  29. package/dist/views/EmailTrackingView/client.js +6 -5
  30. package/dist/views/ImportConversationView/client.cjs +4 -4
  31. package/dist/views/ImportConversationView/client.js +4 -4
  32. package/dist/views/LogsView/client.cjs +3 -2
  33. package/dist/views/LogsView/client.js +3 -2
  34. package/dist/views/NewTicketView/client.cjs +1 -1
  35. package/dist/views/NewTicketView/client.js +1 -1
  36. package/dist/views/PendingEmailsView/client.cjs +7 -6
  37. package/dist/views/PendingEmailsView/client.js +7 -6
  38. package/dist/views/SupportDashboardView/client.cjs +19 -22
  39. package/dist/views/SupportDashboardView/client.js +19 -22
  40. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  41. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  42. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  43. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  44. package/dist/views/TicketDetailView/client.cjs +59 -190
  45. package/dist/views/TicketDetailView/client.js +52 -183
  46. package/dist/views/TicketDetailView/constants.cjs +20 -0
  47. package/dist/views/TicketDetailView/constants.js +16 -0
  48. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  49. package/dist/views/TicketDetailView/helpers.js +20 -0
  50. package/dist/views/TicketDetailView/types.cjs +2 -0
  51. package/dist/views/TicketDetailView/types.js +1 -0
  52. package/dist/views/TicketInboxView/client.cjs +14 -10
  53. package/dist/views/TicketInboxView/client.js +14 -10
  54. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  55. package/dist/views/TicketingSettingsView/client.js +92 -88
  56. package/dist/views/TimeDashboardView/client.cjs +2 -2
  57. package/dist/views/TimeDashboardView/client.js +2 -2
  58. package/dist/views/shared/StatusPill.cjs +23 -12
  59. package/dist/views/shared/StatusPill.js +23 -12
  60. package/dist/views/shared/dateLocale.cjs +5 -0
  61. package/dist/views/shared/dateLocale.js +3 -0
  62. package/package.json +23 -15
  63. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  64. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  65. package/src/__tests__/integration/auth.test.ts +43 -0
  66. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  67. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  68. package/src/__tests__/integration/channels.test.ts +52 -0
  69. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  70. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  71. package/src/__tests__/integration/digest.test.ts +48 -0
  72. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  73. package/src/__tests__/integration/isolation.test.ts +81 -0
  74. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  75. package/src/__tests__/integration/nps.test.ts +37 -0
  76. package/src/__tests__/integration/sanitization.test.ts +35 -0
  77. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  78. package/src/__tests__/integration/smoke.test.ts +26 -0
  79. package/src/__tests__/integration/snooze.test.ts +68 -0
  80. package/src/__tests__/integration/teams.test.ts +38 -0
  81. package/src/__tests__/rateLimiter.test.ts +89 -0
  82. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  83. package/src/__tests__/sla.test.ts +258 -0
  84. package/src/collections/AutomationRules.ts +66 -0
  85. package/src/collections/NotificationQueue.ts +30 -0
  86. package/src/collections/SatisfactionSurveys.ts +11 -2
  87. package/src/collections/SupportClients.ts +58 -1
  88. package/src/collections/SupportTeam.ts +27 -0
  89. package/src/collections/TicketActivityLog.ts +9 -4
  90. package/src/collections/TicketMessages.ts +137 -31
  91. package/src/collections/Tickets.ts +31 -72
  92. package/src/collections/index.ts +3 -0
  93. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  94. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  95. package/src/components/TicketConversation/components/ActionPanels.tsx +82 -1
  96. package/src/components/TicketConversation/components/QuickActions.tsx +25 -0
  97. package/src/components/TicketConversation/constants.ts +29 -0
  98. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  99. package/src/components/TicketConversation/hooks/useTicketActions.ts +51 -0
  100. package/src/components/TicketConversation/index.tsx +29 -157
  101. package/src/endpoints/admin-chat-stream.ts +3 -4
  102. package/src/endpoints/admin-chat.ts +11 -20
  103. package/src/endpoints/admin-stats.ts +70 -32
  104. package/src/endpoints/ai-agent.ts +36 -0
  105. package/src/endpoints/apply-macro.ts +8 -14
  106. package/src/endpoints/auth-2fa.ts +12 -12
  107. package/src/endpoints/auto-close.ts +38 -18
  108. package/src/endpoints/billing.ts +17 -4
  109. package/src/endpoints/bulk-action.ts +7 -12
  110. package/src/endpoints/channels.ts +89 -0
  111. package/src/endpoints/chat-stream.ts +3 -4
  112. package/src/endpoints/chat.ts +7 -12
  113. package/src/endpoints/chatbot.ts +2 -2
  114. package/src/endpoints/client-intelligence.ts +4 -4
  115. package/src/endpoints/delete-account.ts +9 -16
  116. package/src/endpoints/email-stats.ts +2 -2
  117. package/src/endpoints/escalate.ts +2 -2
  118. package/src/endpoints/export-csv.ts +2 -2
  119. package/src/endpoints/export-data.ts +5 -8
  120. package/src/endpoints/import-conversation.ts +6 -10
  121. package/src/endpoints/index.ts +24 -2
  122. package/src/endpoints/invite-collaborator.ts +30 -15
  123. package/src/endpoints/invoice.ts +127 -0
  124. package/src/endpoints/kb-search.ts +2 -2
  125. package/src/endpoints/login.ts +12 -4
  126. package/src/endpoints/merge-clients.ts +14 -19
  127. package/src/endpoints/merge-tickets.ts +11 -18
  128. package/src/endpoints/oauth-google.ts +86 -39
  129. package/src/endpoints/pending-emails-process.ts +15 -25
  130. package/src/endpoints/process-digests.ts +93 -0
  131. package/src/endpoints/process-scheduled.ts +6 -10
  132. package/src/endpoints/process-snooze.ts +78 -0
  133. package/src/endpoints/purge-logs.ts +2 -2
  134. package/src/endpoints/resend-notification.ts +3 -4
  135. package/src/endpoints/round-robin-config.ts +3 -4
  136. package/src/endpoints/satisfaction.ts +21 -14
  137. package/src/endpoints/search.ts +5 -8
  138. package/src/endpoints/seed-kb.ts +3 -4
  139. package/src/endpoints/send-reminder.ts +186 -0
  140. package/src/endpoints/settings.ts +5 -2
  141. package/src/endpoints/signature.ts +3 -4
  142. package/src/endpoints/sla-check.ts +2 -2
  143. package/src/endpoints/split-ticket.ts +7 -12
  144. package/src/endpoints/statuses.ts +2 -2
  145. package/src/endpoints/ticket-feedback.ts +4 -6
  146. package/src/endpoints/ticket-synthesis.ts +2 -2
  147. package/src/endpoints/track-open.ts +16 -28
  148. package/src/endpoints/transfer-ticket.ts +21 -9
  149. package/src/endpoints/user-prefs.ts +3 -4
  150. package/src/hooks/automationRules.ts +84 -0
  151. package/src/hooks/checkSLA.ts +48 -16
  152. package/src/plugin.ts +6 -0
  153. package/src/portal/LiveChat.tsx +2 -63
  154. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  155. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  156. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  157. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  158. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  159. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  160. package/src/portal/helpers.ts +46 -0
  161. package/src/portal/login/page.tsx +3 -8
  162. package/src/portal/types.ts +15 -0
  163. package/src/types/lucide-react.d.ts +34 -0
  164. package/src/types/payload-config.d.ts +10 -0
  165. package/src/utils/adminNotification.ts +2 -2
  166. package/src/utils/aiAgent.ts +147 -0
  167. package/src/utils/auth.ts +5 -4
  168. package/src/utils/channels.ts +36 -0
  169. package/src/utils/db.ts +73 -0
  170. package/src/utils/emailTemplate.ts +8 -1
  171. package/src/utils/fireWebhooks.ts +4 -6
  172. package/src/utils/generateTicketSynthesis.ts +7 -12
  173. package/src/utils/invoicePdf.ts +88 -0
  174. package/src/utils/notificationQueue.ts +36 -0
  175. package/src/utils/readSettings.ts +22 -6
  176. package/src/utils/sanitizeHtml.ts +57 -0
  177. package/src/utils/slugs.ts +6 -0
  178. package/src/utils/teamAccess.ts +27 -0
  179. package/src/utils/ticketAccess.ts +53 -0
  180. package/src/utils/webhookDispatcher.ts +4 -6
  181. package/src/views/BillingView/client.tsx +49 -37
  182. package/src/views/ChatView/client.tsx +4 -3
  183. package/src/views/CrmView/client.tsx +7 -7
  184. package/src/views/EmailTrackingView/client.tsx +7 -6
  185. package/src/views/ImportConversationView/client.tsx +4 -4
  186. package/src/views/LogsView/client.tsx +3 -2
  187. package/src/views/NewTicketView/client.tsx +1 -1
  188. package/src/views/PendingEmailsView/client.tsx +7 -6
  189. package/src/views/SupportDashboardView/client.tsx +21 -21
  190. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  191. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  192. package/src/views/TicketDetailView/client.tsx +54 -179
  193. package/src/views/TicketDetailView/constants.ts +23 -0
  194. package/src/views/TicketDetailView/helpers.ts +17 -0
  195. package/src/views/TicketDetailView/types.ts +10 -0
  196. package/src/views/TicketInboxView/client.tsx +16 -10
  197. package/src/views/TicketingSettingsView/client.tsx +90 -87
  198. package/src/views/TimeDashboardView/client.tsx +2 -2
  199. package/src/views/shared/StatusPill.tsx +24 -12
  200. package/src/views/shared/dateLocale.ts +5 -0
  201. package/src/views/shared/locales/en.json +708 -0
  202. package/src/views/shared/locales/fr.json +708 -0
package/dist/index.cjs CHANGED
@@ -1,10 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  var crypto3 = require('crypto');
4
+ var PDFDocument = require('pdfkit');
5
+ var payload = require('payload');
6
+ var sanitizeHtml = require('sanitize-html');
4
7
 
5
8
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
6
9
 
7
10
  var crypto3__default = /*#__PURE__*/_interopDefault(crypto3);
11
+ var PDFDocument__default = /*#__PURE__*/_interopDefault(PDFDocument);
12
+ var sanitizeHtml__default = /*#__PURE__*/_interopDefault(sanitizeHtml);
8
13
 
9
14
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
10
15
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
@@ -64,6 +69,9 @@ var DEFAULT_SLUGS = {
64
69
  macros: "macros",
65
70
  ticketStatuses: "ticket-statuses",
66
71
  ticketFeedback: "ticket-feedback",
72
+ notificationQueue: "notification-queue",
73
+ automationRules: "automation-rules",
74
+ supportTeams: "support-teams",
67
75
  users: "users",
68
76
  media: "media"
69
77
  };
@@ -95,6 +103,26 @@ function handleAuthError(error) {
95
103
  return null;
96
104
  }
97
105
 
106
+ // src/utils/db.ts
107
+ function dbFind(payload, slug, options = {}) {
108
+ return payload.find({ collection: slug, ...options });
109
+ }
110
+ function dbFindByID(payload, slug, options) {
111
+ return payload.findByID({ collection: slug, ...options });
112
+ }
113
+ function dbCreate(payload, slug, options) {
114
+ return payload.create({ collection: slug, ...options });
115
+ }
116
+ function dbUpdate(payload, slug, options) {
117
+ return payload.update({ collection: slug, ...options });
118
+ }
119
+ function dbCount(payload, slug, options = {}) {
120
+ return payload.count({ collection: slug, ...options });
121
+ }
122
+ function dbDelete(payload, slug, options) {
123
+ return payload.delete({ collection: slug, ...options });
124
+ }
125
+
98
126
  // src/utils/readSettings.ts
99
127
  var PREF_KEY = "support-settings";
100
128
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
@@ -108,10 +136,18 @@ var DEFAULT_USER_PREFS = {
108
136
  locale: "fr",
109
137
  signature: ""
110
138
  };
139
+ var settingsCache = null;
140
+ var SETTINGS_TTL_MS = 6e4;
141
+ function invalidateSupportSettingsCache() {
142
+ settingsCache = null;
143
+ }
111
144
  async function readSupportSettings(payload) {
145
+ if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
146
+ return settingsCache.value;
147
+ }
148
+ let value = { ...DEFAULT_SETTINGS };
112
149
  try {
113
- const prefs = await payload.find({
114
- collection: "payload-preferences",
150
+ const prefs = await dbFind(payload, "payload-preferences", {
115
151
  where: { key: { equals: PREF_KEY } },
116
152
  limit: 1,
117
153
  depth: 0,
@@ -119,7 +155,7 @@ async function readSupportSettings(payload) {
119
155
  });
120
156
  if (prefs.docs.length > 0) {
121
157
  const stored = prefs.docs[0].value;
122
- return {
158
+ value = {
123
159
  email: { ...DEFAULT_SETTINGS.email, ...stored.email },
124
160
  ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
125
161
  sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
@@ -128,13 +164,13 @@ async function readSupportSettings(payload) {
128
164
  }
129
165
  } catch {
130
166
  }
131
- return { ...DEFAULT_SETTINGS };
167
+ settingsCache = { value, ts: Date.now() };
168
+ return value;
132
169
  }
133
170
  async function readUserPrefs(payload, userId) {
134
171
  try {
135
172
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
136
- const prefs = await payload.find({
137
- collection: "payload-preferences",
173
+ const prefs = await dbFind(payload, "payload-preferences", {
138
174
  where: { key: { equals: key } },
139
175
  limit: 1,
140
176
  depth: 0,
@@ -320,8 +356,130 @@ ${text}`;
320
356
  };
321
357
  }
322
358
 
323
- // src/endpoints/client-intelligence.ts
359
+ // src/utils/aiAgent.ts
360
+ async function applyAgentDecision(payload, slugs, ticketId, decision) {
361
+ if (decision.action === "reply" && decision.reply && decision.reply.trim()) {
362
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
363
+ data: {
364
+ ticket: ticketId,
365
+ body: decision.reply.trim(),
366
+ authorType: "admin",
367
+ isInternal: false,
368
+ fromAlias: "Assistant IA"
369
+ },
370
+ overrideAccess: true
371
+ });
372
+ return { acted: "reply", messageId: msg.id, confidence: decision.confidence };
373
+ }
374
+ await dbCreate(payload, slugs.ticketMessages, {
375
+ data: {
376
+ ticket: ticketId,
377
+ body: `[Agent IA] Escalade vers un humain. Raison : ${decision.reason || "non d\xE9termin\xE9e"} (confiance ${Math.round((decision.confidence ?? 0) * 100)} %).`,
378
+ authorType: "admin",
379
+ isInternal: true,
380
+ skipNotification: true
381
+ },
382
+ overrideAccess: true
383
+ });
384
+ await dbUpdate(payload, slugs.tickets, { id: ticketId, data: { status: "escalated" }, overrideAccess: true });
385
+ return { acted: "escalate", confidence: decision.confidence };
386
+ }
324
387
  function getClient2(aiSettings) {
388
+ const Anthropic = __require("@anthropic-ai/sdk").default;
389
+ if (aiSettings.provider === "ollama") {
390
+ return new Anthropic({ apiKey: "ollama", baseURL: process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1" });
391
+ }
392
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
393
+ }
394
+ function stripHtml(s) {
395
+ return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
396
+ }
397
+ function parseDecision(text) {
398
+ try {
399
+ const match = text.match(/\{[\s\S]*\}/);
400
+ if (match) {
401
+ const obj = JSON.parse(match[0]);
402
+ return {
403
+ action: obj.action === "reply" ? "reply" : "escalate",
404
+ reply: typeof obj.reply === "string" ? obj.reply : void 0,
405
+ reason: typeof obj.reason === "string" ? obj.reason : void 0,
406
+ confidence: typeof obj.confidence === "number" ? obj.confidence : 0
407
+ };
408
+ }
409
+ } catch {
410
+ }
411
+ return { action: "escalate", reason: "R\xE9ponse IA non interpr\xE9table", confidence: 0 };
412
+ }
413
+ async function runAiAgent(payload, slugs, ticketId, opts) {
414
+ const settings = await readSupportSettings(payload);
415
+ const threshold = opts?.confidenceThreshold ?? 0.7;
416
+ const ticket = await dbFindByID(payload, slugs.tickets, { id: ticketId, depth: 0, overrideAccess: true });
417
+ const messages = await dbFind(payload, slugs.ticketMessages, { where: { ticket: { equals: ticketId } }, sort: "createdAt", limit: 50, depth: 0, overrideAccess: true });
418
+ const kb = await dbFind(payload, slugs.knowledgeBase, { where: { published: { equals: true } }, limit: 20, depth: 0, overrideAccess: true });
419
+ const conversation = messages.docs.map((m) => `${m.authorType}: ${m.body || ""}`).join("\n");
420
+ const kbText = kb.docs.map((a) => `# ${a.title || ""}
421
+ ${stripHtml(String(a.content ?? ""))}`).join("\n\n").slice(0, 8e3);
422
+ const prompt = `Tu es un agent de support IA. \xC0 partir du ticket et de la base de connaissances ci-dessous, d\xE9cide :
423
+ - "reply" si tu peux r\xE9pondre avec certitude UNIQUEMENT \xE0 partir de la base de connaissances ;
424
+ - "escalate" sinon (question hors KB, ambigu\xEB, sensible, ou n\xE9cessitant une action humaine).
425
+ R\xE9ponds en JSON STRICT, sans texte autour : {"action":"reply"|"escalate","reply":"<r\xE9ponse au client si reply>","reason":"<courte raison>","confidence":<0 \xE0 1>}.
426
+
427
+ TICKET : ${ticket.subject || ""}
428
+ CONVERSATION :
429
+ ${conversation}
430
+
431
+ BASE DE CONNAISSANCES :
432
+ ${kbText || "(vide)"}`;
433
+ let decision;
434
+ try {
435
+ const client = getClient2(settings.ai);
436
+ const resp = await client.messages.create({
437
+ model: settings.ai.model || "claude-haiku-4-5-20251001",
438
+ max_tokens: 1024,
439
+ messages: [{ role: "user", content: prompt }]
440
+ });
441
+ const text = resp?.content?.[0]?.text || "";
442
+ decision = parseDecision(text);
443
+ } catch (err) {
444
+ console.error("[support] AI agent decision failed:", err);
445
+ decision = { action: "escalate", reason: "Erreur du fournisseur IA", confidence: 0 };
446
+ }
447
+ const finalDecision = decision.action === "reply" && (decision.confidence ?? 0) >= threshold ? decision : { action: "escalate", reason: decision.reason || "Confiance insuffisante pour une r\xE9ponse automatique", confidence: decision.confidence };
448
+ return applyAgentDecision(payload, slugs, ticketId, finalDecision);
449
+ }
450
+
451
+ // src/endpoints/ai-agent.ts
452
+ function createAiAgentEndpoint(slugs) {
453
+ return {
454
+ path: "/support/ai-agent",
455
+ method: "post",
456
+ handler: async (req) => {
457
+ try {
458
+ requireAdmin(req, slugs);
459
+ let body = {};
460
+ try {
461
+ body = await req.json();
462
+ } catch {
463
+ }
464
+ if (!body.ticketId) {
465
+ return Response.json({ error: "ticketId requis." }, { status: 400 });
466
+ }
467
+ const result = await runAiAgent(req.payload, slugs, body.ticketId, {
468
+ confidenceThreshold: body.confidenceThreshold
469
+ });
470
+ return Response.json({ ok: true, ...result });
471
+ } catch (error) {
472
+ const authResponse = handleAuthError(error);
473
+ if (authResponse) return authResponse;
474
+ console.error("[ai-agent] Error:", error);
475
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
476
+ }
477
+ }
478
+ };
479
+ }
480
+
481
+ // src/endpoints/client-intelligence.ts
482
+ function getClient3(aiSettings) {
325
483
  const Anthropic = __require("@anthropic-ai/sdk").default;
326
484
  if (aiSettings.provider === "ollama") {
327
485
  const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
@@ -475,7 +633,7 @@ R\xE9ponds en JSON strict (pas de markdown, pas de commentaires) avec cette stru
475
633
  }
476
634
 
477
635
  Sois factuel. Ne d\xE9passe pas 5 items par tableau. R\xE9ponds UNIQUEMENT avec le JSON.`;
478
- const anthropic = getClient2(aiSettings);
636
+ const anthropic = getClient3(aiSettings);
479
637
  const model = getModel2(aiSettings);
480
638
  const res = await anthropic.messages.create({
481
639
  model,
@@ -539,8 +697,7 @@ function createSearchEndpoint(slugs) {
539
697
  return Response.json({ tickets: [], clients: [], messages: [], articles: [] });
540
698
  }
541
699
  const [ticketsRes, clientsRes, messagesRes, articlesRes] = await Promise.all([
542
- payload.find({
543
- collection: slugs.tickets,
700
+ dbFind(payload, slugs.tickets, {
544
701
  where: {
545
702
  or: [
546
703
  { ticketNumber: { contains: q } },
@@ -552,8 +709,7 @@ function createSearchEndpoint(slugs) {
552
709
  depth: 1,
553
710
  overrideAccess: true
554
711
  }),
555
- payload.find({
556
- collection: slugs.supportClients,
712
+ dbFind(payload, slugs.supportClients, {
557
713
  where: {
558
714
  or: [
559
715
  { firstName: { contains: q } },
@@ -566,16 +722,14 @@ function createSearchEndpoint(slugs) {
566
722
  depth: 0,
567
723
  overrideAccess: true
568
724
  }),
569
- payload.find({
570
- collection: slugs.ticketMessages,
725
+ dbFind(payload, slugs.ticketMessages, {
571
726
  where: { body: { contains: q } },
572
727
  sort: "-createdAt",
573
728
  limit: 5,
574
729
  depth: 1,
575
730
  overrideAccess: true
576
731
  }),
577
- payload.find({
578
- collection: slugs.knowledgeBase,
732
+ dbFind(payload, slugs.knowledgeBase, {
579
733
  where: { title: { contains: q } },
580
734
  limit: 5,
581
735
  depth: 0,
@@ -675,8 +829,7 @@ function createKbSearchEndpoint(slugs) {
675
829
  orClauses.push({ category: { contains: t } });
676
830
  }
677
831
  orClauses.push({ title: { contains: q } });
678
- const res = await payload.find({
679
- collection: slugs.knowledgeBase,
832
+ const res = await dbFind(payload, slugs.knowledgeBase, {
680
833
  where: {
681
834
  and: [
682
835
  { published: { equals: true } },
@@ -735,16 +888,14 @@ function createBulkActionEndpoint(slugs) {
735
888
  try {
736
889
  switch (action) {
737
890
  case "close":
738
- await payload.update({
739
- collection: slugs.tickets,
891
+ await dbUpdate(payload, slugs.tickets, {
740
892
  id: ticketId,
741
893
  data: { status: "resolved", resolvedAt: (/* @__PURE__ */ new Date()).toISOString() },
742
894
  overrideAccess: true
743
895
  });
744
896
  break;
745
897
  case "reopen":
746
- await payload.update({
747
- collection: slugs.tickets,
898
+ await dbUpdate(payload, slugs.tickets, {
748
899
  id: ticketId,
749
900
  data: { status: "open" },
750
901
  overrideAccess: true
@@ -752,8 +903,7 @@ function createBulkActionEndpoint(slugs) {
752
903
  break;
753
904
  case "assign":
754
905
  if (value) {
755
- await payload.update({
756
- collection: slugs.tickets,
906
+ await dbUpdate(payload, slugs.tickets, {
757
907
  id: ticketId,
758
908
  data: { assignedTo: Number(value) },
759
909
  overrideAccess: true
@@ -762,8 +912,7 @@ function createBulkActionEndpoint(slugs) {
762
912
  break;
763
913
  case "set_priority":
764
914
  if (value) {
765
- await payload.update({
766
- collection: slugs.tickets,
915
+ await dbUpdate(payload, slugs.tickets, {
767
916
  id: ticketId,
768
917
  data: { priority: String(value) },
769
918
  overrideAccess: true
@@ -772,8 +921,7 @@ function createBulkActionEndpoint(slugs) {
772
921
  break;
773
922
  case "set_category":
774
923
  if (value) {
775
- await payload.update({
776
- collection: slugs.tickets,
924
+ await dbUpdate(payload, slugs.tickets, {
777
925
  id: ticketId,
778
926
  data: { category: String(value) },
779
927
  overrideAccess: true
@@ -781,8 +929,7 @@ function createBulkActionEndpoint(slugs) {
781
929
  }
782
930
  break;
783
931
  case "delete":
784
- await payload.delete({
785
- collection: slugs.tickets,
932
+ await dbDelete(payload, slugs.tickets, {
786
933
  id: ticketId,
787
934
  overrideAccess: true
788
935
  });
@@ -821,8 +968,8 @@ function createMergeTicketsEndpoint(slugs) {
821
968
  return Response.json({ error: "Impossible de fusionner un ticket avec lui-m\xEAme" }, { status: 400 });
822
969
  }
823
970
  const [source, target] = await Promise.all([
824
- payload.findByID({ collection: slugs.tickets, id: sourceTicketId, depth: 0, overrideAccess: true }),
825
- payload.findByID({ collection: slugs.tickets, id: targetTicketId, depth: 0, overrideAccess: true })
971
+ dbFindByID(payload, slugs.tickets, { id: sourceTicketId, depth: 0, overrideAccess: true }),
972
+ dbFindByID(payload, slugs.tickets, { id: targetTicketId, depth: 0, overrideAccess: true })
826
973
  ]);
827
974
  if (!source) return Response.json({ error: "Ticket source introuvable" }, { status: 404 });
828
975
  if (!target) return Response.json({ error: "Ticket cible introuvable" }, { status: 404 });
@@ -831,39 +978,34 @@ function createMergeTicketsEndpoint(slugs) {
831
978
  if (sourceClient !== targetClient) {
832
979
  return Response.json({ error: "Les deux tickets doivent appartenir au m\xEAme client" }, { status: 400 });
833
980
  }
834
- const messages = await payload.find({
835
- collection: slugs.ticketMessages,
981
+ const messages = await dbFind(payload, slugs.ticketMessages, {
836
982
  where: { ticket: { equals: sourceTicketId } },
837
983
  limit: 500,
838
984
  depth: 0,
839
985
  overrideAccess: true
840
986
  });
841
987
  for (const msg of messages.docs) {
842
- await payload.update({
843
- collection: slugs.ticketMessages,
988
+ await dbUpdate(payload, slugs.ticketMessages, {
844
989
  id: msg.id,
845
990
  data: { ticket: targetTicketId },
846
991
  overrideAccess: true
847
992
  });
848
993
  }
849
- const timeEntries = await payload.find({
850
- collection: slugs.timeEntries,
994
+ const timeEntries = await dbFind(payload, slugs.timeEntries, {
851
995
  where: { ticket: { equals: sourceTicketId } },
852
996
  limit: 500,
853
997
  depth: 0,
854
998
  overrideAccess: true
855
999
  });
856
1000
  for (const entry of timeEntries.docs) {
857
- await payload.update({
858
- collection: slugs.timeEntries,
1001
+ await dbUpdate(payload, slugs.timeEntries, {
859
1002
  id: entry.id,
860
1003
  data: { ticket: targetTicketId },
861
1004
  overrideAccess: true
862
1005
  });
863
1006
  }
864
1007
  const sourceNumber = source.ticketNumber || `#${sourceTicketId}`;
865
- await payload.create({
866
- collection: slugs.ticketMessages,
1008
+ await dbCreate(payload, slugs.ticketMessages, {
867
1009
  data: {
868
1010
  ticket: targetTicketId,
869
1011
  body: `Messages fusionn\xE9s depuis ${sourceNumber} (${messages.totalDocs} messages, ${timeEntries.totalDocs} entr\xE9es de temps)`,
@@ -873,13 +1015,11 @@ function createMergeTicketsEndpoint(slugs) {
873
1015
  },
874
1016
  overrideAccess: true
875
1017
  });
876
- await payload.delete({
877
- collection: slugs.tickets,
1018
+ await dbDelete(payload, slugs.tickets, {
878
1019
  id: sourceTicketId,
879
1020
  overrideAccess: true
880
1021
  });
881
- const allTimeEntries = await payload.find({
882
- collection: slugs.timeEntries,
1022
+ const allTimeEntries = await dbFind(payload, slugs.timeEntries, {
883
1023
  where: { ticket: { equals: targetTicketId } },
884
1024
  limit: 500,
885
1025
  depth: 0,
@@ -889,8 +1029,7 @@ function createMergeTicketsEndpoint(slugs) {
889
1029
  (sum, e) => sum + (e.duration || 0),
890
1030
  0
891
1031
  );
892
- await payload.update({
893
- collection: slugs.tickets,
1032
+ await dbUpdate(payload, slugs.tickets, {
894
1033
  id: targetTicketId,
895
1034
  data: { totalTimeMinutes: totalMinutes },
896
1035
  overrideAccess: true
@@ -925,8 +1064,7 @@ function createSplitTicketEndpoint(slugs) {
925
1064
  if (!messageId) {
926
1065
  return Response.json({ error: "messageId required" }, { status: 400 });
927
1066
  }
928
- const message = await payload.findByID({
929
- collection: slugs.ticketMessages,
1067
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
930
1068
  id: messageId,
931
1069
  depth: 1,
932
1070
  overrideAccess: true
@@ -939,8 +1077,7 @@ function createSplitTicketEndpoint(slugs) {
939
1077
  return Response.json({ error: "Cannot resolve source ticket" }, { status: 400 });
940
1078
  }
941
1079
  const clientId = typeof sourceTicket.client === "object" ? sourceTicket.client.id : sourceTicket.client;
942
- const newTicket = await payload.create({
943
- collection: slugs.tickets,
1080
+ const newTicket = await dbCreate(payload, slugs.tickets, {
944
1081
  data: {
945
1082
  subject: subject || `Split: ${sourceTicket.subject}`,
946
1083
  client: clientId,
@@ -955,8 +1092,7 @@ function createSplitTicketEndpoint(slugs) {
955
1092
  const attachments = message.attachments?.map((a) => ({
956
1093
  file: typeof a.file === "object" ? a.file.id : a.file
957
1094
  })) || [];
958
- await payload.create({
959
- collection: slugs.ticketMessages,
1095
+ await dbCreate(payload, slugs.ticketMessages, {
960
1096
  data: {
961
1097
  ticket: newTicket.id,
962
1098
  body: message.body,
@@ -969,8 +1105,7 @@ function createSplitTicketEndpoint(slugs) {
969
1105
  },
970
1106
  overrideAccess: true
971
1107
  });
972
- await payload.create({
973
- collection: slugs.ticketMessages,
1108
+ await dbCreate(payload, slugs.ticketMessages, {
974
1109
  data: {
975
1110
  ticket: sourceTicket.id,
976
1111
  body: `Message extrait vers le ticket ${newTicket.ticketNumber}`,
@@ -982,15 +1117,13 @@ function createSplitTicketEndpoint(slugs) {
982
1117
  });
983
1118
  const existingRelated = Array.isArray(sourceTicket.relatedTickets) ? sourceTicket.relatedTickets.map((t) => typeof t === "object" ? t.id : t) : [];
984
1119
  if (!existingRelated.includes(newTicket.id)) {
985
- await payload.update({
986
- collection: slugs.tickets,
1120
+ await dbUpdate(payload, slugs.tickets, {
987
1121
  id: sourceTicket.id,
988
1122
  data: { relatedTickets: [...existingRelated, newTicket.id] },
989
1123
  overrideAccess: true
990
1124
  });
991
1125
  }
992
- await payload.create({
993
- collection: slugs.ticketActivityLog,
1126
+ await dbCreate(payload, slugs.ticketActivityLog, {
994
1127
  data: {
995
1128
  ticket: sourceTicket.id,
996
1129
  action: "split",
@@ -1205,8 +1338,7 @@ function createSettingsGetEndpoint(slugs) {
1205
1338
  try {
1206
1339
  const payload = req.payload;
1207
1340
  requireAdmin(req, slugs);
1208
- const prefs = await payload.find({
1209
- collection: "payload-preferences",
1341
+ const prefs = await dbFind(payload, "payload-preferences", {
1210
1342
  where: { key: { equals: PREF_KEY2 } },
1211
1343
  limit: 1,
1212
1344
  depth: 0,
@@ -1263,6 +1395,7 @@ function createSettingsPostEndpoint(slugs) {
1263
1395
  ]
1264
1396
  }
1265
1397
  });
1398
+ invalidateSupportSettingsCache();
1266
1399
  return Response.json(merged);
1267
1400
  } catch (error) {
1268
1401
  const authResponse = handleAuthError(error);
@@ -1284,8 +1417,7 @@ function createSignatureGetEndpoint(slugs) {
1284
1417
  try {
1285
1418
  const payload = req.payload;
1286
1419
  requireAdmin(req, slugs);
1287
- const prefs = await payload.find({
1288
- collection: "payload-preferences",
1420
+ const prefs = await dbFind(payload, "payload-preferences", {
1289
1421
  where: { key: { equals: `${PREF_KEY3}-${req.user.id}` } },
1290
1422
  limit: 1,
1291
1423
  depth: 0,
@@ -1312,8 +1444,7 @@ function createSignaturePostEndpoint(slugs) {
1312
1444
  requireAdmin(req, slugs);
1313
1445
  const { signature } = await req.json();
1314
1446
  const key = `${PREF_KEY3}-${req.user.id}`;
1315
- const existing = await payload.find({
1316
- collection: "payload-preferences",
1447
+ const existing = await dbFind(payload, "payload-preferences", {
1317
1448
  where: { key: { equals: key } },
1318
1449
  limit: 1,
1319
1450
  depth: 0,
@@ -1356,8 +1487,7 @@ function createRoundRobinConfigGetEndpoint(slugs) {
1356
1487
  try {
1357
1488
  const payload = req.payload;
1358
1489
  requireAdmin(req, slugs);
1359
- const prefs = await payload.find({
1360
- collection: "payload-preferences",
1490
+ const prefs = await dbFind(payload, "payload-preferences", {
1361
1491
  where: { key: { equals: PREF_KEY4 } },
1362
1492
  limit: 1,
1363
1493
  depth: 0,
@@ -1383,8 +1513,7 @@ function createRoundRobinConfigPostEndpoint(slugs) {
1383
1513
  const payload = req.payload;
1384
1514
  requireAdmin(req, slugs);
1385
1515
  const { enabled } = await req.json();
1386
- const existing = await payload.find({
1387
- collection: "payload-preferences",
1516
+ const existing = await dbFind(payload, "payload-preferences", {
1388
1517
  where: { key: { equals: PREF_KEY4 } },
1389
1518
  limit: 1,
1390
1519
  depth: 0,
@@ -1428,8 +1557,7 @@ function createSlaCheckEndpoint(slugs) {
1428
1557
  requireAdmin(req, slugs);
1429
1558
  const now = /* @__PURE__ */ new Date();
1430
1559
  const nowISO = now.toISOString();
1431
- const { docs: tickets } = await payload.find({
1432
- collection: slugs.tickets,
1560
+ const { docs: tickets } = await dbFind(payload, slugs.tickets, {
1433
1561
  where: {
1434
1562
  and: [
1435
1563
  { status: { in: ["open", "waiting_client"] } },
@@ -1523,6 +1651,107 @@ function createSlaCheckEndpoint(slugs) {
1523
1651
  }
1524
1652
  };
1525
1653
  }
1654
+ var TRANSPARENT_GIF = Buffer.from(
1655
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
1656
+ "base64"
1657
+ );
1658
+ function generateTrackingToken(ticketId, messageId, secret) {
1659
+ return crypto3.createHmac("sha256", secret).update(`${ticketId}:${messageId}`).digest("hex").substring(0, 16);
1660
+ }
1661
+ function createTrackOpenEndpoint(slugs) {
1662
+ return {
1663
+ path: "/support/track-open",
1664
+ method: "get",
1665
+ handler: async (req) => {
1666
+ const url = new URL(req.url);
1667
+ const ticketId = url.searchParams.get("t");
1668
+ const messageId = url.searchParams.get("m");
1669
+ const sig = url.searchParams.get("sig");
1670
+ const parsedId = ticketId ? Number(ticketId) : NaN;
1671
+ const parsedMsgId = messageId ? Number(messageId) : NaN;
1672
+ const secret = process.env.PAYLOAD_SECRET || "";
1673
+ const validSig = !!secret && !!ticketId && !!messageId && !!sig && sig === generateTrackingToken(ticketId, messageId, secret);
1674
+ if (!validSig) {
1675
+ return new Response(TRANSPARENT_GIF, {
1676
+ status: 200,
1677
+ headers: {
1678
+ "Content-Type": "image/gif",
1679
+ "Content-Length": String(TRANSPARENT_GIF.length),
1680
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
1681
+ }
1682
+ });
1683
+ }
1684
+ if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
1685
+ try {
1686
+ const payload = req.payload;
1687
+ const ticket = await dbFindByID(payload, slugs.tickets, {
1688
+ id: parsedId,
1689
+ depth: 0,
1690
+ overrideAccess: true,
1691
+ select: { lastClientReadAt: true }
1692
+ });
1693
+ if (ticket) {
1694
+ const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0;
1695
+ const fiveMinAgo = Date.now() - 5 * 60 * 1e3;
1696
+ if (lastRead < fiveMinAgo) {
1697
+ await dbUpdate(payload, slugs.tickets, {
1698
+ id: parsedId,
1699
+ data: { lastClientReadAt: (/* @__PURE__ */ new Date()).toISOString() },
1700
+ overrideAccess: true
1701
+ });
1702
+ }
1703
+ }
1704
+ if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
1705
+ const msg = await dbFindByID(payload, slugs.ticketMessages, {
1706
+ id: parsedMsgId,
1707
+ depth: 0,
1708
+ overrideAccess: true,
1709
+ select: { emailOpenedAt: true }
1710
+ });
1711
+ if (msg && !msg.emailOpenedAt) {
1712
+ await dbUpdate(payload, slugs.ticketMessages, {
1713
+ id: parsedMsgId,
1714
+ data: { emailOpenedAt: (/* @__PURE__ */ new Date()).toISOString() },
1715
+ overrideAccess: true
1716
+ });
1717
+ const ticketInfo = await dbFindByID(payload, slugs.tickets, {
1718
+ id: parsedId,
1719
+ depth: 1,
1720
+ overrideAccess: true,
1721
+ select: { ticketNumber: true, subject: true, client: true }
1722
+ });
1723
+ const clientName = typeof ticketInfo?.client === "object" ? ticketInfo.client?.firstName || "Client" : "Client";
1724
+ try {
1725
+ await dbCreate(payload, "admin-notifications", {
1726
+ data: {
1727
+ title: `Email ouvert \u2014 ${ticketInfo?.ticketNumber || "TK-????"}`,
1728
+ message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || "ticket"}"`,
1729
+ type: "email_opened",
1730
+ link: `/admin/ticket?id=${parsedId}`
1731
+ },
1732
+ overrideAccess: true
1733
+ });
1734
+ } catch {
1735
+ }
1736
+ }
1737
+ }
1738
+ } catch (err) {
1739
+ console.error("[track-open] Error:", err);
1740
+ }
1741
+ }
1742
+ return new Response(TRANSPARENT_GIF, {
1743
+ status: 200,
1744
+ headers: {
1745
+ "Content-Type": "image/gif",
1746
+ "Content-Length": String(TRANSPARENT_GIF.length),
1747
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
1748
+ "Pragma": "no-cache",
1749
+ "Expires": "0"
1750
+ }
1751
+ });
1752
+ }
1753
+ };
1754
+ }
1526
1755
 
1527
1756
  // src/utils/emailTemplate.ts
1528
1757
  var COLORS = {
@@ -1559,7 +1788,10 @@ function escapeHtml(str) {
1559
1788
  }
1560
1789
  function emailTrackingPixel(ticketId, messageId, baseUrl) {
1561
1790
  const url = process.env.NEXT_PUBLIC_SERVER_URL || "";
1562
- const params = `t=${ticketId}${messageId ? `&m=${messageId}` : ""}`;
1791
+ const secret = process.env.PAYLOAD_SECRET || "";
1792
+ if (!secret || messageId === void 0 || messageId === null) return "";
1793
+ const sig = generateTrackingToken(String(ticketId), String(messageId), secret);
1794
+ const params = `t=${ticketId}&m=${messageId}&sig=${sig}`;
1563
1795
  return `<img src="${url}/api/support/track-open?${params}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`;
1564
1796
  }
1565
1797
  function emailRichContent(html, config) {
@@ -1824,8 +2056,7 @@ function createAutoCloseEndpoint(slugs) {
1824
2056
  const CLOSE_AFTER_REMIND_DAYS = settings.autoClose.reminderDaysBefore;
1825
2057
  const results = { reminded: 0, closed: 0, errors: 0 };
1826
2058
  const remindCutoff = new Date(now.getTime() - REMIND_AFTER_DAYS * 24 * 60 * 60 * 1e3);
1827
- const ticketsToRemind = await payload.find({
1828
- collection: slugs.tickets,
2059
+ const ticketsToRemind = await dbFind(payload, slugs.tickets, {
1829
2060
  where: {
1830
2061
  and: [
1831
2062
  { status: { equals: "waiting_client" } },
@@ -1856,8 +2087,7 @@ function createAutoCloseEndpoint(slugs) {
1856
2087
  <p><a href="${portalUrl}">R\xE9pondre au ticket</a></p>
1857
2088
  </div>`
1858
2089
  });
1859
- await payload.update({
1860
- collection: slugs.tickets,
2090
+ await dbUpdate(payload, slugs.tickets, {
1861
2091
  id: t.id,
1862
2092
  data: { autoCloseRemindedAt: now.toISOString() },
1863
2093
  overrideAccess: true
@@ -1869,16 +2099,24 @@ function createAutoCloseEndpoint(slugs) {
1869
2099
  }
1870
2100
  }
1871
2101
  const closeCutoff = new Date(now.getTime() - CLOSE_AFTER_REMIND_DAYS * 24 * 60 * 60 * 1e3);
1872
- const ticketsToClose = await payload.find({
1873
- collection: slugs.tickets,
2102
+ const ticketsToClose = await dbFind(payload, slugs.tickets, {
1874
2103
  where: {
1875
2104
  and: [
1876
2105
  { status: { equals: "waiting_client" } },
1877
- { autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
1878
2106
  {
1879
2107
  or: [
1880
- { lastClientMessageAt: { exists: false } },
1881
- { lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } }
2108
+ {
2109
+ and: [
2110
+ { autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
2111
+ {
2112
+ or: [
2113
+ { lastClientMessageAt: { exists: false } },
2114
+ { lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } }
2115
+ ]
2116
+ }
2117
+ ]
2118
+ },
2119
+ { autoCloseScheduledAt: { less_than_equal: now.toISOString() } }
1882
2120
  ]
1883
2121
  }
1884
2122
  ]
@@ -1894,21 +2132,23 @@ function createAutoCloseEndpoint(slugs) {
1894
2132
  const ticketNumber = t.ticketNumber || "TK-????";
1895
2133
  const subject = t.subject || "Support";
1896
2134
  const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS;
1897
- await payload.create({
1898
- collection: slugs.ticketMessages,
2135
+ const viaSchedule = !!t.autoCloseScheduledAt && new Date(t.autoCloseScheduledAt).getTime() <= now.getTime();
2136
+ const noteBody = viaSchedule ? "Ticket r\xE9solu automatiquement \u2014 relance manuelle rest\xE9e sans r\xE9ponse du client" : `Ticket r\xE9solu automatiquement \u2014 sans r\xE9ponse client depuis ${closeTotalDays} jours`;
2137
+ await dbCreate(payload, slugs.ticketMessages, {
1899
2138
  data: {
1900
2139
  ticket: t.id,
1901
- body: `Ticket r\xE9solu automatiquement \u2014 sans r\xE9ponse client depuis ${closeTotalDays} jours`,
2140
+ body: noteBody,
1902
2141
  authorType: "admin",
1903
2142
  isInternal: true,
1904
2143
  skipNotification: true
1905
2144
  },
1906
2145
  overrideAccess: true
1907
2146
  });
1908
- await payload.update({
1909
- collection: slugs.tickets,
2147
+ await dbUpdate(payload, slugs.tickets, {
1910
2148
  id: t.id,
1911
- data: { status: "resolved" },
2149
+ // Clear the armed deadline so a later manual reopen can't be
2150
+ // closed instantly by a stale timestamp.
2151
+ data: { status: "resolved", autoCloseScheduledAt: null },
1912
2152
  overrideAccess: true
1913
2153
  });
1914
2154
  if (client?.email) {
@@ -1919,7 +2159,7 @@ function createAutoCloseEndpoint(slugs) {
1919
2159
  subject: `[${ticketNumber}] Ticket r\xE9solu \u2014 ${subject}`,
1920
2160
  html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
1921
2161
  <p>Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,</p>
1922
- <p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em> \u2014 a \xE9t\xE9 r\xE9solu automatiquement apr\xE8s ${closeTotalDays} jours sans r\xE9ponse.</p>
2162
+ <p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em> \u2014 a \xE9t\xE9 r\xE9solu automatiquement ${viaSchedule ? "faute de r\xE9ponse de votre part" : `apr\xE8s ${closeTotalDays} jours sans r\xE9ponse`}.</p>
1923
2163
  <p>Si vous avez encore besoin d'aide, n'h\xE9sitez pas \xE0 rouvrir ce ticket ou \xE0 en cr\xE9er un nouveau.</p>
1924
2164
  <p><a href="${portalUrl}">Consulter le ticket</a></p>
1925
2165
  </div>`
@@ -1944,27 +2184,206 @@ function createAutoCloseEndpoint(slugs) {
1944
2184
  };
1945
2185
  }
1946
2186
 
1947
- // src/endpoints/statuses.ts
1948
- function createStatusesEndpoint(slugs) {
2187
+ // src/utils/rateLimiter.ts
2188
+ var RateLimiter = class {
2189
+ constructor(windowMs, maxRequests) {
2190
+ this.windowMs = windowMs;
2191
+ this.maxRequests = maxRequests;
2192
+ const timer = setInterval(() => this.cleanup(), windowMs);
2193
+ timer.unref();
2194
+ }
2195
+ windowMs;
2196
+ maxRequests;
2197
+ store = /* @__PURE__ */ new Map();
2198
+ /**
2199
+ * Check if a key has exceeded the rate limit.
2200
+ * Returns true if the request should be blocked.
2201
+ */
2202
+ check(key) {
2203
+ const now = Date.now();
2204
+ const entry = this.store.get(key);
2205
+ if (!entry || now > entry.resetAt) {
2206
+ this.store.set(key, { count: 1, resetAt: now + this.windowMs });
2207
+ return false;
2208
+ }
2209
+ entry.count++;
2210
+ return entry.count > this.maxRequests;
2211
+ }
2212
+ /**
2213
+ * Reset the counter for a specific key.
2214
+ */
2215
+ reset(key) {
2216
+ this.store.delete(key);
2217
+ }
2218
+ /**
2219
+ * Remove expired entries from the store.
2220
+ */
2221
+ cleanup() {
2222
+ const now = Date.now();
2223
+ for (const [key, entry] of this.store) {
2224
+ if (now > entry.resetAt) {
2225
+ this.store.delete(key);
2226
+ }
2227
+ }
2228
+ }
2229
+ };
2230
+
2231
+ // src/endpoints/send-reminder.ts
2232
+ var reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30);
2233
+ var MIN_HOURS = 1;
2234
+ var MAX_HOURS = 24 * 30;
2235
+ function formatFr(date, withTime) {
2236
+ return date.toLocaleString("fr-FR", {
2237
+ day: "numeric",
2238
+ month: "long",
2239
+ year: "numeric",
2240
+ ...withTime ? { hour: "2-digit", minute: "2-digit" } : {},
2241
+ timeZone: "Europe/Paris"
2242
+ });
2243
+ }
2244
+ function createSendReminderEndpoint(slugs) {
1949
2245
  return {
1950
- path: "/support/statuses",
1951
- method: "get",
2246
+ path: "/support/send-reminder",
2247
+ method: "post",
1952
2248
  handler: async (req) => {
1953
2249
  try {
1954
2250
  const payload = req.payload;
1955
- if (!req.user) {
1956
- return Response.json({ error: "Unauthorized" }, { status: 401 });
2251
+ requireAdmin(req, slugs);
2252
+ if (reminderLimiter.check(String(req.user.id))) {
2253
+ return Response.json(
2254
+ { error: "Trop de relances. R\xE9essayez dans une heure." },
2255
+ { status: 429 }
2256
+ );
1957
2257
  }
1958
- const { docs } = await payload.find({
1959
- collection: slugs.ticketStatuses,
1960
- sort: "sortOrder",
1961
- limit: 100,
1962
- depth: 0,
2258
+ let body;
2259
+ try {
2260
+ body = await req.json();
2261
+ } catch {
2262
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
2263
+ }
2264
+ const { ticketId } = body;
2265
+ if (!ticketId) {
2266
+ return Response.json({ error: "ticketId requis" }, { status: 400 });
2267
+ }
2268
+ const rawHours = Number(body.hours);
2269
+ const hours = Number.isFinite(rawHours) ? Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.round(rawHours))) : 24;
2270
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2271
+ id: ticketId,
2272
+ depth: 1,
1963
2273
  overrideAccess: true
1964
2274
  });
1965
- return Response.json({
1966
- statuses: docs.map((s) => ({
1967
- id: s.id,
2275
+ if (!ticket) {
2276
+ return Response.json({ error: "Ticket introuvable" }, { status: 404 });
2277
+ }
2278
+ const client = typeof ticket.client === "object" ? ticket.client : null;
2279
+ if (!client?.email) {
2280
+ return Response.json({ error: "Client sans email" }, { status: 400 });
2281
+ }
2282
+ const now = /* @__PURE__ */ new Date();
2283
+ const deadline = new Date(now.getTime() + hours * 60 * 60 * 1e3);
2284
+ const settings = await readSupportSettings(payload);
2285
+ const ticketNumber = ticket.ticketNumber || "TK-????";
2286
+ const subject = ticket.subject || "Support";
2287
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
2288
+ const portalUrl = `${baseUrl}/support/tickets/${ticketId}`;
2289
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || "";
2290
+ let waitingSince = ticket.firstResponseAt || ticket.createdAt;
2291
+ try {
2292
+ const lastAdminMsg = await dbFind(payload, slugs.ticketMessages, {
2293
+ where: {
2294
+ and: [
2295
+ { ticket: { equals: ticketId } },
2296
+ { authorType: { equals: "admin" } },
2297
+ { isInternal: { equals: false } }
2298
+ ]
2299
+ },
2300
+ sort: "-createdAt",
2301
+ limit: 1,
2302
+ depth: 0,
2303
+ overrideAccess: true
2304
+ });
2305
+ if (lastAdminMsg.docs.length > 0 && lastAdminMsg.docs[0].createdAt) {
2306
+ waitingSince = lastAdminMsg.docs[0].createdAt;
2307
+ }
2308
+ } catch {
2309
+ }
2310
+ const sinceLabel = waitingSince ? formatFr(new Date(waitingSince), false) : null;
2311
+ const deadlineLabel = formatFr(deadline, true);
2312
+ await payload.sendEmail({
2313
+ to: client.email,
2314
+ ...replyTo ? { replyTo } : {},
2315
+ subject: `Rappel : [${ticketNumber}] ${subject} \u2014 votre r\xE9ponse est attendue`,
2316
+ html: emailWrapper(`Votre ticket attend votre r\xE9ponse`, [
2317
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
2318
+ emailParagraph(
2319
+ `Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(String(subject))}</em> \u2014 est en attente de votre r\xE9ponse${sinceLabel ? ` depuis le ${escapeHtml(sinceLabel)}` : ""}.`
2320
+ ),
2321
+ emailParagraph(
2322
+ `<strong>Sans retour de votre part, ce ticket sera automatiquement cl\xF4tur\xE9 le ${escapeHtml(deadlineLabel)}.</strong>`
2323
+ ),
2324
+ emailParagraph(
2325
+ `Si vous avez encore besoin d'assistance, il vous suffit de r\xE9pondre \xE0 ce message \u2014 votre r\xE9ponse maintiendra le ticket ouvert.`
2326
+ ),
2327
+ emailButton("R\xE9pondre au ticket", portalUrl, "primary")
2328
+ ].join(""), {
2329
+ kind: "alert",
2330
+ preheader: `Sans r\xE9ponse de votre part, votre ticket ${ticketNumber} sera cl\xF4tur\xE9 le ${deadlineLabel}.`
2331
+ })
2332
+ });
2333
+ await dbCreate(payload, slugs.ticketMessages, {
2334
+ data: {
2335
+ ticket: ticketId,
2336
+ body: `Relance envoy\xE9e \xE0 ${client.email}. Fermeture automatique programm\xE9e le ${deadlineLabel} sans r\xE9ponse du client.`,
2337
+ authorType: "admin",
2338
+ isInternal: true,
2339
+ skipNotification: true
2340
+ },
2341
+ overrideAccess: true
2342
+ });
2343
+ await dbUpdate(payload, slugs.tickets, {
2344
+ id: ticketId,
2345
+ data: {
2346
+ status: "waiting_client",
2347
+ autoCloseRemindedAt: now.toISOString(),
2348
+ autoCloseScheduledAt: deadline.toISOString()
2349
+ },
2350
+ overrideAccess: true
2351
+ });
2352
+ return Response.json({
2353
+ success: true,
2354
+ sentTo: client.email,
2355
+ scheduledCloseAt: deadline.toISOString()
2356
+ });
2357
+ } catch (error) {
2358
+ const authResponse = handleAuthError(error);
2359
+ if (authResponse) return authResponse;
2360
+ console.error("[send-reminder] Error:", error);
2361
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
2362
+ }
2363
+ }
2364
+ };
2365
+ }
2366
+
2367
+ // src/endpoints/statuses.ts
2368
+ function createStatusesEndpoint(slugs) {
2369
+ return {
2370
+ path: "/support/statuses",
2371
+ method: "get",
2372
+ handler: async (req) => {
2373
+ try {
2374
+ const payload = req.payload;
2375
+ if (!req.user) {
2376
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
2377
+ }
2378
+ const { docs } = await dbFind(payload, slugs.ticketStatuses, {
2379
+ sort: "sortOrder",
2380
+ limit: 100,
2381
+ depth: 0,
2382
+ overrideAccess: true
2383
+ });
2384
+ return Response.json({
2385
+ statuses: docs.map((s) => ({
2386
+ id: s.id,
1968
2387
  name: s.name,
1969
2388
  slug: s.slug,
1970
2389
  color: s.color,
@@ -1995,16 +2414,14 @@ function createApplyMacroEndpoint(slugs) {
1995
2414
  if (!macroId || !ticketId) {
1996
2415
  return Response.json({ error: "macroId and ticketId are required" }, { status: 400 });
1997
2416
  }
1998
- const macro = await payload.findByID({
1999
- collection: slugs.macros,
2417
+ const macro = await dbFindByID(payload, slugs.macros, {
2000
2418
  id: macroId,
2001
2419
  depth: 0,
2002
2420
  overrideAccess: true
2003
2421
  });
2004
2422
  if (!macro) return Response.json({ error: "Macro not found" }, { status: 404 });
2005
2423
  if (!macro.isActive) return Response.json({ error: "Macro is disabled" }, { status: 400 });
2006
- const ticket = await payload.findByID({
2007
- collection: slugs.tickets,
2424
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2008
2425
  id: ticketId,
2009
2426
  depth: 0,
2010
2427
  overrideAccess: true
@@ -2016,8 +2433,7 @@ function createApplyMacroEndpoint(slugs) {
2016
2433
  try {
2017
2434
  switch (action.type) {
2018
2435
  case "set_status":
2019
- await payload.update({
2020
- collection: slugs.tickets,
2436
+ await dbUpdate(payload, slugs.tickets, {
2021
2437
  id: ticketId,
2022
2438
  data: { status: action.value },
2023
2439
  overrideAccess: true
@@ -2025,8 +2441,7 @@ function createApplyMacroEndpoint(slugs) {
2025
2441
  appliedActions.push({ type: action.type, value: action.value, success: true });
2026
2442
  break;
2027
2443
  case "set_priority":
2028
- await payload.update({
2029
- collection: slugs.tickets,
2444
+ await dbUpdate(payload, slugs.tickets, {
2030
2445
  id: ticketId,
2031
2446
  data: { priority: action.value },
2032
2447
  overrideAccess: true
@@ -2038,8 +2453,7 @@ function createApplyMacroEndpoint(slugs) {
2038
2453
  if (!currentTags.includes(action.value)) {
2039
2454
  currentTags.push(action.value);
2040
2455
  }
2041
- await payload.update({
2042
- collection: slugs.tickets,
2456
+ await dbUpdate(payload, slugs.tickets, {
2043
2457
  id: ticketId,
2044
2458
  data: { tags: currentTags },
2045
2459
  overrideAccess: true
@@ -2048,8 +2462,7 @@ function createApplyMacroEndpoint(slugs) {
2048
2462
  break;
2049
2463
  }
2050
2464
  case "send_reply":
2051
- await payload.create({
2052
- collection: slugs.ticketMessages,
2465
+ await dbCreate(payload, slugs.ticketMessages, {
2053
2466
  data: {
2054
2467
  ticket: ticketId,
2055
2468
  body: action.value,
@@ -2066,8 +2479,7 @@ function createApplyMacroEndpoint(slugs) {
2066
2479
  appliedActions.push({ type: action.type, value: action.value, success: false, error: "Invalid user ID" });
2067
2480
  break;
2068
2481
  }
2069
- await payload.update({
2070
- collection: slugs.tickets,
2482
+ await dbUpdate(payload, slugs.tickets, {
2071
2483
  id: ticketId,
2072
2484
  data: { assignedTo: userId },
2073
2485
  overrideAccess: true
@@ -2119,8 +2531,7 @@ function createPurgeLogsEndpoint(slugs) {
2119
2531
  return Response.json({ error: "Invalid collection. Use email-logs or auth-logs." }, { status: 400 });
2120
2532
  }
2121
2533
  const cutoff = days > 0 ? new Date(Date.now() - days * 864e5).toISOString() : null;
2122
- const result = await payload.delete({
2123
- collection: allowedCollections[collection],
2534
+ const result = await dbDelete(payload, allowedCollections[collection], {
2124
2535
  where: cutoff ? { createdAt: { less_than: cutoff } } : { id: { exists: true } },
2125
2536
  overrideAccess: true
2126
2537
  });
@@ -2140,50 +2551,6 @@ function createPurgeLogsEndpoint(slugs) {
2140
2551
  };
2141
2552
  }
2142
2553
 
2143
- // src/utils/rateLimiter.ts
2144
- var RateLimiter = class {
2145
- constructor(windowMs, maxRequests) {
2146
- this.windowMs = windowMs;
2147
- this.maxRequests = maxRequests;
2148
- const timer = setInterval(() => this.cleanup(), windowMs);
2149
- timer.unref();
2150
- }
2151
- windowMs;
2152
- maxRequests;
2153
- store = /* @__PURE__ */ new Map();
2154
- /**
2155
- * Check if a key has exceeded the rate limit.
2156
- * Returns true if the request should be blocked.
2157
- */
2158
- check(key) {
2159
- const now = Date.now();
2160
- const entry = this.store.get(key);
2161
- if (!entry || now > entry.resetAt) {
2162
- this.store.set(key, { count: 1, resetAt: now + this.windowMs });
2163
- return false;
2164
- }
2165
- entry.count++;
2166
- return entry.count > this.maxRequests;
2167
- }
2168
- /**
2169
- * Reset the counter for a specific key.
2170
- */
2171
- reset(key) {
2172
- this.store.delete(key);
2173
- }
2174
- /**
2175
- * Remove expired entries from the store.
2176
- */
2177
- cleanup() {
2178
- const now = Date.now();
2179
- for (const [key, entry] of this.store) {
2180
- if (now > entry.resetAt) {
2181
- this.store.delete(key);
2182
- }
2183
- }
2184
- }
2185
- };
2186
-
2187
2554
  // src/endpoints/chatbot.ts
2188
2555
  var chatbotLimiter = new RateLimiter(6e4, 10);
2189
2556
  function createChatbotEndpoint(slugs) {
@@ -2207,8 +2574,7 @@ function createChatbotEndpoint(slugs) {
2207
2574
  return Response.json({ error: "Question too short" }, { status: 400 });
2208
2575
  }
2209
2576
  const payload = req.payload;
2210
- const articles = await payload.find({
2211
- collection: slugs.knowledgeBase,
2577
+ const articles = await dbFind(payload, slugs.knowledgeBase, {
2212
2578
  where: { published: { equals: true } },
2213
2579
  limit: 100,
2214
2580
  depth: 0,
@@ -2298,8 +2664,7 @@ function createChatGetEndpoint(slugs) {
2298
2664
  if (after) {
2299
2665
  where.createdAt = { greater_than: after };
2300
2666
  }
2301
- const messages = await payload.find({
2302
- collection: slugs.chatMessages,
2667
+ const messages = await dbFind(payload, slugs.chatMessages, {
2303
2668
  where,
2304
2669
  sort: "createdAt",
2305
2670
  limit: 100,
@@ -2342,8 +2707,7 @@ function createChatPostEndpoint(slugs) {
2342
2707
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
2343
2708
  }
2344
2709
  const sessionId = `chat_${crypto3__default.default.randomBytes(16).toString("hex")}`;
2345
- const systemMsg = await payload.create({
2346
- collection: slugs.chatMessages,
2710
+ const systemMsg = await dbCreate(payload, slugs.chatMessages, {
2347
2711
  data: {
2348
2712
  session: sessionId,
2349
2713
  client: req.user.id,
@@ -2363,8 +2727,7 @@ function createChatPostEndpoint(slugs) {
2363
2727
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2364
2728
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2365
2729
  }
2366
- const existing = await payload.find({
2367
- collection: slugs.chatMessages,
2730
+ const existing = await dbFind(payload, slugs.chatMessages, {
2368
2731
  where: { session: { equals: session }, client: { equals: req.user.id } },
2369
2732
  limit: 1,
2370
2733
  overrideAccess: true
@@ -2372,8 +2735,7 @@ function createChatPostEndpoint(slugs) {
2372
2735
  if (existing.docs.length === 0) {
2373
2736
  return Response.json({ error: "Session invalide" }, { status: 403 });
2374
2737
  }
2375
- const newMsg = await payload.create({
2376
- collection: slugs.chatMessages,
2738
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2377
2739
  data: {
2378
2740
  session,
2379
2741
  client: req.user.id,
@@ -2386,8 +2748,7 @@ function createChatPostEndpoint(slugs) {
2386
2748
  return Response.json({ message: newMsg });
2387
2749
  }
2388
2750
  if (action === "close" && session) {
2389
- const existing = await payload.find({
2390
- collection: slugs.chatMessages,
2751
+ const existing = await dbFind(payload, slugs.chatMessages, {
2391
2752
  where: { session: { equals: session }, client: { equals: req.user.id } },
2392
2753
  limit: 1,
2393
2754
  overrideAccess: true
@@ -2395,8 +2756,7 @@ function createChatPostEndpoint(slugs) {
2395
2756
  if (existing.docs.length === 0) {
2396
2757
  return Response.json({ error: "Session invalide" }, { status: 403 });
2397
2758
  }
2398
- await payload.create({
2399
- collection: slugs.chatMessages,
2759
+ await dbCreate(payload, slugs.chatMessages, {
2400
2760
  data: {
2401
2761
  session,
2402
2762
  client: req.user.id,
@@ -2438,8 +2798,7 @@ function createChatStreamEndpoint(slugs) {
2438
2798
  if (!sessionId) {
2439
2799
  return Response.json({ error: "Missing session parameter" }, { status: 400 });
2440
2800
  }
2441
- const existing = await req.payload.find({
2442
- collection: slugs.chatMessages,
2801
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
2443
2802
  where: {
2444
2803
  session: { equals: sessionId },
2445
2804
  client: { equals: userId }
@@ -2461,8 +2820,7 @@ function createChatStreamEndpoint(slugs) {
2461
2820
  );
2462
2821
  const interval = setInterval(async () => {
2463
2822
  try {
2464
- const messages = await req.payload.find({
2465
- collection: slugs.chatMessages,
2823
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2466
2824
  where: {
2467
2825
  session: { equals: sessionId },
2468
2826
  createdAt: { greater_than: lastCheck }
@@ -2543,8 +2901,7 @@ function createAdminChatGetEndpoint(slugs) {
2543
2901
  if (session) {
2544
2902
  const where = { session: { equals: session } };
2545
2903
  if (after) where.createdAt = { greater_than: after };
2546
- const messages = await payload.find({
2547
- collection: slugs.chatMessages,
2904
+ const messages = await dbFind(payload, slugs.chatMessages, {
2548
2905
  where,
2549
2906
  sort: "createdAt",
2550
2907
  limit: 200,
@@ -2558,8 +2915,7 @@ function createAdminChatGetEndpoint(slugs) {
2558
2915
  }
2559
2916
  });
2560
2917
  }
2561
- const recentMessages = await payload.find({
2562
- collection: slugs.chatMessages,
2918
+ const recentMessages = await dbFind(payload, slugs.chatMessages, {
2563
2919
  sort: "-createdAt",
2564
2920
  limit: 50,
2565
2921
  depth: 1,
@@ -2622,8 +2978,7 @@ function createAdminChatPostEndpoint(slugs) {
2622
2978
  if (!session) {
2623
2979
  return Response.json({ error: "Session requise" }, { status: 400 });
2624
2980
  }
2625
- const sessionMsg = await payload.find({
2626
- collection: slugs.chatMessages,
2981
+ const sessionMsg = await dbFind(payload, slugs.chatMessages, {
2627
2982
  where: { session: { equals: session } },
2628
2983
  limit: 1,
2629
2984
  depth: 0,
@@ -2641,8 +2996,7 @@ function createAdminChatPostEndpoint(slugs) {
2641
2996
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2642
2997
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2643
2998
  }
2644
- const newMsg = await payload.create({
2645
- collection: slugs.chatMessages,
2999
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2646
3000
  data: {
2647
3001
  session,
2648
3002
  client: clientId,
@@ -2654,8 +3008,7 @@ function createAdminChatPostEndpoint(slugs) {
2654
3008
  overrideAccess: true
2655
3009
  });
2656
3010
  try {
2657
- const linkedTicket = await payload.find({
2658
- collection: slugs.tickets,
3011
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2659
3012
  where: { chatSession: { equals: session } },
2660
3013
  limit: 1,
2661
3014
  depth: 0,
@@ -2663,8 +3016,7 @@ function createAdminChatPostEndpoint(slugs) {
2663
3016
  });
2664
3017
  if (linkedTicket.docs.length > 0) {
2665
3018
  const ticketId = linkedTicket.docs[0].id;
2666
- await payload.create({
2667
- collection: slugs.ticketMessages,
3019
+ await dbCreate(payload, slugs.ticketMessages, {
2668
3020
  data: {
2669
3021
  ticket: ticketId,
2670
3022
  body: trimmedMessage,
@@ -2673,8 +3025,7 @@ function createAdminChatPostEndpoint(slugs) {
2673
3025
  },
2674
3026
  overrideAccess: true
2675
3027
  });
2676
- await payload.update({
2677
- collection: slugs.chatMessages,
3028
+ await dbUpdate(payload, slugs.chatMessages, {
2678
3029
  id: newMsg.id,
2679
3030
  data: { ticket: ticketId },
2680
3031
  overrideAccess: true
@@ -2686,8 +3037,7 @@ function createAdminChatPostEndpoint(slugs) {
2686
3037
  return Response.json({ message: newMsg });
2687
3038
  }
2688
3039
  if (action === "close") {
2689
- await payload.create({
2690
- collection: slugs.chatMessages,
3040
+ await dbCreate(payload, slugs.chatMessages, {
2691
3041
  data: {
2692
3042
  session,
2693
3043
  client: clientId,
@@ -2698,16 +3048,14 @@ function createAdminChatPostEndpoint(slugs) {
2698
3048
  overrideAccess: true
2699
3049
  });
2700
3050
  try {
2701
- const linkedTicket = await payload.find({
2702
- collection: slugs.tickets,
3051
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2703
3052
  where: { chatSession: { equals: session } },
2704
3053
  limit: 1,
2705
3054
  depth: 0,
2706
3055
  overrideAccess: true
2707
3056
  });
2708
3057
  if (linkedTicket.docs.length > 0) {
2709
- await payload.create({
2710
- collection: slugs.ticketMessages,
3058
+ await dbCreate(payload, slugs.ticketMessages, {
2711
3059
  data: {
2712
3060
  ticket: linkedTicket.docs[0].id,
2713
3061
  body: "Session de chat termin\xE9e par un agent.",
@@ -2770,8 +3118,7 @@ function createSessionStream(req, slugs, sessionId) {
2770
3118
  );
2771
3119
  const interval = setInterval(async () => {
2772
3120
  try {
2773
- const messages = await req.payload.find({
2774
- collection: slugs.chatMessages,
3121
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2775
3122
  where: {
2776
3123
  session: { equals: sessionId },
2777
3124
  createdAt: { greater_than: lastCheck }
@@ -2844,8 +3191,7 @@ function createSessionListStream(req, slugs) {
2844
3191
  );
2845
3192
  const interval = setInterval(async () => {
2846
3193
  try {
2847
- const recentMessages = await req.payload.find({
2848
- collection: slugs.chatMessages,
3194
+ const recentMessages = await dbFind(req.payload, slugs.chatMessages, {
2849
3195
  sort: "-createdAt",
2850
3196
  limit: 50,
2851
3197
  depth: 1,
@@ -2932,8 +3278,7 @@ function createAdminStatsEndpoint(slugs) {
2932
3278
  const statuses = ["open", "waiting_client", "resolved"];
2933
3279
  const statusCounts = await Promise.all(
2934
3280
  statuses.map(async (status) => {
2935
- const result = await payload.count({
2936
- collection: slugs.tickets,
3281
+ const result = await dbCount(payload, slugs.tickets, {
2937
3282
  where: { status: { equals: status } },
2938
3283
  overrideAccess: true
2939
3284
  });
@@ -2945,8 +3290,7 @@ function createAdminStatsEndpoint(slugs) {
2945
3290
  const priorities = ["low", "normal", "high", "urgent"];
2946
3291
  const priorityCounts = await Promise.all(
2947
3292
  priorities.map(async (priority) => {
2948
- const result = await payload.count({
2949
- collection: slugs.tickets,
3293
+ const result = await dbCount(payload, slugs.tickets, {
2950
3294
  where: { priority: { equals: priority } },
2951
3295
  overrideAccess: true
2952
3296
  });
@@ -2957,8 +3301,7 @@ function createAdminStatsEndpoint(slugs) {
2957
3301
  const categories = ["bug", "content", "feature", "question", "hosting"];
2958
3302
  const categoryCounts = await Promise.all(
2959
3303
  categories.map(async (category) => {
2960
- const result = await payload.count({
2961
- collection: slugs.tickets,
3304
+ const result = await dbCount(payload, slugs.tickets, {
2962
3305
  where: { category: { equals: category } },
2963
3306
  overrideAccess: true
2964
3307
  });
@@ -2972,17 +3315,34 @@ function createAdminStatsEndpoint(slugs) {
2972
3315
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
2973
3316
  const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
2974
3317
  const [created7, created30] = await Promise.all([
2975
- payload.count({
2976
- collection: slugs.tickets,
3318
+ dbCount(payload, slugs.tickets, {
2977
3319
  where: { createdAt: { greater_than_equal: sevenDaysAgo.toISOString() } },
2978
3320
  overrideAccess: true
2979
3321
  }),
2980
- payload.count({
2981
- collection: slugs.tickets,
3322
+ dbCount(payload, slugs.tickets, {
2982
3323
  where: { createdAt: { greater_than_equal: thirtyDaysAgo.toISOString() } },
2983
3324
  overrideAccess: true
2984
3325
  })
2985
3326
  ]);
3327
+ const volumeByDay = await Promise.all(
3328
+ Array.from({ length: 7 }, (_, i) => i).map(async (offset) => {
3329
+ const dayStart = new Date(now);
3330
+ dayStart.setHours(0, 0, 0, 0);
3331
+ dayStart.setDate(dayStart.getDate() - (6 - offset));
3332
+ const dayEnd = new Date(dayStart);
3333
+ dayEnd.setDate(dayEnd.getDate() + 1);
3334
+ const r = await dbCount(payload, slugs.tickets, {
3335
+ where: {
3336
+ and: [
3337
+ { createdAt: { greater_than_equal: dayStart.toISOString() } },
3338
+ { createdAt: { less_than: dayEnd.toISOString() } }
3339
+ ]
3340
+ },
3341
+ overrideAccess: true
3342
+ });
3343
+ return { date: dayStart.toISOString(), count: r.totalDocs };
3344
+ })
3345
+ );
2986
3346
  const PAGE_SIZE2 = 100;
2987
3347
  const MAX_PAGES2 = 50;
2988
3348
  let totalResponseTimeMs = 0;
@@ -2993,8 +3353,7 @@ function createAdminStatsEndpoint(slugs) {
2993
3353
  let page = 1;
2994
3354
  let hasMore = true;
2995
3355
  while (hasMore && page <= MAX_PAGES2) {
2996
- const batch = await payload.find({
2997
- collection: slugs.tickets,
3356
+ const batch = await dbFind(payload, slugs.tickets, {
2998
3357
  limit: PAGE_SIZE2,
2999
3358
  page,
3000
3359
  depth: 0,
@@ -3027,43 +3386,62 @@ function createAdminStatsEndpoint(slugs) {
3027
3386
  hasMore = batch.hasNextPage ?? false;
3028
3387
  page++;
3029
3388
  }
3030
- const surveyCount = await payload.count({
3031
- collection: slugs.satisfactionSurveys,
3389
+ const surveyCount = await dbCount(payload, slugs.satisfactionSurveys, {
3032
3390
  overrideAccess: true
3033
3391
  });
3034
3392
  let satisfactionAvg = 0;
3393
+ let satisfactionCount = 0;
3394
+ let npsScore = null;
3395
+ let npsCount = 0;
3035
3396
  if (surveyCount.totalDocs > 0) {
3036
- let totalRating = 0;
3397
+ let ratingSum = 0;
3398
+ let ratingN = 0;
3399
+ let promoters = 0;
3400
+ let detractors = 0;
3401
+ let npsN = 0;
3037
3402
  let surveyPage = 1;
3038
3403
  let surveyHasMore = true;
3039
3404
  while (surveyHasMore && surveyPage <= MAX_PAGES2) {
3040
- const batch = await payload.find({
3041
- collection: slugs.satisfactionSurveys,
3405
+ const batch = await dbFind(payload, slugs.satisfactionSurveys, {
3042
3406
  limit: PAGE_SIZE2,
3043
3407
  page: surveyPage,
3044
3408
  depth: 0,
3045
3409
  overrideAccess: true,
3046
- select: { rating: true }
3410
+ select: { rating: true, nps: true }
3047
3411
  });
3048
3412
  for (const s of batch.docs) {
3049
- totalRating += s.rating || 0;
3413
+ const r = s.rating;
3414
+ if (typeof r === "number" && r > 0) {
3415
+ ratingSum += r;
3416
+ ratingN++;
3417
+ }
3418
+ const n = s.nps;
3419
+ if (typeof n === "number") {
3420
+ npsN++;
3421
+ if (n >= 9) promoters++;
3422
+ else if (n <= 6) detractors++;
3423
+ }
3050
3424
  }
3051
3425
  surveyHasMore = batch.hasNextPage ?? false;
3052
3426
  surveyPage++;
3053
3427
  }
3054
- satisfactionAvg = Math.round(totalRating / surveyCount.totalDocs * 10) / 10;
3428
+ satisfactionAvg = ratingN > 0 ? Math.round(ratingSum / ratingN * 10) / 10 : 0;
3429
+ satisfactionCount = ratingN;
3430
+ if (npsN > 0) {
3431
+ npsScore = Math.round((promoters - detractors) / npsN * 100);
3432
+ npsCount = npsN;
3433
+ }
3055
3434
  }
3056
- const [clientCount, pendingEmailsCount] = await Promise.all([
3057
- payload.count({
3058
- collection: slugs.supportClients,
3059
- overrideAccess: true
3060
- }),
3061
- payload.count({
3062
- collection: slugs.pendingEmails,
3435
+ const clientCount = await dbCount(payload, slugs.supportClients, { overrideAccess: true });
3436
+ let pendingEmailsTotal = 0;
3437
+ try {
3438
+ const pe = await dbCount(payload, slugs.pendingEmails, {
3063
3439
  where: { status: { equals: "pending" } },
3064
3440
  overrideAccess: true
3065
- })
3066
- ]);
3441
+ });
3442
+ pendingEmailsTotal = pe.totalDocs;
3443
+ } catch {
3444
+ }
3067
3445
  return new Response(JSON.stringify({
3068
3446
  total,
3069
3447
  byStatus,
@@ -3071,13 +3449,16 @@ function createAdminStatsEndpoint(slugs) {
3071
3449
  byCategory,
3072
3450
  createdLast7Days: created7.totalDocs,
3073
3451
  createdLast30Days: created30.totalDocs,
3452
+ volumeByDay,
3074
3453
  avgResponseTimeHours: responseTimeCount > 0 ? Math.round(totalResponseTimeMs / responseTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3075
3454
  avgResolutionTimeHours: resolutionTimeCount > 0 ? Math.round(totalResolutionTimeMs / resolutionTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3076
3455
  totalTimeMinutes,
3077
3456
  satisfactionAvg,
3078
- satisfactionCount: surveyCount.totalDocs,
3457
+ satisfactionCount,
3458
+ npsScore,
3459
+ npsCount,
3079
3460
  clientCount: clientCount.totalDocs,
3080
- pendingEmailsCount: pendingEmailsCount.totalDocs
3461
+ pendingEmailsCount: pendingEmailsTotal
3081
3462
  }), {
3082
3463
  headers: {
3083
3464
  "Content-Type": "application/json",
@@ -3147,13 +3528,26 @@ function createBillingEndpoint(slugs) {
3147
3528
  let ticketPage = 1;
3148
3529
  let ticketHasMore = true;
3149
3530
  while (ticketHasMore && ticketPage <= MAX_PAGES) {
3150
- const batch = await payload.find({
3151
- collection: slugs.tickets,
3531
+ const batch = await dbFind(payload, slugs.tickets, {
3152
3532
  where: ticketWhere,
3153
3533
  limit: PAGE_SIZE,
3154
3534
  page: ticketPage,
3535
+ // depth 2 is required: we read ticket.project.client.company below.
3155
3536
  depth: 2,
3156
- overrideAccess: true
3537
+ overrideAccess: true,
3538
+ // Bound the payload to only the fields the report consumes (skips body,
3539
+ // richText, SLA fields, etc.) while keeping project/client populated.
3540
+ select: {
3541
+ ticketNumber: true,
3542
+ subject: true,
3543
+ status: true,
3544
+ billedAmount: true,
3545
+ aiSummary: true,
3546
+ aiSummaryGeneratedAt: true,
3547
+ aiSummaryStatus: true,
3548
+ project: true,
3549
+ client: true
3550
+ }
3157
3551
  });
3158
3552
  allTickets.push(...batch.docs);
3159
3553
  ticketHasMore = batch.hasNextPage ?? false;
@@ -3163,8 +3557,7 @@ function createBillingEndpoint(slugs) {
3163
3557
  let entryPage = 1;
3164
3558
  let entryHasMore = true;
3165
3559
  while (entryHasMore && entryPage <= MAX_PAGES) {
3166
- const batch = await payload.find({
3167
- collection: slugs.timeEntries,
3560
+ const batch = await dbFind(payload, slugs.timeEntries, {
3168
3561
  where: {
3169
3562
  and: [
3170
3563
  { date: { greater_than_equal: from } },
@@ -3262,9 +3655,174 @@ function createBillingEndpoint(slugs) {
3262
3655
  }
3263
3656
  };
3264
3657
  }
3658
+ var fmtAmount = (n) => `${n.toFixed(2)} EUR`;
3659
+ var fmtTime = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3660
+ function generateInvoicePdf(data) {
3661
+ return new Promise((resolve, reject) => {
3662
+ try {
3663
+ const doc = new PDFDocument__default.default({ size: "A4", margin: 50 });
3664
+ const chunks = [];
3665
+ doc.on("data", (c) => chunks.push(c));
3666
+ doc.on("end", () => resolve(Buffer.concat(chunks)));
3667
+ doc.on("error", reject);
3668
+ doc.fillColor("#0f172a").fontSize(22).text("Invoice / Pre-billing");
3669
+ doc.moveDown(0.2);
3670
+ doc.fillColor("#64748b").fontSize(10).text(`Period: ${data.from} -> ${data.to}${data.projectId ? ` \xB7 Project #${data.projectId}` : ""}`);
3671
+ doc.moveDown(1);
3672
+ const X = { ticket: 50, subject: 130, client: 320, time: 430, amount: 500 };
3673
+ const drawRow = (r, opts) => {
3674
+ const y = doc.y;
3675
+ doc.fontSize(opts?.header ? 9 : 10).fillColor(opts?.header ? "#475569" : "#0f172a");
3676
+ if (opts?.total) doc.font("Helvetica-Bold");
3677
+ doc.text(r.ticket, X.ticket, y, { width: X.subject - X.ticket - 6 });
3678
+ doc.text(r.subject, X.subject, y, { width: X.client - X.subject - 6 });
3679
+ doc.text(r.client, X.client, y, { width: X.time - X.client - 6 });
3680
+ doc.text(r.time, X.time, y, { width: X.amount - X.time - 6, align: "right" });
3681
+ doc.text(r.amount, X.amount, y, { width: 545 - X.amount, align: "right" });
3682
+ doc.font("Helvetica");
3683
+ doc.moveDown(0.4);
3684
+ };
3685
+ drawRow({ ticket: "Ticket", subject: "Subject", client: "Client", time: "Time", amount: "Amount" }, { header: true });
3686
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#e2e8f0").stroke();
3687
+ doc.moveDown(0.3);
3688
+ for (const l of data.lines) {
3689
+ if (doc.y > 760) doc.addPage();
3690
+ drawRow({
3691
+ ticket: l.ticketNumber || "",
3692
+ subject: l.subject || "",
3693
+ client: l.client || "",
3694
+ time: fmtTime(l.minutes),
3695
+ amount: fmtAmount(l.amount)
3696
+ });
3697
+ }
3698
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#0f172a").stroke();
3699
+ doc.moveDown(0.3);
3700
+ drawRow({
3701
+ ticket: "",
3702
+ subject: `Total (${data.lines.length} ticket${data.lines.length > 1 ? "s" : ""})`,
3703
+ client: "",
3704
+ time: fmtTime(data.totalMinutes),
3705
+ amount: fmtAmount(data.totalAmount)
3706
+ }, { total: true });
3707
+ doc.end();
3708
+ } catch (err) {
3709
+ reject(err);
3710
+ }
3711
+ });
3712
+ }
3713
+
3714
+ // src/endpoints/invoice.ts
3715
+ function createInvoiceEndpoint(slugs) {
3716
+ return {
3717
+ path: "/support/billing/invoice",
3718
+ method: "get",
3719
+ handler: async (req) => {
3720
+ try {
3721
+ requireAdmin(req, slugs);
3722
+ const payload = req.payload;
3723
+ const url = new URL(req.url);
3724
+ const from = url.searchParams.get("from");
3725
+ const to = url.searchParams.get("to");
3726
+ const projectId = url.searchParams.get("projectId");
3727
+ const format = url.searchParams.get("format") === "pdf" ? "pdf" : "html";
3728
+ if (!from || !to) {
3729
+ return Response.json({ error: "Param\xE8tres from et to requis." }, { status: 400 });
3730
+ }
3731
+ const toExclusive = new Date(to);
3732
+ toExclusive.setDate(toExclusive.getDate() + 1);
3733
+ const toIso = toExclusive.toISOString();
3734
+ const where = {
3735
+ and: [
3736
+ { billable: { equals: true } },
3737
+ ...projectId ? [{ project: { equals: Number(projectId) } }] : [],
3738
+ {
3739
+ or: [
3740
+ { and: [{ updatedAt: { greater_than_equal: from } }, { updatedAt: { less_than: toIso } }] },
3741
+ { and: [{ createdAt: { greater_than_equal: from } }, { createdAt: { less_than: toIso } }] },
3742
+ { and: [{ resolvedAt: { greater_than_equal: from } }, { resolvedAt: { less_than: toIso } }] }
3743
+ ]
3744
+ }
3745
+ ]
3746
+ };
3747
+ const tickets = [];
3748
+ let page = 1;
3749
+ let hasMore = true;
3750
+ while (hasMore && page <= 50) {
3751
+ const batch = await dbFind(payload, slugs.tickets, {
3752
+ where,
3753
+ limit: 500,
3754
+ page,
3755
+ depth: 2,
3756
+ overrideAccess: true,
3757
+ select: { ticketNumber: true, subject: true, billedAmount: true, totalTimeMinutes: true, project: true, client: true }
3758
+ });
3759
+ tickets.push(...batch.docs);
3760
+ hasMore = batch.hasNextPage ?? false;
3761
+ page++;
3762
+ }
3763
+ let totalAmount = 0;
3764
+ let totalMinutes = 0;
3765
+ const lines = tickets.map((t) => {
3766
+ const amount = typeof t.billedAmount === "number" ? t.billedAmount : 0;
3767
+ const minutes = typeof t.totalTimeMinutes === "number" ? t.totalTimeMinutes : 0;
3768
+ totalAmount += amount;
3769
+ totalMinutes += minutes;
3770
+ const client = typeof t.client === "object" && t.client ? t.client.company || "" : "";
3771
+ return { ticketNumber: String(t.ticketNumber || ""), subject: String(t.subject || ""), client, minutes, amount };
3772
+ });
3773
+ if (format === "pdf") {
3774
+ const pdf = await generateInvoicePdf({ from, to, projectId, lines, totalMinutes, totalAmount });
3775
+ return new Response(new Uint8Array(pdf), {
3776
+ status: 200,
3777
+ headers: {
3778
+ "Content-Type": "application/pdf",
3779
+ "Content-Disposition": `inline; filename="invoice-${from}_${to}.pdf"`
3780
+ }
3781
+ });
3782
+ }
3783
+ const fmtAmount2 = (n) => `${n.toFixed(2)} \u20AC`;
3784
+ const fmtTime2 = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3785
+ const rows = lines.map((l) => `<tr>
3786
+ <td>${escapeHtml(l.ticketNumber)}</td>
3787
+ <td>${escapeHtml(l.subject)}</td>
3788
+ <td>${escapeHtml(l.client)}</td>
3789
+ <td style="text-align:right;">${fmtTime2(l.minutes)}</td>
3790
+ <td style="text-align:right;">${fmtAmount2(l.amount)}</td>
3791
+ </tr>`).join("");
3792
+ const html = `<!doctype html>
3793
+ <html lang="fr"><head><meta charset="utf-8"><title>Facture ${escapeHtml(from)} \u2192 ${escapeHtml(to)}</title>
3794
+ <style>
3795
+ body { font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 820px; margin: 32px auto; padding: 0 24px; }
3796
+ h1 { font-size: 22px; margin: 0 0 4px; }
3797
+ .period { color: #64748b; margin-bottom: 24px; }
3798
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
3799
+ th, td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; text-align: left; }
3800
+ th { background: #f8fafc; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #475569; }
3801
+ tfoot td { font-weight: 700; border-top: 2px solid #0f172a; border-bottom: none; }
3802
+ @media print { body { margin: 0; } }
3803
+ </style></head>
3804
+ <body>
3805
+ <h1>Facture / Pr\xE9-facture</h1>
3806
+ <div class="period">P\xE9riode : ${escapeHtml(from)} \u2192 ${escapeHtml(to)}${projectId ? ` \xB7 Projet #${escapeHtml(projectId)}` : ""}</div>
3807
+ <table>
3808
+ <thead><tr><th>Ticket</th><th>Sujet</th><th>Client</th><th style="text-align:right;">Temps</th><th style="text-align:right;">Montant</th></tr></thead>
3809
+ <tbody>${rows || '<tr><td colspan="5" style="color:#94a3b8;">Aucun ticket facturable sur la p\xE9riode.</td></tr>'}</tbody>
3810
+ <tfoot><tr><td colspan="3">Total (${lines.length} ticket${lines.length > 1 ? "s" : ""})</td><td style="text-align:right;">${fmtTime2(totalMinutes)}</td><td style="text-align:right;">${fmtAmount2(totalAmount)}</td></tr></tfoot>
3811
+ </table>
3812
+ </body></html>`;
3813
+ return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
3814
+ } catch (error) {
3815
+ const authResponse = handleAuthError(error);
3816
+ if (authResponse) return authResponse;
3817
+ console.error("[invoice] Error:", error);
3818
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
3819
+ }
3820
+ }
3821
+ };
3822
+ }
3265
3823
 
3266
3824
  // src/utils/generateTicketSynthesis.ts
3267
- function getClient3(aiSettings) {
3825
+ function getClient4(aiSettings) {
3268
3826
  const Anthropic = __require("@anthropic-ai/sdk").default;
3269
3827
  if (aiSettings.provider === "ollama") {
3270
3828
  const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
@@ -3281,8 +3839,7 @@ async function generateTicketSynthesis(args) {
3281
3839
  if (settings.ai.enableSynthesis === false) {
3282
3840
  return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "skipped", reason: "synthesis disabled" };
3283
3841
  }
3284
- const ticket = await payload.findByID({
3285
- collection: slugs.tickets,
3842
+ const ticket = await dbFindByID(payload, slugs.tickets, {
3286
3843
  id: ticketId,
3287
3844
  depth: 1,
3288
3845
  overrideAccess: true
@@ -3290,15 +3847,13 @@ async function generateTicketSynthesis(args) {
3290
3847
  if (!ticket) {
3291
3848
  return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: "ticket not found" };
3292
3849
  }
3293
- await payload.update({
3294
- collection: slugs.tickets,
3850
+ await dbUpdate(payload, slugs.tickets, {
3295
3851
  id: ticketId,
3296
3852
  data: { aiSummaryStatus: "pending" },
3297
3853
  overrideAccess: true
3298
3854
  }).catch(() => {
3299
3855
  });
3300
- const messagesResult = await payload.find({
3301
- collection: slugs.ticketMessages,
3856
+ const messagesResult = await dbFind(payload, slugs.ticketMessages, {
3302
3857
  where: { ticket: { equals: ticketId } },
3303
3858
  sort: "createdAt",
3304
3859
  limit: 500,
@@ -3308,8 +3863,7 @@ async function generateTicketSynthesis(args) {
3308
3863
  const messages = messagesResult.docs;
3309
3864
  if (messages.length === 0) {
3310
3865
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3311
- await payload.update({
3312
- collection: slugs.tickets,
3866
+ await dbUpdate(payload, slugs.tickets, {
3313
3867
  id: ticketId,
3314
3868
  data: {
3315
3869
  aiSummary: "(Aucun message dans ce ticket)",
@@ -3354,7 +3908,7 @@ Regles strictes :
3354
3908
  - Si le ticket n'a pas abouti, decris quand meme le travail d'analyse realise
3355
3909
 
3356
3910
  Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3357
- const anthropic = getClient3(settings.ai);
3911
+ const anthropic = getClient4(settings.ai);
3358
3912
  const model = getModel3(settings.ai);
3359
3913
  try {
3360
3914
  const res = await anthropic.messages.create({
@@ -3364,8 +3918,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3364
3918
  });
3365
3919
  const summary = res.content[0]?.type === "text" ? res.content[0].text.trim() : "";
3366
3920
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3367
- await payload.update({
3368
- collection: slugs.tickets,
3921
+ await dbUpdate(payload, slugs.tickets, {
3369
3922
  id: ticketId,
3370
3923
  data: {
3371
3924
  aiSummary: summary,
@@ -3377,8 +3930,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3377
3930
  return { summary, generatedAt, status: "done" };
3378
3931
  } catch (err) {
3379
3932
  const message = err instanceof Error ? err.message : String(err);
3380
- await payload.update({
3381
- collection: slugs.tickets,
3933
+ await dbUpdate(payload, slugs.tickets, {
3382
3934
  id: ticketId,
3383
3935
  data: { aiSummaryStatus: "error" },
3384
3936
  overrideAccess: true
@@ -3408,8 +3960,7 @@ function createTicketSynthesisEndpoint(slugs) {
3408
3960
  return Response.json({ error: "ticketId must be a number" }, { status: 400 });
3409
3961
  }
3410
3962
  if (!force) {
3411
- const existing = await payload.findByID({
3412
- collection: slugs.tickets,
3963
+ const existing = await dbFindByID(payload, slugs.tickets, {
3413
3964
  id: ticketId,
3414
3965
  depth: 0,
3415
3966
  overrideAccess: true
@@ -3453,8 +4004,7 @@ function createEmailStatsEndpoint(slugs) {
3453
4004
  let page = 1;
3454
4005
  let hasMore = true;
3455
4006
  while (hasMore && page <= MAX_PAGES2) {
3456
- const result = await payload.find({
3457
- collection: slugs.emailLogs,
4007
+ const result = await dbFind(payload, slugs.emailLogs, {
3458
4008
  where: { createdAt: { greater_than: cutoff } },
3459
4009
  sort: "-createdAt",
3460
4010
  limit: 500,
@@ -3528,184 +4078,72 @@ function createSatisfactionEndpoint(slugs) {
3528
4078
  const payload = req.payload;
3529
4079
  requireClient(req, slugs);
3530
4080
  const body = await req.json();
3531
- const { ticketId, rating, comment } = body;
3532
- if (!ticketId || !rating || !Number.isInteger(rating) || rating < 1 || rating > 5) {
3533
- return Response.json(
3534
- { error: "ticketId et rating (entier 1-5) sont requis." },
3535
- { status: 400 }
3536
- );
3537
- }
3538
- if (comment && typeof comment === "string" && comment.length > 5e3) {
3539
- return Response.json(
3540
- { error: "Le commentaire ne peut pas d\xE9passer 5000 caract\xE8res." },
3541
- { status: 400 }
3542
- );
3543
- }
3544
- const ticket = await payload.findByID({
3545
- collection: slugs.tickets,
3546
- id: ticketId,
3547
- depth: 0,
3548
- overrideAccess: false,
3549
- user: req.user
3550
- });
3551
- if (!ticket) {
3552
- return Response.json({ error: "Ticket introuvable." }, { status: 404 });
4081
+ const { ticketId, rating, nps, comment } = body;
4082
+ if (!ticketId) {
4083
+ return Response.json({ error: "ticketId est requis." }, { status: 400 });
3553
4084
  }
3554
- if (ticket.status !== "resolved") {
3555
- return Response.json(
3556
- { error: "Le ticket doit \xEAtre r\xE9solu pour laisser un avis." },
3557
- { status: 400 }
3558
- );
4085
+ const hasRating = rating !== void 0 && rating !== null;
4086
+ const hasNps = nps !== void 0 && nps !== null;
4087
+ if (!hasRating && !hasNps) {
4088
+ return Response.json({ error: "Un rating (1-5) et/ou un nps (0-10) est requis." }, { status: 400 });
3559
4089
  }
3560
- const existing = await payload.find({
3561
- collection: slugs.satisfactionSurveys,
3562
- where: { ticket: { equals: ticketId } },
3563
- limit: 1,
3564
- depth: 0,
3565
- overrideAccess: true
3566
- });
3567
- if (existing.docs.length > 0) {
3568
- return Response.json(
3569
- { error: "Vous avez d\xE9j\xE0 \xE9valu\xE9 ce ticket." },
3570
- { status: 409 }
3571
- );
4090
+ if (hasRating && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
4091
+ return Response.json({ error: "rating doit \xEAtre un entier 1-5." }, { status: 400 });
3572
4092
  }
3573
- const survey = await payload.create({
3574
- collection: slugs.satisfactionSurveys,
3575
- data: {
3576
- source: "ticket",
3577
- ticket: ticketId,
3578
- client: req.user.id,
3579
- rating: Math.round(rating),
3580
- ...comment ? { comment: comment.trim() } : {}
3581
- },
3582
- overrideAccess: true
3583
- });
3584
- return Response.json({ success: true, survey });
3585
- } catch (error) {
3586
- const authResponse = handleAuthError(error);
3587
- if (authResponse) return authResponse;
3588
- console.error("[satisfaction] Error:", error);
3589
- return Response.json({ error: "Erreur interne." }, { status: 500 });
3590
- }
3591
- }
3592
- };
3593
- }
3594
- var TRANSPARENT_GIF = Buffer.from(
3595
- "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
3596
- "base64"
3597
- );
3598
- function generateTrackingToken(ticketId, messageId, secret) {
3599
- return crypto3.createHmac("sha256", secret).update(`${ticketId}:${messageId}`).digest("hex").substring(0, 16);
3600
- }
3601
- function createTrackOpenEndpoint(slugs) {
3602
- return {
3603
- path: "/support/track-open",
3604
- method: "get",
3605
- handler: async (req) => {
3606
- const url = new URL(req.url);
3607
- const ticketId = url.searchParams.get("t");
3608
- const messageId = url.searchParams.get("m");
3609
- const sig = url.searchParams.get("sig");
3610
- const parsedId = ticketId ? Number(ticketId) : NaN;
3611
- const parsedMsgId = messageId ? Number(messageId) : NaN;
3612
- const secret = process.env.PAYLOAD_SECRET || "";
3613
- if (secret && ticketId && messageId && sig) {
3614
- const expected = generateTrackingToken(ticketId, messageId, secret);
3615
- if (sig !== expected) {
3616
- return new Response(TRANSPARENT_GIF, {
3617
- status: 200,
3618
- headers: {
3619
- "Content-Type": "image/gif",
3620
- "Content-Length": String(TRANSPARENT_GIF.length),
3621
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
3622
- }
3623
- });
4093
+ if (hasNps && (!Number.isInteger(nps) || nps < 0 || nps > 10)) {
4094
+ return Response.json({ error: "nps doit \xEAtre un entier 0-10." }, { status: 400 });
3624
4095
  }
3625
- } else if (secret && (!sig || !ticketId || !messageId)) {
3626
- return new Response(TRANSPARENT_GIF, {
3627
- status: 200,
3628
- headers: {
3629
- "Content-Type": "image/gif",
3630
- "Content-Length": String(TRANSPARENT_GIF.length),
3631
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
3632
- }
3633
- });
3634
- }
3635
- if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
3636
- try {
3637
- const payload = req.payload;
3638
- const ticket = await payload.findByID({
3639
- collection: slugs.tickets,
3640
- id: parsedId,
3641
- depth: 0,
3642
- overrideAccess: true,
3643
- select: { lastClientReadAt: true }
3644
- });
3645
- if (ticket) {
3646
- const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0;
3647
- const fiveMinAgo = Date.now() - 5 * 60 * 1e3;
3648
- if (lastRead < fiveMinAgo) {
3649
- await payload.update({
3650
- collection: slugs.tickets,
3651
- id: parsedId,
3652
- data: { lastClientReadAt: (/* @__PURE__ */ new Date()).toISOString() },
3653
- overrideAccess: true
3654
- });
3655
- }
3656
- }
3657
- if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
3658
- const msg = await payload.findByID({
3659
- collection: slugs.ticketMessages,
3660
- id: parsedMsgId,
3661
- depth: 0,
3662
- overrideAccess: true,
3663
- select: { emailOpenedAt: true }
3664
- });
3665
- if (msg && !msg.emailOpenedAt) {
3666
- await payload.update({
3667
- collection: slugs.ticketMessages,
3668
- id: parsedMsgId,
3669
- data: { emailOpenedAt: (/* @__PURE__ */ new Date()).toISOString() },
3670
- overrideAccess: true
3671
- });
3672
- const ticketInfo = await payload.findByID({
3673
- collection: slugs.tickets,
3674
- id: parsedId,
3675
- depth: 1,
3676
- overrideAccess: true,
3677
- select: { ticketNumber: true, subject: true, client: true }
3678
- });
3679
- const clientName = typeof ticketInfo?.client === "object" ? ticketInfo.client?.firstName || "Client" : "Client";
3680
- try {
3681
- await payload.create({
3682
- collection: "admin-notifications",
3683
- data: {
3684
- title: `Email ouvert \u2014 ${ticketInfo?.ticketNumber || "TK-????"}`,
3685
- message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || "ticket"}"`,
3686
- type: "email_opened",
3687
- link: `/admin/ticket?id=${parsedId}`
3688
- },
3689
- overrideAccess: true
3690
- });
3691
- } catch {
3692
- }
3693
- }
3694
- }
3695
- } catch (err) {
3696
- console.error("[track-open] Error:", err);
4096
+ if (comment && typeof comment === "string" && comment.length > 5e3) {
4097
+ return Response.json(
4098
+ { error: "Le commentaire ne peut pas d\xE9passer 5000 caract\xE8res." },
4099
+ { status: 400 }
4100
+ );
3697
4101
  }
3698
- }
3699
- return new Response(TRANSPARENT_GIF, {
3700
- status: 200,
3701
- headers: {
3702
- "Content-Type": "image/gif",
3703
- "Content-Length": String(TRANSPARENT_GIF.length),
3704
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
3705
- "Pragma": "no-cache",
3706
- "Expires": "0"
4102
+ const ticket = await dbFindByID(payload, slugs.tickets, {
4103
+ id: ticketId,
4104
+ depth: 0,
4105
+ overrideAccess: false,
4106
+ user: req.user
4107
+ });
4108
+ if (!ticket) {
4109
+ return Response.json({ error: "Ticket introuvable." }, { status: 404 });
3707
4110
  }
3708
- });
4111
+ if (ticket.status !== "resolved") {
4112
+ return Response.json(
4113
+ { error: "Le ticket doit \xEAtre r\xE9solu pour laisser un avis." },
4114
+ { status: 400 }
4115
+ );
4116
+ }
4117
+ const existing = await dbFind(payload, slugs.satisfactionSurveys, {
4118
+ where: { ticket: { equals: ticketId } },
4119
+ limit: 1,
4120
+ depth: 0,
4121
+ overrideAccess: true
4122
+ });
4123
+ if (existing.docs.length > 0) {
4124
+ return Response.json(
4125
+ { error: "Vous avez d\xE9j\xE0 \xE9valu\xE9 ce ticket." },
4126
+ { status: 409 }
4127
+ );
4128
+ }
4129
+ const survey = await dbCreate(payload, slugs.satisfactionSurveys, {
4130
+ data: {
4131
+ source: "ticket",
4132
+ ticket: ticketId,
4133
+ client: req.user.id,
4134
+ ...hasRating ? { rating: Math.round(rating) } : {},
4135
+ ...hasNps ? { nps: Math.round(nps) } : {},
4136
+ ...comment ? { comment: comment.trim() } : {}
4137
+ },
4138
+ overrideAccess: true
4139
+ });
4140
+ return Response.json({ success: true, survey });
4141
+ } catch (error) {
4142
+ const authResponse = handleAuthError(error);
4143
+ if (authResponse) return authResponse;
4144
+ console.error("[satisfaction] Error:", error);
4145
+ return Response.json({ error: "Erreur interne." }, { status: 500 });
4146
+ }
3709
4147
  }
3710
4148
  };
3711
4149
  }
@@ -3724,8 +4162,7 @@ function createExportCsvEndpoint(slugs) {
3724
4162
  let hasMore = true;
3725
4163
  const allDocs = [];
3726
4164
  while (hasMore) {
3727
- const batch = await payload.find({
3728
- collection: slugs.tickets,
4165
+ const batch = await dbFind(payload, slugs.tickets, {
3729
4166
  limit: PAGE_SIZE2,
3730
4167
  page,
3731
4168
  depth: 1,
@@ -3801,21 +4238,18 @@ function createExportDataEndpoint(slugs) {
3801
4238
  const payload = req.payload;
3802
4239
  requireClient(req, slugs);
3803
4240
  const [clientData, ticketsResult, messagesResult, surveysResult] = await Promise.all([
3804
- payload.findByID({
3805
- collection: slugs.supportClients,
4241
+ dbFindByID(payload, slugs.supportClients, {
3806
4242
  id: req.user.id,
3807
4243
  depth: 0,
3808
4244
  overrideAccess: true
3809
4245
  }),
3810
- payload.find({
3811
- collection: slugs.tickets,
4246
+ dbFind(payload, slugs.tickets, {
3812
4247
  where: { client: { equals: req.user.id } },
3813
4248
  limit: 1e3,
3814
4249
  depth: 0,
3815
4250
  overrideAccess: true
3816
4251
  }),
3817
- payload.find({
3818
- collection: slugs.ticketMessages,
4252
+ dbFind(payload, slugs.ticketMessages, {
3819
4253
  where: {
3820
4254
  "ticket.client": { equals: req.user.id },
3821
4255
  authorType: { equals: "client" }
@@ -3824,8 +4258,7 @@ function createExportDataEndpoint(slugs) {
3824
4258
  depth: 0,
3825
4259
  overrideAccess: true
3826
4260
  }),
3827
- payload.find({
3828
- collection: slugs.satisfactionSurveys,
4261
+ dbFind(payload, slugs.satisfactionSurveys, {
3829
4262
  where: { client: { equals: req.user.id } },
3830
4263
  limit: 500,
3831
4264
  depth: 0,
@@ -3899,8 +4332,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
3899
4332
  if (!["create_ticket", "add_to_ticket", "ignore"].includes(action)) {
3900
4333
  return Response.json({ error: "Invalid action" }, { status: 400 });
3901
4334
  }
3902
- const pendingEmail = await payload.findByID({
3903
- collection: slugs.pendingEmails,
4335
+ const pendingEmail = await dbFindByID(payload, slugs.pendingEmails, {
3904
4336
  id: Number(id),
3905
4337
  depth: 1,
3906
4338
  overrideAccess: true
@@ -3914,14 +4346,12 @@ function createPendingEmailsProcessEndpoint(slugs) {
3914
4346
  let clientId = overrideClientId || (typeof pendingEmail.client === "object" ? pendingEmail.client?.id : pendingEmail.client);
3915
4347
  let clientDoc = typeof pendingEmail.client === "object" ? pendingEmail.client : null;
3916
4348
  if (overrideClientId && overrideClientId !== clientDoc?.id) {
3917
- clientDoc = await payload.findByID({
3918
- collection: slugs.supportClients,
4349
+ clientDoc = await dbFindByID(payload, slugs.supportClients, {
3919
4350
  id: overrideClientId,
3920
4351
  depth: 0,
3921
4352
  overrideAccess: true
3922
4353
  });
3923
- await payload.update({
3924
- collection: slugs.pendingEmails,
4354
+ await dbUpdate(payload, slugs.pendingEmails, {
3925
4355
  id: Number(id),
3926
4356
  data: { client: overrideClientId },
3927
4357
  overrideAccess: true
@@ -3938,8 +4368,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
3938
4368
  file: typeof a.file === "object" ? a.file.id : a.file
3939
4369
  })) || [];
3940
4370
  if (action === "ignore") {
3941
- await payload.update({
3942
- collection: slugs.pendingEmails,
4371
+ await dbUpdate(payload, slugs.pendingEmails, {
3943
4372
  id: Number(id),
3944
4373
  data: {
3945
4374
  status: "ignored",
@@ -3951,8 +4380,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
3951
4380
  return Response.json({ action: "ignored", pendingEmailId: Number(id) });
3952
4381
  }
3953
4382
  if (action === "create_ticket") {
3954
- const newTicket = await payload.create({
3955
- collection: slugs.tickets,
4383
+ const newTicket = await dbCreate(payload, slugs.tickets, {
3956
4384
  data: {
3957
4385
  subject: pendingEmail.subject,
3958
4386
  client: clientId,
@@ -3963,8 +4391,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
3963
4391
  },
3964
4392
  overrideAccess: true
3965
4393
  });
3966
- await payload.create({
3967
- collection: slugs.ticketMessages,
4394
+ await dbCreate(payload, slugs.ticketMessages, {
3968
4395
  data: {
3969
4396
  ticket: newTicket.id,
3970
4397
  body: pendingEmail.body,
@@ -3991,8 +4418,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
3991
4418
  } catch (err) {
3992
4419
  console.error("[pending-email-process] Failed to send notification:", err);
3993
4420
  }
3994
- await payload.update({
3995
- collection: slugs.pendingEmails,
4421
+ await dbUpdate(payload, slugs.pendingEmails, {
3996
4422
  id: Number(id),
3997
4423
  data: {
3998
4424
  status: "processed",
@@ -4012,8 +4438,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4012
4438
  if (!ticketId) {
4013
4439
  return Response.json({ error: "ticketId is required for add_to_ticket" }, { status: 400 });
4014
4440
  }
4015
- const targetTicket = await payload.findByID({
4016
- collection: slugs.tickets,
4441
+ const targetTicket = await dbFindByID(payload, slugs.tickets, {
4017
4442
  id: ticketId,
4018
4443
  depth: 0,
4019
4444
  overrideAccess: true
@@ -4021,8 +4446,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4021
4446
  if (!targetTicket) {
4022
4447
  return Response.json({ error: "Target ticket not found" }, { status: 404 });
4023
4448
  }
4024
- await payload.create({
4025
- collection: slugs.ticketMessages,
4449
+ await dbCreate(payload, slugs.ticketMessages, {
4026
4450
  data: {
4027
4451
  ticket: ticketId,
4028
4452
  body: pendingEmail.body,
@@ -4034,15 +4458,13 @@ function createPendingEmailsProcessEndpoint(slugs) {
4034
4458
  overrideAccess: true
4035
4459
  });
4036
4460
  if (targetTicket.status === "resolved") {
4037
- await payload.update({
4038
- collection: slugs.tickets,
4461
+ await dbUpdate(payload, slugs.tickets, {
4039
4462
  id: ticketId,
4040
4463
  data: { status: "open" },
4041
4464
  overrideAccess: true
4042
4465
  });
4043
4466
  }
4044
- await payload.update({
4045
- collection: slugs.pendingEmails,
4467
+ await dbUpdate(payload, slugs.pendingEmails, {
4046
4468
  id: Number(id),
4047
4469
  data: {
4048
4470
  status: "processed",
@@ -4095,8 +4517,7 @@ function createResendNotificationEndpoint(slugs) {
4095
4517
  if (!messageId) {
4096
4518
  return Response.json({ error: "messageId requis" }, { status: 400 });
4097
4519
  }
4098
- const message = await payload.findByID({
4099
- collection: slugs.ticketMessages,
4520
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
4100
4521
  id: messageId,
4101
4522
  depth: 0,
4102
4523
  overrideAccess: true
@@ -4105,8 +4526,7 @@ function createResendNotificationEndpoint(slugs) {
4105
4526
  return Response.json({ error: "Message introuvable" }, { status: 404 });
4106
4527
  }
4107
4528
  const ticketId = typeof message.ticket === "object" ? message.ticket.id : message.ticket;
4108
- const ticket = await payload.findByID({
4109
- collection: slugs.tickets,
4529
+ const ticket = await dbFindByID(payload, slugs.tickets, {
4110
4530
  id: ticketId,
4111
4531
  depth: 1,
4112
4532
  overrideAccess: true
@@ -4232,8 +4652,7 @@ function createSeedKbEndpoint(slugs) {
4232
4652
  let created = 0;
4233
4653
  let skipped = 0;
4234
4654
  for (const article of KB_ARTICLES) {
4235
- const existing = await payload.find({
4236
- collection: slugs.knowledgeBase,
4655
+ const existing = await dbFind(payload, slugs.knowledgeBase, {
4237
4656
  where: { slug: { equals: article.slug } },
4238
4657
  limit: 1,
4239
4658
  depth: 0,
@@ -4260,8 +4679,7 @@ function createSeedKbEndpoint(slugs) {
4260
4679
  version: 1
4261
4680
  }
4262
4681
  };
4263
- await payload.create({
4264
- collection: slugs.knowledgeBase,
4682
+ await dbCreate(payload, slugs.knowledgeBase, {
4265
4683
  data: {
4266
4684
  title: article.title,
4267
4685
  slug: article.slug,
@@ -4316,8 +4734,7 @@ function createLoginEndpoint(slugs) {
4316
4734
  collection: slugs.supportClients,
4317
4735
  data: { email, password }
4318
4736
  });
4319
- payload.create({
4320
- collection: slugs.authLogs,
4737
+ dbCreate(payload, slugs.authLogs, {
4321
4738
  data: { email, success: true, action: "login", ipAddress: ip, userAgent },
4322
4739
  overrideAccess: true
4323
4740
  }).catch(() => {
@@ -4341,12 +4758,14 @@ function createLoginEndpoint(slugs) {
4341
4758
  );
4342
4759
  } catch (err) {
4343
4760
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
4761
+ if (errorMessage.includes("2FA_REQUIRED")) {
4762
+ return Response.json({ requires2FA: true }, { status: 200 });
4763
+ }
4344
4764
  let errorReason = "Identifiants incorrects";
4345
4765
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
4346
4766
  errorReason = "Compte verrouill\xE9 (trop de tentatives)";
4347
4767
  }
4348
- payload.create({
4349
- collection: slugs.authLogs,
4768
+ dbCreate(payload, slugs.authLogs, {
4350
4769
  data: { email, success: false, action: "login", errorReason, ipAddress: ip, userAgent },
4351
4770
  overrideAccess: true
4352
4771
  }).catch(() => {
@@ -4367,7 +4786,10 @@ function generateSecureCode() {
4367
4786
  return String(num);
4368
4787
  }
4369
4788
  function hashCode(code) {
4370
- const secret = process.env.PAYLOAD_SECRET || "payload-support-2fa";
4789
+ const secret = process.env.PAYLOAD_SECRET;
4790
+ if (!secret) {
4791
+ throw new Error("[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to operate 2FA with an insecure fallback secret");
4792
+ }
4371
4793
  return crypto3.createHmac("sha256", secret).update(code).digest("hex");
4372
4794
  }
4373
4795
  function createAuth2faEndpoint(slugs) {
@@ -4392,8 +4814,7 @@ function createAuth2faEndpoint(slugs) {
4392
4814
  if (sendLimiter.check(email)) {
4393
4815
  return Response.json(genericSendResponse);
4394
4816
  }
4395
- const clients = await payload.find({
4396
- collection: slugs.supportClients,
4817
+ const clients = await dbFind(payload, slugs.supportClients, {
4397
4818
  where: { email: { equals: email } },
4398
4819
  limit: 1,
4399
4820
  depth: 0,
@@ -4406,8 +4827,7 @@ function createAuth2faEndpoint(slugs) {
4406
4827
  const plainCode = generateSecureCode();
4407
4828
  const twoFactorCode = hashCode(plainCode);
4408
4829
  const twoFactorExpiry = new Date(Date.now() + 10 * 60 * 1e3).toISOString();
4409
- await payload.update({
4410
- collection: slugs.supportClients,
4830
+ await dbUpdate(payload, slugs.supportClients, {
4411
4831
  id: client.id,
4412
4832
  data: { twoFactorCode, twoFactorExpiry },
4413
4833
  overrideAccess: true
@@ -4438,8 +4858,7 @@ function createAuth2faEndpoint(slugs) {
4438
4858
  { status: 429 }
4439
4859
  );
4440
4860
  }
4441
- const clients = await payload.find({
4442
- collection: slugs.supportClients,
4861
+ const clients = await dbFind(payload, slugs.supportClients, {
4443
4862
  where: { email: { equals: email } },
4444
4863
  limit: 1,
4445
4864
  depth: 0,
@@ -4455,8 +4874,7 @@ function createAuth2faEndpoint(slugs) {
4455
4874
  return Response.json({ error: "Aucun code en attente" }, { status: 400 });
4456
4875
  }
4457
4876
  if (/* @__PURE__ */ new Date() > new Date(storedExpiry)) {
4458
- await payload.update({
4459
- collection: slugs.supportClients,
4877
+ await dbUpdate(payload, slugs.supportClients, {
4460
4878
  id: client.id,
4461
4879
  data: { twoFactorCode: "", twoFactorExpiry: "" },
4462
4880
  overrideAccess: true
@@ -4470,10 +4888,9 @@ function createAuth2faEndpoint(slugs) {
4470
4888
  if (submittedBuffer.length !== storedBuffer.length || !crypto3__default.default.timingSafeEqual(submittedBuffer, storedBuffer)) {
4471
4889
  return Response.json({ error: "Code incorrect" }, { status: 400 });
4472
4890
  }
4473
- await payload.update({
4474
- collection: slugs.supportClients,
4891
+ await dbUpdate(payload, slugs.supportClients, {
4475
4892
  id: client.id,
4476
- data: { twoFactorCode: "", twoFactorExpiry: "" },
4893
+ data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
4477
4894
  overrideAccess: true
4478
4895
  });
4479
4896
  verifyLimiter.reset(email);
@@ -4547,15 +4964,33 @@ function createOAuthGoogleEndpoint(slugs, options) {
4547
4964
  if (!profile.email) {
4548
4965
  return Response.json({ error: "no_email" }, { status: 400 });
4549
4966
  }
4550
- const payload = req.payload;
4551
- const existing = await payload.find({
4552
- collection: slugs.supportClients,
4553
- where: { email: { equals: profile.email } },
4967
+ if (profile.verified_email !== true) {
4968
+ return Response.json({ error: "email_not_verified" }, { status: 403 });
4969
+ }
4970
+ const payload$1 = req.payload;
4971
+ const byGoogle = await dbFind(payload$1, slugs.supportClients, {
4972
+ where: { googleId: { equals: profile.id } },
4554
4973
  limit: 1,
4555
4974
  depth: 0,
4556
4975
  overrideAccess: true
4557
4976
  });
4558
- let clientDoc = existing.docs[0];
4977
+ let clientDoc = byGoogle.docs[0];
4978
+ if (!clientDoc) {
4979
+ const byEmail = await dbFind(payload$1, slugs.supportClients, {
4980
+ where: { email: { equals: profile.email } },
4981
+ limit: 1,
4982
+ depth: 0,
4983
+ overrideAccess: true
4984
+ });
4985
+ clientDoc = byEmail.docs[0];
4986
+ if (clientDoc && profile.id) {
4987
+ await dbUpdate(payload$1, slugs.supportClients, {
4988
+ id: clientDoc.id,
4989
+ data: { googleId: profile.id },
4990
+ overrideAccess: true
4991
+ });
4992
+ }
4993
+ }
4559
4994
  if (!clientDoc) {
4560
4995
  const allowedDomains = options?.allowedEmailDomains;
4561
4996
  if (allowedDomains && allowedDomains.length > 0) {
@@ -4573,43 +5008,64 @@ function createOAuthGoogleEndpoint(slugs, options) {
4573
5008
  const autoPassword = crypto3__default.default.randomBytes(48).toString("base64url");
4574
5009
  const fullName = profile.name || profile.email.split("@")[0];
4575
5010
  const nameParts = fullName.split(" ");
4576
- clientDoc = await payload.create({
4577
- collection: slugs.supportClients,
5011
+ clientDoc = await dbCreate(payload$1, slugs.supportClients, {
4578
5012
  data: {
4579
5013
  email: profile.email,
4580
5014
  firstName: nameParts[0] || fullName,
4581
5015
  lastName: nameParts.slice(1).join(" ") || "-",
4582
5016
  company: fullName,
5017
+ googleId: profile.id,
4583
5018
  password: autoPassword
4584
5019
  },
4585
5020
  overrideAccess: true
4586
5021
  });
4587
5022
  }
4588
- const tempPassword = crypto3__default.default.randomBytes(48).toString("base64url");
4589
- await payload.update({
4590
- collection: slugs.supportClients,
4591
- id: clientDoc.id,
4592
- data: { password: tempPassword },
4593
- overrideAccess: true
4594
- });
4595
- const loginResult = await payload.login({
4596
- collection: slugs.supportClients,
4597
- data: { email: profile.email, password: tempPassword }
4598
- });
4599
- const postLoginPassword = crypto3__default.default.randomBytes(48).toString("base64url");
4600
- await payload.update({
4601
- collection: slugs.supportClients,
4602
- id: clientDoc.id,
4603
- data: { password: postLoginPassword },
4604
- overrideAccess: true
4605
- });
4606
- if (!loginResult.token) {
4607
- return Response.json({ error: "login_failed" }, { status: 400 });
5023
+ const secret = process.env.PAYLOAD_SECRET;
5024
+ if (!secret) {
5025
+ return Response.json({ error: "server_misconfigured" }, { status: 500 });
4608
5026
  }
4609
- return Response.json({
4610
- token: loginResult.token,
4611
- user: loginResult.user,
4612
- exp: loginResult.exp
5027
+ const collectionConfig = payload$1.collections[slugs.supportClients].config;
5028
+ const tokenExpiration = collectionConfig.auth?.tokenExpiration ?? 7200;
5029
+ let sid;
5030
+ if (collectionConfig.auth?.useSessions) {
5031
+ sid = crypto3__default.default.randomUUID();
5032
+ const now = /* @__PURE__ */ new Date();
5033
+ const expiresAt = new Date(now.getTime() + tokenExpiration * 1e3);
5034
+ const fresh = await dbFindByID(payload$1, slugs.supportClients, {
5035
+ id: clientDoc.id,
5036
+ depth: 0,
5037
+ overrideAccess: true,
5038
+ showHiddenFields: true
5039
+ });
5040
+ const kept = Array.isArray(fresh?.sessions) ? fresh.sessions.filter((s) => new Date(s.expiresAt) > now) : [];
5041
+ await payload$1.db.updateOne({
5042
+ collection: slugs.supportClients,
5043
+ id: clientDoc.id,
5044
+ data: {
5045
+ sessions: [
5046
+ ...kept,
5047
+ { id: sid, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString() }
5048
+ ]
5049
+ },
5050
+ returning: false
5051
+ });
5052
+ }
5053
+ const fieldsToSign = payload.getFieldsToSign({
5054
+ collectionConfig,
5055
+ email: clientDoc.email,
5056
+ sid,
5057
+ user: { ...clientDoc, collection: slugs.supportClients }
5058
+ });
5059
+ const { token, exp } = await payload.jwtSign({ fieldsToSign, secret, tokenExpiration });
5060
+ const headers = new Headers({ "Content-Type": "application/json" });
5061
+ const cookieSecure = process.env.NODE_ENV === "production";
5062
+ headers.append(
5063
+ "Set-Cookie",
5064
+ `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5065
+ );
5066
+ return new Response(JSON.stringify({ token, user: clientDoc, exp }), {
5067
+ status: 200,
5068
+ headers
4613
5069
  });
4614
5070
  }
4615
5071
  return Response.json({ error: "Action invalide" }, { status: 400 });
@@ -4650,8 +5106,7 @@ function createDeleteAccountEndpoint(slugs) {
4650
5106
  );
4651
5107
  }
4652
5108
  const clientId = req.user.id;
4653
- const tickets = await payload.find({
4654
- collection: slugs.tickets,
5109
+ const tickets = await dbFind(payload, slugs.tickets, {
4655
5110
  where: { client: { equals: clientId } },
4656
5111
  limit: 1e4,
4657
5112
  depth: 0,
@@ -4660,39 +5115,32 @@ function createDeleteAccountEndpoint(slugs) {
4660
5115
  });
4661
5116
  const ticketIds = tickets.docs.map((t) => t.id);
4662
5117
  if (ticketIds.length > 0) {
4663
- await payload.delete({
4664
- collection: slugs.ticketMessages,
5118
+ await dbDelete(payload, slugs.ticketMessages, {
4665
5119
  where: { ticket: { in: ticketIds } },
4666
5120
  overrideAccess: true
4667
5121
  });
4668
- await payload.delete({
4669
- collection: slugs.ticketActivityLog,
5122
+ await dbDelete(payload, slugs.ticketActivityLog, {
4670
5123
  where: { ticket: { in: ticketIds } },
4671
5124
  overrideAccess: true
4672
5125
  });
4673
- await payload.delete({
4674
- collection: slugs.timeEntries,
5126
+ await dbDelete(payload, slugs.timeEntries, {
4675
5127
  where: { ticket: { in: ticketIds } },
4676
5128
  overrideAccess: true
4677
5129
  });
4678
- await payload.delete({
4679
- collection: slugs.tickets,
5130
+ await dbDelete(payload, slugs.tickets, {
4680
5131
  where: { client: { equals: clientId } },
4681
5132
  overrideAccess: true
4682
5133
  });
4683
5134
  }
4684
- await payload.delete({
4685
- collection: slugs.satisfactionSurveys,
5135
+ await dbDelete(payload, slugs.satisfactionSurveys, {
4686
5136
  where: { client: { equals: clientId } },
4687
5137
  overrideAccess: true
4688
5138
  });
4689
- await payload.delete({
4690
- collection: slugs.chatMessages,
5139
+ await dbDelete(payload, slugs.chatMessages, {
4691
5140
  where: { client: { equals: clientId } },
4692
5141
  overrideAccess: true
4693
5142
  });
4694
- await payload.delete({
4695
- collection: slugs.supportClients,
5143
+ await dbDelete(payload, slugs.supportClients, {
4696
5144
  id: clientId,
4697
5145
  overrideAccess: true
4698
5146
  });
@@ -4733,8 +5181,8 @@ function createMergeClientsEndpoint(slugs) {
4733
5181
  return Response.json({ error: "sourceId and targetId are required and must be different" }, { status: 400 });
4734
5182
  }
4735
5183
  const [source, target] = await Promise.all([
4736
- payload.findByID({ collection: slugs.supportClients, id: sourceId, depth: 0, overrideAccess: true }),
4737
- payload.findByID({ collection: slugs.supportClients, id: targetId, depth: 0, overrideAccess: true })
5184
+ dbFindByID(payload, slugs.supportClients, { id: sourceId, depth: 0, overrideAccess: true }),
5185
+ dbFindByID(payload, slugs.supportClients, { id: targetId, depth: 0, overrideAccess: true })
4738
5186
  ]);
4739
5187
  if (!source || !target) {
4740
5188
  return Response.json({ error: "Source or target client not found" }, { status: 404 });
@@ -4746,63 +5194,57 @@ function createMergeClientsEndpoint(slugs) {
4746
5194
  pendingEmails: 0,
4747
5195
  satisfactionSurveys: 0
4748
5196
  };
4749
- const tickets = await payload.find({
4750
- collection: slugs.tickets,
5197
+ const tickets = await dbFind(payload, slugs.tickets, {
4751
5198
  where: { client: { equals: sourceId } },
4752
5199
  limit: 500,
4753
5200
  depth: 0,
4754
5201
  overrideAccess: true
4755
5202
  });
4756
5203
  for (const ticket of tickets.docs) {
4757
- await payload.update({ collection: slugs.tickets, id: ticket.id, data: { client: targetId }, overrideAccess: true });
5204
+ await dbUpdate(payload, slugs.tickets, { id: ticket.id, data: { client: targetId }, overrideAccess: true });
4758
5205
  results.tickets++;
4759
5206
  }
4760
- const messages = await payload.find({
4761
- collection: slugs.ticketMessages,
5207
+ const messages = await dbFind(payload, slugs.ticketMessages, {
4762
5208
  where: { authorClient: { equals: sourceId } },
4763
5209
  limit: 1e3,
4764
5210
  depth: 0,
4765
5211
  overrideAccess: true
4766
5212
  });
4767
5213
  for (const msg of messages.docs) {
4768
- await payload.update({ collection: slugs.ticketMessages, id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
5214
+ await dbUpdate(payload, slugs.ticketMessages, { id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
4769
5215
  results.ticketMessages++;
4770
5216
  }
4771
- const chats = await payload.find({
4772
- collection: slugs.chatMessages,
5217
+ const chats = await dbFind(payload, slugs.chatMessages, {
4773
5218
  where: { client: { equals: sourceId } },
4774
5219
  limit: 1e3,
4775
5220
  depth: 0,
4776
5221
  overrideAccess: true
4777
5222
  });
4778
5223
  for (const chat of chats.docs) {
4779
- await payload.update({ collection: slugs.chatMessages, id: chat.id, data: { client: targetId }, overrideAccess: true });
5224
+ await dbUpdate(payload, slugs.chatMessages, { id: chat.id, data: { client: targetId }, overrideAccess: true });
4780
5225
  results.chatMessages++;
4781
5226
  }
4782
- const pendingEmails = await payload.find({
4783
- collection: slugs.pendingEmails,
5227
+ const pendingEmails = await dbFind(payload, slugs.pendingEmails, {
4784
5228
  where: { client: { equals: sourceId } },
4785
5229
  limit: 500,
4786
5230
  depth: 0,
4787
5231
  overrideAccess: true
4788
5232
  });
4789
5233
  for (const pe of pendingEmails.docs) {
4790
- await payload.update({ collection: slugs.pendingEmails, id: pe.id, data: { client: targetId }, overrideAccess: true });
5234
+ await dbUpdate(payload, slugs.pendingEmails, { id: pe.id, data: { client: targetId }, overrideAccess: true });
4791
5235
  results.pendingEmails++;
4792
5236
  }
4793
- const surveys = await payload.find({
4794
- collection: slugs.satisfactionSurveys,
5237
+ const surveys = await dbFind(payload, slugs.satisfactionSurveys, {
4795
5238
  where: { client: { equals: sourceId } },
4796
5239
  limit: 500,
4797
5240
  depth: 0,
4798
5241
  overrideAccess: true
4799
5242
  });
4800
5243
  for (const survey of surveys.docs) {
4801
- await payload.update({ collection: slugs.satisfactionSurveys, id: survey.id, data: { client: targetId }, overrideAccess: true });
5244
+ await dbUpdate(payload, slugs.satisfactionSurveys, { id: survey.id, data: { client: targetId }, overrideAccess: true });
4802
5245
  results.satisfactionSurveys++;
4803
5246
  }
4804
- await payload.delete({
4805
- collection: slugs.supportClients,
5247
+ await dbDelete(payload, slugs.supportClients, {
4806
5248
  id: sourceId,
4807
5249
  overrideAccess: true
4808
5250
  });
@@ -4996,8 +5438,7 @@ function createImportConversationEndpoint(slugs) {
4996
5438
  if (blockedEmails.includes(conversation.client.email)) {
4997
5439
  return Response.json({ error: "Cannot create ticket from system email address" }, { status: 400 });
4998
5440
  }
4999
- const clientResult = await payload.find({
5000
- collection: slugs.supportClients,
5441
+ const clientResult = await dbFind(payload, slugs.supportClients, {
5001
5442
  where: { email: { equals: conversation.client.email } },
5002
5443
  limit: 1,
5003
5444
  depth: 0,
@@ -5008,8 +5449,7 @@ function createImportConversationEndpoint(slugs) {
5008
5449
  if (!client) {
5009
5450
  const nameParts = conversation.client.name.split(" ");
5010
5451
  const randomPassword = crypto3__default.default.randomBytes(48).toString("base64url");
5011
- client = await payload.create({
5012
- collection: slugs.supportClients,
5452
+ client = await dbCreate(payload, slugs.supportClients, {
5013
5453
  data: {
5014
5454
  email: conversation.client.email,
5015
5455
  password: randomPassword,
@@ -5021,15 +5461,13 @@ function createImportConversationEndpoint(slugs) {
5021
5461
  });
5022
5462
  isNewClient = true;
5023
5463
  }
5024
- const adminUsers = await payload.find({
5025
- collection: slugs.users,
5464
+ const adminUsers = await dbFind(payload, slugs.users, {
5026
5465
  limit: 1,
5027
5466
  depth: 0,
5028
5467
  overrideAccess: true
5029
5468
  });
5030
5469
  const adminUserId = adminUsers.docs[0]?.id;
5031
- const ticket = await payload.create({
5032
- collection: slugs.tickets,
5470
+ const ticket = await dbCreate(payload, slugs.tickets, {
5033
5471
  data: {
5034
5472
  subject: conversation.subject,
5035
5473
  client: client.id,
@@ -5042,8 +5480,7 @@ function createImportConversationEndpoint(slugs) {
5042
5480
  let importedCount = 0;
5043
5481
  for (const msg of conversation.messages) {
5044
5482
  const isAdmin = msg.from === "admin";
5045
- await payload.create({
5046
- collection: slugs.ticketMessages,
5483
+ await dbCreate(payload, slugs.ticketMessages, {
5047
5484
  data: {
5048
5485
  ticket: ticket.id,
5049
5486
  body: msg.content,
@@ -5079,8 +5516,7 @@ function createImportConversationEndpoint(slugs) {
5079
5516
  // src/utils/fireWebhooks.ts
5080
5517
  async function fireWebhooks(payload, slugs, event, data) {
5081
5518
  try {
5082
- const endpoints = await payload.find({
5083
- collection: slugs.webhookEndpoints,
5519
+ const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
5084
5520
  where: {
5085
5521
  active: { equals: true },
5086
5522
  events: { contains: event }
@@ -5104,8 +5540,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5104
5540
  signal: AbortSignal.timeout(1e4)
5105
5541
  });
5106
5542
  try {
5107
- await payload.update({
5108
- collection: slugs.webhookEndpoints,
5543
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5109
5544
  id: endpoint.id,
5110
5545
  data: {
5111
5546
  lastTriggeredAt: timestamp,
@@ -5118,8 +5553,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5118
5553
  } catch (error) {
5119
5554
  console.warn(`[support] Webhook delivery failed: ${endpoint.url}`, error);
5120
5555
  try {
5121
- await payload.update({
5122
- collection: slugs.webhookEndpoints,
5556
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5123
5557
  id: endpoint.id,
5124
5558
  data: {
5125
5559
  lastTriggeredAt: timestamp,
@@ -5154,8 +5588,7 @@ function createProcessScheduledEndpoint(slugs) {
5154
5588
  const settings = await readSupportSettings(payload);
5155
5589
  const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5156
5590
  const results = { processed: 0, errors: 0 };
5157
- const scheduled = await payload.find({
5158
- collection: slugs.ticketMessages,
5591
+ const scheduled = await dbFind(payload, slugs.ticketMessages, {
5159
5592
  where: {
5160
5593
  and: [
5161
5594
  { scheduledAt: { less_than_equal: now.toISOString() } },
@@ -5175,8 +5608,7 @@ function createProcessScheduledEndpoint(slugs) {
5175
5608
  try {
5176
5609
  const msg = message;
5177
5610
  const ticketId = typeof msg.ticket === "object" ? msg.ticket.id : msg.ticket;
5178
- const ticket = await payload.findByID({
5179
- collection: slugs.tickets,
5611
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5180
5612
  id: ticketId,
5181
5613
  depth: 1,
5182
5614
  overrideAccess: true
@@ -5199,8 +5631,7 @@ function createProcessScheduledEndpoint(slugs) {
5199
5631
  subject: `Re: [${ticketNumber}] ${subject}`,
5200
5632
  html: `<p>Bonjour ${escapeHtml(client.firstName || "")},</p><p>Notre \xE9quipe a r\xE9pondu \xE0 votre ticket <strong>${escapeHtml(ticketNumber)}</strong>.</p><blockquote style="border-left:4px solid #2563eb;padding:12px;margin:16px 0;background:#f8fafc;">${escapeHtml(preview)}</blockquote><p><a href="${baseUrl}/support/tickets/${ticketId}">Consulter le ticket</a></p>`
5201
5633
  });
5202
- await payload.update({
5203
- collection: slugs.ticketMessages,
5634
+ await dbUpdate(payload, slugs.ticketMessages, {
5204
5635
  id: msg.id,
5205
5636
  data: {
5206
5637
  scheduledSent: true,
@@ -5210,16 +5641,14 @@ function createProcessScheduledEndpoint(slugs) {
5210
5641
  overrideAccess: true
5211
5642
  });
5212
5643
  } else {
5213
- await payload.update({
5214
- collection: slugs.ticketMessages,
5644
+ await dbUpdate(payload, slugs.ticketMessages, {
5215
5645
  id: msg.id,
5216
5646
  data: { scheduledSent: true },
5217
5647
  overrideAccess: true
5218
5648
  });
5219
5649
  }
5220
5650
  if (msg.authorType === "admin" && !msg.isInternal) {
5221
- await payload.update({
5222
- collection: slugs.tickets,
5651
+ await dbUpdate(payload, slugs.tickets, {
5223
5652
  id: ticketId,
5224
5653
  data: { status: "waiting_client" },
5225
5654
  overrideAccess: true
@@ -5251,6 +5680,208 @@ function createProcessScheduledEndpoint(slugs) {
5251
5680
  };
5252
5681
  }
5253
5682
 
5683
+ // src/endpoints/process-snooze.ts
5684
+ function createProcessSnoozeEndpoint(slugs) {
5685
+ return {
5686
+ path: "/support/process-snooze",
5687
+ method: "post",
5688
+ handler: async (req) => {
5689
+ const secret = req.headers.get("x-cron-secret");
5690
+ const expectedSecret = process.env.CRON_SECRET;
5691
+ if (!expectedSecret || secret !== expectedSecret) {
5692
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5693
+ }
5694
+ try {
5695
+ const payload = req.payload;
5696
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
5697
+ const results = { processed: 0, errors: 0 };
5698
+ const due = await dbFind(payload, slugs.tickets, {
5699
+ where: {
5700
+ and: [
5701
+ { snoozeUntil: { exists: true } },
5702
+ { snoozeUntil: { less_than_equal: nowIso } }
5703
+ ]
5704
+ },
5705
+ limit: 500,
5706
+ depth: 0,
5707
+ overrideAccess: true,
5708
+ select: { id: true, ticketNumber: true }
5709
+ });
5710
+ for (const ticket of due.docs) {
5711
+ try {
5712
+ await dbUpdate(payload, slugs.tickets, {
5713
+ id: ticket.id,
5714
+ data: { snoozeUntil: null },
5715
+ overrideAccess: true
5716
+ });
5717
+ try {
5718
+ await dbCreate(payload, slugs.ticketActivityLog, {
5719
+ data: {
5720
+ ticket: ticket.id,
5721
+ action: "snooze_expired",
5722
+ detail: "Snooze \xE9chu \u2014 ticket r\xE9activ\xE9",
5723
+ actorType: "system"
5724
+ },
5725
+ overrideAccess: true
5726
+ });
5727
+ } catch {
5728
+ }
5729
+ results.processed++;
5730
+ } catch (err) {
5731
+ console.error("[support] Failed to wake snoozed ticket:", err);
5732
+ results.errors++;
5733
+ }
5734
+ }
5735
+ return Response.json({ ok: true, ...results });
5736
+ } catch (err) {
5737
+ console.error("[process-snooze] Error:", err);
5738
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5739
+ }
5740
+ }
5741
+ };
5742
+ }
5743
+
5744
+ // src/endpoints/process-digests.ts
5745
+ function createProcessDigestsEndpoint(slugs) {
5746
+ return {
5747
+ path: "/support/process-digests",
5748
+ method: "post",
5749
+ handler: async (req) => {
5750
+ const secret = req.headers.get("x-cron-secret");
5751
+ if (!process.env.CRON_SECRET || secret !== process.env.CRON_SECRET) {
5752
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5753
+ }
5754
+ try {
5755
+ const payload = req.payload;
5756
+ let body = {};
5757
+ try {
5758
+ body = await req.json();
5759
+ } catch {
5760
+ }
5761
+ const frequency = body.frequency === "weekly" ? "weekly" : "daily";
5762
+ const settings = await readSupportSettings(payload);
5763
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5764
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
5765
+ const itemsByClient = /* @__PURE__ */ new Map();
5766
+ let page = 1;
5767
+ let hasMore = true;
5768
+ while (hasMore && page <= 50) {
5769
+ const batch = await dbFind(payload, slugs.notificationQueue, { limit: 200, page, depth: 0, overrideAccess: true, sort: "createdAt" });
5770
+ for (const it of batch.docs) {
5771
+ const raw = it;
5772
+ const clientId = typeof raw.client === "object" ? raw.client?.id : raw.client;
5773
+ if (clientId === void 0 || clientId === null) continue;
5774
+ const key = String(clientId);
5775
+ if (!itemsByClient.has(key)) itemsByClient.set(key, []);
5776
+ itemsByClient.get(key).push({ id: raw.id, title: raw.title, message: raw.message });
5777
+ }
5778
+ hasMore = batch.hasNextPage ?? false;
5779
+ page++;
5780
+ }
5781
+ const results = { clients: 0, items: 0, errors: 0 };
5782
+ for (const [clientId, items] of itemsByClient) {
5783
+ try {
5784
+ const client = await dbFindByID(payload, slugs.supportClients, { id: clientId, depth: 0, overrideAccess: true });
5785
+ if (!client || client.notificationFrequency !== frequency) continue;
5786
+ if (client.email) {
5787
+ const rows = items.map((i) => `<tr><td style="padding:8px 0;font-size:14px;color:#1e293b;border-bottom:1px solid #f1f5f9;"><strong>${escapeHtml(i.title || "")}</strong><br/><span style="color:#64748b;font-size:13px;">${escapeHtml(i.message || "")}</span></td></tr>`).join("");
5788
+ const html = emailWrapper("Recapitulatif de vos tickets", [
5789
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
5790
+ emailParagraph(`Voici le recapitulatif de l'activite sur vos tickets (${items.length} notification${items.length > 1 ? "s" : ""}) :`),
5791
+ `<table cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table>`,
5792
+ ...baseUrl ? [emailParagraph(`<a href="${baseUrl}/support/dashboard">Acceder a votre espace support</a>`)] : []
5793
+ ].join(""), { kind: "system" });
5794
+ await payload.sendEmail({ to: client.email, ...replyTo ? { replyTo } : {}, subject: `Recapitulatif support (${items.length})`, html });
5795
+ }
5796
+ for (const i of items) {
5797
+ try {
5798
+ await dbDelete(payload, slugs.notificationQueue, { id: i.id, overrideAccess: true });
5799
+ } catch {
5800
+ }
5801
+ }
5802
+ results.clients++;
5803
+ results.items += items.length;
5804
+ } catch (err) {
5805
+ console.error("[process-digests] client error:", err);
5806
+ results.errors++;
5807
+ }
5808
+ }
5809
+ return Response.json({ ok: true, frequency, ...results });
5810
+ } catch (err) {
5811
+ console.error("[process-digests] Error:", err);
5812
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5813
+ }
5814
+ }
5815
+ };
5816
+ }
5817
+ var PROVIDERS = ["whatsapp", "messenger"];
5818
+ function createChannelsWebhookEndpoint(slugs) {
5819
+ return {
5820
+ path: "/support/channels/webhook",
5821
+ method: "post",
5822
+ handler: async (req) => {
5823
+ const secret = req.headers.get("x-channel-secret");
5824
+ if (!process.env.CHANNELS_WEBHOOK_SECRET || secret !== process.env.CHANNELS_WEBHOOK_SECRET) {
5825
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5826
+ }
5827
+ let body;
5828
+ try {
5829
+ body = await req.json();
5830
+ } catch {
5831
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5832
+ }
5833
+ const provider = body.provider;
5834
+ const from = (body.from || "").trim();
5835
+ const text = (body.text || "").trim();
5836
+ if (!PROVIDERS.includes(provider) || !from || !text) {
5837
+ return Response.json({ error: "provider, from et text requis." }, { status: 400 });
5838
+ }
5839
+ try {
5840
+ const payload = req.payload;
5841
+ const email = `${from}@${provider}.channel`;
5842
+ const existing = await dbFind(payload, slugs.supportClients, { where: { email: { equals: email } }, limit: 1, depth: 0, overrideAccess: true });
5843
+ let clientId;
5844
+ if (existing.docs.length > 0) {
5845
+ clientId = existing.docs[0].id;
5846
+ } else {
5847
+ const created = await dbCreate(payload, slugs.supportClients, {
5848
+ data: { email, firstName: body.name || provider, lastName: from.slice(0, 40), company: provider, password: crypto3.randomBytes(16).toString("hex") },
5849
+ overrideAccess: true
5850
+ });
5851
+ clientId = created.id;
5852
+ }
5853
+ const open = await dbFind(payload, slugs.tickets, {
5854
+ where: { and: [{ client: { equals: clientId } }, { source: { equals: provider } }, { status: { in: ["open", "waiting_client"] } }] },
5855
+ sort: "-createdAt",
5856
+ limit: 1,
5857
+ depth: 0,
5858
+ overrideAccess: true
5859
+ });
5860
+ let ticketId;
5861
+ let createdTicket = false;
5862
+ if (open.docs.length > 0) {
5863
+ ticketId = open.docs[0].id;
5864
+ } else {
5865
+ const t = await dbCreate(payload, slugs.tickets, {
5866
+ data: { subject: text.slice(0, 80), client: clientId, status: "open", priority: "normal", source: provider },
5867
+ overrideAccess: true
5868
+ });
5869
+ ticketId = t.id;
5870
+ createdTicket = true;
5871
+ }
5872
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
5873
+ data: { ticket: ticketId, body: text, authorType: "client", authorClient: clientId },
5874
+ overrideAccess: true
5875
+ });
5876
+ return Response.json({ ok: true, ticketId, createdTicket, messageId: msg.id });
5877
+ } catch (err) {
5878
+ console.error("[channels] Error:", err);
5879
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5880
+ }
5881
+ }
5882
+ };
5883
+ }
5884
+
5254
5885
  // src/endpoints/user-prefs.ts
5255
5886
  var PREF_KEY_PREFIX = "support-user-prefs";
5256
5887
  var DEFAULT_USER_PREFS2 = {
@@ -5266,8 +5897,7 @@ function createUserPrefsGetEndpoint(slugs) {
5266
5897
  const payload = req.payload;
5267
5898
  requireAdmin(req, slugs);
5268
5899
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5269
- const prefs = await payload.find({
5270
- collection: "payload-preferences",
5900
+ const prefs = await dbFind(payload, "payload-preferences", {
5271
5901
  where: { key: { equals: key } },
5272
5902
  limit: 1,
5273
5903
  depth: 0,
@@ -5301,8 +5931,7 @@ function createUserPrefsPostEndpoint(slugs) {
5301
5931
  requireAdmin(req, slugs);
5302
5932
  const body = await req.json();
5303
5933
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5304
- const existing = await payload.find({
5305
- collection: "payload-preferences",
5934
+ const existing = await dbFind(payload, "payload-preferences", {
5306
5935
  where: { key: { equals: key } },
5307
5936
  limit: 1,
5308
5937
  depth: 0,
@@ -5363,8 +5992,7 @@ function createEscalateEndpoint(slugs) {
5363
5992
  if (Number.isNaN(ticketId)) {
5364
5993
  return Response.json({ error: "invalid-id" }, { status: 400 });
5365
5994
  }
5366
- const ticket = await req.payload.update({
5367
- collection: slugs.tickets,
5995
+ const ticket = await dbUpdate(req.payload, slugs.tickets, {
5368
5996
  id: ticketId,
5369
5997
  data: { status: "escalated" },
5370
5998
  overrideAccess: true
@@ -5412,8 +6040,7 @@ function createTicketFeedbackEndpoint(slugs) {
5412
6040
  { status: 400 }
5413
6041
  );
5414
6042
  }
5415
- const ticket = await payload.findByID({
5416
- collection: slugs.tickets,
6043
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5417
6044
  id: ticketId,
5418
6045
  depth: 0,
5419
6046
  overrideAccess: true
@@ -5425,8 +6052,7 @@ function createTicketFeedbackEndpoint(slugs) {
5425
6052
  if (ticketClientId !== req.user.id) {
5426
6053
  return Response.json({ error: "forbidden" }, { status: 403 });
5427
6054
  }
5428
- const existing = await payload.find({
5429
- collection: "ticket-feedback",
6055
+ const existing = await dbFind(payload, "ticket-feedback", {
5430
6056
  where: { ticket: { equals: ticketId } },
5431
6057
  limit: 1,
5432
6058
  depth: 0,
@@ -5438,8 +6064,7 @@ function createTicketFeedbackEndpoint(slugs) {
5438
6064
  { status: 409 }
5439
6065
  );
5440
6066
  }
5441
- const feedback = await payload.create({
5442
- collection: "ticket-feedback",
6067
+ const feedback = await dbCreate(payload, "ticket-feedback", {
5443
6068
  data: {
5444
6069
  ticket: ticketId,
5445
6070
  client: req.user.id,
@@ -5463,6 +6088,7 @@ function createTicketFeedbackEndpoint(slugs) {
5463
6088
  // src/endpoints/transfer-ticket.ts
5464
6089
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5465
6090
  var MAX_TRANSFERS_PER_DAY = 5;
6091
+ var transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY);
5466
6092
  function createTransferTicketEndpoint(slugs) {
5467
6093
  return {
5468
6094
  path: "/support/tickets/:id/transfer",
@@ -5487,8 +6113,7 @@ function createTransferTicketEndpoint(slugs) {
5487
6113
  const includeAttachments = body.includeAttachments !== false;
5488
6114
  const customMessage = typeof body.message === "string" ? body.message.slice(0, 1e3) : "";
5489
6115
  const payload = req.payload;
5490
- const ticket = await payload.findByID({
5491
- collection: slugs.tickets,
6116
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5492
6117
  id: ticketId,
5493
6118
  depth: 1,
5494
6119
  overrideAccess: true
@@ -5499,10 +6124,15 @@ function createTransferTicketEndpoint(slugs) {
5499
6124
  if (!isAdmin && !isOwner) {
5500
6125
  return Response.json({ error: "Forbidden" }, { status: 403 });
5501
6126
  }
6127
+ if (transferLimiter.check(`${req.user.id}:${ticketId}`)) {
6128
+ return Response.json(
6129
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6130
+ { status: 429 }
6131
+ );
6132
+ }
5502
6133
  try {
5503
6134
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
5504
- const existing = await payload.count({
5505
- collection: slugs.emailLogs,
6135
+ const existing = await dbCount(payload, slugs.emailLogs, {
5506
6136
  where: {
5507
6137
  and: [
5508
6138
  { action: { equals: "transfer" } },
@@ -5520,8 +6150,7 @@ function createTransferTicketEndpoint(slugs) {
5520
6150
  }
5521
6151
  } catch {
5522
6152
  }
5523
- const messages = await payload.find({
5524
- collection: slugs.ticketMessages,
6153
+ const messages = await dbFind(payload, slugs.ticketMessages, {
5525
6154
  where: {
5526
6155
  and: [
5527
6156
  { ticket: { equals: ticketId } },
@@ -5601,8 +6230,7 @@ function createTransferTicketEndpoint(slugs) {
5601
6230
  html
5602
6231
  });
5603
6232
  try {
5604
- await payload.create({
5605
- collection: slugs.emailLogs,
6233
+ await dbCreate(payload, slugs.emailLogs, {
5606
6234
  data: {
5607
6235
  status: "success",
5608
6236
  action: "transfer",
@@ -5642,6 +6270,8 @@ function statusToLabel(status) {
5642
6270
  }
5643
6271
  }
5644
6272
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6273
+ var MAX_COLLABORATORS_PER_TICKET = 20;
6274
+ var inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10);
5645
6275
  function createInviteCollaboratorEndpoint(slugs) {
5646
6276
  return {
5647
6277
  path: "/support/tickets/:id/invite",
@@ -5665,8 +6295,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5665
6295
  }
5666
6296
  const role = body.role === "collaborator" ? "collaborator" : "viewer";
5667
6297
  const payload = req.payload;
5668
- const ticket = await payload.findByID({
5669
- collection: slugs.tickets,
6298
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5670
6299
  id: ticketId,
5671
6300
  depth: 1,
5672
6301
  overrideAccess: true
@@ -5678,13 +6307,22 @@ function createInviteCollaboratorEndpoint(slugs) {
5678
6307
  if (!isAdmin && !isOwner) {
5679
6308
  return Response.json({ error: "Forbidden" }, { status: 403 });
5680
6309
  }
6310
+ if (inviteLimiter.check(String(req.user.id))) {
6311
+ return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6312
+ }
6313
+ const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
6314
+ if (collabCount.totalDocs >= MAX_COLLABORATORS_PER_TICKET) {
6315
+ return Response.json(
6316
+ { error: `Limite de ${MAX_COLLABORATORS_PER_TICKET} collaborateurs atteinte sur ce ticket` },
6317
+ { status: 429 }
6318
+ );
6319
+ }
5681
6320
  const ownerEmail = typeof ticket.client === "object" ? ticket.client?.email : void 0;
5682
6321
  if (ownerEmail && ownerEmail.toLowerCase() === email) {
5683
6322
  return Response.json({ error: "Ce client est d\xE9j\xE0 propri\xE9taire du ticket" }, { status: 400 });
5684
6323
  }
5685
6324
  let inviteeId = null;
5686
- const existing = await payload.find({
5687
- collection: slugs.supportClients,
6325
+ const existing = await dbFind(payload, slugs.supportClients, {
5688
6326
  where: { email: { equals: email } },
5689
6327
  limit: 1,
5690
6328
  depth: 0,
@@ -5694,8 +6332,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5694
6332
  inviteeId = existing.docs[0].id;
5695
6333
  } else {
5696
6334
  const tempPassword = crypto3.randomBytes(16).toString("hex");
5697
- const created = await payload.create({
5698
- collection: slugs.supportClients,
6335
+ const created = await dbCreate(payload, slugs.supportClients, {
5699
6336
  data: {
5700
6337
  email,
5701
6338
  firstName: "Invit\xE9",
@@ -5712,8 +6349,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5712
6349
  if (!inviteeId) {
5713
6350
  return Response.json({ error: "Impossible de cr\xE9er le client invit\xE9" }, { status: 500 });
5714
6351
  }
5715
- const dupe = await payload.find({
5716
- collection: "ticket-collaborators",
6352
+ const dupe = await dbFind(payload, "ticket-collaborators", {
5717
6353
  where: {
5718
6354
  and: [
5719
6355
  { ticket: { equals: ticketId } },
@@ -5726,15 +6362,13 @@ function createInviteCollaboratorEndpoint(slugs) {
5726
6362
  });
5727
6363
  const invitationToken = crypto3.randomBytes(24).toString("hex");
5728
6364
  if (dupe.docs.length > 0) {
5729
- await payload.update({
5730
- collection: "ticket-collaborators",
6365
+ await dbUpdate(payload, "ticket-collaborators", {
5731
6366
  id: dupe.docs[0].id,
5732
6367
  data: { role, invitationToken },
5733
6368
  overrideAccess: true
5734
6369
  });
5735
6370
  } else {
5736
- await payload.create({
5737
- collection: "ticket-collaborators",
6371
+ await dbCreate(payload, "ticket-collaborators", {
5738
6372
  data: {
5739
6373
  ticket: ticketId,
5740
6374
  client: inviteeId,
@@ -5777,8 +6411,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5777
6411
  )
5778
6412
  });
5779
6413
  try {
5780
- await payload.create({
5781
- collection: slugs.emailLogs,
6414
+ await dbCreate(payload, slugs.emailLogs, {
5782
6415
  data: {
5783
6416
  status: "success",
5784
6417
  action: "invite",
@@ -5831,6 +6464,7 @@ function createSupportEndpoints(slugs, options) {
5831
6464
  ];
5832
6465
  if (!f || f.ai !== false) {
5833
6466
  endpoints.push(createAiEndpoint(slugs));
6467
+ endpoints.push(createAiAgentEndpoint(slugs));
5834
6468
  endpoints.push(...createClientIntelligenceEndpoint(slugs));
5835
6469
  endpoints.push(createTicketSynthesisEndpoint(slugs));
5836
6470
  }
@@ -5845,7 +6479,10 @@ function createSupportEndpoints(slugs, options) {
5845
6479
  endpoints.push(createSignatureGetEndpoint(slugs), createSignaturePostEndpoint(slugs));
5846
6480
  }
5847
6481
  if (!f || f.sla !== false) endpoints.push(createSlaCheckEndpoint(slugs));
5848
- if (!f || f.autoClose !== false) endpoints.push(createAutoCloseEndpoint(slugs));
6482
+ if (!f || f.autoClose !== false) {
6483
+ endpoints.push(createAutoCloseEndpoint(slugs));
6484
+ endpoints.push(createSendReminderEndpoint(slugs));
6485
+ }
5849
6486
  if (!f || f.customStatuses !== false) endpoints.push(createStatusesEndpoint(slugs));
5850
6487
  if (!f || f.macros !== false) endpoints.push(createApplyMacroEndpoint(slugs));
5851
6488
  if (!f || f.roundRobin !== false) {
@@ -5858,21 +6495,26 @@ function createSupportEndpoints(slugs, options) {
5858
6495
  endpoints.push(createAdminChatGetEndpoint(slugs), createAdminChatPostEndpoint(slugs));
5859
6496
  endpoints.push(createAdminChatStreamEndpoint(slugs));
5860
6497
  }
5861
- if (!f || f.timeTracking !== false) endpoints.push(createBillingEndpoint(slugs));
6498
+ if (!f || f.timeTracking !== false) {
6499
+ endpoints.push(createBillingEndpoint(slugs));
6500
+ endpoints.push(createInvoiceEndpoint(slugs));
6501
+ }
5862
6502
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
5863
6503
  if (!f || f.emailTracking !== false) {
5864
6504
  endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
5865
6505
  }
5866
6506
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
5867
6507
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
6508
+ if (!f || f.snooze !== false) endpoints.push(createProcessSnoozeEndpoint(slugs));
6509
+ endpoints.push(createProcessDigestsEndpoint(slugs));
6510
+ endpoints.push(createChannelsWebhookEndpoint(slugs));
5868
6511
  return endpoints;
5869
6512
  }
5870
6513
 
5871
6514
  // src/utils/adminNotification.ts
5872
6515
  async function createAdminNotification(payload, data, collectionSlug = "admin-notifications") {
5873
6516
  try {
5874
- await payload.create({
5875
- collection: collectionSlug,
6517
+ await dbCreate(payload, collectionSlug, {
5876
6518
  data: {
5877
6519
  title: data.title,
5878
6520
  message: data.message,
@@ -5892,8 +6534,7 @@ function dispatchWebhook(data, event, payload, slugs) {
5892
6534
  }
5893
6535
  async function _dispatch(data, event, payload, slugs) {
5894
6536
  try {
5895
- const { docs: endpoints } = await payload.find({
5896
- collection: slugs.webhookEndpoints,
6537
+ const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
5897
6538
  where: {
5898
6539
  and: [
5899
6540
  { active: { equals: true } },
@@ -5930,8 +6571,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
5930
6571
  signal: AbortSignal.timeout(1e4)
5931
6572
  // 10s timeout
5932
6573
  });
5933
- await payload.update({
5934
- collection: slugs.webhookEndpoints,
6574
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5935
6575
  id: endpoint.id,
5936
6576
  data: {
5937
6577
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -5945,8 +6585,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
5945
6585
  } catch (err) {
5946
6586
  console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
5947
6587
  try {
5948
- await payload.update({
5949
- collection: slugs.webhookEndpoints,
6588
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5950
6589
  id: endpoint.id,
5951
6590
  data: {
5952
6591
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6180,8 +6819,7 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6180
6819
  if (typeof policyRef === "object" && policyRef.firstResponseTime) {
6181
6820
  return policyRef;
6182
6821
  }
6183
- return await payload.findByID({
6184
- collection: slugs.slaPolicies,
6822
+ return await dbFindByID(payload, slugs.slaPolicies, {
6185
6823
  id: policyId,
6186
6824
  depth: 0,
6187
6825
  overrideAccess: true
@@ -6192,8 +6830,7 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6192
6830
  }
6193
6831
  const priority = field(ticket, "priority") || "normal";
6194
6832
  try {
6195
- const defaults = await payload.find({
6196
- collection: slugs.slaPolicies,
6833
+ const defaults = await dbFind(payload, slugs.slaPolicies, {
6197
6834
  where: {
6198
6835
  and: [
6199
6836
  { isDefault: { equals: true } },
@@ -6230,8 +6867,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6230
6867
  if (typeof policyRef === "object" && policyRef.escalateOnBreach !== void 0) {
6231
6868
  policy = policyRef;
6232
6869
  } else {
6233
- policy = await payload.findByID({
6234
- collection: slugs.slaPolicies,
6870
+ policy = await dbFindByID(payload, slugs.slaPolicies, {
6235
6871
  id: policyId,
6236
6872
  depth: 0,
6237
6873
  overrideAccess: true
@@ -6244,8 +6880,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6244
6880
  try {
6245
6881
  const escalateToId = typeof policy.escalateTo === "object" ? policy.escalateTo.id : policy.escalateTo;
6246
6882
  if (!escalateToId) return;
6247
- const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await payload.findByID({
6248
- collection: slugs.users,
6883
+ const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await dbFindByID(payload, slugs.users, {
6249
6884
  id: escalateToId,
6250
6885
  depth: 0,
6251
6886
  overrideAccess: true
@@ -6293,8 +6928,7 @@ function createAssignSlaDeadlines(slugs, notificationSlug = "admin-notifications
6293
6928
  if (!field(doc, "slaPolicy") && policy.id) {
6294
6929
  updateData.slaPolicy = policy.id;
6295
6930
  }
6296
- await payload.update({
6297
- collection: slugs.tickets,
6931
+ await dbUpdate(payload, slugs.tickets, {
6298
6932
  id: doc.id,
6299
6933
  data: updateData,
6300
6934
  overrideAccess: true
@@ -6317,8 +6951,7 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6317
6951
  const now = /* @__PURE__ */ new Date();
6318
6952
  const deadline = new Date(doc.slaResolutionDue);
6319
6953
  const breached = now > deadline;
6320
- await payload.update({
6321
- collection: slugs.tickets,
6954
+ await dbUpdate(payload, slugs.tickets, {
6322
6955
  id: doc.id,
6323
6956
  data: { slaResolutionBreached: breached },
6324
6957
  overrideAccess: true
@@ -6332,6 +6965,35 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6332
6965
  return doc;
6333
6966
  };
6334
6967
  }
6968
+ function createPauseSlaOnHold(slugs) {
6969
+ return async ({ doc, previousDoc, operation, req }) => {
6970
+ if (operation !== "update" || !previousDoc) return doc;
6971
+ if (previousDoc.status === doc.status) return doc;
6972
+ if (!doc.slaResolutionDue) return doc;
6973
+ const wasWaiting = previousDoc.status === "waiting_client";
6974
+ const isWaiting = doc.status === "waiting_client";
6975
+ try {
6976
+ if (!wasWaiting && isWaiting && !doc.slaPausedAt && doc.status !== "resolved") {
6977
+ await req.payload.update({
6978
+ collection: slugs.tickets,
6979
+ id: doc.id,
6980
+ data: { slaPausedAt: (/* @__PURE__ */ new Date()).toISOString() },
6981
+ overrideAccess: true
6982
+ });
6983
+ } else if (wasWaiting && !isWaiting && doc.slaPausedAt) {
6984
+ const pausedMs = Date.now() - new Date(doc.slaPausedAt).getTime();
6985
+ const data = { slaPausedAt: null };
6986
+ if (pausedMs > 0) {
6987
+ data.slaResolutionDue = new Date(new Date(doc.slaResolutionDue).getTime() + pausedMs).toISOString();
6988
+ }
6989
+ await req.payload.update({ collection: slugs.tickets, id: doc.id, data, overrideAccess: true });
6990
+ }
6991
+ } catch (err) {
6992
+ console.error("[sla] Failed to pause/resume SLA on hold:", err);
6993
+ }
6994
+ return doc;
6995
+ };
6996
+ }
6335
6997
  function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications") {
6336
6998
  return async ({ doc, operation, req }) => {
6337
6999
  if (operation !== "create") return doc;
@@ -6339,8 +7001,7 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6339
7001
  try {
6340
7002
  const { payload } = req;
6341
7003
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
6342
- const ticket = await payload.findByID({
6343
- collection: slugs.tickets,
7004
+ const ticket = await dbFindByID(payload, slugs.tickets, {
6344
7005
  id: ticketId,
6345
7006
  depth: 1,
6346
7007
  overrideAccess: true
@@ -6351,8 +7012,7 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6351
7012
  const now = /* @__PURE__ */ new Date();
6352
7013
  const deadline = new Date(ticket.slaFirstResponseDue);
6353
7014
  const breached = now > deadline;
6354
- await payload.update({
6355
- collection: slugs.tickets,
7015
+ await dbUpdate(payload, slugs.tickets, {
6356
7016
  id: ticketId,
6357
7017
  data: { slaFirstResponseBreached: breached },
6358
7018
  overrideAccess: true
@@ -6367,6 +7027,108 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6367
7027
  };
6368
7028
  }
6369
7029
 
7030
+ // src/hooks/automationRules.ts
7031
+ function evaluateConditions(doc, rule) {
7032
+ const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
7033
+ if (conds.length === 0) return true;
7034
+ const results = conds.map((c) => {
7035
+ const fieldVal = String(doc[c.field] ?? "");
7036
+ const v = String(c.value ?? "");
7037
+ switch (c.operator) {
7038
+ case "equals":
7039
+ return fieldVal === v;
7040
+ case "not_equals":
7041
+ return fieldVal !== v;
7042
+ case "contains":
7043
+ return fieldVal.toLowerCase().includes(v.toLowerCase());
7044
+ default:
7045
+ return false;
7046
+ }
7047
+ });
7048
+ return rule.matchType === "any" ? results.some(Boolean) : results.every(Boolean);
7049
+ }
7050
+ function createApplyAutomationRules(slugs) {
7051
+ return async ({ doc, previousDoc, operation, req }) => {
7052
+ if (req.context?.skipAutomation) return doc;
7053
+ const events = [];
7054
+ if (operation === "create") events.push("ticket_created");
7055
+ if (operation === "update") {
7056
+ events.push("ticket_updated");
7057
+ if (previousDoc && previousDoc.status !== doc.status) events.push("ticket_status_changed");
7058
+ }
7059
+ if (events.length === 0) return doc;
7060
+ try {
7061
+ const rules = await dbFind(req.payload, slugs.automationRules, {
7062
+ where: { and: [{ enabled: { equals: true } }, { event: { in: events } }] },
7063
+ sort: "order",
7064
+ limit: 200,
7065
+ depth: 0,
7066
+ overrideAccess: true
7067
+ });
7068
+ const changes = {};
7069
+ const tags = Array.isArray(doc.tags) ? [...doc.tags] : [];
7070
+ let tagsTouched = false;
7071
+ for (const rule of rules.docs) {
7072
+ if (!evaluateConditions(doc, rule)) continue;
7073
+ for (const a of Array.isArray(rule.actions) ? rule.actions : []) {
7074
+ if (!a.value) continue;
7075
+ switch (a.type) {
7076
+ case "set_status":
7077
+ changes.status = a.value;
7078
+ break;
7079
+ case "set_priority":
7080
+ changes.priority = a.value;
7081
+ break;
7082
+ case "set_category":
7083
+ changes.category = a.value;
7084
+ break;
7085
+ case "assign":
7086
+ changes.assignedTo = a.value;
7087
+ break;
7088
+ case "add_tag":
7089
+ if (!tags.includes(a.value)) {
7090
+ tags.push(a.value);
7091
+ tagsTouched = true;
7092
+ }
7093
+ break;
7094
+ }
7095
+ }
7096
+ }
7097
+ if (tagsTouched) changes.tags = tags;
7098
+ if (Object.keys(changes).length > 0) {
7099
+ await req.payload.update({
7100
+ collection: slugs.tickets,
7101
+ id: doc.id,
7102
+ data: changes,
7103
+ overrideAccess: true,
7104
+ context: { skipAutomation: true }
7105
+ });
7106
+ }
7107
+ } catch (err) {
7108
+ console.error("[support] Failed to apply automation rules:", err);
7109
+ }
7110
+ return doc;
7111
+ };
7112
+ }
7113
+
7114
+ // src/utils/teamAccess.ts
7115
+ function isTeamScopingEnabled() {
7116
+ return process.env.SUPPORT_TEAM_SCOPING === "1" || process.env.SUPPORT_TEAM_SCOPING === "true";
7117
+ }
7118
+ async function resolveAgentTeamIds(payload, slugs, userId) {
7119
+ try {
7120
+ const teams = await dbFind(payload, slugs.supportTeams, {
7121
+ where: { members: { in: [userId] } },
7122
+ limit: 200,
7123
+ depth: 0,
7124
+ overrideAccess: true
7125
+ });
7126
+ return teams.docs.map((t) => t.id);
7127
+ } catch {
7128
+ return [];
7129
+ }
7130
+ }
7131
+
6370
7132
  // src/collections/Tickets.ts
6371
7133
  function createAssignTicketNumber(slugs) {
6372
7134
  return async ({ data, operation, req }) => {
@@ -6629,56 +7391,6 @@ function createNotifyClientOnResolve(slugs) {
6629
7391
  return doc;
6630
7392
  };
6631
7393
  }
6632
- function createAutoCalculateSLA(slugs) {
6633
- return async ({ doc, operation, req }) => {
6634
- if (operation !== "create") return doc;
6635
- try {
6636
- const { payload } = req;
6637
- const ticketPriority = doc.priority || "normal";
6638
- let policy = null;
6639
- const byPriority = await payload.find({
6640
- collection: slugs.slaPolicies,
6641
- where: { priority: { equals: ticketPriority } },
6642
- limit: 1,
6643
- depth: 0,
6644
- overrideAccess: true
6645
- });
6646
- if (byPriority.docs.length > 0) {
6647
- policy = byPriority.docs[0];
6648
- } else {
6649
- const defaults = await payload.find({
6650
- collection: slugs.slaPolicies,
6651
- where: { isDefault: { equals: true } },
6652
- limit: 1,
6653
- depth: 0,
6654
- overrideAccess: true
6655
- });
6656
- if (defaults.docs.length > 0) {
6657
- policy = defaults.docs[0];
6658
- }
6659
- }
6660
- if (!policy) return doc;
6661
- const now = /* @__PURE__ */ new Date();
6662
- const firstResponseMinutes = policy.firstResponseTime || 240;
6663
- const resolutionMinutes = policy.resolutionTime || 1440;
6664
- const firstResponseDue = new Date(now.getTime() + firstResponseMinutes * 6e4);
6665
- const resolutionDue = new Date(now.getTime() + resolutionMinutes * 6e4);
6666
- await payload.update({
6667
- collection: slugs.tickets,
6668
- id: doc.id,
6669
- data: {
6670
- slaPolicy: policy.id,
6671
- slaFirstResponseDue: firstResponseDue.toISOString(),
6672
- slaResolutionDue: resolutionDue.toISOString()
6673
- },
6674
- overrideAccess: true
6675
- });
6676
- } catch (err) {
6677
- console.error("[support] Failed to auto-calculate SLA:", err);
6678
- }
6679
- return doc;
6680
- };
6681
- }
6682
7394
  function createFireTicketWebhooks(slugs) {
6683
7395
  return async ({ doc, previousDoc, operation, req }) => {
6684
7396
  const { payload } = req;
@@ -6770,8 +7482,7 @@ function createCascadeDelete(slugs) {
6770
7482
  return async ({ id, req }) => {
6771
7483
  const collections = [slugs.ticketMessages, slugs.ticketActivityLog, slugs.timeEntries, slugs.satisfactionSurveys];
6772
7484
  for (const slug of collections) {
6773
- await req.payload.delete({
6774
- collection: slug,
7485
+ await dbDelete(req.payload, slug, {
6775
7486
  where: { ticket: { equals: id } },
6776
7487
  overrideAccess: true
6777
7488
  });
@@ -6896,6 +7607,7 @@ function createTicketsCollection(slugs, options) {
6896
7607
  {
6897
7608
  name: "status",
6898
7609
  type: "select",
7610
+ index: true,
6899
7611
  defaultValue: "open",
6900
7612
  label: "Statut",
6901
7613
  options: [
@@ -6910,6 +7622,7 @@ function createTicketsCollection(slugs, options) {
6910
7622
  {
6911
7623
  name: "priority",
6912
7624
  type: "select",
7625
+ index: true,
6913
7626
  defaultValue: "normal",
6914
7627
  label: "Priorite",
6915
7628
  options: [
@@ -6925,7 +7638,7 @@ function createTicketsCollection(slugs, options) {
6925
7638
  {
6926
7639
  type: "row",
6927
7640
  fields: [
6928
- { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, label: "Client", admin: { width: "50%" } }
7641
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client", admin: { width: "50%" } }
6929
7642
  ]
6930
7643
  },
6931
7644
  // Billing collapsible
@@ -6994,15 +7707,16 @@ function createTicketsCollection(slugs, options) {
6994
7707
  {
6995
7708
  type: "row",
6996
7709
  fields: [
6997
- { name: "slaFirstResponseDue", type: "date", label: "Echeance 1ere reponse (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
6998
- { name: "slaResolutionDue", type: "date", label: "Echeance resolution (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
7710
+ { name: "slaFirstResponseDue", type: "date", index: true, label: "Echeance 1ere reponse (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7711
+ { name: "slaResolutionDue", type: "date", index: true, label: "Echeance resolution (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
6999
7712
  ]
7000
7713
  },
7001
7714
  {
7002
7715
  type: "row",
7003
7716
  fields: [
7004
7717
  { name: "slaFirstResponseBreached", type: "checkbox", label: "1ere reponse depassee", admin: { readOnly: true, width: "50%" } },
7005
- { name: "slaResolutionBreached", type: "checkbox", label: "Resolution depassee", admin: { readOnly: true, width: "50%" } }
7718
+ { name: "slaResolutionBreached", type: "checkbox", label: "Resolution depassee", admin: { readOnly: true, width: "50%" } },
7719
+ { name: "slaPausedAt", type: "date", label: "SLA en pause depuis", admin: { hidden: true } }
7006
7720
  ]
7007
7721
  }
7008
7722
  ]
@@ -7023,7 +7737,8 @@ function createTicketsCollection(slugs, options) {
7023
7737
  ]
7024
7738
  },
7025
7739
  { name: "mergedInto", type: "relationship", relationTo: slugs.tickets, label: "Fusionne dans", admin: { readOnly: true } },
7026
- { name: "autoCloseRemindedAt", type: "date", label: "Rappel auto-close envoye", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
7740
+ { name: "autoCloseRemindedAt", type: "date", label: "Rappel auto-close envoye", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7741
+ { name: "autoCloseScheduledAt", type: "date", index: true, label: "Fermeture auto programmee", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" }, description: "Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client." } }
7027
7742
  ]
7028
7743
  },
7029
7744
  // Sidebar
@@ -7032,6 +7747,7 @@ function createTicketsCollection(slugs, options) {
7032
7747
  {
7033
7748
  name: "category",
7034
7749
  type: "select",
7750
+ index: true,
7035
7751
  label: "Categorie",
7036
7752
  options: [
7037
7753
  { label: "Bug / Dysfonctionnement", value: "bug" },
@@ -7042,7 +7758,8 @@ function createTicketsCollection(slugs, options) {
7042
7758
  ],
7043
7759
  admin: { position: "sidebar" }
7044
7760
  },
7045
- { name: "assignedTo", type: "relationship", relationTo: slugs.users, label: "Assigne a", admin: { position: "sidebar" } },
7761
+ { name: "assignedTo", type: "relationship", relationTo: slugs.users, index: true, label: "Assigne a", admin: { position: "sidebar" } },
7762
+ { name: "team", type: "relationship", relationTo: slugs.supportTeams, index: true, label: "Equipe", admin: { position: "sidebar" } },
7046
7763
  {
7047
7764
  name: "source",
7048
7765
  type: "select",
@@ -7052,7 +7769,9 @@ function createTicketsCollection(slugs, options) {
7052
7769
  { label: "Portail client", value: "portal" },
7053
7770
  { label: "Chat en direct", value: "live-chat" },
7054
7771
  { label: "Email", value: "email" },
7055
- { label: "Admin", value: "admin" }
7772
+ { label: "Admin", value: "admin" },
7773
+ { label: "WhatsApp", value: "whatsapp" },
7774
+ { label: "Messenger", value: "messenger" }
7056
7775
  ],
7057
7776
  admin: { position: "sidebar" }
7058
7777
  },
@@ -7103,8 +7822,8 @@ function createTicketsCollection(slugs, options) {
7103
7822
  afterChange: [
7104
7823
  createTrackSLA(slugs),
7105
7824
  createTrackAiSummaryOnResolve(slugs),
7106
- createAutoCalculateSLA(slugs),
7107
7825
  createAssignSlaDeadlines(slugs, notificationSlug),
7826
+ createPauseSlaOnHold(slugs),
7108
7827
  createCheckSlaOnResolve(slugs, notificationSlug),
7109
7828
  createLogTicketActivity(slugs),
7110
7829
  createNotifyOnAssignment(slugs),
@@ -7112,7 +7831,8 @@ function createTicketsCollection(slugs, options) {
7112
7831
  createTicketStatusEmail(slugs),
7113
7832
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
7114
7833
  createFireTicketWebhooks(slugs),
7115
- createDispatchWebhookOnTicket(slugs)
7834
+ createDispatchWebhookOnTicket(slugs),
7835
+ createApplyAutomationRules(slugs)
7116
7836
  ],
7117
7837
  beforeDelete: [createCascadeDelete(slugs)]
7118
7838
  },
@@ -7123,12 +7843,20 @@ function createTicketsCollection(slugs, options) {
7123
7843
  return false;
7124
7844
  },
7125
7845
  read: async ({ req }) => {
7126
- if (req.user?.collection === slugs.users) return true;
7846
+ if (req.user?.collection === slugs.users) {
7847
+ if (!isTeamScopingEnabled()) return true;
7848
+ const teamIds = await resolveAgentTeamIds(req.payload, slugs, req.user.id);
7849
+ return {
7850
+ or: [
7851
+ { team: { in: teamIds.length > 0 ? teamIds : [-1] } },
7852
+ { team: { exists: false } }
7853
+ ]
7854
+ };
7855
+ }
7127
7856
  if (req.user?.collection === slugs.supportClients) {
7128
7857
  let collabTicketIds = [];
7129
7858
  try {
7130
- const rows = await req.payload.find({
7131
- collection: "ticket-collaborators",
7859
+ const rows = await dbFind(req.payload, "ticket-collaborators", {
7132
7860
  where: { client: { equals: req.user.id } },
7133
7861
  limit: 1e3,
7134
7862
  depth: 0,
@@ -7163,6 +7891,125 @@ function createTicketsCollection(slugs, options) {
7163
7891
  };
7164
7892
  }
7165
7893
 
7894
+ // src/utils/ticketAccess.ts
7895
+ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
7896
+ const ids = /* @__PURE__ */ new Set();
7897
+ try {
7898
+ const owned = await dbFind(payload, slugs.tickets, {
7899
+ where: { client: { equals: clientId } },
7900
+ limit: 5e3,
7901
+ depth: 0,
7902
+ overrideAccess: true
7903
+ });
7904
+ for (const t of owned.docs) ids.add(t.id);
7905
+ } catch {
7906
+ }
7907
+ try {
7908
+ const collab = await dbFind(payload, "ticket-collaborators", {
7909
+ where: { client: { equals: clientId } },
7910
+ limit: 1e3,
7911
+ depth: 0,
7912
+ overrideAccess: true
7913
+ });
7914
+ for (const r of collab.docs) {
7915
+ const row = r;
7916
+ const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
7917
+ if (tid !== void 0 && tid !== null) ids.add(tid);
7918
+ }
7919
+ } catch {
7920
+ }
7921
+ return Array.from(ids);
7922
+ }
7923
+ var OPTIONS = {
7924
+ allowedTags: [
7925
+ "p",
7926
+ "br",
7927
+ "b",
7928
+ "strong",
7929
+ "i",
7930
+ "em",
7931
+ "u",
7932
+ "s",
7933
+ "strike",
7934
+ "del",
7935
+ "ins",
7936
+ "mark",
7937
+ "a",
7938
+ "ul",
7939
+ "ol",
7940
+ "li",
7941
+ "blockquote",
7942
+ "code",
7943
+ "pre",
7944
+ "kbd",
7945
+ "span",
7946
+ "div",
7947
+ "h1",
7948
+ "h2",
7949
+ "h3",
7950
+ "h4",
7951
+ "h5",
7952
+ "h6",
7953
+ "hr",
7954
+ "img",
7955
+ "table",
7956
+ "thead",
7957
+ "tbody",
7958
+ "tr",
7959
+ "th",
7960
+ "td"
7961
+ ],
7962
+ allowedAttributes: {
7963
+ a: ["href", "title", "target", "rel"],
7964
+ img: ["src", "alt", "title", "width", "height"],
7965
+ code: ["class"],
7966
+ pre: ["class"],
7967
+ span: ["class"],
7968
+ "*": ["style"]
7969
+ },
7970
+ allowedSchemes: ["http", "https", "mailto", "tel"],
7971
+ allowedSchemesByTag: { img: ["http", "https", "data"] },
7972
+ allowProtocolRelative: false,
7973
+ disallowedTagsMode: "discard",
7974
+ // Force safe rel on links opening a new tab (defense against tabnabbing).
7975
+ transformTags: {
7976
+ a: sanitizeHtml__default.default.simpleTransform("a", { rel: "noopener noreferrer nofollow" }, true)
7977
+ },
7978
+ // Only allow CSS that cannot carry a script payload.
7979
+ allowedStyles: {
7980
+ "*": {
7981
+ color: [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
7982
+ "background-color": [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
7983
+ "text-align": [/^(left|right|center|justify)$/],
7984
+ "font-weight": [/^(normal|bold|[1-9]00)$/],
7985
+ "font-style": [/^(normal|italic)$/],
7986
+ "text-decoration": [/^(underline|line-through|none)$/]
7987
+ }
7988
+ }
7989
+ };
7990
+ function sanitizeMessageHtml(html) {
7991
+ if (!html) return html;
7992
+ return sanitizeHtml__default.default(html, OPTIONS);
7993
+ }
7994
+
7995
+ // src/utils/notificationQueue.ts
7996
+ async function queueClientNotification(payload, slugs, item) {
7997
+ try {
7998
+ await dbCreate(payload, slugs.notificationQueue, {
7999
+ data: {
8000
+ client: item.client,
8001
+ type: item.type,
8002
+ title: item.title,
8003
+ message: item.message,
8004
+ ...item.ticket ? { ticket: item.ticket } : {}
8005
+ },
8006
+ overrideAccess: true
8007
+ });
8008
+ } catch (err) {
8009
+ console.error("[support] Failed to queue client notification:", err);
8010
+ }
8011
+ }
8012
+
7166
8013
  // src/collections/TicketMessages.ts
7167
8014
  function createAssignAuthor(slugs) {
7168
8015
  return async ({ data, operation, req }) => {
@@ -7186,8 +8033,12 @@ function createAutoUpdateStatus(slugs) {
7186
8033
  if (!doc.isInternal) {
7187
8034
  if (doc.authorType === "admin") {
7188
8035
  updateData.status = "waiting_client";
8036
+ updateData.autoCloseScheduledAt = null;
8037
+ updateData.autoCloseRemindedAt = null;
7189
8038
  } else if (doc.authorType === "client" || doc.authorType === "email") {
7190
8039
  updateData.lastClientMessageAt = (/* @__PURE__ */ new Date()).toISOString();
8040
+ updateData.autoCloseScheduledAt = null;
8041
+ updateData.autoCloseRemindedAt = null;
7191
8042
  if (ticket.status && ["waiting_client", "resolved"].includes(ticket.status)) {
7192
8043
  updateData.status = "open";
7193
8044
  }
@@ -7221,29 +8072,46 @@ function createNotifyClient(slugs) {
7221
8072
  const internalBcc = process.env.SUPPORT_CLIENT_BCC || void 0;
7222
8073
  const rawContent = doc.bodyHtml ? emailRichContent(doc.bodyHtml) : emailQuote(doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body);
7223
8074
  const replyPreview = (doc.body || "").replace(/\s+/g, " ").trim().slice(0, 90);
7224
- await req.payload.sendEmail({
7225
- to: client.email,
7226
- ...supportEmail ? { replyTo: supportEmail } : {},
7227
- ...internalBcc ? { bcc: internalBcc } : {},
7228
- subject: `Re: [${ticketNumber}] ${subject}`,
7229
- html: emailWrapper(`Nouvelle reponse \u2014 ${ticketNumber}`, [
7230
- emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
7231
- emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`),
7232
- rawContent,
7233
- emailButton("Consulter le ticket", portalUrl),
7234
- emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
7235
- emailTrackingPixel(ticketId, doc.id)
7236
- ].join(""), {
7237
- kind: "reply",
7238
- preheader: replyPreview ? `${replyPreview}\u2026` : `Nouvelle reponse a votre ticket ${ticketNumber}`
7239
- })
7240
- });
7241
- await req.payload.update({
7242
- collection: slugs.ticketMessages,
7243
- id: doc.id,
7244
- data: { emailSentAt: (/* @__PURE__ */ new Date()).toISOString(), emailSentTo: client.email },
7245
- overrideAccess: true
7246
- });
8075
+ const freq = client.notificationFrequency;
8076
+ if (freq === "daily" || freq === "weekly") {
8077
+ await queueClientNotification(req.payload, slugs, {
8078
+ client: client.id,
8079
+ type: "reply",
8080
+ title: `Nouvelle reponse \u2014 ${ticketNumber}`,
8081
+ message: replyPreview || `Reponse a votre ticket ${ticketNumber}`,
8082
+ ticket: ticketId
8083
+ });
8084
+ return doc;
8085
+ }
8086
+ void (async () => {
8087
+ try {
8088
+ await req.payload.sendEmail({
8089
+ to: client.email,
8090
+ ...supportEmail ? { replyTo: supportEmail } : {},
8091
+ ...internalBcc ? { bcc: internalBcc } : {},
8092
+ subject: `Re: [${ticketNumber}] ${subject}`,
8093
+ html: emailWrapper(`Nouvelle reponse \u2014 ${ticketNumber}`, [
8094
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
8095
+ emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`),
8096
+ rawContent,
8097
+ emailButton("Consulter le ticket", portalUrl),
8098
+ emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
8099
+ emailTrackingPixel(ticketId, doc.id)
8100
+ ].join(""), {
8101
+ kind: "reply",
8102
+ preheader: replyPreview ? `${replyPreview}\u2026` : `Nouvelle reponse a votre ticket ${ticketNumber}`
8103
+ })
8104
+ });
8105
+ await req.payload.update({
8106
+ collection: slugs.ticketMessages,
8107
+ id: doc.id,
8108
+ data: { emailSentAt: (/* @__PURE__ */ new Date()).toISOString(), emailSentTo: client.email },
8109
+ overrideAccess: true
8110
+ });
8111
+ } catch (err) {
8112
+ console.error("[support] Failed to notify client (async):", err);
8113
+ }
8114
+ })();
7247
8115
  } catch (err) {
7248
8116
  console.error("[support] Failed to notify client:", err);
7249
8117
  }
@@ -7281,8 +8149,7 @@ function createSyncTicketReplyToChat(slugs) {
7281
8149
  if (!ticket?.chatSession) return doc;
7282
8150
  if (doc.skipNotification) return doc;
7283
8151
  const clientId = typeof ticket.client === "object" ? ticket.client.id : ticket.client;
7284
- await payload.create({
7285
- collection: slugs.chatMessages,
8152
+ await dbCreate(payload, slugs.chatMessages, {
7286
8153
  data: {
7287
8154
  session: ticket.chatSession,
7288
8155
  client: clientId,
@@ -7399,6 +8266,57 @@ function createDispatchWebhookOnReply(slugs) {
7399
8266
  return doc;
7400
8267
  };
7401
8268
  }
8269
+ function createSanitizeMessageHtml() {
8270
+ return ({ data }) => {
8271
+ if (data && typeof data.bodyHtml === "string" && data.bodyHtml) {
8272
+ data.bodyHtml = sanitizeMessageHtml(data.bodyHtml);
8273
+ }
8274
+ return data;
8275
+ };
8276
+ }
8277
+ var MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
8278
+ function createResolveMentions(slugs) {
8279
+ return async ({ data, operation, req }) => {
8280
+ if (operation !== "create") return data;
8281
+ const body = typeof data?.body === "string" ? data.body : "";
8282
+ const emails = Array.from(new Set([...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase())));
8283
+ if (emails.length === 0) return data;
8284
+ try {
8285
+ const users = await dbFind(req.payload, slugs.users, { where: { email: { in: emails } }, limit: 50, depth: 0, overrideAccess: true });
8286
+ const ids = users.docs.map((u) => u.id);
8287
+ if (ids.length > 0) data.mentions = ids;
8288
+ } catch {
8289
+ }
8290
+ return data;
8291
+ };
8292
+ }
8293
+ function createNotifyMentions(slugs) {
8294
+ return async ({ doc, operation, req }) => {
8295
+ if (operation !== "create") return doc;
8296
+ const mentions = Array.isArray(doc.mentions) ? doc.mentions : [];
8297
+ if (mentions.length === 0) return doc;
8298
+ const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
8299
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
8300
+ for (const m of mentions) {
8301
+ const userId = typeof m === "object" ? m.id : m;
8302
+ try {
8303
+ const user = await dbFindByID(req.payload, slugs.users, { id: userId, depth: 0, overrideAccess: true });
8304
+ if (!user?.email) continue;
8305
+ await req.payload.sendEmail({
8306
+ to: user.email,
8307
+ subject: `Vous avez ete mentionne \u2014 ticket #${ticketId}`,
8308
+ html: emailWrapper("Mention", [
8309
+ emailParagraph(`Vous avez ete mentionne dans un message du ticket <strong>#${escapeHtml(String(ticketId))}</strong>.`),
8310
+ ...baseUrl ? [emailButton("Ouvrir le ticket", `${baseUrl}/admin/collections/${slugs.tickets}/${ticketId}`, "dark")] : []
8311
+ ].join(""), { kind: "system" })
8312
+ });
8313
+ } catch (err) {
8314
+ console.error("[support] Failed to notify mention:", err);
8315
+ }
8316
+ }
8317
+ return doc;
8318
+ };
8319
+ }
7402
8320
  function createTicketMessagesCollection(slugs, options) {
7403
8321
  const notificationSlug = options?.notificationSlug || "admin-notifications";
7404
8322
  return {
@@ -7406,7 +8324,7 @@ function createTicketMessagesCollection(slugs, options) {
7406
8324
  labels: { singular: "Message", plural: "Messages" },
7407
8325
  admin: { hidden: true, group: "Support", defaultColumns: ["ticket", "authorType", "createdAt"] },
7408
8326
  fields: [
7409
- { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, label: "Ticket" },
8327
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, index: true, label: "Ticket" },
7410
8328
  { name: "body", type: "textarea", required: true, label: "Message" },
7411
8329
  { name: "bodyHtml", type: "textarea", label: "Message HTML", admin: { hidden: true } },
7412
8330
  {
@@ -7415,6 +8333,7 @@ function createTicketMessagesCollection(slugs, options) {
7415
8333
  {
7416
8334
  name: "authorType",
7417
8335
  type: "select",
8336
+ index: true,
7418
8337
  label: "Type d'auteur",
7419
8338
  defaultValue: "admin",
7420
8339
  options: [
@@ -7450,8 +8369,9 @@ function createTicketMessagesCollection(slugs, options) {
7450
8369
  },
7451
8370
  { name: "isInternal", type: "checkbox", defaultValue: false, label: "Note interne", admin: { position: "sidebar" } },
7452
8371
  { name: "isSolution", type: "checkbox", defaultValue: false, label: "Reponse solution", admin: { position: "sidebar" } },
8372
+ { name: "mentions", type: "relationship", relationTo: slugs.users, hasMany: true, label: "Agents mentionnes", admin: { hidden: true } },
7453
8373
  { name: "skipNotification", type: "checkbox", defaultValue: false, label: "Sans notification", admin: { position: "sidebar", condition: (data) => data?.skipNotification === true } },
7454
- { name: "scheduledAt", type: "date", label: "Programme pour", admin: { date: { pickerAppearance: "dayAndTime" }, position: "sidebar", condition: (data) => !!data?.scheduledAt } },
8374
+ { name: "scheduledAt", type: "date", index: true, label: "Programme pour", admin: { date: { pickerAppearance: "dayAndTime" }, position: "sidebar", condition: (data) => !!data?.scheduledAt } },
7455
8375
  { name: "scheduledSent", type: "checkbox", defaultValue: false, admin: { hidden: true } },
7456
8376
  { name: "editedAt", type: "date", label: "Modifie le", admin: { hidden: true } },
7457
8377
  { name: "deletedAt", type: "date", label: "Supprime le", admin: { hidden: true } },
@@ -7460,7 +8380,7 @@ function createTicketMessagesCollection(slugs, options) {
7460
8380
  { name: "emailOpenedAt", type: "date", label: "Email ouvert le", admin: { hidden: true } }
7461
8381
  ],
7462
8382
  hooks: {
7463
- beforeChange: [createAssignAuthor(slugs)],
8383
+ beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
7464
8384
  afterChange: [
7465
8385
  createAutoUpdateStatus(slugs),
7466
8386
  createNotifyClient(slugs),
@@ -7469,17 +8389,19 @@ function createTicketMessagesCollection(slugs, options) {
7469
8389
  createSyncTicketReplyToChat(slugs),
7470
8390
  createNotifyAdminOnClientMessage(slugs, notificationSlug),
7471
8391
  createFireMessageWebhooks(slugs),
7472
- createDispatchWebhookOnReply(slugs)
8392
+ createDispatchWebhookOnReply(slugs),
8393
+ createNotifyMentions(slugs)
7473
8394
  ]
7474
8395
  },
7475
8396
  access: {
7476
8397
  create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
7477
- read: ({ req }) => {
8398
+ read: async ({ req }) => {
7478
8399
  if (req.user?.collection === slugs.users) return true;
7479
8400
  if (req.user?.collection === slugs.supportClients) {
8401
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
7480
8402
  return {
7481
8403
  and: [
7482
- { "ticket.client": { equals: req.user.id } },
8404
+ { ticket: { in: ids.length > 0 ? ids : [-1] } },
7483
8405
  { isInternal: { equals: false } },
7484
8406
  { or: [{ scheduledAt: { exists: false } }, { scheduledSent: { equals: true } }] }
7485
8407
  ]
@@ -7499,8 +8421,6 @@ function createTicketMessagesCollection(slugs, options) {
7499
8421
  timestamps: true
7500
8422
  };
7501
8423
  }
7502
-
7503
- // src/collections/SupportClients.ts
7504
8424
  function createSendInvitationOnCreate(slugs) {
7505
8425
  return async ({ doc, operation, req }) => {
7506
8426
  if (operation !== "create") return doc;
@@ -7540,6 +8460,26 @@ function createSendInvitationOnCreate(slugs) {
7540
8460
  return doc;
7541
8461
  };
7542
8462
  }
8463
+ var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
8464
+ function createEnforce2FA(slugs) {
8465
+ return async ({ req, user }) => {
8466
+ if (!user?.twoFactorEnabled) return user;
8467
+ const raw = user.twoFactorVerifiedAt;
8468
+ const verifiedAt = raw ? new Date(raw).getTime() : 0;
8469
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
8470
+ throw new payload.APIError("2FA_REQUIRED", 401);
8471
+ }
8472
+ await req.payload.update({
8473
+ collection: slugs.supportClients,
8474
+ id: user.id,
8475
+ data: { twoFactorVerifiedAt: null },
8476
+ req,
8477
+ overrideAccess: true
8478
+ });
8479
+ user.twoFactorVerifiedAt = null;
8480
+ return user;
8481
+ };
8482
+ }
7543
8483
  function createSupportClientsCollection(slugs) {
7544
8484
  return {
7545
8485
  slug: slugs.supportClients,
@@ -7668,6 +8608,10 @@ function createSupportClientsCollection(slugs) {
7668
8608
  name: "opportunities",
7669
8609
  type: "textarea",
7670
8610
  label: "Opportunites commerciales",
8611
+ access: {
8612
+ // Admin-only: never serialize commercial notes onto the client's own doc.
8613
+ read: ({ req }) => req.user?.collection === slugs.users
8614
+ },
7671
8615
  admin: {
7672
8616
  description: "Notes commerciales (visible uniquement par les admins)"
7673
8617
  }
@@ -7711,6 +8655,19 @@ function createSupportClientsCollection(slugs) {
7711
8655
  type: "date",
7712
8656
  admin: { hidden: true }
7713
8657
  },
8658
+ {
8659
+ // Set by /support/2fa "verify"; gates login via the beforeLogin hook.
8660
+ name: "twoFactorVerifiedAt",
8661
+ type: "date",
8662
+ admin: { hidden: true }
8663
+ },
8664
+ {
8665
+ // Google OAuth subject id — accounts are linked by this, not email alone.
8666
+ name: "googleId",
8667
+ type: "text",
8668
+ index: true,
8669
+ admin: { hidden: true }
8670
+ },
7714
8671
  {
7715
8672
  name: "notifyOnReply",
7716
8673
  type: "checkbox",
@@ -7777,6 +8734,10 @@ function createSupportClientsCollection(slugs) {
7777
8734
  name: "notes",
7778
8735
  type: "textarea",
7779
8736
  label: "Notes internes",
8737
+ access: {
8738
+ // Admin-only: never serialize internal notes onto the client's own doc.
8739
+ read: ({ req }) => req.user?.collection === slugs.users
8740
+ },
7780
8741
  admin: {
7781
8742
  description: "Visible uniquement par les admins",
7782
8743
  position: "sidebar"
@@ -7784,6 +8745,7 @@ function createSupportClientsCollection(slugs) {
7784
8745
  }
7785
8746
  ],
7786
8747
  hooks: {
8748
+ beforeLogin: [createEnforce2FA(slugs)],
7787
8749
  afterChange: [createSendInvitationOnCreate(slugs)]
7788
8750
  },
7789
8751
  access: {
@@ -7997,6 +8959,7 @@ function createTicketActivityLogCollection(slugs) {
7997
8959
  type: "relationship",
7998
8960
  relationTo: slugs.tickets,
7999
8961
  required: true,
8962
+ index: true,
8000
8963
  label: "Ticket"
8001
8964
  },
8002
8965
  {
@@ -8042,10 +9005,11 @@ function createTicketActivityLogCollection(slugs) {
8042
9005
  access: {
8043
9006
  create: () => false,
8044
9007
  // Created only by hooks via overrideAccess
8045
- read: ({ req }) => {
9008
+ read: async ({ req }) => {
8046
9009
  if (req.user?.collection === slugs.users) return true;
8047
9010
  if (req.user?.collection === slugs.supportClients) {
8048
- return { "ticket.client": { equals: req.user.id } };
9011
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
9012
+ return { ticket: { in: ids.length > 0 ? ids : [-1] } };
8049
9013
  }
8050
9014
  return false;
8051
9015
  },
@@ -8114,12 +9078,21 @@ function createSatisfactionSurveysCollection(slugs) {
8114
9078
  {
8115
9079
  name: "rating",
8116
9080
  type: "number",
8117
- required: true,
8118
9081
  min: 1,
8119
9082
  max: 5,
8120
9083
  label: "Note (1-5)",
8121
9084
  admin: {
8122
- description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait"
9085
+ description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait (CSAT)"
9086
+ }
9087
+ },
9088
+ {
9089
+ name: "nps",
9090
+ type: "number",
9091
+ min: 0,
9092
+ max: 10,
9093
+ label: "NPS (0-10)",
9094
+ admin: {
9095
+ description: "\xAB Recommanderiez-vous ? \xBB \u2014 promoteur 9-10, passif 7-8, d\xE9tracteur 0-6"
8123
9096
  }
8124
9097
  },
8125
9098
  {
@@ -9365,6 +10338,124 @@ function createTicketCollaboratorsCollection(slugs) {
9365
10338
  };
9366
10339
  }
9367
10340
 
10341
+ // src/collections/NotificationQueue.ts
10342
+ function createNotificationQueueCollection(slugs) {
10343
+ return {
10344
+ slug: slugs.notificationQueue,
10345
+ labels: { singular: "Notification en file", plural: "Notifications en file" },
10346
+ admin: { hidden: true, group: "Support", defaultColumns: ["client", "type", "title", "createdAt"] },
10347
+ access: {
10348
+ // Internal: written/drained only via overrideAccess (hooks + cron).
10349
+ create: () => false,
10350
+ update: () => false,
10351
+ read: ({ req }) => req.user?.collection === slugs.users,
10352
+ delete: ({ req }) => req.user?.collection === slugs.users
10353
+ },
10354
+ fields: [
10355
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client" },
10356
+ { name: "type", type: "text", label: "Type" },
10357
+ { name: "title", type: "text", label: "Titre" },
10358
+ { name: "message", type: "textarea", label: "Message" },
10359
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, label: "Ticket" }
10360
+ ],
10361
+ timestamps: true
10362
+ };
10363
+ }
10364
+
10365
+ // src/collections/AutomationRules.ts
10366
+ var CONDITION_FIELDS = ["status", "priority", "category", "source", "subject"];
10367
+ var ACTION_TYPES = [
10368
+ { label: "D\xE9finir le statut", value: "set_status" },
10369
+ { label: "D\xE9finir la priorit\xE9", value: "set_priority" },
10370
+ { label: "D\xE9finir la cat\xE9gorie", value: "set_category" },
10371
+ { label: "Assigner \xE0 (id agent)", value: "assign" },
10372
+ { label: "Ajouter un tag", value: "add_tag" }
10373
+ ];
10374
+ function createAutomationRulesCollection(slugs) {
10375
+ return {
10376
+ slug: slugs.automationRules,
10377
+ labels: { singular: "R\xE8gle d'automatisation", plural: "R\xE8gles d'automatisation" },
10378
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "event", "enabled", "order"] },
10379
+ access: {
10380
+ read: ({ req }) => req.user?.collection === slugs.users,
10381
+ create: ({ req }) => req.user?.collection === slugs.users,
10382
+ update: ({ req }) => req.user?.collection === slugs.users,
10383
+ delete: ({ req }) => req.user?.collection === slugs.users
10384
+ },
10385
+ fields: [
10386
+ { name: "name", type: "text", required: true, label: "Nom" },
10387
+ { name: "enabled", type: "checkbox", defaultValue: true, label: "Active", index: true },
10388
+ {
10389
+ name: "event",
10390
+ type: "select",
10391
+ defaultValue: "ticket_created",
10392
+ index: true,
10393
+ label: "D\xE9clencheur",
10394
+ options: [
10395
+ { label: "Ticket cr\xE9\xE9", value: "ticket_created" },
10396
+ { label: "Ticket mis \xE0 jour", value: "ticket_updated" },
10397
+ { label: "Changement de statut", value: "ticket_status_changed" }
10398
+ ]
10399
+ },
10400
+ {
10401
+ name: "matchType",
10402
+ type: "select",
10403
+ defaultValue: "all",
10404
+ label: "Correspondance",
10405
+ options: [{ label: "Toutes les conditions (ET)", value: "all" }, { label: "Au moins une (OU)", value: "any" }]
10406
+ },
10407
+ { name: "order", type: "number", defaultValue: 0, label: "Ordre d'\xE9valuation" },
10408
+ {
10409
+ name: "conditions",
10410
+ type: "array",
10411
+ label: "Conditions",
10412
+ fields: [
10413
+ { name: "field", type: "select", required: true, options: CONDITION_FIELDS.map((v) => ({ label: v, value: v })) },
10414
+ {
10415
+ name: "operator",
10416
+ type: "select",
10417
+ defaultValue: "equals",
10418
+ required: true,
10419
+ options: [{ label: "= \xE9gal", value: "equals" }, { label: "\u2260 diff\xE9rent", value: "not_equals" }, { label: "contient", value: "contains" }]
10420
+ },
10421
+ { name: "value", type: "text", required: true }
10422
+ ]
10423
+ },
10424
+ {
10425
+ name: "actions",
10426
+ type: "array",
10427
+ label: "Actions",
10428
+ fields: [
10429
+ { name: "type", type: "select", required: true, options: ACTION_TYPES },
10430
+ { name: "value", type: "text", required: true }
10431
+ ]
10432
+ }
10433
+ ],
10434
+ timestamps: true
10435
+ };
10436
+ }
10437
+
10438
+ // src/collections/SupportTeam.ts
10439
+ function createSupportTeamCollection(slugs) {
10440
+ return {
10441
+ slug: slugs.supportTeams,
10442
+ labels: { singular: "\xC9quipe support", plural: "\xC9quipes support" },
10443
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "members"] },
10444
+ access: {
10445
+ read: ({ req }) => req.user?.collection === slugs.users,
10446
+ create: ({ req }) => req.user?.collection === slugs.users,
10447
+ update: ({ req }) => req.user?.collection === slugs.users,
10448
+ delete: ({ req }) => req.user?.collection === slugs.users
10449
+ },
10450
+ fields: [
10451
+ { name: "name", type: "text", required: true, label: "Nom" },
10452
+ { name: "description", type: "textarea", label: "Description" },
10453
+ { name: "members", type: "relationship", relationTo: slugs.users, hasMany: true, index: true, label: "Membres (agents)" }
10454
+ ],
10455
+ timestamps: true
10456
+ };
10457
+ }
10458
+
9368
10459
  // src/plugin.ts
9369
10460
  function viewConfig(component, path) {
9370
10461
  return { Component: component, path };
@@ -9400,7 +10491,10 @@ function supportPlugin(config) {
9400
10491
  createSatisfactionSurveysCollection(slugs),
9401
10492
  createKnowledgeBaseCollection(slugs),
9402
10493
  createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
9403
- createTicketCollaboratorsCollection(slugs)
10494
+ createTicketCollaboratorsCollection(slugs),
10495
+ createNotificationQueueCollection(slugs),
10496
+ createAutomationRulesCollection(slugs),
10497
+ createSupportTeamCollection(slugs)
9404
10498
  ];
9405
10499
  if (features.authLogs !== false) supportCollections.push(createAuthLogsCollection(slugs));
9406
10500
  if (features.timeTracking !== false) supportCollections.push(createTimeEntriesCollection(slugs));