@consilioweb/payload-support 0.16.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +347 -435
- package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
- package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
- package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
- package/dist/components/TicketConversation/SkeletonText.js +20 -0
- package/dist/components/TicketConversation/constants.cjs +27 -0
- package/dist/components/TicketConversation/constants.js +26 -1
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
- package/dist/components/TicketConversation/index.cjs +10 -147
- package/dist/components/TicketConversation/index.js +4 -141
- package/dist/index.cjs +1604 -671
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1602 -671
- package/dist/views/BillingView/client.cjs +40 -54
- package/dist/views/BillingView/client.js +40 -54
- package/dist/views/ChatView/client.cjs +4 -3
- package/dist/views/ChatView/client.js +4 -3
- package/dist/views/CrmView/client.cjs +7 -7
- package/dist/views/CrmView/client.js +7 -7
- package/dist/views/EmailTrackingView/client.cjs +6 -5
- package/dist/views/EmailTrackingView/client.js +6 -5
- package/dist/views/ImportConversationView/client.cjs +4 -4
- package/dist/views/ImportConversationView/client.js +4 -4
- package/dist/views/LogsView/client.cjs +3 -2
- package/dist/views/LogsView/client.js +3 -2
- package/dist/views/NewTicketView/client.cjs +1 -1
- package/dist/views/NewTicketView/client.js +1 -1
- package/dist/views/PendingEmailsView/client.cjs +7 -6
- package/dist/views/PendingEmailsView/client.js +7 -6
- package/dist/views/SupportDashboardView/client.cjs +19 -22
- package/dist/views/SupportDashboardView/client.js +19 -22
- package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
- package/dist/views/TicketDetailView/NextActionItem.js +34 -0
- package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
- package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
- package/dist/views/TicketDetailView/client.cjs +59 -190
- package/dist/views/TicketDetailView/client.js +52 -183
- package/dist/views/TicketDetailView/constants.cjs +20 -0
- package/dist/views/TicketDetailView/constants.js +16 -0
- package/dist/views/TicketDetailView/helpers.cjs +23 -0
- package/dist/views/TicketDetailView/helpers.js +20 -0
- package/dist/views/TicketDetailView/types.cjs +2 -0
- package/dist/views/TicketDetailView/types.js +1 -0
- package/dist/views/TicketInboxView/client.cjs +14 -10
- package/dist/views/TicketInboxView/client.js +14 -10
- package/dist/views/TicketingSettingsView/client.cjs +101 -96
- package/dist/views/TicketingSettingsView/client.js +92 -88
- package/dist/views/TimeDashboardView/client.cjs +2 -2
- package/dist/views/TimeDashboardView/client.js +2 -2
- package/dist/views/shared/StatusPill.cjs +23 -12
- package/dist/views/shared/StatusPill.js +23 -12
- package/dist/views/shared/dateLocale.cjs +5 -0
- package/dist/views/shared/dateLocale.js +3 -0
- package/package.json +23 -15
- package/src/__tests__/generateTrackingToken.test.ts +53 -0
- package/src/__tests__/integration/ai-agent.test.ts +46 -0
- package/src/__tests__/integration/auth.test.ts +43 -0
- package/src/__tests__/integration/automation-rules.test.ts +68 -0
- package/src/__tests__/integration/buildTestPayload.ts +52 -0
- package/src/__tests__/integration/channels.test.ts +52 -0
- package/src/__tests__/integration/core-behaviors.test.ts +58 -0
- package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
- package/src/__tests__/integration/digest.test.ts +48 -0
- package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
- package/src/__tests__/integration/isolation.test.ts +81 -0
- package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
- package/src/__tests__/integration/nps.test.ts +37 -0
- package/src/__tests__/integration/sanitization.test.ts +35 -0
- package/src/__tests__/integration/sla-pause.test.ts +39 -0
- package/src/__tests__/integration/smoke.test.ts +26 -0
- package/src/__tests__/integration/snooze.test.ts +68 -0
- package/src/__tests__/integration/teams.test.ts +38 -0
- package/src/__tests__/rateLimiter.test.ts +89 -0
- package/src/__tests__/sanitizeHtml.test.ts +165 -0
- package/src/__tests__/sla.test.ts +258 -0
- package/src/collections/AutomationRules.ts +66 -0
- package/src/collections/NotificationQueue.ts +30 -0
- package/src/collections/SatisfactionSurveys.ts +11 -2
- package/src/collections/SupportClients.ts +58 -1
- package/src/collections/SupportTeam.ts +27 -0
- package/src/collections/TicketActivityLog.ts +9 -4
- package/src/collections/TicketMessages.ts +129 -31
- package/src/collections/Tickets.ts +31 -73
- package/src/collections/index.ts +3 -0
- package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
- package/src/components/TicketConversation/SkeletonText.tsx +23 -0
- package/src/components/TicketConversation/constants.ts +29 -0
- package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
- package/src/components/TicketConversation/index.tsx +4 -152
- package/src/endpoints/admin-chat-stream.ts +3 -4
- package/src/endpoints/admin-chat.ts +11 -20
- package/src/endpoints/admin-stats.ts +70 -32
- package/src/endpoints/ai-agent.ts +36 -0
- package/src/endpoints/apply-macro.ts +8 -14
- package/src/endpoints/auth-2fa.ts +12 -12
- package/src/endpoints/auto-close.ts +6 -10
- package/src/endpoints/billing.ts +17 -4
- package/src/endpoints/bulk-action.ts +7 -12
- package/src/endpoints/channels.ts +89 -0
- package/src/endpoints/chat-stream.ts +3 -4
- package/src/endpoints/chat.ts +7 -12
- package/src/endpoints/chatbot.ts +2 -2
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/delete-account.ts +9 -16
- package/src/endpoints/email-stats.ts +2 -2
- package/src/endpoints/escalate.ts +2 -2
- package/src/endpoints/export-csv.ts +2 -2
- package/src/endpoints/export-data.ts +5 -8
- package/src/endpoints/import-conversation.ts +6 -10
- package/src/endpoints/index.ts +18 -1
- package/src/endpoints/invite-collaborator.ts +30 -15
- package/src/endpoints/invoice.ts +127 -0
- package/src/endpoints/kb-search.ts +2 -2
- package/src/endpoints/login.ts +12 -4
- package/src/endpoints/merge-clients.ts +14 -19
- package/src/endpoints/merge-tickets.ts +11 -18
- package/src/endpoints/oauth-google.ts +86 -39
- package/src/endpoints/pending-emails-process.ts +15 -25
- package/src/endpoints/process-digests.ts +93 -0
- package/src/endpoints/process-scheduled.ts +6 -10
- package/src/endpoints/process-snooze.ts +78 -0
- package/src/endpoints/purge-logs.ts +2 -2
- package/src/endpoints/resend-notification.ts +3 -4
- package/src/endpoints/round-robin-config.ts +3 -4
- package/src/endpoints/satisfaction.ts +21 -14
- package/src/endpoints/search.ts +5 -8
- package/src/endpoints/seed-kb.ts +3 -4
- package/src/endpoints/send-reminder.ts +6 -9
- package/src/endpoints/settings.ts +5 -2
- package/src/endpoints/signature.ts +3 -4
- package/src/endpoints/sla-check.ts +2 -2
- package/src/endpoints/split-ticket.ts +7 -12
- package/src/endpoints/statuses.ts +2 -2
- package/src/endpoints/ticket-feedback.ts +4 -6
- package/src/endpoints/ticket-synthesis.ts +2 -2
- package/src/endpoints/track-open.ts +16 -28
- package/src/endpoints/transfer-ticket.ts +21 -9
- package/src/endpoints/user-prefs.ts +3 -4
- package/src/hooks/automationRules.ts +84 -0
- package/src/hooks/checkSLA.ts +48 -16
- package/src/plugin.ts +6 -0
- package/src/portal/LiveChat.tsx +2 -63
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
- package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
- package/src/portal/auth/tickets/detail/page.tsx +2 -2
- package/src/portal/helpers.ts +46 -0
- package/src/portal/login/page.tsx +3 -8
- package/src/portal/types.ts +15 -0
- package/src/types/lucide-react.d.ts +34 -0
- package/src/types/payload-config.d.ts +10 -0
- package/src/utils/adminNotification.ts +2 -2
- package/src/utils/aiAgent.ts +147 -0
- package/src/utils/auth.ts +5 -4
- package/src/utils/channels.ts +36 -0
- package/src/utils/db.ts +73 -0
- package/src/utils/emailTemplate.ts +8 -1
- package/src/utils/fireWebhooks.ts +4 -6
- package/src/utils/generateTicketSynthesis.ts +7 -12
- package/src/utils/invoicePdf.ts +88 -0
- package/src/utils/notificationQueue.ts +36 -0
- package/src/utils/readSettings.ts +22 -6
- package/src/utils/sanitizeHtml.ts +57 -0
- package/src/utils/slugs.ts +6 -0
- package/src/utils/teamAccess.ts +27 -0
- package/src/utils/ticketAccess.ts +53 -0
- package/src/utils/webhookDispatcher.ts +4 -6
- package/src/views/BillingView/client.tsx +49 -37
- package/src/views/ChatView/client.tsx +4 -3
- package/src/views/CrmView/client.tsx +7 -7
- package/src/views/EmailTrackingView/client.tsx +7 -6
- package/src/views/ImportConversationView/client.tsx +4 -4
- package/src/views/LogsView/client.tsx +3 -2
- package/src/views/NewTicketView/client.tsx +1 -1
- package/src/views/PendingEmailsView/client.tsx +7 -6
- package/src/views/SupportDashboardView/client.tsx +21 -21
- package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
- package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
- package/src/views/TicketDetailView/client.tsx +54 -179
- package/src/views/TicketDetailView/constants.ts +23 -0
- package/src/views/TicketDetailView/helpers.ts +17 -0
- package/src/views/TicketDetailView/types.ts +10 -0
- package/src/views/TicketInboxView/client.tsx +16 -10
- package/src/views/TicketingSettingsView/client.tsx +90 -87
- package/src/views/TimeDashboardView/client.tsx +2 -2
- package/src/views/shared/StatusPill.tsx +24 -12
- package/src/views/shared/dateLocale.ts +5 -0
- package/src/views/shared/locales/en.json +708 -0
- package/src/views/shared/locales/fr.json +708 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient stub for 'lucide-react'.
|
|
3
|
+
* lucide-react is an optional peer dependency — the host application installs it.
|
|
4
|
+
* This stub satisfies TypeScript when the plugin is type-checked in isolation.
|
|
5
|
+
*/
|
|
6
|
+
declare module 'lucide-react' {
|
|
7
|
+
import type { FC, SVGProps } from 'react'
|
|
8
|
+
type LucideIcon = FC<SVGProps<SVGSVGElement> & { size?: number | string; strokeWidth?: number | string }>
|
|
9
|
+
// Export every named icon as LucideIcon so any import resolves cleanly
|
|
10
|
+
const MessageCircle: LucideIcon
|
|
11
|
+
const Send: LucideIcon
|
|
12
|
+
const X: LucideIcon
|
|
13
|
+
const Minimize2: LucideIcon
|
|
14
|
+
const ArrowLeft: LucideIcon
|
|
15
|
+
const Inbox: LucideIcon
|
|
16
|
+
const Plus: LucideIcon
|
|
17
|
+
const Link2: LucideIcon
|
|
18
|
+
const Search: LucideIcon
|
|
19
|
+
const ChevronDown: LucideIcon
|
|
20
|
+
const ChevronUp: LucideIcon
|
|
21
|
+
const Paperclip: LucideIcon
|
|
22
|
+
const Settings: LucideIcon
|
|
23
|
+
const Mail: LucideIcon
|
|
24
|
+
const Bot: LucideIcon
|
|
25
|
+
const Clock: LucideIcon
|
|
26
|
+
const Timer: LucideIcon
|
|
27
|
+
const Globe: LucideIcon
|
|
28
|
+
const FileSignature: LucideIcon
|
|
29
|
+
export {
|
|
30
|
+
MessageCircle, Send, X, Minimize2, ArrowLeft,
|
|
31
|
+
Inbox, Plus, Link2, Search, ChevronDown, ChevronUp, Paperclip,
|
|
32
|
+
Settings, Mail, Bot, Clock, Timer, Globe, FileSignature,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient stub for '@payload-config'.
|
|
3
|
+
* The host application provides the real config; this stub satisfies
|
|
4
|
+
* TypeScript when the plugin is type-checked in isolation.
|
|
5
|
+
*/
|
|
6
|
+
declare module '@payload-config' {
|
|
7
|
+
import type { SanitizedConfig } from 'payload'
|
|
8
|
+
const config: Promise<SanitizedConfig>
|
|
9
|
+
export default config
|
|
10
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Payload } from 'payload'
|
|
2
|
+
import { dbCreate } from './db'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Helper to create an admin notification.
|
|
@@ -20,8 +21,7 @@ export async function createAdminNotification(
|
|
|
20
21
|
collectionSlug = 'admin-notifications',
|
|
21
22
|
): Promise<void> {
|
|
22
23
|
try {
|
|
23
|
-
await payload
|
|
24
|
-
collection: collectionSlug as any,
|
|
24
|
+
await dbCreate(payload, collectionSlug, {
|
|
25
25
|
data: {
|
|
26
26
|
title: data.title,
|
|
27
27
|
message: data.message,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Payload } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from './slugs'
|
|
3
|
+
import { readSupportSettings, type SupportSettings } from './readSettings'
|
|
4
|
+
import { dbFind, dbFindByID, dbCreate, dbUpdate } from './db'
|
|
5
|
+
|
|
6
|
+
export interface AgentDecision {
|
|
7
|
+
action: 'reply' | 'escalate'
|
|
8
|
+
reply?: string
|
|
9
|
+
reason?: string
|
|
10
|
+
confidence?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AgentResult {
|
|
14
|
+
acted: 'reply' | 'escalate'
|
|
15
|
+
messageId?: number | string
|
|
16
|
+
confidence?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Execute an AI agent decision on a ticket. DETERMINISTIC (no LLM) — this is the
|
|
21
|
+
* tested core: a `reply` posts a public answer (attributed to the AI assistant),
|
|
22
|
+
* an `escalate` records an internal note with the reasoning and flags the ticket.
|
|
23
|
+
*/
|
|
24
|
+
export async function applyAgentDecision(
|
|
25
|
+
payload: Payload,
|
|
26
|
+
slugs: CollectionSlugs,
|
|
27
|
+
ticketId: number | string,
|
|
28
|
+
decision: AgentDecision,
|
|
29
|
+
): Promise<AgentResult> {
|
|
30
|
+
if (decision.action === 'reply' && decision.reply && decision.reply.trim()) {
|
|
31
|
+
const msg = await dbCreate(payload, slugs.ticketMessages, {
|
|
32
|
+
data: {
|
|
33
|
+
ticket: ticketId,
|
|
34
|
+
body: decision.reply.trim(),
|
|
35
|
+
authorType: 'admin',
|
|
36
|
+
isInternal: false,
|
|
37
|
+
fromAlias: 'Assistant IA',
|
|
38
|
+
},
|
|
39
|
+
overrideAccess: true,
|
|
40
|
+
})
|
|
41
|
+
return { acted: 'reply', messageId: (msg as { id: number | string }).id, confidence: decision.confidence }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Escalate: leave an internal note + flag the ticket for a human.
|
|
45
|
+
await dbCreate(payload, slugs.ticketMessages, {
|
|
46
|
+
data: {
|
|
47
|
+
ticket: ticketId,
|
|
48
|
+
body: `[Agent IA] Escalade vers un humain. Raison : ${decision.reason || 'non déterminée'} (confiance ${Math.round((decision.confidence ?? 0) * 100)} %).`,
|
|
49
|
+
authorType: 'admin',
|
|
50
|
+
isInternal: true,
|
|
51
|
+
skipNotification: true,
|
|
52
|
+
},
|
|
53
|
+
overrideAccess: true,
|
|
54
|
+
})
|
|
55
|
+
await dbUpdate(payload, slugs.tickets, { id: ticketId, data: { status: 'escalated' }, overrideAccess: true })
|
|
56
|
+
return { acted: 'escalate', confidence: decision.confidence }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── LLM decision (runtime only — not unit-tested) ───────────────────
|
|
60
|
+
|
|
61
|
+
function getClient(aiSettings: SupportSettings['ai']) {
|
|
62
|
+
const Anthropic = require('@anthropic-ai/sdk').default
|
|
63
|
+
if (aiSettings.provider === 'ollama') {
|
|
64
|
+
return new Anthropic({ apiKey: 'ollama', baseURL: process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1' })
|
|
65
|
+
}
|
|
66
|
+
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stripHtml(s: string): string {
|
|
70
|
+
return (s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseDecision(text: string): AgentDecision {
|
|
74
|
+
try {
|
|
75
|
+
const match = text.match(/\{[\s\S]*\}/)
|
|
76
|
+
if (match) {
|
|
77
|
+
const obj = JSON.parse(match[0])
|
|
78
|
+
return {
|
|
79
|
+
action: obj.action === 'reply' ? 'reply' : 'escalate',
|
|
80
|
+
reply: typeof obj.reply === 'string' ? obj.reply : undefined,
|
|
81
|
+
reason: typeof obj.reason === 'string' ? obj.reason : undefined,
|
|
82
|
+
confidence: typeof obj.confidence === 'number' ? obj.confidence : 0,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch { /* fall through */ }
|
|
86
|
+
return { action: 'escalate', reason: 'Réponse IA non interprétable', confidence: 0 }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run the autonomous AI agent on a ticket: builds context (ticket + thread + KB),
|
|
91
|
+
* asks the LLM for a structured decision, applies a confidence threshold, then
|
|
92
|
+
* executes it via {@link applyAgentDecision}. Requires an AI provider at runtime.
|
|
93
|
+
*/
|
|
94
|
+
export async function runAiAgent(
|
|
95
|
+
payload: Payload,
|
|
96
|
+
slugs: CollectionSlugs,
|
|
97
|
+
ticketId: number | string,
|
|
98
|
+
opts?: { confidenceThreshold?: number },
|
|
99
|
+
): Promise<AgentResult> {
|
|
100
|
+
const settings = await readSupportSettings(payload)
|
|
101
|
+
const threshold = opts?.confidenceThreshold ?? 0.7
|
|
102
|
+
|
|
103
|
+
const ticket = await dbFindByID(payload, slugs.tickets, { id: ticketId, depth: 0, overrideAccess: true })
|
|
104
|
+
const messages = await dbFind(payload, slugs.ticketMessages, { where: { ticket: { equals: ticketId } }, sort: 'createdAt', limit: 50, depth: 0, overrideAccess: true })
|
|
105
|
+
const kb = await dbFind(payload, slugs.knowledgeBase, { where: { published: { equals: true } }, limit: 20, depth: 0, overrideAccess: true })
|
|
106
|
+
|
|
107
|
+
const conversation = messages.docs.map((m) => `${(m as { authorType?: string }).authorType}: ${(m as { body?: string }).body || ''}`).join('\n')
|
|
108
|
+
const kbText = kb.docs
|
|
109
|
+
.map((a) => `# ${(a as { title?: string }).title || ''}\n${stripHtml(String((a as { content?: unknown }).content ?? ''))}`)
|
|
110
|
+
.join('\n\n')
|
|
111
|
+
.slice(0, 8000)
|
|
112
|
+
|
|
113
|
+
const prompt = `Tu es un agent de support IA. À partir du ticket et de la base de connaissances ci-dessous, décide :
|
|
114
|
+
- "reply" si tu peux répondre avec certitude UNIQUEMENT à partir de la base de connaissances ;
|
|
115
|
+
- "escalate" sinon (question hors KB, ambiguë, sensible, ou nécessitant une action humaine).
|
|
116
|
+
Réponds en JSON STRICT, sans texte autour : {"action":"reply"|"escalate","reply":"<réponse au client si reply>","reason":"<courte raison>","confidence":<0 à 1>}.
|
|
117
|
+
|
|
118
|
+
TICKET : ${(ticket as { subject?: string }).subject || ''}
|
|
119
|
+
CONVERSATION :
|
|
120
|
+
${conversation}
|
|
121
|
+
|
|
122
|
+
BASE DE CONNAISSANCES :
|
|
123
|
+
${kbText || '(vide)'}`
|
|
124
|
+
|
|
125
|
+
let decision: AgentDecision
|
|
126
|
+
try {
|
|
127
|
+
const client = getClient(settings.ai)
|
|
128
|
+
const resp = await client.messages.create({
|
|
129
|
+
model: settings.ai.model || 'claude-haiku-4-5-20251001',
|
|
130
|
+
max_tokens: 1024,
|
|
131
|
+
messages: [{ role: 'user', content: prompt }],
|
|
132
|
+
})
|
|
133
|
+
const text = resp?.content?.[0]?.text || ''
|
|
134
|
+
decision = parseDecision(text)
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error('[support] AI agent decision failed:', err)
|
|
137
|
+
decision = { action: 'escalate', reason: 'Erreur du fournisseur IA', confidence: 0 }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Confidence gate: only auto-reply when the model is confident enough.
|
|
141
|
+
const finalDecision: AgentDecision =
|
|
142
|
+
decision.action === 'reply' && (decision.confidence ?? 0) >= threshold
|
|
143
|
+
? decision
|
|
144
|
+
: { action: 'escalate', reason: decision.reason || 'Confiance insuffisante pour une réponse automatique', confidence: decision.confidence }
|
|
145
|
+
|
|
146
|
+
return applyAgentDecision(payload, slugs, ticketId, finalDecision)
|
|
147
|
+
}
|
package/src/utils/auth.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PayloadRequest } from 'payload'
|
|
1
2
|
import type { CollectionSlugs } from './slugs'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -19,9 +20,9 @@ export class AuthError extends Error {
|
|
|
19
20
|
* Throws AuthError(401) if unauthenticated, AuthError(403) if not an admin.
|
|
20
21
|
* Uses TypeScript assertion signature so `req.user` is narrowed to non-null after call.
|
|
21
22
|
*/
|
|
22
|
-
export function requireAdmin(req:
|
|
23
|
+
export function requireAdmin(req: PayloadRequest, slugs: CollectionSlugs): asserts req is PayloadRequest & { user: NonNullable<PayloadRequest['user']> } {
|
|
23
24
|
if (!req.user) throw new AuthError('Authentication required', 401)
|
|
24
|
-
if (req.user.collection !== slugs.users) throw new AuthError('Admin access required', 403)
|
|
25
|
+
if ((req.user as any).collection !== slugs.users) throw new AuthError('Admin access required', 403)
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -29,9 +30,9 @@ export function requireAdmin(req: { user?: any }, slugs: CollectionSlugs): asser
|
|
|
29
30
|
* Throws AuthError(401) if unauthenticated, AuthError(403) if not a client.
|
|
30
31
|
* Uses TypeScript assertion signature so `req.user` is narrowed to non-null after call.
|
|
31
32
|
*/
|
|
32
|
-
export function requireClient(req:
|
|
33
|
+
export function requireClient(req: PayloadRequest, slugs: CollectionSlugs): asserts req is PayloadRequest & { user: NonNullable<PayloadRequest['user']> } {
|
|
33
34
|
if (!req.user) throw new AuthError('Authentication required', 401)
|
|
34
|
-
if (req.user.collection !== slugs.supportClients) throw new AuthError('Client access required', 403)
|
|
35
|
+
if ((req.user as any).collection !== slugs.supportClients) throw new AuthError('Client access required', 403)
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ChannelProvider = 'whatsapp' | 'messenger'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Send an outbound reply to a social channel (WhatsApp / Messenger) via the Meta
|
|
5
|
+
* Graph API. RUNTIME-only: requires provider credentials in the environment.
|
|
6
|
+
* Returns `{ ok: false }` (and logs) when credentials are missing or the call fails.
|
|
7
|
+
*/
|
|
8
|
+
export async function sendChannelReply(provider: ChannelProvider, to: string, text: string): Promise<{ ok: boolean }> {
|
|
9
|
+
try {
|
|
10
|
+
if (provider === 'whatsapp') {
|
|
11
|
+
const token = process.env.WHATSAPP_TOKEN
|
|
12
|
+
const phoneId = process.env.WHATSAPP_PHONE_ID
|
|
13
|
+
if (!token || !phoneId) return { ok: false }
|
|
14
|
+
const res = await fetch(`https://graph.facebook.com/v21.0/${phoneId}/messages`, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
17
|
+
body: JSON.stringify({ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } }),
|
|
18
|
+
})
|
|
19
|
+
return { ok: res.ok }
|
|
20
|
+
}
|
|
21
|
+
if (provider === 'messenger') {
|
|
22
|
+
const token = process.env.MESSENGER_PAGE_TOKEN
|
|
23
|
+
if (!token) return { ok: false }
|
|
24
|
+
const res = await fetch(`https://graph.facebook.com/v21.0/me/messages?access_token=${encodeURIComponent(token)}`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
body: JSON.stringify({ recipient: { id: to }, message: { text } }),
|
|
28
|
+
})
|
|
29
|
+
return { ok: res.ok }
|
|
30
|
+
}
|
|
31
|
+
return { ok: false }
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error('[support] Channel send failed:', err)
|
|
34
|
+
return { ok: false }
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/utils/db.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Payload } from 'payload'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thin typed wrappers around Payload data operations.
|
|
5
|
+
*
|
|
6
|
+
* Collection slugs are user-overridable config (`string`), not Payload's literal
|
|
7
|
+
* `CollectionSlug` union — so every call site otherwise needs `collection: X as any`.
|
|
8
|
+
* These helpers isolate that single unavoidable cast in ONE place, removing the
|
|
9
|
+
* ~200 scattered `as any` while staying an EXACT behavioral pass-through of options.
|
|
10
|
+
*
|
|
11
|
+
* Returns default to `any` (callers consume docs loosely, as before). Pass a type
|
|
12
|
+
* argument to opt into stronger typing: `dbFind<TicketData>(payload, slug, { ... })`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface PaginatedResult<T> {
|
|
16
|
+
docs: T[]
|
|
17
|
+
totalDocs: number
|
|
18
|
+
totalPages: number
|
|
19
|
+
page?: number
|
|
20
|
+
limit: number
|
|
21
|
+
hasNextPage: boolean
|
|
22
|
+
hasPrevPage: boolean
|
|
23
|
+
nextPage?: number | null
|
|
24
|
+
prevPage?: number | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function dbFind<T = any>(
|
|
28
|
+
payload: Payload,
|
|
29
|
+
slug: string,
|
|
30
|
+
options: Record<string, unknown> = {},
|
|
31
|
+
): Promise<PaginatedResult<T>> {
|
|
32
|
+
return payload.find({ collection: slug as any, ...options }) as any
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function dbFindByID<T = any>(
|
|
36
|
+
payload: Payload,
|
|
37
|
+
slug: string,
|
|
38
|
+
options: { id: number | string } & Record<string, unknown>,
|
|
39
|
+
): Promise<T> {
|
|
40
|
+
return payload.findByID({ collection: slug as any, ...options }) as any
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function dbCreate<T = any>(
|
|
44
|
+
payload: Payload,
|
|
45
|
+
slug: string,
|
|
46
|
+
options: Record<string, unknown>,
|
|
47
|
+
): Promise<T> {
|
|
48
|
+
return (payload.create as any)({ collection: slug, ...options })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function dbUpdate<T = any>(
|
|
52
|
+
payload: Payload,
|
|
53
|
+
slug: string,
|
|
54
|
+
options: Record<string, unknown>,
|
|
55
|
+
): Promise<T> {
|
|
56
|
+
return (payload.update as any)({ collection: slug, ...options })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function dbCount(
|
|
60
|
+
payload: Payload,
|
|
61
|
+
slug: string,
|
|
62
|
+
options: Record<string, unknown> = {},
|
|
63
|
+
): Promise<{ totalDocs: number }> {
|
|
64
|
+
return payload.count({ collection: slug as any, ...options }) as any
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function dbDelete<T = any>(
|
|
68
|
+
payload: Payload,
|
|
69
|
+
slug: string,
|
|
70
|
+
options: Record<string, unknown>,
|
|
71
|
+
): Promise<T> {
|
|
72
|
+
return (payload.delete as any)({ collection: slug, ...options })
|
|
73
|
+
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* emailInfoRow, escapeHtml, createEmailTemplateFactory must not change.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { generateTrackingToken } from '../endpoints/track-open'
|
|
20
|
+
|
|
19
21
|
// ─── Design tokens ───────────────────────────────────────────────────
|
|
20
22
|
|
|
21
23
|
const COLORS = {
|
|
@@ -86,7 +88,12 @@ export function escapeHtml(str: string): string {
|
|
|
86
88
|
*/
|
|
87
89
|
export function emailTrackingPixel(ticketId: number | string, messageId?: number | string, baseUrl?: string): string {
|
|
88
90
|
const url = baseUrl || process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
89
|
-
const
|
|
91
|
+
const secret = process.env.PAYLOAD_SECRET || ''
|
|
92
|
+
// Open-tracking requires a signed URL (HMAC). Without a secret or a messageId we
|
|
93
|
+
// cannot sign, so we emit NO pixel rather than an unsigned (abusable) one.
|
|
94
|
+
if (!secret || messageId === undefined || messageId === null) return ''
|
|
95
|
+
const sig = generateTrackingToken(String(ticketId), String(messageId), secret)
|
|
96
|
+
const params = `t=${ticketId}&m=${messageId}&sig=${sig}`
|
|
90
97
|
return `<img src="${url}/api/support/track-open?${params}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`
|
|
91
98
|
}
|
|
92
99
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Payload } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from './slugs'
|
|
3
|
+
import { dbFind, dbUpdate } from './db'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Fire all active webhooks matching the given event.
|
|
@@ -14,8 +15,7 @@ export async function fireWebhooks(
|
|
|
14
15
|
): Promise<void> {
|
|
15
16
|
try {
|
|
16
17
|
// Find all active webhook endpoints subscribed to this event
|
|
17
|
-
const endpoints = await payload.
|
|
18
|
-
collection: slugs.webhookEndpoints as any,
|
|
18
|
+
const endpoints = await dbFind(payload, slugs.webhookEndpoints, {
|
|
19
19
|
where: {
|
|
20
20
|
active: { equals: true },
|
|
21
21
|
events: { contains: event },
|
|
@@ -45,8 +45,7 @@ export async function fireWebhooks(
|
|
|
45
45
|
|
|
46
46
|
// Update webhook metadata (best-effort, don't throw on failure)
|
|
47
47
|
try {
|
|
48
|
-
await payload.
|
|
49
|
-
collection: slugs.webhookEndpoints as any,
|
|
48
|
+
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
50
49
|
id: endpoint.id,
|
|
51
50
|
data: {
|
|
52
51
|
lastTriggeredAt: timestamp,
|
|
@@ -62,8 +61,7 @@ export async function fireWebhooks(
|
|
|
62
61
|
|
|
63
62
|
// Record the failure
|
|
64
63
|
try {
|
|
65
|
-
await payload.
|
|
66
|
-
collection: slugs.webhookEndpoints as any,
|
|
64
|
+
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
67
65
|
id: endpoint.id,
|
|
68
66
|
data: {
|
|
69
67
|
lastTriggeredAt: timestamp,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Payload } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from './slugs'
|
|
3
3
|
import { readSupportSettings, type SupportSettings } from './readSettings'
|
|
4
|
+
import { dbFindByID, dbUpdate, dbFind } from './db'
|
|
4
5
|
|
|
5
6
|
function getClient(aiSettings: SupportSettings['ai']) {
|
|
6
7
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -53,8 +54,7 @@ export async function generateTicketSynthesis(args: {
|
|
|
53
54
|
return { summary: '', generatedAt: new Date().toISOString(), status: 'skipped', reason: 'synthesis disabled' }
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
const ticket = await payload.
|
|
57
|
-
collection: slugs.tickets as any,
|
|
57
|
+
const ticket = await dbFindByID(payload, slugs.tickets, {
|
|
58
58
|
id: ticketId,
|
|
59
59
|
depth: 1,
|
|
60
60
|
overrideAccess: true,
|
|
@@ -65,16 +65,14 @@ export async function generateTicketSynthesis(args: {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// Mark as pending so the UI can show a spinner
|
|
68
|
-
await payload.
|
|
69
|
-
collection: slugs.tickets as any,
|
|
68
|
+
await dbUpdate(payload, slugs.tickets, {
|
|
70
69
|
id: ticketId,
|
|
71
70
|
data: { aiSummaryStatus: 'pending' },
|
|
72
71
|
overrideAccess: true,
|
|
73
72
|
}).catch(() => { /* non-blocking */ })
|
|
74
73
|
|
|
75
74
|
// Load all ticket messages, ordered chronologically
|
|
76
|
-
const messagesResult = await payload.
|
|
77
|
-
collection: slugs.ticketMessages as any,
|
|
75
|
+
const messagesResult = await dbFind(payload, slugs.ticketMessages, {
|
|
78
76
|
where: { ticket: { equals: ticketId } },
|
|
79
77
|
sort: 'createdAt',
|
|
80
78
|
limit: 500,
|
|
@@ -86,8 +84,7 @@ export async function generateTicketSynthesis(args: {
|
|
|
86
84
|
|
|
87
85
|
if (messages.length === 0) {
|
|
88
86
|
const generatedAt = new Date().toISOString()
|
|
89
|
-
await payload.
|
|
90
|
-
collection: slugs.tickets as any,
|
|
87
|
+
await dbUpdate(payload, slugs.tickets, {
|
|
91
88
|
id: ticketId,
|
|
92
89
|
data: {
|
|
93
90
|
aiSummary: '(Aucun message dans ce ticket)',
|
|
@@ -153,8 +150,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`
|
|
|
153
150
|
const summary = res.content[0]?.type === 'text' ? res.content[0].text.trim() : ''
|
|
154
151
|
const generatedAt = new Date().toISOString()
|
|
155
152
|
|
|
156
|
-
await payload.
|
|
157
|
-
collection: slugs.tickets as any,
|
|
153
|
+
await dbUpdate(payload, slugs.tickets, {
|
|
158
154
|
id: ticketId,
|
|
159
155
|
data: {
|
|
160
156
|
aiSummary: summary,
|
|
@@ -167,8 +163,7 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`
|
|
|
167
163
|
return { summary, generatedAt, status: 'done' }
|
|
168
164
|
} catch (err) {
|
|
169
165
|
const message = err instanceof Error ? err.message : String(err)
|
|
170
|
-
await payload.
|
|
171
|
-
collection: slugs.tickets as any,
|
|
166
|
+
await dbUpdate(payload, slugs.tickets, {
|
|
172
167
|
id: ticketId,
|
|
173
168
|
data: { aiSummaryStatus: 'error' },
|
|
174
169
|
overrideAccess: true,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import PDFDocument from 'pdfkit'
|
|
2
|
+
|
|
3
|
+
export interface InvoiceLine {
|
|
4
|
+
ticketNumber: string
|
|
5
|
+
subject: string
|
|
6
|
+
client: string
|
|
7
|
+
minutes: number
|
|
8
|
+
amount: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface InvoiceData {
|
|
12
|
+
from: string
|
|
13
|
+
to: string
|
|
14
|
+
projectId?: string | null
|
|
15
|
+
lines: InvoiceLine[]
|
|
16
|
+
totalMinutes: number
|
|
17
|
+
totalAmount: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const fmtAmount = (n: number) => `${n.toFixed(2)} EUR`
|
|
21
|
+
const fmtTime = (min: number) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}`
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Render a real (binary) PDF invoice from billing data, using pdfkit. Returns the
|
|
25
|
+
* complete PDF as a Buffer (suitable for an HTTP `application/pdf` response).
|
|
26
|
+
*/
|
|
27
|
+
export function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
try {
|
|
30
|
+
const doc = new PDFDocument({ size: 'A4', margin: 50 })
|
|
31
|
+
const chunks: Buffer[] = []
|
|
32
|
+
doc.on('data', (c: Buffer) => chunks.push(c))
|
|
33
|
+
doc.on('end', () => resolve(Buffer.concat(chunks)))
|
|
34
|
+
doc.on('error', reject)
|
|
35
|
+
|
|
36
|
+
// Header
|
|
37
|
+
doc.fillColor('#0f172a').fontSize(22).text('Invoice / Pre-billing')
|
|
38
|
+
doc.moveDown(0.2)
|
|
39
|
+
doc.fillColor('#64748b').fontSize(10)
|
|
40
|
+
.text(`Period: ${data.from} -> ${data.to}${data.projectId ? ` · Project #${data.projectId}` : ''}`)
|
|
41
|
+
doc.moveDown(1)
|
|
42
|
+
|
|
43
|
+
// Column layout (x positions)
|
|
44
|
+
const X = { ticket: 50, subject: 130, client: 320, time: 430, amount: 500 }
|
|
45
|
+
const drawRow = (r: { ticket: string; subject: string; client: string; time: string; amount: string }, opts?: { header?: boolean; total?: boolean }) => {
|
|
46
|
+
const y = doc.y
|
|
47
|
+
doc.fontSize(opts?.header ? 9 : 10).fillColor(opts?.header ? '#475569' : '#0f172a')
|
|
48
|
+
if (opts?.total) doc.font('Helvetica-Bold')
|
|
49
|
+
doc.text(r.ticket, X.ticket, y, { width: X.subject - X.ticket - 6 })
|
|
50
|
+
doc.text(r.subject, X.subject, y, { width: X.client - X.subject - 6 })
|
|
51
|
+
doc.text(r.client, X.client, y, { width: X.time - X.client - 6 })
|
|
52
|
+
doc.text(r.time, X.time, y, { width: X.amount - X.time - 6, align: 'right' })
|
|
53
|
+
doc.text(r.amount, X.amount, y, { width: 545 - X.amount, align: 'right' })
|
|
54
|
+
doc.font('Helvetica')
|
|
55
|
+
doc.moveDown(0.4)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
drawRow({ ticket: 'Ticket', subject: 'Subject', client: 'Client', time: 'Time', amount: 'Amount' }, { header: true })
|
|
59
|
+
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e2e8f0').stroke()
|
|
60
|
+
doc.moveDown(0.3)
|
|
61
|
+
|
|
62
|
+
for (const l of data.lines) {
|
|
63
|
+
if (doc.y > 760) doc.addPage()
|
|
64
|
+
drawRow({
|
|
65
|
+
ticket: l.ticketNumber || '',
|
|
66
|
+
subject: l.subject || '',
|
|
67
|
+
client: l.client || '',
|
|
68
|
+
time: fmtTime(l.minutes),
|
|
69
|
+
amount: fmtAmount(l.amount),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#0f172a').stroke()
|
|
74
|
+
doc.moveDown(0.3)
|
|
75
|
+
drawRow({
|
|
76
|
+
ticket: '',
|
|
77
|
+
subject: `Total (${data.lines.length} ticket${data.lines.length > 1 ? 's' : ''})`,
|
|
78
|
+
client: '',
|
|
79
|
+
time: fmtTime(data.totalMinutes),
|
|
80
|
+
amount: fmtAmount(data.totalAmount),
|
|
81
|
+
}, { total: true })
|
|
82
|
+
|
|
83
|
+
doc.end()
|
|
84
|
+
} catch (err) {
|
|
85
|
+
reject(err as Error)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Payload } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from './slugs'
|
|
3
|
+
import { dbCreate } from './db'
|
|
4
|
+
|
|
5
|
+
export interface QueuedNotification {
|
|
6
|
+
client: number | string
|
|
7
|
+
type: string
|
|
8
|
+
title: string
|
|
9
|
+
message: string
|
|
10
|
+
ticket?: number | string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Queue a client notification for later digest delivery (best-effort). Used when
|
|
15
|
+
* the client opted into `daily`/`weekly` notification frequency instead of immediate.
|
|
16
|
+
*/
|
|
17
|
+
export async function queueClientNotification(
|
|
18
|
+
payload: Payload,
|
|
19
|
+
slugs: CollectionSlugs,
|
|
20
|
+
item: QueuedNotification,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
try {
|
|
23
|
+
await dbCreate(payload, slugs.notificationQueue, {
|
|
24
|
+
data: {
|
|
25
|
+
client: item.client,
|
|
26
|
+
type: item.type,
|
|
27
|
+
title: item.title,
|
|
28
|
+
message: item.message,
|
|
29
|
+
...(item.ticket ? { ticket: item.ticket } : {}),
|
|
30
|
+
},
|
|
31
|
+
overrideAccess: true,
|
|
32
|
+
})
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('[support] Failed to queue client notification:', err)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Payload } from 'payload'
|
|
2
|
+
import { dbFind } from './db'
|
|
2
3
|
|
|
3
4
|
const PREF_KEY = 'support-settings'
|
|
4
5
|
const USER_PREFS_KEY_PREFIX = 'support-user-prefs'
|
|
@@ -27,16 +28,31 @@ export const DEFAULT_USER_PREFS: UserPrefs = {
|
|
|
27
28
|
signature: '',
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// In-process cache: readSupportSettings is hit many times per ticket mutation
|
|
32
|
+
// (the afterChange hook chain). The settings doc changes rarely, so a short TTL
|
|
33
|
+
// + explicit invalidation on save (endpoints/settings.ts) avoids redundant DB
|
|
34
|
+
// reads of the same `payload-preferences` row.
|
|
35
|
+
let settingsCache: { value: SupportSettings; ts: number } | null = null
|
|
36
|
+
const SETTINGS_TTL_MS = 60_000
|
|
37
|
+
|
|
38
|
+
/** Invalidate the settings cache — call right after writing support settings. */
|
|
39
|
+
export function invalidateSupportSettingsCache(): void {
|
|
40
|
+
settingsCache = null
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
export async function readSupportSettings(payload: Payload): Promise<SupportSettings> {
|
|
44
|
+
if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
|
|
45
|
+
return settingsCache.value
|
|
46
|
+
}
|
|
47
|
+
let value: SupportSettings = { ...DEFAULT_SETTINGS }
|
|
31
48
|
try {
|
|
32
|
-
const prefs = await payload
|
|
33
|
-
collection: 'payload-preferences' as any,
|
|
49
|
+
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
34
50
|
where: { key: { equals: PREF_KEY } },
|
|
35
51
|
limit: 1, depth: 0, overrideAccess: true,
|
|
36
52
|
})
|
|
37
53
|
if (prefs.docs.length > 0) {
|
|
38
54
|
const stored = prefs.docs[0].value as Partial<SupportSettings>
|
|
39
|
-
|
|
55
|
+
value = {
|
|
40
56
|
email: { ...DEFAULT_SETTINGS.email, ...stored.email },
|
|
41
57
|
ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
|
|
42
58
|
sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
|
|
@@ -44,14 +60,14 @@ export async function readSupportSettings(payload: Payload): Promise<SupportSett
|
|
|
44
60
|
}
|
|
45
61
|
}
|
|
46
62
|
} catch { /* fallback to defaults */ }
|
|
47
|
-
|
|
63
|
+
settingsCache = { value, ts: Date.now() }
|
|
64
|
+
return value
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
export async function readUserPrefs(payload: Payload, userId: string | number): Promise<UserPrefs> {
|
|
51
68
|
try {
|
|
52
69
|
const key = `${USER_PREFS_KEY_PREFIX}-${userId}`
|
|
53
|
-
const prefs = await payload
|
|
54
|
-
collection: 'payload-preferences' as any,
|
|
70
|
+
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
55
71
|
where: { key: { equals: key } },
|
|
56
72
|
limit: 1, depth: 0, overrideAccess: true,
|
|
57
73
|
})
|