@consilioweb/payload-support 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +525 -0
  3. package/dist/client.cjs +7 -0
  4. package/dist/client.d.cts +3 -0
  5. package/dist/client.d.ts +3 -0
  6. package/dist/client.js +5 -0
  7. package/dist/index.cjs +7766 -0
  8. package/dist/index.d.cts +384 -0
  9. package/dist/index.d.ts +384 -0
  10. package/dist/index.js +7730 -0
  11. package/dist/views.d.cts +30 -0
  12. package/dist/views.d.ts +30 -0
  13. package/package.json +131 -0
  14. package/src/client.ts +1 -0
  15. package/src/collections/AuthLogs.ts +65 -0
  16. package/src/collections/CannedResponses.ts +69 -0
  17. package/src/collections/ChatMessages.ts +98 -0
  18. package/src/collections/EmailLogs.ts +94 -0
  19. package/src/collections/KnowledgeBase.ts +99 -0
  20. package/src/collections/Macros.ts +98 -0
  21. package/src/collections/PendingEmails.ts +122 -0
  22. package/src/collections/SatisfactionSurveys.ts +98 -0
  23. package/src/collections/SlaPolicies.ts +123 -0
  24. package/src/collections/SupportClients.ts +210 -0
  25. package/src/collections/TicketActivityLog.ts +81 -0
  26. package/src/collections/TicketMessages.ts +364 -0
  27. package/src/collections/TicketStatuses.ts +108 -0
  28. package/src/collections/Tickets.ts +704 -0
  29. package/src/collections/TimeEntries.ts +105 -0
  30. package/src/collections/WebhookEndpoints.ts +96 -0
  31. package/src/collections/index.ts +16 -0
  32. package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
  33. package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
  34. package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
  35. package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
  36. package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
  37. package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
  38. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
  39. package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
  40. package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
  41. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
  42. package/src/components/TicketConversation/config.ts +82 -0
  43. package/src/components/TicketConversation/constants.ts +74 -0
  44. package/src/components/TicketConversation/context.ts +63 -0
  45. package/src/components/TicketConversation/hooks/useAI.ts +180 -0
  46. package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
  47. package/src/components/TicketConversation/hooks/useReply.ts +190 -0
  48. package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
  49. package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
  50. package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
  51. package/src/components/TicketConversation/index.tsx +1110 -0
  52. package/src/components/TicketConversation/locales/en.json +878 -0
  53. package/src/components/TicketConversation/locales/fr.json +878 -0
  54. package/src/components/TicketConversation/types.ts +54 -0
  55. package/src/components/TicketConversation/utils.ts +25 -0
  56. package/src/endpoints/admin-chat-stream.ts +238 -0
  57. package/src/endpoints/admin-chat.ts +263 -0
  58. package/src/endpoints/admin-stats.ts +200 -0
  59. package/src/endpoints/ai.ts +199 -0
  60. package/src/endpoints/apply-macro.ts +144 -0
  61. package/src/endpoints/auth-2fa.ts +163 -0
  62. package/src/endpoints/auto-close.ts +175 -0
  63. package/src/endpoints/billing.ts +167 -0
  64. package/src/endpoints/bulk-action.ts +103 -0
  65. package/src/endpoints/chat-stream.ts +127 -0
  66. package/src/endpoints/chat.ts +188 -0
  67. package/src/endpoints/chatbot.ts +113 -0
  68. package/src/endpoints/delete-account.ts +129 -0
  69. package/src/endpoints/email-stats.ts +109 -0
  70. package/src/endpoints/export-csv.ts +84 -0
  71. package/src/endpoints/export-data.ts +104 -0
  72. package/src/endpoints/import-conversation.ts +307 -0
  73. package/src/endpoints/index.ts +154 -0
  74. package/src/endpoints/login.ts +92 -0
  75. package/src/endpoints/merge-clients.ts +132 -0
  76. package/src/endpoints/merge-tickets.ts +137 -0
  77. package/src/endpoints/oauth-google.ts +179 -0
  78. package/src/endpoints/pending-emails-process.ts +224 -0
  79. package/src/endpoints/presence.ts +104 -0
  80. package/src/endpoints/process-scheduled.ts +144 -0
  81. package/src/endpoints/purge-logs.ts +58 -0
  82. package/src/endpoints/resend-notification.ts +99 -0
  83. package/src/endpoints/round-robin-config.ts +92 -0
  84. package/src/endpoints/satisfaction.ts +93 -0
  85. package/src/endpoints/search.ts +106 -0
  86. package/src/endpoints/seed-kb.ts +153 -0
  87. package/src/endpoints/settings.ts +144 -0
  88. package/src/endpoints/signature.ts +93 -0
  89. package/src/endpoints/sla-check.ts +124 -0
  90. package/src/endpoints/split-ticket.ts +131 -0
  91. package/src/endpoints/statuses.ts +45 -0
  92. package/src/endpoints/track-open.ts +154 -0
  93. package/src/endpoints/typing.ts +101 -0
  94. package/src/endpoints/user-prefs.ts +125 -0
  95. package/src/hooks/checkSLA.ts +414 -0
  96. package/src/hooks/ticketStatusEmail.ts +182 -0
  97. package/src/index.ts +51 -0
  98. package/src/plugin.ts +157 -0
  99. package/src/portal/LiveChat.tsx +1353 -0
  100. package/src/portal/auth/ChatWidget.tsx +350 -0
  101. package/src/portal/auth/ChatbotWidget.tsx +285 -0
  102. package/src/portal/auth/SupportHeader.tsx +409 -0
  103. package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
  104. package/src/portal/auth/dashboard/page.tsx +84 -0
  105. package/src/portal/auth/faq/FAQSearch.tsx +117 -0
  106. package/src/portal/auth/faq/page.tsx +199 -0
  107. package/src/portal/auth/layout.tsx +61 -0
  108. package/src/portal/auth/profile/page.tsx +705 -0
  109. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
  110. package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
  111. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
  112. package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
  113. package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
  114. package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
  115. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
  116. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
  117. package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
  118. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
  119. package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
  120. package/src/portal/auth/tickets/detail/page.tsx +738 -0
  121. package/src/portal/auth/tickets/new/page.tsx +515 -0
  122. package/src/portal/forgot-password/page.tsx +114 -0
  123. package/src/portal/layout.tsx +26 -0
  124. package/src/portal/locales/en.json +374 -0
  125. package/src/portal/locales/fr.json +374 -0
  126. package/src/portal/login/page.tsx +351 -0
  127. package/src/portal/page.tsx +162 -0
  128. package/src/portal/register/page.tsx +281 -0
  129. package/src/portal/reset-password/page.tsx +152 -0
  130. package/src/styles/BillingView.module.scss +311 -0
  131. package/src/styles/ChatView.module.scss +438 -0
  132. package/src/styles/CommandPalette.module.scss +160 -0
  133. package/src/styles/CrmView.module.scss +554 -0
  134. package/src/styles/EmailTracking.module.scss +238 -0
  135. package/src/styles/ImportConversation.module.scss +267 -0
  136. package/src/styles/Layout.module.scss +55 -0
  137. package/src/styles/Logs.module.scss +164 -0
  138. package/src/styles/NewTicket.module.scss +143 -0
  139. package/src/styles/PendingEmails.module.scss +629 -0
  140. package/src/styles/SupportDashboard.module.scss +649 -0
  141. package/src/styles/TicketDetail.module.scss +1043 -0
  142. package/src/styles/TicketInbox.module.scss +296 -0
  143. package/src/styles/TicketingSettings.module.scss +358 -0
  144. package/src/styles/TimeDashboard.module.scss +287 -0
  145. package/src/styles/_tokens.scss +78 -0
  146. package/src/styles/theme.css +633 -0
  147. package/src/types.ts +255 -0
  148. package/src/utils/adminNotification.ts +38 -0
  149. package/src/utils/auth.ts +46 -0
  150. package/src/utils/emailTemplate.ts +343 -0
  151. package/src/utils/fireWebhooks.ts +84 -0
  152. package/src/utils/index.ts +22 -0
  153. package/src/utils/rateLimiter.ts +52 -0
  154. package/src/utils/readSettings.ts +67 -0
  155. package/src/utils/slugs.ts +54 -0
  156. package/src/utils/webhookDispatcher.ts +120 -0
  157. package/src/views/BillingView/client.tsx +137 -0
  158. package/src/views/BillingView/index.tsx +33 -0
  159. package/src/views/ChatView/client.tsx +294 -0
  160. package/src/views/ChatView/index.tsx +33 -0
  161. package/src/views/CrmView/client.tsx +206 -0
  162. package/src/views/CrmView/index.tsx +33 -0
  163. package/src/views/EmailTrackingView/client.tsx +124 -0
  164. package/src/views/EmailTrackingView/index.tsx +33 -0
  165. package/src/views/ImportConversationView/client.tsx +133 -0
  166. package/src/views/ImportConversationView/index.tsx +33 -0
  167. package/src/views/LogsView/client.tsx +151 -0
  168. package/src/views/LogsView/index.tsx +30 -0
  169. package/src/views/NewTicketView/client.tsx +227 -0
  170. package/src/views/NewTicketView/index.tsx +30 -0
  171. package/src/views/PendingEmailsView/client.tsx +177 -0
  172. package/src/views/PendingEmailsView/index.tsx +33 -0
  173. package/src/views/SupportDashboardView/client.tsx +424 -0
  174. package/src/views/SupportDashboardView/index.tsx +33 -0
  175. package/src/views/TicketDetailView/client.tsx +775 -0
  176. package/src/views/TicketDetailView/index.tsx +33 -0
  177. package/src/views/TicketInboxView/client.tsx +313 -0
  178. package/src/views/TicketInboxView/index.tsx +30 -0
  179. package/src/views/TicketingSettingsView/client.tsx +866 -0
  180. package/src/views/TicketingSettingsView/index.tsx +33 -0
  181. package/src/views/TimeDashboardView/client.tsx +144 -0
  182. package/src/views/TimeDashboardView/index.tsx +33 -0
  183. package/src/views/shared/AdminViewHeader.tsx +69 -0
  184. package/src/views/shared/ErrorBoundary.tsx +68 -0
  185. package/src/views/shared/Skeleton.tsx +125 -0
  186. package/src/views/shared/adminTokens.ts +37 -0
  187. package/src/views/shared/config.ts +82 -0
  188. package/src/views/shared/index.ts +6 -0
  189. package/src/views.ts +16 -0
@@ -0,0 +1,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
+ }