@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.
- 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 +710 -174
- package/dist/index.d.cts +165 -3
- package/dist/index.d.ts +165 -3
- package/dist/index.js +703 -176
- 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 +183 -40
- 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
|
}
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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),
|
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'
|