@consilioweb/payload-support 0.16.0 → 1.1.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 (199) hide show
  1. package/README.md +357 -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/constants.cjs +27 -0
  7. package/dist/components/TicketConversation/constants.js +26 -1
  8. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  9. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  10. package/dist/components/TicketConversation/index.cjs +10 -147
  11. package/dist/components/TicketConversation/index.js +4 -141
  12. package/dist/index.cjs +1763 -677
  13. package/dist/index.d.cts +4 -0
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.js +1760 -677
  16. package/dist/views/BillingView/client.cjs +40 -54
  17. package/dist/views/BillingView/client.js +40 -54
  18. package/dist/views/ChatView/client.cjs +4 -3
  19. package/dist/views/ChatView/client.js +4 -3
  20. package/dist/views/CrmView/client.cjs +7 -7
  21. package/dist/views/CrmView/client.js +7 -7
  22. package/dist/views/EmailTrackingView/client.cjs +6 -5
  23. package/dist/views/EmailTrackingView/client.js +6 -5
  24. package/dist/views/ImportConversationView/client.cjs +4 -4
  25. package/dist/views/ImportConversationView/client.js +4 -4
  26. package/dist/views/LogsView/client.cjs +3 -2
  27. package/dist/views/LogsView/client.js +3 -2
  28. package/dist/views/NewTicketView/client.cjs +1 -1
  29. package/dist/views/NewTicketView/client.js +1 -1
  30. package/dist/views/PendingEmailsView/client.cjs +7 -6
  31. package/dist/views/PendingEmailsView/client.js +7 -6
  32. package/dist/views/SupportDashboardView/client.cjs +19 -22
  33. package/dist/views/SupportDashboardView/client.js +19 -22
  34. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  35. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  36. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  37. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  38. package/dist/views/TicketDetailView/client.cjs +59 -190
  39. package/dist/views/TicketDetailView/client.js +52 -183
  40. package/dist/views/TicketDetailView/constants.cjs +20 -0
  41. package/dist/views/TicketDetailView/constants.js +16 -0
  42. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  43. package/dist/views/TicketDetailView/helpers.js +20 -0
  44. package/dist/views/TicketDetailView/types.cjs +2 -0
  45. package/dist/views/TicketDetailView/types.js +1 -0
  46. package/dist/views/TicketInboxView/client.cjs +14 -10
  47. package/dist/views/TicketInboxView/client.js +14 -10
  48. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  49. package/dist/views/TicketingSettingsView/client.js +92 -88
  50. package/dist/views/TimeDashboardView/client.cjs +2 -2
  51. package/dist/views/TimeDashboardView/client.js +2 -2
  52. package/dist/views/shared/StatusPill.cjs +23 -12
  53. package/dist/views/shared/StatusPill.js +23 -12
  54. package/dist/views/shared/dateLocale.cjs +5 -0
  55. package/dist/views/shared/dateLocale.js +3 -0
  56. package/package.json +28 -15
  57. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  58. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  59. package/src/__tests__/integration/auth.test.ts +43 -0
  60. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  61. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  62. package/src/__tests__/integration/channels.test.ts +52 -0
  63. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  64. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  65. package/src/__tests__/integration/digest.test.ts +48 -0
  66. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  67. package/src/__tests__/integration/isolation.test.ts +81 -0
  68. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  69. package/src/__tests__/integration/nps.test.ts +37 -0
  70. package/src/__tests__/integration/per-team-sla.test.ts +44 -0
  71. package/src/__tests__/integration/push.test.ts +75 -0
  72. package/src/__tests__/integration/sanitization.test.ts +35 -0
  73. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  74. package/src/__tests__/integration/smoke.test.ts +26 -0
  75. package/src/__tests__/integration/snooze.test.ts +68 -0
  76. package/src/__tests__/integration/teams.test.ts +38 -0
  77. package/src/__tests__/rateLimiter.test.ts +89 -0
  78. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  79. package/src/__tests__/sla.test.ts +258 -0
  80. package/src/collections/AutomationRules.ts +66 -0
  81. package/src/collections/NotificationQueue.ts +30 -0
  82. package/src/collections/PushSubscription.ts +28 -0
  83. package/src/collections/SatisfactionSurveys.ts +11 -2
  84. package/src/collections/SlaPolicies.ts +10 -0
  85. package/src/collections/SupportClients.ts +58 -1
  86. package/src/collections/SupportTeam.ts +27 -0
  87. package/src/collections/TicketActivityLog.ts +9 -4
  88. package/src/collections/TicketMessages.ts +140 -31
  89. package/src/collections/Tickets.ts +31 -73
  90. package/src/collections/index.ts +4 -0
  91. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  92. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  93. package/src/components/TicketConversation/constants.ts +29 -0
  94. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  95. package/src/components/TicketConversation/index.tsx +4 -152
  96. package/src/endpoints/admin-chat-stream.ts +3 -4
  97. package/src/endpoints/admin-chat.ts +11 -20
  98. package/src/endpoints/admin-stats.ts +77 -35
  99. package/src/endpoints/ai-agent.ts +36 -0
  100. package/src/endpoints/apply-macro.ts +8 -14
  101. package/src/endpoints/auth-2fa.ts +12 -12
  102. package/src/endpoints/auto-close.ts +6 -10
  103. package/src/endpoints/billing.ts +17 -4
  104. package/src/endpoints/bulk-action.ts +7 -12
  105. package/src/endpoints/channels.ts +89 -0
  106. package/src/endpoints/chat-stream.ts +3 -4
  107. package/src/endpoints/chat.ts +7 -12
  108. package/src/endpoints/chatbot.ts +2 -2
  109. package/src/endpoints/client-intelligence.ts +4 -4
  110. package/src/endpoints/delete-account.ts +9 -16
  111. package/src/endpoints/email-stats.ts +2 -2
  112. package/src/endpoints/escalate.ts +2 -2
  113. package/src/endpoints/export-csv.ts +2 -2
  114. package/src/endpoints/export-data.ts +5 -8
  115. package/src/endpoints/import-conversation.ts +6 -10
  116. package/src/endpoints/index.ts +22 -1
  117. package/src/endpoints/invite-collaborator.ts +30 -15
  118. package/src/endpoints/invoice.ts +127 -0
  119. package/src/endpoints/kb-search.ts +2 -2
  120. package/src/endpoints/login.ts +12 -4
  121. package/src/endpoints/merge-clients.ts +14 -19
  122. package/src/endpoints/merge-tickets.ts +11 -18
  123. package/src/endpoints/oauth-google.ts +86 -39
  124. package/src/endpoints/pending-emails-process.ts +15 -25
  125. package/src/endpoints/process-digests.ts +93 -0
  126. package/src/endpoints/process-scheduled.ts +6 -10
  127. package/src/endpoints/process-snooze.ts +78 -0
  128. package/src/endpoints/purge-logs.ts +2 -2
  129. package/src/endpoints/push.ts +49 -0
  130. package/src/endpoints/resend-notification.ts +3 -4
  131. package/src/endpoints/round-robin-config.ts +3 -4
  132. package/src/endpoints/satisfaction.ts +21 -14
  133. package/src/endpoints/search.ts +5 -8
  134. package/src/endpoints/seed-kb.ts +3 -4
  135. package/src/endpoints/send-reminder.ts +6 -9
  136. package/src/endpoints/settings.ts +5 -2
  137. package/src/endpoints/signature.ts +3 -4
  138. package/src/endpoints/sla-check.ts +2 -2
  139. package/src/endpoints/split-ticket.ts +7 -12
  140. package/src/endpoints/statuses.ts +2 -2
  141. package/src/endpoints/ticket-feedback.ts +4 -6
  142. package/src/endpoints/ticket-synthesis.ts +2 -2
  143. package/src/endpoints/track-open.ts +16 -28
  144. package/src/endpoints/transfer-ticket.ts +21 -9
  145. package/src/endpoints/user-prefs.ts +3 -4
  146. package/src/hooks/automationRules.ts +84 -0
  147. package/src/hooks/checkSLA.ts +63 -17
  148. package/src/plugin.ts +8 -0
  149. package/src/portal/LiveChat.tsx +2 -63
  150. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  151. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  152. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  153. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  154. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  155. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  156. package/src/portal/helpers.ts +46 -0
  157. package/src/portal/login/page.tsx +3 -8
  158. package/src/portal/types.ts +15 -0
  159. package/src/types/lucide-react.d.ts +34 -0
  160. package/src/types/payload-config.d.ts +10 -0
  161. package/src/utils/adminNotification.ts +2 -2
  162. package/src/utils/aiAgent.ts +147 -0
  163. package/src/utils/auth.ts +5 -4
  164. package/src/utils/channels.ts +36 -0
  165. package/src/utils/db.ts +73 -0
  166. package/src/utils/emailTemplate.ts +8 -1
  167. package/src/utils/fireWebhooks.ts +4 -6
  168. package/src/utils/generateTicketSynthesis.ts +7 -12
  169. package/src/utils/invoicePdf.ts +88 -0
  170. package/src/utils/notificationQueue.ts +36 -0
  171. package/src/utils/push.ts +63 -0
  172. package/src/utils/readSettings.ts +22 -6
  173. package/src/utils/sanitizeHtml.ts +57 -0
  174. package/src/utils/slugs.ts +8 -0
  175. package/src/utils/teamAccess.ts +27 -0
  176. package/src/utils/ticketAccess.ts +53 -0
  177. package/src/utils/webhookDispatcher.ts +4 -6
  178. package/src/views/BillingView/client.tsx +49 -37
  179. package/src/views/ChatView/client.tsx +4 -3
  180. package/src/views/CrmView/client.tsx +7 -7
  181. package/src/views/EmailTrackingView/client.tsx +7 -6
  182. package/src/views/ImportConversationView/client.tsx +4 -4
  183. package/src/views/LogsView/client.tsx +3 -2
  184. package/src/views/NewTicketView/client.tsx +1 -1
  185. package/src/views/PendingEmailsView/client.tsx +7 -6
  186. package/src/views/SupportDashboardView/client.tsx +21 -21
  187. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  188. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  189. package/src/views/TicketDetailView/client.tsx +54 -179
  190. package/src/views/TicketDetailView/constants.ts +23 -0
  191. package/src/views/TicketDetailView/helpers.ts +17 -0
  192. package/src/views/TicketDetailView/types.ts +10 -0
  193. package/src/views/TicketInboxView/client.tsx +16 -10
  194. package/src/views/TicketingSettingsView/client.tsx +90 -87
  195. package/src/views/TimeDashboardView/client.tsx +2 -2
  196. package/src/views/shared/StatusPill.tsx +24 -12
  197. package/src/views/shared/dateLocale.ts +5 -0
  198. package/src/views/shared/locales/en.json +708 -0
  199. package/src/views/shared/locales/fr.json +708 -0
