@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.
- package/README.md +4 -0
- package/dist/components/TicketConversation/components/AISummaryPanel.cjs +22 -14
- package/dist/components/TicketConversation/components/AISummaryPanel.js +23 -16
- package/dist/components/TicketConversation/components/ActionPanels.cjs +5 -5
- package/dist/components/TicketConversation/components/ActionPanels.js +5 -5
- package/dist/components/TicketConversation/components/ClientBar.cjs +1 -1
- package/dist/components/TicketConversation/components/ClientBar.js +1 -1
- package/dist/components/TicketConversation/components/QuickActions.cjs +2 -2
- package/dist/components/TicketConversation/components/QuickActions.js +2 -2
- package/dist/components/TicketConversation/components/TicketHeader.cjs +1 -1
- package/dist/components/TicketConversation/components/TicketHeader.js +1 -1
- package/dist/components/TicketConversation/index.cjs +10 -10
- package/dist/components/TicketConversation/index.js +10 -10
- package/dist/index.cjs +709 -173
- package/dist/index.d.cts +165 -3
- package/dist/index.d.ts +165 -3
- package/dist/index.js +702 -175
- package/dist/styles/ChatView.module.scss +1 -1
- package/dist/styles/TicketDetail.module.scss +3 -3
- package/dist/styles/_tokens.scss +1 -1
- package/dist/styles/theme.css +6 -6
- package/dist/views/TicketingSettingsView/client.cjs +3 -3
- package/dist/views/TicketingSettingsView/client.js +3 -3
- package/package.json +9 -9
- package/src/__tests__/aiSummaryRendering.test.ts +21 -0
- package/src/__tests__/authResponses.test.ts +69 -0
- package/src/__tests__/capabilities.test.ts +71 -0
- package/src/__tests__/generateTrackingToken.test.ts +18 -4
- package/src/__tests__/integration/core-behaviors.test.ts +23 -0
- package/src/__tests__/rateLimiter.test.ts +60 -66
- package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
- package/src/__tests__/webhookSecurity.test.ts +58 -0
- package/src/collections/PendingEmails.ts +2 -1
- package/src/collections/SupportClients.ts +43 -1
- package/src/collections/SupportCounters.ts +14 -0
- package/src/collections/SupportRateLimits.ts +20 -0
- package/src/collections/TicketMessages.ts +76 -0
- package/src/collections/Tickets.ts +179 -39
- package/src/collections/index.ts +2 -0
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
- package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
- package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
- package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
- package/src/components/TicketConversation/index.tsx +10 -10
- package/src/endpoints/admin-chat.ts +4 -4
- package/src/endpoints/ai-agent.ts +6 -1
- package/src/endpoints/ai.ts +10 -5
- package/src/endpoints/auth-2fa.ts +6 -7
- package/src/endpoints/auto-close.ts +2 -1
- package/src/endpoints/capabilities.ts +144 -0
- package/src/endpoints/channels.ts +2 -1
- package/src/endpoints/chat.ts +6 -6
- package/src/endpoints/chatbot.ts +4 -4
- package/src/endpoints/client-intelligence.ts +13 -4
- package/src/endpoints/import-conversation.ts +6 -5
- package/src/endpoints/index.ts +35 -14
- package/src/endpoints/invite-collaborator.ts +4 -4
- package/src/endpoints/login.ts +4 -5
- package/src/endpoints/oauth-google.ts +1 -1
- package/src/endpoints/process-digests.ts +2 -1
- package/src/endpoints/process-scheduled.ts +2 -1
- package/src/endpoints/process-snooze.ts +2 -1
- package/src/endpoints/resend-notification.ts +4 -4
- package/src/endpoints/send-reminder.ts +4 -4
- package/src/endpoints/ticket-synthesis.ts +17 -2
- package/src/endpoints/track-open.ts +44 -21
- package/src/endpoints/transfer-ticket.ts +4 -4
- package/src/index.ts +7 -0
- package/src/plugin.ts +16 -1
- package/src/portal/LiveChat.tsx +1 -1
- package/src/portal/auth/ChatWidget.tsx +4 -4
- package/src/portal/auth/ChatbotWidget.tsx +4 -4
- package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
- package/src/portal/auth/faq/FAQSearch.tsx +1 -1
- package/src/portal/auth/faq/page.tsx +2 -2
- package/src/portal/auth/profile/page.tsx +17 -17
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
- package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
- package/src/portal/auth/tickets/detail/page.tsx +5 -5
- package/src/portal/auth/tickets/new/page.tsx +8 -8
- package/src/portal/forgot-password/page.tsx +3 -3
- package/src/portal/layout.tsx +5 -2
- package/src/portal/login/page.tsx +7 -7
- package/src/portal/page.tsx +9 -9
- package/src/portal/register/page.tsx +4 -4
- package/src/portal/reset-password/page.tsx +3 -3
- package/src/styles/ChatView.module.scss +1 -1
- package/src/styles/TicketDetail.module.scss +3 -3
- package/src/styles/_tokens.scss +1 -1
- package/src/styles/theme.css +6 -6
- package/src/types.ts +71 -0
- package/src/utils/rateLimiter.ts +131 -38
- package/src/utils/slugs.ts +4 -0
- package/src/utils/webhookSecurity.ts +81 -0
- 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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
collection: slugs.
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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),
|
package/src/collections/index.ts
CHANGED
|
@@ -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('#
|
|
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: '#
|
|
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: '#
|
|
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('#
|
|
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: '#
|
|
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
|
-
|
|
70
|
-
|
|
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 "Régénérer" pour lancer l'analyse
|