@consilioweb/payload-support 0.9.11 → 0.9.13

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.
@@ -13,6 +13,7 @@ import { dispatchWebhook } from '../utils/webhookDispatcher'
13
13
  import { readSupportSettings } from '../utils/readSettings'
14
14
  import { createTicketStatusEmail } from '../hooks/ticketStatusEmail'
15
15
  import { createAssignSlaDeadlines, createCheckSlaOnResolve } from '../hooks/checkSLA'
16
+ import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
16
17
 
17
18
  // ─── Hooks ───────────────────────────────────────────────
18
19
 
@@ -154,6 +155,40 @@ function createTrackSLA(slugs: CollectionSlugs): CollectionAfterChangeHook {
154
155
  }
155
156
  }
156
157
 
158
+ function createTrackAiSummaryOnResolve(slugs: CollectionSlugs): CollectionAfterChangeHook {
159
+ return async ({ doc, previousDoc, operation, req }) => {
160
+ if (operation !== 'update' || !previousDoc) return doc
161
+ const wasResolved = previousDoc.status === 'resolved'
162
+ const isResolved = doc.status === 'resolved'
163
+
164
+ // Reopening: clear the cached summary so the next resolve regenerates it
165
+ if (wasResolved && !isResolved && doc.aiSummary) {
166
+ try {
167
+ await req.payload.update({
168
+ collection: slugs.tickets,
169
+ id: doc.id,
170
+ data: { aiSummary: null, aiSummaryGeneratedAt: null, aiSummaryStatus: null },
171
+ overrideAccess: true,
172
+ })
173
+ } catch (err) {
174
+ console.error('[support] Failed to clear ai summary on reopen:', err)
175
+ }
176
+ return doc
177
+ }
178
+
179
+ // Just resolved: trigger generation if not already cached.
180
+ // Fire-and-forget so the admin UI doesn't block on the LLM call.
181
+ if (!wasResolved && isResolved && !doc.aiSummary) {
182
+ // setImmediate keeps the response snappy; failures are logged inside the util.
183
+ setImmediate(() => {
184
+ generateTicketSynthesis({ payload: req.payload, slugs, ticketId: doc.id })
185
+ .catch((err) => console.error('[support] Background ai synthesis failed:', err))
186
+ })
187
+ }
188
+ return doc
189
+ }
190
+ }
191
+
157
192
  function createLogTicketActivity(slugs: CollectionSlugs): CollectionAfterChangeHook {
158
193
  return async ({ doc, previousDoc, operation, req }) => {
159
194
  if (operation !== 'update' || !previousDoc) return doc
@@ -596,6 +631,48 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
596
631
  admin: { initCollapsed: true },
597
632
  fields: billingFields,
598
633
  },
634
+ // AI Synthesis collapsible — auto-filled when ticket is resolved
635
+ {
636
+ type: 'collapsible', label: 'Synthese IA',
637
+ admin: {
638
+ initCollapsed: true,
639
+ description: 'Recap factuel genere automatiquement au passage en resolu. Sert au copier-coller dans devis/factures.',
640
+ },
641
+ fields: [
642
+ {
643
+ name: 'aiSummary',
644
+ type: 'textarea',
645
+ label: 'Synthese',
646
+ admin: {
647
+ readOnly: true,
648
+ rows: 8,
649
+ description: 'Vide tant que le ticket n\'est pas resolu. Effacee si le ticket est reouvert.',
650
+ },
651
+ },
652
+ {
653
+ type: 'row',
654
+ fields: [
655
+ {
656
+ name: 'aiSummaryGeneratedAt',
657
+ type: 'date',
658
+ label: 'Genere le',
659
+ admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } },
660
+ },
661
+ {
662
+ name: 'aiSummaryStatus',
663
+ type: 'select',
664
+ label: 'Statut',
665
+ options: [
666
+ { label: 'En cours', value: 'pending' },
667
+ { label: 'Genere', value: 'done' },
668
+ { label: 'Erreur', value: 'error' },
669
+ ],
670
+ admin: { readOnly: true, width: '50%' },
671
+ },
672
+ ],
673
+ },
674
+ ],
675
+ },
599
676
  // SLA & Delais
