@consilioweb/payload-support 1.1.1 → 2.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 (99) hide show
  1. package/README.md +4 -0
  2. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +22 -14
  3. package/dist/components/TicketConversation/components/AISummaryPanel.js +23 -16
  4. package/dist/components/TicketConversation/components/ActionPanels.cjs +5 -5
  5. package/dist/components/TicketConversation/components/ActionPanels.js +5 -5
  6. package/dist/components/TicketConversation/components/ClientBar.cjs +1 -1
  7. package/dist/components/TicketConversation/components/ClientBar.js +1 -1
  8. package/dist/components/TicketConversation/components/QuickActions.cjs +2 -2
  9. package/dist/components/TicketConversation/components/QuickActions.js +2 -2
  10. package/dist/components/TicketConversation/components/TicketHeader.cjs +1 -1
  11. package/dist/components/TicketConversation/components/TicketHeader.js +1 -1
  12. package/dist/components/TicketConversation/index.cjs +10 -10
  13. package/dist/components/TicketConversation/index.js +10 -10
  14. package/dist/index.cjs +709 -173
  15. package/dist/index.d.cts +165 -3
  16. package/dist/index.d.ts +165 -3
  17. package/dist/index.js +702 -175
  18. package/dist/styles/ChatView.module.scss +1 -1
  19. package/dist/styles/TicketDetail.module.scss +3 -3
  20. package/dist/styles/_tokens.scss +1 -1
  21. package/dist/styles/theme.css +6 -6
  22. package/dist/views/TicketingSettingsView/client.cjs +3 -3
  23. package/dist/views/TicketingSettingsView/client.js +3 -3
  24. package/package.json +9 -9
  25. package/src/__tests__/aiSummaryRendering.test.ts +21 -0
  26. package/src/__tests__/authResponses.test.ts +69 -0
  27. package/src/__tests__/capabilities.test.ts +71 -0
  28. package/src/__tests__/generateTrackingToken.test.ts +18 -4
  29. package/src/__tests__/integration/core-behaviors.test.ts +23 -0
  30. package/src/__tests__/rateLimiter.test.ts +60 -66
  31. package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
  32. package/src/__tests__/webhookSecurity.test.ts +58 -0
  33. package/src/collections/PendingEmails.ts +2 -1
  34. package/src/collections/SupportClients.ts +43 -1
  35. package/src/collections/SupportCounters.ts +14 -0
  36. package/src/collections/SupportRateLimits.ts +20 -0
  37. package/src/collections/TicketMessages.ts +76 -0
  38. package/src/collections/Tickets.ts +179 -39
  39. package/src/collections/index.ts +2 -0
  40. package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
  41. package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
  42. package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
  43. package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
  44. package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
  45. package/src/components/TicketConversation/index.tsx +10 -10
  46. package/src/endpoints/admin-chat.ts +4 -4
  47. package/src/endpoints/ai-agent.ts +6 -1
  48. package/src/endpoints/ai.ts +10 -5
  49. package/src/endpoints/auth-2fa.ts +6 -7
  50. package/src/endpoints/auto-close.ts +2 -1
  51. package/src/endpoints/capabilities.ts +144 -0
  52. package/src/endpoints/channels.ts +2 -1
  53. package/src/endpoints/chat.ts +6 -6
  54. package/src/endpoints/chatbot.ts +4 -4
  55. package/src/endpoints/client-intelligence.ts +13 -4
  56. package/src/endpoints/import-conversation.ts +6 -5
  57. package/src/endpoints/index.ts +35 -14
  58. package/src/endpoints/invite-collaborator.ts +4 -4
  59. package/src/endpoints/login.ts +4 -5
  60. package/src/endpoints/oauth-google.ts +1 -1
  61. package/src/endpoints/process-digests.ts +2 -1
  62. package/src/endpoints/process-scheduled.ts +2 -1
  63. package/src/endpoints/process-snooze.ts +2 -1
  64. package/src/endpoints/resend-notification.ts +4 -4
  65. package/src/endpoints/send-reminder.ts +4 -4
  66. package/src/endpoints/ticket-synthesis.ts +17 -2
  67. package/src/endpoints/track-open.ts +44 -21
  68. package/src/endpoints/transfer-ticket.ts +4 -4
  69. package/src/index.ts +7 -0
  70. package/src/plugin.ts +16 -1
  71. package/src/portal/LiveChat.tsx +1 -1
  72. package/src/portal/auth/ChatWidget.tsx +4 -4
  73. package/src/portal/auth/ChatbotWidget.tsx +4 -4
  74. package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
  75. package/src/portal/auth/faq/FAQSearch.tsx +1 -1
  76. package/src/portal/auth/faq/page.tsx +2 -2
  77. package/src/portal/auth/profile/page.tsx +17 -17
  78. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
  79. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
  80. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
  81. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
  82. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
  83. package/src/portal/auth/tickets/detail/page.tsx +5 -5
  84. package/src/portal/auth/tickets/new/page.tsx +8 -8
  85. package/src/portal/forgot-password/page.tsx +3 -3
  86. package/src/portal/layout.tsx +5 -2
  87. package/src/portal/login/page.tsx +7 -7
  88. package/src/portal/page.tsx +9 -9
  89. package/src/portal/register/page.tsx +4 -4
  90. package/src/portal/reset-password/page.tsx +3 -3
  91. package/src/styles/ChatView.module.scss +1 -1
  92. package/src/styles/TicketDetail.module.scss +3 -3
  93. package/src/styles/_tokens.scss +1 -1
  94. package/src/styles/theme.css +6 -6
  95. package/src/types.ts +71 -0
  96. package/src/utils/rateLimiter.ts +131 -38
  97. package/src/utils/slugs.ts +4 -0
  98. package/src/utils/webhookSecurity.ts +81 -0
  99. package/src/views/TicketingSettingsView/client.tsx +3 -3
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ DEFAULT_INBOUND_EMAIL_LIMITS,
4
+ validateInboundEmailPayload,
5
+ verifySecret,
6
+ } from '../utils/webhookSecurity'
7
+ import { createProcessDigestsEndpoint } from '../endpoints/process-digests'
8
+ import { resolveSlugs } from '../utils/slugs'
9
+
10
+ describe('verifySecret', () => {
11
+ it('accepts an exact secret and rejects missing, malformed or different values', () => {
12
+ expect(verifySecret('expected-secret', 'expected-secret')).toBe(true)
13
+ expect(verifySecret(undefined, 'expected-secret')).toBe(false)
14
+ expect(verifySecret('short', 'expected-secret')).toBe(false)
15
+ expect(verifySecret('different-secret', 'expected-secret')).toBe(false)
16
+ })
17
+ })
18
+
19
+ describe('cron endpoint authentication', () => {
20
+ it('rejects missing, forged and query-string secrets', async () => {
21
+ process.env.CRON_SECRET = 'expected-secret'
22
+ const endpoint = createProcessDigestsEndpoint(resolveSlugs())
23
+ for (const request of [
24
+ { headers: new Headers(), url: 'http://localhost/api/support/process-digests' },
25
+ { headers: new Headers({ 'x-cron-secret': 'forged' }), url: 'http://localhost/api/support/process-digests' },
26
+ { headers: new Headers(), url: 'http://localhost/api/support/process-digests?secret=expected-secret' },
27
+ ]) {
28
+ const response = await endpoint.handler(request as never)
29
+ expect(response.status).toBe(401)
30
+ }
31
+ })
32
+ })
33
+
34
+ describe('inbound email limits', () => {
35
+ const base = { senderEmail: 'client@example.com', subject: 'Help', body: 'Hello' }
36
+
37
+ it('rejects requests larger than 25 MiB', () => {
38
+ expect(validateInboundEmailPayload(base, DEFAULT_INBOUND_EMAIL_LIMITS.maxRequestBytes + 1))
39
+ .toEqual({ code: 'request_too_large', status: 413 })
40
+ })
41
+
42
+ it('rejects more than 10 attachments', () => {
43
+ const attachments = Array.from({ length: 11 }, (_, index) => ({ filename: `${index}.txt`, size: 1 }))
44
+ expect(validateInboundEmailPayload({ ...base, attachments }))
45
+ .toEqual({ code: 'too_many_attachments', status: 413 })
46
+ })
47
+
48
+ it('rejects an attachment larger than 10 MiB', () => {
49
+ const attachments = [{ filename: 'large.zip', size: DEFAULT_INBOUND_EMAIL_LIMITS.maxAttachmentBytes + 1 }]
50
+ expect(validateInboundEmailPayload({ ...base, attachments }))
51
+ .toEqual({ code: 'attachment_too_large', status: 413 })
52
+ })
53
+
54
+ it('rejects oversized text fields', () => {
55
+ expect(validateInboundEmailPayload({ ...base, subject: 'x'.repeat(1_001) }))
56
+ .toEqual({ code: 'text_field_too_large', status: 413 })
57
+ })
58
+ })
@@ -1,5 +1,6 @@
1
1
  import type { CollectionConfig } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
