@consilioweb/payload-support 4.0.0 → 6.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 (159) hide show
  1. package/README.md +272 -35
  2. package/dist/components/RichTextEditor/index.js +1 -1
  3. package/dist/components/TicketConversation/RewriteDropdown.js +1 -1
  4. package/dist/components/TicketConversation/components/AISummaryPanel.js +3 -0
  5. package/dist/components/TicketConversation/components/ActionPanels.js +13 -8
  6. package/dist/components/TicketConversation/components/ClientHistory.js +1 -0
  7. package/dist/components/TicketConversation/components/CodeBlock.js +1 -0
  8. package/dist/components/TicketConversation/components/CodeBlockInserter.js +1 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +10 -8
  10. package/dist/components/TicketConversation/components/TicketHeader.js +2 -0
  11. package/dist/components/TicketConversation/components/TimeTrackingPanel.js +15 -9
  12. package/dist/components/TicketConversation/index.d.ts +10 -0
  13. package/dist/components/TicketConversation/index.js +25 -7
  14. package/dist/index.cjs +421 -73
  15. package/dist/index.d.cts +127 -38
  16. package/dist/index.d.ts +127 -38
  17. package/dist/index.js +415 -72
  18. package/dist/styles/BillingView.module.scss +11 -11
  19. package/dist/styles/ChatView.module.scss +7 -7
  20. package/dist/styles/CommandPalette.module.scss +1 -1
  21. package/dist/styles/CrmView.module.scss +9 -9
  22. package/dist/styles/EmailTracking.module.scss +6 -6
  23. package/dist/styles/ImportConversation.module.scss +5 -5
  24. package/dist/styles/Logs.module.scss +5 -5
  25. package/dist/styles/NewTicket.module.scss +2 -2
  26. package/dist/styles/PendingEmails.module.scss +13 -13
  27. package/dist/styles/SupportDashboard.module.scss +24 -8
  28. package/dist/styles/TicketDetail.module.scss +20 -20
  29. package/dist/styles/TicketInbox.module.scss +6 -6
  30. package/dist/styles/TicketingSettings.module.scss +10 -10
  31. package/dist/styles/TimeDashboard.module.scss +6 -6
  32. package/dist/styles/_tokens.scss +3 -1
  33. package/dist/utils/db.d.ts +34 -0
  34. package/dist/utils/readSettings.d.ts +90 -0
  35. package/dist/views/BillingView/client.js +15 -8
  36. package/dist/views/BillingView/index.js +4 -4
  37. package/dist/views/ChatView/client.js +2 -0
  38. package/dist/views/ChatView/index.js +4 -4
  39. package/dist/views/CrmView/client.js +2 -0
  40. package/dist/views/CrmView/index.js +4 -4
  41. package/dist/views/EmailTrackingView/client.js +23 -11
  42. package/dist/views/EmailTrackingView/index.js +4 -4
  43. package/dist/views/ImportConversationView/client.js +1 -0
  44. package/dist/views/ImportConversationView/index.js +4 -4
  45. package/dist/views/LogsView/client.js +1 -1
  46. package/dist/views/LogsView/index.js +4 -2
  47. package/dist/views/NewTicketView/client.js +20 -13
  48. package/dist/views/NewTicketView/index.js +4 -2
  49. package/dist/views/PendingEmailsView/client.js +9 -3
  50. package/dist/views/PendingEmailsView/index.js +4 -4
  51. package/dist/views/SupportDashboardView/client.js +17 -19
  52. package/dist/views/SupportDashboardView/index.js +4 -4
  53. package/dist/views/TicketDetailView/client.js +23 -11
  54. package/dist/views/TicketDetailView/index.js +4 -4
  55. package/dist/views/TicketInboxView/client.js +52 -26
  56. package/dist/views/TicketInboxView/index.js +4 -2
  57. package/dist/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  58. package/dist/views/TicketingSettingsView/client.js +44 -26
  59. package/dist/views/TicketingSettingsView/index.js +4 -4
  60. package/dist/views/TimeDashboardView/client.js +10 -7
  61. package/dist/views/TimeDashboardView/index.js +4 -4
  62. package/dist/views/shared/ErrorBoundary.d.ts +36 -1
  63. package/dist/views/shared/ErrorBoundary.js +59 -28
  64. package/dist/views/shared/adminTokens.d.ts +15 -5
  65. package/dist/views/shared/adminTokens.js +23 -7
  66. package/dist/views/shared/icons.d.ts +49 -0
  67. package/dist/views/shared/icons.js +92 -0
  68. package/dist/views/shared/locales/en.json +15 -1
  69. package/dist/views/shared/locales/fr.json +15 -1
  70. package/dist/views/shared/viewAccess.d.ts +29 -0
  71. package/dist/views/shared/viewAccess.js +24 -0
  72. package/package.json +31 -21
  73. package/scripts/uninstall-data.mjs +178 -0
  74. package/scripts/uninstall.mjs +184 -0
  75. package/src/collections/Tickets.ts +7 -0
  76. package/src/collections/TimeEntries.ts +11 -3
  77. package/src/components/RichTextEditor/index.tsx +1 -1
  78. package/src/components/TicketConversation/RewriteDropdown.tsx +1 -1
  79. package/src/components/TicketConversation/components/AISummaryPanel.tsx +3 -0
  80. package/src/components/TicketConversation/components/ActionPanels.tsx +13 -8
  81. package/src/components/TicketConversation/components/ClientHistory.tsx +1 -0
  82. package/src/components/TicketConversation/components/CodeBlock.tsx +1 -0
  83. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +1 -1
  84. package/src/components/TicketConversation/components/QuickActions.tsx +10 -8
  85. package/src/components/TicketConversation/components/TicketHeader.tsx +2 -0
  86. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +17 -9
  87. package/src/components/TicketConversation/index.tsx +41 -7
  88. package/src/endpoints/auth-2fa.ts +53 -8
  89. package/src/endpoints/capabilities.ts +2 -4
  90. package/src/endpoints/chatbot.ts +2 -2
  91. package/src/endpoints/delete-account.ts +191 -47
  92. package/src/endpoints/export-data.ts +104 -4
  93. package/src/endpoints/import-conversation.ts +2 -2
  94. package/src/endpoints/login.ts +13 -3
  95. package/src/endpoints/purge-logs.ts +6 -15
  96. package/src/endpoints/push.ts +14 -1
  97. package/src/endpoints/statuses.ts +17 -0
  98. package/src/index.ts +2 -0
  99. package/src/plugin.ts +51 -1
  100. package/src/portal/LiveChat.tsx +1 -1
  101. package/src/portal/auth/profile/page.tsx +3 -2
  102. package/src/portal/icons.tsx +91 -0
  103. package/src/portal/login/page.tsx +14 -4
  104. package/src/styles/BillingView.module.scss +11 -11
  105. package/src/styles/ChatView.module.scss +7 -7
  106. package/src/styles/CommandPalette.module.scss +1 -1
  107. package/src/styles/CrmView.module.scss +9 -9
  108. package/src/styles/EmailTracking.module.scss +6 -6
  109. package/src/styles/ImportConversation.module.scss +5 -5
  110. package/src/styles/Logs.module.scss +5 -5
  111. package/src/styles/NewTicket.module.scss +2 -2
  112. package/src/styles/PendingEmails.module.scss +13 -13
  113. package/src/styles/SupportDashboard.module.scss +24 -8
  114. package/src/styles/TicketDetail.module.scss +20 -20
  115. package/src/styles/TicketInbox.module.scss +6 -6
  116. package/src/styles/TicketingSettings.module.scss +10 -10
  117. package/src/styles/TimeDashboard.module.scss +6 -6
  118. package/src/styles/_tokens.scss +3 -1
  119. package/src/types.ts +20 -0
  120. package/src/utils/push.ts +22 -0
  121. package/src/utils/rateLimiter.ts +98 -0
  122. package/src/utils/retention.ts +111 -0
  123. package/src/utils/slugs.ts +15 -0
  124. package/src/utils/twoFactorChallenge.ts +6 -1
  125. package/src/utils/urlSafety.ts +36 -1
  126. package/src/views/BillingView/client.tsx +17 -8
  127. package/src/views/BillingView/index.tsx +4 -4
  128. package/src/views/ChatView/client.tsx +2 -0
  129. package/src/views/ChatView/index.tsx +4 -4
  130. package/src/views/CrmView/client.tsx +2 -0
  131. package/src/views/CrmView/index.tsx +4 -4
  132. package/src/views/EmailTrackingView/client.tsx +18 -7
  133. package/src/views/EmailTrackingView/index.tsx +4 -4
  134. package/src/views/ImportConversationView/client.tsx +1 -0
  135. package/src/views/ImportConversationView/index.tsx +4 -4
  136. package/src/views/LogsView/client.tsx +1 -1
  137. package/src/views/LogsView/index.tsx +4 -2
  138. package/src/views/NewTicketView/client.tsx +22 -13
  139. package/src/views/NewTicketView/index.tsx +4 -2
  140. package/src/views/PendingEmailsView/client.tsx +9 -3
  141. package/src/views/PendingEmailsView/index.tsx +4 -4
  142. package/src/views/SupportDashboardView/client.tsx +11 -5
  143. package/src/views/SupportDashboardView/index.tsx +4 -4
  144. package/src/views/TicketDetailView/client.tsx +25 -11
  145. package/src/views/TicketDetailView/index.tsx +4 -4
  146. package/src/views/TicketInboxView/client.tsx +47 -7
  147. package/src/views/TicketInboxView/index.tsx +4 -2
  148. package/src/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  149. package/src/views/TicketingSettingsView/client.tsx +201 -144
  150. package/src/views/TicketingSettingsView/index.tsx +4 -4
  151. package/src/views/TimeDashboardView/client.tsx +10 -7
  152. package/src/views/TimeDashboardView/index.tsx +4 -4
  153. package/src/views/shared/ErrorBoundary.tsx +95 -31
  154. package/src/views/shared/adminTokens.ts +28 -11
  155. package/src/views/shared/icons.tsx +146 -0
  156. package/src/views/shared/locales/en.json +15 -1
  157. package/src/views/shared/locales/fr.json +15 -1
  158. package/src/views/shared/viewAccess.ts +73 -0
  159. package/src/types/lucide-react.d.ts +0 -42