600
677
  {
601
678
  type: 'collapsible', label: 'SLA & Delais',
@@ -710,6 +787,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
710
787
  ],
711
788
  afterChange: [
712
789
  createTrackSLA(slugs),
790
+ createTrackAiSummaryOnResolve(slugs),
713
791
  createAutoCalculateSLA(slugs),
714
792
  createAssignSlaDeadlines(slugs, notificationSlug),
715
793
  createCheckSlaOnResolve(slugs, notificationSlug),
@@ -8,6 +8,10 @@ const MAX_PAGES = 50
8
8
  /**
9
9
  * GET /api/support/billing?from=...&to=...&projectId=...
10
10
  * Admin-only endpoint returning billing data.
11
+ *
12
+ * Includes ALL billable tickets active during the period (i.e. updated/created/resolved
13
+ * between from and to) — even those without any time entries, so the admin can spot
14
+ * tickets where time was forgotten.
11
15
  */
12
16
  export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
13
17
  return {
@@ -29,12 +33,43 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
29
33
 
30
34
  const projectId = url.searchParams.get('projectId')
31
35
 
36
+ // Build an exclusive upper-bound for ISO datetime fields:
37
+ // when filtering on updatedAt / resolvedAt with `to=2026-04-30`, we want to include
38
+ // events that happened on April 30 after 00:00.
39
+ const toExclusive = new Date(to)
40
+ toExclusive.setDate(toExclusive.getDate() + 1)
41
+ const toExclusiveIso = toExclusive.toISOString()
42
+
32
43
  const ticketWhere: Where = {
33
- billable: { equals: true },
34
- ...(projectId ? { project: { equals: Number(projectId) } } : {}),
44
+ and: [
45
+ { billable: { equals: true } },
46
+ ...(projectId ? [{ project: { equals: Number(projectId) } } as Where] : []),
47
+ {
48
+ or: [
49
+ {
50
+ and: [
51
+ { updatedAt: { greater_than_equal: from } },
52
+ { updatedAt: { less_than: toExclusiveIso } },
53
+ ],
54
+ },
55
+ {
56
+ and: [
57
+ { createdAt: { greater_than_equal: from } },
58
+ { createdAt: { less_than: toExclusiveIso } },
59
+ ],
60
+ },
61
+ {
62
+ and: [
63
+ { resolvedAt: { greater_than_equal: from } },
64
+ { resolvedAt: { less_than: toExclusiveIso } },
65
+ ],
66
+ },
67
+ ],
68
+ },
69
+ ],
35
70
  }
36
71
 
37
- // Paginate tickets instead of limit:0
72
+ // Paginate tickets
38
73
  const allTickets: Array<Record<string, unknown>> = []
39
74
  let ticketPage = 1
40
75
  let ticketHasMore = true
@@ -53,7 +88,7 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
53
88
  ticketPage++
54
89
  }
55
90
 
56
- // Paginate time entries instead of limit:0
91
+ // Paginate time entries within the period
57
92
  const allEntries: Array<Record<string, unknown>> = []
58
93
  let entryPage = 1
59
94
  let entryHasMore = true
@@ -94,15 +129,27 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
94
129
  const projectGroups = new Map<string, {
95
130
  project: { id: number; name: string } | null
96
131
  client: { company: string } | null
97
- tickets: Array<{ id: number; ticketNumber: string; subject: string; entries: any[]; totalMinutes: number; billedAmount: number | null }>
132
+ tickets: Array<{
133
+ id: number
134
+ ticketNumber: string
135
+ subject: string
136
+ status: string
137
+ entries: Array<{ duration: number; description: string; date: string }>
138
+ totalMinutes: number
139
+ billedAmount: number | null
140
+ hasNoTimeEntries: boolean
141
+ aiSummary: string | null
142
+ aiSummaryGeneratedAt: string | null
143
+ aiSummaryStatus: string | null
144
+ }>
98
145
  totalMinutes: number
99
146
  totalBilledAmount: number
100
147
  }>()
101
148
 
102
149
  for (const ticket of allTickets) {
103
150
  const t = ticket as any
104
- const ticketEntries = entriesByTicket.get(t.id)
105
- if (!ticketEntries || ticketEntries.length === 0) continue
151
+ const ticketEntries = entriesByTicket.get(t.id) || []
152
+ const hasNoTimeEntries = ticketEntries.length === 0
106
153
 
107
154
  const project = typeof t.project === 'object' && t.project
108
155
  ? { id: t.project.id, name: t.project.name || 'Sans nom' }
@@ -138,9 +185,14 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
138
185
  id: t.id,
139
186
  ticketNumber: t.ticketNumber || '',
140
187
  subject: t.subject || '',
188
+ status: t.status || '',
141
189
  entries: ticketEntries,
142
190
  totalMinutes: ticketTotalMinutes,
143
191
  billedAmount,
192
+ hasNoTimeEntries,
193
+ aiSummary: t.aiSummary || null,
194
+ aiSummaryGeneratedAt: t.aiSummaryGeneratedAt || null,
195
+ aiSummaryStatus: t.aiSummaryStatus || null,
144
196
  })
