@consilioweb/payload-support 2.0.1 → 3.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 +636 -402
- package/dist/components/RichTextEditor/index.d.ts +19 -0
- package/dist/components/TicketConversation/RewriteDropdown.d.ts +6 -0
- package/dist/components/TicketConversation/SkeletonText.d.ts +4 -0
- package/dist/components/TicketConversation/components/AISummaryPanel.d.ts +14 -0
- package/dist/components/TicketConversation/components/ActionPanels.d.ts +47 -0
- package/dist/components/TicketConversation/components/ActivityLog.d.ts +5 -0
- package/dist/components/TicketConversation/components/ClientBar.d.ts +5 -0
- package/dist/components/TicketConversation/components/ClientHistory.d.ts +24 -0
- package/dist/components/TicketConversation/components/CodeBlock.d.ts +26 -0
- package/dist/components/TicketConversation/components/CodeBlockInserter.d.ts +12 -0
- package/dist/components/TicketConversation/components/QuickActions.d.ts +32 -0
- package/dist/components/TicketConversation/components/TicketHeader.d.ts +19 -0
- package/dist/components/TicketConversation/components/TimeTrackingPanel.d.ts +24 -0
- package/dist/components/TicketConversation/config.d.ts +10 -0
- package/dist/components/TicketConversation/config.js +1 -40
- package/dist/components/TicketConversation/constants.d.ts +56 -0
- package/dist/components/TicketConversation/context.d.ts +53 -0
- package/dist/components/TicketConversation/hooks/useAI.d.ts +21 -0
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.d.ts +3 -0
- package/dist/components/TicketConversation/hooks/useFeatures.d.ts +23 -0
- package/dist/components/TicketConversation/hooks/useFeatures.js +61 -0
- package/dist/components/TicketConversation/hooks/useMessageActions.d.ts +20 -0
- package/dist/components/TicketConversation/hooks/useReply.d.ts +27 -0
- package/dist/components/TicketConversation/hooks/useTicketActions.d.ts +58 -0
- package/dist/components/TicketConversation/hooks/useTimeTracking.d.ts +18 -0
- package/dist/components/TicketConversation/hooks/useTranslation.d.ts +33 -0
- package/dist/components/TicketConversation/index.d.ts +4 -0
- package/dist/components/TicketConversation/index.js +2 -2
- package/dist/components/TicketConversation/types.d.ts +56 -0
- package/dist/components/TicketConversation/utils.d.ts +2 -0
- package/dist/index.cjs +392 -320
- package/dist/index.d.cts +100 -3
- package/dist/index.d.ts +100 -3
- package/dist/index.js +388 -321
- package/dist/utils/features.d.ts +123 -0
- package/dist/utils/features.js +147 -0
- package/dist/views/BillingView/client.d.ts +2 -0
- package/dist/views/BillingView/index.d.ts +4 -0
- package/dist/views/ChatView/client.d.ts +2 -0
- package/dist/views/ChatView/client.js +9 -9
- package/dist/views/ChatView/index.d.ts +4 -0
- package/dist/views/CrmView/client.d.ts +2 -0
- package/dist/views/CrmView/client.js +6 -9
- package/dist/views/CrmView/index.d.ts +4 -0
- package/dist/views/EmailTrackingView/client.d.ts +2 -0
- package/dist/views/EmailTrackingView/index.d.ts +4 -0
- package/dist/views/ImportConversationView/client.d.ts +2 -0
- package/dist/views/ImportConversationView/index.d.ts +4 -0
- package/dist/views/LogsView/client.d.ts +2 -0
- package/dist/views/LogsView/index.d.ts +4 -0
- package/dist/views/NewTicketView/client.d.ts +2 -0
- package/dist/views/NewTicketView/index.d.ts +4 -0
- package/dist/views/PendingEmailsView/client.d.ts +2 -0
- package/dist/views/PendingEmailsView/index.d.ts +4 -0
- package/dist/views/SupportDashboardView/client.d.ts +2 -0
- package/dist/views/SupportDashboardView/index.d.ts +4 -0
- package/dist/views/TicketDetailView/NextActionItem.d.ts +6 -0
- package/dist/views/TicketDetailView/RewriteDropdown.d.ts +7 -0
- package/dist/views/TicketDetailView/client.d.ts +2 -0
- package/dist/views/TicketDetailView/client.js +2 -2
- package/dist/views/TicketDetailView/constants.d.ts +13 -0
- package/dist/views/TicketDetailView/helpers.d.ts +2 -0
- package/dist/views/TicketDetailView/index.d.ts +4 -0
- package/dist/views/TicketDetailView/types.d.ts +40 -0
- package/dist/views/TicketInboxView/client.d.ts +2 -0
- package/dist/views/TicketInboxView/client.js +5 -5
- package/dist/views/TicketInboxView/index.d.ts +4 -0
- package/dist/views/TicketingSettingsView/client.d.ts +2 -0
- package/dist/views/TicketingSettingsView/client.js +5 -4
- package/dist/views/TicketingSettingsView/index.d.ts +4 -0
- package/dist/views/TimeDashboardView/client.d.ts +2 -0
- package/dist/views/TimeDashboardView/index.d.ts +4 -0
- package/dist/views/shared/AdminViewHeader.d.ts +13 -0
- package/dist/views/shared/ErrorBoundary.d.ts +17 -0
- package/dist/views/shared/Skeleton.d.ts +21 -0
- package/dist/views/shared/StatusPill.d.ts +8 -0
- package/dist/views/shared/adminTokens.d.ts +19 -0
- package/dist/views/shared/config.d.ts +8 -0
- package/dist/views/shared/config.js +1 -40
- package/dist/views/shared/dateLocale.d.ts +5 -0
- package/dist/views/shared/index.d.ts +12 -0
- package/dist/views/shared/index.js +2 -1
- package/dist/views/shared/sla.d.ts +14 -0
- package/dist/views.d.ts +13 -13
- package/package.json +16 -31
- package/src/collections/SupportClients.ts +29 -0
- package/src/collections/TicketCollaborators.ts +6 -1
- package/src/collections/TicketMessages.ts +46 -24
- package/src/collections/Tickets.ts +117 -79
- package/src/collections/TimeEntries.ts +57 -32
- package/src/components/TicketConversation/config.ts +16 -80
- package/src/components/TicketConversation/hooks/useFeatures.ts +109 -0
- package/src/components/TicketConversation/index.tsx +2 -2
- package/src/endpoints/ai.ts +2 -1
- package/src/endpoints/client-intelligence.ts +2 -1
- package/src/endpoints/process-scheduled.ts +13 -8
- package/src/endpoints/round-robin-config.ts +30 -20
- package/src/endpoints/settings.ts +28 -77
- package/src/index.ts +6 -2
- package/src/portal/LiveChat.tsx +33 -22
- package/src/portal/auth/tickets/detail/TicketPolling.tsx +5 -1
- package/src/types/lucide-react.d.ts +10 -2
- package/src/utils/aiAgent.ts +2 -1
- package/src/utils/aiProvider.ts +21 -0
- package/src/utils/features.ts +297 -0
- package/src/utils/fireWebhooks.ts +6 -0
- package/src/utils/generateTicketSynthesis.ts +2 -1
- package/src/utils/index.ts +4 -2
- package/src/utils/readSettings.ts +82 -9
- package/src/utils/webhookDispatcher.ts +19 -7
- package/src/views/ChatView/client.tsx +13 -9
- package/src/views/CrmView/client.tsx +10 -11
- package/src/views/TicketDetailView/client.tsx +2 -2
- package/src/views/TicketInboxView/client.tsx +10 -5
- package/src/views/TicketingSettingsView/client.tsx +8 -6
- package/src/views/shared/config.ts +14 -80
- package/src/views/shared/index.ts +3 -1
- package/dist/components/RichTextEditor/index.cjs +0 -279
- package/dist/components/TicketConversation/RewriteDropdown.cjs +0 -98
- package/dist/components/TicketConversation/SkeletonText.cjs +0 -21
- package/dist/components/TicketConversation/components/AISummaryPanel.cjs +0 -85
- package/dist/components/TicketConversation/components/ActionPanels.cjs +0 -193
- package/dist/components/TicketConversation/components/ActivityLog.cjs +0 -24
- package/dist/components/TicketConversation/components/ClientBar.cjs +0 -43
- package/dist/components/TicketConversation/components/ClientHistory.cjs +0 -138
- package/dist/components/TicketConversation/components/CodeBlock.cjs +0 -171
- package/dist/components/TicketConversation/components/CodeBlockInserter.cjs +0 -156
- package/dist/components/TicketConversation/components/QuickActions.cjs +0 -87
- package/dist/components/TicketConversation/components/TicketHeader.cjs +0 -93
- package/dist/components/TicketConversation/components/TimeTrackingPanel.cjs +0 -134
- package/dist/components/TicketConversation/config.cjs +0 -44
- package/dist/components/TicketConversation/constants.cjs +0 -127
- package/dist/components/TicketConversation/context.cjs +0 -13
- package/dist/components/TicketConversation/hooks/useAI.cjs +0 -196
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +0 -20
- package/dist/components/TicketConversation/hooks/useMessageActions.cjs +0 -136
- package/dist/components/TicketConversation/hooks/useReply.cjs +0 -188
- package/dist/components/TicketConversation/hooks/useTicketActions.cjs +0 -290
- package/dist/components/TicketConversation/hooks/useTimeTracking.cjs +0 -123
- package/dist/components/TicketConversation/hooks/useTranslation.cjs +0 -101
- package/dist/components/TicketConversation/index.cjs +0 -1114
- package/dist/components/TicketConversation/types.cjs +0 -2
- package/dist/components/TicketConversation/utils.cjs +0 -27
- package/dist/views/BillingView/client.cjs +0 -469
- package/dist/views/BillingView/index.cjs +0 -34
- package/dist/views/ChatView/client.cjs +0 -301
- package/dist/views/ChatView/index.cjs +0 -34
- package/dist/views/CrmView/client.cjs +0 -458
- package/dist/views/CrmView/index.cjs +0 -34
- package/dist/views/EmailTrackingView/client.cjs +0 -171
- package/dist/views/EmailTrackingView/index.cjs +0 -34
- package/dist/views/ImportConversationView/client.cjs +0 -238
- package/dist/views/ImportConversationView/index.cjs +0 -34
- package/dist/views/LogsView/client.cjs +0 -148
- package/dist/views/LogsView/index.cjs +0 -32
- package/dist/views/NewTicketView/client.cjs +0 -213
- package/dist/views/NewTicketView/index.cjs +0 -32
- package/dist/views/PendingEmailsView/client.cjs +0 -471
- package/dist/views/PendingEmailsView/index.cjs +0 -34
- package/dist/views/SupportDashboardView/client.cjs +0 -437
- package/dist/views/SupportDashboardView/index.cjs +0 -34
- package/dist/views/TicketDetailView/NextActionItem.cjs +0 -40
- package/dist/views/TicketDetailView/RewriteDropdown.cjs +0 -80
- package/dist/views/TicketDetailView/client.cjs +0 -1412
- package/dist/views/TicketDetailView/constants.cjs +0 -20
- package/dist/views/TicketDetailView/helpers.cjs +0 -23
- package/dist/views/TicketDetailView/index.cjs +0 -34
- package/dist/views/TicketDetailView/types.cjs +0 -2
- package/dist/views/TicketInboxView/client.cjs +0 -359
- package/dist/views/TicketInboxView/index.cjs +0 -32
- package/dist/views/TicketingSettingsView/client.cjs +0 -740
- package/dist/views/TicketingSettingsView/index.cjs +0 -34
- package/dist/views/TimeDashboardView/client.cjs +0 -180
- package/dist/views/TimeDashboardView/index.cjs +0 -34
- package/dist/views/shared/AdminViewHeader.cjs +0 -68
- package/dist/views/shared/ErrorBoundary.cjs +0 -55
- package/dist/views/shared/Skeleton.cjs +0 -77
- package/dist/views/shared/StatusPill.cjs +0 -47
- package/dist/views/shared/adminTokens.cjs +0 -34
- package/dist/views/shared/config.cjs +0 -44
- package/dist/views/shared/dateLocale.cjs +0 -5
- package/dist/views/shared/index.cjs +0 -72
- package/dist/views/shared/sla.cjs +0 -37
- package/dist/views.cjs +0 -86
- package/dist/views.d.cts +0 -13
- package/src/__tests__/aiSummaryRendering.test.ts +0 -21
- package/src/__tests__/authResponses.test.ts +0 -69
- package/src/__tests__/capabilities.test.ts +0 -71
- package/src/__tests__/generateTrackingToken.test.ts +0 -67
- package/src/__tests__/integration/ai-agent.test.ts +0 -46
- package/src/__tests__/integration/auth.test.ts +0 -43
- package/src/__tests__/integration/automation-rules.test.ts +0 -68
- package/src/__tests__/integration/buildTestPayload.ts +0 -52
- package/src/__tests__/integration/channels.test.ts +0 -52
- package/src/__tests__/integration/core-behaviors.test.ts +0 -81
- package/src/__tests__/integration/dashboard-volume.test.ts +0 -31
- package/src/__tests__/integration/digest.test.ts +0 -48
- package/src/__tests__/integration/invoice-pdf.test.ts +0 -30
- package/src/__tests__/integration/isolation.test.ts +0 -81
- package/src/__tests__/integration/mentions-invoice.test.ts +0 -50
- package/src/__tests__/integration/nps.test.ts +0 -37
- package/src/__tests__/integration/per-team-sla.test.ts +0 -44
- package/src/__tests__/integration/push.test.ts +0 -75
- package/src/__tests__/integration/sanitization.test.ts +0 -35
- package/src/__tests__/integration/sla-pause.test.ts +0 -39
- package/src/__tests__/integration/smoke.test.ts +0 -26
- package/src/__tests__/integration/snooze.test.ts +0 -68
- package/src/__tests__/integration/teams.test.ts +0 -38
- package/src/__tests__/rateLimiter.test.ts +0 -83
- package/src/__tests__/sanitizeHtml.test.ts +0 -165
- package/src/__tests__/sla.test.ts +0 -258
- package/src/__tests__/trackOpenEndpoint.test.ts +0 -54
- package/src/__tests__/translations.test.ts +0 -67
- package/src/__tests__/webhookSecurity.test.ts +0 -58
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
|
|
4
|
-
const PW = 'test-pw-12345'
|
|
5
|
-
|
|
6
|
-
async function makeClient(payload: Awaited<ReturnType<typeof buildTestPayload>>, email: string) {
|
|
7
|
-
return payload.create({ collection: 'support-clients', data: { email, password: PW, firstName: 'C', lastName: 'C', company: 'C' }, overrideAccess: true })
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Regression coverage for core ticketing behaviours that the P2 features will be
|
|
12
|
-
* built on top of: ticket numbering, auto-status transitions on reply, and SLA
|
|
13
|
-
* deadline assignment.
|
|
14
|
-
*/
|
|
15
|
-
describe('core ticketing behaviours', () => {
|
|
16
|
-
it('auto-assigns a unique TK- number to each ticket', async () => {
|
|
17
|
-
const payload = await buildTestPayload()
|
|
18
|
-
const c = await makeClient(payload, 'num@example.com')
|
|
19
|
-
const t1 = await payload.create({ collection: 'tickets', data: { subject: 'one', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
20
|
-
const t2 = await payload.create({ collection: 'tickets', data: { subject: 'two', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
21
|
-
expect(t1.ticketNumber).toMatch(/^TK-\d{4}/)
|
|
22
|
-
expect(t2.ticketNumber).toMatch(/^TK-\d{4}/)
|
|
23
|
-
expect(t1.ticketNumber).not.toBe(t2.ticketNumber)
|
|
24
|
-
}, 60_000)
|
|
25
|
-
|
|
26
|
-
it('allocates sequential numbers under concurrent creation and after deletion', async () => {
|
|
27
|
-
const payload = await buildTestPayload()
|
|
28
|
-
const c = await makeClient(payload, 'concurrent-num@example.com')
|
|
29
|
-
const tickets = await Promise.all(
|
|
30
|
-
Array.from({ length: 12 }, (_, index) => payload.create({
|
|
31
|
-
collection: 'tickets',
|
|
32
|
-
data: { subject: `concurrent-${index}`, client: c.id, status: 'open', priority: 'normal' },
|
|
33
|
-
overrideAccess: true,
|
|
34
|
-
})),
|
|
35
|
-
)
|
|
36
|
-
const numbers = tickets.map((ticket) => Number(String(ticket.ticketNumber).replace('TK-', '')))
|
|
37
|
-
expect(new Set(numbers)).toHaveLength(12)
|
|
38
|
-
|
|
39
|
-
const highest = Math.max(...numbers)
|
|
40
|
-
await payload.delete({ collection: 'tickets', id: tickets[0].id, overrideAccess: true })
|
|
41
|
-
const next = await payload.create({
|
|
42
|
-
collection: 'tickets',
|
|
43
|
-
data: { subject: 'after-delete', client: c.id, status: 'open', priority: 'normal' },
|
|
44
|
-
overrideAccess: true,
|
|
45
|
-
})
|
|
46
|
-
expect(Number(String(next.ticketNumber).replace('TK-', ''))).toBe(highest + 1)
|
|
47
|
-
}, 60_000)
|
|
48
|
-
|
|
49
|
-
it('moves status to waiting_client on admin reply and back to open on client reply', async () => {
|
|
50
|
-
const payload = await buildTestPayload()
|
|
51
|
-
const c = await makeClient(payload, 'status@example.com')
|
|
52
|
-
const t = await payload.create({ collection: 'tickets', data: { subject: 'flow', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
53
|
-
|
|
54
|
-
await payload.create({ collection: 'ticket-messages', data: { ticket: t.id, body: 'admin reply', authorType: 'admin' }, overrideAccess: true })
|
|
55
|
-
let reread = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
56
|
-
expect(reread.status).toBe('waiting_client')
|
|
57
|
-
|
|
58
|
-
await payload.create({ collection: 'ticket-messages', data: { ticket: t.id, body: 'client reply', authorType: 'client', authorClient: c.id }, overrideAccess: true })
|
|
59
|
-
reread = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
60
|
-
expect(reread.status).toBe('open')
|
|
61
|
-
}, 60_000)
|
|
62
|
-
|
|
63
|
-
it('assigns SLA deadlines on create from the default policy', async () => {
|
|
64
|
-
const payload = await buildTestPayload()
|
|
65
|
-
await payload.create({
|
|
66
|
-
collection: 'sla-policies',
|
|
67
|
-
data: { name: 'Default', priority: 'normal', firstResponseTime: 60, resolutionTime: 240, businessHoursOnly: false, isDefault: true },
|
|
68
|
-
overrideAccess: true,
|
|
69
|
-
})
|
|
70
|
-
const c = await makeClient(payload, 'sla@example.com')
|
|
71
|
-
const t = await payload.create({ collection: 'tickets', data: { subject: 'sla', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
72
|
-
|
|
73
|
-
const reread = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
74
|
-
expect(reread.slaFirstResponseDue).toBeTruthy()
|
|
75
|
-
expect(reread.slaResolutionDue).toBeTruthy()
|
|
76
|
-
// resolution deadline must be later than the first-response deadline
|
|
77
|
-
expect(new Date(reread.slaResolutionDue as string).getTime()).toBeGreaterThan(
|
|
78
|
-
new Date(reread.slaFirstResponseDue as string).getTime(),
|
|
79
|
-
)
|
|
80
|
-
}, 60_000)
|
|
81
|
-
})
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
import { createAdminStatsEndpoint } from '../../endpoints/admin-stats'
|
|
4
|
-
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
-
|
|
6
|
-
const slugs = resolveSlugs({ users: 'users' })
|
|
7
|
-
const PW = 'test-pw-12345'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Dashboard real volume series (P2 #4): admin-stats returns a real per-day ticket
|
|
11
|
-
* count for the last 7 days (replacing the previous synthetic distribution).
|
|
12
|
-
*/
|
|
13
|
-
describe('dashboard real volume series (P2 #4)', () => {
|
|
14
|
-
it('returns 7 chronological daily buckets, today reflecting created tickets', async () => {
|
|
15
|
-
const payload = await buildTestPayload()
|
|
16
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'vol@example.com', password: PW, firstName: 'V', lastName: 'O', company: 'C' }, overrideAccess: true })
|
|
17
|
-
for (let i = 0; i < 3; i++) {
|
|
18
|
-
await payload.create({ collection: 'tickets', data: { subject: `v${i}`, client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const admin = await payload.create({ collection: 'users', data: { email: 'vol-admin@example.com', password: PW } as never, overrideAccess: true })
|
|
22
|
-
const ep = createAdminStatsEndpoint(slugs)
|
|
23
|
-
const req = { payload, user: { ...admin, collection: 'users' }, url: 'http://localhost/api/support/admin-stats', headers: new Headers({}) }
|
|
24
|
-
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
25
|
-
const json = await res.json()
|
|
26
|
-
|
|
27
|
-
expect(Array.isArray(json.volumeByDay)).toBe(true)
|
|
28
|
-
expect(json.volumeByDay).toHaveLength(7)
|
|
29
|
-
expect(json.volumeByDay[6].count).toBeGreaterThanOrEqual(3) // today (last bucket)
|
|
30
|
-
}, 60_000)
|
|
31
|
-
})
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
import { createProcessDigestsEndpoint } from '../../endpoints/process-digests'
|
|
4
|
-
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
-
|
|
6
|
-
const slugs = resolveSlugs({ users: 'users' })
|
|
7
|
-
const PW = 'test-pw-12345'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Digest notifications (P2 #5): clients on daily/weekly frequency have their reply
|
|
11
|
-
* notifications queued instead of emailed immediately, then drained as one recap
|
|
12
|
-
* by the process-digests cron. Immediate-frequency clients are never queued.
|
|
13
|
-
*/
|
|
14
|
-
describe('digest notifications (P2 #5)', () => {
|
|
15
|
-
it('queues reply notifications for daily clients and drains them via the cron', async () => {
|
|
16
|
-
const payload = await buildTestPayload()
|
|
17
|
-
process.env.CRON_SECRET = 'test-cron-secret'
|
|
18
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'digest@example.com', password: PW, firstName: 'D', lastName: 'G', company: 'C', notificationFrequency: 'daily' }, overrideAccess: true })
|
|
19
|
-
const t = await payload.create({ collection: 'tickets', data: { subject: 'digest', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
20
|
-
|
|
21
|
-
// Admin reply → queued (client is on daily cadence), not emailed.
|
|
22
|
-
await payload.create({ collection: 'ticket-messages', data: { ticket: t.id, body: 'admin reply', authorType: 'admin' }, overrideAccess: true })
|
|
23
|
-
|
|
24
|
-
const queued = await payload.find({ collection: 'notification-queue', where: { client: { equals: c.id } }, overrideAccess: true })
|
|
25
|
-
expect(queued.totalDocs).toBe(1)
|
|
26
|
-
|
|
27
|
-
// Daily digest cron drains the queue.
|
|
28
|
-
const ep = createProcessDigestsEndpoint(slugs)
|
|
29
|
-
const req = { payload, headers: new Headers({ 'x-cron-secret': 'test-cron-secret' }), json: async () => ({ frequency: 'daily' }) }
|
|
30
|
-
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
31
|
-
const json = await res.json()
|
|
32
|
-
expect(json.ok).toBe(true)
|
|
33
|
-
expect(json.items).toBeGreaterThanOrEqual(1)
|
|
34
|
-
|
|
35
|
-
const after = await payload.find({ collection: 'notification-queue', where: { client: { equals: c.id } }, overrideAccess: true })
|
|
36
|
-
expect(after.totalDocs).toBe(0)
|
|
37
|
-
}, 60_000)
|
|
38
|
-
|
|
39
|
-
it('does not queue for immediate-frequency clients', async () => {
|
|
40
|
-
const payload = await buildTestPayload()
|
|
41
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'imm@example.com', password: PW, firstName: 'I', lastName: 'M', company: 'C' }, overrideAccess: true })
|
|
42
|
-
const t = await payload.create({ collection: 'tickets', data: { subject: 'imm', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
43
|
-
await payload.create({ collection: 'ticket-messages', data: { ticket: t.id, body: 'admin reply', authorType: 'admin' }, overrideAccess: true })
|
|
44
|
-
|
|
45
|
-
const queued = await payload.find({ collection: 'notification-queue', where: { client: { equals: c.id } }, overrideAccess: true })
|
|
46
|
-
expect(queued.totalDocs).toBe(0)
|
|
47
|
-
}, 60_000)
|
|
48
|
-
})
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
import { createInvoiceEndpoint } from '../../endpoints/invoice'
|
|
4
|
-
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
-
|
|
6
|
-
const slugs = resolveSlugs({ users: 'users' })
|
|
7
|
-
const PW = 'test-pw-12345'
|
|
8
|
-
|
|
9
|
-
/** Native binary PDF invoice (roadmap): `format=pdf` returns a real PDF document. */
|
|
10
|
-
describe('binary PDF invoice (roadmap)', () => {
|
|
11
|
-
it('returns an application/pdf document with the %PDF header', async () => {
|
|
12
|
-
const payload = await buildTestPayload()
|
|
13
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'pdf@example.com', password: PW, firstName: 'P', lastName: 'D', company: 'ACME Corp' }, overrideAccess: true })
|
|
14
|
-
await payload.create({ collection: 'tickets', data: { subject: 'pdf-a', client: c.id, status: 'resolved', priority: 'normal', billable: true, billedAmount: 100 }, overrideAccess: true })
|
|
15
|
-
await payload.create({ collection: 'tickets', data: { subject: 'pdf-b', client: c.id, status: 'resolved', priority: 'normal', billable: true, billedAmount: 50 }, overrideAccess: true })
|
|
16
|
-
|
|
17
|
-
const admin = await payload.create({ collection: 'users', data: { email: 'pdf-admin@example.com', password: PW } as never, overrideAccess: true })
|
|
18
|
-
const from = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
|
|
19
|
-
const to = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10)
|
|
20
|
-
const ep = createInvoiceEndpoint(slugs)
|
|
21
|
-
const req = { payload, user: { ...admin, collection: 'users' }, url: `http://localhost/api/support/billing/invoice?from=${from}&to=${to}&format=pdf`, headers: new Headers({}) }
|
|
22
|
-
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
23
|
-
|
|
24
|
-
expect(res.status).toBe(200)
|
|
25
|
-
expect(res.headers.get('content-type')).toMatch(/application\/pdf/)
|
|
26
|
-
const buf = Buffer.from(await res.arrayBuffer())
|
|
27
|
-
expect(buf.subarray(0, 4).toString('latin1')).toBe('%PDF')
|
|
28
|
-
expect(buf.length).toBeGreaterThan(500)
|
|
29
|
-
}, 60_000)
|
|
30
|
-
})
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
|
|
4
|
-
const PW = 'test-pw-12345'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Validates the cross-client isolation fix (P0-4): a support-client must never
|
|
8
|
-
* read another client's ticket messages / activity, and field-level access
|
|
9
|
-
* (P0-7) must hide admin-only fields from the client's own doc.
|
|
10
|
-
*/
|
|
11
|
-
describe('cross-client isolation (P0-4 / P0-7)', () => {
|
|
12
|
-
it('a client cannot read another client\'s ticket messages', async () => {
|
|
13
|
-
const payload = await buildTestPayload()
|
|
14
|
-
|
|
15
|
-
const a = await payload.create({ collection: 'support-clients', data: { email: 'iso-a@example.com', password: PW, firstName: 'A', lastName: 'A', company: 'A' }, overrideAccess: true })
|
|
16
|
-
const b = await payload.create({ collection: 'support-clients', data: { email: 'iso-b@example.com', password: PW, firstName: 'B', lastName: 'B', company: 'B' }, overrideAccess: true })
|
|
17
|
-
|
|
18
|
-
const ta = await payload.create({ collection: 'tickets', data: { subject: 'A ticket', client: a.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
19
|
-
const tb = await payload.create({ collection: 'tickets', data: { subject: 'B ticket', client: b.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
20
|
-
|
|
21
|
-
await payload.create({ collection: 'ticket-messages', data: { ticket: ta.id, body: 'SECRET-A-MESSAGE', authorType: 'admin' }, overrideAccess: true })
|
|
22
|
-
await payload.create({ collection: 'ticket-messages', data: { ticket: tb.id, body: 'SECRET-B-MESSAGE', authorType: 'admin' }, overrideAccess: true })
|
|
23
|
-
|
|
24
|
-
// Read messages AS client A, with access control enforced.
|
|
25
|
-
const asA = await payload.find({
|
|
26
|
-
collection: 'ticket-messages',
|
|
27
|
-
overrideAccess: false,
|
|
28
|
-
user: { ...a, collection: 'support-clients' } as never,
|
|
29
|
-
limit: 200,
|
|
30
|
-
})
|
|
31
|
-
const bodies = asA.docs.map((m) => (m as { body?: string }).body)
|
|
32
|
-
|
|
33
|
-
expect(bodies).toContain('SECRET-A-MESSAGE')
|
|
34
|
-
expect(bodies).not.toContain('SECRET-B-MESSAGE') // ← the cross-client boundary
|
|
35
|
-
}, 60_000)
|
|
36
|
-
|
|
37
|
-
it('a client cannot read another client\'s activity log', async () => {
|
|
38
|
-
const payload = await buildTestPayload()
|
|
39
|
-
|
|
40
|
-
const a = await payload.create({ collection: 'support-clients', data: { email: 'act-a@example.com', password: PW, firstName: 'A', lastName: 'A', company: 'A' }, overrideAccess: true })
|
|
41
|
-
const b = await payload.create({ collection: 'support-clients', data: { email: 'act-b@example.com', password: PW, firstName: 'B', lastName: 'B', company: 'B' }, overrideAccess: true })
|
|
42
|
-
const ta = await payload.create({ collection: 'tickets', data: { subject: 'A', client: a.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
43
|
-
const tb = await payload.create({ collection: 'tickets', data: { subject: 'B', client: b.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
44
|
-
await payload.create({ collection: 'ticket-activity-log', data: { ticket: ta.id, action: 'status_changed', detail: 'ACT-A' }, overrideAccess: true })
|
|
45
|
-
await payload.create({ collection: 'ticket-activity-log', data: { ticket: tb.id, action: 'status_changed', detail: 'ACT-B' }, overrideAccess: true })
|
|
46
|
-
|
|
47
|
-
const asA = await payload.find({
|
|
48
|
-
collection: 'ticket-activity-log',
|
|
49
|
-
overrideAccess: false,
|
|
50
|
-
user: { ...a, collection: 'support-clients' } as never,
|
|
51
|
-
limit: 200,
|
|
52
|
-
})
|
|
53
|
-
const details = asA.docs.map((m) => (m as { detail?: string }).detail)
|
|
54
|
-
expect(details).toContain('ACT-A')
|
|
55
|
-
expect(details).not.toContain('ACT-B')
|
|
56
|
-
}, 60_000)
|
|
57
|
-
|
|
58
|
-
it('does not serialize admin-only opportunities/notes to the client (P0-7)', async () => {
|
|
59
|
-
const payload = await buildTestPayload()
|
|
60
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'fa@example.com', password: PW, firstName: 'F', lastName: 'A', company: 'C', opportunities: 'SECRET-DEAL', notes: 'INTERNAL-NOTE' }, overrideAccess: true })
|
|
61
|
-
|
|
62
|
-
const asClient = await payload.findByID({
|
|
63
|
-
collection: 'support-clients',
|
|
64
|
-
id: c.id,
|
|
65
|
-
overrideAccess: false,
|
|
66
|
-
user: { ...c, collection: 'support-clients' } as never,
|
|
67
|
-
})
|
|
68
|
-
expect((asClient as { opportunities?: string }).opportunities).toBeFalsy()
|
|
69
|
-
expect((asClient as { notes?: string }).notes).toBeFalsy()
|
|
70
|
-
|
|
71
|
-
// Admin (users collection) still sees them.
|
|
72
|
-
const admin = await payload.create({ collection: 'users', data: { email: 'admin-fa@example.com', password: PW } as never, overrideAccess: true })
|
|
73
|
-
const asAdmin = await payload.findByID({
|
|
74
|
-
collection: 'support-clients',
|
|
75
|
-
id: c.id,
|
|
76
|
-
overrideAccess: false,
|
|
77
|
-
user: { ...admin, collection: 'users' } as never,
|
|
78
|
-
})
|
|
79
|
-
expect((asAdmin as { opportunities?: string }).opportunities).toBe('SECRET-DEAL')
|
|
80
|
-
}, 60_000)
|
|
81
|
-
})
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
import { createInvoiceEndpoint } from '../../endpoints/invoice'
|
|
4
|
-
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
-
|
|
6
|
-
const slugs = resolveSlugs({ users: 'users' })
|
|
7
|
-
const PW = 'test-pw-12345'
|
|
8
|
-
|
|
9
|
-
/** @mentions (P2 #6): `@email` in a message resolves to agent IDs on `mentions`. */
|
|
10
|
-
describe('@mentions (P2 #6)', () => {
|
|
11
|
-
it('resolves @email mentions in a message body to agent IDs', async () => {
|
|
12
|
-
const payload = await buildTestPayload()
|
|
13
|
-
const agent = await payload.create({ collection: 'users', data: { email: 'agent@example.com', password: PW } as never, overrideAccess: true })
|
|
14
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'mc@example.com', password: PW, firstName: 'M', lastName: 'C', company: 'C' }, overrideAccess: true })
|
|
15
|
-
const t = await payload.create({ collection: 'tickets', data: { subject: 'mention', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
16
|
-
|
|
17
|
-
const m = await payload.create({
|
|
18
|
-
collection: 'ticket-messages',
|
|
19
|
-
data: { ticket: t.id, body: 'Hey @agent@example.com can you check this?', authorType: 'admin', isInternal: true },
|
|
20
|
-
overrideAccess: true,
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
const reread = await payload.findByID({ collection: 'ticket-messages', id: m.id, depth: 0, overrideAccess: true })
|
|
24
|
-
const mentions = (reread as { mentions?: Array<number | string> }).mentions || []
|
|
25
|
-
expect(mentions.map(String)).toContain(String(agent.id))
|
|
26
|
-
}, 60_000)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
/** Invoice (P2 #6): a print-ready HTML invoice with the correct total. */
|
|
30
|
-
describe('printable invoice (P2 #6)', () => {
|
|
31
|
-
it('renders an HTML invoice summing billable tickets', async () => {
|
|
32
|
-
const payload = await buildTestPayload()
|
|
33
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'inv@example.com', password: PW, firstName: 'I', lastName: 'V', company: 'ACME Corp' }, overrideAccess: true })
|
|
34
|
-
const t1 = await payload.create({ collection: 'tickets', data: { subject: 'inv-a', client: c.id, status: 'resolved', priority: 'normal', billable: true, billedAmount: 100 }, overrideAccess: true })
|
|
35
|
-
await payload.create({ collection: 'tickets', data: { subject: 'inv-b', client: c.id, status: 'resolved', priority: 'normal', billable: true, billedAmount: 50 }, overrideAccess: true })
|
|
36
|
-
|
|
37
|
-
const admin = await payload.create({ collection: 'users', data: { email: 'inv-admin@example.com', password: PW } as never, overrideAccess: true })
|
|
38
|
-
const from = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
|
|
39
|
-
const to = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10)
|
|
40
|
-
const ep = createInvoiceEndpoint(slugs)
|
|
41
|
-
const req = { payload, user: { ...admin, collection: 'users' }, url: `http://localhost/api/support/billing/invoice?from=${from}&to=${to}`, headers: new Headers({}) }
|
|
42
|
-
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
43
|
-
|
|
44
|
-
expect(res.headers.get('content-type')).toMatch(/text\/html/)
|
|
45
|
-
const body = await res.text()
|
|
46
|
-
expect(body).toContain('150.00 €') // 100 + 50
|
|
47
|
-
expect(body).toContain(String(t1.ticketNumber))
|
|
48
|
-
expect(body).toContain('ACME Corp')
|
|
49
|
-
}, 60_000)
|
|
50
|
-
})
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
import { createAdminStatsEndpoint } from '../../endpoints/admin-stats'
|
|
4
|
-
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
-
|
|
6
|
-
const slugs = resolveSlugs({ users: 'users' })
|
|
7
|
-
const PW = 'test-pw-12345'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* NPS (P2): satisfaction surveys can carry an NPS score (0-10), and admin-stats
|
|
11
|
-
* computes the Net Promoter Score = %promoters(9-10) − %detractors(0-6).
|
|
12
|
-
*/
|
|
13
|
-
describe('NPS (P2)', () => {
|
|
14
|
-
it('stores NPS scores and computes the NPS in admin-stats', async () => {
|
|
15
|
-
const payload = await buildTestPayload()
|
|
16
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'nps@example.com', password: PW, firstName: 'N', lastName: 'P', company: 'C' }, overrideAccess: true })
|
|
17
|
-
|
|
18
|
-
// 3 promoters (10,9,9), 1 passive (7), 2 detractors (3,5) → (3-2)/6 = 16.67% ≈ 17
|
|
19
|
-
for (const nps of [10, 9, 9, 7, 3, 5]) {
|
|
20
|
-
await payload.create({ collection: 'satisfaction-surveys', data: { source: 'ticket', client: c.id, nps }, overrideAccess: true })
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const admin = await payload.create({ collection: 'users', data: { email: 'nps-admin@example.com', password: PW } as never, overrideAccess: true })
|
|
24
|
-
const ep = createAdminStatsEndpoint(slugs)
|
|
25
|
-
const req = {
|
|
26
|
-
payload,
|
|
27
|
-
user: { ...admin, collection: 'users' },
|
|
28
|
-
url: 'http://localhost/api/support/admin-stats',
|
|
29
|
-
headers: new Headers({}),
|
|
30
|
-
}
|
|
31
|
-
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
32
|
-
const json = await res.json()
|
|
33
|
-
|
|
34
|
-
expect(json.npsCount).toBe(6)
|
|
35
|
-
expect(json.npsScore).toBe(17)
|
|
36
|
-
}, 60_000)
|
|
37
|
-
})
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
import { createAdminStatsEndpoint } from '../../endpoints/admin-stats'
|
|
4
|
-
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
-
|
|
6
|
-
const slugs = resolveSlugs({ users: 'users' })
|
|
7
|
-
const PW = 'test-pw-12345'
|
|
8
|
-
|
|
9
|
-
/** Per-team SLA policies & dashboards (roadmap). */
|
|
10
|
-
describe('per-team SLA & dashboards (roadmap)', () => {
|
|
11
|
-
it('uses the team-specific SLA policy over the global default', async () => {
|
|
12
|
-
const payload = await buildTestPayload()
|
|
13
|
-
const team = await payload.create({ collection: 'support-teams', data: { name: 'Team SLA' }, overrideAccess: true })
|
|
14
|
-
// Team policy: 2h resolution. Global default: 24h.
|
|
15
|
-
await payload.create({ collection: 'sla-policies', data: { name: 'Team', priority: 'normal', firstResponseTime: 30, resolutionTime: 120, businessHoursOnly: false, team: team.id }, overrideAccess: true })
|
|
16
|
-
await payload.create({ collection: 'sla-policies', data: { name: 'Default', priority: 'normal', firstResponseTime: 120, resolutionTime: 1440, businessHoursOnly: false, isDefault: true }, overrideAccess: true })
|
|
17
|
-
|
|
18
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'pts@example.com', password: PW, firstName: 'P', lastName: 'T', company: 'C' }, overrideAccess: true })
|
|
19
|
-
const t = await payload.create({ collection: 'tickets', data: { subject: 'team sla', client: c.id, status: 'open', priority: 'normal', team: team.id }, overrideAccess: true })
|
|
20
|
-
|
|
21
|
-
const reread = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
22
|
-
const created = new Date(reread.createdAt as string).getTime()
|
|
23
|
-
const diffMin = (new Date(reread.slaResolutionDue as string).getTime() - created) / 60000
|
|
24
|
-
expect(diffMin).toBeGreaterThan(110) // ~120 (team policy), NOT 1440 (default)
|
|
25
|
-
expect(diffMin).toBeLessThan(130)
|
|
26
|
-
}, 60_000)
|
|
27
|
-
|
|
28
|
-
it('scopes the dashboard counts to a team via ?teamId', async () => {
|
|
29
|
-
const payload = await buildTestPayload()
|
|
30
|
-
const teamC = await payload.create({ collection: 'support-teams', data: { name: 'Team C' }, overrideAccess: true })
|
|
31
|
-
const teamD = await payload.create({ collection: 'support-teams', data: { name: 'Team D' }, overrideAccess: true })
|
|
32
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'ptd@example.com', password: PW, firstName: 'P', lastName: 'D', company: 'C' }, overrideAccess: true })
|
|
33
|
-
await payload.create({ collection: 'tickets', data: { subject: 'c1', client: c.id, status: 'open', priority: 'normal', team: teamC.id }, overrideAccess: true })
|
|
34
|
-
await payload.create({ collection: 'tickets', data: { subject: 'c2', client: c.id, status: 'open', priority: 'normal', team: teamC.id }, overrideAccess: true })
|
|
35
|
-
await payload.create({ collection: 'tickets', data: { subject: 'd1', client: c.id, status: 'open', priority: 'normal', team: teamD.id }, overrideAccess: true })
|
|
36
|
-
|
|
37
|
-
const admin = await payload.create({ collection: 'users', data: { email: 'pt-admin@example.com', password: PW } as never, overrideAccess: true })
|
|
38
|
-
const ep = createAdminStatsEndpoint(slugs)
|
|
39
|
-
const req = { payload, user: { ...admin, collection: 'users' }, url: `http://localhost/api/support/admin-stats?teamId=${teamC.id}`, headers: new Headers({}) }
|
|
40
|
-
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
41
|
-
const json = await res.json()
|
|
42
|
-
expect(json.total).toBe(2) // only Team C's tickets
|
|
43
|
-
}, 60_000)
|
|
44
|
-
})
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
import { createVapidKeyEndpoint, createPushSubscribeEndpoint } from '../../endpoints/push'
|
|
4
|
-
import { sendPushToUser } from '../../utils/push'
|
|
5
|
-
import { resolveSlugs } from '../../utils/slugs'
|
|
6
|
-
|
|
7
|
-
const slugs = resolveSlugs({ users: 'users' })
|
|
8
|
-
const PW = 'test-pw-12345'
|
|
9
|
-
|
|
10
|
-
/** Native push / browser notifications (roadmap). */
|
|
11
|
-
describe('push notifications (roadmap)', () => {
|
|
12
|
-
it('subscribe stores a subscription row for the agent', async () => {
|
|
13
|
-
const payload = await buildTestPayload()
|
|
14
|
-
const admin = await payload.create({ collection: 'users', data: { email: 'push-admin@example.com', password: PW } as never, overrideAccess: true })
|
|
15
|
-
const ep = createPushSubscribeEndpoint(slugs)
|
|
16
|
-
const req = {
|
|
17
|
-
payload,
|
|
18
|
-
user: { ...admin, collection: 'users' },
|
|
19
|
-
headers: new Headers({ 'user-agent': 'vitest' }),
|
|
20
|
-
json: async () => ({ subscription: { endpoint: 'https://push.example.com/abc', keys: { p256dh: 'pk', auth: 'ak' } } }),
|
|
21
|
-
}
|
|
22
|
-
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
23
|
-
expect(res.status).toBe(200)
|
|
24
|
-
|
|
25
|
-
const stored = await payload.find({ collection: 'push-subscriptions', where: { endpoint: { equals: 'https://push.example.com/abc' } }, overrideAccess: true })
|
|
26
|
-
expect(stored.docs.length).toBe(1)
|
|
27
|
-
expect((stored.docs[0] as { auth?: string }).auth).toBe('ak')
|
|
28
|
-
}, 60_000)
|
|
29
|
-
|
|
30
|
-
it('subscribe is idempotent on endpoint (upsert, no duplicate)', async () => {
|
|
31
|
-
const payload = await buildTestPayload()
|
|
32
|
-
const admin = await payload.create({ collection: 'users', data: { email: 'push-admin2@example.com', password: PW } as never, overrideAccess: true })
|
|
33
|
-
const ep = createPushSubscribeEndpoint(slugs)
|
|
34
|
-
const mk = (auth: string) => ({
|
|
35
|
-
payload,
|
|
36
|
-
user: { ...admin, collection: 'users' },
|
|
37
|
-
headers: new Headers({}),
|
|
38
|
-
json: async () => ({ subscription: { endpoint: 'https://push.example.com/dup', keys: { p256dh: 'pk', auth } } }),
|
|
39
|
-
})
|
|
40
|
-
await (ep.handler as (r: unknown) => Promise<Response>)(mk('first'))
|
|
41
|
-
await (ep.handler as (r: unknown) => Promise<Response>)(mk('second'))
|
|
42
|
-
const stored = await payload.find({ collection: 'push-subscriptions', where: { endpoint: { equals: 'https://push.example.com/dup' } }, overrideAccess: true })
|
|
43
|
-
expect(stored.docs.length).toBe(1)
|
|
44
|
-
expect((stored.docs[0] as { auth?: string }).auth).toBe('second')
|
|
45
|
-
}, 60_000)
|
|
46
|
-
|
|
47
|
-
it('subscribe rejects an anonymous request', async () => {
|
|
48
|
-
const payload = await buildTestPayload()
|
|
49
|
-
const ep = createPushSubscribeEndpoint(slugs)
|
|
50
|
-
const req = {
|
|
51
|
-
payload,
|
|
52
|
-
user: null,
|
|
53
|
-
headers: new Headers({}),
|
|
54
|
-
json: async () => ({ subscription: { endpoint: 'https://push.example.com/x', keys: { p256dh: 'pk', auth: 'ak' } } }),
|
|
55
|
-
}
|
|
56
|
-
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
57
|
-
expect(res.status).toBe(401)
|
|
58
|
-
}, 60_000)
|
|
59
|
-
|
|
60
|
-
it('vapid-public-key returns null when VAPID is not configured', async () => {
|
|
61
|
-
delete process.env.VAPID_PUBLIC_KEY
|
|
62
|
-
const ep = createVapidKeyEndpoint()
|
|
63
|
-
const res = await (ep.handler as (r: unknown) => Promise<Response>)({})
|
|
64
|
-
const json = await res.json()
|
|
65
|
-
expect(json.publicKey).toBeNull()
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('sendPushToUser no-ops without VAPID keys', async () => {
|
|
69
|
-
delete process.env.VAPID_PUBLIC_KEY
|
|
70
|
-
delete process.env.VAPID_PRIVATE_KEY
|
|
71
|
-
const payload = await buildTestPayload()
|
|
72
|
-
const result = await sendPushToUser(payload, slugs, 1, { title: 'Hi', body: 'There' })
|
|
73
|
-
expect(result.sent).toBe(0)
|
|
74
|
-
}, 60_000)
|
|
75
|
-
})
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
|
|
4
|
-
const PW = 'test-pw-12345'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Validates that the bodyHtml sanitization (P0-1) is actually wired into the
|
|
8
|
-
* TicketMessages beforeChange hook — i.e. stored HTML is stripped of script /
|
|
9
|
-
* event handlers before it can be persisted and later rendered.
|
|
10
|
-
*/
|
|
11
|
-
describe('stored-XSS sanitization on write (P0-1)', () => {
|
|
12
|
-
it('strips <script> and inline handlers from bodyHtml at persistence', async () => {
|
|
13
|
-
const payload = await buildTestPayload()
|
|
14
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'xss@example.com', password: PW, firstName: 'X', lastName: 'S', company: 'C' }, overrideAccess: true })
|
|
15
|
-
const t = await payload.create({ collection: 'tickets', data: { subject: 'xss', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
16
|
-
|
|
17
|
-
const m = await payload.create({
|
|
18
|
-
collection: 'ticket-messages',
|
|
19
|
-
data: {
|
|
20
|
-
ticket: t.id,
|
|
21
|
-
body: 'hello',
|
|
22
|
-
bodyHtml: '<img src=x onerror=alert(1)><script>alert(2)</script><a href="javascript:alert(3)">x</a><b>kept</b>',
|
|
23
|
-
authorType: 'client',
|
|
24
|
-
authorClient: c.id,
|
|
25
|
-
},
|
|
26
|
-
overrideAccess: true,
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
const stored = (m as { bodyHtml?: string }).bodyHtml || ''
|
|
30
|
-
expect(stored).not.toMatch(/onerror/i)
|
|
31
|
-
expect(stored).not.toMatch(/<script/i)
|
|
32
|
-
expect(stored).not.toMatch(/javascript:/i)
|
|
33
|
-
expect(stored).toContain('<b>kept</b>') // legitimate formatting preserved
|
|
34
|
-
}, 60_000)
|
|
35
|
-
})
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
|
|
4
|
-
const PW = 'test-pw-12345'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* SLA pause-on-hold (P2): time spent waiting on the client must not count against
|
|
8
|
-
* the resolution SLA — the resolution deadline is pushed forward by the paused span.
|
|
9
|
-
*/
|
|
10
|
-
describe('SLA pause-on-hold (P2)', () => {
|
|
11
|
-
it('extends the resolution deadline by the time spent in waiting_client', async () => {
|
|
12
|
-
const payload = await buildTestPayload()
|
|
13
|
-
await payload.create({ collection: 'sla-policies', data: { name: 'P', priority: 'normal', firstResponseTime: 60, resolutionTime: 240, businessHoursOnly: false, isDefault: true }, overrideAccess: true })
|
|
14
|
-
const c = await payload.create({ collection: 'support-clients', data: { email: 'pause@example.com', password: PW, firstName: 'P', lastName: 'H', company: 'C' }, overrideAccess: true })
|
|
15
|
-
const t = await payload.create({ collection: 'tickets', data: { subject: 'pause', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
16
|
-
|
|
17
|
-
const before = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
18
|
-
const dueBefore = new Date(before.slaResolutionDue as string).getTime()
|
|
19
|
-
expect(dueBefore).toBeGreaterThan(0)
|
|
20
|
-
|
|
21
|
-
// Enter hold → the clock should pause.
|
|
22
|
-
await payload.update({ collection: 'tickets', id: t.id, data: { status: 'waiting_client' }, overrideAccess: true })
|
|
23
|
-
const mid = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
24
|
-
expect(mid.slaPausedAt).toBeTruthy()
|
|
25
|
-
|
|
26
|
-
// Simulate ~2h of waiting by backdating the pause marker (status unchanged → hook no-op).
|
|
27
|
-
const twoHoursAgo = new Date(Date.now() - 2 * 3_600_000).toISOString()
|
|
28
|
-
await payload.update({ collection: 'tickets', id: t.id, data: { slaPausedAt: twoHoursAgo }, overrideAccess: true })
|
|
29
|
-
|
|
30
|
-
// Leave hold → deadline extended, pause cleared.
|
|
31
|
-
await payload.update({ collection: 'tickets', id: t.id, data: { status: 'open' }, overrideAccess: true })
|
|
32
|
-
const after = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
33
|
-
|
|
34
|
-
expect(after.slaPausedAt).toBeFalsy()
|
|
35
|
-
const extensionMs = new Date(after.slaResolutionDue as string).getTime() - dueBefore
|
|
36
|
-
expect(extensionMs).toBeGreaterThan(1.9 * 3_600_000)
|
|
37
|
-
expect(extensionMs).toBeLessThan(2.2 * 3_600_000)
|
|
38
|
-
}, 60_000)
|
|
39
|
-
})
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
-
|
|
4
|
-
describe('integration harness', () => {
|
|
5
|
-
it('boots Payload with the support plugin and round-trips a ticket', async () => {
|
|
6
|
-
const payload = await buildTestPayload()
|
|
7
|
-
|
|
8
|
-
const client = await payload.create({
|
|
9
|
-
collection: 'support-clients',
|
|
10
|
-
data: { email: 'smoke@example.com', password: 'pw-smoke-123', firstName: 'Smoke', lastName: 'Test', company: 'ACME' },
|
|
11
|
-
overrideAccess: true,
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
const ticket = await payload.create({
|
|
15
|
-
collection: 'tickets',
|
|
16
|
-
data: { subject: 'Smoke ticket', client: client.id, status: 'open', priority: 'normal' },
|
|
17
|
-
overrideAccess: true,
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
expect(ticket.id).toBeTruthy()
|
|
21
|
-
expect(ticket.ticketNumber).toMatch(/^TK-/)
|
|
22
|
-
|
|
23
|
-
const reread = await payload.findByID({ collection: 'tickets', id: ticket.id, overrideAccess: true })
|
|
24
|
-
expect(reread.subject).toBe('Smoke ticket')
|
|
25
|
-
}, 60_000)
|
|
26
|
-
})
|