@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.
Files changed (99) hide show
  1. package/README.md +4 -0
  2. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +22 -14
  3. package/dist/components/TicketConversation/components/AISummaryPanel.js +23 -16
  4. package/dist/components/TicketConversation/components/ActionPanels.cjs +5 -5
  5. package/dist/components/TicketConversation/components/ActionPanels.js +5 -5
  6. package/dist/components/TicketConversation/components/ClientBar.cjs +1 -1
  7. package/dist/components/TicketConversation/components/ClientBar.js +1 -1
  8. package/dist/components/TicketConversation/components/QuickActions.cjs +2 -2
  9. package/dist/components/TicketConversation/components/QuickActions.js +2 -2
  10. package/dist/components/TicketConversation/components/TicketHeader.cjs +1 -1
  11. package/dist/components/TicketConversation/components/TicketHeader.js +1 -1
  12. package/dist/components/TicketConversation/index.cjs +10 -10
  13. package/dist/components/TicketConversation/index.js +10 -10
  14. package/dist/index.cjs +709 -173
  15. package/dist/index.d.cts +165 -3
  16. package/dist/index.d.ts +165 -3
  17. package/dist/index.js +702 -175
  18. package/dist/styles/ChatView.module.scss +1 -1
  19. package/dist/styles/TicketDetail.module.scss +3 -3
  20. package/dist/styles/_tokens.scss +1 -1
  21. package/dist/styles/theme.css +6 -6
  22. package/dist/views/TicketingSettingsView/client.cjs +3 -3
  23. package/dist/views/TicketingSettingsView/client.js +3 -3
  24. package/package.json +9 -9
  25. package/src/__tests__/aiSummaryRendering.test.ts +21 -0
  26. package/src/__tests__/authResponses.test.ts +69 -0
  27. package/src/__tests__/capabilities.test.ts +71 -0
  28. package/src/__tests__/generateTrackingToken.test.ts +18 -4
  29. package/src/__tests__/integration/core-behaviors.test.ts +23 -0
  30. package/src/__tests__/rateLimiter.test.ts +60 -66
  31. package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
  32. package/src/__tests__/webhookSecurity.test.ts +58 -0
  33. package/src/collections/PendingEmails.ts +2 -1
  34. package/src/collections/SupportClients.ts +43 -1
  35. package/src/collections/SupportCounters.ts +14 -0
  36. package/src/collections/SupportRateLimits.ts +20 -0
  37. package/src/collections/TicketMessages.ts +76 -0
  38. package/src/collections/Tickets.ts +179 -39
  39. package/src/collections/index.ts +2 -0
  40. package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
  41. package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
  42. package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
  43. package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
  44. package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
  45. package/src/components/TicketConversation/index.tsx +10 -10
  46. package/src/endpoints/admin-chat.ts +4 -4
  47. package/src/endpoints/ai-agent.ts +6 -1
  48. package/src/endpoints/ai.ts +10 -5
  49. package/src/endpoints/auth-2fa.ts +6 -7
  50. package/src/endpoints/auto-close.ts +2 -1
  51. package/src/endpoints/capabilities.ts +144 -0
  52. package/src/endpoints/channels.ts +2 -1
  53. package/src/endpoints/chat.ts +6 -6
  54. package/src/endpoints/chatbot.ts +4 -4
  55. package/src/endpoints/client-intelligence.ts +13 -4
  56. package/src/endpoints/import-conversation.ts +6 -5
  57. package/src/endpoints/index.ts +35 -14
  58. package/src/endpoints/invite-collaborator.ts +4 -4
  59. package/src/endpoints/login.ts +4 -5
  60. package/src/endpoints/oauth-google.ts +1 -1
  61. package/src/endpoints/process-digests.ts +2 -1
  62. package/src/endpoints/process-scheduled.ts +2 -1
  63. package/src/endpoints/process-snooze.ts +2 -1
  64. package/src/endpoints/resend-notification.ts +4 -4
  65. package/src/endpoints/send-reminder.ts +4 -4
  66. package/src/endpoints/ticket-synthesis.ts +17 -2
  67. package/src/endpoints/track-open.ts +44 -21
  68. package/src/endpoints/transfer-ticket.ts +4 -4
  69. package/src/index.ts +7 -0
  70. package/src/plugin.ts +16 -1
  71. package/src/portal/LiveChat.tsx +1 -1
  72. package/src/portal/auth/ChatWidget.tsx +4 -4
  73. package/src/portal/auth/ChatbotWidget.tsx +4 -4
  74. package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
  75. package/src/portal/auth/faq/FAQSearch.tsx +1 -1
  76. package/src/portal/auth/faq/page.tsx +2 -2
  77. package/src/portal/auth/profile/page.tsx +17 -17
  78. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
  79. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
  80. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
  81. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
  82. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
  83. package/src/portal/auth/tickets/detail/page.tsx +5 -5
  84. package/src/portal/auth/tickets/new/page.tsx +8 -8
  85. package/src/portal/forgot-password/page.tsx +3 -3
  86. package/src/portal/layout.tsx +5 -2
  87. package/src/portal/login/page.tsx +7 -7
  88. package/src/portal/page.tsx +9 -9
  89. package/src/portal/register/page.tsx +4 -4
  90. package/src/portal/reset-password/page.tsx +3 -3
  91. package/src/styles/ChatView.module.scss +1 -1
  92. package/src/styles/TicketDetail.module.scss +3 -3
  93. package/src/styles/_tokens.scss +1 -1
  94. package/src/styles/theme.css +6 -6
  95. package/src/types.ts +71 -0
  96. package/src/utils/rateLimiter.ts +131 -38
  97. package/src/utils/slugs.ts +4 -0
  98. package/src/utils/webhookSecurity.ts +81 -0
  99. 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('#818cf8', sendingExtMsg || !extMsgBody.trim())}>
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: '#faf5ff', border: '1px solid #e9d5ff', marginBottom: '14px' }}>
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('#8b5cf6', snoozeSaving), fontSize: '12px' }}>1 jour</button>
211
- <button onClick={() => handleSnooze(3)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#8b5cf6', snoozeSaving), fontSize: '12px' }}>3 jours</button>
212
- <button onClick={() => handleSnooze(7)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#8b5cf6', snoozeSaving), fontSize: '12px' }}>1 semaine</button>
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('#7c3aed'), fontSize: '11px', padding: '4px 10px' }}
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('#7c3aed', snoozeSaving), fontSize: '12px', padding: '5px 10px' }}>
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('#7c3aed')}>Snooze</button>
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('#f5f3ff', '#7c3aed')}>
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('#f3e8ff', '#7c3aed')}>{'\u23F0'} Rédigé le {createdDate} — envoi programmé le {scheduledDate}</span>
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: '#7c3aed', fontWeight: 500,
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: '#7c3aed', animation: 'bounce 1s infinite', animationDelay: '0ms' }} />
730
- <span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: '#7c3aed', animation: 'bounce 1s infinite', animationDelay: '150ms' }} />
731
- <span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: '#7c3aed', animation: 'bounce 1s infinite', animationDelay: '300ms' }} />
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&apos;é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('#7c3aed', aiReplying || messages.length === 0), fontSize: '11px', padding: '3px 10px', borderRadius: '14px' }}
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('#7c3aed', !replyBody.trim() && !replyHtml), fontSize: '12px', padding: '8px 12px' }}
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: '#faf5ff', border: '1px solid #e9d5ff' }}>
915
- <span style={{ fontSize: '12px', fontWeight: 600, color: '#7c3aed' }}>Programmer pour :</span>
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('#7c3aed', sending || !scheduleDate || (!replyBody.trim() && !replyHtml)), fontSize: '12px', padding: '6px 14px' }}
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) {
@@ -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
- // Dynamic import to avoid hard dependency
8
- const Anthropic = require('@anthropic-ai/sdk').default
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 (!expectedSecret || secret !== expectedSecret) {
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 (!process.env.CHANNELS_WEBHOOK_SECRET || secret !== process.env.CHANNELS_WEBHOOK_SECRET) {
26
+ if (!verifySecret(secret, process.env.CHANNELS_WEBHOOK_SECRET)) {
26
27
  return Response.json({ error: 'Non autorisé' }, { status: 401 })
27
28
  }
28
29
 
@@ -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
 
@@ -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 Anthropic = require('@anthropic-ai/sdk').default
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 && process.env.SUPPORT_WEBHOOK_SECRET && webhookSecret === process.env.SUPPORT_WEBHOOK_SECRET) {
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