@consilioweb/payload-support 0.15.0 → 1.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 (202) hide show
  1. package/README.md +347 -435
  2. package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
  4. package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
  5. package/dist/components/TicketConversation/SkeletonText.js +20 -0
  6. package/dist/components/TicketConversation/components/ActionPanels.cjs +72 -0
  7. package/dist/components/TicketConversation/components/ActionPanels.js +73 -2
  8. package/dist/components/TicketConversation/components/QuickActions.cjs +22 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +22 -1
  10. package/dist/components/TicketConversation/constants.cjs +27 -0
  11. package/dist/components/TicketConversation/constants.js +26 -1
  12. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  13. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  14. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +59 -0
  15. package/dist/components/TicketConversation/hooks/useTicketActions.js +59 -0
  16. package/dist/components/TicketConversation/index.cjs +42 -149
  17. package/dist/components/TicketConversation/index.js +37 -144
  18. package/dist/index.cjs +1835 -741
  19. package/dist/index.d.cts +3 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +1833 -741
  22. package/dist/views/BillingView/client.cjs +40 -54
  23. package/dist/views/BillingView/client.js +40 -54
  24. package/dist/views/ChatView/client.cjs +4 -3
  25. package/dist/views/ChatView/client.js +4 -3
  26. package/dist/views/CrmView/client.cjs +7 -7
  27. package/dist/views/CrmView/client.js +7 -7
  28. package/dist/views/EmailTrackingView/client.cjs +6 -5
  29. package/dist/views/EmailTrackingView/client.js +6 -5
  30. package/dist/views/ImportConversationView/client.cjs +4 -4
  31. package/dist/views/ImportConversationView/client.js +4 -4
  32. package/dist/views/LogsView/client.cjs +3 -2
  33. package/dist/views/LogsView/client.js +3 -2
  34. package/dist/views/NewTicketView/client.cjs +1 -1
  35. package/dist/views/NewTicketView/client.js +1 -1
  36. package/dist/views/PendingEmailsView/client.cjs +7 -6
  37. package/dist/views/PendingEmailsView/client.js +7 -6
  38. package/dist/views/SupportDashboardView/client.cjs +19 -22
  39. package/dist/views/SupportDashboardView/client.js +19 -22
  40. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  41. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  42. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  43. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  44. package/dist/views/TicketDetailView/client.cjs +59 -190
  45. package/dist/views/TicketDetailView/client.js +52 -183
  46. package/dist/views/TicketDetailView/constants.cjs +20 -0
  47. package/dist/views/TicketDetailView/constants.js +16 -0
  48. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  49. package/dist/views/TicketDetailView/helpers.js +20 -0
  50. package/dist/views/TicketDetailView/types.cjs +2 -0
  51. package/dist/views/TicketDetailView/types.js +1 -0
  52. package/dist/views/TicketInboxView/client.cjs +14 -10
  53. package/dist/views/TicketInboxView/client.js +14 -10
  54. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  55. package/dist/views/TicketingSettingsView/client.js +92 -88
  56. package/dist/views/TimeDashboardView/client.cjs +2 -2
  57. package/dist/views/TimeDashboardView/client.js +2 -2
  58. package/dist/views/shared/StatusPill.cjs +23 -12
  59. package/dist/views/shared/StatusPill.js +23 -12
  60. package/dist/views/shared/dateLocale.cjs +5 -0
  61. package/dist/views/shared/dateLocale.js +3 -0
  62. package/package.json +23 -15
  63. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  64. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  65. package/src/__tests__/integration/auth.test.ts +43 -0
  66. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  67. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  68. package/src/__tests__/integration/channels.test.ts +52 -0
  69. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  70. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  71. package/src/__tests__/integration/digest.test.ts +48 -0
  72. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  73. package/src/__tests__/integration/isolation.test.ts +81 -0
  74. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  75. package/src/__tests__/integration/nps.test.ts +37 -0
  76. package/src/__tests__/integration/sanitization.test.ts +35 -0
  77. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  78. package/src/__tests__/integration/smoke.test.ts +26 -0
  79. package/src/__tests__/integration/snooze.test.ts +68 -0
  80. package/src/__tests__/integration/teams.test.ts +38 -0
  81. package/src/__tests__/rateLimiter.test.ts +89 -0
  82. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  83. package/src/__tests__/sla.test.ts +258 -0
  84. package/src/collections/AutomationRules.ts +66 -0
  85. package/src/collections/NotificationQueue.ts +30 -0
  86. package/src/collections/SatisfactionSurveys.ts +11 -2
  87. package/src/collections/SupportClients.ts +58 -1
  88. package/src/collections/SupportTeam.ts +27 -0
  89. package/src/collections/TicketActivityLog.ts +9 -4
  90. package/src/collections/TicketMessages.ts +137 -31
  91. package/src/collections/Tickets.ts +31 -72
  92. package/src/collections/index.ts +3 -0
  93. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  94. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  95. package/src/components/TicketConversation/components/ActionPanels.tsx +82 -1
  96. package/src/components/TicketConversation/components/QuickActions.tsx +25 -0
  97. package/src/components/TicketConversation/constants.ts +29 -0
  98. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  99. package/src/components/TicketConversation/hooks/useTicketActions.ts +51 -0
  100. package/src/components/TicketConversation/index.tsx +29 -157
  101. package/src/endpoints/admin-chat-stream.ts +3 -4
  102. package/src/endpoints/admin-chat.ts +11 -20
  103. package/src/endpoints/admin-stats.ts +70 -32
  104. package/src/endpoints/ai-agent.ts +36 -0
  105. package/src/endpoints/apply-macro.ts +8 -14
  106. package/src/endpoints/auth-2fa.ts +12 -12
  107. package/src/endpoints/auto-close.ts +38 -18
  108. package/src/endpoints/billing.ts +17 -4
  109. package/src/endpoints/bulk-action.ts +7 -12
  110. package/src/endpoints/channels.ts +89 -0
  111. package/src/endpoints/chat-stream.ts +3 -4
  112. package/src/endpoints/chat.ts +7 -12
  113. package/src/endpoints/chatbot.ts +2 -2
  114. package/src/endpoints/client-intelligence.ts +4 -4
  115. package/src/endpoints/delete-account.ts +9 -16
  116. package/src/endpoints/email-stats.ts +2 -2
  117. package/src/endpoints/escalate.ts +2 -2
  118. package/src/endpoints/export-csv.ts +2 -2
  119. package/src/endpoints/export-data.ts +5 -8
  120. package/src/endpoints/import-conversation.ts +6 -10
  121. package/src/endpoints/index.ts +24 -2
  122. package/src/endpoints/invite-collaborator.ts +30 -15
  123. package/src/endpoints/invoice.ts +127 -0
  124. package/src/endpoints/kb-search.ts +2 -2
  125. package/src/endpoints/login.ts +12 -4
  126. package/src/endpoints/merge-clients.ts +14 -19
  127. package/src/endpoints/merge-tickets.ts +11 -18
  128. package/src/endpoints/oauth-google.ts +86 -39
  129. package/src/endpoints/pending-emails-process.ts +15 -25
  130. package/src/endpoints/process-digests.ts +93 -0
  131. package/src/endpoints/process-scheduled.ts +6 -10
  132. package/src/endpoints/process-snooze.ts +78 -0
  133. package/src/endpoints/purge-logs.ts +2 -2
  134. package/src/endpoints/resend-notification.ts +3 -4
  135. package/src/endpoints/round-robin-config.ts +3 -4
  136. package/src/endpoints/satisfaction.ts +21 -14
  137. package/src/endpoints/search.ts +5 -8
  138. package/src/endpoints/seed-kb.ts +3 -4
  139. package/src/endpoints/send-reminder.ts +186 -0
  140. package/src/endpoints/settings.ts +5 -2
  141. package/src/endpoints/signature.ts +3 -4
  142. package/src/endpoints/sla-check.ts +2 -2
  143. package/src/endpoints/split-ticket.ts +7 -12
  144. package/src/endpoints/statuses.ts +2 -2
  145. package/src/endpoints/ticket-feedback.ts +4 -6
  146. package/src/endpoints/ticket-synthesis.ts +2 -2
  147. package/src/endpoints/track-open.ts +16 -28
  148. package/src/endpoints/transfer-ticket.ts +21 -9
  149. package/src/endpoints/user-prefs.ts +3 -4
  150. package/src/hooks/automationRules.ts +84 -0
  151. package/src/hooks/checkSLA.ts +48 -16
  152. package/src/plugin.ts +6 -0
  153. package/src/portal/LiveChat.tsx +2 -63
  154. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  155. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  156. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  157. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  158. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  159. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  160. package/src/portal/helpers.ts +46 -0
  161. package/src/portal/login/page.tsx +3 -8
  162. package/src/portal/types.ts +15 -0
  163. package/src/types/lucide-react.d.ts +34 -0
  164. package/src/types/payload-config.d.ts +10 -0
  165. package/src/utils/adminNotification.ts +2 -2
  166. package/src/utils/aiAgent.ts +147 -0
  167. package/src/utils/auth.ts +5 -4
  168. package/src/utils/channels.ts +36 -0
  169. package/src/utils/db.ts +73 -0
  170. package/src/utils/emailTemplate.ts +8 -1
  171. package/src/utils/fireWebhooks.ts +4 -6
  172. package/src/utils/generateTicketSynthesis.ts +7 -12
  173. package/src/utils/invoicePdf.ts +88 -0
  174. package/src/utils/notificationQueue.ts +36 -0
  175. package/src/utils/readSettings.ts +22 -6
  176. package/src/utils/sanitizeHtml.ts +57 -0
  177. package/src/utils/slugs.ts +6 -0
  178. package/src/utils/teamAccess.ts +27 -0
  179. package/src/utils/ticketAccess.ts +53 -0
  180. package/src/utils/webhookDispatcher.ts +4 -6
  181. package/src/views/BillingView/client.tsx +49 -37
  182. package/src/views/ChatView/client.tsx +4 -3
  183. package/src/views/CrmView/client.tsx +7 -7
  184. package/src/views/EmailTrackingView/client.tsx +7 -6
  185. package/src/views/ImportConversationView/client.tsx +4 -4
  186. package/src/views/LogsView/client.tsx +3 -2
  187. package/src/views/NewTicketView/client.tsx +1 -1
  188. package/src/views/PendingEmailsView/client.tsx +7 -6
  189. package/src/views/SupportDashboardView/client.tsx +21 -21
  190. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  191. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  192. package/src/views/TicketDetailView/client.tsx +54 -179
  193. package/src/views/TicketDetailView/constants.ts +23 -0
  194. package/src/views/TicketDetailView/helpers.ts +17 -0
  195. package/src/views/TicketDetailView/types.ts +10 -0
  196. package/src/views/TicketInboxView/client.tsx +16 -10
  197. package/src/views/TicketingSettingsView/client.tsx +90 -87
  198. package/src/views/TimeDashboardView/client.tsx +2 -2
  199. package/src/views/shared/StatusPill.tsx +24 -12
  200. package/src/views/shared/dateLocale.ts +5 -0
  201. package/src/views/shared/locales/en.json +708 -0
  202. package/src/views/shared/locales/fr.json +708 -0
