@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,414 @@
|
|
|
1
|
+
import type { CollectionAfterChangeHook, Payload } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { createAdminNotification } from '../utils/adminNotification'
|
|
4
|
+
import { emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
|
|
5
|
+
import { readSupportSettings } from '../utils/readSettings'
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
type AnyDoc = any
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Calculate a business-hours deadline from a start date.
|
|
12
|
+
* Business hours: Mon-Fri, 9:00-18:00 (Europe/Paris).
|
|
13
|
+
* @param start - start date
|
|
14
|
+
* @param minutes - number of business-hour minutes to add
|
|
15
|
+
* @returns deadline date
|
|
16
|
+
*/
|
|
17
|
+
export function calculateBusinessHoursDeadline(start: Date, minutes: number): Date {
|
|
18
|
+
const BUSINESS_START = 9
|
|
19
|
+
const BUSINESS_END = 18
|
|
20
|
+
|
|
21
|
+
let remaining = minutes
|
|
22
|
+
const current = new Date(start)
|
|
23
|
+
|
|
24
|
+
// If start is outside business hours, move to next business period
|
|
25
|
+
current.setMilliseconds(0)
|
|
26
|
+
current.setSeconds(0)
|
|
27
|
+
moveToBusinessHours(current, BUSINESS_START, BUSINESS_END)
|
|
28
|
+
|
|
29
|
+
while (remaining > 0) {
|
|
30
|
+
const day = current.getDay() // 0=Sun, 6=Sat
|
|
31
|
+
|
|
32
|
+
// Skip weekends
|
|
33
|
+
if (day === 0) {
|
|
34
|
+
current.setDate(current.getDate() + 1)
|
|
35
|
+
current.setHours(BUSINESS_START, 0, 0, 0)
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (day === 6) {
|
|
39
|
+
current.setDate(current.getDate() + 2)
|
|
40
|
+
current.setHours(BUSINESS_START, 0, 0, 0)
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Calculate remaining minutes in the current business day
|
|
45
|
+
const currentMinuteOfDay = current.getHours() * 60 + current.getMinutes()
|
|
46
|
+
const endOfDayMinute = BUSINESS_END * 60
|
|
47
|
+
const minutesLeftToday = Math.max(0, endOfDayMinute - currentMinuteOfDay)
|
|
48
|
+
|
|
49
|
+
if (remaining <= minutesLeftToday) {
|
|
50
|
+
// Deadline falls within today
|
|
51
|
+
current.setMinutes(current.getMinutes() + remaining)
|
|
52
|
+
remaining = 0
|
|
53
|
+
} else {
|
|
54
|
+
// Consume the rest of today and move to next business day
|
|
55
|
+
remaining -= minutesLeftToday
|
|
56
|
+
current.setDate(current.getDate() + 1)
|
|
57
|
+
current.setHours(BUSINESS_START, 0, 0, 0)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return current
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Move a date forward to the next business-hours period if currently outside.
|
|
66
|
+
*/
|
|
67
|
+
function moveToBusinessHours(date: Date, startHour: number, endHour: number): void {
|
|
68
|
+
const day = date.getDay()
|
|
69
|
+
const hour = date.getHours()
|
|
70
|
+
|
|
71
|
+
// Weekend -> next Monday
|
|
72
|
+
if (day === 0) {
|
|
73
|
+
date.setDate(date.getDate() + 1)
|
|
74
|
+
date.setHours(startHour, 0, 0, 0)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (day === 6) {
|
|
78
|
+
date.setDate(date.getDate() + 2)
|
|
79
|
+
date.setHours(startHour, 0, 0, 0)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Before business hours -> move to start
|
|
84
|
+
if (hour < startHour) {
|
|
85
|
+
date.setHours(startHour, 0, 0, 0)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// After business hours -> next business day
|
|
90
|
+
if (hour >= endHour) {
|
|
91
|
+
date.setDate(date.getDate() + 1)
|
|
92
|
+
date.setHours(startHour, 0, 0, 0)
|
|
93
|
+
// Skip weekend if needed
|
|
94
|
+
const newDay = date.getDay()
|
|
95
|
+
if (newDay === 6) {
|
|
96
|
+
date.setDate(date.getDate() + 2)
|
|
97
|
+
} else if (newDay === 0) {
|
|
98
|
+
date.setDate(date.getDate() + 1)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Calculate a simple calendar-time deadline.
|
|
105
|
+
*/
|
|
106
|
+
function calculateCalendarDeadline(start: Date, minutes: number): Date {
|
|
107
|
+
return new Date(start.getTime() + minutes * 60 * 1000)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Safely read a field from a doc that may be a Payload typed object.
|
|
112
|
+
*/
|
|
113
|
+
function field(doc: AnyDoc, key: string): unknown {
|
|
114
|
+
return doc?.[key] ?? undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Extract numeric ID from a value that can be a number, object with id, or null.
|
|
119
|
+
*/
|
|
120
|
+
function resolveId(value: unknown): number | string | null {
|
|
121
|
+
if (value === null || value === undefined) return null
|
|
122
|
+
if (typeof value === 'number' || typeof value === 'string') return value
|
|
123
|
+
if (typeof value === 'object') return (value as AnyDoc).id as number | string | null
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve the SLA policy for a ticket.
|
|
129
|
+
* Priority: explicit slaPolicy > default policy matching priority > null
|
|
130
|
+
*/
|
|
131
|
+
async function resolveSlaPolicy(
|
|
132
|
+
payload: Payload,
|
|
133
|
+
ticket: AnyDoc,
|
|
134
|
+
slugs: CollectionSlugs,
|
|
135
|
+
): Promise<AnyDoc | null> {
|
|
136
|
+
// If ticket has an explicit SLA policy assigned
|
|
137
|
+
const policyRef = field(ticket, 'slaPolicy')
|
|
138
|
+
if (policyRef) {
|
|
139
|
+
const policyId = typeof policyRef === 'object' && policyRef !== null
|
|
140
|
+
? (policyRef as AnyDoc).id
|
|
141
|
+
: policyRef
|
|
142
|
+
if (policyId) {
|
|
143
|
+
try {
|
|
144
|
+
// If already populated with data, use it directly
|
|
145
|
+
if (typeof policyRef === 'object' && (policyRef as AnyDoc).firstResponseTime) {
|
|
146
|
+
return policyRef
|
|
147
|
+
}
|
|
148
|
+
return await payload.findByID({
|
|
149
|
+
collection: slugs.slaPolicies as any,
|
|
150
|
+
id: policyId as number,
|
|
151
|
+
depth: 0,
|
|
152
|
+
overrideAccess: true,
|
|
153
|
+
})
|
|
154
|
+
} catch {
|
|
155
|
+
// Policy not found, fall through
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Look for a default policy matching this ticket's priority
|
|
161
|
+
const priority = field(ticket, 'priority') || 'normal'
|
|
162
|
+
try {
|
|
163
|
+
const defaults = await payload.find({
|
|
164
|
+
collection: slugs.slaPolicies as any,
|
|
165
|
+
where: {
|
|
166
|
+
and: [
|
|
167
|
+
{ isDefault: { equals: true } },
|
|
168
|
+
{ priority: { equals: priority } },
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
limit: 1,
|
|
172
|
+
depth: 0,
|
|
173
|
+
overrideAccess: true,
|
|
174
|
+
})
|
|
175
|
+
if (defaults.docs.length > 0) {
|
|
176
|
+
return defaults.docs[0]
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// Collection might not exist yet during migration
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Handle an SLA breach: create notification and send email if escalation is configured.
|
|
187
|
+
*/
|
|
188
|
+
async function handleSlaBreach(
|
|
189
|
+
payload: Payload,
|
|
190
|
+
ticket: AnyDoc,
|
|
191
|
+
type: 'first_response' | 'resolution',
|
|
192
|
+
slugs: CollectionSlugs,
|
|
193
|
+
notificationSlug: string,
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
const ticketNumber = (ticket.ticketNumber as string) || 'TK-????'
|
|
196
|
+
const subject = (ticket.subject as string) || 'Support'
|
|
197
|
+
const typeLabel = type === 'first_response' ? 'premiere reponse' : 'resolution'
|
|
198
|
+
|
|
199
|
+
// Create admin notification
|
|
200
|
+
await createAdminNotification(payload, {
|
|
201
|
+
title: `SLA depasse : ${ticketNumber}`,
|
|
202
|
+
message: `Le delai de ${typeLabel} a ete depasse pour le ticket ${ticketNumber} — ${subject}`,
|
|
203
|
+
type: 'sla_alert',
|
|
204
|
+
link: `/admin/collections/${slugs.tickets}/${ticket.id}`,
|
|
205
|
+
}, notificationSlug)
|
|
206
|
+
|
|
207
|
+
// Check if the SLA policy has escalation configured
|
|
208
|
+
const policyRef = field(ticket, 'slaPolicy')
|
|
209
|
+
if (!policyRef) return
|
|
210
|
+
|
|
211
|
+
let policy: AnyDoc | null = null
|
|
212
|
+
try {
|
|
213
|
+
const policyId = typeof policyRef === 'object' && policyRef !== null
|
|
214
|
+
? (policyRef as AnyDoc).id
|
|
215
|
+
: policyRef
|
|
216
|
+
if (!policyId) return
|
|
217
|
+
|
|
218
|
+
if (typeof policyRef === 'object' && (policyRef as AnyDoc).escalateOnBreach !== undefined) {
|
|
219
|
+
policy = policyRef
|
|
220
|
+
} else {
|
|
221
|
+
policy = await payload.findByID({
|
|
222
|
+
collection: slugs.slaPolicies as any,
|
|
223
|
+
id: policyId as number,
|
|
224
|
+
depth: 0,
|
|
225
|
+
overrideAccess: true,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!policy?.escalateOnBreach || !policy.escalateTo) return
|
|
233
|
+
|
|
234
|
+
// Send escalation email
|
|
235
|
+
try {
|
|
236
|
+
const escalateToId = typeof policy.escalateTo === 'object'
|
|
237
|
+
? (policy.escalateTo as AnyDoc).id
|
|
238
|
+
: policy.escalateTo
|
|
239
|
+
if (!escalateToId) return
|
|
240
|
+
|
|
241
|
+
const escalateUser = typeof policy.escalateTo === 'object' && (policy.escalateTo as AnyDoc).email
|
|
242
|
+
? policy.escalateTo
|
|
243
|
+
: await payload.findByID({
|
|
244
|
+
collection: slugs.users as any,
|
|
245
|
+
id: escalateToId as number,
|
|
246
|
+
depth: 0,
|
|
247
|
+
overrideAccess: true,
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
if (!escalateUser?.email) return
|
|
251
|
+
|
|
252
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
253
|
+
const adminUrl = `${baseUrl}/admin/collections/${slugs.tickets}/${ticket.id}`
|
|
254
|
+
const settings = await readSupportSettings(payload)
|
|
255
|
+
const supportEmail = settings.email.replyToAddress || settings.sla.escalationEmail || process.env.SUPPORT_EMAIL || ''
|
|
256
|
+
|
|
257
|
+
await payload.sendEmail({
|
|
258
|
+
to: escalateUser.email as string,
|
|
259
|
+
...(supportEmail ? { replyTo: supportEmail } : {}),
|
|
260
|
+
subject: `SLA depasse : [${ticketNumber}] ${subject}`,
|
|
261
|
+
html: emailWrapper(`SLA depasse — ${ticketNumber}`, [
|
|
262
|
+
emailParagraph(`Le delai de <strong>${typeLabel}</strong> a ete depasse pour le ticket <strong>${ticketNumber}</strong> — <em>${subject}</em>.`),
|
|
263
|
+
emailParagraph(`Ce ticket necessite une attention immediate.`),
|
|
264
|
+
emailButton('Ouvrir le ticket', adminUrl, 'dark'),
|
|
265
|
+
].join('')),
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
console.log(`[sla] Escalation email sent to ${escalateUser.email} for ${ticketNumber} (${type})`)
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.error('[sla] Failed to send escalation email:', err)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Factory: Assign SLA deadlines when a ticket is created or SLA policy changes.
|
|
276
|
+
* Runs as afterChange on tickets collection.
|
|
277
|
+
*/
|
|
278
|
+
export function createAssignSlaDeadlines(slugs: CollectionSlugs, notificationSlug = 'admin-notifications'): CollectionAfterChangeHook {
|
|
279
|
+
return async ({ doc, previousDoc, operation, req }) => {
|
|
280
|
+
const { payload } = req
|
|
281
|
+
|
|
282
|
+
// Only compute on create, or when slaPolicy/priority changes on update
|
|
283
|
+
const isCreate = operation === 'create'
|
|
284
|
+
const policyChanged = operation === 'update' && (
|
|
285
|
+
resolveId(field(doc, 'slaPolicy')) !== resolveId(field(previousDoc, 'slaPolicy')) ||
|
|
286
|
+
field(doc, 'priority') !== field(previousDoc, 'priority')
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if (!isCreate && !policyChanged) return doc
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const policy = await resolveSlaPolicy(payload, doc, slugs)
|
|
293
|
+
if (!policy) return doc
|
|
294
|
+
|
|
295
|
+
const createdAt = new Date(doc.createdAt as string)
|
|
296
|
+
const businessOnly = policy.businessHoursOnly as boolean
|
|
297
|
+
const firstResponseMinutes = policy.firstResponseTime as number
|
|
298
|
+
const resolutionMinutes = policy.resolutionTime as number
|
|
299
|
+
|
|
300
|
+
const calcDeadline = businessOnly ? calculateBusinessHoursDeadline : calculateCalendarDeadline
|
|
301
|
+
|
|
302
|
+
const slaFirstResponseDue = calcDeadline(createdAt, firstResponseMinutes).toISOString()
|
|
303
|
+
const slaResolutionDue = calcDeadline(createdAt, resolutionMinutes).toISOString()
|
|
304
|
+
|
|
305
|
+
const updateData: Record<string, unknown> = {
|
|
306
|
+
slaFirstResponseDue,
|
|
307
|
+
slaResolutionDue,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Also link default policy if none was explicitly set
|
|
311
|
+
if (!field(doc, 'slaPolicy') && policy.id) {
|
|
312
|
+
updateData.slaPolicy = policy.id
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await payload.update({
|
|
316
|
+
collection: slugs.tickets as any,
|
|
317
|
+
id: doc.id,
|
|
318
|
+
data: updateData,
|
|
319
|
+
overrideAccess: true,
|
|
320
|
+
})
|
|
321
|
+
} catch (err) {
|
|
322
|
+
console.error('[sla] Failed to assign SLA deadlines:', err)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return doc
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Factory: Check SLA resolution breach when ticket is resolved.
|
|
331
|
+
* Runs as afterChange on tickets collection.
|
|
332
|
+
*/
|
|
333
|
+
export function createCheckSlaOnResolve(slugs: CollectionSlugs, notificationSlug = 'admin-notifications'): CollectionAfterChangeHook {
|
|
334
|
+
return async ({ doc, previousDoc, operation, req }) => {
|
|
335
|
+
if (operation !== 'update') return doc
|
|
336
|
+
if (previousDoc?.status === doc.status) return doc
|
|
337
|
+
if (doc.status !== 'resolved') return doc
|
|
338
|
+
if (!doc.slaResolutionDue) return doc
|
|
339
|
+
|
|
340
|
+
// Skip if already checked
|
|
341
|
+
if (doc.slaResolutionBreached !== undefined && doc.slaResolutionBreached !== null) return doc
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const { payload } = req
|
|
345
|
+
const now = new Date()
|
|
346
|
+
const deadline = new Date(doc.slaResolutionDue as string)
|
|
347
|
+
const breached = now > deadline
|
|
348
|
+
|
|
349
|
+
await payload.update({
|
|
350
|
+
collection: slugs.tickets as any,
|
|
351
|
+
id: doc.id,
|
|
352
|
+
data: { slaResolutionBreached: breached },
|
|
353
|
+
overrideAccess: true,
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
if (breached) {
|
|
357
|
+
await handleSlaBreach(payload, doc, 'resolution', slugs, notificationSlug)
|
|
358
|
+
}
|
|
359
|
+
} catch (err) {
|
|
360
|
+
console.error('[sla] Failed to check SLA on resolve:', err)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return doc
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Factory: Check SLA breach on first admin response.
|
|
369
|
+
* Runs as afterChange on ticket-messages collection.
|
|
370
|
+
*/
|
|
371
|
+
export function createCheckSlaOnReply(slugs: CollectionSlugs, notificationSlug = 'admin-notifications'): CollectionAfterChangeHook {
|
|
372
|
+
return async ({ doc, operation, req }) => {
|
|
373
|
+
if (operation !== 'create') return doc
|
|
374
|
+
if (doc.authorType !== 'admin' || doc.isInternal) return doc
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
const { payload } = req
|
|
378
|
+
const ticketId = typeof doc.ticket === 'object' ? (doc.ticket as AnyDoc).id : doc.ticket
|
|
379
|
+
|
|
380
|
+
const ticket = await payload.findByID({
|
|
381
|
+
collection: slugs.tickets as any,
|
|
382
|
+
id: ticketId as number,
|
|
383
|
+
depth: 1,
|
|
384
|
+
overrideAccess: true,
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
if (!ticket) return doc
|
|
388
|
+
|
|
389
|
+
// Only check first response SLA if not already breached/recorded
|
|
390
|
+
if (ticket.slaFirstResponseBreached !== undefined && ticket.slaFirstResponseBreached !== null) return doc
|
|
391
|
+
if (!ticket.slaFirstResponseDue) return doc
|
|
392
|
+
|
|
393
|
+
const now = new Date()
|
|
394
|
+
const deadline = new Date(ticket.slaFirstResponseDue as string)
|
|
395
|
+
const breached = now > deadline
|
|
396
|
+
|
|
397
|
+
await payload.update({
|
|
398
|
+
collection: slugs.tickets as any,
|
|
399
|
+
id: ticketId as number,
|
|
400
|
+
data: { slaFirstResponseBreached: breached },
|
|
401
|
+
overrideAccess: true,
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
// Notify on breach if escalation is configured
|
|
405
|
+
if (breached) {
|
|
406
|
+
await handleSlaBreach(payload, ticket, 'first_response', slugs, notificationSlug)
|
|
407
|
+
}
|
|
408
|
+
} catch (err) {
|
|
409
|
+
console.error('[sla] Failed to check SLA on reply:', err)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return doc
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { CollectionAfterChangeHook } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { emailWrapper, emailButton, emailParagraph, escapeHtml } from '../utils/emailTemplate'
|
|
4
|
+
import { readSupportSettings } from '../utils/readSettings'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Status labels in French — maps status value to display label
|
|
8
|
+
*/
|
|
9
|
+
const STATUS_LABELS: Record<string, string> = {
|
|
10
|
+
open: 'Ouvert',
|
|
11
|
+
waiting_client: 'En attente client',
|
|
12
|
+
resolved: 'Resolu',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the client's email and first name from the ticket's `client` relationship.
|
|
17
|
+
* Returns null if the client cannot be resolved or has no email.
|
|
18
|
+
*/
|
|
19
|
+
async function resolveClient(
|
|
20
|
+
doc: Record<string, unknown>,
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
payload: { findByID: (args: any) => Promise<any> },
|
|
23
|
+
clientSlug: string,
|
|
24
|
+
): Promise<{ email: string; firstName: string; notifyOnStatusChange: boolean } | null> {
|
|
25
|
+
const client = doc.client as Record<string, unknown> | number | null | undefined
|
|
26
|
+
|
|
27
|
+
if (!client) return null
|
|
28
|
+
|
|
29
|
+
// If client is already populated (object with email)
|
|
30
|
+
if (typeof client === 'object' && client !== null && typeof client.email === 'string') {
|
|
31
|
+
return {
|
|
32
|
+
email: client.email,
|
|
33
|
+
firstName: (client.firstName as string) || '',
|
|
34
|
+
notifyOnStatusChange: client.notifyOnStatusChange !== false,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// If client is an ID, fetch it
|
|
39
|
+
const clientId = typeof client === 'number' ? client : (client as Record<string, unknown>)?.id
|
|
40
|
+
if (!clientId) return null
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const clientData = await payload.findByID({
|
|
44
|
+
collection: clientSlug,
|
|
45
|
+
id: clientId,
|
|
46
|
+
depth: 0,
|
|
47
|
+
overrideAccess: true,
|
|
48
|
+
})
|
|
49
|
+
if (!clientData?.email) return null
|
|
50
|
+
return {
|
|
51
|
+
email: clientData.email,
|
|
52
|
+
firstName: clientData.firstName || '',
|
|
53
|
+
notifyOnStatusChange: clientData.notifyOnStatusChange !== false,
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Factory: Send email notification to the client when a ticket is created
|
|
62
|
+
* or when its status changes to `waiting_client`.
|
|
63
|
+
*
|
|
64
|
+
* Note: Resolved notifications are handled by the existing
|
|
65
|
+
* `notifyClientOnResolve` hook, so this hook skips those transitions.
|
|
66
|
+
*/
|
|
67
|
+
export function createTicketStatusEmail(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
68
|
+
return async ({ doc, previousDoc, operation, req }) => {
|
|
69
|
+
const { payload } = req
|
|
70
|
+
const settings = await readSupportSettings(payload)
|
|
71
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
72
|
+
const supportEmail = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || ''
|
|
73
|
+
|
|
74
|
+
// --- Ticket creation: send confirmation email ---
|
|
75
|
+
if (operation === 'create') {
|
|
76
|
+
try {
|
|
77
|
+
const client = await resolveClient(doc, payload, slugs.supportClients)
|
|
78
|
+
if (!client?.email) return doc
|
|
79
|
+
|
|
80
|
+
const ticketNumber = doc.ticketNumber || 'TK-????'
|
|
81
|
+
const subject = doc.subject || 'Support'
|
|
82
|
+
const ticketUrl = `${baseUrl}/support/tickets/${doc.id}`
|
|
83
|
+
|
|
84
|
+
await payload.sendEmail({
|
|
85
|
+
to: client.email,
|
|
86
|
+
...(supportEmail ? { replyTo: supportEmail } : {}),
|
|
87
|
+
subject: `[${ticketNumber}] Demande enregistree — ${subject}`,
|
|
88
|
+
html: emailWrapper(`Votre demande a ete enregistree`, [
|
|
89
|
+
emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName)}</strong>,`),
|
|
90
|
+
emailParagraph(`Nous avons bien recu votre demande de support. Voici les details :`),
|
|
91
|
+
`<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom: 20px;">
|
|
92
|
+
<tr>
|
|
93
|
+
<td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">N° Ticket</td>
|
|
94
|
+
<td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;"><strong>${escapeHtml(String(ticketNumber))}</strong></td>
|
|
95
|
+
</tr>
|
|
96
|
+
<tr>
|
|
97
|
+
<td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">Sujet</td>
|
|
98
|
+
<td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;">${escapeHtml(String(subject))}</td>
|
|
99
|
+
</tr>
|
|
100
|
+
<tr>
|
|
101
|
+
<td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">Statut</td>
|
|
102
|
+
<td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;">${STATUS_LABELS[doc.status] || doc.status}</td>
|
|
103
|
+
</tr>
|
|
104
|
+
</table>`,
|
|
105
|
+
emailParagraph('Notre equipe vous repondra dans les meilleurs delais.'),
|
|
106
|
+
emailButton('Consulter le ticket', ticketUrl),
|
|
107
|
+
].join('')),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
payload.logger.info(`[tickets] Creation notification sent to ${client.email} for ${ticketNumber}`)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
payload.logger.error(`[tickets] Failed to send creation email: ${error}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return doc
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Status change on update ---
|
|
119
|
+
if (operation === 'update' && previousDoc?.status !== doc.status) {
|
|
120
|
+
// Skip resolved — handled by notifyClientOnResolve hook
|
|
121
|
+
if (doc.status === 'resolved') return doc
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const client = await resolveClient(doc, payload, slugs.supportClients)
|
|
125
|
+
if (!client?.email) return doc
|
|
126
|
+
|
|
127
|
+
// Respect client notification preferences
|
|
128
|
+
if (!client.notifyOnStatusChange) return doc
|
|
129
|
+
|
|
130
|
+
const ticketNumber = doc.ticketNumber || 'TK-????'
|
|
131
|
+
const subject = doc.subject || 'Support'
|
|
132
|
+
const statusLabel = STATUS_LABELS[doc.status] || doc.status
|
|
133
|
+
const previousStatusLabel = STATUS_LABELS[previousDoc?.status] || previousDoc?.status || 'N/A'
|
|
134
|
+
const ticketUrl = `${baseUrl}/support/tickets/${doc.id}`
|
|
135
|
+
|
|
136
|
+
// Contextual message based on new status
|
|
137
|
+
let contextMessage = ''
|
|
138
|
+
if (doc.status === 'waiting_client') {
|
|
139
|
+
contextMessage = emailParagraph(
|
|
140
|
+
'Nous avons besoin d\'informations supplementaires de votre part pour continuer le traitement de votre demande. Merci de consulter le ticket et de nous repondre.',
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await payload.sendEmail({
|
|
145
|
+
to: client.email,
|
|
146
|
+
...(supportEmail ? { replyTo: supportEmail } : {}),
|
|
147
|
+
subject: `[${ticketNumber}] Statut mis a jour : ${statusLabel}`,
|
|
148
|
+
html: emailWrapper(`Mise a jour de votre ticket`, [
|
|
149
|
+
emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName)}</strong>,`),
|
|
150
|
+
emailParagraph(`Le statut de votre ticket a ete mis a jour :`),
|
|
151
|
+
`<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom: 20px;">
|
|
152
|
+
<tr>
|
|
153
|
+
<td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">N° Ticket</td>
|
|
154
|
+
<td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;"><strong>${escapeHtml(String(ticketNumber))}</strong></td>
|
|
155
|
+
</tr>
|
|
156
|
+
<tr>
|
|
157
|
+
<td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">Sujet</td>
|
|
158
|
+
<td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;">${escapeHtml(String(subject))}</td>
|
|
159
|
+
</tr>
|
|
160
|
+
<tr>
|
|
161
|
+
<td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">Ancien statut</td>
|
|
162
|
+
<td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;">${escapeHtml(String(previousStatusLabel))}</td>
|
|
163
|
+
</tr>
|
|
164
|
+
<tr>
|
|
165
|
+
<td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">Nouveau statut</td>
|
|
166
|
+
<td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;"><strong>${escapeHtml(String(statusLabel))}</strong></td>
|
|
167
|
+
</tr>
|
|
168
|
+
</table>`,
|
|
169
|
+
contextMessage,
|
|
170
|
+
emailButton('Consulter le ticket', ticketUrl),
|
|
171
|
+
].join('')),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
payload.logger.info(`[tickets] Status change notification sent to ${client.email} for ${ticketNumber} (${previousDoc?.status} -> ${doc.status})`)
|
|
175
|
+
} catch (error) {
|
|
176
|
+
payload.logger.error(`[tickets] Failed to send status change email: ${error}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return doc
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Plugin
|
|
2
|
+
export { supportPlugin } from './plugin'
|
|
3
|
+
|
|
4
|
+
// Types
|
|
5
|
+
export type {
|
|
6
|
+
SupportPluginConfig,
|
|
7
|
+
SupportFeatures,
|
|
8
|
+
AIProviderConfig,
|
|
9
|
+
EmailConfig,
|
|
10
|
+
TicketData,
|
|
11
|
+
MessageData,
|
|
12
|
+
TimeEntryData,
|
|
13
|
+
ClientData,
|
|
14
|
+
CannedResponseData,
|
|
15
|
+
ActivityEntryData,
|
|
16
|
+
SatisfactionSurveyData,
|
|
17
|
+
} from './types'
|
|
18
|
+
|
|
19
|
+
export { DEFAULT_FEATURES } from './types'
|
|
20
|
+
|
|
21
|
+
// Utils
|
|
22
|
+
export { resolveSlugs, DEFAULT_SLUGS } from './utils/slugs'
|
|
23
|
+
export type { CollectionSlugs } from './utils/slugs'
|
|
24
|
+
export { readSupportSettings, readUserPrefs, DEFAULT_SETTINGS, DEFAULT_USER_PREFS } from './utils/readSettings'
|
|
25
|
+
export type { SupportSettings, UserPrefs } from './utils/readSettings'
|
|
26
|
+
export { createAdminNotification } from './utils/adminNotification'
|
|
27
|
+
export { dispatchWebhook } from './utils/webhookDispatcher'
|
|
28
|
+
|
|
29
|
+
// Hooks
|
|
30
|
+
export { createAssignSlaDeadlines, createCheckSlaOnResolve, createCheckSlaOnReply, calculateBusinessHoursDeadline } from './hooks/checkSLA'
|
|
31
|
+
export { createTicketStatusEmail } from './hooks/ticketStatusEmail'
|
|
32
|
+
|
|
33
|
+
// Collection factories (for advanced usage — standalone collection creation)
|
|
34
|
+
export {
|
|
35
|
+
createTicketsCollection,
|
|
36
|
+
createTicketMessagesCollection,
|
|
37
|
+
createSupportClientsCollection,
|
|
38
|
+
createTimeEntriesCollection,
|
|
39
|
+
createCannedResponsesCollection,
|
|
40
|
+
createTicketActivityLogCollection,
|
|
41
|
+
createSatisfactionSurveysCollection,
|
|
42
|
+
createKnowledgeBaseCollection,
|
|
43
|
+
createChatMessagesCollection,
|
|
44
|
+
createPendingEmailsCollection,
|
|
45
|
+
createEmailLogsCollection,
|
|
46
|
+
createAuthLogsCollection,
|
|
47
|
+
createWebhookEndpointsCollection,
|
|
48
|
+
createSlaPoliciesCollection,
|
|
49
|
+
createMacrosCollection,
|
|
50
|
+
createTicketStatusesCollection,
|
|
51
|
+
} from './collections'
|