145
197
  projectGroups.get(projectKey)!.totalMinutes += ticketTotalMinutes
146
198
  if (billedAmount) projectGroups.get(projectKey)!.totalBilledAmount += billedAmount
@@ -149,8 +201,17 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
149
201
  const groups = Array.from(projectGroups.values())
150
202
  const grandTotalMinutes = groups.reduce((sum, g) => sum + g.totalMinutes, 0)
151
203
  const grandTotalBilledAmount = groups.reduce((sum, g) => sum + g.totalBilledAmount, 0)
152
-
153
- return new Response(JSON.stringify({ groups, grandTotalMinutes, grandTotalBilledAmount }), {
204
+ const ticketsWithoutTime = groups.reduce(
205
+ (sum, g) => sum + g.tickets.filter((t) => t.hasNoTimeEntries).length,
206
+ 0,
207
+ )
208
+
209
+ return new Response(JSON.stringify({
210
+ groups,
211
+ grandTotalMinutes,
212
+ grandTotalBilledAmount,
213
+ ticketsWithoutTime,
214
+ }), {
154
215
  headers: {
155
216
  'Content-Type': 'application/json',
156
217
  'Cache-Control': 'private, max-age=300, stale-while-revalidate=600',
@@ -25,6 +25,7 @@ import { createAdminChatGetEndpoint, createAdminChatPostEndpoint } from './admin
25
25
  import { createAdminChatStreamEndpoint } from './admin-chat-stream'
26
26
  import { createAdminStatsEndpoint } from './admin-stats'
27
27
  import { createBillingEndpoint } from './billing'
28
+ import { createTicketSynthesisEndpoint } from './ticket-synthesis'
28
29
  import { createEmailStatsEndpoint } from './email-stats'
29
30
  import { createSatisfactionEndpoint } from './satisfaction'
30
31
  import { createTrackOpenEndpoint } from './track-open'
@@ -65,6 +66,7 @@ export { createAdminChatGetEndpoint, createAdminChatPostEndpoint } from './admin
65
66
  export { createAdminChatStreamEndpoint } from './admin-chat-stream'
66
67
  export { createAdminStatsEndpoint } from './admin-stats'
67
68
  export { createBillingEndpoint } from './billing'
69
+ export { createTicketSynthesisEndpoint } from './ticket-synthesis'
68
70
  export { createEmailStatsEndpoint } from './email-stats'
69
71
  export { createSatisfactionEndpoint } from './satisfaction'
70
72
  export { createTrackOpenEndpoint } from './track-open'
@@ -121,6 +123,7 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
121
123
  if (!f || f.ai !== false) {
122
124
  endpoints.push(createAiEndpoint(slugs))
123
125
  endpoints.push(...createClientIntelligenceEndpoint(slugs))
126
+ endpoints.push(createTicketSynthesisEndpoint(slugs))
124
127
  }
125
128
  if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs))
126
129
  if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs))
@@ -0,0 +1,67 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
5
+
6
+ /**
7
+ * POST /api/support/ticket-synthesis?ticketId=X[&force=true]
8
+ *
9
+ * Admin-only. Generates (or returns the cached) AI bullet-point synthesis for a single ticket.
10
+ * The synthesis is persisted on the ticket itself (aiSummary, aiSummaryGeneratedAt, aiSummaryStatus).
11
+ *
12
+ * Cache behaviour:
13
+ * - If aiSummary already exists and force is not set, returns the cached value (status: cached).
14
+ * - If force=true, regenerates regardless of existing summary.
15
+ * - The hook on Tickets clears aiSummary when a ticket is reopened, so the next pass through
16
+ * "resolved" status will trigger a regeneration automatically.
17
+ */
18
+ export function createTicketSynthesisEndpoint(slugs: CollectionSlugs): Endpoint {
19
+ return {
20
+ path: '/support/ticket-synthesis',
21
+ method: 'post',
22
+ handler: async (req) => {
23
+ try {
24
+ requireAdmin(req, slugs)
25
+ const payload = req.payload
26
+
27
+ const url = new URL(req.url || '', 'http://localhost')
28
+ const ticketIdRaw = url.searchParams.get('ticketId')
29
+ const force = url.searchParams.get('force') === 'true'
30
+
31
+ if (!ticketIdRaw) {
32
+ return Response.json({ error: 'ticketId required' }, { status: 400 })
33
+ }
34
+
35
+ const ticketId = Number(ticketIdRaw)
36
+ if (Number.isNaN(ticketId)) {
37
+ return Response.json({ error: 'ticketId must be a number' }, { status: 400 })
38
+ }
39
+
40
+ if (!force) {
41
+ const existing = await payload.findByID({
42
+ collection: slugs.tickets as any,
43
+ id: ticketId,
44
+ depth: 0,
45
+ overrideAccess: true,
46
+ }) as { aiSummary?: string; aiSummaryGeneratedAt?: string; aiSummaryStatus?: string } | null
47
+
48
+ if (existing?.aiSummary && existing.aiSummaryStatus === 'done') {
49
+ return Response.json({
50
+ summary: existing.aiSummary,
51
+ generatedAt: existing.aiSummaryGeneratedAt,
52
+ status: 'cached',
53
+ })
54
+ }
55
+ }
56
+
57
+ const result = await generateTicketSynthesis({ payload, slugs, ticketId })
58
+ return Response.json(result)
59
+ } catch (err) {
60
+ const authResponse = handleAuthError(err)
61
+ if (authResponse) return authResponse
62
+ console.error('[support/ticket-synthesis] Error:', err)
63
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
64
+ }
65
+ },
66
+ }
67
+ }
package/src/index.ts CHANGED
@@ -25,6 +25,8 @@ export { readSupportSettings, readUserPrefs, DEFAULT_SETTINGS, DEFAULT_USER_PREF
25
25
  export type { SupportSettings, UserPrefs } from './utils/readSettings'
26
26
  export { createAdminNotification } from './utils/adminNotification'
27
27
  export { dispatchWebhook } from './utils/webhookDispatcher'
28
+ export { generateTicketSynthesis } from './utils/generateTicketSynthesis'
29
+ export type { TicketSynthesisResult } from './utils/generateTicketSynthesis'
28
30
 
29
31
  // Hooks
30
32
  export { createAssignSlaDeadlines, createCheckSlaOnResolve, createCheckSlaOnReply, calculateBusinessHoursDeadline } from './hooks/checkSLA'
@@ -276,6 +276,138 @@
276
276
  color: #16a34a;
277
277
  }
278
278
 
279
+ // Warning banner — tickets without time
280
+ .warningBanner {
281
+ padding: 10px 16px;
282
+ border: 1px solid #f59e0b;
283
+ border-radius: 8px;
284
+ background: rgba(245, 158, 11, 0.08);
285
+ margin-bottom: 16px;
286
+ display: flex;
287
+ justify-content: space-between;
288
+ align-items: center;
289
+ flex-wrap: wrap;
290
+ gap: 12px;
291
+ font-size: 13px;
292
+ color: var(--theme-text);
293
+ }
294
+
295
+ .toggleLabel {
296
+ display: flex;
297
+ align-items: center;
298
+ gap: 6px;
299
+ cursor: pointer;
300
+ font-weight: 600;
301
+ user-select: none;
302
+ }
303
+
304
+ .noTimeBadge {
305
+ display: inline-block;
306
+ margin-left: 8px;
307
+ padding: 2px 8px;
308
+ border-radius: 999px;
309
+ background: rgba(245, 158, 11, 0.15);
310
+ color: #b45309;
311
+ font-size: 11px;
312
+ font-weight: 700;
313
+ white-space: nowrap;
314
+ }
315
+
316
+ .tableRowNoTime {
317
+ border-bottom: 1px solid var(--theme-elevation-200);
318
+ background: rgba(245, 158, 11, 0.04);
319
+ }
320
+
321
+ // AI summary toggle button
322
+ .summaryBtn {
323
+ display: inline-block;
324
+ margin-left: 6px;
325
+ padding: 1px 6px;
326
+ border-radius: 4px;
327
+ border: 1px solid var(--theme-elevation-300);
328
+ background: var(--theme-elevation-100);
329
+ color: var(--theme-elevation-500);
330
+ font-size: 10px;
331
+ font-weight: 600;
332
+ cursor: pointer;
333
+ vertical-align: middle;
334
+ transition: background-color 100ms, color 100ms;
335
+
336
+ &:hover {
337
+ background: #2563eb;
338
+ color: #fff;
339
+ border-color: #2563eb;
340
+ }
341
+ }
342
+
343
+ // AI summary expanded row
344
+ .summaryRow {
345
+ background: var(--theme-elevation-50);
346
+ border-bottom: 1px solid var(--theme-elevation-300);
347
+ }
348
+
349
+ .summaryCell {
350
+ padding: 12px 16px 16px;
351
+ }
352
+
353
+ .summaryHeader {
354
+ display: flex;
355
+ justify-content: space-between;
356
+ align-items: center;
357
+ margin-bottom: 8px;
358
+ flex-wrap: wrap;
359
+ gap: 8px;
360
+ }
361
+
362
+ .summaryActions {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 8px;
366
+ }
367
+
368
+ .summaryMeta {
369
+ font-size: 11px;
370
+ color: var(--theme-elevation-500);
371
+ }
372
+
373
+ .summaryAction {
374
+ padding: 4px 10px;
375
+ border-radius: 6px;
376
+ border: 1px solid var(--theme-elevation-300);
377
+ background: var(--theme-elevation-100);
378
+ color: var(--theme-text);
379
+ font-size: 12px;
380
+ font-weight: 600;
381
+ cursor: pointer;
382
+ transition: background-color 100ms;
383
+
384
+ &:hover { background: var(--theme-elevation-200); }
385
+ &:disabled { cursor: not-allowed; opacity: 0.5; }
386
+ }
387
+
388
+ .summaryText {
389
+ margin: 0;
390
+ padding: 10px 12px;
391
+ background: var(--theme-elevation-100);
392
+ border: 1px solid var(--theme-elevation-300);
393
+ border-radius: 6px;
394
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif;
395
+ font-size: 13px;
396
+ color: var(--theme-text);
397
+ white-space: pre-wrap;
398
+ word-break: break-word;
399
+ }
400
+
401
+ .summaryEmpty {
402
+ padding: 10px 12px;
403
+ background: var(--theme-elevation-100);
404
+ border: 1px dashed var(--theme-elevation-300);
405
+ border-radius: 6px;
406
+ color: var(--theme-elevation-500);
407
+ font-size: 12px;
408
+ font-style: italic;
409
+ }
410
+
279
411
  // Grand total
280
412
  .grandTotal {
281
413
  padding: 16px;
@@ -0,0 +1,178 @@
1
+ import type { Payload } from 'payload'
2
+ import type { CollectionSlugs } from './slugs'
3
+ import { readSupportSettings, type SupportSettings } from './readSettings'
4
+
5
+ function getClient(aiSettings: SupportSettings['ai']) {
6
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
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
+ interface TicketMessageDoc {
20
+ authorType?: string
21
+ body?: string
22
+ createdAt?: string
23
+ }
24
+
25
+ interface TicketDoc {
26
+ id: number | string
27
+ subject?: string
28
+ ticketNumber?: string
29
+ client?: number | { id: number; firstName?: string; lastName?: string; company?: string } | null
30
+ }
31
+
32
+ export interface TicketSynthesisResult {
33
+ summary: string
34
+ generatedAt: string
35
+ status: 'done' | 'error' | 'skipped'
36
+ reason?: string
37
+ }
38
+
39
+ /**
40
+ * Generates a billing-oriented bullet-point synthesis for a single support ticket.
41
+ * Persists the result on the ticket (aiSummary, aiSummaryGeneratedAt, aiSummaryStatus).
42
+ * Designed to be called from an HTTP endpoint OR from a Payload hook (async fire-and-forget).
43
+ */
44
+ export async function generateTicketSynthesis(args: {
45
+ payload: Payload
46
+ slugs: CollectionSlugs
47
+ ticketId: number | string
48
+ }): Promise<TicketSynthesisResult> {
49
+ const { payload, slugs, ticketId } = args
50
+
51
+ const settings = await readSupportSettings(payload)
52
+ if (settings.ai.enableSynthesis === false) {
53
+ return { summary: '', generatedAt: new Date().toISOString(), status: 'skipped', reason: 'synthesis disabled' }
54
+ }
55
+
56
+ const ticket = await payload.findByID({
57
+ collection: slugs.tickets as any,
58
+ id: ticketId,
59
+ depth: 1,
60
+ overrideAccess: true,
61
+ }) as TicketDoc
62
+
63
+ if (!ticket) {
64
+ return { summary: '', generatedAt: new Date().toISOString(), status: 'error', reason: 'ticket not found' }
65
+ }
66
+
67
+ // Mark as pending so the UI can show a spinner
68
+ await payload.update({
69
+ collection: slugs.tickets as any,
70
+ id: ticketId,
71
+ data: { aiSummaryStatus: 'pending' },
72
+ overrideAccess: true,
73
+ }).catch(() => { /* non-blocking */ })
74
+
75
+ // Load all ticket messages, ordered chronologically
76
+ const messagesResult = await payload.find({
77
+ collection: slugs.ticketMessages as any,
78
+ where: { ticket: { equals: ticketId } },
79
+ sort: 'createdAt',
80
+ limit: 500,
81
+ depth: 0,
82
+ overrideAccess: true,
83
+ })
84
+
85
+ const messages = messagesResult.docs as TicketMessageDoc[]
86
+
87
+ if (messages.length === 0) {
88
+ const generatedAt = new Date().toISOString()
89
+ await payload.update({
90
+ collection: slugs.tickets as any,
91
+ id: ticketId,
92
+ data: {
93
+ aiSummary: '(Aucun message dans ce ticket)',
94
+ aiSummaryGeneratedAt: generatedAt,
95
+ aiSummaryStatus: 'done',
96
+ },
97
+ overrideAccess: true,
98
+ })
99
+ return { summary: '(Aucun message dans ce ticket)', generatedAt, status: 'done' }
100
+ }
101
+
102
+ const conversation = messages
103
+ .map((m) => {
104
+ const author = m.authorType === 'admin' ? 'Support' : 'Client'
105
+ const date = m.createdAt
106
+ ? new Date(m.createdAt).toLocaleDateString('fr-FR', {
107
+ day: 'numeric',
108
+ month: 'short',
109
+ hour: '2-digit',
110
+ minute: '2-digit',
111
+ timeZone: 'Europe/Paris',
112
+ })
113
+ : ''
114
+ return `[${date}] ${author}: ${m.body || ''}`
115
+ })
116
+ .join('\n\n')
117
+
118
+ const clientObj = typeof ticket.client === 'object' && ticket.client ? ticket.client : null
119
+ const clientCompany = clientObj?.company || ''
120
+ const clientName = clientObj ? [clientObj.firstName, clientObj.lastName].filter(Boolean).join(' ') : ''
121
+
122
+ const prompt = `Tu es un consultant technique qui prepare un recap factuel pour une facturation client.
123
+
124
+ Sujet du ticket : ${ticket.subject || '(sans sujet)'}
125
+ Client : ${clientName || 'Inconnu'}${clientCompany ? ` — ${clientCompany}` : ''}
126
+
127
+ Conversation complete du ticket :
128
+ ${conversation}
129
+
130
+ Genere un recap factuel sous forme d'une liste a puces courtes et actionnables, decrivant CE QUI A ETE FAIT cote support pendant ce ticket. C'est destine a etre colle dans un devis ou une facture.
131
+
132
+ Regles strictes :
133
+ - Une puce = une action realisee, formulee en groupe nominal court (ex : "Diagnostic configuration DNS et authentification Mailchimp")
134
+ - Pas de phrases completes, pas de "j'ai fait", pas de pronoms
135
+ - Pas de salutations, pas d'introduction, pas de conclusion
136
+ - Pas de markdown autre que les puces "- "
137
+ - 5 a 10 puces maximum, ordonnees chronologiquement
138
+ - Ne mentionne PAS le client par son nom dans les puces
139
+ - Si le ticket n'a pas abouti, decris quand meme le travail d'analyse realise
140
+
141
+ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`
142
+
143
+ const anthropic = getClient(settings.ai)
144
+ const model = getModel(settings.ai)
145
+
146
+ try {
147
+ const res = await anthropic.messages.create({
148
+ model,
149
+ max_tokens: 600,
150
+ messages: [{ role: 'user', content: prompt }],
151
+ })
152
+
153
+ const summary = res.content[0]?.type === 'text' ? res.content[0].text.trim() : ''
154
+ const generatedAt = new Date().toISOString()
155
+
156
+ await payload.update({
157
+ collection: slugs.tickets as any,
158
+ id: ticketId,
159
+ data: {
160
+ aiSummary: summary,
161
+ aiSummaryGeneratedAt: generatedAt,
162
+ aiSummaryStatus: 'done',
163
+ },
164
+ overrideAccess: true,
165
+ })
166
+
167
+ return { summary, generatedAt, status: 'done' }
168
+ } catch (err) {
169
+ const message = err instanceof Error ? err.message : String(err)
170
+ await payload.update({
171
+ collection: slugs.tickets as any,
172
+ id: ticketId,
173
+ data: { aiSummaryStatus: 'error' },
174
+ overrideAccess: true,
175
+ }).catch(() => { /* non-blocking */ })
176
+ return { summary: '', generatedAt: new Date().toISOString(), status: 'error', reason: message }
177
+ }
178
+ }