@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.
Files changed (110) hide show
  1. package/README.md +74 -3
  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/hooks/useTranslation.cjs +25 -2
  13. package/dist/components/TicketConversation/hooks/useTranslation.js +22 -3
  14. package/dist/components/TicketConversation/index.cjs +10 -10
  15. package/dist/components/TicketConversation/index.js +10 -10
  16. package/dist/index.cjs +709 -173
  17. package/dist/index.d.cts +165 -3
  18. package/dist/index.d.ts +165 -3
  19. package/dist/index.js +702 -175
  20. package/dist/styles/ChatView.module.scss +1 -1
  21. package/dist/styles/TicketDetail.module.scss +3 -3
  22. package/dist/styles/_tokens.scss +1 -1
  23. package/dist/styles/theme.css +6 -6
  24. package/dist/views/SupportDashboardView/client.cjs +1 -1
  25. package/dist/views/SupportDashboardView/client.js +1 -1
  26. package/dist/views/TicketingSettingsView/client.cjs +4 -4
  27. package/dist/views/TicketingSettingsView/client.js +4 -4
  28. package/dist/views/shared/locales/en.json +708 -0
  29. package/dist/views/shared/locales/fr.json +708 -0
  30. package/package.json +19 -19
  31. package/src/__tests__/aiSummaryRendering.test.ts +21 -0
  32. package/src/__tests__/authResponses.test.ts +69 -0
  33. package/src/__tests__/capabilities.test.ts +71 -0
  34. package/src/__tests__/generateTrackingToken.test.ts +18 -4
  35. package/src/__tests__/integration/core-behaviors.test.ts +23 -0
  36. package/src/__tests__/rateLimiter.test.ts +60 -66
  37. package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
  38. package/src/__tests__/translations.test.ts +67 -0
  39. package/src/__tests__/webhookSecurity.test.ts +58 -0
  40. package/src/collections/PendingEmails.ts +2 -1
  41. package/src/collections/SupportClients.ts +43 -1
  42. package/src/collections/SupportCounters.ts +14 -0
  43. package/src/collections/SupportRateLimits.ts +20 -0
  44. package/src/collections/TicketMessages.ts +76 -0
  45. package/src/collections/Tickets.ts +179 -39
  46. package/src/collections/index.ts +2 -0
  47. package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
  48. package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
  49. package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
  50. package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
  51. package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
  52. package/src/components/TicketConversation/hooks/useTranslation.ts +38 -4
  53. package/src/components/TicketConversation/index.tsx +10 -10
  54. package/src/endpoints/admin-chat.ts +4 -4
  55. package/src/endpoints/ai-agent.ts +6 -1
  56. package/src/endpoints/ai.ts +10 -5
  57. package/src/endpoints/auth-2fa.ts +6 -7
  58. package/src/endpoints/auto-close.ts +2 -1
  59. package/src/endpoints/capabilities.ts +144 -0
  60. package/src/endpoints/channels.ts +2 -1
  61. package/src/endpoints/chat.ts +6 -6
  62. package/src/endpoints/chatbot.ts +4 -4
  63. package/src/endpoints/client-intelligence.ts +13 -4
  64. package/src/endpoints/import-conversation.ts +6 -5
  65. package/src/endpoints/index.ts +35 -14
  66. package/src/endpoints/invite-collaborator.ts +4 -4
  67. package/src/endpoints/login.ts +4 -5
  68. package/src/endpoints/oauth-google.ts +1 -1
  69. package/src/endpoints/process-digests.ts +2 -1
  70. package/src/endpoints/process-scheduled.ts +2 -1
  71. package/src/endpoints/process-snooze.ts +2 -1
  72. package/src/endpoints/resend-notification.ts +4 -4
  73. package/src/endpoints/send-reminder.ts +4 -4
  74. package/src/endpoints/ticket-synthesis.ts +17 -2
  75. package/src/endpoints/track-open.ts +44 -21
  76. package/src/endpoints/transfer-ticket.ts +4 -4
  77. package/src/index.ts +7 -0
  78. package/src/plugin.ts +16 -1
  79. package/src/portal/LiveChat.tsx +1 -1
  80. package/src/portal/auth/ChatWidget.tsx +4 -4
  81. package/src/portal/auth/ChatbotWidget.tsx +4 -4
  82. package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
  83. package/src/portal/auth/faq/FAQSearch.tsx +1 -1
  84. package/src/portal/auth/faq/page.tsx +2 -2
  85. package/src/portal/auth/profile/page.tsx +17 -17
  86. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
  87. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
  88. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
  89. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
  90. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
  91. package/src/portal/auth/tickets/detail/page.tsx +5 -5
  92. package/src/portal/auth/tickets/new/page.tsx +8 -8
  93. package/src/portal/forgot-password/page.tsx +3 -3
  94. package/src/portal/layout.tsx +5 -2
  95. package/src/portal/login/page.tsx +7 -7
  96. package/src/portal/page.tsx +9 -9
  97. package/src/portal/register/page.tsx +4 -4
  98. package/src/portal/reset-password/page.tsx +3 -3
  99. package/src/styles/ChatView.module.scss +1 -1
  100. package/src/styles/TicketDetail.module.scss +3 -3
  101. package/src/styles/_tokens.scss +1 -1
  102. package/src/styles/theme.css +6 -6
  103. package/src/types.ts +71 -0
  104. package/src/utils/rateLimiter.ts +131 -38
  105. package/src/utils/slugs.ts +4 -0
  106. package/src/utils/webhookSecurity.ts +81 -0
  107. package/src/views/SupportDashboardView/client.tsx +1 -1
  108. package/src/views/TicketingSettingsView/client.tsx +4 -4
  109. package/src/views/shared/locales/en.json +2 -2
  110. package/src/views/shared/locales/fr.json +2 -2
