@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,92 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { RateLimiter } from '../utils/rateLimiter'
|
|
4
|
+
|
|
5
|
+
const loginLimiter = new RateLimiter(15 * 60_000, 10) // 10 per 15 min
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POST /api/support/login
|
|
9
|
+
* Client login endpoint.
|
|
10
|
+
*/
|
|
11
|
+
export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
12
|
+
return {
|
|
13
|
+
path: '/support/login',
|
|
14
|
+
method: 'post',
|
|
15
|
+
handler: async (req) => {
|
|
16
|
+
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
|
|
17
|
+
|
|
18
|
+
if (loginLimiter.check(ip)) {
|
|
19
|
+
return Response.json(
|
|
20
|
+
{ error: 'Trop de tentatives. Réessayez dans quelques minutes.' },
|
|
21
|
+
{ status: 429 },
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const payload = req.payload
|
|
26
|
+
let body: { email?: string; password?: string }
|
|
27
|
+
try {
|
|
28
|
+
body = await req.json!()
|
|
29
|
+
} catch {
|
|
30
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
31
|
+
}
|
|
32
|
+
const { email, password } = body
|
|
33
|
+
const userAgent = req.headers.get('user-agent') || ''
|
|
34
|
+
|
|
35
|
+
if (!email || !password) {
|
|
36
|
+
return Response.json({ error: 'Email et mot de passe requis.' }, { status: 400 })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const result = await payload.login({
|
|
41
|
+
collection: slugs.supportClients as any,
|
|
42
|
+
data: { email, password },
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Log successful login (fire-and-forget)
|
|
46
|
+
payload.create({
|
|
47
|
+
collection: slugs.authLogs as any,
|
|
48
|
+
data: { email, success: true, action: 'login', ipAddress: ip, userAgent },
|
|
49
|
+
overrideAccess: true,
|
|
50
|
+
}).catch(() => {})
|
|
51
|
+
|
|
52
|
+
const headers = new Headers({ 'Content-Type': 'application/json' })
|
|
53
|
+
|
|
54
|
+
if (result.token) {
|
|
55
|
+
const secure = process.env.NODE_ENV === 'production'
|
|
56
|
+
headers.append(
|
|
57
|
+
'Set-Cookie',
|
|
58
|
+
`payload-token=${result.token}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=7200`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Response(
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
message: 'Login successful',
|
|
65
|
+
user: result.user,
|
|
66
|
+
token: result.token,
|
|
67
|
+
exp: result.exp,
|
|
68
|
+
}),
|
|
69
|
+
{ status: 200, headers },
|
|
70
|
+
)
|
|
71
|
+
} catch (err: unknown) {
|
|
72
|
+
const errorMessage = err instanceof Error ? err.message : 'Erreur inconnue'
|
|
73
|
+
|
|
74
|
+
let errorReason = 'Identifiants incorrects'
|
|
75
|
+
if (errorMessage.includes('locked') || errorMessage.includes('verrouillé') || errorMessage.includes('Too many')) {
|
|
76
|
+
errorReason = 'Compte verrouillé (trop de tentatives)'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
payload.create({
|
|
80
|
+
collection: slugs.authLogs as any,
|
|
81
|
+
data: { email, success: false, action: 'login', errorReason, ipAddress: ip, userAgent },
|
|
82
|
+
overrideAccess: true,
|
|
83
|
+
}).catch(() => {})
|
|
84
|
+
|
|
85
|
+
return Response.json(
|
|
86
|
+
{ errors: [{ message: 'Email ou mot de passe incorrect.' }] },
|
|
87
|
+
{ status: 401 },
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
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/merge-clients
|
|
7
|
+
* Merge client B (source) into client A (target). Admin-only.
|
|
8
|
+
*/
|
|
9
|
+
export function createMergeClientsEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
10
|
+
return {
|
|
11
|
+
path: '/support/merge-clients',
|
|
12
|
+
method: 'post',
|
|
13
|
+
handler: async (req) => {
|
|
14
|
+
try {
|
|
15
|
+
const payload = req.payload
|
|
16
|
+
|
|
17
|
+
requireAdmin(req, slugs)
|
|
18
|
+
|
|
19
|
+
const { sourceId, targetId } = await req.json!()
|
|
20
|
+
|
|
21
|
+
if (!sourceId || !targetId || sourceId === targetId) {
|
|
22
|
+
return Response.json({ error: 'sourceId and targetId are required and must be different' }, { status: 400 })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const [source, target] = await Promise.all([
|
|
26
|
+
payload.findByID({ collection: slugs.supportClients as any, id: sourceId, depth: 0, overrideAccess: true }),
|
|
27
|
+
payload.findByID({ collection: slugs.supportClients as any, id: targetId, depth: 0, overrideAccess: true }),
|
|
28
|
+
]) as [any, any]
|
|
29
|
+
|
|
30
|
+
if (!source || !target) {
|
|
31
|
+
return Response.json({ error: 'Source or target client not found' }, { status: 404 })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const results = {
|
|
35
|
+
tickets: 0,
|
|
36
|
+
ticketMessages: 0,
|
|
37
|
+
chatMessages: 0,
|
|
38
|
+
pendingEmails: 0,
|
|
39
|
+
satisfactionSurveys: 0,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 1. Transfer tickets
|
|
43
|
+
const tickets = await payload.find({
|
|
44
|
+
collection: slugs.tickets as any,
|
|
45
|
+
where: { client: { equals: sourceId } },
|
|
46
|
+
limit: 500,
|
|
47
|
+
depth: 0,
|
|
48
|
+
overrideAccess: true,
|
|
49
|
+
})
|
|
50
|
+
for (const ticket of tickets.docs) {
|
|
51
|
+
await payload.update({ collection: slugs.tickets as any, id: ticket.id, data: { client: targetId }, overrideAccess: true })
|
|
52
|
+
results.tickets++
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Transfer ticket messages (authorClient)
|
|
56
|
+
const messages = await payload.find({
|
|
57
|
+
collection: slugs.ticketMessages as any,
|
|
58
|
+
where: { authorClient: { equals: sourceId } },
|
|
59
|
+
limit: 1000,
|
|
60
|
+
depth: 0,
|
|
61
|
+
overrideAccess: true,
|
|
62
|
+
})
|
|
63
|
+
for (const msg of messages.docs) {
|
|
64
|
+
await payload.update({ collection: slugs.ticketMessages as any, id: msg.id, data: { authorClient: targetId }, overrideAccess: true })
|
|
65
|
+
results.ticketMessages++
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 3. Transfer chat messages
|
|
69
|
+
const chats = await payload.find({
|
|
70
|
+
collection: slugs.chatMessages as any,
|
|
71
|
+
where: { client: { equals: sourceId } },
|
|
72
|
+
limit: 1000,
|
|
73
|
+
depth: 0,
|
|
74
|
+
overrideAccess: true,
|
|
75
|
+
})
|
|
76
|
+
for (const chat of chats.docs) {
|
|
77
|
+
await payload.update({ collection: slugs.chatMessages as any, id: chat.id, data: { client: targetId }, overrideAccess: true })
|
|
78
|
+
results.chatMessages++
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 4. Transfer pending emails
|
|
82
|
+
const pendingEmails = await payload.find({
|
|
83
|
+
collection: slugs.pendingEmails as any,
|
|
84
|
+
where: { client: { equals: sourceId } },
|
|
85
|
+
limit: 500,
|
|
86
|
+
depth: 0,
|
|
87
|
+
overrideAccess: true,
|
|
88
|
+
})
|
|
89
|
+
for (const pe of pendingEmails.docs) {
|
|
90
|
+
await payload.update({ collection: slugs.pendingEmails as any, id: pe.id, data: { client: targetId }, overrideAccess: true })
|
|
91
|
+
results.pendingEmails++
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 5. Transfer satisfaction surveys
|
|
95
|
+
const surveys = await payload.find({
|
|
96
|
+
collection: slugs.satisfactionSurveys as any,
|
|
97
|
+
where: { client: { equals: sourceId } },
|
|
98
|
+
limit: 500,
|
|
99
|
+
depth: 0,
|
|
100
|
+
overrideAccess: true,
|
|
101
|
+
})
|
|
102
|
+
for (const survey of surveys.docs) {
|
|
103
|
+
await payload.update({ collection: slugs.satisfactionSurveys as any, id: survey.id, data: { client: targetId }, overrideAccess: true })
|
|
104
|
+
results.satisfactionSurveys++
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 6. Delete source client
|
|
108
|
+
await payload.delete({
|
|
109
|
+
collection: slugs.supportClients as any,
|
|
110
|
+
id: sourceId,
|
|
111
|
+
overrideAccess: true,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const sourceLabel = `${source.firstName} ${source.lastName} (${source.email})`
|
|
115
|
+
const targetLabel = `${target.firstName} ${target.lastName} (${target.email})`
|
|
116
|
+
|
|
117
|
+
return Response.json({
|
|
118
|
+
success: true,
|
|
119
|
+
message: `Client "${sourceLabel}" fusionné dans "${targetLabel}"`,
|
|
120
|
+
merged: results,
|
|
121
|
+
deletedClientId: sourceId,
|
|
122
|
+
targetClientId: targetId,
|
|
123
|
+
})
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const authResponse = handleAuthError(error)
|
|
126
|
+
if (authResponse) return authResponse
|
|
127
|
+
console.error('[merge-clients] Error:', error)
|
|
128
|
+
return Response.json({ error: 'Internal server error' }, { status: 500 })
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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/merge-tickets
|
|
7
|
+
* Merge source ticket into target ticket. Admin-only.
|
|
8
|
+
*/
|
|
9
|
+
export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
10
|
+
return {
|
|
11
|
+
path: '/support/merge-tickets',
|
|
12
|
+
method: 'post',
|
|
13
|
+
handler: async (req) => {
|
|
14
|
+
try {
|
|
15
|
+
const payload = req.payload
|
|
16
|
+
|
|
17
|
+
requireAdmin(req, slugs)
|
|
18
|
+
|
|
19
|
+
const { sourceTicketId, targetTicketId } = await req.json!()
|
|
20
|
+
|
|
21
|
+
if (!sourceTicketId || !targetTicketId) {
|
|
22
|
+
return Response.json({ error: 'sourceTicketId et targetTicketId requis' }, { status: 400 })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (sourceTicketId === targetTicketId) {
|
|
26
|
+
return Response.json({ error: 'Impossible de fusionner un ticket avec lui-même' }, { status: 400 })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const [source, target] = await Promise.all([
|
|
30
|
+
payload.findByID({ collection: slugs.tickets as any, id: sourceTicketId, depth: 0, overrideAccess: true }),
|
|
31
|
+
payload.findByID({ collection: slugs.tickets as any, id: targetTicketId, depth: 0, overrideAccess: true }),
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
if (!source) return Response.json({ error: 'Ticket source introuvable' }, { status: 404 })
|
|
35
|
+
if (!target) return Response.json({ error: 'Ticket cible introuvable' }, { status: 404 })
|
|
36
|
+
|
|
37
|
+
const sourceClient = typeof source.client === 'object' ? (source.client as any).id : source.client
|
|
38
|
+
const targetClient = typeof target.client === 'object' ? (target.client as any).id : target.client
|
|
39
|
+
|
|
40
|
+
if (sourceClient !== targetClient) {
|
|
41
|
+
return Response.json({ error: 'Les deux tickets doivent appartenir au même client' }, { status: 400 })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Move all messages from source to target
|
|
45
|
+
const messages = await payload.find({
|
|
46
|
+
collection: slugs.ticketMessages as any,
|
|
47
|
+
where: { ticket: { equals: sourceTicketId } },
|
|
48
|
+
limit: 500,
|
|
49
|
+
depth: 0,
|
|
50
|
+
overrideAccess: true,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
for (const msg of messages.docs) {
|
|
54
|
+
await payload.update({
|
|
55
|
+
collection: slugs.ticketMessages as any,
|
|
56
|
+
id: msg.id,
|
|
57
|
+
data: { ticket: targetTicketId },
|
|
58
|
+
overrideAccess: true,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Move time entries
|
|
63
|
+
const timeEntries = await payload.find({
|
|
64
|
+
collection: slugs.timeEntries as any,
|
|
65
|
+
where: { ticket: { equals: sourceTicketId } },
|
|
66
|
+
limit: 500,
|
|
67
|
+
depth: 0,
|
|
68
|
+
overrideAccess: true,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
for (const entry of timeEntries.docs) {
|
|
72
|
+
await payload.update({
|
|
73
|
+
collection: slugs.timeEntries as any,
|
|
74
|
+
id: entry.id,
|
|
75
|
+
data: { ticket: targetTicketId },
|
|
76
|
+
overrideAccess: true,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Add internal note to target
|
|
81
|
+
const sourceNumber = (source as any).ticketNumber || `#${sourceTicketId}`
|
|
82
|
+
await payload.create({
|
|
83
|
+
collection: slugs.ticketMessages as any,
|
|
84
|
+
data: {
|
|
85
|
+
ticket: targetTicketId,
|
|
86
|
+
body: `Messages fusionnés depuis ${sourceNumber} (${messages.totalDocs} messages, ${timeEntries.totalDocs} entrées de temps)`,
|
|
87
|
+
authorType: 'admin',
|
|
88
|
+
isInternal: true,
|
|
89
|
+
skipNotification: true,
|
|
90
|
+
},
|
|
91
|
+
overrideAccess: true,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Delete source ticket
|
|
95
|
+
await payload.delete({
|
|
96
|
+
collection: slugs.tickets as any,
|
|
97
|
+
id: sourceTicketId,
|
|
98
|
+
overrideAccess: true,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Recalculate totalTimeMinutes on target
|
|
102
|
+
const allTimeEntries = await payload.find({
|
|
103
|
+
collection: slugs.timeEntries as any,
|
|
104
|
+
where: { ticket: { equals: targetTicketId } },
|
|
105
|
+
limit: 500,
|
|
106
|
+
depth: 0,
|
|
107
|
+
overrideAccess: true,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const totalMinutes = allTimeEntries.docs.reduce(
|
|
111
|
+
(sum: number, e: any) => sum + (e.duration || 0),
|
|
112
|
+
0,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
await payload.update({
|
|
116
|
+
collection: slugs.tickets as any,
|
|
117
|
+
id: targetTicketId,
|
|
118
|
+
data: { totalTimeMinutes: totalMinutes },
|
|
119
|
+
overrideAccess: true,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return Response.json({
|
|
123
|
+
success: true,
|
|
124
|
+
messagesMoved: messages.totalDocs,
|
|
125
|
+
timeEntriesMoved: timeEntries.totalDocs,
|
|
126
|
+
sourceTicket: sourceNumber,
|
|
127
|
+
targetTicket: (target as any).ticketNumber || `#${targetTicketId}`,
|
|
128
|
+
})
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const authResponse = handleAuthError(error)
|
|
131
|
+
if (authResponse) return authResponse
|
|
132
|
+
console.error('[merge-tickets] Error:', error)
|
|
133
|
+
return Response.json({ error: 'Erreur interne' }, { status: 500 })
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import crypto from 'crypto'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* POST /api/support/oauth/google
|
|
7
|
+
* Google OAuth — handles both login redirect and callback.
|
|
8
|
+
* Body: { action: 'login' } or { code: string, state: string, cookieState: string }
|
|
9
|
+
*/
|
|
10
|
+
export interface OAuthGoogleOptions {
|
|
11
|
+
allowedEmailDomains?: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAuthGoogleOptions): Endpoint {
|
|
15
|
+
return {
|
|
16
|
+
path: '/support/oauth/google',
|
|
17
|
+
method: 'post',
|
|
18
|
+
handler: async (req) => {
|
|
19
|
+
const GOOGLE_CLIENT_ID = process.env.GOOGLE_OAUTH_CLIENT_ID || ''
|
|
20
|
+
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_OAUTH_CLIENT_SECRET || ''
|
|
21
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
22
|
+
|
|
23
|
+
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
|
|
24
|
+
return Response.json(
|
|
25
|
+
{ error: 'Google OAuth non configuré.' },
|
|
26
|
+
{ status: 501 },
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const body = await req.json!()
|
|
32
|
+
const { action, code, state: queryState, cookieState } = body
|
|
33
|
+
|
|
34
|
+
// Step 1: Generate OAuth URL
|
|
35
|
+
if (action === 'login') {
|
|
36
|
+
const oauthState = crypto.randomBytes(32).toString('hex')
|
|
37
|
+
const redirectUri = `${baseUrl}/api/support/oauth/google`
|
|
38
|
+
const params = new URLSearchParams({
|
|
39
|
+
client_id: GOOGLE_CLIENT_ID,
|
|
40
|
+
redirect_uri: redirectUri,
|
|
41
|
+
response_type: 'code',
|
|
42
|
+
scope: 'openid email profile',
|
|
43
|
+
state: oauthState,
|
|
44
|
+
prompt: 'select_account',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return Response.json({
|
|
48
|
+
url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
|
|
49
|
+
state: oauthState,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Step 2: Handle callback (code exchange)
|
|
54
|
+
if (code) {
|
|
55
|
+
// Validate state
|
|
56
|
+
if (!cookieState || !queryState || cookieState !== queryState) {
|
|
57
|
+
return Response.json({ error: 'state_mismatch' }, { status: 400 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const redirectUri = `${baseUrl}/api/support/oauth/google`
|
|
61
|
+
|
|
62
|
+
// Exchange code for tokens
|
|
63
|
+
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
66
|
+
body: new URLSearchParams({
|
|
67
|
+
code,
|
|
68
|
+
client_id: GOOGLE_CLIENT_ID,
|
|
69
|
+
client_secret: GOOGLE_CLIENT_SECRET,
|
|
70
|
+
redirect_uri: redirectUri,
|
|
71
|
+
grant_type: 'authorization_code',
|
|
72
|
+
}),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const tokens = await tokenRes.json()
|
|
76
|
+
|
|
77
|
+
if (!tokens.access_token) {
|
|
78
|
+
return Response.json({ error: 'oauth_failed' }, { status: 400 })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Get user profile from Google
|
|
82
|
+
const profileRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
83
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const profile = await profileRes.json()
|
|
87
|
+
|
|
88
|
+
if (!profile.email) {
|
|
89
|
+
return Response.json({ error: 'no_email' }, { status: 400 })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const payload = req.payload
|
|
93
|
+
|
|
94
|
+
// Find existing support client by email
|
|
95
|
+
const existing = await payload.find({
|
|
96
|
+
collection: slugs.supportClients as any,
|
|
97
|
+
where: { email: { equals: profile.email } },
|
|
98
|
+
limit: 1,
|
|
99
|
+
depth: 0,
|
|
100
|
+
overrideAccess: true,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
let clientDoc = existing.docs[0]
|
|
104
|
+
|
|
105
|
+
// Auto-create account if needed
|
|
106
|
+
if (!clientDoc) {
|
|
107
|
+
// Domain restriction check for new accounts
|
|
108
|
+
const allowedDomains = options?.allowedEmailDomains
|
|
109
|
+
if (allowedDomains && allowedDomains.length > 0) {
|
|
110
|
+
const emailDomain = profile.email.split('@')[1]?.toLowerCase()
|
|
111
|
+
const isAllowed = allowedDomains.some(
|
|
112
|
+
(d: string) => d.toLowerCase() === emailDomain,
|
|
113
|
+
)
|
|
114
|
+
if (!isAllowed) {
|
|
115
|
+
return Response.json(
|
|
116
|
+
{ error: 'Inscription non autorisée pour ce domaine email.' },
|
|
117
|
+
{ status: 403 },
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const autoPassword = crypto.randomBytes(48).toString('base64url')
|
|
122
|
+
const fullName = profile.name || profile.email.split('@')[0]
|
|
123
|
+
const nameParts = fullName.split(' ')
|
|
124
|
+
|
|
125
|
+
clientDoc = await payload.create({
|
|
126
|
+
collection: slugs.supportClients as any,
|
|
127
|
+
data: {
|
|
128
|
+
email: profile.email,
|
|
129
|
+
firstName: nameParts[0] || fullName,
|
|
130
|
+
lastName: nameParts.slice(1).join(' ') || '-',
|
|
131
|
+
company: fullName,
|
|
132
|
+
password: autoPassword,
|
|
133
|
+
},
|
|
134
|
+
overrideAccess: true,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Generate temp password, login, then rotate
|
|
139
|
+
const tempPassword = crypto.randomBytes(48).toString('base64url')
|
|
140
|
+
await payload.update({
|
|
141
|
+
collection: slugs.supportClients as any,
|
|
142
|
+
id: clientDoc.id,
|
|
143
|
+
data: { password: tempPassword },
|
|
144
|
+
overrideAccess: true,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const loginResult = await payload.login({
|
|
148
|
+
collection: slugs.supportClients as any,
|
|
149
|
+
data: { email: profile.email, password: tempPassword },
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Immediately rotate password
|
|
153
|
+
const postLoginPassword = crypto.randomBytes(48).toString('base64url')
|
|
154
|
+
await payload.update({
|
|
155
|
+
collection: slugs.supportClients as any,
|
|
156
|
+
id: clientDoc.id,
|
|
157
|
+
data: { password: postLoginPassword },
|
|
158
|
+
overrideAccess: true,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
if (!loginResult.token) {
|
|
162
|
+
return Response.json({ error: 'login_failed' }, { status: 400 })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return Response.json({
|
|
166
|
+
token: loginResult.token,
|
|
167
|
+
user: loginResult.user,
|
|
168
|
+
exp: loginResult.exp,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return Response.json({ error: 'Action invalide' }, { status: 400 })
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error('[oauth/google] Error:', err)
|
|
175
|
+
return Response.json({ error: 'oauth_error' }, { status: 500 })
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
}
|