@@ -0,0 +1,127 @@
1
+ import type { Endpoint, Where } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { dbFind } from '../utils/db'
5
+ import { escapeHtml } from '../utils/emailTemplate'
6
+ import { generateInvoicePdf, type InvoiceLine } from '../utils/invoicePdf'
7
+
8
+ /**
9
+ * GET /api/support/billing/invoice?from=YYYY-MM-DD&to=YYYY-MM-DD&projectId=...&format=html|pdf
10
+ *
11
+ * Admin-only. Renders an invoice for the billable tickets active in the period.
12
+ * `format=pdf` returns a real binary PDF (pdfkit); otherwise a self-contained,
13
+ * print-ready HTML document. Defaults to HTML.
14
+ */
15
+ export function createInvoiceEndpoint(slugs: CollectionSlugs): Endpoint {
16
+ return {
17
+ path: '/support/billing/invoice',
18
+ method: 'get',
19
+ handler: async (req) => {
20
+ try {
21
+ requireAdmin(req, slugs)
22
+ const payload = req.payload
23
+ const url = new URL(req.url!)
24
+ const from = url.searchParams.get('from')
25
+ const to = url.searchParams.get('to')
26
+ const projectId = url.searchParams.get('projectId')
27
+ const format = url.searchParams.get('format') === 'pdf' ? 'pdf' : 'html'
28
+ if (!from || !to) {
29
+ return Response.json({ error: 'Paramètres from et to requis.' }, { status: 400 })
30
+ }
31
+
32
+ const toExclusive = new Date(to)
33
+ toExclusive.setDate(toExclusive.getDate() + 1)
34
+ const toIso = toExclusive.toISOString()
35
+
36
+ const where: Where = {
37
+ and: [
38
+ { billable: { equals: true } },
39
+ ...(projectId ? [{ project: { equals: Number(projectId) } } as Where] : []),
40
+ {
41
+ or: [
42
+ { and: [{ updatedAt: { greater_than_equal: from } }, { updatedAt: { less_than: toIso } }] },
43
+ { and: [{ createdAt: { greater_than_equal: from } }, { createdAt: { less_than: toIso } }] },
44
+ { and: [{ resolvedAt: { greater_than_equal: from } }, { resolvedAt: { less_than: toIso } }] },
45
+ ],
46
+ },
47
+ ],
48
+ }
49
+
50
+ const tickets: Array<Record<string, unknown>> = []
51
+ let page = 1
52
+ let hasMore = true
53
+ while (hasMore && page <= 50) {
54
+ const batch = await dbFind(payload, slugs.tickets, {
55
+ where, limit: 500, page, depth: 2, overrideAccess: true,
56
+ select: { ticketNumber: true, subject: true, billedAmount: true, totalTimeMinutes: true, project: true, client: true },
57
+ })
58
+ tickets.push(...(batch.docs as Array<Record<string, unknown>>))
59
+ hasMore = batch.hasNextPage ?? false
60
+ page++
61
+ }
62
+
63
+ // Shared structured lines (used by both HTML and PDF renderers).
64
+ let totalAmount = 0
65
+ let totalMinutes = 0
66
+ const lines: InvoiceLine[] = tickets.map((t) => {
67
+ const amount = typeof t.billedAmount === 'number' ? t.billedAmount : 0
68
+ const minutes = typeof t.totalTimeMinutes === 'number' ? t.totalTimeMinutes : 0
69
+ totalAmount += amount
70
+ totalMinutes += minutes
71
+ const client = typeof t.client === 'object' && t.client ? (t.client as { company?: string }).company || '' : ''
72
+ return { ticketNumber: String(t.ticketNumber || ''), subject: String(t.subject || ''), client, minutes, amount }
73
+ })
74
+
75
+ if (format === 'pdf') {
76
+ const pdf = await generateInvoicePdf({ from, to, projectId, lines, totalMinutes, totalAmount })
77
+ return new Response(new Uint8Array(pdf), {
78
+ status: 200,
79
+ headers: {
80
+ 'Content-Type': 'application/pdf',
81
+ 'Content-Disposition': `inline; filename="invoice-${from}_${to}.pdf"`,
82
+ },
83
+ })
84
+ }
85
+
86
+ const fmtAmount = (n: number) => `${n.toFixed(2)} €`
87
+ const fmtTime = (min: number) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}`
88
+ const rows = lines.map((l) => `<tr>
89
+ <td>${escapeHtml(l.ticketNumber)}</td>
90
+ <td>${escapeHtml(l.subject)}</td>
91
+ <td>${escapeHtml(l.client)}</td>
92
+ <td style="text-align:right;">${fmtTime(l.minutes)}</td>
93
+ <td style="text-align:right;">${fmtAmount(l.amount)}</td>
94
+ </tr>`).join('')
95
+
96
+ const html = `<!doctype html>
97
+ <html lang="fr"><head><meta charset="utf-8"><title>Facture ${escapeHtml(from)} → ${escapeHtml(to)}</title>
98
+ <style>
99
+ body { font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 820px; margin: 32px auto; padding: 0 24px; }
100
+ h1 { font-size: 22px; margin: 0 0 4px; }
101
+ .period { color: #64748b; margin-bottom: 24px; }
102
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
103
+ th, td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; text-align: left; }
104
+ th { background: #f8fafc; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #475569; }
105
+ tfoot td { font-weight: 700; border-top: 2px solid #0f172a; border-bottom: none; }
106
+ @media print { body { margin: 0; } }
107
+ </style></head>
108
+ <body>
109
+ <h1>Facture / Pré-facture</h1>
110
+ <div class="period">Période : ${escapeHtml(from)} → ${escapeHtml(to)}${projectId ? ` · Projet #${escapeHtml(projectId)}` : ''}</div>
111
+ <table>
112
+ <thead><tr><th>Ticket</th><th>Sujet</th><th>Client</th><th style="text-align:right;">Temps</th><th style="text-align:right;">Montant</th></tr></thead>
113
+ <tbody>${rows || '<tr><td colspan="5" style="color:#94a3b8;">Aucun ticket facturable sur la période.</td></tr>'}</tbody>
114
+ <tfoot><tr><td colspan="3">Total (${lines.length} ticket${lines.length > 1 ? 's' : ''})</td><td style="text-align:right;">${fmtTime(totalMinutes)}</td><td style="text-align:right;">${fmtAmount(totalAmount)}</td></tr></tfoot>
115
+ </table>
116
+ </body></html>`
117
+
118
+ return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } })
119
+ } catch (error) {
120
+ const authResponse = handleAuthError(error)
121
+ if (authResponse) return authResponse
122
+ console.error('[invoice] Error:', error)
123
+ return Response.json({ error: 'Erreur interne' }, { status: 500 })
124
+ }
125
+ },
126
+ }
127
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
+ import { dbFind } from '../utils/db'
3
4
 
4
5
  // Combining diacritics range used to strip accents after NFD normalization.
5
6
  const DIACRITICS = /[̀-ͯ]/g
@@ -107,8 +108,7 @@ export function createKbSearchEndpoint(slugs: CollectionSlugs): Endpoint {
107
108
  // Always include the raw query for short single-word matches.
108
109
  orClauses.push({ title: { contains: q } })
109
110
 
110
- const res = await payload.find({
111
- collection: slugs.knowledgeBase as any,
111
+ const res = await dbFind(payload, slugs.knowledgeBase, {
112
112
  where: {
113
113
  and: [
114
114
  { published: { equals: true } },
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { RateLimiter } from '../utils/rateLimiter'
4
+ import { dbCreate } from '../utils/db'
4
5
 
5
6
  const loginLimiter = new RateLimiter(15 * 60_000, 10) // 10 per 15 min
6
7
 
@@ -13,6 +14,9 @@ export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
13
14
  path: '/support/login',
14
15
  method: 'post',
15
16
  handler: async (req) => {
17
+ // NOTE: x-forwarded-for is spoofable unless this app sits strictly behind a
18
+ // trusted proxy that rewrites it. This IP rate-limit is a SECONDARY defense;
19
+ // the primary brute-force control is Payload's account lock (maxLoginAttempts).
16
20
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
17
21
 
18
22
  if (loginLimiter.check(ip)) {
@@ -43,8 +47,7 @@ export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
43
47
  })
44
48
 
45
49
  // Log successful login (fire-and-forget)
46
- payload.create({
47
- collection: slugs.authLogs as any,
50
+ dbCreate(payload, slugs.authLogs, {
48
51
  data: { email, success: true, action: 'login', ipAddress: ip, userAgent },
49
52
  overrideAccess: true,
50
53
  }).catch(() => {})
@@ -71,13 +74,18 @@ export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
71
74
  } catch (err: unknown) {
72
75
  const errorMessage = err instanceof Error ? err.message : 'Erreur inconnue'
73
76
 
77
+ // 2FA gate: password was correct but a fresh 2FA verification is required.
78
+ // No session/cookie is issued (Payload rolled it back on the throw).
79
+ if (errorMessage.includes('2FA_REQUIRED')) {
80
+ return Response.json({ requires2FA: true }, { status: 200 })
81
+ }
82
+
74
83
  let errorReason = 'Identifiants incorrects'
75
84
  if (errorMessage.includes('locked') || errorMessage.includes('verrouillé') || errorMessage.includes('Too many')) {
76
85
  errorReason = 'Compte verrouillé (trop de tentatives)'
77
86
  }
78
87
 
79
- payload.create({
80
- collection: slugs.authLogs as any,
88
+ dbCreate(payload, slugs.authLogs, {
81
89
  data: { email, success: false, action: 'login', errorReason, ipAddress: ip, userAgent },
82
90
  overrideAccess: true,
83
91
  }).catch(() => {})
@@ -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, dbFindByID, dbUpdate, dbDelete } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * POST /api/support/merge-clients
@@ -23,8 +24,8 @@ export function createMergeClientsEndpoint(slugs: CollectionSlugs): Endpoint {
23
24
  }
24
25
 
25
26
  const [source, target] = await Promise.all([
26
- payload.findByID({ collection: slugs.supportClients as any, id: sourceId, depth: 0, overrideAccess: true }),
27
- payload.findByID({ collection: slugs.supportClients as any, id: targetId, depth: 0, overrideAccess: true }),
27
+ dbFindByID(payload, slugs.supportClients, { id: sourceId, depth: 0, overrideAccess: true }),
28
+ dbFindByID(payload, slugs.supportClients, { id: targetId, depth: 0, overrideAccess: true }),
28
29
  ]) as [any, any]
29
30
 
30
31
  if (!source || !target) {
@@ -40,73 +41,67 @@ export function createMergeClientsEndpoint(slugs: CollectionSlugs): Endpoint {
40
41
  }
41
42
 
42
43
  // 1. Transfer tickets
43
- const tickets = await payload.find({
44
- collection: slugs.tickets as any,
44
+ const tickets = await dbFind(payload, slugs.tickets, {
45
45
  where: { client: { equals: sourceId } },
46
46
  limit: 500,
47
47
  depth: 0,
48
48
  overrideAccess: true,
49
49
  })
50
50
  for (const ticket of tickets.docs) {
51
- await payload.update({ collection: slugs.tickets as any, id: ticket.id, data: { client: targetId }, overrideAccess: true })
51
+ await dbUpdate(payload, slugs.tickets, { id: ticket.id, data: { client: targetId }, overrideAccess: true })
52
52
  results.tickets++
53
53
  }
54
54
 
55
55
  // 2. Transfer ticket messages (authorClient)
56
- const messages = await payload.find({
57
- collection: slugs.ticketMessages as any,
56
+ const messages = await dbFind(payload, slugs.ticketMessages, {
58
57
  where: { authorClient: { equals: sourceId } },
59
58
  limit: 1000,
60
59
  depth: 0,
61
60
  overrideAccess: true,
62
61
  })
63
62
  for (const msg of messages.docs) {
64
- await payload.update({ collection: slugs.ticketMessages as any, id: msg.id, data: { authorClient: targetId }, overrideAccess: true })
63
+ await dbUpdate(payload, slugs.ticketMessages, { id: msg.id, data: { authorClient: targetId }, overrideAccess: true })
65
64
  results.ticketMessages++
66
65
  }
67
66
 
68
67
  // 3. Transfer chat messages
69
- const chats = await payload.find({
70
- collection: slugs.chatMessages as any,
68
+ const chats = await dbFind(payload, slugs.chatMessages, {
71
69
  where: { client: { equals: sourceId } },
72
70
  limit: 1000,
73
71
  depth: 0,
74
72
  overrideAccess: true,
75
73
  })
76
74
  for (const chat of chats.docs) {
77
- await payload.update({ collection: slugs.chatMessages as any, id: chat.id, data: { client: targetId }, overrideAccess: true })
75
+ await dbUpdate(payload, slugs.chatMessages, { id: chat.id, data: { client: targetId }, overrideAccess: true })
78
76
  results.chatMessages++
79
77
  }
80
78
 
81
79
  // 4. Transfer pending emails
82
- const pendingEmails = await payload.find({
83
- collection: slugs.pendingEmails as any,
80
+ const pendingEmails = await dbFind(payload, slugs.pendingEmails, {
84
81
  where: { client: { equals: sourceId } },
85
82
  limit: 500,
86
83
  depth: 0,
87
84
  overrideAccess: true,
88
85
  })
89
86
  for (const pe of pendingEmails.docs) {
90
- await payload.update({ collection: slugs.pendingEmails as any, id: pe.id, data: { client: targetId }, overrideAccess: true })
87
+ await dbUpdate(payload, slugs.pendingEmails, { id: pe.id, data: { client: targetId }, overrideAccess: true })
91
88
  results.pendingEmails++
92
89
  }
93
90
 
94
91
  // 5. Transfer satisfaction surveys
95
- const surveys = await payload.find({
96
- collection: slugs.satisfactionSurveys as any,
92
+ const surveys = await dbFind(payload, slugs.satisfactionSurveys, {
97
93
  where: { client: { equals: sourceId } },
98
94
  limit: 500,
99
95
  depth: 0,
100
96
  overrideAccess: true,
101
97
  })
102
98
  for (const survey of surveys.docs) {
103
- await payload.update({ collection: slugs.satisfactionSurveys as any, id: survey.id, data: { client: targetId }, overrideAccess: true })
99
+ await dbUpdate(payload, slugs.satisfactionSurveys, { id: survey.id, data: { client: targetId }, overrideAccess: true })
104
100
  results.satisfactionSurveys++
105
101
  }
106
102
 
107
103
  // 6. Delete source client
108
- await payload.delete({
109
- collection: slugs.supportClients as any,
104
+ await dbDelete(payload, slugs.supportClients, {
110
105
  id: sourceId,
111
106
  overrideAccess: true,
112
107
  })
@@ -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, dbFindByID, dbCreate, dbUpdate, dbDelete } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * POST /api/support/merge-tickets
@@ -27,8 +28,8 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
27
28
  }
28
29
 
29
30
  const [source, target] = await Promise.all([
30
- payload.findByID({ collection: slugs.tickets as any, id: sourceTicketId, depth: 0, overrideAccess: true }),
31
- payload.findByID({ collection: slugs.tickets as any, id: targetTicketId, depth: 0, overrideAccess: true }),
31
+ dbFindByID(payload, slugs.tickets, { id: sourceTicketId, depth: 0, overrideAccess: true }),
32
+ dbFindByID(payload, slugs.tickets, { id: targetTicketId, depth: 0, overrideAccess: true }),
32
33
  ])
33
34
 
34
35
  if (!source) return Response.json({ error: 'Ticket source introuvable' }, { status: 404 })
@@ -42,8 +43,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
42
43
  }
43
44
 
44
45
  // Move all messages from source to target
45
- const messages = await payload.find({
46
- collection: slugs.ticketMessages as any,
46
+ const messages = await dbFind(payload, slugs.ticketMessages, {
47
47
  where: { ticket: { equals: sourceTicketId } },
48
48
  limit: 500,
49
49
  depth: 0,
@@ -51,8 +51,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
51
51
  })
52
52
 
53
53
  for (const msg of messages.docs) {
54
- await payload.update({
55
- collection: slugs.ticketMessages as any,
54
+ await dbUpdate(payload, slugs.ticketMessages, {
56
55
  id: msg.id,
57
56
  data: { ticket: targetTicketId },
58
57
  overrideAccess: true,
@@ -60,8 +59,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
60
59
  }
61
60
 
62
61
  // Move time entries
63
- const timeEntries = await payload.find({
64
- collection: slugs.timeEntries as any,
62
+ const timeEntries = await dbFind(payload, slugs.timeEntries, {
65
63
  where: { ticket: { equals: sourceTicketId } },
66
64
  limit: 500,
67
65
  depth: 0,
@@ -69,8 +67,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
69
67
  })
70
68
 
71
69
  for (const entry of timeEntries.docs) {
72
- await payload.update({
73
- collection: slugs.timeEntries as any,
70
+ await dbUpdate(payload, slugs.timeEntries, {
74
71
  id: entry.id,
75
72
  data: { ticket: targetTicketId },
76
73
  overrideAccess: true,
@@ -79,8 +76,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
79
76
 
80
77
  // Add internal note to target
81
78
  const sourceNumber = (source as any).ticketNumber || `#${sourceTicketId}`
82
- await payload.create({
83
- collection: slugs.ticketMessages as any,
79
+ await dbCreate(payload, slugs.ticketMessages, {
84
80
  data: {
85
81
  ticket: targetTicketId,
86
82
  body: `Messages fusionnés depuis ${sourceNumber} (${messages.totalDocs} messages, ${timeEntries.totalDocs} entrées de temps)`,
@@ -92,15 +88,13 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
92
88
  })
93
89
 
94
90
  // Delete source ticket
95
- await payload.delete({
96
- collection: slugs.tickets as any,
91
+ await dbDelete(payload, slugs.tickets, {
97
92
  id: sourceTicketId,
98
93
  overrideAccess: true,
99
94
  })
100
95
 
101
96
  // Recalculate totalTimeMinutes on target
102
- const allTimeEntries = await payload.find({
103
- collection: slugs.timeEntries as any,
97
+ const allTimeEntries = await dbFind(payload, slugs.timeEntries, {
104
98
  where: { ticket: { equals: targetTicketId } },
105
99
  limit: 500,
106
100
  depth: 0,
@@ -112,8 +106,7 @@ export function createMergeTicketsEndpoint(slugs: CollectionSlugs): Endpoint {
112
106
  0,
113
107
  )
114
108
 
115
- await payload.update({
116
- collection: slugs.tickets as any,
109
+ await dbUpdate(payload, slugs.tickets, {
117
110
  id: targetTicketId,
118
111
  data: { totalTimeMinutes: totalMinutes },
119
112
  overrideAccess: true,
@@ -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