@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.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { initTransaction, commitTransaction, killTransaction, getFieldsToSign, jwtSign, APIError } from 'payload';
|
|
2
2
|
import crypto3, { createHash, timingSafeEqual, createHmac, randomBytes } from 'crypto';
|
|
3
3
|
import PDFDocument from 'pdfkit';
|
|
4
|
+
import { lookup } from 'dns/promises';
|
|
4
5
|
import webpush from 'web-push';
|
|
5
6
|
import sanitizeHtml from 'sanitize-html';
|
|
6
7
|
|
|
@@ -158,22 +159,44 @@ var PayloadRateLimitStore = class {
|
|
|
158
159
|
return { payload: context };
|
|
159
160
|
}
|
|
160
161
|
};
|
|
162
|
+
function principalRateKey(user) {
|
|
163
|
+
if (!user || user.id === void 0 || user.id === null) return "anonymous";
|
|
164
|
+
const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
|
|
165
|
+
return `${collection}:${String(user.id)}`;
|
|
166
|
+
}
|
|
161
167
|
var RateLimiter = class {
|
|
162
|
-
|
|
168
|
+
/**
|
|
169
|
+
* @param namespace Endpoint-scoped prefix for every key this limiter writes.
|
|
170
|
+
* The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
|
|
171
|
+
* all endpoints), and the raw keys collide across endpoints: `ip` was used
|
|
172
|
+
* by both the login and the chatbot limiter — 10 forged chatbot requests
|
|
173
|
+
* locked a victim out of the portal for 15 minutes — and `String(user.id)`
|
|
174
|
+
* by five different endpoints. Always pass one; it is optional only because
|
|
175
|
+
* `RateLimiter` is part of the published API surface.
|
|
176
|
+
*/
|
|
177
|
+
constructor(windowMs, maxRequests, store, namespace) {
|
|
163
178
|
this.windowMs = windowMs;
|
|
164
179
|
this.maxRequests = maxRequests;
|
|
165
180
|
this.store = store ?? new MemoryRateLimitStore();
|
|
181
|
+
this.prefix = namespace ? `${namespace}:` : "";
|
|
166
182
|
}
|
|
167
183
|
windowMs;
|
|
168
184
|
maxRequests;
|
|
169
185
|
store;
|
|
186
|
+
prefix;
|
|
187
|
+
/** The key actually written to the store. Exposed for assertions in tests. */
|
|
188
|
+
scopedKey(key) {
|
|
189
|
+
return `${this.prefix}${key}`;
|
|
190
|
+
}
|
|
170
191
|
async check(key, context) {
|
|
171
|
-
const
|
|
192
|
+
const scoped = this.scopedKey(key);
|
|
193
|
+
const entry = context === void 0 ? await this.store.increment(scoped, this.windowMs) : await this.store.increment(scoped, this.windowMs, context);
|
|
172
194
|
return entry.count > this.maxRequests;
|
|
173
195
|
}
|
|
174
196
|
async reset(key, context) {
|
|
175
|
-
|
|
176
|
-
|
|
197
|
+
const scoped = this.scopedKey(key);
|
|
198
|
+
if (context === void 0) await this.store.reset(scoped);
|
|
199
|
+
else await this.store.reset(scoped, context);
|
|
177
200
|
}
|
|
178
201
|
};
|
|
179
202
|
var DEFAULT_INBOUND_EMAIL_LIMITS = {
|
|
@@ -211,7 +234,7 @@ function validateInboundEmailPayload(input, contentLength, limits = DEFAULT_INBO
|
|
|
211
234
|
|
|
212
235
|
// src/endpoints/capabilities.ts
|
|
213
236
|
function createInboundEmailEndpoint(capability, store) {
|
|
214
|
-
const limiter = new RateLimiter(6e4, 60, store);
|
|
237
|
+
const limiter = new RateLimiter(6e4, 60, store, "inbound-email");
|
|
215
238
|
return {
|
|
216
239
|
path: "/support-webhook/inbound-email",
|
|
217
240
|
method: "post",
|
|
@@ -242,7 +265,7 @@ function createInboundEmailEndpoint(capability, store) {
|
|
|
242
265
|
};
|
|
243
266
|
}
|
|
244
267
|
function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
245
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
268
|
+
const limiter = new RateLimiter(6e4, 20, store, "suggest-projects");
|
|
246
269
|
return {
|
|
247
270
|
path: "/support/suggest-projects",
|
|
248
271
|
method: "post",
|
|
@@ -250,7 +273,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
250
273
|
if (!req.user || req.user.collection !== slugs.users) {
|
|
251
274
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
252
275
|
}
|
|
253
|
-
const key =
|
|
276
|
+
const key = principalRateKey(req.user);
|
|
254
277
|
if (await limiter.check(key, req)) {
|
|
255
278
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
256
279
|
}
|
|
@@ -259,7 +282,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
259
282
|
};
|
|
260
283
|
}
|
|
261
284
|
function createTicketTitleEndpoint(slugs, capability, store) {
|
|
262
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
285
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-title");
|
|
263
286
|
return {
|
|
264
287
|
path: "/support/ticket-title",
|
|
265
288
|
method: "post",
|
|
@@ -281,7 +304,7 @@ function createTicketTitleEndpoint(slugs, capability, store) {
|
|
|
281
304
|
};
|
|
282
305
|
}
|
|
283
306
|
function createGenerateMissingTitlesEndpoint(slugs, capability, store) {
|
|
284
|
-
const limiter = new RateLimiter(6e4, 5, store);
|
|
307
|
+
const limiter = new RateLimiter(6e4, 5, store, "generate-missing-titles");
|
|
285
308
|
return {
|
|
286
309
|
path: "/support/generate-missing-titles",
|
|
287
310
|
method: "post",
|
|
@@ -368,55 +391,201 @@ function dbDelete(payload, slug, options) {
|
|
|
368
391
|
return payload.delete({ collection: slug, ...options });
|
|
369
392
|
}
|
|
370
393
|
|
|
394
|
+
// src/utils/features.ts
|
|
395
|
+
var DEFAULT_TICKETING_FEATURES = {
|
|
396
|
+
timeTracking: true,
|
|
397
|
+
ai: true,
|
|
398
|
+
satisfaction: true,
|
|
399
|
+
chat: true,
|
|
400
|
+
emailTracking: true,
|
|
401
|
+
canned: true,
|
|
402
|
+
merge: true,
|
|
403
|
+
snooze: true,
|
|
404
|
+
externalMessages: true,
|
|
405
|
+
clientHistory: true,
|
|
406
|
+
activityLog: true,
|
|
407
|
+
splitTicket: true,
|
|
408
|
+
scheduledReplies: true,
|
|
409
|
+
autoClose: true,
|
|
410
|
+
autoCloseDays: 7,
|
|
411
|
+
roundRobin: false
|
|
412
|
+
};
|
|
413
|
+
var BOOLEAN_FEATURE_KEYS = [
|
|
414
|
+
"timeTracking",
|
|
415
|
+
"ai",
|
|
416
|
+
"satisfaction",
|
|
417
|
+
"chat",
|
|
418
|
+
"emailTracking",
|
|
419
|
+
"canned",
|
|
420
|
+
"merge",
|
|
421
|
+
"snooze",
|
|
422
|
+
"externalMessages",
|
|
423
|
+
"clientHistory",
|
|
424
|
+
"activityLog",
|
|
425
|
+
"splitTicket",
|
|
426
|
+
"scheduledReplies",
|
|
427
|
+
"autoClose",
|
|
428
|
+
"roundRobin"
|
|
429
|
+
];
|
|
430
|
+
var PROJECTED_FEATURE_KEYS = ["autoClose", "autoCloseDays"];
|
|
431
|
+
function normalizeFeatures(raw) {
|
|
432
|
+
const out = { ...DEFAULT_TICKETING_FEATURES };
|
|
433
|
+
if (!raw || typeof raw !== "object") return out;
|
|
434
|
+
const src = raw;
|
|
435
|
+
for (const key of BOOLEAN_FEATURE_KEYS) {
|
|
436
|
+
if (typeof src[key] === "boolean") out[key] = src[key];
|
|
437
|
+
}
|
|
438
|
+
const days = src.autoCloseDays;
|
|
439
|
+
if (typeof days === "number" && Number.isFinite(days) && days >= 1) {
|
|
440
|
+
out.autoCloseDays = Math.floor(days);
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
function projectAutoClose(features, autoClose) {
|
|
445
|
+
if (!autoClose) return features;
|
|
446
|
+
return {
|
|
447
|
+
...features,
|
|
448
|
+
autoClose: typeof autoClose.enabled === "boolean" ? autoClose.enabled : features.autoClose,
|
|
449
|
+
autoCloseDays: typeof autoClose.daysBeforeClose === "number" && autoClose.daysBeforeClose >= 1 ? Math.floor(autoClose.daysBeforeClose) : features.autoCloseDays
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function stripProjectedFeatures(features) {
|
|
453
|
+
const out = { ...features };
|
|
454
|
+
for (const key of PROJECTED_FEATURE_KEYS) delete out[key];
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
371
458
|
// src/utils/readSettings.ts
|
|
372
|
-
var
|
|
459
|
+
var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
|
|
460
|
+
var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
|
|
373
461
|
var USER_PREFS_KEY_PREFIX = "support-user-prefs";
|
|
462
|
+
var LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
|
|
463
|
+
var SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
|
|
464
|
+
function resolveStaffPrefSlug(payload, staffSlug) {
|
|
465
|
+
if (staffSlug) return staffSlug;
|
|
466
|
+
const config = payload.config;
|
|
467
|
+
const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
|
|
468
|
+
if (typeof registered === "string" && registered) return registered;
|
|
469
|
+
return config?.admin?.user || "users";
|
|
470
|
+
}
|
|
374
471
|
var DEFAULT_SETTINGS = {
|
|
375
472
|
email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
|
|
376
473
|
ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
|
|
377
474
|
sla: { firstResponseMinutes: 120, resolutionMinutes: 1440, businessHoursOnly: true, escalationEmail: "" },
|
|
378
|
-
autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 }
|
|
475
|
+
autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 },
|
|
476
|
+
features: { ...DEFAULT_TICKETING_FEATURES }
|
|
379
477
|
};
|
|
380
478
|
var DEFAULT_USER_PREFS = {
|
|
381
479
|
locale: "fr",
|
|
382
480
|
signature: ""
|
|
383
481
|
};
|
|
384
|
-
var settingsCache =
|
|
482
|
+
var settingsCache = /* @__PURE__ */ new Map();
|
|
385
483
|
var SETTINGS_TTL_MS = 6e4;
|
|
484
|
+
var SETTINGS_CACHE_MAX = 8;
|
|
485
|
+
var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
|
|
386
486
|
function invalidateSupportSettingsCache() {
|
|
387
|
-
settingsCache
|
|
487
|
+
settingsCache.clear();
|
|
488
|
+
warnedForeignSettingsRow.clear();
|
|
388
489
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
490
|
+
function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
|
|
491
|
+
const autoClose = { ...base.autoClose, ...stored?.autoClose };
|
|
492
|
+
return {
|
|
493
|
+
email: { ...base.email, ...stored?.email },
|
|
494
|
+
ai: { ...base.ai, ...stored?.ai },
|
|
495
|
+
sla: { ...base.sla, ...stored?.sla },
|
|
496
|
+
autoClose,
|
|
497
|
+
// `autoClose` / `autoCloseDays` are projections of the block above, so they
|
|
498
|
+
// are recomputed here rather than trusted from whatever was persisted.
|
|
499
|
+
features: projectAutoClose(
|
|
500
|
+
normalizeFeatures({ ...base.features, ...stored?.features }),
|
|
501
|
+
autoClose
|
|
502
|
+
)
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
async function readSupportSettingsState(payload, staffSlug) {
|
|
506
|
+
const staff = resolveStaffPrefSlug(payload, staffSlug);
|
|
507
|
+
const cached = settingsCache.get(staff);
|
|
508
|
+
if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
|
|
509
|
+
return cached.value;
|
|
392
510
|
}
|
|
393
|
-
let value = {
|
|
511
|
+
let value = {
|
|
512
|
+
settings: mergeSupportSettings(null),
|
|
513
|
+
featuresConfigured: false
|
|
514
|
+
};
|
|
394
515
|
try {
|
|
395
516
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
396
|
-
|
|
517
|
+
// Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
|
|
518
|
+
// security boundary: without it any authenticated principal can plant a
|
|
519
|
+
// `support-settings` row and own the plugin's server settings.
|
|
520
|
+
where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
|
|
521
|
+
// The upsert is scoped per admin user, so several rows can share the key.
|
|
522
|
+
// Sorting makes "last write wins" deterministic instead of arbitrary.
|
|
523
|
+
sort: "-updatedAt",
|
|
397
524
|
limit: 1,
|
|
398
525
|
depth: 0,
|
|
399
526
|
overrideAccess: true
|
|
400
527
|
});
|
|
401
528
|
if (prefs.docs.length > 0) {
|
|
402
529
|
const stored = prefs.docs[0].value;
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
};
|
|
530
|
+
const featuresConfigured = !!stored.features && typeof stored.features === "object";
|
|
531
|
+
const settings = mergeSupportSettings(stored);
|
|
532
|
+
if (!featuresConfigured) {
|
|
533
|
+
settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
|
|
534
|
+
}
|
|
535
|
+
value = { settings, featuresConfigured };
|
|
536
|
+
} else {
|
|
537
|
+
await warnOnForeignSettingsRow(payload, staff);
|
|
409
538
|
}
|
|
410
539
|
} catch {
|
|
411
540
|
}
|
|
412
|
-
settingsCache
|
|
541
|
+
if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
|
|
542
|
+
settingsCache.set(staff, { value, ts: Date.now() });
|
|
413
543
|
return value;
|
|
414
544
|
}
|
|
415
|
-
async function
|
|
545
|
+
async function warnOnForeignSettingsRow(payload, staff) {
|
|
546
|
+
if (warnedForeignSettingsRow.has(staff)) return;
|
|
547
|
+
try {
|
|
548
|
+
const any = await dbFind(payload, "payload-preferences", {
|
|
549
|
+
where: { key: { equals: PREF_KEY } },
|
|
550
|
+
limit: 1,
|
|
551
|
+
depth: 0,
|
|
552
|
+
overrideAccess: true
|
|
553
|
+
});
|
|
554
|
+
if (any.docs.length === 0) return;
|
|
555
|
+
if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
|
|
556
|
+
warnedForeignSettingsRow.add(staff);
|
|
557
|
+
console.warn(
|
|
558
|
+
`[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.`
|
|
559
|
+
);
|
|
560
|
+
} catch {
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function readSupportSettings(payload, staffSlug) {
|
|
564
|
+
return (await readSupportSettingsState(payload, staffSlug)).settings;
|
|
565
|
+
}
|
|
566
|
+
async function readLegacyRoundRobin(payload, staff) {
|
|
567
|
+
try {
|
|
568
|
+
const prefs = await dbFind(payload, "payload-preferences", {
|
|
569
|
+
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
|
|
570
|
+
limit: 1,
|
|
571
|
+
depth: 0,
|
|
572
|
+
overrideAccess: true
|
|
573
|
+
});
|
|
574
|
+
if (prefs.docs.length > 0) {
|
|
575
|
+
return prefs.docs[0].value?.enabled === true;
|
|
576
|
+
}
|
|
577
|
+
} catch {
|
|
578
|
+
}
|
|
579
|
+
return DEFAULT_TICKETING_FEATURES.roundRobin;
|
|
580
|
+
}
|
|
581
|
+
async function readUserPrefs(payload, userId, staffSlug) {
|
|
416
582
|
try {
|
|
417
583
|
const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
|
|
418
584
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
419
|
-
where: {
|
|
585
|
+
where: {
|
|
586
|
+
key: { equals: key },
|
|
587
|
+
"user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
|
|
588
|
+
},
|
|
420
589
|
limit: 1,
|
|
421
590
|
depth: 0,
|
|
422
591
|
overrideAccess: true
|
|
@@ -432,13 +601,20 @@ async function readUserPrefs(payload, userId) {
|
|
|
432
601
|
}
|
|
433
602
|
return { ...DEFAULT_USER_PREFS };
|
|
434
603
|
}
|
|
604
|
+
function resolveOllamaBaseUrl() {
|
|
605
|
+
const baseURL = process.env.OLLAMA_API_URL;
|
|
606
|
+
if (!baseURL) {
|
|
607
|
+
throw new APIError("provider 'ollama' requires OLLAMA_API_URL", 500);
|
|
608
|
+
}
|
|
609
|
+
return baseURL;
|
|
610
|
+
}
|
|
435
611
|
|
|
436
612
|
// src/endpoints/ai.ts
|
|
437
613
|
async function getClient(aiSettings) {
|
|
438
614
|
const moduleName = "@anthropic-ai/sdk";
|
|
439
615
|
const { default: Anthropic } = await import(moduleName);
|
|
440
616
|
if (aiSettings.provider === "ollama") {
|
|
441
|
-
const baseURL =
|
|
617
|
+
const baseURL = resolveOllamaBaseUrl();
|
|
442
618
|
return new Anthropic({ apiKey: "ollama", baseURL });
|
|
443
619
|
}
|
|
444
620
|
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
@@ -447,7 +623,7 @@ function getModel(aiSettings) {
|
|
|
447
623
|
return aiSettings.model || "claude-haiku-4-5-20251001";
|
|
448
624
|
}
|
|
449
625
|
function createAiEndpoint(slugs, store) {
|
|
450
|
-
const limiter = new RateLimiter(6e4, 30, store);
|
|
626
|
+
const limiter = new RateLimiter(6e4, 30, store, "ai");
|
|
451
627
|
return {
|
|
452
628
|
path: "/support/ai",
|
|
453
629
|
method: "post",
|
|
@@ -455,7 +631,7 @@ function createAiEndpoint(slugs, store) {
|
|
|
455
631
|
try {
|
|
456
632
|
const payload = req.payload;
|
|
457
633
|
requireAdmin(req, slugs);
|
|
458
|
-
if (await limiter.check(
|
|
634
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
459
635
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
460
636
|
}
|
|
461
637
|
const settings = await readSupportSettings(payload);
|
|
@@ -637,7 +813,7 @@ async function applyAgentDecision(payload, slugs, ticketId, decision) {
|
|
|
637
813
|
function getClient2(aiSettings) {
|
|
638
814
|
const Anthropic = __require("@anthropic-ai/sdk").default;
|
|
639
815
|
if (aiSettings.provider === "ollama") {
|
|
640
|
-
return new Anthropic({ apiKey: "ollama", baseURL:
|
|
816
|
+
return new Anthropic({ apiKey: "ollama", baseURL: resolveOllamaBaseUrl() });
|
|
641
817
|
}
|
|
642
818
|
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
643
819
|
}
|
|
@@ -700,14 +876,14 @@ ${kbText || "(vide)"}`;
|
|
|
700
876
|
|
|
701
877
|
// src/endpoints/ai-agent.ts
|
|
702
878
|
function createAiAgentEndpoint(slugs, store) {
|
|
703
|
-
const limiter = new RateLimiter(6e4, 10, store);
|
|
879
|
+
const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
|
|
704
880
|
return {
|
|
705
881
|
path: "/support/ai-agent",
|
|
706
882
|
method: "post",
|
|
707
883
|
handler: async (req) => {
|
|
708
884
|
try {
|
|
709
885
|
requireAdmin(req, slugs);
|
|
710
|
-
if (await limiter.check(
|
|
886
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
711
887
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
712
888
|
}
|
|
713
889
|
let body = {};
|
|
@@ -737,7 +913,7 @@ async function getClient3(aiSettings) {
|
|
|
737
913
|
const moduleName = "@anthropic-ai/sdk";
|
|
738
914
|
const { default: Anthropic } = await import(moduleName);
|
|
739
915
|
if (aiSettings.provider === "ollama") {
|
|
740
|
-
const baseURL =
|
|
916
|
+
const baseURL = resolveOllamaBaseUrl();
|
|
741
917
|
return new Anthropic({ apiKey: "ollama", baseURL });
|
|
742
918
|
}
|
|
743
919
|
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
@@ -747,11 +923,11 @@ function getModel2(aiSettings) {
|
|
|
747
923
|
}
|
|
748
924
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
749
925
|
function createClientIntelligenceEndpoint(slugs, store) {
|
|
750
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
926
|
+
const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
|
|
751
927
|
const getHandler = async (req) => {
|
|
752
928
|
try {
|
|
753
929
|
requireAdmin(req, slugs);
|
|
754
|
-
if (await limiter.check(
|
|
930
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
755
931
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
756
932
|
}
|
|
757
933
|
const payload = req.payload;
|
|
@@ -783,7 +959,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
|
|
|
783
959
|
const postHandler = async (req) => {
|
|
784
960
|
try {
|
|
785
961
|
requireAdmin(req, slugs);
|
|
786
|
-
if (await limiter.check(
|
|
962
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
787
963
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
788
964
|
}
|
|
789
965
|
const payload = req.payload;
|
|
@@ -1412,6 +1588,14 @@ function createSplitTicketEndpoint(slugs) {
|
|
|
1412
1588
|
// src/endpoints/typing.ts
|
|
1413
1589
|
var typingState = /* @__PURE__ */ new Map();
|
|
1414
1590
|
var TYPING_TTL = 5e3;
|
|
1591
|
+
var MAX_TYPING_KEYS = 500;
|
|
1592
|
+
var TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
1593
|
+
function normalizeTicketId(raw) {
|
|
1594
|
+
if (typeof raw === "number") return Number.isInteger(raw) && raw > 0 ? String(raw) : null;
|
|
1595
|
+
if (typeof raw !== "string") return null;
|
|
1596
|
+
const value = raw.trim();
|
|
1597
|
+
return TICKET_ID_PATTERN.test(value) ? value : null;
|
|
1598
|
+
}
|
|
1415
1599
|
function cleanExpired(ticketId) {
|
|
1416
1600
|
const state = typingState.get(ticketId);
|
|
1417
1601
|
if (!state) return;
|
|
@@ -1426,6 +1610,36 @@ function cleanExpired(ticketId) {
|
|
|
1426
1610
|
}
|
|
1427
1611
|
if (!state.admin && !state.client) typingState.delete(ticketId);
|
|
1428
1612
|
}
|
|
1613
|
+
function sweepExpired() {
|
|
1614
|
+
for (const key of Array.from(typingState.keys())) cleanExpired(key);
|
|
1615
|
+
}
|
|
1616
|
+
function evictOldest() {
|
|
1617
|
+
let oldestKey = null;
|
|
1618
|
+
let oldestTs = Infinity;
|
|
1619
|
+
for (const [key, state] of typingState) {
|
|
1620
|
+
const ts = Math.max(state.admin || 0, state.client || 0);
|
|
1621
|
+
if (ts < oldestTs) {
|
|
1622
|
+
oldestTs = ts;
|
|
1623
|
+
oldestKey = key;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
if (oldestKey !== null) typingState.delete(oldestKey);
|
|
1627
|
+
}
|
|
1628
|
+
async function mayAccessTicket(req, slugs, ticketId) {
|
|
1629
|
+
const collection = req.user?.collection;
|
|
1630
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) return false;
|
|
1631
|
+
try {
|
|
1632
|
+
const doc = await dbFindByID(req.payload, slugs.tickets, {
|
|
1633
|
+
id: ticketId,
|
|
1634
|
+
depth: 0,
|
|
1635
|
+
overrideAccess: false,
|
|
1636
|
+
user: req.user
|
|
1637
|
+
});
|
|
1638
|
+
return !!doc;
|
|
1639
|
+
} catch {
|
|
1640
|
+
return false;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1429
1643
|
function createTypingPostEndpoint(slugs) {
|
|
1430
1644
|
return {
|
|
1431
1645
|
path: "/support/typing",
|
|
@@ -1436,11 +1650,18 @@ function createTypingPostEndpoint(slugs) {
|
|
|
1436
1650
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1437
1651
|
}
|
|
1438
1652
|
const { ticketId } = await req.json();
|
|
1439
|
-
|
|
1653
|
+
const key = normalizeTicketId(ticketId);
|
|
1654
|
+
if (!key) {
|
|
1440
1655
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1441
1656
|
}
|
|
1442
|
-
|
|
1657
|
+
if (!await mayAccessTicket(req, slugs, key)) {
|
|
1658
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
1659
|
+
}
|
|
1443
1660
|
const state = typingState.get(key) || {};
|
|
1661
|
+
if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
|
|
1662
|
+
sweepExpired();
|
|
1663
|
+
if (typingState.size >= MAX_TYPING_KEYS) evictOldest();
|
|
1664
|
+
}
|
|
1444
1665
|
if (req.user.collection === slugs.users) {
|
|
1445
1666
|
state.admin = Date.now();
|
|
1446
1667
|
state.adminName = req.user.firstName || "Support";
|
|
@@ -1461,30 +1682,33 @@ function createTypingGetEndpoint(slugs) {
|
|
|
1461
1682
|
path: "/support/typing",
|
|
1462
1683
|
method: "get",
|
|
1463
1684
|
handler: async (req) => {
|
|
1685
|
+
const idle = { typing: false, name: null };
|
|
1464
1686
|
try {
|
|
1465
1687
|
if (!req.user) {
|
|
1466
1688
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1467
1689
|
}
|
|
1468
1690
|
const url = new URL(req.url);
|
|
1469
|
-
const
|
|
1470
|
-
if (!
|
|
1691
|
+
const key = normalizeTicketId(url.searchParams.get("ticketId"));
|
|
1692
|
+
if (!key) {
|
|
1471
1693
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1472
1694
|
}
|
|
1473
|
-
cleanExpired(
|
|
1474
|
-
const state = typingState.get(
|
|
1695
|
+
cleanExpired(key);
|
|
1696
|
+
const state = typingState.get(key);
|
|
1697
|
+
if (!state) return Response.json(idle);
|
|
1698
|
+
if (!await mayAccessTicket(req, slugs, key)) return Response.json(idle);
|
|
1475
1699
|
if (req.user.collection === slugs.users) {
|
|
1476
1700
|
return Response.json({
|
|
1477
|
-
typing: !!state
|
|
1478
|
-
name: state
|
|
1701
|
+
typing: !!state.client,
|
|
1702
|
+
name: state.clientName || null
|
|
1479
1703
|
});
|
|
1480
1704
|
} else {
|
|
1481
1705
|
return Response.json({
|
|
1482
|
-
typing: !!state
|
|
1483
|
-
name: state
|
|
1706
|
+
typing: !!state.admin,
|
|
1707
|
+
name: state.adminName || null
|
|
1484
1708
|
});
|
|
1485
1709
|
}
|
|
1486
1710
|
} catch {
|
|
1487
|
-
return Response.json(
|
|
1711
|
+
return Response.json(idle);
|
|
1488
1712
|
}
|
|
1489
1713
|
}
|
|
1490
1714
|
};
|
|
@@ -1573,25 +1797,6 @@ function createPresenceGetEndpoint(slugs) {
|
|
|
1573
1797
|
}
|
|
1574
1798
|
|
|
1575
1799
|
// src/endpoints/settings.ts
|
|
1576
|
-
var PREF_KEY2 = "support-settings";
|
|
1577
|
-
var DEFAULT_SUPPORT_SETTINGS = {
|
|
1578
|
-
email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
|
|
1579
|
-
ai: {
|
|
1580
|
-
provider: "anthropic",
|
|
1581
|
-
model: "claude-haiku-4-5-20251001",
|
|
1582
|
-
enableSentiment: true,
|
|
1583
|
-
enableSynthesis: true,
|
|
1584
|
-
enableSuggestion: true,
|
|
1585
|
-
enableRewrite: true
|
|
1586
|
-
},
|
|
1587
|
-
sla: {
|
|
1588
|
-
firstResponseMinutes: 120,
|
|
1589
|
-
resolutionMinutes: 1440,
|
|
1590
|
-
businessHoursOnly: true,
|
|
1591
|
-
escalationEmail: ""
|
|
1592
|
-
},
|
|
1593
|
-
autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 }
|
|
1594
|
-
};
|
|
1595
1800
|
function createSettingsGetEndpoint(slugs) {
|
|
1596
1801
|
return {
|
|
1597
1802
|
path: "/support/settings",
|
|
@@ -1600,23 +1805,8 @@ function createSettingsGetEndpoint(slugs) {
|
|
|
1600
1805
|
try {
|
|
1601
1806
|
const payload = req.payload;
|
|
1602
1807
|
requireAdmin(req, slugs);
|
|
1603
|
-
const
|
|
1604
|
-
|
|
1605
|
-
limit: 1,
|
|
1606
|
-
depth: 0,
|
|
1607
|
-
overrideAccess: true
|
|
1608
|
-
});
|
|
1609
|
-
let settings = { ...DEFAULT_SUPPORT_SETTINGS };
|
|
1610
|
-
if (prefs.docs.length > 0) {
|
|
1611
|
-
const stored = prefs.docs[0].value;
|
|
1612
|
-
settings = {
|
|
1613
|
-
email: { ...DEFAULT_SUPPORT_SETTINGS.email, ...stored.email },
|
|
1614
|
-
ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...stored.ai },
|
|
1615
|
-
sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...stored.sla },
|
|
1616
|
-
autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...stored.autoClose }
|
|
1617
|
-
};
|
|
1618
|
-
}
|
|
1619
|
-
return Response.json(settings);
|
|
1808
|
+
const { settings, featuresConfigured } = await readSupportSettingsState(payload);
|
|
1809
|
+
return Response.json({ ...settings, featuresConfigured });
|
|
1620
1810
|
} catch (error) {
|
|
1621
1811
|
const authResponse = handleAuthError(error);
|
|
1622
1812
|
if (authResponse) return authResponse;
|
|
@@ -1635,30 +1825,31 @@ function createSettingsPostEndpoint(slugs) {
|
|
|
1635
1825
|
const payload = req.payload;
|
|
1636
1826
|
requireAdmin(req, slugs);
|
|
1637
1827
|
const body = await req.json();
|
|
1638
|
-
const
|
|
1639
|
-
|
|
1640
|
-
ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...body.ai },
|
|
1641
|
-
sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...body.sla },
|
|
1642
|
-
autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...body.autoClose }
|
|
1643
|
-
};
|
|
1828
|
+
const { settings: current } = await readSupportSettingsState(payload);
|
|
1829
|
+
const merged = mergeSupportSettings(body, current);
|
|
1644
1830
|
await payload.db.upsert({
|
|
1645
1831
|
collection: "payload-preferences",
|
|
1646
1832
|
data: {
|
|
1647
|
-
key:
|
|
1833
|
+
key: SUPPORT_SETTINGS_PREF_KEY,
|
|
1648
1834
|
user: { relationTo: req.user.collection, value: req.user.id },
|
|
1649
|
-
value:
|
|
1835
|
+
value: {
|
|
1836
|
+
...merged,
|
|
1837
|
+
// Persist the flags without the two projected ones, so `features`
|
|
1838
|
+
// and the `autoClose` block can never drift apart.
|
|
1839
|
+
features: stripProjectedFeatures(merged.features)
|
|
1840
|
+
}
|
|
1650
1841
|
},
|
|
1651
1842
|
req: { payload, user: req.user },
|
|
1652
1843
|
where: {
|
|
1653
1844
|
and: [
|
|
1654
|
-
{ key: { equals:
|
|
1845
|
+
{ key: { equals: SUPPORT_SETTINGS_PREF_KEY } },
|
|
1655
1846
|
{ "user.value": { equals: req.user.id } },
|
|
1656
1847
|
{ "user.relationTo": { equals: req.user.collection } }
|
|
1657
1848
|
]
|
|
1658
1849
|
}
|
|
1659
1850
|
});
|
|
1660
1851
|
invalidateSupportSettingsCache();
|
|
1661
|
-
return Response.json(merged);
|
|
1852
|
+
return Response.json({ ...merged, featuresConfigured: true });
|
|
1662
1853
|
} catch (error) {
|
|
1663
1854
|
const authResponse = handleAuthError(error);
|
|
1664
1855
|
if (authResponse) return authResponse;
|
|
@@ -1670,7 +1861,7 @@ function createSettingsPostEndpoint(slugs) {
|
|
|
1670
1861
|
}
|
|
1671
1862
|
|
|
1672
1863
|
// src/endpoints/signature.ts
|
|
1673
|
-
var
|
|
1864
|
+
var PREF_KEY2 = "email-signature";
|
|
1674
1865
|
function createSignatureGetEndpoint(slugs) {
|
|
1675
1866
|
return {
|
|
1676
1867
|
path: "/support/signature",
|
|
@@ -1680,7 +1871,14 @@ function createSignatureGetEndpoint(slugs) {
|
|
|
1680
1871
|
const payload = req.payload;
|
|
1681
1872
|
requireAdmin(req, slugs);
|
|
1682
1873
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
1683
|
-
|
|
1874
|
+
// Scope to the staff auth collection: `payload-preferences` accepts a
|
|
1875
|
+
// write from ANY authenticated principal, and ids collide between auth
|
|
1876
|
+
// collections — a support-client with the same id would otherwise own
|
|
1877
|
+
// the `email-signature-<id>` row read back for the agent.
|
|
1878
|
+
where: {
|
|
1879
|
+
key: { equals: `${PREF_KEY2}-${req.user.id}` },
|
|
1880
|
+
"user.relationTo": { equals: slugs.users }
|
|
1881
|
+
},
|
|
1684
1882
|
limit: 1,
|
|
1685
1883
|
depth: 0,
|
|
1686
1884
|
overrideAccess: true
|
|
@@ -1705,9 +1903,9 @@ function createSignaturePostEndpoint(slugs) {
|
|
|
1705
1903
|
const payload = req.payload;
|
|
1706
1904
|
requireAdmin(req, slugs);
|
|
1707
1905
|
const { signature } = await req.json();
|
|
1708
|
-
const key = `${
|
|
1906
|
+
const key = `${PREF_KEY2}-${req.user.id}`;
|
|
1709
1907
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
1710
|
-
where: { key: { equals: key } },
|
|
1908
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
1711
1909
|
limit: 1,
|
|
1712
1910
|
depth: 0,
|
|
1713
1911
|
overrideAccess: true
|
|
@@ -1740,7 +1938,6 @@ function createSignaturePostEndpoint(slugs) {
|
|
|
1740
1938
|
}
|
|
1741
1939
|
|
|
1742
1940
|
// src/endpoints/round-robin-config.ts
|
|
1743
|
-
var PREF_KEY4 = "support-round-robin";
|
|
1744
1941
|
function createRoundRobinConfigGetEndpoint(slugs) {
|
|
1745
1942
|
return {
|
|
1746
1943
|
path: "/support/round-robin-config",
|
|
@@ -1749,14 +1946,8 @@ function createRoundRobinConfigGetEndpoint(slugs) {
|
|
|
1749
1946
|
try {
|
|
1750
1947
|
const payload = req.payload;
|
|
1751
1948
|
requireAdmin(req, slugs);
|
|
1752
|
-
const
|
|
1753
|
-
|
|
1754
|
-
limit: 1,
|
|
1755
|
-
depth: 0,
|
|
1756
|
-
overrideAccess: true
|
|
1757
|
-
});
|
|
1758
|
-
const enabled = prefs.docs.length > 0 ? prefs.docs[0].value?.enabled === true : false;
|
|
1759
|
-
return Response.json({ enabled });
|
|
1949
|
+
const { settings } = await readSupportSettingsState(payload);
|
|
1950
|
+
return Response.json({ enabled: settings.features.roundRobin });
|
|
1760
1951
|
} catch (error) {
|
|
1761
1952
|
const authResponse = handleAuthError(error);
|
|
1762
1953
|
if (authResponse) return authResponse;
|
|
@@ -1775,28 +1966,31 @@ function createRoundRobinConfigPostEndpoint(slugs) {
|
|
|
1775
1966
|
const payload = req.payload;
|
|
1776
1967
|
requireAdmin(req, slugs);
|
|
1777
1968
|
const { enabled } = await req.json();
|
|
1778
|
-
const
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
});
|
|
1969
|
+
const { settings: current } = await readSupportSettingsState(payload);
|
|
1970
|
+
const merged = mergeSupportSettings(
|
|
1971
|
+
{ features: { ...current.features, roundRobin: !!enabled } },
|
|
1972
|
+
current
|
|
1973
|
+
);
|
|
1784
1974
|
await payload.db.upsert({
|
|
1785
1975
|
collection: "payload-preferences",
|
|
1786
1976
|
data: {
|
|
1787
|
-
key:
|
|
1977
|
+
key: SUPPORT_SETTINGS_PREF_KEY,
|
|
1788
1978
|
user: { relationTo: req.user.collection, value: req.user.id },
|
|
1789
|
-
value: {
|
|
1979
|
+
value: {
|
|
1980
|
+
...merged,
|
|
1981
|
+
features: stripProjectedFeatures(merged.features)
|
|
1982
|
+
}
|
|
1790
1983
|
},
|
|
1791
1984
|
req: { payload, user: req.user },
|
|
1792
1985
|
where: {
|
|
1793
1986
|
and: [
|
|
1794
|
-
{ key: { equals:
|
|
1987
|
+
{ key: { equals: SUPPORT_SETTINGS_PREF_KEY } },
|
|
1795
1988
|
{ "user.value": { equals: req.user.id } },
|
|
1796
1989
|
{ "user.relationTo": { equals: req.user.collection } }
|
|
1797
1990
|
]
|
|
1798
1991
|
}
|
|
1799
1992
|
});
|
|
1993
|
+
invalidateSupportSettingsCache();
|
|
1800
1994
|
return Response.json({ enabled: !!enabled });
|
|
1801
1995
|
} catch (error) {
|
|
1802
1996
|
const authResponse = handleAuthError(error);
|
|
@@ -2472,7 +2666,7 @@ function formatFr(date, withTime) {
|
|
|
2472
2666
|
});
|
|
2473
2667
|
}
|
|
2474
2668
|
function createSendReminderEndpoint(slugs, store) {
|
|
2475
|
-
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
|
|
2669
|
+
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
|
|
2476
2670
|
return {
|
|
2477
2671
|
path: "/support/send-reminder",
|
|
2478
2672
|
method: "post",
|
|
@@ -2480,7 +2674,7 @@ function createSendReminderEndpoint(slugs, store) {
|
|
|
2480
2674
|
try {
|
|
2481
2675
|
const payload = req.payload;
|
|
2482
2676
|
requireAdmin(req, slugs);
|
|
2483
|
-
if (await reminderLimiter.check(
|
|
2677
|
+
if (await reminderLimiter.check(principalRateKey(req.user), req)) {
|
|
2484
2678
|
return Response.json(
|
|
2485
2679
|
{ error: "Trop de relances. R\xE9essayez dans une heure." },
|
|
2486
2680
|
{ status: 429 }
|
|
@@ -2783,8 +2977,14 @@ function createPurgeLogsEndpoint(slugs) {
|
|
|
2783
2977
|
}
|
|
2784
2978
|
|
|
2785
2979
|
// src/endpoints/chatbot.ts
|
|
2786
|
-
|
|
2787
|
-
|
|
2980
|
+
var DEFAULT_CHATBOT_MAX_PER_HOUR = 200;
|
|
2981
|
+
function resolveMaxPerHour(explicit) {
|
|
2982
|
+
const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR);
|
|
2983
|
+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR;
|
|
2984
|
+
}
|
|
2985
|
+
function createChatbotEndpoint(slugs, store, maxPerHour) {
|
|
2986
|
+
const chatbotLimiter = new RateLimiter(6e4, 10, store, "chatbot:ip");
|
|
2987
|
+
const globalLimiter = new RateLimiter(60 * 6e4, resolveMaxPerHour(), store, "chatbot:global");
|
|
2788
2988
|
return {
|
|
2789
2989
|
path: "/support/chatbot",
|
|
2790
2990
|
method: "post",
|
|
@@ -2804,6 +3004,15 @@ function createChatbotEndpoint(slugs, store) {
|
|
|
2804
3004
|
if (!question?.trim() || question.trim().length < 5) {
|
|
2805
3005
|
return Response.json({ error: "Question too short" }, { status: 400 });
|
|
2806
3006
|
}
|
|
3007
|
+
if (await globalLimiter.check("all", req)) {
|
|
3008
|
+
return Response.json({
|
|
3009
|
+
answer: null,
|
|
3010
|
+
confidence: 0,
|
|
3011
|
+
suggestion: "create_ticket",
|
|
3012
|
+
aiUnavailable: true,
|
|
3013
|
+
message: "L'assistant est momentan\xE9ment indisponible. Cr\xE9ez un ticket, un agent vous r\xE9pondra."
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
2807
3016
|
const payload = req.payload;
|
|
2808
3017
|
const articles = await dbFind(payload, slugs.knowledgeBase, {
|
|
2809
3018
|
where: { published: { equals: true } },
|
|
@@ -2916,8 +3125,8 @@ function createChatGetEndpoint(slugs) {
|
|
|
2916
3125
|
};
|
|
2917
3126
|
}
|
|
2918
3127
|
function createChatPostEndpoint(slugs, store) {
|
|
2919
|
-
const chatSessionLimiter = new RateLimiter(36e5, 5, store);
|
|
2920
|
-
const chatMessageLimiter = new RateLimiter(6e4, 15, store);
|
|
3128
|
+
const chatSessionLimiter = new RateLimiter(36e5, 5, store, "chat:session");
|
|
3129
|
+
const chatMessageLimiter = new RateLimiter(6e4, 15, store, "chat:message");
|
|
2921
3130
|
return {
|
|
2922
3131
|
path: "/support/chat",
|
|
2923
3132
|
method: "post",
|
|
@@ -2933,8 +3142,9 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
2933
3142
|
}
|
|
2934
3143
|
const { action, session, message } = body;
|
|
2935
3144
|
const userId = String(req.user.id);
|
|
3145
|
+
const rateKey = principalRateKey(req.user);
|
|
2936
3146
|
if (action === "start") {
|
|
2937
|
-
if (await chatSessionLimiter.check(
|
|
3147
|
+
if (await chatSessionLimiter.check(rateKey, req)) {
|
|
2938
3148
|
return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
|
|
2939
3149
|
}
|
|
2940
3150
|
const sessionId = `chat_${crypto3.randomBytes(16).toString("hex")}`;
|
|
@@ -2951,7 +3161,7 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
2951
3161
|
return Response.json({ session: sessionId, messages: [systemMsg] });
|
|
2952
3162
|
}
|
|
2953
3163
|
if (action === "send" && session && message) {
|
|
2954
|
-
if (await chatMessageLimiter.check(
|
|
3164
|
+
if (await chatMessageLimiter.check(rateKey, req)) {
|
|
2955
3165
|
return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
|
|
2956
3166
|
}
|
|
2957
3167
|
const trimmedMessage = String(message).trim();
|
|
@@ -3191,7 +3401,7 @@ function createAdminChatGetEndpoint(slugs) {
|
|
|
3191
3401
|
};
|
|
3192
3402
|
}
|
|
3193
3403
|
function createAdminChatPostEndpoint(slugs, store) {
|
|
3194
|
-
const adminChatLimiter = new RateLimiter(6e4, 30, store);
|
|
3404
|
+
const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
|
|
3195
3405
|
return {
|
|
3196
3406
|
path: "/support/admin-chat",
|
|
3197
3407
|
method: "post",
|
|
@@ -3220,7 +3430,7 @@ function createAdminChatPostEndpoint(slugs, store) {
|
|
|
3220
3430
|
}
|
|
3221
3431
|
const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
|
|
3222
3432
|
if (action === "send" && message) {
|
|
3223
|
-
if (await adminChatLimiter.check(
|
|
3433
|
+
if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
|
|
3224
3434
|
return Response.json({ error: "Rate limit atteint." }, { status: 429 });
|
|
3225
3435
|
}
|
|
3226
3436
|
const trimmedMessage = String(message).trim();
|
|
@@ -4058,7 +4268,7 @@ function createInvoiceEndpoint(slugs) {
|
|
|
4058
4268
|
function getClient4(aiSettings) {
|
|
4059
4269
|
const Anthropic = __require("@anthropic-ai/sdk").default;
|
|
4060
4270
|
if (aiSettings.provider === "ollama") {
|
|
4061
|
-
const baseURL =
|
|
4271
|
+
const baseURL = resolveOllamaBaseUrl();
|
|
4062
4272
|
return new Anthropic({ apiKey: "ollama", baseURL });
|
|
4063
4273
|
}
|
|
4064
4274
|
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
@@ -4175,14 +4385,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
|
|
|
4175
4385
|
|
|
4176
4386
|
// src/endpoints/ticket-synthesis.ts
|
|
4177
4387
|
function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
4178
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
4388
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
|
|
4179
4389
|
return {
|
|
4180
4390
|
path: "/support/ticket-synthesis",
|
|
4181
4391
|
method: "post",
|
|
4182
4392
|
handler: async (req) => {
|
|
4183
4393
|
try {
|
|
4184
4394
|
requireAdmin(req, slugs);
|
|
4185
|
-
if (await limiter.check(
|
|
4395
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
4186
4396
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
4187
4397
|
}
|
|
4188
4398
|
const payload = req.payload;
|
|
@@ -4226,15 +4436,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
|
4226
4436
|
}
|
|
4227
4437
|
|
|
4228
4438
|
// src/endpoints/email-stats.ts
|
|
4229
|
-
function createEmailStatsEndpoint(slugs) {
|
|
4439
|
+
function createEmailStatsEndpoint(slugs, store) {
|
|
4440
|
+
const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
|
|
4230
4441
|
return {
|
|
4231
4442
|
path: "/support/email-stats",
|
|
4232
4443
|
method: "get",
|
|
4233
4444
|
handler: async (req) => {
|
|
4234
4445
|
try {
|
|
4235
4446
|
const payload = req.payload;
|
|
4236
|
-
|
|
4237
|
-
|
|
4447
|
+
requireAdmin(req, slugs);
|
|
4448
|
+
if (await statsLimiter.check(principalRateKey(req.user), req)) {
|
|
4449
|
+
return Response.json({ error: "Too many requests." }, { status: 429 });
|
|
4238
4450
|
}
|
|
4239
4451
|
const url = new URL(req.url);
|
|
4240
4452
|
const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
|
|
@@ -4301,6 +4513,8 @@ function createEmailStatsEndpoint(slugs) {
|
|
|
4301
4513
|
actions: Object.fromEntries(actionMap)
|
|
4302
4514
|
});
|
|
4303
4515
|
} catch (err) {
|
|
4516
|
+
const authResponse = handleAuthError(err);
|
|
4517
|
+
if (authResponse) return authResponse;
|
|
4304
4518
|
console.error("[email-stats] Error:", err);
|
|
4305
4519
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
4306
4520
|
}
|
|
@@ -4733,7 +4947,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
|
|
|
4733
4947
|
|
|
4734
4948
|
// src/endpoints/resend-notification.ts
|
|
4735
4949
|
function createResendNotificationEndpoint(slugs, store) {
|
|
4736
|
-
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
4950
|
+
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
|
|
4737
4951
|
return {
|
|
4738
4952
|
path: "/support/resend-notification",
|
|
4739
4953
|
method: "post",
|
|
@@ -4741,7 +4955,7 @@ function createResendNotificationEndpoint(slugs, store) {
|
|
|
4741
4955
|
try {
|
|
4742
4956
|
const payload = req.payload;
|
|
4743
4957
|
requireAdmin(req, slugs);
|
|
4744
|
-
if (await resendLimiter.check(
|
|
4958
|
+
if (await resendLimiter.check(principalRateKey(req.user), req)) {
|
|
4745
4959
|
return Response.json(
|
|
4746
4960
|
{ error: "Trop de renvois. R\xE9essayez dans une heure." },
|
|
4747
4961
|
{ status: 429 }
|
|
@@ -4942,10 +5156,48 @@ function createSeedKbEndpoint(slugs) {
|
|
|
4942
5156
|
}
|
|
4943
5157
|
};
|
|
4944
5158
|
}
|
|
5159
|
+
var TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1e3;
|
|
5160
|
+
function challengeSecret() {
|
|
5161
|
+
const secret = process.env.PAYLOAD_SECRET;
|
|
5162
|
+
if (!secret) {
|
|
5163
|
+
throw new Error(
|
|
5164
|
+
"[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to issue a 2FA challenge with an insecure fallback secret"
|
|
5165
|
+
);
|
|
5166
|
+
}
|
|
5167
|
+
return secret;
|
|
5168
|
+
}
|
|
5169
|
+
function normalizeEmail(email) {
|
|
5170
|
+
return String(email).trim().toLowerCase();
|
|
5171
|
+
}
|
|
5172
|
+
function sign(email, expiresAt) {
|
|
5173
|
+
return createHmac("sha256", challengeSecret()).update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`).digest("hex");
|
|
5174
|
+
}
|
|
5175
|
+
function issueTwoFactorChallenge(email, now = Date.now()) {
|
|
5176
|
+
const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS;
|
|
5177
|
+
return `${expiresAt}.${sign(email, expiresAt)}`;
|
|
5178
|
+
}
|
|
5179
|
+
function verifyTwoFactorChallenge(email, token, now = Date.now()) {
|
|
5180
|
+
if (typeof email !== "string" || !email || typeof token !== "string") return false;
|
|
5181
|
+
const separator = token.indexOf(".");
|
|
5182
|
+
if (separator <= 0) return false;
|
|
5183
|
+
const expiresAt = Number(token.slice(0, separator));
|
|
5184
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false;
|
|
5185
|
+
const received = token.slice(separator + 1);
|
|
5186
|
+
if (!/^[0-9a-f]{64}$/i.test(received)) return false;
|
|
5187
|
+
let expected;
|
|
5188
|
+
try {
|
|
5189
|
+
expected = sign(email, expiresAt);
|
|
5190
|
+
} catch {
|
|
5191
|
+
return false;
|
|
5192
|
+
}
|
|
5193
|
+
const a = Buffer.from(expected, "hex");
|
|
5194
|
+
const b = Buffer.from(received.toLowerCase(), "hex");
|
|
5195
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
5196
|
+
}
|
|
4945
5197
|
|
|
4946
5198
|
// src/endpoints/login.ts
|
|
4947
5199
|
function createLoginEndpoint(slugs, store) {
|
|
4948
|
-
const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
|
|
5200
|
+
const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
|
|
4949
5201
|
return {
|
|
4950
5202
|
path: "/support/login",
|
|
4951
5203
|
method: "post",
|
|
@@ -4998,7 +5250,12 @@ function createLoginEndpoint(slugs, store) {
|
|
|
4998
5250
|
} catch (err) {
|
|
4999
5251
|
const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
|
|
5000
5252
|
if (errorMessage.includes("2FA_REQUIRED")) {
|
|
5001
|
-
|
|
5253
|
+
let challenge;
|
|
5254
|
+
try {
|
|
5255
|
+
challenge = issueTwoFactorChallenge(email);
|
|
5256
|
+
} catch {
|
|
5257
|
+
}
|
|
5258
|
+
return Response.json({ requires2FA: true, ...challenge ? { challenge } : {} }, { status: 200 });
|
|
5002
5259
|
}
|
|
5003
5260
|
let errorReason = "Identifiants incorrects";
|
|
5004
5261
|
if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
|
|
@@ -5030,8 +5287,8 @@ function hashCode(code) {
|
|
|
5030
5287
|
return createHmac("sha256", secret).update(code).digest("hex");
|
|
5031
5288
|
}
|
|
5032
5289
|
function createAuth2faEndpoint(slugs, store) {
|
|
5033
|
-
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
|
|
5034
|
-
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store);
|
|
5290
|
+
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store, "2fa:send");
|
|
5291
|
+
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store, "2fa:verify");
|
|
5035
5292
|
return {
|
|
5036
5293
|
path: "/support/2fa",
|
|
5037
5294
|
method: "post",
|
|
@@ -5044,12 +5301,18 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5044
5301
|
} catch {
|
|
5045
5302
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
5046
5303
|
}
|
|
5047
|
-
const { action, email, code } = body;
|
|
5304
|
+
const { action, email, code, challenge } = body;
|
|
5048
5305
|
if (!action || !email) {
|
|
5049
5306
|
return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
|
|
5050
5307
|
}
|
|
5051
5308
|
const genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
|
|
5052
5309
|
if (action === "send") {
|
|
5310
|
+
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
5311
|
+
return Response.json(
|
|
5312
|
+
{ error: "Authentification requise avant l'envoi d'un code." },
|
|
5313
|
+
{ status: 401 }
|
|
5314
|
+
);
|
|
5315
|
+
}
|
|
5053
5316
|
if (await sendLimiter.check(email, req)) {
|
|
5054
5317
|
return Response.json(genericSendResponse);
|
|
5055
5318
|
}
|
|
@@ -5143,6 +5406,33 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5143
5406
|
}
|
|
5144
5407
|
};
|
|
5145
5408
|
}
|
|
5409
|
+
var OAUTH_STATE_COOKIE = "support-oauth-state";
|
|
5410
|
+
var OAUTH_STATE_MAX_AGE = 600;
|
|
5411
|
+
var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
|
|
5412
|
+
function readCookie(header, name) {
|
|
5413
|
+
if (!header) return null;
|
|
5414
|
+
for (const part of header.split(";")) {
|
|
5415
|
+
const eq = part.indexOf("=");
|
|
5416
|
+
if (eq === -1) continue;
|
|
5417
|
+
if (part.slice(0, eq).trim() !== name) continue;
|
|
5418
|
+
try {
|
|
5419
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
5420
|
+
} catch {
|
|
5421
|
+
return part.slice(eq + 1).trim();
|
|
5422
|
+
}
|
|
5423
|
+
}
|
|
5424
|
+
return null;
|
|
5425
|
+
}
|
|
5426
|
+
function clearedStateCookie() {
|
|
5427
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5428
|
+
return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=0`;
|
|
5429
|
+
}
|
|
5430
|
+
function statesMatch(a, b) {
|
|
5431
|
+
if (!a || !b) return false;
|
|
5432
|
+
const left = crypto3.createHash("sha256").update(a).digest();
|
|
5433
|
+
const right = crypto3.createHash("sha256").update(b).digest();
|
|
5434
|
+
return crypto3.timingSafeEqual(left, right);
|
|
5435
|
+
}
|
|
5146
5436
|
function createOAuthGoogleEndpoint(slugs, options) {
|
|
5147
5437
|
return {
|
|
5148
5438
|
path: "/support/oauth/google",
|
|
@@ -5159,7 +5449,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5159
5449
|
}
|
|
5160
5450
|
try {
|
|
5161
5451
|
const body = await req.json();
|
|
5162
|
-
const { action, code, state: queryState
|
|
5452
|
+
const { action, code, state: queryState } = body;
|
|
5163
5453
|
if (action === "login") {
|
|
5164
5454
|
const oauthState = crypto3.randomBytes(32).toString("hex");
|
|
5165
5455
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5171,13 +5461,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5171
5461
|
state: oauthState,
|
|
5172
5462
|
prompt: "select_account"
|
|
5173
5463
|
});
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5464
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5465
|
+
const loginHeaders = new Headers({ "Content-Type": "application/json" });
|
|
5466
|
+
loginHeaders.append(
|
|
5467
|
+
"Set-Cookie",
|
|
5468
|
+
`${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`
|
|
5469
|
+
);
|
|
5470
|
+
return new Response(
|
|
5471
|
+
JSON.stringify({
|
|
5472
|
+
url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
|
|
5473
|
+
// Kept for callers that echo it back in the redirect URL; the
|
|
5474
|
+
// server no longer trusts anything the caller returns.
|
|
5475
|
+
state: oauthState
|
|
5476
|
+
}),
|
|
5477
|
+
{ status: 200, headers: loginHeaders }
|
|
5478
|
+
);
|
|
5178
5479
|
}
|
|
5179
5480
|
if (code) {
|
|
5180
|
-
|
|
5481
|
+
const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
|
|
5482
|
+
if (!statesMatch(issuedState, queryState)) {
|
|
5181
5483
|
return Response.json({ error: "state_mismatch" }, { status: 400 });
|
|
5182
5484
|
}
|
|
5183
5485
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5259,6 +5561,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5259
5561
|
overrideAccess: true
|
|
5260
5562
|
});
|
|
5261
5563
|
}
|
|
5564
|
+
const twoFactorDoc = await dbFindByID(payload, slugs.supportClients, {
|
|
5565
|
+
id: clientDoc.id,
|
|
5566
|
+
depth: 0,
|
|
5567
|
+
overrideAccess: true,
|
|
5568
|
+
showHiddenFields: true
|
|
5569
|
+
});
|
|
5570
|
+
if (twoFactorDoc?.twoFactorEnabled) {
|
|
5571
|
+
const raw = twoFactorDoc.twoFactorVerifiedAt;
|
|
5572
|
+
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
5573
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
|
|
5574
|
+
let challenge;
|
|
5575
|
+
try {
|
|
5576
|
+
challenge = issueTwoFactorChallenge(clientDoc.email);
|
|
5577
|
+
} catch {
|
|
5578
|
+
}
|
|
5579
|
+
return new Response(JSON.stringify({ requires2FA: true, ...challenge ? { challenge } : {} }), {
|
|
5580
|
+
status: 200,
|
|
5581
|
+
headers: new Headers({
|
|
5582
|
+
"Content-Type": "application/json",
|
|
5583
|
+
"Set-Cookie": clearedStateCookie()
|
|
5584
|
+
})
|
|
5585
|
+
});
|
|
5586
|
+
}
|
|
5587
|
+
await dbUpdate(payload, slugs.supportClients, {
|
|
5588
|
+
id: clientDoc.id,
|
|
5589
|
+
data: { twoFactorVerifiedAt: null },
|
|
5590
|
+
overrideAccess: true
|
|
5591
|
+
});
|
|
5592
|
+
}
|
|
5262
5593
|
const secret = process.env.PAYLOAD_SECRET;
|
|
5263
5594
|
if (!secret) {
|
|
5264
5595
|
return Response.json({ error: "server_misconfigured" }, { status: 500 });
|
|
@@ -5302,6 +5633,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5302
5633
|
"Set-Cookie",
|
|
5303
5634
|
`payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
|
|
5304
5635
|
);
|
|
5636
|
+
headers.append("Set-Cookie", clearedStateCookie());
|
|
5305
5637
|
return new Response(JSON.stringify({ user: clientDoc, exp }), {
|
|
5306
5638
|
status: 200,
|
|
5307
5639
|
headers
|
|
@@ -5608,7 +5940,7 @@ ONLY JSON, nothing else.`
|
|
|
5608
5940
|
}
|
|
5609
5941
|
}
|
|
5610
5942
|
function createImportConversationEndpoint(slugs, store) {
|
|
5611
|
-
const importLimiter = new RateLimiter(36e5, 10, store);
|
|
5943
|
+
const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
|
|
5612
5944
|
return {
|
|
5613
5945
|
path: "/support/import-conversation",
|
|
5614
5946
|
method: "post",
|
|
@@ -5751,62 +6083,204 @@ function createImportConversationEndpoint(slugs, store) {
|
|
|
5751
6083
|
}
|
|
5752
6084
|
};
|
|
5753
6085
|
}
|
|
6086
|
+
function httpAllowed() {
|
|
6087
|
+
return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === "1";
|
|
6088
|
+
}
|
|
6089
|
+
var BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa"];
|
|
6090
|
+
function parseIPv4(host) {
|
|
6091
|
+
const parts = host.split(".");
|
|
6092
|
+
if (parts.length !== 4) return null;
|
|
6093
|
+
const octets = [];
|
|
6094
|
+
for (const part of parts) {
|
|
6095
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
6096
|
+
const n = Number(part);
|
|
6097
|
+
if (n > 255) return null;
|
|
6098
|
+
octets.push(n);
|
|
6099
|
+
}
|
|
6100
|
+
return octets;
|
|
6101
|
+
}
|
|
6102
|
+
function isPrivateIPv4(octets) {
|
|
6103
|
+
const [a, b] = octets;
|
|
6104
|
+
if (a === 0) return true;
|
|
6105
|
+
if (a === 10) return true;
|
|
6106
|
+
if (a === 127) return true;
|
|
6107
|
+
if (a === 169 && b === 254) return true;
|
|
6108
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
6109
|
+
if (a === 192 && b === 168) return true;
|
|
6110
|
+
if (a === 192 && b === 0) return true;
|
|
6111
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
6112
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
6113
|
+
if (a >= 224) return true;
|
|
6114
|
+
return false;
|
|
6115
|
+
}
|
|
6116
|
+
function isPrivateIPv6(host) {
|
|
6117
|
+
const lower = host.toLowerCase();
|
|
6118
|
+
if (lower === "::" || lower === "::1") return true;
|
|
6119
|
+
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
|
|
6120
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
6121
|
+
if (lower.startsWith("ff")) return true;
|
|
6122
|
+
const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
6123
|
+
if (dotted) {
|
|
6124
|
+
const octets = parseIPv4(dotted[1]);
|
|
6125
|
+
return octets ? isPrivateIPv4(octets) : true;
|
|
6126
|
+
}
|
|
6127
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
6128
|
+
if (hex) {
|
|
6129
|
+
const high = parseInt(hex[1], 16);
|
|
6130
|
+
const low = parseInt(hex[2], 16);
|
|
6131
|
+
return isPrivateIPv4([high >> 8, high & 255, low >> 8, low & 255]);
|
|
6132
|
+
}
|
|
6133
|
+
return false;
|
|
6134
|
+
}
|
|
6135
|
+
function normalizeHost(hostname) {
|
|
6136
|
+
const host = hostname.trim().toLowerCase();
|
|
6137
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
6138
|
+
}
|
|
6139
|
+
function isBlockedHost(hostname) {
|
|
6140
|
+
const host = normalizeHost(hostname);
|
|
6141
|
+
if (!host) return true;
|
|
6142
|
+
if (host === "localhost") return true;
|
|
6143
|
+
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
|
|
6144
|
+
const v4 = parseIPv4(host);
|
|
6145
|
+
if (v4) return isPrivateIPv4(v4);
|
|
6146
|
+
if (host.includes(":")) return isPrivateIPv6(host);
|
|
6147
|
+
return false;
|
|
6148
|
+
}
|
|
6149
|
+
function validateWebhookUrl(raw) {
|
|
6150
|
+
if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "invalid_url" };
|
|
6151
|
+
let url;
|
|
6152
|
+
try {
|
|
6153
|
+
url = new URL(raw.trim());
|
|
6154
|
+
} catch {
|
|
6155
|
+
return { ok: false, reason: "invalid_url" };
|
|
6156
|
+
}
|
|
6157
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && httpAllowed())) {
|
|
6158
|
+
return { ok: false, reason: "scheme_not_allowed" };
|
|
6159
|
+
}
|
|
6160
|
+
if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
|
|
6161
|
+
return { ok: true, url };
|
|
6162
|
+
}
|
|
6163
|
+
var WEBHOOK_URL_MESSAGES = {
|
|
6164
|
+
invalid_url: "URL invalide.",
|
|
6165
|
+
scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
|
|
6166
|
+
private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
|
|
6167
|
+
};
|
|
6168
|
+
var LOOKUP_TIMEOUT_MS = 5e3;
|
|
6169
|
+
var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
|
|
6170
|
+
var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
|
|
6171
|
+
async function assertPublicHost(hostname) {
|
|
6172
|
+
const host = normalizeHost(hostname);
|
|
6173
|
+
if (isBlockedHost(host)) return false;
|
|
6174
|
+
if (parseIPv4(host) || host.includes(":")) return true;
|
|
6175
|
+
try {
|
|
6176
|
+
const addresses = await Promise.race([
|
|
6177
|
+
lookup(host, { all: true }),
|
|
6178
|
+
new Promise(
|
|
6179
|
+
(resolve) => setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.()
|
|
6180
|
+
)
|
|
6181
|
+
]);
|
|
6182
|
+
if (addresses === LOOKUP_TIMED_OUT) return false;
|
|
6183
|
+
if (!Array.isArray(addresses) || addresses.length === 0) return false;
|
|
6184
|
+
return addresses.every((entry) => !isBlockedHost(entry.address));
|
|
6185
|
+
} catch (error) {
|
|
6186
|
+
const code = error?.code;
|
|
6187
|
+
return typeof code === "string" && NONEXISTENT_HOST_CODES.has(code);
|
|
6188
|
+
}
|
|
6189
|
+
}
|
|
6190
|
+
var BlockedRequestError = class extends Error {
|
|
6191
|
+
constructor(reason) {
|
|
6192
|
+
super(`Blocked outbound request: ${reason}`);
|
|
6193
|
+
this.reason = reason;
|
|
6194
|
+
this.name = "BlockedRequestError";
|
|
6195
|
+
}
|
|
6196
|
+
reason;
|
|
6197
|
+
};
|
|
6198
|
+
var MAX_REDIRECTS = 3;
|
|
6199
|
+
async function safeFetch(rawUrl, init) {
|
|
6200
|
+
let current = rawUrl;
|
|
6201
|
+
let body = init.body;
|
|
6202
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
6203
|
+
const validation = validateWebhookUrl(current);
|
|
6204
|
+
if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || "invalid_url");
|
|
6205
|
+
if (!await assertPublicHost(validation.url.hostname)) throw new BlockedRequestError("private_host");
|
|
6206
|
+
const response = await fetch(current, { ...init, body, redirect: "manual" });
|
|
6207
|
+
if (response.status < 300 || response.status > 399) return response;
|
|
6208
|
+
const location = response.headers.get("location");
|
|
6209
|
+
if (!location) return response;
|
|
6210
|
+
current = new URL(location, current).toString();
|
|
6211
|
+
if (response.status === 303) body = void 0;
|
|
6212
|
+
}
|
|
6213
|
+
throw new BlockedRequestError("too_many_redirects");
|
|
6214
|
+
}
|
|
5754
6215
|
|
|
5755
|
-
// src/utils/
|
|
5756
|
-
|
|
6216
|
+
// src/utils/webhookDispatcher.ts
|
|
6217
|
+
function dispatchWebhook(data, event, payload, slugs) {
|
|
6218
|
+
const done = _dispatch(data, event, payload, slugs);
|
|
6219
|
+
return done;
|
|
6220
|
+
}
|
|
6221
|
+
async function _dispatch(data, event, payload, slugs) {
|
|
5757
6222
|
try {
|
|
5758
|
-
const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
|
|
6223
|
+
const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
|
|
5759
6224
|
where: {
|
|
5760
|
-
|
|
5761
|
-
|
|
6225
|
+
and: [
|
|
6226
|
+
{ active: { equals: true } },
|
|
6227
|
+
{ events: { contains: event } }
|
|
6228
|
+
]
|
|
5762
6229
|
},
|
|
5763
6230
|
limit: 50,
|
|
5764
6231
|
depth: 0,
|
|
5765
6232
|
overrideAccess: true
|
|
5766
6233
|
});
|
|
5767
|
-
if (endpoints.
|
|
5768
|
-
const
|
|
6234
|
+
if (endpoints.length === 0) return;
|
|
6235
|
+
const body = JSON.stringify({ event, data, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5769
6236
|
await Promise.allSettled(
|
|
5770
|
-
endpoints.
|
|
5771
|
-
try {
|
|
5772
|
-
const res = await fetch(endpoint.url, {
|
|
5773
|
-
method: "POST",
|
|
5774
|
-
headers: {
|
|
5775
|
-
"Content-Type": "application/json",
|
|
5776
|
-
...endpoint.secret ? { "X-Webhook-Secret": endpoint.secret } : {}
|
|
5777
|
-
},
|
|
5778
|
-
body: JSON.stringify({ event, data, timestamp }),
|
|
5779
|
-
signal: AbortSignal.timeout(1e4)
|
|
5780
|
-
});
|
|
5781
|
-
try {
|
|
5782
|
-
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
5783
|
-
id: endpoint.id,
|
|
5784
|
-
data: {
|
|
5785
|
-
lastTriggeredAt: timestamp,
|
|
5786
|
-
lastStatus: res.status
|
|
5787
|
-
},
|
|
5788
|
-
overrideAccess: true
|
|
5789
|
-
});
|
|
5790
|
-
} catch {
|
|
5791
|
-
}
|
|
5792
|
-
} catch (error) {
|
|
5793
|
-
console.warn(`[support] Webhook delivery failed: ${endpoint.url}`, error);
|
|
5794
|
-
try {
|
|
5795
|
-
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
5796
|
-
id: endpoint.id,
|
|
5797
|
-
data: {
|
|
5798
|
-
lastTriggeredAt: timestamp,
|
|
5799
|
-
lastStatus: 0
|
|
5800
|
-
},
|
|
5801
|
-
overrideAccess: true
|
|
5802
|
-
});
|
|
5803
|
-
} catch {
|
|
5804
|
-
}
|
|
5805
|
-
}
|
|
5806
|
-
})
|
|
6237
|
+
endpoints.map((endpoint) => _sendToEndpoint(endpoint, body, payload, slugs))
|
|
5807
6238
|
);
|
|
5808
|
-
} catch (
|
|
5809
|
-
console.error(
|
|
6239
|
+
} catch (err) {
|
|
6240
|
+
console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err);
|
|
6241
|
+
}
|
|
6242
|
+
}
|
|
6243
|
+
async function _sendToEndpoint(endpoint, body, payload, slugs) {
|
|
6244
|
+
try {
|
|
6245
|
+
const headers = {
|
|
6246
|
+
"Content-Type": "application/json",
|
|
6247
|
+
"User-Agent": "PayloadSupport-Webhook/1.0"
|
|
6248
|
+
};
|
|
6249
|
+
if (endpoint.secret) {
|
|
6250
|
+
const signature = crypto3.createHmac("sha256", endpoint.secret).update(body).digest("hex");
|
|
6251
|
+
headers["X-Webhook-Signature"] = signature;
|
|
6252
|
+
}
|
|
6253
|
+
const response = await safeFetch(endpoint.url, {
|
|
6254
|
+
method: "POST",
|
|
6255
|
+
headers,
|
|
6256
|
+
body,
|
|
6257
|
+
signal: AbortSignal.timeout(1e4)
|
|
6258
|
+
// 10s timeout
|
|
6259
|
+
});
|
|
6260
|
+
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
6261
|
+
id: endpoint.id,
|
|
6262
|
+
data: {
|
|
6263
|
+
lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6264
|
+
lastStatus: response.status
|
|
6265
|
+
},
|
|
6266
|
+
overrideAccess: true
|
|
6267
|
+
});
|
|
6268
|
+
if (!response.ok) {
|
|
6269
|
+
console.warn(`[webhook] ${endpoint.name || endpoint.url} returned ${response.status}`);
|
|
6270
|
+
}
|
|
6271
|
+
} catch (err) {
|
|
6272
|
+
console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
|
|
6273
|
+
try {
|
|
6274
|
+
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
6275
|
+
id: endpoint.id,
|
|
6276
|
+
data: {
|
|
6277
|
+
lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6278
|
+
lastStatus: 0
|
|
6279
|
+
},
|
|
6280
|
+
overrideAccess: true
|
|
6281
|
+
});
|
|
6282
|
+
} catch {
|
|
6283
|
+
}
|
|
5810
6284
|
}
|
|
5811
6285
|
}
|
|
5812
6286
|
|
|
@@ -5893,13 +6367,18 @@ function createProcessScheduledEndpoint(slugs) {
|
|
|
5893
6367
|
overrideAccess: true
|
|
5894
6368
|
});
|
|
5895
6369
|
}
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
6370
|
+
dispatchWebhook(
|
|
6371
|
+
{
|
|
6372
|
+
ticketId,
|
|
6373
|
+
messageId: msg.id,
|
|
6374
|
+
authorType: msg.authorType,
|
|
6375
|
+
scheduled: true,
|
|
6376
|
+
body: msg.body?.length > 500 ? msg.body.slice(0, 500) + "..." : msg.body
|
|
6377
|
+
},
|
|
6378
|
+
"ticket_replied",
|
|
6379
|
+
payload,
|
|
6380
|
+
slugs
|
|
6381
|
+
);
|
|
5903
6382
|
results.processed++;
|
|
5904
6383
|
} catch (err) {
|
|
5905
6384
|
console.error(`[process-scheduled] Error processing message ${message.id}:`, err);
|
|
@@ -6225,7 +6704,7 @@ function createUserPrefsGetEndpoint(slugs) {
|
|
|
6225
6704
|
requireAdmin(req, slugs);
|
|
6226
6705
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6227
6706
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
6228
|
-
where: { key: { equals: key } },
|
|
6707
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6229
6708
|
limit: 1,
|
|
6230
6709
|
depth: 0,
|
|
6231
6710
|
overrideAccess: true
|
|
@@ -6259,7 +6738,7 @@ function createUserPrefsPostEndpoint(slugs) {
|
|
|
6259
6738
|
const body = await req.json();
|
|
6260
6739
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6261
6740
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
6262
|
-
where: { key: { equals: key } },
|
|
6741
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6263
6742
|
limit: 1,
|
|
6264
6743
|
depth: 0,
|
|
6265
6744
|
overrideAccess: true
|
|
@@ -6415,8 +6894,10 @@ function createTicketFeedbackEndpoint(slugs) {
|
|
|
6415
6894
|
// src/endpoints/transfer-ticket.ts
|
|
6416
6895
|
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6417
6896
|
var MAX_TRANSFERS_PER_DAY = 5;
|
|
6897
|
+
var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
|
|
6418
6898
|
function createTransferTicketEndpoint(slugs, store) {
|
|
6419
|
-
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store);
|
|
6899
|
+
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store, "transfer:ticket");
|
|
6900
|
+
const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_USER_PER_DAY, store, "transfer:user");
|
|
6420
6901
|
return {
|
|
6421
6902
|
path: "/support/tickets/:id/transfer",
|
|
6422
6903
|
method: "post",
|
|
@@ -6451,12 +6932,18 @@ function createTransferTicketEndpoint(slugs, store) {
|
|
|
6451
6932
|
if (!isAdmin && !isOwner) {
|
|
6452
6933
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6453
6934
|
}
|
|
6454
|
-
if (await transferLimiter.check(`${req.user
|
|
6935
|
+
if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
|
|
6455
6936
|
return Response.json(
|
|
6456
6937
|
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
|
|
6457
6938
|
{ status: 429 }
|
|
6458
6939
|
);
|
|
6459
6940
|
}
|
|
6941
|
+
if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
|
|
6942
|
+
return Response.json(
|
|
6943
|
+
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
|
|
6944
|
+
{ status: 429 }
|
|
6945
|
+
);
|
|
6946
|
+
}
|
|
6460
6947
|
try {
|
|
6461
6948
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
|
|
6462
6949
|
const existing = await dbCount(payload, slugs.emailLogs, {
|
|
@@ -6598,8 +7085,11 @@ function statusToLabel(status) {
|
|
|
6598
7085
|
}
|
|
6599
7086
|
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6600
7087
|
var MAX_COLLABORATORS_PER_TICKET = 20;
|
|
7088
|
+
var MAX_NEW_ACCOUNTS_PER_INVITER = 30;
|
|
7089
|
+
var NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
6601
7090
|
function createInviteCollaboratorEndpoint(slugs, store) {
|
|
6602
|
-
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
7091
|
+
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "invite-collaborator");
|
|
7092
|
+
const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, "invite-collaborator:new-account");
|
|
6603
7093
|
return {
|
|
6604
7094
|
path: "/support/tickets/:id/invite",
|
|
6605
7095
|
method: "post",
|
|
@@ -6634,7 +7124,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6634
7124
|
if (!isAdmin && !isOwner) {
|
|
6635
7125
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6636
7126
|
}
|
|
6637
|
-
if (await inviteLimiter.check(
|
|
7127
|
+
if (await inviteLimiter.check(principalRateKey(req.user), req)) {
|
|
6638
7128
|
return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
|
|
6639
7129
|
}
|
|
6640
7130
|
const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
|
|
@@ -6658,6 +7148,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6658
7148
|
if (existing.docs.length > 0) {
|
|
6659
7149
|
inviteeId = existing.docs[0].id;
|
|
6660
7150
|
} else {
|
|
7151
|
+
if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
|
|
7152
|
+
return Response.json(
|
|
7153
|
+
{ error: "Trop de nouveaux comptes invit\xE9s. R\xE9essayez plus tard." },
|
|
7154
|
+
{ status: 429 }
|
|
7155
|
+
);
|
|
7156
|
+
}
|
|
6661
7157
|
const tempPassword = randomBytes(16).toString("hex");
|
|
6662
7158
|
const created = await dbCreate(payload, slugs.supportClients, {
|
|
6663
7159
|
data: {
|
|
@@ -6829,7 +7325,7 @@ function createSupportEndpoints(slugs, options) {
|
|
|
6829
7325
|
}
|
|
6830
7326
|
if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
|
|
6831
7327
|
if (!f || f.emailTracking !== false) {
|
|
6832
|
-
endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
|
|
7328
|
+
endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
|
|
6833
7329
|
}
|
|
6834
7330
|
if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
|
|
6835
7331
|
if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
|
|
@@ -6869,74 +7365,6 @@ async function createAdminNotification(payload, data, collectionSlug = "admin-no
|
|
|
6869
7365
|
console.error("[notification] Failed to create:", err);
|
|
6870
7366
|
}
|
|
6871
7367
|
}
|
|
6872
|
-
function dispatchWebhook(data, event, payload, slugs) {
|
|
6873
|
-
void _dispatch(data, event, payload, slugs);
|
|
6874
|
-
}
|
|
6875
|
-
async function _dispatch(data, event, payload, slugs) {
|
|
6876
|
-
try {
|
|
6877
|
-
const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
|
|
6878
|
-
where: {
|
|
6879
|
-
and: [
|
|
6880
|
-
{ active: { equals: true } },
|
|
6881
|
-
{ events: { contains: event } }
|
|
6882
|
-
]
|
|
6883
|
-
},
|
|
6884
|
-
limit: 50,
|
|
6885
|
-
depth: 0,
|
|
6886
|
-
overrideAccess: true
|
|
6887
|
-
});
|
|
6888
|
-
if (endpoints.length === 0) return;
|
|
6889
|
-
const body = JSON.stringify({ event, data, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6890
|
-
for (const endpoint of endpoints) {
|
|
6891
|
-
void _sendToEndpoint(endpoint, body, payload, slugs);
|
|
6892
|
-
}
|
|
6893
|
-
} catch (err) {
|
|
6894
|
-
console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err);
|
|
6895
|
-
}
|
|
6896
|
-
}
|
|
6897
|
-
async function _sendToEndpoint(endpoint, body, payload, slugs) {
|
|
6898
|
-
try {
|
|
6899
|
-
const headers = {
|
|
6900
|
-
"Content-Type": "application/json",
|
|
6901
|
-
"User-Agent": "PayloadSupport-Webhook/1.0"
|
|
6902
|
-
};
|
|
6903
|
-
if (endpoint.secret) {
|
|
6904
|
-
const signature = crypto3.createHmac("sha256", endpoint.secret).update(body).digest("hex");
|
|
6905
|
-
headers["X-Webhook-Signature"] = signature;
|
|
6906
|
-
}
|
|
6907
|
-
const response = await fetch(endpoint.url, {
|
|
6908
|
-
method: "POST",
|
|
6909
|
-
headers,
|
|
6910
|
-
body,
|
|
6911
|
-
signal: AbortSignal.timeout(1e4)
|
|
6912
|
-
// 10s timeout
|
|
6913
|
-
});
|
|
6914
|
-
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
6915
|
-
id: endpoint.id,
|
|
6916
|
-
data: {
|
|
6917
|
-
lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6918
|
-
lastStatus: response.status
|
|
6919
|
-
},
|
|
6920
|
-
overrideAccess: true
|
|
6921
|
-
});
|
|
6922
|
-
if (!response.ok) {
|
|
6923
|
-
console.warn(`[webhook] ${endpoint.name || endpoint.url} returned ${response.status}`);
|
|
6924
|
-
}
|
|
6925
|
-
} catch (err) {
|
|
6926
|
-
console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err);
|
|
6927
|
-
try {
|
|
6928
|
-
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
6929
|
-
id: endpoint.id,
|
|
6930
|
-
data: {
|
|
6931
|
-
lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6932
|
-
lastStatus: 0
|
|
6933
|
-
},
|
|
6934
|
-
overrideAccess: true
|
|
6935
|
-
});
|
|
6936
|
-
} catch {
|
|
6937
|
-
}
|
|
6938
|
-
}
|
|
6939
|
-
}
|
|
6940
7368
|
|
|
6941
7369
|
// src/hooks/ticketStatusEmail.ts
|
|
6942
7370
|
var STATUS_LABELS = {
|
|
@@ -7553,12 +7981,28 @@ function createAssignTicketNumber(slugs, options) {
|
|
|
7553
7981
|
}
|
|
7554
7982
|
function createAssignClientOnCreate(slugs) {
|
|
7555
7983
|
return async ({ data, operation, req }) => {
|
|
7556
|
-
if (operation === "create" && req.user?.collection === slugs.supportClients
|
|
7984
|
+
if (operation === "create" && req.user?.collection === slugs.supportClients) {
|
|
7557
7985
|
data.client = req.user.id;
|
|
7558
7986
|
}
|
|
7559
7987
|
return data;
|
|
7560
7988
|
};
|
|
7561
7989
|
}
|
|
7990
|
+
var CLIENT_CREATABLE_TICKET_FIELDS = ["subject", "category", "priority", "project"];
|
|
7991
|
+
function createRestrictClientCreate(slugs) {
|
|
7992
|
+
return async ({ data, operation, req }) => {
|
|
7993
|
+
if (operation !== "create") return data;
|
|
7994
|
+
if (req.user?.collection !== slugs.supportClients) return data;
|
|
7995
|
+
const incoming = data ?? {};
|
|
7996
|
+
const sanitized = {};
|
|
7997
|
+
for (const field2 of CLIENT_CREATABLE_TICKET_FIELDS) {
|
|
7998
|
+
if (incoming[field2] !== void 0) sanitized[field2] = incoming[field2];
|
|
7999
|
+
}
|
|
8000
|
+
sanitized.status = "open";
|
|
8001
|
+
sanitized.client = req.user.id;
|
|
8002
|
+
sanitized.source = "portal";
|
|
8003
|
+
return sanitized;
|
|
8004
|
+
};
|
|
8005
|
+
}
|
|
7562
8006
|
var autoPaidAt = async ({ data, operation, originalDoc }) => {
|
|
7563
8007
|
if (operation !== "update") return data;
|
|
7564
8008
|
if (data.paymentStatus === "paid" && originalDoc?.paymentStatus !== "paid" && !data.paidAt) {
|
|
@@ -7584,16 +8028,8 @@ function createAutoAssignAdmin(slugs) {
|
|
|
7584
8028
|
const { payload } = req;
|
|
7585
8029
|
let roundRobinEnabled = false;
|
|
7586
8030
|
try {
|
|
7587
|
-
const
|
|
7588
|
-
|
|
7589
|
-
where: { key: { equals: "support-round-robin" } },
|
|
7590
|
-
limit: 1,
|
|
7591
|
-
depth: 0,
|
|
7592
|
-
overrideAccess: true
|
|
7593
|
-
});
|
|
7594
|
-
if (prefs.docs.length > 0) {
|
|
7595
|
-
roundRobinEnabled = prefs.docs[0].value?.enabled === true;
|
|
7596
|
-
}
|
|
8031
|
+
const settings = await readSupportSettings(payload);
|
|
8032
|
+
roundRobinEnabled = settings.features.roundRobin === true;
|
|
7597
8033
|
} catch (err) {
|
|
7598
8034
|
console.warn("[support] Failed to read round-robin preferences:", err);
|
|
7599
8035
|
}
|
|
@@ -7802,54 +8238,6 @@ function createNotifyClientOnResolve(slugs) {
|
|
|
7802
8238
|
return doc;
|
|
7803
8239
|
};
|
|
7804
8240
|
}
|
|
7805
|
-
function createFireTicketWebhooks(slugs) {
|
|
7806
|
-
return async ({ doc, previousDoc, operation, req }) => {
|
|
7807
|
-
const { payload } = req;
|
|
7808
|
-
if (operation === "create") {
|
|
7809
|
-
fireWebhooks(payload, slugs, "ticket_created", {
|
|
7810
|
-
id: doc.id,
|
|
7811
|
-
ticketNumber: doc.ticketNumber,
|
|
7812
|
-
subject: doc.subject,
|
|
7813
|
-
status: doc.status,
|
|
7814
|
-
priority: doc.priority,
|
|
7815
|
-
category: doc.category
|
|
7816
|
-
});
|
|
7817
|
-
return doc;
|
|
7818
|
-
}
|
|
7819
|
-
if (operation === "update" && previousDoc) {
|
|
7820
|
-
if (previousDoc.status !== doc.status && doc.status === "resolved") {
|
|
7821
|
-
fireWebhooks(payload, slugs, "ticket_resolved", {
|
|
7822
|
-
id: doc.id,
|
|
7823
|
-
ticketNumber: doc.ticketNumber,
|
|
7824
|
-
subject: doc.subject,
|
|
7825
|
-
previousStatus: previousDoc.status
|
|
7826
|
-
});
|
|
7827
|
-
const clientId = typeof doc.client === "object" ? doc.client?.id : doc.client;
|
|
7828
|
-
if (clientId) {
|
|
7829
|
-
payload.update({
|
|
7830
|
-
collection: "client-summaries",
|
|
7831
|
-
where: { client: { equals: clientId } },
|
|
7832
|
-
data: { generatedAt: (/* @__PURE__ */ new Date(0)).toISOString() },
|
|
7833
|
-
// Force cache expiry
|
|
7834
|
-
overrideAccess: true
|
|
7835
|
-
}).catch(() => {
|
|
7836
|
-
});
|
|
7837
|
-
}
|
|
7838
|
-
}
|
|
7839
|
-
const oldAssigned = typeof previousDoc.assignedTo === "object" ? previousDoc.assignedTo?.id : previousDoc.assignedTo;
|
|
7840
|
-
const newAssigned = typeof doc.assignedTo === "object" ? doc.assignedTo?.id : doc.assignedTo;
|
|
7841
|
-
if (newAssigned && oldAssigned !== newAssigned) {
|
|
7842
|
-
fireWebhooks(payload, slugs, "ticket_assigned", {
|
|
7843
|
-
id: doc.id,
|
|
7844
|
-
ticketNumber: doc.ticketNumber,
|
|
7845
|
-
subject: doc.subject,
|
|
7846
|
-
assignedTo: newAssigned
|
|
7847
|
-
});
|
|
7848
|
-
}
|
|
7849
|
-
}
|
|
7850
|
-
return doc;
|
|
7851
|
-
};
|
|
7852
|
-
}
|
|
7853
8241
|
function createNotifyAdminOnNewTicket(slugs, notificationSlug) {
|
|
7854
8242
|
return async ({ doc, operation, req }) => {
|
|
7855
8243
|
if (operation !== "create") return doc;
|
|
@@ -7870,21 +8258,66 @@ function createNotifyAdminOnNewTicket(slugs, notificationSlug) {
|
|
|
7870
8258
|
}
|
|
7871
8259
|
function createDispatchWebhookOnTicket(slugs) {
|
|
7872
8260
|
return async ({ doc, previousDoc, operation, req }) => {
|
|
8261
|
+
const { payload } = req;
|
|
7873
8262
|
if (operation === "create") {
|
|
7874
8263
|
dispatchWebhook(
|
|
7875
|
-
{
|
|
8264
|
+
{
|
|
8265
|
+
ticketId: doc.id,
|
|
8266
|
+
id: doc.id,
|
|
8267
|
+
ticketNumber: doc.ticketNumber,
|
|
8268
|
+
subject: doc.subject,
|
|
8269
|
+
status: doc.status,
|
|
8270
|
+
priority: doc.priority,
|
|
8271
|
+
category: doc.category
|
|
8272
|
+
},
|
|
7876
8273
|
"ticket_created",
|
|
7877
|
-
|
|
8274
|
+
payload,
|
|
7878
8275
|
slugs
|
|
7879
8276
|
);
|
|
8277
|
+
return doc;
|
|
7880
8278
|
}
|
|
7881
|
-
if (operation === "update" && previousDoc
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
8279
|
+
if (operation === "update" && previousDoc) {
|
|
8280
|
+
if (previousDoc.status !== doc.status && doc.status === "resolved") {
|
|
8281
|
+
dispatchWebhook(
|
|
8282
|
+
{
|
|
8283
|
+
ticketId: doc.id,
|
|
8284
|
+
id: doc.id,
|
|
8285
|
+
ticketNumber: doc.ticketNumber,
|
|
8286
|
+
subject: doc.subject,
|
|
8287
|
+
previousStatus: previousDoc.status
|
|
8288
|
+
},
|
|
8289
|
+
"ticket_resolved",
|
|
8290
|
+
payload,
|
|
8291
|
+
slugs
|
|
8292
|
+
);
|
|
8293
|
+
const clientId = typeof doc.client === "object" ? doc.client?.id : doc.client;
|
|
8294
|
+
if (clientId) {
|
|
8295
|
+
payload.update({
|
|
8296
|
+
collection: "client-summaries",
|
|
8297
|
+
where: { client: { equals: clientId } },
|
|
8298
|
+
data: { generatedAt: (/* @__PURE__ */ new Date(0)).toISOString() },
|
|
8299
|
+
// Force cache expiry
|
|
8300
|
+
overrideAccess: true
|
|
8301
|
+
}).catch(() => {
|
|
8302
|
+
});
|
|
8303
|
+
}
|
|
8304
|
+
}
|
|
8305
|
+
const oldAssigned = typeof previousDoc.assignedTo === "object" ? previousDoc.assignedTo?.id : previousDoc.assignedTo;
|
|
8306
|
+
const newAssigned = typeof doc.assignedTo === "object" ? doc.assignedTo?.id : doc.assignedTo;
|
|
8307
|
+
if (newAssigned && oldAssigned !== newAssigned) {
|
|
8308
|
+
dispatchWebhook(
|
|
8309
|
+
{
|
|
8310
|
+
ticketId: doc.id,
|
|
8311
|
+
id: doc.id,
|
|
8312
|
+
ticketNumber: doc.ticketNumber,
|
|
8313
|
+
subject: doc.subject,
|
|
8314
|
+
assignedTo: newAssigned
|
|
8315
|
+
},
|
|
8316
|
+
"ticket_assigned",
|
|
8317
|
+
payload,
|
|
8318
|
+
slugs
|
|
8319
|
+
);
|
|
8320
|
+
}
|
|
7888
8321
|
}
|
|
7889
8322
|
return doc;
|
|
7890
8323
|
};
|
|
@@ -8010,7 +8443,10 @@ function createTicketsCollection(slugs, options) {
|
|
|
8010
8443
|
{ label: "Paiement partiel", value: "partial" },
|
|
8011
8444
|
{ label: "Pay\xE9", value: "paid" }
|
|
8012
8445
|
],
|
|
8013
|
-
|
|
8446
|
+
// `slugs.users`, not the literal 'users': the users collection slug is
|
|
8447
|
+
// configurable, and a hard-coded one silently opens this field to everybody
|
|
8448
|
+
// on a host that renamed it.
|
|
8449
|
+
access: { update: ({ req }) => req.user?.collection === slugs.users },
|
|
8014
8450
|
admin: { width: "50%", condition: (data) => !!data?.invoice }
|
|
8015
8451
|
},
|
|
8016
8452
|
{
|
|
@@ -8292,6 +8728,7 @@ function createTicketsCollection(slugs, options) {
|
|
|
8292
8728
|
{ name: "totalTimeMinutes", type: "number", defaultValue: 0, label: "Temps total (minutes)", admin: { readOnly: true, position: "sidebar" } }
|
|
8293
8729
|
],
|
|
8294
8730
|
hooks: {
|
|
8731
|
+
beforeValidate: [createRestrictClientCreate(slugs)],
|
|
8295
8732
|
beforeChange: [
|
|
8296
8733
|
createAssignTicketNumber(slugs, options?.ticketNumber),
|
|
8297
8734
|
createAssignClientOnCreate(slugs),
|
|
@@ -8312,7 +8749,6 @@ function createTicketsCollection(slugs, options) {
|
|
|
8312
8749
|
createNotifyClientOnResolve(slugs),
|
|
8313
8750
|
createTicketStatusEmail(slugs),
|
|
8314
8751
|
createNotifyAdminOnNewTicket(slugs, notificationSlug),
|
|
8315
|
-
createFireTicketWebhooks(slugs),
|
|
8316
8752
|
createDispatchWebhookOnTicket(slugs),
|
|
8317
8753
|
createApplyAutomationRules(slugs)
|
|
8318
8754
|
],
|
|
@@ -8374,7 +8810,7 @@ function createTicketsCollection(slugs, options) {
|
|
|
8374
8810
|
}
|
|
8375
8811
|
|
|
8376
8812
|
// src/utils/ticketAccess.ts
|
|
8377
|
-
async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
8813
|
+
async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
|
|
8378
8814
|
const ids = /* @__PURE__ */ new Set();
|
|
8379
8815
|
try {
|
|
8380
8816
|
const owned = await dbFind(payload, slugs.tickets, {
|
|
@@ -8395,6 +8831,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
|
8395
8831
|
});
|
|
8396
8832
|
for (const r of collab.docs) {
|
|
8397
8833
|
const row = r;
|
|
8834
|
+
if (mode === "write" && row.role !== "collaborator") continue;
|
|
8398
8835
|
const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
|
|
8399
8836
|
if (tid !== void 0 && tid !== null) ids.add(tid);
|
|
8400
8837
|
}
|
|
@@ -8493,6 +8930,21 @@ async function queueClientNotification(payload, slugs, item) {
|
|
|
8493
8930
|
}
|
|
8494
8931
|
|
|
8495
8932
|
// src/collections/TicketMessages.ts
|
|
8933
|
+
function createRestrictClientTicketTarget(slugs) {
|
|
8934
|
+
return async ({ data, operation, req, originalDoc }) => {
|
|
8935
|
+
if (req.user?.collection !== slugs.supportClients) return data;
|
|
8936
|
+
const raw = data.ticket ?? (operation === "update" ? originalDoc?.ticket : void 0);
|
|
8937
|
+
const targetId = raw && typeof raw === "object" ? raw.id : raw;
|
|
8938
|
+
if (targetId === void 0 || targetId === null || targetId === "") {
|
|
8939
|
+
throw new APIError("Ticket cible requis.", 400);
|
|
8940
|
+
}
|
|
8941
|
+
const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
|
|
8942
|
+
if (!accessible.some((id) => String(id) === String(targetId))) {
|
|
8943
|
+
throw new APIError("Ticket inaccessible.", 403);
|
|
8944
|
+
}
|
|
8945
|
+
return data;
|
|
8946
|
+
};
|
|
8947
|
+
}
|
|
8496
8948
|
function createAssignAuthor(slugs) {
|
|
8497
8949
|
return async ({ data, operation, req }) => {
|
|
8498
8950
|
if (operation === "create" && req.user?.collection === slugs.supportClients) {
|
|
@@ -8727,21 +9179,6 @@ function createNotifyAdminOnClientMessage(slugs, notificationSlug) {
|
|
|
8727
9179
|
return doc;
|
|
8728
9180
|
};
|
|
8729
9181
|
}
|
|
8730
|
-
function createFireMessageWebhooks(slugs) {
|
|
8731
|
-
return async ({ doc, operation, req }) => {
|
|
8732
|
-
if (operation !== "create") return doc;
|
|
8733
|
-
if (doc.scheduledAt && !doc.scheduledSent) return doc;
|
|
8734
|
-
if (doc.isInternal) return doc;
|
|
8735
|
-
const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
|
|
8736
|
-
fireWebhooks(req.payload, slugs, "ticket_replied", {
|
|
8737
|
-
ticketId,
|
|
8738
|
-
messageId: doc.id,
|
|
8739
|
-
authorType: doc.authorType,
|
|
8740
|
-
body: doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body
|
|
8741
|
-
});
|
|
8742
|
-
return doc;
|
|
8743
|
-
};
|
|
8744
|
-
}
|
|
8745
9182
|
function createDispatchWebhookOnReply(slugs) {
|
|
8746
9183
|
return async ({ doc, operation, req }) => {
|
|
8747
9184
|
if (operation !== "create") return doc;
|
|
@@ -8749,7 +9186,12 @@ function createDispatchWebhookOnReply(slugs) {
|
|
|
8749
9186
|
if (doc.scheduledAt && !doc.scheduledSent) return doc;
|
|
8750
9187
|
const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
|
|
8751
9188
|
dispatchWebhook(
|
|
8752
|
-
{
|
|
9189
|
+
{
|
|
9190
|
+
ticketId,
|
|
9191
|
+
messageId: doc.id,
|
|
9192
|
+
authorType: doc.authorType,
|
|
9193
|
+
body: doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body
|
|
9194
|
+
},
|
|
8753
9195
|
"ticket_replied",
|
|
8754
9196
|
req.payload,
|
|
8755
9197
|
slugs
|
|
@@ -8932,7 +9374,7 @@ function createTicketMessagesCollection(slugs, options) {
|
|
|
8932
9374
|
] : []
|
|
8933
9375
|
],
|
|
8934
9376
|
hooks: {
|
|
8935
|
-
beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
|
|
9377
|
+
beforeChange: [createRestrictClientTicketTarget(slugs), createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
|
|
8936
9378
|
afterChange: [
|
|
8937
9379
|
...options?.capabilities?.sms ? [createSmsNotification(slugs, options.capabilities.sms)] : [],
|
|
8938
9380
|
createAutoUpdateStatus(slugs),
|
|
@@ -8941,7 +9383,6 @@ function createTicketMessagesCollection(slugs, options) {
|
|
|
8941
9383
|
createCheckSlaOnReply(slugs, notificationSlug),
|
|
8942
9384
|
createSyncTicketReplyToChat(slugs),
|
|
8943
9385
|
createNotifyAdminOnClientMessage(slugs, notificationSlug),
|
|
8944
|
-
createFireMessageWebhooks(slugs),
|
|
8945
9386
|
createDispatchWebhookOnReply(slugs),
|
|
8946
9387
|
createNotifyMentions(slugs),
|
|
8947
9388
|
...options?.capabilities?.threadCleanup ? [createThreadCleanup(options.capabilities.threadCleanup)] : []
|
|
@@ -9014,13 +9455,13 @@ function createSendInvitationOnCreate(slugs) {
|
|
|
9014
9455
|
return doc;
|
|
9015
9456
|
};
|
|
9016
9457
|
}
|
|
9017
|
-
var
|
|
9458
|
+
var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
|
|
9018
9459
|
function createEnforce2FA(slugs) {
|
|
9019
9460
|
return async ({ req, user }) => {
|
|
9020
9461
|
if (!user?.twoFactorEnabled) return user;
|
|
9021
9462
|
const raw = user.twoFactorVerifiedAt;
|
|
9022
9463
|
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
9023
|
-
if (!(verifiedAt > Date.now() -
|
|
9464
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
|
|
9024
9465
|
throw new APIError("2FA_REQUIRED", 401);
|
|
9025
9466
|
}
|
|
9026
9467
|
await req.payload.update({
|
|
@@ -9034,7 +9475,11 @@ function createEnforce2FA(slugs) {
|
|
|
9034
9475
|
return user;
|
|
9035
9476
|
};
|
|
9036
9477
|
}
|
|
9478
|
+
function createAdminOnlyFieldUpdate(slugs) {
|
|
9479
|
+
return ({ req }) => req.user?.collection === slugs.users;
|
|
9480
|
+
}
|
|
9037
9481
|
function createSupportClientsCollection(slugs, options) {
|
|
9482
|
+
const adminOnlyFieldUpdate = createAdminOnlyFieldUpdate(slugs);
|
|
9038
9483
|
return {
|
|
9039
9484
|
slug: slugs.supportClients,
|
|
9040
9485
|
labels: {
|
|
@@ -9164,7 +9609,8 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9164
9609
|
label: "Opportunites commerciales",
|
|
9165
9610
|
access: {
|
|
9166
9611
|
// Admin-only: never serialize commercial notes onto the client's own doc.
|
|
9167
|
-
read: ({ req }) => req.user?.collection === slugs.users
|
|
9612
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
9613
|
+
update: adminOnlyFieldUpdate
|
|
9168
9614
|
},
|
|
9169
9615
|
admin: {
|
|
9170
9616
|
description: "Notes commerciales (visible uniquement par les admins)"
|
|
@@ -9174,6 +9620,7 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9174
9620
|
name: "tier",
|
|
9175
9621
|
type: "select",
|
|
9176
9622
|
label: "Plan",
|
|
9623
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9177
9624
|
options: [
|
|
9178
9625
|
{ label: "Free", value: "free" },
|
|
9179
9626
|
{ label: "Basic", value: "basic" },
|
|
@@ -9187,6 +9634,7 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9187
9634
|
type: "relationship",
|
|
9188
9635
|
relationTo: slugs.users,
|
|
9189
9636
|
label: "Agent referent",
|
|
9637
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9190
9638
|
admin: { position: "sidebar" }
|
|
9191
9639
|
},
|
|
9192
9640
|
{
|
|
@@ -9202,17 +9650,20 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9202
9650
|
{
|
|
9203
9651
|
name: "twoFactorCode",
|
|
9204
9652
|
type: "text",
|
|
9653
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9205
9654
|
admin: { hidden: true }
|
|
9206
9655
|
},
|
|
9207
9656
|
{
|
|
9208
9657
|
name: "twoFactorExpiry",
|
|
9209
9658
|
type: "date",
|
|
9659
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9210
9660
|
admin: { hidden: true }
|
|
9211
9661
|
},
|
|
9212
9662
|
{
|
|
9213
9663
|
// Set by /support/2fa "verify"; gates login via the beforeLogin hook.
|
|
9214
9664
|
name: "twoFactorVerifiedAt",
|
|
9215
9665
|
type: "date",
|
|
9666
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9216
9667
|
admin: { hidden: true }
|
|
9217
9668
|
},
|
|
9218
9669
|
{
|
|
@@ -9220,6 +9671,7 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9220
9671
|
name: "googleId",
|
|
9221
9672
|
type: "text",
|
|
9222
9673
|
index: true,
|
|
9674
|
+
access: { update: adminOnlyFieldUpdate },
|
|
9223
9675
|
admin: { hidden: true }
|
|
9224
9676
|
},
|
|
9225
9677
|
{
|
|
@@ -9290,7 +9742,8 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9290
9742
|
label: "Notes internes",
|
|
9291
9743
|
access: {
|
|
9292
9744
|
// Admin-only: never serialize internal notes onto the client's own doc.
|
|
9293
|
-
read: ({ req }) => req.user?.collection === slugs.users
|
|
9745
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
9746
|
+
update: adminOnlyFieldUpdate
|
|
9294
9747
|
},
|
|
9295
9748
|
admin: {
|
|
9296
9749
|
description: "Visible uniquement par les admins",
|
|
@@ -9363,36 +9816,49 @@ function createSupportClientsCollection(slugs, options) {
|
|
|
9363
9816
|
}
|
|
9364
9817
|
|
|
9365
9818
|
// src/collections/TimeEntries.ts
|
|
9819
|
+
async function recalculateTicketTime(payload, slugs, ticketId) {
|
|
9820
|
+
let totalMinutes = 0;
|
|
9821
|
+
let page = 1;
|
|
9822
|
+
let hasMore = true;
|
|
9823
|
+
while (hasMore) {
|
|
9824
|
+
const entries = await payload.find({
|
|
9825
|
+
collection: slugs.timeEntries,
|
|
9826
|
+
where: { ticket: { equals: ticketId } },
|
|
9827
|
+
limit: 100,
|
|
9828
|
+
page,
|
|
9829
|
+
depth: 0,
|
|
9830
|
+
overrideAccess: true,
|
|
9831
|
+
select: { duration: true }
|
|
9832
|
+
});
|
|
9833
|
+
for (const entry of entries.docs) {
|
|
9834
|
+
totalMinutes += entry.duration || 0;
|
|
9835
|
+
}
|
|
9836
|
+
hasMore = entries.hasNextPage ?? false;
|
|
9837
|
+
page++;
|
|
9838
|
+
}
|
|
9839
|
+
await payload.update({
|
|
9840
|
+
collection: slugs.tickets,
|
|
9841
|
+
id: ticketId,
|
|
9842
|
+
data: { totalTimeMinutes: totalMinutes },
|
|
9843
|
+
overrideAccess: true
|
|
9844
|
+
});
|
|
9845
|
+
}
|
|
9366
9846
|
function createRecalculateTicketTime(slugs) {
|
|
9367
9847
|
return async ({ doc, req }) => {
|
|
9368
9848
|
if (!doc.ticket) return;
|
|
9369
|
-
const { payload } = req;
|
|
9370
9849
|
const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
|
|
9371
|
-
|
|
9372
|
-
|
|
9373
|
-
|
|
9374
|
-
|
|
9375
|
-
|
|
9376
|
-
|
|
9377
|
-
|
|
9378
|
-
|
|
9379
|
-
|
|
9380
|
-
|
|
9381
|
-
|
|
9382
|
-
select: { duration: true }
|
|
9383
|
-
});
|
|
9384
|
-
for (const entry of entries.docs) {
|
|
9385
|
-
totalMinutes += entry.duration || 0;
|
|
9386
|
-
}
|
|
9387
|
-
hasMore = entries.hasNextPage ?? false;
|
|
9388
|
-
page++;
|
|
9850
|
+
await recalculateTicketTime(req.payload, slugs, ticketId);
|
|
9851
|
+
};
|
|
9852
|
+
}
|
|
9853
|
+
function createRecalculateTicketTimeOnDelete(slugs) {
|
|
9854
|
+
return async ({ doc, req }) => {
|
|
9855
|
+
if (!doc?.ticket) return;
|
|
9856
|
+
const ticketId = typeof doc.ticket === "object" ? doc.ticket.id : doc.ticket;
|
|
9857
|
+
try {
|
|
9858
|
+
await recalculateTicketTime(req.payload, slugs, ticketId);
|
|
9859
|
+
} catch (err) {
|
|
9860
|
+
console.error("[support] Failed to recalculate ticket time after delete:", err);
|
|
9389
9861
|
}
|
|
9390
|
-
await payload.update({
|
|
9391
|
-
collection: slugs.tickets,
|
|
9392
|
-
id: ticketId,
|
|
9393
|
-
data: { totalTimeMinutes: totalMinutes },
|
|
9394
|
-
overrideAccess: true
|
|
9395
|
-
});
|
|
9396
9862
|
};
|
|
9397
9863
|
}
|
|
9398
9864
|
function createTimeEntriesCollection(slugs) {
|
|
@@ -9454,7 +9920,8 @@ function createTimeEntriesCollection(slugs) {
|
|
|
9454
9920
|
}
|
|
9455
9921
|
],
|
|
9456
9922
|
hooks: {
|
|
9457
|
-
afterChange: [createRecalculateTicketTime(slugs)]
|
|
9923
|
+
afterChange: [createRecalculateTicketTime(slugs)],
|
|
9924
|
+
afterDelete: [createRecalculateTicketTimeOnDelete(slugs)]
|
|
9458
9925
|
},
|
|
9459
9926
|
access: {
|
|
9460
9927
|
create: ({ req }) => req.user?.collection === slugs.users,
|
|
@@ -9835,8 +10302,28 @@ function createKnowledgeBaseCollection(slugs) {
|
|
|
9835
10302
|
timestamps: true
|
|
9836
10303
|
};
|
|
9837
10304
|
}
|
|
9838
|
-
|
|
9839
|
-
|
|
10305
|
+
function createRestrictClientChatWrite(slugs) {
|
|
10306
|
+
return async ({ data, req }) => {
|
|
10307
|
+
if (req.user?.collection !== slugs.supportClients) return data;
|
|
10308
|
+
data.client = req.user.id;
|
|
10309
|
+
data.senderType = "client";
|
|
10310
|
+
delete data.agent;
|
|
10311
|
+
const session = typeof data.session === "string" ? data.session : null;
|
|
10312
|
+
if (!session) return data;
|
|
10313
|
+
const existing = await dbFind(req.payload, slugs.chatMessages, {
|
|
10314
|
+
where: { session: { equals: session } },
|
|
10315
|
+
limit: 1,
|
|
10316
|
+
depth: 0,
|
|
10317
|
+
overrideAccess: true
|
|
10318
|
+
});
|
|
10319
|
+
const owner = existing.docs[0]?.client;
|
|
10320
|
+
const ownerId = owner && typeof owner === "object" ? owner.id : owner;
|
|
10321
|
+
if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== String(req.user.id)) {
|
|
10322
|
+
throw new APIError("Session inaccessible.", 403);
|
|
10323
|
+
}
|
|
10324
|
+
return data;
|
|
10325
|
+
};
|
|
10326
|
+
}
|
|
9840
10327
|
function createChatMessagesCollection(slugs) {
|
|
9841
10328
|
return {
|
|
9842
10329
|
slug: slugs.chatMessages,
|
|
@@ -9858,7 +10345,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9858
10345
|
}
|
|
9859
10346
|
return false;
|
|
9860
10347
|
},
|
|
9861
|
-
|
|
10348
|
+
// Staff and support-clients only — NOT "any authenticated principal":
|
|
10349
|
+
// a user of any other auth collection of the host app satisfied `!!req.user`.
|
|
10350
|
+
create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
|
|
9862
10351
|
update: ({ req }) => req.user?.collection === slugs.users,
|
|
9863
10352
|
delete: ({ req }) => req.user?.collection === slugs.users
|
|
9864
10353
|
},
|
|
@@ -9927,6 +10416,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9927
10416
|
}
|
|
9928
10417
|
}
|
|
9929
10418
|
],
|
|
10419
|
+
hooks: {
|
|
10420
|
+
beforeChange: [createRestrictClientChatWrite(slugs)]
|
|
10421
|
+
},
|
|
9930
10422
|
timestamps: true
|
|
9931
10423
|
};
|
|
9932
10424
|
}
|
|
@@ -10204,8 +10696,19 @@ function createAuthLogsCollection(slugs) {
|
|
|
10204
10696
|
timestamps: true
|
|
10205
10697
|
};
|
|
10206
10698
|
}
|
|
10207
|
-
|
|
10208
|
-
|
|
10699
|
+
function createValidateWebhookUrl() {
|
|
10700
|
+
return ({ data, operation, originalDoc }) => {
|
|
10701
|
+
const incoming = data?.url;
|
|
10702
|
+
if (incoming === void 0 || incoming === null) return data;
|
|
10703
|
+
const previous = originalDoc?.url;
|
|
10704
|
+
if (operation === "update" && incoming === previous) return data;
|
|
10705
|
+
const result = validateWebhookUrl(incoming);
|
|
10706
|
+
if (!result.ok) {
|
|
10707
|
+
throw new APIError(WEBHOOK_URL_MESSAGES[result.reason || "invalid_url"], 400);
|
|
10708
|
+
}
|
|
10709
|
+
return data;
|
|
10710
|
+
};
|
|
10711
|
+
}
|
|
10209
10712
|
function createWebhookEndpointsCollection(slugs) {
|
|
10210
10713
|
return {
|
|
10211
10714
|
slug: slugs.webhookEndpoints,
|
|
@@ -10239,8 +10742,11 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10239
10742
|
type: "text",
|
|
10240
10743
|
required: true,
|
|
10241
10744
|
label: "URL",
|
|
10745
|
+
// The SSRF check lives in the collection `beforeValidate` above, NOT in a
|
|
10746
|
+
// field `validate`: the latter re-runs on the merged document and would
|
|
10747
|
+
// freeze every pre-existing row on any unrelated edit.
|
|
10242
10748
|
admin: {
|
|
10243
|
-
description: "URL du webhook \xE0 appeler (POST)"
|
|
10749
|
+
description: "URL https:// du webhook \xE0 appeler (POST). Les adresses priv\xE9es et loopback sont refus\xE9es."
|
|
10244
10750
|
}
|
|
10245
10751
|
},
|
|
10246
10752
|
{
|
|
@@ -10294,6 +10800,9 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10294
10800
|
}
|
|
10295
10801
|
}
|
|
10296
10802
|
],
|
|
10803
|
+
hooks: {
|
|
10804
|
+
beforeValidate: [createValidateWebhookUrl()]
|
|
10805
|
+
},
|
|
10297
10806
|
timestamps: true
|
|
10298
10807
|
};
|
|
10299
10808
|
}
|
|
@@ -10745,11 +11254,17 @@ function createClientSummariesCollection(slugs) {
|
|
|
10745
11254
|
admin: { readOnly: true }
|
|
10746
11255
|
}
|
|
10747
11256
|
],
|
|
11257
|
+
// Staff-only, on the SAME source of truth as every other collection and as
|
|
11258
|
+
// `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
|
|
11259
|
+
// against is the DEFAULT slug, not the configured one — on a host app whose
|
|
11260
|
+
// staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
|
|
11261
|
+
// the front-office collection instead, opening read/create/update/delete on
|
|
11262
|
+
// AI-generated client intelligence to it while locking the real agents out.
|
|
10748
11263
|
access: {
|
|
10749
|
-
create: ({ req }) => req.user?.collection ===
|
|
10750
|
-
read: ({ req }) => req.user?.collection ===
|
|
10751
|
-
update: ({ req }) => req.user?.collection ===
|
|
10752
|
-
delete: ({ req }) => req.user?.collection ===
|
|
11264
|
+
create: ({ req }) => req.user?.collection === slugs.users,
|
|
11265
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
11266
|
+
update: ({ req }) => req.user?.collection === slugs.users,
|
|
11267
|
+
delete: ({ req }) => req.user?.collection === slugs.users
|
|
10753
11268
|
},
|
|
10754
11269
|
timestamps: true
|
|
10755
11270
|
};
|
|
@@ -10880,7 +11395,12 @@ function createTicketCollaboratorsCollection(slugs) {
|
|
|
10880
11395
|
}
|
|
10881
11396
|
return false;
|
|
10882
11397
|
},
|
|
10883
|
-
|
|
11398
|
+
// Staff only. This collection is the source of truth for
|
|
11399
|
+
// resolveAccessibleTicketIds(), which widens `read` on tickets, messages
|
|
11400
|
+
// and the activity log — so letting any authenticated principal POST here
|
|
11401
|
+
// let a support client grant themselves access to any ticket by id.
|
|
11402
|
+
// The /invite endpoint is unaffected: it writes with overrideAccess: true.
|
|
11403
|
+
create: ({ req }) => req.user?.collection === slugs.users,
|
|
10884
11404
|
update: () => false,
|
|
10885
11405
|
delete: ({ req }) => req.user?.collection === slugs.users
|
|
10886
11406
|
},
|
|
@@ -11208,6 +11728,17 @@ function supportPlugin(config) {
|
|
|
11208
11728
|
});
|
|
11209
11729
|
return {
|
|
11210
11730
|
...incomingConfig,
|
|
11731
|
+
// Publish the resolved staff collection so the server-side readers share
|
|
11732
|
+
// ONE source of truth with the writers. `requireAdmin` compares against
|
|
11733
|
+
// `slugs.users`; the `payload-preferences` reads used to scope themselves
|
|
11734
|
+
// on `config.admin.user`, which Payload silently defaults to the first
|
|
11735
|
+
// auth collection of the host app — a different collection on any app
|
|
11736
|
+
// that declares `collectionSlugs.users`, and the settings-poisoning hole
|
|
11737
|
+
// reopened right there.
|
|
11738
|
+
custom: {
|
|
11739
|
+
...incomingConfig.custom,
|
|
11740
|
+
[SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users
|
|
11741
|
+
},
|
|
11211
11742
|
collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
|
|
11212
11743
|
endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
|
|
11213
11744
|
admin: {
|
|
@@ -11221,4 +11752,4 @@ function supportPlugin(config) {
|
|
|
11221
11752
|
};
|
|
11222
11753
|
}
|
|
11223
11754
|
|
|
11224
|
-
export { DEFAULT_FEATURES, DEFAULT_INBOUND_EMAIL_LIMITS, DEFAULT_SETTINGS, DEFAULT_SLUGS, DEFAULT_USER_PREFS, MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter, calculateBusinessHoursDeadline, createAdminNotification, createAssignSlaDeadlines, createAuthLogsCollection, createCannedResponsesCollection, createChatMessagesCollection, createCheckSlaOnReply, createCheckSlaOnResolve, createEmailLogsCollection, createKnowledgeBaseCollection, createLoginEndpoint, createMacrosCollection, createOAuthGoogleEndpoint, createPendingEmailsCollection, createSatisfactionSurveysCollection, createSlaPoliciesCollection, createSupportClientsCollection, createTicketActivityLogCollection, createTicketCollaboratorsCollection, createTicketMessagesCollection, createTicketStatusEmail, createTicketStatusesCollection, createTicketsCollection, createTimeEntriesCollection, createTrackOpenEndpoint, createWebhookEndpointsCollection, dispatchWebhook, generateTicketSynthesis, readSupportSettings, readUserPrefs, resolveSlugs, supportPlugin, validateInboundEmailPayload, verifySecret };
|
|
11755
|
+
export { DEFAULT_FEATURES, DEFAULT_INBOUND_EMAIL_LIMITS, DEFAULT_SETTINGS, DEFAULT_SLUGS, DEFAULT_TICKETING_FEATURES, DEFAULT_USER_PREFS, MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter, calculateBusinessHoursDeadline, createAdminNotification, createAssignSlaDeadlines, createAuthLogsCollection, createCannedResponsesCollection, createChatMessagesCollection, createCheckSlaOnReply, createCheckSlaOnResolve, createEmailLogsCollection, createKnowledgeBaseCollection, createLoginEndpoint, createMacrosCollection, createOAuthGoogleEndpoint, createPendingEmailsCollection, createSatisfactionSurveysCollection, createSlaPoliciesCollection, createSupportClientsCollection, createTicketActivityLogCollection, createTicketCollaboratorsCollection, createTicketMessagesCollection, createTicketStatusEmail, createTicketStatusesCollection, createTicketsCollection, createTimeEntriesCollection, createTrackOpenEndpoint, createWebhookEndpointsCollection, dispatchWebhook, generateTicketSynthesis, normalizeFeatures, projectAutoClose, readSupportSettings, readSupportSettingsState, readUserPrefs, resolveSlugs, stripProjectedFeatures, supportPlugin, validateInboundEmailPayload, verifySecret };
|