@consilioweb/payload-support 0.16.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +347 -435
- package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
- package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
- package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
- package/dist/components/TicketConversation/SkeletonText.js +20 -0
- package/dist/components/TicketConversation/constants.cjs +27 -0
- package/dist/components/TicketConversation/constants.js +26 -1
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
- package/dist/components/TicketConversation/index.cjs +10 -147
- package/dist/components/TicketConversation/index.js +4 -141
- package/dist/index.cjs +1604 -671
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1602 -671
- package/dist/views/BillingView/client.cjs +40 -54
- package/dist/views/BillingView/client.js +40 -54
- package/dist/views/ChatView/client.cjs +4 -3
- package/dist/views/ChatView/client.js +4 -3
- package/dist/views/CrmView/client.cjs +7 -7
- package/dist/views/CrmView/client.js +7 -7
- package/dist/views/EmailTrackingView/client.cjs +6 -5
- package/dist/views/EmailTrackingView/client.js +6 -5
- package/dist/views/ImportConversationView/client.cjs +4 -4
- package/dist/views/ImportConversationView/client.js +4 -4
- package/dist/views/LogsView/client.cjs +3 -2
- package/dist/views/LogsView/client.js +3 -2
- package/dist/views/NewTicketView/client.cjs +1 -1
- package/dist/views/NewTicketView/client.js +1 -1
- package/dist/views/PendingEmailsView/client.cjs +7 -6
- package/dist/views/PendingEmailsView/client.js +7 -6
- package/dist/views/SupportDashboardView/client.cjs +19 -22
- package/dist/views/SupportDashboardView/client.js +19 -22
- package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
- package/dist/views/TicketDetailView/NextActionItem.js +34 -0
- package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
- package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
- package/dist/views/TicketDetailView/client.cjs +59 -190
- package/dist/views/TicketDetailView/client.js +52 -183
- package/dist/views/TicketDetailView/constants.cjs +20 -0
- package/dist/views/TicketDetailView/constants.js +16 -0
- package/dist/views/TicketDetailView/helpers.cjs +23 -0
- package/dist/views/TicketDetailView/helpers.js +20 -0
- package/dist/views/TicketDetailView/types.cjs +2 -0
- package/dist/views/TicketDetailView/types.js +1 -0
- package/dist/views/TicketInboxView/client.cjs +14 -10
- package/dist/views/TicketInboxView/client.js +14 -10
- package/dist/views/TicketingSettingsView/client.cjs +101 -96
- package/dist/views/TicketingSettingsView/client.js +92 -88
- package/dist/views/TimeDashboardView/client.cjs +2 -2
- package/dist/views/TimeDashboardView/client.js +2 -2
- package/dist/views/shared/StatusPill.cjs +23 -12
- package/dist/views/shared/StatusPill.js +23 -12
- package/dist/views/shared/dateLocale.cjs +5 -0
- package/dist/views/shared/dateLocale.js +3 -0
- package/package.json +23 -15
- package/src/__tests__/generateTrackingToken.test.ts +53 -0
- package/src/__tests__/integration/ai-agent.test.ts +46 -0
- package/src/__tests__/integration/auth.test.ts +43 -0
- package/src/__tests__/integration/automation-rules.test.ts +68 -0
- package/src/__tests__/integration/buildTestPayload.ts +52 -0
- package/src/__tests__/integration/channels.test.ts +52 -0
- package/src/__tests__/integration/core-behaviors.test.ts +58 -0
- package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
- package/src/__tests__/integration/digest.test.ts +48 -0
- package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
- package/src/__tests__/integration/isolation.test.ts +81 -0
- package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
- package/src/__tests__/integration/nps.test.ts +37 -0
- package/src/__tests__/integration/sanitization.test.ts +35 -0
- package/src/__tests__/integration/sla-pause.test.ts +39 -0
- package/src/__tests__/integration/smoke.test.ts +26 -0
- package/src/__tests__/integration/snooze.test.ts +68 -0
- package/src/__tests__/integration/teams.test.ts +38 -0
- package/src/__tests__/rateLimiter.test.ts +89 -0
- package/src/__tests__/sanitizeHtml.test.ts +165 -0
- package/src/__tests__/sla.test.ts +258 -0
- package/src/collections/AutomationRules.ts +66 -0
- package/src/collections/NotificationQueue.ts +30 -0
- package/src/collections/SatisfactionSurveys.ts +11 -2
- package/src/collections/SupportClients.ts +58 -1
- package/src/collections/SupportTeam.ts +27 -0
- package/src/collections/TicketActivityLog.ts +9 -4
- package/src/collections/TicketMessages.ts +129 -31
- package/src/collections/Tickets.ts +31 -73
- package/src/collections/index.ts +3 -0
- package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
- package/src/components/TicketConversation/SkeletonText.tsx +23 -0
- package/src/components/TicketConversation/constants.ts +29 -0
- package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
- package/src/components/TicketConversation/index.tsx +4 -152
- package/src/endpoints/admin-chat-stream.ts +3 -4
- package/src/endpoints/admin-chat.ts +11 -20
- package/src/endpoints/admin-stats.ts +70 -32
- package/src/endpoints/ai-agent.ts +36 -0
- package/src/endpoints/apply-macro.ts +8 -14
- package/src/endpoints/auth-2fa.ts +12 -12
- package/src/endpoints/auto-close.ts +6 -10
- package/src/endpoints/billing.ts +17 -4
- package/src/endpoints/bulk-action.ts +7 -12
- package/src/endpoints/channels.ts +89 -0
- package/src/endpoints/chat-stream.ts +3 -4
- package/src/endpoints/chat.ts +7 -12
- package/src/endpoints/chatbot.ts +2 -2
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/delete-account.ts +9 -16
- package/src/endpoints/email-stats.ts +2 -2
- package/src/endpoints/escalate.ts +2 -2
- package/src/endpoints/export-csv.ts +2 -2
- package/src/endpoints/export-data.ts +5 -8
- package/src/endpoints/import-conversation.ts +6 -10
- package/src/endpoints/index.ts +18 -1
- package/src/endpoints/invite-collaborator.ts +30 -15
- package/src/endpoints/invoice.ts +127 -0
- package/src/endpoints/kb-search.ts +2 -2
- package/src/endpoints/login.ts +12 -4
- package/src/endpoints/merge-clients.ts +14 -19
- package/src/endpoints/merge-tickets.ts +11 -18
- package/src/endpoints/oauth-google.ts +86 -39
- package/src/endpoints/pending-emails-process.ts +15 -25
- package/src/endpoints/process-digests.ts +93 -0
- package/src/endpoints/process-scheduled.ts +6 -10
- package/src/endpoints/process-snooze.ts +78 -0
- package/src/endpoints/purge-logs.ts +2 -2
- package/src/endpoints/resend-notification.ts +3 -4
- package/src/endpoints/round-robin-config.ts +3 -4
- package/src/endpoints/satisfaction.ts +21 -14
- package/src/endpoints/search.ts +5 -8
- package/src/endpoints/seed-kb.ts +3 -4
- package/src/endpoints/send-reminder.ts +6 -9
- package/src/endpoints/settings.ts +5 -2
- package/src/endpoints/signature.ts +3 -4
- package/src/endpoints/sla-check.ts +2 -2
- package/src/endpoints/split-ticket.ts +7 -12
- package/src/endpoints/statuses.ts +2 -2
- package/src/endpoints/ticket-feedback.ts +4 -6
- package/src/endpoints/ticket-synthesis.ts +2 -2
- package/src/endpoints/track-open.ts +16 -28
- package/src/endpoints/transfer-ticket.ts +21 -9
- package/src/endpoints/user-prefs.ts +3 -4
- package/src/hooks/automationRules.ts +84 -0
- package/src/hooks/checkSLA.ts +48 -16
- package/src/plugin.ts +6 -0
- package/src/portal/LiveChat.tsx +2 -63
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
- package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
- package/src/portal/auth/tickets/detail/page.tsx +2 -2
- package/src/portal/helpers.ts +46 -0
- package/src/portal/login/page.tsx +3 -8
- package/src/portal/types.ts +15 -0
- package/src/types/lucide-react.d.ts +34 -0
- package/src/types/payload-config.d.ts +10 -0
- package/src/utils/adminNotification.ts +2 -2
- package/src/utils/aiAgent.ts +147 -0
- package/src/utils/auth.ts +5 -4
- package/src/utils/channels.ts +36 -0
- package/src/utils/db.ts +73 -0
- package/src/utils/emailTemplate.ts +8 -1
- package/src/utils/fireWebhooks.ts +4 -6
- package/src/utils/generateTicketSynthesis.ts +7 -12
- package/src/utils/invoicePdf.ts +88 -0
- package/src/utils/notificationQueue.ts +36 -0
- package/src/utils/readSettings.ts +22 -6
- package/src/utils/sanitizeHtml.ts +57 -0
- package/src/utils/slugs.ts +6 -0
- package/src/utils/teamAccess.ts +27 -0
- package/src/utils/ticketAccess.ts +53 -0
- package/src/utils/webhookDispatcher.ts +4 -6
- package/src/views/BillingView/client.tsx +49 -37
- package/src/views/ChatView/client.tsx +4 -3
- package/src/views/CrmView/client.tsx +7 -7
- package/src/views/EmailTrackingView/client.tsx +7 -6
- package/src/views/ImportConversationView/client.tsx +4 -4
- package/src/views/LogsView/client.tsx +3 -2
- package/src/views/NewTicketView/client.tsx +1 -1
- package/src/views/PendingEmailsView/client.tsx +7 -6
- package/src/views/SupportDashboardView/client.tsx +21 -21
- package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
- package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
- package/src/views/TicketDetailView/client.tsx +54 -179
- package/src/views/TicketDetailView/constants.ts +23 -0
- package/src/views/TicketDetailView/helpers.ts +17 -0
- package/src/views/TicketDetailView/types.ts +10 -0
- package/src/views/TicketInboxView/client.tsx +16 -10
- package/src/views/TicketingSettingsView/client.tsx +90 -87
- package/src/views/TimeDashboardView/client.tsx +2 -2
- package/src/views/shared/StatusPill.tsx +24 -12
- package/src/views/shared/dateLocale.ts +5 -0
- package/src/views/shared/locales/en.json +708 -0
- package/src/views/shared/locales/fr.json +708 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { computeSlaState, formatSlaRemaining } from '../views/shared/sla'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// computeSlaState
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
describe('computeSlaState — no SLA data', () => {
|
|
9
|
+
it('returns state "none" when no due date is set', () => {
|
|
10
|
+
const result = computeSlaState({})
|
|
11
|
+
expect(result.state).toBe('none')
|
|
12
|
+
expect(result.remainingMs).toBeNull()
|
|
13
|
+
expect(result.due).toBeNull()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns state "none" when dueRaw is null', () => {
|
|
17
|
+
const result = computeSlaState({ slaFirstResponseDue: null })
|
|
18
|
+
expect(result.state).toBe('none')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns state "none" when dueRaw is an invalid date string', () => {
|
|
22
|
+
const result = computeSlaState({ slaFirstResponseDue: 'not-a-date' })
|
|
23
|
+
expect(result.state).toBe('none')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('computeSlaState — first response SLA (no firstResponseAt)', () => {
|
|
28
|
+
const future2h = new Date(Date.now() + 2 * 3_600_000).toISOString()
|
|
29
|
+
const future30m = new Date(Date.now() + 30 * 60_000).toISOString()
|
|
30
|
+
const past = new Date(Date.now() - 60_000).toISOString()
|
|
31
|
+
|
|
32
|
+
it('returns "ok" when deadline is more than 1 hour away', () => {
|
|
33
|
+
const result = computeSlaState({ slaFirstResponseDue: future2h })
|
|
34
|
+
expect(result.state).toBe('ok')
|
|
35
|
+
expect(result.remainingMs).toBeGreaterThan(3_600_000)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns "warn" when deadline is less than 1 hour away', () => {
|
|
39
|
+
const result = computeSlaState({ slaFirstResponseDue: future30m })
|
|
40
|
+
expect(result.state).toBe('warn')
|
|
41
|
+
expect(result.remainingMs).toBeGreaterThan(0)
|
|
42
|
+
expect(result.remainingMs).toBeLessThan(3_600_000)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns "breach" when deadline is in the past', () => {
|
|
46
|
+
const result = computeSlaState({ slaFirstResponseDue: past })
|
|
47
|
+
expect(result.state).toBe('breach')
|
|
48
|
+
expect(result.remainingMs).toBeLessThan(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns "breach" when slaFirstResponseBreached flag is set (even with future due)', () => {
|
|
52
|
+
const result = computeSlaState({
|
|
53
|
+
slaFirstResponseDue: future2h,
|
|
54
|
+
slaFirstResponseBreached: true,
|
|
55
|
+
})
|
|
56
|
+
expect(result.state).toBe('breach')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('exposes the due ISO string in result', () => {
|
|
60
|
+
const result = computeSlaState({ slaFirstResponseDue: future2h })
|
|
61
|
+
expect(result.due).toBe(future2h)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('computeSlaState — resolution SLA (firstResponseAt present)', () => {
|
|
66
|
+
const firstResponseAt = new Date(Date.now() - 60_000).toISOString()
|
|
67
|
+
const future2h = new Date(Date.now() + 2 * 3_600_000).toISOString()
|
|
68
|
+
const past = new Date(Date.now() - 60_000).toISOString()
|
|
69
|
+
|
|
70
|
+
it('uses resolution SLA when firstResponseAt is set', () => {
|
|
71
|
+
const result = computeSlaState({
|
|
72
|
+
firstResponseAt,
|
|
73
|
+
slaFirstResponseDue: past, // would be breach if used
|
|
74
|
+
slaResolutionDue: future2h, // ok
|
|
75
|
+
})
|
|
76
|
+
// Should use resolution SLA → ok
|
|
77
|
+
expect(result.state).toBe('ok')
|
|
78
|
+
expect(result.due).toBe(future2h)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns "breach" when resolution deadline is passed', () => {
|
|
82
|
+
const result = computeSlaState({
|
|
83
|
+
firstResponseAt,
|
|
84
|
+
slaResolutionDue: past,
|
|
85
|
+
})
|
|
86
|
+
expect(result.state).toBe('breach')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('returns "breach" when slaResolutionBreached flag is set', () => {
|
|
90
|
+
const result = computeSlaState({
|
|
91
|
+
firstResponseAt,
|
|
92
|
+
slaResolutionDue: future2h,
|
|
93
|
+
slaResolutionBreached: true,
|
|
94
|
+
})
|
|
95
|
+
expect(result.state).toBe('breach')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('computeSlaState — pinned "now" parameter', () => {
|
|
100
|
+
it('uses the provided "now" date for comparison', () => {
|
|
101
|
+
const due = '2024-01-15T10:00:00.000Z'
|
|
102
|
+
// now before due → ok
|
|
103
|
+
const before = new Date('2024-01-15T08:00:00.000Z')
|
|
104
|
+
expect(computeSlaState({ slaFirstResponseDue: due }, before).state).toBe('ok')
|
|
105
|
+
// now after due → breach
|
|
106
|
+
const after = new Date('2024-01-15T12:00:00.000Z')
|
|
107
|
+
expect(computeSlaState({ slaFirstResponseDue: due }, after).state).toBe('breach')
|
|
108
|
+
// now 30 min before due → warn
|
|
109
|
+
const soon = new Date('2024-01-15T09:35:00.000Z')
|
|
110
|
+
expect(computeSlaState({ slaFirstResponseDue: due }, soon).state).toBe('warn')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// formatSlaRemaining
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
describe('formatSlaRemaining', () => {
|
|
119
|
+
it('returns "—" for null', () => {
|
|
120
|
+
expect(formatSlaRemaining(null)).toBe('—')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('formats less-than-1-hour remaining as minutes (positive)', () => {
|
|
124
|
+
const thirtyMin = 30 * 60_000
|
|
125
|
+
expect(formatSlaRemaining(thirtyMin)).toBe('30m')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('formats 0 remaining as "0m"', () => {
|
|
129
|
+
expect(formatSlaRemaining(0)).toBe('0m')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('formats negative < 1 hour as −Xm', () => {
|
|
133
|
+
const minus15min = -(15 * 60_000)
|
|
134
|
+
expect(formatSlaRemaining(minus15min)).toBe('−15m')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('formats exactly 1 hour as "1h"', () => {
|
|
138
|
+
const oneHour = 3_600_000
|
|
139
|
+
expect(formatSlaRemaining(oneHour)).toBe('1h')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('formats 90 minutes as "1h 30"', () => {
|
|
143
|
+
const ninetyMin = 90 * 60_000
|
|
144
|
+
expect(formatSlaRemaining(ninetyMin)).toBe('1h 30')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('formats negative hours as −Xh', () => {
|
|
148
|
+
const minus2h = -(2 * 3_600_000)
|
|
149
|
+
expect(formatSlaRemaining(minus2h)).toBe('−2h')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('formats 24h+ as days (Xj)', () => {
|
|
153
|
+
const twoDays = 48 * 3_600_000
|
|
154
|
+
expect(formatSlaRemaining(twoDays)).toBe('2j')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('formats negative 24h+ as −Xj', () => {
|
|
158
|
+
const minusThreeDays = -(3 * 24 * 3_600_000)
|
|
159
|
+
expect(formatSlaRemaining(minusThreeDays)).toBe('−3j')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('formats whole hours without trailing "00" minutes', () => {
|
|
163
|
+
const twoHoursExact = 2 * 3_600_000
|
|
164
|
+
const result = formatSlaRemaining(twoHoursExact)
|
|
165
|
+
expect(result).toBe('2h')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// calculateBusinessHoursDeadline (imported from hooks/checkSLA)
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
import { calculateBusinessHoursDeadline } from '../hooks/checkSLA'
|
|
174
|
+
|
|
175
|
+
describe('calculateBusinessHoursDeadline', () => {
|
|
176
|
+
// Use UTC dates — the function uses local Date methods without TZ conversion.
|
|
177
|
+
// We pin times to Tuesday 2024-01-09 so all boundary math is predictable.
|
|
178
|
+
|
|
179
|
+
it('adds 60 minutes within the same business day', () => {
|
|
180
|
+
// Tuesday 2024-01-09 at 10:00 local
|
|
181
|
+
const start = new Date(2024, 0, 9, 10, 0, 0) // month is 0-indexed
|
|
182
|
+
const result = calculateBusinessHoursDeadline(start, 60)
|
|
183
|
+
expect(result.getHours()).toBe(11)
|
|
184
|
+
expect(result.getMinutes()).toBe(0)
|
|
185
|
+
expect(result.getDate()).toBe(9)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('carries over minutes past end-of-day to the next business day', () => {
|
|
189
|
+
// Tuesday at 17:00, add 120 minutes (only 60 remain today: 17:00→18:00)
|
|
190
|
+
// Remaining 60 minutes spills into Wednesday morning: 9:00→10:00
|
|
191
|
+
const start = new Date(2024, 0, 9, 17, 0, 0)
|
|
192
|
+
const result = calculateBusinessHoursDeadline(start, 120)
|
|
193
|
+
expect(result.getDate()).toBe(10) // Wednesday
|
|
194
|
+
expect(result.getHours()).toBe(10)
|
|
195
|
+
expect(result.getMinutes()).toBe(0)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('skips Saturday and Sunday', () => {
|
|
199
|
+
// Friday 2024-01-12 at 17:00, add 120 minutes → skips Sat/Sun → Monday 10:00
|
|
200
|
+
const start = new Date(2024, 0, 12, 17, 0, 0) // Friday
|
|
201
|
+
const result = calculateBusinessHoursDeadline(start, 120)
|
|
202
|
+
// 60 min remaining Friday (17→18), then 60 min on Monday (9→10)
|
|
203
|
+
expect(result.getDay()).toBe(1) // Monday
|
|
204
|
+
expect(result.getHours()).toBe(10)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('moves a Saturday start to Monday 9:00 before counting', () => {
|
|
208
|
+
// Saturday at 10:00, add 60 minutes → Monday 10:00
|
|
209
|
+
const start = new Date(2024, 0, 13, 10, 0, 0) // Saturday
|
|
210
|
+
const result = calculateBusinessHoursDeadline(start, 60)
|
|
211
|
+
expect(result.getDay()).toBe(1) // Monday
|
|
212
|
+
expect(result.getHours()).toBe(10)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('moves a Sunday start to Monday 9:00 before counting', () => {
|
|
216
|
+
// Sunday at 15:00, add 30 minutes → Monday 9:30
|
|
217
|
+
const start = new Date(2024, 0, 14, 15, 0, 0) // Sunday
|
|
218
|
+
const result = calculateBusinessHoursDeadline(start, 30)
|
|
219
|
+
expect(result.getDay()).toBe(1) // Monday
|
|
220
|
+
expect(result.getHours()).toBe(9)
|
|
221
|
+
expect(result.getMinutes()).toBe(30)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('moves a before-hours start to 9:00 before counting', () => {
|
|
225
|
+
// Tuesday at 7:00 (before 9:00), add 30 minutes → 9:30 same day
|
|
226
|
+
const start = new Date(2024, 0, 9, 7, 0, 0)
|
|
227
|
+
const result = calculateBusinessHoursDeadline(start, 30)
|
|
228
|
+
expect(result.getDate()).toBe(9)
|
|
229
|
+
expect(result.getHours()).toBe(9)
|
|
230
|
+
expect(result.getMinutes()).toBe(30)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('moves an after-hours start to next business day 9:00', () => {
|
|
234
|
+
// Tuesday at 19:00 (after 18:00), add 60 minutes → Wednesday 10:00
|
|
235
|
+
const start = new Date(2024, 0, 9, 19, 0, 0)
|
|
236
|
+
const result = calculateBusinessHoursDeadline(start, 60)
|
|
237
|
+
expect(result.getDate()).toBe(10) // Wednesday
|
|
238
|
+
expect(result.getHours()).toBe(10)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('handles 0 minutes (deadline = start clamped to business hours)', () => {
|
|
242
|
+
const start = new Date(2024, 0, 9, 10, 30, 0) // Tuesday at 10:30
|
|
243
|
+
const result = calculateBusinessHoursDeadline(start, 0)
|
|
244
|
+
// Start is within business hours → no movement
|
|
245
|
+
expect(result.getDate()).toBe(9)
|
|
246
|
+
expect(result.getHours()).toBe(10)
|
|
247
|
+
expect(result.getMinutes()).toBe(30)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('handles a full business day (540 min = 9 hours) spanning exactly one day', () => {
|
|
251
|
+
// Tuesday at 9:00, add 540 min (exactly one business day) → Tuesday 18:00
|
|
252
|
+
const start = new Date(2024, 0, 9, 9, 0, 0)
|
|
253
|
+
const result = calculateBusinessHoursDeadline(start, 540)
|
|
254
|
+
expect(result.getDate()).toBe(9)
|
|
255
|
+
expect(result.getHours()).toBe(18)
|
|
256
|
+
expect(result.getMinutes()).toBe(0)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { CollectionConfig } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
|
|
4
|
+
const CONDITION_FIELDS = ['status', 'priority', 'category', 'source', 'subject'] as const
|
|
5
|
+
const ACTION_TYPES = [
|
|
6
|
+
{ label: 'Définir le statut', value: 'set_status' },
|
|
7
|
+
{ label: 'Définir la priorité', value: 'set_priority' },
|
|
8
|
+
{ label: 'Définir la catégorie', value: 'set_category' },
|
|
9
|
+
{ label: 'Assigner à (id agent)', value: 'assign' },
|
|
10
|
+
{ label: 'Ajouter un tag', value: 'add_tag' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Visual automation rules (Zendesk-style triggers): when an event fires, if the
|
|
15
|
+
* conditions match the ticket, the actions are applied. Evaluated by the
|
|
16
|
+
* `createApplyAutomationRules` hook on the Tickets collection.
|
|
17
|
+
*/
|
|
18
|
+
export function createAutomationRulesCollection(slugs: CollectionSlugs): CollectionConfig {
|
|
19
|
+
return {
|
|
20
|
+
slug: slugs.automationRules,
|
|
21
|
+
labels: { singular: 'Règle d\'automatisation', plural: 'Règles d\'automatisation' },
|
|
22
|
+
admin: { useAsTitle: 'name', group: 'Support', defaultColumns: ['name', 'event', 'enabled', 'order'] },
|
|
23
|
+
access: {
|
|
24
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
25
|
+
create: ({ req }) => req.user?.collection === slugs.users,
|
|
26
|
+
update: ({ req }) => req.user?.collection === slugs.users,
|
|
27
|
+
delete: ({ req }) => req.user?.collection === slugs.users,
|
|
28
|
+
},
|
|
29
|
+
fields: [
|
|
30
|
+
{ name: 'name', type: 'text', required: true, label: 'Nom' },
|
|
31
|
+
{ name: 'enabled', type: 'checkbox', defaultValue: true, label: 'Active', index: true },
|
|
32
|
+
{
|
|
33
|
+
name: 'event', type: 'select', defaultValue: 'ticket_created', index: true, label: 'Déclencheur',
|
|
34
|
+
options: [
|
|
35
|
+
{ label: 'Ticket créé', value: 'ticket_created' },
|
|
36
|
+
{ label: 'Ticket mis à jour', value: 'ticket_updated' },
|
|
37
|
+
{ label: 'Changement de statut', value: 'ticket_status_changed' },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'matchType', type: 'select', defaultValue: 'all', label: 'Correspondance',
|
|
42
|
+
options: [{ label: 'Toutes les conditions (ET)', value: 'all' }, { label: 'Au moins une (OU)', value: 'any' }],
|
|
43
|
+
},
|
|
44
|
+
{ name: 'order', type: 'number', defaultValue: 0, label: 'Ordre d\'évaluation' },
|
|
45
|
+
{
|
|
46
|
+
name: 'conditions', type: 'array', label: 'Conditions',
|
|
47
|
+
fields: [
|
|
48
|
+
{ name: 'field', type: 'select', required: true, options: CONDITION_FIELDS.map((v) => ({ label: v, value: v })) },
|
|
49
|
+
{
|
|
50
|
+
name: 'operator', type: 'select', defaultValue: 'equals', required: true,
|
|
51
|
+
options: [{ label: '= égal', value: 'equals' }, { label: '≠ différent', value: 'not_equals' }, { label: 'contient', value: 'contains' }],
|
|
52
|
+
},
|
|
53
|
+
{ name: 'value', type: 'text', required: true },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'actions', type: 'array', label: 'Actions',
|
|
58
|
+
fields: [
|
|
59
|
+
{ name: 'type', type: 'select', required: true, options: ACTION_TYPES },
|
|
60
|
+
{ name: 'value', type: 'text', required: true },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
timestamps: true,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CollectionConfig } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pending client notifications, queued instead of sent immediately when the
|
|
6
|
+
* client's `notificationFrequency` is `daily` or `weekly`. The `process-digests`
|
|
7
|
+
* cron drains this per client and sends a single recap email.
|
|
8
|
+
*/
|
|
9
|
+
export function createNotificationQueueCollection(slugs: CollectionSlugs): CollectionConfig {
|
|
10
|
+
return {
|
|
11
|
+
slug: slugs.notificationQueue,
|
|
12
|
+
labels: { singular: 'Notification en file', plural: 'Notifications en file' },
|
|
13
|
+
admin: { hidden: true, group: 'Support', defaultColumns: ['client', 'type', 'title', 'createdAt'] },
|
|
14
|
+
access: {
|
|
15
|
+
// Internal: written/drained only via overrideAccess (hooks + cron).
|
|
16
|
+
create: () => false,
|
|
17
|
+
update: () => false,
|
|
18
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
19
|
+
delete: ({ req }) => req.user?.collection === slugs.users,
|
|
20
|
+
},
|
|
21
|
+
fields: [
|
|
22
|
+
{ name: 'client', type: 'relationship', relationTo: slugs.supportClients, required: true, index: true, label: 'Client' },
|
|
23
|
+
{ name: 'type', type: 'text', label: 'Type' },
|
|
24
|
+
{ name: 'title', type: 'text', label: 'Titre' },
|
|
25
|
+
{ name: 'message', type: 'textarea', label: 'Message' },
|
|
26
|
+
{ name: 'ticket', type: 'relationship', relationTo: slugs.tickets, label: 'Ticket' },
|
|
27
|
+
],
|
|
28
|
+
timestamps: true,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -60,12 +60,21 @@ export function createSatisfactionSurveysCollection(slugs: CollectionSlugs): Col
|
|
|
60
60
|
{
|
|
61
61
|
name: 'rating',
|
|
62
62
|
type: 'number',
|
|
63
|
-
required: true,
|
|
64
63
|
min: 1,
|
|
65
64
|
max: 5,
|
|
66
65
|
label: 'Note (1-5)',
|
|
67
66
|
admin: {
|
|
68
|
-
description: '1 = Très insatisfait, 5 = Très satisfait',
|
|
67
|
+
description: '1 = Très insatisfait, 5 = Très satisfait (CSAT)',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'nps',
|
|
72
|
+
type: 'number',
|
|
73
|
+
min: 0,
|
|
74
|
+
max: 10,
|
|
75
|
+
label: 'NPS (0-10)',
|
|
76
|
+
admin: {
|
|
77
|
+
description: '« Recommanderiez-vous ? » — promoteur 9-10, passif 7-8, détracteur 0-6',
|
|
69
78
|
},
|
|
70
79
|
},
|
|
71
80
|
{
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { CollectionConfig, CollectionAfterChangeHook } from 'payload'
|
|
1
|
+
import type { CollectionConfig, CollectionAfterChangeHook, CollectionBeforeLoginHook } from 'payload'
|
|
2
|
+
import { APIError } from 'payload'
|
|
2
3
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
4
|
import { escapeHtml, emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
|
|
4
5
|
import { readSupportSettings } from '../utils/readSettings'
|
|
@@ -55,6 +56,40 @@ function createSendInvitationOnCreate(slugs: CollectionSlugs): CollectionAfterCh
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
// ─── 2FA enforcement ─────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
// A successful /support/2fa "verify" stamps `twoFactorVerifiedAt`. Login is only
|
|
62
|
+
// allowed within this window, after which the marker is consumed (single-use).
|
|
63
|
+
const TWO_FA_WINDOW_MS = 5 * 60 * 1000
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Enforce 2FA at the auth layer for EVERY login path (custom endpoint AND the
|
|
67
|
+
* native /api/<collection>/login), closing the bypass where a token was issued
|
|
68
|
+
* before any 2FA check. Payload verifies the password BEFORE beforeLogin, and
|
|
69
|
+
* throwing here rolls back the session it just created — so no token is issued.
|
|
70
|
+
*/
|
|
71
|
+
function createEnforce2FA(slugs: CollectionSlugs): CollectionBeforeLoginHook {
|
|
72
|
+
return async ({ req, user }) => {
|
|
73
|
+
if (!(user as { twoFactorEnabled?: boolean })?.twoFactorEnabled) return user
|
|
74
|
+
const raw = (user as { twoFactorVerifiedAt?: string | null }).twoFactorVerifiedAt
|
|
75
|
+
const verifiedAt = raw ? new Date(raw).getTime() : 0
|
|
76
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
|
|
77
|
+
// Credentials valid, but a fresh 2FA verification is required.
|
|
78
|
+
throw new APIError('2FA_REQUIRED', 401)
|
|
79
|
+
}
|
|
80
|
+
// Single-use: consume the marker so the verified window can't be replayed.
|
|
81
|
+
await req.payload.update({
|
|
82
|
+
collection: slugs.supportClients,
|
|
83
|
+
id: user.id,
|
|
84
|
+
data: { twoFactorVerifiedAt: null },
|
|
85
|
+
req,
|
|
86
|
+
overrideAccess: true,
|
|
87
|
+
})
|
|
88
|
+
;(user as { twoFactorVerifiedAt?: string | null }).twoFactorVerifiedAt = null
|
|
89
|
+
return user
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
58
93
|
// ─── Collection factory ──────────────────────────────────
|
|
59
94
|
|
|
60
95
|
export function createSupportClientsCollection(slugs: CollectionSlugs): CollectionConfig {
|
|
@@ -184,6 +219,10 @@ export function createSupportClientsCollection(slugs: CollectionSlugs): Collecti
|
|
|
184
219
|
name: 'opportunities',
|
|
185
220
|
type: 'textarea',
|
|
186
221
|
label: 'Opportunites commerciales',
|
|
222
|
+
access: {
|
|
223
|
+
// Admin-only: never serialize commercial notes onto the client's own doc.
|
|
224
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
225
|
+
},
|
|
187
226
|
admin: {
|
|
188
227
|
description: 'Notes commerciales (visible uniquement par les admins)',
|
|
189
228
|
},
|
|
@@ -227,6 +266,19 @@ export function createSupportClientsCollection(slugs: CollectionSlugs): Collecti
|
|
|
227
266
|
type: 'date',
|
|
228
267
|
admin: { hidden: true },
|
|
229
268
|
},
|
|
269
|
+
{
|
|
270
|
+
// Set by /support/2fa "verify"; gates login via the beforeLogin hook.
|
|
271
|
+
name: 'twoFactorVerifiedAt',
|
|
272
|
+
type: 'date',
|
|
273
|
+
admin: { hidden: true },
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
// Google OAuth subject id — accounts are linked by this, not email alone.
|
|
277
|
+
name: 'googleId',
|
|
278
|
+
type: 'text',
|
|
279
|
+
index: true,
|
|
280
|
+
admin: { hidden: true },
|
|
281
|
+
},
|
|
230
282
|
{
|
|
231
283
|
name: 'notifyOnReply',
|
|
232
284
|
type: 'checkbox',
|
|
@@ -293,6 +345,10 @@ export function createSupportClientsCollection(slugs: CollectionSlugs): Collecti
|
|
|
293
345
|
name: 'notes',
|
|
294
346
|
type: 'textarea',
|
|
295
347
|
label: 'Notes internes',
|
|
348
|
+
access: {
|
|
349
|
+
// Admin-only: never serialize internal notes onto the client's own doc.
|
|
350
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
351
|
+
},
|
|
296
352
|
admin: {
|
|
297
353
|
description: 'Visible uniquement par les admins',
|
|
298
354
|
position: 'sidebar',
|
|
@@ -300,6 +356,7 @@ export function createSupportClientsCollection(slugs: CollectionSlugs): Collecti
|
|
|
300
356
|
},
|
|
301
357
|
],
|
|
302
358
|
hooks: {
|
|
359
|
+
beforeLogin: [createEnforce2FA(slugs)],
|
|
303
360
|
afterChange: [createSendInvitationOnCreate(slugs)],
|
|
304
361
|
},
|
|
305
362
|
access: {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CollectionConfig } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Support teams / workspaces. Agents belong to teams; tickets can be assigned to
|
|
6
|
+
* a team. When `SUPPORT_TEAM_SCOPING` is enabled, agents only see the tickets of
|
|
7
|
+
* their teams (plus team-less tickets) — see the Tickets read access.
|
|
8
|
+
*/
|
|
9
|
+
export function createSupportTeamCollection(slugs: CollectionSlugs): CollectionConfig {
|
|
10
|
+
return {
|
|
11
|
+
slug: slugs.supportTeams,
|
|
12
|
+
labels: { singular: 'Équipe support', plural: 'Équipes support' },
|
|
13
|
+
admin: { useAsTitle: 'name', group: 'Support', defaultColumns: ['name', 'members'] },
|
|
14
|
+
access: {
|
|
15
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
16
|
+
create: ({ req }) => req.user?.collection === slugs.users,
|
|
17
|
+
update: ({ req }) => req.user?.collection === slugs.users,
|
|
18
|
+
delete: ({ req }) => req.user?.collection === slugs.users,
|
|
19
|
+
},
|
|
20
|
+
fields: [
|
|
21
|
+
{ name: 'name', type: 'text', required: true, label: 'Nom' },
|
|
22
|
+
{ name: 'description', type: 'textarea', label: 'Description' },
|
|
23
|
+
{ name: 'members', type: 'relationship', relationTo: slugs.users, hasMany: true, index: true, label: 'Membres (agents)' },
|
|
24
|
+
],
|
|
25
|
+
timestamps: true,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { CollectionConfig } from 'payload'
|
|
1
|
+
import type { CollectionConfig, Where } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { resolveAccessibleTicketIds } from '../utils/ticketAccess'
|
|
3
4
|
|
|
4
5
|
// ─── Collection factory ──────────────────────────────────
|
|
5
6
|
|
|
@@ -21,6 +22,7 @@ export function createTicketActivityLogCollection(slugs: CollectionSlugs): Colle
|
|
|
21
22
|
type: 'relationship',
|
|
22
23
|
relationTo: slugs.tickets,
|
|
23
24
|
required: true,
|
|
25
|
+
index: true,
|
|
24
26
|
label: 'Ticket',
|
|
25
27
|
},
|
|
26
28
|
{
|
|
@@ -65,11 +67,14 @@ export function createTicketActivityLogCollection(slugs: CollectionSlugs): Colle
|
|
|
65
67
|
],
|
|
66
68
|
access: {
|
|
67
69
|
create: () => false, // Created only by hooks via overrideAccess
|
|
68
|
-
read: ({ req }) => {
|
|
70
|
+
read: async ({ req }) => {
|
|
69
71
|
if (req.user?.collection === slugs.users) return true
|
|
70
|
-
// Clients can read activity logs for
|
|
72
|
+
// Clients can read activity logs only for tickets they own/collaborate on.
|
|
73
|
+
// Constrain by `ticket IN (...)` rather than a nested relationship filter
|
|
74
|
+
// (security boundary). Empty set → sentinel id (fail closed).
|
|
71
75
|
if (req.user?.collection === slugs.supportClients) {
|
|
72
|
-
|
|
76
|
+
const ids = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id)
|
|
77
|
+
return { ticket: { in: ids.length > 0 ? ids : [-1] } } as Where
|
|
73
78
|
}
|
|
74
79
|
return false
|
|
75
80
|
},
|