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