@consilioweb/payload-support 0.5.1
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/LICENSE +21 -0
- package/README.md +525 -0
- package/dist/client.cjs +7 -0
- package/dist/client.d.cts +3 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.js +5 -0
- package/dist/index.cjs +7766 -0
- package/dist/index.d.cts +384 -0
- package/dist/index.d.ts +384 -0
- package/dist/index.js +7730 -0
- package/dist/views.d.cts +30 -0
- package/dist/views.d.ts +30 -0
- package/package.json +131 -0
- package/src/client.ts +1 -0
- package/src/collections/AuthLogs.ts +65 -0
- package/src/collections/CannedResponses.ts +69 -0
- package/src/collections/ChatMessages.ts +98 -0
- package/src/collections/EmailLogs.ts +94 -0
- package/src/collections/KnowledgeBase.ts +99 -0
- package/src/collections/Macros.ts +98 -0
- package/src/collections/PendingEmails.ts +122 -0
- package/src/collections/SatisfactionSurveys.ts +98 -0
- package/src/collections/SlaPolicies.ts +123 -0
- package/src/collections/SupportClients.ts +210 -0
- package/src/collections/TicketActivityLog.ts +81 -0
- package/src/collections/TicketMessages.ts +364 -0
- package/src/collections/TicketStatuses.ts +108 -0
- package/src/collections/Tickets.ts +704 -0
- package/src/collections/TimeEntries.ts +105 -0
- package/src/collections/WebhookEndpoints.ts +96 -0
- package/src/collections/index.ts +16 -0
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
- package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
- package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
- package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
- package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
- package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
- package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
- package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
- package/src/components/TicketConversation/config.ts +82 -0
- package/src/components/TicketConversation/constants.ts +74 -0
- package/src/components/TicketConversation/context.ts +63 -0
- package/src/components/TicketConversation/hooks/useAI.ts +180 -0
- package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
- package/src/components/TicketConversation/hooks/useReply.ts +190 -0
- package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
- package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
- package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
- package/src/components/TicketConversation/index.tsx +1110 -0
- package/src/components/TicketConversation/locales/en.json +878 -0
- package/src/components/TicketConversation/locales/fr.json +878 -0
- package/src/components/TicketConversation/types.ts +54 -0
- package/src/components/TicketConversation/utils.ts +25 -0
- package/src/endpoints/admin-chat-stream.ts +238 -0
- package/src/endpoints/admin-chat.ts +263 -0
- package/src/endpoints/admin-stats.ts +200 -0
- package/src/endpoints/ai.ts +199 -0
- package/src/endpoints/apply-macro.ts +144 -0
- package/src/endpoints/auth-2fa.ts +163 -0
- package/src/endpoints/auto-close.ts +175 -0
- package/src/endpoints/billing.ts +167 -0
- package/src/endpoints/bulk-action.ts +103 -0
- package/src/endpoints/chat-stream.ts +127 -0
- package/src/endpoints/chat.ts +188 -0
- package/src/endpoints/chatbot.ts +113 -0
- package/src/endpoints/delete-account.ts +129 -0
- package/src/endpoints/email-stats.ts +109 -0
- package/src/endpoints/export-csv.ts +84 -0
- package/src/endpoints/export-data.ts +104 -0
- package/src/endpoints/import-conversation.ts +307 -0
- package/src/endpoints/index.ts +154 -0
- package/src/endpoints/login.ts +92 -0
- package/src/endpoints/merge-clients.ts +132 -0
- package/src/endpoints/merge-tickets.ts +137 -0
- package/src/endpoints/oauth-google.ts +179 -0
- package/src/endpoints/pending-emails-process.ts +224 -0
- package/src/endpoints/presence.ts +104 -0
- package/src/endpoints/process-scheduled.ts +144 -0
- package/src/endpoints/purge-logs.ts +58 -0
- package/src/endpoints/resend-notification.ts +99 -0
- package/src/endpoints/round-robin-config.ts +92 -0
- package/src/endpoints/satisfaction.ts +93 -0
- package/src/endpoints/search.ts +106 -0
- package/src/endpoints/seed-kb.ts +153 -0
- package/src/endpoints/settings.ts +144 -0
- package/src/endpoints/signature.ts +93 -0
- package/src/endpoints/sla-check.ts +124 -0
- package/src/endpoints/split-ticket.ts +131 -0
- package/src/endpoints/statuses.ts +45 -0
- package/src/endpoints/track-open.ts +154 -0
- package/src/endpoints/typing.ts +101 -0
- package/src/endpoints/user-prefs.ts +125 -0
- package/src/hooks/checkSLA.ts +414 -0
- package/src/hooks/ticketStatusEmail.ts +182 -0
- package/src/index.ts +51 -0
- package/src/plugin.ts +157 -0
- package/src/portal/LiveChat.tsx +1353 -0
- package/src/portal/auth/ChatWidget.tsx +350 -0
- package/src/portal/auth/ChatbotWidget.tsx +285 -0
- package/src/portal/auth/SupportHeader.tsx +409 -0
- package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
- package/src/portal/auth/dashboard/page.tsx +84 -0
- package/src/portal/auth/faq/FAQSearch.tsx +117 -0
- package/src/portal/auth/faq/page.tsx +199 -0
- package/src/portal/auth/layout.tsx +61 -0
- package/src/portal/auth/profile/page.tsx +705 -0
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
- package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
- package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
- package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
- package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
- package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
- package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
- package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
- package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
- package/src/portal/auth/tickets/detail/page.tsx +738 -0
- package/src/portal/auth/tickets/new/page.tsx +515 -0
- package/src/portal/forgot-password/page.tsx +114 -0
- package/src/portal/layout.tsx +26 -0
- package/src/portal/locales/en.json +374 -0
- package/src/portal/locales/fr.json +374 -0
- package/src/portal/login/page.tsx +351 -0
- package/src/portal/page.tsx +162 -0
- package/src/portal/register/page.tsx +281 -0
- package/src/portal/reset-password/page.tsx +152 -0
- package/src/styles/BillingView.module.scss +311 -0
- package/src/styles/ChatView.module.scss +438 -0
- package/src/styles/CommandPalette.module.scss +160 -0
- package/src/styles/CrmView.module.scss +554 -0
- package/src/styles/EmailTracking.module.scss +238 -0
- package/src/styles/ImportConversation.module.scss +267 -0
- package/src/styles/Layout.module.scss +55 -0
- package/src/styles/Logs.module.scss +164 -0
- package/src/styles/NewTicket.module.scss +143 -0
- package/src/styles/PendingEmails.module.scss +629 -0
- package/src/styles/SupportDashboard.module.scss +649 -0
- package/src/styles/TicketDetail.module.scss +1043 -0
- package/src/styles/TicketInbox.module.scss +296 -0
- package/src/styles/TicketingSettings.module.scss +358 -0
- package/src/styles/TimeDashboard.module.scss +287 -0
- package/src/styles/_tokens.scss +78 -0
- package/src/styles/theme.css +633 -0
- package/src/types.ts +255 -0
- package/src/utils/adminNotification.ts +38 -0
- package/src/utils/auth.ts +46 -0
- package/src/utils/emailTemplate.ts +343 -0
- package/src/utils/fireWebhooks.ts +84 -0
- package/src/utils/index.ts +22 -0
- package/src/utils/rateLimiter.ts +52 -0
- package/src/utils/readSettings.ts +67 -0
- package/src/utils/slugs.ts +54 -0
- package/src/utils/webhookDispatcher.ts +120 -0
- package/src/views/BillingView/client.tsx +137 -0
- package/src/views/BillingView/index.tsx +33 -0
- package/src/views/ChatView/client.tsx +294 -0
- package/src/views/ChatView/index.tsx +33 -0
- package/src/views/CrmView/client.tsx +206 -0
- package/src/views/CrmView/index.tsx +33 -0
- package/src/views/EmailTrackingView/client.tsx +124 -0
- package/src/views/EmailTrackingView/index.tsx +33 -0
- package/src/views/ImportConversationView/client.tsx +133 -0
- package/src/views/ImportConversationView/index.tsx +33 -0
- package/src/views/LogsView/client.tsx +151 -0
- package/src/views/LogsView/index.tsx +30 -0
- package/src/views/NewTicketView/client.tsx +227 -0
- package/src/views/NewTicketView/index.tsx +30 -0
- package/src/views/PendingEmailsView/client.tsx +177 -0
- package/src/views/PendingEmailsView/index.tsx +33 -0
- package/src/views/SupportDashboardView/client.tsx +424 -0
- package/src/views/SupportDashboardView/index.tsx +33 -0
- package/src/views/TicketDetailView/client.tsx +775 -0
- package/src/views/TicketDetailView/index.tsx +33 -0
- package/src/views/TicketInboxView/client.tsx +313 -0
- package/src/views/TicketInboxView/index.tsx +30 -0
- package/src/views/TicketingSettingsView/client.tsx +866 -0
- package/src/views/TicketingSettingsView/index.tsx +33 -0
- package/src/views/TimeDashboardView/client.tsx +144 -0
- package/src/views/TimeDashboardView/index.tsx +33 -0
- package/src/views/shared/AdminViewHeader.tsx +69 -0
- package/src/views/shared/ErrorBoundary.tsx +68 -0
- package/src/views/shared/Skeleton.tsx +125 -0
- package/src/views/shared/adminTokens.ts +37 -0
- package/src/views/shared/config.ts +82 -0
- package/src/views/shared/index.ts +6 -0
- package/src/views.ts +16 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { CollectionConfig } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
|
|
4
|
+
// ─── Collection factory ──────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export function createTicketActivityLogCollection(slugs: CollectionSlugs): CollectionConfig {
|
|
7
|
+
return {
|
|
8
|
+
slug: slugs.ticketActivityLog,
|
|
9
|
+
labels: {
|
|
10
|
+
singular: 'Activité ticket',
|
|
11
|
+
plural: 'Activités tickets',
|
|
12
|
+
},
|
|
13
|
+
admin: {
|
|
14
|
+
hidden: true,
|
|
15
|
+
group: 'Gestion',
|
|
16
|
+
defaultColumns: ['ticket', 'action', 'actorEmail', 'createdAt'],
|
|
17
|
+
},
|
|
18
|
+
fields: [
|
|
19
|
+
{
|
|
20
|
+
name: 'ticket',
|
|
21
|
+
type: 'relationship',
|
|
22
|
+
relationTo: slugs.tickets,
|
|
23
|
+
required: true,
|
|
24
|
+
label: 'Ticket',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'action',
|
|
28
|
+
type: 'text',
|
|
29
|
+
required: true,
|
|
30
|
+
label: 'Action',
|
|
31
|
+
admin: {
|
|
32
|
+
description: 'Ex: status_changed, priority_changed, assigned, merged',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'detail',
|
|
37
|
+
type: 'text',
|
|
38
|
+
label: 'Détail',
|
|
39
|
+
admin: {
|
|
40
|
+
description: 'Ex: "status: open → in_progress"',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: 'row',
|
|
45
|
+
fields: [
|
|
46
|
+
{
|
|
47
|
+
name: 'actorType',
|
|
48
|
+
type: 'select',
|
|
49
|
+
label: 'Type acteur',
|
|
50
|
+
options: [
|
|
51
|
+
{ label: 'Admin', value: 'admin' },
|
|
52
|
+
{ label: 'Client', value: 'client' },
|
|
53
|
+
{ label: 'Système', value: 'system' },
|
|
54
|
+
],
|
|
55
|
+
admin: { width: '50%' },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'actorEmail',
|
|
59
|
+
type: 'text',
|
|
60
|
+
label: 'Email acteur',
|
|
61
|
+
admin: { width: '50%' },
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
access: {
|
|
67
|
+
create: () => false, // Created only by hooks via overrideAccess
|
|
68
|
+
read: ({ req }) => {
|
|
69
|
+
if (req.user?.collection === slugs.users) return true
|
|
70
|
+
// Clients can read activity logs for their own tickets
|
|
71
|
+
if (req.user?.collection === slugs.supportClients) {
|
|
72
|
+
return { 'ticket.client': { equals: req.user.id } }
|
|
73
|
+
}
|
|
74
|
+
return false
|
|
75
|
+
},
|
|
76
|
+
update: () => false,
|
|
77
|
+
delete: ({ req }) => req.user?.collection === slugs.users,
|
|
78
|
+
},
|
|
79
|
+
timestamps: true,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import type { CollectionConfig, CollectionBeforeChangeHook, CollectionAfterChangeHook, Where } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { escapeHtml, emailWrapper, emailButton, emailQuote, emailParagraph, emailRichContent, emailTrackingPixel } from '../utils/emailTemplate'
|
|
4
|
+
import { fireWebhooks } from '../utils/fireWebhooks'
|
|
5
|
+
import { createAdminNotification } from '../utils/adminNotification'
|
|
6
|
+
import { dispatchWebhook } from '../utils/webhookDispatcher'
|
|
7
|
+
import { readSupportSettings } from '../utils/readSettings'
|
|
8
|
+
import { createCheckSlaOnReply } from '../hooks/checkSLA'
|
|
9
|
+
|
|
10
|
+
function createAssignAuthor(slugs: CollectionSlugs): CollectionBeforeChangeHook {
|
|
11
|
+
return async ({ data, operation, req }) => {
|
|
12
|
+
if (operation === 'create' && req.user?.collection === slugs.supportClients) {
|
|
13
|
+
data.authorType = 'client'
|
|
14
|
+
data.authorClient = req.user.id
|
|
15
|
+
data.isInternal = false
|
|
16
|
+
}
|
|
17
|
+
return data
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createAutoUpdateStatus(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
22
|
+
return async ({ doc, operation, req }) => {
|
|
23
|
+
if (operation !== 'create') return doc
|
|
24
|
+
if (doc.scheduledAt && !doc.scheduledSent) return doc
|
|
25
|
+
try {
|
|
26
|
+
const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
|
|
27
|
+
const ticket = await req.payload.findByID({ collection: slugs.tickets, id: ticketId, depth: 0, overrideAccess: true })
|
|
28
|
+
if (!ticket) return doc
|
|
29
|
+
const updateData: Record<string, unknown> = {}
|
|
30
|
+
if (!doc.isInternal) {
|
|
31
|
+
if (doc.authorType === 'admin') {
|
|
32
|
+
updateData.status = 'waiting_client'
|
|
33
|
+
} else if (doc.authorType === 'client' || doc.authorType === 'email') {
|
|
34
|
+
updateData.lastClientMessageAt = new Date().toISOString()
|
|
35
|
+
if (ticket.status && ['waiting_client', 'resolved'].includes(ticket.status as string)) {
|
|
36
|
+
updateData.status = 'open'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await req.payload.update({ collection: slugs.tickets, id: ticketId, data: updateData, overrideAccess: true })
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error('[support] Failed to auto-update ticket status:', err)
|
|
43
|
+
}
|
|
44
|
+
return doc
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createNotifyClient(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
49
|
+
return async ({ doc, operation, req }) => {
|
|
50
|
+
if (operation !== 'create') return doc
|
|
51
|
+
if (doc.authorType !== 'admin' || doc.isInternal || doc.skipNotification) return doc
|
|
52
|
+
if (doc.scheduledAt && !doc.scheduledSent) return doc
|
|
53
|
+
try {
|
|
54
|
+
const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
|
|
55
|
+
const ticket = await req.payload.findByID({ collection: slugs.tickets, id: ticketId, depth: 1, overrideAccess: true })
|
|
56
|
+
if (!ticket) return doc
|
|
57
|
+
const client = typeof ticket.client === 'object' ? ticket.client : null
|
|
58
|
+
if (!client?.email) return doc
|
|
59
|
+
|
|
60
|
+
// Respect client notification preferences
|
|
61
|
+
if (client.notifyOnReply === false) return doc
|
|
62
|
+
|
|
63
|
+
const settings = await readSupportSettings(req.payload)
|
|
64
|
+
const ticketNumber = (ticket.ticketNumber as string) || 'TK-????'
|
|
65
|
+
const subject = (ticket.subject as string) || 'Support'
|
|
66
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
67
|
+
const supportEmail = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || ''
|
|
68
|
+
const portalUrl = `${baseUrl}/support/tickets/${ticketId}`
|
|
69
|
+
|
|
70
|
+
// Use rich HTML content if available, otherwise plain text preview
|
|
71
|
+
const rawContent = doc.bodyHtml
|
|
72
|
+
? emailRichContent(doc.bodyHtml)
|
|
73
|
+
: emailQuote(doc.body?.length > 500 ? doc.body.slice(0, 500) + '...' : doc.body)
|
|
74
|
+
|
|
75
|
+
await req.payload.sendEmail({
|
|
76
|
+
to: client.email,
|
|
77
|
+
...(supportEmail ? { replyTo: supportEmail } : {}),
|
|
78
|
+
subject: `Re: [${ticketNumber}] ${subject}`,
|
|
79
|
+
html: emailWrapper(`Nouvelle reponse — ${ticketNumber}`, [
|
|
80
|
+
emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,`),
|
|
81
|
+
emailParagraph(`Notre equipe a apporte une reponse a votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em>.`),
|
|
82
|
+
rawContent,
|
|
83
|
+
emailButton('Consulter le ticket', portalUrl),
|
|
84
|
+
emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
|
|
85
|
+
emailTrackingPixel(ticketId, doc.id),
|
|
86
|
+
].join('')),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
await req.payload.update({
|
|
90
|
+
collection: slugs.ticketMessages,
|
|
91
|
+
id: doc.id,
|
|
92
|
+
data: { emailSentAt: new Date().toISOString(), emailSentTo: client.email },
|
|
93
|
+
overrideAccess: true,
|
|
94
|
+
})
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error('[support] Failed to notify client:', err)
|
|
97
|
+
}
|
|
98
|
+
return doc
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createTrackFirstResponse(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
103
|
+
return async ({ doc, operation, req }) => {
|
|
104
|
+
if (operation !== 'create' || doc.authorType !== 'admin' || doc.isInternal) return doc
|
|
105
|
+
try {
|
|
106
|
+
const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
|
|
107
|
+
const ticket = await req.payload.findByID({ collection: slugs.tickets, id: ticketId, depth: 0, overrideAccess: true })
|
|
108
|
+
if (ticket && !ticket.firstResponseAt) {
|
|
109
|
+
await req.payload.update({ collection: slugs.tickets, id: ticketId, data: { firstResponseAt: new Date().toISOString() }, overrideAccess: true })
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error('[support] Failed to track first response:', err)
|
|
113
|
+
}
|
|
114
|
+
return doc
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createSyncTicketReplyToChat(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
119
|
+
return async ({ doc, operation, req }) => {
|
|
120
|
+
if (operation !== 'create') return doc
|
|
121
|
+
if (doc.authorType !== 'admin' || doc.isInternal) return doc
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const { payload } = req
|
|
125
|
+
const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
|
|
126
|
+
|
|
127
|
+
const ticket = await payload.findByID({
|
|
128
|
+
collection: slugs.tickets,
|
|
129
|
+
id: ticketId,
|
|
130
|
+
depth: 0,
|
|
131
|
+
overrideAccess: true,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
if (!ticket?.chatSession) return doc
|
|
135
|
+
|
|
136
|
+
// Check if this message was already synced from admin-chat (skipNotification = true means it came from chat)
|
|
137
|
+
if (doc.skipNotification) return doc
|
|
138
|
+
|
|
139
|
+
const clientId = typeof ticket.client === 'object' ? (ticket.client as { id: number | string }).id : ticket.client
|
|
140
|
+
|
|
141
|
+
// Create a chat message so the client sees it in the widget
|
|
142
|
+
await payload.create({
|
|
143
|
+
collection: slugs.chatMessages as any,
|
|
144
|
+
data: {
|
|
145
|
+
session: ticket.chatSession,
|
|
146
|
+
client: clientId,
|
|
147
|
+
senderType: 'agent',
|
|
148
|
+
message: doc.body,
|
|
149
|
+
status: 'active',
|
|
150
|
+
ticket: ticketId,
|
|
151
|
+
},
|
|
152
|
+
overrideAccess: true,
|
|
153
|
+
})
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error('[support] Failed to sync reply to chat:', err)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return doc
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function createNotifyAdminOnClientMessage(slugs: CollectionSlugs, notificationSlug: string): CollectionAfterChangeHook {
|
|
163
|
+
return async ({ doc, operation, req }) => {
|
|
164
|
+
if (operation !== 'create') return doc
|
|
165
|
+
if (doc.authorType !== 'client' && doc.authorType !== 'email') return doc
|
|
166
|
+
if (doc.skipNotification) return doc
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const { payload } = req
|
|
170
|
+
const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
|
|
171
|
+
|
|
172
|
+
const ticket = await payload.findByID({
|
|
173
|
+
collection: slugs.tickets,
|
|
174
|
+
id: ticketId,
|
|
175
|
+
depth: 1,
|
|
176
|
+
overrideAccess: true,
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
if (!ticket) return doc
|
|
180
|
+
|
|
181
|
+
const client = typeof ticket.client === 'object' ? ticket.client : null
|
|
182
|
+
const settings = await readSupportSettings(payload)
|
|
183
|
+
const clientName = client?.firstName || 'Client'
|
|
184
|
+
const clientEmail = client?.email || 'inconnu'
|
|
185
|
+
const ticketNumber = ticket.ticketNumber || 'TK-????'
|
|
186
|
+
const subject = ticket.subject || 'Support'
|
|
187
|
+
const supportEmail = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || ''
|
|
188
|
+
const contactEmail = process.env.CONTACT_EMAIL || supportEmail
|
|
189
|
+
const assignedAdmin = typeof ticket.assignedTo === 'object' ? ticket.assignedTo : null
|
|
190
|
+
const assignedEmail = assignedAdmin?.email
|
|
191
|
+
const primaryEmail = contactEmail
|
|
192
|
+
const ccEmail = assignedEmail && assignedEmail !== contactEmail ? assignedEmail : undefined
|
|
193
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
194
|
+
const adminUrl = `${baseUrl}/admin/collections/${slugs.tickets}/${ticketId}`
|
|
195
|
+
|
|
196
|
+
// Check if this is the first message (new ticket) or a follow-up
|
|
197
|
+
const messageCount = await payload.count({
|
|
198
|
+
collection: slugs.ticketMessages,
|
|
199
|
+
where: { ticket: { equals: ticketId } },
|
|
200
|
+
overrideAccess: true,
|
|
201
|
+
})
|
|
202
|
+
const isNewTicket = messageCount.totalDocs <= 1
|
|
203
|
+
|
|
204
|
+
// Create admin notification for client replies (new tickets already handled by notifyAdminOnNewTicket)
|
|
205
|
+
if (!isNewTicket) {
|
|
206
|
+
await createAdminNotification(payload, {
|
|
207
|
+
title: `Reponse client — ${ticketNumber}`,
|
|
208
|
+
message: `${clientName} a repondu au ticket ${ticketNumber}`,
|
|
209
|
+
type: 'client_message',
|
|
210
|
+
link: `/admin/collections/${slugs.tickets}/${ticketId}`,
|
|
211
|
+
}, notificationSlug)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const preview = doc.body?.length > 500 ? doc.body.slice(0, 500) + '...' : doc.body
|
|
215
|
+
const headerTitle = isNewTicket ? `Nouveau ticket ${ticketNumber}` : `Nouveau message — ${ticketNumber}`
|
|
216
|
+
|
|
217
|
+
if (primaryEmail) {
|
|
218
|
+
await payload.sendEmail({
|
|
219
|
+
to: primaryEmail,
|
|
220
|
+
...(ccEmail ? { cc: ccEmail } : {}),
|
|
221
|
+
...(clientEmail !== 'inconnu' ? { replyTo: clientEmail } : (supportEmail ? { replyTo: supportEmail } : {})),
|
|
222
|
+
subject: `${isNewTicket ? 'Nouveau ticket' : 'Reponse client'} [${ticketNumber}] ${subject}`,
|
|
223
|
+
html: emailWrapper(headerTitle, [
|
|
224
|
+
emailParagraph(`<strong>${escapeHtml(clientName)}</strong> (${escapeHtml(clientEmail)}) a ${isNewTicket ? 'ouvert un nouveau ticket' : 'repondu au ticket'} <strong>${escapeHtml(ticketNumber)}</strong> :`),
|
|
225
|
+
`<p style="margin: 0 0 4px 0; font-size: 14px; font-weight: 600; color: #374151;">Sujet : ${escapeHtml(subject)}</p>`,
|
|
226
|
+
emailQuote(preview, isNewTicket ? '#FFD600' : '#00E5FF'),
|
|
227
|
+
emailButton('Ouvrir dans l\'admin', adminUrl, 'dark'),
|
|
228
|
+
].join(''), { headerColor: isNewTicket ? 'secondary' : 'primary' }),
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
console.log(`[support] Admin notified for ${ticketNumber} (${isNewTicket ? 'new' : 'reply'})`)
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.error('[support] Failed to notify admin on client message:', err)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return doc
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function createFireMessageWebhooks(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
242
|
+
return async ({ doc, operation, req }) => {
|
|
243
|
+
if (operation !== 'create') return doc
|
|
244
|
+
// Don't fire for scheduled messages that haven't been sent yet
|
|
245
|
+
if (doc.scheduledAt && !doc.scheduledSent) return doc
|
|
246
|
+
// Don't fire for internal notes
|
|
247
|
+
if (doc.isInternal) return doc
|
|
248
|
+
|
|
249
|
+
const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
|
|
250
|
+
fireWebhooks(req.payload, slugs, 'ticket_replied', {
|
|
251
|
+
ticketId,
|
|
252
|
+
messageId: doc.id,
|
|
253
|
+
authorType: doc.authorType,
|
|
254
|
+
body: doc.body?.length > 500 ? doc.body.slice(0, 500) + '...' : doc.body,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
return doc
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function createDispatchWebhookOnReply(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
262
|
+
return async ({ doc, operation, req }) => {
|
|
263
|
+
if (operation !== 'create') return doc
|
|
264
|
+
if (doc.isInternal) return doc
|
|
265
|
+
if (doc.scheduledAt && !doc.scheduledSent) return doc
|
|
266
|
+
|
|
267
|
+
const ticketId = typeof doc.ticket === 'object' ? doc.ticket.id : doc.ticket
|
|
268
|
+
dispatchWebhook(
|
|
269
|
+
{ ticketId, messageId: doc.id, authorType: doc.authorType },
|
|
270
|
+
'ticket_replied',
|
|
271
|
+
req.payload,
|
|
272
|
+
slugs,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return doc
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function createTicketMessagesCollection(slugs: CollectionSlugs, options?: {
|
|
280
|
+
notificationSlug?: string
|
|
281
|
+
}): CollectionConfig {
|
|
282
|
+
const notificationSlug = options?.notificationSlug || 'admin-notifications'
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
slug: slugs.ticketMessages,
|
|
286
|
+
labels: { singular: 'Message', plural: 'Messages' },
|
|
287
|
+
admin: { hidden: true, group: 'Support', defaultColumns: ['ticket', 'authorType', 'createdAt'] },
|
|
288
|
+
fields: [
|
|
289
|
+
{ name: 'ticket', type: 'relationship', relationTo: slugs.tickets, required: true, label: 'Ticket' },
|
|
290
|
+
{ name: 'body', type: 'textarea', required: true, label: 'Message' },
|
|
291
|
+
{ name: 'bodyHtml', type: 'textarea', label: 'Message HTML', admin: { hidden: true } },
|
|
292
|
+
{
|
|
293
|
+
type: 'row',
|
|
294
|
+
fields: [
|
|
295
|
+
{
|
|
296
|
+
name: 'authorType', type: 'select', label: 'Type d\'auteur', defaultValue: 'admin',
|
|
297
|
+
options: [
|
|
298
|
+
{ label: 'Client', value: 'client' },
|
|
299
|
+
{ label: 'Support', value: 'admin' },
|
|
300
|
+
{ label: 'Email entrant', value: 'email' },
|
|
301
|
+
],
|
|
302
|
+
admin: { width: '50%' },
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: 'authorClient', type: 'relationship', relationTo: slugs.supportClients, label: 'Auteur (client)',
|
|
306
|
+
admin: { width: '50%', condition: (data) => data?.authorType === 'client' || data?.authorType === 'email' },
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
name: 'attachments', type: 'array', label: 'Pieces jointes',
|
|
312
|
+
fields: [{ name: 'file', type: 'upload', relationTo: slugs.media, required: true, label: 'Fichier' }],
|
|
313
|
+
},
|
|
314
|
+
{ name: 'isInternal', type: 'checkbox', defaultValue: false, label: 'Note interne', admin: { position: 'sidebar' } },
|
|
315
|
+
{ name: 'isSolution', type: 'checkbox', defaultValue: false, label: 'Reponse solution', admin: { position: 'sidebar' } },
|
|
316
|
+
{ name: 'skipNotification', type: 'checkbox', defaultValue: false, label: 'Sans notification', admin: { position: 'sidebar', condition: (data) => data?.skipNotification === true } },
|
|
317
|
+
{ name: 'scheduledAt', type: 'date', label: 'Programme pour', admin: { date: { pickerAppearance: 'dayAndTime' }, position: 'sidebar', condition: (data) => !!data?.scheduledAt } },
|
|
318
|
+
{ name: 'scheduledSent', type: 'checkbox', defaultValue: false, admin: { hidden: true } },
|
|
319
|
+
{ name: 'editedAt', type: 'date', label: 'Modifie le', admin: { hidden: true } },
|
|
320
|
+
{ name: 'deletedAt', type: 'date', label: 'Supprime le', admin: { hidden: true } },
|
|
321
|
+
{ name: 'emailSentAt', type: 'date', label: 'Email envoye le', admin: { hidden: true } },
|
|
322
|
+
{ name: 'emailSentTo', type: 'text', label: 'Email envoye a', admin: { hidden: true } },
|
|
323
|
+
{ name: 'emailOpenedAt', type: 'date', label: 'Email ouvert le', admin: { hidden: true } },
|
|
324
|
+
],
|
|
325
|
+
hooks: {
|
|
326
|
+
beforeChange: [createAssignAuthor(slugs)],
|
|
327
|
+
afterChange: [
|
|
328
|
+
createAutoUpdateStatus(slugs),
|
|
329
|
+
createNotifyClient(slugs),
|
|
330
|
+
createTrackFirstResponse(slugs),
|
|
331
|
+
createCheckSlaOnReply(slugs, notificationSlug),
|
|
332
|
+
createSyncTicketReplyToChat(slugs),
|
|
333
|
+
createNotifyAdminOnClientMessage(slugs, notificationSlug),
|
|
334
|
+
createFireMessageWebhooks(slugs),
|
|
335
|
+
createDispatchWebhookOnReply(slugs),
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
access: {
|
|
339
|
+
create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
|
|
340
|
+
read: ({ req }) => {
|
|
341
|
+
if (req.user?.collection === slugs.users) return true
|
|
342
|
+
if (req.user?.collection === slugs.supportClients) {
|
|
343
|
+
return {
|
|
344
|
+
and: [
|
|
345
|
+
{ 'ticket.client': { equals: req.user.id } } as Where,
|
|
346
|
+
{ isInternal: { equals: false } } as Where,
|
|
347
|
+
{ or: [{ scheduledAt: { exists: false } } as Where, { scheduledSent: { equals: true } } as Where] } as Where,
|
|
348
|
+
],
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return false
|
|
352
|
+
},
|
|
353
|
+
update: ({ req }) => {
|
|
354
|
+
if (req.user?.collection === slugs.users) return true
|
|
355
|
+
if (req.user?.collection === slugs.supportClients) {
|
|
356
|
+
return { and: [{ authorClient: { equals: req.user.id } } as Where, { authorType: { equals: 'client' } } as Where] }
|
|
357
|
+
}
|
|
358
|
+
return false
|
|
359
|
+
},
|
|
360
|
+
delete: ({ req }) => req.user?.collection === slugs.users,
|
|
361
|
+
},
|
|
362
|
+
timestamps: true,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { CollectionConfig } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
|
|
4
|
+
// ─── Collection factory ──────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export function createTicketStatusesCollection(slugs: CollectionSlugs): CollectionConfig {
|
|
7
|
+
return {
|
|
8
|
+
slug: slugs.ticketStatuses,
|
|
9
|
+
labels: {
|
|
10
|
+
singular: 'Statut de ticket',
|
|
11
|
+
plural: 'Statuts de ticket',
|
|
12
|
+
},
|
|
13
|
+
admin: {
|
|
14
|
+
useAsTitle: 'name',
|
|
15
|
+
group: 'Support',
|
|
16
|
+
defaultColumns: ['name', 'slug', 'type', 'color', 'isDefault', 'sortOrder'],
|
|
17
|
+
},
|
|
18
|
+
fields: [
|
|
19
|
+
{
|
|
20
|
+
type: 'row',
|
|
21
|
+
fields: [
|
|
22
|
+
{
|
|
23
|
+
name: 'name',
|
|
24
|
+
type: 'text',
|
|
25
|
+
required: true,
|
|
26
|
+
label: 'Nom',
|
|
27
|
+
admin: {
|
|
28
|
+
width: '50%',
|
|
29
|
+
description: 'Libellé affiché (ex: "Ouvert", "En attente client")',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'slug',
|
|
34
|
+
type: 'text',
|
|
35
|
+
required: true,
|
|
36
|
+
unique: true,
|
|
37
|
+
label: 'Slug',
|
|
38
|
+
admin: {
|
|
39
|
+
width: '50%',
|
|
40
|
+
description: 'Identifiant technique unique (ex: "open", "waiting_client")',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'row',
|
|
47
|
+
fields: [
|
|
48
|
+
{
|
|
49
|
+
name: 'color',
|
|
50
|
+
type: 'text',
|
|
51
|
+
required: true,
|
|
52
|
+
label: 'Couleur',
|
|
53
|
+
admin: {
|
|
54
|
+
width: '50%',
|
|
55
|
+
description: 'Couleur hexadécimale (ex: #22c55e)',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'type',
|
|
60
|
+
type: 'select',
|
|
61
|
+
required: true,
|
|
62
|
+
label: 'Type sémantique',
|
|
63
|
+
options: [
|
|
64
|
+
{ label: 'Ouvert', value: 'open' },
|
|
65
|
+
{ label: 'En attente', value: 'pending' },
|
|
66
|
+
{ label: 'Fermé', value: 'closed' },
|
|
67
|
+
],
|
|
68
|
+
admin: {
|
|
69
|
+
width: '50%',
|
|
70
|
+
description: 'Type logique pour la logique SLA et auto-close',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'isDefault',
|
|
77
|
+
type: 'checkbox',
|
|
78
|
+
defaultValue: false,
|
|
79
|
+
label: 'Statut par défaut',
|
|
80
|
+
admin: {
|
|
81
|
+
position: 'sidebar',
|
|
82
|
+
description: 'Statut assigné automatiquement aux nouveaux tickets',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'sortOrder',
|
|
87
|
+
type: 'number',
|
|
88
|
+
defaultValue: 0,
|
|
89
|
+
label: 'Ordre',
|
|
90
|
+
admin: {
|
|
91
|
+
position: 'sidebar',
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
access: {
|
|
96
|
+
create: ({ req }) => req.user?.collection === slugs.users,
|
|
97
|
+
read: ({ req }) => {
|
|
98
|
+
// Authenticated users (admin or support-clients) can read statuses
|
|
99
|
+
if (req.user?.collection === slugs.users) return true
|
|
100
|
+
if (req.user?.collection === slugs.supportClients) return true
|
|
101
|
+
return false
|
|
102
|
+
},
|
|
103
|
+
update: ({ req }) => req.user?.collection === slugs.users,
|
|
104
|
+
delete: ({ req }) => req.user?.collection === slugs.users,
|
|
105
|
+
},
|
|
106
|
+
timestamps: true,
|
|
107
|
+
}
|
|
108
|
+
}
|