@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,124 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+
5
+ /**
6
+ * GET /api/support/sla-check
7
+ * Returns tickets currently breaching or at risk of breaching SLA. Admin-only.
8
+ */
9
+ export function createSlaCheckEndpoint(slugs: CollectionSlugs): Endpoint {
10
+ return {
11
+ path: '/support/sla-check',
12
+ method: 'get',
13
+ handler: async (req) => {
14
+ try {
15
+ const payload = req.payload
16
+
17
+ requireAdmin(req, slugs)
18
+
19
+ const now = new Date()
20
+ const nowISO = now.toISOString()
21
+
22
+ const { docs: tickets } = await payload.find({
23
+ collection: slugs.tickets as any,
24
+ where: {
25
+ and: [
26
+ { status: { in: ['open', 'waiting_client'] } },
27
+ {
28
+ or: [
29
+ { slaFirstResponseDue: { exists: true } },
30
+ { slaResolutionDue: { exists: true } },
31
+ ],
32
+ },
33
+ ],
34
+ },
35
+ limit: 500,
36
+ depth: 1,
37
+ overrideAccess: true,
38
+ select: {
39
+ ticketNumber: true,
40
+ subject: true,
41
+ status: true,
42
+ priority: true,
43
+ client: true,
44
+ assignedTo: true,
45
+ slaPolicy: true,
46
+ slaFirstResponseDue: true,
47
+ slaResolutionDue: true,
48
+ slaFirstResponseBreached: true,
49
+ slaResolutionBreached: true,
50
+ firstResponseAt: true,
51
+ createdAt: true,
52
+ },
53
+ })
54
+
55
+ const breached: Array<Record<string, unknown>> = []
56
+ const atRisk: Array<Record<string, unknown>> = []
57
+
58
+ for (const ticket of tickets) {
59
+ const t = ticket as any
60
+ const ticketData = {
61
+ id: t.id,
62
+ ticketNumber: t.ticketNumber,
63
+ subject: t.subject,
64
+ status: t.status,
65
+ priority: t.priority,
66
+ client: t.client,
67
+ assignedTo: t.assignedTo,
68
+ createdAt: t.createdAt,
69
+ breachTypes: [] as string[],
70
+ riskTypes: [] as string[],
71
+ }
72
+
73
+ // Check first response SLA
74
+ if (t.slaFirstResponseDue && !t.firstResponseAt) {
75
+ const deadline = new Date(t.slaFirstResponseDue)
76
+ if (now > deadline) {
77
+ ticketData.breachTypes.push('first_response')
78
+ } else {
79
+ const created = new Date(t.createdAt)
80
+ const totalWindow = deadline.getTime() - created.getTime()
81
+ const elapsed = now.getTime() - created.getTime()
82
+ if (totalWindow > 0 && elapsed / totalWindow >= 0.8) {
83
+ ticketData.riskTypes.push('first_response')
84
+ }
85
+ }
86
+ }
87
+
88
+ // Check resolution SLA
89
+ if (t.slaResolutionDue) {
90
+ const deadline = new Date(t.slaResolutionDue)
91
+ if (now > deadline) {
92
+ ticketData.breachTypes.push('resolution')
93
+ } else {
94
+ const created = new Date(t.createdAt)
95
+ const totalWindow = deadline.getTime() - created.getTime()
96
+ const elapsed = now.getTime() - created.getTime()
97
+ if (totalWindow > 0 && elapsed / totalWindow >= 0.8) {
98
+ ticketData.riskTypes.push('resolution')
99
+ }
100
+ }
101
+ }
102
+
103
+ if (ticketData.breachTypes.length > 0) {
104
+ breached.push(ticketData)
105
+ } else if (ticketData.riskTypes.length > 0) {
106
+ atRisk.push(ticketData)
107
+ }
108
+ }
109
+
110
+ return new Response(JSON.stringify({ breached, atRisk, checkedAt: nowISO, totalChecked: tickets.length }), {
111
+ headers: {
112
+ 'Content-Type': 'application/json',
113
+ 'Cache-Control': 'private, max-age=60, stale-while-revalidate=120',
114
+ },
115
+ })
116
+ } catch (error) {
117
+ const authResponse = handleAuthError(error)
118
+ if (authResponse) return authResponse
119
+ console.error('[sla-check] Error:', error)
120
+ return Response.json({ error: 'Internal error' }, { status: 500 })
121
+ }
122
+ },
123
+ }
124
+ }
@@ -0,0 +1,131 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+
5
+ /**
6
+ * POST /api/support/split-ticket
7
+ * Extract a message into a new ticket. Admin-only.
8
+ */
9
+ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
10
+ return {
11
+ path: '/support/split-ticket',
12
+ method: 'post',
13
+ handler: async (req) => {
14
+ try {
15
+ const payload = req.payload
16
+
17
+ requireAdmin(req, slugs)
18
+
19
+ const { messageId, subject } = (await req.json!()) as { messageId: number; subject?: string }
20
+ if (!messageId) {
21
+ return Response.json({ error: 'messageId required' }, { status: 400 })
22
+ }
23
+
24
+ const message = await payload.findByID({
25
+ collection: slugs.ticketMessages as any,
26
+ id: messageId,
27
+ depth: 1,
28
+ overrideAccess: true,
29
+ }) as any
30
+
31
+ if (!message) {
32
+ return Response.json({ error: 'Message not found' }, { status: 404 })
33
+ }
34
+
35
+ const sourceTicket = typeof message.ticket === 'object' ? message.ticket : null
36
+ if (!sourceTicket) {
37
+ return Response.json({ error: 'Cannot resolve source ticket' }, { status: 400 })
38
+ }
39
+
40
+ const clientId = typeof sourceTicket.client === 'object'
41
+ ? sourceTicket.client.id
42
+ : sourceTicket.client
43
+
44
+ // Create new ticket
45
+ const newTicket = await payload.create({
46
+ collection: slugs.tickets as any,
47
+ data: {
48
+ subject: subject || `Split: ${sourceTicket.subject}`,
49
+ client: clientId,
50
+ status: 'open',
51
+ priority: sourceTicket.priority || 'normal',
52
+ category: sourceTicket.category || 'question',
53
+ source: sourceTicket.source || 'portal',
54
+ relatedTickets: [sourceTicket.id],
55
+ },
56
+ overrideAccess: true,
57
+ }) as any
58
+
59
+ // Copy message to new ticket
60
+ const attachments = (message.attachments as Array<{ file: { id: number } | number }> | undefined)?.map((a: any) => ({
61
+ file: typeof a.file === 'object' ? a.file.id : a.file,
62
+ })) || []
63
+
64
+ await payload.create({
65
+ collection: slugs.ticketMessages as any,
66
+ data: {
67
+ ticket: newTicket.id,
68
+ body: message.body,
69
+ bodyHtml: message.bodyHtml || undefined,
70
+ authorType: message.authorType,
71
+ authorClient: typeof message.authorClient === 'object' ? message.authorClient?.id : message.authorClient,
72
+ isInternal: false,
73
+ skipNotification: true,
74
+ ...(attachments.length > 0 && { attachments }),
75
+ },
76
+ overrideAccess: true,
77
+ })
78
+
79
+ // Add system note on source ticket
80
+ await payload.create({
81
+ collection: slugs.ticketMessages as any,
82
+ data: {
83
+ ticket: sourceTicket.id,
84
+ body: `Message extrait vers le ticket ${newTicket.ticketNumber}`,
85
+ authorType: 'admin',
86
+ isInternal: true,
87
+ skipNotification: true,
88
+ },
89
+ overrideAccess: true,
90
+ })
91
+
92
+ // Link source ticket to new ticket (bidirectional)
93
+ const existingRelated = Array.isArray(sourceTicket.relatedTickets)
94
+ ? sourceTicket.relatedTickets.map((t: any) => typeof t === 'object' ? t.id : t)
95
+ : []
96
+
97
+ if (!existingRelated.includes(newTicket.id)) {
98
+ await payload.update({
99
+ collection: slugs.tickets as any,
100
+ id: sourceTicket.id,
101
+ data: { relatedTickets: [...existingRelated, newTicket.id] },
102
+ overrideAccess: true,
103
+ })
104
+ }
105
+
106
+ // Log activity
107
+ await payload.create({
108
+ collection: slugs.ticketActivityLog as any,
109
+ data: {
110
+ ticket: sourceTicket.id,
111
+ action: 'split',
112
+ detail: `Message extrait vers ${newTicket.ticketNumber}`,
113
+ actorType: 'admin',
114
+ actorEmail: req.user.email,
115
+ },
116
+ overrideAccess: true,
117
+ })
118
+
119
+ return Response.json({
120
+ ticketId: newTicket.id,
121
+ ticketNumber: newTicket.ticketNumber,
122
+ })
123
+ } catch (error) {
124
+ const authResponse = handleAuthError(error)
125
+ if (authResponse) return authResponse
126
+ console.error('[split-ticket] Error:', error)
127
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
128
+ }
129
+ },
130
+ }
131
+ }
@@ -0,0 +1,45 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+
4
+ /**
5
+ * GET /api/support/statuses
6
+ * Returns all ticket statuses sorted by sortOrder.
7
+ */
8
+ export function createStatusesEndpoint(slugs: CollectionSlugs): Endpoint {
9
+ return {
10
+ path: '/support/statuses',
11
+ method: 'get',
12
+ handler: async (req) => {
13
+ try {
14
+ const payload = req.payload
15
+
16
+ if (!req.user) {
17
+ return Response.json({ error: 'Unauthorized' }, { status: 401 })
18
+ }
19
+
20
+ const { docs } = await payload.find({
21
+ collection: slugs.ticketStatuses as any,
22
+ sort: 'sortOrder',
23
+ limit: 100,
24
+ depth: 0,
25
+ overrideAccess: true,
26
+ })
27
+
28
+ return Response.json({
29
+ statuses: docs.map((s: any) => ({
30
+ id: s.id,
31
+ name: s.name,
32
+ slug: s.slug,
33
+ color: s.color,
34
+ type: s.type,
35
+ isDefault: s.isDefault,
36
+ sortOrder: s.sortOrder,
37
+ })),
38
+ })
39
+ } catch (error) {
40
+ console.error('[statuses] Error:', error)
41
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
42
+ }
43
+ },
44
+ }
45
+ }
@@ -0,0 +1,154 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { createHmac } from 'crypto'
4
+
5
+ // 1x1 transparent GIF (43 bytes)
6
+ const TRANSPARENT_GIF = Buffer.from(
7
+ 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
8
+ 'base64',
9
+ )
10
+
11
+ /**
12
+ * Generate an HMAC signature for tracking pixel URLs.
13
+ * Use this when building tracking URLs in email templates.
14
+ */
15
+ export function generateTrackingToken(ticketId: string, messageId: string, secret: string): string {
16
+ return createHmac('sha256', secret).update(`${ticketId}:${messageId}`).digest('hex').substring(0, 16)
17
+ }
18
+
19
+ /**
20
+ * GET /api/support/track-open?t=<ticketId>&m=<messageId>&sig=<hmac>
21
+ * Tracking pixel for email open detection. No auth required.
22
+ * Validates HMAC signature to prevent enumeration attacks.
23
+ */
24
+ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
25
+ return {
26
+ path: '/support/track-open',
27
+ method: 'get',
28
+ handler: async (req) => {
29
+ const url = new URL(req.url!)
30
+ const ticketId = url.searchParams.get('t')
31
+ const messageId = url.searchParams.get('m')
32
+ const sig = url.searchParams.get('sig')
33
+
34
+ const parsedId = ticketId ? Number(ticketId) : NaN
35
+ const parsedMsgId = messageId ? Number(messageId) : NaN
36
+
37
+ // Validate HMAC signature
38
+ const secret = process.env.PAYLOAD_SECRET || ''
39
+ if (secret && ticketId && messageId && sig) {
40
+ const expected = generateTrackingToken(ticketId, messageId, secret)
41
+ if (sig !== expected) {
42
+ // Return transparent GIF silently (don't leak information)
43
+ return new Response(TRANSPARENT_GIF, {
44
+ status: 200,
45
+ headers: {
46
+ 'Content-Type': 'image/gif',
47
+ 'Content-Length': String(TRANSPARENT_GIF.length),
48
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
49
+ },
50
+ })
51
+ }
52
+ } else if (secret && (!sig || !ticketId || !messageId)) {
53
+ // Missing signature param: return GIF but don't process
54
+ return new Response(TRANSPARENT_GIF, {
55
+ status: 200,
56
+ headers: {
57
+ 'Content-Type': 'image/gif',
58
+ 'Content-Length': String(TRANSPARENT_GIF.length),
59
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
60
+ },
61
+ })
62
+ }
63
+
64
+ if (ticketId && Number.isInteger(parsedId) && parsedId > 0) {
65
+ try {
66
+ const payload = req.payload
67
+
68
+ const ticket = await payload.findByID({
69
+ collection: slugs.tickets as any,
70
+ id: parsedId,
71
+ depth: 0,
72
+ overrideAccess: true,
73
+ select: { lastClientReadAt: true },
74
+ }) as any
75
+
76
+ if (ticket) {
77
+ const lastRead = ticket.lastClientReadAt ? new Date(ticket.lastClientReadAt).getTime() : 0
78
+ const fiveMinAgo = Date.now() - 5 * 60 * 1000
79
+
80
+ if (lastRead < fiveMinAgo) {
81
+ await payload.update({
82
+ collection: slugs.tickets as any,
83
+ id: parsedId,
84
+ data: { lastClientReadAt: new Date().toISOString() },
85
+ overrideAccess: true,
86
+ })
87
+ }
88
+ }
89
+
90
+ // Track at message level
91
+ if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
92
+ const msg = await payload.findByID({
93
+ collection: slugs.ticketMessages as any,
94
+ id: parsedMsgId,
95
+ depth: 0,
96
+ overrideAccess: true,
97
+ select: { emailOpenedAt: true },
98
+ }) as any
99
+
100
+ if (msg && !msg.emailOpenedAt) {
101
+ await payload.update({
102
+ collection: slugs.ticketMessages as any,
103
+ id: parsedMsgId,
104
+ data: { emailOpenedAt: new Date().toISOString() },
105
+ overrideAccess: true,
106
+ })
107
+
108
+ const ticketInfo = await payload.findByID({
109
+ collection: slugs.tickets as any,
110
+ id: parsedId,
111
+ depth: 1,
112
+ overrideAccess: true,
113
+ select: { ticketNumber: true, subject: true, client: true },
114
+ }) as any
115
+
116
+ const clientName = typeof ticketInfo?.client === 'object'
117
+ ? ticketInfo.client?.firstName || 'Client'
118
+ : 'Client'
119
+
120
+ // Try to create admin notification (collection may not exist)
121
+ try {
122
+ await payload.create({
123
+ collection: 'admin-notifications' as any,
124
+ data: {
125
+ title: `Email ouvert — ${ticketInfo?.ticketNumber || 'TK-????'}`,
126
+ message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || 'ticket'}"`,
127
+ type: 'email_opened',
128
+ link: `/admin/ticket?id=${parsedId}`,
129
+ },
130
+ overrideAccess: true,
131
+ })
132
+ } catch {
133
+ // admin-notifications collection may not exist in the plugin
134
+ }
135
+ }
136
+ }
137
+ } catch (err) {
138
+ console.error('[track-open] Error:', err)
139
+ }
140
+ }
141
+
142
+ return new Response(TRANSPARENT_GIF, {
143
+ status: 200,
144
+ headers: {
145
+ 'Content-Type': 'image/gif',
146
+ 'Content-Length': String(TRANSPARENT_GIF.length),
147
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
148
+ 'Pragma': 'no-cache',
149
+ 'Expires': '0',
150
+ },
151
+ })
152
+ },
153
+ }
154
+ }
@@ -0,0 +1,101 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+
4
+ // In-memory typing state (ticketId -> { admin?: timestamp, client?: timestamp })
5
+ const typingState = new Map<string, { admin?: number; client?: number; adminName?: string; clientName?: string }>()
6
+
7
+ const TYPING_TTL = 5000 // 5 seconds
8
+
9
+ function cleanExpired(ticketId: string) {
10
+ const state = typingState.get(ticketId)
11
+ if (!state) return
12
+ const now = Date.now()
13
+ if (state.admin && now - state.admin > TYPING_TTL) {
14
+ state.admin = undefined
15
+ state.adminName = undefined
16
+ }
17
+ if (state.client && now - state.client > TYPING_TTL) {
18
+ state.client = undefined
19
+ state.clientName = undefined
20
+ }
21
+ if (!state.admin && !state.client) typingState.delete(ticketId)
22
+ }
23
+
24
+ /**
25
+ * POST /api/support/typing — Signal that user is typing
26
+ */
27
+ export function createTypingPostEndpoint(slugs: CollectionSlugs): Endpoint {
28
+ return {
29
+ path: '/support/typing',
30
+ method: 'post',
31
+ handler: async (req) => {
32
+ try {
33
+ if (!req.user) {
34
+ return Response.json({ error: 'Unauthorized' }, { status: 401 })
35
+ }
36
+
37
+ const { ticketId } = (await req.json!()) as { ticketId: number }
38
+ if (!ticketId) {
39
+ return Response.json({ error: 'ticketId required' }, { status: 400 })
40
+ }
41
+
42
+ const key = String(ticketId)
43
+ const state = typingState.get(key) || {}
44
+
45
+ if (req.user.collection === slugs.users) {
46
+ state.admin = Date.now()
47
+ state.adminName = (req.user as any).firstName || 'Support'
48
+ } else {
49
+ state.client = Date.now()
50
+ state.clientName = (req.user as any).firstName || 'Client'
51
+ }
52
+
53
+ typingState.set(key, state)
54
+ return Response.json({ ok: true })
55
+ } catch {
56
+ return Response.json({ error: 'Error' }, { status: 500 })
57
+ }
58
+ },
59
+ }
60
+ }
61
+
62
+ /**
63
+ * GET /api/support/typing?ticketId=123 — Check who is typing
64
+ */
65
+ export function createTypingGetEndpoint(slugs: CollectionSlugs): Endpoint {
66
+ return {
67
+ path: '/support/typing',
68
+ method: 'get',
69
+ handler: async (req) => {
70
+ try {
71
+ if (!req.user) {
72
+ return Response.json({ error: 'Unauthorized' }, { status: 401 })
73
+ }
74
+
75
+ const url = new URL(req.url!)
76
+ const ticketId = url.searchParams.get('ticketId')
77
+ if (!ticketId) {
78
+ return Response.json({ error: 'ticketId required' }, { status: 400 })
79
+ }
80
+
81
+ cleanExpired(ticketId)
82
+ const state = typingState.get(ticketId)
83
+
84
+ // Admin sees client typing, client sees admin typing
85
+ if (req.user.collection === slugs.users) {
86
+ return Response.json({
87
+ typing: !!state?.client,
88
+ name: state?.clientName || null,
89
+ })
90
+ } else {
91
+ return Response.json({
92
+ typing: !!state?.admin,
93
+ name: state?.adminName || null,
94
+ })
95
+ }
96
+ } catch {
97
+ return Response.json({ typing: false, name: null })
98
+ }
99
+ },
100
+ }
101
+ }
@@ -0,0 +1,125 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+
5
+ const PREF_KEY_PREFIX = 'support-user-prefs'
6
+
7
+ export interface UserPrefs {
8
+ locale: 'fr' | 'en'
9
+ signature: string
10
+ }
11
+
12
+ const DEFAULT_USER_PREFS: UserPrefs = {
13
+ locale: 'fr',
14
+ signature: '',
15
+ }
16
+
17
+ /**
18
+ * GET /api/support/user-prefs — Read current user's preferences
19
+ */
20
+ export function createUserPrefsGetEndpoint(slugs: CollectionSlugs): Endpoint {
21
+ return {
22
+ path: '/support/user-prefs',
23
+ method: 'get',
24
+ handler: async (req) => {
25
+ try {
26
+ const payload = req.payload
27
+
28
+ requireAdmin(req, slugs)
29
+
30
+ const key = `${PREF_KEY_PREFIX}-${req.user!.id}`
31
+
32
+ const prefs = await payload.find({
33
+ collection: 'payload-preferences' as any,
34
+ where: { key: { equals: key } },
35
+ limit: 1,
36
+ depth: 0,
37
+ overrideAccess: true,
38
+ })
39
+
40
+ let userPrefs = { ...DEFAULT_USER_PREFS }
41
+ if (prefs.docs.length > 0) {
42
+ const stored = prefs.docs[0].value as Partial<UserPrefs>
43
+ userPrefs = {
44
+ locale: stored.locale || DEFAULT_USER_PREFS.locale,
45
+ signature: stored.signature ?? DEFAULT_USER_PREFS.signature,
46
+ }
47
+ }
48
+
49
+ return Response.json(userPrefs)
50
+ } catch (error) {
51
+ const authResponse = handleAuthError(error)
52
+ if (authResponse) return authResponse
53
+ console.warn('[support/user-prefs] GET error:', error)
54
+ return Response.json({ error: 'Error' }, { status: 500 })
55
+ }
56
+ },
57
+ }
58
+ }
59
+
60
+ /**
61
+ * POST /api/support/user-prefs — Save current user's preferences
62
+ */
63
+ export function createUserPrefsPostEndpoint(slugs: CollectionSlugs): Endpoint {
64
+ return {
65
+ path: '/support/user-prefs',
66
+ method: 'post',
67
+ handler: async (req) => {
68
+ try {
69
+ const payload = req.payload
70
+
71
+ requireAdmin(req, slugs)
72
+
73
+ const body = (await req.json!()) as Partial<UserPrefs>
74
+ const key = `${PREF_KEY_PREFIX}-${req.user!.id}`
75
+
76
+ // Read existing prefs to merge
77
+ const existing = await payload.find({
78
+ collection: 'payload-preferences' as any,
79
+ where: { key: { equals: key } },
80
+ limit: 1,
81
+ depth: 0,
82
+ overrideAccess: true,
83
+ })
84
+
85
+ let current = { ...DEFAULT_USER_PREFS }
86
+ if (existing.docs.length > 0) {
87
+ const stored = existing.docs[0].value as Partial<UserPrefs>
88
+ current = {
89
+ locale: stored.locale || DEFAULT_USER_PREFS.locale,
90
+ signature: stored.signature ?? DEFAULT_USER_PREFS.signature,
91
+ }
92
+ }
93
+
94
+ const merged: UserPrefs = {
95
+ locale: body.locale || current.locale,
96
+ signature: body.signature ?? current.signature,
97
+ }
98
+
99
+ await payload.db.upsert({
100
+ collection: 'payload-preferences',
101
+ data: {
102
+ key,
103
+ user: { relationTo: req.user!.collection, value: req.user!.id },
104
+ value: merged as unknown as Record<string, unknown>,
105
+ },
106
+ req: { payload, user: req.user } as any,
107
+ where: {
108
+ and: [
109
+ { key: { equals: key } },
110
+ { 'user.value': { equals: req.user!.id } },
111
+ { 'user.relationTo': { equals: req.user!.collection } },
112
+ ],
113
+ },
114
+ })
115
+
116
+ return Response.json(merged)
117
+ } catch (error) {
118
+ const authResponse = handleAuthError(error)
119
+ if (authResponse) return authResponse
120
+ console.error('[support/user-prefs] POST error:', error)
121
+ return Response.json({ error: 'Error saving user preferences' }, { status: 500 })
122
+ }
123
+ },
124
+ }
125
+ }