@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consilioweb/payload-support",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -52,7 +52,9 @@
|
|
|
52
52
|
"scripts": {
|
|
53
53
|
"build": "tsup",
|
|
54
54
|
"typecheck": "tsc --noEmit",
|
|
55
|
-
"prepublishOnly": "npm run build"
|
|
55
|
+
"prepublishOnly": "npm run build",
|
|
56
|
+
"test": "vitest run",
|
|
57
|
+
"test:watch": "vitest"
|
|
56
58
|
},
|
|
57
59
|
"keywords": [
|
|
58
60
|
"payload",
|
|
@@ -93,11 +95,11 @@
|
|
|
93
95
|
"@payloadcms/next": "^3.0.0",
|
|
94
96
|
"@payloadcms/translations": "^3.0.0",
|
|
95
97
|
"@payloadcms/ui": "^3.0.0",
|
|
98
|
+
"lucide-react": ">=0.300.0",
|
|
96
99
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
97
100
|
"payload": "^3.0.0",
|
|
98
101
|
"react": "^18.0.0 || ^19.0.0",
|
|
99
|
-
"react-dom": "^18.0.0 || ^19.0.0"
|
|
100
|
-
"lucide-react": ">=0.300.0"
|
|
102
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
101
103
|
},
|
|
102
104
|
"peerDependenciesMeta": {
|
|
103
105
|
"@payloadcms/ui": {
|
|
@@ -114,26 +116,32 @@
|
|
|
114
116
|
},
|
|
115
117
|
"next": {
|
|
116
118
|
"optional": true
|
|
117
|
-
},
|
|
118
|
-
"react": {
|
|
119
|
-
"optional": true
|
|
120
|
-
},
|
|
121
|
-
"react-dom": {
|
|
122
|
-
"optional": true
|
|
123
119
|
}
|
|
124
120
|
},
|
|
125
121
|
"engines": {
|
|
126
122
|
"node": ">=18"
|
|
127
123
|
},
|
|
128
124
|
"devDependencies": {
|
|
129
|
-
"@payloadcms/
|
|
130
|
-
"@payloadcms/
|
|
131
|
-
"@payloadcms/
|
|
125
|
+
"@payloadcms/db-sqlite": "^3.85.1",
|
|
126
|
+
"@payloadcms/graphql": "^3.85.1",
|
|
127
|
+
"@payloadcms/next": "^3.85.1",
|
|
128
|
+
"@payloadcms/richtext-lexical": "^3.85.1",
|
|
129
|
+
"@payloadcms/translations": "^3.85.1",
|
|
130
|
+
"@payloadcms/ui": "^3.85.1",
|
|
131
|
+
"@types/pdfkit": "^0.17.6",
|
|
132
132
|
"@types/react": "^19.0.0",
|
|
133
|
+
"@types/sanitize-html": "^2.16.1",
|
|
133
134
|
"esbuild-sass-plugin": "^3.7.0",
|
|
134
135
|
"next": "^15.5.14",
|
|
135
|
-
"payload": "^3.
|
|
136
|
+
"payload": "^3.85.1",
|
|
137
|
+
"react": "^19.2.7",
|
|
138
|
+
"react-dom": "^19.2.7",
|
|
136
139
|
"tsup": "^8.0.0",
|
|
137
|
-
"typescript": "^5.5.0"
|
|
140
|
+
"typescript": "^5.5.0",
|
|
141
|
+
"vitest": "^4.1.9"
|
|
142
|
+
},
|
|
143
|
+
"dependencies": {
|
|
144
|
+
"pdfkit": "^0.19.1",
|
|
145
|
+
"sanitize-html": "^2.17.5"
|
|
138
146
|
}
|
|
139
147
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { generateTrackingToken } from '../endpoints/track-open'
|
|
3
|
+
|
|
4
|
+
describe('generateTrackingToken — determinism', () => {
|
|
5
|
+
it('returns the same token for identical inputs (deterministic)', () => {
|
|
6
|
+
const a = generateTrackingToken('42', '7', 'mysecret')
|
|
7
|
+
const b = generateTrackingToken('42', '7', 'mysecret')
|
|
8
|
+
expect(a).toBe(b)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('always returns exactly 16 characters', () => {
|
|
12
|
+
const token = generateTrackingToken('1', '2', 'secret')
|
|
13
|
+
expect(token).toHaveLength(16)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns only hex characters (0-9 a-f)', () => {
|
|
17
|
+
const token = generateTrackingToken('100', '200', 'anothersecret')
|
|
18
|
+
expect(token).toMatch(/^[0-9a-f]{16}$/)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('generateTrackingToken — sensitivity to inputs', () => {
|
|
23
|
+
it('produces different tokens for different ticketIds', () => {
|
|
24
|
+
const t1 = generateTrackingToken('1', '5', 'secret')
|
|
25
|
+
const t2 = generateTrackingToken('2', '5', 'secret')
|
|
26
|
+
expect(t1).not.toBe(t2)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('produces different tokens for different messageIds', () => {
|
|
30
|
+
const t1 = generateTrackingToken('10', '1', 'secret')
|
|
31
|
+
const t2 = generateTrackingToken('10', '2', 'secret')
|
|
32
|
+
expect(t1).not.toBe(t2)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('produces different tokens for different secrets', () => {
|
|
36
|
+
const t1 = generateTrackingToken('42', '7', 'secret-A')
|
|
37
|
+
const t2 = generateTrackingToken('42', '7', 'secret-B')
|
|
38
|
+
expect(t1).not.toBe(t2)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('is sensitive to ticketId/messageId order (no commutative collision)', () => {
|
|
42
|
+
const t1 = generateTrackingToken('1', '23', 'secret')
|
|
43
|
+
const t2 = generateTrackingToken('12', '3', 'secret')
|
|
44
|
+
// '1:23' vs '12:3' — different strings → different HMACs
|
|
45
|
+
expect(t1).not.toBe(t2)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('treats ticket "0" and messageId "0" differently from "00"/"00"', () => {
|
|
49
|
+
const t1 = generateTrackingToken('0', '0', 'secret')
|
|
50
|
+
const t2 = generateTrackingToken('00', '00', 'secret')
|
|
51
|
+
expect(t1).not.toBe(t2)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
import { applyAgentDecision } from '../../utils/aiAgent'
|
|
4
|
+
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
+
|
|
6
|
+
const slugs = resolveSlugs({ users: 'users' })
|
|
7
|
+
const PW = 'test-pw-12345'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Autonomous AI agent (P2 #8). The LLM decision is runtime-only; here we test the
|
|
11
|
+
* DETERMINISTIC execution of a decision: a `reply` posts a public AI answer, an
|
|
12
|
+
* `escalate` flags the ticket and records an internal note.
|
|
13
|
+
*/
|
|
14
|
+
describe('AI agent — decision execution (P2 #8)', () => {
|
|
15
|
+
it('reply decision posts a public answer attributed to the AI assistant', async () => {
|
|
16
|
+
const payload = await buildTestPayload()
|
|
17
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'aiagent@example.com', password: PW, firstName: 'A', lastName: 'I', company: 'C' }, overrideAccess: true })
|
|
18
|
+
const t = await payload.create({ collection: 'tickets', data: { subject: 'reset password', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
19
|
+
|
|
20
|
+
const res = await applyAgentDecision(payload, slugs, t.id, { action: 'reply', reply: 'Cliquez sur « Mot de passe oublié » sur la page de connexion.', confidence: 0.95 })
|
|
21
|
+
expect(res.acted).toBe('reply')
|
|
22
|
+
expect(res.messageId).toBeTruthy()
|
|
23
|
+
|
|
24
|
+
const msgs = await payload.find({ collection: 'ticket-messages', where: { ticket: { equals: t.id } }, overrideAccess: true })
|
|
25
|
+
const aiMsg = msgs.docs.find((m) => (m as { fromAlias?: string }).fromAlias === 'Assistant IA') as { body?: string; isInternal?: boolean } | undefined
|
|
26
|
+
expect(aiMsg).toBeTruthy()
|
|
27
|
+
expect(aiMsg?.body).toContain('Mot de passe oublié')
|
|
28
|
+
expect(aiMsg?.isInternal).toBe(false)
|
|
29
|
+
}, 60_000)
|
|
30
|
+
|
|
31
|
+
it('escalate decision flags the ticket and adds an internal note', async () => {
|
|
32
|
+
const payload = await buildTestPayload()
|
|
33
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'aiagent2@example.com', password: PW, firstName: 'A', lastName: 'I', company: 'C' }, overrideAccess: true })
|
|
34
|
+
const t = await payload.create({ collection: 'tickets', data: { subject: 'legal question', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
35
|
+
|
|
36
|
+
const res = await applyAgentDecision(payload, slugs, t.id, { action: 'escalate', reason: 'Question juridique', confidence: 0.2 })
|
|
37
|
+
expect(res.acted).toBe('escalate')
|
|
38
|
+
|
|
39
|
+
const reread = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
40
|
+
expect(reread.status).toBe('escalated')
|
|
41
|
+
|
|
42
|
+
const msgs = await payload.find({ collection: 'ticket-messages', where: { and: [{ ticket: { equals: t.id } }, { isInternal: { equals: true } }] }, overrideAccess: true })
|
|
43
|
+
const note = msgs.docs.find((m) => ((m as { body?: string }).body || '').includes('[Agent IA]'))
|
|
44
|
+
expect(note).toBeTruthy()
|
|
45
|
+
}, 60_000)
|
|
46
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
|
|
4
|
+
const PW = 'test-pw-12345'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validates the server-side 2FA enforcement (P0-2): the beforeLogin hook must
|
|
8
|
+
* block login when 2FA is enabled without a fresh verification marker, allow it
|
|
9
|
+
* with one, and consume the marker (single-use) so it can't be replayed.
|
|
10
|
+
*/
|
|
11
|
+
describe('2FA enforcement (P0-2)', () => {
|
|
12
|
+
it('blocks login without a fresh marker, allows with one, then consumes it', async () => {
|
|
13
|
+
const payload = await buildTestPayload()
|
|
14
|
+
|
|
15
|
+
const c = await payload.create({
|
|
16
|
+
collection: 'support-clients',
|
|
17
|
+
data: { email: '2fa@example.com', password: PW, firstName: 'T', lastName: 'F', company: 'C', twoFactorEnabled: true },
|
|
18
|
+
overrideAccess: true,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// No marker → blocked even with the correct password.
|
|
22
|
+
await expect(
|
|
23
|
+
payload.login({ collection: 'support-clients', data: { email: '2fa@example.com', password: PW } }),
|
|
24
|
+
).rejects.toThrow(/2FA_REQUIRED/)
|
|
25
|
+
|
|
26
|
+
// Fresh marker → login succeeds.
|
|
27
|
+
await payload.update({ collection: 'support-clients', id: c.id, data: { twoFactorVerifiedAt: new Date().toISOString() }, overrideAccess: true })
|
|
28
|
+
const ok = await payload.login({ collection: 'support-clients', data: { email: '2fa@example.com', password: PW } })
|
|
29
|
+
expect(ok.token).toBeTruthy()
|
|
30
|
+
|
|
31
|
+
// Marker is single-use → the next login is blocked again.
|
|
32
|
+
await expect(
|
|
33
|
+
payload.login({ collection: 'support-clients', data: { email: '2fa@example.com', password: PW } }),
|
|
34
|
+
).rejects.toThrow(/2FA_REQUIRED/)
|
|
35
|
+
}, 60_000)
|
|
36
|
+
|
|
37
|
+
it('lets a non-2FA client log in normally', async () => {
|
|
38
|
+
const payload = await buildTestPayload()
|
|
39
|
+
await payload.create({ collection: 'support-clients', data: { email: 'no2fa@example.com', password: PW, firstName: 'N', lastName: 'O', company: 'C' }, overrideAccess: true })
|
|
40
|
+
const ok = await payload.login({ collection: 'support-clients', data: { email: 'no2fa@example.com', password: PW } })
|
|
41
|
+
expect(ok.token).toBeTruthy()
|
|
42
|
+
}, 60_000)
|
|
43
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
|
|
4
|
+
const PW = 'test-pw-12345'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Automation rules engine (P2 #7): when a ticket event fires and the rule's
|
|
8
|
+
* conditions match, its actions are applied — in a single guarded update (no loop).
|
|
9
|
+
*/
|
|
10
|
+
describe('automation rules engine (P2 #7)', () => {
|
|
11
|
+
it('applies actions when conditions match, leaves non-matching tickets untouched', async () => {
|
|
12
|
+
const payload = await buildTestPayload()
|
|
13
|
+
await payload.create({
|
|
14
|
+
collection: 'automation-rules',
|
|
15
|
+
data: {
|
|
16
|
+
name: 'Bugs are urgent',
|
|
17
|
+
enabled: true,
|
|
18
|
+
event: 'ticket_created',
|
|
19
|
+
matchType: 'all',
|
|
20
|
+
order: 0,
|
|
21
|
+
conditions: [{ field: 'category', operator: 'equals', value: 'bug' }],
|
|
22
|
+
actions: [{ type: 'set_priority', value: 'urgent' }],
|
|
23
|
+
},
|
|
24
|
+
overrideAccess: true,
|
|
25
|
+
})
|
|
26
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'auto@example.com', password: PW, firstName: 'A', lastName: 'U', company: 'C' }, overrideAccess: true })
|
|
27
|
+
|
|
28
|
+
// Matching ticket → priority upgraded to urgent by the rule.
|
|
29
|
+
const bug = await payload.create({ collection: 'tickets', data: { subject: 'crash', client: c.id, status: 'open', priority: 'normal', category: 'bug' }, overrideAccess: true })
|
|
30
|
+
const bugRead = await payload.findByID({ collection: 'tickets', id: bug.id, overrideAccess: true })
|
|
31
|
+
expect(bugRead.priority).toBe('urgent')
|
|
32
|
+
|
|
33
|
+
// Non-matching ticket → unchanged.
|
|
34
|
+
const q = await payload.create({ collection: 'tickets', data: { subject: 'how to', client: c.id, status: 'open', priority: 'normal', category: 'question' }, overrideAccess: true })
|
|
35
|
+
const qRead = await payload.findByID({ collection: 'tickets', id: q.id, overrideAccess: true })
|
|
36
|
+
expect(qRead.priority).toBe('normal')
|
|
37
|
+
}, 60_000)
|
|
38
|
+
|
|
39
|
+
it('supports multiple actions and the "any" match type', async () => {
|
|
40
|
+
const payload = await buildTestPayload()
|
|
41
|
+
await payload.create({
|
|
42
|
+
collection: 'automation-rules',
|
|
43
|
+
data: {
|
|
44
|
+
name: 'Triage hosting/feature',
|
|
45
|
+
enabled: true,
|
|
46
|
+
event: 'ticket_created',
|
|
47
|
+
matchType: 'any',
|
|
48
|
+
order: 0,
|
|
49
|
+
conditions: [
|
|
50
|
+
{ field: 'category', operator: 'equals', value: 'hosting' },
|
|
51
|
+
{ field: 'subject', operator: 'contains', value: 'urgent' },
|
|
52
|
+
],
|
|
53
|
+
actions: [
|
|
54
|
+
{ type: 'set_status', value: 'escalated' },
|
|
55
|
+
{ type: 'set_priority', value: 'high' },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
overrideAccess: true,
|
|
59
|
+
})
|
|
60
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'auto2@example.com', password: PW, firstName: 'A', lastName: 'U', company: 'C' }, overrideAccess: true })
|
|
61
|
+
|
|
62
|
+
// Matches via subject contains "urgent" (any) → both actions applied.
|
|
63
|
+
const t = await payload.create({ collection: 'tickets', data: { subject: 'this is URGENT please', client: c.id, status: 'open', priority: 'low', category: 'question' }, overrideAccess: true })
|
|
64
|
+
const r = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
65
|
+
expect(r.status).toBe('escalated')
|
|
66
|
+
expect(r.priority).toBe('high')
|
|
67
|
+
}, 60_000)
|
|
68
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { buildConfig, getPayload, type Payload, type CollectionConfig, type SanitizedConfig } from 'payload'
|
|
2
|
+
import { sqliteAdapter } from '@payloadcms/db-sqlite'
|
|
3
|
+
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
|
4
|
+
import sharp from 'sharp'
|
|
5
|
+
import { supportPlugin } from '../../plugin'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Boots a real Payload instance backed by an in-memory SQLite database, with the
|
|
9
|
+
* support plugin applied. Used by integration tests to exercise access control,
|
|
10
|
+
* hooks and endpoints for real (the unit tests only cover pure functions).
|
|
11
|
+
*
|
|
12
|
+
* `skipViews: true` avoids loading the admin view components (which resolve to the
|
|
13
|
+
* built dist subpath and need bundling) — integration tests use the Local API only.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Minimal host collections the plugin relies on.
|
|
17
|
+
const Users: CollectionConfig = {
|
|
18
|
+
slug: 'users',
|
|
19
|
+
auth: true,
|
|
20
|
+
fields: [{ name: 'name', type: 'text' }],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const Media: CollectionConfig = {
|
|
24
|
+
slug: 'media',
|
|
25
|
+
upload: { staticDir: '/tmp/payload-support-test-media' },
|
|
26
|
+
fields: [{ name: 'alt', type: 'text' }],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let cached: Promise<Payload> | null = null
|
|
30
|
+
|
|
31
|
+
export function buildTestPayload(): Promise<Payload> {
|
|
32
|
+
if (cached) return cached
|
|
33
|
+
process.env.PAYLOAD_SECRET = process.env.PAYLOAD_SECRET || 'test-secret-payload-support-integration'
|
|
34
|
+
cached = (async () => {
|
|
35
|
+
const config: SanitizedConfig = await buildConfig({
|
|
36
|
+
secret: process.env.PAYLOAD_SECRET as string,
|
|
37
|
+
db: sqliteAdapter({ client: { url: ':memory:' } }),
|
|
38
|
+
editor: lexicalEditor(),
|
|
39
|
+
collections: [Users, Media],
|
|
40
|
+
plugins: [
|
|
41
|
+
supportPlugin({
|
|
42
|
+
features: { chat: false, pendingEmails: false },
|
|
43
|
+
skipViews: true,
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
sharp,
|
|
47
|
+
telemetry: false,
|
|
48
|
+
})
|
|
49
|
+
return getPayload({ config })
|
|
50
|
+
})()
|
|
51
|
+
return cached
|
|
52
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
import { createChannelsWebhookEndpoint } from '../../endpoints/channels'
|
|
4
|
+
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
+
|
|
6
|
+
const slugs = resolveSlugs({ users: 'users' })
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Social channels (roadmap): the inbound webhook turns a normalized WhatsApp /
|
|
10
|
+
* Messenger message into a ticket (creating the channel client on first contact)
|
|
11
|
+
* and appends follow-ups to the same open ticket.
|
|
12
|
+
*/
|
|
13
|
+
describe('social channels inbound webhook (roadmap)', () => {
|
|
14
|
+
it('creates a ticket from a WhatsApp message, then appends follow-ups', async () => {
|
|
15
|
+
const payload = await buildTestPayload()
|
|
16
|
+
process.env.CHANNELS_WEBHOOK_SECRET = 'test-channel-secret'
|
|
17
|
+
const ep = createChannelsWebhookEndpoint(slugs)
|
|
18
|
+
const post = (payloadBody: unknown) =>
|
|
19
|
+
(ep.handler as (r: unknown) => Promise<Response>)({
|
|
20
|
+
payload,
|
|
21
|
+
headers: new Headers({ 'x-channel-secret': 'test-channel-secret' }),
|
|
22
|
+
json: async () => payloadBody,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const res1 = await post({ provider: 'whatsapp', from: '33612345678', name: 'Jean', text: "Bonjour, j'ai besoin d'aide" })
|
|
26
|
+
const j1 = await res1.json()
|
|
27
|
+
expect(j1.ok).toBe(true)
|
|
28
|
+
expect(j1.createdTicket).toBe(true)
|
|
29
|
+
|
|
30
|
+
const ticket = await payload.findByID({ collection: 'tickets', id: j1.ticketId, overrideAccess: true })
|
|
31
|
+
expect(ticket.source).toBe('whatsapp')
|
|
32
|
+
|
|
33
|
+
const msgs1 = await payload.find({ collection: 'ticket-messages', where: { ticket: { equals: j1.ticketId } }, overrideAccess: true })
|
|
34
|
+
expect(msgs1.docs.some((m) => ((m as { body?: string }).body || '').includes("besoin d'aide"))).toBe(true)
|
|
35
|
+
|
|
36
|
+
// Follow-up from the same sender → same ticket, no new ticket.
|
|
37
|
+
const res2 = await post({ provider: 'whatsapp', from: '33612345678', text: 'Toujours bloqué' })
|
|
38
|
+
const j2 = await res2.json()
|
|
39
|
+
expect(j2.createdTicket).toBe(false)
|
|
40
|
+
expect(String(j2.ticketId)).toBe(String(j1.ticketId))
|
|
41
|
+
}, 60_000)
|
|
42
|
+
|
|
43
|
+
it('rejects without the channel secret', async () => {
|
|
44
|
+
const payload = await buildTestPayload()
|
|
45
|
+
process.env.CHANNELS_WEBHOOK_SECRET = 'test-channel-secret'
|
|
46
|
+
const ep = createChannelsWebhookEndpoint(slugs)
|
|
47
|
+
const res = await (ep.handler as (r: unknown) => Promise<Response>)({
|
|
48
|
+
payload, headers: new Headers({}), json: async () => ({ provider: 'whatsapp', from: 'x', text: 'y' }),
|
|
49
|
+
})
|
|
50
|
+
expect(res.status).toBe(401)
|
|
51
|
+
}, 60_000)
|
|
52
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
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('moves status to waiting_client on admin reply and back to open on client reply', async () => {
|
|
27
|
+
const payload = await buildTestPayload()
|
|
28
|
+
const c = await makeClient(payload, 'status@example.com')
|
|
29
|
+
const t = await payload.create({ collection: 'tickets', data: { subject: 'flow', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
30
|
+
|
|
31
|
+
await payload.create({ collection: 'ticket-messages', data: { ticket: t.id, body: 'admin reply', authorType: 'admin' }, overrideAccess: true })
|
|
32
|
+
let reread = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
33
|
+
expect(reread.status).toBe('waiting_client')
|
|
34
|
+
|
|
35
|
+
await payload.create({ collection: 'ticket-messages', data: { ticket: t.id, body: 'client reply', authorType: 'client', authorClient: c.id }, overrideAccess: true })
|
|
36
|
+
reread = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
37
|
+
expect(reread.status).toBe('open')
|
|
38
|
+
}, 60_000)
|
|
39
|
+
|
|
40
|
+
it('assigns SLA deadlines on create from the default policy', async () => {
|
|
41
|
+
const payload = await buildTestPayload()
|
|
42
|
+
await payload.create({
|
|
43
|
+
collection: 'sla-policies',
|
|
44
|
+
data: { name: 'Default', priority: 'normal', firstResponseTime: 60, resolutionTime: 240, businessHoursOnly: false, isDefault: true },
|
|
45
|
+
overrideAccess: true,
|
|
46
|
+
})
|
|
47
|
+
const c = await makeClient(payload, 'sla@example.com')
|
|
48
|
+
const t = await payload.create({ collection: 'tickets', data: { subject: 'sla', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
49
|
+
|
|
50
|
+
const reread = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
51
|
+
expect(reread.slaFirstResponseDue).toBeTruthy()
|
|
52
|
+
expect(reread.slaResolutionDue).toBeTruthy()
|
|
53
|
+
// resolution deadline must be later than the first-response deadline
|
|
54
|
+
expect(new Date(reread.slaResolutionDue as string).getTime()).toBeGreaterThan(
|
|
55
|
+
new Date(reread.slaFirstResponseDue as string).getTime(),
|
|
56
|
+
)
|
|
57
|
+
}, 60_000)
|
|
58
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
})
|