@@ -3,6 +3,7 @@
3
3
  import React, { useCallback, useEffect, useRef, useState } from 'react'
4
4
 
5
5
  type Toast = { type: 'success' | 'error'; message: string } | null
6
+ type ToastPayload = NonNullable<Toast>
6
7
  type Modal = 'transfer' | 'invite' | null
7
8
 
8
9
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@@ -116,7 +117,7 @@ function TransferModal({
116
117
  }: {
117
118
  ticketId: number | string
118
119
  onClose: () => void
119
- onResult: (t: Toast) => void
120
+ onResult: (t: ToastPayload) => void
120
121
  }) {
121
122
  const [email, setEmail] = useState('')
122
123
  const [includeAttachments, setIncludeAttachments] = useState(true)
@@ -204,7 +205,7 @@ function InviteModal({
204
205
  }: {
205
206
  ticketId: number | string
206
207
  onClose: () => void
207
- onResult: (t: Toast) => void
208
+ onResult: (t: ToastPayload) => void
208
209
  }) {
209
210
  const [email, setEmail] = useState('')
210
211
  const [role, setRole] = useState<'viewer' | 'collaborator'>('viewer')
@@ -748,7 +748,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
748
748
  {/* Edit/delete actions for client messages */}
749
749
  {isClient && msg.authorType === 'client' && !(msg as unknown as { deletedAt?: string }).deletedAt && (
750
750
  <MessageActions
751
- messageId={msg.id}
751
+ messageId={Number(msg.id)}
752
752
  body={msg.body}
753
753
  createdAt={msg.createdAt}
754
754
  />
@@ -786,7 +786,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
786
786
 
787
787
  {/* Typing indicator inside conversation */}
788
788
  <div className="px-4 sm:px-6 pb-2">
789
- <TypingIndicator ticketId={ticket.id} />
789
+ <TypingIndicator ticketId={Number(ticket.id)} />
790
790
  </div>
791
791
  </div>
792
792
 
@@ -0,0 +1,46 @@
1
+ import type { LiveChatSession } from './types'
2
+
3
+ // ─── LocalStorage keys ──────────────────────────────────────
4
+
5
+ export const LS_KEY = 'support-livechat'
6
+
7
+ export function loadSession(): LiveChatSession | null {
8
+ if (typeof window === 'undefined') return null
9
+ try {
10
+ const raw = localStorage.getItem(LS_KEY)
11
+ if (!raw) return null
12
+ const session = JSON.parse(raw) as LiveChatSession
13
+
14
+ // Validate token expiry (24h) — extract timestamp from token
15
+ if (session.clientToken) {
16
+ const parts = session.clientToken.split('_')
17
+ if (parts.length === 4) {
18
+ const timestamp = parseInt(parts[2], 10)
19
+ if (!isNaN(timestamp) && Date.now() - timestamp > 24 * 60 * 60 * 1000) {
20
+ localStorage.removeItem(LS_KEY)
21
+ return null
22
+ }
23
+ }
24
+ }
25
+
26
+ return session
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ export function saveSession(session: LiveChatSession): void {
33
+ try {
34
+ localStorage.setItem(LS_KEY, JSON.stringify(session))
35
+ } catch {
36
+ // localStorage full or disabled
37
+ }
38
+ }
39
+
40
+ export function clearSession(): void {
41
+ try {
42
+ localStorage.removeItem(LS_KEY)
43
+ } catch {
44
+ // Ignore
45
+ }
46
+ }
@@ -55,14 +55,9 @@ function SupportLoginContent() {
55
55
  return
56
56
  }
57
57
 
58
- // Check if 2FA is enabled for this user
59
- if (data.user?.twoFactorEnabled) {
60
- // Logout immediately (we need 2FA first)
61
- await fetch('/api/support-clients/logout', {
62
- method: 'POST',
63
- credentials: 'include',
64
- })
65
-
58
+ // Server requires 2FA: credentials were valid but NO session was issued.
59
+ // Request the code and switch to the verification step.
60
+ if (data.requires2FA) {
66
61
  // Send 2FA code
67
62
  setSending2FA(true)
68
63
  const codeRes = await fetch('/api/support/2fa', {
@@ -0,0 +1,15 @@
1
+ export interface ChatMessage {
2
+ id: number | string
3
+ body: string
4
+ senderType: 'client' | 'agent' | 'system'
5
+ createdAt: string
6
+ }
7
+
8
+ export interface LiveChatSession {
9
+ clientToken: string
10
+ email: string
11
+ name: string
12
+ sessionId?: string
13
+ }
14
+
15
+ export type Screen = 'closed' | 'identify' | 'chat' | 'rating'
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Ambient stub for 'lucide-react'.
3
+ * lucide-react is an optional peer dependency — the host application installs it.
4
+ * This stub satisfies TypeScript when the plugin is type-checked in isolation.
5
+ */
6
+ declare module 'lucide-react' {
7
+ import type { FC, SVGProps } from 'react'
8
+ type LucideIcon = FC<SVGProps<SVGSVGElement> & { size?: number | string; strokeWidth?: number | string }>
9
+ // Export every named icon as LucideIcon so any import resolves cleanly
10
+ const MessageCircle: LucideIcon
11
+ const Send: LucideIcon
12
+ const X: LucideIcon
13
+ const Minimize2: LucideIcon
14
+ const ArrowLeft: LucideIcon
15
+ const Inbox: LucideIcon
16
+ const Plus: LucideIcon
17
+ const Link2: LucideIcon
18
+ const Search: LucideIcon
19
+ const ChevronDown: LucideIcon
20
+ const ChevronUp: LucideIcon
21
+ const Paperclip: LucideIcon
22
+ const Settings: LucideIcon
23
+ const Mail: LucideIcon
24
+ const Bot: LucideIcon
25
+ const Clock: LucideIcon
26
+ const Timer: LucideIcon
27
+ const Globe: LucideIcon
28
+ const FileSignature: LucideIcon
29
+ export {
30
+ MessageCircle, Send, X, Minimize2, ArrowLeft,
31
+ Inbox, Plus, Link2, Search, ChevronDown, ChevronUp, Paperclip,
32
+ Settings, Mail, Bot, Clock, Timer, Globe, FileSignature,
33
+ }
34
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Ambient stub for '@payload-config'.
3
+ * The host application provides the real config; this stub satisfies
4
+ * TypeScript when the plugin is type-checked in isolation.
5
+ */
6
+ declare module '@payload-config' {
7
+ import type { SanitizedConfig } from 'payload'
8
+ const config: Promise<SanitizedConfig>
9
+ export default config
10
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Payload } from 'payload'
2
+ import { dbCreate } from './db'
2
3
 
3
4
  /**
4
5
  * Helper to create an admin notification.
@@ -20,8 +21,7 @@ export async function createAdminNotification(
20
21
  collectionSlug = 'admin-notifications',
21
22
  ): Promise<void> {
22
23
  try {
23
- await payload.create({
24
- collection: collectionSlug as any,
24
+ await dbCreate(payload, collectionSlug, {
25
25
  data: {
26
26
  title: data.title,
27
27
  message: data.message,
@@ -0,0 +1,147 @@
1
+ import type { Payload } from 'payload'
2
+ import type { CollectionSlugs } from './slugs'
3
+ import { readSupportSettings, type SupportSettings } from './readSettings'
4
+ import { dbFind, dbFindByID, dbCreate, dbUpdate } from './db'
5
+
6
+ export interface AgentDecision {
7
+ action: 'reply' | 'escalate'
8
+ reply?: string
9
+ reason?: string
10
+ confidence?: number
11
+ }
12
+
13
+ export interface AgentResult {
14
+ acted: 'reply' | 'escalate'
15
+ messageId?: number | string
16
+ confidence?: number
17
+ }
18
+
19
+ /**
20
+ * Execute an AI agent decision on a ticket. DETERMINISTIC (no LLM) — this is the
21
+ * tested core: a `reply` posts a public answer (attributed to the AI assistant),
22
+ * an `escalate` records an internal note with the reasoning and flags the ticket.
23
+ */
24
+ export async function applyAgentDecision(
25
+ payload: Payload,
26
+ slugs: CollectionSlugs,
27
+ ticketId: number | string,
28
+ decision: AgentDecision,
29
+ ): Promise<AgentResult> {
30
+ if (decision.action === 'reply' && decision.reply && decision.reply.trim()) {
31
+ const msg = await dbCreate(payload, slugs.ticketMessages, {
32
+ data: {
33
+ ticket: ticketId,
34
+ body: decision.reply.trim(),
35
+ authorType: 'admin',
36
+ isInternal: false,
37
+ fromAlias: 'Assistant IA',
38
+ },
39
+ overrideAccess: true,
40
+ })
41
+ return { acted: 'reply', messageId: (msg as { id: number | string }).id, confidence: decision.confidence }
42
+ }
43
+
44
+ // Escalate: leave an internal note + flag the ticket for a human.
45
+ await dbCreate(payload, slugs.ticketMessages, {
46
+ data: {
47
+ ticket: ticketId,
48
+ body: `[Agent IA] Escalade vers un humain. Raison : ${decision.reason || 'non déterminée'} (confiance ${Math.round((decision.confidence ?? 0) * 100)} %).`,
49
+ authorType: 'admin',
50
+ isInternal: true,
51
+ skipNotification: true,
52
+ },
53
+ overrideAccess: true,
54
+ })
55
+ await dbUpdate(payload, slugs.tickets, { id: ticketId, data: { status: 'escalated' }, overrideAccess: true })
56
+ return { acted: 'escalate', confidence: decision.confidence }
57
+ }
58
+
59
+ // ─── LLM decision (runtime only — not unit-tested) ───────────────────
60
+
61
+ function getClient(aiSettings: SupportSettings['ai']) {
62
+ const Anthropic = require('@anthropic-ai/sdk').default
63
+ if (aiSettings.provider === 'ollama') {
64
+ return new Anthropic({ apiKey: 'ollama', baseURL: process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1' })
65
+ }
66
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
67
+ }
68
+
69
+ function stripHtml(s: string): string {
70
+ return (s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
71
+ }
72
+
73
+ function parseDecision(text: string): AgentDecision {
74
+ try {
75
+ const match = text.match(/\{[\s\S]*\}/)
76
+ if (match) {
77
+ const obj = JSON.parse(match[0])
78
+ return {
79
+ action: obj.action === 'reply' ? 'reply' : 'escalate',
80
+ reply: typeof obj.reply === 'string' ? obj.reply : undefined,
81
+ reason: typeof obj.reason === 'string' ? obj.reason : undefined,
82
+ confidence: typeof obj.confidence === 'number' ? obj.confidence : 0,
83
+ }
84
+ }
85
+ } catch { /* fall through */ }
86
+ return { action: 'escalate', reason: 'Réponse IA non interprétable', confidence: 0 }
87
+ }
88
+
89
+ /**
90
+ * Run the autonomous AI agent on a ticket: builds context (ticket + thread + KB),
91
+ * asks the LLM for a structured decision, applies a confidence threshold, then
92
+ * executes it via {@link applyAgentDecision}. Requires an AI provider at runtime.
93
+ */
94
+ export async function runAiAgent(
95
+ payload: Payload,
96
+ slugs: CollectionSlugs,
97
+ ticketId: number | string,
98
+ opts?: { confidenceThreshold?: number },
99
+ ): Promise<AgentResult> {
100
+ const settings = await readSupportSettings(payload)
101
+ const threshold = opts?.confidenceThreshold ?? 0.7
102
+
103
+ const ticket = await dbFindByID(payload, slugs.tickets, { id: ticketId, depth: 0, overrideAccess: true })
104
+ const messages = await dbFind(payload, slugs.ticketMessages, { where: { ticket: { equals: ticketId } }, sort: 'createdAt', limit: 50, depth: 0, overrideAccess: true })
105
+ const kb = await dbFind(payload, slugs.knowledgeBase, { where: { published: { equals: true } }, limit: 20, depth: 0, overrideAccess: true })
106
+
107
+ const conversation = messages.docs.map((m) => `${(m as { authorType?: string }).authorType}: ${(m as { body?: string }).body || ''}`).join('\n')
108
+ const kbText = kb.docs
109
+ .map((a) => `# ${(a as { title?: string }).title || ''}\n${stripHtml(String((a as { content?: unknown }).content ?? ''))}`)
110
+ .join('\n\n')
111
+ .slice(0, 8000)
112
+
113
+ const prompt = `Tu es un agent de support IA. À partir du ticket et de la base de connaissances ci-dessous, décide :
114
+ - "reply" si tu peux répondre avec certitude UNIQUEMENT à partir de la base de connaissances ;
115
+ - "escalate" sinon (question hors KB, ambiguë, sensible, ou nécessitant une action humaine).
116
+ Réponds en JSON STRICT, sans texte autour : {"action":"reply"|"escalate","reply":"<réponse au client si reply>","reason":"<courte raison>","confidence":<0 à 1>}.
117
+
118
+ TICKET : ${(ticket as { subject?: string }).subject || ''}
119
+ CONVERSATION :
120
+ ${conversation}
121
+
122
+ BASE DE CONNAISSANCES :
123
+ ${kbText || '(vide)'}`
124
+
125
+ let decision: AgentDecision
126
+ try {
127
+ const client = getClient(settings.ai)
128
+ const resp = await client.messages.create({
129
+ model: settings.ai.model || 'claude-haiku-4-5-20251001',
130
+ max_tokens: 1024,
131
+ messages: [{ role: 'user', content: prompt }],
132
+ })
133
+ const text = resp?.content?.[0]?.text || ''
134
+ decision = parseDecision(text)
135
+ } catch (err) {
136
+ console.error('[support] AI agent decision failed:', err)
137
+ decision = { action: 'escalate', reason: 'Erreur du fournisseur IA', confidence: 0 }
138
+ }
139
+
140
+ // Confidence gate: only auto-reply when the model is confident enough.
141
+ const finalDecision: AgentDecision =
142
+ decision.action === 'reply' && (decision.confidence ?? 0) >= threshold
143
+ ? decision
144
+ : { action: 'escalate', reason: decision.reason || 'Confiance insuffisante pour une réponse automatique', confidence: decision.confidence }
145
+
146
+ return applyAgentDecision(payload, slugs, ticketId, finalDecision)
147
+ }
package/src/utils/auth.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { PayloadRequest } from 'payload'
1
2
  import type { CollectionSlugs } from './slugs'
2
3
 
3
4
  /**
@@ -19,9 +20,9 @@ export class AuthError extends Error {
19
20
  * Throws AuthError(401) if unauthenticated, AuthError(403) if not an admin.
20
21
  * Uses TypeScript assertion signature so `req.user` is narrowed to non-null after call.
21
22
  */
22
- export function requireAdmin(req: { user?: any }, slugs: CollectionSlugs): asserts req is { user: NonNullable<typeof req.user> } {
23
+ export function requireAdmin(req: PayloadRequest, slugs: CollectionSlugs): asserts req is PayloadRequest & { user: NonNullable<PayloadRequest['user']> } {
23
24
  if (!req.user) throw new AuthError('Authentication required', 401)
24
- if (req.user.collection !== slugs.users) throw new AuthError('Admin access required', 403)
25
+ if ((req.user as any).collection !== slugs.users) throw new AuthError('Admin access required', 403)
25
26
  }
26
27
 
27
28
  /**
@@ -29,9 +30,9 @@ export function requireAdmin(req: { user?: any }, slugs: CollectionSlugs): asser
29
30
  * Throws AuthError(401) if unauthenticated, AuthError(403) if not a client.
30
31
  * Uses TypeScript assertion signature so `req.user` is narrowed to non-null after call.
31
32
  */
32
- export function requireClient(req: { user?: any }, slugs: CollectionSlugs): asserts req is { user: NonNullable<typeof req.user> } {
33
+ export function requireClient(req: PayloadRequest, slugs: CollectionSlugs): asserts req is PayloadRequest & { user: NonNullable<PayloadRequest['user']> } {
33
34
  if (!req.user) throw new AuthError('Authentication required', 401)
34
- if (req.user.collection !== slugs.supportClients) throw new AuthError('Client access required', 403)
35
+ if ((req.user as any).collection !== slugs.supportClients) throw new AuthError('Client access required', 403)
35
36
  }
36
37
 
37
38
  /**
@@ -0,0 +1,36 @@
1
+ export type ChannelProvider = 'whatsapp' | 'messenger'
2
+
3
+ /**
4
+ * Send an outbound reply to a social channel (WhatsApp / Messenger) via the Meta
5
+ * Graph API. RUNTIME-only: requires provider credentials in the environment.
6
+ * Returns `{ ok: false }` (and logs) when credentials are missing or the call fails.
7
+ */
8
+ export async function sendChannelReply(provider: ChannelProvider, to: string, text: string): Promise<{ ok: boolean }> {
9
+ try {
10
+ if (provider === 'whatsapp') {
11
+ const token = process.env.WHATSAPP_TOKEN
12
+ const phoneId = process.env.WHATSAPP_PHONE_ID
13
+ if (!token || !phoneId) return { ok: false }
14
+ const res = await fetch(`https://graph.facebook.com/v21.0/${phoneId}/messages`, {
15
+ method: 'POST',
16
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
17
+ body: JSON.stringify({ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } }),
18
+ })
19
+ return { ok: res.ok }
20
+ }
21
+ if (provider === 'messenger') {
22
+ const token = process.env.MESSENGER_PAGE_TOKEN
23
+ if (!token) return { ok: false }
24
+ const res = await fetch(`https://graph.facebook.com/v21.0/me/messages?access_token=${encodeURIComponent(token)}`, {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ body: JSON.stringify({ recipient: { id: to }, message: { text } }),
28
+ })
29
+ return { ok: res.ok }
30
+ }
31
+ return { ok: false }
32
+ } catch (err) {
33
+ console.error('[support] Channel send failed:', err)
34
+ return { ok: false }
35
+ }
36
+ }
@@ -0,0 +1,73 @@
1
+ import type { Payload } from 'payload'
2
+
3
+ /**
4
+ * Thin typed wrappers around Payload data operations.
5
+ *
6
+ * Collection slugs are user-overridable config (`string`), not Payload's literal
7
+ * `CollectionSlug` union — so every call site otherwise needs `collection: X as any`.
8
+ * These helpers isolate that single unavoidable cast in ONE place, removing the
9
+ * ~200 scattered `as any` while staying an EXACT behavioral pass-through of options.
10
+ *
11
+ * Returns default to `any` (callers consume docs loosely, as before). Pass a type
12
+ * argument to opt into stronger typing: `dbFind<TicketData>(payload, slug, { ... })`.
13
+ */
14
+
15
+ interface PaginatedResult<T> {
16
+ docs: T[]
17
+ totalDocs: number
18
+ totalPages: number
19
+ page?: number
20
+ limit: number
21
+ hasNextPage: boolean
22
+ hasPrevPage: boolean
23
+ nextPage?: number | null
24
+ prevPage?: number | null
25
+ }
26
+
27
+ export function dbFind<T = any>(
28
+ payload: Payload,
29
+ slug: string,
30
+ options: Record<string, unknown> = {},
31
+ ): Promise<PaginatedResult<T>> {
32
+ return payload.find({ collection: slug as any, ...options }) as any
33
+ }
34
+
35
+ export function dbFindByID<T = any>(
36
+ payload: Payload,
37
+ slug: string,
38
+ options: { id: number | string } & Record<string, unknown>,
39
+ ): Promise<T> {
40
+ return payload.findByID({ collection: slug as any, ...options }) as any
41
+ }
42
+
43
+ export function dbCreate<T = any>(
44
+ payload: Payload,
45
+ slug: string,
46
+ options: Record<string, unknown>,
47
+ ): Promise<T> {
48
+ return (payload.create as any)({ collection: slug, ...options })
49
+ }
50
+
51
+ export function dbUpdate<T = any>(
52
+ payload: Payload,
53
+ slug: string,
54
+ options: Record<string, unknown>,
55
+ ): Promise<T> {
56
+ return (payload.update as any)({ collection: slug, ...options })
57
+ }
58
+
59
+ export function dbCount(
60
+ payload: Payload,
61
+ slug: string,
62
+ options: Record<string, unknown> = {},
63
+ ): Promise<{ totalDocs: number }> {
64
+ return payload.count({ collection: slug as any, ...options }) as any
65
+ }
66
+
67
+ export function dbDelete<T = any>(
68
+ payload: Payload,
69
+ slug: string,
70
+ options: Record<string, unknown>,
71
+ ): Promise<T> {
72
+ return (payload.delete as any)({ collection: slug, ...options })
73
+ }
@@ -16,6 +16,8 @@
16
16
  * emailInfoRow, escapeHtml, createEmailTemplateFactory must not change.
17
17
  */
18
18
 
19
+ import { generateTrackingToken } from '../endpoints/track-open'
20
+
19
21
  // ─── Design tokens ───────────────────────────────────────────────────
20
22
 
21
23
  const COLORS = {
@@ -86,7 +88,12 @@ export function escapeHtml(str: string): string {
86
88
  */
87
89
  export function emailTrackingPixel(ticketId: number | string, messageId?: number | string, baseUrl?: string): string {
88
90
  const url = baseUrl || process.env.NEXT_PUBLIC_SERVER_URL || ''
89
- const params = `t=${ticketId}${messageId ? `&m=${messageId}` : ''}`
91
+ const secret = process.env.PAYLOAD_SECRET || ''
92
+ // Open-tracking requires a signed URL (HMAC). Without a secret or a messageId we
93
+ // cannot sign, so we emit NO pixel rather than an unsigned (abusable) one.
94
+ if (!secret || messageId === undefined || messageId === null) return ''
95
+ const sig = generateTrackingToken(String(ticketId), String(messageId), secret)
96
+ const params = `t=${ticketId}&m=${messageId}&sig=${sig}`
90
97
  return `<img src="${url}/api/support/track-open?${params}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`
91
98
  }
92
99
 
@@ -1,5 +1,6 @@
1
1
  import type { Payload } from 'payload'
2
2
  import type { CollectionSlugs } from './slugs'
3
+ import { dbFind, dbUpdate } from './db'
3
4
 
4
5
  /**
5
6
  * Fire all active webhooks matching the given event.
@@ -14,8 +15,7 @@ export async function fireWebhooks(
14
15
  ): Promise<void> {
15
16
  try {
16
17
  // Find all active webhook endpoints subscribed to this event
17
- const endpoints = await payload.find({
18
- collection: slugs.webhookEndpoints as any,
18
+ const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
19
19
  where: {
20
20
  active: { equals: true },
21
21
  events: { contains: event },
@@ -45,8 +45,7 @@ export async function fireWebhooks(
45
45
 
46
46
  // Update webhook metadata (best-effort, don't throw on failure)
47
47
  try {
48
- await payload.update({
49
- collection: slugs.webhookEndpoints as any,
48
+ await dbUpdate(payload, slugs.webhookEndpoints, {
50
49
  id: endpoint.id,
51
50
  data: {
52
51
  lastTriggeredAt: timestamp,
@@ -62,8 +61,7 @@ export async function fireWebhooks(
62
61
 
63
62
  // Record the failure
64
63
  try {
65
- await payload.update({
66
- collection: slugs.webhookEndpoints as any,
64
+ await dbUpdate(payload, slugs.webhookEndpoints, {
67
65
  id: endpoint.id,
68
66
  data: {
69
67
  lastTriggeredAt: timestamp,
@@ -1,6 +1,7 @@
1
1
  import type { Payload } from 'payload'
2
2
  import type { CollectionSlugs } from './slugs'
3
3
  import { readSupportSettings, type SupportSettings } from './readSettings'
4
+ import { dbFindByID, dbUpdate, dbFind } from './db'
4
5
 
5
6
  function getClient(aiSettings: SupportSettings['ai']) {
6
7
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -53,8 +54,7 @@ export async function generateTicketSynthesis(args: {
53
54
  return { summary: '', generatedAt: new Date().toISOString(), status: 'skipped', reason: 'synthesis disabled' }
54
55
  }
55
56
 
56
- const ticket = await payload.findByID({
57
- collection: slugs.tickets as any,
57
+ const ticket = await dbFindByID(payload, slugs.tickets, {
58
58
  id: ticketId,
59
59
  depth: 1,
60
60
  overrideAccess: true,
@@ -65,16 +65,14 @@ export async function generateTicketSynthesis(args: {
65
65
  }
66
66
 
67
67
  // Mark as pending so the UI can show a spinner
68
- await payload.update({
69
- collection: slugs.tickets as any,
68
+ await dbUpdate(payload, slugs.tickets, {
70
69
  id: ticketId,
71
70
  data: { aiSummaryStatus: 'pending' },
72
71
  overrideAccess: true,
73
72
  }).catch(() => { /* non-blocking */ })
74
73
 
75
74
  // Load all ticket messages, ordered chronologically
76
- const messagesResult = await payload.find({
77
- collection: slugs.ticketMessages as any,
75
+ const messagesResult = await dbFind(payload, slugs.ticketMessages, {
78
76
  where: { ticket: { equals: ticketId } },
79
77
  sort: 'createdAt',
80
78
  limit: 500,
@@ -86,8 +84,7 @@ export async function generateTicketSynthesis(args: {
86
84
 
87
85
  if (messages.length === 0) {
88
86
  const generatedAt = new Date().toISOString()
89
- await payload.update({
90
- collection: slugs.tickets as any,
87
+ await dbUpdate(payload, slugs.tickets, {
91
88
  id: ticketId,
92
89
  data: {
93
90
  aiSummary: '(Aucun message dans ce ticket)',
@@ -153,8 +150,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`
153
150
  const summary = res.content[0]?.type === 'text' ? res.content[0].text.trim() : ''
154
151
  const generatedAt = new Date().toISOString()
155
152
 
156
- await payload.update({
157
- collection: slugs.tickets as any,
153
+ await dbUpdate(payload, slugs.tickets, {
158
154
  id: ticketId,
159
155
  data: {
160
156
  aiSummary: summary,
@@ -167,8 +163,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`
167
163
  return { summary, generatedAt, status: 'done' }
168
164
  } catch (err) {
169
165
  const message = err instanceof Error ? err.message : String(err)
170
- await payload.update({
171
- collection: slugs.tickets as any,
166
+ await dbUpdate(payload, slugs.tickets, {
172
167
  id: ticketId,
173
168
  data: { aiSummaryStatus: 'error' },
174
169
  overrideAccess: true,