@consilioweb/payload-support 1.1.1 → 2.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 +4 -0
- 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/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/TicketingSettingsView/client.cjs +3 -3
- package/dist/views/TicketingSettingsView/client.js +3 -3
- package/package.json +9 -9
- 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__/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/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/TicketingSettingsView/client.tsx +3 -3
|
@@ -108,7 +108,7 @@ export function ExtMessagePanel({
|
|
|
108
108
|
))}
|
|
109
109
|
</div>
|
|
110
110
|
)}
|
|
111
|
-
<button onClick={handleSendExtMsg} disabled={sendingExtMsg || !extMsgBody.trim()} style={s.btn('#
|
|
111
|
+
<button onClick={handleSendExtMsg} disabled={sendingExtMsg || !extMsgBody.trim()} style={s.btn('#1d2b4d', sendingExtMsg || !extMsgBody.trim())}>
|
|
112
112
|
{sendingExtMsg ? 'Ajout...' : 'Ajouter (sans notification)'}
|
|
113
113
|
</button>
|
|
114
114
|
</div>
|
|
@@ -204,12 +204,12 @@ interface SnoozePanelProps {
|
|
|
204
204
|
|
|
205
205
|
export function SnoozePanel({ snoozeSaving, handleSnooze }: SnoozePanelProps) {
|
|
206
206
|
return (
|
|
207
|
-
<div style={{ padding: '14px 18px', borderRadius: '8px', backgroundColor: '#
|
|
207
|
+
<div style={{ padding: '14px 18px', borderRadius: '8px', backgroundColor: '#eef8f7', border: '1px solid #b8dcda', marginBottom: '14px' }}>
|
|
208
208
|
<h4 style={{ fontSize: '13px', fontWeight: 600, marginBottom: '10px', color: '#5b21b6' }}>Snooze — masquer temporairement</h4>
|
|
209
209
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
210
|
-
<button onClick={() => handleSnooze(1)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#
|
|
211
|
-
<button onClick={() => handleSnooze(3)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#
|
|
212
|
-
<button onClick={() => handleSnooze(7)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#
|
|
210
|
+
<button onClick={() => handleSnooze(1)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 jour</button>
|
|
211
|
+
<button onClick={() => handleSnooze(3)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>3 jours</button>
|
|
212
|
+
<button onClick={() => handleSnooze(7)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 semaine</button>
|
|
213
213
|
<input
|
|
214
214
|
type="datetime-local"
|
|
215
215
|
onChange={(e) => { if (e.target.value) handleSnooze(null, e.target.value) }}
|
|
@@ -26,7 +26,7 @@ export function ClientBar({ client }: { client: ClientInfo }) {
|
|
|
26
26
|
<button
|
|
27
27
|
type="button"
|
|
28
28
|
onClick={() => window.open(`/api/admin/impersonate?clientId=${client.id}`, '_blank')}
|
|
29
|
-
style={{ ...s.ghostBtn('#
|
|
29
|
+
style={{ ...s.ghostBtn('#17807c'), fontSize: '11px', padding: '4px 10px' }}
|
|
30
30
|
title="Se connecter au portail support en tant que ce client"
|
|
31
31
|
>
|
|
32
32
|
Voir en tant que client
|
|
@@ -57,14 +57,14 @@ export function QuickActions({
|
|
|
57
57
|
</button>
|
|
58
58
|
))}
|
|
59
59
|
{snoozeUntil && new Date(snoozeUntil) > new Date() && (
|
|
60
|
-
<button onClick={onCancelSnooze} disabled={snoozeSaving} style={{ ...s.ghostBtn('#
|
|
60
|
+
<button onClick={onCancelSnooze} disabled={snoozeSaving} style={{ ...s.ghostBtn('#17807c', snoozeSaving), fontSize: '12px', padding: '5px 10px' }}>
|
|
61
61
|
Annuler snooze
|
|
62
62
|
</button>
|
|
63
63
|
)}
|
|
64
64
|
<span style={{ borderLeft: `1px solid ${C.border}`, height: '20px', margin: '0 4px' }} />
|
|
65
65
|
<button onClick={onToggleMerge} style={s.ghostBtn('#be185d')}>Fusionner</button>
|
|
66
66
|
<button onClick={onToggleExtMsg} style={s.ghostBtn('#4f46e5')}>+ Message reçu</button>
|
|
67
|
-
<button onClick={onToggleSnooze} style={s.ghostBtn('#
|
|
67
|
+
<button onClick={onToggleSnooze} style={s.ghostBtn('#17807c')}>Snooze</button>
|
|
68
68
|
{showReminderButton && (
|
|
69
69
|
<button onClick={onToggleReminder} style={s.ghostBtn('#ea580c')}>{'⏰'} Relancer</button>
|
|
70
70
|
)}
|
|
@@ -57,7 +57,7 @@ export function TicketHeader({
|
|
|
57
57
|
</span>
|
|
58
58
|
)}
|
|
59
59
|
{snoozeUntil && new Date(snoozeUntil) > new Date() && (
|
|
60
|
-
<span style={s.badge('#
|
|
60
|
+
<span style={s.badge('#eef8f7', '#17807c')}>
|
|
61
61
|
Snoozé {new Date(snoozeUntil).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
|
62
62
|
</span>
|
|
63
63
|
)}
|
|
@@ -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
|
|