@consilioweb/payload-support 0.16.0 → 1.1.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 (199) hide show
  1. package/README.md +357 -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 +1763 -677
  13. package/dist/index.d.cts +4 -0
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.js +1760 -677
  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 +28 -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/per-team-sla.test.ts +44 -0
  71. package/src/__tests__/integration/push.test.ts +75 -0
  72. package/src/__tests__/integration/sanitization.test.ts +35 -0
  73. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  74. package/src/__tests__/integration/smoke.test.ts +26 -0
  75. package/src/__tests__/integration/snooze.test.ts +68 -0
  76. package/src/__tests__/integration/teams.test.ts +38 -0
  77. package/src/__tests__/rateLimiter.test.ts +89 -0
  78. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  79. package/src/__tests__/sla.test.ts +258 -0
  80. package/src/collections/AutomationRules.ts +66 -0
  81. package/src/collections/NotificationQueue.ts +30 -0
  82. package/src/collections/PushSubscription.ts +28 -0
  83. package/src/collections/SatisfactionSurveys.ts +11 -2
  84. package/src/collections/SlaPolicies.ts +10 -0
  85. package/src/collections/SupportClients.ts +58 -1
  86. package/src/collections/SupportTeam.ts +27 -0
  87. package/src/collections/TicketActivityLog.ts +9 -4
  88. package/src/collections/TicketMessages.ts +140 -31
  89. package/src/collections/Tickets.ts +31 -73
  90. package/src/collections/index.ts +4 -0
  91. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  92. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  93. package/src/components/TicketConversation/constants.ts +29 -0
  94. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  95. package/src/components/TicketConversation/index.tsx +4 -152
  96. package/src/endpoints/admin-chat-stream.ts +3 -4
  97. package/src/endpoints/admin-chat.ts +11 -20
  98. package/src/endpoints/admin-stats.ts +77 -35
  99. package/src/endpoints/ai-agent.ts +36 -0
  100. package/src/endpoints/apply-macro.ts +8 -14
  101. package/src/endpoints/auth-2fa.ts +12 -12
  102. package/src/endpoints/auto-close.ts +6 -10
  103. package/src/endpoints/billing.ts +17 -4
  104. package/src/endpoints/bulk-action.ts +7 -12
  105. package/src/endpoints/channels.ts +89 -0
  106. package/src/endpoints/chat-stream.ts +3 -4
  107. package/src/endpoints/chat.ts +7 -12
  108. package/src/endpoints/chatbot.ts +2 -2
  109. package/src/endpoints/client-intelligence.ts +4 -4
  110. package/src/endpoints/delete-account.ts +9 -16
  111. package/src/endpoints/email-stats.ts +2 -2
  112. package/src/endpoints/escalate.ts +2 -2
  113. package/src/endpoints/export-csv.ts +2 -2
  114. package/src/endpoints/export-data.ts +5 -8
  115. package/src/endpoints/import-conversation.ts +6 -10
  116. package/src/endpoints/index.ts +22 -1
  117. package/src/endpoints/invite-collaborator.ts +30 -15
  118. package/src/endpoints/invoice.ts +127 -0
  119. package/src/endpoints/kb-search.ts +2 -2
  120. package/src/endpoints/login.ts +12 -4
  121. package/src/endpoints/merge-clients.ts +14 -19
  122. package/src/endpoints/merge-tickets.ts +11 -18
  123. package/src/endpoints/oauth-google.ts +86 -39
  124. package/src/endpoints/pending-emails-process.ts +15 -25
  125. package/src/endpoints/process-digests.ts +93 -0
  126. package/src/endpoints/process-scheduled.ts +6 -10
  127. package/src/endpoints/process-snooze.ts +78 -0
  128. package/src/endpoints/purge-logs.ts +2 -2
  129. package/src/endpoints/push.ts +49 -0
  130. package/src/endpoints/resend-notification.ts +3 -4
  131. package/src/endpoints/round-robin-config.ts +3 -4
  132. package/src/endpoints/satisfaction.ts +21 -14
  133. package/src/endpoints/search.ts +5 -8
  134. package/src/endpoints/seed-kb.ts +3 -4
  135. package/src/endpoints/send-reminder.ts +6 -9
  136. package/src/endpoints/settings.ts +5 -2
  137. package/src/endpoints/signature.ts +3 -4
  138. package/src/endpoints/sla-check.ts +2 -2
  139. package/src/endpoints/split-ticket.ts +7 -12
  140. package/src/endpoints/statuses.ts +2 -2
  141. package/src/endpoints/ticket-feedback.ts +4 -6
  142. package/src/endpoints/ticket-synthesis.ts +2 -2
  143. package/src/endpoints/track-open.ts +16 -28
  144. package/src/endpoints/transfer-ticket.ts +21 -9
  145. package/src/endpoints/user-prefs.ts +3 -4
  146. package/src/hooks/automationRules.ts +84 -0
  147. package/src/hooks/checkSLA.ts +63 -17
  148. package/src/plugin.ts +8 -0
  149. package/src/portal/LiveChat.tsx +2 -63
  150. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  151. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  152. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  153. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  154. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  155. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  156. package/src/portal/helpers.ts +46 -0
  157. package/src/portal/login/page.tsx +3 -8
  158. package/src/portal/types.ts +15 -0
  159. package/src/types/lucide-react.d.ts +34 -0
  160. package/src/types/payload-config.d.ts +10 -0
  161. package/src/utils/adminNotification.ts +2 -2
  162. package/src/utils/aiAgent.ts +147 -0
  163. package/src/utils/auth.ts +5 -4
  164. package/src/utils/channels.ts +36 -0
  165. package/src/utils/db.ts +73 -0
  166. package/src/utils/emailTemplate.ts +8 -1
  167. package/src/utils/fireWebhooks.ts +4 -6
  168. package/src/utils/generateTicketSynthesis.ts +7 -12
  169. package/src/utils/invoicePdf.ts +88 -0
  170. package/src/utils/notificationQueue.ts +36 -0
  171. package/src/utils/push.ts +63 -0
  172. package/src/utils/readSettings.ts +22 -6
  173. package/src/utils/sanitizeHtml.ts +57 -0
  174. package/src/utils/slugs.ts +8 -0
  175. package/src/utils/teamAccess.ts +27 -0
  176. package/src/utils/ticketAccess.ts +53 -0
  177. package/src/utils/webhookDispatcher.ts +4 -6
  178. package/src/views/BillingView/client.tsx +49 -37
  179. package/src/views/ChatView/client.tsx +4 -3
  180. package/src/views/CrmView/client.tsx +7 -7
  181. package/src/views/EmailTrackingView/client.tsx +7 -6
  182. package/src/views/ImportConversationView/client.tsx +4 -4
  183. package/src/views/LogsView/client.tsx +3 -2
  184. package/src/views/NewTicketView/client.tsx +1 -1
  185. package/src/views/PendingEmailsView/client.tsx +7 -6
  186. package/src/views/SupportDashboardView/client.tsx +21 -21
  187. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  188. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  189. package/src/views/TicketDetailView/client.tsx +54 -179
  190. package/src/views/TicketDetailView/constants.ts +23 -0
  191. package/src/views/TicketDetailView/helpers.ts +17 -0
  192. package/src/views/TicketDetailView/types.ts +10 -0
  193. package/src/views/TicketInboxView/client.tsx +16 -10
  194. package/src/views/TicketingSettingsView/client.tsx +90 -87
  195. package/src/views/TimeDashboardView/client.tsx +2 -2
  196. package/src/views/shared/StatusPill.tsx +24 -12
  197. package/src/views/shared/dateLocale.ts +5 -0
  198. package/src/views/shared/locales/en.json +708 -0
  199. package/src/views/shared/locales/fr.json +708 -0
