@consilioweb/payload-support 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +525 -0
- package/dist/client.cjs +7 -0
- package/dist/client.d.cts +3 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.js +5 -0
- package/dist/index.cjs +7766 -0
- package/dist/index.d.cts +384 -0
- package/dist/index.d.ts +384 -0
- package/dist/index.js +7730 -0
- package/dist/views.d.cts +30 -0
- package/dist/views.d.ts +30 -0
- package/package.json +131 -0
- package/src/client.ts +1 -0
- package/src/collections/AuthLogs.ts +65 -0
- package/src/collections/CannedResponses.ts +69 -0
- package/src/collections/ChatMessages.ts +98 -0
- package/src/collections/EmailLogs.ts +94 -0
- package/src/collections/KnowledgeBase.ts +99 -0
- package/src/collections/Macros.ts +98 -0
- package/src/collections/PendingEmails.ts +122 -0
- package/src/collections/SatisfactionSurveys.ts +98 -0
- package/src/collections/SlaPolicies.ts +123 -0
- package/src/collections/SupportClients.ts +210 -0
- package/src/collections/TicketActivityLog.ts +81 -0
- package/src/collections/TicketMessages.ts +364 -0
- package/src/collections/TicketStatuses.ts +108 -0
- package/src/collections/Tickets.ts +704 -0
- package/src/collections/TimeEntries.ts +105 -0
- package/src/collections/WebhookEndpoints.ts +96 -0
- package/src/collections/index.ts +16 -0
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
- package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
- package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
- package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
- package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
- package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
- package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
- package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
- package/src/components/TicketConversation/config.ts +82 -0
- package/src/components/TicketConversation/constants.ts +74 -0
- package/src/components/TicketConversation/context.ts +63 -0
- package/src/components/TicketConversation/hooks/useAI.ts +180 -0
- package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
- package/src/components/TicketConversation/hooks/useReply.ts +190 -0
- package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
- package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
- package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
- package/src/components/TicketConversation/index.tsx +1110 -0
- package/src/components/TicketConversation/locales/en.json +878 -0
- package/src/components/TicketConversation/locales/fr.json +878 -0
- package/src/components/TicketConversation/types.ts +54 -0
- package/src/components/TicketConversation/utils.ts +25 -0
- package/src/endpoints/admin-chat-stream.ts +238 -0
- package/src/endpoints/admin-chat.ts +263 -0
- package/src/endpoints/admin-stats.ts +200 -0
- package/src/endpoints/ai.ts +199 -0
- package/src/endpoints/apply-macro.ts +144 -0
- package/src/endpoints/auth-2fa.ts +163 -0
- package/src/endpoints/auto-close.ts +175 -0
- package/src/endpoints/billing.ts +167 -0
- package/src/endpoints/bulk-action.ts +103 -0
- package/src/endpoints/chat-stream.ts +127 -0
- package/src/endpoints/chat.ts +188 -0
- package/src/endpoints/chatbot.ts +113 -0
- package/src/endpoints/delete-account.ts +129 -0
- package/src/endpoints/email-stats.ts +109 -0
- package/src/endpoints/export-csv.ts +84 -0
- package/src/endpoints/export-data.ts +104 -0
- package/src/endpoints/import-conversation.ts +307 -0
- package/src/endpoints/index.ts +154 -0
- package/src/endpoints/login.ts +92 -0
- package/src/endpoints/merge-clients.ts +132 -0
- package/src/endpoints/merge-tickets.ts +137 -0
- package/src/endpoints/oauth-google.ts +179 -0
- package/src/endpoints/pending-emails-process.ts +224 -0
- package/src/endpoints/presence.ts +104 -0
- package/src/endpoints/process-scheduled.ts +144 -0
- package/src/endpoints/purge-logs.ts +58 -0
- package/src/endpoints/resend-notification.ts +99 -0
- package/src/endpoints/round-robin-config.ts +92 -0
- package/src/endpoints/satisfaction.ts +93 -0
- package/src/endpoints/search.ts +106 -0
- package/src/endpoints/seed-kb.ts +153 -0
- package/src/endpoints/settings.ts +144 -0
- package/src/endpoints/signature.ts +93 -0
- package/src/endpoints/sla-check.ts +124 -0
- package/src/endpoints/split-ticket.ts +131 -0
- package/src/endpoints/statuses.ts +45 -0
- package/src/endpoints/track-open.ts +154 -0
- package/src/endpoints/typing.ts +101 -0
- package/src/endpoints/user-prefs.ts +125 -0
- package/src/hooks/checkSLA.ts +414 -0
- package/src/hooks/ticketStatusEmail.ts +182 -0
- package/src/index.ts +51 -0
- package/src/plugin.ts +157 -0
- package/src/portal/LiveChat.tsx +1353 -0
- package/src/portal/auth/ChatWidget.tsx +350 -0
- package/src/portal/auth/ChatbotWidget.tsx +285 -0
- package/src/portal/auth/SupportHeader.tsx +409 -0
- package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
- package/src/portal/auth/dashboard/page.tsx +84 -0
- package/src/portal/auth/faq/FAQSearch.tsx +117 -0
- package/src/portal/auth/faq/page.tsx +199 -0
- package/src/portal/auth/layout.tsx +61 -0
- package/src/portal/auth/profile/page.tsx +705 -0
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
- package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
- package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
- package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
- package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
- package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
- package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
- package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
- package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
- package/src/portal/auth/tickets/detail/page.tsx +738 -0
- package/src/portal/auth/tickets/new/page.tsx +515 -0
- package/src/portal/forgot-password/page.tsx +114 -0
- package/src/portal/layout.tsx +26 -0
- package/src/portal/locales/en.json +374 -0
- package/src/portal/locales/fr.json +374 -0
- package/src/portal/login/page.tsx +351 -0
- package/src/portal/page.tsx +162 -0
- package/src/portal/register/page.tsx +281 -0
- package/src/portal/reset-password/page.tsx +152 -0
- package/src/styles/BillingView.module.scss +311 -0
- package/src/styles/ChatView.module.scss +438 -0
- package/src/styles/CommandPalette.module.scss +160 -0
- package/src/styles/CrmView.module.scss +554 -0
- package/src/styles/EmailTracking.module.scss +238 -0
- package/src/styles/ImportConversation.module.scss +267 -0
- package/src/styles/Layout.module.scss +55 -0
- package/src/styles/Logs.module.scss +164 -0
- package/src/styles/NewTicket.module.scss +143 -0
- package/src/styles/PendingEmails.module.scss +629 -0
- package/src/styles/SupportDashboard.module.scss +649 -0
- package/src/styles/TicketDetail.module.scss +1043 -0
- package/src/styles/TicketInbox.module.scss +296 -0
- package/src/styles/TicketingSettings.module.scss +358 -0
- package/src/styles/TimeDashboard.module.scss +287 -0
- package/src/styles/_tokens.scss +78 -0
- package/src/styles/theme.css +633 -0
- package/src/types.ts +255 -0
- package/src/utils/adminNotification.ts +38 -0
- package/src/utils/auth.ts +46 -0
- package/src/utils/emailTemplate.ts +343 -0
- package/src/utils/fireWebhooks.ts +84 -0
- package/src/utils/index.ts +22 -0
- package/src/utils/rateLimiter.ts +52 -0
- package/src/utils/readSettings.ts +67 -0
- package/src/utils/slugs.ts +54 -0
- package/src/utils/webhookDispatcher.ts +120 -0
- package/src/views/BillingView/client.tsx +137 -0
- package/src/views/BillingView/index.tsx +33 -0
- package/src/views/ChatView/client.tsx +294 -0
- package/src/views/ChatView/index.tsx +33 -0
- package/src/views/CrmView/client.tsx +206 -0
- package/src/views/CrmView/index.tsx +33 -0
- package/src/views/EmailTrackingView/client.tsx +124 -0
- package/src/views/EmailTrackingView/index.tsx +33 -0
- package/src/views/ImportConversationView/client.tsx +133 -0
- package/src/views/ImportConversationView/index.tsx +33 -0
- package/src/views/LogsView/client.tsx +151 -0
- package/src/views/LogsView/index.tsx +30 -0
- package/src/views/NewTicketView/client.tsx +227 -0
- package/src/views/NewTicketView/index.tsx +30 -0
- package/src/views/PendingEmailsView/client.tsx +177 -0
- package/src/views/PendingEmailsView/index.tsx +33 -0
- package/src/views/SupportDashboardView/client.tsx +424 -0
- package/src/views/SupportDashboardView/index.tsx +33 -0
- package/src/views/TicketDetailView/client.tsx +775 -0
- package/src/views/TicketDetailView/index.tsx +33 -0
- package/src/views/TicketInboxView/client.tsx +313 -0
- package/src/views/TicketInboxView/index.tsx +30 -0
- package/src/views/TicketingSettingsView/client.tsx +866 -0
- package/src/views/TicketingSettingsView/index.tsx +33 -0
- package/src/views/TimeDashboardView/client.tsx +144 -0
- package/src/views/TimeDashboardView/index.tsx +33 -0
- package/src/views/shared/AdminViewHeader.tsx +69 -0
- package/src/views/shared/ErrorBoundary.tsx +68 -0
- package/src/views/shared/Skeleton.tsx +125 -0
- package/src/views/shared/adminTokens.ts +37 -0
- package/src/views/shared/config.ts +82 -0
- package/src/views/shared/index.ts +6 -0
- package/src/views.ts +16 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { escapeHtml } from '../utils/emailTemplate'
|
|
4
|
+
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
5
|
+
import { readSupportSettings } from '../utils/readSettings'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POST /api/support/pending-emails/:id/process
|
|
9
|
+
* Admin-only endpoint to process a pending email.
|
|
10
|
+
*/
|
|
11
|
+
export function createPendingEmailsProcessEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
12
|
+
return {
|
|
13
|
+
path: '/support/pending-emails/:id/process',
|
|
14
|
+
method: 'post',
|
|
15
|
+
handler: async (req) => {
|
|
16
|
+
try {
|
|
17
|
+
const payload = req.payload
|
|
18
|
+
|
|
19
|
+
requireAdmin(req, slugs)
|
|
20
|
+
|
|
21
|
+
const id = req.routeParams?.id as string
|
|
22
|
+
const body = await req.json!()
|
|
23
|
+
const { action, ticketId, clientId: overrideClientId } = body as {
|
|
24
|
+
action: string
|
|
25
|
+
ticketId?: number
|
|
26
|
+
clientId?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!['create_ticket', 'add_to_ticket', 'ignore'].includes(action)) {
|
|
30
|
+
return Response.json({ error: 'Invalid action' }, { status: 400 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const pendingEmail = await payload.findByID({
|
|
34
|
+
collection: slugs.pendingEmails as any,
|
|
35
|
+
id: Number(id),
|
|
36
|
+
depth: 1,
|
|
37
|
+
overrideAccess: true,
|
|
38
|
+
}) as any
|
|
39
|
+
|
|
40
|
+
if (!pendingEmail) {
|
|
41
|
+
return Response.json({ error: 'Pending email not found' }, { status: 404 })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (pendingEmail.status !== 'pending') {
|
|
45
|
+
return Response.json({ error: 'Email already processed' }, { status: 409 })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let clientId = overrideClientId || (typeof pendingEmail.client === 'object' ? pendingEmail.client?.id : pendingEmail.client)
|
|
49
|
+
let clientDoc = typeof pendingEmail.client === 'object' ? pendingEmail.client : null
|
|
50
|
+
|
|
51
|
+
if (overrideClientId && overrideClientId !== clientDoc?.id) {
|
|
52
|
+
clientDoc = await payload.findByID({
|
|
53
|
+
collection: slugs.supportClients as any,
|
|
54
|
+
id: overrideClientId,
|
|
55
|
+
depth: 0,
|
|
56
|
+
overrideAccess: true,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
await payload.update({
|
|
60
|
+
collection: slugs.pendingEmails as any,
|
|
61
|
+
id: Number(id),
|
|
62
|
+
data: { client: overrideClientId },
|
|
63
|
+
overrideAccess: true,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const settings = await readSupportSettings(payload)
|
|
68
|
+
const clientEmail = clientDoc?.email || pendingEmail.senderEmail || ''
|
|
69
|
+
const clientName = clientDoc?.firstName || pendingEmail.senderName || clientEmail
|
|
70
|
+
const portalUrl = `${process.env.NEXT_PUBLIC_SERVER_URL || ''}/support/dashboard`
|
|
71
|
+
|
|
72
|
+
if (!clientId && action !== 'ignore') {
|
|
73
|
+
return Response.json({ error: 'No client associated with this pending email' }, { status: 400 })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const attachments = (pendingEmail.attachments as Array<{ file: { id: number } | number }> | undefined)?.map((a: any) => ({
|
|
77
|
+
file: typeof a.file === 'object' ? a.file.id : a.file,
|
|
78
|
+
})) || []
|
|
79
|
+
|
|
80
|
+
if (action === 'ignore') {
|
|
81
|
+
await payload.update({
|
|
82
|
+
collection: slugs.pendingEmails as any,
|
|
83
|
+
id: Number(id),
|
|
84
|
+
data: {
|
|
85
|
+
status: 'ignored',
|
|
86
|
+
processedAction: 'ignored',
|
|
87
|
+
processedAt: new Date().toISOString(),
|
|
88
|
+
},
|
|
89
|
+
overrideAccess: true,
|
|
90
|
+
})
|
|
91
|
+
return Response.json({ action: 'ignored', pendingEmailId: Number(id) })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (action === 'create_ticket') {
|
|
95
|
+
const newTicket = await payload.create({
|
|
96
|
+
collection: slugs.tickets as any,
|
|
97
|
+
data: {
|
|
98
|
+
subject: pendingEmail.subject,
|
|
99
|
+
client: clientId!,
|
|
100
|
+
status: 'open',
|
|
101
|
+
priority: 'normal',
|
|
102
|
+
category: 'question',
|
|
103
|
+
source: 'email',
|
|
104
|
+
},
|
|
105
|
+
overrideAccess: true,
|
|
106
|
+
}) as any
|
|
107
|
+
|
|
108
|
+
await payload.create({
|
|
109
|
+
collection: slugs.ticketMessages as any,
|
|
110
|
+
data: {
|
|
111
|
+
ticket: newTicket.id,
|
|
112
|
+
body: pendingEmail.body,
|
|
113
|
+
authorType: 'email',
|
|
114
|
+
authorClient: clientId,
|
|
115
|
+
isInternal: false,
|
|
116
|
+
...(attachments.length > 0 && { attachments }),
|
|
117
|
+
},
|
|
118
|
+
overrideAccess: true,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Send confirmation email
|
|
122
|
+
try {
|
|
123
|
+
await payload.sendEmail({
|
|
124
|
+
to: clientEmail,
|
|
125
|
+
replyTo: settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || '',
|
|
126
|
+
subject: `[${newTicket.ticketNumber}] ${pendingEmail.subject}`,
|
|
127
|
+
html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
128
|
+
<p>Bonjour <strong>${escapeHtml(clientName)}</strong>,</p>
|
|
129
|
+
<p>Votre demande a été enregistrée sous le numéro <strong>${newTicket.ticketNumber}</strong>.</p>
|
|
130
|
+
<p>Sujet : ${escapeHtml(pendingEmail.subject)}</p>
|
|
131
|
+
<p><a href="${portalUrl}">Accéder à mon espace</a></p>
|
|
132
|
+
<p style="font-size: 14px; color: #666;">Vous pouvez aussi répondre directement à cet email.</p>
|
|
133
|
+
</div>`,
|
|
134
|
+
})
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error('[pending-email-process] Failed to send notification:', err)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await payload.update({
|
|
140
|
+
collection: slugs.pendingEmails as any,
|
|
141
|
+
id: Number(id),
|
|
142
|
+
data: {
|
|
143
|
+
status: 'processed',
|
|
144
|
+
processedAction: 'ticket_created',
|
|
145
|
+
processedTicket: newTicket.id,
|
|
146
|
+
processedAt: new Date().toISOString(),
|
|
147
|
+
},
|
|
148
|
+
overrideAccess: true,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
return Response.json({
|
|
152
|
+
action: 'ticket_created',
|
|
153
|
+
ticketNumber: newTicket.ticketNumber,
|
|
154
|
+
ticketId: newTicket.id,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (action === 'add_to_ticket') {
|
|
159
|
+
if (!ticketId) {
|
|
160
|
+
return Response.json({ error: 'ticketId is required for add_to_ticket' }, { status: 400 })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const targetTicket = await payload.findByID({
|
|
164
|
+
collection: slugs.tickets as any,
|
|
165
|
+
id: ticketId,
|
|
166
|
+
depth: 0,
|
|
167
|
+
overrideAccess: true,
|
|
168
|
+
}) as any
|
|
169
|
+
|
|
170
|
+
if (!targetTicket) {
|
|
171
|
+
return Response.json({ error: 'Target ticket not found' }, { status: 404 })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await payload.create({
|
|
175
|
+
collection: slugs.ticketMessages as any,
|
|
176
|
+
data: {
|
|
177
|
+
ticket: ticketId,
|
|
178
|
+
body: pendingEmail.body,
|
|
179
|
+
authorType: 'email',
|
|
180
|
+
authorClient: clientId,
|
|
181
|
+
isInternal: false,
|
|
182
|
+
...(attachments.length > 0 && { attachments }),
|
|
183
|
+
},
|
|
184
|
+
overrideAccess: true,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
if (targetTicket.status === 'resolved') {
|
|
188
|
+
await payload.update({
|
|
189
|
+
collection: slugs.tickets as any,
|
|
190
|
+
id: ticketId,
|
|
191
|
+
data: { status: 'open' },
|
|
192
|
+
overrideAccess: true,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await payload.update({
|
|
197
|
+
collection: slugs.pendingEmails as any,
|
|
198
|
+
id: Number(id),
|
|
199
|
+
data: {
|
|
200
|
+
status: 'processed',
|
|
201
|
+
processedAction: 'message_added',
|
|
202
|
+
processedTicket: ticketId,
|
|
203
|
+
processedAt: new Date().toISOString(),
|
|
204
|
+
},
|
|
205
|
+
overrideAccess: true,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
return Response.json({
|
|
209
|
+
action: 'message_added',
|
|
210
|
+
ticketNumber: targetTicket.ticketNumber,
|
|
211
|
+
ticketId: targetTicket.id,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return Response.json({ error: 'Invalid action' }, { status: 400 })
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const authResponse = handleAuthError(error)
|
|
218
|
+
if (authResponse) return authResponse
|
|
219
|
+
console.error('[pending-email-process] Error:', error)
|
|
220
|
+
return Response.json({ error: 'Internal server error' }, { status: 500 })
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
+
|
|
5
|
+
// In-memory presence state: ticketId -> Map<userId, { name, email, timestamp }>
|
|
6
|
+
const presenceState = new Map<string, Map<string | number, { name: string; email: string; ts: number }>>()
|
|
7
|
+
|
|
8
|
+
const PRESENCE_TTL = 30_000 // 30 seconds
|
|
9
|
+
|
|
10
|
+
function cleanExpired(ticketId: string) {
|
|
11
|
+
const viewers = presenceState.get(ticketId)
|
|
12
|
+
if (!viewers) return
|
|
13
|
+
const now = Date.now()
|
|
14
|
+
for (const [userId, entry] of viewers) {
|
|
15
|
+
if (now - entry.ts > PRESENCE_TTL) {
|
|
16
|
+
viewers.delete(userId)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (viewers.size === 0) presenceState.delete(ticketId)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* POST /api/support/presence — Register or remove presence on a ticket
|
|
24
|
+
*/
|
|
25
|
+
export function createPresencePostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
26
|
+
return {
|
|
27
|
+
path: '/support/presence',
|
|
28
|
+
method: 'post',
|
|
29
|
+
handler: async (req) => {
|
|
30
|
+
try {
|
|
31
|
+
requireAdmin(req, slugs)
|
|
32
|
+
|
|
33
|
+
const { ticketId, action } = (await req.json!()) as { ticketId: number; action: 'join' | 'leave' }
|
|
34
|
+
if (!ticketId || !action) {
|
|
35
|
+
return Response.json({ error: 'ticketId and action required' }, { status: 400 })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const key = String(ticketId)
|
|
39
|
+
|
|
40
|
+
if (action === 'join') {
|
|
41
|
+
if (!presenceState.has(key)) {
|
|
42
|
+
presenceState.set(key, new Map())
|
|
43
|
+
}
|
|
44
|
+
presenceState.get(key)!.set(req.user.id, {
|
|
45
|
+
name: (req.user as any).firstName || req.user.email || 'Admin',
|
|
46
|
+
email: req.user.email || '',
|
|
47
|
+
ts: Date.now(),
|
|
48
|
+
})
|
|
49
|
+
} else if (action === 'leave') {
|
|
50
|
+
presenceState.get(key)?.delete(req.user.id)
|
|
51
|
+
if (presenceState.get(key)?.size === 0) presenceState.delete(key)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return Response.json({ ok: true })
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const authResponse = handleAuthError(error)
|
|
57
|
+
if (authResponse) return authResponse
|
|
58
|
+
console.warn('[presence] POST error:', error)
|
|
59
|
+
return Response.json({ error: 'Error' }, { status: 500 })
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* GET /api/support/presence?ticketId=123 — Get list of admins viewing this ticket
|
|
67
|
+
*/
|
|
68
|
+
export function createPresenceGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
69
|
+
return {
|
|
70
|
+
path: '/support/presence',
|
|
71
|
+
method: 'get',
|
|
72
|
+
handler: async (req) => {
|
|
73
|
+
try {
|
|
74
|
+
requireAdmin(req, slugs)
|
|
75
|
+
|
|
76
|
+
const url = new URL(req.url!)
|
|
77
|
+
const ticketId = url.searchParams.get('ticketId')
|
|
78
|
+
if (!ticketId) {
|
|
79
|
+
return Response.json({ error: 'ticketId required' }, { status: 400 })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
cleanExpired(ticketId)
|
|
83
|
+
const viewers = presenceState.get(ticketId)
|
|
84
|
+
|
|
85
|
+
if (!viewers || viewers.size === 0) {
|
|
86
|
+
return Response.json({ viewers: [] })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const result: { name: string; email: string }[] = []
|
|
90
|
+
for (const [userId, entry] of viewers) {
|
|
91
|
+
if (userId !== req.user.id) {
|
|
92
|
+
result.push({ name: entry.name, email: entry.email })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Response.json({ viewers: result })
|
|
97
|
+
} catch (error) {
|
|
98
|
+
const authResponse = handleAuthError(error)
|
|
99
|
+
if (authResponse) return authResponse
|
|
100
|
+
return Response.json({ viewers: [] })
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { escapeHtml } from '../utils/emailTemplate'
|
|
4
|
+
import { fireWebhooks } from '../utils/fireWebhooks'
|
|
5
|
+
import { readSupportSettings } from '../utils/readSettings'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POST /api/support/process-scheduled
|
|
9
|
+
* Process all scheduled replies whose scheduledAt has passed and scheduledSent is false.
|
|
10
|
+
* Protected by x-cron-secret header (same as auto-close).
|
|
11
|
+
*/
|
|
12
|
+
export function createProcessScheduledEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
13
|
+
return {
|
|
14
|
+
path: '/support/process-scheduled',
|
|
15
|
+
method: 'post',
|
|
16
|
+
handler: async (req) => {
|
|
17
|
+
const secret = req.headers.get('x-cron-secret')
|
|
18
|
+
const expectedSecret = process.env.CRON_SECRET
|
|
19
|
+
|
|
20
|
+
if (!expectedSecret || secret !== expectedSecret) {
|
|
21
|
+
return Response.json({ error: 'Non autorisé' }, { status: 401 })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const payload = req.payload
|
|
26
|
+
const now = new Date()
|
|
27
|
+
const settings = await readSupportSettings(payload)
|
|
28
|
+
const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || ''
|
|
29
|
+
const results = { processed: 0, errors: 0 }
|
|
30
|
+
|
|
31
|
+
// Find all messages where scheduledAt is in the past and scheduledSent is not true
|
|
32
|
+
const scheduled = await payload.find({
|
|
33
|
+
collection: slugs.ticketMessages as any,
|
|
34
|
+
where: {
|
|
35
|
+
and: [
|
|
36
|
+
{ scheduledAt: { less_than_equal: now.toISOString() } },
|
|
37
|
+
{
|
|
38
|
+
or: [
|
|
39
|
+
{ scheduledSent: { equals: false } },
|
|
40
|
+
{ scheduledSent: { exists: false } },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
limit: 100,
|
|
46
|
+
depth: 0,
|
|
47
|
+
overrideAccess: true,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
for (const message of scheduled.docs) {
|
|
51
|
+
try {
|
|
52
|
+
const msg = message as any
|
|
53
|
+
const ticketId = typeof msg.ticket === 'object' ? msg.ticket.id : msg.ticket
|
|
54
|
+
|
|
55
|
+
// Fetch the ticket with client data
|
|
56
|
+
const ticket = await payload.findByID({
|
|
57
|
+
collection: slugs.tickets as any,
|
|
58
|
+
id: ticketId,
|
|
59
|
+
depth: 1,
|
|
60
|
+
overrideAccess: true,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
if (!ticket) {
|
|
64
|
+
console.warn(`[process-scheduled] Ticket ${ticketId} not found for message ${msg.id}`)
|
|
65
|
+
results.errors++
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const t = ticket as any
|
|
70
|
+
const client = typeof t.client === 'object' ? t.client : null
|
|
71
|
+
|
|
72
|
+
// Send email notification to client (same logic as createNotifyClient hook)
|
|
73
|
+
if (client?.email && msg.authorType === 'admin' && !msg.isInternal) {
|
|
74
|
+
const ticketNumber = t.ticketNumber || 'TK-????'
|
|
75
|
+
const subject = t.subject || 'Support'
|
|
76
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
77
|
+
const preview = msg.body?.length > 500 ? msg.body.slice(0, 500) + '...' : msg.body
|
|
78
|
+
|
|
79
|
+
await payload.sendEmail({
|
|
80
|
+
to: client.email,
|
|
81
|
+
...(replyTo ? { replyTo } : {}),
|
|
82
|
+
subject: `Re: [${ticketNumber}] ${subject}`,
|
|
83
|
+
html: `<p>Bonjour ${escapeHtml(client.firstName || '')},</p><p>Notre équipe a répondu à votre ticket <strong>${escapeHtml(ticketNumber)}</strong>.</p><blockquote style="border-left:4px solid #2563eb;padding:12px;margin:16px 0;background:#f8fafc;">${escapeHtml(preview)}</blockquote><p><a href="${baseUrl}/support/tickets/${ticketId}">Consulter le ticket</a></p>`,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Record email delivery info
|
|
87
|
+
await payload.update({
|
|
88
|
+
collection: slugs.ticketMessages as any,
|
|
89
|
+
id: msg.id,
|
|
90
|
+
data: {
|
|
91
|
+
scheduledSent: true,
|
|
92
|
+
emailSentAt: now.toISOString(),
|
|
93
|
+
emailSentTo: client.email,
|
|
94
|
+
},
|
|
95
|
+
overrideAccess: true,
|
|
96
|
+
})
|
|
97
|
+
} else {
|
|
98
|
+
// No email needed (internal note or no client email), just mark as sent
|
|
99
|
+
await payload.update({
|
|
100
|
+
collection: slugs.ticketMessages as any,
|
|
101
|
+
id: msg.id,
|
|
102
|
+
data: { scheduledSent: true },
|
|
103
|
+
overrideAccess: true,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Update ticket status if this is an admin reply
|
|
108
|
+
if (msg.authorType === 'admin' && !msg.isInternal) {
|
|
109
|
+
await payload.update({
|
|
110
|
+
collection: slugs.tickets as any,
|
|
111
|
+
id: ticketId,
|
|
112
|
+
data: { status: 'waiting_client' },
|
|
113
|
+
overrideAccess: true,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fire webhook for the now-sent reply
|
|
118
|
+
fireWebhooks(payload, slugs, 'ticket_replied', {
|
|
119
|
+
ticketId,
|
|
120
|
+
messageId: msg.id,
|
|
121
|
+
authorType: msg.authorType,
|
|
122
|
+
scheduled: true,
|
|
123
|
+
body: msg.body?.length > 500 ? msg.body.slice(0, 500) + '...' : msg.body,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
results.processed++
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.error(`[process-scheduled] Error processing message ${(message as any).id}:`, err)
|
|
129
|
+
results.errors++
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return Response.json({
|
|
134
|
+
success: true,
|
|
135
|
+
...results,
|
|
136
|
+
timestamp: now.toISOString(),
|
|
137
|
+
})
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error('[process-scheduled] Error:', error)
|
|
140
|
+
return Response.json({ error: 'Erreur interne' }, { status: 500 })
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DELETE /api/support/purge-logs?collection=email-logs&days=30
|
|
7
|
+
* Purge old logs older than X days. Admin-only.
|
|
8
|
+
*/
|
|
9
|
+
export function createPurgeLogsEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
10
|
+
return {
|
|
11
|
+
path: '/support/purge-logs',
|
|
12
|
+
method: 'delete',
|
|
13
|
+
handler: async (req) => {
|
|
14
|
+
try {
|
|
15
|
+
const payload = req.payload
|
|
16
|
+
|
|
17
|
+
requireAdmin(req, slugs)
|
|
18
|
+
|
|
19
|
+
const url = new URL(req.url!)
|
|
20
|
+
const collection = url.searchParams.get('collection')
|
|
21
|
+
const days = Number(url.searchParams.get('days') || '0')
|
|
22
|
+
|
|
23
|
+
// Map collection param to slug
|
|
24
|
+
const allowedCollections: Record<string, string> = {
|
|
25
|
+
'email-logs': slugs.emailLogs,
|
|
26
|
+
'auth-logs': slugs.authLogs,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!collection || !allowedCollections[collection]) {
|
|
30
|
+
return Response.json({ error: 'Invalid collection. Use email-logs or auth-logs.' }, { status: 400 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const cutoff = days > 0 ? new Date(Date.now() - days * 86400000).toISOString() : null
|
|
34
|
+
|
|
35
|
+
const result = await payload.delete({
|
|
36
|
+
collection: allowedCollections[collection] as any,
|
|
37
|
+
where: cutoff
|
|
38
|
+
? { createdAt: { less_than: cutoff } }
|
|
39
|
+
: { id: { exists: true } },
|
|
40
|
+
overrideAccess: true,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const count = Array.isArray(result.docs) ? result.docs.length : 0
|
|
44
|
+
|
|
45
|
+
return Response.json({
|
|
46
|
+
purged: count,
|
|
47
|
+
collection,
|
|
48
|
+
days: days || 'all',
|
|
49
|
+
})
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const authResponse = handleAuthError(error)
|
|
52
|
+
if (authResponse) return authResponse
|
|
53
|
+
console.error('[purge-logs] Error:', error)
|
|
54
|
+
return Response.json({ error: 'Internal server error' }, { status: 500 })
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
+
import { RateLimiter } from '../utils/rateLimiter'
|
|
5
|
+
import { escapeHtml } from '../utils/emailTemplate'
|
|
6
|
+
import { readSupportSettings } from '../utils/readSettings'
|
|
7
|
+
|
|
8
|
+
const resendLimiter = new RateLimiter(60 * 60 * 1000, 10) // 10 per hour
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* POST /api/support/resend-notification
|
|
12
|
+
* Resend the email notification for a specific ticket message. Admin-only.
|
|
13
|
+
*/
|
|
14
|
+
export function createResendNotificationEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
15
|
+
return {
|
|
16
|
+
path: '/support/resend-notification',
|
|
17
|
+
method: 'post',
|
|
18
|
+
handler: async (req) => {
|
|
19
|
+
try {
|
|
20
|
+
const payload = req.payload
|
|
21
|
+
|
|
22
|
+
requireAdmin(req, slugs)
|
|
23
|
+
|
|
24
|
+
if (resendLimiter.check(String(req.user.id))) {
|
|
25
|
+
return Response.json(
|
|
26
|
+
{ error: 'Trop de renvois. Réessayez dans une heure.' },
|
|
27
|
+
{ status: 429 },
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let body: { messageId?: string | number }
|
|
32
|
+
try {
|
|
33
|
+
body = await req.json!()
|
|
34
|
+
} catch {
|
|
35
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
36
|
+
}
|
|
37
|
+
const { messageId } = body
|
|
38
|
+
|
|
39
|
+
if (!messageId) {
|
|
40
|
+
return Response.json({ error: 'messageId requis' }, { status: 400 })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const message = await payload.findByID({
|
|
44
|
+
collection: slugs.ticketMessages as any,
|
|
45
|
+
id: messageId,
|
|
46
|
+
depth: 0,
|
|
47
|
+
overrideAccess: true,
|
|
48
|
+
}) as any
|
|
49
|
+
|
|
50
|
+
if (!message) {
|
|
51
|
+
return Response.json({ error: 'Message introuvable' }, { status: 404 })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ticketId = typeof message.ticket === 'object' ? message.ticket.id : message.ticket
|
|
55
|
+
const ticket = await payload.findByID({
|
|
56
|
+
collection: slugs.tickets as any,
|
|
57
|
+
id: ticketId,
|
|
58
|
+
depth: 1,
|
|
59
|
+
overrideAccess: true,
|
|
60
|
+
}) as any
|
|
61
|
+
|
|
62
|
+
if (!ticket) {
|
|
63
|
+
return Response.json({ error: 'Ticket introuvable' }, { status: 404 })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const client = typeof ticket.client === 'object' ? ticket.client : null
|
|
67
|
+
if (!client?.email) {
|
|
68
|
+
return Response.json({ error: 'Client sans email' }, { status: 400 })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const settings = await readSupportSettings(payload)
|
|
72
|
+
const ticketNumber = ticket.ticketNumber || 'TK-????'
|
|
73
|
+
const subject = ticket.subject || 'Support'
|
|
74
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
75
|
+
const portalUrl = `${baseUrl}/support/tickets/${ticketId}`
|
|
76
|
+
const preview = message.body?.length > 500 ? message.body.slice(0, 500) + '...' : message.body
|
|
77
|
+
|
|
78
|
+
await payload.sendEmail({
|
|
79
|
+
to: client.email,
|
|
80
|
+
replyTo: settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || '',
|
|
81
|
+
subject: `Re: [${ticketNumber}] ${subject}`,
|
|
82
|
+
html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
83
|
+
<p>Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,</p>
|
|
84
|
+
<p>Notre équipe a apporté une réponse à votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em>.</p>
|
|
85
|
+
<blockquote style="border-left: 4px solid #ccc; padding: 8px 16px; margin: 16px 0; color: #555;">${escapeHtml(preview)}</blockquote>
|
|
86
|
+
<p><a href="${portalUrl}">Consulter le ticket</a></p>
|
|
87
|
+
</div>`,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return Response.json({ success: true })
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const authResponse = handleAuthError(error)
|
|
93
|
+
if (authResponse) return authResponse
|
|
94
|
+
console.error('[resend-notification] Error:', error)
|
|
95
|
+
return Response.json({ error: 'Erreur interne' }, { status: 500 })
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|