package/dist/index.js CHANGED
@@ -72,15 +72,51 @@ var DEFAULT_SLUGS = {
72
72
  users: "users",
73
73
  media: "media"
74
74
  };
75
+ var FIXED_SLUGS = {
76
+ clientSummaries: "client-summaries",
77
+ ticketCollaborators: "ticket-collaborators",
78
+ ticketFeedback: "ticket-feedback"
79
+ };
75
80
  function resolveSlugs(overrides) {
76
81
  return { ...DEFAULT_SLUGS, ...overrides };
77
82
  }
83
+ var MAX_MEMORY_RATE_LIMIT_KEYS = 1e4;
78
84
  var MemoryRateLimitStore = class {
85
+ constructor(maxKeys = MAX_MEMORY_RATE_LIMIT_KEYS) {
86
+ this.maxKeys = maxKeys;
87
+ }
88
+ maxKeys;
79
89
  entries = /* @__PURE__ */ new Map();
90
+ /** Distinct keys currently held. Exposed so the ceiling can be asserted. */
91
+ get size() {
92
+ return this.entries.size;
93
+ }
94
+ /** Reclaims every window that has already closed. */
95
+ sweepExpired(now) {
96
+ for (const [key, entry] of this.entries) {
97
+ if (now > entry.resetAt) this.entries.delete(key);
98
+ }
99
+ }
100
+ /** Soonest-closing window first, so the ceiling drops the least useful entry. */
101
+ evictOldest() {
102
+ let oldestKey = null;
103
+ let oldestResetAt = Infinity;
104
+ for (const [key, entry] of this.entries) {
105
+ if (entry.resetAt < oldestResetAt) {
106
+ oldestResetAt = entry.resetAt;
107
+ oldestKey = key;
108
+ }
109
+ }
110
+ if (oldestKey !== null) this.entries.delete(oldestKey);
111
+ }
80
112
  async increment(key, windowMs) {
81
113
  const now = Date.now();
82
114
  const current = this.entries.get(key);
83
115
  const next = !current || now > current.resetAt ? { count: 1, resetAt: now + windowMs } : { ...current, count: current.count + 1 };
116
+ if (!current && this.entries.size >= this.maxKeys) {
117
+ this.sweepExpired(now);
118
+ if (this.entries.size >= this.maxKeys) this.evictOldest();
119
+ }
84
120
  this.entries.set(key, next);
85
121
  return next;
86
122
  }
@@ -164,6 +200,23 @@ function principalRateKey(user) {
164
200
  const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
165
201
  return `${collection}:${String(user.id)}`;
166
202
  }
203
+ var MAX_IP_KEY_LENGTH = 45;
204
+ var IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/;
205
+ var IPV6_PATTERN = /^[0-9a-fA-F:.%]+$/;
206
+ function clientIpRateKey(req) {
207
+ const candidate = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip")?.trim() || "";
208
+ return normalizeIpKey(candidate);
209
+ }
210
+ function normalizeIpKey(candidate) {
211
+ if (!candidate || candidate.length > MAX_IP_KEY_LENGTH) return "unknown";
212
+ const host = candidate.startsWith("[") && candidate.endsWith("]") ? candidate.slice(1, -1) : candidate;
213
+ if (!host) return "unknown";
214
+ if (IPV4_PATTERN.test(host)) {
215
+ return host.split(".").every((octet) => Number(octet) <= 255) ? host : "unknown";
216
+ }
217
+ if (host.includes(":") && IPV6_PATTERN.test(host)) return host.toLowerCase();
218
+ return "unknown";
219
+ }
167
220
  var RateLimiter = class {
168
221
  /**
169
222
  * @param namespace Endpoint-scoped prefix for every key this limiter writes.
@@ -243,7 +296,7 @@ function createInboundEmailEndpoint(capability, store) {
243
296
  if (!verifySecret(req.headers.get(secretHeader), capability.secret)) {
244
297
  return Response.json({ error: "Unauthorized" }, { status: 401 });
245
298
  }
246
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
299
+ const ip = clientIpRateKey(req);
247
300
  if (await limiter.check(ip, req)) {
248
301
  return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
249
302
  }
@@ -2800,6 +2853,10 @@ function createStatusesEndpoint(slugs) {
2800
2853
  if (!req.user) {
2801
2854
  return Response.json({ error: "Unauthorized" }, { status: 401 });
2802
2855
  }
2856
+ const collection = req.user.collection;
2857
+ if (collection !== slugs.users && collection !== slugs.supportClients) {
2858
+ return Response.json({ error: "Forbidden" }, { status: 403 });
2859
+ }
2803
2860
  const { docs } = await dbFind(payload, slugs.ticketStatuses, {
2804
2861
  sort: "sortOrder",
2805
2862
  limit: 100,
@@ -2936,6 +2993,51 @@ function createApplyMacroEndpoint(slugs) {
2936
2993
  };
2937
2994
  }
2938
2995
 
2996
+ // src/utils/retention.ts
2997
+ var DEFAULT_RETENTION = {
2998
+ authLogsDays: 180,
2999
+ emailLogsDays: 365,
3000
+ cron: "0 30 3 * * *",
3001
+ queue: "default"
3002
+ };
3003
+ var PURGE_LOGS_TASK_SLUG = "support-purge-logs";
3004
+ function purgeableCollections(slugs) {
3005
+ return {
3006
+ "email-logs": slugs.emailLogs,
3007
+ "auth-logs": slugs.authLogs
3008
+ };
3009
+ }
3010
+ async function purgeOlderThan(payload, slug, days, req) {
3011
+ const cutoff = days > 0 ? new Date(Date.now() - days * 864e5).toISOString() : null;
3012
+ const result = await dbDelete(payload, slug, {
3013
+ where: cutoff ? { createdAt: { less_than: cutoff } } : { id: { exists: true } },
3014
+ overrideAccess: true,
3015
+ ...req ? { req } : {}
3016
+ });
3017
+ return Array.isArray(result?.docs) ? result.docs.length : 0;
3018
+ }
3019
+ async function runScheduledPurge(payload, slugs, retention, req) {
3020
+ if (retention === false) return { purged: {}, skipped: ["auth-logs", "email-logs"] };
3021
+ const plan = [
3022
+ ["auth-logs", slugs.authLogs, retention?.authLogsDays ?? DEFAULT_RETENTION.authLogsDays],
3023
+ ["email-logs", slugs.emailLogs, retention?.emailLogsDays ?? DEFAULT_RETENTION.emailLogsDays]
3024
+ ];
3025
+ const purged = {};
3026
+ const skipped = [];
3027
+ for (const [name, slug, days] of plan) {
3028
+ if (!Number.isFinite(days) || days <= 0) {
3029
+ skipped.push(name);
3030
+ continue;
3031
+ }
3032
+ if (!payload.collections?.[slug]) {
3033
+ skipped.push(name);
3034
+ continue;
3035
+ }
3036
+ purged[name] = await purgeOlderThan(payload, slug, days, req);
3037
+ }
3038
+ return { purged, skipped };
3039
+ }
3040
+
2939
3041
  // src/endpoints/purge-logs.ts
2940
3042
  function createPurgeLogsEndpoint(slugs) {
2941
3043
  return {
@@ -2948,19 +3050,11 @@ function createPurgeLogsEndpoint(slugs) {
2948
3050
  const url = new URL(req.url);
2949
3051
  const collection = url.searchParams.get("collection");
2950
3052
  const days = Number(url.searchParams.get("days") || "0");
2951
- const allowedCollections = {
2952
- "email-logs": slugs.emailLogs,
2953
- "auth-logs": slugs.authLogs
2954
- };
3053
+ const allowedCollections = purgeableCollections(slugs);
2955
3054
  if (!collection || !allowedCollections[collection]) {
2956
3055
  return Response.json({ error: "Invalid collection. Use email-logs or auth-logs." }, { status: 400 });
2957
3056
  }
2958
- const cutoff = days > 0 ? new Date(Date.now() - days * 864e5).toISOString() : null;
2959
- const result = await dbDelete(payload, allowedCollections[collection], {
2960
- where: cutoff ? { createdAt: { less_than: cutoff } } : { id: { exists: true } },
2961
- overrideAccess: true
2962
- });
2963
- const count = Array.isArray(result.docs) ? result.docs.length : 0;
3057
+ const count = await purgeOlderThan(payload, allowedCollections[collection], days, req);
2964
3058
  return Response.json({
2965
3059
  purged: count,
2966
3060
  collection,
@@ -2990,7 +3084,7 @@ function createChatbotEndpoint(slugs, store, maxPerHour) {
2990
3084
  method: "post",
2991
3085
  handler: async (req) => {
2992
3086
  try {
2993
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
3087
+ const ip = clientIpRateKey(req);
2994
3088
  if (await chatbotLimiter.check(ip, req)) {
2995
3089
  return Response.json({ error: "Too many requests. Please wait a moment." }, { status: 429 });
2996
3090
  }
@@ -4683,6 +4777,7 @@ function createExportCsvEndpoint(slugs) {
4683
4777
  }
4684
4778
 
4685
4779
  // src/endpoints/export-data.ts
4780
+ var EMPTY = { docs: [] };
4686
4781
  function createExportDataEndpoint(slugs) {
4687
4782
  return {
4688
4783
  path: "/support/export-data",
@@ -4691,7 +4786,16 @@ function createExportDataEndpoint(slugs) {
4691
4786
  try {
4692
4787
  const payload = req.payload;
4693
4788
  requireClient(req, slugs);
4694
- const [clientData, ticketsResult, messagesResult, surveysResult] = await Promise.all([
4789
+ const isRegistered = (slug) => Boolean(payload.collections?.[slug]);
4790
+ const [
4791
+ clientData,
4792
+ ticketsResult,
4793
+ messagesResult,
4794
+ surveysResult,
4795
+ chatResult,
4796
+ feedbackResult,
4797
+ summariesResult
4798
+ ] = await Promise.all([
4695
4799
  dbFindByID(payload, slugs.supportClients, {
4696
4800
  id: req.user.id,
4697
4801
  depth: 0,
@@ -4717,12 +4821,35 @@ function createExportDataEndpoint(slugs) {
4717
4821
  limit: 500,
4718
4822
  depth: 0,
4719
4823
  overrideAccess: true
4720
- })
4824
+ }),
4825
+ isRegistered(slugs.chatMessages) ? dbFind(payload, slugs.chatMessages, {
4826
+ where: { client: { equals: req.user.id } },
4827
+ limit: 5e3,
4828
+ depth: 0,
4829
+ overrideAccess: true
4830
+ }) : EMPTY,
4831
+ isRegistered(FIXED_SLUGS.ticketFeedback) ? dbFind(payload, FIXED_SLUGS.ticketFeedback, {
4832
+ where: { client: { equals: req.user.id } },
4833
+ limit: 500,
4834
+ depth: 0,
4835
+ overrideAccess: true
4836
+ }) : EMPTY,
4837
+ isRegistered(FIXED_SLUGS.clientSummaries) ? dbFind(payload, FIXED_SLUGS.clientSummaries, {
4838
+ where: { client: { equals: req.user.id } },
4839
+ limit: 100,
4840
+ depth: 0,
4841
+ overrideAccess: true
4842
+ }) : EMPTY
4721
4843
  ]);
4844
+ const ticketIds = ticketsResult.docs.map((t) => t.id);
4845
+ const timeResult = ticketIds.length > 0 && isRegistered(slugs.timeEntries) ? await dbFind(payload, slugs.timeEntries, {
4846
+ where: { ticket: { in: ticketIds } },
4847
+ limit: 5e3,
4848
+ depth: 0,
4849
+ overrideAccess: true
4850
+ }) : EMPTY;
4722
4851
  const c = clientData;
4723
- const exportData = {
4724
- exportDate: (/* @__PURE__ */ new Date()).toISOString(),
4725
- exportType: "RGPD - Export des donn\xE9es personnelles",
4852
+ const providedByYou = {
4726
4853
  profile: {
4727
4854
  email: c.email,
4728
4855
  firstName: c.firstName,
@@ -4751,8 +4878,50 @@ function createExportDataEndpoint(slugs) {
4751
4878
  rating: s.rating,
4752
4879
  comment: s.comment,
4753
4880
  createdAt: s.createdAt
4881
+ })),
4882
+ feedback: feedbackResult.docs.map((f) => ({
4883
+ ticketId: f.ticket,
4884
+ rating: f.rating,
4885
+ comment: f.comment,
4886
+ submittedFrom: f.submittedFrom,
4887
+ createdAt: f.createdAt
4888
+ })),
4889
+ chatMessages: chatResult.docs.map((m) => ({
4890
+ session: m.session,
4891
+ senderType: m.senderType,
4892
+ message: m.message,
4893
+ createdAt: m.createdAt
4894
+ })),
4895
+ timeEntries: timeResult.docs.map((e) => ({
4896
+ ticketId: e.ticket,
4897
+ duration: e.duration,
4898
+ date: e.date,
4899
+ description: e.description,
4900
+ billable: e.billable
4754
4901
  }))
4755
4902
  };
