@consilioweb/payload-support 0.16.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 (193) 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/constants.cjs +27 -0
  7. package/dist/components/TicketConversation/constants.js +26 -1
  8. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  9. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  10. package/dist/components/TicketConversation/index.cjs +10 -147
  11. package/dist/components/TicketConversation/index.js +4 -141
  12. package/dist/index.cjs +1604 -671
  13. package/dist/index.d.cts +3 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +1602 -671
  16. package/dist/views/BillingView/client.cjs +40 -54
  17. package/dist/views/BillingView/client.js +40 -54
  18. package/dist/views/ChatView/client.cjs +4 -3
  19. package/dist/views/ChatView/client.js +4 -3
  20. package/dist/views/CrmView/client.cjs +7 -7
  21. package/dist/views/CrmView/client.js +7 -7
  22. package/dist/views/EmailTrackingView/client.cjs +6 -5
  23. package/dist/views/EmailTrackingView/client.js +6 -5
  24. package/dist/views/ImportConversationView/client.cjs +4 -4
  25. package/dist/views/ImportConversationView/client.js +4 -4
  26. package/dist/views/LogsView/client.cjs +3 -2
  27. package/dist/views/LogsView/client.js +3 -2
  28. package/dist/views/NewTicketView/client.cjs +1 -1
  29. package/dist/views/NewTicketView/client.js +1 -1
  30. package/dist/views/PendingEmailsView/client.cjs +7 -6
  31. package/dist/views/PendingEmailsView/client.js +7 -6
  32. package/dist/views/SupportDashboardView/client.cjs +19 -22
  33. package/dist/views/SupportDashboardView/client.js +19 -22
  34. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  35. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  36. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  37. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  38. package/dist/views/TicketDetailView/client.cjs +59 -190
  39. package/dist/views/TicketDetailView/client.js +52 -183
  40. package/dist/views/TicketDetailView/constants.cjs +20 -0
  41. package/dist/views/TicketDetailView/constants.js +16 -0
  42. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  43. package/dist/views/TicketDetailView/helpers.js +20 -0
  44. package/dist/views/TicketDetailView/types.cjs +2 -0
  45. package/dist/views/TicketDetailView/types.js +1 -0
  46. package/dist/views/TicketInboxView/client.cjs +14 -10
  47. package/dist/views/TicketInboxView/client.js +14 -10
  48. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  49. package/dist/views/TicketingSettingsView/client.js +92 -88
  50. package/dist/views/TimeDashboardView/client.cjs +2 -2
  51. package/dist/views/TimeDashboardView/client.js +2 -2
  52. package/dist/views/shared/StatusPill.cjs +23 -12
  53. package/dist/views/shared/StatusPill.js +23 -12
  54. package/dist/views/shared/dateLocale.cjs +5 -0
  55. package/dist/views/shared/dateLocale.js +3 -0
  56. package/package.json +23 -15
  57. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  58. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  59. package/src/__tests__/integration/auth.test.ts +43 -0
  60. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  61. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  62. package/src/__tests__/integration/channels.test.ts +52 -0
  63. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  64. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  65. package/src/__tests__/integration/digest.test.ts +48 -0
  66. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  67. package/src/__tests__/integration/isolation.test.ts +81 -0
  68. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  69. package/src/__tests__/integration/nps.test.ts +37 -0
  70. package/src/__tests__/integration/sanitization.test.ts +35 -0
  71. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  72. package/src/__tests__/integration/smoke.test.ts +26 -0
  73. package/src/__tests__/integration/snooze.test.ts +68 -0
  74. package/src/__tests__/integration/teams.test.ts +38 -0
  75. package/src/__tests__/rateLimiter.test.ts +89 -0
  76. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  77. package/src/__tests__/sla.test.ts +258 -0
  78. package/src/collections/AutomationRules.ts +66 -0
  79. package/src/collections/NotificationQueue.ts +30 -0
  80. package/src/collections/SatisfactionSurveys.ts +11 -2
  81. package/src/collections/SupportClients.ts +58 -1
  82. package/src/collections/SupportTeam.ts +27 -0
  83. package/src/collections/TicketActivityLog.ts +9 -4
  84. package/src/collections/TicketMessages.ts +129 -31
  85. package/src/collections/Tickets.ts +31 -73
  86. package/src/collections/index.ts +3 -0
  87. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  88. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  89. package/src/components/TicketConversation/constants.ts +29 -0
  90. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  91. package/src/components/TicketConversation/index.tsx +4 -152
  92. package/src/endpoints/admin-chat-stream.ts +3 -4
  93. package/src/endpoints/admin-chat.ts +11 -20
  94. package/src/endpoints/admin-stats.ts +70 -32
  95. package/src/endpoints/ai-agent.ts +36 -0
  96. package/src/endpoints/apply-macro.ts +8 -14
  97. package/src/endpoints/auth-2fa.ts +12 -12
  98. package/src/endpoints/auto-close.ts +6 -10
  99. package/src/endpoints/billing.ts +17 -4
  100. package/src/endpoints/bulk-action.ts +7 -12
  101. package/src/endpoints/channels.ts +89 -0
  102. package/src/endpoints/chat-stream.ts +3 -4
  103. package/src/endpoints/chat.ts +7 -12
  104. package/src/endpoints/chatbot.ts +2 -2
  105. package/src/endpoints/client-intelligence.ts +4 -4
  106. package/src/endpoints/delete-account.ts +9 -16
  107. package/src/endpoints/email-stats.ts +2 -2
  108. package/src/endpoints/escalate.ts +2 -2
  109. package/src/endpoints/export-csv.ts +2 -2
  110. package/src/endpoints/export-data.ts +5 -8
  111. package/src/endpoints/import-conversation.ts +6 -10
  112. package/src/endpoints/index.ts +18 -1
  113. package/src/endpoints/invite-collaborator.ts +30 -15
  114. package/src/endpoints/invoice.ts +127 -0
  115. package/src/endpoints/kb-search.ts +2 -2
  116. package/src/endpoints/login.ts +12 -4
  117. package/src/endpoints/merge-clients.ts +14 -19
  118. package/src/endpoints/merge-tickets.ts +11 -18
  119. package/src/endpoints/oauth-google.ts +86 -39
  120. package/src/endpoints/pending-emails-process.ts +15 -25
  121. package/src/endpoints/process-digests.ts +93 -0
  122. package/src/endpoints/process-scheduled.ts +6 -10
  123. package/src/endpoints/process-snooze.ts +78 -0
  124. package/src/endpoints/purge-logs.ts +2 -2
  125. package/src/endpoints/resend-notification.ts +3 -4
  126. package/src/endpoints/round-robin-config.ts +3 -4
  127. package/src/endpoints/satisfaction.ts +21 -14
  128. package/src/endpoints/search.ts +5 -8
  129. package/src/endpoints/seed-kb.ts +3 -4
  130. package/src/endpoints/send-reminder.ts +6 -9
  131. package/src/endpoints/settings.ts +5 -2
  132. package/src/endpoints/signature.ts +3 -4
  133. package/src/endpoints/sla-check.ts +2 -2
  134. package/src/endpoints/split-ticket.ts +7 -12
  135. package/src/endpoints/statuses.ts +2 -2
  136. package/src/endpoints/ticket-feedback.ts +4 -6
  137. package/src/endpoints/ticket-synthesis.ts +2 -2
  138. package/src/endpoints/track-open.ts +16 -28
  139. package/src/endpoints/transfer-ticket.ts +21 -9
  140. package/src/endpoints/user-prefs.ts +3 -4
  141. package/src/hooks/automationRules.ts +84 -0
  142. package/src/hooks/checkSLA.ts +48 -16
  143. package/src/plugin.ts +6 -0
  144. package/src/portal/LiveChat.tsx +2 -63
  145. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  146. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  147. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  148. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  149. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  150. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  151. package/src/portal/helpers.ts +46 -0
  152. package/src/portal/login/page.tsx +3 -8
  153. package/src/portal/types.ts +15 -0
  154. package/src/types/lucide-react.d.ts +34 -0
  155. package/src/types/payload-config.d.ts +10 -0
  156. package/src/utils/adminNotification.ts +2 -2
  157. package/src/utils/aiAgent.ts +147 -0
  158. package/src/utils/auth.ts +5 -4
  159. package/src/utils/channels.ts +36 -0
  160. package/src/utils/db.ts +73 -0
  161. package/src/utils/emailTemplate.ts +8 -1
  162. package/src/utils/fireWebhooks.ts +4 -6
  163. package/src/utils/generateTicketSynthesis.ts +7 -12
  164. package/src/utils/invoicePdf.ts +88 -0
  165. package/src/utils/notificationQueue.ts +36 -0
  166. package/src/utils/readSettings.ts +22 -6
  167. package/src/utils/sanitizeHtml.ts +57 -0
  168. package/src/utils/slugs.ts +6 -0
  169. package/src/utils/teamAccess.ts +27 -0
  170. package/src/utils/ticketAccess.ts +53 -0
  171. package/src/utils/webhookDispatcher.ts +4 -6
  172. package/src/views/BillingView/client.tsx +49 -37
  173. package/src/views/ChatView/client.tsx +4 -3
  174. package/src/views/CrmView/client.tsx +7 -7
  175. package/src/views/EmailTrackingView/client.tsx +7 -6
  176. package/src/views/ImportConversationView/client.tsx +4 -4
  177. package/src/views/LogsView/client.tsx +3 -2
  178. package/src/views/NewTicketView/client.tsx +1 -1
  179. package/src/views/PendingEmailsView/client.tsx +7 -6
  180. package/src/views/SupportDashboardView/client.tsx +21 -21
  181. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  182. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  183. package/src/views/TicketDetailView/client.tsx +54 -179
  184. package/src/views/TicketDetailView/constants.ts +23 -0
  185. package/src/views/TicketDetailView/helpers.ts +17 -0
  186. package/src/views/TicketDetailView/types.ts +10 -0
  187. package/src/views/TicketInboxView/client.tsx +16 -10
  188. package/src/views/TicketingSettingsView/client.tsx +90 -87
  189. package/src/views/TimeDashboardView/client.tsx +2 -2
  190. package/src/views/shared/StatusPill.tsx +24 -12
  191. package/src/views/shared/dateLocale.ts +5 -0
  192. package/src/views/shared/locales/en.json +708 -0
  193. package/src/views/shared/locales/fr.json +708 -0
