@consilioweb/payload-support 0.16.0 → 1.0.0
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/README.md +347 -435
- package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
- package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
- package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
- package/dist/components/TicketConversation/SkeletonText.js +20 -0
- package/dist/components/TicketConversation/constants.cjs +27 -0
- package/dist/components/TicketConversation/constants.js +26 -1
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
- package/dist/components/TicketConversation/index.cjs +10 -147
- package/dist/components/TicketConversation/index.js +4 -141
- package/dist/index.cjs +1604 -671
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1602 -671
- package/dist/views/BillingView/client.cjs +40 -54
- package/dist/views/BillingView/client.js +40 -54
- package/dist/views/ChatView/client.cjs +4 -3
- package/dist/views/ChatView/client.js +4 -3
- package/dist/views/CrmView/client.cjs +7 -7
- package/dist/views/CrmView/client.js +7 -7
- package/dist/views/EmailTrackingView/client.cjs +6 -5
- package/dist/views/EmailTrackingView/client.js +6 -5
- package/dist/views/ImportConversationView/client.cjs +4 -4
- package/dist/views/ImportConversationView/client.js +4 -4
- package/dist/views/LogsView/client.cjs +3 -2
- package/dist/views/LogsView/client.js +3 -2
- package/dist/views/NewTicketView/client.cjs +1 -1
- package/dist/views/NewTicketView/client.js +1 -1
- package/dist/views/PendingEmailsView/client.cjs +7 -6
- package/dist/views/PendingEmailsView/client.js +7 -6
- package/dist/views/SupportDashboardView/client.cjs +19 -22
- package/dist/views/SupportDashboardView/client.js +19 -22
- package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
- package/dist/views/TicketDetailView/NextActionItem.js +34 -0
- package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
- package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
- package/dist/views/TicketDetailView/client.cjs +59 -190
- package/dist/views/TicketDetailView/client.js +52 -183
- package/dist/views/TicketDetailView/constants.cjs +20 -0
- package/dist/views/TicketDetailView/constants.js +16 -0
- package/dist/views/TicketDetailView/helpers.cjs +23 -0
- package/dist/views/TicketDetailView/helpers.js +20 -0
- package/dist/views/TicketDetailView/types.cjs +2 -0
- package/dist/views/TicketDetailView/types.js +1 -0
- package/dist/views/TicketInboxView/client.cjs +14 -10
- package/dist/views/TicketInboxView/client.js +14 -10
- package/dist/views/TicketingSettingsView/client.cjs +101 -96
- package/dist/views/TicketingSettingsView/client.js +92 -88
- package/dist/views/TimeDashboardView/client.cjs +2 -2
- package/dist/views/TimeDashboardView/client.js +2 -2
- package/dist/views/shared/StatusPill.cjs +23 -12
- package/dist/views/shared/StatusPill.js +23 -12
- package/dist/views/shared/dateLocale.cjs +5 -0
- package/dist/views/shared/dateLocale.js +3 -0
- package/package.json +23 -15
- package/src/__tests__/generateTrackingToken.test.ts +53 -0
- package/src/__tests__/integration/ai-agent.test.ts +46 -0
- package/src/__tests__/integration/auth.test.ts +43 -0
- package/src/__tests__/integration/automation-rules.test.ts +68 -0
- package/src/__tests__/integration/buildTestPayload.ts +52 -0
- package/src/__tests__/integration/channels.test.ts +52 -0
- package/src/__tests__/integration/core-behaviors.test.ts +58 -0
- package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
- package/src/__tests__/integration/digest.test.ts +48 -0
- package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
- package/src/__tests__/integration/isolation.test.ts +81 -0
- package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
- package/src/__tests__/integration/nps.test.ts +37 -0
- package/src/__tests__/integration/sanitization.test.ts +35 -0
- package/src/__tests__/integration/sla-pause.test.ts +39 -0
- package/src/__tests__/integration/smoke.test.ts +26 -0
- package/src/__tests__/integration/snooze.test.ts +68 -0
- package/src/__tests__/integration/teams.test.ts +38 -0
- package/src/__tests__/rateLimiter.test.ts +89 -0
- package/src/__tests__/sanitizeHtml.test.ts +165 -0
- package/src/__tests__/sla.test.ts +258 -0
- package/src/collections/AutomationRules.ts +66 -0
- package/src/collections/NotificationQueue.ts +30 -0
- package/src/collections/SatisfactionSurveys.ts +11 -2
- package/src/collections/SupportClients.ts +58 -1
- package/src/collections/SupportTeam.ts +27 -0
- package/src/collections/TicketActivityLog.ts +9 -4
- package/src/collections/TicketMessages.ts +129 -31
- package/src/collections/Tickets.ts +31 -73
- package/src/collections/index.ts +3 -0
- package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
- package/src/components/TicketConversation/SkeletonText.tsx +23 -0
- package/src/components/TicketConversation/constants.ts +29 -0
- package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
- package/src/components/TicketConversation/index.tsx +4 -152
- package/src/endpoints/admin-chat-stream.ts +3 -4
- package/src/endpoints/admin-chat.ts +11 -20
- package/src/endpoints/admin-stats.ts +70 -32
- package/src/endpoints/ai-agent.ts +36 -0
- package/src/endpoints/apply-macro.ts +8 -14
- package/src/endpoints/auth-2fa.ts +12 -12
- package/src/endpoints/auto-close.ts +6 -10
- package/src/endpoints/billing.ts +17 -4
- package/src/endpoints/bulk-action.ts +7 -12
- package/src/endpoints/channels.ts +89 -0
- package/src/endpoints/chat-stream.ts +3 -4
- package/src/endpoints/chat.ts +7 -12
- package/src/endpoints/chatbot.ts +2 -2
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/delete-account.ts +9 -16
- package/src/endpoints/email-stats.ts +2 -2
- package/src/endpoints/escalate.ts +2 -2
- package/src/endpoints/export-csv.ts +2 -2
- package/src/endpoints/export-data.ts +5 -8
- package/src/endpoints/import-conversation.ts +6 -10
- package/src/endpoints/index.ts +18 -1
- package/src/endpoints/invite-collaborator.ts +30 -15
- package/src/endpoints/invoice.ts +127 -0
- package/src/endpoints/kb-search.ts +2 -2
- package/src/endpoints/login.ts +12 -4
- package/src/endpoints/merge-clients.ts +14 -19
- package/src/endpoints/merge-tickets.ts +11 -18
- package/src/endpoints/oauth-google.ts +86 -39
- package/src/endpoints/pending-emails-process.ts +15 -25
- package/src/endpoints/process-digests.ts +93 -0
- package/src/endpoints/process-scheduled.ts +6 -10
- package/src/endpoints/process-snooze.ts +78 -0
- package/src/endpoints/purge-logs.ts +2 -2
- package/src/endpoints/resend-notification.ts +3 -4
- package/src/endpoints/round-robin-config.ts +3 -4
- package/src/endpoints/satisfaction.ts +21 -14
- package/src/endpoints/search.ts +5 -8
- package/src/endpoints/seed-kb.ts +3 -4
- package/src/endpoints/send-reminder.ts +6 -9
- package/src/endpoints/settings.ts +5 -2
- package/src/endpoints/signature.ts +3 -4
- package/src/endpoints/sla-check.ts +2 -2
- package/src/endpoints/split-ticket.ts +7 -12
- package/src/endpoints/statuses.ts +2 -2
- package/src/endpoints/ticket-feedback.ts +4 -6
- package/src/endpoints/ticket-synthesis.ts +2 -2
- package/src/endpoints/track-open.ts +16 -28
- package/src/endpoints/transfer-ticket.ts +21 -9
- package/src/endpoints/user-prefs.ts +3 -4
- package/src/hooks/automationRules.ts +84 -0
- package/src/hooks/checkSLA.ts +48 -16
- package/src/plugin.ts +6 -0
- package/src/portal/LiveChat.tsx +2 -63
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
- package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
- package/src/portal/auth/tickets/detail/page.tsx +2 -2
- package/src/portal/helpers.ts +46 -0
- package/src/portal/login/page.tsx +3 -8
- package/src/portal/types.ts +15 -0
- package/src/types/lucide-react.d.ts +34 -0
- package/src/types/payload-config.d.ts +10 -0
- package/src/utils/adminNotification.ts +2 -2
- package/src/utils/aiAgent.ts +147 -0
- package/src/utils/auth.ts +5 -4
- package/src/utils/channels.ts +36 -0
- package/src/utils/db.ts +73 -0
- package/src/utils/emailTemplate.ts +8 -1
- package/src/utils/fireWebhooks.ts +4 -6
- package/src/utils/generateTicketSynthesis.ts +7 -12
- package/src/utils/invoicePdf.ts +88 -0
- package/src/utils/notificationQueue.ts +36 -0
- package/src/utils/readSettings.ts +22 -6
- package/src/utils/sanitizeHtml.ts +57 -0
- package/src/utils/slugs.ts +6 -0
- package/src/utils/teamAccess.ts +27 -0
- package/src/utils/ticketAccess.ts +53 -0
- package/src/utils/webhookDispatcher.ts +4 -6
- package/src/views/BillingView/client.tsx +49 -37
- package/src/views/ChatView/client.tsx +4 -3
- package/src/views/CrmView/client.tsx +7 -7
- package/src/views/EmailTrackingView/client.tsx +7 -6
- package/src/views/ImportConversationView/client.tsx +4 -4
- package/src/views/LogsView/client.tsx +3 -2
- package/src/views/NewTicketView/client.tsx +1 -1
- package/src/views/PendingEmailsView/client.tsx +7 -6
- package/src/views/SupportDashboardView/client.tsx +21 -21
- package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
- package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
- package/src/views/TicketDetailView/client.tsx +54 -179
- package/src/views/TicketDetailView/constants.ts +23 -0
- package/src/views/TicketDetailView/helpers.ts +17 -0
- package/src/views/TicketDetailView/types.ts +10 -0
- package/src/views/TicketInboxView/client.tsx +16 -10
- package/src/views/TicketingSettingsView/client.tsx +90 -87
- package/src/views/TimeDashboardView/client.tsx +2 -2
- package/src/views/shared/StatusPill.tsx +24 -12
- package/src/views/shared/dateLocale.ts +5 -0
- package/src/views/shared/locales/en.json +708 -0
- package/src/views/shared/locales/fr.json +708 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
|
+
import { getFieldsToSign, jwtSign } from 'payload'
|
|
2
3
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
4
|
import crypto from 'crypto'
|
|
5
|
+
import { dbFind, dbUpdate, dbCreate, dbFindByID } from '../utils/db'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* POST /api/support/oauth/google
|
|
@@ -89,22 +91,43 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
|
|
|
89
91
|
return Response.json({ error: 'no_email' }, { status: 400 })
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
// Only trust Google-verified emails — an unverified email matching an
|
|
95
|
+
// existing client would otherwise allow account takeover.
|
|
96
|
+
if (profile.verified_email !== true) {
|
|
97
|
+
return Response.json({ error: 'email_not_verified' }, { status: 403 })
|
|
98
|
+
}
|
|
99
|
+
|
|
92
100
|
const payload = req.payload
|
|
93
101
|
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
where: {
|
|
102
|
+
// Resolve the account by the stable Google subject id first, then by the
|
|
103
|
+
// (verified) email — linking the googleId so subsequent logins are stable.
|
|
104
|
+
const byGoogle = await dbFind(payload, slugs.supportClients, {
|
|
105
|
+
where: { googleId: { equals: profile.id } },
|
|
98
106
|
limit: 1,
|
|
99
107
|
depth: 0,
|
|
100
108
|
overrideAccess: true,
|
|
101
109
|
})
|
|
110
|
+
let clientDoc = byGoogle.docs[0] as { id: number | string; email: string } | undefined
|
|
102
111
|
|
|
103
|
-
|
|
112
|
+
if (!clientDoc) {
|
|
113
|
+
const byEmail = await dbFind(payload, slugs.supportClients, {
|
|
114
|
+
where: { email: { equals: profile.email } },
|
|
115
|
+
limit: 1,
|
|
116
|
+
depth: 0,
|
|
117
|
+
overrideAccess: true,
|
|
118
|
+
})
|
|
119
|
+
clientDoc = byEmail.docs[0] as { id: number | string; email: string } | undefined
|
|
120
|
+
if (clientDoc && profile.id) {
|
|
121
|
+
await dbUpdate(payload, slugs.supportClients, {
|
|
122
|
+
id: clientDoc.id,
|
|
123
|
+
data: { googleId: profile.id },
|
|
124
|
+
overrideAccess: true,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
104
128
|
|
|
105
|
-
// Auto-create account if needed
|
|
129
|
+
// Auto-create account if needed (domain restriction enforced)
|
|
106
130
|
if (!clientDoc) {
|
|
107
|
-
// Domain restriction check for new accounts
|
|
108
131
|
const allowedDomains = options?.allowedEmailDomains
|
|
109
132
|
if (allowedDomains && allowedDomains.length > 0) {
|
|
110
133
|
const emailDomain = profile.email.split('@')[1]?.toLowerCase()
|
|
@@ -122,50 +145,74 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
|
|
|
122
145
|
const fullName = profile.name || profile.email.split('@')[0]
|
|
123
146
|
const nameParts = fullName.split(' ')
|
|
124
147
|
|
|
125
|
-
clientDoc = await payload.
|
|
126
|
-
collection: slugs.supportClients as any,
|
|
148
|
+
clientDoc = await dbCreate(payload, slugs.supportClients, {
|
|
127
149
|
data: {
|
|
128
150
|
email: profile.email,
|
|
129
151
|
firstName: nameParts[0] || fullName,
|
|
130
152
|
lastName: nameParts.slice(1).join(' ') || '-',
|
|
131
153
|
company: fullName,
|
|
154
|
+
googleId: profile.id,
|
|
132
155
|
password: autoPassword,
|
|
133
156
|
},
|
|
134
157
|
overrideAccess: true,
|
|
135
|
-
})
|
|
158
|
+
}) as { id: number | string; email: string }
|
|
136
159
|
}
|
|
137
160
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
// Mint a Payload session WITHOUT touching the user's password.
|
|
162
|
+
// Uses Payload's own jwtSign/getFieldsToSign so the token format matches
|
|
163
|
+
// exactly, and replicates addSessionToUser for the sessions array.
|
|
164
|
+
const secret = process.env.PAYLOAD_SECRET
|
|
165
|
+
if (!secret) {
|
|
166
|
+
return Response.json({ error: 'server_misconfigured' }, { status: 500 })
|
|
167
|
+
}
|
|
168
|
+
const collectionConfig = (payload as any).collections[slugs.supportClients].config
|
|
169
|
+
const tokenExpiration: number = collectionConfig.auth?.tokenExpiration ?? 7200
|
|
170
|
+
|
|
171
|
+
let sid: string | undefined
|
|
172
|
+
if (collectionConfig.auth?.useSessions) {
|
|
173
|
+
sid = crypto.randomUUID()
|
|
174
|
+
const now = new Date()
|
|
175
|
+
const expiresAt = new Date(now.getTime() + tokenExpiration * 1000)
|
|
176
|
+
const fresh = (await dbFindByID(payload, slugs.supportClients, {
|
|
177
|
+
id: clientDoc.id,
|
|
178
|
+
depth: 0,
|
|
179
|
+
overrideAccess: true,
|
|
180
|
+
showHiddenFields: true,
|
|
181
|
+
})) as { sessions?: Array<{ id: string; createdAt: string; expiresAt: string }> }
|
|
182
|
+
const kept = Array.isArray(fresh?.sessions)
|
|
183
|
+
? fresh.sessions.filter((s) => new Date(s.expiresAt) > now)
|
|
184
|
+
: []
|
|
185
|
+
await payload.db.updateOne({
|
|
186
|
+
collection: slugs.supportClients,
|
|
187
|
+
id: clientDoc.id,
|
|
188
|
+
data: {
|
|
189
|
+
sessions: [
|
|
190
|
+
...kept,
|
|
191
|
+
{ id: sid, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString() },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
returning: false,
|
|
195
|
+
} as any)
|
|
163
196
|
}
|
|
164
197
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
198
|
+
const fieldsToSign = getFieldsToSign({
|
|
199
|
+
collectionConfig,
|
|
200
|
+
email: clientDoc.email,
|
|
201
|
+
sid,
|
|
202
|
+
user: { ...clientDoc, collection: slugs.supportClients },
|
|
203
|
+
} as any)
|
|
204
|
+
const { token, exp } = await jwtSign({ fieldsToSign, secret, tokenExpiration })
|
|
205
|
+
|
|
206
|
+
const headers = new Headers({ 'Content-Type': 'application/json' })
|
|
207
|
+
const cookieSecure = process.env.NODE_ENV === 'production'
|
|
208
|
+
headers.append(
|
|
209
|
+
'Set-Cookie',
|
|
210
|
+
`payload-token=${token}; HttpOnly; ${cookieSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return new Response(JSON.stringify({ token, user: clientDoc, exp }), {
|
|
214
|
+
status: 200,
|
|
215
|
+
headers,
|
|
169
216
|
})
|
|
170
217
|
}
|
|
171
218
|
|
|
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
|
|
|
3
3
|
import { escapeHtml } from '../utils/emailTemplate'
|
|
4
4
|
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
5
5
|
import { readSupportSettings } from '../utils/readSettings'
|
|
6
|
+
import { dbFindByID, dbCreate, dbUpdate } from '../utils/db'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* POST /api/support/pending-emails/:id/process
|
|
@@ -30,12 +31,11 @@ export function createPendingEmailsProcessEndpoint(slugs: CollectionSlugs): Endp
|
|
|
30
31
|
return Response.json({ error: 'Invalid action' }, { status: 400 })
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
const pendingEmail = await payload.
|
|
34
|
-
collection: slugs.pendingEmails as any,
|
|
34
|
+
const pendingEmail = await dbFindByID(payload, slugs.pendingEmails, {
|
|
35
35
|
id: Number(id),
|
|
36
36
|
depth: 1,
|
|
37
37
|
overrideAccess: true,
|
|
38
|
-
})
|
|
38
|
+
})
|
|
39
39
|
|
|
40
40
|
if (!pendingEmail) {
|
|
41
41
|
return Response.json({ error: 'Pending email not found' }, { status: 404 })
|
|
@@ -49,15 +49,13 @@ export function createPendingEmailsProcessEndpoint(slugs: CollectionSlugs): Endp
|
|
|
49
49
|
let clientDoc = typeof pendingEmail.client === 'object' ? pendingEmail.client : null
|
|
50
50
|
|
|
51
51
|
if (overrideClientId && overrideClientId !== clientDoc?.id) {
|
|
52
|
-
clientDoc = await payload.
|
|
53
|
-
collection: slugs.supportClients as any,
|
|
52
|
+
clientDoc = await dbFindByID(payload, slugs.supportClients, {
|
|
54
53
|
id: overrideClientId,
|
|
55
54
|
depth: 0,
|
|
56
55
|
overrideAccess: true,
|
|
57
56
|
})
|
|
58
57
|
|
|
59
|
-
await payload.
|
|
60
|
-
collection: slugs.pendingEmails as any,
|
|
58
|
+
await dbUpdate(payload, slugs.pendingEmails, {
|
|
61
59
|
id: Number(id),
|
|
62
60
|
data: { client: overrideClientId },
|
|
63
61
|
overrideAccess: true,
|
|
@@ -78,8 +76,7 @@ export function createPendingEmailsProcessEndpoint(slugs: CollectionSlugs): Endp
|
|
|
78
76
|
})) || []
|
|
79
77
|
|
|
80
78
|
if (action === 'ignore') {
|
|
81
|
-
await payload.
|
|
82
|
-
collection: slugs.pendingEmails as any,
|
|
79
|
+
await dbUpdate(payload, slugs.pendingEmails, {
|
|
83
80
|
id: Number(id),
|
|
84
81
|
data: {
|
|
85
82
|
status: 'ignored',
|
|
@@ -92,8 +89,7 @@ export function createPendingEmailsProcessEndpoint(slugs: CollectionSlugs): Endp
|
|
|
92
89
|
}
|
|
93
90
|
|
|
94
91
|
if (action === 'create_ticket') {
|
|
95
|
-
const newTicket = await payload.
|
|
96
|
-
collection: slugs.tickets as any,
|
|
92
|
+
const newTicket = await dbCreate(payload, slugs.tickets, {
|
|
97
93
|
data: {
|
|
98
94
|
subject: pendingEmail.subject,
|
|
99
95
|
client: clientId!,
|
|
@@ -103,10 +99,9 @@ export function createPendingEmailsProcessEndpoint(slugs: CollectionSlugs): Endp
|
|
|
103
99
|
source: 'email',
|
|
104
100
|
},
|
|
105
101
|
overrideAccess: true,
|
|
106
|
-
})
|
|
102
|
+
})
|
|
107
103
|
|
|
108
|
-
await payload.
|
|
109
|
-
collection: slugs.ticketMessages as any,
|
|
104
|
+
await dbCreate(payload, slugs.ticketMessages, {
|
|
110
105
|
data: {
|
|
111
106
|
ticket: newTicket.id,
|
|
112
107
|
body: pendingEmail.body,
|
|
@@ -136,8 +131,7 @@ export function createPendingEmailsProcessEndpoint(slugs: CollectionSlugs): Endp
|
|
|
136
131
|
console.error('[pending-email-process] Failed to send notification:', err)
|
|
137
132
|
}
|
|
138
133
|
|
|
139
|
-
await payload.
|
|
140
|
-
collection: slugs.pendingEmails as any,
|
|
134
|
+
await dbUpdate(payload, slugs.pendingEmails, {
|
|
141
135
|
id: Number(id),
|
|
142
136
|
data: {
|
|
143
137
|
status: 'processed',
|
|
@@ -160,19 +154,17 @@ export function createPendingEmailsProcessEndpoint(slugs: CollectionSlugs): Endp
|
|
|
160
154
|
return Response.json({ error: 'ticketId is required for add_to_ticket' }, { status: 400 })
|
|
161
155
|
}
|
|
162
156
|
|
|
163
|
-
const targetTicket = await payload.
|
|
164
|
-
collection: slugs.tickets as any,
|
|
157
|
+
const targetTicket = await dbFindByID(payload, slugs.tickets, {
|
|
165
158
|
id: ticketId,
|
|
166
159
|
depth: 0,
|
|
167
160
|
overrideAccess: true,
|
|
168
|
-
})
|
|
161
|
+
})
|
|
169
162
|
|
|
170
163
|
if (!targetTicket) {
|
|
171
164
|
return Response.json({ error: 'Target ticket not found' }, { status: 404 })
|
|
172
165
|
}
|
|
173
166
|
|
|
174
|
-
await payload.
|
|
175
|
-
collection: slugs.ticketMessages as any,
|
|
167
|
+
await dbCreate(payload, slugs.ticketMessages, {
|
|
176
168
|
data: {
|
|
177
169
|
ticket: ticketId,
|
|
178
170
|
body: pendingEmail.body,
|
|
@@ -185,16 +177,14 @@ export function createPendingEmailsProcessEndpoint(slugs: CollectionSlugs): Endp
|
|
|
185
177
|
})
|
|
186
178
|
|
|
187
179
|
if (targetTicket.status === 'resolved') {
|
|
188
|
-
await payload.
|
|
189
|
-
collection: slugs.tickets as any,
|
|
180
|
+
await dbUpdate(payload, slugs.tickets, {
|
|
190
181
|
id: ticketId,
|
|
191
182
|
data: { status: 'open' },
|
|
192
183
|
overrideAccess: true,
|
|
193
184
|
})
|
|
194
185
|
}
|
|
195
186
|
|
|
196
|
-
await payload.
|
|
197
|
-
collection: slugs.pendingEmails as any,
|
|
187
|
+
await dbUpdate(payload, slugs.pendingEmails, {
|
|
198
188
|
id: Number(id),
|
|
199
189
|
data: {
|
|
200
190
|
status: 'processed',
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { dbFind, dbFindByID, dbDelete } from '../utils/db'
|
|
4
|
+
import { emailWrapper, emailParagraph, escapeHtml } from '../utils/emailTemplate'
|
|
5
|
+
import { readSupportSettings } from '../utils/readSettings'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POST /api/support/process-digests body: { frequency?: 'daily' | 'weekly' }
|
|
9
|
+
*
|
|
10
|
+
* Drains the notification queue for clients whose `notificationFrequency` matches
|
|
11
|
+
* the requested cadence, sending each one a single recap email. Run a daily job
|
|
12
|
+
* with `frequency=daily` and a weekly job with `frequency=weekly`.
|
|
13
|
+
* Protected by the x-cron-secret header.
|
|
14
|
+
*/
|
|
15
|
+
export function createProcessDigestsEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
16
|
+
return {
|
|
17
|
+
path: '/support/process-digests',
|
|
18
|
+
method: 'post',
|
|
19
|
+
handler: async (req) => {
|
|
20
|
+
const secret = req.headers.get('x-cron-secret')
|
|
21
|
+
if (!process.env.CRON_SECRET || secret !== process.env.CRON_SECRET) {
|
|
22
|
+
return Response.json({ error: 'Non autorisé' }, { status: 401 })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const payload = req.payload
|
|
27
|
+
let body: { frequency?: string } = {}
|
|
28
|
+
try { body = (await req.json!()) as { frequency?: string } } catch { /* no body */ }
|
|
29
|
+
const frequency = body.frequency === 'weekly' ? 'weekly' : 'daily'
|
|
30
|
+
|
|
31
|
+
const settings = await readSupportSettings(payload)
|
|
32
|
+
const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || ''
|
|
33
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
34
|
+
|
|
35
|
+
// Gather all queued items, grouped by client.
|
|
36
|
+
type Item = { id: number | string; title?: string; message?: string }
|
|
37
|
+
const itemsByClient = new Map<string, Item[]>()
|
|
38
|
+
let page = 1
|
|
39
|
+
let hasMore = true
|
|
40
|
+
while (hasMore && page <= 50) {
|
|
41
|
+
const batch = await dbFind(payload, slugs.notificationQueue, { limit: 200, page, depth: 0, overrideAccess: true, sort: 'createdAt' })
|
|
42
|
+
for (const it of batch.docs) {
|
|
43
|
+
const raw = it as { client?: number | string | { id?: number | string } } & Item
|
|
44
|
+
const clientId = typeof raw.client === 'object' ? raw.client?.id : raw.client
|
|
45
|
+
if (clientId === undefined || clientId === null) continue
|
|
46
|
+
const key = String(clientId)
|
|
47
|
+
if (!itemsByClient.has(key)) itemsByClient.set(key, [])
|
|
48
|
+
itemsByClient.get(key)!.push({ id: raw.id, title: raw.title, message: raw.message })
|
|
49
|
+
}
|
|
50
|
+
hasMore = batch.hasNextPage ?? false
|
|
51
|
+
page++
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const results = { clients: 0, items: 0, errors: 0 }
|
|
55
|
+
for (const [clientId, items] of itemsByClient) {
|
|
56
|
+
try {
|
|
57
|
+
const client = await dbFindByID(payload, slugs.supportClients, { id: clientId, depth: 0, overrideAccess: true })
|
|
58
|
+
// Only deliver for clients whose cadence matches this run.
|
|
59
|
+
if (!client || client.notificationFrequency !== frequency) continue
|
|
60
|
+
|
|
61
|
+
if (client.email) {
|
|
62
|
+
const rows = items
|
|
63
|
+
.map((i) => `<tr><td style="padding:8px 0;font-size:14px;color:#1e293b;border-bottom:1px solid #f1f5f9;"><strong>${escapeHtml(i.title || '')}</strong><br/><span style="color:#64748b;font-size:13px;">${escapeHtml(i.message || '')}</span></td></tr>`)
|
|
64
|
+
.join('')
|
|
65
|
+
const html = emailWrapper('Recapitulatif de vos tickets', [
|
|
66
|
+
emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,`),
|
|
67
|
+
emailParagraph(`Voici le recapitulatif de l'activite sur vos tickets (${items.length} notification${items.length > 1 ? 's' : ''}) :`),
|
|
68
|
+
`<table cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table>`,
|
|
69
|
+
...(baseUrl ? [emailParagraph(`<a href="${baseUrl}/support/dashboard">Acceder a votre espace support</a>`)] : []),
|
|
70
|
+
].join(''), { kind: 'system' })
|
|
71
|
+
await payload.sendEmail({ to: client.email, ...(replyTo ? { replyTo } : {}), subject: `Recapitulatif support (${items.length})`, html })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Drain the queue regardless of email success (avoid resend loops).
|
|
75
|
+
for (const i of items) {
|
|
76
|
+
try { await dbDelete(payload, slugs.notificationQueue, { id: i.id, overrideAccess: true }) } catch { /* ignore */ }
|
|
77
|
+
}
|
|
78
|
+
results.clients++
|
|
79
|
+
results.items += items.length
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error('[process-digests] client error:', err)
|
|
82
|
+
results.errors++
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return Response.json({ ok: true, frequency, ...results })
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error('[process-digests] Error:', err)
|
|
89
|
+
return Response.json({ error: 'Erreur interne' }, { status: 500 })
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
|
|
|
3
3
|
import { escapeHtml } from '../utils/emailTemplate'
|
|
4
4
|
import { fireWebhooks } from '../utils/fireWebhooks'
|
|
5
5
|
import { readSupportSettings } from '../utils/readSettings'
|
|
6
|
+
import { dbFind, dbFindByID, dbUpdate } from '../utils/db'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* POST /api/support/process-scheduled
|
|
@@ -29,8 +30,7 @@ export function createProcessScheduledEndpoint(slugs: CollectionSlugs): Endpoint
|
|
|
29
30
|
const results = { processed: 0, errors: 0 }
|
|
30
31
|
|
|
31
32
|
// Find all messages where scheduledAt is in the past and scheduledSent is not true
|
|
32
|
-
const scheduled = await payload.
|
|
33
|
-
collection: slugs.ticketMessages as any,
|
|
33
|
+
const scheduled = await dbFind(payload, slugs.ticketMessages, {
|
|
34
34
|
where: {
|
|
35
35
|
and: [
|
|
36
36
|
{ scheduledAt: { less_than_equal: now.toISOString() } },
|
|
@@ -53,8 +53,7 @@ export function createProcessScheduledEndpoint(slugs: CollectionSlugs): Endpoint
|
|
|
53
53
|
const ticketId = typeof msg.ticket === 'object' ? msg.ticket.id : msg.ticket
|
|
54
54
|
|
|
55
55
|
// Fetch the ticket with client data
|
|
56
|
-
const ticket = await payload.
|
|
57
|
-
collection: slugs.tickets as any,
|
|
56
|
+
const ticket = await dbFindByID(payload, slugs.tickets, {
|
|
58
57
|
id: ticketId,
|
|
59
58
|
depth: 1,
|
|
60
59
|
overrideAccess: true,
|
|
@@ -84,8 +83,7 @@ export function createProcessScheduledEndpoint(slugs: CollectionSlugs): Endpoint
|
|
|
84
83
|
})
|
|
85
84
|
|
|
86
85
|
// Record email delivery info
|
|
87
|
-
await payload.
|
|
88
|
-
collection: slugs.ticketMessages as any,
|
|
86
|
+
await dbUpdate(payload, slugs.ticketMessages, {
|
|
89
87
|
id: msg.id,
|
|
90
88
|
data: {
|
|
91
89
|
scheduledSent: true,
|
|
@@ -96,8 +94,7 @@ export function createProcessScheduledEndpoint(slugs: CollectionSlugs): Endpoint
|
|
|
96
94
|
})
|
|
97
95
|
} else {
|
|
98
96
|
// No email needed (internal note or no client email), just mark as sent
|
|
99
|
-
await payload.
|
|
100
|
-
collection: slugs.ticketMessages as any,
|
|
97
|
+
await dbUpdate(payload, slugs.ticketMessages, {
|
|
101
98
|
id: msg.id,
|
|
102
99
|
data: { scheduledSent: true },
|
|
103
100
|
overrideAccess: true,
|
|
@@ -106,8 +103,7 @@ export function createProcessScheduledEndpoint(slugs: CollectionSlugs): Endpoint
|
|
|
106
103
|
|
|
107
104
|
// Update ticket status if this is an admin reply
|
|
108
105
|
if (msg.authorType === 'admin' && !msg.isInternal) {
|
|
109
|
-
await payload.
|
|
110
|
-
collection: slugs.tickets as any,
|
|
106
|
+
await dbUpdate(payload, slugs.tickets, {
|
|
111
107
|
id: ticketId,
|
|
112
108
|
data: { status: 'waiting_client' },
|
|
113
109
|
overrideAccess: true,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { dbFind, dbUpdate, dbCreate } from '../utils/db'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* POST /api/support/process-snooze
|
|
7
|
+
*
|
|
8
|
+
* Wakes up tickets whose snooze deadline has passed: clears `snoozeUntil` so the
|
|
9
|
+
* ticket stops being filtered out of the inbox, and records a `snooze_expired`
|
|
10
|
+
* activity entry. Tickets also resurface automatically once `snoozeUntil` is in
|
|
11
|
+
* the past (the inbox query hides only future snoozes) — this cron just cleans up
|
|
12
|
+
* the stale field and leaves an audit trail.
|
|
13
|
+
*
|
|
14
|
+
* Protected by the `x-cron-secret` header (same pattern as process-scheduled).
|
|
15
|
+
*/
|
|
16
|
+
export function createProcessSnoozeEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
17
|
+
return {
|
|
18
|
+
path: '/support/process-snooze',
|
|
19
|
+
method: 'post',
|
|
20
|
+
handler: async (req) => {
|
|
21
|
+
const secret = req.headers.get('x-cron-secret')
|
|
22
|
+
const expectedSecret = process.env.CRON_SECRET
|
|
23
|
+
if (!expectedSecret || secret !== expectedSecret) {
|
|
24
|
+
return Response.json({ error: 'Non autorisé' }, { status: 401 })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const payload = req.payload
|
|
29
|
+
const nowIso = new Date().toISOString()
|
|
30
|
+
const results = { processed: 0, errors: 0 }
|
|
31
|
+
|
|
32
|
+
const due = await dbFind(payload, slugs.tickets, {
|
|
33
|
+
where: {
|
|
34
|
+
and: [
|
|
35
|
+
{ snoozeUntil: { exists: true } },
|
|
36
|
+
{ snoozeUntil: { less_than_equal: nowIso } },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
limit: 500,
|
|
40
|
+
depth: 0,
|
|
41
|
+
overrideAccess: true,
|
|
42
|
+
select: { id: true, ticketNumber: true },
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
for (const ticket of due.docs) {
|
|
46
|
+
try {
|
|
47
|
+
await dbUpdate(payload, slugs.tickets, {
|
|
48
|
+
id: (ticket as { id: number | string }).id,
|
|
49
|
+
data: { snoozeUntil: null },
|
|
50
|
+
overrideAccess: true,
|
|
51
|
+
})
|
|
52
|
+
// Audit trail — best-effort (activity-log may be unavailable).
|
|
53
|
+
try {
|
|
54
|
+
await dbCreate(payload, slugs.ticketActivityLog, {
|
|
55
|
+
data: {
|
|
56
|
+
ticket: (ticket as { id: number | string }).id,
|
|
57
|
+
action: 'snooze_expired',
|
|
58
|
+
detail: 'Snooze échu — ticket réactivé',
|
|
59
|
+
actorType: 'system',
|
|
60
|
+
},
|
|
61
|
+
overrideAccess: true,
|
|
62
|
+
})
|
|
63
|
+
} catch { /* activity log optional */ }
|
|
64
|
+
results.processed++
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error('[support] Failed to wake snoozed ticket:', err)
|
|
67
|
+
results.errors++
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return Response.json({ ok: true, ...results })
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error('[process-snooze] Error:', err)
|
|
74
|
+
return Response.json({ error: 'Erreur interne' }, { status: 500 })
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
3
|
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
+
import { dbDelete } from '../utils/db'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* DELETE /api/support/purge-logs?collection=email-logs&days=30
|
|
@@ -32,8 +33,7 @@ export function createPurgeLogsEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
32
33
|
|
|
33
34
|
const cutoff = days > 0 ? new Date(Date.now() - days * 86400000).toISOString() : null
|
|
34
35
|
|
|
35
|
-
const result = await payload
|
|
36
|
-
collection: allowedCollections[collection] as any,
|
|
36
|
+
const result = await dbDelete(payload, allowedCollections[collection], {
|
|
37
37
|
where: cutoff
|
|
38
38
|
? { createdAt: { less_than: cutoff } }
|
|
39
39
|
: { id: { exists: true } },
|
|
@@ -4,6 +4,7 @@ import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
|
4
4
|
import { RateLimiter } from '../utils/rateLimiter'
|
|
5
5
|
import { escapeHtml } from '../utils/emailTemplate'
|
|
6
6
|
import { readSupportSettings } from '../utils/readSettings'
|
|
7
|
+
import { dbFindByID } from '../utils/db'
|
|
7
8
|
|
|
8
9
|
const resendLimiter = new RateLimiter(60 * 60 * 1000, 10) // 10 per hour
|
|
9
10
|
|
|
@@ -40,8 +41,7 @@ export function createResendNotificationEndpoint(slugs: CollectionSlugs): Endpoi
|
|
|
40
41
|
return Response.json({ error: 'messageId requis' }, { status: 400 })
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
const message = await payload.
|
|
44
|
-
collection: slugs.ticketMessages as any,
|
|
44
|
+
const message = await dbFindByID(payload, slugs.ticketMessages, {
|
|
45
45
|
id: messageId,
|
|
46
46
|
depth: 0,
|
|
47
47
|
overrideAccess: true,
|
|
@@ -52,8 +52,7 @@ export function createResendNotificationEndpoint(slugs: CollectionSlugs): Endpoi
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
const ticketId = typeof message.ticket === 'object' ? message.ticket.id : message.ticket
|
|
55
|
-
const ticket = await payload.
|
|
56
|
-
collection: slugs.tickets as any,
|
|
55
|
+
const ticket = await dbFindByID(payload, slugs.tickets, {
|
|
57
56
|
id: ticketId,
|
|
58
57
|
depth: 1,
|
|
59
58
|
overrideAccess: true,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
3
|
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
+
import { dbFind } from '../utils/db'
|
|
4
5
|
|
|
5
6
|
const PREF_KEY = 'support-round-robin'
|
|
6
7
|
|
|
@@ -17,8 +18,7 @@ export function createRoundRobinConfigGetEndpoint(slugs: CollectionSlugs): Endpo
|
|
|
17
18
|
|
|
18
19
|
requireAdmin(req, slugs)
|
|
19
20
|
|
|
20
|
-
const prefs = await payload
|
|
21
|
-
collection: 'payload-preferences' as any,
|
|
21
|
+
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
22
22
|
where: { key: { equals: PREF_KEY } },
|
|
23
23
|
limit: 1,
|
|
24
24
|
depth: 0,
|
|
@@ -55,8 +55,7 @@ export function createRoundRobinConfigPostEndpoint(slugs: CollectionSlugs): Endp
|
|
|
55
55
|
|
|
56
56
|
const { enabled } = (await req.json!()) as { enabled: boolean }
|
|
57
57
|
|
|
58
|
-
const existing = await payload
|
|
59
|
-
collection: 'payload-preferences' as any,
|
|
58
|
+
const existing = await dbFind(payload, 'payload-preferences', {
|
|
60
59
|
where: { key: { equals: PREF_KEY } },
|
|
61
60
|
limit: 1,
|
|
62
61
|
depth: 0,
|