@consilioweb/payload-support 0.6.5 → 0.8.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.
@@ -87,7 +87,7 @@ export function useAI(
87
87
  setAiReplying(false)
88
88
  }
89
89
 
90
- const handleAiRewrite = async () => {
90
+ const handleAiRewrite = async (style: string = 'auto') => {
91
91
  if (!replyBody.trim()) return
92
92
  setAiRewriting(true)
93
93
  try {
@@ -95,7 +95,7 @@ export function useAI(
95
95
  method: 'POST',
96
96
  headers: { 'Content-Type': 'application/json' },
97
97
  credentials: 'include',
98
- body: JSON.stringify({ action: 'rewrite', text: replyBody }),
98
+ body: JSON.stringify({ action: 'rewrite', text: replyBody, style }),
99
99
  })
100
100
  if (res.ok) {
101
101
  const data = await res.json()
@@ -82,6 +82,98 @@ const layoutStyles = {
82
82
  } as React.CSSProperties,
83
83
  }
84
84
 
85
+ // ── Rewrite style dropdown ──────────────────────────────────────────────
86
+ const REWRITE_STYLES = [
87
+ { id: 'auto', label: '✏️ Auto', desc: 'Garde le ton actuel' },
88
+ { id: 'tutoyer', label: '👋 Tutoyer', desc: 'Passe en tu' },
89
+ { id: 'vouvoyer', label: '🎩 Vouvoyer', desc: 'Passe en vous' },
90
+ { id: 'formel', label: '💼 Formel', desc: 'Ton professionnel' },
91
+ { id: 'amical', label: '😊 Amical', desc: 'Ton chaleureux' },
92
+ { id: 'court', label: '⚡ Court', desc: 'Version concise' },
93
+ ]
94
+
95
+ const RewriteDropdown: React.FC<{
96
+ disabled: boolean
97
+ loading: boolean
98
+ onSelect: (style: string) => void
99
+ }> = ({ disabled, loading, onSelect }) => {
100
+ const [open, setOpen] = useState(false)
101
+ const ref = React.useRef<HTMLDivElement>(null)
102
+
103
+ React.useEffect(() => {
104
+ if (!open) return
105
+ const close = (e: MouseEvent) => {
106
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
107
+ }
108
+ document.addEventListener('mousedown', close)
109
+ return () => document.removeEventListener('mousedown', close)
110
+ }, [open])
111
+
112
+ return (
113
+ <div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
114
+ <button
115
+ type="button"
116
+ onClick={() => setOpen(!open)}
117
+ disabled={disabled}
118
+ style={{
119
+ ...s.outlineBtn('#0891b2', disabled),
120
+ fontSize: '11px',
121
+ padding: '3px 10px',
122
+ borderRadius: '14px',
123
+ display: 'flex',
124
+ alignItems: 'center',
125
+ gap: '4px',
126
+ }}
127
+ >
128
+ {loading ? 'Reformulation...' : '✏️ Reformuler'}
129
+ {!loading && <span style={{ fontSize: 9, opacity: 0.7 }}>▼</span>}
130
+ </button>
131
+ {open && !disabled && !loading && (
132
+ <div
133
+ style={{
134
+ position: 'absolute',
135
+ bottom: '100%',
136
+ left: 0,
137
+ marginBottom: 4,
138
+ background: 'var(--theme-elevation-0, #fff)',
139
+ border: '1px solid var(--theme-elevation-200, #e5e7eb)',
140
+ borderRadius: 8,
141
+ boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
142
+ zIndex: 100,
143
+ minWidth: 180,
144
+ overflow: 'hidden',
145
+ }}
146
+ >
147
+ {REWRITE_STYLES.map((style) => (
148
+ <button
149
+ key={style.id}
150
+ type="button"
151
+ onClick={() => { setOpen(false); onSelect(style.id) }}
152
+ style={{
153
+ display: 'flex',
154
+ flexDirection: 'column',
155
+ width: '100%',
156
+ padding: '8px 12px',
157
+ border: 'none',
158
+ background: 'transparent',
159
+ cursor: 'pointer',
160
+ textAlign: 'left',
161
+ borderBottom: '1px solid var(--theme-elevation-100, #f3f4f6)',
162
+ transition: 'background 120ms',
163
+ }}
164
+ onMouseEnter={(e) => { (e.target as HTMLElement).style.background = 'var(--theme-elevation-50, #f9fafb)' }}
165
+ onMouseLeave={(e) => { (e.target as HTMLElement).style.background = 'transparent' }}
166
+ >
167
+ <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--theme-text, #111)' }}>{style.label}</span>
168
+ <span style={{ fontSize: 10, color: 'var(--theme-elevation-500, #6b7280)' }}>{style.desc}</span>
169
+ </button>
170
+ ))}
171
+ </div>
172
+ )}
173
+ </div>
174
+ )
175
+ }
176
+
85
177
  const TicketConversation: React.FC = () => {
86
178
  const { id } = useDocumentIdFromUrl()
87
179
  const [features] = useState<TicketingFeatures>(() => getFeatures())
@@ -836,13 +928,11 @@ const TicketConversation: React.FC = () => {
836
928
  </button>
837
929
  )}
838
930
  {features.ai && (
839
- <button
840
- onClick={handleAiRewrite}
931
+ <RewriteDropdown
841
932
  disabled={aiRewriting || !replyBody.trim()}
842
- style={{ ...s.outlineBtn('#0891b2', aiRewriting || !replyBody.trim()), fontSize: '11px', padding: '3px 10px', borderRadius: '14px' }}
843
- >
844
- {aiRewriting ? 'Reformulation...' : 'Reformuler'}
845
- </button>
933
+ loading={aiRewriting}
934
+ onSelect={(style) => handleAiRewrite(style)}
935
+ />
846
936
  )}
847
937
  <CodeBlockInserter
848
938
  style={{ ...s.outlineBtn('#059669', false), fontSize: '11px', padding: '3px 10px', borderRadius: '14px' }}
@@ -169,10 +169,20 @@ Rédige une réponse appropriée au dernier message du client. Sois concis (3-5
169
169
  if (!aiSettings.enableRewrite) {
170
170
  return Response.json({ rewritten: '', disabled: true })
171
171
  }
172
- const { text } = body as { text: string }
172
+ const { text, style } = body as { text: string; style?: string }
173
173
  if (!text?.trim()) return Response.json({ error: 'text required' }, { status: 400 })
174
174
 
175
- const prompt = `Tu es un agent de support technique professionnel. Reformule le texte ci-dessous de manière plus professionnelle et corrige les fautes d'orthographe/grammaire. Garde le même sens et le même ton (tutoiement/vouvoiement). Ne change pas le fond du message, améliore uniquement la forme. Réponds UNIQUEMENT avec le texte reformulé, sans commentaire ni explication.
175
+ const styleInstructions: Record<string, string> = {
176
+ auto: 'Garde le même ton (tutoiement/vouvoiement).',
177
+ tutoyer: 'Utilise le tutoiement. Si le texte vouvoie, convertis en tutoiement.',
178
+ vouvoyer: 'Utilise le vouvoiement. Si le texte tutoie, convertis en vouvoiement.',
179
+ formel: 'Adopte un ton formel et professionnel avec vouvoiement.',
180
+ court: 'Raccourcis le texte au maximum tout en gardant le sens. Sois concis et direct.',
181
+ amical: 'Adopte un ton chaleureux et amical avec tutoiement.',
182
+ }
183
+ const styleGuide = styleInstructions[style || 'auto'] || styleInstructions.auto
184
+
185
+ const prompt = `Tu es un agent de support technique professionnel. Reformule le texte ci-dessous de manière plus professionnelle et corrige les fautes d'orthographe/grammaire. ${styleGuide} Ne change pas le fond du message, améliore uniquement la forme. Réponds UNIQUEMENT avec le texte reformulé, sans commentaire ni explication.
176
186
 
177
187
  Texte original :
178
188
  ${text}`
@@ -0,0 +1,257 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { readSupportSettings, type SupportSettings } from '../utils/readSettings'
5
+
6
+ function getClient(aiSettings: SupportSettings['ai']) {
7
+ const Anthropic = require('@anthropic-ai/sdk').default
8
+ if (aiSettings.provider === 'ollama') {
9
+ const baseURL = process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1'
10
+ return new Anthropic({ apiKey: 'ollama', baseURL })
11
+ }
12
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
13
+ }
14
+
15
+ function getModel(aiSettings: SupportSettings['ai']): string {
16
+ return aiSettings.model || 'claude-haiku-4-5-20251001'
17
+ }
18
+
19
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
20
+
21
+ /**
22
+ * GET /api/support/client-intelligence?clientId=X
23
+ * Returns cached summary or generates a new one.
24
+ *
25
+ * POST /api/support/client-intelligence?clientId=X
26
+ * Force-refreshes the summary.
27
+ */
28
+ export function createClientIntelligenceEndpoint(slugs: CollectionSlugs): Endpoint[] {
29
+ const getHandler = async (req: any) => {
30
+ try {
31
+ requireAdmin(req, slugs)
32
+ const payload = req.payload
33
+ const url = new URL(req.url || '', 'http://localhost')
34
+ const clientId = url.searchParams.get('clientId')
35
+ if (!clientId) return Response.json({ error: 'clientId required' }, { status: 400 })
36
+
37
+ // Check cache
38
+ const existing = await payload.find({
39
+ collection: 'client-summaries',
40
+ where: { client: { equals: Number(clientId) } },
41
+ limit: 1,
42
+ depth: 0,
43
+ overrideAccess: true,
44
+ })
45
+
46
+ if (existing.docs.length > 0) {
47
+ const cached = existing.docs[0]
48
+ const age = Date.now() - new Date(cached.generatedAt || 0).getTime()
49
+ if (age < CACHE_TTL_MS) {
50
+ return Response.json({ ...cached, fromCache: true })
51
+ }
52
+ }
53
+
54
+ // Generate new summary
55
+ return await generateSummary(payload, clientId, slugs, existing.docs[0]?.id)
56
+ } catch (error) {
57
+ const authResponse = handleAuthError(error)
58
+ if (authResponse) return authResponse
59
+ console.error('[client-intelligence] Error:', error)
60
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
61
+ }
62
+ }
63
+
64
+ const postHandler = async (req: any) => {
65
+ try {
66
+ requireAdmin(req, slugs)
67
+ const payload = req.payload
68
+ const body = await req.json?.() || {}
69
+ const clientId = body.clientId
70
+ if (!clientId) return Response.json({ error: 'clientId required' }, { status: 400 })
71
+
72
+ // Find existing to update
73
+ const existing = await payload.find({
74
+ collection: 'client-summaries',
75
+ where: { client: { equals: Number(clientId) } },
76
+ limit: 1,
77
+ depth: 0,
78
+ overrideAccess: true,
79
+ })
80
+
81
+ return await generateSummary(payload, clientId, slugs, existing.docs[0]?.id)
82
+ } catch (error) {
83
+ const authResponse = handleAuthError(error)
84
+ if (authResponse) return authResponse
85
+ console.error('[client-intelligence] Refresh error:', error)
86
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
87
+ }
88
+ }
89
+
90
+ return [
91
+ { path: '/support/client-intelligence', method: 'get', handler: getHandler },
92
+ { path: '/support/client-intelligence', method: 'post', handler: postHandler },
93
+ ]
94
+ }
95
+
96
+ async function generateSummary(
97
+ payload: any,
98
+ clientId: string,
99
+ slugs: CollectionSlugs,
100
+ existingId?: number,
101
+ ) {
102
+ const aiSettings = (await readSupportSettings(payload)).ai
103
+ if (!aiSettings.enableSynthesis) {
104
+ return Response.json({ error: 'AI synthesis disabled in settings' }, { status: 400 })
105
+ }
106
+
107
+ // 1. Fetch client info
108
+ const client = await payload.findByID({
109
+ collection: slugs.supportClients,
110
+ id: Number(clientId),
111
+ depth: 0,
112
+ overrideAccess: true,
113
+ })
114
+ if (!client) return Response.json({ error: 'Client not found' }, { status: 404 })
115
+
116
+ const clientName = [client.firstName, client.lastName].filter(Boolean).join(' ') || client.company || client.email
117
+
118
+ // 2. Fetch all tickets for this client
119
+ const tickets = await payload.find({
120
+ collection: slugs.tickets,
121
+ where: { client: { equals: Number(clientId) } },
122
+ sort: '-createdAt',
123
+ limit: 50,
124
+ depth: 0,
125
+ overrideAccess: true,
126
+ })
127
+
128
+ if (tickets.totalDocs === 0) {
129
+ return Response.json({
130
+ summary: 'Aucun ticket pour ce client.',
131
+ recurringTopics: [],
132
+ patterns: [],
133
+ keyFacts: [],
134
+ ticketCount: 0,
135
+ messageCount: 0,
136
+ })
137
+ }
138
+
139
+ // 3. Fetch messages for recent tickets (last 20)
140
+ const ticketIds = tickets.docs.slice(0, 20).map((t: any) => t.id)
141
+ const messages = await payload.find({
142
+ collection: slugs.ticketMessages,
143
+ where: { ticket: { in: ticketIds.join(',') } },
144
+ sort: 'createdAt',
145
+ limit: 200,
146
+ depth: 0,
147
+ overrideAccess: true,
148
+ })
149
+
150
+ // 4. Fetch satisfaction surveys
151
+ let avgSatisfaction: number | null = null
152
+ try {
153
+ const surveys = await payload.find({
154
+ collection: slugs.satisfactionSurveys || 'satisfaction-surveys',
155
+ where: { client: { equals: Number(clientId) } },
156
+ limit: 50,
157
+ depth: 0,
158
+ overrideAccess: true,
159
+ })
160
+ if (surveys.totalDocs > 0) {
161
+ const ratings = surveys.docs.filter((s: any) => s.rating).map((s: any) => s.rating)
162
+ if (ratings.length > 0) avgSatisfaction = Math.round((ratings.reduce((a: number, b: number) => a + b, 0) / ratings.length) * 10) / 10
163
+ }
164
+ } catch { /* satisfaction collection might not exist */ }
165
+
166
+ // 5. Build context for AI
167
+ const ticketSummaries = tickets.docs.map((t: any) => {
168
+ const msgs = messages.docs.filter((m: any) => {
169
+ const mTicket = typeof m.ticket === 'object' ? m.ticket.id : m.ticket
170
+ return mTicket === t.id
171
+ })
172
+ const clientMsgs = msgs.filter((m: any) => m.authorType === 'client' || m.authorType === 'email')
173
+ const adminMsgs = msgs.filter((m: any) => m.authorType === 'admin')
174
+ return `Ticket ${t.ticketNumber} (${t.status}) — "${t.subject}"
175
+ Client: ${clientMsgs.map((m: any) => m.body?.slice(0, 200)).join(' | ')}
176
+ Admin: ${adminMsgs.map((m: any) => m.body?.slice(0, 200)).join(' | ')}`
177
+ }).join('\n\n')
178
+
179
+ const prompt = `Tu es un assistant d'analyse CRM pour un support technique. Analyse l'historique complet de ce client et génère un rapport structuré.
180
+
181
+ CLIENT : ${clientName} (${client.company || 'pas de société'})
182
+ Email : ${client.email}
183
+ Nombre de tickets : ${tickets.totalDocs}
184
+ Satisfaction moyenne : ${avgSatisfaction ?? 'non évaluée'}
185
+
186
+ HISTORIQUE DES TICKETS :
187
+ ${ticketSummaries.slice(0, 4000)}
188
+
189
+ Réponds en JSON strict (pas de markdown, pas de commentaires) avec cette structure :
190
+ {
191
+ "summary": "Résumé global du client en 2-3 phrases (qui il est, ce qu'il demande habituellement, son niveau de satisfaction)",
192
+ "recurringTopics": [{"topic": "nom du sujet", "count": N, "lastSeen": "YYYY-MM-DD"}],
193
+ "patterns": ["pattern 1 observé", "pattern 2 observé"],
194
+ "keyFacts": ["fait clé 1 sur le client", "fait clé 2"]
195
+ }
196
+
197
+ Sois factuel. Ne dépasse pas 5 items par tableau. Réponds UNIQUEMENT avec le JSON.`
198
+
199
+ // 6. Call AI
200
+ const anthropic = getClient(aiSettings)
201
+ const model = getModel(aiSettings)
202
+
203
+ const res = await anthropic.messages.create({
204
+ model,
205
+ max_tokens: 1000,
206
+ messages: [{ role: 'user', content: prompt }],
207
+ })
208
+
209
+ const rawText = res.content[0].type === 'text' ? res.content[0].text : '{}'
210
+
211
+ // 7. Parse AI response
212
+ let parsed: any = {}
213
+ try {
214
+ // Extract JSON from potential markdown fences
215
+ const jsonMatch = rawText.match(/\{[\s\S]*\}/)
216
+ if (jsonMatch) parsed = JSON.parse(jsonMatch[0])
217
+ } catch {
218
+ parsed = { summary: rawText, recurringTopics: [], patterns: [], keyFacts: [] }
219
+ }
220
+
221
+ // 8. Save to DB
222
+ const data = {
223
+ client: Number(clientId),
224
+ clientName,
225
+ summary: parsed.summary || 'Résumé non disponible',
226
+ recurringTopics: parsed.recurringTopics || [],
227
+ patterns: parsed.patterns || [],
228
+ keyFacts: parsed.keyFacts || [],
229
+ ticketCount: tickets.totalDocs,
230
+ messageCount: messages.totalDocs,
231
+ averageSatisfaction: avgSatisfaction,
232
+ firstTicketAt: tickets.docs[tickets.docs.length - 1]?.createdAt || null,
233
+ lastTicketAt: tickets.docs[0]?.createdAt || null,
234
+ generatedAt: new Date().toISOString(),
235
+ aiModel: model,
236
+ }
237
+
238
+ let saved: any
239
+ if (existingId) {
240
+ saved = await payload.update({
241
+ collection: 'client-summaries',
242
+ id: existingId,
243
+ data,
244
+ overrideAccess: true,
245
+ })
246
+ } else {
247
+ saved = await payload.create({
248
+ collection: 'client-summaries',
249
+ data,
250
+ overrideAccess: true,
251
+ })
252
+ }
253
+
254
+ console.log(`[client-intelligence] Generated summary for ${clientName} (${tickets.totalDocs} tickets, ${messages.totalDocs} messages)`)
255
+
256
+ return Response.json({ ...saved, fromCache: false })
257
+ }
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import type { SupportFeatures } from '../types'
4
4
 
5
5
  import { createAiEndpoint } from './ai'
6
+ import { createClientIntelligenceEndpoint } from './client-intelligence'
6
7
  import { createSearchEndpoint } from './search'
7
8
  import { createBulkActionEndpoint } from './bulk-action'
8
9
  import { createMergeTicketsEndpoint } from './merge-tickets'
@@ -117,7 +118,10 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
117
118
  ]
118
119
 
119
120
  // Conditional endpoints based on feature flags
120
- if (!f || f.ai !== false) endpoints.push(createAiEndpoint(slugs))
121
+ if (!f || f.ai !== false) {
122
+ endpoints.push(createAiEndpoint(slugs))
123
+ endpoints.push(...createClientIntelligenceEndpoint(slugs))
124
+ }
121
125
  if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs))
122
126
  if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs))
123
127
  if (!f || f.splitTicket !== false) endpoints.push(createSplitTicketEndpoint(slugs))
package/src/plugin.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  createSlaPoliciesCollection,
21
21
  createMacrosCollection,
22
22
  createTicketStatusesCollection,
23
+ createClientSummariesCollection,
23
24
  } from './collections'
24
25
 
25
26
  function viewConfig(component: string, path: string): AdminViewConfig {
@@ -99,6 +100,7 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
99
100
  if (features.customStatuses !== false) supportCollections.push(createTicketStatusesCollection(slugs))
100
101
  if (features.chat) supportCollections.push(createChatMessagesCollection(slugs))
101
102
  if (features.pendingEmails) supportCollections.push(createPendingEmailsCollection(slugs))
103
+ if (features.ai !== false) supportCollections.push(createClientSummariesCollection(slugs))
102
104
 
103
105
  // ─── Admin Views ─────────────────────────────────────
104
106