@consilioweb/payload-support 0.15.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.
Files changed (202) hide show
  1. package/README.md +347 -435
  2. package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
  4. package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
  5. package/dist/components/TicketConversation/SkeletonText.js +20 -0
  6. package/dist/components/TicketConversation/components/ActionPanels.cjs +72 -0
  7. package/dist/components/TicketConversation/components/ActionPanels.js +73 -2
  8. package/dist/components/TicketConversation/components/QuickActions.cjs +22 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +22 -1
  10. package/dist/components/TicketConversation/constants.cjs +27 -0
  11. package/dist/components/TicketConversation/constants.js +26 -1
  12. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  13. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  14. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +59 -0
  15. package/dist/components/TicketConversation/hooks/useTicketActions.js +59 -0
  16. package/dist/components/TicketConversation/index.cjs +42 -149
  17. package/dist/components/TicketConversation/index.js +37 -144
  18. package/dist/index.cjs +1835 -741
  19. package/dist/index.d.cts +3 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +1833 -741
  22. package/dist/views/BillingView/client.cjs +40 -54
  23. package/dist/views/BillingView/client.js +40 -54
  24. package/dist/views/ChatView/client.cjs +4 -3
  25. package/dist/views/ChatView/client.js +4 -3
  26. package/dist/views/CrmView/client.cjs +7 -7
  27. package/dist/views/CrmView/client.js +7 -7
  28. package/dist/views/EmailTrackingView/client.cjs +6 -5
  29. package/dist/views/EmailTrackingView/client.js +6 -5
  30. package/dist/views/ImportConversationView/client.cjs +4 -4
  31. package/dist/views/ImportConversationView/client.js +4 -4
  32. package/dist/views/LogsView/client.cjs +3 -2
  33. package/dist/views/LogsView/client.js +3 -2
  34. package/dist/views/NewTicketView/client.cjs +1 -1
  35. package/dist/views/NewTicketView/client.js +1 -1
  36. package/dist/views/PendingEmailsView/client.cjs +7 -6
  37. package/dist/views/PendingEmailsView/client.js +7 -6
  38. package/dist/views/SupportDashboardView/client.cjs +19 -22
  39. package/dist/views/SupportDashboardView/client.js +19 -22
  40. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  41. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  42. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  43. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  44. package/dist/views/TicketDetailView/client.cjs +59 -190
  45. package/dist/views/TicketDetailView/client.js +52 -183
  46. package/dist/views/TicketDetailView/constants.cjs +20 -0
  47. package/dist/views/TicketDetailView/constants.js +16 -0
  48. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  49. package/dist/views/TicketDetailView/helpers.js +20 -0
  50. package/dist/views/TicketDetailView/types.cjs +2 -0
  51. package/dist/views/TicketDetailView/types.js +1 -0
  52. package/dist/views/TicketInboxView/client.cjs +14 -10
  53. package/dist/views/TicketInboxView/client.js +14 -10
  54. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  55. package/dist/views/TicketingSettingsView/client.js +92 -88
  56. package/dist/views/TimeDashboardView/client.cjs +2 -2
  57. package/dist/views/TimeDashboardView/client.js +2 -2
  58. package/dist/views/shared/StatusPill.cjs +23 -12
  59. package/dist/views/shared/StatusPill.js +23 -12
  60. package/dist/views/shared/dateLocale.cjs +5 -0
  61. package/dist/views/shared/dateLocale.js +3 -0
  62. package/package.json +23 -15
  63. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  64. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  65. package/src/__tests__/integration/auth.test.ts +43 -0
  66. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  67. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  68. package/src/__tests__/integration/channels.test.ts +52 -0
  69. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  70. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  71. package/src/__tests__/integration/digest.test.ts +48 -0
  72. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  73. package/src/__tests__/integration/isolation.test.ts +81 -0
  74. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  75. package/src/__tests__/integration/nps.test.ts +37 -0
  76. package/src/__tests__/integration/sanitization.test.ts +35 -0
  77. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  78. package/src/__tests__/integration/smoke.test.ts +26 -0
  79. package/src/__tests__/integration/snooze.test.ts +68 -0
  80. package/src/__tests__/integration/teams.test.ts +38 -0
  81. package/src/__tests__/rateLimiter.test.ts +89 -0
  82. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  83. package/src/__tests__/sla.test.ts +258 -0
  84. package/src/collections/AutomationRules.ts +66 -0
  85. package/src/collections/NotificationQueue.ts +30 -0
  86. package/src/collections/SatisfactionSurveys.ts +11 -2
  87. package/src/collections/SupportClients.ts +58 -1
  88. package/src/collections/SupportTeam.ts +27 -0
  89. package/src/collections/TicketActivityLog.ts +9 -4
  90. package/src/collections/TicketMessages.ts +137 -31
  91. package/src/collections/Tickets.ts +31 -72
  92. package/src/collections/index.ts +3 -0
  93. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  94. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  95. package/src/components/TicketConversation/components/ActionPanels.tsx +82 -1
  96. package/src/components/TicketConversation/components/QuickActions.tsx +25 -0
  97. package/src/components/TicketConversation/constants.ts +29 -0
  98. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  99. package/src/components/TicketConversation/hooks/useTicketActions.ts +51 -0
  100. package/src/components/TicketConversation/index.tsx +29 -157
  101. package/src/endpoints/admin-chat-stream.ts +3 -4
  102. package/src/endpoints/admin-chat.ts +11 -20
  103. package/src/endpoints/admin-stats.ts +70 -32
  104. package/src/endpoints/ai-agent.ts +36 -0
  105. package/src/endpoints/apply-macro.ts +8 -14
  106. package/src/endpoints/auth-2fa.ts +12 -12
  107. package/src/endpoints/auto-close.ts +38 -18
  108. package/src/endpoints/billing.ts +17 -4
  109. package/src/endpoints/bulk-action.ts +7 -12
  110. package/src/endpoints/channels.ts +89 -0
  111. package/src/endpoints/chat-stream.ts +3 -4
  112. package/src/endpoints/chat.ts +7 -12
  113. package/src/endpoints/chatbot.ts +2 -2
  114. package/src/endpoints/client-intelligence.ts +4 -4
  115. package/src/endpoints/delete-account.ts +9 -16
  116. package/src/endpoints/email-stats.ts +2 -2
  117. package/src/endpoints/escalate.ts +2 -2
  118. package/src/endpoints/export-csv.ts +2 -2
  119. package/src/endpoints/export-data.ts +5 -8
  120. package/src/endpoints/import-conversation.ts +6 -10
  121. package/src/endpoints/index.ts +24 -2
  122. package/src/endpoints/invite-collaborator.ts +30 -15
  123. package/src/endpoints/invoice.ts +127 -0
  124. package/src/endpoints/kb-search.ts +2 -2
  125. package/src/endpoints/login.ts +12 -4
  126. package/src/endpoints/merge-clients.ts +14 -19
  127. package/src/endpoints/merge-tickets.ts +11 -18
  128. package/src/endpoints/oauth-google.ts +86 -39
  129. package/src/endpoints/pending-emails-process.ts +15 -25
  130. package/src/endpoints/process-digests.ts +93 -0
  131. package/src/endpoints/process-scheduled.ts +6 -10
  132. package/src/endpoints/process-snooze.ts +78 -0
  133. package/src/endpoints/purge-logs.ts +2 -2
  134. package/src/endpoints/resend-notification.ts +3 -4
  135. package/src/endpoints/round-robin-config.ts +3 -4
  136. package/src/endpoints/satisfaction.ts +21 -14
  137. package/src/endpoints/search.ts +5 -8
  138. package/src/endpoints/seed-kb.ts +3 -4
  139. package/src/endpoints/send-reminder.ts +186 -0
  140. package/src/endpoints/settings.ts +5 -2
  141. package/src/endpoints/signature.ts +3 -4
  142. package/src/endpoints/sla-check.ts +2 -2
  143. package/src/endpoints/split-ticket.ts +7 -12
  144. package/src/endpoints/statuses.ts +2 -2
  145. package/src/endpoints/ticket-feedback.ts +4 -6
  146. package/src/endpoints/ticket-synthesis.ts +2 -2
  147. package/src/endpoints/track-open.ts +16 -28
  148. package/src/endpoints/transfer-ticket.ts +21 -9
  149. package/src/endpoints/user-prefs.ts +3 -4
  150. package/src/hooks/automationRules.ts +84 -0
  151. package/src/hooks/checkSLA.ts +48 -16
  152. package/src/plugin.ts +6 -0
  153. package/src/portal/LiveChat.tsx +2 -63
  154. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  155. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  156. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  157. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  158. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  159. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  160. package/src/portal/helpers.ts +46 -0
  161. package/src/portal/login/page.tsx +3 -8
  162. package/src/portal/types.ts +15 -0
  163. package/src/types/lucide-react.d.ts +34 -0
  164. package/src/types/payload-config.d.ts +10 -0
  165. package/src/utils/adminNotification.ts +2 -2
  166. package/src/utils/aiAgent.ts +147 -0
  167. package/src/utils/auth.ts +5 -4
  168. package/src/utils/channels.ts +36 -0
  169. package/src/utils/db.ts +73 -0
  170. package/src/utils/emailTemplate.ts +8 -1
  171. package/src/utils/fireWebhooks.ts +4 -6
  172. package/src/utils/generateTicketSynthesis.ts +7 -12
  173. package/src/utils/invoicePdf.ts +88 -0
  174. package/src/utils/notificationQueue.ts +36 -0
  175. package/src/utils/readSettings.ts +22 -6
  176. package/src/utils/sanitizeHtml.ts +57 -0
  177. package/src/utils/slugs.ts +6 -0
  178. package/src/utils/teamAccess.ts +27 -0
  179. package/src/utils/ticketAccess.ts +53 -0
  180. package/src/utils/webhookDispatcher.ts +4 -6
  181. package/src/views/BillingView/client.tsx +49 -37
  182. package/src/views/ChatView/client.tsx +4 -3
  183. package/src/views/CrmView/client.tsx +7 -7
  184. package/src/views/EmailTrackingView/client.tsx +7 -6
  185. package/src/views/ImportConversationView/client.tsx +4 -4
  186. package/src/views/LogsView/client.tsx +3 -2
  187. package/src/views/NewTicketView/client.tsx +1 -1
  188. package/src/views/PendingEmailsView/client.tsx +7 -6
  189. package/src/views/SupportDashboardView/client.tsx +21 -21
  190. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  191. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  192. package/src/views/TicketDetailView/client.tsx +54 -179
  193. package/src/views/TicketDetailView/constants.ts +23 -0
  194. package/src/views/TicketDetailView/helpers.ts +17 -0
  195. package/src/views/TicketDetailView/types.ts +10 -0
  196. package/src/views/TicketInboxView/client.tsx +16 -10
  197. package/src/views/TicketingSettingsView/client.tsx +90 -87
  198. package/src/views/TimeDashboardView/client.tsx +2 -2
  199. package/src/views/shared/StatusPill.tsx +24 -12
  200. package/src/views/shared/dateLocale.ts +5 -0
  201. package/src/views/shared/locales/en.json +708 -0
  202. 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
