@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
@@ -0,0 +1,88 @@
1
+ import PDFDocument from 'pdfkit'
2
+
3
+ export interface InvoiceLine {
4
+ ticketNumber: string
5
+ subject: string
6
+ client: string
7
+ minutes: number
8
+ amount: number
9
+ }
10
+
11
+ export interface InvoiceData {
12
+ from: string
13
+ to: string
14
+ projectId?: string | null
15
+ lines: InvoiceLine[]
16
+ totalMinutes: number
17
+ totalAmount: number
18
+ }
19
+
20
+ const fmtAmount = (n: number) => `${n.toFixed(2)} EUR`
21
+ const fmtTime = (min: number) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}`
22
+
23
+ /**
24
+ * Render a real (binary) PDF invoice from billing data, using pdfkit. Returns the
25
+ * complete PDF as a Buffer (suitable for an HTTP `application/pdf` response).
26
+ */
27
+ export function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
28
+ return new Promise((resolve, reject) => {
29
+ try {
30
+ const doc = new PDFDocument({ size: 'A4', margin: 50 })
31
+ const chunks: Buffer[] = []
32
+ doc.on('data', (c: Buffer) => chunks.push(c))
33
+ doc.on('end', () => resolve(Buffer.concat(chunks)))
34
+ doc.on('error', reject)
35
+
36
+ // Header
37
+ doc.fillColor('#0f172a').fontSize(22).text('Invoice / Pre-billing')
38
+ doc.moveDown(0.2)
39
+ doc.fillColor('#64748b').fontSize(10)
40
+ .text(`Period: ${data.from} -> ${data.to}${data.projectId ? ` · Project #${data.projectId}` : ''}`)
41
+ doc.moveDown(1)
42
+
43
+ // Column layout (x positions)
44
+ const X = { ticket: 50, subject: 130, client: 320, time: 430, amount: 500 }
45
+ const drawRow = (r: { ticket: string; subject: string; client: string; time: string; amount: string }, opts?: { header?: boolean; total?: boolean }) => {
46
+ const y = doc.y
47
+ doc.fontSize(opts?.header ? 9 : 10).fillColor(opts?.header ? '#475569' : '#0f172a')
48
+ if (opts?.total) doc.font('Helvetica-Bold')
49
+ doc.text(r.ticket, X.ticket, y, { width: X.subject - X.ticket - 6 })
50
+ doc.text(r.subject, X.subject, y, { width: X.client - X.subject - 6 })
51
+ doc.text(r.client, X.client, y, { width: X.time - X.client - 6 })
52
+ doc.text(r.time, X.time, y, { width: X.amount - X.time - 6, align: 'right' })
53
+ doc.text(r.amount, X.amount, y, { width: 545 - X.amount, align: 'right' })
54
+ doc.font('Helvetica')
55
+ doc.moveDown(0.4)
56
+ }
57
+
58
+ drawRow({ ticket: 'Ticket', subject: 'Subject', client: 'Client', time: 'Time', amount: 'Amount' }, { header: true })
59
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e2e8f0').stroke()
60
+ doc.moveDown(0.3)
61
+
62
+ for (const l of data.lines) {
63
+ if (doc.y > 760) doc.addPage()
64
+ drawRow({
65
+ ticket: l.ticketNumber || '',
66
+ subject: l.subject || '',
67
+ client: l.client || '',
68
+ time: fmtTime(l.minutes),
69
+ amount: fmtAmount(l.amount),
70
+ })
71
+ }
72
+
73
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#0f172a').stroke()
74
+ doc.moveDown(0.3)
75
+ drawRow({
76
+ ticket: '',
77
+ subject: `Total (${data.lines.length} ticket${data.lines.length > 1 ? 's' : ''})`,
78
+ client: '',
79
+ time: fmtTime(data.totalMinutes),
80
+ amount: fmtAmount(data.totalAmount),
81
+ }, { total: true })
82
+
83
+ doc.end()
84
+ } catch (err) {
85
+ reject(err as Error)
86
+ }
87
+ })
88
+ }
@@ -0,0 +1,36 @@
1
+ import type { Payload } from 'payload'
2
+ import type { CollectionSlugs } from './slugs'
3
+ import { dbCreate } from './db'
4
+
5
+ export interface QueuedNotification {
6
+ client: number | string
7
+ type: string
8
+ title: string
9
+ message: string
10
+ ticket?: number | string
11
+ }
12
+
13
+ /**
14
+ * Queue a client notification for later digest delivery (best-effort). Used when
15
+ * the client opted into `daily`/`weekly` notification frequency instead of immediate.
16
+ */
17
+ export async function queueClientNotification(
18
+ payload: Payload,
19
+ slugs: CollectionSlugs,
20
+ item: QueuedNotification,
21
+ ): Promise<void> {
22
+ try {
23
+ await dbCreate(payload, slugs.notificationQueue, {
24
+ data: {
25
+ client: item.client,
26
+ type: item.type,
27
+ title: item.title,
28
+ message: item.message,
29
+ ...(item.ticket ? { ticket: item.ticket } : {}),
30
+ },
31
+ overrideAccess: true,
32
+ })
33
+ } catch (err) {
34
+ console.error('[support] Failed to queue client notification:', err)
35
+ }
36
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Payload } from 'payload'
2
+ import { dbFind } from './db'
2
3
 
3
4
  const PREF_KEY = 'support-settings'
4
5
  const USER_PREFS_KEY_PREFIX = 'support-user-prefs'
@@ -27,16 +28,31 @@ export const DEFAULT_USER_PREFS: UserPrefs = {
27
28
  signature: '',
28
29
  }
29
30
 
31
+ // In-process cache: readSupportSettings is hit many times per ticket mutation
32
+ // (the afterChange hook chain). The settings doc changes rarely, so a short TTL
33
+ // + explicit invalidation on save (endpoints/settings.ts) avoids redundant DB
34
+ // reads of the same `payload-preferences` row.
35
+ let settingsCache: { value: SupportSettings; ts: number } | null = null
36
+ const SETTINGS_TTL_MS = 60_000
37
+
38
+ /** Invalidate the settings cache — call right after writing support settings. */
39
+ export function invalidateSupportSettingsCache(): void {
40
+ settingsCache = null
41
+ }
42
+
30
43
  export async function readSupportSettings(payload: Payload): Promise<SupportSettings> {
44
+ if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
45
+ return settingsCache.value
46
+ }
47
+ let value: SupportSettings = { ...DEFAULT_SETTINGS }
31
48
  try {
32
- const prefs = await payload.find({
33
- collection: 'payload-preferences' as any,
49
+ const prefs = await dbFind(payload, 'payload-preferences', {
34
50
  where: { key: { equals: PREF_KEY } },
35
51
  limit: 1, depth: 0, overrideAccess: true,
36
52
  })
37
53
  if (prefs.docs.length > 0) {
38
54
  const stored = prefs.docs[0].value as Partial<SupportSettings>
39
- return {
55
+ value = {
40
56
  email: { ...DEFAULT_SETTINGS.email, ...stored.email },
41
57
  ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
42
58
  sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
@@ -44,14 +60,14 @@ export async function readSupportSettings(payload: Payload): Promise<SupportSett
44
60
  }
45
61
  }
46
62
  } catch { /* fallback to defaults */ }
47
- return { ...DEFAULT_SETTINGS }
63
+ settingsCache = { value, ts: Date.now() }
64
+ return value
48
65
  }
49
66
 
50
67
  export async function readUserPrefs(payload: Payload, userId: string | number): Promise<UserPrefs> {
51
68
  try {
52
69
  const key = `${USER_PREFS_KEY_PREFIX}-${userId}`
53
- const prefs = await payload.find({
54
- collection: 'payload-preferences' as any,
70
+ const prefs = await dbFind(payload, 'payload-preferences', {
55
71
  where: { key: { equals: key } },
56
72
  limit: 1, depth: 0, overrideAccess: true,
57
73
  })
@@ -0,0 +1,57 @@
1
+ import sanitizeHtml from 'sanitize-html'
2
+
3
+ /**
4
+ * Allow-list sanitizer for user/agent-submitted rich message content (`bodyHtml`).
5
+ *
6
+ * `bodyHtml` is rendered with `dangerouslySetInnerHTML` in BOTH the portal and the
7
+ * admin views, so unsanitized HTML is a stored-XSS vector: a client could inject
8
+ * a payload that runs in an agent's session → session theft → cross-client access.
9
+ *
10
+ * Applied server-side on write (TicketMessages `beforeChange`). Strips <script>,
11
+ * <iframe>/<embed>/<object>, inline event handlers (`on*`) and `javascript:` URIs,
12
+ * and restricts inline styles to a safe property/value allow-list (no `url()` /
13
+ * `expression()`).
14
+ */
15
+ const OPTIONS: sanitizeHtml.IOptions = {
16
+ allowedTags: [
17
+ 'p', 'br', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins', 'mark',
18
+ 'a', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'kbd', 'span', 'div',
19
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img',
20
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
21
+ ],
22
+ allowedAttributes: {
23
+ a: ['href', 'title', 'target', 'rel'],
24
+ img: ['src', 'alt', 'title', 'width', 'height'],
25
+ code: ['class'],
26
+ pre: ['class'],
27
+ span: ['class'],
28
+ '*': ['style'],
29
+ },
30
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
31
+ allowedSchemesByTag: { img: ['http', 'https', 'data'] },
32
+ allowProtocolRelative: false,
33
+ disallowedTagsMode: 'discard',
34
+ // Force safe rel on links opening a new tab (defense against tabnabbing).
35
+ transformTags: {
36
+ a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
37
+ },
38
+ // Only allow CSS that cannot carry a script payload.
39
+ allowedStyles: {
40
+ '*': {
41
+ color: [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
42
+ 'background-color': [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
43
+ 'text-align': [/^(left|right|center|justify)$/],
44
+ 'font-weight': [/^(normal|bold|[1-9]00)$/],
45
+ 'font-style': [/^(normal|italic)$/],
46
+ 'text-decoration': [/^(underline|line-through|none)$/],
47
+ },
48
+ },
49
+ }
50
+
51
+ /**
52
+ * Sanitize a rich-HTML message body. Returns the input unchanged when empty.
53
+ */
54
+ export function sanitizeMessageHtml(html: string): string {
55
+ if (!html) return html
56
+ return sanitizeHtml(html, OPTIONS)
57
+ }
@@ -20,6 +20,9 @@ export interface CollectionSlugs {
20
20
  macros: string
21
21
  ticketStatuses: string
22
22
  ticketFeedback: string
23
+ notificationQueue: string
24
+ automationRules: string
25
+ supportTeams: string
23
26
  users: string
24
27
  media: string
25
28
  }
@@ -42,6 +45,9 @@ export const DEFAULT_SLUGS: CollectionSlugs = {
42
45
  macros: 'macros',
43
46
  ticketStatuses: 'ticket-statuses',
44
47
  ticketFeedback: 'ticket-feedback',
48
+ notificationQueue: 'notification-queue',
49
+ automationRules: 'automation-rules',
50
+ supportTeams: 'support-teams',
45
51
  users: 'users',
46
52
  media: 'media',
47
53
  }
@@ -0,0 +1,27 @@
1
+ import type { Payload } from 'payload'
2
+ import type { CollectionSlugs } from './slugs'
3
+ import { dbFind } from './db'
4
+
5
+ /** Team scoping is opt-in — off by default so existing installs are unaffected. */
6
+ export function isTeamScopingEnabled(): boolean {
7
+ return process.env.SUPPORT_TEAM_SCOPING === '1' || process.env.SUPPORT_TEAM_SCOPING === 'true'
8
+ }
9
+
10
+ /** Resolve the team IDs an agent belongs to. */
11
+ export async function resolveAgentTeamIds(
12
+ payload: Payload,
13
+ slugs: CollectionSlugs,
14
+ userId: number | string,
15
+ ): Promise<Array<number | string>> {
16
+ try {
17
+ const teams = await dbFind(payload, slugs.supportTeams, {
18
+ where: { members: { in: [userId] } },
19
+ limit: 200,
20
+ depth: 0,
21
+ overrideAccess: true,
22
+ })
23
+ return teams.docs.map((t) => (t as { id: number | string }).id)
24
+ } catch {
25
+ return []
26
+ }
27
+ }
@@ -0,0 +1,53 @@
1
+ import type { Payload } from 'payload'
2
+ import type { CollectionSlugs } from './slugs'
3
+ import { dbFind } from './db'
4
+
5
+ /**
6
+ * Resolve the set of ticket IDs a support-client may access: tickets they own
7
+ * (`client = id`) plus tickets they collaborate on (`ticket-collaborators` table).
8
+ *
9
+ * Used as a SECURITY BOUNDARY for child collections (messages, activity log):
10
+ * callers constrain `ticket IN (...)` instead of relying on a nested relationship
11
+ * filter (`'ticket.client'`), whose behaviour through the SQLite/Drizzle adapter
12
+ * is not guaranteed. Mirrors the owner/collaborator resolution in `Tickets.ts`.
13
+ *
14
+ * Returns an empty array when the client has no accessible tickets — callers MUST
15
+ * treat that as "deny" (e.g. filter on a sentinel id) rather than "allow all".
16
+ */
17
+ export async function resolveAccessibleTicketIds(
18
+ payload: Payload,
19
+ slugs: CollectionSlugs,
20
+ clientId: number | string,
21
+ ): Promise<Array<number | string>> {
22
+ const ids = new Set<number | string>()
23
+
24
+ try {
25
+ const owned = await dbFind(payload, slugs.tickets, {
26
+ where: { client: { equals: clientId } },
27
+ limit: 5000,
28
+ depth: 0,
29
+ overrideAccess: true,
30
+ })
31
+ for (const t of owned.docs) ids.add(t.id as number | string)
32
+ } catch {
33
+ // ignore — fall through with whatever we resolved
34
+ }
35
+
36
+ try {
37
+ const collab = await dbFind(payload, 'ticket-collaborators', {
38
+ where: { client: { equals: clientId } },
39
+ limit: 1000,
40
+ depth: 0,
41
+ overrideAccess: true,
42
+ })
43
+ for (const r of collab.docs) {
44
+ const row = r as { ticket?: number | string | { id?: number | string } }
45
+ const tid = typeof row.ticket === 'object' ? row.ticket?.id : row.ticket
46
+ if (tid !== undefined && tid !== null) ids.add(tid)
47
+ }
48
+ } catch {
49
+ // ticket-collaborators may not exist on older deployments — owner-only fallback
50
+ }
51
+
52
+ return Array.from(ids)
53
+ }
@@ -1,6 +1,7 @@
1
1
  import crypto from 'crypto'
2
2
  import type { BasePayload } from 'payload'
3
3
  import type { CollectionSlugs } from './slugs'
4
+ import { dbFind, dbUpdate } from './db'
4
5
 
5
6
  type WebhookEvent = 'ticket_created' | 'ticket_resolved' | 'ticket_replied' | 'sla_breached'
6
7
 
@@ -32,8 +33,7 @@ async function _dispatch(
32
33
  slugs: CollectionSlugs,
33
34
  ): Promise<void> {
34
35
  try {
35
- const { docs: endpoints } = await payload.find({
36
- collection: slugs.webhookEndpoints as any,
36
+ const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
37
37
  where: {
38
38
  and: [
39
39
  { active: { equals: true } },
@@ -86,8 +86,7 @@ async function _sendToEndpoint(
86
86
  })
87
87
 
88
88
  // Update last triggered info on the endpoint
89
- await payload.update({
90
- collection: slugs.webhookEndpoints as any,
89
+ await dbUpdate(payload, slugs.webhookEndpoints, {
91
90
  id: endpoint.id,
92
91
  data: {
93
92
  lastTriggeredAt: new Date().toISOString(),
@@ -104,8 +103,7 @@ async function _sendToEndpoint(
104
103
 
105
104
  // Still update with error status
106
105
  try {
107
- await payload.update({
108
- collection: slugs.webhookEndpoints as any,
106
+ await dbUpdate(payload, slugs.webhookEndpoints, {
109
107
  id: endpoint.id,
110
108
  data: {
111
109
  lastTriggeredAt: new Date().toISOString(),
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useState, useCallback } from 'react'
4
4
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
+ import { DATE_LOCALE } from '../shared/dateLocale'
5
6
  import styles from '../../styles/BillingView.module.scss'
6
7
 
7
8
  interface BillingEntry {
@@ -215,28 +216,30 @@ export const BillingClient: React.FC = () => {
215
216
  const copyRecap = useCallback(() => {
216
217
  if (!data) return
217
218
  const lines: string[] = []
218
- lines.push(`PRE-FACTURATION Du ${from} au ${to}`)
219
- lines.push(`Taux horaire : ${rate} EUR/h`)
219
+ lines.push(t('billing.recap.header', { from, to }))
220
+ lines.push(t('billing.recap.hourlyRate', { rate: String(rate) }))
220
221
  lines.push('='.repeat(50))
221
222
 
222
223
  for (const group of visibleGroups) {
223
224
  lines.push('')
224
- lines.push(`PROJET : ${group.project?.name || 'Sans projet'}`)
225
- if (group.client?.company) lines.push(`Client : ${group.client.company}`)
225
+ lines.push(group.project?.name
226
+ ? t('billing.recap.project', { name: group.project.name })
227
+ : t('billing.recap.project', { name: t('billing.recap.noProject') }))
228
+ if (group.client?.company) lines.push(t('billing.recap.client', { company: group.client.company }))
226
229
  lines.push('-'.repeat(40))
227
230
 
228
231
  for (const ticket of group.tickets) {
229
- const flag = ticket.hasNoTimeEntries ? ' [AUCUN TEMPS SAISI]' : ''
232
+ const flag = ticket.hasNoTimeEntries ? t('billing.recap.noTime') : ''
230
233
  lines.push(` ${ticket.ticketNumber} — ${ticket.subject}${flag}`)
231
234
  for (const entry of ticket.entries) {
232
235
  lines.push(` ${entry.date} | ${formatDuration(entry.duration)} | ${entry.description || '-'}`)
233
236
  }
234
237
  if (ticket.entries.length > 0) {
235
238
  const ticketAmount = ticket.billedAmount || Number(formatAmount(ticket.totalMinutes, rate))
236
- lines.push(` Sous-total : ${formatDuration(ticket.totalMinutes)} = ${ticketAmount.toFixed(2)} EUR${ticket.billedAmount ? ' (forfait)' : ''}`)
239
+ lines.push(` ${t('billing.recap.subtotal')} : ${formatDuration(ticket.totalMinutes)} = ${ticketAmount.toFixed(2)}${t('billing.recap.eur')}${ticket.billedAmount ? t('billing.recap.forfait') : ''}`)
237
240
  }
238
241
  if (ticket.aiSummary) {
239
- lines.push(' Detail des actions :')
242
+ lines.push(` ${t('billing.recap.actionDetails')} :`)
240
243
  for (const detailLine of ticket.aiSummary.split('\n')) {
241
244
  lines.push(` ${detailLine}`)
242
245
  }
@@ -245,17 +248,17 @@ export const BillingClient: React.FC = () => {
245
248
  const groupAmount = group.totalBilledAmount > 0
246
249
  ? group.totalBilledAmount
247
250
  : Number(formatAmount(group.totalMinutes, rate))
248
- lines.push(` Total projet : ${formatDuration(group.totalMinutes)} = ${groupAmount.toFixed(2)} EUR`)
251
+ lines.push(` ${t('billing.recap.projectTotal')} : ${formatDuration(group.totalMinutes)} = ${groupAmount.toFixed(2)}${t('billing.recap.eur')}`)
249
252
  }
250
253
 
251
254
  lines.push('')
252
255
  lines.push('='.repeat(50))
253
- lines.push(`TOTAL GENERAL : ${formatDuration(data.grandTotalMinutes)} = ${formatAmount(data.grandTotalMinutes, rate)} EUR`)
256
+ lines.push(`${t('billing.recap.grandTotal')} : ${formatDuration(data.grandTotalMinutes)} = ${formatAmount(data.grandTotalMinutes, rate)}${t('billing.recap.eur')}`)
254
257
 
255
258
  navigator.clipboard.writeText(lines.join('\n'))
256
259
  setCopied(true)
257
260
  setTimeout(() => setCopied(false), 2000)
258
- }, [data, visibleGroups, from, to, rate])
261
+ }, [data, visibleGroups, from, to, rate, t])
259
262
 
260
263
  const copyTicketSummary = useCallback((ticket: BillingTicket) => {
261
264
  const lines = [`${ticket.ticketNumber} — ${ticket.subject}`]
@@ -324,11 +327,13 @@ export const BillingClient: React.FC = () => {
324
327
  {data.ticketsWithoutTime > 0 && (
325
328
  <div className={styles.warningBanner}>
326
329
  <span>
327
- <strong>{data.ticketsWithoutTime}</strong> ticket{data.ticketsWithoutTime > 1 ? 's' : ''} actif{data.ticketsWithoutTime > 1 ? 's' : ''} sans temps saisi sur la periode.
330
+ {data.ticketsWithoutTime > 1
331
+ ? t('billing.warningTicketsPlural', { count: String(data.ticketsWithoutTime) })
332
+ : t('billing.warningTickets', { count: String(data.ticketsWithoutTime) })}
328
333
  </span>
329
334
  <label className={styles.toggleLabel}>
330
335
  <input type="checkbox" checked={hideEmpty} onChange={(e) => setHideEmpty(e.target.checked)} />
331
- <span>Masquer ces tickets</span>
336
+ <span>{t('billing.hideEmpty')}</span>
332
337
  </label>
333
338
  </div>
334
339
  )}
@@ -341,7 +346,7 @@ export const BillingClient: React.FC = () => {
341
346
  <div key={gi} className={styles.groupCard}>
342
347
  <div className={styles.groupHeader}>
343
348
  <div>
344
- <span className={styles.groupName}>{group.project?.name || 'Sans projet'}</span>
349
+ <span className={styles.groupName}>{group.project?.name || t('billing.noProject')}</span>
345
350
  {group.client?.company && (
346
351
  <span className={styles.groupClient}>— {group.client.company}</span>
347
352
  )}
@@ -350,7 +355,7 @@ export const BillingClient: React.FC = () => {
350
355
  <div className={styles.groupDuration}>{formatDuration(group.totalMinutes)}</div>
351
356
  {group.totalBilledAmount > 0 ? (
352
357
  <>
353
- <div className={styles.groupAmountBilled}>{group.totalBilledAmount.toFixed(2)} EUR facture</div>
358
+ <div className={styles.groupAmountBilled}>{group.totalBilledAmount.toFixed(2)} EUR {t('billing.groupAmountBilled')}</div>
354
359
  <div className={styles.groupAmountStrike}>{formatAmount(group.totalMinutes, rate)} EUR (temps)</div>
355
360
  </>
356
361
  ) : (
@@ -377,16 +382,16 @@ export const BillingClient: React.FC = () => {
377
382
  })
378
383
  }}
379
384
  className={styles.checkbox}
380
- title="Tout cocher/decocher"
385
+ title={t('billing.tableCheckboxTitle')}
381
386
  />
382
387
  </th>
383
- <th className={styles.th}>N° Ticket</th>
384
- <th className={styles.thLeft}>Sujet</th>
385
- <th className={styles.th}>Date</th>
386
- <th className={styles.th}>Duree</th>
387
- <th className={styles.thLeft}>Description</th>
388
- <th className={styles.th}>Montant</th>
389
- <th className={styles.th}>Facture</th>
388
+ <th className={styles.th}>{t('billing.tableHeaders.number')}</th>
389
+ <th className={styles.thLeft}>{t('billing.tableHeaders.subject')}</th>
390
+ <th className={styles.th}>{t('billing.tableHeaders.date')}</th>
391
+ <th className={styles.th}>{t('billing.tableHeaders.duration')}</th>
392
+ <th className={styles.thLeft}>{t('billing.tableHeaders.description')}</th>
393
+ <th className={styles.th}>{t('billing.tableHeaders.amount')}</th>
394
+ <th className={styles.th}>{t('billing.tableHeaders.billed')}</th>
390
395
  </tr>
391
396
  </thead>
392
397
  <tbody>
@@ -403,7 +408,7 @@ export const BillingClient: React.FC = () => {
403
408
  checked={isBilled}
404
409
  onChange={() => toggleBilled(ticket.id)}
405
410
  className={styles.checkbox}
406
- title={isBilled ? 'Marquer comme non facture' : 'Marquer comme facture'}
411
+ title={isBilled ? t('billing.markUnbilledTitle') : t('billing.markBilledTitle')}
407
412
  />
408
413
  </td>
409
414
  <td className={`${styles.td} ${styles.bold} ${isBilled ? styles.strikethrough : ''}`} rowSpan={rowSpan}>
@@ -413,7 +418,7 @@ export const BillingClient: React.FC = () => {
413
418
  <button
414
419
  className={styles.summaryBtn}
415
420
  onClick={() => toggleSummary(ticket.id)}
416
- title={isExpanded ? 'Masquer le detail' : 'Afficher le detail IA'}
421
+ title={isExpanded ? t('billing.summaryToggleHide') : t('billing.summaryToggleShow')}
417
422
  >
418
423
  {isExpanded ? '▼' : '▶'} IA
419
424
  </button>
@@ -421,8 +426,8 @@ export const BillingClient: React.FC = () => {
421
426
  <td className={`${styles.tdLeft} ${isBilled ? styles.strikethrough : ''}`} rowSpan={rowSpan}>
422
427
  {ticket.subject}
423
428
  {ticket.hasNoTimeEntries && (
424
- <span className={styles.noTimeBadge} title="Aucun temps saisi sur la periode">
425
- ⚠ Aucun temps saisi
429
+ <span className={styles.noTimeBadge} title={t('billing.noTimeBadgeTitle')}>
430
+ {t('billing.noTimeBadge')}
426
431
  </span>
427
432
  )}
428
433
  </td>
@@ -439,7 +444,7 @@ export const BillingClient: React.FC = () => {
439
444
  >
440
445
  {renderTicketHeaderCells()}
441
446
  <td className={`${styles.td} ${styles.secondary}`} colSpan={5}>
442
- <em>Pas de saisie de temps. Verifier si du temps a ete oublie.</em>
447
+ <em>{t('billing.noTimeRow')}</em>
443
448
  </td>
444
449
  </tr>,
445
450
  )
@@ -477,11 +482,11 @@ export const BillingClient: React.FC = () => {
477
482
  <tr key={`${ticket.id}-summary`} className={styles.summaryRow}>
478
483
  <td colSpan={8} className={styles.summaryCell}>
479
484
  <div className={styles.summaryHeader}>
480
- <strong>Synthese IA des actions</strong>
485
+ <strong>{t('billing.summaryTitle')}</strong>
481
486
  <div className={styles.summaryActions}>
482
487
  {ticket.aiSummaryGeneratedAt && (
483
488
  <span className={styles.summaryMeta}>
484
- Genere le {new Date(ticket.aiSummaryGeneratedAt).toLocaleString('fr-FR')}
489
+ {t('billing.summaryGeneratedAt', { date: new Date(ticket.aiSummaryGeneratedAt).toLocaleString(DATE_LOCALE) })}
485
490
  </span>
486
491
  )}
487
492
  {ticket.aiSummary && (
@@ -489,7 +494,7 @@ export const BillingClient: React.FC = () => {
489
494
  className={styles.summaryAction}
490
495
  onClick={() => copyTicketSummary(ticket)}
491
496
  >
492
- Copier
497
+ {t('billing.summaryCopy')}
493
498
  </button>
494
499
  )}
495
500
  <button
@@ -498,20 +503,20 @@ export const BillingClient: React.FC = () => {
498
503
  disabled={isRegenerating}
499
504
  >
500
505
  {isRegenerating
501
- ? 'Generation...'
502
- : ticket.aiSummary ? 'Regenerer' : 'Generer'}
506
+ ? t('billing.summaryGenerating')
507
+ : ticket.aiSummary ? t('billing.summaryRegenerate') : t('billing.summaryGenerate')}
503
508
  </button>
504
509
  </div>
505
510
  </div>
506
511
  {ticket.aiSummaryStatus === 'pending' && !ticket.aiSummary ? (
507
512
  <div className={styles.summaryEmpty}>
508
- Generation en cours en arriere-plan. Cliquer sur "Generer" pour forcer.
513
+ {t('billing.summaryPending')}
509
514
  </div>
510
515
  ) : ticket.aiSummary ? (
511
516
  <pre className={styles.summaryText}>{ticket.aiSummary}</pre>
512
517
  ) : (
513
518
  <div className={styles.summaryEmpty}>
514
- Pas de synthese disponible. La synthese est generee automatiquement quand le ticket passe en "resolu", ou manuellement via le bouton "Generer".
519
+ {t('billing.summaryEmpty')}
515
520
  </div>
516
521
  )}
517
522
  </td>
@@ -529,15 +534,22 @@ export const BillingClient: React.FC = () => {
529
534
  <div className={styles.grandTotal}>
530
535
  <div>
531
536
  <div className={styles.totalMeta}>
532
- {totalTickets} ticket{totalTickets > 1 ? 's' : ''} affiche{totalTickets > 1 ? 's' : ''}
537
+ {totalTickets > 1
538
+ ? t('billing.ticketsDisplayedPlural', { count: String(totalTickets) })
539
+ : t('billing.ticketsDisplayed', { count: String(totalTickets) })}
533
540
  {billedTickets.size > 0 && (
534
541
  <span className={styles.totalChecked}>
535
- ({allTicketIds.filter((id) => billedTickets.has(id)).length} coche{allTicketIds.filter((id) => billedTickets.has(id)).length > 1 ? 's' : ''})
542
+ {(() => {
543
+ const checkedCount = allTicketIds.filter((id) => billedTickets.has(id)).length
544
+ return checkedCount > 1
545
+ ? `(${t('billing.ticketsCheckedPlural', { count: String(checkedCount) })})`
546
+ : `(${t('billing.ticketsChecked', { count: String(checkedCount) })})`
547
+ })()}
536
548
  </span>
537
549
  )}
538
550
  </div>
539
551
  <div className={styles.totalAmount}>
540
- Total : {formatDuration(data.grandTotalMinutes)} ={' '}
552
+ {t('billing.totalLabel')} : {formatDuration(data.grandTotalMinutes)} ={' '}
541
553
  {data.grandTotalBilledAmount > 0
542
554
  ? `${data.grandTotalBilledAmount.toFixed(2)} EUR`
543
555
  : `${formatAmount(data.grandTotalMinutes, rate)} EUR`
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useState, useEffect, useRef, useCallback } from 'react'
4
4
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
+ import { DATE_LOCALE } from '../shared/dateLocale'
5
6
  import styles from '../../styles/ChatView.module.scss'
6
7
 
7
8
  interface ChatSession {
@@ -255,7 +256,7 @@ export const ChatViewClient: React.FC = () => {
255
256
  ) : s.lastMessage}
256
257
  </div>
257
258
  <div className={styles.sessionMeta}>
258
- {new Date(s.lastMessageAt).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
259
+ {new Date(s.lastMessageAt).toLocaleString(DATE_LOCALE, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
259
260
  {' · '}{s.messageCount} {t('chat.msg')}
260
261
  </div>
261
262
  </button>
@@ -306,7 +307,7 @@ export const ChatViewClient: React.FC = () => {
306
307
  : msg.message.replace(/Note:\s*[★☆]+\s*\(\d\/5\)\s*/, '').replace('Commentaire: ', '')}
307
308
  </div>
308
309
  <div className={styles.ratingMeta}>
309
- {t('chat.clientReview')} · {new Date(msg.createdAt).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
310
+ {t('chat.clientReview')} · {new Date(msg.createdAt).toLocaleString(DATE_LOCALE, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
310
311
  </div>
311
312
  </div>
312
313
  ) : (
@@ -325,7 +326,7 @@ export const ChatViewClient: React.FC = () => {
325
326
  {msg.message}
326
327
  </div>
327
328
  <div className={styles.bubbleTime}>
328
- {new Date(msg.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
329
+ {new Date(msg.createdAt).toLocaleTimeString(DATE_LOCALE, { hour: '2-digit', minute: '2-digit' })}
329
330
  </div>
330
331
  </div>
331
332
  )}