@consilioweb/payload-support 1.1.1 → 2.0.1
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 +74 -3
- package/dist/components/TicketConversation/components/AISummaryPanel.cjs +22 -14
- package/dist/components/TicketConversation/components/AISummaryPanel.js +23 -16
- package/dist/components/TicketConversation/components/ActionPanels.cjs +5 -5
- package/dist/components/TicketConversation/components/ActionPanels.js +5 -5
- package/dist/components/TicketConversation/components/ClientBar.cjs +1 -1
- package/dist/components/TicketConversation/components/ClientBar.js +1 -1
- package/dist/components/TicketConversation/components/QuickActions.cjs +2 -2
- package/dist/components/TicketConversation/components/QuickActions.js +2 -2
- package/dist/components/TicketConversation/components/TicketHeader.cjs +1 -1
- package/dist/components/TicketConversation/components/TicketHeader.js +1 -1
- package/dist/components/TicketConversation/hooks/useTranslation.cjs +25 -2
- package/dist/components/TicketConversation/hooks/useTranslation.js +22 -3
- package/dist/components/TicketConversation/index.cjs +10 -10
- package/dist/components/TicketConversation/index.js +10 -10
- package/dist/index.cjs +709 -173
- package/dist/index.d.cts +165 -3
- package/dist/index.d.ts +165 -3
- package/dist/index.js +702 -175
- package/dist/styles/ChatView.module.scss +1 -1
- package/dist/styles/TicketDetail.module.scss +3 -3
- package/dist/styles/_tokens.scss +1 -1
- package/dist/styles/theme.css +6 -6
- package/dist/views/SupportDashboardView/client.cjs +1 -1
- package/dist/views/SupportDashboardView/client.js +1 -1
- package/dist/views/TicketingSettingsView/client.cjs +4 -4
- package/dist/views/TicketingSettingsView/client.js +4 -4
- package/dist/views/shared/locales/en.json +708 -0
- package/dist/views/shared/locales/fr.json +708 -0
- package/package.json +19 -19
- package/src/__tests__/aiSummaryRendering.test.ts +21 -0
- package/src/__tests__/authResponses.test.ts +69 -0
- package/src/__tests__/capabilities.test.ts +71 -0
- package/src/__tests__/generateTrackingToken.test.ts +18 -4
- package/src/__tests__/integration/core-behaviors.test.ts +23 -0
- package/src/__tests__/rateLimiter.test.ts +60 -66
- package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
- package/src/__tests__/translations.test.ts +67 -0
- package/src/__tests__/webhookSecurity.test.ts +58 -0
- package/src/collections/PendingEmails.ts +2 -1
- package/src/collections/SupportClients.ts +43 -1
- package/src/collections/SupportCounters.ts +14 -0
- package/src/collections/SupportRateLimits.ts +20 -0
- package/src/collections/TicketMessages.ts +76 -0
- package/src/collections/Tickets.ts +179 -39
- package/src/collections/index.ts +2 -0
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
- package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
- package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
- package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
- package/src/components/TicketConversation/hooks/useTranslation.ts +38 -4
- package/src/components/TicketConversation/index.tsx +10 -10
- package/src/endpoints/admin-chat.ts +4 -4
- package/src/endpoints/ai-agent.ts +6 -1
- package/src/endpoints/ai.ts +10 -5
- package/src/endpoints/auth-2fa.ts +6 -7
- package/src/endpoints/auto-close.ts +2 -1
- package/src/endpoints/capabilities.ts +144 -0
- package/src/endpoints/channels.ts +2 -1
- package/src/endpoints/chat.ts +6 -6
- package/src/endpoints/chatbot.ts +4 -4
- package/src/endpoints/client-intelligence.ts +13 -4
- package/src/endpoints/import-conversation.ts +6 -5
- package/src/endpoints/index.ts +35 -14
- package/src/endpoints/invite-collaborator.ts +4 -4
- package/src/endpoints/login.ts +4 -5
- package/src/endpoints/oauth-google.ts +1 -1
- package/src/endpoints/process-digests.ts +2 -1
- package/src/endpoints/process-scheduled.ts +2 -1
- package/src/endpoints/process-snooze.ts +2 -1
- package/src/endpoints/resend-notification.ts +4 -4
- package/src/endpoints/send-reminder.ts +4 -4
- package/src/endpoints/ticket-synthesis.ts +17 -2
- package/src/endpoints/track-open.ts +44 -21
- package/src/endpoints/transfer-ticket.ts +4 -4
- package/src/index.ts +7 -0
- package/src/plugin.ts +16 -1
- package/src/portal/LiveChat.tsx +1 -1
- package/src/portal/auth/ChatWidget.tsx +4 -4
- package/src/portal/auth/ChatbotWidget.tsx +4 -4
- package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
- package/src/portal/auth/faq/FAQSearch.tsx +1 -1
- package/src/portal/auth/faq/page.tsx +2 -2
- package/src/portal/auth/profile/page.tsx +17 -17
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
- package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
- package/src/portal/auth/tickets/detail/page.tsx +5 -5
- package/src/portal/auth/tickets/new/page.tsx +8 -8
- package/src/portal/forgot-password/page.tsx +3 -3
- package/src/portal/layout.tsx +5 -2
- package/src/portal/login/page.tsx +7 -7
- package/src/portal/page.tsx +9 -9
- package/src/portal/register/page.tsx +4 -4
- package/src/portal/reset-password/page.tsx +3 -3
- package/src/styles/ChatView.module.scss +1 -1
- package/src/styles/TicketDetail.module.scss +3 -3
- package/src/styles/_tokens.scss +1 -1
- package/src/styles/theme.css +6 -6
- package/src/types.ts +71 -0
- package/src/utils/rateLimiter.ts +131 -38
- package/src/utils/slugs.ts +4 -0
- package/src/utils/webhookSecurity.ts +81 -0
- package/src/views/SupportDashboardView/client.tsx +1 -1
- package/src/views/TicketingSettingsView/client.tsx +4 -4
- package/src/views/shared/locales/en.json +2 -2
- package/src/views/shared/locales/fr.json +2 -2
|
@@ -1,22 +1,56 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect } from 'react'
|
|
2
2
|
import frTranslations from '../locales/fr.json'
|
|
3
3
|
import enTranslations from '../locales/en.json'
|
|
4
|
+
import frViewTranslations from '../../../views/shared/locales/fr.json'
|
|
5
|
+
import enViewTranslations from '../../../views/shared/locales/en.json'
|
|
4
6
|
|
|
5
7
|
type Locale = 'fr' | 'en'
|
|
6
8
|
|
|
7
9
|
const LOCALE_STORAGE_KEY = 'support_locale'
|
|
8
10
|
const DEFAULT_LOCALE: Locale = 'fr'
|
|
9
11
|
|
|
12
|
+
function mergeTranslations(
|
|
13
|
+
base: Record<string, unknown>,
|
|
14
|
+
supplement: Record<string, unknown>,
|
|
15
|
+
): Record<string, unknown> {
|
|
16
|
+
const merged = { ...base }
|
|
17
|
+
|
|
18
|
+
for (const [key, value] of Object.entries(supplement)) {
|
|
19
|
+
const current = merged[key]
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
current != null
|
|
23
|
+
&& value != null
|
|
24
|
+
&& typeof current === 'object'
|
|
25
|
+
&& typeof value === 'object'
|
|
26
|
+
&& !Array.isArray(current)
|
|
27
|
+
&& !Array.isArray(value)
|
|
28
|
+
) {
|
|
29
|
+
merged[key] = mergeTranslations(
|
|
30
|
+
current as Record<string, unknown>,
|
|
31
|
+
value as Record<string, unknown>,
|
|
32
|
+
)
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (current === undefined || (typeof current !== 'object' && typeof value === 'object')) {
|
|
37
|
+
merged[key] = value
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return merged
|
|
42
|
+
}
|
|
43
|
+
|
|
10
44
|
const translations: Record<Locale, Record<string, unknown>> = {
|
|
11
|
-
fr: frTranslations,
|
|
12
|
-
en: enTranslations,
|
|
45
|
+
fr: mergeTranslations(frTranslations, frViewTranslations),
|
|
46
|
+
en: mergeTranslations(enTranslations, enViewTranslations),
|
|
13
47
|
}
|
|
14
48
|
|
|
15
49
|
/**
|
|
16
50
|
* Resolve a dot-notation key from a nested object.
|
|
17
51
|
* e.g. getNestedValue(obj, 'ticket.status.open') => 'Ouvert'
|
|
18
52
|
*/
|
|
19
|
-
function getNestedValue(obj: Record<string, unknown>, path: string): string {
|
|
53
|
+
export function getNestedValue(obj: Record<string, unknown>, path: string): string {
|
|
20
54
|
const keys = path.split('.')
|
|
21
55
|
let current: unknown = obj
|
|
22
56
|
|
|
@@ -113,4 +147,4 @@ export function useTranslation() {
|
|
|
113
147
|
}
|
|
114
148
|
|
|
115
149
|
export type { Locale }
|
|
116
|
-
export { DEFAULT_LOCALE, LOCALE_STORAGE_KEY }
|
|
150
|
+
export { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, translations }
|
|
@@ -525,7 +525,7 @@ const TicketConversation: React.FC = () => {
|
|
|
525
525
|
return sched.scheduledSent ? (
|
|
526
526
|
<span style={s.badge('#f0fdf4', '#16a34a')}>{'\u2713'} Programmé le {createdDate} — rédigé le {scheduledDate !== createdDate ? scheduledDate : createdDate}</span>
|
|
527
527
|
) : (
|
|
528
|
-
<span style={s.badge('#
|
|
528
|
+
<span style={s.badge('#eef8f7', '#17807c')}>{'\u23F0'} Rédigé le {createdDate} — envoi programmé le {scheduledDate}</span>
|
|
529
529
|
)
|
|
530
530
|
})()}
|
|
531
531
|
</span>
|
|
@@ -723,12 +723,12 @@ const TicketConversation: React.FC = () => {
|
|
|
723
723
|
{clientTyping && (
|
|
724
724
|
<div style={{
|
|
725
725
|
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
|
|
726
|
-
fontSize: 12, color: '#
|
|
726
|
+
fontSize: 12, color: '#17807c', fontWeight: 500,
|
|
727
727
|
}}>
|
|
728
728
|
<span style={{ display: 'flex', gap: 2 }}>
|
|
729
|
-
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: '#
|
|
730
|
-
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: '#
|
|
731
|
-
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: '#
|
|
729
|
+
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: '#17807c', animation: 'bounce 1s infinite', animationDelay: '0ms' }} />
|
|
730
|
+
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: '#17807c', animation: 'bounce 1s infinite', animationDelay: '150ms' }} />
|
|
731
|
+
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: '#17807c', animation: 'bounce 1s infinite', animationDelay: '300ms' }} />
|
|
732
732
|
</span>
|
|
733
733
|
{clientTypingName || 'Client'} est en train d'écrire...
|
|
734
734
|
</div>
|
|
@@ -777,7 +777,7 @@ const TicketConversation: React.FC = () => {
|
|
|
777
777
|
<button
|
|
778
778
|
onClick={handleAiSuggestReply}
|
|
779
779
|
disabled={aiReplying || messages.length === 0}
|
|
780
|
-
style={{ ...s.outlineBtn('#
|
|
780
|
+
style={{ ...s.outlineBtn('#17807c', aiReplying || messages.length === 0), fontSize: '11px', padding: '3px 10px', borderRadius: '14px' }}
|
|
781
781
|
>
|
|
782
782
|
{aiReplying ? 'Génération...' : 'Suggestion IA'}
|
|
783
783
|
</button>
|
|
@@ -903,7 +903,7 @@ const TicketConversation: React.FC = () => {
|
|
|
903
903
|
<button
|
|
904
904
|
onClick={() => setShowSchedule(!showSchedule)}
|
|
905
905
|
disabled={!replyBody.trim() && !replyHtml}
|
|
906
|
-
style={{ ...s.outlineBtn('#
|
|
906
|
+
style={{ ...s.outlineBtn('#17807c', !replyBody.trim() && !replyHtml), fontSize: '12px', padding: '8px 12px' }}
|
|
907
907
|
title="Programmer l'envoi à une date/heure précise"
|
|
908
908
|
>
|
|
909
909
|
{'\u23F0'}
|
|
@@ -911,8 +911,8 @@ const TicketConversation: React.FC = () => {
|
|
|
911
911
|
)}
|
|
912
912
|
</div>
|
|
913
913
|
{showSchedule && (
|
|
914
|
-
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginTop: '8px', padding: '10px 14px', borderRadius: '8px', backgroundColor: '#
|
|
915
|
-
<span style={{ fontSize: '12px', fontWeight: 600, color: '#
|
|
914
|
+
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginTop: '8px', padding: '10px 14px', borderRadius: '8px', backgroundColor: '#eef8f7', border: '1px solid #b8dcda' }}>
|
|
915
|
+
<span style={{ fontSize: '12px', fontWeight: 600, color: '#17807c' }}>Programmer pour :</span>
|
|
916
916
|
<input
|
|
917
917
|
type="datetime-local"
|
|
918
918
|
value={scheduleDate}
|
|
@@ -923,7 +923,7 @@ const TicketConversation: React.FC = () => {
|
|
|
923
923
|
<button
|
|
924
924
|
onClick={handleScheduleReply}
|
|
925
925
|
disabled={sending || !scheduleDate || (!replyBody.trim() && !replyHtml)}
|
|
926
|
-
style={{ ...s.btn('#
|
|
926
|
+
style={{ ...s.btn('#17807c', sending || !scheduleDate || (!replyBody.trim() && !replyHtml)), fontSize: '12px', padding: '6px 14px' }}
|
|
927
927
|
>
|
|
928
928
|
{sending ? '...' : '\u23F0 Programmer'}
|
|
929
929
|
</button>
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { Where } from 'payload'
|
|
3
3
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
4
|
-
import { RateLimiter } from '../utils/rateLimiter'
|
|
4
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
5
5
|
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
6
6
|
import { dbFind, dbCreate, dbUpdate } from '../utils/db'
|
|
7
7
|
|
|
8
|
-
const adminChatLimiter = new RateLimiter(60_000, 30) // 30 per minute
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* GET /api/support/admin-chat?session=xxx&after=timestamp
|
|
@@ -111,7 +110,8 @@ export function createAdminChatGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
111
110
|
* POST /api/support/admin-chat
|
|
112
111
|
* Admin sends a message or closes a session.
|
|
113
112
|
*/
|
|
114
|
-
export function createAdminChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
113
|
+
export function createAdminChatPostEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
114
|
+
const adminChatLimiter = new RateLimiter(60_000, 30, store)
|
|
115
115
|
return {
|
|
116
116
|
path: '/support/admin-chat',
|
|
117
117
|
method: 'post',
|
|
@@ -150,7 +150,7 @@ export function createAdminChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
150
150
|
|
|
151
151
|
// Agent sends a message
|
|
152
152
|
if (action === 'send' && message) {
|
|
153
|
-
if (adminChatLimiter.check(String(req.user.id))) {
|
|
153
|
+
if (await adminChatLimiter.check(String(req.user.id), req)) {
|
|
154
154
|
return Response.json({ error: 'Rate limit atteint.' }, { status: 429 })
|
|
155
155
|
}
|
|
156
156
|
|
|
@@ -2,6 +2,7 @@ import type { Endpoint } from 'payload'
|
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
3
|
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
4
|
import { runAiAgent } from '../utils/aiAgent'
|
|
5
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* POST /api/support/ai-agent body: { ticketId, confidenceThreshold? }
|
|
@@ -9,13 +10,17 @@ import { runAiAgent } from '../utils/aiAgent'
|
|
|
9
10
|
* Runs the autonomous AI agent on a ticket: it either posts a KB-grounded answer
|
|
10
11
|
* (when confident) or escalates to a human with an internal note. Admin-only.
|
|
11
12
|
*/
|
|
12
|
-
export function createAiAgentEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
13
|
+
export function createAiAgentEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
14
|
+
const limiter = new RateLimiter(60_000, 10, store)
|
|
13
15
|
return {
|
|
14
16
|
path: '/support/ai-agent',
|
|
15
17
|
method: 'post',
|
|
16
18
|
handler: async (req) => {
|
|
17
19
|
try {
|
|
18
20
|
requireAdmin(req, slugs)
|
|
21
|
+
if (await limiter.check(String(req.user!.id), req)) {
|
|
22
|
+
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
23
|
+
}
|
|
19
24
|
let body: { ticketId?: number | string; confidenceThreshold?: number } = {}
|
|
20
25
|
try { body = (await req.json!()) as typeof body } catch { /* no body */ }
|
|
21
26
|
if (!body.ticketId) {
|
package/src/endpoints/ai.ts
CHANGED
|
@@ -2,10 +2,11 @@ import type { Endpoint } from 'payload'
|
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
3
|
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
4
|
import { readSupportSettings, type SupportSettings } from '../utils/readSettings'
|
|
5
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
5
6
|
|
|
6
|
-
function getClient(aiSettings: SupportSettings['ai']) {
|
|
7
|
-
|
|
8
|
-
const Anthropic =
|
|
7
|
+
async function getClient(aiSettings: SupportSettings['ai']) {
|
|
8
|
+
const moduleName = '@anthropic-ai/sdk'
|
|
9
|
+
const { default: Anthropic } = await import(moduleName)
|
|
9
10
|
if (aiSettings.provider === 'ollama') {
|
|
10
11
|
const baseURL = process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1'
|
|
11
12
|
return new Anthropic({ apiKey: 'ollama', baseURL })
|
|
@@ -23,7 +24,8 @@ type AiAction = 'sentiment' | 'synthesis' | 'suggest_reply' | 'rewrite'
|
|
|
23
24
|
* POST /api/support/ai
|
|
24
25
|
* Admin-only endpoint for AI features in support.
|
|
25
26
|
*/
|
|
26
|
-
export function createAiEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
27
|
+
export function createAiEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
28
|
+
const limiter = new RateLimiter(60_000, 30, store)
|
|
27
29
|
return {
|
|
28
30
|
path: '/support/ai',
|
|
29
31
|
method: 'post',
|
|
@@ -32,6 +34,9 @@ export function createAiEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
32
34
|
const payload = req.payload
|
|
33
35
|
|
|
34
36
|
requireAdmin(req, slugs)
|
|
37
|
+
if (await limiter.check(String(req.user!.id), req)) {
|
|
38
|
+
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
39
|
+
}
|
|
35
40
|
|
|
36
41
|
const settings = await readSupportSettings(payload)
|
|
37
42
|
const aiSettings = settings.ai
|
|
@@ -43,7 +48,7 @@ export function createAiEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
43
48
|
}
|
|
44
49
|
const { action } = body as { action: AiAction }
|
|
45
50
|
|
|
46
|
-
const anthropic = getClient(aiSettings)
|
|
51
|
+
const anthropic = await getClient(aiSettings)
|
|
47
52
|
const model = getModel(aiSettings)
|
|
48
53
|
|
|
49
54
|
if (action === 'sentiment') {
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
3
|
import crypto, { createHmac } from 'crypto'
|
|
4
|
-
import { RateLimiter } from '../utils/rateLimiter'
|
|
4
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
5
5
|
import { escapeHtml } from '../utils/emailTemplate'
|
|
6
6
|
import { dbFind, dbUpdate } from '../utils/db'
|
|
7
7
|
|
|
8
|
-
const sendLimiter = new RateLimiter(60 * 60 * 1000, 3) // 3 per hour
|
|
9
|
-
const verifyLimiter = new RateLimiter(15 * 60 * 1000, 5) // 5 per 15 min
|
|
10
|
-
|
|
11
8
|
function generateSecureCode(): string {
|
|
12
9
|
const buf = crypto.randomBytes(4)
|
|
13
10
|
const num = buf.readUInt32BE(0) % 900000 + 100000
|
|
@@ -27,7 +24,9 @@ function hashCode(code: string): string {
|
|
|
27
24
|
* POST /api/support/2fa
|
|
28
25
|
* Send or verify a 2FA code.
|
|
29
26
|
*/
|
|
30
|
-
export function createAuth2faEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
27
|
+
export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
28
|
+
const sendLimiter = new RateLimiter(60 * 60 * 1000, 3, store)
|
|
29
|
+
const verifyLimiter = new RateLimiter(15 * 60 * 1000, 5, store)
|
|
31
30
|
return {
|
|
32
31
|
path: '/support/2fa',
|
|
33
32
|
method: 'post',
|
|
@@ -49,7 +48,7 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
49
48
|
const genericSendResponse = { success: true, message: 'Si un compte existe, un code a été envoyé.' }
|
|
50
49
|
|
|
51
50
|
if (action === 'send') {
|
|
52
|
-
if (sendLimiter.check(email)) {
|
|
51
|
+
if (await sendLimiter.check(email, req)) {
|
|
53
52
|
return Response.json(genericSendResponse)
|
|
54
53
|
}
|
|
55
54
|
|
|
@@ -98,7 +97,7 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
98
97
|
return Response.json({ error: 'Code manquant' }, { status: 400 })
|
|
99
98
|
}
|
|
100
99
|
|
|
101
|
-
if (verifyLimiter.check(email)) {
|
|
100
|
+
if (await verifyLimiter.check(email, req)) {
|
|
102
101
|
return Response.json(
|
|
103
102
|
{ error: 'Trop de tentatives. Réessayez dans 15 minutes.' },
|
|
104
103
|
{ status: 429 },
|
|
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
|
|
|
3
3
|
import { escapeHtml } from '../utils/emailTemplate'
|
|
4
4
|
import { readSupportSettings } from '../utils/readSettings'
|
|
5
5
|
import { dbFind, dbCreate, dbUpdate } from '../utils/db'
|
|
6
|
+
import { verifySecret } from '../utils/webhookSecurity'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* GET /api/support/auto-close
|
|
@@ -16,7 +17,7 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
16
17
|
const secret = req.headers.get('x-cron-secret')
|
|
17
18
|
const expectedSecret = process.env.CRON_SECRET
|
|
18
19
|
|
|
19
|
-
if (!
|
|
20
|
+
if (!verifySecret(secret, expectedSecret)) {
|
|
20
21
|
return Response.json({ error: 'Non autorisé' }, { status: 401 })
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Endpoint, Where } from 'payload'
|
|
2
|
+
import type { SupportCapabilities } from '../types'
|
|
3
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
4
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
5
|
+
import {
|
|
6
|
+
validateInboundEmailPayload,
|
|
7
|
+
verifySecret,
|
|
8
|
+
type InboundEmailInput,
|
|
9
|
+
} from '../utils/webhookSecurity'
|
|
10
|
+
|
|
11
|
+
export function createInboundEmailEndpoint(
|
|
12
|
+
capability: NonNullable<SupportCapabilities['inboundEmail']>,
|
|
13
|
+
store?: RateLimitStore,
|
|
14
|
+
): Endpoint {
|
|
15
|
+
const limiter = new RateLimiter(60_000, 60, store)
|
|
16
|
+
return {
|
|
17
|
+
path: '/support-webhook/inbound-email',
|
|
18
|
+
method: 'post',
|
|
19
|
+
handler: async (req) => {
|
|
20
|
+
const secretHeader = capability.secretHeader || 'x-webhook-secret'
|
|
21
|
+
if (!verifySecret(req.headers.get(secretHeader), capability.secret)) {
|
|
22
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
23
|
+
}
|
|
24
|
+
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
|
25
|
+
|| req.headers.get('x-real-ip')
|
|
26
|
+
|| 'unknown'
|
|
27
|
+
if (await limiter.check(ip, req)) {
|
|
28
|
+
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const declaredLength = Number(req.headers.get('content-length') || 0) || undefined
|
|
32
|
+
let input: Record<string, unknown>
|
|
33
|
+
try {
|
|
34
|
+
input = await req.json!() as Record<string, unknown>
|
|
35
|
+
} catch {
|
|
36
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
37
|
+
}
|
|
38
|
+
const measuredLength = new TextEncoder().encode(JSON.stringify(input)).byteLength
|
|
39
|
+
const validation = validateInboundEmailPayload(
|
|
40
|
+
input as InboundEmailInput,
|
|
41
|
+
Math.max(declaredLength || 0, measuredLength),
|
|
42
|
+
)
|
|
43
|
+
if (validation) return Response.json({ error: validation.code }, { status: validation.status })
|
|
44
|
+
return capability.handle(req, input as InboundEmailInput)
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createProjectSuggestionsEndpoint(
|
|
50
|
+
slugs: CollectionSlugs,
|
|
51
|
+
capability: NonNullable<SupportCapabilities['projectSuggestions']>,
|
|
52
|
+
store?: RateLimitStore,
|
|
53
|
+
): Endpoint {
|
|
54
|
+
const limiter = new RateLimiter(60_000, 20, store)
|
|
55
|
+
return {
|
|
56
|
+
path: '/support/suggest-projects',
|
|
57
|
+
method: 'post',
|
|
58
|
+
handler: async (req) => {
|
|
59
|
+
if (!req.user || req.user.collection !== slugs.users) {
|
|
60
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
61
|
+
}
|
|
62
|
+
const key = req.user?.id ? String(req.user.id) : 'anonymous'
|
|
63
|
+
if (await limiter.check(key, req)) {
|
|
64
|
+
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
65
|
+
}
|
|
66
|
+
return capability.suggest(req)
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createTicketTitleEndpoint(
|
|
72
|
+
slugs: CollectionSlugs,
|
|
73
|
+
capability: NonNullable<SupportCapabilities['aiTitles']>,
|
|
74
|
+
store?: RateLimitStore,
|
|
75
|
+
): Endpoint {
|
|
76
|
+
const limiter = new RateLimiter(60_000, 20, store)
|
|
77
|
+
return {
|
|
78
|
+
path: '/support/ticket-title',
|
|
79
|
+
method: 'post',
|
|
80
|
+
handler: async (req) => {
|
|
81
|
+
if (!req.user || req.user.collection !== slugs.users) {
|
|
82
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
83
|
+
}
|
|
84
|
+
if (await limiter.check(String(req.user.id), req)) {
|
|
85
|
+
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
86
|
+
}
|
|
87
|
+
const ticketId = Number(new URL(req.url || '', 'http://localhost').searchParams.get('ticketId'))
|
|
88
|
+
if (!Number.isFinite(ticketId) || ticketId <= 0) {
|
|
89
|
+
return Response.json({ error: 'ticketId required' }, { status: 400 })
|
|
90
|
+
}
|
|
91
|
+
const title = await capability.generate(req.payload, ticketId)
|
|
92
|
+
if (!title) return Response.json({ error: 'Generation unavailable' }, { status: 502 })
|
|
93
|
+
return Response.json({ title, status: 'suggested' })
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createGenerateMissingTitlesEndpoint(
|
|
99
|
+
slugs: CollectionSlugs,
|
|
100
|
+
capability: NonNullable<SupportCapabilities['aiTitles']>,
|
|
101
|
+
store?: RateLimitStore,
|
|
102
|
+
): Endpoint {
|
|
103
|
+
const limiter = new RateLimiter(60_000, 5, store)
|
|
104
|
+
return {
|
|
105
|
+
path: '/support/generate-missing-titles',
|
|
106
|
+
method: 'post',
|
|
107
|
+
handler: async (req) => {
|
|
108
|
+
if (!req.user || req.user.collection !== slugs.users) {
|
|
109
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
110
|
+
}
|
|
111
|
+
if (await limiter.check(String(req.user.id), req)) {
|
|
112
|
+
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
113
|
+
}
|
|
114
|
+
const limit = Math.min(
|
|
115
|
+
Math.max(Number(new URL(req.url || '', 'http://localhost').searchParams.get('limit')) || 8, 1),
|
|
116
|
+
20,
|
|
117
|
+
)
|
|
118
|
+
const where: Where = {
|
|
119
|
+
and: [
|
|
120
|
+
{ displayTitle: { exists: false } },
|
|
121
|
+
{ displayTitleStatus: { not_equals: 'error' } },
|
|
122
|
+
],
|
|
123
|
+
}
|
|
124
|
+
const batch = await req.payload.find({
|
|
125
|
+
collection: slugs.tickets,
|
|
126
|
+
where,
|
|
127
|
+
limit,
|
|
128
|
+
depth: 0,
|
|
129
|
+
overrideAccess: true,
|
|
130
|
+
sort: '-createdAt',
|
|
131
|
+
})
|
|
132
|
+
let generated = 0
|
|
133
|
+
for (const ticket of batch.docs) {
|
|
134
|
+
if (await capability.generate(req.payload, ticket.id)) generated++
|
|
135
|
+
}
|
|
136
|
+
const remaining = await req.payload.count({
|
|
137
|
+
collection: slugs.tickets,
|
|
138
|
+
where,
|
|
139
|
+
overrideAccess: true,
|
|
140
|
+
})
|
|
141
|
+
return Response.json({ generated, remaining: remaining.totalDocs })
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { Endpoint } from 'payload'
|
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
3
|
import { dbFind, dbCreate } from '../utils/db'
|
|
4
4
|
import { randomBytes } from 'crypto'
|
|
5
|
+
import { verifySecret } from '../utils/webhookSecurity'
|
|
5
6
|
|
|
6
7
|
const PROVIDERS = ['whatsapp', 'messenger'] as const
|
|
7
8
|
type Provider = (typeof PROVIDERS)[number]
|
|
@@ -22,7 +23,7 @@ export function createChannelsWebhookEndpoint(slugs: CollectionSlugs): Endpoint
|
|
|
22
23
|
method: 'post',
|
|
23
24
|
handler: async (req) => {
|
|
24
25
|
const secret = req.headers.get('x-channel-secret')
|
|
25
|
-
if (!
|
|
26
|
+
if (!verifySecret(secret, process.env.CHANNELS_WEBHOOK_SECRET)) {
|
|
26
27
|
return Response.json({ error: 'Non autorisé' }, { status: 401 })
|
|
27
28
|
}
|
|
28
29
|
|
package/src/endpoints/chat.ts
CHANGED
|
@@ -2,12 +2,10 @@ import type { Endpoint } from 'payload'
|
|
|
2
2
|
import type { Where } from 'payload'
|
|
3
3
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
4
4
|
import crypto from 'crypto'
|
|
5
|
-
import { RateLimiter } from '../utils/rateLimiter'
|
|
5
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
6
6
|
import { requireClient, handleAuthError } from '../utils/auth'
|
|
7
7
|
import { dbFind, dbCreate } from '../utils/db'
|
|
8
8
|
|
|
9
|
-
const chatSessionLimiter = new RateLimiter(3_600_000, 5) // 5 sessions per hour
|
|
10
|
-
const chatMessageLimiter = new RateLimiter(60_000, 15) // 15 messages per minute
|
|
11
9
|
|
|
12
10
|
/**
|
|
13
11
|
* GET /api/support/chat?session=xxx&after=timestamp
|
|
@@ -68,7 +66,9 @@ export function createChatGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
68
66
|
* POST /api/support/chat
|
|
69
67
|
* Send a message or start a new chat session. Client-only.
|
|
70
68
|
*/
|
|
71
|
-
export function createChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
69
|
+
export function createChatPostEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
70
|
+
const chatSessionLimiter = new RateLimiter(3_600_000, 5, store)
|
|
71
|
+
const chatMessageLimiter = new RateLimiter(60_000, 15, store)
|
|
72
72
|
return {
|
|
73
73
|
path: '/support/chat',
|
|
74
74
|
method: 'post',
|
|
@@ -89,7 +89,7 @@ export function createChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
89
89
|
|
|
90
90
|
// Start a new session
|
|
91
91
|
if (action === 'start') {
|
|
92
|
-
if (chatSessionLimiter.check(userId)) {
|
|
92
|
+
if (await chatSessionLimiter.check(userId, req)) {
|
|
93
93
|
return Response.json({ error: 'Trop de sessions créées. Réessayez plus tard.' }, { status: 429 })
|
|
94
94
|
}
|
|
95
95
|
|
|
@@ -111,7 +111,7 @@ export function createChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
111
111
|
|
|
112
112
|
// Send a message
|
|
113
113
|
if (action === 'send' && session && message) {
|
|
114
|
-
if (chatMessageLimiter.check(userId)) {
|
|
114
|
+
if (await chatMessageLimiter.check(userId, req)) {
|
|
115
115
|
return Response.json({ error: 'Trop de messages. Attendez un moment.' }, { status: 429 })
|
|
116
116
|
}
|
|
117
117
|
|
package/src/endpoints/chatbot.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
-
import { RateLimiter } from '../utils/rateLimiter'
|
|
3
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
4
4
|
import { dbFind } from '../utils/db'
|
|
5
5
|
|
|
6
|
-
const chatbotLimiter = new RateLimiter(60_000, 10) // 10 requests per minute per IP
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* POST /api/support/chatbot
|
|
10
9
|
* AI chatbot that answers from the knowledge base before creating a ticket.
|
|
11
10
|
* Public endpoint (accessible from the support portal).
|
|
12
11
|
*/
|
|
13
|
-
export function createChatbotEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
12
|
+
export function createChatbotEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
13
|
+
const chatbotLimiter = new RateLimiter(60_000, 10, store)
|
|
14
14
|
return {
|
|
15
15
|
path: '/support/chatbot',
|
|
16
16
|
method: 'post',
|
|
17
17
|
handler: async (req) => {
|
|
18
18
|
try {
|
|
19
19
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
|
|
20
|
-
if (chatbotLimiter.check(ip)) {
|
|
20
|
+
if (await chatbotLimiter.check(ip, req)) {
|
|
21
21
|
return Response.json({ error: 'Too many requests. Please wait a moment.' }, { status: 429 })
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -2,9 +2,11 @@ import type { Endpoint, PayloadRequest } from 'payload'
|
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
3
|
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
4
|
import { readSupportSettings, type SupportSettings } from '../utils/readSettings'
|
|
5
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
5
6
|
|
|
6
|
-
function getClient(aiSettings: SupportSettings['ai']) {
|
|
7
|
-
const
|
|
7
|
+
async function getClient(aiSettings: SupportSettings['ai']) {
|
|
8
|
+
const moduleName = '@anthropic-ai/sdk'
|
|
9
|
+
const { default: Anthropic } = await import(moduleName)
|
|
8
10
|
if (aiSettings.provider === 'ollama') {
|
|
9
11
|
const baseURL = process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1'
|
|
10
12
|
return new Anthropic({ apiKey: 'ollama', baseURL })
|
|
@@ -25,10 +27,14 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
|
|
|
25
27
|
* POST /api/support/client-intelligence?clientId=X
|
|
26
28
|
* Force-refreshes the summary.
|
|
27
29
|
*/
|
|
28
|
-
export function createClientIntelligenceEndpoint(slugs: CollectionSlugs): Endpoint[] {
|
|
30
|
+
export function createClientIntelligenceEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint[] {
|
|
31
|
+
const limiter = new RateLimiter(60_000, 20, store)
|
|
29
32
|
const getHandler = async (req: PayloadRequest) => {
|
|
30
33
|
try {
|
|
31
34
|
requireAdmin(req, slugs)
|
|
35
|
+
if (await limiter.check(String(req.user!.id), req)) {
|
|
36
|
+
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
37
|
+
}
|
|
32
38
|
const payload = req.payload
|
|
33
39
|
const url = new URL(req.url || '', 'http://localhost')
|
|
34
40
|
const clientId = url.searchParams.get('clientId')
|
|
@@ -64,6 +70,9 @@ export function createClientIntelligenceEndpoint(slugs: CollectionSlugs): Endpoi
|
|
|
64
70
|
const postHandler = async (req: PayloadRequest) => {
|
|
65
71
|
try {
|
|
66
72
|
requireAdmin(req, slugs)
|
|
73
|
+
if (await limiter.check(String(req.user!.id), req)) {
|
|
74
|
+
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
75
|
+
}
|
|
67
76
|
const payload = req.payload
|
|
68
77
|
const body = await req.json?.() || {}
|
|
69
78
|
const clientId = body.clientId
|
|
@@ -197,7 +206,7 @@ Réponds en JSON strict (pas de markdown, pas de commentaires) avec cette struct
|
|
|
197
206
|
Sois factuel. Ne dépasse pas 5 items par tableau. Réponds UNIQUEMENT avec le JSON.`
|
|
198
207
|
|
|
199
208
|
// 6. Call AI
|
|
200
|
-
const anthropic = getClient(aiSettings)
|
|
209
|
+
const anthropic = await getClient(aiSettings)
|
|
201
210
|
const model = getModel(aiSettings)
|
|
202
211
|
|
|
203
212
|
const res = await anthropic.messages.create({
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
-
import { RateLimiter } from '../utils/rateLimiter'
|
|
3
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
4
4
|
import { readSupportSettings } from '../utils/readSettings'
|
|
5
5
|
import { dbFind, dbCreate } from '../utils/db'
|
|
6
|
+
import { verifySecret } from '../utils/webhookSecurity'
|
|
6
7
|
import crypto from 'crypto'
|
|
7
8
|
|
|
8
9
|
interface ParsedConversation {
|
|
@@ -11,7 +12,6 @@ interface ParsedConversation {
|
|
|
11
12
|
messages: { from: 'client' | 'admin'; name: string; date: string; content: string }[]
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
const importLimiter = new RateLimiter(3_600_000, 10) // 10 per hour
|
|
15
15
|
|
|
16
16
|
function parseStructuredMarkdown(markdown: string): ParsedConversation | null {
|
|
17
17
|
const clientMatch = markdown.match(
|
|
@@ -131,7 +131,8 @@ ONLY JSON, nothing else.`,
|
|
|
131
131
|
* POST /api/support/import-conversation
|
|
132
132
|
* Import a conversation from markdown into the ticket system.
|
|
133
133
|
*/
|
|
134
|
-
export function createImportConversationEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
134
|
+
export function createImportConversationEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
135
|
+
const importLimiter = new RateLimiter(3_600_000, 10, store)
|
|
135
136
|
return {
|
|
136
137
|
path: '/support/import-conversation',
|
|
137
138
|
method: 'post',
|
|
@@ -143,7 +144,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs): Endpoi
|
|
|
143
144
|
const webhookSecret = req.headers.get('x-webhook-secret')
|
|
144
145
|
let isAuthed = false
|
|
145
146
|
|
|
146
|
-
if (webhookSecret
|
|
147
|
+
if (verifySecret(webhookSecret, process.env.SUPPORT_WEBHOOK_SECRET)) {
|
|
147
148
|
isAuthed = true
|
|
148
149
|
} else if (req.user && req.user.collection === slugs.users) {
|
|
149
150
|
isAuthed = true
|
|
@@ -154,7 +155,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs): Endpoi
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
|
157
|
-
if (importLimiter.check(ip)) {
|
|
158
|
+
if (await importLimiter.check(ip, req)) {
|
|
158
159
|
return Response.json({ error: 'Rate limit exceeded. Maximum 10 imports per hour.' }, { status: 429 })
|
|
159
160
|
}
|
|
160
161
|
|