+ import { verifySecret } from '../utils/webhookSecurity'
3
4
 
4
5
  // ─── Collection factory ──────────────────────────────────
5
6
 
@@ -20,7 +21,7 @@ export function createPendingEmailsCollection(slugs: CollectionSlugs): Collectio
20
21
  // Allow admin users or webhook calls with secret header
21
22
  if (req.user?.collection === slugs.users) return true
22
23
  const webhookSecret = req.headers.get('x-webhook-secret')
23
- if (webhookSecret && process.env.SUPPORT_WEBHOOK_SECRET && webhookSecret === process.env.SUPPORT_WEBHOOK_SECRET) return true
24
+ if (verifySecret(webhookSecret, process.env.SUPPORT_WEBHOOK_SECRET)) return true
24
25
  return false
25
26
  },
26
27
  },
@@ -1,6 +1,7 @@
1
1
  import type { CollectionConfig, CollectionAfterChangeHook, CollectionBeforeLoginHook } from 'payload'
2
2
  import { APIError } from 'payload'
3
3
  import type { CollectionSlugs } from '../utils/slugs'
4
+ import type { SupportCapabilities } from '../types'
4
5
  import { escapeHtml, emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
5
6
  import { readSupportSettings } from '../utils/readSettings'
6
7
 
@@ -92,7 +93,10 @@ function createEnforce2FA(slugs: CollectionSlugs): CollectionBeforeLoginHook {
92
93
 
93
94
  // ─── Collection factory ──────────────────────────────────
94
95
 
95
- export function createSupportClientsCollection(slugs: CollectionSlugs): CollectionConfig {
96
+ export function createSupportClientsCollection(
97
+ slugs: CollectionSlugs,
98
+ options?: { capabilities?: SupportCapabilities },
99
+ ): CollectionConfig {
96
100
  return {
97
101
  slug: slugs.supportClients,
98
102
  labels: {
@@ -354,6 +358,44 @@ export function createSupportClientsCollection(slugs: CollectionSlugs): Collecti
354
358
  position: 'sidebar',
355
359
  },
356
360
  },
361
+ ...(options?.capabilities?.sms ? [
362
+ {
363
+ name: 'notifyBySmsChannel',
364
+ type: 'checkbox' as const,
365
+ defaultValue: false,
366
+ label: 'Canal SMS activé',
367
+ admin: { position: 'sidebar' as const },
368
+ },
369
+ ] : []),
370
+ ...(options?.capabilities?.aiTitles || options?.capabilities?.aiSummaries ? [
371
+ {
372
+ name: 'preferredFormality',
373
+ type: 'select' as const,
374
+ defaultValue: 'auto',
375
+ label: 'Formule IA',
376
+ options: [
377
+ { label: 'Auto', value: 'auto' },
378
+ { label: 'Tutoiement', value: 'tutoyer' },
379
+ { label: 'Vouvoiement', value: 'vouvoyer' },
380
+ ],
381
+ admin: { position: 'sidebar' as const },
382
+ },
383
+ {
384
+ name: 'preferredTone',
385
+ type: 'select' as const,
386
+ defaultValue: 'auto',
387
+ label: 'Ton IA',
388
+ options: [
389
+ { label: 'Auto', value: 'auto' },
390
+ { label: 'Neutre / professionnel', value: 'neutre' },
391
+ { label: 'Amical', value: 'amical' },
392
+ { label: 'Direct / concis', value: 'direct' },
393
+ { label: 'Formel', value: 'formel' },
394
+ ],
395
+ admin: { position: 'sidebar' as const },
396
+ },
397
+ { name: 'lastRewriteStyle', type: 'text' as const, admin: { hidden: true } },
398
+ ] : []),
357
399
  ],
358
400
  hooks: {
359
401
  beforeLogin: [createEnforce2FA(slugs)],
@@ -0,0 +1,14 @@
1
+ import type { CollectionConfig } from 'payload'
2
+
3
+ export function createSupportCountersCollection(slug = 'support-counters'): CollectionConfig {
4
+ return {
5
+ slug,
6
+ admin: { hidden: true },
7
+ access: { create: () => false, read: () => false, update: () => false, delete: () => false },
8
+ fields: [
9
+ { name: 'key', type: 'text', required: true, unique: true, index: true },
10
+ { name: 'nextValue', type: 'number', required: true, min: 1 },
11
+ ],
12
+ timestamps: false,
13
+ }
14
+ }
@@ -0,0 +1,20 @@
1
+ import type { CollectionConfig } from 'payload'
2
+
3
+ export function createSupportRateLimitsCollection(slug = 'support-rate-limits'): CollectionConfig {
4
+ return {
5
+ slug,
6
+ admin: { hidden: true },
7
+ access: {
8
+ create: () => false,
9
+ read: () => false,
10
+ update: () => false,
11
+ delete: () => false,
12
+ },
13
+ fields: [
14
+ { name: 'key', type: 'text', required: true, unique: true, index: true },
15
+ { name: 'count', type: 'number', required: true, min: 1 },
16
+ { name: 'resetAt', type: 'date', required: true, index: true },
17
+ ],
18
+ timestamps: false,
19
+ }
20
+ }
@@ -1,5 +1,6 @@
1
1
  import type { CollectionConfig, CollectionBeforeChangeHook, CollectionAfterChangeHook, Where } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
+ import type { SupportCapabilities } from '../types'
3
4
  import { escapeHtml, emailWrapper, emailButton, emailQuote, emailParagraph, emailRichContent, emailTrackingPixel } from '../utils/emailTemplate'
4
5
  import { fireWebhooks } from '../utils/fireWebhooks'
5
6
  import { createAdminNotification } from '../utils/adminNotification'
@@ -403,8 +404,70 @@ function createNotifyMentions(slugs: CollectionSlugs): CollectionAfterChangeHook
403
404
  }
404
405
  }
405
406
 
407
+ function createSmsNotification(
408
+ slugs: CollectionSlugs,
409
+ capability: NonNullable<SupportCapabilities['sms']>,
410
+ ): CollectionAfterChangeHook {
411
+ return async ({ doc, operation, req }) => {
412
+ try {
413
+ if (operation !== 'create' || !doc.notifyBySms) return doc
414
+ if (doc.authorType !== 'admin' || doc.isInternal) return doc
415
+ if (doc.scheduledAt && !doc.scheduledSent) return doc
416
+ if (capability.adapter.isConfigured && !capability.adapter.isConfigured()) return doc
417
+
418
+ const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
419
+ const ticket = await req.payload.findByID({
420
+ collection: slugs.tickets,
421
+ id: ticketId,
422
+ depth: 1,
423
+ overrideAccess: true,
424
+ }) as Record<string, unknown>
425
+ const client = typeof ticket.client === 'object' && ticket.client
426
+ ? ticket.client as Record<string, unknown>
427
+ : null
428
+ if (!client) return doc
429
+ const target = (typeof doc.smsTo === 'string' && doc.smsTo.trim()) || String(client.phone || '')
430
+ if (!target) return doc
431
+ const fallback = `ConsilioWEB : nouvelle réponse au ticket ${String(ticket.ticketNumber || '')}.`
432
+ const message = (typeof doc.smsMessage === 'string' && doc.smsMessage.trim())
433
+ || (capability.buildMessage ? await capability.buildMessage({ client, ticket }) : fallback)
434
+ const result = await capability.adapter.send({
435
+ message,
436
+ to: target,
437
+ payload: req.payload,
438
+ ticket,
439
+ client,
440
+ })
441
+ if (!result.sent && !result.skipped) {
442
+ req.payload.logger?.warn(`[support:sms] Delivery failed: ${result.error || 'unknown error'}`)
443
+ }
444
+ } catch (error) {
445
+ req.payload.logger?.warn(`[support:sms] Adapter error: ${error instanceof Error ? error.message : String(error)}`)
446
+ }
447
+ return doc
448
+ }
449
+ }
450
+
451
+ function createThreadCleanup(
452
+ capability: NonNullable<SupportCapabilities['threadCleanup']>,
453
+ ): CollectionAfterChangeHook {
454
+ return async ({ doc, operation, req }) => {
455
+ if (operation !== 'create' || doc.isInternal) return doc
456
+ if (doc.authorType !== 'client' && doc.authorType !== 'email') return doc
457
+ const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
458
+ if (!ticketId) return doc
459
+ try {
460
+ await capability.clean(req.payload, ticketId, doc.id)
461
+ } catch (error) {
462
+ req.payload.logger?.warn(`[support:thread-cleanup] ${error instanceof Error ? error.message : String(error)}`)
463
+ }
464
+ return doc
465
+ }
466
+ }
467
+
406
468
  export function createTicketMessagesCollection(slugs: CollectionSlugs, options?: {
407
469
  notificationSlug?: string
470
+ capabilities?: SupportCapabilities
408
471
  }): CollectionConfig {
409
472
  const notificationSlug = options?.notificationSlug || 'admin-notifications'
410
473
 
@@ -458,10 +521,22 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
458
521
  { name: 'emailSentAt', type: 'date', label: 'Email envoye le', admin: { hidden: true } },
459
522
  { name: 'emailSentTo', type: 'text', label: 'Email envoye a', admin: { hidden: true } },
460
523
  { name: 'emailOpenedAt', type: 'date', label: 'Email ouvert le', admin: { hidden: true } },
524
+ ...(options?.capabilities?.sms ? [
525
+ { name: 'notifyBySms', type: 'checkbox' as const, defaultValue: false, admin: { hidden: true } },
526
+ { name: 'smsTo', type: 'text' as const, admin: { hidden: true } },
527
+ { name: 'smsMessage', type: 'textarea' as const, admin: { hidden: true } },
528
+ ] : []),
529
+ ...(options?.capabilities?.threadCleanup ? [
530
+ { name: 'isMarkedAsNoise', type: 'checkbox' as const, defaultValue: false, admin: { hidden: true } },
531
+ { name: 'noiseReason', type: 'text' as const, admin: { hidden: true } },
532
+ { name: 'emailContext', type: 'json' as const, admin: { hidden: true } },
533
+ { name: 'emailBodyOriginal', type: 'textarea' as const, admin: { hidden: true } },
534
+ ] : []),
461
535
  ],
462
536
  hooks: {
463
537
  beforeChange: [createSanitizeMessageHtml(), createResolveMentions(slugs), createAssignAuthor(slugs)],
464
538
  afterChange: [
539
+ ...(options?.capabilities?.sms ? [createSmsNotification(slugs, options.capabilities.sms)] : []),
465
540
  createAutoUpdateStatus(slugs),
466
541
  createNotifyClient(slugs),
467
542
  createTrackFirstResponse(slugs),
@@ -471,6 +546,7 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
471
546
  createFireMessageWebhooks(slugs),
472
547
  createDispatchWebhookOnReply(slugs),
473
548
  createNotifyMentions(slugs),
549
+ ...(options?.capabilities?.threadCleanup ? [createThreadCleanup(options.capabilities.threadCleanup)] : []),
474
550
  ],
475
551
  },
476
552
  access: {
@@ -7,6 +7,7 @@ import type {
7
7
  Where,
8
8
  } from 'payload'
9
9
  import type { CollectionSlugs } from '../utils/slugs'
10
+ import type { SupportCapabilities } from '../types'
10
11
  import { escapeHtml, emailWrapper, emailParagraph, emailButton } from '../utils/emailTemplate'
11
12
  import { fireWebhooks } from '../utils/fireWebhooks'
12
13
  import { createAdminNotification } from '../utils/adminNotification'
@@ -21,46 +22,73 @@ import { dbFind, dbCreate, dbDelete } from '../utils/db'
21
22
 
22
23
  // ─── Hooks ───────────────────────────────────────────────
23
24
 
24
- function createAssignTicketNumber(slugs: CollectionSlugs): CollectionBeforeChangeHook {
25
+ const ticketNumberQueues = new Map<string, Promise<void>>()
26
+
27
+ async function withTicketNumberLock<T>(key: string, operation: () => Promise<T>): Promise<T> {
28
+ const previous = ticketNumberQueues.get(key) ?? Promise.resolve()
29
+ let release!: () => void
30
+ const gate = new Promise<void>((resolve) => { release = resolve })
31
+ const queued = previous.then(() => gate)
32
+ ticketNumberQueues.set(key, queued)
33
+ await previous
34
+ try {
35
+ return await operation()
36
+ } finally {
37
+ release()
38
+ if (ticketNumberQueues.get(key) === queued) ticketNumberQueues.delete(key)
39
+ }
40
+ }
41
+
42
+ function createAssignTicketNumber(
43
+ slugs: CollectionSlugs,
44
+ options?: { prefix?: string; padding?: number },
45
+ ): CollectionBeforeChangeHook {
25
46
  return async ({ data, operation, req }) => {
26
47
  if (operation === 'create') {
27
- // Retry loop to handle unique constraint collisions (e.g. concurrent inserts in SQLite).
28
- let retries = 3
29
- while (retries > 0) {
30
- try {
31
- const countResult = await req.payload.count({
32
- collection: slugs.tickets,
33
- overrideAccess: true,
34
- })
35
- const baseNumber = countResult.totalDocs + 1
36
- data.ticketNumber = `TK-${String(baseNumber).padStart(4, '0')}`
37
-
38
- // Double-check: if this number already exists, append a timestamp suffix
39
- const existing = await req.payload.find({
40
- collection: slugs.tickets,
41
- where: { ticketNumber: { equals: data.ticketNumber } },
42
- limit: 1,
43
- depth: 0,
48
+ const value = await withTicketNumberLock(slugs.tickets, async () => {
49
+ const counterResult = await req.payload.find({
50
+ collection: slugs.counters as any,
51
+ where: { key: { equals: slugs.tickets } },
52
+ limit: 1,
53
+ depth: 0,
54
+ overrideAccess: true,
55
+ req,
56
+ })
57
+ const counter = counterResult.docs[0] as { id: number | string; nextValue: number } | undefined
58
+ if (counter) {
59
+ const allocated = Number(counter.nextValue)
60
+ await req.payload.update({
61
+ collection: slugs.counters as any,
62
+ id: counter.id,
63
+ data: { nextValue: allocated + 1 },
44
64
  overrideAccess: true,
65
+ req,
45
66
  })
46
- if (existing.docs.length > 0) {
47
- const suffix = Date.now() % 10000
48
- data.ticketNumber = `TK-${String(baseNumber + suffix).padStart(4, '0')}`
49
- }
50
- break
51
- } catch (error: any) {
52
- if (
53
- retries > 1 &&
54
- (error?.message?.includes('UNIQUE') ||
55
- error?.message?.includes('unique') ||
56
- error?.code === 'SQLITE_CONSTRAINT')
57
- ) {
58
- retries--
59
- continue
60
- }
61
- throw error
67
+ return allocated
62
68
  }
63
- }
69
+
70
+ const tickets = await req.payload.find({
71
+ collection: slugs.tickets as any,
72
+ limit: 0,
73
+ depth: 0,
74
+ overrideAccess: true,
75
+ select: { ticketNumber: true },
76
+ req,
77
+ })
78
+ const highest = tickets.docs.reduce((max, ticket) => {
79
+ const match = String((ticket as { ticketNumber?: string }).ticketNumber || '').match(/(\d+)$/)
80
+ return match ? Math.max(max, Number(match[1])) : max
81
+ }, 0)
82
+ const allocated = highest + 1
83
+ await req.payload.create({
84
+ collection: slugs.counters as any,
85
+ data: { key: slugs.tickets, nextValue: allocated + 1 },
86
+ overrideAccess: true,
87
+ req,
88
+ })
89
+ return allocated
90
+ })
91
+ data.ticketNumber = `${options?.prefix ?? 'TK-'}${String(value).padStart(options?.padding ?? 4, '0')}`
64
92
  }
65
93
  return data
66
94
  }
@@ -162,7 +190,10 @@ function createTrackSLA(slugs: CollectionSlugs): CollectionAfterChangeHook {
162
190
  }
163
191
  }
164
192
 
165
- function createTrackAiSummaryOnResolve(slugs: CollectionSlugs): CollectionAfterChangeHook {
193
+ function createTrackAiSummaryOnResolve(
194
+ slugs: CollectionSlugs,
195
+ generator?: SupportCapabilities['aiSummaries'],
196
+ ): CollectionAfterChangeHook {
166
197
  return async ({ doc, previousDoc, operation, req }) => {
167
198
  if (operation !== 'update' || !previousDoc) return doc
168
199
  const wasResolved = previousDoc.status === 'resolved'
@@ -188,7 +219,10 @@ function createTrackAiSummaryOnResolve(slugs: CollectionSlugs): CollectionAfterC
188
219
  if (!wasResolved && isResolved && !doc.aiSummary) {
189
220
  // setImmediate keeps the response snappy; failures are logged inside the util.
190
221
  setImmediate(() => {
191
- generateTicketSynthesis({ payload: req.payload, slugs, ticketId: doc.id })
222
+ const operation = generator
223
+ ? generator.generate(req.payload, doc.id)
224
+ : generateTicketSynthesis({ payload: req.payload, slugs, ticketId: doc.id })
225
+ operation
192
226
  .catch((err) => console.error('[support] Background ai synthesis failed:', err))
193
227
  })
194
228
  }
@@ -196,6 +230,36 @@ function createTrackAiSummaryOnResolve(slugs: CollectionSlugs): CollectionAfterC
196
230
  }
197
231
  }
198
232
 
233
+ function createTrackWaitingSince(): CollectionBeforeChangeHook {
234
+ return ({ data, originalDoc, operation }) => {
235
+ if (operation !== 'update') return data
236
+ const previous = originalDoc?.status
237
+ const next = data.status ?? previous
238
+ if (next === 'waiting_client' && previous !== 'waiting_client') {
239
+ data.waitingSince = new Date().toISOString()
240
+ data.autoCloseRemindedAt = null
241
+ } else if (next !== 'waiting_client' && previous === 'waiting_client') {
242
+ data.waitingSince = null
243
+ data.autoCloseRemindedAt = null
244
+ }
245
+ return data
246
+ }
247
+ }
248
+
249
+ function createGenerateTitleOnCreate(
250
+ generator: NonNullable<SupportCapabilities['aiTitles']>,
251
+ ): CollectionAfterChangeHook {
252
+ return ({ doc, operation, req }) => {
253
+ if (operation === 'create' && doc?.id && !doc.displayTitle) {
254
+ setImmediate(() => {
255
+ generator.generate(req.payload, doc.id)
256
+ .catch((err) => console.error('[support] Background title generation failed:', err))
257
+ })
258
+ }
259
+ return doc
260
+ }
261
+ }
262
+
199
263
  function createLogTicketActivity(slugs: CollectionSlugs): CollectionAfterChangeHook {
200
264
  return async ({ doc, previousDoc, operation, req }) => {
201
265
  if (operation !== 'update' || !previousDoc) return doc
@@ -441,11 +505,14 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
441
505
  projectCollectionSlug?: string
442
506
  documentsCollectionSlug?: string
443
507
  notificationSlug?: string
508
+ ticketNumber?: { prefix?: string; padding?: number }
509
+ capabilities?: SupportCapabilities
444
510
  }): CollectionConfig {
445
511
  const notificationSlug = options?.notificationSlug || 'admin-notifications'
446
512
 
447
513
  // Build dynamic fields
448
514
  const dynamicFields: Field[] = []
515
+ const capabilities = options?.capabilities
449
516
 
450
517
  // Conversation UI field at the top
451
518
  dynamicFields.push({
@@ -469,6 +536,29 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
469
536
  })
470
537
  }
471
538
 
539
+ if (capabilities?.aiTitles) {
540
+ dynamicFields.push(
541
+ {
542
+ name: 'displayTitle',
543
+ type: 'text',
544
+ label: "Titre d'affichage",
545
+ admin: { description: "Titre court suggéré par l'IA, éditable sans modifier le sujet email." },
546
+ },
547
+ {
548
+ name: 'displayTitleStatus',
549
+ type: 'select',
550
+ defaultValue: 'none',
551
+ options: [
552
+ { label: 'Aucun', value: 'none' },
553
+ { label: 'Génération en cours', value: 'pending' },
554
+ { label: 'Suggéré', value: 'suggested' },
555
+ { label: 'Validé', value: 'validated' },
556
+ { label: 'Échec', value: 'error' },
557
+ ],
558
+ },
559
+ )
560
+ }
561
+
472
562
  // Billing collapsible fields
473
563
  const billingFields: Field[] = [
474
564
  {
@@ -549,6 +639,41 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
549
639
  },
550
640
  ]
551
641
 
642
+ if (capabilities?.detailedBilling) {
643
+ billingFields.push(
644
+ {
645
+ name: 'billedAt',
646
+ type: 'date',
647
+ label: 'Facturé le',
648
+ },
649
+ {
650
+ name: 'billingLines',
651
+ type: 'array',
652
+ label: 'Lignes de facturation',
653
+ fields: [
654
+ { name: 'period', type: 'text', label: 'Période (AAAA-MM)' },
655
+ { name: 'label', type: 'text', label: 'Libellé' },
656
+ { name: 'amount', type: 'number', label: 'Montant (€)' },
657
+ { name: 'billingType', type: 'select', options: ['hourly', 'flat'], defaultValue: 'flat', label: 'Type' },
658
+ { name: 'billed', type: 'checkbox', defaultValue: false, label: 'Facturé' },
659
+ { name: 'billedAt', type: 'date', label: 'Facturé le' },
660
+ ],
661
+ },
662
+ )
663
+ }
664
+
665
+ if (capabilities?.volunteering) {
666
+ billingFields.push(
667
+ { name: 'volunteer', type: 'checkbox', defaultValue: false, label: 'Bénévolat (non facturé)' },
668
+ {
669
+ name: 'volunteerValue',
670
+ type: 'number',
671
+ label: 'Valeur offerte (€)',
672
+ admin: { condition: (data) => Boolean(data?.volunteer) },
673
+ },
674
+ )
675
+ }
676
+
552
677
  return {
553
678
  slug: slugs.tickets,
554
679
  labels: { singular: 'Ticket', plural: 'Tickets' },
@@ -688,6 +813,19 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
688
813
  { name: 'mergedInto', type: 'relationship', relationTo: slugs.tickets, label: 'Fusionne dans', admin: { readOnly: true } },
689
814
  { name: 'autoCloseRemindedAt', type: 'date', label: 'Rappel auto-close envoye', admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
690
815
  { name: 'autoCloseScheduledAt', type: 'date', index: true, label: 'Fermeture auto programmee', admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' }, description: 'Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client.' } },
816
+ { name: 'waitingSince', type: 'date', index: true, label: 'En attente depuis', admin: { readOnly: true } },
817
+ {
818
+ name: 'relanceDelayDays',
819
+ type: 'select',
820
+ defaultValue: '2',
821
+ label: 'Délai de relance client',
822
+ options: [
823
+ { label: '1 jour', value: '1' },
824
+ { label: '2 jours', value: '2' },
825
+ { label: '3 jours', value: '3' },
826
+ ],
827
+ },
828
+ { name: 'satisfactionRemindedAt', type: 'date', admin: { hidden: true, readOnly: true } },
691
829
  ],
692
830
  },
693
831
  // Sidebar
@@ -753,15 +891,17 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
753
891
  ],
754
892
  hooks: {
755
893
  beforeChange: [
756
- createAssignTicketNumber(slugs),
894
+ createAssignTicketNumber(slugs, options?.ticketNumber),
757
895
  createAssignClientOnCreate(slugs),
758
896
  createAutoAssignAdmin(slugs),
759
897
  autoPaidAt,
898
+ createTrackWaitingSince(),
760
899
  createRestrictClientUpdates(slugs),
761
900
  ],
762
901
  afterChange: [
763
902
  createTrackSLA(slugs),
764
- createTrackAiSummaryOnResolve(slugs),
903
+ createTrackAiSummaryOnResolve(slugs, capabilities?.aiSummaries),
904
+ ...(capabilities?.aiTitles ? [createGenerateTitleOnCreate(capabilities.aiTitles)] : []),
765
905
  createAssignSlaDeadlines(slugs, notificationSlug),
766
906
  createPauseSlaOnHold(slugs),
767
907
  createCheckSlaOnResolve(slugs, notificationSlug),
@@ -21,3 +21,5 @@ export { createNotificationQueueCollection } from './NotificationQueue'
21
21
  export { createAutomationRulesCollection } from './AutomationRules'
22
22
  export { createSupportTeamCollection } from './SupportTeam'
23
23
  export { createPushSubscriptionCollection } from './PushSubscription'
24
+ export { createSupportRateLimitsCollection } from './SupportRateLimits'
25
+ export { createSupportCountersCollection } from './SupportCounters'
@@ -14,6 +14,24 @@ interface AISummaryPanelProps {
14
14
  handleAiSave: () => void
15
15
  }
16
16
 
17
+ export function renderAiSummary(summary: string): React.ReactNode[] {
18
+ const lines = summary.split('\n')
19
+ const result: React.ReactNode[] = []
20
+
21
+ lines.forEach((line, lineIndex) => {
22
+ if (lineIndex > 0) result.push(<br key={`break-${lineIndex}`} />)
23
+ line.split(/(\*\*.*?\*\*)/g).filter(Boolean).forEach((part, partIndex) => {
24
+ if (part.startsWith('**') && part.endsWith('**')) {
25
+ result.push(<strong key={`${lineIndex}-${partIndex}`}>{part.slice(2, -2)}</strong>)
26
+ } else {
27
+ result.push(part)
28
+ }
29
+ })
30
+ })
31
+
32
+ return result
33
+ }
34
+
17
35
  export function AISummaryPanel({
18
36
  showAiSummary, setShowAiSummary, aiSummary, aiGenerating, aiSaving, aiSaved,
19
37
  handleAiGenerate, handleAiSave,
@@ -23,7 +41,7 @@ export function AISummaryPanel({
23
41
  <button
24
42
  onClick={() => { setShowAiSummary(!showAiSummary); if (!showAiSummary && !aiSummary) handleAiGenerate() }}
25
43
  style={{
26
- ...s.ghostBtn('#7c3aed', false),
44
+ ...s.ghostBtn('#17807c', false),
27
45
  fontSize: '12px',
28
46
  display: 'inline-flex',
29
47
  alignItems: 'center',
@@ -35,17 +53,17 @@ export function AISummaryPanel({
35
53
  {showAiSummary && (
36
54
  <div style={{
37
55
  marginTop: '10px', padding: '14px 18px', borderRadius: '8px',
38
- backgroundColor: '#faf5ff', border: '1px solid #e9d5ff',
56
+ backgroundColor: '#eef8f7', border: '1px solid #b8dcda',
39
57
  }}>
40
58
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
41
- <h4 style={{ fontSize: '13px', fontWeight: 600, color: '#7c3aed', margin: 0 }}>
59
+ <h4 style={{ fontSize: '13px', fontWeight: 600, color: '#17807c', margin: 0 }}>
42
60
  Synthèse IA
43
61
  </h4>
44
62
  <div style={{ display: 'flex', gap: '6px' }}>
45
63
  <button
46
64
  onClick={handleAiGenerate}
47
65
  disabled={aiGenerating}
48
- style={{ ...s.outlineBtn('#7c3aed', aiGenerating), fontSize: '11px', padding: '4px 10px' }}
66
+ style={{ ...s.outlineBtn('#17807c', aiGenerating), fontSize: '11px', padding: '4px 10px' }}
49
67
  >
50
68
  {aiGenerating ? 'Génération...' : 'Régénérer'}
51
69
  </button>
@@ -61,18 +79,13 @@ export function AISummaryPanel({
61
79
  </div>
62
80
  </div>
63
81
  {aiGenerating ? (
64
- <div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#7c3aed', fontSize: '13px' }}>
82
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#17807c', fontSize: '13px' }}>
65
83
  Analyse de la conversation en cours...
66
84
  </div>
67
85
  ) : aiSummary ? (
68
- <div
69
- style={{ fontSize: '13px', lineHeight: '1.7', color: '#1e1b4b', whiteSpace: 'pre-wrap' }}
70
- dangerouslySetInnerHTML={{
71
- __html: aiSummary
72
- .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
73
- .replace(/\n/g, '<br/>'),
74
- }}
75
- />
86
+ <div style={{ fontSize: '13px', lineHeight: '1.7', color: '#1e1b4b', whiteSpace: 'pre-wrap' }}>
87
+ {renderAiSummary(aiSummary)}
88
+ </div>
76
89
  ) : (
77
90
  <p style={{ color: '#999', fontStyle: 'italic', fontSize: '13px', margin: 0 }}>
78
91
  Cliquez sur &quot;Régénérer&quot; pour lancer l&apos;analyse