@consilioweb/payload-support 1.1.0 → 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 +710 -174
  15. package/dist/index.d.cts +165 -3
  16. package/dist/index.d.ts +165 -3
  17. package/dist/index.js +703 -176
  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 +183 -40
  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
  }
@@ -87,7 +115,10 @@ function createRestrictClientUpdates(slugs: CollectionSlugs): CollectionBeforeCh
87
115
  return async ({ data, operation, req, originalDoc }) => {
88
116
  if (operation !== 'update') return data
89
117
  if (req.user?.collection !== slugs.supportClients) return data
90
- const allowedStatuses = ['open', 'resolved']
118
+ // Clients may move a ticket to: open / waiting_support (reopen) or resolved (close).
119
+ // 'waiting_support' is required so the reopen button actually puts the ball back
120
+ // in the support team's court instead of being silently rejected.
121
+ const allowedStatuses = ['open', 'waiting_support', 'resolved']
91
122
  const newData: Record<string, unknown> = {}
92
123
  if (data.status && allowedStatuses.includes(data.status as string)) {
93
124
  newData.status = data.status
@@ -159,7 +190,10 @@ function createTrackSLA(slugs: CollectionSlugs): CollectionAfterChangeHook {
159
190
  }
160
191
  }
161
192
 
162
- function createTrackAiSummaryOnResolve(slugs: CollectionSlugs): CollectionAfterChangeHook {
193
+ function createTrackAiSummaryOnResolve(
194
+ slugs: CollectionSlugs,
195
+ generator?: SupportCapabilities['aiSummaries'],
196
+ ): CollectionAfterChangeHook {
163
197
  return async ({ doc, previousDoc, operation, req }) => {
164
198
  if (operation !== 'update' || !previousDoc) return doc
165
199
  const wasResolved = previousDoc.status === 'resolved'
@@ -185,7 +219,10 @@ function createTrackAiSummaryOnResolve(slugs: CollectionSlugs): CollectionAfterC
185
219
  if (!wasResolved && isResolved && !doc.aiSummary) {
186
220
  // setImmediate keeps the response snappy; failures are logged inside the util.
187
221
  setImmediate(() => {
188
- 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
189
226
  .catch((err) => console.error('[support] Background ai synthesis failed:', err))
190
227
  })
191
228
  }
@@ -193,6 +230,36 @@ function createTrackAiSummaryOnResolve(slugs: CollectionSlugs): CollectionAfterC
193
230
  }
194
231
  }
195
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
+
196
263
  function createLogTicketActivity(slugs: CollectionSlugs): CollectionAfterChangeHook {
197
264
  return async ({ doc, previousDoc, operation, req }) => {
198
265
  if (operation !== 'update' || !previousDoc) return doc
@@ -438,11 +505,14 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
438
505
  projectCollectionSlug?: string
439
506
  documentsCollectionSlug?: string
440
507
  notificationSlug?: string
508
+ ticketNumber?: { prefix?: string; padding?: number }
509
+ capabilities?: SupportCapabilities
441
510
  }): CollectionConfig {
442
511
  const notificationSlug = options?.notificationSlug || 'admin-notifications'
443
512
 
444
513
  // Build dynamic fields
445
514
  const dynamicFields: Field[] = []
515
+ const capabilities = options?.capabilities
446
516
 
447
517
  // Conversation UI field at the top
448
518
  dynamicFields.push({
@@ -466,6 +536,29 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
466
536
  })
467
537
  }
468
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
+
469
562
  // Billing collapsible fields
470
563
  const billingFields: Field[] = [
471
564
  {
@@ -546,6 +639,41 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
546
639
  },
547
640
  ]
548
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
+
549
677
  return {
550
678
  slug: slugs.tickets,
551
679
  labels: { singular: 'Ticket', plural: 'Tickets' },
@@ -685,6 +813,19 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
685
813
  { name: 'mergedInto', type: 'relationship', relationTo: slugs.tickets, label: 'Fusionne dans', admin: { readOnly: true } },
686
814
  { name: 'autoCloseRemindedAt', type: 'date', label: 'Rappel auto-close envoye', admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
687
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 } },
688
829
  ],
689
830
  },
690
831
  // Sidebar
@@ -750,15 +891,17 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
750
891
  ],
751
892
  hooks: {
752
893
  beforeChange: [
753
- createAssignTicketNumber(slugs),
894
+ createAssignTicketNumber(slugs, options?.ticketNumber),
754
895
  createAssignClientOnCreate(slugs),
755
896
  createAutoAssignAdmin(slugs),
756
897
  autoPaidAt,
898
+ createTrackWaitingSince(),
757
899
  createRestrictClientUpdates(slugs),
758
900
  ],
759
901
  afterChange: [
760
902
  createTrackSLA(slugs),
761
- createTrackAiSummaryOnResolve(slugs),
903
+ createTrackAiSummaryOnResolve(slugs, capabilities?.aiSummaries),
904
+ ...(capabilities?.aiTitles ? [createGenerateTitleOnCreate(capabilities.aiTitles)] : []),
762
905
  createAssignSlaDeadlines(slugs, notificationSlug),
763
906
  createPauseSlaOnHold(slugs),
764
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'