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