@@ -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,
@@ -0,0 +1,88 @@
1
+ import PDFDocument from 'pdfkit'
2
+
3
+ export interface InvoiceLine {
4
+ ticketNumber: string
5
+ subject: string
6
+ client: string
7
+ minutes: number
8
+ amount: number
9
+ }
10
+
11
+ export interface InvoiceData {
12
+ from: string
13
+ to: string
14
+ projectId?: string | null
15
+ lines: InvoiceLine[]
16
+ totalMinutes: number
17
+ totalAmount: number
18
+ }
19
+
20
+ const fmtAmount = (n: number) => `${n.toFixed(2)} EUR`
21
+ const fmtTime = (min: number) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}`
22
+
23
+ /**
24
+ * Render a real (binary) PDF invoice from billing data, using pdfkit. Returns the
25
+ * complete PDF as a Buffer (suitable for an HTTP `application/pdf` response).
26
+ */
27
+ export function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
28
+ return new Promise((resolve, reject) => {
29
+ try {
30
+ const doc = new PDFDocument({ size: 'A4', margin: 50 })
31
+ const chunks: Buffer[] = []
32
+ doc.on('data', (c: Buffer) => chunks.push(c))
33
+ doc.on('end', () => resolve(Buffer.concat(chunks)))
34
+ doc.on('error', reject)
35
+
36
+ // Header
37
+ doc.fillColor('#0f172a').fontSize(22).text('Invoice / Pre-billing')
38
+ doc.moveDown(0.2)
39
+ doc.fillColor('#64748b').fontSize(10)
40
+ .text(`Period: ${data.from} -> ${data.to}${data.projectId ? ` · Project #${data.projectId}` : ''}`)
41
+ doc.moveDown(1)
42
+
43
+ // Column layout (x positions)
44
+ const X = { ticket: 50, subject: 130, client: 320, time: 430, amount: 500 }
45
+ const drawRow = (r: { ticket: string; subject: string; client: string; time: string; amount: string }, opts?: { header?: boolean; total?: boolean }) => {
46
+ const y = doc.y
47
+ doc.fontSize(opts?.header ? 9 : 10).fillColor(opts?.header ? '#475569' : '#0f172a')
48
+ if (opts?.total) doc.font('Helvetica-Bold')
49
+ doc.text(r.ticket, X.ticket, y, { width: X.subject - X.ticket - 6 })
50
+ doc.text(r.subject, X.subject, y, { width: X.client - X.subject - 6 })
51
+ doc.text(r.client, X.client, y, { width: X.time - X.client - 6 })
52
+ doc.text(r.time, X.time, y, { width: X.amount - X.time - 6, align: 'right' })
53
+ doc.text(r.amount, X.amount, y, { width: 545 - X.amount, align: 'right' })
54
+ doc.font('Helvetica')
55
+ doc.moveDown(0.4)
56
+ }
57
+
58
+ drawRow({ ticket: 'Ticket', subject: 'Subject', client: 'Client', time: 'Time', amount: 'Amount' }, { header: true })
59
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e2e8f0').stroke()
60
+ doc.moveDown(0.3)
61
+
62
+ for (const l of data.lines) {
63
+ if (doc.y > 760) doc.addPage()
64
+ drawRow({
65
+ ticket: l.ticketNumber || '',
66
+ subject: l.subject || '',
67
+ client: l.client || '',
68
+ time: fmtTime(l.minutes),
69
+ amount: fmtAmount(l.amount),
70
+ })
71
+ }
72
+
73
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#0f172a').stroke()
74
+ doc.moveDown(0.3)
75
+ drawRow({
76
+ ticket: '',
77
+ subject: `Total (${data.lines.length} ticket${data.lines.length > 1 ? 's' : ''})`,
78
+ client: '',
79
+ time: fmtTime(data.totalMinutes),
80
+ amount: fmtAmount(data.totalAmount),
81
+ }, { total: true })
82
+
83
+ doc.end()
84
+ } catch (err) {
85
+ reject(err as Error)
86
+ }
87
+ })
88
+ }
@@ -0,0 +1,36 @@
1
+ import type { Payload } from 'payload'
2
+ import type { CollectionSlugs } from './slugs'
3
+ import { dbCreate } from './db'
4
+
5
+ export interface QueuedNotification {
6
+ client: number | string
7
+ type: string
8
+ title: string
9
+ message: string
10
+ ticket?: number | string
11
+ }
12
+
13
+ /**
14
+ * Queue a client notification for later digest delivery (best-effort). Used when
15
+ * the client opted into `daily`/`weekly` notification frequency instead of immediate.
16
+ */
17
+ export async function queueClientNotification(
18
+ payload: Payload,
19
+ slugs: CollectionSlugs,
20
+ item: QueuedNotification,
21
+ ): Promise<void> {
22
+ try {
23
+ await dbCreate(payload, slugs.notificationQueue, {
24
+ data: {
25
+ client: item.client,
26
+ type: item.type,
27
+ title: item.title,
28
+ message: item.message,
29
+ ...(item.ticket ? { ticket: item.ticket } : {}),
30
+ },
31
+ overrideAccess: true,
32
+ })
33
+ } catch (err) {
34
+ console.error('[support] Failed to queue client notification:', err)
35
+ }
36
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Payload } from 'payload'
2
+ import { dbFind } from './db'
2
3
 
3
4
  const PREF_KEY = 'support-settings'
4
5
  const USER_PREFS_KEY_PREFIX = 'support-user-prefs'
@@ -27,16 +28,31 @@ export const DEFAULT_USER_PREFS: UserPrefs = {
27
28
  signature: '',
28
29
  }
29
30
 
31
+ // In-process cache: readSupportSettings is hit many times per ticket mutation
32
+ // (the afterChange hook chain). The settings doc changes rarely, so a short TTL
33
+ // + explicit invalidation on save (endpoints/settings.ts) avoids redundant DB
34
+ // reads of the same `payload-preferences` row.
35
+ let settingsCache: { value: SupportSettings; ts: number } | null = null
36
+ const SETTINGS_TTL_MS = 60_000
37
+
38
+ /** Invalidate the settings cache — call right after writing support settings. */
39
+ export function invalidateSupportSettingsCache(): void {
40
+ settingsCache = null
41
+ }
42
+
30
43
  export async function readSupportSettings(payload: Payload): Promise<SupportSettings> {
44
+ if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
45
+ return settingsCache.value
46
+ }
47
+ let value: SupportSettings = { ...DEFAULT_SETTINGS }
31
48
  try {
32
- const prefs = await payload.find({
33
- collection: 'payload-preferences' as any,
49
+ const prefs = await dbFind(payload, 'payload-preferences', {
34
50
  where: { key: { equals: PREF_KEY } },
35
51
  limit: 1, depth: 0, overrideAccess: true,
36
52
  })
37
53
  if (prefs.docs.length > 0) {
38
54
  const stored = prefs.docs[0].value as Partial<SupportSettings>
39
- return {
55
+ value = {
40
56
  email: { ...DEFAULT_SETTINGS.email, ...stored.email },
41
57
  ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
42
58
  sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
@@ -44,14 +60,14 @@ export async function readSupportSettings(payload: Payload): Promise<SupportSett
44
60
  }
45
61
  }
46
62
  } catch { /* fallback to defaults */ }
47
- return { ...DEFAULT_SETTINGS }
63
+ settingsCache = { value, ts: Date.now() }
64
+ return value
48
65
  }
49
66
 
50
67
  export async function readUserPrefs(payload: Payload, userId: string | number): Promise<UserPrefs> {
51
68
  try {
52
69
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`
53
- const prefs = await payload.find({
54
- collection: 'payload-preferences' as any,
70
+ const prefs = await dbFind(payload, 'payload-preferences', {
55
71
  where: { key: { equals: key } },
56
72
  limit: 1, depth: 0, overrideAccess: true,
57
73
  })