4903
+ const exportData = {
4904
+ exportDate: (/* @__PURE__ */ new Date()).toISOString(),
4905
+ exportType: "RGPD - Export des donn\xE9es personnelles",
4906
+ providedByYou: {
4907
+ legalBasis: "Art. 20 RGPD - droit \xE0 la portabilit\xE9",
4908
+ ...providedByYou
4909
+ },
4910
+ derivedData: {
4911
+ legalBasis: "Art. 15 RGPD - droit d'acc\xE8s (donn\xE9es d\xE9riv\xE9es, hors portabilit\xE9)",
4912
+ clientSummaries: summariesResult.docs.map((s) => ({
4913
+ summary: s.summary,
4914
+ recurringTopics: s.recurringTopics,
4915
+ patterns: s.patterns,
4916
+ keyFacts: s.keyFacts,
4917
+ ticketCount: s.ticketCount,
4918
+ messageCount: s.messageCount,
4919
+ averageSatisfaction: s.averageSatisfaction,
4920
+ generatedAt: s.generatedAt,
4921
+ aiModel: s.aiModel
4922
+ }))
4923
+ }
4924
+ };
4756
4925
  const json = JSON.stringify(exportData, null, 2);
4757
4926
  return new Response(json, {
4758
4927
  status: 200,
@@ -5196,13 +5365,14 @@ function verifyTwoFactorChallenge(email, token, now = Date.now()) {
5196
5365
  }
5197
5366
 
5198
5367
  // src/endpoints/login.ts
5368
+ var MAX_LOGGED_USER_AGENT = 256;
5199
5369
  function createLoginEndpoint(slugs, store) {
5200
5370
  const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
5201
5371
  return {
5202
5372
  path: "/support/login",
5203
5373
  method: "post",
5204
5374
  handler: async (req) => {
5205
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
5375
+ const ip = clientIpRateKey(req);
5206
5376
  if (await loginLimiter.check(ip, req)) {
5207
5377
  return Response.json(
5208
5378
  { error: "Trop de tentatives. R\xE9essayez dans quelques minutes." },
@@ -5217,7 +5387,7 @@ function createLoginEndpoint(slugs, store) {
5217
5387
  return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5218
5388
  }
5219
5389
  const { email, password } = body;
5220
- const userAgent = req.headers.get("user-agent") || "";
5390
+ const userAgent = (req.headers.get("user-agent") || "").slice(0, MAX_LOGGED_USER_AGENT);
5221
5391
  if (!email || !password) {
5222
5392
  return Response.json({ error: "Email et mot de passe requis." }, { status: 400 });
5223
5393
  }
@@ -5305,7 +5475,19 @@ function createAuth2faEndpoint(slugs, store) {
5305
5475
  if (!action || !email) {
5306
5476
  return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
5307
5477
  }
5308
- const genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
5478
+ const limiterKey = normalizeEmail(email);
5479
+ const sendResponse = () => {
5480
+ let refreshed;
5481
+ try {
5482
+ refreshed = issueTwoFactorChallenge(email);
5483
+ } catch {
5484
+ }
5485
+ return Response.json({
5486
+ success: true,
5487
+ message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9.",
5488
+ ...refreshed ? { challenge: refreshed } : {}
5489
+ });
5490
+ };
5309
5491
  if (action === "send") {
5310
5492
  if (!verifyTwoFactorChallenge(email, challenge)) {
5311
5493
  return Response.json(
@@ -5313,8 +5495,8 @@ function createAuth2faEndpoint(slugs, store) {
5313
5495
  { status: 401 }
5314
5496
  );
5315
5497
  }
5316
- if (await sendLimiter.check(email, req)) {
5317
- return Response.json(genericSendResponse);
5498
+ if (await sendLimiter.check(limiterKey, req)) {
5499
+ return sendResponse();
5318
5500
  }
5319
5501
  const clients = await dbFind(payload, slugs.supportClients, {
5320
5502
  where: { email: { equals: email } },
@@ -5323,7 +5505,7 @@ function createAuth2faEndpoint(slugs, store) {
5323
5505
  overrideAccess: true
5324
5506
  });
5325
5507
  if (clients.docs.length === 0) {
5326
- return Response.json(genericSendResponse);
5508
+ return sendResponse();
5327
5509
  }
5328
5510
  const client = clients.docs[0];
5329
5511
  const plainCode = generateSecureCode();
@@ -5348,13 +5530,19 @@ function createAuth2faEndpoint(slugs, store) {
5348
5530
  <p style="font-size: 13px; color: #6b7280;">Ce code est valable 10 minutes.</p>
5349
5531
  </div>`
5350
5532
  });
5351
- return Response.json(genericSendResponse);
5533
+ return sendResponse();
5352
5534
  }
5353
5535
  if (action === "verify") {
5536
+ if (!verifyTwoFactorChallenge(email, challenge)) {
5537
+ return Response.json(
5538
+ { error: "Authentification requise avant la v\xE9rification d'un code." },
5539
+ { status: 401 }
5540
+ );
5541
+ }
5354
5542
  if (!code) {
5355
5543
  return Response.json({ error: "Code manquant" }, { status: 400 });
5356
5544
  }
5357
- if (await verifyLimiter.check(email, req)) {
5545
+ if (await verifyLimiter.check(limiterKey, req)) {
5358
5546
  return Response.json(
5359
5547
  { error: "Trop de tentatives. R\xE9essayez dans 15 minutes." },
5360
5548
  { status: 429 }
@@ -5395,7 +5583,7 @@ function createAuth2faEndpoint(slugs, store) {
5395
5583
  data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
5396
5584
  overrideAccess: true
5397
5585
  });
5398
- verifyLimiter.reset(email);
5586
+ await verifyLimiter.reset(limiterKey, req);
5399
5587
  return Response.json({ success: true, verified: true });
5400
5588
  }
5401
5589
  return Response.json({ error: "Action invalide" }, { status: 400 });
@@ -5647,8 +5835,15 @@ function createOAuthGoogleEndpoint(slugs, options) {
5647
5835
  }
5648
5836
  };
5649
5837
  }
5650
-
5651
- // src/endpoints/delete-account.ts
5838
+ function collectAttachmentIds(docs, into) {
5839
+ for (const doc of docs) {
5840
+ for (const attachment of doc.attachments ?? []) {
5841
+ const file = attachment?.file;
5842
+ const id = file != null && typeof file === "object" ? file.id : file;
5843
+ if (id != null) into.add(id);
5844
+ }
5845
+ }
5846
+ }
5652
5847
  function createDeleteAccountEndpoint(slugs) {
5653
5848
  return {
5654
5849
  path: "/support/delete-account",
@@ -5677,44 +5872,135 @@ function createDeleteAccountEndpoint(slugs) {
5677
5872
  );
5678
5873
  }
5679
5874
  const clientId = req.user.id;
5680
- const tickets = await dbFind(payload, slugs.tickets, {
5681
- where: { client: { equals: clientId } },
5682
- limit: 1e4,
5683
- depth: 0,
5684
- overrideAccess: true,
5685
- select: { id: true }
5686
- });
5687
- const ticketIds = tickets.docs.map((t) => t.id);
5688
- if (ticketIds.length > 0) {
5689
- await dbDelete(payload, slugs.ticketMessages, {
5690
- where: { ticket: { in: ticketIds } },
5691
- overrideAccess: true
5692
- });
5693
- await dbDelete(payload, slugs.ticketActivityLog, {
5694
- where: { ticket: { in: ticketIds } },
5695
- overrideAccess: true
5696
- });
5697
- await dbDelete(payload, slugs.timeEntries, {
5698
- where: { ticket: { in: ticketIds } },
5699
- overrideAccess: true
5700
- });
5701
- await dbDelete(payload, slugs.tickets, {
5875
+ const clientEmail = req.user.email;
5876
+ const isRegistered = (slug) => Boolean(payload.collections?.[slug]);
5877
+ const startedTransaction = await initTransaction(req);
5878
+ try {
5879
+ const tickets = await dbFind(payload, slugs.tickets, {
5702
5880
  where: { client: { equals: clientId } },
5703
- overrideAccess: true
5881
+ limit: 1e4,
5882
+ depth: 0,
5883
+ overrideAccess: true,
5884
+ select: { id: true },
5885
+ req
5886
+ });
5887
+ const ticketIds = tickets.docs.map((t) => t.id);
5888
+ const attachmentIds = /* @__PURE__ */ new Set();
5889
+ if (ticketIds.length > 0) {
5890
+ const messages = await dbFind(
5891
+ payload,
5892
+ slugs.ticketMessages,
5893
+ {
5894
+ where: { ticket: { in: ticketIds } },
5895
+ limit: 1e4,
5896
+ depth: 0,
5897
+ overrideAccess: true,
5898
+ req
5899
+ }
5900
+ );
5901
+ collectAttachmentIds(messages.docs, attachmentIds);
5902
+ }
5903
+ if (isRegistered(slugs.pendingEmails)) {
5904
+ const pendingEmails = await dbFind(
5905
+ payload,
5906
+ slugs.pendingEmails,
5907
+ {
5908
+ where: { client: { equals: clientId } },
5909
+ limit: 1e4,
5910
+ depth: 0,
5911
+ overrideAccess: true,
5912
+ req
5913
+ }
5914
+ );
5915
+ collectAttachmentIds(pendingEmails.docs, attachmentIds);
5916
+ }
5917
+ if (ticketIds.length > 0) {
5918
+ for (const slug of [FIXED_SLUGS.ticketFeedback, FIXED_SLUGS.ticketCollaborators]) {
5919
+ if (!isRegistered(slug)) continue;
5920
+ await dbDelete(payload, slug, {
5921
+ where: {
5922
+ or: [
5923
+ { ticket: { in: ticketIds } },
5924
+ { client: { equals: clientId } }
5925
+ ]
5926
+ },
5927
+ overrideAccess: true,
5928
+ req
5929
+ });
5930
+ }
5931
+ }
5932
+ if (ticketIds.length > 0) {
5933
+ await dbDelete(payload, slugs.tickets, {
5934
+ where: { client: { equals: clientId } },
5935
+ overrideAccess: true,
5936
+ req
5937
+ });
5938
+ }
5939
+ for (const slug of [
5940
+ slugs.satisfactionSurveys,
5941
+ slugs.chatMessages,
5942
+ slugs.notificationQueue,
5943
+ slugs.pendingEmails,
5944
+ FIXED_SLUGS.clientSummaries,
5945
+ FIXED_SLUGS.ticketFeedback
5946
+ ]) {
5947
+ if (!isRegistered(slug)) continue;
5948
+ await dbDelete(payload, slug, {
5949
+ where: { client: { equals: clientId } },
5950
+ overrideAccess: true,
5951
+ req
5952
+ });
5953
+ }
5954
+ if (isRegistered(FIXED_SLUGS.ticketCollaborators)) {
5955
+ await dbDelete(payload, FIXED_SLUGS.ticketCollaborators, {
5956
+ where: {
5957
+ or: [
5958
+ { client: { equals: clientId } },
5959
+ ...clientEmail ? [{ email: { equals: clientEmail } }] : []
5960
+ ]
5961
+ },
5962
+ overrideAccess: true,
5963
+ req
5964
+ });
5965
+ }
5966
+ if (clientEmail) {
5967
+ if (isRegistered(slugs.authLogs)) {
5968
+ await dbDelete(payload, slugs.authLogs, {
5969
+ where: { email: { equals: clientEmail } },
5970
+ overrideAccess: true,
5971
+ req
5972
+ });
5973
+ }
5974
+ if (isRegistered(slugs.emailLogs)) {
5975
+ await dbDelete(payload, slugs.emailLogs, {
5976
+ where: {
5977
+ or: [
5978
+ { senderEmail: { equals: clientEmail } },
5979
+ { recipientEmail: { equals: clientEmail } }
5980
+ ]
5981
+ },
5982
+ overrideAccess: true,
5983
+ req
5984
+ });
5985
+ }
5986
+ }
5987
+ for (const id of isRegistered(slugs.media) ? attachmentIds : []) {
5988
+ await dbDelete(payload, slugs.media, {
5989
+ id,
5990
+ overrideAccess: true,
5991
+ req
5992
+ });
5993
+ }
5994
+ await dbDelete(payload, slugs.supportClients, {
5995
+ id: clientId,
5996
+ overrideAccess: true,
5997
+ req
5704
5998
  });
5999
+ if (startedTransaction) await commitTransaction(req);
6000
+ } catch (err) {
6001
+ if (startedTransaction) await killTransaction(req);
6002
+ throw err;
5705
6003
  }
5706
- await dbDelete(payload, slugs.satisfactionSurveys, {
5707
- where: { client: { equals: clientId } },
5708
- overrideAccess: true
5709
- });
5710
- await dbDelete(payload, slugs.chatMessages, {
5711
- where: { client: { equals: clientId } },
5712
- overrideAccess: true
5713
- });
5714
- await dbDelete(payload, slugs.supportClients, {
5715
- id: clientId,
5716
- overrideAccess: true
5717
- });
5718
6004
  const headers = new Headers({ "Content-Type": "application/json" });
5719
6005
  const secure = process.env.NODE_ENV === "production";
5720
6006
  headers.append(
@@ -5957,7 +6243,7 @@ function createImportConversationEndpoint(slugs, store) {
5957
6243
  if (!isAuthed) {
5958
6244
  return Response.json({ error: "Unauthorized" }, { status: 401 });
5959
6245
  }
5960
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
6246
+ const ip = clientIpRateKey(req);
5961
6247
  if (await importLimiter.check(ip, req)) {
5962
6248
  return Response.json({ error: "Rate limit exceeded. Maximum 10 imports per hour." }, { status: 429 });
5963
6249
  }
@@ -6165,6 +6451,21 @@ var WEBHOOK_URL_MESSAGES = {
6165
6451
  scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
6166
6452
  private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
6167
6453
  };
6454
+ var MAX_PUSH_ENDPOINT_LENGTH = 2048;
6455
+ function validatePushEndpoint(raw) {
6456
+ if (typeof raw !== "string" || !raw.trim() || raw.length > MAX_PUSH_ENDPOINT_LENGTH) {
6457
+ return { ok: false, reason: "invalid_url" };
6458
+ }
6459
+ let url;
6460
+ try {
6461
+ url = new URL(raw.trim());
6462
+ } catch {
6463
+ return { ok: false, reason: "invalid_url" };
6464
+ }
6465
+ if (url.protocol !== "https:") return { ok: false, reason: "scheme_not_allowed" };
6466
+ if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
6467
+ return { ok: true, url };
6468
+ }
6168
6469
  var LOOKUP_TIMEOUT_MS = 5e3;
6169
6470
  var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
6170
6471
  var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
@@ -6620,6 +6921,15 @@ async function sendPushToUser(payload, slugs, userId, notification) {
6620
6921
  for (const s of subs.docs) {
6621
6922
  const row = s;
6622
6923
  if (!row.endpoint || !row.p256dh || !row.auth) continue;
6924
+ const check = validatePushEndpoint(row.endpoint);
6925
+ if (!check.ok || !check.url) {
6926
+ console.warn("[support] Push endpoint refused (unsafe URL):", check.reason);
6927
+ continue;
6928
+ }
6929
+ if (!await assertPublicHost(check.url.hostname)) {
6930
+ console.warn("[support] Push endpoint refused (host resolves to a private address)");
6931
+ continue;
6932
+ }
6623
6933
  try {
6624
6934
  await webpush.sendNotification(
6625
6935
  { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
@@ -6670,7 +6980,14 @@ function createPushSubscribeEndpoint(slugs) {
6670
6980
  if (!endpoint || !p256dh || !auth) {
6671
6981
  return Response.json({ error: "subscription invalide (endpoint + keys requis)." }, { status: 400 });
6672
6982
  }
6673
- const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: req.headers.get("user-agent") || "" };
6983
+ const endpointCheck = validatePushEndpoint(endpoint);
6984
+ if (!endpointCheck.ok) {
6985
+ return Response.json(
6986
+ { error: WEBHOOK_URL_MESSAGES[endpointCheck.reason || "invalid_url"] },
6987
+ { status: 400 }
6988
+ );
6989
+ }
6990
+ const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: (req.headers.get("user-agent") || "").slice(0, 256) };
6674
6991
  const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true });
6675
6992
  if (existing.docs.length > 0) {
6676
6993
  await dbUpdate(req.payload, slugs.pushSubscriptions, { id: existing.docs[0].id, data, overrideAccess: true });
@@ -8326,9 +8643,11 @@ function createCascadeDelete(slugs) {
8326
8643
  return async ({ id, req }) => {
8327
8644
  const collections = [slugs.ticketMessages, slugs.ticketActivityLog, slugs.timeEntries, slugs.satisfactionSurveys];
8328
8645
  for (const slug of collections) {
8646
+ if (!req.payload.collections?.[slug]) continue;
8329
8647
  await dbDelete(req.payload, slug, {
8330
8648
  where: { ticket: { equals: id } },
8331
- overrideAccess: true
8649
+ overrideAccess: true,
8650
+ req
8332
8651
  });
8333
8652
  }
8334
8653
  };
@@ -9816,7 +10135,7 @@ function createSupportClientsCollection(slugs, options) {
9816
10135
  }
9817
10136
 
9818
10137
  // src/collections/TimeEntries.ts
9819
- async function recalculateTicketTime(payload, slugs, ticketId) {
10138
+ async function recalculateTicketTime(payload, slugs, ticketId, req) {
9820
10139
  let totalMinutes = 0;
9821
10140
  let page = 1;
9822
10141
  let hasMore = true;
@@ -9828,7 +10147,8 @@ async function recalculateTicketTime(payload, slugs, ticketId) {
9828
10147
  page,
9829
10148
  depth: 0,
9830
10149
  overrideAccess: true,
9831
- select: { duration: true }
10150
+ select: { duration: true },
10151
+ req
9832
10152
  });
9833
10153
  for (const entry of entries.docs) {
9834
10154
  totalMinutes += entry.duration || 0;
@@ -9840,14 +10160,15 @@ async function recalculateTicketTime(payload, slugs, ticketId) {
9840
10160
  collection: slugs.tickets,
9841
10161
  id: ticketId,
9842
10162
  data: { totalTimeMinutes: totalMinutes },
9843
- overrideAccess: true
10163
+ overrideAccess: true,
10164
+ req
9844
10165
  });
9845
10166
  }
9846
10167
  function createRecalculateTicketTime(slugs) {
9847
10168
  return async ({ doc, req }) => {
9848
10169
  if (!doc.ticket) return;
9849
10170
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
9850
- await recalculateTicketTime(req.payload, slugs, ticketId);
10171
+ await recalculateTicketTime(req.payload, slugs, ticketId, req);
9851
10172
  };
9852
10173
  }
9853
10174
  function createRecalculateTicketTimeOnDelete(slugs) {
@@ -9855,7 +10176,7 @@ function createRecalculateTicketTimeOnDelete(slugs) {
9855
10176
  if (!doc?.ticket) return;
9856
10177
  const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
9857
10178
  try {
9858
- await recalculateTicketTime(req.payload, slugs, ticketId);
10179
+ await recalculateTicketTime(req.payload, slugs, ticketId, req);
9859
10180
  } catch (err) {
9860
10181
  console.error("[support] Failed to recalculate ticket time after delete:", err);
9861
10182
  }
@@ -11726,8 +12047,30 @@ function supportPlugin(config) {
11726
12047
  rateLimitStore,
11727
12048
  capabilities: config?.capabilities
11728
12049
  });
12050
+ const retention = config?.retention === false ? void 0 : config?.retention;
12051
+ const retentionEnabled = Boolean(retention);
12052
+ const purgeTask = {
12053
+ slug: PURGE_LOGS_TASK_SLUG,
12054
+ label: "Support \u2014 purge des journaux",
12055
+ schedule: [
12056
+ {
12057
+ cron: retention?.cron ?? DEFAULT_RETENTION.cron,
12058
+ queue: retention?.queue ?? DEFAULT_RETENTION.queue
12059
+ }
12060
+ ],
12061
+ handler: async ({ req }) => {
12062
+ const result = await runScheduledPurge(req.payload, slugs, retention, req);
12063
+ return { output: result };
12064
+ }
12065
+ };
12066
+ const existingJobs = incomingConfig.jobs;
12067
+ const jobs = retentionEnabled ? {
12068
+ ...existingJobs,
12069
+ tasks: [...existingJobs?.tasks ?? [], purgeTask]
12070
+ } : existingJobs;
11729
12071
  return {
11730
12072
  ...incomingConfig,
12073
+ jobs,
11731
12074
  // Publish the resolved staff collection so the server-side readers share
11732
12075
  // ONE source of truth with the writers. `requireAdmin` compares against
11733
12076
  // `slugs.users`; the `payload-preferences` reads used to scope themselves
@@ -11752,4 +12095,4 @@ function supportPlugin(config) {
11752
12095
  };
11753
12096
  }
11754
12097
 
11755
- export { DEFAULT_FEATURES, DEFAULT_INBOUND_EMAIL_LIMITS, DEFAULT_SETTINGS, DEFAULT_SLUGS, DEFAULT_TICKETING_FEATURES, DEFAULT_USER_PREFS, MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter, calculateBusinessHoursDeadline, createAdminNotification, createAssignSlaDeadlines, createAuthLogsCollection, createCannedResponsesCollection, createChatMessagesCollection, createCheckSlaOnReply, createCheckSlaOnResolve, createEmailLogsCollection, createKnowledgeBaseCollection, createLoginEndpoint, createMacrosCollection, createOAuthGoogleEndpoint, createPendingEmailsCollection, createSatisfactionSurveysCollection, createSlaPoliciesCollection, createSupportClientsCollection, createTicketActivityLogCollection, createTicketCollaboratorsCollection, createTicketMessagesCollection, createTicketStatusEmail, createTicketStatusesCollection, createTicketsCollection, createTimeEntriesCollection, createTrackOpenEndpoint, createWebhookEndpointsCollection, dispatchWebhook, generateTicketSynthesis, normalizeFeatures, projectAutoClose, readSupportSettings, readSupportSettingsState, readUserPrefs, resolveSlugs, stripProjectedFeatures, supportPlugin, validateInboundEmailPayload, verifySecret };
12098
+ export { DEFAULT_FEATURES, DEFAULT_INBOUND_EMAIL_LIMITS, DEFAULT_RETENTION, DEFAULT_SETTINGS, DEFAULT_SLUGS, DEFAULT_TICKETING_FEATURES, DEFAULT_USER_PREFS, MemoryRateLimitStore, PURGE_LOGS_TASK_SLUG, PayloadRateLimitStore, RateLimiter, calculateBusinessHoursDeadline, createAdminNotification, createAssignSlaDeadlines, createAuthLogsCollection, createCannedResponsesCollection, createChatMessagesCollection, createCheckSlaOnReply, createCheckSlaOnResolve, createEmailLogsCollection, createKnowledgeBaseCollection, createLoginEndpoint, createMacrosCollection, createOAuthGoogleEndpoint, createPendingEmailsCollection, createSatisfactionSurveysCollection, createSlaPoliciesCollection, createSupportClientsCollection, createTicketActivityLogCollection, createTicketCollaboratorsCollection, createTicketMessagesCollection, createTicketStatusEmail, createTicketStatusesCollection, createTicketsCollection, createTimeEntriesCollection, createTrackOpenEndpoint, createWebhookEndpointsCollection, dispatchWebhook, generateTicketSynthesis, normalizeFeatures, projectAutoClose, purgeOlderThan, purgeableCollections, readSupportSettings, readSupportSettingsState, readUserPrefs, resolveSlugs, runScheduledPurge, stripProjectedFeatures, supportPlugin, validateInboundEmailPayload, verifySecret };