@@ -6,7 +6,7 @@ const supportEmail = process.env.SUPPORT_EMAIL || 'support@example.com'
6
6
 
7
7
  export default function SupportPage() {
8
8
  return (
9
- <div className="flex min-h-screen flex-col bg-white dark:bg-slate-950">
9
+ <div className="flex min-h-[100dvh] flex-col bg-white dark:bg-slate-950">
10
10
  {/* Header */}
11
11
  <header className="border-b border-slate-200 bg-white/80 backdrop-blur-lg dark:border-slate-800 dark:bg-slate-950/80">
12
12
  <div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
@@ -20,7 +20,7 @@ export default function SupportPage() {
20
20
  </Link>
21
21
  <Link
22
22
  href="/support/login"
23
- className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-blue-700 hover:shadow-md active:scale-[0.98]"
23
+ className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-blue-700 hover:shadow-md active:scale-[0.98]"
24
24
  >
25
25
  Se connecter
26
26
  </Link>
@@ -49,7 +49,7 @@ export default function SupportPage() {
49
49
  <div className="mt-10 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
50
50
  <Link
51
51
  href="/support/login"
52
- className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-8 py-3.5 text-base font-medium text-white shadow-lg shadow-blue-600/25 transition-all hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-600/30 active:scale-[0.98] dark:shadow-blue-600/20"
52
+ className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-8 py-3.5 text-base font-medium text-white shadow-lg shadow-blue-600/25 transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-600/30 active:scale-[0.98] dark:shadow-blue-600/20"
53
53
  >
54
54
  Accéder à mon espace
55
55
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-5 w-5">
@@ -58,7 +58,7 @@ export default function SupportPage() {
58
58
  </Link>
59
59
  <Link
60
60
  href="/support/register"
61
- className="inline-flex items-center rounded-xl border border-slate-300 bg-white px-8 py-3.5 text-base font-medium text-slate-700 shadow-sm transition-all hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
61
+ className="inline-flex items-center rounded-xl border border-slate-300 bg-white px-8 py-3.5 text-base font-medium text-slate-700 shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
62
62
  >
63
63
  Créer un compte
64
64
  </Link>
@@ -80,7 +80,7 @@ export default function SupportPage() {
80
80
  </p>
81
81
  <div className="grid gap-8 sm:grid-cols-3">
82
82
  {/* Card 1 */}
83
- <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-all hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
83
+ <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
84
84
  <div className="mb-5 flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 text-blue-600 transition-colors group-hover:bg-blue-600 group-hover:text-white dark:bg-blue-950 dark:text-blue-400 dark:group-hover:bg-blue-600 dark:group-hover:text-white">
85
85
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6">
86
86
  <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
@@ -96,7 +96,7 @@ export default function SupportPage() {
96
96
  </div>
97
97
 
98
98
  {/* Card 2 */}
99
- <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-all hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
99
+ <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
100
100
  <div className="mb-5 flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 text-blue-600 transition-colors group-hover:bg-blue-600 group-hover:text-white dark:bg-blue-950 dark:text-blue-400 dark:group-hover:bg-blue-600 dark:group-hover:text-white">
101
101
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6">
102
102
  <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
@@ -109,7 +109,7 @@ export default function SupportPage() {
109
109
  </div>
110
110
 
111
111
  {/* Card 3 */}
112
- <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-all hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
112
+ <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
113
113
  <div className="mb-5 flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 text-blue-600 transition-colors group-hover:bg-blue-600 group-hover:text-white dark:bg-blue-950 dark:text-blue-400 dark:group-hover:bg-blue-600 dark:group-hover:text-white">
114
114
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6">
115
115
  <circle cx="12" cy="12" r="10" />
@@ -139,13 +139,13 @@ export default function SupportPage() {
139
139
  <div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
140
140
  <Link
141
141
  href="/support/register"
142
- className="inline-flex items-center rounded-xl bg-blue-600 px-8 py-3.5 text-base font-medium text-white shadow-lg shadow-blue-600/25 transition-all hover:bg-blue-700 hover:shadow-xl active:scale-[0.98]"
142
+ className="inline-flex items-center rounded-xl bg-blue-600 px-8 py-3.5 text-base font-medium text-white shadow-lg shadow-blue-600/25 transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-blue-700 hover:shadow-xl active:scale-[0.98]"
143
143
  >
144
144
  Créer un compte
145
145
  </Link>
146
146
  <a
147
147
  href={`mailto:${supportEmail}`}
148
- className="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-8 py-3.5 text-base font-medium text-slate-700 shadow-sm transition-all hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
148
+ className="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-8 py-3.5 text-base font-medium text-slate-700 shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
149
149
  >
150
150
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-5 w-5 text-slate-400">
151
151
  <path d="M3 4a2 2 0 00-2 2v1.161l8.441 4.221a1.25 1.25 0 001.118 0L19 7.162V6a2 2 0 00-2-2H3z" />
@@ -73,10 +73,10 @@ export default function SupportRegisterPage() {
73
73
  }
74
74
 
75
75
  const inputClassName =
76
- 'w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition-all placeholder:text-slate-400 focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500 dark:focus:border-blue-500 dark:focus:ring-blue-500/20'
76
+ 'w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none placeholder:text-slate-400 focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500 dark:focus:border-blue-500 dark:focus:ring-blue-500/20'
77
77
 
78
78
  return (
79
- <div className="flex min-h-screen flex-col items-center justify-center px-4 py-12 bg-gradient-to-br from-slate-50 via-white to-blue-50 dark:from-slate-950 dark:via-slate-950 dark:to-blue-950/20">
79
+ <div className="flex min-h-[100dvh] flex-col items-center justify-center px-4 py-12 bg-gradient-to-br from-slate-50 via-white to-blue-50 dark:from-slate-950 dark:via-slate-950 dark:to-blue-950/20">
80
80
  {/* Decorative background element */}
81
81
  <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_60%_50%_at_50%_0%,rgba(37,99,235,0.08),transparent)] dark:bg-[radial-gradient(ellipse_60%_50%_at_50%_0%,rgba(37,99,235,0.12),transparent)]" />
82
82
 
@@ -242,7 +242,7 @@ export default function SupportRegisterPage() {
242
242
  <button
243
243
  type="submit"
244
244
  disabled={loading}
245
- className="w-full rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-lg shadow-blue-600/25 transition-all hover:bg-blue-700 hover:shadow-xl active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
245
+ className="w-full rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-lg shadow-blue-600/25 transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-blue-700 hover:shadow-xl active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
246
246
  >
247
247
  {loading ? 'Creation du compte...' : 'Créer mon compte'}
248
248
  </button>
@@ -257,7 +257,7 @@ export default function SupportRegisterPage() {
257
257
  {/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
258
258
  <a
259
259
  href="/api/support/oauth/google?action=register"
260
- className="flex w-full items-center justify-center gap-3 rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 transition-all hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-600 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
260
+ className="flex w-full items-center justify-center gap-3 rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-600 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
261
261
  >
262
262
  <svg className="h-5 w-5" viewBox="0 0 24 24">
263
263
  <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
@@ -82,7 +82,7 @@ function ResetPasswordForm() {
82
82
  </div>
83
83
  <Link
84
84
  href="/support/login"
85
- className="block w-full rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-center text-base font-black text-black shadow-[4px_4px_0px_#000] transition-all hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000]"
85
+ className="block w-full rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-center text-base font-black text-black shadow-[4px_4px_0px_#000] transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000]"
86
86
  >
87
87
  Se connecter
88
88
  </Link>
@@ -131,7 +131,7 @@ function ResetPasswordForm() {
131
131
  <button
132
132
  type="submit"
133
133
  disabled={loading}
134
- className="w-full rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-base font-black text-black shadow-[4px_4px_0px_#000] transition-all hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000] disabled:opacity-50"
134
+ className="w-full rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-base font-black text-black shadow-[4px_4px_0px_#000] transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000] disabled:opacity-50"
135
135
  >
136
136
  {loading ? 'Modification...' : 'Modifier le mot de passe'}
137
137
  </button>
@@ -143,7 +143,7 @@ function ResetPasswordForm() {
143
143
 
144
144
  export default function ResetPasswordPage() {
145
145
  return (
146
- <div className="flex min-h-screen items-center justify-center px-4 py-12">
146
+ <div className="flex min-h-[100dvh] items-center justify-center px-4 py-12">
147
147
  <Suspense fallback={<p className="text-gray-400">Chargement...</p>}>
148
148
  <ResetPasswordForm />
149
149
  </Suspense>
@@ -37,7 +37,7 @@ $font-stack: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui,
37
37
  .container {
38
38
  display: flex;
39
39
  gap: 16px;
40
- height: calc(100vh - 220px);
40
+ height: calc(100dvh - 220px);
41
41
  min-height: 500px;
42
42
  }
43
43
 
@@ -531,7 +531,7 @@
531
531
  gap: 8px;
532
532
  padding: 8px 14px;
533
533
  font-size: 12px;
534
- color: #7c3aed;
534
+ color: #17807c;
535
535
  font-weight: 500;
536
536
  }
537
537
 
@@ -539,7 +539,7 @@
539
539
  display: flex;
540
540
  gap: 3px;
541
541
  span {
542
- width: 5px; height: 5px; border-radius: 50%; background: #7c3aed;
542
+ width: 5px; height: 5px; border-radius: 50%; background: #17807c;
543
543
  animation: bounce 1.2s infinite;
544
544
  &:nth-child(2) { animation-delay: 150ms; }
545
545
  &:nth-child(3) { animation-delay: 300ms; }
@@ -612,7 +612,7 @@
612
612
  &:disabled { opacity: 0.4; cursor: not-allowed; }
613
613
  }
614
614
 
615
- // Bouton IA — accent purple, pill style
615
+ // AI buttonteal accent, pill style
616
616
  .toolbarBtnAi {
617
617
  background: var(--theme-accent-50, color-mix(in srgb, var(--theme-accent-500, #6b46e8) 10%, transparent));
618
618
  border-color: color-mix(in srgb, var(--theme-accent-500, #6b46e8) 25%, transparent);
@@ -18,7 +18,7 @@ $green: #16a34a;
18
18
  $amber: #d97706;
19
19
  $orange: #ea580c;
20
20
  $red: #dc2626;
21
- $purple: #7c3aed;
21
+ $teal: #17807c;
22
22
  $cyan: #0891b2;
23
23
 
24
24
  // Mixins
@@ -136,7 +136,7 @@
136
136
  --support-cat-core: #2563eb;
137
137
  --support-cat-communication: #16a34a;
138
138
  --support-cat-productivity: #d97706;
139
- --support-cat-advanced: #7c3aed;
139
+ --support-cat-advanced: #17807c;
140
140
 
141
141
  /* ------------------------------------------
142
142
  * 8. PROJECT STATUS COLORS
@@ -385,7 +385,7 @@
385
385
  --support-activity-line-color: var(--support-border-color);
386
386
  --support-activity-dot-default: var(--support-text-muted);
387
387
  --support-activity-dot-status: var(--support-primary);
388
- --support-activity-dot-assign: #7c3aed;
388
+ --support-activity-dot-assign: #17807c;
389
389
  --support-activity-dot-merge: #0891b2;
390
390
 
391
391
  /* ------------------------------------------
@@ -465,10 +465,10 @@
465
465
  * Indicator for messages scheduled to be
466
466
  * sent at a future date/time.
467
467
  * ------------------------------------------ */
468
- --support-scheduled-bg: #f5f3ff;
468
+ --support-scheduled-bg: #eef8f7;
469
469
  --support-scheduled-border: #a78bfa;
470
470
  --support-scheduled-text: #5b21b6;
471
- --support-scheduled-icon: #7c3aed;
471
+ --support-scheduled-icon: #17807c;
472
472
 
473
473
  /* ------------------------------------------
474
474
  * 34. EMAIL TRACKING
@@ -615,8 +615,8 @@
615
615
 
616
616
  /* Scheduled */
617
617
  --support-scheduled-bg: #2e1065;
618
- --support-scheduled-border: #7c3aed;
619
- --support-scheduled-text: #c4b5fd;
618
+ --support-scheduled-border: #17807c;
619
+ --support-scheduled-text: #80bbb7;
620
620
 
621
621
  /* Email tracking */
622
622
  --support-email-pending: #64748b;
package/src/types.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import type { RateLimitStore } from './utils/rateLimiter'
2
+ import type { InboundEmailInput } from './utils/webhookSecurity'
3
+ import type { Payload, PayloadRequest } from 'payload'
4
+
1
5
  // ─── Feature flags ───────────────────────────────────────
2
6
 
3
7
  export interface SupportFeatures {
@@ -76,6 +80,62 @@ export interface EmailConfig {
76
80
  replyTo?: string
77
81
  }
78
82
 
83
+ export interface SmsSendResult {
84
+ sent: boolean
85
+ skipped?: boolean
86
+ error?: string
87
+ }
88
+
89
+ export interface SmsAdapter {
90
+ isConfigured?: () => boolean
91
+ send: (input: {
92
+ message: string
93
+ to: string
94
+ payload: Payload
95
+ ticket: Record<string, unknown>
96
+ client: Record<string, unknown>
97
+ }) => Promise<SmsSendResult>
98
+ }
99
+
100
+ export interface SupportCapabilities {
101
+ /** Optional SMS transport. The module never depends on a concrete provider. */
102
+ sms?: {
103
+ adapter: SmsAdapter
104
+ buildMessage?: (input: {
105
+ client: Record<string, unknown>
106
+ ticket: Record<string, unknown>
107
+ }) => string | Promise<string>
108
+ }
109
+ /** Native daily/weekly notification queue. */
110
+ digests?: boolean
111
+ /** Incoming email business adapter, protected and validated by the module. */
112
+ inboundEmail?: {
113
+ handle: (req: PayloadRequest, input: InboundEmailInput) => Promise<Response>
114
+ secret?: string
115
+ secretHeader?: string
116
+ }
117
+ /** Generate a short, editable display title after ticket creation. */
118
+ aiTitles?: {
119
+ generate: (payload: Payload, ticketId: number | string) => Promise<unknown>
120
+ }
121
+ /** Override the module's default resolved-ticket synthesis generator. */
122
+ aiSummaries?: {
123
+ generate: (payload: Payload, ticketId: number | string) => Promise<unknown>
124
+ }
125
+ /** Persist billing lines and their invoicing state. */
126
+ detailedBilling?: boolean
127
+ /** Persist pro-bono work and its estimated value. */
128
+ volunteering?: boolean
129
+ /** Best-effort cleanup of noisy messages after an inbound client reply. */
130
+ threadCleanup?: {
131
+ clean: (payload: Payload, ticketId: number | string, messageId: number | string) => Promise<unknown>
132
+ }
133
+ /** Deployment-specific project suggestion adapter. */
134
+ projectSuggestions?: {
135
+ suggest: (req: PayloadRequest) => Promise<Response>
136
+ }
137
+ }
138
+
79
139
  // ─── Plugin configuration ────────────────────────────────
80
140
 
81
141
  export interface SupportPluginConfig {
@@ -88,6 +148,15 @@ export interface SupportPluginConfig {
88
148
  /** Email configuration for ticket notifications */
89
149
  email?: EmailConfig
90
150
 
151
+ /** Shared rate-limit storage. Defaults to process-local memory. */
152
+ rateLimitStore?: RateLimitStore | 'payload'
153
+
154
+ /** Sequential ticket number formatting. */
155
+ ticketNumber?: { prefix?: string; padding?: number }
156
+
157
+ /** Optional generic capabilities backed by deployment-specific adapters. */
158
+ capabilities?: SupportCapabilities
159
+
91
160
  /** Locale: 'fr' or 'en' (default: 'fr') */
92
161
  locale?: 'fr' | 'en'
93
162
 
@@ -136,6 +205,8 @@ export interface SupportPluginConfig {
136
205
  macros?: string
137
206
  ticketStatuses?: string
138
207
  ticketFeedback?: string
208
+ rateLimits?: string
209
+ counters?: string
139
210
  }
140
211
 
141
212
  /** Admin notification collection slug (default: 'admin-notifications') */
@@ -1,52 +1,145 @@
1
+ export interface RateLimitEntry {
2
+ count: number
3
+ resetAt: number
4
+ }
5
+
6
+ export interface RateLimitStore {
7
+ increment(key: string, windowMs: number, context?: unknown): Promise<RateLimitEntry>
8
+ reset(key: string, context?: unknown): Promise<void>
9
+ }
10
+
1
11
  /**
2
- * Shared in-memory rate limiter with automatic cleanup.
3
- * Replaces duplicated rate-limiting logic across endpoints.
12
+ * Process-local fallback store. Applications running more than one process
13
+ * should provide a persistent RateLimitStore through the plugin options.
4
14
  */
5
- export class RateLimiter {
6
- private store = new Map<string, { count: number; resetAt: number }>()
15
+ export class MemoryRateLimitStore implements RateLimitStore {
16
+ private readonly entries = new Map<string, RateLimitEntry>()
7
17
 
8
- constructor(
9
- private windowMs: number,
10
- private maxRequests: number,
11
- ) {
12
- // Periodic cleanup to prevent memory leaks
13
- const timer = setInterval(() => this.cleanup(), windowMs)
14
- timer.unref()
18
+ async increment(key: string, windowMs: number): Promise<RateLimitEntry> {
19
+ const now = Date.now()
20
+ const current = this.entries.get(key)
21
+ const next = !current || now > current.resetAt
22
+ ? { count: 1, resetAt: now + windowMs }
23
+ : { ...current, count: current.count + 1 }
24
+
25
+ this.entries.set(key, next)
26
+ return next
15
27
  }
16
28
 
17
- /**
18
- * Check if a key has exceeded the rate limit.
19
- * Returns true if the request should be blocked.
20
- */
21
- check(key: string): boolean {
22
- const now = Date.now()
23
- const entry = this.store.get(key)
29
+ async reset(key: string): Promise<void> {
30
+ this.entries.delete(key)
31
+ }
32
+ }
24
33
 
25
- if (!entry || now > entry.resetAt) {
26
- this.store.set(key, { count: 1, resetAt: now + this.windowMs })
27
- return false
28
- }
34
+ interface PayloadRateLimitContext {
35
+ find(args: Record<string, unknown>): Promise<{ docs: Array<Record<string, unknown>> }>
36
+ create(args: Record<string, unknown>): Promise<Record<string, unknown>>
37
+ update(args: Record<string, unknown>): Promise<Record<string, unknown>>
38
+ delete(args: Record<string, unknown>): Promise<unknown>
39
+ }
29
40
 
30
- entry.count++
31
- return entry.count > this.maxRequests
41
+ type PayloadRateLimitRequest = Pick<PayloadRequest, 'payload' | 'transactionID'>
42
+
43
+ export class PayloadRateLimitStore implements RateLimitStore {
44
+ constructor(private readonly collectionSlug = 'support-rate-limits') {}
45
+
46
+ async increment(key: string, windowMs: number, context?: unknown): Promise<RateLimitEntry> {
47
+ const { payload, req } = this.resolveContext(context)
48
+ const ownsTransaction = req ? await initTransaction(req) : false
49
+ try {
50
+ const now = Date.now()
51
+ const result = await payload.find({
52
+ collection: this.collectionSlug,
53
+ where: { key: { equals: key } },
54
+ limit: 1,
55
+ depth: 0,
56
+ overrideAccess: true,
57
+ ...(req ? { req } : {}),
58
+ })
59
+ const current = result.docs[0]
60
+ const currentResetAt = current?.resetAt ? new Date(String(current.resetAt)).getTime() : 0
61
+ const next = !current || now > currentResetAt
62
+ ? { count: 1, resetAt: now + windowMs }
63
+ : { count: Number(current.count || 0) + 1, resetAt: currentResetAt }
64
+
65
+ if (current?.id != null) {
66
+ await payload.update({
67
+ collection: this.collectionSlug,
68
+ id: current.id,
69
+ data: { count: next.count, resetAt: new Date(next.resetAt).toISOString() },
70
+ overrideAccess: true,
71
+ ...(req ? { req } : {}),
72
+ })
73
+ } else {
74
+ await payload.create({
75
+ collection: this.collectionSlug,
76
+ data: { key, count: next.count, resetAt: new Date(next.resetAt).toISOString() },
77
+ overrideAccess: true,
78
+ ...(req ? { req } : {}),
79
+ })
80
+ }
81
+
82
+ if (ownsTransaction && req) await commitTransaction(req)
83
+ return next
84
+ } catch (error) {
85
+ if (ownsTransaction && req) await killTransaction(req)
86
+ throw error
87
+ }
32
88
  }
33
89
 
34
- /**
35
- * Reset the counter for a specific key.
36
- */
37
- reset(key: string): void {
38
- this.store.delete(key)
90
+ async reset(key: string, context?: unknown): Promise<void> {
91
+ const { payload, req } = this.resolveContext(context)
92
+ const ownsTransaction = req ? await initTransaction(req) : false
93
+ try {
94
+ await payload.delete({
95
+ collection: this.collectionSlug,
96
+ where: { key: { equals: key } },
97
+ overrideAccess: true,
98
+ ...(req ? { req } : {}),
99
+ })
100
+ if (ownsTransaction && req) await commitTransaction(req)
101
+ } catch (error) {
102
+ if (ownsTransaction && req) await killTransaction(req)
103
+ throw error
104
+ }
39
105
  }
40
106
 
41
- /**
42
- * Remove expired entries from the store.
43
- */
44
- private cleanup(): void {
45
- const now = Date.now()
46
- for (const [key, entry] of this.store) {
47
- if (now > entry.resetAt) {
48
- this.store.delete(key)
49
- }
107
+ private resolveContext(context: unknown): {
108
+ payload: PayloadRateLimitContext
109
+ req?: PayloadRateLimitRequest
110
+ } {
111
+ if (!context || typeof context !== 'object') {
112
+ throw new Error('PayloadRateLimitStore requires the current Payload request or instance')
50
113
  }
114
+ if ('payload' in context) {
115
+ const req = context as PayloadRateLimitRequest
116
+ return { payload: req.payload as unknown as PayloadRateLimitContext, req }
117
+ }
118
+ return { payload: context as PayloadRateLimitContext }
119
+ }
120
+ }
121
+
122
+ export class RateLimiter {
123
+ private readonly store: RateLimitStore
124
+
125
+ constructor(
126
+ private readonly windowMs: number,
127
+ private readonly maxRequests: number,
128
+ store?: RateLimitStore,
129
+ ) {
130
+ this.store = store ?? new MemoryRateLimitStore()
131
+ }
132
+
133
+ async check(key: string, context?: unknown): Promise<boolean> {
134
+ const entry = context === undefined
135
+ ? await this.store.increment(key, this.windowMs)
136
+ : await this.store.increment(key, this.windowMs, context)
137
+ return entry.count > this.maxRequests
138
+ }
139
+
140
+ async reset(key: string, context?: unknown): Promise<void> {
141
+ if (context === undefined) await this.store.reset(key)
142
+ else await this.store.reset(key, context)
51
143
  }
52
144
  }
145
+ import { commitTransaction, initTransaction, killTransaction, type PayloadRequest } from 'payload'
@@ -24,6 +24,8 @@ export interface CollectionSlugs {
24
24
  automationRules: string
25
25
  supportTeams: string
26
26
  pushSubscriptions: string
27
+ rateLimits: string
28
+ counters: string
27
29
  users: string
28
30
  media: string
29
31
  }
@@ -50,6 +52,8 @@ export const DEFAULT_SLUGS: CollectionSlugs = {
50
52
  automationRules: 'automation-rules',
51
53
  supportTeams: 'support-teams',
52
54
  pushSubscriptions: 'push-subscriptions',
55
+ rateLimits: 'support-rate-limits',
56
+ counters: 'support-counters',
53
57
  users: 'users',
54
58
  media: 'media',
55
59
  }
@@ -0,0 +1,81 @@
1
+ import { createHash, timingSafeEqual } from 'crypto'
2
+
3
+ export interface InboundEmailLimits {
4
+ maxRequestBytes: number
5
+ maxAttachmentBytes: number
6
+ maxAttachments: number
7
+ maxSubjectLength: number
8
+ maxNameLength: number
9
+ maxEmailLength: number
10
+ maxBodyLength: number
11
+ }
12
+
13
+ export const DEFAULT_INBOUND_EMAIL_LIMITS: InboundEmailLimits = {
14
+ maxRequestBytes: 25 * 1024 * 1024,
15
+ maxAttachmentBytes: 10 * 1024 * 1024,
16
+ maxAttachments: 10,
17
+ maxSubjectLength: 1_000,
18
+ maxNameLength: 500,
19
+ maxEmailLength: 320,
20
+ maxBodyLength: 38_000,
21
+ }
22
+
23
+ interface InboundAttachment {
24
+ filename?: string
25
+ size?: number
26
+ content?: string
27
+ }
28
+
29
+ export interface InboundEmailInput {
30
+ senderEmail?: string
31
+ senderName?: string
32
+ recipientEmail?: string
33
+ cc?: string
34
+ subject?: string
35
+ body?: string
36
+ htmlBody?: string
37
+ attachments?: InboundAttachment[]
38
+ }
39
+
40
+ export interface InboundEmailValidationError {
41
+ code: 'request_too_large' | 'too_many_attachments' | 'attachment_too_large' | 'text_field_too_large'
42
+ status: 413
43
+ }
44
+
45
+ export function verifySecret(provided: string | null | undefined, expected: string | null | undefined): boolean {
46
+ if (!provided || !expected) return false
47
+ const providedDigest = createHash('sha256').update(provided).digest()
48
+ const expectedDigest = createHash('sha256').update(expected).digest()
49
+ return timingSafeEqual(providedDigest, expectedDigest)
50
+ }
51
+
52
+ export function validateInboundEmailPayload(
53
+ input: InboundEmailInput,
54
+ contentLength?: number,
55
+ limits: InboundEmailLimits = DEFAULT_INBOUND_EMAIL_LIMITS,
56
+ ): InboundEmailValidationError | null {
57
+ if (contentLength != null && contentLength > limits.maxRequestBytes) {
58
+ return { code: 'request_too_large', status: 413 }
59
+ }
60
+
61
+ const attachments = Array.isArray(input.attachments) ? input.attachments : []
62
+ if (attachments.length > limits.maxAttachments) {
63
+ return { code: 'too_many_attachments', status: 413 }
64
+ }
65
+ for (const attachment of attachments) {
66
+ const decodedSize = attachment.content ? Math.ceil(attachment.content.length * 0.75) : 0
67
+ if (Math.max(Number(attachment.size || 0), decodedSize) > limits.maxAttachmentBytes) {
68
+ return { code: 'attachment_too_large', status: 413 }
69
+ }
70
+ }
71
+
72
+ const tooLong =
73
+ (input.subject?.length || 0) > limits.maxSubjectLength ||
74
+ (input.senderName?.length || 0) > limits.maxNameLength ||
75
+ (input.senderEmail?.length || 0) > limits.maxEmailLength ||
76
+ (input.recipientEmail?.length || 0) > limits.maxEmailLength ||
77
+ (input.cc?.length || 0) > limits.maxEmailLength ||
78
+ (input.body?.length || 0) > limits.maxBodyLength ||
79
+ (input.htmlBody?.length || 0) > limits.maxBodyLength
80
+ return tooLong ? { code: 'text_field_too_large', status: 413 } : null
81
+ }
@@ -540,7 +540,7 @@ export const SupportDashboardClient: React.FC = () => {
540
540
 
541
541
  {/* CSAT ring */}
542
542
  <div className={styles.panel}>
543
- <h2 className={styles.panelTitle}>{t('dashboard.csat')}</h2>
543
+ <h2 className={styles.panelTitle}>{t('dashboard.csatTitle')}</h2>
544
544
  <CSATRing score={stats.satisfactionAvg} count={stats.satisfactionCount} />
545
545
  </div>
546
546
  </div>
@@ -206,7 +206,7 @@ function getCategories(t: (key: string) => string) {
206
206
  { key: 'core', label: t('settingsView.categories.core'), color: V.blue },
207
207
  { key: 'communication', label: t('settingsView.categories.communication'), color: V.green },
208
208
  { key: 'productivity', label: t('settingsView.categories.productivity'), color: V.amber },
209
- { key: 'advanced', label: t('settingsView.categories.advanced'), color: '#7c3aed' },
209
+ { key: 'advanced', label: t('settingsView.categories.advanced'), color: '#17807c' },
210
210
  ]
211
211
  }
212
212
 
@@ -413,7 +413,7 @@ export const TicketingSettingsClient: React.FC = () => {
413
413
  * SECTION 1 — Feature Flags
414
414
  * ======================================== */}
415
415
  <CollapsibleSection
416
- title={t('settingsView.features')}
416
+ title={t('settingsView.featuresTitle')}
417
417
  icon={<Settings size={16} />}
418
418
  color={V.blue}
419
419
  badge={
@@ -532,7 +532,7 @@ export const TicketingSettingsClient: React.FC = () => {
532
532
  <CollapsibleSection
533
533
  title={t('settingsView.aiTitle')}
534
534
  icon={<Bot size={16} />}
535
- color="#7c3aed"
535
+ color="#17807c"
536
536
  defaultOpen={false}
537
537
  badge={
538
538
  features.ai
@@ -604,7 +604,7 @@ export const TicketingSettingsClient: React.FC = () => {
604
604
  <Toggle
605
605
  checked={settings.ai[item.key]}
606
606
  onChange={() => updateAI(item.key, !settings.ai[item.key])}
607
- color="#7c3aed"
607
+ color="#17807c"
608
608
  size="sm"
609
609
  />
610
610
  <div style={{ flex: 1 }}>