@consilioweb/payload-support 2.0.1 → 4.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.
- package/README.md +670 -402
- package/dist/components/RichTextEditor/index.d.ts +19 -0
- package/dist/components/TicketConversation/RewriteDropdown.d.ts +6 -0
- package/dist/components/TicketConversation/SkeletonText.d.ts +4 -0
- package/dist/components/TicketConversation/components/AISummaryPanel.d.ts +14 -0
- package/dist/components/TicketConversation/components/ActionPanels.d.ts +47 -0
- package/dist/components/TicketConversation/components/ActivityLog.d.ts +5 -0
- package/dist/components/TicketConversation/components/ClientBar.d.ts +5 -0
- package/dist/components/TicketConversation/components/ClientHistory.d.ts +24 -0
- package/dist/components/TicketConversation/components/CodeBlock.d.ts +26 -0
- package/dist/components/TicketConversation/components/CodeBlockInserter.d.ts +12 -0
- package/dist/components/TicketConversation/components/QuickActions.d.ts +32 -0
- package/dist/components/TicketConversation/components/TicketHeader.d.ts +19 -0
- package/dist/components/TicketConversation/components/TimeTrackingPanel.d.ts +24 -0
- package/dist/components/TicketConversation/config.d.ts +10 -0
- package/dist/components/TicketConversation/config.js +1 -40
- package/dist/components/TicketConversation/constants.d.ts +56 -0
- package/dist/components/TicketConversation/context.d.ts +53 -0
- package/dist/components/TicketConversation/hooks/useAI.d.ts +21 -0
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.d.ts +3 -0
- package/dist/components/TicketConversation/hooks/useFeatures.d.ts +23 -0
- package/dist/components/TicketConversation/hooks/useFeatures.js +61 -0
- package/dist/components/TicketConversation/hooks/useMessageActions.d.ts +20 -0
- package/dist/components/TicketConversation/hooks/useReply.d.ts +27 -0
- package/dist/components/TicketConversation/hooks/useTicketActions.d.ts +58 -0
- package/dist/components/TicketConversation/hooks/useTimeTracking.d.ts +18 -0
- package/dist/components/TicketConversation/hooks/useTranslation.d.ts +33 -0
- package/dist/components/TicketConversation/index.d.ts +4 -0
- package/dist/components/TicketConversation/index.js +2 -2
- package/dist/components/TicketConversation/types.d.ts +56 -0
- package/dist/components/TicketConversation/utils.d.ts +2 -0
- package/dist/index.cjs +940 -404
- package/dist/index.d.cts +119 -7
- package/dist/index.d.ts +119 -7
- package/dist/index.js +936 -405
- package/dist/utils/features.d.ts +123 -0
- package/dist/utils/features.js +147 -0
- package/dist/views/BillingView/client.d.ts +2 -0
- package/dist/views/BillingView/index.d.ts +4 -0
- package/dist/views/ChatView/client.d.ts +2 -0
- package/dist/views/ChatView/client.js +9 -9
- package/dist/views/ChatView/index.d.ts +4 -0
- package/dist/views/CrmView/client.d.ts +2 -0
- package/dist/views/CrmView/client.js +6 -9
- package/dist/views/CrmView/index.d.ts +4 -0
- package/dist/views/EmailTrackingView/client.d.ts +2 -0
- package/dist/views/EmailTrackingView/index.d.ts +4 -0
- package/dist/views/ImportConversationView/client.d.ts +2 -0
- package/dist/views/ImportConversationView/index.d.ts +4 -0
- package/dist/views/LogsView/client.d.ts +2 -0
- package/dist/views/LogsView/index.d.ts +4 -0
- package/dist/views/NewTicketView/client.d.ts +2 -0
- package/dist/views/NewTicketView/index.d.ts +4 -0
- package/dist/views/PendingEmailsView/client.d.ts +2 -0
- package/dist/views/PendingEmailsView/index.d.ts +4 -0
- package/dist/views/SupportDashboardView/client.d.ts +2 -0
- package/dist/views/SupportDashboardView/index.d.ts +4 -0
- package/dist/views/TicketDetailView/NextActionItem.d.ts +6 -0
- package/dist/views/TicketDetailView/RewriteDropdown.d.ts +7 -0
- package/dist/views/TicketDetailView/client.d.ts +2 -0
- package/dist/views/TicketDetailView/client.js +2 -2
- package/dist/views/TicketDetailView/constants.d.ts +13 -0
- package/dist/views/TicketDetailView/helpers.d.ts +2 -0
- package/dist/views/TicketDetailView/index.d.ts +4 -0
- package/dist/views/TicketDetailView/types.d.ts +40 -0
- package/dist/views/TicketInboxView/client.d.ts +2 -0
- package/dist/views/TicketInboxView/client.js +5 -5
- package/dist/views/TicketInboxView/index.d.ts +4 -0
- package/dist/views/TicketingSettingsView/client.d.ts +2 -0
- package/dist/views/TicketingSettingsView/client.js +5 -4
- package/dist/views/TicketingSettingsView/index.d.ts +4 -0
- package/dist/views/TimeDashboardView/client.d.ts +2 -0
- package/dist/views/TimeDashboardView/index.d.ts +4 -0
- package/dist/views/shared/AdminViewHeader.d.ts +13 -0
- package/dist/views/shared/ErrorBoundary.d.ts +17 -0
- package/dist/views/shared/Skeleton.d.ts +21 -0
- package/dist/views/shared/StatusPill.d.ts +8 -0
- package/dist/views/shared/adminTokens.d.ts +19 -0
- package/dist/views/shared/config.d.ts +8 -0
- package/dist/views/shared/config.js +1 -40
- package/dist/views/shared/dateLocale.d.ts +5 -0
- package/dist/views/shared/index.d.ts +12 -0
- package/dist/views/shared/index.js +2 -1
- package/dist/views/shared/sla.d.ts +14 -0
- package/dist/views.d.ts +13 -13
- package/package.json +16 -31
- package/src/collections/ChatMessages.ts +59 -2
- package/src/collections/ClientSummaries.ts +10 -4
- package/src/collections/SupportClients.ts +29 -0
- package/src/collections/TicketCollaborators.ts +6 -1
- package/src/collections/TicketMessages.ts +49 -24
- package/src/collections/Tickets.ts +117 -79
- package/src/collections/TimeEntries.ts +57 -32
- package/src/collections/WebhookEndpoints.ts +44 -2
- package/src/components/TicketConversation/config.ts +16 -80
- package/src/components/TicketConversation/hooks/useFeatures.ts +109 -0
- package/src/components/TicketConversation/index.tsx +2 -2
- package/src/endpoints/admin-chat.ts +3 -3
- package/src/endpoints/ai-agent.ts +3 -3
- package/src/endpoints/ai.ts +5 -4
- package/src/endpoints/auth-2fa.ts +16 -4
- package/src/endpoints/capabilities.ts +6 -6
- package/src/endpoints/chat.ts +7 -5
- package/src/endpoints/chatbot.ts +48 -2
- package/src/endpoints/client-intelligence.ts +6 -5
- package/src/endpoints/email-stats.ts +19 -3
- package/src/endpoints/import-conversation.ts +1 -1
- package/src/endpoints/index.ts +1 -1
- package/src/endpoints/invite-collaborator.ts +29 -3
- package/src/endpoints/login.ts +15 -2
- package/src/endpoints/oauth-google.ts +130 -8
- package/src/endpoints/process-scheduled.ts +13 -8
- package/src/endpoints/resend-notification.ts +3 -3
- package/src/endpoints/round-robin-config.ts +30 -20
- package/src/endpoints/send-reminder.ts +3 -3
- package/src/endpoints/settings.ts +28 -77
- package/src/endpoints/signature.ts +9 -2
- package/src/endpoints/ticket-synthesis.ts +3 -3
- package/src/endpoints/transfer-ticket.ts +28 -3
- package/src/endpoints/typing.ts +117 -14
- package/src/endpoints/user-prefs.ts +5 -2
- package/src/index.ts +6 -2
- package/src/plugin.ts +12 -0
- package/src/portal/LiveChat.tsx +33 -22
- package/src/portal/auth/layout.tsx +19 -1
- package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
- package/src/portal/auth/tickets/detail/TicketPolling.tsx +5 -1
- package/src/portal/auth/tickets/detail/page.tsx +2 -6
- package/src/portal/login/page.tsx +11 -3
- package/src/types/lucide-react.d.ts +10 -2
- package/src/utils/aiAgent.ts +2 -1
- package/src/utils/aiProvider.ts +21 -0
- package/src/utils/features.ts +297 -0
- package/src/utils/fireWebhooks.ts +10 -1
- package/src/utils/generateTicketSynthesis.ts +2 -1
- package/src/utils/index.ts +4 -2
- package/src/utils/rateLimiter.ts +39 -5
- package/src/utils/readSettings.ts +199 -16
- package/src/utils/ticketAccess.ts +16 -1
- package/src/utils/twoFactorChallenge.ts +80 -0
- package/src/utils/urlSafety.ts +230 -0
- package/src/utils/webhookDispatcher.ts +24 -8
- package/src/views/ChatView/client.tsx +13 -9
- package/src/views/CrmView/client.tsx +10 -11
- package/src/views/TicketDetailView/client.tsx +2 -2
- package/src/views/TicketInboxView/client.tsx +10 -5
- package/src/views/TicketingSettingsView/client.tsx +8 -6
- package/src/views/shared/config.ts +14 -80
- package/src/views/shared/index.ts +3 -1
- package/dist/components/RichTextEditor/index.cjs +0 -279
- package/dist/components/TicketConversation/RewriteDropdown.cjs +0 -98
- package/dist/components/TicketConversation/SkeletonText.cjs +0 -21
- package/dist/components/TicketConversation/components/AISummaryPanel.cjs +0 -85
- package/dist/components/TicketConversation/components/ActionPanels.cjs +0 -193
- package/dist/components/TicketConversation/components/ActivityLog.cjs +0 -24
- package/dist/components/TicketConversation/components/ClientBar.cjs +0 -43
- package/dist/components/TicketConversation/components/ClientHistory.cjs +0 -138
- package/dist/components/TicketConversation/components/CodeBlock.cjs +0 -171
- package/dist/components/TicketConversation/components/CodeBlockInserter.cjs +0 -156
- package/dist/components/TicketConversation/components/QuickActions.cjs +0 -87
- package/dist/components/TicketConversation/components/TicketHeader.cjs +0 -93
- package/dist/components/TicketConversation/components/TimeTrackingPanel.cjs +0 -134
- package/dist/components/TicketConversation/config.cjs +0 -44
- package/dist/components/TicketConversation/constants.cjs +0 -127
- package/dist/components/TicketConversation/context.cjs +0 -13
- package/dist/components/TicketConversation/hooks/useAI.cjs +0 -196
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +0 -20
- package/dist/components/TicketConversation/hooks/useMessageActions.cjs +0 -136
- package/dist/components/TicketConversation/hooks/useReply.cjs +0 -188
- package/dist/components/TicketConversation/hooks/useTicketActions.cjs +0 -290
- package/dist/components/TicketConversation/hooks/useTimeTracking.cjs +0 -123
- package/dist/components/TicketConversation/hooks/useTranslation.cjs +0 -101
- package/dist/components/TicketConversation/index.cjs +0 -1114
- package/dist/components/TicketConversation/types.cjs +0 -2
- package/dist/components/TicketConversation/utils.cjs +0 -27
- package/dist/views/BillingView/client.cjs +0 -469
- package/dist/views/BillingView/index.cjs +0 -34
- package/dist/views/ChatView/client.cjs +0 -301
- package/dist/views/ChatView/index.cjs +0 -34
- package/dist/views/CrmView/client.cjs +0 -458
- package/dist/views/CrmView/index.cjs +0 -34
- package/dist/views/EmailTrackingView/client.cjs +0 -171
- package/dist/views/EmailTrackingView/index.cjs +0 -34
- package/dist/views/ImportConversationView/client.cjs +0 -238
- package/dist/views/ImportConversationView/index.cjs +0 -34
- package/dist/views/LogsView/client.cjs +0 -148
- package/dist/views/LogsView/index.cjs +0 -32
- package/dist/views/NewTicketView/client.cjs +0 -213
- package/dist/views/NewTicketView/index.cjs +0 -32
- package/dist/views/PendingEmailsView/client.cjs +0 -471
- package/dist/views/PendingEmailsView/index.cjs +0 -34
- package/dist/views/SupportDashboardView/client.cjs +0 -437
- package/dist/views/SupportDashboardView/index.cjs +0 -34
- package/dist/views/TicketDetailView/NextActionItem.cjs +0 -40
- package/dist/views/TicketDetailView/RewriteDropdown.cjs +0 -80
- package/dist/views/TicketDetailView/client.cjs +0 -1412
- package/dist/views/TicketDetailView/constants.cjs +0 -20
- package/dist/views/TicketDetailView/helpers.cjs +0 -23
- package/dist/views/TicketDetailView/index.cjs +0 -34
- package/dist/views/TicketDetailView/types.cjs +0 -2
- package/dist/views/TicketInboxView/client.cjs +0 -359
- package/dist/views/TicketInboxView/index.cjs +0 -32
- package/dist/views/TicketingSettingsView/client.cjs +0 -740
- package/dist/views/TicketingSettingsView/index.cjs +0 -34
- package/dist/views/TimeDashboardView/client.cjs +0 -180
- package/dist/views/TimeDashboardView/index.cjs +0 -34
- package/dist/views/shared/AdminViewHeader.cjs +0 -68
- package/dist/views/shared/ErrorBoundary.cjs +0 -55
- package/dist/views/shared/Skeleton.cjs +0 -77
- package/dist/views/shared/StatusPill.cjs +0 -47
- package/dist/views/shared/adminTokens.cjs +0 -34
- package/dist/views/shared/config.cjs +0 -44
- package/dist/views/shared/dateLocale.cjs +0 -5
- package/dist/views/shared/index.cjs +0 -72
- package/dist/views/shared/sla.cjs +0 -37
- package/dist/views.cjs +0 -86
- package/dist/views.d.cts +0 -13
- package/src/__tests__/aiSummaryRendering.test.ts +0 -21
- package/src/__tests__/authResponses.test.ts +0 -69
- package/src/__tests__/capabilities.test.ts +0 -71
- package/src/__tests__/generateTrackingToken.test.ts +0 -67
- package/src/__tests__/integration/ai-agent.test.ts +0 -46
- package/src/__tests__/integration/auth.test.ts +0 -43
- package/src/__tests__/integration/automation-rules.test.ts +0 -68
- package/src/__tests__/integration/buildTestPayload.ts +0 -52
- package/src/__tests__/integration/channels.test.ts +0 -52
- package/src/__tests__/integration/core-behaviors.test.ts +0 -81
- package/src/__tests__/integration/dashboard-volume.test.ts +0 -31
- package/src/__tests__/integration/digest.test.ts +0 -48
- package/src/__tests__/integration/invoice-pdf.test.ts +0 -30
- package/src/__tests__/integration/isolation.test.ts +0 -81
- package/src/__tests__/integration/mentions-invoice.test.ts +0 -50
- package/src/__tests__/integration/nps.test.ts +0 -37
- package/src/__tests__/integration/per-team-sla.test.ts +0 -44
- package/src/__tests__/integration/push.test.ts +0 -75
- package/src/__tests__/integration/sanitization.test.ts +0 -35
- package/src/__tests__/integration/sla-pause.test.ts +0 -39
- package/src/__tests__/integration/smoke.test.ts +0 -26
- package/src/__tests__/integration/snooze.test.ts +0 -68
- package/src/__tests__/integration/teams.test.ts +0 -38
- package/src/__tests__/rateLimiter.test.ts +0 -83
- package/src/__tests__/sanitizeHtml.test.ts +0 -165
- package/src/__tests__/sla.test.ts +0 -258
- package/src/__tests__/trackOpenEndpoint.test.ts +0 -54
- package/src/__tests__/translations.test.ts +0 -67
- package/src/__tests__/webhookSecurity.test.ts +0 -58
package/dist/index.cjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
var payload = require('payload');
|
|
4
4
|
var crypto3 = require('crypto');
|
|
5
5
|
var PDFDocument = require('pdfkit');
|
|
6
|
+
var promises = require('dns/promises');
|
|
6
7
|
var webpush = require('web-push');
|
|
7
8
|
var sanitizeHtml = require('sanitize-html');
|
|
8
9
|
|
|
@@ -167,22 +168,44 @@ var PayloadRateLimitStore = class {
|
|
|
167
168
|
return { payload: context };
|
|
168
169
|
}
|
|
169
170
|
};
|
|
171
|
+
function principalRateKey(user) {
|
|
172
|
+
if (!user || user.id === void 0 || user.id === null) return "anonymous";
|
|
173
|
+
const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
|
|
174
|
+
return `${collection}:${String(user.id)}`;
|
|
175
|
+
}
|
|
170
176
|
var RateLimiter = class {
|
|
171
|
-
|
|
177
|
+
/**
|
|
178
|
+
* @param namespace Endpoint-scoped prefix for every key this limiter writes.
|
|
179
|
+
* The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
|
|
180
|
+
* all endpoints), and the raw keys collide across endpoints: `ip` was used
|
|
181
|
+
* by both the login and the chatbot limiter — 10 forged chatbot requests
|
|
182
|
+
* locked a victim out of the portal for 15 minutes — and `String(user.id)`
|
|
183
|
+
* by five different endpoints. Always pass one; it is optional only because
|
|
184
|
+
* `RateLimiter` is part of the published API surface.
|
|
185
|
+
*/
|
|
186
|
+
constructor(windowMs, maxRequests, store, namespace) {
|
|
172
187
|
this.windowMs = windowMs;
|
|
173
188
|
this.maxRequests = maxRequests;
|
|
174
189
|
this.store = store ?? new MemoryRateLimitStore();
|
|
190
|
+
this.prefix = namespace ? `${namespace}:` : "";
|
|
175
191
|
}
|
|
176
192
|
windowMs;
|
|
177
193
|
maxRequests;
|
|
178
194
|
store;
|
|
195
|
+
prefix;
|
|
196
|
+
/** The key actually written to the store. Exposed for assertions in tests. */
|
|
197
|
+
scopedKey(key) {
|
|
198
|
+
return `${this.prefix}${key}`;
|
|
199
|
+
}
|
|
179
200
|
async check(key, context) {
|
|
180
|
-
const
|
|
201
|
+
const scoped = this.scopedKey(key);
|
|
202
|
+
const entry = context === void 0 ? await this.store.increment(scoped, this.windowMs) : await this.store.increment(scoped, this.windowMs, context);
|
|
181
203
|
return entry.count > this.maxRequests;
|
|
182
204
|
}
|
|
183
205
|
async reset(key, context) {
|
|
184
|
-
|
|
185
|
-
|
|
206
|
+
const scoped = this.scopedKey(key);
|
|
207
|
+
if (context === void 0) await this.store.reset(scoped);
|
|
208
|
+
else await this.store.reset(scoped, context);
|
|
186
209
|
}
|
|
187
210
|
};
|
|
188
211
|
var DEFAULT_INBOUND_EMAIL_LIMITS = {
|
|
@@ -220,7 +243,7 @@ function validateInboundEmailPayload(input, contentLength, limits = DEFAULT_INBO
|
|
|
220
243
|
|
|
221
244
|
// src/endpoints/capabilities.ts
|
|
222
245
|
function createInboundEmailEndpoint(capability, store) {
|
|
223
|
-
const limiter = new RateLimiter(6e4, 60, store);
|
|
246
|
+
const limiter = new RateLimiter(6e4, 60, store, "inbound-email");
|
|
224
247
|
return {
|
|
225
248
|
path: "/support-webhook/inbound-email",
|
|
226
249
|
method: "post",
|
|
@@ -251,7 +274,7 @@ function createInboundEmailEndpoint(capability, store) {
|
|
|
251
274
|
};
|
|
252
275
|
}
|
|
253
276
|
function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
254
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
277
|
+
const limiter = new RateLimiter(6e4, 20, store, "suggest-projects");
|
|
255
278
|
return {
|
|
256
279
|
path: "/support/suggest-projects",
|
|
257
280
|
method: "post",
|
|
@@ -259,7 +282,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
259
282
|
if (!req.user || req.user.collection !== slugs.users) {
|
|
260
283
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
261
284
|
}
|
|
262
|
-
const key =
|
|
285
|
+
const key = principalRateKey(req.user);
|
|
263
286
|
if (await limiter.check(key, req)) {
|
|
264
287
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
265
288
|
}
|
|
@@ -268,7 +291,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
268
291
|
};
|
|
269
292
|
}
|
|
270
293
|
function createTicketTitleEndpoint(slugs, capability, store) {
|
|
271
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
294
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-title");
|
|
272
295
|
return {
|
|
273
296
|
path: "/support/ticket-title",
|
|
274
297
|
method: "post",
|
|
@@ -290,7 +313,7 @@ function createTicketTitleEndpoint(slugs, capability, store) {
|
|
|
290
313
|
};
|
|
291
314
|
}
|
|
292
315
|
function createGenerateMissingTitlesEndpoint(slugs, capability, store) {
|
|
293
|
-
const limiter = new RateLimiter(6e4, 5, store);
|
|
316
|
+
const limiter = new RateLimiter(6e4, 5, store, "generate-missing-titles");
|
|
294
317
|
return {
|
|
295
318
|
path: "/support/generate-missing-titles",
|
|
296
319
|
method: "post",
|
|
@@ -377,55 +400,201 @@ function dbDelete(payload, slug, options) {
|
|
|
377
400
|
return payload.delete({ collection: slug, ...options });
|
|
378
401
|
}
|
|
379
402
|
|
|
403
|
+
// src/utils/features.ts
|
|
404
|
+
var DEFAULT_TICKETING_FEATURES = {
|
|
405
|
+
timeTracking: true,
|
|
406
|
+
ai: true,
|
|
407
|
+
satisfaction: true,
|
|
408
|
+
chat: true,
|
|
409
|
+
emailTracking: true,
|
|
410
|
+
canned: true,
|
|
411
|
+
merge: true,
|
|
412
|
+
snooze: true,
|
|
413
|
+
externalMessages: true,
|
|
414
|
+
clientHistory: true,
|
|
415
|
+
activityLog: true,
|
|
416
|
+
splitTicket: true,
|
|
417
|
+
scheduledReplies: true,
|
|
418
|
+
autoClose: true,
|
|
419
|
+
autoCloseDays: 7,
|
|
420
|
+
roundRobin: false
|
|
421
|
+
};
|
|
422
|
+
var BOOLEAN_FEATURE_KEYS = [
|
|
423
|
+
"timeTracking",
|
|
424
|
+
"ai",
|
|
425
|
+
"satisfaction",
|
|
426
|
+
"chat",
|
|
427
|
+
"emailTracking",
|
|
428
|
+
"canned",
|
|
429
|
+
"merge",
|
|
430
|
+
"snooze",
|
|
431
|
+
"externalMessages",
|
|
432
|
+
"clientHistory",
|
|
433
|
+
"activityLog",
|
|
434
|
+
"splitTicket",
|
|
435
|
+
"scheduledReplies",
|
|
436
|
+
"autoClose",
|
|
437
|
+
"roundRobin"
|
|
438
|
+
];
|
|
439
|
+
var PROJECTED_FEATURE_KEYS = ["autoClose", "autoCloseDays"];
|
|
440
|
+
function normalizeFeatures(raw) {
|
|
441
|
+
const out = { ...DEFAULT_TICKETING_FEATURES };
|
|
442
|
+
if (!raw || typeof raw !== "object") return out;
|
|
443
|
+
const src = raw;
|
|
444
|
+
for (const key of BOOLEAN_FEATURE_KEYS) {
|
|
445
|
+
if (typeof src[key] === "boolean") out[key] = src[key];
|
|
446
|
+
}
|
|
447
|
+
const days = src.autoCloseDays;
|
|
448
|
+
if (typeof days === "number" && Number.isFinite(days) && days >= 1) {
|
|
449
|
+
out.autoCloseDays = Math.floor(days);
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
function projectAutoClose(features, autoClose) {
|
|
454
|
+
if (!autoClose) return features;
|
|
455
|
+
return {
|
|
456
|
+
...features,
|
|
457
|
+
autoClose: typeof autoClose.enabled === "boolean" ? autoClose.enabled : features.autoClose,
|
|
458
|
+
autoCloseDays: typeof autoClose.daysBeforeClose === "number" && autoClose.daysBeforeClose >= 1 ? Math.floor(autoClose.daysBeforeClose) : features.autoCloseDays
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
function stripProjectedFeatures(features) {
|
|
462
|
+
const out = { ...features };
|
|
463
|
+
for (const key of PROJECTED_FEATURE_KEYS) delete out[key];
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
|
|
380
467
|
// src/utils/readSettings.ts
|
|
381
|
-
var
|
|
468
|
+
var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
|
|
469
|
+
var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
|
|
382
470
|
var USER_PREFS_KEY_PREFIX = "support-user-prefs";
|
|
471
|
+
var LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
|
|
472
|
+
var SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
|
|
473
|
+
function resolveStaffPrefSlug(payload, staffSlug) {
|
|
474
|
+
if (staffSlug) return staffSlug;
|
|
475
|
+
const config = payload.config;
|
|
476
|
+
const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
|
|
477
|
+
if (typeof registered === "string" && registered) return registered;
|
|
478
|
+
return config?.admin?.user || "users";
|
|
479
|
+
}
|
|
383
480
|
var DEFAULT_SETTINGS = {
|
|
384
481
|
email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
|
|
385
482
|
ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
|
|
386
483
|
sla: { firstResponseMinutes: 120, resolutionMinutes: 1440, businessHoursOnly: true, escalationEmail: "" },
|
|
387
|
-
autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 }
|
|
484
|
+
autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 },
|
|
485
|
+
features: { ...DEFAULT_TICKETING_FEATURES }
|
|
388
486
|
};
|
|
389
487
|
var DEFAULT_USER_PREFS = {
|
|
390
488
|
locale: "fr",
|
|
391
489
|
signature: ""
|
|
392
490
|
};
|
|
393
|
-
var settingsCache =
|
|
491
|
+
var settingsCache = /* @__PURE__ */ new Map();
|
|
394
492
|
var SETTINGS_TTL_MS = 6e4;
|
|
493
|
+
var SETTINGS_CACHE_MAX = 8;
|
|
494
|
+
var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
|
|
395
495
|
function invalidateSupportSettingsCache() {
|
|
396
|
-
settingsCache
|
|
496
|
+
settingsCache.clear();
|
|
497
|
+
warnedForeignSettingsRow.clear();
|
|
397
498
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
499
|
+
function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
|
|
500
|
+
const autoClose = { ...base.autoClose, ...stored?.autoClose };
|
|
501
|
+
return {
|
|
502
|
+
email: { ...base.email, ...stored?.email },
|
|
503
|
+
ai: { ...base.ai, ...stored?.ai },
|
|
504
|
+
sla: { ...base.sla, ...stored?.sla },
|
|
505
|
+
autoClose,
|
|
506
|
+
// `autoClose` / `autoCloseDays` are projections of the block above, so they
|
|
507
|
+
// are recomputed here rather than trusted from whatever was persisted.
|
|
508
|
+
features: projectAutoClose(
|
|
509
|
+
normalizeFeatures({ ...base.features, ...stored?.features }),
|
|
510
|
+
autoClose
|
|
511
|
+
)
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
async function readSupportSettingsState(payload, staffSlug) {
|
|
515
|
+
const staff = resolveStaffPrefSlug(payload, staffSlug);
|
|
516
|
+
const cached = settingsCache.get(staff);
|
|
517
|
+
if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
|
|
518
|
+
return cached.value;
|
|
401
519
|
}
|
|
402
|
-
let value = {
|
|
520
|
+
let value = {
|
|
521
|
+
settings: mergeSupportSettings(null),
|
|
522
|
+
featuresConfigured: false
|
|
523
|
+
};
|
|
403
524
|
try {
|
|
404
525
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
405
|
-
|
|
526
|
+
// Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
|
|
527
|
+
// security boundary: without it any authenticated principal can plant a
|
|
528
|
+
// `support-settings` row and own the plugin's server settings.
|
|
529
|
+
where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
|
|
530
|
+
// The upsert is scoped per admin user, so several rows can share the key.
|
|
531
|
+
// Sorting makes "last write wins" deterministic instead of arbitrary.
|
|
532
|
+
sort: "-updatedAt",
|
|
406
533
|
limit: 1,
|
|
407
534
|
depth: 0,
|
|
408
535
|
overrideAccess: true
|
|
409
536
|
});
|
|
410
537
|
if (prefs.docs.length > 0) {
|
|
411
538
|
const stored = prefs.docs[0].value;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
};
|
|
539
|
+
const featuresConfigured = !!stored.features && typeof stored.features === "object";
|
|
540
|
+
const settings = mergeSupportSettings(stored);
|
|
541
|
+
if (!featuresConfigured) {
|
|
542
|
+
settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
|
|
543
|
+
}
|
|
544
|
+
value = { settings, featuresConfigured };
|
|
545
|
+
} else {
|
|
546
|
+
await warnOnForeignSettingsRow(payload, staff);
|
|
418
547
|
}
|
|
419
548
|
} catch {
|
|
420
549
|
}
|
|
421
|
-
settingsCache
|
|
550
|
+
if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
|
|
551
|
+
settingsCache.set(staff, { value, ts: Date.now() });
|
|
422
552
|
return value;
|
|
423
553
|
}
|
|
424
|
-
async function
|
|
554
|
+
async function warnOnForeignSettingsRow(payload, staff) {
|
|
555
|
+
if (warnedForeignSettingsRow.has(staff)) return;
|
|
556
|
+
try {
|
|
557
|
+
const any = await dbFind(payload, "payload-preferences", {
|
|
558
|
+
where: { key: { equals: PREF_KEY } },
|
|
559
|
+
limit: 1,
|
|
560
|
+
depth: 0,
|
|
561
|
+
overrideAccess: true
|
|
562
|
+
});
|
|
563
|
+
if (any.docs.length === 0) return;
|
|
564
|
+
if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
|
|
565
|
+
warnedForeignSettingsRow.add(staff);
|
|
566
|
+
console.warn(
|
|
567
|
+
`[support] A "${PREF_KEY}" preference row exists but none is owned by the "${staff}" collection: the plugin is running on its DEFAULT settings. Either the staff auth collection differs from \`admin.user\`, or the row was written by a principal that is not staff \u2014 in which case it is ignored on purpose.`
|
|
568
|
+
);
|
|
569
|
+
} catch {
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async function readSupportSettings(payload, staffSlug) {
|
|
573
|
+
return (await readSupportSettingsState(payload, staffSlug)).settings;
|
|
574
|
+
}
|
|
575
|
+
async function readLegacyRoundRobin(payload, staff) {
|
|
576
|
+
try {
|
|
577
|
+
const prefs = await dbFind(payload, "payload-preferences", {
|
|
578
|
+
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
|
|
579
|
+
limit: 1,
|
|
580
|
+
depth: 0,
|
|
581
|
+
overrideAccess: true
|
|
582
|
+
});
|
|
583
|
+
if (prefs.docs.length > 0) {
|
|
584
|
+
return prefs.docs[0].value?.enabled === true;
|
|
585
|
+
}
|
|
586
|
+
} catch {
|
|
587
|
+
}
|
|
588
|
+
return DEFAULT_TICKETING_FEATURES.roundRobin;
|
|
589
|
+
}
|
|
590
|
+
async function readUserPrefs(payload, userId, staffSlug) {
|
|
425
591
|
try {
|
|
426
592
|
const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
|
|
427
593
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
428
|
-
where: {
|
|
594
|
+
where: {
|
|
595
|
+
key: { equals: key },
|
|
596
|
+
"user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
|
|
597
|
+
},
|
|
429
598
|
limit: 1,
|
|
430
599
|
depth: 0,
|
|
431
600
|
overrideAccess: true
|
|
@@ -441,13 +610,20 @@ async function readUserPrefs(payload, userId) {
|
|
|
441
610
|
}
|
|
442
611
|
return { ...DEFAULT_USER_PREFS };
|
|
443
612
|
}
|
|
613
|
+
function resolveOllamaBaseUrl() {
|
|
614
|
+
const baseURL = process.env.OLLAMA_API_URL;
|
|
615
|
+
if (!baseURL) {
|
|
616
|
+
throw new payload.APIError("provider 'ollama' requires OLLAMA_API_URL", 500);
|
|
617
|
+
}
|
|
618
|
+
return baseURL;
|
|
619
|
+
}
|
|
444
620
|
|
|
445
621
|
// src/endpoints/ai.ts
|
|
446
622
|
async function getClient(aiSettings) {
|
|
447
623
|
const moduleName = "@anthropic-ai/sdk";
|
|
448
624
|
const { default: Anthropic } = await import(moduleName);
|
|
449
625
|
if (aiSettings.provider === "ollama") {
|
|
450
|
-
const baseURL =
|
|
626
|
+
const baseURL = resolveOllamaBaseUrl();
|
|
451
627
|
return new Anthropic({ apiKey: "ollama", baseURL });
|
|
452
628
|
}
|
|
453
629
|
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
@@ -456,7 +632,7 @@ function getModel(aiSettings) {
|
|
|
456
632
|
return aiSettings.model || "claude-haiku-4-5-20251001";
|
|
457
633
|
}
|
|
458
634
|
function createAiEndpoint(slugs, store) {
|
|
459
|
-
const limiter = new RateLimiter(6e4, 30, store);
|
|
635
|
+
const limiter = new RateLimiter(6e4, 30, store, "ai");
|
|
460
636
|
return {
|
|
461
637
|
path: "/support/ai",
|
|
462
638
|
method: "post",
|
|
@@ -464,7 +640,7 @@ function createAiEndpoint(slugs, store) {
|
|
|
464
640
|
try {
|
|
465
641
|
const payload = req.payload;
|
|
466
642
|
requireAdmin(req, slugs);
|
|
467
|
-
if (await limiter.check(
|
|
643
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
468
644
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
469
645
|
}
|
|
470
646
|
const settings = await readSupportSettings(payload);
|
|
@@ -646,7 +822,7 @@ async function applyAgentDecision(payload, slugs, ticketId, decision) {
|
|
|
646
822
|
function getClient2(aiSettings) {
|
|
647
823
|
const Anthropic = __require("@anthropic-ai/sdk").default;
|
|
648
824
|
if (aiSettings.provider === "ollama") {
|
|
649
|
-
return new Anthropic({ apiKey: "ollama", baseURL:
|
|
825
|
+
return new Anthropic({ apiKey: "ollama", baseURL: resolveOllamaBaseUrl() });
|
|
650
826
|
}
|
|
651
827
|
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
652
828
|
}
|
|
@@ -709,14 +885,14 @@ ${kbText || "(vide)"}`;
|
|
|
709
885
|
|
|
710
886
|
// src/endpoints/ai-agent.ts
|
|
711
887
|
function createAiAgentEndpoint(slugs, store) {
|
|
712
|
-
const limiter = new RateLimiter(6e4, 10, store);
|
|
888
|
+
const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
|
|
713
889
|
return {
|
|
714
890
|
path: "/support/ai-agent",
|
|
715
891
|
method: "post",
|
|
716
892
|
handler: async (req) => {
|
|
717
893
|
try {
|
|
718
894
|
requireAdmin(req, slugs);
|
|
719
|
-
if (await limiter.check(
|
|
895
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
720
896
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
721
897
|
}
|
|
722
898
|
let body = {};
|
|
@@ -746,7 +922,7 @@ async function getClient3(aiSettings) {
|
|
|
746
922
|
const moduleName = "@anthropic-ai/sdk";
|
|
747
923
|
const { default: Anthropic } = await import(moduleName);
|
|
748
924
|
if (aiSettings.provider === "ollama") {
|
|
749
|
-
const baseURL =
|
|
925
|
+
const baseURL = resolveOllamaBaseUrl();
|
|
750
926
|
return new Anthropic({ apiKey: "ollama", baseURL });
|
|
751
927
|
}
|
|
752
928
|
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
@@ -756,11 +932,11 @@ function getModel2(aiSettings) {
|
|
|
756
932
|
}
|
|
757
933
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
758
934
|
function createClientIntelligenceEndpoint(slugs, store) {
|
|
759
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
935
|
+
const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
|
|
760
936
|
const getHandler = async (req) => {
|
|
761
937
|
try {
|
|
762
938
|
requireAdmin(req, slugs);
|
|
763
|
-
if (await limiter.check(
|
|
939
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
764
940
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
765
941
|
}
|
|
766
942
|
const payload = req.payload;
|
|
@@ -792,7 +968,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
|
|
|
792
968
|
const postHandler = async (req) => {
|
|
793
969
|
try {
|
|
794
970
|
requireAdmin(req, slugs);
|
|
795
|
-
if (await limiter.check(
|
|
971
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
796
972
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
797
973
|
}
|
|
798
974
|
const payload = req.payload;
|
|
@@ -1421,6 +1597,14 @@ function createSplitTicketEndpoint(slugs) {
|
|
|
1421
1597
|
// src/endpoints/typing.ts
|
|
1422
1598
|
var typingState = /* @__PURE__ */ new Map();
|
|
1423
1599
|
var TYPING_TTL = 5e3;
|
|
1600
|
+
var MAX_TYPING_KEYS = 500;
|
|
1601
|
+
var TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
1602
|
+
function normalizeTicketId(raw) {
|
|
1603
|
+
if (typeof raw === "number") return Number.isInteger(raw) && raw > 0 ? String(raw) : null;
|
|
1604
|
+
if (typeof raw !== "string") return null;
|
|
1605
|
+
const value = raw.trim();
|
|
1606
|
+
return TICKET_ID_PATTERN.test(value) ? value : null;
|
|
1607
|
+
}
|
|
1424
1608
|
function cleanExpired(ticketId) {
|
|
1425
1609
|
const state = typingState.get(ticketId);
|
|
1426
1610
|
if (!state) return;
|
|
@@ -1435,6 +1619,36 @@ function cleanExpired(ticketId) {
|
|
|
1435
1619
|
}
|
|
1436
1620
|
if (!state.admin && !state.client) typingState.delete(ticketId);
|
|
1437
1621
|
}
|
|
1622
|
+
function sweepExpired() {
|
|
1623
|
+
for (const key of Array.from(typingState.keys())) cleanExpired(key);
|
|
1624
|
+
}
|
|
1625
|
+
function evictOldest() {
|
|
1626
|
+
let oldestKey = null;
|
|
1627
|
+
let oldestTs = Infinity;
|
|
1628
|
+
for (const [key, state] of typingState) {
|
|
1629
|
+
const ts = Math.max(state.admin || 0, state.client || 0);
|
|
1630
|
+
if (ts < oldestTs) {
|
|
1631
|
+
oldestTs = ts;
|
|
1632
|
+
oldestKey = key;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
if (oldestKey !== null) typingState.delete(oldestKey);
|
|
1636
|
+
}
|
|
1637
|
+
async function mayAccessTicket(req, slugs, ticketId) {
|
|
1638
|
+
const collection = req.user?.collection;
|
|
1639
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) return false;
|
|
1640
|
+
try {
|
|
1641
|
+
const doc = await dbFindByID(req.payload, slugs.tickets, {
|
|
1642
|
+
id: ticketId,
|
|
1643
|
+
depth: 0,
|
|
1644
|
+
overrideAccess: false,
|
|
1645
|
+
user: req.user
|
|
1646
|
+
});
|
|
1647
|
+
return !!doc;
|
|
1648
|
+
} catch {
|
|
1649
|
+
return false;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1438
1652
|
function createTypingPostEndpoint(slugs) {
|
|
1439
1653
|
return {
|
|
1440
1654
|
path: "/support/typing",
|
|
@@ -1445,11 +1659,18 @@ function createTypingPostEndpoint(slugs) {
|
|
|
1445
1659
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1446
1660
|
}
|
|
1447
1661
|
const { ticketId } = await req.json();
|
|
1448
|
-
|
|
1662
|
+
const key = normalizeTicketId(ticketId);
|
|
1663
|
+
if (!key) {
|
|
1449
1664
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1450
1665
|
}
|
|
1451
|
-
|
|
1666
|
+
if (!await mayAccessTicket(req, slugs, key)) {
|
|
1667
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
1668
|
+
}
|
|
1452
1669
|
const state = typingState.get(key) || {};
|
|
1670
|
+
if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
|
|
1671
|
+
sweepExpired();
|
|
1672
|
+
if (typingState.size >= MAX_TYPING_KEYS) evictOldest();
|
|
1673
|
+
}
|
|
1453
1674
|
if (req.user.collection === slugs.users) {
|
|
1454
1675
|
state.admin = Date.now();
|
|
1455
1676
|
state.adminName = req.user.firstName || "Support";
|
|
@@ -1470,30 +1691,33 @@ function createTypingGetEndpoint(slugs) {
|
|
|
1470
1691
|
path: "/support/typing",
|
|
1471
1692
|
method: "get",
|
|
1472
1693
|
handler: async (req) => {
|
|
1694
|
+
const idle = { typing: false, name: null };
|
|
1473
1695
|
try {
|
|
1474
1696
|
if (!req.user) {
|
|
1475
1697
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1476
1698
|
}
|
|
1477
1699
|
const url = new URL(req.url);
|
|
1478
|
-
const
|
|
1479
|
-
if (!
|
|
1700
|
+
const key = normalizeTicketId(url.searchParams.get("ticketId"));
|
|
1701
|
+
if (!key) {
|
|
1480
1702
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1481
1703
|
}
|
|
1482
|
-
cleanExpired(
|
|
1483
|
-
const state = typingState.get(
|
|
1704
|
+
cleanExpired(key);
|
|
1705
|
+
const state = typingState.get(key);
|
|
1706
|
+
if (!state) return Response.json(idle);
|
|
1707
|
+
if (!await mayAccessTicket(req, slugs, key)) return Response.json(idle);
|
|
1484
1708
|
if (req.user.collection === slugs.users) {
|
|
1485
1709
|
return Response.json({
|
|
1486
|
-
typing: !!state
|
|
1487
|
-
name: state
|
|
1710
|
+
typing: !!state.client,
|
|
1711
|
+
name: state.clientName || null
|
|
1488
1712
|
});
|
|
1489
1713
|
} else {
|
|
1490
1714
|
return Response.json({
|
|
1491
|
-
typing: !!state
|
|
1492
|
-
name: state
|
|
1715
|
+
typing: !!state.admin,
|
|
1716
|
+
name: state.adminName || null
|
|
1493
1717
|
});
|
|
1494
1718
|
}
|
|
1495
1719
|
} catch {
|
|
1496
|
-
return Response.json(
|
|
1720
|
+
return Response.json(idle);
|
|
1497
1721
|
}
|
|
1498
1722
|
}
|
|
1499
1723
|
};
|
|
@@ -1582,25 +1806,6 @@ function createPresenceGetEndpoint(slugs) {
|
|
|
1582
1806
|
}
|
|
1583
1807
|
|
|
1584
1808
|
// src/endpoints/settings.ts
|
|
1585
|
-
var PREF_KEY2 = "support-settings";
|
|
1586
|
-
var DEFAULT_SUPPORT_SETTINGS = {
|
|
1587
|
-
email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
|
|
1588
|
-
ai: {
|
|
1589
|
-
provider: "anthropic",
|
|
1590
|
-
model: "claude-haiku-4-5-20251001",
|
|
1591
|
-
enableSentiment: true,
|
|
1592
|
-
enableSynthesis: true,
|
|
1593
|
-
enableSuggestion: true,
|
|
1594
|
-
enableRewrite: true
|
|
1595
|
-
},
|
|
1596
|
-
sla: {
|
|
1597
|
-
firstResponseMinutes: 120,
|
|
1598
|
-
resolutionMinutes: 1440,
|
|
1599
|
-
businessHoursOnly: true,
|
|
1600
|
-
escalationEmail: ""
|
|
1601
|
-
},
|
|
1602
|
-
autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 }
|
|
1603
|
-
};
|
|
1604
1809
|
function createSettingsGetEndpoint(slugs) {
|
|
1605
1810
|
return {
|
|
1606
1811
|
path: "/support/settings",
|
|
@@ -1609,23 +1814,8 @@ function createSettingsGetEndpoint(slugs) {
|
|
|
1609
1814
|
try {
|
|
1610
1815
|
const payload = req.payload;
|
|
1611
1816
|
requireAdmin(req, slugs);
|
|
1612
|
-
const
|
|
1613
|
-
|
|
1614
|
-
limit: 1,
|
|
1615
|
-
depth: 0,
|
|
1616
|
-
overrideAccess: true
|
|
1617
|
-
});
|
|
1618
|
-
let settings = { ...DEFAULT_SUPPORT_SETTINGS };
|
|
1619
|
-
if (prefs.docs.length > 0) {
|
|
1620
|
-
const stored = prefs.docs[0].value;
|
|
1621
|
-
settings = {
|
|
1622
|
-
email: { ...DEFAULT_SUPPORT_SETTINGS.email, ...stored.email },
|
|
1623
|
-
ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...stored.ai },
|
|
1624
|
-
sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...stored.sla },
|
|
1625
|
-
autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...stored.autoClose }
|
|
1626
|
-
};
|
|
1627
|
-
}
|
|
1628
|
-
return Response.json(settings);
|
|
1817
|
+
const { settings, featuresConfigured } = await readSupportSettingsState(payload);
|
|
1818
|
+
return Response.json({ ...settings, featuresConfigured });
|
|
1629
1819
|
} catch (error) {
|
|
1630
1820
|
const authResponse = handleAuthError(error);
|
|
1631
1821
|
if (authResponse) return authResponse;
|
|
@@ -1644,30 +1834,31 @@ function createSettingsPostEndpoint(slugs) {
|
|
|
1644
1834
|
const payload = req.payload;
|
|
1645
1835
|
requireAdmin(req, slugs);
|
|
1646
1836
|
const body = await req.json();
|
|
1647
|
-
const
|
|
1648
|
-
|
|
1649
|
-
ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...body.ai },
|
|
1650
|
-
sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...body.sla },
|
|
1651
|
-
autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...body.autoClose }
|
|
1652
|
-
};
|
|
1837
|
+
const { settings: current } = await readSupportSettingsState(payload);
|
|
1838
|
+
const merged = mergeSupportSettings(body, current);
|
|
1653
1839
|
await payload.db.upsert({
|
|
1654
1840
|
collection: "payload-preferences",
|
|
1655
1841
|
data: {
|
|
1656
|
-
key:
|
|
1842
|
+
key: SUPPORT_SETTINGS_PREF_KEY,
|
|
1657
1843
|
user: { relationTo: req.user.collection, value: req.user.id },
|
|
1658
|
-
value:
|
|
1844
|
+
value: {
|
|
1845
|
+
...merged,
|
|
1846
|
+
// Persist the flags without the two projected ones, so `features`
|
|
1847
|
+
// and the `autoClose` block can never drift apart.
|
|
1848
|
+
features: stripProjectedFeatures(merged.features)
|
|
1849
|
+
}
|
|
1659
1850
|
},
|
|
1660
1851
|
req: { payload, user: req.user },
|
|
1661
1852
|
where: {
|
|
1662
1853
|
and: [
|
|
1663
|
-
{ key: { equals:
|
|
1854
|
+
{ key: { equals: SUPPORT_SETTINGS_PREF_KEY } },
|
|
1664
1855
|
{ "user.value": { equals: req.user.id } },
|
|
1665
1856
|
{ "user.relationTo": { equals: req.user.collection } }
|
|
1666
1857
|
]
|
|
1667
1858
|
}
|
|
1668
1859
|
});
|
|
1669
1860
|
invalidateSupportSettingsCache();
|
|
1670
|
-
return Response.json(merged);
|
|
1861
|
+
return Response.json({ ...merged, featuresConfigured: true });
|
|
1671
1862
|
} catch (error) {
|
|
1672
1863
|
const authResponse = handleAuthError(error);
|
|
1673
1864
|
if (authResponse) return authResponse;
|
|
@@ -1679,7 +1870,7 @@ function createSettingsPostEndpoint(slugs) {
|
|
|
1679
1870
|
}
|
|
1680
1871
|
|
|
1681
1872
|
// src/endpoints/signature.ts
|
|
1682
|
-
var
|
|
1873
|
+
var PREF_KEY2 = "email-signature";
|
|
1683
1874
|
function createSignatureGetEndpoint(slugs) {
|
|
1684
1875
|
return {
|
|
1685
1876
|
path: "/support/signature",
|
|
@@ -1689,7 +1880,14 @@ function createSignatureGetEndpoint(slugs) {
|
|
|
1689
1880
|
const payload = req.payload;
|
|
1690
1881
|
requireAdmin(req, slugs);
|
|
1691
1882
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
1692
|
-
|
|
1883
|
+
// Scope to the staff auth collection: `payload-preferences` accepts a
|
|
1884
|
+
// write from ANY authenticated principal, and ids collide between auth
|
|
1885
|
+
// collections — a support-client with the same id would otherwise own
|
|
1886
|
+
// the `email-signature-<id>` row read back for the agent.
|
|
1887
|
+
where: {
|
|
1888
|
+
key: { equals: `${PREF_KEY2}-${req.user.id}` },
|
|
1889
|
+
"user.relationTo": { equals: slugs.users }
|
|
1890
|
+
},
|
|
1693
1891
|
limit: 1,
|
|
1694
1892
|
depth: 0,
|
|
1695
1893
|
overrideAccess: true
|
|
@@ -1714,9 +1912,9 @@ function createSignaturePostEndpoint(slugs) {
|
|
|
1714
1912
|
const payload = req.payload;
|
|
1715
1913
|
requireAdmin(req, slugs);
|
|
1716
1914
|
const { signature } = await req.json();
|
|
1717
|
-
const key = `${
|
|
1915
|
+
const key = `${PREF_KEY2}-${req.user.id}`;
|
|
1718
1916
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
1719
|
-
where: { key: { equals: key } },
|
|
1917
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
1720
1918
|
limit: 1,
|
|
1721
1919
|
depth: 0,
|
|
1722
1920
|
overrideAccess: true
|
|
@@ -1749,7 +1947,6 @@ function createSignaturePostEndpoint(slugs) {
|
|
|
1749
1947
|
}
|
|
1750
1948
|
|
|
1751
1949
|
// src/endpoints/round-robin-config.ts
|
|
1752
|
-
var PREF_KEY4 = "support-round-robin";
|
|
1753
1950
|
function createRoundRobinConfigGetEndpoint(slugs) {
|
|
1754
1951
|
return {
|
|
1755
1952
|
path: "/support/round-robin-config",
|
|
@@ -1758,14 +1955,8 @@ function createRoundRobinConfigGetEndpoint(slugs) {
|
|
|
1758
1955
|
try {
|
|
1759
1956
|
const payload = req.payload;
|
|
1760
1957
|
requireAdmin(req, slugs);
|
|
1761
|
-
const
|
|
1762
|
-
|
|
1763
|
-
limit: 1,
|
|
1764
|
-
depth: 0,
|
|
1765
|
-
overrideAccess: true
|
|
1766
|
-
});
|
|
1767
|
-
const enabled = prefs.docs.length > 0 ? prefs.docs[0].value?.enabled === true : false;
|
|
1768
|
-
return Response.json({ enabled });
|
|
1958
|
+
const { settings } = await readSupportSettingsState(payload);
|
|
1959
|
+
return Response.json({ enabled: settings.features.roundRobin });
|
|
1769
1960
|
} catch (error) {
|
|
1770
1961
|
const authResponse = handleAuthError(error);
|
|
1771
1962
|
if (authResponse) return authResponse;
|
|
@@ -1784,28 +1975,31 @@ function createRoundRobinConfigPostEndpoint(slugs) {
|
|
|
1784
1975
|
const payload = req.payload;
|
|
1785
1976
|
requireAdmin(req, slugs);
|
|
1786
1977
|
const { enabled } = await req.json();
|
|
1787
|
-
const
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
});
|
|
1978
|
+
const { settings: current } = await readSupportSettingsState(payload);
|
|
1979
|
+
const merged = mergeSupportSettings(
|
|
1980
|
+
{ features: { ...current.features, roundRobin: !!enabled } },
|
|
1981
|
+
current
|
|
1982
|
+
);
|
|
1793
1983
|
await payload.db.upsert({
|
|
1794
1984
|
collection: "payload-preferences",
|
|
1795
1985
|
data: {
|
|
1796
|
-
key:
|
|
1986
|
+
key: SUPPORT_SETTINGS_PREF_KEY,
|
|
1797
1987
|
user: { relationTo: req.user.collection, value: req.user.id },
|
|
1798
|
-
value: {
|
|
1988
|
+
value: {
|
|
1989
|
+
...merged,
|
|
1990
|
+
features: stripProjectedFeatures(merged.features)
|
|
1991
|
+
}
|
|
1799
1992
|
},
|
|
1800
1993
|
req: { payload, user: req.user },
|
|
1801
1994
|
where: {
|
|
1802
1995
|
and: [
|
|
1803
|
-
{ key: { equals:
|
|
1996
|
+
{ key: { equals: SUPPORT_SETTINGS_PREF_KEY } },
|
|
1804
1997
|
{ "user.value": { equals: req.user.id } },
|
|
1805
1998
|
{ "user.relationTo": { equals: req.user.collection } }
|
|
1806
1999
|
]
|
|
1807
2000
|
}
|
|
1808
2001
|
});
|
|
2002
|
+
invalidateSupportSettingsCache();
|
|
1809
2003
|
return Response.json({ enabled: !!enabled });
|
|
1810
2004
|
} catch (error) {
|
|
1811
2005
|
const authResponse = handleAuthError(error);
|
|
@@ -2481,7 +2675,7 @@ function formatFr(date, withTime) {
|
|
|
2481
2675
|
});
|
|
2482
2676
|
}
|
|
2483
2677
|
function createSendReminderEndpoint(slugs, store) {
|
|
2484
|
-
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
|
|
2678
|
+
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
|
|
2485
2679
|
return {
|
|
2486
2680
|
path: "/support/send-reminder",
|
|
2487
2681
|
method: "post",
|
|
@@ -2489,7 +2683,7 @@ function createSendReminderEndpoint(slugs, store) {
|
|
|
2489
2683
|
try {
|
|
2490
2684
|
const payload = req.payload;
|
|
2491
2685
|
requireAdmin(req, slugs);
|
|
2492
|
-
if (await reminderLimiter.check(
|
|
2686
|
+
if (await reminderLimiter.check(principalRateKey(req.user), req)) {
|
|
2493
2687
|
return Response.json(
|
|
2494
2688
|
{ error: "Trop de relances. R\xE9essayez dans une heure." },
|
|
2495
2689
|
{ status: 429 }
|
|
@@ -2792,8 +2986,14 @@ function createPurgeLogsEndpoint(slugs) {
|
|
|
2792
2986
|
}
|
|
2793
2987
|
|
|
2794
2988
|
// src/endpoints/chatbot.ts
|
|
2795
|
-
|
|
2796
|
-
|
|
2989
|
+
var DEFAULT_CHATBOT_MAX_PER_HOUR = 200;
|
|
2990
|
+
function resolveMaxPerHour(explicit) {
|
|
2991
|
+
const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR);
|
|
2992
|
+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR;
|
|
2993
|
+
}
|
|
2994
|
+
function createChatbotEndpoint(slugs, store, maxPerHour) {
|
|
2995
|
+
const chatbotLimiter = new RateLimiter(6e4, 10, store, "chatbot:ip");
|
|
2996
|
+
const globalLimiter = new RateLimiter(60 * 6e4, resolveMaxPerHour(), store, "chatbot:global");
|
|
2797
2997
|
return {
|
|
2798
2998
|
path: "/support/chatbot",
|
|
2799
2999
|
method: "post",
|
|
@@ -2813,6 +3013,15 @@ function createChatbotEndpoint(slugs, store) {
|
|
|
2813
3013
|
if (!question?.trim() || question.trim().length < 5) {
|
|
2814
3014
|
return Response.json({ error: "Question too short" }, { status: 400 });
|
|
2815
3015
|
}
|
|
3016
|
+
if (await globalLimiter.check("all", req)) {
|
|
3017
|
+
return Response.json({
|
|
3018
|
+
answer: null,
|
|
3019
|
+
confidence: 0,
|
|
3020
|
+
suggestion: "create_ticket",
|
|
3021
|
+
aiUnavailable: true,
|
|
3022
|
+
message: "L'assistant est momentan\xE9ment indisponible. Cr\xE9ez un ticket, un agent vous r\xE9pondra."
|
|
3023
|
+
});
|
|
3024
|
+
}
|
|
2816
3025
|
const payload = req.payload;
|
|
2817
3026
|
const articles = await dbFind(payload, slugs.knowledgeBase, {
|
|
2818
3027
|
where: { published: { equals: true } },
|
|
@@ -2925,8 +3134,8 @@ function createChatGetEndpoint(slugs) {
|
|
|
2925
3134
|
};
|
|
2926
3135
|
}
|
|
2927
3136
|
function createChatPostEndpoint(slugs, store) {
|
|
2928
|
-
const chatSessionLimiter = new RateLimiter(36e5, 5, store);
|
|
2929
|
-
const chatMessageLimiter = new RateLimiter(6e4, 15, store);
|
|
3137
|
+
const chatSessionLimiter = new RateLimiter(36e5, 5, store, "chat:session");
|
|
3138
|
+
const chatMessageLimiter = new RateLimiter(6e4, 15, store, "chat:message");
|
|
2930
3139
|
return {
|
|
2931
3140
|
path: "/support/chat",
|
|
2932
3141
|
method: "post",
|
|
@@ -2942,8 +3151,9 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
2942
3151
|
}
|
|
2943
3152
|
const { action, session, message } = body;
|
|
2944
3153
|
const userId = String(req.user.id);
|
|
3154
|
+
const rateKey = principalRateKey(req.user);
|
|
2945
3155
|
if (action === "start") {
|
|
2946
|
-
if (await chatSessionLimiter.check(
|
|
3156
|
+
if (await chatSessionLimiter.check(rateKey, req)) {
|
|
2947
3157
|
return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
|
|
2948
3158
|
}
|
|
2949
3159
|
const sessionId = `chat_${crypto3__default.default.randomBytes(16).toString("hex")}`;
|
|
@@ -2960,7 +3170,7 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
2960
3170
|
return Response.json({ session: sessionId, messages: [systemMsg] });
|
|
2961
3171
|
}
|
|
2962
3172
|
if (action === "send" && session && message) {
|
|
2963
|
-
if (await chatMessageLimiter.check(
|
|
3173
|
+
if (await chatMessageLimiter.check(rateKey, req)) {
|
|
2964
3174
|
return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
|
|
2965
3175
|
}
|
|
2966
3176
|
const trimmedMessage = String(message).trim();
|
|
@@ -3200,7 +3410,7 @@ function createAdminChatGetEndpoint(slugs) {
|
|
|
3200
3410
|
};
|
|
3201
3411
|
}
|
|
3202
3412
|
function createAdminChatPostEndpoint(slugs, store) {
|
|
3203
|
-
const adminChatLimiter = new RateLimiter(6e4, 30, store);
|
|
3413
|
+
const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
|
|
3204
3414
|
return {
|
|
3205
3415
|
path: "/support/admin-chat",
|
|
3206
3416
|
method: "post",
|
|
@@ -3229,7 +3439,7 @@ function createAdminChatPostEndpoint(slugs, store) {
|
|
|
3229
3439
|
}
|
|
3230
3440
|
const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
|
|
3231
3441
|
if (action === "send" && message) {
|
|
3232
|
-
if (await adminChatLimiter.check(
|
|
3442
|
+
if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
|
|
3233
3443
|
return Response.json({ error: "Rate limit atteint." }, { status: 429 });
|
|
3234
3444
|
}
|
|
3235
3445
|
const trimmedMessage = String(message).trim();
|
|
@@ -4067,7 +4277,7 @@ function createInvoiceEndpoint(slugs) {
|
|
|
4067
4277
|
function getClient4(aiSettings) {
|
|
4068
4278
|
const Anthropic = __require("@anthropic-ai/sdk").default;
|
|
4069
4279
|
if (aiSettings.provider === "ollama") {
|
|
4070
|
-
const baseURL =
|
|
4280
|
+
const baseURL = resolveOllamaBaseUrl();
|
|
4071
4281
|
return new Anthropic({ apiKey: "ollama", baseURL });
|
|
4072
4282
|
}
|
|
4073
4283
|
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
@@ -4184,14 +4394,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
|
|
|
4184
4394
|
|
|
4185
4395
|
// src/endpoints/ticket-synthesis.ts
|
|
4186
4396
|
function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
4187
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
4397
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
|
|
4188
4398
|
return {
|
|
4189
4399
|
path: "/support/ticket-synthesis",
|
|
4190
4400
|
method: "post",
|
|
4191
4401
|
handler: async (req) => {
|
|
4192
4402
|
try {
|
|
4193
4403
|
requireAdmin(req, slugs);
|
|
4194
|
-
if (await limiter.check(
|
|
4404
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
4195
4405
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
4196
4406
|
}
|
|
4197
4407
|
const payload = req.payload;
|
|
@@ -4235,15 +4445,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
|
4235
4445
|
}
|
|
4236
4446
|
|
|
4237
4447
|
// src/endpoints/email-stats.ts
|
|
4238
|
-
function createEmailStatsEndpoint(slugs) {
|
|
4448
|
+
function createEmailStatsEndpoint(slugs, store) {
|
|
4449
|
+
const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
|
|
4239
4450
|
return {
|
|
4240
4451
|
path: "/support/email-stats",
|
|
4241
4452
|
method: "get",
|
|
4242
4453
|
handler: async (req) => {
|
|
4243
4454
|
try {
|
|
4244
4455
|
const payload = req.payload;
|
|
4245
|
-
|
|
4246
|
-
|
|
4456
|
+
requireAdmin(req, slugs);
|
|
4457
|
+
if (await statsLimiter.check(principalRateKey(req.user), req)) {
|
|
4458
|
+
return Response.json({ error: "Too many requests." }, { status: 429 });
|
|
4247
4459
|
}
|
|
4248
4460
|
const url = new URL(req.url);
|
|
4249
4461
|
const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
|
|
@@ -4310,6 +4522,8 @@ function createEmailStatsEndpoint(slugs) {
|
|
|
4310
4522
|
actions: Object.fromEntries(actionMap)
|
|
4311
4523
|
});
|
|
4312
4524
|
} catch (err) {
|
|
4525
|
+
const authResponse = handleAuthError(err);
|
|
4526
|
+
if (authResponse) return authResponse;
|
|
4313
4527
|
console.error("[email-stats] Error:", err);
|
|
4314
4528
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
4315
4529
|
}
|
|
@@ -4742,7 +4956,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
|
|
|
4742
4956
|
|
|
4743
4957
|
// src/endpoints/resend-notification.ts
|
|
4744
4958
|
function createResendNotificationEndpoint(slugs, store) {
|
|
4745
|
-
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
4959
|
+
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
|
|
4746
4960
|
return {
|
|
4747
4961
|
path: "/support/resend-notification",
|
|
4748
4962
|
method: "post",
|
|
@@ -4750,7 +4964,7 @@ function createResendNotificationEndpoint(slugs, store) {
|
|
|
4750
4964
|
try {
|
|
4751
4965
|
const payload = req.payload;
|
|
4752
4966
|
requireAdmin(req, slugs);
|
|
4753
|
-
if (await resendLimiter.check(
|
|
4967
|
+
if (await resendLimiter.check(principalRateKey(req.user), req)) {
|
|
4754
4968
|
return Response.json(
|
|
4755
4969
|
{ error: "Trop de renvois. R\xE9essayez dans une heure." },
|
|
4756
4970
|
{ status: 429 }
|
|
@@ -4951,10 +5165,48 @@ function createSeedKbEndpoint(slugs) {
|
|
|
4951
5165
|
}
|
|
4952
5166
|
};
|
|
4953
5167
|
}
|
|
5168
|
+
var TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1e3;
|
|
5169
|
+
function challengeSecret() {
|
|
5170
|
+
const secret = process.env.PAYLOAD_SECRET;
|
|
5171
|
+
if (!secret) {
|
|
5172
|
+
throw new Error(
|
|
5173
|
+
"[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to issue a 2FA challenge with an insecure fallback secret"
|
|
5174
|
+
);
|
|
5175
|
+
}
|
|
5176
|
+
return secret;
|
|
5177
|
+
}
|
|
5178
|
+
function normalizeEmail(email) {
|
|
5179
|
+
return String(email).trim().toLowerCase();
|
|
5180
|
+
}
|
|
5181
|
+
function sign(email, expiresAt) {
|
|
5182
|
+
return crypto3.createHmac("sha256", challengeSecret()).update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`).digest("hex");
|
|
5183
|
+
}
|
|
5184
|
+
function issueTwoFactorChallenge(email, now = Date.now()) {
|
|
5185
|
+
const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS;
|
|
5186
|
+
return `${expiresAt}.${sign(email, expiresAt)}`;
|
|
5187
|
+
}
|
|
5188
|
+
function verifyTwoFactorChallenge(email, token, now = Date.now()) {
|
|
5189
|
+
if (typeof email !== "string" || !email || typeof token !== "string") return false;
|
|
5190
|
+
const separator = token.indexOf(".");
|
|
5191
|
+
if (separator <= 0) return false;
|
|
5192
|
+
const expiresAt = Number(token.slice(0, separator));
|
|
5193
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false;
|
|
5194
|
+
const received = token.slice(separator + 1);
|
|
5195
|
+
if (!/^[0-9a-f]{64}$/i.test(received)) return false;
|
|
5196
|
+
let expected;
|
|
5197
|
+
try {
|
|
5198
|
+
expected = sign(email, expiresAt);
|
|
5199
|
+
} catch {
|
|
5200
|
+
return false;
|
|
5201
|
+
}
|
|
5202
|
+
const a = Buffer.from(expected, "hex");
|
|
5203
|
+
const b = Buffer.from(received.toLowerCase(), "hex");
|
|
5204
|
+
return a.length === b.length && crypto3.timingSafeEqual(a, b);
|
|
5205
|
+
}
|
|
4954
5206
|
|
|
4955
5207
|
// src/endpoints/login.ts
|
|
4956
5208
|
function createLoginEndpoint(slugs, store) {
|
|
4957
|
-
const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
|
|
5209
|
+
const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
|
|
4958
5210
|
return {
|
|
4959
5211
|
path: "/support/login",
|
|
4960
5212
|
method: "post",
|
|
@@ -5007,7 +5259,12 @@ function createLoginEndpoint(slugs, store) {
|
|
|
5007
5259
|
} catch (err) {
|
|
5008
5260
|
const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
|
|
5009
5261
|
if (errorMessage.includes("2FA_REQUIRED")) {
|
|
5010
|
-
|
|
5262
|
+
let challenge;
|
|
5263
|
+
try {
|
|
5264
|
+
challenge = issueTwoFactorChallenge(email);
|
|
5265
|
+
} catch {
|
|
5266
|
+
}
|
|
5267
|
+
return Response.json({ requires2FA: true, ...challenge ? { challenge } : {} }, { status: 200 });
|
|
5011
5268
|
}
|
|
5012
5269
|
let errorReason = "Identifiants incorrects";
|
|
5013
5270
|
if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
|
|
@@ -5039,8 +5296,8 @@ function hashCode(code) {
|
|
|
5039
5296
|
return crypto3.createHmac("sha256", secret).update(code).digest("hex");
|
|
5040
5297
|
}
|
|
5041
5298
|
function createAuth2faEndpoint(slugs, store) {
|
|
5042
|
-
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
|
|
5043
|
-
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store);
|
|
5299
|
+
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store, "2fa:send");
|
|
5300
|
+
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store, "2fa:verify");
|
|
5044
5301
|
return {
|
|
5045
5302
|
path: "/support/2fa",
|
|
5046
5303
|
method: "post",
|
|
@@ -5053,12 +5310,18 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5053
5310
|
} catch {
|
|
5054
5311
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
5055
5312
|
}
|
|
5056
|
-
const { action, email, code } = body;
|
|
5313
|
+
const { action, email, code, challenge } = body;
|
|
5057
5314
|
if (!action || !email) {
|
|
5058
5315
|
return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
|
|
5059
5316
|
}
|
|
5060
5317
|
const genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
|
|
5061
5318
|
if (action === "send") {
|
|
5319
|
+
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
5320
|
+
return Response.json(
|
|
5321
|
+
{ error: "Authentification requise avant l'envoi d'un code." },
|
|
5322
|
+
{ status: 401 }
|
|
5323
|
+
);
|
|
5324
|
+
}
|
|
5062
5325
|
if (await sendLimiter.check(email, req)) {
|
|
5063
5326
|
return Response.json(genericSendResponse);
|
|
5064
5327
|
}
|
|
@@ -5152,6 +5415,33 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5152
5415
|
}
|
|
5153
5416
|
};
|
|
5154
5417
|
}
|
|
5418
|
+
var OAUTH_STATE_COOKIE = "support-oauth-state";
|
|
5419
|
+
var OAUTH_STATE_MAX_AGE = 600;
|
|
5420
|
+
var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
|
|
5421
|
+
function readCookie(header, name) {
|
|
5422
|
+
if (!header) return null;
|
|
5423
|
+
for (const part of header.split(";")) {
|
|
5424
|
+
const eq = part.indexOf("=");
|
|
5425
|
+
if (eq === -1) continue;
|
|
5426
|
+
if (part.slice(0, eq).trim() !== name) continue;
|
|
5427
|
+
try {
|
|
5428
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
5429
|
+
} catch {
|
|
5430
|
+
return part.slice(eq + 1).trim();
|
|
5431
|
+
}
|
|
5432
|
+
}
|
|
5433
|
+
return null;
|
|
5434
|
+
}
|
|
5435
|
+
function clearedStateCookie() {
|
|
5436
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5437
|
+
return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=0`;
|
|
5438
|
+
}
|
|
5439
|
+
function statesMatch(a, b) {
|
|
5440
|
+
if (!a || !b) return false;
|
|
5441
|
+
const left = crypto3__default.default.createHash("sha256").update(a).digest();
|
|
5442
|
+
const right = crypto3__default.default.createHash("sha256").update(b).digest();
|
|
5443
|
+
return crypto3__default.default.timingSafeEqual(left, right);
|
|
5444
|
+
}
|
|
5155
5445
|
function createOAuthGoogleEndpoint(slugs, options) {
|
|
5156
5446
|
return {
|
|
5157
5447
|
path: "/support/oauth/google",
|
|
@@ -5168,7 +5458,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5168
5458
|
}
|
|
5169
5459
|
try {
|
|
5170
5460
|
const body = await req.json();
|
|
5171
|
-
const { action, code, state: queryState
|
|
5461
|
+
const { action, code, state: queryState } = body;
|
|
5172
5462
|
if (action === "login") {
|
|
5173
5463
|
const oauthState = crypto3__default.default.randomBytes(32).toString("hex");
|
|
5174
5464
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5180,13 +5470,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5180
5470
|
state: oauthState,
|
|
5181
5471
|
prompt: "select_account"
|
|
5182
5472
|
});
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5473
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5474
|
+
const loginHeaders = new Headers({ "Content-Type": "application/json" });
|
|
5475
|
+
loginHeaders.append(
|
|
5476
|
+
"Set-Cookie",
|
|
5477
|
+
`${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`
|
|
5478
|
+
);
|
|
5479
|
+
return new Response(
|
|
5480
|
+
JSON.stringify({
|
|
5481
|
+
url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
|
|
5482
|
+
// Kept for callers that echo it back in the redirect URL; the
|
|
5483
|
+
// server no longer trusts anything the caller returns.
|
|
5484
|
+
state: oauthState
|
|
5485
|
+
}),
|
|
5486
|
+
{ status: 200, headers: loginHeaders }
|
|
5487
|
+
);
|
|
5187
5488
|
}
|
|
5188
5489
|
if (code) {
|
|
5189
|
-
|
|
5490
|
+
const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
|
|
5491
|
+
if (!statesMatch(issuedState, queryState)) {
|
|
5190
5492
|
return Response.json({ error: "state_mismatch" }, { status: 400 });
|
|
5191
5493
|
}
|
|
5192
5494
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5268,6 +5570,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5268
5570
|
overrideAccess: true
|
|
5269
5571
|
});
|
|
5270
5572
|
}
|
|
5573
|
+
const twoFactorDoc = await dbFindByID(payload$1, slugs.supportClients, {
|
|
5574
|
+
id: clientDoc.id,
|
|
5575
|
+
depth: 0,
|
|
5576
|
+
overrideAccess: true,
|
|
5577
|
+
showHiddenFields: true
|
|
5578
|
+
});
|
|
5579
|
+
if (twoFactorDoc?.twoFactorEnabled) {
|
|
5580
|
+
const raw = twoFactorDoc.twoFactorVerifiedAt;
|
|
5581
|
+
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
5582
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
|
|
5583
|
+
let challenge;
|
|
5584
|
+
try {
|
|
5585
|
+
challenge = issueTwoFactorChallenge(clientDoc.email);
|
|
5586
|
+
} catch {
|
|
5587
|
+
}
|
|
5588
|
+
return new Response(JSON.stringify({ requires2FA: true, ...challenge ? { challenge } : {} }), {
|
|
5589
|
+
status: 200,
|
|
5590
|
+
headers: new Headers({
|
|
5591
|
+
"Content-Type": "application/json",
|
|
5592
|
+
"Set-Cookie": clearedStateCookie()
|
|
5593
|
+
})
|
|
5594
|
+
});
|
|
5595
|
+
}
|
|
5596
|
+
await dbUpdate(payload$1, slugs.supportClients, {
|
|
5597
|
+
id: clientDoc.id,
|
|
5598
|
+
data: { twoFactorVerifiedAt: null },
|
|
5599
|
+
overrideAccess: true
|
|
5600
|
+
});
|
|
5601
|
+
}
|
|
5271
5602
|
const secret = process.env.PAYLOAD_SECRET;
|
|
5272
5603
|
if (!secret) {
|
|
5273
5604
|
return Response.json({ error: "server_misconfigured" }, { status: 500 });
|
|
@@ -5311,6 +5642,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5311
5642
|
"Set-Cookie",
|
|
5312
5643
|
`payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
|
|
5313
5644
|
);
|
|
5645
|
+
headers.append("Set-Cookie", clearedStateCookie());
|
|
5314
5646
|
return new Response(JSON.stringify({ user: clientDoc, exp }), {
|
|
5315
5647
|
status: 200,
|
|
5316
5648
|
headers
|
|
@@ -5617,7 +5949,7 @@ ONLY JSON, nothing else.`
|
|
|
5617
5949
|
}
|
|
5618
5950
|
}
|
|
5619
5951
|
function createImportConversationEndpoint(slugs, store) {
|
|
5620
|
-
const importLimiter = new RateLimiter(36e5, 10, store);
|
|
5952
|
+
const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
|
|
5621
5953
|
return {
|
|
5622
5954
|
path: "/support/import-conversation",
|
|
5623
5955
|
method: "post",
|
|
@@ -5760,62 +6092,204 @@ function createImportConversationEndpoint(slugs, store) {
|
|
|
5760
6092
|
}
|
|
5761
6093
|
};
|
|
5762
6094
|
}
|
|
6095
|
+
function httpAllowed() {
|
|
6096
|
+
return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === "1";
|
|
6097
|
+
}
|
|
6098
|
+
var BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa"];
|
|
6099
|
+
function parseIPv4(host) {
|
|
6100
|
+
const parts = host.split(".");
|
|
6101
|
+
if (parts.length !== 4) return null;
|
|
6102
|
+
const octets = [];
|
|
6103
|
+
for (const part of parts) {
|
|
6104
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
6105
|
+
const n = Number(part);
|
|
6106
|
+
if (n > 255) return null;
|
|
6107
|
+
octets.push(n);
|
|
6108
|
+
}
|
|
6109
|
+
return octets;
|
|
6110
|
+
}
|
|
6111
|
+
function isPrivateIPv4(octets) {
|
|
6112
|
+
const [a, b] = octets;
|
|
6113
|
+
if (a === 0) return true;
|
|
6114
|
+
if (a === 10) return true;
|
|
6115
|
+
if (a === 127) return true;
|
|
6116
|
+
if (a === 169 && b === 254) return true;
|
|
6117
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
6118
|
+
if (a === 192 && b === 168) return true;
|
|
6119
|
+
if (a === 192 && b === 0) return true;
|
|
6120
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
6121
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
6122
|
+
if (a >= 224) return true;
|
|
6123
|
+
return false;
|
|
6124
|
+
}
|
|
6125
|
+
function isPrivateIPv6(host) {
|
|
6126
|
+
const lower = host.toLowerCase();
|
|
6127
|
+
if (lower === "::" || lower === "::1") return true;
|
|
6128
|
+
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
|
|
6129
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
6130
|
+
if (lower.startsWith("ff")) return true;
|
|
6131
|
+
const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
6132
|
+
if (dotted) {
|
|
6133
|
+
const octets = parseIPv4(dotted[1]);
|
|
6134
|
+
return octets ? isPrivateIPv4(octets) : true;
|
|
6135
|
+
}
|
|
6136
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
6137
|
+
if (hex) {
|
|
6138
|
+
const high = parseInt(hex[1], 16);
|
|
6139
|
+
const low = parseInt(hex[2], 16);
|
|
6140
|
+
return isPrivateIPv4([high >> 8, high & 255, low >> 8, low & 255]);
|
|
6141
|
+
}
|
|
6142
|
+
return false;
|
|
6143
|
+
}
|
|
6144
|
+
function normalizeHost(hostname) {
|
|
6145
|
+
const host = hostname.trim().toLowerCase();
|
|
6146
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
6147
|
+
}
|
|
6148
|
+
function isBlockedHost(hostname) {
|
|
6149
|
+
const host = normalizeHost(hostname);
|
|
6150
|
+
if (!host) return true;
|
|
6151
|
+
if (host === "localhost") return true;
|
|
6152
|
+
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
|
|
6153
|
+
const v4 = parseIPv4(host);
|
|
6154
|
+
if (v4) return isPrivateIPv4(v4);
|
|
6155
|
+
if (host.includes(":")) return isPrivateIPv6(host);
|
|
6156
|
+
return false;
|
|
6157
|
+
}
|
|
6158
|
+
function validateWebhookUrl(raw) {
|
|
6159
|
+
if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "invalid_url" };
|
|
6160
|
+
let url;
|
|
6161
|
+
try {
|
|
6162
|
+
url = new URL(raw.trim());
|
|
6163
|
+
} catch {
|
|
6164
|
+
return { ok: false, reason: "invalid_url" };
|
|
6165
|
+
}
|
|
6166
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && httpAllowed())) {
|
|
6167
|
+
return { ok: false, reason: "scheme_not_allowed" };
|
|
6168
|
+
}
|
|
6169
|
+
if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
|
|
6170
|
+
return { ok: true, url };
|
|
6171
|
+
}
|
|
6172
|
+
var WEBHOOK_URL_MESSAGES = {
|
|
6173
|
+
invalid_url: "URL invalide.",
|
|
6174
|
+
scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
|
|
6175
|
+
private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
|
|
6176
|
+
};
|
|
6177
|
+
var LOOKUP_TIMEOUT_MS = 5e3;
|
|
6178
|
+
var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
|
|
6179
|
+
var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
|
|
6180
|
+
async function assertPublicHost(hostname) {
|
|
6181
|
+
const host = normalizeHost(hostname);
|
|
6182
|
+
if (isBlockedHost(host)) return false;
|
|
6183
|
+
if (parseIPv4(host) || host.includes(":")) return true;
|
|
6184
|
+
try {
|
|
6185
|
+
const addresses = await Promise.race([
|
|
6186
|
+
promises.lookup(host, { all: true }),
|
|
6187
|
+
new Promise(
|
|
6188
|
+
(resolve) => setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.()
|
|
6189
|
+
)
|
|
6190
|
+
]);
|
|
6191
|
+
if (addresses === LOOKUP_TIMED_OUT) return false;
|
|
6192
|
+
if (!Array.isArray(addresses) || addresses.length === 0) return false;
|
|
6193
|
+
return addresses.every((entry) => !isBlockedHost(entry.address));
|
|
6194
|
+
} catch (error) {
|
|
6195
|
+
const code = error?.code;
|
|
6196
|
+
return typeof code === "string" && NONEXISTENT_HOST_CODES.has(code);
|
|
6197
|
+
}
|
|
6198
|
+
}
|
|
6199
|
+
var BlockedRequestError = class extends Error {
|
|
6200
|
+
constructor(reason) {
|
|
6201
|
+
super(`Blocked outbound request: ${reason}`);
|
|
6202
|
+
this.reason = reason;
|
|
6203
|
+
this.name = "BlockedRequestError";
|
|
6204
|
+
}
|
|
6205
|
+
reason;
|
|
6206
|
+
};
|
|
6207
|
+
var MAX_REDIRECTS = 3;
|
|
6208
|
+
async function safeFetch(rawUrl, init) {
|
|
6209
|
+
let current = rawUrl;
|
|
6210
|
+
let body = init.body;
|
|
6211
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
6212
|
+
const validation = validateWebhookUrl(current);
|
|
6213
|
+
if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || "invalid_url");
|
|
6214
|
+
if (!await assertPublicHost(validation.url.hostname)) throw new BlockedRequestError("private_host");
|
|
6215
|
+
const response = await fetch(current, { ...init, body, redirect: "manual" });
|
|
6216
|
+
if (response.status < 300 || response.status > 399) return response;
|
|
6217
|
+
const location = response.headers.get("location");
|
|
6218
|
+
if (!location) return response;
|
|
6219
|
+
current = new URL(location, current).toString();
|
|
6220
|
+
if (response.status === 303) body = void 0;
|
|
6221
|
+
}
|
|
6222
|
+
throw new BlockedRequestError("too_many_redirects");
|
|
6223
|
+
}
|
|
5763
6224
|
|
|
5764
|
-
// src/utils/
|
|
5765
|
-
|
|
6225
|
+
// src/utils/webhookDispatcher.ts
|
|
6226
|
+
function dispatchWebhook(data, event, payload, slugs) {
|
|
6227
|
+
const done = _dispatch(data, event, payload, slugs);
|
|
6228
|
+
return done;
|
|
6229
|
+
}
|
|
6230
|
+
async function _dispatch(data, event, payload, slugs) {
|
|
5766
6231
|
try {
|
|
5767
|
-
const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
|
|
6232
|
+
const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
|
|
5768
6233
|
where: {
|
|
5769
|
-
|
|
5770
|
-
|
|
6234
|
+
and: [
|
|
6235
|
+
{ active: { equals: true } },
|
|
6236
|
+
{ events: { contains: event } }
|
|
6237
|
+
]
|
|
5771
6238
|
},
|
|
5772
6239
|
limit: 50,
|
|
5773
6240
|
depth: 0,
|
|
5774
6241
|
overrideAccess: true
|
|
5775
6242
|
});
|
|
5776
|
-
if (endpoints.
|
|
5777
|
-
const
|
|
6243
|
+
if (endpoints.length === 0) return;
|
|
6244
|
+
const body = JSON.stringify({ event, data, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5778
6245
|
await Promise.allSettled(
|
|
5779
|
-
endpoints.
|
|
5780
|
-
try {
|
|
5781
|
-
const res = await fetch(endpoint.url, {
|
|
5782
|
-
method: "POST",
|
|
5783
|
-
headers: {
|
|
5784
|
-
"Content-Type": "application/json",
|
|
5785
|
-
...endpoint.secret ? { "X-Webhook-Secret": endpoint.secret } : {}
|
|
5786
|
-
},
|
|
5787
|
-
body: JSON.stringify({ event, data, timestamp }),
|
|
5788
|
-
signal: AbortSignal.timeout(1e4)
|
|
5789
|
-
});
|
|
5790
|
-
try {
|
|
5791
|
-
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
5792
|
-
id: endpoint.id,
|
|
5793
|
-
data: {
|
|
5794
|
-
lastTriggeredAt: timestamp,
|
|
5795
|
-
lastStatus: res.status
|
|
5796
|
-
},
|
|
5797
|
-
overrideAccess: true
|
|
5798
|
-
});
|
|
5799
|
-
} catch {
|
|
5800
|
-
}
|
|
5801
|
-
} catch (error) {
|
|
5802
|
-
console.warn(`[support] Webhook delivery failed: ${endpoint.url}`, error);
|
|
5803
|
-
try {
|
|
5804
|
-
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
5805
|
-
id: endpoint.id,
|
|
5806
|
-
data: {
|
|
5807
|
-
lastTriggeredAt: timestamp,
|
|
5808
|
-
lastStatus: 0
|
|
5809
|
-
},
|
|
5810
|
-
overrideAccess: true
|
|
5811
|
-
});
|
|
5812
|
-
} catch {
|
|
5813
|
-
}
|
|
5814
|
-
}
|
|
5815
|
-
})
|
|
6246
|
+
endpoints.map((endpoint) => _sendToEndpoint(endpoint, body, payload, slugs))
|
|
5816
6247
|
);
|
|
5817
|
-
} catch (
|
|
5818
|
-
console.error(
|
|
6248
|
+
} catch (err) {
|
|
6249
|
+
console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err);
|
|
6250
|
+
}
|
|
6251
|
+
}
|
|
6252
|
+
async function _sendToEndpoint(endpoint, body, payload, slugs) {
|
|
6253
|
+
try {
|
|
6254
|
+
const headers = {
|
|
6255
|
+
"Content-Type": "application/json",
|
|
6256
|
+
"User-Agent": "PayloadSupport-Webhook/1.0"
|
|
6257
|
+
};
|
|
6258
|
+
if (endpoint.secret) {
|
|
6259
|
+
const signature = crypto3__default.default.createHmac("sha256", endpoint.secret).update(body).digest("hex");
|
|
6260
|
+
headers["X-Webhook-Signature"] = signature;
|
|
6261
|
+
}
|
|
6262
|
+
const response = await safeFetch(endpoint.url, {
|
|
6263
|
+
method: "POST",
|
|
6264
|
+
headers,
|
|
6265
|
+
body,
|
|
6266
|
+
signal: AbortSignal.timeout(1e4)
|
|
6267
|
+
// 10s timeout
|
|
6268
|
+
});
|
|
6269
|
+
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
6270
|
+
id: endpoint.id,
|
|
6271
|
+
data: {
|
|
6272
|
+
lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6273
|
+
lastStatus: response.status
|
|
6274
|
+
},
|
|
6275
|
+
overrideAccess: true
|
|
6276
|
+
});
|
|
6277
|
+
if (!response.ok) {
|
|
6278
|
+
console.warn(`[webhook] ${endpoint.name || endpoint.url} returned ${response.status}`);
|
|
6279
|
+
}
|
|
6280
|
+
} catch (err) {
|
|
6281
|
+
console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
|
|
6282
|
+
try {
|
|
6283
|
+
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
6284
|
+
id: endpoint.id,
|
|
6285
|
+
data: {
|
|
6286
|
+
lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6287
|
+
lastStatus: 0
|
|
6288
|
+
},
|
|
6289
|
+
overrideAccess: true
|
|
6290
|
+
});
|
|
6291
|
+
} catch {
|
|
6292
|
+
}
|
|
5819
6293
|
}
|
|
5820
6294
|
}
|
|
5821
6295
|
|
|
@@ -5902,13 +6376,18 @@ function createProcessScheduledEndpoint(slugs) {
|
|
|
5902
6376
|
overrideAccess: true
|
|
5903
6377
|
});
|
|
5904
6378
|
}
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
6379
|
+
dispatchWebhook(
|
|
6380
|
+
{
|
|
6381
|
+
ticketId,
|
|
6382
|
+
messageId: msg.id,
|
|
6383
|
+
authorType: msg.authorType,
|
|
6384
|
+
scheduled: true,
|
|
6385
|
+
body: msg.body?.length > 500 ? msg.body.slice(0, 500) + "..." : msg.body
|
|
6386
|
+
},
|
|
6387
|
+
"ticket_replied",
|
|
6388
|
+
payload,
|
|
6389
|
+
slugs
|
|
6390
|
+
);
|
|
5912
6391
|
results.processed++;
|
|
5913
6392
|
} catch (err) {
|
|
5914
6393
|
console.error(`[process-scheduled] Error processing message ${message.id}:`, err);
|
|
@@ -6234,7 +6713,7 @@ function createUserPrefsGetEndpoint(slugs) {
|
|
|
6234
6713
|
requireAdmin(req, slugs);
|
|
6235
6714
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6236
6715
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
6237
|
-
where: { key: { equals: key } },
|
|
6716
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6238
6717
|
limit: 1,
|
|
6239
6718
|
depth: 0,
|
|
6240
6719
|
overrideAccess: true
|
|
@@ -6268,7 +6747,7 @@ function createUserPrefsPostEndpoint(slugs) {
|
|
|
6268
6747
|
const body = await req.json();
|
|
6269
6748
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6270
6749
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
6271
|
-
where: { key: { equals: key } },
|
|
6750
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6272
6751
|
limit: 1,
|
|
6273
6752
|
depth: 0,
|
|
6274
6753
|
overrideAccess: true
|
|
@@ -6424,8 +6903,10 @@ function createTicketFeedbackEndpoint(slugs) {
|
|
|
6424
6903
|
// src/endpoints/transfer-ticket.ts
|
|
6425
6904
|
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6426
6905
|
var MAX_TRANSFERS_PER_DAY = 5;
|
|
6906
|
+
var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
|
|
6427
6907
|
function createTransferTicketEndpoint(slugs, store) {
|
|
6428
|
-
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store);
|
|
6908
|
+
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store, "transfer:ticket");
|
|
6909
|
+
const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_USER_PER_DAY, store, "transfer:user");
|
|
6429
6910
|
return {
|
|
6430
6911
|
path: "/support/tickets/:id/transfer",
|
|
6431
6912
|
method: "post",
|
|
@@ -6460,12 +6941,18 @@ function createTransferTicketEndpoint(slugs, store) {
|
|
|
6460
6941
|
if (!isAdmin && !isOwner) {
|
|
6461
6942
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6462
6943
|
}
|
|
6463
|
-
if (await transferLimiter.check(`${req.user
|
|
6944
|
+
if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
|
|
6464
6945
|
return Response.json(
|
|
6465
6946
|
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
|
|
6466
6947
|
{ status: 429 }
|
|
6467
6948
|
);
|
|
6468
6949
|
}
|
|
6950
|
+
if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
|
|
6951
|
+
return Response.json(
|
|
6952
|
+
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
|
|
6953
|
+
{ status: 429 }
|
|
6954
|
+
);
|
|
6955
|
+
}
|
|
6469
6956
|
try {
|
|
6470
6957
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
|
|
6471
6958
|
const existing = await dbCount(payload, slugs.emailLogs, {
|
|
@@ -6607,8 +7094,11 @@ function statusToLabel(status) {
|
|
|
6607
7094
|
}
|
|
6608
7095
|
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6609
7096
|
var MAX_COLLABORATORS_PER_TICKET = 20;
|
|
7097
|
+
var MAX_NEW_ACCOUNTS_PER_INVITER = 30;
|
|
7098
|
+
var NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
6610
7099
|
function createInviteCollaboratorEndpoint(slugs, store) {
|
|
6611
|
-
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
7100
|
+
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "invite-collaborator");
|
|
7101
|
+
const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, "invite-collaborator:new-account");
|
|
6612
7102
|
return {
|
|
6613
7103
|
path: "/support/tickets/:id/invite",
|
|
6614
7104
|
method: "post",
|
|
@@ -6643,7 +7133,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6643
7133
|
if (!isAdmin && !isOwner) {
|
|
6644
7134
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6645
7135
|
}
|
|
6646
|
-
if (await inviteLimiter.check(
|
|
7136
|
+
if (await inviteLimiter.check(principalRateKey(req.user), req)) {
|
|
6647
7137
|
return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
|
|
6648
7138
|
}
|
|
6649
7139
|
const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
|
|
@@ -6667,6 +7157,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6667
7157
|
if (existing.docs.length > 0) {
|
|
6668
7158
|
inviteeId = existing.docs[0].id;
|
|
6669
7159
|
} else {
|
|
7160
|
+
if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
|
|
7161
|
+
return Response.json(
|
|
7162
|
+
{ error: "Trop de nouveaux comptes invit\xE9s. R\xE9essayez plus tard." },
|
|
7163
|
+
{ status: 429 }
|
|
7164
|
+
);
|
|
7165
|
+
}
|
|
6670
7166
|
const tempPassword = crypto3.randomBytes(16).toString("hex");
|
|
6671
7167
|
const created = await dbCreate(payload, slugs.supportClients, {
|
|
6672
7168
|
data: {
|
|
@@ -6838,7 +7334,7 @@ function createSupportEndpoints(slugs, options) {
|
|
|
6838
7334
|
}
|
|
6839
7335
|
if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
|
|
6840
7336
|
if (!f || f.emailTracking !== false) {
|
|
6841
|
-
endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
|
|
7337
|
+
endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
|
|
6842
7338
|
}
|
|
6843
7339
|
if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
|
|
6844
7340
|
if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
|
|
@@ -6878,74 +7374,6 @@ async function createAdminNotification(payload, data, collectionSlug = "admin-no
|
|
|
6878
7374
|
console.error("[notification] Failed to create:", err);
|
|
6879
7375
|
}
|
|
6880
7376
|
}
|
|
6881
|
-
function dispatchWebhook(data, event, payload, slugs) {
|
|
6882
|
-
void _dispatch(data, event, payload, slugs);
|
|
6883
|
-
}
|
|
6884
|
-
async function _dispatch(data, event, payload, slugs) {
|
|
6885
|
-
try {
|
|
6886
|
-
const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
|
|
6887
|
-
where: {
|
|
6888
|
-
and: [
|
|
6889
|
-
{ active: { equals: true } },
|
|
6890
|
-
{ events: { contains: event } }
|
|
6891
|
-
]
|
|
6892
|
-
},
|
|
6893
|
-
limit: 50,
|
|
6894
|
-
depth: 0,
|
|
6895
|
-
overrideAccess: true
|
|
6896
|
-
});
|
|
6897
|
-
if (endpoints.length === 0) return;
|
|
6898
|
-
const body = JSON.stringify({ event, data, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6899
|
-
for (const endpoint of endpoints) {
|
|
6900
|
-
void _sendToEndpoint(endpoint, body, payload, slugs);
|
|
6901
|
-
}
|
|
6902
|
-
} catch (err) {
|
|
6903
|
-
console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err);
|
|
6904
|
-
}
|
|
6905
|
-
}
|
|
6906
|
-
async function _sendToEndpoint(endpoint, body, payload, slugs) {
|
|
6907
|
-
try {
|
|
6908
|
-
const headers = {
|
|
6909
|
-
"Content-Type": "application/json",
|
|
6910
|
-
"User-Agent": "PayloadSupport-Webhook/1.0"
|
|
6911
|
-
};
|
|
6912
|
-
if (endpoint.secret) {
|
|
6913
|
-
const signature = crypto3__default.default.createHmac("sha256", endpoint.secret).update(body).digest("hex");
|
|
6914
|
-
headers["X-Webhook-Signature"] = signature;
|
|
6915
|
-
}
|
|
6916
|
-
const response = await fetch(endpoint.url, {
|
|
6917
|
-
method: "POST",
|
|
6918
|
-
headers,
|
|
6919
|
-
body,
|
|
6920
|
-
signal: AbortSignal.timeout(1e4)
|
|
6921
|
-
// 10s timeout
|
|
6922
|
-
});
|
|
6923
|
-
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
6924
|
-
id: endpoint.id,
|
|
6925
|
-
data: {
|
|
6926
|
-
lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6927
|
-
lastStatus: response.status
|
|
6928
|
-
},
|
|
6929
|
-
overrideAccess: true
|
|
6930
|
-
});
|
|
6931
|
-
if (!response.ok) {
|
|
6932
|
-
console.warn(`[webhook] ${endpoint.name || endpoint.url} returned ${response.status}`);
|
|
6933
|
-
}
|
|
6934
|
-
} catch (err) {
|
|
6935
|
-
console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
|
|
6936
|
-
try {
|
|
6937
|
-
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
6938
|
-
id: endpoint.id,
|
|
6939
|
-
data: {
|
|
6940
|
-
lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6941
|
-
lastStatus: 0
|
|
6942
|
-
},
|
|
6943
|
-
overrideAccess: true
|
|
6944
|
-
});
|
|
6945
|
-
} catch {
|
|
6946
|
-
}
|
|
6947
|
-
}
|
|
6948
|
-
}
|
|
6949
7377
|
|
|
6950
7378
|
// src/hooks/ticketStatusEmail.ts
|
|
6951
7379
|
var STATUS_LABELS = {
|
|
@@ -7562,12 +7990,28 @@ function createAssignTicketNumber(slugs, options) {
|
|
|
7562
7990
|
}
|
|
7563
7991
|
function createAssignClientOnCreate(slugs) {
|
|
7564
7992
|
return async ({ data, operation, req }) => {
|
|
7565
|
-
if (operation === "create" && req.user?.collection === slugs.supportClients
|
|
7993
|
+
if (operation === "create" && req.user?.collection === slugs.supportClients) {
|
|
7566
7994
|
data.client = req.user.id;
|
|
7567
7995
|
}
|
|
7568
7996
|
return data;
|
|
7569
7997
|
};
|
|
7570
7998
|
}
|
|
7999
|
+
var CLIENT_CREATABLE_TICKET_FIELDS = ["subject", "category", "priority", "project"];
|
|
8000
|
+
function createRestrictClientCreate(slugs) {
|
|
8001
|
+
return async ({ data, operation, req }) => {
|
|
8002
|
+
if (operation !== "create") return data;
|
|
8003
|
+
if (req.user?.collection !== slugs.supportClients) return data;
|
|
8004
|
+
const incoming = data ?? {};
|
|
8005
|
+
const sanitized = {};
|
|
8006
|
+
for (const field2 of CLIENT_CREATABLE_TICKET_FIELDS) {
|
|
8007
|
+
if (incoming[field2] !== void 0) sanitized[field2] = incoming[field2];
|
|
8008
|
+
}
|
|
8009
|
+
sanitized.status = "open";
|
|
8010
|
+
sanitized.client = req.user.id;
|
|
8011
|
+
sanitized.source = "portal";
|
|
8012
|
+
return sanitized;
|
|
8013
|
+
};
|
|
8014
|
+
}
|
|
7571
8015
|
var autoPaidAt = async ({ data, operation, originalDoc }) => {
|
|
7572
8016
|
if (operation !== "update") return data;
|
|
7573
8017
|
if (data.paymentStatus === "paid" && originalDoc?.paymentStatus !== "paid" && !data.paidAt) {
|
|
@@ -7593,16 +8037,8 @@ function createAutoAssignAdmin(slugs) {
|
|
|
7593
8037
|
const { payload } = req;
|
|
7594
8038
|
let roundRobinEnabled = false;
|
|
7595
8039
|
try {
|
|
7596
|
-
const
|
|
7597
|
-
|
|
7598
|
-
where: { key: { equals: "support-round-robin" } },
|
|
7599
|
-
limit: 1,
|
|
7600
|
-
depth: 0,
|
|
7601
|
-
overrideAccess: true
|
|
7602
|
-
});
|
|
7603
|
-
if (prefs.docs.length > 0) {
|
|
7604
|
-
roundRobinEnabled = prefs.docs[0].value?.enabled === true;
|
|
7605
|
-
}
|
|
8040
|
+
const settings = await readSupportSettings(payload);
|
|
8041
|
+
roundRobinEnabled = settings.features.roundRobin === true;
|
|
7606
8042
|
} catch (err) {
|
|
7607
8043
|
console.warn("[support] Failed to read round-robin preferences:", err);
|
|
7608
8044
|
}
|
|
@@ -7811,54 +8247,6 @@ function createNotifyClientOnResolve(slugs) {
|
|
|
7811
8247
|
return doc;
|
|
7812
8248
|
};
|
|
7813
8249
|
}
|
|
7814
|
-
function createFireTicketWebhooks(slugs) {
|
|
7815
|
-
return async ({ doc, previousDoc, operation, req }) => {
|
|
7816
|
-
const { payload } = req;
|
|
7817
|
-
if (operation === "create") {
|
|
7818
|
-
fireWebhooks(payload, slugs, "ticket_created", {
|
|
7819
|
-
id: doc.id,
|
|
7820
|
-
ticketNumber: doc.ticketNumber,
|
|
7821
|
-
subject: doc.subject,
|
|
7822
|
-
status: doc.status,
|
|
7823
|
-
priority: doc.priority,
|
|
7824
|
-
category: doc.category
|
|
7825
|
-
});
|
|
7826
|
-
return doc;
|
|
7827
|
-
}
|
|
7828
|
-
if (operation === "update" && previousDoc) {
|
|
7829
|
-
if (previousDoc.status !== doc.status && doc.status === "resolved") {
|
|
7830
|
-
fireWebhooks(payload, slugs, "ticket_resolved", {
|
|
7831
|
-
id: doc.id,
|
|
7832
|
-
ticketNumber: doc.ticketNumber,
|
|
7833
|
-
subject: doc.subject,
|
|
7834
|
-
previousStatus: previousDoc.status
|
|
7835
|
-
});
|
|
7836
|
-
const clientId = typeof doc.client === "object" ? doc.client?.id : doc.client;
|
|
7837
|
-
if (clientId) {
|
|
7838
|
-
payload.update({
|
|
7839
|
-
collection: "client-summaries",
|
|
7840
|
-
where: { client: { equals: clientId } },
|
|
7841
|
-
data: { generatedAt: (/* @__PURE__ */ new Date(0)).toISOString() },
|
|
7842
|
-
// Force cache expiry
|
|
7843
|
-
overrideAccess: true
|
|
7844
|
-
}).catch(() => {
|
|
7845
|
-
});
|
|
7846
|
-
}
|
|
7847
|
-
}
|
|
7848
|
-
const oldAssigned = typeof previousDoc.assignedTo === "object" ? previousDoc.assignedTo?.id : previousDoc.assignedTo;
|
|
7849
|
-
const newAssigned = typeof doc.assignedTo === "object" ? doc.assignedTo?.id : doc.assignedTo;
|
|
7850
|
-
if (newAssigned && oldAssigned !== newAssigned) {
|
|
7851
|
-
fireWebhooks(payload, slugs, "ticket_assigned", {
|
|
7852
|
-
id: doc.id,
|
|
7853
|
-
ticketNumber: doc.ticketNumber,
|
|
7854
|
-
subject: doc.subject,
|
|
7855
|
-
assignedTo: newAssigned
|
|
7856
|
-
});
|
|
7857
|
-
}
|
|
7858
|
-
}
|
|
7859
|
-
return doc;
|
|
7860
|
-
};
|
|
7861
|
-
}
|
|
7862
8250
|
function createNotifyAdminOnNewTicket(slugs, notificationSlug) {
|
|
7863
8251
|
return async ({ doc, operation, req }) => {
|
|
7864
8252
|
if (operation !== "create") return doc;
|
|
@@ -7879,21 +8267,66 @@ function createNotifyAdminOnNewTicket(slugs, notificationSlug) {
|
|
|
7879
8267
|
}
|
|
7880
8268
|
function createDispatchWebhookOnTicket(slugs) {
|
|
7881
8269
|
return async ({ doc, previousDoc, operation, req }) => {
|
|
8270
|
+
const { payload } = req;
|
|
7882
8271
|
if (operation === "create") {
|
|
7883
8272
|
dispatchWebhook(
|
|
7884
|
-
{
|
|
8273
|
+
{
|
|
8274
|
+
ticketId: doc.id,
|
|
8275
|
+
id: doc.id,
|
|
8276
|
+
ticketNumber: doc.ticketNumber,
|
|
8277
|
+
subject: doc.subject,
|
|
8278
|
+
status: doc.status,
|
|
8279
|
+
priority: doc.priority,
|
|
8280
|
+
category: doc.category
|
|
8281
|
+
},
|
|
7885
8282
|
"ticket_created",
|
|
7886
|
-
|
|
8283
|
+
payload,
|
|
7887
8284
|
slugs
|
|
7888
8285
|
);
|
|
8286
|
+
return doc;
|
|
7889
8287
|
}
|
|
7890
|
-
if (operation === "update" && previousDoc
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
8288
|
+
if (operation === "update" && previousDoc) {
|
|
8289
|
+
if (previousDoc.status !== doc.status && doc.status === "resolved") {
|
|
8290
|
+
dispatchWebhook(
|
|
8291
|
+
{
|
|
8292
|
+
ticketId: doc.id,
|
|
8293
|
+
id: doc.id,
|
|
8294
|
+
ticketNumber: doc.ticketNumber,
|
|
8295
|
+
subject: doc.subject,
|
|
8296
|
+
previousStatus: previousDoc.status
|
|
8297
|
+
},
|
|
8298
|
+
"ticket_resolved",
|
|
8299
|
+
payload,
|
|
8300
|
+
slugs
|
|
8301
|
+
);
|
|
8302
|
+
const clientId = typeof doc.client === "object" ? doc.client?.id : doc.client;
|
|
8303
|
+
if (clientId) {
|
|
8304
|
+
payload.update({
|
|
8305
|
+
collection: "client-summaries",
|
|
8306
|
+
where: { client: { equals: clientId } },
|
|
8307
|
+
data: { generatedAt: (/* @__PURE__ */ new Date(0)).toISOString() },
|
|
8308
|
+
// Force cache expiry
|
|
8309
|
+
overrideAccess: true
|
|
8310
|
+
}).catch(() => {
|
|
8311
|
+
});
|
|
8312
|
+
}
|
|
8313
|
+
}
|
|
8314
|
+
const oldAssigned = typeof previousDoc.assignedTo === "object" ? previousDoc.assignedTo?.id : previousDoc.assignedTo;
|
|
8315
|
+
const newAssigned = typeof doc.assignedTo === "object" ? doc.assignedTo?.id : doc.assignedTo;
|
|
8316
|
+
if (newAssigned && oldAssigned !== newAssigned) {
|
|
8317
|
+
dispatchWebhook(
|
|
8318
|
+
{
|
|
8319
|
+
ticketId: doc.id,
|
|
8320
|
+
id: doc.id,
|
|
8321
|
+
ticketNumber: doc.ticketNumber,
|
|
8322
|
+
subject: doc.subject,
|
|
8323
|
+
assignedTo: newAssigned
|
|
8324
|
+
},
|
|
8325
|
+
"ticket_assigned",
|
|
8326
|
+
payload,
|
|
8327
|
+
slugs
|
|
8328
|
+
);
|
|
8329
|
+
}
|
|
7897
8330
|
}
|
|
7898
8331
|
return doc;
|
|
7899
8332
|
};
|
|
@@ -8019,7 +8452,10 @@ function createTicketsCollection(slugs, options) {
|
|
|
8019
8452
|
{ label: "Paiement partiel", value: "partial" },
|
|
8020
8453
|
{ label: "Pay\xE9", value: "paid" }
|
|
8021
8454
|
],
|
|
8022
|
-
|
|
8455
|
+
// `slugs.users`, not the literal 'users': the users collection slug is
|
|
8456
|
+
// configurable, and a hard-coded one silently opens this field to everybody
|
|
8457
|
+
// on a host that renamed it.
|
|
8458
|
+
access: { update: ({ req }) => req.user?.collection === slugs.users },
|
|
8023
8459
|
admin: { width: "50%", condition: (data) => !!data?.invoice }
|
|
8024
8460
|
},
|
|
8025
8461
|
{
|
|
@@ -8301,6 +8737,7 @@ function createTicketsCollection(slugs, options) {
|
|
|
8301
8737
|
{ name: "totalTimeMinutes", type: "number", defaultValue: 0, label: "Temps total (minutes)", admin: { readOnly: true, position: "sidebar" } }
|
|
8302
8738
|
],
|
|
8303
8739
|
hooks: {
|
|
8740
|
+
beforeValidate: [createRestrictClientCreate(slugs)],
|
|
8304
8741
|
beforeChange: [
|
|
8305
8742
|
createAssignTicketNumber(slugs, options?.ticketNumber),
|
|
8306
8743
|
createAssignClientOnCreate(slugs),
|
|
@@ -8321,7 +8758,6 @@ function createTicketsCollection(slugs, options) {
|
|
|
8321
8758
|
createNotifyClientOnResolve(slugs),
|
|
8322
8759
|
createTicketStatusEmail(slugs),
|
|
8323
8760
|
createNotifyAdminOnNewTicket(slugs, notificationSlug),
|
|
8324
|
-
createFireTicketWebhooks(slugs),
|
|
8325
8761
|
createDispatchWebhookOnTicket(slugs),
|
|
8326
8762
|
createApplyAutomationRules(slugs)
|
|
8327
8763
|
],
|
|
@@ -8383,7 +8819,7 @@ function createTicketsCollection(slugs, options) {
|
|
|
8383
8819
|
}
|
|
8384
8820
|
|
|
8385
8821
|
// src/utils/ticketAccess.ts
|
|
8386
|
-
async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
8822
|
+
async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
|
|
8387
8823
|
const ids = /* @__PURE__ */ new Set();
|
|
8388
8824
|
try {
|
|
8389
8825
|
const owned = await dbFind(payload, slugs.tickets, {
|
|
@@ -8404,6 +8840,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
|
8404
8840
|
});
|
|
8405
8841
|
for (const r of collab.docs) {
|
|
8406
8842
|
const row = r;
|
|
8843
|
+
if (mode === "write" && row.role !== "collaborator") continue;
|
|
8407
8844
|
const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
|
|
8408
8845
|
if (tid !== void 0 && tid !== null) ids.add(tid);
|
|
8409
8846
|
}
|
|
@@ -8502,6 +8939,21 @@ async function queueClientNotification(payload, slugs, item) {
|
|
|
8502
8939
|
}
|
|
8503
8940
|
|
|
8504
8941
|
// src/collections/TicketMessages.ts
|
|
8942
|
+
function createRestrictClientTicketTarget(slugs) {
|
|
8943
|
+
return async ({ data, operation, req, originalDoc }) => {
|
|
8944
|
+
if (req.user?.collection !== slugs.supportClients) return data;
|
|
8945
|
+
const raw = data.ticket ?? (operation === "update" ? originalDoc?.ticket : void 0);
|
|
8946
|
+
const targetId = raw && typeof raw === "object" ? raw.id : raw;
|
|
8947
|
+
if (targetId === void 0 || targetId === null || targetId === "") {
|
|
8948
|
+
throw new payload.APIError("Ticket cible requis.", 400);
|
|
8949
|
+
}
|
|
8950
|
+
const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
|
|
8951
|
+
if (!accessible.some((id) => String(id) === String(targetId))) {
|
|
8952
|
+
throw new payload.APIError("Ticket inaccessible.", 403);
|
|
8953
|
+
}
|
|
8954
|
+
return data;
|
|
8955
|
+
};
|
|
8956
|
+
}
|
|
8505
8957
|
function createAssignAuthor(slugs) {
|
|
8506
8958
|
return async ({ data, operation, req }) => {
|
|
8507
8959
|
if (operation === "create" && req.user?.collection === slugs.supportClients) {
|
|
@@ -8736,21 +9188,6 @@ function createNotifyAdminOnClientMessage(slugs, notificationSlug) {
|
|
|
8736
9188
|
return doc;
|
|
8737
9189
|
};
|
|
8738
9190
|
}
|
|
8739
|
-
function createFireMessageWebhooks(slugs) {
|
|
8740
|
-
return async ({ doc, operation, req }) => {
|
|
8741
|
-
if (operation !== "create") return doc;
|
|
8742
|
-
if (doc.scheduledAt && !doc.scheduledSent) return doc;
|
|
8743
|
-
if (doc.isInternal) return doc;
|
|
8744
|
-
const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
|
|
8745
|
-
fireWebhooks(req.payload, slugs, "ticket_replied", {
|
|
8746
|
-
ticketId,
|
|
8747
|
-
messageId: doc.id,
|
|
8748
|
-
authorType: doc.authorType,
|
|
8749
|
-
body: doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body
|
|
8750
|
-
});
|
|
8751
|
-
return doc;
|
|
8752
|
-
};
|
|
8753
|
-
}
|
|
8754
9191
|
function createDispatchWebhookOnReply(slugs) {
|
|
8755
9192
|
return async ({ doc, operation, req }) => {
|
|
8756
9193
|
if (operation !== "create") return doc;
|
|
@@ -8758,7 +9195,12 @@ function createDispatchWebhookOnReply(slugs) {
|
|
|
8758
9195
|
if (doc.scheduledAt && !doc.scheduledSent) return doc;
|
|
8759
9196
|
const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
|
|
8760
9197
|
dispatchWebhook(
|
|
8761
|
-
{
|
|
9198
|
+
{
|
|
9199
|
+
ticketId,
|
|
9200
|
+
messageId: doc.id,
|
|
9201
|
+
authorType: doc.authorType,
|
|
9202
|
+
body: doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body
|
|
9203
|
+
},
|
|
8762
9204
|
"ticket_replied",
|
|
8763
9205
|
req.payload,
|
|
8764
9206
|
slugs
|
|
@@ -8941,7 +9383,7 @@ function createTicketMessagesCollection(slugs, options) {
|
|
|
8941
9383
|
] : []
|
|
8942
9384
|
],
|
|
8943
9385
|
hooks: {
|
|
8944
|
-
beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
|
|
9386
|
+
beforeChange: [createRestrictClientTicketTarget(slugs), createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
|
|
8945
9387
|
afterChange: [
|
|
8946
9388
|
...options?.capabilities?.sms ? [createSmsNotification(slugs, options.capabilities.sms)] : [],
|
|
8947
9389
|
createAutoUpdateStatus(slugs),
|
|
@@ -8950,7 +9392,6 @@ function createTicketMessagesCollection(slugs, options) {
|
|
|
8950
9392
|
createCheckSlaOnReply(slugs, notificationSlug),
|
|
8951
9393
|
createSyncTicketReplyToChat(slugs),
|
|
8952
9394
|
createNotifyAdminOnClientMessage(slugs, notificationSlug),
|
|
8953
|
-
createFireMessageWebhooks(slugs),
|
|
8954
9395
|
createDispatchWebhookOnReply(slugs),
|
|
8955
9396
|
createNotifyMentions(slugs),
|
|
8956
9397
|
...options?.capabilities?.threadCleanup ? [createThreadCleanup(options.capabilities.threadCleanup)] : []
|
|
@@ -9023,13 +9464,13 @@ function createSendInvitationOnCreate(slugs) {
|
|
|
9023
9464
|
return doc;
|
|
9024
9465
|
};
|
|
9025
9466
|
}
|
|
9026
|
-
var
|
|
9467
|
+
var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
|
|
9027
9468
|
function createEnforce2FA(slugs) {
|
|
9028
9469
|
return async ({ req, user }) => {
|
|
9029
9470
|
if (!user?.twoFactorEnabled) return user;
|
|
9030
9471
|
const raw = user.twoFactorVerifiedAt;
|
|
9031
9472
|
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
9032
|
-
if (!(verifiedAt > Date.now() -
|
|
9473
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
|
|
9033
9474
|
throw new payload.APIError("2FA_REQUIRED", 401);
|
|
9034
9475
|
}
|
|
9035
9476
|
await req.payload.update({
|
|
@@ -9043,7 +9484,11 @@ function createEnforce2FA(slugs) {
|
|
|
9043
9484
|
return user;
|
|
9044
9485
|
};
|
|
9045
9486
|
}
|
|
9487
|
+
function createAdminOnlyFieldUpdate(slugs) {
|
|
9488
|
+
return ({ req }) => req.user?.collection === slugs.users;
|
|
9489
|
+
}
|
|
9046
9490
|
function createSupportClientsCollection(slugs, options) {
|
|
9491
|
+
const adminOnlyFieldUpdate = createAdminOnlyFieldUpdate(slugs);
|
|
9047
9492
|
return {
|
|
9048
9493
|
slug: slugs.supportClients,
|
|
9049
9494
|
labels: {
|
|
@@ -9173,7 +9618,8 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9173
9618
|
label: "Opportunites commerciales",
|
|
9174
9619
|
access: {
|
|
9175
9620
|
// Admin-only: never serialize commercial notes onto the client's own doc.
|
|
9176
|
-
read: ({ req }) => req.user?.collection === slugs.users
|
|
9621
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
9622
|
+
update: adminOnlyFieldUpdate
|
|
9177
9623
|
},
|
|
9178
9624
|
admin: {
|
|
9179
9625
|
description: "Notes commerciales (visible uniquement par les admins)"
|
|
@@ -9183,6 +9629,7 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9183
9629
|
name: "tier",
|
|
9184
9630
|
type: "select",
|
|
9185
9631
|
label: "Plan",
|
|
9632
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9186
9633
|
options: [
|
|
9187
9634
|
{ label: "Free", value: "free" },
|
|
9188
9635
|
{ label: "Basic", value: "basic" },
|
|
@@ -9196,6 +9643,7 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9196
9643
|
type: "relationship",
|
|
9197
9644
|
relationTo: slugs.users,
|
|
9198
9645
|
label: "Agent referent",
|
|
9646
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9199
9647
|
admin: { position: "sidebar" }
|
|
9200
9648
|
},
|
|
9201
9649
|
{
|
|
@@ -9211,17 +9659,20 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9211
9659
|
{
|
|
9212
9660
|
name: "twoFactorCode",
|
|
9213
9661
|
type: "text",
|
|
9662
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9214
9663
|
admin: { hidden: true }
|
|
9215
9664
|
},
|
|
9216
9665
|
{
|
|
9217
9666
|
name: "twoFactorExpiry",
|
|
9218
9667
|
type: "date",
|
|
9668
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9219
9669
|
admin: { hidden: true }
|
|
9220
9670
|
},
|
|
9221
9671
|
{
|
|
9222
9672
|
// Set by /support/2fa "verify"; gates login via the beforeLogin hook.
|
|
9223
9673
|
name: "twoFactorVerifiedAt",
|
|
9224
9674
|
type: "date",
|
|
9675
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9225
9676
|
admin: { hidden: true }
|
|
9226
9677
|
},
|
|
9227
9678
|
{
|
|
@@ -9229,6 +9680,7 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9229
9680
|
name: "googleId",
|
|
9230
9681
|
type: "text",
|
|
9231
9682
|
index: true,
|
|
9683
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9232
9684
|
admin: { hidden: true }
|
|
9233
9685
|
},
|
|
9234
9686
|
{
|
|
@@ -9299,7 +9751,8 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9299
9751
|
label: "Notes internes",
|
|
9300
9752
|
access: {
|
|
9301
9753
|
// Admin-only: never serialize internal notes onto the client's own doc.
|
|
9302
|
-
read: ({ req }) => req.user?.collection === slugs.users
|
|
9754
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
9755
|
+
update: adminOnlyFieldUpdate
|
|
9303
9756
|
},
|
|
9304
9757
|
admin: {
|
|
9305
9758
|
description: "Visible uniquement par les admins",
|
|
@@ -9372,36 +9825,49 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9372
9825
|
}
|
|
9373
9826
|
|
|
9374
9827
|
// src/collections/TimeEntries.ts
|
|
9828
|
+
async function recalculateTicketTime(payload, slugs, ticketId) {
|
|
9829
|
+
let totalMinutes = 0;
|
|
9830
|
+
let page = 1;
|
|
9831
|
+
let hasMore = true;
|
|
9832
|
+
while (hasMore) {
|
|
9833
|
+
const entries = await payload.find({
|
|
9834
|
+
collection: slugs.timeEntries,
|
|
9835
|
+
where: { ticket: { equals: ticketId } },
|
|
9836
|
+
limit: 100,
|
|
9837
|
+
page,
|
|
9838
|
+
depth: 0,
|
|
9839
|
+
overrideAccess: true,
|
|
9840
|
+
select: { duration: true }
|
|
9841
|
+
});
|
|
9842
|
+
for (const entry of entries.docs) {
|
|
9843
|
+
totalMinutes += entry.duration || 0;
|
|
9844
|
+
}
|
|
9845
|
+
hasMore = entries.hasNextPage ?? false;
|
|
9846
|
+
page++;
|
|
9847
|
+
}
|
|
9848
|
+
await payload.update({
|
|
9849
|
+
collection: slugs.tickets,
|
|
9850
|
+
id: ticketId,
|
|
9851
|
+
data: { totalTimeMinutes: totalMinutes },
|
|
9852
|
+
overrideAccess: true
|
|
9853
|
+
});
|
|
9854
|
+
}
|
|
9375
9855
|
function createRecalculateTicketTime(slugs) {
|
|
9376
9856
|
return async ({ doc, req }) => {
|
|
9377
9857
|
if (!doc.ticket) return;
|
|
9378
|
-
const { payload } = req;
|
|
9379
9858
|
const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
|
|
9380
|
-
|
|
9381
|
-
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
|
|
9385
|
-
|
|
9386
|
-
|
|
9387
|
-
|
|
9388
|
-
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
select: { duration: true }
|
|
9392
|
-
});
|
|
9393
|
-
for (const entry of entries.docs) {
|
|
9394
|
-
totalMinutes += entry.duration || 0;
|
|
9395
|
-
}
|
|
9396
|
-
hasMore = entries.hasNextPage ?? false;
|
|
9397
|
-
page++;
|
|
9859
|
+
await recalculateTicketTime(req.payload, slugs, ticketId);
|
|
9860
|
+
};
|
|
9861
|
+
}
|
|
9862
|
+
function createRecalculateTicketTimeOnDelete(slugs) {
|
|
9863
|
+
return async ({ doc, req }) => {
|
|
9864
|
+
if (!doc?.ticket) return;
|
|
9865
|
+
const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
|
|
9866
|
+
try {
|
|
9867
|
+
await recalculateTicketTime(req.payload, slugs, ticketId);
|
|
9868
|
+
} catch (err) {
|
|
9869
|
+
console.error("[support] Failed to recalculate ticket time after delete:", err);
|
|
9398
9870
|
}
|
|
9399
|
-
await payload.update({
|
|
9400
|
-
collection: slugs.tickets,
|
|
9401
|
-
id: ticketId,
|
|
9402
|
-
data: { totalTimeMinutes: totalMinutes },
|
|
9403
|
-
overrideAccess: true
|
|
9404
|
-
});
|
|
9405
9871
|
};
|
|
9406
9872
|
}
|
|
9407
9873
|
function createTimeEntriesCollection(slugs) {
|
|
@@ -9463,7 +9929,8 @@ function createTimeEntriesCollection(slugs) {
|
|
|
9463
9929
|
}
|
|
9464
9930
|
],
|
|
9465
9931
|
hooks: {
|
|
9466
|
-
afterChange: [createRecalculateTicketTime(slugs)]
|
|
9932
|
+
afterChange: [createRecalculateTicketTime(slugs)],
|
|
9933
|
+
afterDelete: [createRecalculateTicketTimeOnDelete(slugs)]
|
|
9467
9934
|
},
|
|
9468
9935
|
access: {
|
|
9469
9936
|
create: ({ req }) => req.user?.collection === slugs.users,
|
|
@@ -9844,8 +10311,28 @@ function createKnowledgeBaseCollection(slugs) {
|
|
|
9844
10311
|
timestamps: true
|
|
9845
10312
|
};
|
|
9846
10313
|
}
|
|
9847
|
-
|
|
9848
|
-
|
|
10314
|
+
function createRestrictClientChatWrite(slugs) {
|
|
10315
|
+
return async ({ data, req }) => {
|
|
10316
|
+
if (req.user?.collection !== slugs.supportClients) return data;
|
|
10317
|
+
data.client = req.user.id;
|
|
10318
|
+
data.senderType = "client";
|
|
10319
|
+
delete data.agent;
|
|
10320
|
+
const session = typeof data.session === "string" ? data.session : null;
|
|
10321
|
+
if (!session) return data;
|
|
10322
|
+
const existing = await dbFind(req.payload, slugs.chatMessages, {
|
|
10323
|
+
where: { session: { equals: session } },
|
|
10324
|
+
limit: 1,
|
|
10325
|
+
depth: 0,
|
|
10326
|
+
overrideAccess: true
|
|
10327
|
+
});
|
|
10328
|
+
const owner = existing.docs[0]?.client;
|
|
10329
|
+
const ownerId = owner && typeof owner === "object" ? owner.id : owner;
|
|
10330
|
+
if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== String(req.user.id)) {
|
|
10331
|
+
throw new payload.APIError("Session inaccessible.", 403);
|
|
10332
|
+
}
|
|
10333
|
+
return data;
|
|
10334
|
+
};
|
|
10335
|
+
}
|
|
9849
10336
|
function createChatMessagesCollection(slugs) {
|
|
9850
10337
|
return {
|
|
9851
10338
|
slug: slugs.chatMessages,
|
|
@@ -9867,7 +10354,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9867
10354
|
}
|
|
9868
10355
|
return false;
|
|
9869
10356
|
},
|
|
9870
|
-
|
|
10357
|
+
// Staff and support-clients only — NOT "any authenticated principal":
|
|
10358
|
+
// a user of any other auth collection of the host app satisfied `!!req.user`.
|
|
10359
|
+
create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
|
|
9871
10360
|
update: ({ req }) => req.user?.collection === slugs.users,
|
|
9872
10361
|
delete: ({ req }) => req.user?.collection === slugs.users
|
|
9873
10362
|
},
|
|
@@ -9936,6 +10425,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9936
10425
|
}
|
|
9937
10426
|
}
|
|
9938
10427
|
],
|
|
10428
|
+
hooks: {
|
|
10429
|
+
beforeChange: [createRestrictClientChatWrite(slugs)]
|
|
10430
|
+
},
|
|
9939
10431
|
timestamps: true
|
|
9940
10432
|
};
|
|
9941
10433
|
}
|
|
@@ -10213,8 +10705,19 @@ function createAuthLogsCollection(slugs) {
|
|
|
10213
10705
|
timestamps: true
|
|
10214
10706
|
};
|
|
10215
10707
|
}
|
|
10216
|
-
|
|
10217
|
-
|
|
10708
|
+
function createValidateWebhookUrl() {
|
|
10709
|
+
return ({ data, operation, originalDoc }) => {
|
|
10710
|
+
const incoming = data?.url;
|
|
10711
|
+
if (incoming === void 0 || incoming === null) return data;
|
|
10712
|
+
const previous = originalDoc?.url;
|
|
10713
|
+
if (operation === "update" && incoming === previous) return data;
|
|
10714
|
+
const result = validateWebhookUrl(incoming);
|
|
10715
|
+
if (!result.ok) {
|
|
10716
|
+
throw new payload.APIError(WEBHOOK_URL_MESSAGES[result.reason || "invalid_url"], 400);
|
|
10717
|
+
}
|
|
10718
|
+
return data;
|
|
10719
|
+
};
|
|
10720
|
+
}
|
|
10218
10721
|
function createWebhookEndpointsCollection(slugs) {
|
|
10219
10722
|
return {
|
|
10220
10723
|
slug: slugs.webhookEndpoints,
|
|
@@ -10248,8 +10751,11 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10248
10751
|
type: "text",
|
|
10249
10752
|
required: true,
|
|
10250
10753
|
label: "URL",
|
|
10754
|
+
// The SSRF check lives in the collection `beforeValidate` above, NOT in a
|
|
10755
|
+
// field `validate`: the latter re-runs on the merged document and would
|
|
10756
|
+
// freeze every pre-existing row on any unrelated edit.
|
|
10251
10757
|
admin: {
|
|
10252
|
-
description: "URL du webhook \xE0 appeler (POST)"
|
|
10758
|
+
description: "URL https:// du webhook \xE0 appeler (POST). Les adresses priv\xE9es et loopback sont refus\xE9es."
|
|
10253
10759
|
}
|
|
10254
10760
|
},
|
|
10255
10761
|
{
|
|
@@ -10303,6 +10809,9 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10303
10809
|
}
|
|
10304
10810
|
}
|
|
10305
10811
|
],
|
|
10812
|
+
hooks: {
|
|
10813
|
+
beforeValidate: [createValidateWebhookUrl()]
|
|
10814
|
+
},
|
|
10306
10815
|
timestamps: true
|
|
10307
10816
|
};
|
|
10308
10817
|
}
|
|
@@ -10754,11 +11263,17 @@ function createClientSummariesCollection(slugs) {
|
|
|
10754
11263
|
admin: { readOnly: true }
|
|
10755
11264
|
}
|
|
10756
11265
|
],
|
|
11266
|
+
// Staff-only, on the SAME source of truth as every other collection and as
|
|
11267
|
+
// `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
|
|
11268
|
+
// against is the DEFAULT slug, not the configured one — on a host app whose
|
|
11269
|
+
// staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
|
|
11270
|
+
// the front-office collection instead, opening read/create/update/delete on
|
|
11271
|
+
// AI-generated client intelligence to it while locking the real agents out.
|
|
10757
11272
|
access: {
|
|
10758
|
-
create: ({ req }) => req.user?.collection ===
|
|
10759
|
-
read: ({ req }) => req.user?.collection ===
|
|
10760
|
-
update: ({ req }) => req.user?.collection ===
|
|
10761
|
-
delete: ({ req }) => req.user?.collection ===
|
|
11273
|
+
create: ({ req }) => req.user?.collection === slugs.users,
|
|
11274
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
11275
|
+
update: ({ req }) => req.user?.collection === slugs.users,
|
|
11276
|
+
delete: ({ req }) => req.user?.collection === slugs.users
|
|
10762
11277
|
},
|
|
10763
11278
|
timestamps: true
|
|
10764
11279
|
};
|
|
@@ -10889,7 +11404,12 @@ function createTicketCollaboratorsCollection(slugs) {
|
|
|
10889
11404
|
}
|
|
10890
11405
|
return false;
|
|
10891
11406
|
},
|
|
10892
|
-
|
|
11407
|
+
// Staff only. This collection is the source of truth for
|
|
11408
|
+
// resolveAccessibleTicketIds(), which widens `read` on tickets, messages
|
|
11409
|
+
// and the activity log — so letting any authenticated principal POST here
|
|
11410
|
+
// let a support client grant themselves access to any ticket by id.
|
|
11411
|
+
// The /invite endpoint is unaffected: it writes with overrideAccess: true.
|
|
11412
|
+
create: ({ req }) => req.user?.collection === slugs.users,
|
|
10893
11413
|
update: () => false,
|
|
10894
11414
|
delete: ({ req }) => req.user?.collection === slugs.users
|
|
10895
11415
|
},
|
|
@@ -11217,6 +11737,17 @@ function supportPlugin(config) {
|
|
|
11217
11737
|
});
|
|
11218
11738
|
return {
|
|
11219
11739
|
...incomingConfig,
|
|
11740
|
+
// Publish the resolved staff collection so the server-side readers share
|
|
11741
|
+
// ONE source of truth with the writers. `requireAdmin` compares against
|
|
11742
|
+
// `slugs.users`; the `payload-preferences` reads used to scope themselves
|
|
11743
|
+
// on `config.admin.user`, which Payload silently defaults to the first
|
|
11744
|
+
// auth collection of the host app — a different collection on any app
|
|
11745
|
+
// that declares `collectionSlugs.users`, and the settings-poisoning hole
|
|
11746
|
+
// reopened right there.
|
|
11747
|
+
custom: {
|
|
11748
|
+
...incomingConfig.custom,
|
|
11749
|
+
[SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users
|
|
11750
|
+
},
|
|
11220
11751
|
collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
|
|
11221
11752
|
endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
|
|
11222
11753
|
admin: {
|
|
@@ -11234,6 +11765,7 @@ exports.DEFAULT_FEATURES = DEFAULT_FEATURES;
|
|
|
11234
11765
|
exports.DEFAULT_INBOUND_EMAIL_LIMITS = DEFAULT_INBOUND_EMAIL_LIMITS;
|
|
11235
11766
|
exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
|
|
11236
11767
|
exports.DEFAULT_SLUGS = DEFAULT_SLUGS;
|
|
11768
|
+
exports.DEFAULT_TICKETING_FEATURES = DEFAULT_TICKETING_FEATURES;
|
|
11237
11769
|
exports.DEFAULT_USER_PREFS = DEFAULT_USER_PREFS;
|
|
11238
11770
|
exports.MemoryRateLimitStore = MemoryRateLimitStore;
|
|
11239
11771
|
exports.PayloadRateLimitStore = PayloadRateLimitStore;
|
|
@@ -11266,9 +11798,13 @@ exports.createTrackOpenEndpoint = createTrackOpenEndpoint;
|
|
|
11266
11798
|
exports.createWebhookEndpointsCollection = createWebhookEndpointsCollection;
|
|
11267
11799
|
exports.dispatchWebhook = dispatchWebhook;
|
|
11268
11800
|
exports.generateTicketSynthesis = generateTicketSynthesis;
|
|
11801
|
+
exports.normalizeFeatures = normalizeFeatures;
|
|
11802
|
+
exports.projectAutoClose = projectAutoClose;
|
|
11269
11803
|
exports.readSupportSettings = readSupportSettings;
|
|
11804
|
+
exports.readSupportSettingsState = readSupportSettingsState;
|
|
11270
11805
|
exports.readUserPrefs = readUserPrefs;
|
|
11271
11806
|
exports.resolveSlugs = resolveSlugs;
|
|
11807
|
+
exports.stripProjectedFeatures = stripProjectedFeatures;
|
|
11272
11808
|
exports.supportPlugin = supportPlugin;
|
|
11273
11809
|
exports.validateInboundEmailPayload = validateInboundEmailPayload;
|
|
11274
11810
|
exports.verifySecret = verifySecret;
|