@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,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
+ }