@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.
Files changed (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +525 -0
  3. package/dist/client.cjs +7 -0
  4. package/dist/client.d.cts +3 -0
  5. package/dist/client.d.ts +3 -0
  6. package/dist/client.js +5 -0
  7. package/dist/index.cjs +7766 -0
  8. package/dist/index.d.cts +384 -0
  9. package/dist/index.d.ts +384 -0
  10. package/dist/index.js +7730 -0
  11. package/dist/views.d.cts +30 -0
  12. package/dist/views.d.ts +30 -0
  13. package/package.json +131 -0
  14. package/src/client.ts +1 -0
  15. package/src/collections/AuthLogs.ts +65 -0
  16. package/src/collections/CannedResponses.ts +69 -0
  17. package/src/collections/ChatMessages.ts +98 -0
  18. package/src/collections/EmailLogs.ts +94 -0
  19. package/src/collections/KnowledgeBase.ts +99 -0
  20. package/src/collections/Macros.ts +98 -0
  21. package/src/collections/PendingEmails.ts +122 -0
  22. package/src/collections/SatisfactionSurveys.ts +98 -0
  23. package/src/collections/SlaPolicies.ts +123 -0
  24. package/src/collections/SupportClients.ts +210 -0
  25. package/src/collections/TicketActivityLog.ts +81 -0
  26. package/src/collections/TicketMessages.ts +364 -0
  27. package/src/collections/TicketStatuses.ts +108 -0
  28. package/src/collections/Tickets.ts +704 -0
  29. package/src/collections/TimeEntries.ts +105 -0
  30. package/src/collections/WebhookEndpoints.ts +96 -0
  31. package/src/collections/index.ts +16 -0
  32. package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
  33. package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
  34. package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
  35. package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
  36. package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
  37. package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
  38. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
  39. package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
  40. package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
  41. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
  42. package/src/components/TicketConversation/config.ts +82 -0
  43. package/src/components/TicketConversation/constants.ts +74 -0
  44. package/src/components/TicketConversation/context.ts +63 -0
  45. package/src/components/TicketConversation/hooks/useAI.ts +180 -0
  46. package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
  47. package/src/components/TicketConversation/hooks/useReply.ts +190 -0
  48. package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
  49. package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
  50. package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
  51. package/src/components/TicketConversation/index.tsx +1110 -0
  52. package/src/components/TicketConversation/locales/en.json +878 -0
  53. package/src/components/TicketConversation/locales/fr.json +878 -0
  54. package/src/components/TicketConversation/types.ts +54 -0
  55. package/src/components/TicketConversation/utils.ts +25 -0
  56. package/src/endpoints/admin-chat-stream.ts +238 -0
  57. package/src/endpoints/admin-chat.ts +263 -0
  58. package/src/endpoints/admin-stats.ts +200 -0
  59. package/src/endpoints/ai.ts +199 -0
  60. package/src/endpoints/apply-macro.ts +144 -0
  61. package/src/endpoints/auth-2fa.ts +163 -0
  62. package/src/endpoints/auto-close.ts +175 -0
  63. package/src/endpoints/billing.ts +167 -0
  64. package/src/endpoints/bulk-action.ts +103 -0
  65. package/src/endpoints/chat-stream.ts +127 -0
  66. package/src/endpoints/chat.ts +188 -0
  67. package/src/endpoints/chatbot.ts +113 -0
  68. package/src/endpoints/delete-account.ts +129 -0
  69. package/src/endpoints/email-stats.ts +109 -0
  70. package/src/endpoints/export-csv.ts +84 -0
  71. package/src/endpoints/export-data.ts +104 -0
  72. package/src/endpoints/import-conversation.ts +307 -0
  73. package/src/endpoints/index.ts +154 -0
  74. package/src/endpoints/login.ts +92 -0
  75. package/src/endpoints/merge-clients.ts +132 -0
  76. package/src/endpoints/merge-tickets.ts +137 -0
  77. package/src/endpoints/oauth-google.ts +179 -0
  78. package/src/endpoints/pending-emails-process.ts +224 -0
  79. package/src/endpoints/presence.ts +104 -0
  80. package/src/endpoints/process-scheduled.ts +144 -0
  81. package/src/endpoints/purge-logs.ts +58 -0
  82. package/src/endpoints/resend-notification.ts +99 -0
  83. package/src/endpoints/round-robin-config.ts +92 -0
  84. package/src/endpoints/satisfaction.ts +93 -0
  85. package/src/endpoints/search.ts +106 -0
  86. package/src/endpoints/seed-kb.ts +153 -0
  87. package/src/endpoints/settings.ts +144 -0
  88. package/src/endpoints/signature.ts +93 -0
  89. package/src/endpoints/sla-check.ts +124 -0
  90. package/src/endpoints/split-ticket.ts +131 -0
  91. package/src/endpoints/statuses.ts +45 -0
  92. package/src/endpoints/track-open.ts +154 -0
  93. package/src/endpoints/typing.ts +101 -0
  94. package/src/endpoints/user-prefs.ts +125 -0
  95. package/src/hooks/checkSLA.ts +414 -0
  96. package/src/hooks/ticketStatusEmail.ts +182 -0
  97. package/src/index.ts +51 -0
  98. package/src/plugin.ts +157 -0
  99. package/src/portal/LiveChat.tsx +1353 -0
  100. package/src/portal/auth/ChatWidget.tsx +350 -0
  101. package/src/portal/auth/ChatbotWidget.tsx +285 -0
  102. package/src/portal/auth/SupportHeader.tsx +409 -0
  103. package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
  104. package/src/portal/auth/dashboard/page.tsx +84 -0
  105. package/src/portal/auth/faq/FAQSearch.tsx +117 -0
  106. package/src/portal/auth/faq/page.tsx +199 -0
  107. package/src/portal/auth/layout.tsx +61 -0
  108. package/src/portal/auth/profile/page.tsx +705 -0
  109. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
  110. package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
  111. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
  112. package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
  113. package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
  114. package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
  115. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
  116. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
  117. package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
  118. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
  119. package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
  120. package/src/portal/auth/tickets/detail/page.tsx +738 -0
  121. package/src/portal/auth/tickets/new/page.tsx +515 -0
  122. package/src/portal/forgot-password/page.tsx +114 -0
  123. package/src/portal/layout.tsx +26 -0
  124. package/src/portal/locales/en.json +374 -0
  125. package/src/portal/locales/fr.json +374 -0
  126. package/src/portal/login/page.tsx +351 -0
  127. package/src/portal/page.tsx +162 -0
  128. package/src/portal/register/page.tsx +281 -0
  129. package/src/portal/reset-password/page.tsx +152 -0
  130. package/src/styles/BillingView.module.scss +311 -0
  131. package/src/styles/ChatView.module.scss +438 -0
  132. package/src/styles/CommandPalette.module.scss +160 -0
  133. package/src/styles/CrmView.module.scss +554 -0
  134. package/src/styles/EmailTracking.module.scss +238 -0
  135. package/src/styles/ImportConversation.module.scss +267 -0
  136. package/src/styles/Layout.module.scss +55 -0
  137. package/src/styles/Logs.module.scss +164 -0
  138. package/src/styles/NewTicket.module.scss +143 -0
  139. package/src/styles/PendingEmails.module.scss +629 -0
  140. package/src/styles/SupportDashboard.module.scss +649 -0
  141. package/src/styles/TicketDetail.module.scss +1043 -0
  142. package/src/styles/TicketInbox.module.scss +296 -0
  143. package/src/styles/TicketingSettings.module.scss +358 -0
  144. package/src/styles/TimeDashboard.module.scss +287 -0
  145. package/src/styles/_tokens.scss +78 -0
  146. package/src/styles/theme.css +633 -0
  147. package/src/types.ts +255 -0
  148. package/src/utils/adminNotification.ts +38 -0
  149. package/src/utils/auth.ts +46 -0
  150. package/src/utils/emailTemplate.ts +343 -0
  151. package/src/utils/fireWebhooks.ts +84 -0
  152. package/src/utils/index.ts +22 -0
  153. package/src/utils/rateLimiter.ts +52 -0
  154. package/src/utils/readSettings.ts +67 -0
  155. package/src/utils/slugs.ts +54 -0
  156. package/src/utils/webhookDispatcher.ts +120 -0
  157. package/src/views/BillingView/client.tsx +137 -0
  158. package/src/views/BillingView/index.tsx +33 -0
  159. package/src/views/ChatView/client.tsx +294 -0
  160. package/src/views/ChatView/index.tsx +33 -0
  161. package/src/views/CrmView/client.tsx +206 -0
  162. package/src/views/CrmView/index.tsx +33 -0
  163. package/src/views/EmailTrackingView/client.tsx +124 -0
  164. package/src/views/EmailTrackingView/index.tsx +33 -0
  165. package/src/views/ImportConversationView/client.tsx +133 -0
  166. package/src/views/ImportConversationView/index.tsx +33 -0
  167. package/src/views/LogsView/client.tsx +151 -0
  168. package/src/views/LogsView/index.tsx +30 -0
  169. package/src/views/NewTicketView/client.tsx +227 -0
  170. package/src/views/NewTicketView/index.tsx +30 -0
  171. package/src/views/PendingEmailsView/client.tsx +177 -0
  172. package/src/views/PendingEmailsView/index.tsx +33 -0
  173. package/src/views/SupportDashboardView/client.tsx +424 -0
  174. package/src/views/SupportDashboardView/index.tsx +33 -0
  175. package/src/views/TicketDetailView/client.tsx +775 -0
  176. package/src/views/TicketDetailView/index.tsx +33 -0
  177. package/src/views/TicketInboxView/client.tsx +313 -0
  178. package/src/views/TicketInboxView/index.tsx +30 -0
  179. package/src/views/TicketingSettingsView/client.tsx +866 -0
  180. package/src/views/TicketingSettingsView/index.tsx +33 -0
  181. package/src/views/TimeDashboardView/client.tsx +144 -0
  182. package/src/views/TimeDashboardView/index.tsx +33 -0
  183. package/src/views/shared/AdminViewHeader.tsx +69 -0
  184. package/src/views/shared/ErrorBoundary.tsx +68 -0
  185. package/src/views/shared/Skeleton.tsx +125 -0
  186. package/src/views/shared/adminTokens.ts +37 -0
  187. package/src/views/shared/config.ts +82 -0
  188. package/src/views/shared/index.ts +6 -0
  189. 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&deg; 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&deg; 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'