@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.js CHANGED
@@ -1,4 +1,7 @@
1
- import crypto3, { randomBytes, createHmac } from 'crypto';
1
+ import crypto3, { createHmac, randomBytes } from 'crypto';
2
+ import PDFDocument from 'pdfkit';
3
+ import { APIError, getFieldsToSign, jwtSign } from 'payload';
4
+ import sanitizeHtml from 'sanitize-html';
2
5
 
3
6
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
4
7
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
@@ -58,6 +61,9 @@ var DEFAULT_SLUGS = {
58
61
  macros: "macros",
59
62
  ticketStatuses: "ticket-statuses",
60
63
  ticketFeedback: "ticket-feedback",
64
+ notificationQueue: "notification-queue",
65
+ automationRules: "automation-rules",
66
+ supportTeams: "support-teams",
61
67
  users: "users",
62
68
  media: "media"
63
69
  };
@@ -89,6 +95,26 @@ function handleAuthError(error) {
89
95
  return null;
90
96
  }
91
97
 
98
+ // src/utils/db.ts
99
+ function dbFind(payload, slug, options = {}) {
100
+ return payload.find({ collection: slug, ...options });
101
+ }
102
+ function dbFindByID(payload, slug, options) {
103
+ return payload.findByID({ collection: slug, ...options });
104
+ }
105
+ function dbCreate(payload, slug, options) {
106
+ return payload.create({ collection: slug, ...options });
107
+ }
108
+ function dbUpdate(payload, slug, options) {
109
+ return payload.update({ collection: slug, ...options });
110
+ }
111
+ function dbCount(payload, slug, options = {}) {
112
+ return payload.count({ collection: slug, ...options });
113
+ }
114
+ function dbDelete(payload, slug, options) {
115
+ return payload.delete({ collection: slug, ...options });
116
+ }
117
+
92
118
  // src/utils/readSettings.ts
93
119
  var PREF_KEY = "support-settings";
94
120
  var USER_PREFS_KEY_PREFIX = "support-user-prefs";
@@ -102,10 +128,18 @@ var DEFAULT_USER_PREFS = {
102
128
  locale: "fr",
103
129
  signature: ""
104
130
  };
