@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,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireClient, handleAuthError } from '../utils/auth'
4
+ import { dbFindByID, dbFind, dbCreate } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * POST /api/support/satisfaction
@@ -17,13 +18,21 @@ export function createSatisfactionEndpoint(slugs: CollectionSlugs): Endpoint {
17
18
  requireClient(req, slugs)
18
19
 
19
20
  const body = await req.json!()
20
- const { ticketId, rating, comment } = body
21
+ const { ticketId, rating, nps, comment } = body
21
22
 
22
- if (!ticketId || !rating || !Number.isInteger(rating) || rating < 1 || rating > 5) {
23
- return Response.json(
24
- { error: 'ticketId et rating (entier 1-5) sont requis.' },
25
- { status: 400 },
26
- )
23
+ if (!ticketId) {
24
+ return Response.json({ error: 'ticketId est requis.' }, { status: 400 })
25
+ }
26
+ const hasRating = rating !== undefined && rating !== null
27
+ const hasNps = nps !== undefined && nps !== null
28
+ if (!hasRating && !hasNps) {
29
+ return Response.json({ error: 'Un rating (1-5) et/ou un nps (0-10) est requis.' }, { status: 400 })
30
+ }
31
+ if (hasRating && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
32
+ return Response.json({ error: 'rating doit être un entier 1-5.' }, { status: 400 })
33
+ }
34
+ if (hasNps && (!Number.isInteger(nps) || nps < 0 || nps > 10)) {
35
+ return Response.json({ error: 'nps doit être un entier 0-10.' }, { status: 400 })
27
36
  }
28
37
 
29
38
  if (comment && typeof comment === 'string' && comment.length > 5000) {
@@ -34,13 +43,12 @@ export function createSatisfactionEndpoint(slugs: CollectionSlugs): Endpoint {
34
43
  }
35
44
 
36
45
  // Verify ticket belongs to client and is resolved
37
- const ticket = await payload.findByID({
38
- collection: slugs.tickets as any,
46
+ const ticket = await dbFindByID(payload, slugs.tickets, {
39
47
  id: ticketId,
40
48
  depth: 0,
41
49
  overrideAccess: false,
42
50
  user: req.user,
43
- }) as any
51
+ })
44
52
 
45
53
  if (!ticket) {
46
54
  return Response.json({ error: 'Ticket introuvable.' }, { status: 404 })
@@ -54,8 +62,7 @@ export function createSatisfactionEndpoint(slugs: CollectionSlugs): Endpoint {
54
62
  }
55
63
 
56
64
  // Check if survey already exists
57
- const existing = await payload.find({
58
- collection: slugs.satisfactionSurveys as any,
65
+ const existing = await dbFind(payload, slugs.satisfactionSurveys, {
59
66
  where: { ticket: { equals: ticketId } },
60
67
  limit: 1,
61
68
  depth: 0,
@@ -69,13 +76,13 @@ export function createSatisfactionEndpoint(slugs: CollectionSlugs): Endpoint {
69
76
  )
70
77
  }
71
78
 
72
- const survey = await payload.create({
73
- collection: slugs.satisfactionSurveys as any,
79
+ const survey = await dbCreate(payload, slugs.satisfactionSurveys, {
74
80
  data: {
75
81
  source: 'ticket',
76
82
  ticket: ticketId,
77
83
  client: req.user.id,
78
- rating: Math.round(rating),
84
+ ...(hasRating ? { rating: Math.round(rating) } : {}),
85
+ ...(hasNps ? { nps: Math.round(nps) } : {}),
79
86
  ...(comment ? { comment: comment.trim() } : {}),
80
87
  },
81
88
  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
  /**
6
7
  * GET /api/support/search?q=term
@@ -24,8 +25,7 @@ export function createSearchEndpoint(slugs: CollectionSlugs): Endpoint {
24
25
  }
25
26
 
26
27
  const [ticketsRes, clientsRes, messagesRes, articlesRes] = await Promise.all([
27
- payload.find({
28
- collection: slugs.tickets as any,
28
+ dbFind(payload, slugs.tickets, {
29
29
  where: {
30
30
  or: [
31
31
  { ticketNumber: { contains: q } },
@@ -37,8 +37,7 @@ export function createSearchEndpoint(slugs: CollectionSlugs): Endpoint {
37
37
  depth: 1,
38
38
  overrideAccess: true,
39
39
  }),
40
- payload.find({
41
- collection: slugs.supportClients as any,
40
+ dbFind(payload, slugs.supportClients, {
42
41
  where: {
43
42
  or: [
44
43
  { firstName: { contains: q } },
@@ -51,16 +50,14 @@ export function createSearchEndpoint(slugs: CollectionSlugs): Endpoint {
51
50
  depth: 0,
52
51
  overrideAccess: true,
53
52
  }),
54
- payload.find({
55
- collection: slugs.ticketMessages as any,
53
+ dbFind(payload, slugs.ticketMessages, {
56
54
  where: { body: { contains: q } },
57
55
  sort: '-createdAt',
58
56
  limit: 5,
59
57
  depth: 1,
60
58
  overrideAccess: true,
61
59
  }),
62
- payload.find({
63
- collection: slugs.knowledgeBase as any,
60
+ dbFind(payload, slugs.knowledgeBase, {
64
61
  where: { title: { contains: q } },
65
62
  limit: 5,
66
63
  depth: 0,
@@ -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, dbCreate } from '../utils/db'
4
5
 
5
6
  const KB_ARTICLES = [
6
7
  {
@@ -95,8 +96,7 @@ export function createSeedKbEndpoint(slugs: CollectionSlugs): Endpoint {
95
96
  let skipped = 0
96
97
 
97
98
  for (const article of KB_ARTICLES) {
98
- const existing = await payload.find({
99
- collection: slugs.knowledgeBase as any,
99
+ const existing = await dbFind(payload, slugs.knowledgeBase, {
100
100
  where: { slug: { equals: article.slug } },
101
101
  limit: 1,
102
102
  depth: 0,
@@ -126,8 +126,7 @@ export function createSeedKbEndpoint(slugs: CollectionSlugs): Endpoint {
126
126
  },
127
127
  }
128
128
 
129
- await payload.create({
130
- collection: slugs.knowledgeBase as any,
129
+ await dbCreate(payload, slugs.knowledgeBase, {
131
130
  data: {
132
131
  title: article.title,
133
132
  slug: article.slug,
@@ -0,0 +1,186 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { RateLimiter } from '../utils/rateLimiter'
5
+ import { escapeHtml, emailWrapper, emailParagraph, emailButton } from '../utils/emailTemplate'
6
+ import { readSupportSettings } from '../utils/readSettings'
7
+ import { dbFindByID, dbFind, dbCreate, dbUpdate } from '../utils/db'
8
+
9
+ // Manual reminders are a deliberate admin action, so the cap is generous —
10
+ // it only exists to stop a runaway script, not normal usage.
11
+ const reminderLimiter = new RateLimiter(60 * 60 * 1000, 30) // 30 per hour per admin
12
+
13
+ const MIN_HOURS = 1
14
+ const MAX_HOURS = 24 * 30 // 30 days
15
+
16
+ function formatFr(date: Date, withTime: boolean): string {
17
+ return date.toLocaleString('fr-FR', {
18
+ day: 'numeric',
19
+ month: 'long',
20
+ year: 'numeric',
21
+ ...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
22
+ timeZone: 'Europe/Paris',
23
+ })
24
+ }
25
+
26
+ /**
27
+ * POST /api/support/send-reminder
28
+ *
29
+ * Manually nudge a client whose ticket is awaiting their reply: sends a generic
30
+ * "your ticket will be closed if you don't respond" email, drops an internal
31
+ * note in the thread, and arms the ticket for automatic closure.
32
+ *
33
+ * Arming = set `status: waiting_client`, `autoCloseRemindedAt: now` (so the
34
+ * day-based cron reminder skips it) and `autoCloseScheduledAt: now + hours`.
35
+ * The existing `/api/support/auto-close` cron then resolves the ticket once the
36
+ * deadline passes — UNLESS the client replies first, which flips the status back
37
+ * to `open` and clears `autoCloseScheduledAt` (see TicketMessages auto-update hook).
38
+ *
39
+ * Admin-only, rate-limited.
40
+ */
41
+ export function createSendReminderEndpoint(slugs: CollectionSlugs): Endpoint {
42
+ return {
43
+ path: '/support/send-reminder',
44
+ method: 'post',
45
+ handler: async (req) => {
46
+ try {
47
+ const payload = req.payload
48
+
49
+ requireAdmin(req, slugs)
50
+
51
+ if (reminderLimiter.check(String(req.user.id))) {
52
+ return Response.json(
53
+ { error: 'Trop de relances. Réessayez dans une heure.' },
54
+ { status: 429 },
55
+ )
56
+ }
57
+
58
+ let body: { ticketId?: string | number; hours?: number }
59
+ try {
60
+ body = await req.json!()
61
+ } catch {
62
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
63
+ }
64
+
65
+ const { ticketId } = body
66
+ if (!ticketId) {
67
+ return Response.json({ error: 'ticketId requis' }, { status: 400 })
68
+ }
69
+
70
+ const rawHours = Number(body.hours)
71
+ const hours = Number.isFinite(rawHours)
72
+ ? Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.round(rawHours)))
73
+ : 24
74
+
75
+ const ticket = await dbFindByID(payload, slugs.tickets, {
76
+ id: ticketId,
77
+ depth: 1,
78
+ overrideAccess: true,
79
+ })
80
+
81
+ if (!ticket) {
82
+ return Response.json({ error: 'Ticket introuvable' }, { status: 404 })
83
+ }
84
+
85
+ const client = typeof ticket.client === 'object' ? ticket.client : null
86
+ if (!client?.email) {
87
+ return Response.json({ error: 'Client sans email' }, { status: 400 })
88
+ }
89
+
90
+ const now = new Date()
91
+ const deadline = new Date(now.getTime() + hours * 60 * 60 * 1000)
92
+
93
+ const settings = await readSupportSettings(payload)
94
+ const ticketNumber = ticket.ticketNumber || 'TK-????'
95
+ const subject = ticket.subject || 'Support'
96
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
97
+ const portalUrl = `${baseUrl}/support/tickets/${ticketId}`
98
+ const replyTo = settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || ''
99
+
100
+ // "En attente depuis le …" — anchored on our last public reply (that's
101
+ // what the client owes a response to), falling back to the first
102
+ // response then to ticket creation.
103
+ let waitingSince: string | undefined = ticket.firstResponseAt || ticket.createdAt
104
+ try {
105
+ const lastAdminMsg = await dbFind(payload, slugs.ticketMessages, {
106
+ where: {
107
+ and: [
108
+ { ticket: { equals: ticketId } },
109
+ { authorType: { equals: 'admin' } },
110
+ { isInternal: { equals: false } },
111
+ ],
112
+ },
113
+ sort: '-createdAt',
114
+ limit: 1,
115
+ depth: 0,
116
+ overrideAccess: true,
117
+ })
118
+ if (lastAdminMsg.docs.length > 0 && lastAdminMsg.docs[0].createdAt) {
119
+ waitingSince = lastAdminMsg.docs[0].createdAt as string
120
+ }
121
+ } catch { /* fallback already set */ }
122
+
123
+ const sinceLabel = waitingSince ? formatFr(new Date(waitingSince), false) : null
124
+ const deadlineLabel = formatFr(deadline, true)
125
+
126
+ await payload.sendEmail({
127
+ to: client.email,
128
+ ...(replyTo ? { replyTo } : {}),
129
+ subject: `Rappel : [${ticketNumber}] ${subject} — votre réponse est attendue`,
130
+ html: emailWrapper(`Votre ticket attend votre réponse`, [
131
+ emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,`),
132
+ emailParagraph(
133
+ `Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(String(subject))}</em> — est en attente de votre réponse${sinceLabel ? ` depuis le ${escapeHtml(sinceLabel)}` : ''}.`,
134
+ ),
135
+ emailParagraph(
136
+ `<strong>Sans retour de votre part, ce ticket sera automatiquement clôturé le ${escapeHtml(deadlineLabel)}.</strong>`,
137
+ ),
138
+ emailParagraph(
139
+ `Si vous avez encore besoin d'assistance, il vous suffit de répondre à ce message — votre réponse maintiendra le ticket ouvert.`,
140
+ ),
141
+ emailButton('Répondre au ticket', portalUrl, 'primary'),
142
+ ].join(''), {
143
+ kind: 'alert',
144
+ preheader: `Sans réponse de votre part, votre ticket ${ticketNumber} sera clôturé le ${deadlineLabel}.`,
145
+ }),
146
+ })
147
+
148
+ // Internal note for the conversation timeline (never sent to the client).
149
+ await dbCreate(payload, slugs.ticketMessages, {
150
+ data: {
151
+ ticket: ticketId,
152
+ body: `Relance envoyée à ${client.email}. Fermeture automatique programmée le ${deadlineLabel} sans réponse du client.`,
153
+ authorType: 'admin',
154
+ isInternal: true,
155
+ skipNotification: true,
156
+ },
157
+ overrideAccess: true,
158
+ })
159
+
160
+ // Arm the ticket. `autoCloseRemindedAt: now` keeps the day-based cron
161
+ // reminder from firing a second time; `autoCloseScheduledAt` is the hard
162
+ // 24h-style deadline the cron close step honours.
163
+ await dbUpdate(payload, slugs.tickets, {
164
+ id: ticketId,
165
+ data: {
166
+ status: 'waiting_client',
167
+ autoCloseRemindedAt: now.toISOString(),
168
+ autoCloseScheduledAt: deadline.toISOString(),
169
+ },
170
+ overrideAccess: true,
171
+ })
172
+
173
+ return Response.json({
174
+ success: true,
175
+ sentTo: client.email,
176
+ scheduledCloseAt: deadline.toISOString(),
177
+ })
178
+ } catch (error) {
179
+ const authResponse = handleAuthError(error)
180
+ if (authResponse) return authResponse
181
+ console.error('[send-reminder] Error:', error)
182
+ return Response.json({ error: 'Erreur interne' }, { status: 500 })
183
+ }
184
+ },
185
+ }
186
+ }
@@ -1,6 +1,8 @@
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 { invalidateSupportSettingsCache } from '../utils/readSettings'
5
+ import { dbFind } from '../utils/db'
4
6
 
5
7
  const PREF_KEY = 'support-settings'
6
8
 
@@ -63,8 +65,7 @@ export function createSettingsGetEndpoint(slugs: CollectionSlugs): Endpoint {
63
65
 
64
66
  requireAdmin(req, slugs)
65
67
 
66
- const prefs = await payload.find({
67
- collection: 'payload-preferences' as any,
68
+ const prefs = await dbFind(payload, 'payload-preferences', {
68
69
  where: { key: { equals: PREF_KEY } },
69
70
  limit: 1,
70
71
  depth: 0,
@@ -132,6 +133,8 @@ export function createSettingsPostEndpoint(slugs: CollectionSlugs): Endpoint {
132
133
  },
133
134
  })
134
135
 
136
+ invalidateSupportSettingsCache()
137
+
135
138
  return Response.json(merged)
136
139
  } catch (error) {
137
140
  const authResponse = handleAuthError(error)
@@ -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 = 'email-signature'
6
7
 
@@ -17,8 +18,7 @@ export function createSignatureGetEndpoint(slugs: CollectionSlugs): Endpoint {
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}-${req.user.id}` } },
23
23
  limit: 1,
24
24
  depth: 0,
@@ -56,8 +56,7 @@ export function createSignaturePostEndpoint(slugs: CollectionSlugs): Endpoint {
56
56
  const { signature } = (await req.json!()) as { signature: string }
57
57
  const key = `${PREF_KEY}-${req.user.id}`
58
58
 
59
- const existing = await payload.find({
60
- collection: 'payload-preferences' as any,
59
+ const existing = await dbFind(payload, 'payload-preferences', {
61
60
  where: { key: { equals: key } },
62
61
  limit: 1,
63
62
  depth: 0,
@@ -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
  /**
6
7
  * GET /api/support/sla-check
@@ -19,8 +20,7 @@ export function createSlaCheckEndpoint(slugs: CollectionSlugs): Endpoint {
19
20
  const now = new Date()
20
21
  const nowISO = now.toISOString()
21
22
 
22
- const { docs: tickets } = await payload.find({
23
- collection: slugs.tickets as any,
23
+ const { docs: tickets } = await dbFind(payload, slugs.tickets, {
24
24
  where: {
25
25
  and: [
26
26
  { status: { in: ['open', 'waiting_client'] } },
@@ -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 { dbFindByID, dbCreate, dbUpdate } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * POST /api/support/split-ticket
@@ -21,8 +22,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
21
22
  return Response.json({ error: 'messageId required' }, { status: 400 })
22
23
  }
23
24
 
24
- const message = await payload.findByID({
25
- collection: slugs.ticketMessages as any,
25
+ const message = await dbFindByID(payload, slugs.ticketMessages, {
26
26
  id: messageId,
27
27
  depth: 1,
28
28
  overrideAccess: true,
@@ -42,8 +42,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
42
42
  : sourceTicket.client
43
43
 
44
44
  // Create new ticket
45
- const newTicket = await payload.create({
46
- collection: slugs.tickets as any,
45
+ const newTicket = await dbCreate(payload, slugs.tickets, {
47
46
  data: {
48
47
  subject: subject || `Split: ${sourceTicket.subject}`,
49
48
  client: clientId,
@@ -61,8 +60,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
61
60
  file: typeof a.file === 'object' ? a.file.id : a.file,
62
61
  })) || []
63
62
 
64
- await payload.create({
65
- collection: slugs.ticketMessages as any,
63
+ await dbCreate(payload, slugs.ticketMessages, {
66
64
  data: {
67
65
  ticket: newTicket.id,
68
66
  body: message.body,
@@ -77,8 +75,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
77
75
  })
78
76
 
79
77
  // Add system note on source ticket
80
- await payload.create({
81
- collection: slugs.ticketMessages as any,
78
+ await dbCreate(payload, slugs.ticketMessages, {
82
79
  data: {
83
80
  ticket: sourceTicket.id,
84
81
  body: `Message extrait vers le ticket ${newTicket.ticketNumber}`,
@@ -95,8 +92,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
95
92
  : []
96
93
 
97
94
  if (!existingRelated.includes(newTicket.id)) {
98
- await payload.update({
99
- collection: slugs.tickets as any,
95
+ await dbUpdate(payload, slugs.tickets, {
100
96
  id: sourceTicket.id,
101
97
  data: { relatedTickets: [...existingRelated, newTicket.id] },
102
98
  overrideAccess: true,
@@ -104,8 +100,7 @@ export function createSplitTicketEndpoint(slugs: CollectionSlugs): Endpoint {
104
100
  }
105
101
 
106
102
  // Log activity
107
- await payload.create({
108
- collection: slugs.ticketActivityLog as any,
103
+ await dbCreate(payload, slugs.ticketActivityLog, {
109
104
  data: {
110
105
  ticket: sourceTicket.id,
111
106
  action: 'split',
@@ -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
  /**
5
6
  * GET /api/support/statuses
@@ -17,8 +18,7 @@ export function createStatusesEndpoint(slugs: CollectionSlugs): Endpoint {
17
18
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
18
19
  }
19
20
 
20
- const { docs } = await payload.find({
21
- collection: slugs.ticketStatuses as any,
21
+ const { docs } = await dbFind(payload, slugs.ticketStatuses, {
22
22
  sort: 'sortOrder',
23
23
  limit: 100,
24
24
  depth: 0,
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireClient, handleAuthError } from '../utils/auth'
4
+ import { dbFind, dbFindByID, dbCreate } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * POST /api/support/tickets/:id/feedback
@@ -46,8 +47,7 @@ export function createTicketFeedbackEndpoint(slugs: CollectionSlugs): Endpoint {
46
47
  }
47
48
 
48
49
  // Find ticket and check it belongs to the requesting client
49
- const ticket = (await payload.findByID({
50
- collection: slugs.tickets as any,
50
+ const ticket = (await dbFindByID(payload, slugs.tickets, {
51
51
  id: ticketId,
52
52
  depth: 0,
53
53
  overrideAccess: true,
@@ -66,8 +66,7 @@ export function createTicketFeedbackEndpoint(slugs: CollectionSlugs): Endpoint {
66
66
  }
67
67
 
68
68
  // Check no feedback already exists for this ticket
69
- const existing = await payload.find({
70
- collection: 'ticket-feedback' as any,
69
+ const existing = await dbFind(payload, 'ticket-feedback', {
71
70
  where: { ticket: { equals: ticketId } },
72
71
  limit: 1,
73
72
  depth: 0,
@@ -80,8 +79,7 @@ export function createTicketFeedbackEndpoint(slugs: CollectionSlugs): Endpoint {
80
79
  )
81
80
  }
82
81
 
83
- const feedback = await payload.create({
84
- collection: 'ticket-feedback' as any,
82
+ const feedback = await dbCreate(payload, 'ticket-feedback', {
85
83
  data: {
86
84
  ticket: ticketId,
87
85
  client: req.user.id,
@@ -2,6 +2,7 @@ import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
4
  import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
5
+ import { dbFindByID } from '../utils/db'
5
6
 
6
7
  /**
7
8
  * POST /api/support/ticket-synthesis?ticketId=X[&force=true]
@@ -38,8 +39,7 @@ export function createTicketSynthesisEndpoint(slugs: CollectionSlugs): Endpoint
38
39
  }
39
40
 
40
41
  if (!force) {
41
- const existing = await payload.findByID({
42
- collection: slugs.tickets as any,
42
+ const existing = await dbFindByID(payload, slugs.tickets, {
43
43
  id: ticketId,
44
44
  depth: 0,
45
45
  overrideAccess: true,
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { createHmac } from 'crypto'
4
+ import { dbFindByID, dbUpdate, dbCreate } from '../utils/db'
4
5
 
5
6
  // 1x1 transparent GIF (43 bytes)
6
7
  const TRANSPARENT_GIF = Buffer.from(
@@ -34,23 +35,16 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
34
35
  const parsedId = ticketId ? Number(ticketId) : NaN
35
36
  const parsedMsgId = messageId ? Number(messageId) : NaN
36
37
 
37
- // Validate HMAC signature
38
+ // Open-tracking REQUIRES a valid HMAC signature. Without one we never
39
+ // process (no DB writes) — this prevents enumeration/abuse of t/m params
40
+ // and the silent "tracking writes without auth" hole.
38
41
  const secret = process.env.PAYLOAD_SECRET || ''
39
- if (secret && ticketId && messageId && sig) {
40
- const expected = generateTrackingToken(ticketId, messageId, secret)
41
- if (sig !== expected) {
42
- // Return transparent GIF silently (don't leak information)
43
- return new Response(TRANSPARENT_GIF, {
44
- status: 200,
45
- headers: {
46
- 'Content-Type': 'image/gif',
47
- 'Content-Length': String(TRANSPARENT_GIF.length),
48
- 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
49
- },
50
- })
51
- }
52
- } else if (secret && (!sig || !ticketId || !messageId)) {
53
- // Missing signature param: return GIF but don't process
42
+ const validSig =
43
+ !!secret && !!ticketId && !!messageId && !!sig &&
44
+ sig === generateTrackingToken(ticketId, messageId, secret)
45
+
46
+ if (!validSig) {
47
+ // Return transparent GIF silently (don't leak information, don't process)
54
48
  return new Response(TRANSPARENT_GIF, {
55
49
  status: 200,
56
50
  headers: {
@@ -65,8 +59,7 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
65
59
  try {
66
60
  const payload = req.payload
67
61
 
68
- const ticket = await payload.findByID({
69
- collection: slugs.tickets as any,
62
+ const ticket = await dbFindByID(payload, slugs.tickets, {
70
63
  id: parsedId,
71
64
  depth: 0,
72
65
  overrideAccess: true,
@@ -78,8 +71,7 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
78
71
  const fiveMinAgo = Date.now() - 5 * 60 * 1000
79
72
 
80
73
  if (lastRead < fiveMinAgo) {
81
- await payload.update({
82
- collection: slugs.tickets as any,
74
+ await dbUpdate(payload, slugs.tickets, {
83
75
  id: parsedId,
84
76
  data: { lastClientReadAt: new Date().toISOString() },
85
77
  overrideAccess: true,
@@ -89,8 +81,7 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
89
81
 
90
82
  // Track at message level
91
83
  if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
92
- const msg = await payload.findByID({
93
- collection: slugs.ticketMessages as any,
84
+ const msg = await dbFindByID(payload, slugs.ticketMessages, {
94
85
  id: parsedMsgId,
95
86
  depth: 0,
96
87
  overrideAccess: true,
@@ -98,15 +89,13 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
98
89
  }) as any
99
90
 
100
91
  if (msg && !msg.emailOpenedAt) {
101
- await payload.update({
102
- collection: slugs.ticketMessages as any,
92
+ await dbUpdate(payload, slugs.ticketMessages, {
103
93
  id: parsedMsgId,
104
94
  data: { emailOpenedAt: new Date().toISOString() },
105
95
  overrideAccess: true,
106
96
  })
107
97
 
108
- const ticketInfo = await payload.findByID({
109
- collection: slugs.tickets as any,
98
+ const ticketInfo = await dbFindByID(payload, slugs.tickets, {
110
99
  id: parsedId,
111
100
  depth: 1,
112
101
  overrideAccess: true,
@@ -119,8 +108,7 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
119
108
 
120
109
  // Try to create admin notification (collection may not exist)
121
110
  try {
122
- await payload.create({
123
- collection: 'admin-notifications' as any,
111
+ await dbCreate(payload, 'admin-notifications', {
124
112
  data: {
125
113
  title: `Email ouvert — ${ticketInfo?.ticketNumber || 'TK-????'}`,
126
114
  message: `${clientName} a ouvert votre email pour "${ticketInfo?.subject || 'ticket'}"`,