- // Find existing support client by email
95
- const existing = await payload.find({
96
- collection: slugs.supportClients as any,
97
- where: { email: { equals: profile.email } },
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
- let clientDoc = existing.docs[0]
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.create({
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
- // Generate temp password, login, then rotate
139
- const tempPassword = crypto.randomBytes(48).toString('base64url')
140
- await payload.update({
141
- collection: slugs.supportClients as any,
142
- id: clientDoc.id,
143
- data: { password: tempPassword },
144
- overrideAccess: true,
145
- })
146
-
147
- const loginResult = await payload.login({
148
- collection: slugs.supportClients as any,
149
- data: { email: profile.email, password: tempPassword },
150
- })
151
-
152
- // Immediately rotate password
153
- const postLoginPassword = crypto.randomBytes(48).toString('base64url')
154
- await payload.update({
155
- collection: slugs.supportClients as any,
156
- id: clientDoc.id,
157
- data: { password: postLoginPassword },
158
- overrideAccess: true,
159
- })
160
-
161
- if (!loginResult.token) {
162
- return Response.json({ error: 'login_failed' }, { status: 400 })
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
- return Response.json({
166
- token: loginResult.token,
167
- user: loginResult.user,
168
- exp: loginResult.exp,
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.findByID({
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
- }) as any
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.findByID({
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.update({
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.update({
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.create({
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
- }) as any
102
+ })
107
103
 
108
- await payload.create({
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.update({
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.findByID({
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
- }) as any
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.create({
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.update({
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.update({
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.find({
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.findByID({
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.update({
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.update({
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.update({
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.delete({
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.findByID({
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.findByID({
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.find({
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.find({
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,