131
+ var settingsCache = null;
132
+ var SETTINGS_TTL_MS = 6e4;
133
+ function invalidateSupportSettingsCache() {
134
+ settingsCache = null;
135
+ }
105
136
  async function readSupportSettings(payload) {
137
+ if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
138
+ return settingsCache.value;
139
+ }
140
+ let value = { ...DEFAULT_SETTINGS };
106
141
  try {
107
- const prefs = await payload.find({
108
- collection: "payload-preferences",
142
+ const prefs = await dbFind(payload, "payload-preferences", {
109
143
  where: { key: { equals: PREF_KEY } },
110
144
  limit: 1,
111
145
  depth: 0,
@@ -113,7 +147,7 @@ async function readSupportSettings(payload) {
113
147
  });
114
148
  if (prefs.docs.length > 0) {
115
149
  const stored = prefs.docs[0].value;
116
- return {
150
+ value = {
117
151
  email: { ...DEFAULT_SETTINGS.email, ...stored.email },
118
152
  ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
119
153
  sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
@@ -122,13 +156,13 @@ async function readSupportSettings(payload) {
122
156
  }
123
157
  } catch {
124
158
  }
125
- return { ...DEFAULT_SETTINGS };
159
+ settingsCache = { value, ts: Date.now() };
160
+ return value;
126
161
  }
127
162
  async function readUserPrefs(payload, userId) {
128
163
  try {
129
164
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
130
- const prefs = await payload.find({
131
- collection: "payload-preferences",
165
+ const prefs = await dbFind(payload, "payload-preferences", {
132
166
  where: { key: { equals: key } },
133
167
  limit: 1,
134
168
  depth: 0,
@@ -314,8 +348,130 @@ ${text}`;
314
348
  };
315
349
  }
316
350
 
317
- // src/endpoints/client-intelligence.ts
351
+ // src/utils/aiAgent.ts
352
+ async function applyAgentDecision(payload, slugs, ticketId, decision) {
353
+ if (decision.action === "reply" && decision.reply && decision.reply.trim()) {
354
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
355
+ data: {
356
+ ticket: ticketId,
357
+ body: decision.reply.trim(),
358
+ authorType: "admin",
359
+ isInternal: false,
360
+ fromAlias: "Assistant IA"
361
+ },
362
+ overrideAccess: true
363
+ });
364
+ return { acted: "reply", messageId: msg.id, confidence: decision.confidence };
365
+ }
366
+ await dbCreate(payload, slugs.ticketMessages, {
367
+ data: {
368
+ ticket: ticketId,
369
+ body: `[Agent IA] Escalade vers un humain. Raison : ${decision.reason || "non d\xE9termin\xE9e"} (confiance ${Math.round((decision.confidence ?? 0) * 100)} %).`,
370
+ authorType: "admin",
371
+ isInternal: true,
372
+ skipNotification: true
373
+ },
374
+ overrideAccess: true
375
+ });
376
+ await dbUpdate(payload, slugs.tickets, { id: ticketId, data: { status: "escalated" }, overrideAccess: true });
377
+ return { acted: "escalate", confidence: decision.confidence };
378
+ }
318
379
  function getClient2(aiSettings) {
380
+ const Anthropic = __require("@anthropic-ai/sdk").default;
381
+ if (aiSettings.provider === "ollama") {
382
+ return new Anthropic({ apiKey: "ollama", baseURL: process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1" });
383
+ }
384
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
385
+ }
386
+ function stripHtml(s) {
387
+ return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
388
+ }
389
+ function parseDecision(text) {
390
+ try {
391
+ const match = text.match(/\{[\s\S]*\}/);
392
+ if (match) {
393
+ const obj = JSON.parse(match[0]);
394
+ return {
395
+ action: obj.action === "reply" ? "reply" : "escalate",
396
+ reply: typeof obj.reply === "string" ? obj.reply : void 0,
397
+ reason: typeof obj.reason === "string" ? obj.reason : void 0,
398
+ confidence: typeof obj.confidence === "number" ? obj.confidence : 0
399
+ };
400
+ }
401
+ } catch {
402
+ }
403
+ return { action: "escalate", reason: "R\xE9ponse IA non interpr\xE9table", confidence: 0 };
404
+ }
405
+ async function runAiAgent(payload, slugs, ticketId, opts) {
406
+ const settings = await readSupportSettings(payload);
407
+ const threshold = opts?.confidenceThreshold ?? 0.7;
408
+ const ticket = await dbFindByID(payload, slugs.tickets, { id: ticketId, depth: 0, overrideAccess: true });
409
+ const messages = await dbFind(payload, slugs.ticketMessages, { where: { ticket: { equals: ticketId } }, sort: "createdAt", limit: 50, depth: 0, overrideAccess: true });
410
+ const kb = await dbFind(payload, slugs.knowledgeBase, { where: { published: { equals: true } }, limit: 20, depth: 0, overrideAccess: true });
411
+ const conversation = messages.docs.map((m) => `${m.authorType}: ${m.body || ""}`).join("\n");
412
+ const kbText = kb.docs.map((a) => `# ${a.title || ""}
413
+ ${stripHtml(String(a.content ?? ""))}`).join("\n\n").slice(0, 8e3);
414
+ const prompt = `Tu es un agent de support IA. \xC0 partir du ticket et de la base de connaissances ci-dessous, d\xE9cide :
415
+ - "reply" si tu peux r\xE9pondre avec certitude UNIQUEMENT \xE0 partir de la base de connaissances ;
416
+ - "escalate" sinon (question hors KB, ambigu\xEB, sensible, ou n\xE9cessitant une action humaine).
417
+ R\xE9ponds en JSON STRICT, sans texte autour : {"action":"reply"|"escalate","reply":"<r\xE9ponse au client si reply>","reason":"<courte raison>","confidence":<0 \xE0 1>}.
418
+
419
+ TICKET : ${ticket.subject || ""}
420
+ CONVERSATION :
421
+ ${conversation}
422
+
423
+ BASE DE CONNAISSANCES :
424
+ ${kbText || "(vide)"}`;
425
+ let decision;
426
+ try {
427
+ const client = getClient2(settings.ai);
428
+ const resp = await client.messages.create({
429
+ model: settings.ai.model || "claude-haiku-4-5-20251001",
430
+ max_tokens: 1024,
431
+ messages: [{ role: "user", content: prompt }]
432
+ });
433
+ const text = resp?.content?.[0]?.text || "";
434
+ decision = parseDecision(text);
435
+ } catch (err) {
436
+ console.error("[support] AI agent decision failed:", err);
437
+ decision = { action: "escalate", reason: "Erreur du fournisseur IA", confidence: 0 };
438
+ }
439
+ const finalDecision = decision.action === "reply" && (decision.confidence ?? 0) >= threshold ? decision : { action: "escalate", reason: decision.reason || "Confiance insuffisante pour une r\xE9ponse automatique", confidence: decision.confidence };
440
+ return applyAgentDecision(payload, slugs, ticketId, finalDecision);
441
+ }
442
+
443
+ // src/endpoints/ai-agent.ts
444
+ function createAiAgentEndpoint(slugs) {
445
+ return {
446
+ path: "/support/ai-agent",
447
+ method: "post",
448
+ handler: async (req) => {
449
+ try {
450
+ requireAdmin(req, slugs);
451
+ let body = {};
452
+ try {
453
+ body = await req.json();
454
+ } catch {
455
+ }
456
+ if (!body.ticketId) {
457
+ return Response.json({ error: "ticketId requis." }, { status: 400 });
458
+ }
459
+ const result = await runAiAgent(req.payload, slugs, body.ticketId, {
460
+ confidenceThreshold: body.confidenceThreshold
461
+ });
462
+ return Response.json({ ok: true, ...result });
463
+ } catch (error) {
464
+ const authResponse = handleAuthError(error);
465
+ if (authResponse) return authResponse;
466
+ console.error("[ai-agent] Error:", error);
467
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
468
+ }
469
+ }
470
+ };
471
+ }
472
+
473
+ // src/endpoints/client-intelligence.ts
474
+ function getClient3(aiSettings) {
319
475
  const Anthropic = __require("@anthropic-ai/sdk").default;
320
476
  if (aiSettings.provider === "ollama") {
321
477
  const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
@@ -469,7 +625,7 @@ R\xE9ponds en JSON strict (pas de markdown, pas de commentaires) avec cette stru
469
625
  }
470
626
 
471
627
  Sois factuel. Ne d\xE9passe pas 5 items par tableau. R\xE9ponds UNIQUEMENT avec le JSON.`;
472
- const anthropic = getClient2(aiSettings);
628
+ const anthropic = getClient3(aiSettings);
473
629
  const model = getModel2(aiSettings);
474
630
  const res = await anthropic.messages.create({
475
631
  model,
@@ -533,8 +689,7 @@ function createSearchEndpoint(slugs) {
533
689
  return Response.json({ tickets: [], clients: [], messages: [], articles: [] });
534
690
  }
535
691
  const [ticketsRes, clientsRes, messagesRes, articlesRes] = await Promise.all([
536
- payload.find({
537
- collection: slugs.tickets,
692
+ dbFind(payload, slugs.tickets, {
538
693
  where: {
539
694
  or: [
540
695
  { ticketNumber: { contains: q } },
@@ -546,8 +701,7 @@ function createSearchEndpoint(slugs) {
546
701
  depth: 1,
547
702
  overrideAccess: true
548
703
  }),
549
- payload.find({
550
- collection: slugs.supportClients,
704
+ dbFind(payload, slugs.supportClients, {
551
705
  where: {
552
706
  or: [
553
707
  { firstName: { contains: q } },
@@ -560,16 +714,14 @@ function createSearchEndpoint(slugs) {
560
714
  depth: 0,
561
715
  overrideAccess: true
562
716
  }),
563
- payload.find({
564
- collection: slugs.ticketMessages,
717
+ dbFind(payload, slugs.ticketMessages, {
565
718
  where: { body: { contains: q } },
566
719
  sort: "-createdAt",
567
720
  limit: 5,
568
721
  depth: 1,
569
722
  overrideAccess: true
570
723
  }),
571
- payload.find({
572
- collection: slugs.knowledgeBase,
724
+ dbFind(payload, slugs.knowledgeBase, {
573
725
  where: { title: { contains: q } },
574
726
  limit: 5,
575
727
  depth: 0,
@@ -669,8 +821,7 @@ function createKbSearchEndpoint(slugs) {
669
821
  orClauses.push({ category: { contains: t } });
670
822
  }
671
823
  orClauses.push({ title: { contains: q } });
672
- const res = await payload.find({
673
- collection: slugs.knowledgeBase,
824
+ const res = await dbFind(payload, slugs.knowledgeBase, {
674
825
  where: {
675
826
  and: [
676
827
  { published: { equals: true } },
@@ -729,16 +880,14 @@ function createBulkActionEndpoint(slugs) {
729
880
  try {
730
881
  switch (action) {
731
882
  case "close":
732
- await payload.update({
733
- collection: slugs.tickets,
883
+ await dbUpdate(payload, slugs.tickets, {
734
884
  id: ticketId,
735
885
  data: { status: "resolved", resolvedAt: (/* @__PURE__ */ new Date()).toISOString() },
736
886
  overrideAccess: true
737
887
  });
738
888
  break;
739
889
  case "reopen":
740
- await payload.update({
741
- collection: slugs.tickets,
890
+ await dbUpdate(payload, slugs.tickets, {
742
891
  id: ticketId,
743
892
  data: { status: "open" },
744
893
  overrideAccess: true
@@ -746,8 +895,7 @@ function createBulkActionEndpoint(slugs) {
746
895
  break;
747
896
  case "assign":
748
897
  if (value) {
749
- await payload.update({
750
- collection: slugs.tickets,
898
+ await dbUpdate(payload, slugs.tickets, {
751
899
  id: ticketId,
752
900
  data: { assignedTo: Number(value) },
753
901
  overrideAccess: true
@@ -756,8 +904,7 @@ function createBulkActionEndpoint(slugs) {
756
904
  break;
757
905
  case "set_priority":
758
906
  if (value) {
759
- await payload.update({
760
- collection: slugs.tickets,
907
+ await dbUpdate(payload, slugs.tickets, {
761
908
  id: ticketId,
762
909
  data: { priority: String(value) },
763
910
  overrideAccess: true
@@ -766,8 +913,7 @@ function createBulkActionEndpoint(slugs) {
766
913
  break;
767
914
  case "set_category":
768
915
  if (value) {
769
- await payload.update({
770
- collection: slugs.tickets,
916
+ await dbUpdate(payload, slugs.tickets, {
771
917
  id: ticketId,
772
918
  data: { category: String(value) },
773
919
  overrideAccess: true
@@ -775,8 +921,7 @@ function createBulkActionEndpoint(slugs) {
775
921
  }
776
922
  break;
777
923
  case "delete":
778
- await payload.delete({
779
- collection: slugs.tickets,
924
+ await dbDelete(payload, slugs.tickets, {
780
925
  id: ticketId,
781
926
  overrideAccess: true
782
927
  });
@@ -815,8 +960,8 @@ function createMergeTicketsEndpoint(slugs) {
815
960
  return Response.json({ error: "Impossible de fusionner un ticket avec lui-m\xEAme" }, { status: 400 });
816
961
  }
817
962
  const [source, target] = await Promise.all([
818
- payload.findByID({ collection: slugs.tickets, id: sourceTicketId, depth: 0, overrideAccess: true }),
819
- payload.findByID({ collection: slugs.tickets, id: targetTicketId, depth: 0, overrideAccess: true })
963
+ dbFindByID(payload, slugs.tickets, { id: sourceTicketId, depth: 0, overrideAccess: true }),
964
+ dbFindByID(payload, slugs.tickets, { id: targetTicketId, depth: 0, overrideAccess: true })
820
965
  ]);
821
966
  if (!source) return Response.json({ error: "Ticket source introuvable" }, { status: 404 });
822
967
  if (!target) return Response.json({ error: "Ticket cible introuvable" }, { status: 404 });
@@ -825,39 +970,34 @@ function createMergeTicketsEndpoint(slugs) {
825
970
  if (sourceClient !== targetClient) {
826
971
  return Response.json({ error: "Les deux tickets doivent appartenir au m\xEAme client" }, { status: 400 });
827
972
  }
828
- const messages = await payload.find({
829
- collection: slugs.ticketMessages,
973
+ const messages = await dbFind(payload, slugs.ticketMessages, {
830
974
  where: { ticket: { equals: sourceTicketId } },
831
975
  limit: 500,
832
976
  depth: 0,
833
977
  overrideAccess: true
834
978
  });
835
979
  for (const msg of messages.docs) {
836
- await payload.update({
837
- collection: slugs.ticketMessages,
980
+ await dbUpdate(payload, slugs.ticketMessages, {
838
981
  id: msg.id,
839
982
  data: { ticket: targetTicketId },
840
983
  overrideAccess: true
841
984
  });
842
985
  }
843
- const timeEntries = await payload.find({
844
- collection: slugs.timeEntries,
986
+ const timeEntries = await dbFind(payload, slugs.timeEntries, {
845
987
  where: { ticket: { equals: sourceTicketId } },
846
988
  limit: 500,
847
989
  depth: 0,
848
990
  overrideAccess: true
849
991
  });
850
992
  for (const entry of timeEntries.docs) {
851
- await payload.update({
852
- collection: slugs.timeEntries,
993
+ await dbUpdate(payload, slugs.timeEntries, {
853
994
  id: entry.id,
854
995
  data: { ticket: targetTicketId },
855
996
  overrideAccess: true
856
997
  });
857
998
  }
858
999
  const sourceNumber = source.ticketNumber || `#${sourceTicketId}`;
859
- await payload.create({
860
- collection: slugs.ticketMessages,
1000
+ await dbCreate(payload, slugs.ticketMessages, {
861
1001
  data: {
862
1002
  ticket: targetTicketId,
863
1003
  body: `Messages fusionn\xE9s depuis ${sourceNumber} (${messages.totalDocs} messages, ${timeEntries.totalDocs} entr\xE9es de temps)`,
@@ -867,13 +1007,11 @@ function createMergeTicketsEndpoint(slugs) {
867
1007
  },
868
1008
  overrideAccess: true
869
1009
  });
870
- await payload.delete({
871
- collection: slugs.tickets,
1010
+ await dbDelete(payload, slugs.tickets, {
872
1011
  id: sourceTicketId,
873
1012
  overrideAccess: true
874
1013
  });
875
- const allTimeEntries = await payload.find({
876
- collection: slugs.timeEntries,
1014
+ const allTimeEntries = await dbFind(payload, slugs.timeEntries, {
877
1015
  where: { ticket: { equals: targetTicketId } },
878
1016
  limit: 500,
879
1017
  depth: 0,
@@ -883,8 +1021,7 @@ function createMergeTicketsEndpoint(slugs) {
883
1021
  (sum, e) => sum + (e.duration || 0),
884
1022
  0
885
1023
  );
886
- await payload.update({
887
- collection: slugs.tickets,
1024
+ await dbUpdate(payload, slugs.tickets, {
888
1025
  id: targetTicketId,
889
1026
  data: { totalTimeMinutes: totalMinutes },
890
1027
  overrideAccess: true
@@ -919,8 +1056,7 @@ function createSplitTicketEndpoint(slugs) {
919
1056
  if (!messageId) {
920
1057
  return Response.json({ error: "messageId required" }, { status: 400 });
921
1058
  }
922
- const message = await payload.findByID({
923
- collection: slugs.ticketMessages,
1059
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
924
1060
  id: messageId,
925
1061
  depth: 1,
926
1062
  overrideAccess: true
@@ -933,8 +1069,7 @@ function createSplitTicketEndpoint(slugs) {
933
1069
  return Response.json({ error: "Cannot resolve source ticket" }, { status: 400 });
934
1070
  }
935
1071
  const clientId = typeof sourceTicket.client === "object" ? sourceTicket.client.id : sourceTicket.client;
936
- const newTicket = await payload.create({
937
- collection: slugs.tickets,
1072
+ const newTicket = await dbCreate(payload, slugs.tickets, {
938
1073
  data: {
939
1074
  subject: subject || `Split: ${sourceTicket.subject}`,
940
1075
  client: clientId,
@@ -949,8 +1084,7 @@ function createSplitTicketEndpoint(slugs) {
949
1084
  const attachments = message.attachments?.map((a) => ({
950
1085
  file: typeof a.file === "object" ? a.file.id : a.file
951
1086
  })) || [];
952
- await payload.create({
953
- collection: slugs.ticketMessages,
1087
+ await dbCreate(payload, slugs.ticketMessages, {
954
1088
  data: {
955
1089
  ticket: newTicket.id,
956
1090
  body: message.body,
@@ -963,8 +1097,7 @@ function createSplitTicketEndpoint(slugs) {
963
1097
  },
964
1098
  overrideAccess: true
965
1099
  });
966
- await payload.create({
967
- collection: slugs.ticketMessages,
1100
+ await dbCreate(payload, slugs.ticketMessages, {
968
1101
  data: {
969
1102
  ticket: sourceTicket.id,
970
1103
  body: `Message extrait vers le ticket ${newTicket.ticketNumber}`,
@@ -976,15 +1109,13 @@ function createSplitTicketEndpoint(slugs) {
976
1109
  });
977
1110
  const existingRelated = Array.isArray(sourceTicket.relatedTickets) ? sourceTicket.relatedTickets.map((t) => typeof t === "object" ? t.id : t) : [];
978
1111
  if (!existingRelated.includes(newTicket.id)) {
979
- await payload.update({
980
- collection: slugs.tickets,
1112
+ await dbUpdate(payload, slugs.tickets, {
981
1113
  id: sourceTicket.id,
982
1114
  data: { relatedTickets: [...existingRelated, newTicket.id] },
983
1115
  overrideAccess: true
984
1116
  });
985
1117
  }
986
- await payload.create({
987
- collection: slugs.ticketActivityLog,
1118
+ await dbCreate(payload, slugs.ticketActivityLog, {
988
1119
  data: {
989
1120
  ticket: sourceTicket.id,
990
1121
  action: "split",
@@ -1199,8 +1330,7 @@ function createSettingsGetEndpoint(slugs) {
1199
1330
  try {
1200
1331
  const payload = req.payload;
1201
1332
  requireAdmin(req, slugs);
1202
- const prefs = await payload.find({
1203
- collection: "payload-preferences",
1333
+ const prefs = await dbFind(payload, "payload-preferences", {
1204
1334
  where: { key: { equals: PREF_KEY2 } },
1205
1335
  limit: 1,
1206
1336
  depth: 0,
@@ -1257,6 +1387,7 @@ function createSettingsPostEndpoint(slugs) {
1257
1387
  ]
1258
1388
  }
1259
1389
  });
1390
+ invalidateSupportSettingsCache();
1260
1391
  return Response.json(merged);
1261
1392
  } catch (error) {
1262
1393
  const authResponse = handleAuthError(error);
@@ -1278,8 +1409,7 @@ function createSignatureGetEndpoint(slugs) {
1278
1409
  try {
1279
1410
  const payload = req.payload;
1280
1411
  requireAdmin(req, slugs);
1281
- const prefs = await payload.find({
1282
- collection: "payload-preferences",
1412
+ const prefs = await dbFind(payload, "payload-preferences", {
1283
1413
  where: { key: { equals: `${PREF_KEY3}-${req.user.id}` } },
1284
1414
  limit: 1,
1285
1415
  depth: 0,
@@ -1306,8 +1436,7 @@ function createSignaturePostEndpoint(slugs) {
1306
1436
  requireAdmin(req, slugs);
1307
1437
  const { signature } = await req.json();
1308
1438
  const key = `${PREF_KEY3}-${req.user.id}`;
1309
- const existing = await payload.find({
1310
- collection: "payload-preferences",
1439
+ const existing = await dbFind(payload, "payload-preferences", {
1311
1440
  where: { key: { equals: key } },
1312
1441
  limit: 1,
1313
1442
  depth: 0,
@@ -1350,8 +1479,7 @@ function createRoundRobinConfigGetEndpoint(slugs) {
1350
1479
  try {
1351
1480
  const payload = req.payload;
1352
1481
  requireAdmin(req, slugs);
1353
- const prefs = await payload.find({
1354
- collection: "payload-preferences",
1482
+ const prefs = await dbFind(payload, "payload-preferences", {
1355
1483
  where: { key: { equals: PREF_KEY4 } },
1356
1484
  limit: 1,
1357
1485
  depth: 0,
@@ -1377,8 +1505,7 @@ function createRoundRobinConfigPostEndpoint(slugs) {
1377
1505
  const payload = req.payload;
1378
1506
  requireAdmin(req, slugs);
1379
1507
  const { enabled } = await req.json();
1380
- const existing = await payload.find({
1381
- collection: "payload-preferences",
1508
+ const existing = await dbFind(payload, "payload-preferences", {
1382
1509
  where: { key: { equals: PREF_KEY4 } },
1383
1510
  limit: 1,
1384
1511
  depth: 0,
@@ -1422,8 +1549,7 @@ function createSlaCheckEndpoint(slugs) {
1422
1549
  requireAdmin(req, slugs);
1423
1550
  const now = /* @__PURE__ */ new Date();
1424
1551
  const nowISO = now.toISOString();
1425
- const { docs: tickets } = await payload.find({
1426
- collection: slugs.tickets,
1552
+ const { docs: tickets } = await dbFind(payload, slugs.tickets, {
1427
1553
  where: {
1428
1554
  and: [
1429
1555
  { status: { in: ["open", "waiting_client"] } },
@@ -1517,6 +1643,107 @@ function createSlaCheckEndpoint(slugs) {
1517
1643
  }
1518
1644
  };
1519
1645
  }
1646
+ var TRANSPARENT_GIF = Buffer.from(
1647
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
1648
+ "base64"
1649
+ );
1650
+ function generateTrackingToken(ticketId, messageId, secret) {
1651
+ return createHmac("sha256", secret).update(`${ticketId}:${messageId}`).digest("hex").substring(0, 16);
1652
+ }
1653
+ function createTrackOpenEndpoint(slugs) {
1654
+ return {
1655
+ path: "/support/track-open",
1656
+ method: "get",
1657
+ handler: async (req) => {
1658
+ const url = new URL(req.url);
1659
+ const ticketId = url.searchParams.get("t");
1660
+ const messageId = url.searchParams.get("m");
1661
+ const sig = url.searchParams.get("sig");
1662
+ const parsedId = ticketId ? Number(ticketId) : NaN;
1663
+ const parsedMsgId = messageId ? Number(messageId) : NaN;
1664
+ const secret = process.env.PAYLOAD_SECRET || "";
1665
+ const validSig = !!secret && !!ticketId && !!messageId && !!sig && sig === generateTrackingToken(ticketId, messageId, secret);
1666
+ if (!validSig) {
1667
+ return new Response(TRANSPARENT_GIF, {
1668
+ status: 200,
1669
+ headers: {
1670
+ "Content-Type": "image/gif",
1671
+ "Content-Length": String(TRANSPARENT_GIF.length),
1672
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
1673
+ }
1674
+ });
1675
+ }
1676
+ if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
1677
+ try {
1678
+ const payload = req.payload;
1679
+ const ticket = await dbFindByID(payload, slugs.tickets, {
1680
+ id: parsedId,
1681
+ depth: 0,
1682
+ overrideAccess: true,
1683
+ select: { lastClientReadAt: true }
1684
+ });
1685
+ if (ticket) {
1686
+ const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0;
1687
+ const fiveMinAgo = Date.now() - 5 * 60 * 1e3;
1688
+ if (lastRead < fiveMinAgo) {
1689
+ await dbUpdate(payload, slugs.tickets, {
1690
+ id: parsedId,
1691
+ data: { lastClientReadAt: (/* @__PURE__ */ new Date()).toISOString() },
1692
+ overrideAccess: true
1693
+ });
1694
+ }
1695
+ }
1696
+ if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
1697
+ const msg = await dbFindByID(payload, slugs.ticketMessages, {
1698
+ id: parsedMsgId,
1699
+ depth: 0,
1700
+ overrideAccess: true,
1701
+ select: { emailOpenedAt: true }
1702
+ });
1703
+ if (msg && !msg.emailOpenedAt) {
1704
+ await dbUpdate(payload, slugs.ticketMessages, {
1705
+ id: parsedMsgId,
1706
+ data: { emailOpenedAt: (/* @__PURE__ */ new Date()).toISOString() },
1707
+ overrideAccess: true
1708
+ });
1709
+ const ticketInfo = await dbFindByID(payload, slugs.tickets, {
1710
+ id: parsedId,
1711
+ depth: 1,
1712
+ overrideAccess: true,
1713
+ select: { ticketNumber: true, subject: true, client: true }
1714
+ });
1715
+ const clientName = typeof ticketInfo?.client === "object" ? ticketInfo.client?.firstName || "Client" : "Client";
1716
+ try {
1717
+ await dbCreate(payload, "admin-notifications", {
1718
+ data: {
1719
+ title: `Email ouvert \u2014 ${ticketInfo?.ticketNumber || "TK-????"}`,
1720
+ message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || "ticket"}"`,
1721
+ type: "email_opened",
1722
+ link: `/admin/ticket?id=${parsedId}`
1723
+ },
1724
+ overrideAccess: true
1725
+ });
1726
+ } catch {
1727
+ }
1728
+ }
1729
+ }
1730
+ } catch (err) {
1731
+ console.error("[track-open] Error:", err);
1732
+ }
1733
+ }
1734
+ return new Response(TRANSPARENT_GIF, {
1735
+ status: 200,
1736
+ headers: {
1737
+ "Content-Type": "image/gif",
1738
+ "Content-Length": String(TRANSPARENT_GIF.length),
1739
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
1740
+ "Pragma": "no-cache",
1741
+ "Expires": "0"
1742
+ }
1743
+ });
1744
+ }
1745
+ };
1746
+ }
1520
1747
 
1521
1748
  // src/utils/emailTemplate.ts
1522
1749
  var COLORS = {
@@ -1553,7 +1780,10 @@ function escapeHtml(str) {
1553
1780
  }
1554
1781
  function emailTrackingPixel(ticketId, messageId, baseUrl) {
1555
1782
  const url = process.env.NEXT_PUBLIC_SERVER_URL || "";
1556
- const params = `t=${ticketId}${messageId ? `&m=${messageId}` : ""}`;
1783
+ const secret = process.env.PAYLOAD_SECRET || "";
1784
+ if (!secret || messageId === void 0 || messageId === null) return "";
1785
+ const sig = generateTrackingToken(String(ticketId), String(messageId), secret);
1786
+ const params = `t=${ticketId}&m=${messageId}&sig=${sig}`;
1557
1787
  return `<img src="${url}/api/support/track-open?${params}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`;
1558
1788
  }
1559
1789
  function emailRichContent(html, config) {
@@ -1818,8 +2048,7 @@ function createAutoCloseEndpoint(slugs) {
1818
2048
  const CLOSE_AFTER_REMIND_DAYS = settings.autoClose.reminderDaysBefore;
1819
2049
  const results = { reminded: 0, closed: 0, errors: 0 };
1820
2050
  const remindCutoff = new Date(now.getTime() - REMIND_AFTER_DAYS * 24 * 60 * 60 * 1e3);
1821
- const ticketsToRemind = await payload.find({
1822
- collection: slugs.tickets,
2051
+ const ticketsToRemind = await dbFind(payload, slugs.tickets, {
1823
2052
  where: {
1824
2053
  and: [
1825
2054
  { status: { equals: "waiting_client" } },
@@ -1850,8 +2079,7 @@ function createAutoCloseEndpoint(slugs) {
1850
2079
  <p><a href="${portalUrl}">R\xE9pondre au ticket</a></p>
1851
2080
  </div>`
1852
2081
  });
1853
- await payload.update({
1854
- collection: slugs.tickets,
2082
+ await dbUpdate(payload, slugs.tickets, {
1855
2083
  id: t.id,
1856
2084
  data: { autoCloseRemindedAt: now.toISOString() },
1857
2085
  overrideAccess: true
@@ -1863,16 +2091,24 @@ function createAutoCloseEndpoint(slugs) {
1863
2091
  }
1864
2092
  }
1865
2093
  const closeCutoff = new Date(now.getTime() - CLOSE_AFTER_REMIND_DAYS * 24 * 60 * 60 * 1e3);
1866
- const ticketsToClose = await payload.find({
1867
- collection: slugs.tickets,
2094
+ const ticketsToClose = await dbFind(payload, slugs.tickets, {
1868
2095
  where: {
1869
2096
  and: [
1870
2097
  { status: { equals: "waiting_client" } },
1871
- { autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
1872
2098
  {
1873
2099
  or: [
1874
- { lastClientMessageAt: { exists: false } },
1875
- { lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } }
2100
+ {
2101
+ and: [
2102
+ { autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
2103
+ {
2104
+ or: [
2105
+ { lastClientMessageAt: { exists: false } },
2106
+ { lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } }
2107
+ ]
2108
+ }
2109
+ ]
2110
+ },
2111
+ { autoCloseScheduledAt: { less_than_equal: now.toISOString() } }
1876
2112
  ]
1877
2113
  }
1878
2114
  ]
@@ -1888,21 +2124,23 @@ function createAutoCloseEndpoint(slugs) {
1888
2124
  const ticketNumber = t.ticketNumber || "TK-????";
1889
2125
  const subject = t.subject || "Support";
1890
2126
  const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS;
1891
- await payload.create({
1892
- collection: slugs.ticketMessages,
2127
+ const viaSchedule = !!t.autoCloseScheduledAt && new Date(t.autoCloseScheduledAt).getTime() <= now.getTime();
2128
+ const noteBody = viaSchedule ? "Ticket r\xE9solu automatiquement \u2014 relance manuelle rest\xE9e sans r\xE9ponse du client" : `Ticket r\xE9solu automatiquement \u2014 sans r\xE9ponse client depuis ${closeTotalDays} jours`;
2129
+ await dbCreate(payload, slugs.ticketMessages, {
1893
2130
  data: {
1894
2131
  ticket: t.id,
1895
- body: `Ticket r\xE9solu automatiquement \u2014 sans r\xE9ponse client depuis ${closeTotalDays} jours`,
2132
+ body: noteBody,
1896
2133
  authorType: "admin",
1897
2134
  isInternal: true,
1898
2135
  skipNotification: true
1899
2136
  },
1900
2137
  overrideAccess: true
1901
2138
  });
1902
- await payload.update({
1903
- collection: slugs.tickets,
2139
+ await dbUpdate(payload, slugs.tickets, {
1904
2140
  id: t.id,
1905
- data: { status: "resolved" },
2141
+ // Clear the armed deadline so a later manual reopen can't be
2142
+ // closed instantly by a stale timestamp.
2143
+ data: { status: "resolved", autoCloseScheduledAt: null },
1906
2144
  overrideAccess: true
1907
2145
  });
1908
2146
  if (client?.email) {
@@ -1913,7 +2151,7 @@ function createAutoCloseEndpoint(slugs) {
1913
2151
  subject: `[${ticketNumber}] Ticket r\xE9solu \u2014 ${subject}`,
1914
2152
  html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
1915
2153
  <p>Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,</p>
1916
- <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>
2154
+ <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>
1917
2155
  <p>Si vous avez encore besoin d'aide, n'h\xE9sitez pas \xE0 rouvrir ce ticket ou \xE0 en cr\xE9er un nouveau.</p>
1918
2156
  <p><a href="${portalUrl}">Consulter le ticket</a></p>
1919
2157
  </div>`
@@ -1938,27 +2176,206 @@ function createAutoCloseEndpoint(slugs) {
1938
2176
  };
1939
2177
  }
1940
2178
 
1941
- // src/endpoints/statuses.ts
1942
- function createStatusesEndpoint(slugs) {
2179
+ // src/utils/rateLimiter.ts
2180
+ var RateLimiter = class {
2181
+ constructor(windowMs, maxRequests) {
2182
+ this.windowMs = windowMs;
2183
+ this.maxRequests = maxRequests;
2184
+ const timer = setInterval(() => this.cleanup(), windowMs);
2185
+ timer.unref();
2186
+ }
2187
+ windowMs;
2188
+ maxRequests;
2189
+ store = /* @__PURE__ */ new Map();
2190
+ /**
2191
+ * Check if a key has exceeded the rate limit.
2192
+ * Returns true if the request should be blocked.
2193
+ */
2194
+ check(key) {
2195
+ const now = Date.now();
2196
+ const entry = this.store.get(key);
2197
+ if (!entry || now > entry.resetAt) {
2198
+ this.store.set(key, { count: 1, resetAt: now + this.windowMs });
2199
+ return false;
2200
+ }
2201
+ entry.count++;
2202
+ return entry.count > this.maxRequests;
2203
+ }
2204
+ /**
2205
+ * Reset the counter for a specific key.
2206
+ */
2207
+ reset(key) {
2208
+ this.store.delete(key);
2209
+ }
2210
+ /**
2211
+ * Remove expired entries from the store.
2212
+ */
2213
+ cleanup() {
2214
+ const now = Date.now();
2215
+ for (const [key, entry] of this.store) {
2216
+ if (now > entry.resetAt) {
2217
+ this.store.delete(key);
2218
+ }
2219
+ }
2220
+ }
2221
+ };
2222
+
2223
+ // src/endpoints/send-reminder.ts
2224
+ var reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30);
2225
+ var MIN_HOURS = 1;
2226
+ var MAX_HOURS = 24 * 30;
2227
+ function formatFr(date, withTime) {
2228
+ return date.toLocaleString("fr-FR", {
2229
+ day: "numeric",
2230
+ month: "long",
2231
+ year: "numeric",
2232
+ ...withTime ? { hour: "2-digit", minute: "2-digit" } : {},
2233
+ timeZone: "Europe/Paris"
2234
+ });
2235
+ }
2236
+ function createSendReminderEndpoint(slugs) {
1943
2237
  return {
1944
- path: "/support/statuses",
1945
- method: "get",
2238
+ path: "/support/send-reminder",
2239
+ method: "post",
1946
2240
  handler: async (req) => {
1947
2241
  try {
1948
2242
  const payload = req.payload;
1949
- if (!req.user) {
1950
- return Response.json({ error: "Unauthorized" }, { status: 401 });
2243
+ requireAdmin(req, slugs);
2244
+ if (reminderLimiter.check(String(req.user.id))) {
2245
+ return Response.json(
2246
+ { error: "Trop de relances. R\xE9essayez dans une heure." },
2247
+ { status: 429 }
2248
+ );
1951
2249
  }
1952
- const { docs } = await payload.find({
1953
- collection: slugs.ticketStatuses,
1954
- sort: "sortOrder",
1955
- limit: 100,
1956
- depth: 0,
2250
+ let body;
2251
+ try {
2252
+ body = await req.json();
2253
+ } catch {
2254
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
2255
+ }
2256
+ const { ticketId } = body;
2257
+ if (!ticketId) {
2258
+ return Response.json({ error: "ticketId requis" }, { status: 400 });
2259
+ }
2260
+ const rawHours = Number(body.hours);
2261
+ const hours = Number.isFinite(rawHours) ? Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.round(rawHours))) : 24;
2262
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2263
+ id: ticketId,
2264
+ depth: 1,
1957
2265
  overrideAccess: true
1958
2266
  });
1959
- return Response.json({
1960
- statuses: docs.map((s) => ({
1961
- id: s.id,
2267
+ if (!ticket) {
2268
+ return Response.json({ error: "Ticket introuvable" }, { status: 404 });
2269
+ }
2270
+ const client = typeof ticket.client === "object" ? ticket.client : null;
2271
+ if (!client?.email) {
2272
+ return Response.json({ error: "Client sans email" }, { status: 400 });
2273
+ }
2274
+ const now = /* @__PURE__ */ new Date();
2275
+ const deadline = new Date(now.getTime() + hours * 60 * 60 * 1e3);
2276
+ const settings = await readSupportSettings(payload);
2277
+ const ticketNumber = ticket.ticketNumber || "TK-????";
2278
+ const subject = ticket.subject || "Support";
2279
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
2280
+ const portalUrl = `${baseUrl}/support/tickets/${ticketId}`;
2281
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || "";
2282
+ let waitingSince = ticket.firstResponseAt || ticket.createdAt;
2283
+ try {
2284
+ const lastAdminMsg = await dbFind(payload, slugs.ticketMessages, {
2285
+ where: {
2286
+ and: [
2287
+ { ticket: { equals: ticketId } },
2288
+ { authorType: { equals: "admin" } },
2289
+ { isInternal: { equals: false } }
2290
+ ]
2291
+ },
2292
+ sort: "-createdAt",
2293
+ limit: 1,
2294
+ depth: 0,
2295
+ overrideAccess: true
2296
+ });
2297
+ if (lastAdminMsg.docs.length > 0 && lastAdminMsg.docs[0].createdAt) {
2298
+ waitingSince = lastAdminMsg.docs[0].createdAt;
2299
+ }
2300
+ } catch {
2301
+ }
2302
+ const sinceLabel = waitingSince ? formatFr(new Date(waitingSince), false) : null;
2303
+ const deadlineLabel = formatFr(deadline, true);
2304
+ await payload.sendEmail({
2305
+ to: client.email,
2306
+ ...replyTo ? { replyTo } : {},
2307
+ subject: `Rappel : [${ticketNumber}] ${subject} \u2014 votre r\xE9ponse est attendue`,
2308
+ html: emailWrapper(`Votre ticket attend votre r\xE9ponse`, [
2309
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
2310
+ emailParagraph(
2311
+ `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)}` : ""}.`
2312
+ ),
2313
+ emailParagraph(
2314
+ `<strong>Sans retour de votre part, ce ticket sera automatiquement cl\xF4tur\xE9 le ${escapeHtml(deadlineLabel)}.</strong>`
2315
+ ),
2316
+ emailParagraph(
2317
+ `Si vous avez encore besoin d'assistance, il vous suffit de r\xE9pondre \xE0 ce message \u2014 votre r\xE9ponse maintiendra le ticket ouvert.`
2318
+ ),
2319
+ emailButton("R\xE9pondre au ticket", portalUrl, "primary")
2320
+ ].join(""), {
2321
+ kind: "alert",
2322
+ preheader: `Sans r\xE9ponse de votre part, votre ticket ${ticketNumber} sera cl\xF4tur\xE9 le ${deadlineLabel}.`
2323
+ })
2324
+ });
2325
+ await dbCreate(payload, slugs.ticketMessages, {
2326
+ data: {
2327
+ ticket: ticketId,
2328
+ body: `Relance envoy\xE9e \xE0 ${client.email}. Fermeture automatique programm\xE9e le ${deadlineLabel} sans r\xE9ponse du client.`,
2329
+ authorType: "admin",
2330
+ isInternal: true,
2331
+ skipNotification: true
2332
+ },
2333
+ overrideAccess: true
2334
+ });
2335
+ await dbUpdate(payload, slugs.tickets, {
2336
+ id: ticketId,
2337
+ data: {
2338
+ status: "waiting_client",
2339
+ autoCloseRemindedAt: now.toISOString(),
2340
+ autoCloseScheduledAt: deadline.toISOString()
2341
+ },
2342
+ overrideAccess: true
2343
+ });
2344
+ return Response.json({
2345
+ success: true,
2346
+ sentTo: client.email,
2347
+ scheduledCloseAt: deadline.toISOString()
2348
+ });
2349
+ } catch (error) {
2350
+ const authResponse = handleAuthError(error);
2351
+ if (authResponse) return authResponse;
2352
+ console.error("[send-reminder] Error:", error);
2353
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
2354
+ }
2355
+ }
2356
+ };
2357
+ }
2358
+
2359
+ // src/endpoints/statuses.ts
2360
+ function createStatusesEndpoint(slugs) {
2361
+ return {
2362
+ path: "/support/statuses",
2363
+ method: "get",
2364
+ handler: async (req) => {
2365
+ try {
2366
+ const payload = req.payload;
2367
+ if (!req.user) {
2368
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
2369
+ }
2370
+ const { docs } = await dbFind(payload, slugs.ticketStatuses, {
2371
+ sort: "sortOrder",
2372
+ limit: 100,
2373
+ depth: 0,
2374
+ overrideAccess: true
2375
+ });
2376
+ return Response.json({
2377
+ statuses: docs.map((s) => ({
2378
+ id: s.id,
1962
2379
  name: s.name,
1963
2380
  slug: s.slug,
1964
2381
  color: s.color,
@@ -1989,16 +2406,14 @@ function createApplyMacroEndpoint(slugs) {
1989
2406
  if (!macroId || !ticketId) {
1990
2407
  return Response.json({ error: "macroId and ticketId are required" }, { status: 400 });
1991
2408
  }
1992
- const macro = await payload.findByID({
1993
- collection: slugs.macros,
2409
+ const macro = await dbFindByID(payload, slugs.macros, {
1994
2410
  id: macroId,
1995
2411
  depth: 0,
1996
2412
  overrideAccess: true
1997
2413
  });
1998
2414
  if (!macro) return Response.json({ error: "Macro not found" }, { status: 404 });
1999
2415
  if (!macro.isActive) return Response.json({ error: "Macro is disabled" }, { status: 400 });
2000
- const ticket = await payload.findByID({
2001
- collection: slugs.tickets,
2416
+ const ticket = await dbFindByID(payload, slugs.tickets, {
2002
2417
  id: ticketId,
2003
2418
  depth: 0,
2004
2419
  overrideAccess: true
@@ -2010,8 +2425,7 @@ function createApplyMacroEndpoint(slugs) {
2010
2425
  try {
2011
2426
  switch (action.type) {
2012
2427
  case "set_status":
2013
- await payload.update({
2014
- collection: slugs.tickets,
2428
+ await dbUpdate(payload, slugs.tickets, {
2015
2429
  id: ticketId,
2016
2430
  data: { status: action.value },
2017
2431
  overrideAccess: true
@@ -2019,8 +2433,7 @@ function createApplyMacroEndpoint(slugs) {
2019
2433
  appliedActions.push({ type: action.type, value: action.value, success: true });
2020
2434
  break;
2021
2435
  case "set_priority":
2022
- await payload.update({
2023
- collection: slugs.tickets,
2436
+ await dbUpdate(payload, slugs.tickets, {
2024
2437
  id: ticketId,
2025
2438
  data: { priority: action.value },
2026
2439
  overrideAccess: true
@@ -2032,8 +2445,7 @@ function createApplyMacroEndpoint(slugs) {
2032
2445
  if (!currentTags.includes(action.value)) {
2033
2446
  currentTags.push(action.value);
2034
2447
  }
2035
- await payload.update({
2036
- collection: slugs.tickets,
2448
+ await dbUpdate(payload, slugs.tickets, {
2037
2449
  id: ticketId,
2038
2450
  data: { tags: currentTags },
2039
2451
  overrideAccess: true
@@ -2042,8 +2454,7 @@ function createApplyMacroEndpoint(slugs) {
2042
2454
  break;
2043
2455
  }
2044
2456
  case "send_reply":
2045
- await payload.create({
2046
- collection: slugs.ticketMessages,
2457
+ await dbCreate(payload, slugs.ticketMessages, {
2047
2458
  data: {
2048
2459
  ticket: ticketId,
2049
2460
  body: action.value,
@@ -2060,8 +2471,7 @@ function createApplyMacroEndpoint(slugs) {
2060
2471
  appliedActions.push({ type: action.type, value: action.value, success: false, error: "Invalid user ID" });
2061
2472
  break;
2062
2473
  }
2063
- await payload.update({
2064
- collection: slugs.tickets,
2474
+ await dbUpdate(payload, slugs.tickets, {
2065
2475
  id: ticketId,
2066
2476
  data: { assignedTo: userId },
2067
2477
  overrideAccess: true
@@ -2113,8 +2523,7 @@ function createPurgeLogsEndpoint(slugs) {
2113
2523
  return Response.json({ error: "Invalid collection. Use email-logs or auth-logs." }, { status: 400 });
2114
2524
  }
2115
2525
  const cutoff = days > 0 ? new Date(Date.now() - days * 864e5).toISOString() : null;
2116
- const result = await payload.delete({
2117
- collection: allowedCollections[collection],
2526
+ const result = await dbDelete(payload, allowedCollections[collection], {
2118
2527
  where: cutoff ? { createdAt: { less_than: cutoff } } : { id: { exists: true } },
2119
2528
  overrideAccess: true
2120
2529
  });
@@ -2134,50 +2543,6 @@ function createPurgeLogsEndpoint(slugs) {
2134
2543
  };
2135
2544
  }
2136
2545
 
2137
- // src/utils/rateLimiter.ts
2138
- var RateLimiter = class {
2139
- constructor(windowMs, maxRequests) {
2140
- this.windowMs = windowMs;
2141
- this.maxRequests = maxRequests;
2142
- const timer = setInterval(() => this.cleanup(), windowMs);
2143
- timer.unref();
2144
- }
2145
- windowMs;
2146
- maxRequests;
2147
- store = /* @__PURE__ */ new Map();
2148
- /**
2149
- * Check if a key has exceeded the rate limit.
2150
- * Returns true if the request should be blocked.
2151
- */
2152
- check(key) {
2153
- const now = Date.now();
2154
- const entry = this.store.get(key);
2155
- if (!entry || now > entry.resetAt) {
2156
- this.store.set(key, { count: 1, resetAt: now + this.windowMs });
2157
- return false;
2158
- }
2159
- entry.count++;
2160
- return entry.count > this.maxRequests;
2161
- }
2162
- /**
2163
- * Reset the counter for a specific key.
2164
- */
2165
- reset(key) {
2166
- this.store.delete(key);
2167
- }
2168
- /**
2169
- * Remove expired entries from the store.
2170
- */
2171
- cleanup() {
2172
- const now = Date.now();
2173
- for (const [key, entry] of this.store) {
2174
- if (now > entry.resetAt) {
2175
- this.store.delete(key);
2176
- }
2177
- }
2178
- }
2179
- };
2180
-
2181
2546
  // src/endpoints/chatbot.ts
2182
2547
  var chatbotLimiter = new RateLimiter(6e4, 10);
2183
2548
  function createChatbotEndpoint(slugs) {
@@ -2201,8 +2566,7 @@ function createChatbotEndpoint(slugs) {
2201
2566
  return Response.json({ error: "Question too short" }, { status: 400 });
2202
2567
  }
2203
2568
  const payload = req.payload;
2204
- const articles = await payload.find({
2205
- collection: slugs.knowledgeBase,
2569
+ const articles = await dbFind(payload, slugs.knowledgeBase, {
2206
2570
  where: { published: { equals: true } },
2207
2571
  limit: 100,
2208
2572
  depth: 0,
@@ -2292,8 +2656,7 @@ function createChatGetEndpoint(slugs) {
2292
2656
  if (after) {
2293
2657
  where.createdAt = { greater_than: after };
2294
2658
  }
2295
- const messages = await payload.find({
2296
- collection: slugs.chatMessages,
2659
+ const messages = await dbFind(payload, slugs.chatMessages, {
2297
2660
  where,
2298
2661
  sort: "createdAt",
2299
2662
  limit: 100,
@@ -2336,8 +2699,7 @@ function createChatPostEndpoint(slugs) {
2336
2699
  return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
2337
2700
  }
2338
2701
  const sessionId = `chat_${crypto3.randomBytes(16).toString("hex")}`;
2339
- const systemMsg = await payload.create({
2340
- collection: slugs.chatMessages,
2702
+ const systemMsg = await dbCreate(payload, slugs.chatMessages, {
2341
2703
  data: {
2342
2704
  session: sessionId,
2343
2705
  client: req.user.id,
@@ -2357,8 +2719,7 @@ function createChatPostEndpoint(slugs) {
2357
2719
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2358
2720
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2359
2721
  }
2360
- const existing = await payload.find({
2361
- collection: slugs.chatMessages,
2722
+ const existing = await dbFind(payload, slugs.chatMessages, {
2362
2723
  where: { session: { equals: session }, client: { equals: req.user.id } },
2363
2724
  limit: 1,
2364
2725
  overrideAccess: true
@@ -2366,8 +2727,7 @@ function createChatPostEndpoint(slugs) {
2366
2727
  if (existing.docs.length === 0) {
2367
2728
  return Response.json({ error: "Session invalide" }, { status: 403 });
2368
2729
  }
2369
- const newMsg = await payload.create({
2370
- collection: slugs.chatMessages,
2730
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2371
2731
  data: {
2372
2732
  session,
2373
2733
  client: req.user.id,
@@ -2380,8 +2740,7 @@ function createChatPostEndpoint(slugs) {
2380
2740
  return Response.json({ message: newMsg });
2381
2741
  }
2382
2742
  if (action === "close" && session) {
2383
- const existing = await payload.find({
2384
- collection: slugs.chatMessages,
2743
+ const existing = await dbFind(payload, slugs.chatMessages, {
2385
2744
  where: { session: { equals: session }, client: { equals: req.user.id } },
2386
2745
  limit: 1,
2387
2746
  overrideAccess: true
@@ -2389,8 +2748,7 @@ function createChatPostEndpoint(slugs) {
2389
2748
  if (existing.docs.length === 0) {
2390
2749
  return Response.json({ error: "Session invalide" }, { status: 403 });
2391
2750
  }
2392
- await payload.create({
2393
- collection: slugs.chatMessages,
2751
+ await dbCreate(payload, slugs.chatMessages, {
2394
2752
  data: {
2395
2753
  session,
2396
2754
  client: req.user.id,
@@ -2432,8 +2790,7 @@ function createChatStreamEndpoint(slugs) {
2432
2790
  if (!sessionId) {
2433
2791
  return Response.json({ error: "Missing session parameter" }, { status: 400 });
2434
2792
  }
2435
- const existing = await req.payload.find({
2436
- collection: slugs.chatMessages,
2793
+ const existing = await dbFind(req.payload, slugs.chatMessages, {
2437
2794
  where: {
2438
2795
  session: { equals: sessionId },
2439
2796
  client: { equals: userId }
@@ -2455,8 +2812,7 @@ function createChatStreamEndpoint(slugs) {
2455
2812
  );
2456
2813
  const interval = setInterval(async () => {
2457
2814
  try {
2458
- const messages = await req.payload.find({
2459
- collection: slugs.chatMessages,
2815
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2460
2816
  where: {
2461
2817
  session: { equals: sessionId },
2462
2818
  createdAt: { greater_than: lastCheck }
@@ -2537,8 +2893,7 @@ function createAdminChatGetEndpoint(slugs) {
2537
2893
  if (session) {
2538
2894
  const where = { session: { equals: session } };
2539
2895
  if (after) where.createdAt = { greater_than: after };
2540
- const messages = await payload.find({
2541
- collection: slugs.chatMessages,
2896
+ const messages = await dbFind(payload, slugs.chatMessages, {
2542
2897
  where,
2543
2898
  sort: "createdAt",
2544
2899
  limit: 200,
@@ -2552,8 +2907,7 @@ function createAdminChatGetEndpoint(slugs) {
2552
2907
  }
2553
2908
  });
2554
2909
  }
2555
- const recentMessages = await payload.find({
2556
- collection: slugs.chatMessages,
2910
+ const recentMessages = await dbFind(payload, slugs.chatMessages, {
2557
2911
  sort: "-createdAt",
2558
2912
  limit: 50,
2559
2913
  depth: 1,
@@ -2616,8 +2970,7 @@ function createAdminChatPostEndpoint(slugs) {
2616
2970
  if (!session) {
2617
2971
  return Response.json({ error: "Session requise" }, { status: 400 });
2618
2972
  }
2619
- const sessionMsg = await payload.find({
2620
- collection: slugs.chatMessages,
2973
+ const sessionMsg = await dbFind(payload, slugs.chatMessages, {
2621
2974
  where: { session: { equals: session } },
2622
2975
  limit: 1,
2623
2976
  depth: 0,
@@ -2635,8 +2988,7 @@ function createAdminChatPostEndpoint(slugs) {
2635
2988
  if (!trimmedMessage || trimmedMessage.length > 2e3) {
2636
2989
  return Response.json({ error: "Message invalide (1-2000 caract\xE8res)." }, { status: 400 });
2637
2990
  }
2638
- const newMsg = await payload.create({
2639
- collection: slugs.chatMessages,
2991
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
2640
2992
  data: {
2641
2993
  session,
2642
2994
  client: clientId,
@@ -2648,8 +3000,7 @@ function createAdminChatPostEndpoint(slugs) {
2648
3000
  overrideAccess: true
2649
3001
  });
2650
3002
  try {
2651
- const linkedTicket = await payload.find({
2652
- collection: slugs.tickets,
3003
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2653
3004
  where: { chatSession: { equals: session } },
2654
3005
  limit: 1,
2655
3006
  depth: 0,
@@ -2657,8 +3008,7 @@ function createAdminChatPostEndpoint(slugs) {
2657
3008
  });
2658
3009
  if (linkedTicket.docs.length > 0) {
2659
3010
  const ticketId = linkedTicket.docs[0].id;
2660
- await payload.create({
2661
- collection: slugs.ticketMessages,
3011
+ await dbCreate(payload, slugs.ticketMessages, {
2662
3012
  data: {
2663
3013
  ticket: ticketId,
2664
3014
  body: trimmedMessage,
@@ -2667,8 +3017,7 @@ function createAdminChatPostEndpoint(slugs) {
2667
3017
  },
2668
3018
  overrideAccess: true
2669
3019
  });
2670
- await payload.update({
2671
- collection: slugs.chatMessages,
3020
+ await dbUpdate(payload, slugs.chatMessages, {
2672
3021
  id: newMsg.id,
2673
3022
  data: { ticket: ticketId },
2674
3023
  overrideAccess: true
@@ -2680,8 +3029,7 @@ function createAdminChatPostEndpoint(slugs) {
2680
3029
  return Response.json({ message: newMsg });
2681
3030
  }
2682
3031
  if (action === "close") {
2683
- await payload.create({
2684
- collection: slugs.chatMessages,
3032
+ await dbCreate(payload, slugs.chatMessages, {
2685
3033
  data: {
2686
3034
  session,
2687
3035
  client: clientId,
@@ -2692,16 +3040,14 @@ function createAdminChatPostEndpoint(slugs) {
2692
3040
  overrideAccess: true
2693
3041
  });
2694
3042
  try {
2695
- const linkedTicket = await payload.find({
2696
- collection: slugs.tickets,
3043
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
2697
3044
  where: { chatSession: { equals: session } },
2698
3045
  limit: 1,
2699
3046
  depth: 0,
2700
3047
  overrideAccess: true
2701
3048
  });
2702
3049
  if (linkedTicket.docs.length > 0) {
2703
- await payload.create({
2704
- collection: slugs.ticketMessages,
3050
+ await dbCreate(payload, slugs.ticketMessages, {
2705
3051
  data: {
2706
3052
  ticket: linkedTicket.docs[0].id,
2707
3053
  body: "Session de chat termin\xE9e par un agent.",
@@ -2764,8 +3110,7 @@ function createSessionStream(req, slugs, sessionId) {
2764
3110
  );
2765
3111
  const interval = setInterval(async () => {
2766
3112
  try {
2767
- const messages = await req.payload.find({
2768
- collection: slugs.chatMessages,
3113
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
2769
3114
  where: {
2770
3115
  session: { equals: sessionId },
2771
3116
  createdAt: { greater_than: lastCheck }
@@ -2838,8 +3183,7 @@ function createSessionListStream(req, slugs) {
2838
3183
  );
2839
3184
  const interval = setInterval(async () => {
2840
3185
  try {
2841
- const recentMessages = await req.payload.find({
2842
- collection: slugs.chatMessages,
3186
+ const recentMessages = await dbFind(req.payload, slugs.chatMessages, {
2843
3187
  sort: "-createdAt",
2844
3188
  limit: 50,
2845
3189
  depth: 1,
@@ -2926,8 +3270,7 @@ function createAdminStatsEndpoint(slugs) {
2926
3270
  const statuses = ["open", "waiting_client", "resolved"];
2927
3271
  const statusCounts = await Promise.all(
2928
3272
  statuses.map(async (status) => {
2929
- const result = await payload.count({
2930
- collection: slugs.tickets,
3273
+ const result = await dbCount(payload, slugs.tickets, {
2931
3274
  where: { status: { equals: status } },
2932
3275
  overrideAccess: true
2933
3276
  });
@@ -2939,8 +3282,7 @@ function createAdminStatsEndpoint(slugs) {
2939
3282
  const priorities = ["low", "normal", "high", "urgent"];
2940
3283
  const priorityCounts = await Promise.all(
2941
3284
  priorities.map(async (priority) => {
2942
- const result = await payload.count({
2943
- collection: slugs.tickets,
3285
+ const result = await dbCount(payload, slugs.tickets, {
2944
3286
  where: { priority: { equals: priority } },
2945
3287
  overrideAccess: true
2946
3288
  });
@@ -2951,8 +3293,7 @@ function createAdminStatsEndpoint(slugs) {
2951
3293
  const categories = ["bug", "content", "feature", "question", "hosting"];
2952
3294
  const categoryCounts = await Promise.all(
2953
3295
  categories.map(async (category) => {
2954
- const result = await payload.count({
2955
- collection: slugs.tickets,
3296
+ const result = await dbCount(payload, slugs.tickets, {
2956
3297
  where: { category: { equals: category } },
2957
3298
  overrideAccess: true
2958
3299
  });
@@ -2966,17 +3307,34 @@ function createAdminStatsEndpoint(slugs) {
2966
3307
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
2967
3308
  const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
2968
3309
  const [created7, created30] = await Promise.all([
2969
- payload.count({
2970
- collection: slugs.tickets,
3310
+ dbCount(payload, slugs.tickets, {
2971
3311
  where: { createdAt: { greater_than_equal: sevenDaysAgo.toISOString() } },
2972
3312
  overrideAccess: true
2973
3313
  }),
2974
- payload.count({
2975
- collection: slugs.tickets,
3314
+ dbCount(payload, slugs.tickets, {
2976
3315
  where: { createdAt: { greater_than_equal: thirtyDaysAgo.toISOString() } },
2977
3316
  overrideAccess: true
2978
3317
  })
2979
3318
  ]);
3319
+ const volumeByDay = await Promise.all(
3320
+ Array.from({ length: 7 }, (_, i) => i).map(async (offset) => {
3321
+ const dayStart = new Date(now);
3322
+ dayStart.setHours(0, 0, 0, 0);
3323
+ dayStart.setDate(dayStart.getDate() - (6 - offset));
3324
+ const dayEnd = new Date(dayStart);
3325
+ dayEnd.setDate(dayEnd.getDate() + 1);
3326
+ const r = await dbCount(payload, slugs.tickets, {
3327
+ where: {
3328
+ and: [
3329
+ { createdAt: { greater_than_equal: dayStart.toISOString() } },
3330
+ { createdAt: { less_than: dayEnd.toISOString() } }
3331
+ ]
3332
+ },
3333
+ overrideAccess: true
3334
+ });
3335
+ return { date: dayStart.toISOString(), count: r.totalDocs };
3336
+ })
3337
+ );
2980
3338
  const PAGE_SIZE2 = 100;
2981
3339
  const MAX_PAGES2 = 50;
2982
3340
  let totalResponseTimeMs = 0;
@@ -2987,8 +3345,7 @@ function createAdminStatsEndpoint(slugs) {
2987
3345
  let page = 1;
2988
3346
  let hasMore = true;
2989
3347
  while (hasMore && page <= MAX_PAGES2) {
2990
- const batch = await payload.find({
2991
- collection: slugs.tickets,
3348
+ const batch = await dbFind(payload, slugs.tickets, {
2992
3349
  limit: PAGE_SIZE2,
2993
3350
  page,
2994
3351
  depth: 0,
@@ -3021,43 +3378,62 @@ function createAdminStatsEndpoint(slugs) {
3021
3378
  hasMore = batch.hasNextPage ?? false;
3022
3379
  page++;
3023
3380
  }
3024
- const surveyCount = await payload.count({
3025
- collection: slugs.satisfactionSurveys,
3381
+ const surveyCount = await dbCount(payload, slugs.satisfactionSurveys, {
3026
3382
  overrideAccess: true
3027
3383
  });
3028
3384
  let satisfactionAvg = 0;
3385
+ let satisfactionCount = 0;
3386
+ let npsScore = null;
3387
+ let npsCount = 0;
3029
3388
  if (surveyCount.totalDocs > 0) {
3030
- let totalRating = 0;
3389
+ let ratingSum = 0;
3390
+ let ratingN = 0;
3391
+ let promoters = 0;
3392
+ let detractors = 0;
3393
+ let npsN = 0;
3031
3394
  let surveyPage = 1;
3032
3395
  let surveyHasMore = true;
3033
3396
  while (surveyHasMore && surveyPage <= MAX_PAGES2) {
3034
- const batch = await payload.find({
3035
- collection: slugs.satisfactionSurveys,
3397
+ const batch = await dbFind(payload, slugs.satisfactionSurveys, {
3036
3398
  limit: PAGE_SIZE2,
3037
3399
  page: surveyPage,
3038
3400
  depth: 0,
3039
3401
  overrideAccess: true,
3040
- select: { rating: true }
3402
+ select: { rating: true, nps: true }
3041
3403
  });
3042
3404
  for (const s of batch.docs) {
3043
- totalRating += s.rating || 0;
3405
+ const r = s.rating;
3406
+ if (typeof r === "number" && r > 0) {
3407
+ ratingSum += r;
3408
+ ratingN++;
3409
+ }
3410
+ const n = s.nps;
3411
+ if (typeof n === "number") {
3412
+ npsN++;
3413
+ if (n >= 9) promoters++;
3414
+ else if (n <= 6) detractors++;
3415
+ }
3044
3416
  }
3045
3417
  surveyHasMore = batch.hasNextPage ?? false;
3046
3418
  surveyPage++;
3047
3419
  }
3048
- satisfactionAvg = Math.round(totalRating / surveyCount.totalDocs * 10) / 10;
3420
+ satisfactionAvg = ratingN > 0 ? Math.round(ratingSum / ratingN * 10) / 10 : 0;
3421
+ satisfactionCount = ratingN;
3422
+ if (npsN > 0) {
3423
+ npsScore = Math.round((promoters - detractors) / npsN * 100);
3424
+ npsCount = npsN;
3425
+ }
3049
3426
  }
3050
- const [clientCount, pendingEmailsCount] = await Promise.all([
3051
- payload.count({
3052
- collection: slugs.supportClients,
3053
- overrideAccess: true
3054
- }),
3055
- payload.count({
3056
- collection: slugs.pendingEmails,
3427
+ const clientCount = await dbCount(payload, slugs.supportClients, { overrideAccess: true });
3428
+ let pendingEmailsTotal = 0;
3429
+ try {
3430
+ const pe = await dbCount(payload, slugs.pendingEmails, {
3057
3431
  where: { status: { equals: "pending" } },
3058
3432
  overrideAccess: true
3059
- })
3060
- ]);
3433
+ });
3434
+ pendingEmailsTotal = pe.totalDocs;
3435
+ } catch {
3436
+ }
3061
3437
  return new Response(JSON.stringify({
3062
3438
  total,
3063
3439
  byStatus,
@@ -3065,13 +3441,16 @@ function createAdminStatsEndpoint(slugs) {
3065
3441
  byCategory,
3066
3442
  createdLast7Days: created7.totalDocs,
3067
3443
  createdLast30Days: created30.totalDocs,
3444
+ volumeByDay,
3068
3445
  avgResponseTimeHours: responseTimeCount > 0 ? Math.round(totalResponseTimeMs / responseTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3069
3446
  avgResolutionTimeHours: resolutionTimeCount > 0 ? Math.round(totalResolutionTimeMs / resolutionTimeCount / (1e3 * 60 * 60) * 10) / 10 : null,
3070
3447
  totalTimeMinutes,
3071
3448
  satisfactionAvg,
3072
- satisfactionCount: surveyCount.totalDocs,
3449
+ satisfactionCount,
3450
+ npsScore,
3451
+ npsCount,
3073
3452
  clientCount: clientCount.totalDocs,
3074
- pendingEmailsCount: pendingEmailsCount.totalDocs
3453
+ pendingEmailsCount: pendingEmailsTotal
3075
3454
  }), {
3076
3455
  headers: {
3077
3456
  "Content-Type": "application/json",
@@ -3141,13 +3520,26 @@ function createBillingEndpoint(slugs) {
3141
3520
  let ticketPage = 1;
3142
3521
  let ticketHasMore = true;
3143
3522
  while (ticketHasMore && ticketPage <= MAX_PAGES) {
3144
- const batch = await payload.find({
3145
- collection: slugs.tickets,
3523
+ const batch = await dbFind(payload, slugs.tickets, {
3146
3524
  where: ticketWhere,
3147
3525
  limit: PAGE_SIZE,
3148
3526
  page: ticketPage,
3527
+ // depth 2 is required: we read ticket.project.client.company below.
3149
3528
  depth: 2,
3150
- overrideAccess: true
3529
+ overrideAccess: true,
3530
+ // Bound the payload to only the fields the report consumes (skips body,
3531
+ // richText, SLA fields, etc.) while keeping project/client populated.
3532
+ select: {
3533
+ ticketNumber: true,
3534
+ subject: true,
3535
+ status: true,
3536
+ billedAmount: true,
3537
+ aiSummary: true,
3538
+ aiSummaryGeneratedAt: true,
3539
+ aiSummaryStatus: true,
3540
+ project: true,
3541
+ client: true
3542
+ }
3151
3543
  });
3152
3544
  allTickets.push(...batch.docs);
3153
3545
  ticketHasMore = batch.hasNextPage ?? false;
@@ -3157,8 +3549,7 @@ function createBillingEndpoint(slugs) {
3157
3549
  let entryPage = 1;
3158
3550
  let entryHasMore = true;
3159
3551
  while (entryHasMore && entryPage <= MAX_PAGES) {
3160
- const batch = await payload.find({
3161
- collection: slugs.timeEntries,
3552
+ const batch = await dbFind(payload, slugs.timeEntries, {
3162
3553
  where: {
3163
3554
  and: [
3164
3555
  { date: { greater_than_equal: from } },
@@ -3256,9 +3647,174 @@ function createBillingEndpoint(slugs) {
3256
3647
  }
3257
3648
  };
3258
3649
  }
3650
+ var fmtAmount = (n) => `${n.toFixed(2)} EUR`;
3651
+ var fmtTime = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3652
+ function generateInvoicePdf(data) {
3653
+ return new Promise((resolve, reject) => {
3654
+ try {
3655
+ const doc = new PDFDocument({ size: "A4", margin: 50 });
3656
+ const chunks = [];
3657
+ doc.on("data", (c) => chunks.push(c));
3658
+ doc.on("end", () => resolve(Buffer.concat(chunks)));
3659
+ doc.on("error", reject);
3660
+ doc.fillColor("#0f172a").fontSize(22).text("Invoice / Pre-billing");
3661
+ doc.moveDown(0.2);
3662
+ doc.fillColor("#64748b").fontSize(10).text(`Period: ${data.from} -> ${data.to}${data.projectId ? ` \xB7 Project #${data.projectId}` : ""}`);
3663
+ doc.moveDown(1);
3664
+ const X = { ticket: 50, subject: 130, client: 320, time: 430, amount: 500 };
3665
+ const drawRow = (r, opts) => {
3666
+ const y = doc.y;
3667
+ doc.fontSize(opts?.header ? 9 : 10).fillColor(opts?.header ? "#475569" : "#0f172a");
3668
+ if (opts?.total) doc.font("Helvetica-Bold");
3669
+ doc.text(r.ticket, X.ticket, y, { width: X.subject - X.ticket - 6 });
3670
+ doc.text(r.subject, X.subject, y, { width: X.client - X.subject - 6 });
3671
+ doc.text(r.client, X.client, y, { width: X.time - X.client - 6 });
3672
+ doc.text(r.time, X.time, y, { width: X.amount - X.time - 6, align: "right" });
3673
+ doc.text(r.amount, X.amount, y, { width: 545 - X.amount, align: "right" });
3674
+ doc.font("Helvetica");
3675
+ doc.moveDown(0.4);
3676
+ };
3677
+ drawRow({ ticket: "Ticket", subject: "Subject", client: "Client", time: "Time", amount: "Amount" }, { header: true });
3678
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#e2e8f0").stroke();
3679
+ doc.moveDown(0.3);
3680
+ for (const l of data.lines) {
3681
+ if (doc.y > 760) doc.addPage();
3682
+ drawRow({
3683
+ ticket: l.ticketNumber || "",
3684
+ subject: l.subject || "",
3685
+ client: l.client || "",
3686
+ time: fmtTime(l.minutes),
3687
+ amount: fmtAmount(l.amount)
3688
+ });
3689
+ }
3690
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor("#0f172a").stroke();
3691
+ doc.moveDown(0.3);
3692
+ drawRow({
3693
+ ticket: "",
3694
+ subject: `Total (${data.lines.length} ticket${data.lines.length > 1 ? "s" : ""})`,
3695
+ client: "",
3696
+ time: fmtTime(data.totalMinutes),
3697
+ amount: fmtAmount(data.totalAmount)
3698
+ }, { total: true });
3699
+ doc.end();
3700
+ } catch (err) {
3701
+ reject(err);
3702
+ }
3703
+ });
3704
+ }
3705
+
3706
+ // src/endpoints/invoice.ts
3707
+ function createInvoiceEndpoint(slugs) {
3708
+ return {
3709
+ path: "/support/billing/invoice",
3710
+ method: "get",
3711
+ handler: async (req) => {
3712
+ try {
3713
+ requireAdmin(req, slugs);
3714
+ const payload = req.payload;
3715
+ const url = new URL(req.url);
3716
+ const from = url.searchParams.get("from");
3717
+ const to = url.searchParams.get("to");
3718
+ const projectId = url.searchParams.get("projectId");
3719
+ const format = url.searchParams.get("format") === "pdf" ? "pdf" : "html";
3720
+ if (!from || !to) {
3721
+ return Response.json({ error: "Param\xE8tres from et to requis." }, { status: 400 });
3722
+ }
3723
+ const toExclusive = new Date(to);
3724
+ toExclusive.setDate(toExclusive.getDate() + 1);
3725
+ const toIso = toExclusive.toISOString();
3726
+ const where = {
3727
+ and: [
3728
+ { billable: { equals: true } },
3729
+ ...projectId ? [{ project: { equals: Number(projectId) } }] : [],
3730
+ {
3731
+ or: [
3732
+ { and: [{ updatedAt: { greater_than_equal: from } }, { updatedAt: { less_than: toIso } }] },
3733
+ { and: [{ createdAt: { greater_than_equal: from } }, { createdAt: { less_than: toIso } }] },
3734
+ { and: [{ resolvedAt: { greater_than_equal: from } }, { resolvedAt: { less_than: toIso } }] }
3735
+ ]
3736
+ }
3737
+ ]
3738
+ };
3739
+ const tickets = [];
3740
+ let page = 1;
3741
+ let hasMore = true;
3742
+ while (hasMore && page <= 50) {
3743
+ const batch = await dbFind(payload, slugs.tickets, {
3744
+ where,
3745
+ limit: 500,
3746
+ page,
3747
+ depth: 2,
3748
+ overrideAccess: true,
3749
+ select: { ticketNumber: true, subject: true, billedAmount: true, totalTimeMinutes: true, project: true, client: true }
3750
+ });
3751
+ tickets.push(...batch.docs);
3752
+ hasMore = batch.hasNextPage ?? false;
3753
+ page++;
3754
+ }
3755
+ let totalAmount = 0;
3756
+ let totalMinutes = 0;
3757
+ const lines = tickets.map((t) => {
3758
+ const amount = typeof t.billedAmount === "number" ? t.billedAmount : 0;
3759
+ const minutes = typeof t.totalTimeMinutes === "number" ? t.totalTimeMinutes : 0;
3760
+ totalAmount += amount;
3761
+ totalMinutes += minutes;
3762
+ const client = typeof t.client === "object" && t.client ? t.client.company || "" : "";
3763
+ return { ticketNumber: String(t.ticketNumber || ""), subject: String(t.subject || ""), client, minutes, amount };
3764
+ });
3765
+ if (format === "pdf") {
3766
+ const pdf = await generateInvoicePdf({ from, to, projectId, lines, totalMinutes, totalAmount });
3767
+ return new Response(new Uint8Array(pdf), {
3768
+ status: 200,
3769
+ headers: {
3770
+ "Content-Type": "application/pdf",
3771
+ "Content-Disposition": `inline; filename="invoice-${from}_${to}.pdf"`
3772
+ }
3773
+ });
3774
+ }
3775
+ const fmtAmount2 = (n) => `${n.toFixed(2)} \u20AC`;
3776
+ const fmtTime2 = (min) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
3777
+ const rows = lines.map((l) => `<tr>
3778
+ <td>${escapeHtml(l.ticketNumber)}</td>
3779
+ <td>${escapeHtml(l.subject)}</td>
3780
+ <td>${escapeHtml(l.client)}</td>
3781
+ <td style="text-align:right;">${fmtTime2(l.minutes)}</td>
3782
+ <td style="text-align:right;">${fmtAmount2(l.amount)}</td>
3783
+ </tr>`).join("");
3784
+ const html = `<!doctype html>
3785
+ <html lang="fr"><head><meta charset="utf-8"><title>Facture ${escapeHtml(from)} \u2192 ${escapeHtml(to)}</title>
3786
+ <style>
3787
+ body { font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 820px; margin: 32px auto; padding: 0 24px; }
3788
+ h1 { font-size: 22px; margin: 0 0 4px; }
3789
+ .period { color: #64748b; margin-bottom: 24px; }
3790
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
3791
+ th, td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; text-align: left; }
3792
+ th { background: #f8fafc; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #475569; }
3793
+ tfoot td { font-weight: 700; border-top: 2px solid #0f172a; border-bottom: none; }
3794
+ @media print { body { margin: 0; } }
3795
+ </style></head>
3796
+ <body>
3797
+ <h1>Facture / Pr\xE9-facture</h1>
3798
+ <div class="period">P\xE9riode : ${escapeHtml(from)} \u2192 ${escapeHtml(to)}${projectId ? ` \xB7 Projet #${escapeHtml(projectId)}` : ""}</div>
3799
+ <table>
3800
+ <thead><tr><th>Ticket</th><th>Sujet</th><th>Client</th><th style="text-align:right;">Temps</th><th style="text-align:right;">Montant</th></tr></thead>
3801
+ <tbody>${rows || '<tr><td colspan="5" style="color:#94a3b8;">Aucun ticket facturable sur la p\xE9riode.</td></tr>'}</tbody>
3802
+ <tfoot><tr><td colspan="3">Total (${lines.length} ticket${lines.length > 1 ? "s" : ""})</td><td style="text-align:right;">${fmtTime2(totalMinutes)}</td><td style="text-align:right;">${fmtAmount2(totalAmount)}</td></tr></tfoot>
3803
+ </table>
3804
+ </body></html>`;
3805
+ return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
3806
+ } catch (error) {
3807
+ const authResponse = handleAuthError(error);
3808
+ if (authResponse) return authResponse;
3809
+ console.error("[invoice] Error:", error);
3810
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
3811
+ }
3812
+ }
3813
+ };
3814
+ }
3259
3815
 
3260
3816
  // src/utils/generateTicketSynthesis.ts
3261
- function getClient3(aiSettings) {
3817
+ function getClient4(aiSettings) {
3262
3818
  const Anthropic = __require("@anthropic-ai/sdk").default;
3263
3819
  if (aiSettings.provider === "ollama") {
3264
3820
  const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
@@ -3275,8 +3831,7 @@ async function generateTicketSynthesis(args) {
3275
3831
  if (settings.ai.enableSynthesis === false) {
3276
3832
  return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "skipped", reason: "synthesis disabled" };
3277
3833
  }
3278
- const ticket = await payload.findByID({
3279
- collection: slugs.tickets,
3834
+ const ticket = await dbFindByID(payload, slugs.tickets, {
3280
3835
  id: ticketId,
3281
3836
  depth: 1,
3282
3837
  overrideAccess: true
@@ -3284,15 +3839,13 @@ async function generateTicketSynthesis(args) {
3284
3839
  if (!ticket) {
3285
3840
  return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: "ticket not found" };
3286
3841
  }
3287
- await payload.update({
3288
- collection: slugs.tickets,
3842
+ await dbUpdate(payload, slugs.tickets, {
3289
3843
  id: ticketId,
3290
3844
  data: { aiSummaryStatus: "pending" },
3291
3845
  overrideAccess: true
3292
3846
  }).catch(() => {
3293
3847
  });
3294
- const messagesResult = await payload.find({
3295
- collection: slugs.ticketMessages,
3848
+ const messagesResult = await dbFind(payload, slugs.ticketMessages, {
3296
3849
  where: { ticket: { equals: ticketId } },
3297
3850
  sort: "createdAt",
3298
3851
  limit: 500,
@@ -3302,8 +3855,7 @@ async function generateTicketSynthesis(args) {
3302
3855
  const messages = messagesResult.docs;
3303
3856
  if (messages.length === 0) {
3304
3857
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3305
- await payload.update({
3306
- collection: slugs.tickets,
3858
+ await dbUpdate(payload, slugs.tickets, {
3307
3859
  id: ticketId,
3308
3860
  data: {
3309
3861
  aiSummary: "(Aucun message dans ce ticket)",
@@ -3348,7 +3900,7 @@ Regles strictes :
3348
3900
  - Si le ticket n'a pas abouti, decris quand meme le travail d'analyse realise
3349
3901
 
3350
3902
  Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3351
- const anthropic = getClient3(settings.ai);
3903
+ const anthropic = getClient4(settings.ai);
3352
3904
  const model = getModel3(settings.ai);
3353
3905
  try {
3354
3906
  const res = await anthropic.messages.create({
@@ -3358,8 +3910,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3358
3910
  });
3359
3911
  const summary = res.content[0]?.type === "text" ? res.content[0].text.trim() : "";
3360
3912
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3361
- await payload.update({
3362
- collection: slugs.tickets,
3913
+ await dbUpdate(payload, slugs.tickets, {
3363
3914
  id: ticketId,
3364
3915
  data: {
3365
3916
  aiSummary: summary,
@@ -3371,8 +3922,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3371
3922
  return { summary, generatedAt, status: "done" };
3372
3923
  } catch (err) {
3373
3924
  const message = err instanceof Error ? err.message : String(err);
3374
- await payload.update({
3375
- collection: slugs.tickets,
3925
+ await dbUpdate(payload, slugs.tickets, {
3376
3926
  id: ticketId,
3377
3927
  data: { aiSummaryStatus: "error" },
3378
3928
  overrideAccess: true
@@ -3402,8 +3952,7 @@ function createTicketSynthesisEndpoint(slugs) {
3402
3952
  return Response.json({ error: "ticketId must be a number" }, { status: 400 });
3403
3953
  }
3404
3954
  if (!force) {
3405
- const existing = await payload.findByID({
3406
- collection: slugs.tickets,
3955
+ const existing = await dbFindByID(payload, slugs.tickets, {
3407
3956
  id: ticketId,
3408
3957
  depth: 0,
3409
3958
  overrideAccess: true
@@ -3447,8 +3996,7 @@ function createEmailStatsEndpoint(slugs) {
3447
3996
  let page = 1;
3448
3997
  let hasMore = true;
3449
3998
  while (hasMore && page <= MAX_PAGES2) {
3450
- const result = await payload.find({
3451
- collection: slugs.emailLogs,
3999
+ const result = await dbFind(payload, slugs.emailLogs, {
3452
4000
  where: { createdAt: { greater_than: cutoff } },
3453
4001
  sort: "-createdAt",
3454
4002
  limit: 500,
@@ -3522,184 +4070,72 @@ function createSatisfactionEndpoint(slugs) {
3522
4070
  const payload = req.payload;
3523
4071
  requireClient(req, slugs);
3524
4072
  const body = await req.json();
3525
- const { ticketId, rating, comment } = body;
3526
- if (!ticketId || !rating || !Number.isInteger(rating) || rating < 1 || rating > 5) {
3527
- return Response.json(
3528
- { error: "ticketId et rating (entier 1-5) sont requis." },
3529
- { status: 400 }
3530
- );
4073
+ const { ticketId, rating, nps, comment } = body;
4074
+ if (!ticketId) {
4075
+ return Response.json({ error: "ticketId est requis." }, { status: 400 });
3531
4076
  }
3532
- if (comment && typeof comment === "string" && comment.length > 5e3) {
3533
- return Response.json(
3534
- { error: "Le commentaire ne peut pas d\xE9passer 5000 caract\xE8res." },
3535
- { status: 400 }
3536
- );
4077
+ const hasRating = rating !== void 0 && rating !== null;
4078
+ const hasNps = nps !== void 0 && nps !== null;
4079
+ if (!hasRating && !hasNps) {
4080
+ return Response.json({ error: "Un rating (1-5) et/ou un nps (0-10) est requis." }, { status: 400 });
3537
4081
  }
3538
- const ticket = await payload.findByID({
3539
- collection: slugs.tickets,
3540
- id: ticketId,
3541
- depth: 0,
3542
- overrideAccess: false,
3543
- user: req.user
3544
- });
3545
- if (!ticket) {
3546
- return Response.json({ error: "Ticket introuvable." }, { status: 404 });
3547
- }
3548
- if (ticket.status !== "resolved") {
3549
- return Response.json(
3550
- { error: "Le ticket doit \xEAtre r\xE9solu pour laisser un avis." },
3551
- { status: 400 }
3552
- );
4082
+ if (hasRating && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
4083
+ return Response.json({ error: "rating doit \xEAtre un entier 1-5." }, { status: 400 });
3553
4084
  }
3554
- const existing = await payload.find({
3555
- collection: slugs.satisfactionSurveys,
3556
- where: { ticket: { equals: ticketId } },
3557
- limit: 1,
3558
- depth: 0,
3559
- overrideAccess: true
3560
- });
3561
- if (existing.docs.length > 0) {
3562
- return Response.json(
3563
- { error: "Vous avez d\xE9j\xE0 \xE9valu\xE9 ce ticket." },
3564
- { status: 409 }
3565
- );
4085
+ if (hasNps && (!Number.isInteger(nps) || nps < 0 || nps > 10)) {
4086
+ return Response.json({ error: "nps doit \xEAtre un entier 0-10." }, { status: 400 });
3566
4087
  }
3567
- const survey = await payload.create({
3568
- collection: slugs.satisfactionSurveys,
3569
- data: {
3570
- source: "ticket",
3571
- ticket: ticketId,
3572
- client: req.user.id,
3573
- rating: Math.round(rating),
3574
- ...comment ? { comment: comment.trim() } : {}
3575
- },
3576
- overrideAccess: true
3577
- });
3578
- return Response.json({ success: true, survey });
3579
- } catch (error) {
3580
- const authResponse = handleAuthError(error);
3581
- if (authResponse) return authResponse;
3582
- console.error("[satisfaction] Error:", error);
3583
- return Response.json({ error: "Erreur interne." }, { status: 500 });
3584
- }
3585
- }
3586
- };
3587
- }
3588
- var TRANSPARENT_GIF = Buffer.from(
3589
- "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
3590
- "base64"
3591
- );
3592
- function generateTrackingToken(ticketId, messageId, secret) {
3593
- return createHmac("sha256", secret).update(`${ticketId}:${messageId}`).digest("hex").substring(0, 16);
3594
- }
3595
- function createTrackOpenEndpoint(slugs) {
3596
- return {
3597
- path: "/support/track-open",
3598
- method: "get",
3599
- handler: async (req) => {
3600
- const url = new URL(req.url);
3601
- const ticketId = url.searchParams.get("t");
3602
- const messageId = url.searchParams.get("m");
3603
- const sig = url.searchParams.get("sig");
3604
- const parsedId = ticketId ? Number(ticketId) : NaN;
3605
- const parsedMsgId = messageId ? Number(messageId) : NaN;
3606
- const secret = process.env.PAYLOAD_SECRET || "";
3607
- if (secret && ticketId && messageId && sig) {
3608
- const expected = generateTrackingToken(ticketId, messageId, secret);
3609
- if (sig !== expected) {
3610
- return new Response(TRANSPARENT_GIF, {
3611
- status: 200,
3612
- headers: {
3613
- "Content-Type": "image/gif",
3614
- "Content-Length": String(TRANSPARENT_GIF.length),
3615
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
3616
- }
3617
- });
3618
- }
3619
- } else if (secret && (!sig || !ticketId || !messageId)) {
3620
- return new Response(TRANSPARENT_GIF, {
3621
- status: 200,
3622
- headers: {
3623
- "Content-Type": "image/gif",
3624
- "Content-Length": String(TRANSPARENT_GIF.length),
3625
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
3626
- }
3627
- });
3628
- }
3629
- if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
3630
- try {
3631
- const payload = req.payload;
3632
- const ticket = await payload.findByID({
3633
- collection: slugs.tickets,
3634
- id: parsedId,
3635
- depth: 0,
3636
- overrideAccess: true,
3637
- select: { lastClientReadAt: true }
3638
- });
3639
- if (ticket) {
3640
- const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0;
3641
- const fiveMinAgo = Date.now() - 5 * 60 * 1e3;
3642
- if (lastRead < fiveMinAgo) {
3643
- await payload.update({
3644
- collection: slugs.tickets,
3645
- id: parsedId,
3646
- data: { lastClientReadAt: (/* @__PURE__ */ new Date()).toISOString() },
3647
- overrideAccess: true
3648
- });
3649
- }
3650
- }
3651
- if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
3652
- const msg = await payload.findByID({
3653
- collection: slugs.ticketMessages,
3654
- id: parsedMsgId,
3655
- depth: 0,
3656
- overrideAccess: true,
3657
- select: { emailOpenedAt: true }
3658
- });
3659
- if (msg && !msg.emailOpenedAt) {
3660
- await payload.update({
3661
- collection: slugs.ticketMessages,
3662
- id: parsedMsgId,
3663
- data: { emailOpenedAt: (/* @__PURE__ */ new Date()).toISOString() },
3664
- overrideAccess: true
3665
- });
3666
- const ticketInfo = await payload.findByID({
3667
- collection: slugs.tickets,
3668
- id: parsedId,
3669
- depth: 1,
3670
- overrideAccess: true,
3671
- select: { ticketNumber: true, subject: true, client: true }
3672
- });
3673
- const clientName = typeof ticketInfo?.client === "object" ? ticketInfo.client?.firstName || "Client" : "Client";
3674
- try {
3675
- await payload.create({
3676
- collection: "admin-notifications",
3677
- data: {
3678
- title: `Email ouvert \u2014 ${ticketInfo?.ticketNumber || "TK-????"}`,
3679
- message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || "ticket"}"`,
3680
- type: "email_opened",
3681
- link: `/admin/ticket?id=${parsedId}`
3682
- },
3683
- overrideAccess: true
3684
- });
3685
- } catch {
3686
- }
3687
- }
3688
- }
3689
- } catch (err) {
3690
- console.error("[track-open] Error:", err);
4088
+ if (comment && typeof comment === "string" && comment.length > 5e3) {
4089
+ return Response.json(
4090
+ { error: "Le commentaire ne peut pas d\xE9passer 5000 caract\xE8res." },
4091
+ { status: 400 }
4092
+ );
3691
4093
  }
3692
- }
3693
- return new Response(TRANSPARENT_GIF, {
3694
- status: 200,
3695
- headers: {
3696
- "Content-Type": "image/gif",
3697
- "Content-Length": String(TRANSPARENT_GIF.length),
3698
- "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
3699
- "Pragma": "no-cache",
3700
- "Expires": "0"
4094
+ const ticket = await dbFindByID(payload, slugs.tickets, {
4095
+ id: ticketId,
4096
+ depth: 0,
4097
+ overrideAccess: false,
4098
+ user: req.user
4099
+ });
4100
+ if (!ticket) {
4101
+ return Response.json({ error: "Ticket introuvable." }, { status: 404 });
3701
4102
  }
3702
- });
4103
+ if (ticket.status !== "resolved") {
4104
+ return Response.json(
4105
+ { error: "Le ticket doit \xEAtre r\xE9solu pour laisser un avis." },
4106
+ { status: 400 }
4107
+ );
4108
+ }
4109
+ const existing = await dbFind(payload, slugs.satisfactionSurveys, {
4110
+ where: { ticket: { equals: ticketId } },
4111
+ limit: 1,
4112
+ depth: 0,
4113
+ overrideAccess: true
4114
+ });
4115
+ if (existing.docs.length > 0) {
4116
+ return Response.json(
4117
+ { error: "Vous avez d\xE9j\xE0 \xE9valu\xE9 ce ticket." },
4118
+ { status: 409 }
4119
+ );
4120
+ }
4121
+ const survey = await dbCreate(payload, slugs.satisfactionSurveys, {
4122
+ data: {
4123
+ source: "ticket",
4124
+ ticket: ticketId,
4125
+ client: req.user.id,
4126
+ ...hasRating ? { rating: Math.round(rating) } : {},
4127
+ ...hasNps ? { nps: Math.round(nps) } : {},
4128
+ ...comment ? { comment: comment.trim() } : {}
4129
+ },
4130
+ overrideAccess: true
4131
+ });
4132
+ return Response.json({ success: true, survey });
4133
+ } catch (error) {
4134
+ const authResponse = handleAuthError(error);
4135
+ if (authResponse) return authResponse;
4136
+ console.error("[satisfaction] Error:", error);
4137
+ return Response.json({ error: "Erreur interne." }, { status: 500 });
4138
+ }
3703
4139
  }
3704
4140
  };
3705
4141
  }
@@ -3718,8 +4154,7 @@ function createExportCsvEndpoint(slugs) {
3718
4154
  let hasMore = true;
3719
4155
  const allDocs = [];
3720
4156
  while (hasMore) {
3721
- const batch = await payload.find({
3722
- collection: slugs.tickets,
4157
+ const batch = await dbFind(payload, slugs.tickets, {
3723
4158
  limit: PAGE_SIZE2,
3724
4159
  page,
3725
4160
  depth: 1,
@@ -3795,21 +4230,18 @@ function createExportDataEndpoint(slugs) {
3795
4230
  const payload = req.payload;
3796
4231
  requireClient(req, slugs);
3797
4232
  const [clientData, ticketsResult, messagesResult, surveysResult] = await Promise.all([
3798
- payload.findByID({
3799
- collection: slugs.supportClients,
4233
+ dbFindByID(payload, slugs.supportClients, {
3800
4234
  id: req.user.id,
3801
4235
  depth: 0,
3802
4236
  overrideAccess: true
3803
4237
  }),
3804
- payload.find({
3805
- collection: slugs.tickets,
4238
+ dbFind(payload, slugs.tickets, {
3806
4239
  where: { client: { equals: req.user.id } },
3807
4240
  limit: 1e3,
3808
4241
  depth: 0,
3809
4242
  overrideAccess: true
3810
4243
  }),
3811
- payload.find({
3812
- collection: slugs.ticketMessages,
4244
+ dbFind(payload, slugs.ticketMessages, {
3813
4245
  where: {
3814
4246
  "ticket.client": { equals: req.user.id },
3815
4247
  authorType: { equals: "client" }
@@ -3818,8 +4250,7 @@ function createExportDataEndpoint(slugs) {
3818
4250
  depth: 0,
3819
4251
  overrideAccess: true
3820
4252
  }),
3821
- payload.find({
3822
- collection: slugs.satisfactionSurveys,
4253
+ dbFind(payload, slugs.satisfactionSurveys, {
3823
4254
  where: { client: { equals: req.user.id } },
3824
4255
  limit: 500,
3825
4256
  depth: 0,
@@ -3893,8 +4324,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
3893
4324
  if (!["create_ticket", "add_to_ticket", "ignore"].includes(action)) {
3894
4325
  return Response.json({ error: "Invalid action" }, { status: 400 });
3895
4326
  }
3896
- const pendingEmail = await payload.findByID({
3897
- collection: slugs.pendingEmails,
4327
+ const pendingEmail = await dbFindByID(payload, slugs.pendingEmails, {
3898
4328
  id: Number(id),
3899
4329
  depth: 1,
3900
4330
  overrideAccess: true
@@ -3908,14 +4338,12 @@ function createPendingEmailsProcessEndpoint(slugs) {
3908
4338
  let clientId = overrideClientId || (typeof pendingEmail.client === "object" ? pendingEmail.client?.id : pendingEmail.client);
3909
4339
  let clientDoc = typeof pendingEmail.client === "object" ? pendingEmail.client : null;
3910
4340
  if (overrideClientId && overrideClientId !== clientDoc?.id) {
3911
- clientDoc = await payload.findByID({
3912
- collection: slugs.supportClients,
4341
+ clientDoc = await dbFindByID(payload, slugs.supportClients, {
3913
4342
  id: overrideClientId,
3914
4343
  depth: 0,
3915
4344
  overrideAccess: true
3916
4345
  });
3917
- await payload.update({
3918
- collection: slugs.pendingEmails,
4346
+ await dbUpdate(payload, slugs.pendingEmails, {
3919
4347
  id: Number(id),
3920
4348
  data: { client: overrideClientId },
3921
4349
  overrideAccess: true
@@ -3932,8 +4360,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
3932
4360
  file: typeof a.file === "object" ? a.file.id : a.file
3933
4361
  })) || [];
3934
4362
  if (action === "ignore") {
3935
- await payload.update({
3936
- collection: slugs.pendingEmails,
4363
+ await dbUpdate(payload, slugs.pendingEmails, {
3937
4364
  id: Number(id),
3938
4365
  data: {
3939
4366
  status: "ignored",
@@ -3945,8 +4372,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
3945
4372
  return Response.json({ action: "ignored", pendingEmailId: Number(id) });
3946
4373
  }
3947
4374
  if (action === "create_ticket") {
3948
- const newTicket = await payload.create({
3949
- collection: slugs.tickets,
4375
+ const newTicket = await dbCreate(payload, slugs.tickets, {
3950
4376
  data: {
3951
4377
  subject: pendingEmail.subject,
3952
4378
  client: clientId,
@@ -3957,8 +4383,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
3957
4383
  },
3958
4384
  overrideAccess: true
3959
4385
  });
3960
- await payload.create({
3961
- collection: slugs.ticketMessages,
4386
+ await dbCreate(payload, slugs.ticketMessages, {
3962
4387
  data: {
3963
4388
  ticket: newTicket.id,
3964
4389
  body: pendingEmail.body,
@@ -3985,8 +4410,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
3985
4410
  } catch (err) {
3986
4411
  console.error("[pending-email-process] Failed to send notification:", err);
3987
4412
  }
3988
- await payload.update({
3989
- collection: slugs.pendingEmails,
4413
+ await dbUpdate(payload, slugs.pendingEmails, {
3990
4414
  id: Number(id),
3991
4415
  data: {
3992
4416
  status: "processed",
@@ -4006,8 +4430,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4006
4430
  if (!ticketId) {
4007
4431
  return Response.json({ error: "ticketId is required for add_to_ticket" }, { status: 400 });
4008
4432
  }
4009
- const targetTicket = await payload.findByID({
4010
- collection: slugs.tickets,
4433
+ const targetTicket = await dbFindByID(payload, slugs.tickets, {
4011
4434
  id: ticketId,
4012
4435
  depth: 0,
4013
4436
  overrideAccess: true
@@ -4015,8 +4438,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
4015
4438
  if (!targetTicket) {
4016
4439
  return Response.json({ error: "Target ticket not found" }, { status: 404 });
4017
4440
  }
4018
- await payload.create({
4019
- collection: slugs.ticketMessages,
4441
+ await dbCreate(payload, slugs.ticketMessages, {
4020
4442
  data: {
4021
4443
  ticket: ticketId,
4022
4444
  body: pendingEmail.body,
@@ -4028,15 +4450,13 @@ function createPendingEmailsProcessEndpoint(slugs) {
4028
4450
  overrideAccess: true
4029
4451
  });
4030
4452
  if (targetTicket.status === "resolved") {
4031
- await payload.update({
4032
- collection: slugs.tickets,
4453
+ await dbUpdate(payload, slugs.tickets, {
4033
4454
  id: ticketId,
4034
4455
  data: { status: "open" },
4035
4456
  overrideAccess: true
4036
4457
  });
4037
4458
  }
4038
- await payload.update({
4039
- collection: slugs.pendingEmails,
4459
+ await dbUpdate(payload, slugs.pendingEmails, {
4040
4460
  id: Number(id),
4041
4461
  data: {
4042
4462
  status: "processed",
@@ -4089,8 +4509,7 @@ function createResendNotificationEndpoint(slugs) {
4089
4509
  if (!messageId) {
4090
4510
  return Response.json({ error: "messageId requis" }, { status: 400 });
4091
4511
  }
4092
- const message = await payload.findByID({
4093
- collection: slugs.ticketMessages,
4512
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
4094
4513
  id: messageId,
4095
4514
  depth: 0,
4096
4515
  overrideAccess: true
@@ -4099,8 +4518,7 @@ function createResendNotificationEndpoint(slugs) {
4099
4518
  return Response.json({ error: "Message introuvable" }, { status: 404 });
4100
4519
  }
4101
4520
  const ticketId = typeof message.ticket === "object" ? message.ticket.id : message.ticket;
4102
- const ticket = await payload.findByID({
4103
- collection: slugs.tickets,
4521
+ const ticket = await dbFindByID(payload, slugs.tickets, {
4104
4522
  id: ticketId,
4105
4523
  depth: 1,
4106
4524
  overrideAccess: true
@@ -4226,8 +4644,7 @@ function createSeedKbEndpoint(slugs) {
4226
4644
  let created = 0;
4227
4645
  let skipped = 0;
4228
4646
  for (const article of KB_ARTICLES) {
4229
- const existing = await payload.find({
4230
- collection: slugs.knowledgeBase,
4647
+ const existing = await dbFind(payload, slugs.knowledgeBase, {
4231
4648
  where: { slug: { equals: article.slug } },
4232
4649
  limit: 1,
4233
4650
  depth: 0,
@@ -4254,8 +4671,7 @@ function createSeedKbEndpoint(slugs) {
4254
4671
  version: 1
4255
4672
  }
4256
4673
  };
4257
- await payload.create({
4258
- collection: slugs.knowledgeBase,
4674
+ await dbCreate(payload, slugs.knowledgeBase, {
4259
4675
  data: {
4260
4676
  title: article.title,
4261
4677
  slug: article.slug,
@@ -4310,8 +4726,7 @@ function createLoginEndpoint(slugs) {
4310
4726
  collection: slugs.supportClients,
4311
4727
  data: { email, password }
4312
4728
  });
4313
- payload.create({
4314
- collection: slugs.authLogs,
4729
+ dbCreate(payload, slugs.authLogs, {
4315
4730
  data: { email, success: true, action: "login", ipAddress: ip, userAgent },
4316
4731
  overrideAccess: true
4317
4732
  }).catch(() => {
@@ -4335,12 +4750,14 @@ function createLoginEndpoint(slugs) {
4335
4750
  );
4336
4751
  } catch (err) {
4337
4752
  const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
4753
+ if (errorMessage.includes("2FA_REQUIRED")) {
4754
+ return Response.json({ requires2FA: true }, { status: 200 });
4755
+ }
4338
4756
  let errorReason = "Identifiants incorrects";
4339
4757
  if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
4340
4758
  errorReason = "Compte verrouill\xE9 (trop de tentatives)";
4341
4759
  }
4342
- payload.create({
4343
- collection: slugs.authLogs,
4760
+ dbCreate(payload, slugs.authLogs, {
4344
4761
  data: { email, success: false, action: "login", errorReason, ipAddress: ip, userAgent },
4345
4762
  overrideAccess: true
4346
4763
  }).catch(() => {
@@ -4361,7 +4778,10 @@ function generateSecureCode() {
4361
4778
  return String(num);
4362
4779
  }
4363
4780
  function hashCode(code) {
4364
- const secret = process.env.PAYLOAD_SECRET || "payload-support-2fa";
4781
+ const secret = process.env.PAYLOAD_SECRET;
4782
+ if (!secret) {
4783
+ throw new Error("[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to operate 2FA with an insecure fallback secret");
4784
+ }
4365
4785
  return createHmac("sha256", secret).update(code).digest("hex");
4366
4786
  }
4367
4787
  function createAuth2faEndpoint(slugs) {
@@ -4386,8 +4806,7 @@ function createAuth2faEndpoint(slugs) {
4386
4806
  if (sendLimiter.check(email)) {
4387
4807
  return Response.json(genericSendResponse);
4388
4808
  }
4389
- const clients = await payload.find({
4390
- collection: slugs.supportClients,
4809
+ const clients = await dbFind(payload, slugs.supportClients, {
4391
4810
  where: { email: { equals: email } },
4392
4811
  limit: 1,
4393
4812
  depth: 0,
@@ -4400,8 +4819,7 @@ function createAuth2faEndpoint(slugs) {
4400
4819
  const plainCode = generateSecureCode();
4401
4820
  const twoFactorCode = hashCode(plainCode);
4402
4821
  const twoFactorExpiry = new Date(Date.now() + 10 * 60 * 1e3).toISOString();
4403
- await payload.update({
4404
- collection: slugs.supportClients,
4822
+ await dbUpdate(payload, slugs.supportClients, {
4405
4823
  id: client.id,
4406
4824
  data: { twoFactorCode, twoFactorExpiry },
4407
4825
  overrideAccess: true
@@ -4432,8 +4850,7 @@ function createAuth2faEndpoint(slugs) {
4432
4850
  { status: 429 }
4433
4851
  );
4434
4852
  }
4435
- const clients = await payload.find({
4436
- collection: slugs.supportClients,
4853
+ const clients = await dbFind(payload, slugs.supportClients, {
4437
4854
  where: { email: { equals: email } },
4438
4855
  limit: 1,
4439
4856
  depth: 0,
@@ -4449,8 +4866,7 @@ function createAuth2faEndpoint(slugs) {
4449
4866
  return Response.json({ error: "Aucun code en attente" }, { status: 400 });
4450
4867
  }
4451
4868
  if (/* @__PURE__ */ new Date() > new Date(storedExpiry)) {
4452
- await payload.update({
4453
- collection: slugs.supportClients,
4869
+ await dbUpdate(payload, slugs.supportClients, {
4454
4870
  id: client.id,
4455
4871
  data: { twoFactorCode: "", twoFactorExpiry: "" },
4456
4872
  overrideAccess: true
@@ -4464,10 +4880,9 @@ function createAuth2faEndpoint(slugs) {
4464
4880
  if (submittedBuffer.length !== storedBuffer.length || !crypto3.timingSafeEqual(submittedBuffer, storedBuffer)) {
4465
4881
  return Response.json({ error: "Code incorrect" }, { status: 400 });
4466
4882
  }
4467
- await payload.update({
4468
- collection: slugs.supportClients,
4883
+ await dbUpdate(payload, slugs.supportClients, {
4469
4884
  id: client.id,
4470
- data: { twoFactorCode: "", twoFactorExpiry: "" },
4885
+ data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
4471
4886
  overrideAccess: true
4472
4887
  });
4473
4888
  verifyLimiter.reset(email);
@@ -4541,15 +4956,33 @@ function createOAuthGoogleEndpoint(slugs, options) {
4541
4956
  if (!profile.email) {
4542
4957
  return Response.json({ error: "no_email" }, { status: 400 });
4543
4958
  }
4959
+ if (profile.verified_email !== true) {
4960
+ return Response.json({ error: "email_not_verified" }, { status: 403 });
4961
+ }
4544
4962
  const payload = req.payload;
4545
- const existing = await payload.find({
4546
- collection: slugs.supportClients,
4547
- where: { email: { equals: profile.email } },
4963
+ const byGoogle = await dbFind(payload, slugs.supportClients, {
4964
+ where: { googleId: { equals: profile.id } },
4548
4965
  limit: 1,
4549
4966
  depth: 0,
4550
4967
  overrideAccess: true
4551
4968
  });
4552
- let clientDoc = existing.docs[0];
4969
+ let clientDoc = byGoogle.docs[0];
4970
+ if (!clientDoc) {
4971
+ const byEmail = await dbFind(payload, slugs.supportClients, {
4972
+ where: { email: { equals: profile.email } },
4973
+ limit: 1,
4974
+ depth: 0,
4975
+ overrideAccess: true
4976
+ });
4977
+ clientDoc = byEmail.docs[0];
4978
+ if (clientDoc && profile.id) {
4979
+ await dbUpdate(payload, slugs.supportClients, {
4980
+ id: clientDoc.id,
4981
+ data: { googleId: profile.id },
4982
+ overrideAccess: true
4983
+ });
4984
+ }
4985
+ }
4553
4986
  if (!clientDoc) {
4554
4987
  const allowedDomains = options?.allowedEmailDomains;
4555
4988
  if (allowedDomains && allowedDomains.length > 0) {
@@ -4567,43 +5000,64 @@ function createOAuthGoogleEndpoint(slugs, options) {
4567
5000
  const autoPassword = crypto3.randomBytes(48).toString("base64url");
4568
5001
  const fullName = profile.name || profile.email.split("@")[0];
4569
5002
  const nameParts = fullName.split(" ");
4570
- clientDoc = await payload.create({
4571
- collection: slugs.supportClients,
5003
+ clientDoc = await dbCreate(payload, slugs.supportClients, {
4572
5004
  data: {
4573
5005
  email: profile.email,
4574
5006
  firstName: nameParts[0] || fullName,
4575
5007
  lastName: nameParts.slice(1).join(" ") || "-",
4576
5008
  company: fullName,
5009
+ googleId: profile.id,
4577
5010
  password: autoPassword
4578
5011
  },
4579
5012
  overrideAccess: true
4580
5013
  });
4581
5014
  }
4582
- const tempPassword = crypto3.randomBytes(48).toString("base64url");
4583
- await payload.update({
4584
- collection: slugs.supportClients,
4585
- id: clientDoc.id,
4586
- data: { password: tempPassword },
4587
- overrideAccess: true
4588
- });
4589
- const loginResult = await payload.login({
4590
- collection: slugs.supportClients,
4591
- data: { email: profile.email, password: tempPassword }
4592
- });
4593
- const postLoginPassword = crypto3.randomBytes(48).toString("base64url");
4594
- await payload.update({
4595
- collection: slugs.supportClients,
4596
- id: clientDoc.id,
4597
- data: { password: postLoginPassword },
4598
- overrideAccess: true
4599
- });
4600
- if (!loginResult.token) {
4601
- return Response.json({ error: "login_failed" }, { status: 400 });
5015
+ const secret = process.env.PAYLOAD_SECRET;
5016
+ if (!secret) {
5017
+ return Response.json({ error: "server_misconfigured" }, { status: 500 });
4602
5018
  }
4603
- return Response.json({
4604
- token: loginResult.token,
4605
- user: loginResult.user,
4606
- exp: loginResult.exp
5019
+ const collectionConfig = payload.collections[slugs.supportClients].config;
5020
+ const tokenExpiration = collectionConfig.auth?.tokenExpiration ?? 7200;
5021
+ let sid;
5022
+ if (collectionConfig.auth?.useSessions) {
5023
+ sid = crypto3.randomUUID();
5024
+ const now = /* @__PURE__ */ new Date();
5025
+ const expiresAt = new Date(now.getTime() + tokenExpiration * 1e3);
5026
+ const fresh = await dbFindByID(payload, slugs.supportClients, {
5027
+ id: clientDoc.id,
5028
+ depth: 0,
5029
+ overrideAccess: true,
5030
+ showHiddenFields: true
5031
+ });
5032
+ const kept = Array.isArray(fresh?.sessions) ? fresh.sessions.filter((s) => new Date(s.expiresAt) > now) : [];
5033
+ await payload.db.updateOne({
5034
+ collection: slugs.supportClients,
5035
+ id: clientDoc.id,
5036
+ data: {
5037
+ sessions: [
5038
+ ...kept,
5039
+ { id: sid, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString() }
5040
+ ]
5041
+ },
5042
+ returning: false
5043
+ });
5044
+ }
5045
+ const fieldsToSign = getFieldsToSign({
5046
+ collectionConfig,
5047
+ email: clientDoc.email,
5048
+ sid,
5049
+ user: { ...clientDoc, collection: slugs.supportClients }
5050
+ });
5051
+ const { token, exp } = await jwtSign({ fieldsToSign, secret, tokenExpiration });
5052
+ const headers = new Headers({ "Content-Type": "application/json" });
5053
+ const cookieSecure = process.env.NODE_ENV === "production";
5054
+ headers.append(
5055
+ "Set-Cookie",
5056
+ `payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
5057
+ );
5058
+ return new Response(JSON.stringify({ token, user: clientDoc, exp }), {
5059
+ status: 200,
5060
+ headers
4607
5061
  });
4608
5062
  }
4609
5063
  return Response.json({ error: "Action invalide" }, { status: 400 });
@@ -4644,8 +5098,7 @@ function createDeleteAccountEndpoint(slugs) {
4644
5098
  );
4645
5099
  }
4646
5100
  const clientId = req.user.id;
4647
- const tickets = await payload.find({
4648
- collection: slugs.tickets,
5101
+ const tickets = await dbFind(payload, slugs.tickets, {
4649
5102
  where: { client: { equals: clientId } },
4650
5103
  limit: 1e4,
4651
5104
  depth: 0,
@@ -4654,39 +5107,32 @@ function createDeleteAccountEndpoint(slugs) {
4654
5107
  });
4655
5108
  const ticketIds = tickets.docs.map((t) => t.id);
4656
5109
  if (ticketIds.length > 0) {
4657
- await payload.delete({
4658
- collection: slugs.ticketMessages,
5110
+ await dbDelete(payload, slugs.ticketMessages, {
4659
5111
  where: { ticket: { in: ticketIds } },
4660
5112
  overrideAccess: true
4661
5113
  });
4662
- await payload.delete({
4663
- collection: slugs.ticketActivityLog,
5114
+ await dbDelete(payload, slugs.ticketActivityLog, {
4664
5115
  where: { ticket: { in: ticketIds } },
4665
5116
  overrideAccess: true
4666
5117
  });
4667
- await payload.delete({
4668
- collection: slugs.timeEntries,
5118
+ await dbDelete(payload, slugs.timeEntries, {
4669
5119
  where: { ticket: { in: ticketIds } },
4670
5120
  overrideAccess: true
4671
5121
  });
4672
- await payload.delete({
4673
- collection: slugs.tickets,
5122
+ await dbDelete(payload, slugs.tickets, {
4674
5123
  where: { client: { equals: clientId } },
4675
5124
  overrideAccess: true
4676
5125
  });
4677
5126
  }
4678
- await payload.delete({
4679
- collection: slugs.satisfactionSurveys,
5127
+ await dbDelete(payload, slugs.satisfactionSurveys, {
4680
5128
  where: { client: { equals: clientId } },
4681
5129
  overrideAccess: true
4682
5130
  });
4683
- await payload.delete({
4684
- collection: slugs.chatMessages,
5131
+ await dbDelete(payload, slugs.chatMessages, {
4685
5132
  where: { client: { equals: clientId } },
4686
5133
  overrideAccess: true
4687
5134
  });
4688
- await payload.delete({
4689
- collection: slugs.supportClients,
5135
+ await dbDelete(payload, slugs.supportClients, {
4690
5136
  id: clientId,
4691
5137
  overrideAccess: true
4692
5138
  });
@@ -4727,8 +5173,8 @@ function createMergeClientsEndpoint(slugs) {
4727
5173
  return Response.json({ error: "sourceId and targetId are required and must be different" }, { status: 400 });
4728
5174
  }
4729
5175
  const [source, target] = await Promise.all([
4730
- payload.findByID({ collection: slugs.supportClients, id: sourceId, depth: 0, overrideAccess: true }),
4731
- payload.findByID({ collection: slugs.supportClients, id: targetId, depth: 0, overrideAccess: true })
5176
+ dbFindByID(payload, slugs.supportClients, { id: sourceId, depth: 0, overrideAccess: true }),
5177
+ dbFindByID(payload, slugs.supportClients, { id: targetId, depth: 0, overrideAccess: true })
4732
5178
  ]);
4733
5179
  if (!source || !target) {
4734
5180
  return Response.json({ error: "Source or target client not found" }, { status: 404 });
@@ -4740,63 +5186,57 @@ function createMergeClientsEndpoint(slugs) {
4740
5186
  pendingEmails: 0,
4741
5187
  satisfactionSurveys: 0
4742
5188
  };
4743
- const tickets = await payload.find({
4744
- collection: slugs.tickets,
5189
+ const tickets = await dbFind(payload, slugs.tickets, {
4745
5190
  where: { client: { equals: sourceId } },
4746
5191
  limit: 500,
4747
5192
  depth: 0,
4748
5193
  overrideAccess: true
4749
5194
  });
4750
5195
  for (const ticket of tickets.docs) {
4751
- await payload.update({ collection: slugs.tickets, id: ticket.id, data: { client: targetId }, overrideAccess: true });
5196
+ await dbUpdate(payload, slugs.tickets, { id: ticket.id, data: { client: targetId }, overrideAccess: true });
4752
5197
  results.tickets++;
4753
5198
  }
4754
- const messages = await payload.find({
4755
- collection: slugs.ticketMessages,
5199
+ const messages = await dbFind(payload, slugs.ticketMessages, {
4756
5200
  where: { authorClient: { equals: sourceId } },
4757
5201
  limit: 1e3,
4758
5202
  depth: 0,
4759
5203
  overrideAccess: true
4760
5204
  });
4761
5205
  for (const msg of messages.docs) {
4762
- await payload.update({ collection: slugs.ticketMessages, id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
5206
+ await dbUpdate(payload, slugs.ticketMessages, { id: msg.id, data: { authorClient: targetId }, overrideAccess: true });
4763
5207
  results.ticketMessages++;
4764
5208
  }
4765
- const chats = await payload.find({
4766
- collection: slugs.chatMessages,
5209
+ const chats = await dbFind(payload, slugs.chatMessages, {
4767
5210
  where: { client: { equals: sourceId } },
4768
5211
  limit: 1e3,
4769
5212
  depth: 0,
4770
5213
  overrideAccess: true
4771
5214
  });
4772
5215
  for (const chat of chats.docs) {
4773
- await payload.update({ collection: slugs.chatMessages, id: chat.id, data: { client: targetId }, overrideAccess: true });
5216
+ await dbUpdate(payload, slugs.chatMessages, { id: chat.id, data: { client: targetId }, overrideAccess: true });
4774
5217
  results.chatMessages++;
4775
5218
  }
4776
- const pendingEmails = await payload.find({
4777
- collection: slugs.pendingEmails,
5219
+ const pendingEmails = await dbFind(payload, slugs.pendingEmails, {
4778
5220
  where: { client: { equals: sourceId } },
4779
5221
  limit: 500,
4780
5222
  depth: 0,
4781
5223
  overrideAccess: true
4782
5224
  });
4783
5225
  for (const pe of pendingEmails.docs) {
4784
- await payload.update({ collection: slugs.pendingEmails, id: pe.id, data: { client: targetId }, overrideAccess: true });
5226
+ await dbUpdate(payload, slugs.pendingEmails, { id: pe.id, data: { client: targetId }, overrideAccess: true });
4785
5227
  results.pendingEmails++;
4786
5228
  }
4787
- const surveys = await payload.find({
4788
- collection: slugs.satisfactionSurveys,
5229
+ const surveys = await dbFind(payload, slugs.satisfactionSurveys, {
4789
5230
  where: { client: { equals: sourceId } },
4790
5231
  limit: 500,
4791
5232
  depth: 0,
4792
5233
  overrideAccess: true
4793
5234
  });
4794
5235
  for (const survey of surveys.docs) {
4795
- await payload.update({ collection: slugs.satisfactionSurveys, id: survey.id, data: { client: targetId }, overrideAccess: true });
5236
+ await dbUpdate(payload, slugs.satisfactionSurveys, { id: survey.id, data: { client: targetId }, overrideAccess: true });
4796
5237
  results.satisfactionSurveys++;
4797
5238
  }
4798
- await payload.delete({
4799
- collection: slugs.supportClients,
5239
+ await dbDelete(payload, slugs.supportClients, {
4800
5240
  id: sourceId,
4801
5241
  overrideAccess: true
4802
5242
  });
@@ -4990,8 +5430,7 @@ function createImportConversationEndpoint(slugs) {
4990
5430
  if (blockedEmails.includes(conversation.client.email)) {
4991
5431
  return Response.json({ error: "Cannot create ticket from system email address" }, { status: 400 });
4992
5432
  }
4993
- const clientResult = await payload.find({
4994
- collection: slugs.supportClients,
5433
+ const clientResult = await dbFind(payload, slugs.supportClients, {
4995
5434
  where: { email: { equals: conversation.client.email } },
4996
5435
  limit: 1,
4997
5436
  depth: 0,
@@ -5002,8 +5441,7 @@ function createImportConversationEndpoint(slugs) {
5002
5441
  if (!client) {
5003
5442
  const nameParts = conversation.client.name.split(" ");
5004
5443
  const randomPassword = crypto3.randomBytes(48).toString("base64url");
5005
- client = await payload.create({
5006
- collection: slugs.supportClients,
5444
+ client = await dbCreate(payload, slugs.supportClients, {
5007
5445
  data: {
5008
5446
  email: conversation.client.email,
5009
5447
  password: randomPassword,
@@ -5015,15 +5453,13 @@ function createImportConversationEndpoint(slugs) {
5015
5453
  });
5016
5454
  isNewClient = true;
5017
5455
  }
5018
- const adminUsers = await payload.find({
5019
- collection: slugs.users,
5456
+ const adminUsers = await dbFind(payload, slugs.users, {
5020
5457
  limit: 1,
5021
5458
  depth: 0,
5022
5459
  overrideAccess: true
5023
5460
  });
5024
5461
  const adminUserId = adminUsers.docs[0]?.id;
5025
- const ticket = await payload.create({
5026
- collection: slugs.tickets,
5462
+ const ticket = await dbCreate(payload, slugs.tickets, {
5027
5463
  data: {
5028
5464
  subject: conversation.subject,
5029
5465
  client: client.id,
@@ -5036,8 +5472,7 @@ function createImportConversationEndpoint(slugs) {
5036
5472
  let importedCount = 0;
5037
5473
  for (const msg of conversation.messages) {
5038
5474
  const isAdmin = msg.from === "admin";
5039
- await payload.create({
5040
- collection: slugs.ticketMessages,
5475
+ await dbCreate(payload, slugs.ticketMessages, {
5041
5476
  data: {
5042
5477
  ticket: ticket.id,
5043
5478
  body: msg.content,
@@ -5073,8 +5508,7 @@ function createImportConversationEndpoint(slugs) {
5073
5508
  // src/utils/fireWebhooks.ts
5074
5509
  async function fireWebhooks(payload, slugs, event, data) {
5075
5510
  try {
5076
- const endpoints = await payload.find({
5077
- collection: slugs.webhookEndpoints,
5511
+ const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
5078
5512
  where: {
5079
5513
  active: { equals: true },
5080
5514
  events: { contains: event }
@@ -5098,8 +5532,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5098
5532
  signal: AbortSignal.timeout(1e4)
5099
5533
  });
5100
5534
  try {
5101
- await payload.update({
5102
- collection: slugs.webhookEndpoints,
5535
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5103
5536
  id: endpoint.id,
5104
5537
  data: {
5105
5538
  lastTriggeredAt: timestamp,
@@ -5112,8 +5545,7 @@ async function fireWebhooks(payload, slugs, event, data) {
5112
5545
  } catch (error) {
5113
5546
  console.warn(`[support] Webhook delivery failed: ${endpoint.url}`, error);
5114
5547
  try {
5115
- await payload.update({
5116
- collection: slugs.webhookEndpoints,
5548
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5117
5549
  id: endpoint.id,
5118
5550
  data: {
5119
5551
  lastTriggeredAt: timestamp,
@@ -5148,8 +5580,7 @@ function createProcessScheduledEndpoint(slugs) {
5148
5580
  const settings = await readSupportSettings(payload);
5149
5581
  const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5150
5582
  const results = { processed: 0, errors: 0 };
5151
- const scheduled = await payload.find({
5152
- collection: slugs.ticketMessages,
5583
+ const scheduled = await dbFind(payload, slugs.ticketMessages, {
5153
5584
  where: {
5154
5585
  and: [
5155
5586
  { scheduledAt: { less_than_equal: now.toISOString() } },
@@ -5169,8 +5600,7 @@ function createProcessScheduledEndpoint(slugs) {
5169
5600
  try {
5170
5601
  const msg = message;
5171
5602
  const ticketId = typeof msg.ticket === "object" ? msg.ticket.id : msg.ticket;
5172
- const ticket = await payload.findByID({
5173
- collection: slugs.tickets,
5603
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5174
5604
  id: ticketId,
5175
5605
  depth: 1,
5176
5606
  overrideAccess: true
@@ -5193,8 +5623,7 @@ function createProcessScheduledEndpoint(slugs) {
5193
5623
  subject: `Re: [${ticketNumber}] ${subject}`,
5194
5624
  html: `<p>Bonjour ${escapeHtml(client.firstName || "")},</p><p>Notre \xE9quipe a r\xE9pondu \xE0 votre ticket <strong>${escapeHtml(ticketNumber)}</strong>.</p><blockquote style="border-left:4px solid #2563eb;padding:12px;margin:16px 0;background:#f8fafc;">${escapeHtml(preview)}</blockquote><p><a href="${baseUrl}/support/tickets/${ticketId}">Consulter le ticket</a></p>`
5195
5625
  });
5196
- await payload.update({
5197
- collection: slugs.ticketMessages,
5626
+ await dbUpdate(payload, slugs.ticketMessages, {
5198
5627
  id: msg.id,
5199
5628
  data: {
5200
5629
  scheduledSent: true,
@@ -5204,16 +5633,14 @@ function createProcessScheduledEndpoint(slugs) {
5204
5633
  overrideAccess: true
5205
5634
  });
5206
5635
  } else {
5207
- await payload.update({
5208
- collection: slugs.ticketMessages,
5636
+ await dbUpdate(payload, slugs.ticketMessages, {
5209
5637
  id: msg.id,
5210
5638
  data: { scheduledSent: true },
5211
5639
  overrideAccess: true
5212
5640
  });
5213
5641
  }
5214
5642
  if (msg.authorType === "admin" && !msg.isInternal) {
5215
- await payload.update({
5216
- collection: slugs.tickets,
5643
+ await dbUpdate(payload, slugs.tickets, {
5217
5644
  id: ticketId,
5218
5645
  data: { status: "waiting_client" },
5219
5646
  overrideAccess: true
@@ -5245,6 +5672,208 @@ function createProcessScheduledEndpoint(slugs) {
5245
5672
  };
5246
5673
  }
5247
5674
 
5675
+ // src/endpoints/process-snooze.ts
5676
+ function createProcessSnoozeEndpoint(slugs) {
5677
+ return {
5678
+ path: "/support/process-snooze",
5679
+ method: "post",
5680
+ handler: async (req) => {
5681
+ const secret = req.headers.get("x-cron-secret");
5682
+ const expectedSecret = process.env.CRON_SECRET;
5683
+ if (!expectedSecret || secret !== expectedSecret) {
5684
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5685
+ }
5686
+ try {
5687
+ const payload = req.payload;
5688
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
5689
+ const results = { processed: 0, errors: 0 };
5690
+ const due = await dbFind(payload, slugs.tickets, {
5691
+ where: {
5692
+ and: [
5693
+ { snoozeUntil: { exists: true } },
5694
+ { snoozeUntil: { less_than_equal: nowIso } }
5695
+ ]
5696
+ },
5697
+ limit: 500,
5698
+ depth: 0,
5699
+ overrideAccess: true,
5700
+ select: { id: true, ticketNumber: true }
5701
+ });
5702
+ for (const ticket of due.docs) {
5703
+ try {
5704
+ await dbUpdate(payload, slugs.tickets, {
5705
+ id: ticket.id,
5706
+ data: { snoozeUntil: null },
5707
+ overrideAccess: true
5708
+ });
5709
+ try {
5710
+ await dbCreate(payload, slugs.ticketActivityLog, {
5711
+ data: {
5712
+ ticket: ticket.id,
5713
+ action: "snooze_expired",
5714
+ detail: "Snooze \xE9chu \u2014 ticket r\xE9activ\xE9",
5715
+ actorType: "system"
5716
+ },
5717
+ overrideAccess: true
5718
+ });
5719
+ } catch {
5720
+ }
5721
+ results.processed++;
5722
+ } catch (err) {
5723
+ console.error("[support] Failed to wake snoozed ticket:", err);
5724
+ results.errors++;
5725
+ }
5726
+ }
5727
+ return Response.json({ ok: true, ...results });
5728
+ } catch (err) {
5729
+ console.error("[process-snooze] Error:", err);
5730
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5731
+ }
5732
+ }
5733
+ };
5734
+ }
5735
+
5736
+ // src/endpoints/process-digests.ts
5737
+ function createProcessDigestsEndpoint(slugs) {
5738
+ return {
5739
+ path: "/support/process-digests",
5740
+ method: "post",
5741
+ handler: async (req) => {
5742
+ const secret = req.headers.get("x-cron-secret");
5743
+ if (!process.env.CRON_SECRET || secret !== process.env.CRON_SECRET) {
5744
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5745
+ }
5746
+ try {
5747
+ const payload = req.payload;
5748
+ let body = {};
5749
+ try {
5750
+ body = await req.json();
5751
+ } catch {
5752
+ }
5753
+ const frequency = body.frequency === "weekly" ? "weekly" : "daily";
5754
+ const settings = await readSupportSettings(payload);
5755
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || "";
5756
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
5757
+ const itemsByClient = /* @__PURE__ */ new Map();
5758
+ let page = 1;
5759
+ let hasMore = true;
5760
+ while (hasMore && page <= 50) {
5761
+ const batch = await dbFind(payload, slugs.notificationQueue, { limit: 200, page, depth: 0, overrideAccess: true, sort: "createdAt" });
5762
+ for (const it of batch.docs) {
5763
+ const raw = it;
5764
+ const clientId = typeof raw.client === "object" ? raw.client?.id : raw.client;
5765
+ if (clientId === void 0 || clientId === null) continue;
5766
+ const key = String(clientId);
5767
+ if (!itemsByClient.has(key)) itemsByClient.set(key, []);
5768
+ itemsByClient.get(key).push({ id: raw.id, title: raw.title, message: raw.message });
5769
+ }
5770
+ hasMore = batch.hasNextPage ?? false;
5771
+ page++;
5772
+ }
5773
+ const results = { clients: 0, items: 0, errors: 0 };
5774
+ for (const [clientId, items] of itemsByClient) {
5775
+ try {
5776
+ const client = await dbFindByID(payload, slugs.supportClients, { id: clientId, depth: 0, overrideAccess: true });
5777
+ if (!client || client.notificationFrequency !== frequency) continue;
5778
+ if (client.email) {
5779
+ const rows = items.map((i) => `<tr><td style="padding:8px 0;font-size:14px;color:#1e293b;border-bottom:1px solid #f1f5f9;"><strong>${escapeHtml(i.title || "")}</strong><br/><span style="color:#64748b;font-size:13px;">${escapeHtml(i.message || "")}</span></td></tr>`).join("");
5780
+ const html = emailWrapper("Recapitulatif de vos tickets", [
5781
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
5782
+ emailParagraph(`Voici le recapitulatif de l'activite sur vos tickets (${items.length} notification${items.length > 1 ? "s" : ""}) :`),
5783
+ `<table cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table>`,
5784
+ ...baseUrl ? [emailParagraph(`<a href="${baseUrl}/support/dashboard">Acceder a votre espace support</a>`)] : []
5785
+ ].join(""), { kind: "system" });
5786
+ await payload.sendEmail({ to: client.email, ...replyTo ? { replyTo } : {}, subject: `Recapitulatif support (${items.length})`, html });
5787
+ }
5788
+ for (const i of items) {
5789
+ try {
5790
+ await dbDelete(payload, slugs.notificationQueue, { id: i.id, overrideAccess: true });
5791
+ } catch {
5792
+ }
5793
+ }
5794
+ results.clients++;
5795
+ results.items += items.length;
5796
+ } catch (err) {
5797
+ console.error("[process-digests] client error:", err);
5798
+ results.errors++;
5799
+ }
5800
+ }
5801
+ return Response.json({ ok: true, frequency, ...results });
5802
+ } catch (err) {
5803
+ console.error("[process-digests] Error:", err);
5804
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5805
+ }
5806
+ }
5807
+ };
5808
+ }
5809
+ var PROVIDERS = ["whatsapp", "messenger"];
5810
+ function createChannelsWebhookEndpoint(slugs) {
5811
+ return {
5812
+ path: "/support/channels/webhook",
5813
+ method: "post",
5814
+ handler: async (req) => {
5815
+ const secret = req.headers.get("x-channel-secret");
5816
+ if (!process.env.CHANNELS_WEBHOOK_SECRET || secret !== process.env.CHANNELS_WEBHOOK_SECRET) {
5817
+ return Response.json({ error: "Non autoris\xE9" }, { status: 401 });
5818
+ }
5819
+ let body;
5820
+ try {
5821
+ body = await req.json();
5822
+ } catch {
5823
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5824
+ }
5825
+ const provider = body.provider;
5826
+ const from = (body.from || "").trim();
5827
+ const text = (body.text || "").trim();
5828
+ if (!PROVIDERS.includes(provider) || !from || !text) {
5829
+ return Response.json({ error: "provider, from et text requis." }, { status: 400 });
5830
+ }
5831
+ try {
5832
+ const payload = req.payload;
5833
+ const email = `${from}@${provider}.channel`;
5834
+ const existing = await dbFind(payload, slugs.supportClients, { where: { email: { equals: email } }, limit: 1, depth: 0, overrideAccess: true });
5835
+ let clientId;
5836
+ if (existing.docs.length > 0) {
5837
+ clientId = existing.docs[0].id;
5838
+ } else {
5839
+ const created = await dbCreate(payload, slugs.supportClients, {
5840
+ data: { email, firstName: body.name || provider, lastName: from.slice(0, 40), company: provider, password: randomBytes(16).toString("hex") },
5841
+ overrideAccess: true
5842
+ });
5843
+ clientId = created.id;
5844
+ }
5845
+ const open = await dbFind(payload, slugs.tickets, {
5846
+ where: { and: [{ client: { equals: clientId } }, { source: { equals: provider } }, { status: { in: ["open", "waiting_client"] } }] },
5847
+ sort: "-createdAt",
5848
+ limit: 1,
5849
+ depth: 0,
5850
+ overrideAccess: true
5851
+ });
5852
+ let ticketId;
5853
+ let createdTicket = false;
5854
+ if (open.docs.length > 0) {
5855
+ ticketId = open.docs[0].id;
5856
+ } else {
5857
+ const t = await dbCreate(payload, slugs.tickets, {
5858
+ data: { subject: text.slice(0, 80), client: clientId, status: "open", priority: "normal", source: provider },
5859
+ overrideAccess: true
5860
+ });
5861
+ ticketId = t.id;
5862
+ createdTicket = true;
5863
+ }
5864
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
5865
+ data: { ticket: ticketId, body: text, authorType: "client", authorClient: clientId },
5866
+ overrideAccess: true
5867
+ });
5868
+ return Response.json({ ok: true, ticketId, createdTicket, messageId: msg.id });
5869
+ } catch (err) {
5870
+ console.error("[channels] Error:", err);
5871
+ return Response.json({ error: "Erreur interne" }, { status: 500 });
5872
+ }
5873
+ }
5874
+ };
5875
+ }
5876
+
5248
5877
  // src/endpoints/user-prefs.ts
5249
5878
  var PREF_KEY_PREFIX = "support-user-prefs";
5250
5879
  var DEFAULT_USER_PREFS2 = {
@@ -5260,8 +5889,7 @@ function createUserPrefsGetEndpoint(slugs) {
5260
5889
  const payload = req.payload;
5261
5890
  requireAdmin(req, slugs);
5262
5891
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5263
- const prefs = await payload.find({
5264
- collection: "payload-preferences",
5892
+ const prefs = await dbFind(payload, "payload-preferences", {
5265
5893
  where: { key: { equals: key } },
5266
5894
  limit: 1,
5267
5895
  depth: 0,
@@ -5295,8 +5923,7 @@ function createUserPrefsPostEndpoint(slugs) {
5295
5923
  requireAdmin(req, slugs);
5296
5924
  const body = await req.json();
5297
5925
  const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
5298
- const existing = await payload.find({
5299
- collection: "payload-preferences",
5926
+ const existing = await dbFind(payload, "payload-preferences", {
5300
5927
  where: { key: { equals: key } },
5301
5928
  limit: 1,
5302
5929
  depth: 0,
@@ -5357,8 +5984,7 @@ function createEscalateEndpoint(slugs) {
5357
5984
  if (Number.isNaN(ticketId)) {
5358
5985
  return Response.json({ error: "invalid-id" }, { status: 400 });
5359
5986
  }
5360
- const ticket = await req.payload.update({
5361
- collection: slugs.tickets,
5987
+ const ticket = await dbUpdate(req.payload, slugs.tickets, {
5362
5988
  id: ticketId,
5363
5989
  data: { status: "escalated" },
5364
5990
  overrideAccess: true
@@ -5406,8 +6032,7 @@ function createTicketFeedbackEndpoint(slugs) {
5406
6032
  { status: 400 }
5407
6033
  );
5408
6034
  }
5409
- const ticket = await payload.findByID({
5410
- collection: slugs.tickets,
6035
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5411
6036
  id: ticketId,
5412
6037
  depth: 0,
5413
6038
  overrideAccess: true
@@ -5419,8 +6044,7 @@ function createTicketFeedbackEndpoint(slugs) {
5419
6044
  if (ticketClientId !== req.user.id) {
5420
6045
  return Response.json({ error: "forbidden" }, { status: 403 });
5421
6046
  }
5422
- const existing = await payload.find({
5423
- collection: "ticket-feedback",
6047
+ const existing = await dbFind(payload, "ticket-feedback", {
5424
6048
  where: { ticket: { equals: ticketId } },
5425
6049
  limit: 1,
5426
6050
  depth: 0,
@@ -5432,8 +6056,7 @@ function createTicketFeedbackEndpoint(slugs) {
5432
6056
  { status: 409 }
5433
6057
  );
5434
6058
  }
5435
- const feedback = await payload.create({
5436
- collection: "ticket-feedback",
6059
+ const feedback = await dbCreate(payload, "ticket-feedback", {
5437
6060
  data: {
5438
6061
  ticket: ticketId,
5439
6062
  client: req.user.id,
@@ -5457,6 +6080,7 @@ function createTicketFeedbackEndpoint(slugs) {
5457
6080
  // src/endpoints/transfer-ticket.ts
5458
6081
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5459
6082
  var MAX_TRANSFERS_PER_DAY = 5;
6083
+ var transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY);
5460
6084
  function createTransferTicketEndpoint(slugs) {
5461
6085
  return {
5462
6086
  path: "/support/tickets/:id/transfer",
@@ -5481,8 +6105,7 @@ function createTransferTicketEndpoint(slugs) {
5481
6105
  const includeAttachments = body.includeAttachments !== false;
5482
6106
  const customMessage = typeof body.message === "string" ? body.message.slice(0, 1e3) : "";
5483
6107
  const payload = req.payload;
5484
- const ticket = await payload.findByID({
5485
- collection: slugs.tickets,
6108
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5486
6109
  id: ticketId,
5487
6110
  depth: 1,
5488
6111
  overrideAccess: true
@@ -5493,10 +6116,15 @@ function createTransferTicketEndpoint(slugs) {
5493
6116
  if (!isAdmin && !isOwner) {
5494
6117
  return Response.json({ error: "Forbidden" }, { status: 403 });
5495
6118
  }
6119
+ if (transferLimiter.check(`${req.user.id}:${ticketId}`)) {
6120
+ return Response.json(
6121
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
6122
+ { status: 429 }
6123
+ );
6124
+ }
5496
6125
  try {
5497
6126
  const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
5498
- const existing = await payload.count({
5499
- collection: slugs.emailLogs,
6127
+ const existing = await dbCount(payload, slugs.emailLogs, {
5500
6128
  where: {
5501
6129
  and: [
5502
6130
  { action: { equals: "transfer" } },
@@ -5514,8 +6142,7 @@ function createTransferTicketEndpoint(slugs) {
5514
6142
  }
5515
6143
  } catch {
5516
6144
  }
5517
- const messages = await payload.find({
5518
- collection: slugs.ticketMessages,
6145
+ const messages = await dbFind(payload, slugs.ticketMessages, {
5519
6146
  where: {
5520
6147
  and: [
5521
6148
  { ticket: { equals: ticketId } },
@@ -5595,8 +6222,7 @@ function createTransferTicketEndpoint(slugs) {
5595
6222
  html
5596
6223
  });
5597
6224
  try {
5598
- await payload.create({
5599
- collection: slugs.emailLogs,
6225
+ await dbCreate(payload, slugs.emailLogs, {
5600
6226
  data: {
5601
6227
  status: "success",
5602
6228
  action: "transfer",
@@ -5636,6 +6262,8 @@ function statusToLabel(status) {
5636
6262
  }
5637
6263
  }
5638
6264
  var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6265
+ var MAX_COLLABORATORS_PER_TICKET = 20;
6266
+ var inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10);
5639
6267
  function createInviteCollaboratorEndpoint(slugs) {
5640
6268
  return {
5641
6269
  path: "/support/tickets/:id/invite",
@@ -5659,8 +6287,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5659
6287
  }
5660
6288
  const role = body.role === "collaborator" ? "collaborator" : "viewer";
5661
6289
  const payload = req.payload;
5662
- const ticket = await payload.findByID({
5663
- collection: slugs.tickets,
6290
+ const ticket = await dbFindByID(payload, slugs.tickets, {
5664
6291
  id: ticketId,
5665
6292
  depth: 1,
5666
6293
  overrideAccess: true
@@ -5672,13 +6299,22 @@ function createInviteCollaboratorEndpoint(slugs) {
5672
6299
  if (!isAdmin && !isOwner) {
5673
6300
  return Response.json({ error: "Forbidden" }, { status: 403 });
5674
6301
  }
6302
+ if (inviteLimiter.check(String(req.user.id))) {
6303
+ return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
6304
+ }
6305
+ const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
6306
+ if (collabCount.totalDocs >= MAX_COLLABORATORS_PER_TICKET) {
6307
+ return Response.json(
6308
+ { error: `Limite de ${MAX_COLLABORATORS_PER_TICKET} collaborateurs atteinte sur ce ticket` },
6309
+ { status: 429 }
6310
+ );
6311
+ }
5675
6312
  const ownerEmail = typeof ticket.client === "object" ? ticket.client?.email : void 0;
5676
6313
  if (ownerEmail && ownerEmail.toLowerCase() === email) {
5677
6314
  return Response.json({ error: "Ce client est d\xE9j\xE0 propri\xE9taire du ticket" }, { status: 400 });
5678
6315
  }
5679
6316
  let inviteeId = null;
5680
- const existing = await payload.find({
5681
- collection: slugs.supportClients,
6317
+ const existing = await dbFind(payload, slugs.supportClients, {
5682
6318
  where: { email: { equals: email } },
5683
6319
  limit: 1,
5684
6320
  depth: 0,
@@ -5688,8 +6324,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5688
6324
  inviteeId = existing.docs[0].id;
5689
6325
  } else {
5690
6326
  const tempPassword = randomBytes(16).toString("hex");
5691
- const created = await payload.create({
5692
- collection: slugs.supportClients,
6327
+ const created = await dbCreate(payload, slugs.supportClients, {
5693
6328
  data: {
5694
6329
  email,
5695
6330
  firstName: "Invit\xE9",
@@ -5706,8 +6341,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5706
6341
  if (!inviteeId) {
5707
6342
  return Response.json({ error: "Impossible de cr\xE9er le client invit\xE9" }, { status: 500 });
5708
6343
  }
5709
- const dupe = await payload.find({
5710
- collection: "ticket-collaborators",
6344
+ const dupe = await dbFind(payload, "ticket-collaborators", {
5711
6345
  where: {
5712
6346
  and: [
5713
6347
  { ticket: { equals: ticketId } },
@@ -5720,15 +6354,13 @@ function createInviteCollaboratorEndpoint(slugs) {
5720
6354
  });
5721
6355
  const invitationToken = randomBytes(24).toString("hex");
5722
6356
  if (dupe.docs.length > 0) {
5723
- await payload.update({
5724
- collection: "ticket-collaborators",
6357
+ await dbUpdate(payload, "ticket-collaborators", {
5725
6358
  id: dupe.docs[0].id,
5726
6359
  data: { role, invitationToken },
5727
6360
  overrideAccess: true
5728
6361
  });
5729
6362
  } else {
5730
- await payload.create({
5731
- collection: "ticket-collaborators",
6363
+ await dbCreate(payload, "ticket-collaborators", {
5732
6364
  data: {
5733
6365
  ticket: ticketId,
5734
6366
  client: inviteeId,
@@ -5771,8 +6403,7 @@ function createInviteCollaboratorEndpoint(slugs) {
5771
6403
  )
5772
6404
  });
5773
6405
  try {
5774
- await payload.create({
5775
- collection: slugs.emailLogs,
6406
+ await dbCreate(payload, slugs.emailLogs, {
5776
6407
  data: {
5777
6408
  status: "success",
5778
6409
  action: "invite",
@@ -5825,6 +6456,7 @@ function createSupportEndpoints(slugs, options) {
5825
6456
  ];
5826
6457
  if (!f || f.ai !== false) {
5827
6458
  endpoints.push(createAiEndpoint(slugs));
6459
+ endpoints.push(createAiAgentEndpoint(slugs));
5828
6460
  endpoints.push(...createClientIntelligenceEndpoint(slugs));
5829
6461
  endpoints.push(createTicketSynthesisEndpoint(slugs));
5830
6462
  }
@@ -5839,7 +6471,10 @@ function createSupportEndpoints(slugs, options) {
5839
6471
  endpoints.push(createSignatureGetEndpoint(slugs), createSignaturePostEndpoint(slugs));
5840
6472
  }
5841
6473
  if (!f || f.sla !== false) endpoints.push(createSlaCheckEndpoint(slugs));
5842
- if (!f || f.autoClose !== false) endpoints.push(createAutoCloseEndpoint(slugs));
6474
+ if (!f || f.autoClose !== false) {
6475
+ endpoints.push(createAutoCloseEndpoint(slugs));
6476
+ endpoints.push(createSendReminderEndpoint(slugs));
6477
+ }
5843
6478
  if (!f || f.customStatuses !== false) endpoints.push(createStatusesEndpoint(slugs));
5844
6479
  if (!f || f.macros !== false) endpoints.push(createApplyMacroEndpoint(slugs));
5845
6480
  if (!f || f.roundRobin !== false) {
@@ -5852,21 +6487,26 @@ function createSupportEndpoints(slugs, options) {
5852
6487
  endpoints.push(createAdminChatGetEndpoint(slugs), createAdminChatPostEndpoint(slugs));
5853
6488
  endpoints.push(createAdminChatStreamEndpoint(slugs));
5854
6489
  }
5855
- if (!f || f.timeTracking !== false) endpoints.push(createBillingEndpoint(slugs));
6490
+ if (!f || f.timeTracking !== false) {
6491
+ endpoints.push(createBillingEndpoint(slugs));
6492
+ endpoints.push(createInvoiceEndpoint(slugs));
6493
+ }
5856
6494
  if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
5857
6495
  if (!f || f.emailTracking !== false) {
5858
6496
  endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
5859
6497
  }
5860
6498
  if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
5861
6499
  if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
6500
+ if (!f || f.snooze !== false) endpoints.push(createProcessSnoozeEndpoint(slugs));
6501
+ endpoints.push(createProcessDigestsEndpoint(slugs));
6502
+ endpoints.push(createChannelsWebhookEndpoint(slugs));
5862
6503
  return endpoints;
5863
6504
  }
5864
6505
 
5865
6506
  // src/utils/adminNotification.ts
5866
6507
  async function createAdminNotification(payload, data, collectionSlug = "admin-notifications") {
5867
6508
  try {
5868
- await payload.create({
5869
- collection: collectionSlug,
6509
+ await dbCreate(payload, collectionSlug, {
5870
6510
  data: {
5871
6511
  title: data.title,
5872
6512
  message: data.message,
@@ -5886,8 +6526,7 @@ function dispatchWebhook(data, event, payload, slugs) {
5886
6526
  }
5887
6527
  async function _dispatch(data, event, payload, slugs) {
5888
6528
  try {
5889
- const { docs: endpoints } = await payload.find({
5890
- collection: slugs.webhookEndpoints,
6529
+ const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
5891
6530
  where: {
5892
6531
  and: [
5893
6532
  { active: { equals: true } },
@@ -5924,8 +6563,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
5924
6563
  signal: AbortSignal.timeout(1e4)
5925
6564
  // 10s timeout
5926
6565
  });
5927
- await payload.update({
5928
- collection: slugs.webhookEndpoints,
6566
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5929
6567
  id: endpoint.id,
5930
6568
  data: {
5931
6569
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -5939,8 +6577,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
5939
6577
  } catch (err) {
5940
6578
  console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
5941
6579
  try {
5942
- await payload.update({
5943
- collection: slugs.webhookEndpoints,
6580
+ await dbUpdate(payload, slugs.webhookEndpoints, {
5944
6581
  id: endpoint.id,
5945
6582
  data: {
5946
6583
  lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -6174,8 +6811,7 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6174
6811
  if (typeof policyRef === "object" && policyRef.firstResponseTime) {
6175
6812
  return policyRef;
6176
6813
  }
6177
- return await payload.findByID({
6178
- collection: slugs.slaPolicies,
6814
+ return await dbFindByID(payload, slugs.slaPolicies, {
6179
6815
  id: policyId,
6180
6816
  depth: 0,
6181
6817
  overrideAccess: true
@@ -6186,8 +6822,7 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
6186
6822
  }
6187
6823
  const priority = field(ticket, "priority") || "normal";
6188
6824
  try {
6189
- const defaults = await payload.find({
6190
- collection: slugs.slaPolicies,
6825
+ const defaults = await dbFind(payload, slugs.slaPolicies, {
6191
6826
  where: {
6192
6827
  and: [
6193
6828
  { isDefault: { equals: true } },
@@ -6224,8 +6859,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6224
6859
  if (typeof policyRef === "object" && policyRef.escalateOnBreach !== void 0) {
6225
6860
  policy = policyRef;
6226
6861
  } else {
6227
- policy = await payload.findByID({
6228
- collection: slugs.slaPolicies,
6862
+ policy = await dbFindByID(payload, slugs.slaPolicies, {
6229
6863
  id: policyId,
6230
6864
  depth: 0,
6231
6865
  overrideAccess: true
@@ -6238,8 +6872,7 @@ async function handleSlaBreach(payload, ticket, type, slugs, notificationSlug) {
6238
6872
  try {
6239
6873
  const escalateToId = typeof policy.escalateTo === "object" ? policy.escalateTo.id : policy.escalateTo;
6240
6874
  if (!escalateToId) return;
6241
- const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await payload.findByID({
6242
- collection: slugs.users,
6875
+ const escalateUser = typeof policy.escalateTo === "object" && policy.escalateTo.email ? policy.escalateTo : await dbFindByID(payload, slugs.users, {
6243
6876
  id: escalateToId,
6244
6877
  depth: 0,
6245
6878
  overrideAccess: true
@@ -6287,8 +6920,7 @@ function createAssignSlaDeadlines(slugs, notificationSlug = "admin-notifications
6287
6920
  if (!field(doc, "slaPolicy") && policy.id) {
6288
6921
  updateData.slaPolicy = policy.id;
6289
6922
  }
6290
- await payload.update({
6291
- collection: slugs.tickets,
6923
+ await dbUpdate(payload, slugs.tickets, {
6292
6924
  id: doc.id,
6293
6925
  data: updateData,
6294
6926
  overrideAccess: true
@@ -6311,8 +6943,7 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6311
6943
  const now = /* @__PURE__ */ new Date();
6312
6944
  const deadline = new Date(doc.slaResolutionDue);
6313
6945
  const breached = now > deadline;
6314
- await payload.update({
6315
- collection: slugs.tickets,
6946
+ await dbUpdate(payload, slugs.tickets, {
6316
6947
  id: doc.id,
6317
6948
  data: { slaResolutionBreached: breached },
6318
6949
  overrideAccess: true
@@ -6326,6 +6957,35 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
6326
6957
  return doc;
6327
6958
  };
6328
6959
  }
6960
+ function createPauseSlaOnHold(slugs) {
6961
+ return async ({ doc, previousDoc, operation, req }) => {
6962
+ if (operation !== "update" || !previousDoc) return doc;
6963
+ if (previousDoc.status === doc.status) return doc;
6964
+ if (!doc.slaResolutionDue) return doc;
6965
+ const wasWaiting = previousDoc.status === "waiting_client";
6966
+ const isWaiting = doc.status === "waiting_client";
6967
+ try {
6968
+ if (!wasWaiting && isWaiting && !doc.slaPausedAt && doc.status !== "resolved") {
6969
+ await req.payload.update({
6970
+ collection: slugs.tickets,
6971
+ id: doc.id,
6972
+ data: { slaPausedAt: (/* @__PURE__ */ new Date()).toISOString() },
6973
+ overrideAccess: true
6974
+ });
6975
+ } else if (wasWaiting && !isWaiting && doc.slaPausedAt) {
6976
+ const pausedMs = Date.now() - new Date(doc.slaPausedAt).getTime();
6977
+ const data = { slaPausedAt: null };
6978
+ if (pausedMs > 0) {
6979
+ data.slaResolutionDue = new Date(new Date(doc.slaResolutionDue).getTime() + pausedMs).toISOString();
6980
+ }
6981
+ await req.payload.update({ collection: slugs.tickets, id: doc.id, data, overrideAccess: true });
6982
+ }
6983
+ } catch (err) {
6984
+ console.error("[sla] Failed to pause/resume SLA on hold:", err);
6985
+ }
6986
+ return doc;
6987
+ };
6988
+ }
6329
6989
  function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications") {
6330
6990
  return async ({ doc, operation, req }) => {
6331
6991
  if (operation !== "create") return doc;
@@ -6333,8 +6993,7 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6333
6993
  try {
6334
6994
  const { payload } = req;
6335
6995
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
6336
- const ticket = await payload.findByID({
6337
- collection: slugs.tickets,
6996
+ const ticket = await dbFindByID(payload, slugs.tickets, {
6338
6997
  id: ticketId,
6339
6998
  depth: 1,
6340
6999
  overrideAccess: true
@@ -6345,8 +7004,7 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6345
7004
  const now = /* @__PURE__ */ new Date();
6346
7005
  const deadline = new Date(ticket.slaFirstResponseDue);
6347
7006
  const breached = now > deadline;
6348
- await payload.update({
6349
- collection: slugs.tickets,
7007
+ await dbUpdate(payload, slugs.tickets, {
6350
7008
  id: ticketId,
6351
7009
  data: { slaFirstResponseBreached: breached },
6352
7010
  overrideAccess: true
@@ -6361,6 +7019,108 @@ function createCheckSlaOnReply(slugs, notificationSlug = "admin-notifications")
6361
7019
  };
6362
7020
  }
6363
7021
 
7022
+ // src/hooks/automationRules.ts
7023
+ function evaluateConditions(doc, rule) {
7024
+ const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
7025
+ if (conds.length === 0) return true;
7026
+ const results = conds.map((c) => {
7027
+ const fieldVal = String(doc[c.field] ?? "");
7028
+ const v = String(c.value ?? "");
7029
+ switch (c.operator) {
7030
+ case "equals":
7031
+ return fieldVal === v;
7032
+ case "not_equals":
7033
+ return fieldVal !== v;
7034
+ case "contains":
7035
+ return fieldVal.toLowerCase().includes(v.toLowerCase());
7036
+ default:
7037
+ return false;
7038
+ }
7039
+ });
7040
+ return rule.matchType === "any" ? results.some(Boolean) : results.every(Boolean);
7041
+ }
7042
+ function createApplyAutomationRules(slugs) {
7043
+ return async ({ doc, previousDoc, operation, req }) => {
7044
+ if (req.context?.skipAutomation) return doc;
7045
+ const events = [];
7046
+ if (operation === "create") events.push("ticket_created");
7047
+ if (operation === "update") {
7048
+ events.push("ticket_updated");
7049
+ if (previousDoc && previousDoc.status !== doc.status) events.push("ticket_status_changed");
7050
+ }
7051
+ if (events.length === 0) return doc;
7052
+ try {
7053
+ const rules = await dbFind(req.payload, slugs.automationRules, {
7054
+ where: { and: [{ enabled: { equals: true } }, { event: { in: events } }] },
7055
+ sort: "order",
7056
+ limit: 200,
7057
+ depth: 0,
7058
+ overrideAccess: true
7059
+ });
7060
+ const changes = {};
7061
+ const tags = Array.isArray(doc.tags) ? [...doc.tags] : [];
7062
+ let tagsTouched = false;
7063
+ for (const rule of rules.docs) {
7064
+ if (!evaluateConditions(doc, rule)) continue;
7065
+ for (const a of Array.isArray(rule.actions) ? rule.actions : []) {
7066
+ if (!a.value) continue;
7067
+ switch (a.type) {
7068
+ case "set_status":
7069
+ changes.status = a.value;
7070
+ break;
7071
+ case "set_priority":
7072
+ changes.priority = a.value;
7073
+ break;
7074
+ case "set_category":
7075
+ changes.category = a.value;
7076
+ break;
7077
+ case "assign":
7078
+ changes.assignedTo = a.value;
7079
+ break;
7080
+ case "add_tag":
7081
+ if (!tags.includes(a.value)) {
7082
+ tags.push(a.value);
7083
+ tagsTouched = true;
7084
+ }
7085
+ break;
7086
+ }
7087
+ }
7088
+ }
7089
+ if (tagsTouched) changes.tags = tags;
7090
+ if (Object.keys(changes).length > 0) {
7091
+ await req.payload.update({
7092
+ collection: slugs.tickets,
7093
+ id: doc.id,
7094
+ data: changes,
7095
+ overrideAccess: true,
7096
+ context: { skipAutomation: true }
7097
+ });
7098
+ }
7099
+ } catch (err) {
7100
+ console.error("[support] Failed to apply automation rules:", err);
7101
+ }
7102
+ return doc;
7103
+ };
7104
+ }
7105
+
7106
+ // src/utils/teamAccess.ts
7107
+ function isTeamScopingEnabled() {
7108
+ return process.env.SUPPORT_TEAM_SCOPING === "1" || process.env.SUPPORT_TEAM_SCOPING === "true";
7109
+ }
7110
+ async function resolveAgentTeamIds(payload, slugs, userId) {
7111
+ try {
7112
+ const teams = await dbFind(payload, slugs.supportTeams, {
7113
+ where: { members: { in: [userId] } },
7114
+ limit: 200,
7115
+ depth: 0,
7116
+ overrideAccess: true
7117
+ });
7118
+ return teams.docs.map((t) => t.id);
7119
+ } catch {
7120
+ return [];
7121
+ }
7122
+ }
7123
+
6364
7124
  // src/collections/Tickets.ts
6365
7125
  function createAssignTicketNumber(slugs) {
6366
7126
  return async ({ data, operation, req }) => {
@@ -6623,56 +7383,6 @@ function createNotifyClientOnResolve(slugs) {
6623
7383
  return doc;
6624
7384
  };
6625
7385
  }
6626
- function createAutoCalculateSLA(slugs) {
6627
- return async ({ doc, operation, req }) => {
6628
- if (operation !== "create") return doc;
6629
- try {
6630
- const { payload } = req;
6631
- const ticketPriority = doc.priority || "normal";
6632
- let policy = null;
6633
- const byPriority = await payload.find({
6634
- collection: slugs.slaPolicies,
6635
- where: { priority: { equals: ticketPriority } },
6636
- limit: 1,
6637
- depth: 0,
6638
- overrideAccess: true
6639
- });
6640
- if (byPriority.docs.length > 0) {
6641
- policy = byPriority.docs[0];
6642
- } else {
6643
- const defaults = await payload.find({
6644
- collection: slugs.slaPolicies,
6645
- where: { isDefault: { equals: true } },
6646
- limit: 1,
6647
- depth: 0,
6648
- overrideAccess: true
6649
- });
6650
- if (defaults.docs.length > 0) {
6651
- policy = defaults.docs[0];
6652
- }
6653
- }
6654
- if (!policy) return doc;
6655
- const now = /* @__PURE__ */ new Date();
6656
- const firstResponseMinutes = policy.firstResponseTime || 240;
6657
- const resolutionMinutes = policy.resolutionTime || 1440;
6658
- const firstResponseDue = new Date(now.getTime() + firstResponseMinutes * 6e4);
6659
- const resolutionDue = new Date(now.getTime() + resolutionMinutes * 6e4);
6660
- await payload.update({
6661
- collection: slugs.tickets,
6662
- id: doc.id,
6663
- data: {
6664
- slaPolicy: policy.id,
6665
- slaFirstResponseDue: firstResponseDue.toISOString(),
6666
- slaResolutionDue: resolutionDue.toISOString()
6667
- },
6668
- overrideAccess: true
6669
- });
6670
- } catch (err) {
6671
- console.error("[support] Failed to auto-calculate SLA:", err);
6672
- }
6673
- return doc;
6674
- };
6675
- }
6676
7386
  function createFireTicketWebhooks(slugs) {
6677
7387
  return async ({ doc, previousDoc, operation, req }) => {
6678
7388
  const { payload } = req;
@@ -6764,8 +7474,7 @@ function createCascadeDelete(slugs) {
6764
7474
  return async ({ id, req }) => {
6765
7475
  const collections = [slugs.ticketMessages, slugs.ticketActivityLog, slugs.timeEntries, slugs.satisfactionSurveys];
6766
7476
  for (const slug of collections) {
6767
- await req.payload.delete({
6768
- collection: slug,
7477
+ await dbDelete(req.payload, slug, {
6769
7478
  where: { ticket: { equals: id } },
6770
7479
  overrideAccess: true
6771
7480
  });
@@ -6890,6 +7599,7 @@ function createTicketsCollection(slugs, options) {
6890
7599
  {
6891
7600
  name: "status",
6892
7601
  type: "select",
7602
+ index: true,
6893
7603
  defaultValue: "open",
6894
7604
  label: "Statut",
6895
7605
  options: [
@@ -6904,6 +7614,7 @@ function createTicketsCollection(slugs, options) {
6904
7614
  {
6905
7615
  name: "priority",
6906
7616
  type: "select",
7617
+ index: true,
6907
7618
  defaultValue: "normal",
6908
7619
  label: "Priorite",
6909
7620
  options: [
@@ -6919,7 +7630,7 @@ function createTicketsCollection(slugs, options) {
6919
7630
  {
6920
7631
  type: "row",
6921
7632
  fields: [
6922
- { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, label: "Client", admin: { width: "50%" } }
7633
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client", admin: { width: "50%" } }
6923
7634
  ]
6924
7635
  },
6925
7636
  // Billing collapsible
@@ -6988,15 +7699,16 @@ function createTicketsCollection(slugs, options) {
6988
7699
  {
6989
7700
  type: "row",
6990
7701
  fields: [
6991
- { name: "slaFirstResponseDue", type: "date", label: "Echeance 1ere reponse (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
6992
- { name: "slaResolutionDue", type: "date", label: "Echeance resolution (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
7702
+ { name: "slaFirstResponseDue", type: "date", index: true, label: "Echeance 1ere reponse (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7703
+ { name: "slaResolutionDue", type: "date", index: true, label: "Echeance resolution (SLA)", admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
6993
7704
  ]
6994
7705
  },
6995
7706
  {
6996
7707
  type: "row",
6997
7708
  fields: [
6998
7709
  { name: "slaFirstResponseBreached", type: "checkbox", label: "1ere reponse depassee", admin: { readOnly: true, width: "50%" } },
6999
- { name: "slaResolutionBreached", type: "checkbox", label: "Resolution depassee", admin: { readOnly: true, width: "50%" } }
7710
+ { name: "slaResolutionBreached", type: "checkbox", label: "Resolution depassee", admin: { readOnly: true, width: "50%" } },
7711
+ { name: "slaPausedAt", type: "date", label: "SLA en pause depuis", admin: { hidden: true } }
7000
7712
  ]
7001
7713
  }
7002
7714
  ]
@@ -7017,7 +7729,8 @@ function createTicketsCollection(slugs, options) {
7017
7729
  ]
7018
7730
  },
7019
7731
  { name: "mergedInto", type: "relationship", relationTo: slugs.tickets, label: "Fusionne dans", admin: { readOnly: true } },
7020
- { name: "autoCloseRemindedAt", type: "date", label: "Rappel auto-close envoye", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
7732
+ { name: "autoCloseRemindedAt", type: "date", label: "Rappel auto-close envoye", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
7733
+ { name: "autoCloseScheduledAt", type: "date", index: true, label: "Fermeture auto programmee", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" }, description: "Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client." } }
7021
7734
  ]
7022
7735
  },
7023
7736
  // Sidebar
@@ -7026,6 +7739,7 @@ function createTicketsCollection(slugs, options) {
7026
7739
  {
7027
7740
  name: "category",
7028
7741
  type: "select",
7742
+ index: true,
7029
7743
  label: "Categorie",
7030
7744
  options: [
7031
7745
  { label: "Bug / Dysfonctionnement", value: "bug" },
@@ -7036,7 +7750,8 @@ function createTicketsCollection(slugs, options) {
7036
7750
  ],
7037
7751
  admin: { position: "sidebar" }
7038
7752
  },
7039
- { name: "assignedTo", type: "relationship", relationTo: slugs.users, label: "Assigne a", admin: { position: "sidebar" } },
7753
+ { name: "assignedTo", type: "relationship", relationTo: slugs.users, index: true, label: "Assigne a", admin: { position: "sidebar" } },
7754
+ { name: "team", type: "relationship", relationTo: slugs.supportTeams, index: true, label: "Equipe", admin: { position: "sidebar" } },
7040
7755
  {
7041
7756
  name: "source",
7042
7757
  type: "select",
@@ -7046,7 +7761,9 @@ function createTicketsCollection(slugs, options) {
7046
7761
  { label: "Portail client", value: "portal" },
7047
7762
  { label: "Chat en direct", value: "live-chat" },
7048
7763
  { label: "Email", value: "email" },
7049
- { label: "Admin", value: "admin" }
7764
+ { label: "Admin", value: "admin" },
7765
+ { label: "WhatsApp", value: "whatsapp" },
7766
+ { label: "Messenger", value: "messenger" }
7050
7767
  ],
7051
7768
  admin: { position: "sidebar" }
7052
7769
  },
@@ -7097,8 +7814,8 @@ function createTicketsCollection(slugs, options) {
7097
7814
  afterChange: [
7098
7815
  createTrackSLA(slugs),
7099
7816
  createTrackAiSummaryOnResolve(slugs),
7100
- createAutoCalculateSLA(slugs),
7101
7817
  createAssignSlaDeadlines(slugs, notificationSlug),
7818
+ createPauseSlaOnHold(slugs),
7102
7819
  createCheckSlaOnResolve(slugs, notificationSlug),
7103
7820
  createLogTicketActivity(slugs),
7104
7821
  createNotifyOnAssignment(slugs),
@@ -7106,7 +7823,8 @@ function createTicketsCollection(slugs, options) {
7106
7823
  createTicketStatusEmail(slugs),
7107
7824
  createNotifyAdminOnNewTicket(slugs, notificationSlug),
7108
7825
  createFireTicketWebhooks(slugs),
7109
- createDispatchWebhookOnTicket(slugs)
7826
+ createDispatchWebhookOnTicket(slugs),
7827
+ createApplyAutomationRules(slugs)
7110
7828
  ],
7111
7829
  beforeDelete: [createCascadeDelete(slugs)]
7112
7830
  },
@@ -7117,12 +7835,20 @@ function createTicketsCollection(slugs, options) {
7117
7835
  return false;
7118
7836
  },
7119
7837
  read: async ({ req }) => {
7120
- if (req.user?.collection === slugs.users) return true;
7838
+ if (req.user?.collection === slugs.users) {
7839
+ if (!isTeamScopingEnabled()) return true;
7840
+ const teamIds = await resolveAgentTeamIds(req.payload, slugs, req.user.id);
7841
+ return {
7842
+ or: [
7843
+ { team: { in: teamIds.length > 0 ? teamIds : [-1] } },
7844
+ { team: { exists: false } }
7845
+ ]
7846
+ };
7847
+ }
7121
7848
  if (req.user?.collection === slugs.supportClients) {
7122
7849
  let collabTicketIds = [];
7123
7850
  try {
7124
- const rows = await req.payload.find({
7125
- collection: "ticket-collaborators",
7851
+ const rows = await dbFind(req.payload, "ticket-collaborators", {
7126
7852
  where: { client: { equals: req.user.id } },
7127
7853
  limit: 1e3,
7128
7854
  depth: 0,
@@ -7157,6 +7883,125 @@ function createTicketsCollection(slugs, options) {
7157
7883
  };
7158
7884
  }
7159
7885
 
7886
+ // src/utils/ticketAccess.ts
7887
+ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
7888
+ const ids = /* @__PURE__ */ new Set();
7889
+ try {
7890
+ const owned = await dbFind(payload, slugs.tickets, {
7891
+ where: { client: { equals: clientId } },
7892
+ limit: 5e3,
7893
+ depth: 0,
7894
+ overrideAccess: true
7895
+ });
7896
+ for (const t of owned.docs) ids.add(t.id);
7897
+ } catch {
7898
+ }
7899
+ try {
7900
+ const collab = await dbFind(payload, "ticket-collaborators", {
7901
+ where: { client: { equals: clientId } },
7902
+ limit: 1e3,
7903
+ depth: 0,
7904
+ overrideAccess: true
7905
+ });
7906
+ for (const r of collab.docs) {
7907
+ const row = r;
7908
+ const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
7909
+ if (tid !== void 0 && tid !== null) ids.add(tid);
7910
+ }
7911
+ } catch {
7912
+ }
7913
+ return Array.from(ids);
7914
+ }
7915
+ var OPTIONS = {
7916
+ allowedTags: [
7917
+ "p",
7918
+ "br",
7919
+ "b",
7920
+ "strong",
7921
+ "i",
7922
+ "em",
7923
+ "u",
7924
+ "s",
7925
+ "strike",
7926
+ "del",
7927
+ "ins",
7928
+ "mark",
7929
+ "a",
7930
+ "ul",
7931
+ "ol",
7932
+ "li",
7933
+ "blockquote",
7934
+ "code",
7935
+ "pre",
7936
+ "kbd",
7937
+ "span",
7938
+ "div",
7939
+ "h1",
7940
+ "h2",
7941
+ "h3",
7942
+ "h4",
7943
+ "h5",
7944
+ "h6",
7945
+ "hr",
7946
+ "img",
7947
+ "table",
7948
+ "thead",
7949
+ "tbody",
7950
+ "tr",
7951
+ "th",
7952
+ "td"
7953
+ ],
7954
+ allowedAttributes: {
7955
+ a: ["href", "title", "target", "rel"],
7956
+ img: ["src", "alt", "title", "width", "height"],
7957
+ code: ["class"],
7958
+ pre: ["class"],
7959
+ span: ["class"],
7960
+ "*": ["style"]
7961
+ },
7962
+ allowedSchemes: ["http", "https", "mailto", "tel"],
7963
+ allowedSchemesByTag: { img: ["http", "https", "data"] },
7964
+ allowProtocolRelative: false,
7965
+ disallowedTagsMode: "discard",
7966
+ // Force safe rel on links opening a new tab (defense against tabnabbing).
7967
+ transformTags: {
7968
+ a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer nofollow" }, true)
7969
+ },
7970
+ // Only allow CSS that cannot carry a script payload.
7971
+ allowedStyles: {
7972
+ "*": {
7973
+ color: [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
7974
+ "background-color": [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
7975
+ "text-align": [/^(left|right|center|justify)$/],
7976
+ "font-weight": [/^(normal|bold|[1-9]00)$/],
7977
+ "font-style": [/^(normal|italic)$/],
7978
+ "text-decoration": [/^(underline|line-through|none)$/]
7979
+ }
7980
+ }
7981
+ };
7982
+ function sanitizeMessageHtml(html) {
7983
+ if (!html) return html;
7984
+ return sanitizeHtml(html, OPTIONS);
7985
+ }
7986
+
7987
+ // src/utils/notificationQueue.ts
7988
+ async function queueClientNotification(payload, slugs, item) {
7989
+ try {
7990
+ await dbCreate(payload, slugs.notificationQueue, {
7991
+ data: {
7992
+ client: item.client,
7993
+ type: item.type,
7994
+ title: item.title,
7995
+ message: item.message,
7996
+ ...item.ticket ? { ticket: item.ticket } : {}
7997
+ },
7998
+ overrideAccess: true
7999
+ });
8000
+ } catch (err) {
8001
+ console.error("[support] Failed to queue client notification:", err);
8002
+ }
8003
+ }
8004
+
7160
8005
  // src/collections/TicketMessages.ts
7161
8006
  function createAssignAuthor(slugs) {
7162
8007
  return async ({ data, operation, req }) => {
@@ -7180,8 +8025,12 @@ function createAutoUpdateStatus(slugs) {
7180
8025
  if (!doc.isInternal) {
7181
8026
  if (doc.authorType === "admin") {
7182
8027
  updateData.status = "waiting_client";
8028
+ updateData.autoCloseScheduledAt = null;
8029
+ updateData.autoCloseRemindedAt = null;
7183
8030
  } else if (doc.authorType === "client" || doc.authorType === "email") {
7184
8031
  updateData.lastClientMessageAt = (/* @__PURE__ */ new Date()).toISOString();
8032
+ updateData.autoCloseScheduledAt = null;
8033
+ updateData.autoCloseRemindedAt = null;
7185
8034
  if (ticket.status && ["waiting_client", "resolved"].includes(ticket.status)) {
7186
8035
  updateData.status = "open";
7187
8036
  }
@@ -7215,29 +8064,46 @@ function createNotifyClient(slugs) {
7215
8064
  const internalBcc = process.env.SUPPORT_CLIENT_BCC || void 0;
7216
8065
  const rawContent = doc.bodyHtml ? emailRichContent(doc.bodyHtml) : emailQuote(doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body);
7217
8066
  const replyPreview = (doc.body || "").replace(/\s+/g, " ").trim().slice(0, 90);
7218
- await req.payload.sendEmail({
7219
- to: client.email,
7220
- ...supportEmail ? { replyTo: supportEmail } : {},
7221
- ...internalBcc ? { bcc: internalBcc } : {},
7222
- subject: `Re: [${ticketNumber}] ${subject}`,
7223
- html: emailWrapper(`Nouvelle reponse \u2014 ${ticketNumber}`, [
7224
- emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
7225
- emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`),
7226
- rawContent,
7227
- emailButton("Consulter le ticket", portalUrl),
7228
- emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
7229
- emailTrackingPixel(ticketId, doc.id)
7230
- ].join(""), {
7231
- kind: "reply",
7232
- preheader: replyPreview ? `${replyPreview}\u2026` : `Nouvelle reponse a votre ticket ${ticketNumber}`
7233
- })
7234
- });
7235
- await req.payload.update({
7236
- collection: slugs.ticketMessages,
7237
- id: doc.id,
7238
- data: { emailSentAt: (/* @__PURE__ */ new Date()).toISOString(), emailSentTo: client.email },
7239
- overrideAccess: true
7240
- });
8067
+ const freq = client.notificationFrequency;
8068
+ if (freq === "daily" || freq === "weekly") {
8069
+ await queueClientNotification(req.payload, slugs, {
8070
+ client: client.id,
8071
+ type: "reply",
8072
+ title: `Nouvelle reponse \u2014 ${ticketNumber}`,
8073
+ message: replyPreview || `Reponse a votre ticket ${ticketNumber}`,
8074
+ ticket: ticketId
8075
+ });
8076
+ return doc;
8077
+ }
8078
+ void (async () => {
8079
+ try {
8080
+ await req.payload.sendEmail({
8081
+ to: client.email,
8082
+ ...supportEmail ? { replyTo: supportEmail } : {},
8083
+ ...internalBcc ? { bcc: internalBcc } : {},
8084
+ subject: `Re: [${ticketNumber}] ${subject}`,
8085
+ html: emailWrapper(`Nouvelle reponse \u2014 ${ticketNumber}`, [
8086
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
8087
+ emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`),
8088
+ rawContent,
8089
+ emailButton("Consulter le ticket", portalUrl),
8090
+ emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
8091
+ emailTrackingPixel(ticketId, doc.id)
8092
+ ].join(""), {
8093
+ kind: "reply",
8094
+ preheader: replyPreview ? `${replyPreview}\u2026` : `Nouvelle reponse a votre ticket ${ticketNumber}`
8095
+ })
8096
+ });
8097
+ await req.payload.update({
8098
+ collection: slugs.ticketMessages,
8099
+ id: doc.id,
8100
+ data: { emailSentAt: (/* @__PURE__ */ new Date()).toISOString(), emailSentTo: client.email },
8101
+ overrideAccess: true
8102
+ });
8103
+ } catch (err) {
8104
+ console.error("[support] Failed to notify client (async):", err);
8105
+ }
8106
+ })();
7241
8107
  } catch (err) {
7242
8108
  console.error("[support] Failed to notify client:", err);
7243
8109
  }
@@ -7275,8 +8141,7 @@ function createSyncTicketReplyToChat(slugs) {
7275
8141
  if (!ticket?.chatSession) return doc;
7276
8142
  if (doc.skipNotification) return doc;
7277
8143
  const clientId = typeof ticket.client === "object" ? ticket.client.id : ticket.client;
7278
- await payload.create({
7279
- collection: slugs.chatMessages,
8144
+ await dbCreate(payload, slugs.chatMessages, {
7280
8145
  data: {
7281
8146
  session: ticket.chatSession,
7282
8147
  client: clientId,
@@ -7393,6 +8258,57 @@ function createDispatchWebhookOnReply(slugs) {
7393
8258
  return doc;
7394
8259
  };
7395
8260
  }
8261
+ function createSanitizeMessageHtml() {
8262
+ return ({ data }) => {
8263
+ if (data && typeof data.bodyHtml === "string" && data.bodyHtml) {
8264
+ data.bodyHtml = sanitizeMessageHtml(data.bodyHtml);
8265
+ }
8266
+ return data;
8267
+ };
8268
+ }
8269
+ var MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
8270
+ function createResolveMentions(slugs) {
8271
+ return async ({ data, operation, req }) => {
8272
+ if (operation !== "create") return data;
8273
+ const body = typeof data?.body === "string" ? data.body : "";
8274
+ const emails = Array.from(new Set([...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase())));
8275
+ if (emails.length === 0) return data;
8276
+ try {
8277
+ const users = await dbFind(req.payload, slugs.users, { where: { email: { in: emails } }, limit: 50, depth: 0, overrideAccess: true });
8278
+ const ids = users.docs.map((u) => u.id);
8279
+ if (ids.length > 0) data.mentions = ids;
8280
+ } catch {
8281
+ }
8282
+ return data;
8283
+ };
8284
+ }
8285
+ function createNotifyMentions(slugs) {
8286
+ return async ({ doc, operation, req }) => {
8287
+ if (operation !== "create") return doc;
8288
+ const mentions = Array.isArray(doc.mentions) ? doc.mentions : [];
8289
+ if (mentions.length === 0) return doc;
8290
+ const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
8291
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
8292
+ for (const m of mentions) {
8293
+ const userId = typeof m === "object" ? m.id : m;
8294
+ try {
8295
+ const user = await dbFindByID(req.payload, slugs.users, { id: userId, depth: 0, overrideAccess: true });
8296
+ if (!user?.email) continue;
8297
+ await req.payload.sendEmail({
8298
+ to: user.email,
8299
+ subject: `Vous avez ete mentionne \u2014 ticket #${ticketId}`,
8300
+ html: emailWrapper("Mention", [
8301
+ emailParagraph(`Vous avez ete mentionne dans un message du ticket <strong>#${escapeHtml(String(ticketId))}</strong>.`),
8302
+ ...baseUrl ? [emailButton("Ouvrir le ticket", `${baseUrl}/admin/collections/${slugs.tickets}/${ticketId}`, "dark")] : []
8303
+ ].join(""), { kind: "system" })
8304
+ });
8305
+ } catch (err) {
8306
+ console.error("[support] Failed to notify mention:", err);
8307
+ }
8308
+ }
8309
+ return doc;
8310
+ };
8311
+ }
7396
8312
  function createTicketMessagesCollection(slugs, options) {
7397
8313
  const notificationSlug = options?.notificationSlug || "admin-notifications";
7398
8314
  return {
@@ -7400,7 +8316,7 @@ function createTicketMessagesCollection(slugs, options) {
7400
8316
  labels: { singular: "Message", plural: "Messages" },
7401
8317
  admin: { hidden: true, group: "Support", defaultColumns: ["ticket", "authorType", "createdAt"] },
7402
8318
  fields: [
7403
- { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, label: "Ticket" },
8319
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, required: true, index: true, label: "Ticket" },
7404
8320
  { name: "body", type: "textarea", required: true, label: "Message" },
7405
8321
  { name: "bodyHtml", type: "textarea", label: "Message HTML", admin: { hidden: true } },
7406
8322
  {
@@ -7409,6 +8325,7 @@ function createTicketMessagesCollection(slugs, options) {
7409
8325
  {
7410
8326
  name: "authorType",
7411
8327
  type: "select",
8328
+ index: true,
7412
8329
  label: "Type d'auteur",
7413
8330
  defaultValue: "admin",
7414
8331
  options: [
@@ -7444,8 +8361,9 @@ function createTicketMessagesCollection(slugs, options) {
7444
8361
  },
7445
8362
  { name: "isInternal", type: "checkbox", defaultValue: false, label: "Note interne", admin: { position: "sidebar" } },
7446
8363
  { name: "isSolution", type: "checkbox", defaultValue: false, label: "Reponse solution", admin: { position: "sidebar" } },
8364
+ { name: "mentions", type: "relationship", relationTo: slugs.users, hasMany: true, label: "Agents mentionnes", admin: { hidden: true } },
7447
8365
  { name: "skipNotification", type: "checkbox", defaultValue: false, label: "Sans notification", admin: { position: "sidebar", condition: (data) => data?.skipNotification === true } },
7448
- { name: "scheduledAt", type: "date", label: "Programme pour", admin: { date: { pickerAppearance: "dayAndTime" }, position: "sidebar", condition: (data) => !!data?.scheduledAt } },
8366
+ { name: "scheduledAt", type: "date", index: true, label: "Programme pour", admin: { date: { pickerAppearance: "dayAndTime" }, position: "sidebar", condition: (data) => !!data?.scheduledAt } },
7449
8367
  { name: "scheduledSent", type: "checkbox", defaultValue: false, admin: { hidden: true } },
7450
8368
  { name: "editedAt", type: "date", label: "Modifie le", admin: { hidden: true } },
7451
8369
  { name: "deletedAt", type: "date", label: "Supprime le", admin: { hidden: true } },
@@ -7454,7 +8372,7 @@ function createTicketMessagesCollection(slugs, options) {
7454
8372
  { name: "emailOpenedAt", type: "date", label: "Email ouvert le", admin: { hidden: true } }
7455
8373
  ],
7456
8374
  hooks: {
7457
- beforeChange: [createAssignAuthor(slugs)],
8375
+ beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
7458
8376
  afterChange: [
7459
8377
  createAutoUpdateStatus(slugs),
7460
8378
  createNotifyClient(slugs),
@@ -7463,17 +8381,19 @@ function createTicketMessagesCollection(slugs, options) {
7463
8381
  createSyncTicketReplyToChat(slugs),
7464
8382
  createNotifyAdminOnClientMessage(slugs, notificationSlug),
7465
8383
  createFireMessageWebhooks(slugs),
7466
- createDispatchWebhookOnReply(slugs)
8384
+ createDispatchWebhookOnReply(slugs),
8385
+ createNotifyMentions(slugs)
7467
8386
  ]
7468
8387
  },
7469
8388
  access: {
7470
8389
  create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
7471
- read: ({ req }) => {
8390
+ read: async ({ req }) => {
7472
8391
  if (req.user?.collection === slugs.users) return true;
7473
8392
  if (req.user?.collection === slugs.supportClients) {
8393
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
7474
8394
  return {
7475
8395
  and: [
7476
- { "ticket.client": { equals: req.user.id } },
8396
+ { ticket: { in: ids.length > 0 ? ids : [-1] } },
7477
8397
  { isInternal: { equals: false } },
7478
8398
  { or: [{ scheduledAt: { exists: false } }, { scheduledSent: { equals: true } }] }
7479
8399
  ]
@@ -7493,8 +8413,6 @@ function createTicketMessagesCollection(slugs, options) {
7493
8413
  timestamps: true
7494
8414
  };
7495
8415
  }
7496
-
7497
- // src/collections/SupportClients.ts
7498
8416
  function createSendInvitationOnCreate(slugs) {
7499
8417
  return async ({ doc, operation, req }) => {
7500
8418
  if (operation !== "create") return doc;
@@ -7534,6 +8452,26 @@ function createSendInvitationOnCreate(slugs) {
7534
8452
  return doc;
7535
8453
  };
7536
8454
  }
8455
+ var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
8456
+ function createEnforce2FA(slugs) {
8457
+ return async ({ req, user }) => {
8458
+ if (!user?.twoFactorEnabled) return user;
8459
+ const raw = user.twoFactorVerifiedAt;
8460
+ const verifiedAt = raw ? new Date(raw).getTime() : 0;
8461
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
8462
+ throw new APIError("2FA_REQUIRED", 401);
8463
+ }
8464
+ await req.payload.update({
8465
+ collection: slugs.supportClients,
8466
+ id: user.id,
8467
+ data: { twoFactorVerifiedAt: null },
8468
+ req,
8469
+ overrideAccess: true
8470
+ });
8471
+ user.twoFactorVerifiedAt = null;
8472
+ return user;
8473
+ };
8474
+ }
7537
8475
  function createSupportClientsCollection(slugs) {
7538
8476
  return {
7539
8477
  slug: slugs.supportClients,
@@ -7662,6 +8600,10 @@ function createSupportClientsCollection(slugs) {
7662
8600
  name: "opportunities",
7663
8601
  type: "textarea",
7664
8602
  label: "Opportunites commerciales",
8603
+ access: {
8604
+ // Admin-only: never serialize commercial notes onto the client's own doc.
8605
+ read: ({ req }) => req.user?.collection === slugs.users
8606
+ },
7665
8607
  admin: {
7666
8608
  description: "Notes commerciales (visible uniquement par les admins)"
7667
8609
  }
@@ -7705,6 +8647,19 @@ function createSupportClientsCollection(slugs) {
7705
8647
  type: "date",
7706
8648
  admin: { hidden: true }
7707
8649
  },
8650
+ {
8651
+ // Set by /support/2fa "verify"; gates login via the beforeLogin hook.
8652
+ name: "twoFactorVerifiedAt",
8653
+ type: "date",
8654
+ admin: { hidden: true }
8655
+ },
8656
+ {
8657
+ // Google OAuth subject id — accounts are linked by this, not email alone.
8658
+ name: "googleId",
8659
+ type: "text",
8660
+ index: true,
8661
+ admin: { hidden: true }
8662
+ },
7708
8663
  {
7709
8664
  name: "notifyOnReply",
7710
8665
  type: "checkbox",
@@ -7771,6 +8726,10 @@ function createSupportClientsCollection(slugs) {
7771
8726
  name: "notes",
7772
8727
  type: "textarea",
7773
8728
  label: "Notes internes",
8729
+ access: {
8730
+ // Admin-only: never serialize internal notes onto the client's own doc.
8731
+ read: ({ req }) => req.user?.collection === slugs.users
8732
+ },
7774
8733
  admin: {
7775
8734
  description: "Visible uniquement par les admins",
7776
8735
  position: "sidebar"
@@ -7778,6 +8737,7 @@ function createSupportClientsCollection(slugs) {
7778
8737
  }
7779
8738
  ],
7780
8739
  hooks: {
8740
+ beforeLogin: [createEnforce2FA(slugs)],
7781
8741
  afterChange: [createSendInvitationOnCreate(slugs)]
7782
8742
  },
7783
8743
  access: {
@@ -7991,6 +8951,7 @@ function createTicketActivityLogCollection(slugs) {
7991
8951
  type: "relationship",
7992
8952
  relationTo: slugs.tickets,
7993
8953
  required: true,
8954
+ index: true,
7994
8955
  label: "Ticket"
7995
8956
  },
7996
8957
  {
@@ -8036,10 +8997,11 @@ function createTicketActivityLogCollection(slugs) {
8036
8997
  access: {
8037
8998
  create: () => false,
8038
8999
  // Created only by hooks via overrideAccess
8039
- read: ({ req }) => {
9000
+ read: async ({ req }) => {
8040
9001
  if (req.user?.collection === slugs.users) return true;
8041
9002
  if (req.user?.collection === slugs.supportClients) {
8042
- return { "ticket.client": { equals: req.user.id } };
9003
+ const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
9004
+ return { ticket: { in: ids.length > 0 ? ids : [-1] } };
8043
9005
  }
8044
9006
  return false;
8045
9007
  },
@@ -8108,12 +9070,21 @@ function createSatisfactionSurveysCollection(slugs) {
8108
9070
  {
8109
9071
  name: "rating",
8110
9072
  type: "number",
8111
- required: true,
8112
9073
  min: 1,
8113
9074
  max: 5,
8114
9075
  label: "Note (1-5)",
8115
9076
  admin: {
8116
- description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait"
9077
+ description: "1 = Tr\xE8s insatisfait, 5 = Tr\xE8s satisfait (CSAT)"
9078
+ }
9079
+ },
9080
+ {
9081
+ name: "nps",
9082
+ type: "number",
9083
+ min: 0,
9084
+ max: 10,
9085
+ label: "NPS (0-10)",
9086
+ admin: {
9087
+ description: "\xAB Recommanderiez-vous ? \xBB \u2014 promoteur 9-10, passif 7-8, d\xE9tracteur 0-6"
8117
9088
  }
8118
9089
  },
8119
9090
  {
@@ -9359,6 +10330,124 @@ function createTicketCollaboratorsCollection(slugs) {
9359
10330
  };
9360
10331
  }
9361
10332
 
10333
+ // src/collections/NotificationQueue.ts
10334
+ function createNotificationQueueCollection(slugs) {
10335
+ return {
10336
+ slug: slugs.notificationQueue,
10337
+ labels: { singular: "Notification en file", plural: "Notifications en file" },
10338
+ admin: { hidden: true, group: "Support", defaultColumns: ["client", "type", "title", "createdAt"] },
10339
+ access: {
10340
+ // Internal: written/drained only via overrideAccess (hooks + cron).
10341
+ create: () => false,
10342
+ update: () => false,
10343
+ read: ({ req }) => req.user?.collection === slugs.users,
10344
+ delete: ({ req }) => req.user?.collection === slugs.users
10345
+ },
10346
+ fields: [
10347
+ { name: "client", type: "relationship", relationTo: slugs.supportClients, required: true, index: true, label: "Client" },
10348
+ { name: "type", type: "text", label: "Type" },
10349
+ { name: "title", type: "text", label: "Titre" },
10350
+ { name: "message", type: "textarea", label: "Message" },
10351
+ { name: "ticket", type: "relationship", relationTo: slugs.tickets, label: "Ticket" }
10352
+ ],
10353
+ timestamps: true
10354
+ };
10355
+ }
10356
+
10357
+ // src/collections/AutomationRules.ts
10358
+ var CONDITION_FIELDS = ["status", "priority", "category", "source", "subject"];
10359
+ var ACTION_TYPES = [
10360
+ { label: "D\xE9finir le statut", value: "set_status" },
10361
+ { label: "D\xE9finir la priorit\xE9", value: "set_priority" },
10362
+ { label: "D\xE9finir la cat\xE9gorie", value: "set_category" },
10363
+ { label: "Assigner \xE0 (id agent)", value: "assign" },
10364
+ { label: "Ajouter un tag", value: "add_tag" }
10365
+ ];
10366
+ function createAutomationRulesCollection(slugs) {
10367
+ return {
10368
+ slug: slugs.automationRules,
10369
+ labels: { singular: "R\xE8gle d'automatisation", plural: "R\xE8gles d'automatisation" },
10370
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "event", "enabled", "order"] },
10371
+ access: {
10372
+ read: ({ req }) => req.user?.collection === slugs.users,
10373
+ create: ({ req }) => req.user?.collection === slugs.users,
10374
+ update: ({ req }) => req.user?.collection === slugs.users,
10375
+ delete: ({ req }) => req.user?.collection === slugs.users
10376
+ },
10377
+ fields: [
10378
+ { name: "name", type: "text", required: true, label: "Nom" },
10379
+ { name: "enabled", type: "checkbox", defaultValue: true, label: "Active", index: true },
10380
+ {
10381
+ name: "event",
10382
+ type: "select",
10383
+ defaultValue: "ticket_created",
10384
+ index: true,
10385
+ label: "D\xE9clencheur",
10386
+ options: [
10387
+ { label: "Ticket cr\xE9\xE9", value: "ticket_created" },
10388
+ { label: "Ticket mis \xE0 jour", value: "ticket_updated" },
10389
+ { label: "Changement de statut", value: "ticket_status_changed" }
10390
+ ]
10391
+ },
10392
+ {
10393
+ name: "matchType",
10394
+ type: "select",
10395
+ defaultValue: "all",
10396
+ label: "Correspondance",
10397
+ options: [{ label: "Toutes les conditions (ET)", value: "all" }, { label: "Au moins une (OU)", value: "any" }]
10398
+ },
10399
+ { name: "order", type: "number", defaultValue: 0, label: "Ordre d'\xE9valuation" },
10400
+ {
10401
+ name: "conditions",
10402
+ type: "array",
10403
+ label: "Conditions",
10404
+ fields: [
10405
+ { name: "field", type: "select", required: true, options: CONDITION_FIELDS.map((v) => ({ label: v, value: v })) },
10406
+ {
10407
+ name: "operator",
10408
+ type: "select",
10409
+ defaultValue: "equals",
10410
+ required: true,
10411
+ options: [{ label: "= \xE9gal", value: "equals" }, { label: "\u2260 diff\xE9rent", value: "not_equals" }, { label: "contient", value: "contains" }]
10412
+ },
10413
+ { name: "value", type: "text", required: true }
10414
+ ]
10415
+ },
10416
+ {
10417
+ name: "actions",
10418
+ type: "array",
10419
+ label: "Actions",
10420
+ fields: [
10421
+ { name: "type", type: "select", required: true, options: ACTION_TYPES },
10422
+ { name: "value", type: "text", required: true }
10423
+ ]
10424
+ }
10425
+ ],
10426
+ timestamps: true
10427
+ };
10428
+ }
10429
+
10430
+ // src/collections/SupportTeam.ts
10431
+ function createSupportTeamCollection(slugs) {
10432
+ return {
10433
+ slug: slugs.supportTeams,
10434
+ labels: { singular: "\xC9quipe support", plural: "\xC9quipes support" },
10435
+ admin: { useAsTitle: "name", group: "Support", defaultColumns: ["name", "members"] },
10436
+ access: {
10437
+ read: ({ req }) => req.user?.collection === slugs.users,
10438
+ create: ({ req }) => req.user?.collection === slugs.users,
10439
+ update: ({ req }) => req.user?.collection === slugs.users,
10440
+ delete: ({ req }) => req.user?.collection === slugs.users
10441
+ },
10442
+ fields: [
10443
+ { name: "name", type: "text", required: true, label: "Nom" },
10444
+ { name: "description", type: "textarea", label: "Description" },
10445
+ { name: "members", type: "relationship", relationTo: slugs.users, hasMany: true, index: true, label: "Membres (agents)" }
10446
+ ],
10447
+ timestamps: true
10448
+ };
10449
+ }
10450
+
9362
10451
  // src/plugin.ts
9363
10452
  function viewConfig(component, path) {
9364
10453
  return { Component: component, path };
@@ -9394,7 +10483,10 @@ function supportPlugin(config) {
9394
10483
  createSatisfactionSurveysCollection(slugs),
9395
10484
  createKnowledgeBaseCollection(slugs),
9396
10485
  createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
9397
- createTicketCollaboratorsCollection(slugs)
10486
+ createTicketCollaboratorsCollection(slugs),
10487
+ createNotificationQueueCollection(slugs),
10488
+ createAutomationRulesCollection(slugs),
10489
+ createSupportTeamCollection(slugs)
9398
10490
  ];
9399
10491
  if (features.authLogs !== false) supportCollections.push(createAuthLogsCollection(slugs));
9400
10492
  if (features.timeTracking !== false) supportCollections.push(createTimeEntriesCollection(slugs));