@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,127 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { requireClient, handleAuthError } from '../utils/auth'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/support/chat-stream?session=xxx
|
|
7
|
+
* Server-Sent Events endpoint for real-time chat messages (client-side).
|
|
8
|
+
* Replaces polling on GET /api/support/chat for connected clients.
|
|
9
|
+
* The existing polling endpoint is kept as fallback.
|
|
10
|
+
*/
|
|
11
|
+
export function createChatStreamEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
12
|
+
return {
|
|
13
|
+
path: '/support/chat-stream',
|
|
14
|
+
method: 'get',
|
|
15
|
+
handler: async (req) => {
|
|
16
|
+
try {
|
|
17
|
+
requireClient(req, slugs)
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const authResponse = handleAuthError(error)
|
|
20
|
+
if (authResponse) return authResponse
|
|
21
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const url = new URL(req.url!)
|
|
25
|
+
const sessionId = url.searchParams.get('session')
|
|
26
|
+
const userId = req.user!.id
|
|
27
|
+
|
|
28
|
+
if (!sessionId) {
|
|
29
|
+
return Response.json({ error: 'Missing session parameter' }, { status: 400 })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Verify session belongs to user
|
|
33
|
+
const existing = await req.payload.find({
|
|
34
|
+
collection: slugs.chatMessages as any,
|
|
35
|
+
where: {
|
|
36
|
+
session: { equals: sessionId },
|
|
37
|
+
client: { equals: userId },
|
|
38
|
+
},
|
|
39
|
+
limit: 1,
|
|
40
|
+
overrideAccess: true,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if (existing.docs.length === 0) {
|
|
44
|
+
return Response.json({ error: 'Session invalide' }, { status: 403 })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let lastCheck = new Date().toISOString()
|
|
48
|
+
const encoder = new TextEncoder()
|
|
49
|
+
|
|
50
|
+
const stream = new ReadableStream({
|
|
51
|
+
start(controller) {
|
|
52
|
+
// Send initial connection event
|
|
53
|
+
controller.enqueue(
|
|
54
|
+
encoder.encode(`data: ${JSON.stringify({ type: 'connected', session: sessionId })}\n\n`),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// Poll DB every 2 seconds for new messages
|
|
58
|
+
const interval = setInterval(async () => {
|
|
59
|
+
try {
|
|
60
|
+
const messages = await req.payload.find({
|
|
61
|
+
collection: slugs.chatMessages as any,
|
|
62
|
+
where: {
|
|
63
|
+
session: { equals: sessionId },
|
|
64
|
+
createdAt: { greater_than: lastCheck },
|
|
65
|
+
},
|
|
66
|
+
sort: 'createdAt',
|
|
67
|
+
limit: 50,
|
|
68
|
+
depth: 1,
|
|
69
|
+
overrideAccess: true,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (messages.docs.length > 0) {
|
|
73
|
+
const data = JSON.stringify({
|
|
74
|
+
type: 'messages',
|
|
75
|
+
data: messages.docs,
|
|
76
|
+
})
|
|
77
|
+
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
78
|
+
lastCheck = (messages.docs[messages.docs.length - 1] as any).createdAt
|
|
79
|
+
|
|
80
|
+
// Check if session was closed
|
|
81
|
+
const lastMsg = messages.docs[messages.docs.length - 1] as any
|
|
82
|
+
if (lastMsg.status === 'closed') {
|
|
83
|
+
controller.enqueue(
|
|
84
|
+
encoder.encode(
|
|
85
|
+
`data: ${JSON.stringify({ type: 'closed', session: sessionId })}\n\n`,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.warn('[support] chat-stream SSE poll error:', error)
|
|
92
|
+
}
|
|
93
|
+
}, 2000)
|
|
94
|
+
|
|
95
|
+
// Heartbeat every 30s to keep connection alive
|
|
96
|
+
const heartbeat = setInterval(() => {
|
|
97
|
+
try {
|
|
98
|
+
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
|
|
99
|
+
} catch {
|
|
100
|
+
// Stream already closed
|
|
101
|
+
}
|
|
102
|
+
}, 30000)
|
|
103
|
+
|
|
104
|
+
// Cleanup on client disconnect
|
|
105
|
+
req.signal?.addEventListener('abort', () => {
|
|
106
|
+
clearInterval(interval)
|
|
107
|
+
clearInterval(heartbeat)
|
|
108
|
+
try {
|
|
109
|
+
controller.close()
|
|
110
|
+
} catch {
|
|
111
|
+
// Already closed
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return new Response(stream, {
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'text/event-stream',
|
|
120
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
121
|
+
Connection: 'keep-alive',
|
|
122
|
+
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { Where } from 'payload'
|
|
3
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
4
|
+
import crypto from 'crypto'
|
|
5
|
+
import { RateLimiter } from '../utils/rateLimiter'
|
|
6
|
+
import { requireClient, handleAuthError } from '../utils/auth'
|
|
7
|
+
|
|
8
|
+
const chatSessionLimiter = new RateLimiter(3_600_000, 5) // 5 sessions per hour
|
|
9
|
+
const chatMessageLimiter = new RateLimiter(60_000, 15) // 15 messages per minute
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET /api/support/chat?session=xxx&after=timestamp
|
|
13
|
+
* Fetch chat messages for a session (polling). Client-only.
|
|
14
|
+
*/
|
|
15
|
+
export function createChatGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
16
|
+
return {
|
|
17
|
+
path: '/support/chat',
|
|
18
|
+
method: 'get',
|
|
19
|
+
handler: async (req) => {
|
|
20
|
+
try {
|
|
21
|
+
const payload = req.payload
|
|
22
|
+
|
|
23
|
+
requireClient(req, slugs)
|
|
24
|
+
|
|
25
|
+
const url = new URL(req.url!)
|
|
26
|
+
const session = url.searchParams.get('session')
|
|
27
|
+
const after = url.searchParams.get('after')
|
|
28
|
+
|
|
29
|
+
if (!session) {
|
|
30
|
+
return Response.json({ error: 'Session requise' }, { status: 400 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const where: Where = {
|
|
34
|
+
session: { equals: session },
|
|
35
|
+
client: { equals: req.user.id },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (after) {
|
|
39
|
+
where.createdAt = { greater_than: after }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const messages = await payload.find({
|
|
43
|
+
collection: slugs.chatMessages as any,
|
|
44
|
+
where,
|
|
45
|
+
sort: 'createdAt',
|
|
46
|
+
limit: 100,
|
|
47
|
+
depth: 1,
|
|
48
|
+
overrideAccess: true,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return new Response(JSON.stringify({ messages: messages.docs, session }), {
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
'Cache-Control': 'private, max-age=300, stale-while-revalidate=600',
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const authResponse = handleAuthError(error)
|
|
59
|
+
if (authResponse) return authResponse
|
|
60
|
+
console.error('[support/chat] GET Error:', error)
|
|
61
|
+
return Response.json({ error: 'Erreur interne du serveur' }, { status: 500 })
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* POST /api/support/chat
|
|
69
|
+
* Send a message or start a new chat session. Client-only.
|
|
70
|
+
*/
|
|
71
|
+
export function createChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
72
|
+
return {
|
|
73
|
+
path: '/support/chat',
|
|
74
|
+
method: 'post',
|
|
75
|
+
handler: async (req) => {
|
|
76
|
+
try {
|
|
77
|
+
const payload = req.payload
|
|
78
|
+
|
|
79
|
+
requireClient(req, slugs)
|
|
80
|
+
|
|
81
|
+
let body: { action?: string; session?: string; message?: string }
|
|
82
|
+
try {
|
|
83
|
+
body = await req.json!()
|
|
84
|
+
} catch {
|
|
85
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
86
|
+
}
|
|
87
|
+
const { action, session, message } = body
|
|
88
|
+
const userId = String(req.user.id)
|
|
89
|
+
|
|
90
|
+
// Start a new session
|
|
91
|
+
if (action === 'start') {
|
|
92
|
+
if (chatSessionLimiter.check(userId)) {
|
|
93
|
+
return Response.json({ error: 'Trop de sessions créées. Réessayez plus tard.' }, { status: 429 })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sessionId = `chat_${crypto.randomBytes(16).toString('hex')}`
|
|
97
|
+
|
|
98
|
+
const systemMsg = await payload.create({
|
|
99
|
+
collection: slugs.chatMessages as any,
|
|
100
|
+
data: {
|
|
101
|
+
session: sessionId,
|
|
102
|
+
client: req.user.id,
|
|
103
|
+
senderType: 'system',
|
|
104
|
+
message: 'Chat démarré. Un agent vous répondra sous peu.',
|
|
105
|
+
status: 'active',
|
|
106
|
+
},
|
|
107
|
+
overrideAccess: true,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
return Response.json({ session: sessionId, messages: [systemMsg] })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Send a message
|
|
114
|
+
if (action === 'send' && session && message) {
|
|
115
|
+
if (chatMessageLimiter.check(userId)) {
|
|
116
|
+
return Response.json({ error: 'Trop de messages. Attendez un moment.' }, { status: 429 })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const trimmedMessage = String(message).trim()
|
|
120
|
+
if (!trimmedMessage || trimmedMessage.length > 2000) {
|
|
121
|
+
return Response.json({ error: 'Message invalide (1-2000 caractères).' }, { status: 400 })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Verify session belongs to user
|
|
125
|
+
const existing = await payload.find({
|
|
126
|
+
collection: slugs.chatMessages as any,
|
|
127
|
+
where: { session: { equals: session }, client: { equals: req.user.id } },
|
|
128
|
+
limit: 1,
|
|
129
|
+
overrideAccess: true,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
if (existing.docs.length === 0) {
|
|
133
|
+
return Response.json({ error: 'Session invalide' }, { status: 403 })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const newMsg = await payload.create({
|
|
137
|
+
collection: slugs.chatMessages as any,
|
|
138
|
+
data: {
|
|
139
|
+
session,
|
|
140
|
+
client: req.user.id,
|
|
141
|
+
senderType: 'client',
|
|
142
|
+
message: trimmedMessage,
|
|
143
|
+
status: 'active',
|
|
144
|
+
},
|
|
145
|
+
overrideAccess: true,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return Response.json({ message: newMsg })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Close session
|
|
152
|
+
if (action === 'close' && session) {
|
|
153
|
+
const existing = await payload.find({
|
|
154
|
+
collection: slugs.chatMessages as any,
|
|
155
|
+
where: { session: { equals: session }, client: { equals: req.user.id } },
|
|
156
|
+
limit: 1,
|
|
157
|
+
overrideAccess: true,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
if (existing.docs.length === 0) {
|
|
161
|
+
return Response.json({ error: 'Session invalide' }, { status: 403 })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await payload.create({
|
|
165
|
+
collection: slugs.chatMessages as any,
|
|
166
|
+
data: {
|
|
167
|
+
session,
|
|
168
|
+
client: req.user.id,
|
|
169
|
+
senderType: 'system',
|
|
170
|
+
message: 'Chat terminé par le client.',
|
|
171
|
+
status: 'closed',
|
|
172
|
+
},
|
|
173
|
+
overrideAccess: true,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
return Response.json({ closed: true })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return Response.json({ error: 'Action invalide' }, { status: 400 })
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const authResponse = handleAuthError(error)
|
|
182
|
+
if (authResponse) return authResponse
|
|
183
|
+
console.error('[support/chat] POST Error:', error)
|
|
184
|
+
return Response.json({ error: 'Erreur interne du serveur' }, { status: 500 })
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { RateLimiter } from '../utils/rateLimiter'
|
|
4
|
+
|
|
5
|
+
const chatbotLimiter = new RateLimiter(60_000, 10) // 10 requests per minute per IP
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POST /api/support/chatbot
|
|
9
|
+
* AI chatbot that answers from the knowledge base before creating a ticket.
|
|
10
|
+
* Public endpoint (accessible from the support portal).
|
|
11
|
+
*/
|
|
12
|
+
export function createChatbotEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
13
|
+
return {
|
|
14
|
+
path: '/support/chatbot',
|
|
15
|
+
method: 'post',
|
|
16
|
+
handler: async (req) => {
|
|
17
|
+
try {
|
|
18
|
+
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
|
|
19
|
+
if (chatbotLimiter.check(ip)) {
|
|
20
|
+
return Response.json({ error: 'Too many requests. Please wait a moment.' }, { status: 429 })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let body: { question?: string }
|
|
24
|
+
try {
|
|
25
|
+
body = await req.json!()
|
|
26
|
+
} catch {
|
|
27
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
28
|
+
}
|
|
29
|
+
const { question } = body
|
|
30
|
+
|
|
31
|
+
if (!question?.trim() || question.trim().length < 5) {
|
|
32
|
+
return Response.json({ error: 'Question too short' }, { status: 400 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const payload = req.payload
|
|
36
|
+
|
|
37
|
+
const articles = await payload.find({
|
|
38
|
+
collection: slugs.knowledgeBase as any,
|
|
39
|
+
where: { published: { equals: true } },
|
|
40
|
+
limit: 100,
|
|
41
|
+
depth: 0,
|
|
42
|
+
overrideAccess: true,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
if (articles.docs.length === 0) {
|
|
46
|
+
return Response.json({
|
|
47
|
+
answer: null,
|
|
48
|
+
confidence: 0,
|
|
49
|
+
suggestion: 'create_ticket',
|
|
50
|
+
message: 'Aucun article disponible. Créez un ticket pour obtenir de l\'aide.',
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const knowledgeContext = articles.docs
|
|
55
|
+
.map((a: any) => `## ${a.title}\n${JSON.stringify(a.body || '').slice(0, 500)}`)
|
|
56
|
+
.join('\n\n---\n\n')
|
|
57
|
+
|
|
58
|
+
const apiKey = process.env.ANTHROPIC_API_KEY
|
|
59
|
+
if (!apiKey) {
|
|
60
|
+
return Response.json({
|
|
61
|
+
answer: null,
|
|
62
|
+
confidence: 0,
|
|
63
|
+
suggestion: 'create_ticket',
|
|
64
|
+
message: 'Le chatbot IA n\'est pas configuré.',
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const Anthropic = require('@anthropic-ai/sdk').default
|
|
69
|
+
const anthropic = new Anthropic({ apiKey })
|
|
70
|
+
|
|
71
|
+
const response = await anthropic.messages.create({
|
|
72
|
+
model: 'claude-haiku-4-5-20251001',
|
|
73
|
+
max_tokens: 500,
|
|
74
|
+
messages: [
|
|
75
|
+
{
|
|
76
|
+
role: 'user',
|
|
77
|
+
content: `Tu es un assistant de support. Tu dois répondre à la question du client en utilisant UNIQUEMENT les articles de la base de connaissances ci-dessous. Si la réponse n'est pas dans la base, dis-le clairement.
|
|
78
|
+
|
|
79
|
+
BASE DE CONNAISSANCES :
|
|
80
|
+
${knowledgeContext}
|
|
81
|
+
|
|
82
|
+
QUESTION DU CLIENT :
|
|
83
|
+
${question}
|
|
84
|
+
|
|
85
|
+
Réponds en français, de manière concise et utile. Si tu ne trouves pas la réponse dans la base, réponds exactement "INCONNU" et rien d'autre.`,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const answer = response.content[0].type === 'text' ? response.content[0].text : ''
|
|
91
|
+
|
|
92
|
+
if (answer.trim() === 'INCONNU' || answer.trim().length < 10) {
|
|
93
|
+
return Response.json({
|
|
94
|
+
answer: null,
|
|
95
|
+
confidence: 0,
|
|
96
|
+
suggestion: 'create_ticket',
|
|
97
|
+
message: 'Je n\'ai pas trouvé de réponse dans notre base de connaissances. Souhaitez-vous créer un ticket de support ?',
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return Response.json({
|
|
102
|
+
answer: answer.trim(),
|
|
103
|
+
confidence: 1,
|
|
104
|
+
suggestion: 'resolved',
|
|
105
|
+
message: null,
|
|
106
|
+
})
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error('[chatbot] Error:', error)
|
|
109
|
+
return Response.json({ error: 'Internal server error' }, { status: 500 })
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { requireClient, handleAuthError } from '../utils/auth'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* POST /api/support/delete-account
|
|
7
|
+
* RGPD — Right to erasure (Article 17).
|
|
8
|
+
* Allows a support client to request permanent deletion of their account.
|
|
9
|
+
* Uses batch deletes via `where` clauses instead of sequential per-document deletion.
|
|
10
|
+
*/
|
|
11
|
+
export function createDeleteAccountEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
12
|
+
return {
|
|
13
|
+
path: '/support/delete-account',
|
|
14
|
+
method: 'post',
|
|
15
|
+
handler: async (req) => {
|
|
16
|
+
try {
|
|
17
|
+
const payload = req.payload
|
|
18
|
+
|
|
19
|
+
requireClient(req, slugs)
|
|
20
|
+
|
|
21
|
+
const body = await req.json!()
|
|
22
|
+
const { confirmPassword } = body
|
|
23
|
+
|
|
24
|
+
if (!confirmPassword) {
|
|
25
|
+
return Response.json(
|
|
26
|
+
{ error: 'Mot de passe requis pour confirmer la suppression.' },
|
|
27
|
+
{ status: 400 },
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Verify password
|
|
32
|
+
try {
|
|
33
|
+
await payload.login({
|
|
34
|
+
collection: slugs.supportClients as any,
|
|
35
|
+
data: { email: req.user.email!, password: confirmPassword },
|
|
36
|
+
})
|
|
37
|
+
} catch {
|
|
38
|
+
return Response.json(
|
|
39
|
+
{ error: 'Mot de passe incorrect.' },
|
|
40
|
+
{ status: 403 },
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const clientId = req.user.id
|
|
45
|
+
|
|
46
|
+
// 1. Find all ticket IDs for this client
|
|
47
|
+
const tickets = await payload.find({
|
|
48
|
+
collection: slugs.tickets as any,
|
|
49
|
+
where: { client: { equals: clientId } },
|
|
50
|
+
limit: 10000,
|
|
51
|
+
depth: 0,
|
|
52
|
+
overrideAccess: true,
|
|
53
|
+
select: { id: true },
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const ticketIds = tickets.docs.map((t) => t.id)
|
|
57
|
+
|
|
58
|
+
// 2. Batch delete related data using where clauses
|
|
59
|
+
if (ticketIds.length > 0) {
|
|
60
|
+
await payload.delete({
|
|
61
|
+
collection: slugs.ticketMessages as any,
|
|
62
|
+
where: { ticket: { in: ticketIds } },
|
|
63
|
+
overrideAccess: true,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
await payload.delete({
|
|
67
|
+
collection: slugs.ticketActivityLog as any,
|
|
68
|
+
where: { ticket: { in: ticketIds } },
|
|
69
|
+
overrideAccess: true,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await payload.delete({
|
|
73
|
+
collection: slugs.timeEntries as any,
|
|
74
|
+
where: { ticket: { in: ticketIds } },
|
|
75
|
+
overrideAccess: true,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// Delete all tickets
|
|
79
|
+
await payload.delete({
|
|
80
|
+
collection: slugs.tickets as any,
|
|
81
|
+
where: { client: { equals: clientId } },
|
|
82
|
+
overrideAccess: true,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. Batch delete satisfaction surveys
|
|
87
|
+
await payload.delete({
|
|
88
|
+
collection: slugs.satisfactionSurveys as any,
|
|
89
|
+
where: { client: { equals: clientId } },
|
|
90
|
+
overrideAccess: true,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// 4. Batch delete chat messages
|
|
94
|
+
await payload.delete({
|
|
95
|
+
collection: slugs.chatMessages as any,
|
|
96
|
+
where: { client: { equals: clientId } },
|
|
97
|
+
overrideAccess: true,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// 5. Delete the client account
|
|
101
|
+
await payload.delete({
|
|
102
|
+
collection: slugs.supportClients as any,
|
|
103
|
+
id: clientId,
|
|
104
|
+
overrideAccess: true,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const headers = new Headers({ 'Content-Type': 'application/json' })
|
|
108
|
+
const secure = process.env.NODE_ENV === 'production'
|
|
109
|
+
headers.append(
|
|
110
|
+
'Set-Cookie',
|
|
111
|
+
`payload-token=; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=0`,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return new Response(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
deleted: true,
|
|
117
|
+
message: 'Votre compte et toutes vos données ont été supprimés définitivement.',
|
|
118
|
+
}),
|
|
119
|
+
{ status: 200, headers },
|
|
120
|
+
)
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const authResponse = handleAuthError(err)
|
|
123
|
+
if (authResponse) return authResponse
|
|
124
|
+
console.error('[delete-account] Error:', err)
|
|
125
|
+
return Response.json({ error: 'Erreur interne' }, { status: 500 })
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/support/email-stats?days=7
|
|
6
|
+
* Aggregates EmailLogs data for the tracking dashboard. Admin-only.
|
|
7
|
+
*/
|
|
8
|
+
export function createEmailStatsEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
9
|
+
return {
|
|
10
|
+
path: '/support/email-stats',
|
|
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 url = new URL(req.url!)
|
|
21
|
+
const days = Math.min(Number(url.searchParams.get('days')) || 7, 365)
|
|
22
|
+
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
|
|
23
|
+
|
|
24
|
+
const allDocs: Array<{
|
|
25
|
+
status: string
|
|
26
|
+
processingTimeMs?: number | null
|
|
27
|
+
action?: string | null
|
|
28
|
+
createdAt: string
|
|
29
|
+
}> = []
|
|
30
|
+
const MAX_PAGES = 50
|
|
31
|
+
let page = 1
|
|
32
|
+
let hasMore = true
|
|
33
|
+
|
|
34
|
+
while (hasMore && page <= MAX_PAGES) {
|
|
35
|
+
const result = await payload.find({
|
|
36
|
+
collection: slugs.emailLogs as any,
|
|
37
|
+
where: { createdAt: { greater_than: cutoff } },
|
|
38
|
+
sort: '-createdAt',
|
|
39
|
+
limit: 500,
|
|
40
|
+
page,
|
|
41
|
+
depth: 0,
|
|
42
|
+
overrideAccess: true,
|
|
43
|
+
select: {
|
|
44
|
+
status: true,
|
|
45
|
+
processingTimeMs: true,
|
|
46
|
+
action: true,
|
|
47
|
+
createdAt: true,
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
allDocs.push(
|
|
52
|
+
...result.docs.map((d: any) => ({
|
|
53
|
+
status: d.status,
|
|
54
|
+
processingTimeMs: d.processingTimeMs,
|
|
55
|
+
action: d.action,
|
|
56
|
+
createdAt: d.createdAt,
|
|
57
|
+
})),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
hasMore = result.hasNextPage
|
|
61
|
+
page++
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const total = allDocs.length
|
|
65
|
+
const success = allDocs.filter((d) => d.status === 'success').length
|
|
66
|
+
const errors = allDocs.filter((d) => d.status === 'error').length
|
|
67
|
+
const ignored = allDocs.filter((d) => d.status === 'ignored').length
|
|
68
|
+
const successRate = total > 0 ? Math.round((success / total) * 1000) / 10 : 0
|
|
69
|
+
|
|
70
|
+
const withTime = allDocs.filter((d) => typeof d.processingTimeMs === 'number' && d.processingTimeMs > 0)
|
|
71
|
+
const avgProcessingTime = withTime.length > 0
|
|
72
|
+
? Math.round(withTime.reduce((sum, d) => sum + (d.processingTimeMs || 0), 0) / withTime.length)
|
|
73
|
+
: 0
|
|
74
|
+
|
|
75
|
+
// Daily breakdown
|
|
76
|
+
const dailyMap = new Map<string, { success: number; error: number; ignored: number }>()
|
|
77
|
+
for (const doc of allDocs) {
|
|
78
|
+
const day = doc.createdAt.slice(0, 10)
|
|
79
|
+
const entry = dailyMap.get(day) || { success: 0, error: 0, ignored: 0 }
|
|
80
|
+
if (doc.status === 'success') entry.success++
|
|
81
|
+
else if (doc.status === 'error') entry.error++
|
|
82
|
+
else if (doc.status === 'ignored') entry.ignored++
|
|
83
|
+
dailyMap.set(day, entry)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Action breakdown
|
|
87
|
+
const actionMap = new Map<string, number>()
|
|
88
|
+
for (const doc of allDocs) {
|
|
89
|
+
const action = doc.action || 'unknown'
|
|
90
|
+
actionMap.set(action, (actionMap.get(action) || 0) + 1)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Response.json({
|
|
94
|
+
total,
|
|
95
|
+
success,
|
|
96
|
+
errors,
|
|
97
|
+
ignored,
|
|
98
|
+
successRate,
|
|
99
|
+
avgProcessingTime,
|
|
100
|
+
daily: Object.fromEntries(dailyMap),
|
|
101
|
+
actions: Object.fromEntries(actionMap),
|
|
102
|
+
})
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error('[email-stats] Error:', err)
|
|
105
|
+
return Response.json({ error: 'Internal server error' }, { status: 500 })
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|