@@ -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
+ }
@@ -0,0 +1,63 @@
1
+ import type { Payload } from 'payload'
2
+ import type { CollectionSlugs } from './slugs'
3
+ import { dbFind, dbDelete } from './db'
4
+ import webpush from 'web-push'
5
+
6
+ let configured = false
7
+ function ensureVapid(): boolean {
8
+ if (configured) return true
9
+ const publicKey = process.env.VAPID_PUBLIC_KEY
10
+ const privateKey = process.env.VAPID_PRIVATE_KEY
11
+ if (!publicKey || !privateKey) return false
12
+ webpush.setVapidDetails(process.env.VAPID_SUBJECT || 'mailto:support@example.com', publicKey, privateKey)
13
+ configured = true
14
+ return true
15
+ }
16
+
17
+ /** Public VAPID key for the browser to subscribe (null if push isn't configured). */
18
+ export function getVapidPublicKey(): string | null {
19
+ return process.env.VAPID_PUBLIC_KEY || null
20
+ }
21
+
22
+ export interface PushNotification {
23
+ title: string
24
+ body: string
25
+ url?: string
26
+ }
27
+
28
+ /**
29
+ * Send a Web Push notification to all of an agent's subscriptions. RUNTIME-only:
30
+ * requires VAPID keys; without them it no-ops (`{ sent: 0 }`). Stale subscriptions
31
+ * (404/410) are pruned automatically.
32
+ */
33
+ export async function sendPushToUser(
34
+ payload: Payload,
35
+ slugs: CollectionSlugs,
36
+ userId: number | string,
37
+ notification: PushNotification,
38
+ ): Promise<{ sent: number }> {
39
+ if (!ensureVapid()) return { sent: 0 }
40
+ let sent = 0
41
+ try {
42
+ const subs = await dbFind(payload, slugs.pushSubscriptions, { where: { user: { equals: userId } }, limit: 100, depth: 0, overrideAccess: true })
43
+ for (const s of subs.docs) {
44
+ const row = s as { id: number | string; endpoint?: string; p256dh?: string; auth?: string }
45
+ if (!row.endpoint || !row.p256dh || !row.auth) continue
46
+ try {
47
+ await webpush.sendNotification(
48
+ { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
49
+ JSON.stringify(notification),
50
+ )
51
+ sent++
52
+ } catch (err) {
53
+ const code = (err as { statusCode?: number })?.statusCode
54
+ if (code === 404 || code === 410) {
55
+ try { await dbDelete(payload, slugs.pushSubscriptions, { id: row.id, overrideAccess: true }) } catch { /* ignore */ }
56
+ }
57
+ }
58
+ }
59
+ } catch (err) {
60
+ console.error('[support] Push send failed:', err)
61
+ }
62
+ return { sent }
63
+ }
@@ -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
  })