@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,163 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import crypto, { createHmac } from 'crypto'
4
+ import { RateLimiter } from '../utils/rateLimiter'
5
+ import { escapeHtml } from '../utils/emailTemplate'
6
+
7
+ const sendLimiter = new RateLimiter(60 * 60 * 1000, 3) // 3 per hour
8
+ const verifyLimiter = new RateLimiter(15 * 60 * 1000, 5) // 5 per 15 min
9
+
10
+ function generateSecureCode(): string {
11
+ const buf = crypto.randomBytes(4)
12
+ const num = buf.readUInt32BE(0) % 900000 + 100000
13
+ return String(num)
14
+ }
15
+
16
+ function hashCode(code: string): string {
17
+ const secret = process.env.PAYLOAD_SECRET || 'payload-support-2fa'
18
+ return createHmac('sha256', secret).update(code).digest('hex')
19
+ }
20
+
21
+ /**
22
+ * POST /api/support/2fa
23
+ * Send or verify a 2FA code.
24
+ */
25
+ export function createAuth2faEndpoint(slugs: CollectionSlugs): Endpoint {
26
+ return {
27
+ path: '/support/2fa',
28
+ method: 'post',
29
+ handler: async (req) => {
30
+ try {
31
+ const payload = req.payload
32
+ let body: { action?: string; email?: string; code?: string }
33
+ try {
34
+ body = await req.json!()
35
+ } catch {
36
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
37
+ }
38
+ const { action, email, code } = body
39
+
40
+ if (!action || !email) {
41
+ return Response.json({ error: 'Paramètres manquants' }, { status: 400 })
42
+ }
43
+
44
+ const genericSendResponse = { success: true, message: 'Si un compte existe, un code a été envoyé.' }
45
+
46
+ if (action === 'send') {
47
+ if (sendLimiter.check(email)) {
48
+ return Response.json(genericSendResponse)
49
+ }
50
+
51
+ const clients = await payload.find({
52
+ collection: slugs.supportClients as any,
53
+ where: { email: { equals: email } },
54
+ limit: 1,
55
+ depth: 0,
56
+ overrideAccess: true,
57
+ })
58
+
59
+ if (clients.docs.length === 0) {
60
+ return Response.json(genericSendResponse)
61
+ }
62
+
63
+ const client = clients.docs[0] as any
64
+ const plainCode = generateSecureCode()
65
+ const twoFactorCode = hashCode(plainCode)
66
+ const twoFactorExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString()
67
+
68
+ await payload.update({
69
+ collection: slugs.supportClients as any,
70
+ id: client.id,
71
+ data: { twoFactorCode, twoFactorExpiry },
72
+ overrideAccess: true,
73
+ })
74
+
75
+ await payload.sendEmail({
76
+ to: email,
77
+ subject: 'Code de vérification — Support',
78
+ html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
79
+ <p>Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,</p>
80
+ <p>Votre code de vérification :</p>
81
+ <div style="text-align: center; margin: 24px 0;">
82
+ <span style="display: inline-block; font-size: 32px; font-weight: 900; letter-spacing: 8px; padding: 16px 32px; border: 3px solid #000; border-radius: 16px; background: #FFD600;">
83
+ ${plainCode}
84
+ </span>
85
+ </div>
86
+ <p style="font-size: 13px; color: #6b7280;">Ce code est valable 10 minutes.</p>
87
+ </div>`,
88
+ })
89
+
90
+ return Response.json(genericSendResponse)
91
+ }
92
+
93
+ if (action === 'verify') {
94
+ if (!code) {
95
+ return Response.json({ error: 'Code manquant' }, { status: 400 })
96
+ }
97
+
98
+ if (verifyLimiter.check(email)) {
99
+ return Response.json(
100
+ { error: 'Trop de tentatives. Réessayez dans 15 minutes.' },
101
+ { status: 429 },
102
+ )
103
+ }
104
+
105
+ const clients = await payload.find({
106
+ collection: slugs.supportClients as any,
107
+ where: { email: { equals: email } },
108
+ limit: 1,
109
+ depth: 0,
110
+ overrideAccess: true,
111
+ })
112
+
113
+ if (clients.docs.length === 0) {
114
+ return Response.json({ error: 'Code incorrect' }, { status: 400 })
115
+ }
116
+
117
+ const client = clients.docs[0] as any
118
+ const storedCode = client.twoFactorCode
119
+ const storedExpiry = client.twoFactorExpiry
120
+
121
+ if (!storedCode || !storedExpiry) {
122
+ return Response.json({ error: 'Aucun code en attente' }, { status: 400 })
123
+ }
124
+
125
+ if (new Date() > new Date(storedExpiry)) {
126
+ await payload.update({
127
+ collection: slugs.supportClients as any,
128
+ id: client.id,
129
+ data: { twoFactorCode: '', twoFactorExpiry: '' },
130
+ overrideAccess: true,
131
+ })
132
+ return Response.json({ error: 'Code expiré. Veuillez en demander un nouveau.' }, { status: 400 })
133
+ }
134
+
135
+ // Hash the submitted code and compare with stored hash (constant-time)
136
+ const submittedHash = hashCode(String(code).padStart(6, '0'))
137
+ const enc = new TextEncoder()
138
+ const submittedBuffer = enc.encode(submittedHash)
139
+ const storedBuffer = enc.encode(storedCode)
140
+ if (submittedBuffer.length !== storedBuffer.length || !crypto.timingSafeEqual(submittedBuffer, storedBuffer)) {
141
+ return Response.json({ error: 'Code incorrect' }, { status: 400 })
142
+ }
143
+
144
+ await payload.update({
145
+ collection: slugs.supportClients as any,
146
+ id: client.id,
147
+ data: { twoFactorCode: '', twoFactorExpiry: '' },
148
+ overrideAccess: true,
149
+ })
150
+
151
+ verifyLimiter.reset(email)
152
+
153
+ return Response.json({ success: true, verified: true })
154
+ }
155
+
156
+ return Response.json({ error: 'Action invalide' }, { status: 400 })
157
+ } catch (err) {
158
+ console.error('[2fa] Error:', err)
159
+ return Response.json({ error: 'Erreur interne' }, { status: 500 })
160
+ }
161
+ },
162
+ }
163
+ }
@@ -0,0 +1,175 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { escapeHtml } from '../utils/emailTemplate'
4
+ import { readSupportSettings } from '../utils/readSettings'
5
+
6
+ /**
7
+ * GET /api/support/auto-close
8
+ * Auto-close inactive tickets. Protected by x-cron-secret header.
9
+ */
10
+ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
11
+ return {
12
+ path: '/support/auto-close',
13
+ method: 'get',
14
+ handler: async (req) => {
15
+ const secret = req.headers.get('x-cron-secret')
16
+ const expectedSecret = process.env.CRON_SECRET
17
+
18
+ if (!expectedSecret || secret !== expectedSecret) {
19
+ return Response.json({ error: 'Non autorisé' }, { status: 401 })
20
+ }
21
+
22
+ try {
23
+ const payload = req.payload
24
+ const now = new Date()
25
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
26
+ const settings = await readSupportSettings(payload)
27
+
28
+ if (!settings.autoClose.enabled) {
29
+ return Response.json({ success: true, skipped: true, reason: 'auto-close disabled in settings' })
30
+ }
31
+
32
+ const url = new URL(req.url!)
33
+ const totalDaysParam = url.searchParams.get('days')
34
+ const totalDays = totalDaysParam ? parseInt(totalDaysParam, 10) : settings.autoClose.daysBeforeClose
35
+ const REMIND_AFTER_DAYS = Math.max(1, totalDays - settings.autoClose.reminderDaysBefore)
36
+ const CLOSE_AFTER_REMIND_DAYS = settings.autoClose.reminderDaysBefore
37
+
38
+ const results = { reminded: 0, closed: 0, errors: 0 }
39
+
40
+ // Step 1: Find tickets needing a reminder
41
+ const remindCutoff = new Date(now.getTime() - REMIND_AFTER_DAYS * 24 * 60 * 60 * 1000)
42
+
43
+ const ticketsToRemind = await payload.find({
44
+ collection: slugs.tickets as any,
45
+ where: {
46
+ and: [
47
+ { status: { equals: 'waiting_client' } },
48
+ { autoCloseRemindedAt: { exists: false } },
49
+ { updatedAt: { less_than: remindCutoff.toISOString() } },
50
+ ],
51
+ },
52
+ limit: 50,
53
+ depth: 1,
54
+ overrideAccess: true,
55
+ })
56
+
57
+ for (const ticket of ticketsToRemind.docs) {
58
+ try {
59
+ const t = ticket as any
60
+ const client = typeof t.client === 'object' ? t.client : null
61
+ if (!client?.email) continue
62
+
63
+ const ticketNumber = t.ticketNumber || 'TK-????'
64
+ const subject = t.subject || 'Support'
65
+ const portalUrl = `${baseUrl}/support/tickets/${t.id}`
66
+
67
+ await payload.sendEmail({
68
+ to: client.email,
69
+ replyTo: settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || '',
70
+ subject: `Rappel : [${ticketNumber}] ${subject} — En attente de votre réponse`,
71
+ html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
72
+ <p>Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,</p>
73
+ <p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em> — est en attente de votre réponse depuis ${REMIND_AFTER_DAYS} jours.</p>
74
+ <p>Si le problème est résolu ou si vous n'avez plus besoin d'assistance, ce ticket sera automatiquement marqué comme résolu dans 2 jours.</p>
75
+ <p><a href="${portalUrl}">Répondre au ticket</a></p>
76
+ </div>`,
77
+ })
78
+
79
+ await payload.update({
80
+ collection: slugs.tickets as any,
81
+ id: t.id,
82
+ data: { autoCloseRemindedAt: now.toISOString() },
83
+ overrideAccess: true,
84
+ })
85
+
86
+ results.reminded++
87
+ } catch (err) {
88
+ console.error(`[auto-close] Error reminding ticket ${(ticket as any).id}:`, err)
89
+ results.errors++
90
+ }
91
+ }
92
+
93
+ // Step 2: Close tickets that were reminded > CLOSE_AFTER_REMIND_DAYS ago
94
+ const closeCutoff = new Date(now.getTime() - CLOSE_AFTER_REMIND_DAYS * 24 * 60 * 60 * 1000)
95
+
96
+ const ticketsToClose = await payload.find({
97
+ collection: slugs.tickets as any,
98
+ where: {
99
+ and: [
100
+ { status: { equals: 'waiting_client' } },
101
+ { autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
102
+ {
103
+ or: [
104
+ { lastClientMessageAt: { exists: false } },
105
+ { lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } },
106
+ ],
107
+ },
108
+ ],
109
+ },
110
+ limit: 50,
111
+ depth: 1,
112
+ overrideAccess: true,
113
+ })
114
+
115
+ for (const ticket of ticketsToClose.docs) {
116
+ try {
117
+ const t = ticket as any
118
+ const client = typeof t.client === 'object' ? t.client : null
119
+ const ticketNumber = t.ticketNumber || 'TK-????'
120
+ const subject = t.subject || 'Support'
121
+ const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS
122
+
123
+ await payload.create({
124
+ collection: slugs.ticketMessages as any,
125
+ data: {
126
+ ticket: t.id,
127
+ body: `Ticket résolu automatiquement — sans réponse client depuis ${closeTotalDays} jours`,
128
+ authorType: 'admin',
129
+ isInternal: true,
130
+ skipNotification: true,
131
+ },
132
+ overrideAccess: true,
133
+ })
134
+
135
+ await payload.update({
136
+ collection: slugs.tickets as any,
137
+ id: t.id,
138
+ data: { status: 'resolved' },
139
+ overrideAccess: true,
140
+ })
141
+
142
+ if (client?.email) {
143
+ const portalUrl = `${baseUrl}/support/tickets/${t.id}`
144
+ await payload.sendEmail({
145
+ to: client.email,
146
+ replyTo: settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || '',
147
+ subject: `[${ticketNumber}] Ticket résolu — ${subject}`,
148
+ html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
149
+ <p>Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,</p>
150
+ <p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em> — a été résolu automatiquement après ${closeTotalDays} jours sans réponse.</p>
151
+ <p>Si vous avez encore besoin d'aide, n'hésitez pas à rouvrir ce ticket ou à en créer un nouveau.</p>
152
+ <p><a href="${portalUrl}">Consulter le ticket</a></p>
153
+ </div>`,
154
+ })
155
+ }
156
+
157
+ results.closed++
158
+ } catch (err) {
159
+ console.error(`[auto-close] Error closing ticket ${(ticket as any).id}:`, err)
160
+ results.errors++
161
+ }
162
+ }
163
+
164
+ return Response.json({
165
+ success: true,
166
+ ...results,
167
+ timestamp: now.toISOString(),
168
+ })
169
+ } catch (error) {
170
+ console.error('[auto-close] Error:', error)
171
+ return Response.json({ error: 'Erreur interne' }, { status: 500 })
172
+ }
173
+ },
174
+ }
175
+ }
@@ -0,0 +1,167 @@
1
+ import type { Endpoint, Where } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+
5
+ const PAGE_SIZE = 500
6
+ const MAX_PAGES = 50
7
+
8
+ /**
9
+ * GET /api/support/billing?from=...&to=...&projectId=...
10
+ * Admin-only endpoint returning billing data.
11
+ */
12
+ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
13
+ return {
14
+ path: '/support/billing',
15
+ method: 'get',
16
+ handler: async (req) => {
17
+ try {
18
+ const payload = req.payload
19
+
20
+ requireAdmin(req, slugs)
21
+
22
+ const url = new URL(req.url!)
23
+ const from = url.searchParams.get('from')
24
+ const to = url.searchParams.get('to')
25
+
26
+ if (!from || !to) {
27
+ return Response.json({ error: 'Missing from/to params' }, { status: 400 })
28
+ }
29
+
30
+ const projectId = url.searchParams.get('projectId')
31
+
32
+ const ticketWhere: Where = {
33
+ billable: { equals: true },
34
+ ...(projectId ? { project: { equals: Number(projectId) } } : {}),
35
+ }
36
+
37
+ // Paginate tickets instead of limit:0
38
+ const allTickets: Array<Record<string, unknown>> = []
39
+ let ticketPage = 1
40
+ let ticketHasMore = true
41
+
42
+ while (ticketHasMore && ticketPage <= MAX_PAGES) {
43
+ const batch = await payload.find({
44
+ collection: slugs.tickets as any,
45
+ where: ticketWhere,
46
+ limit: PAGE_SIZE,
47
+ page: ticketPage,
48
+ depth: 2,
49
+ overrideAccess: true,
50
+ })
51
+ allTickets.push(...batch.docs as any[])
52
+ ticketHasMore = batch.hasNextPage ?? false
53
+ ticketPage++
54
+ }
55
+
56
+ // Paginate time entries instead of limit:0
57
+ const allEntries: Array<Record<string, unknown>> = []
58
+ let entryPage = 1
59
+ let entryHasMore = true
60
+
61
+ while (entryHasMore && entryPage <= MAX_PAGES) {
62
+ const batch = await payload.find({
63
+ collection: slugs.timeEntries as any,
64
+ where: {
65
+ and: [
66
+ { date: { greater_than_equal: from } },
67
+ { date: { less_than_equal: to } },
68
+ ],
69
+ },
70
+ limit: PAGE_SIZE,
71
+ page: entryPage,
72
+ depth: 0,
73
+ overrideAccess: true,
74
+ })
75
+ allEntries.push(...batch.docs as any[])
76
+ entryHasMore = batch.hasNextPage ?? false
77
+ entryPage++
78
+ }
79
+
80
+ // Index entries by ticket ID
81
+ const entriesByTicket = new Map<number, Array<{ duration: number; description: string; date: string }>>()
82
+ for (const entry of allEntries) {
83
+ const e = entry as any
84
+ const ticketId = typeof e.ticket === 'object' ? e.ticket.id : e.ticket
85
+ if (!entriesByTicket.has(ticketId)) entriesByTicket.set(ticketId, [])
86
+ entriesByTicket.get(ticketId)!.push({
87
+ duration: e.duration || 0,
88
+ description: e.description || '',
89
+ date: e.date || '',
90
+ })
91
+ }
92
+
93
+ // Group by project
94
+ const projectGroups = new Map<string, {
95
+ project: { id: number; name: string } | null
96
+ client: { company: string } | null
97
+ tickets: Array<{ id: number; ticketNumber: string; subject: string; entries: any[]; totalMinutes: number; billedAmount: number | null }>
98
+ totalMinutes: number
99
+ totalBilledAmount: number
100
+ }>()
101
+
102
+ for (const ticket of allTickets) {
103
+ const t = ticket as any
104
+ const ticketEntries = entriesByTicket.get(t.id)
105
+ if (!ticketEntries || ticketEntries.length === 0) continue
106
+
107
+ const project = typeof t.project === 'object' && t.project
108
+ ? { id: t.project.id, name: t.project.name || 'Sans nom' }
109
+ : null
110
+
111
+ const projectKey = project ? String(project.id) : 'no-project'
112
+
113
+ if (!projectGroups.has(projectKey)) {
114
+ let clientInfo: { company: string } | null = null
115
+ if (project && typeof t.project === 'object' && t.project) {
116
+ const proj = t.project as any
117
+ if (typeof proj.client === 'object' && proj.client) {
118
+ clientInfo = { company: proj.client.company || '' }
119
+ }
120
+ }
121
+ if (!clientInfo && typeof t.client === 'object' && t.client) {
122
+ clientInfo = { company: t.client.company || '' }
123
+ }
124
+
125
+ projectGroups.set(projectKey, {
126
+ project,
127
+ client: clientInfo,
128
+ tickets: [],
129
+ totalMinutes: 0,
130
+ totalBilledAmount: 0,
131
+ })
132
+ }
133
+
134
+ const ticketTotalMinutes = ticketEntries.reduce((sum, e) => sum + e.duration, 0)
135
+ const billedAmount = t.billedAmount || null
136
+
137
+ projectGroups.get(projectKey)!.tickets.push({
138
+ id: t.id,
139
+ ticketNumber: t.ticketNumber || '',
140
+ subject: t.subject || '',
141
+ entries: ticketEntries,
142
+ totalMinutes: ticketTotalMinutes,
143
+ billedAmount,
144
+ })
145
+ projectGroups.get(projectKey)!.totalMinutes += ticketTotalMinutes
146
+ if (billedAmount) projectGroups.get(projectKey)!.totalBilledAmount += billedAmount
147
+ }
148
+
149
+ const groups = Array.from(projectGroups.values())
150
+ const grandTotalMinutes = groups.reduce((sum, g) => sum + g.totalMinutes, 0)
151
+ const grandTotalBilledAmount = groups.reduce((sum, g) => sum + g.totalBilledAmount, 0)
152
+
153
+ return new Response(JSON.stringify({ groups, grandTotalMinutes, grandTotalBilledAmount }), {
154
+ headers: {
155
+ 'Content-Type': 'application/json',
156
+ 'Cache-Control': 'private, max-age=300, stale-while-revalidate=600',
157
+ },
158
+ })
159
+ } catch (err) {
160
+ const authResponse = handleAuthError(err)
161
+ if (authResponse) return authResponse
162
+ console.error('[billing] Error:', err)
163
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
164
+ }
165
+ },
166
+ }
167
+ }
@@ -0,0 +1,103 @@
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/bulk-action
7
+ * Apply an action to multiple tickets at once. Admin-only.
8
+ */
9
+ export function createBulkActionEndpoint(slugs: CollectionSlugs): Endpoint {
10
+ return {
11
+ path: '/support/bulk-action',
12
+ method: 'post',
13
+ handler: async (req) => {
14
+ try {
15
+ const payload = req.payload
16
+
17
+ requireAdmin(req, slugs)
18
+
19
+ const { ticketIds, action, value } = (await req.json!()) as {
20
+ ticketIds: number[]
21
+ action: 'close' | 'reopen' | 'assign' | 'tag' | 'delete' | 'set_priority' | 'set_category'
22
+ value?: string | number
23
+ }
24
+
25
+ if (!ticketIds?.length || !action) {
26
+ return Response.json({ error: 'ticketIds and action required' }, { status: 400 })
27
+ }
28
+
29
+ let processed = 0
30
+
31
+ for (const ticketId of ticketIds) {
32
+ try {
33
+ switch (action) {
34
+ case 'close':
35
+ await payload.update({
36
+ collection: slugs.tickets as any,
37
+ id: ticketId,
38
+ data: { status: 'resolved', resolvedAt: new Date().toISOString() },
39
+ overrideAccess: true,
40
+ })
41
+ break
42
+ case 'reopen':
43
+ await payload.update({
44
+ collection: slugs.tickets as any,
45
+ id: ticketId,
46
+ data: { status: 'open' },
47
+ overrideAccess: true,
48
+ })
49
+ break
50
+ case 'assign':
51
+ if (value) {
52
+ await payload.update({
53
+ collection: slugs.tickets as any,
54
+ id: ticketId,
55
+ data: { assignedTo: Number(value) },
56
+ overrideAccess: true,
57
+ })
58
+ }
59
+ break
60
+ case 'set_priority':
61
+ if (value) {
62
+ await payload.update({
63
+ collection: slugs.tickets as any,
64
+ id: ticketId,
65
+ data: { priority: String(value) },
66
+ overrideAccess: true,
67
+ })
68
+ }
69
+ break
70
+ case 'set_category':
71
+ if (value) {
72
+ await payload.update({
73
+ collection: slugs.tickets as any,
74
+ id: ticketId,
75
+ data: { category: String(value) },
76
+ overrideAccess: true,
77
+ })
78
+ }
79
+ break
80
+ case 'delete':
81
+ await payload.delete({
82
+ collection: slugs.tickets as any,
83
+ id: ticketId,
84
+ overrideAccess: true,
85
+ })
86
+ break
87
+ }
88
+ processed++
89
+ } catch (err) {
90
+ console.error(`[bulk-action] Failed for ticket ${ticketId}:`, err)
91
+ }
92
+ }
93
+
94
+ return Response.json({ processed, total: ticketIds.length })
95
+ } catch (error) {
96
+ const authResponse = handleAuthError(error)
97
+ if (authResponse) return authResponse
98
+ console.error('[bulk-action] Error:', error)
99
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
100
+ }
101
+ },
102
+ }
103
+ }