@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,84 @@
|
|
|
1
|
+
import type { CollectionAfterChangeHook } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { dbFind } from '../utils/db'
|
|
4
|
+
|
|
5
|
+
interface Condition { field?: string; operator?: string; value?: string }
|
|
6
|
+
interface Action { type?: string; value?: string }
|
|
7
|
+
interface Rule { matchType?: string; conditions?: Condition[]; actions?: Action[] }
|
|
8
|
+
|
|
9
|
+
function evaluateConditions(doc: Record<string, unknown>, rule: Rule): boolean {
|
|
10
|
+
const conds = Array.isArray(rule.conditions) ? rule.conditions : []
|
|
11
|
+
if (conds.length === 0) return true
|
|
12
|
+
const results = conds.map((c) => {
|
|
13
|
+
const fieldVal = String(doc[c.field as string] ?? '')
|
|
14
|
+
const v = String(c.value ?? '')
|
|
15
|
+
switch (c.operator) {
|
|
16
|
+
case 'equals': return fieldVal === v
|
|
17
|
+
case 'not_equals': return fieldVal !== v
|
|
18
|
+
case 'contains': return fieldVal.toLowerCase().includes(v.toLowerCase())
|
|
19
|
+
default: return false
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
return rule.matchType === 'any' ? results.some(Boolean) : results.every(Boolean)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Evaluate enabled automation rules against a ticket and apply their actions.
|
|
27
|
+
* Runs as afterChange on the Tickets collection. All matched actions are applied
|
|
28
|
+
* in a SINGLE update flagged `skipAutomation` to avoid re-triggering (no loop).
|
|
29
|
+
*/
|
|
30
|
+
export function createApplyAutomationRules(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
31
|
+
return async ({ doc, previousDoc, operation, req }) => {
|
|
32
|
+
if (req.context?.skipAutomation) return doc
|
|
33
|
+
|
|
34
|
+
const events: string[] = []
|
|
35
|
+
if (operation === 'create') events.push('ticket_created')
|
|
36
|
+
if (operation === 'update') {
|
|
37
|
+
events.push('ticket_updated')
|
|
38
|
+
if (previousDoc && previousDoc.status !== doc.status) events.push('ticket_status_changed')
|
|
39
|
+
}
|
|
40
|
+
if (events.length === 0) return doc
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const rules = await dbFind<Rule & { actions?: Action[] }>(req.payload, slugs.automationRules, {
|
|
44
|
+
where: { and: [{ enabled: { equals: true } }, { event: { in: events } }] },
|
|
45
|
+
sort: 'order',
|
|
46
|
+
limit: 200,
|
|
47
|
+
depth: 0,
|
|
48
|
+
overrideAccess: true,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const changes: Record<string, unknown> = {}
|
|
52
|
+
const tags: string[] = Array.isArray(doc.tags) ? [...(doc.tags as string[])] : []
|
|
53
|
+
let tagsTouched = false
|
|
54
|
+
|
|
55
|
+
for (const rule of rules.docs) {
|
|
56
|
+
if (!evaluateConditions(doc as Record<string, unknown>, rule)) continue
|
|
57
|
+
for (const a of Array.isArray(rule.actions) ? rule.actions : []) {
|
|
58
|
+
if (!a.value) continue
|
|
59
|
+
switch (a.type) {
|
|
60
|
+
case 'set_status': changes.status = a.value; break
|
|
61
|
+
case 'set_priority': changes.priority = a.value; break
|
|
62
|
+
case 'set_category': changes.category = a.value; break
|
|
63
|
+
case 'assign': changes.assignedTo = a.value; break
|
|
64
|
+
case 'add_tag': if (!tags.includes(a.value)) { tags.push(a.value); tagsTouched = true } break
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (tagsTouched) changes.tags = tags
|
|
70
|
+
if (Object.keys(changes).length > 0) {
|
|
71
|
+
await req.payload.update({
|
|
72
|
+
collection: slugs.tickets,
|
|
73
|
+
id: doc.id,
|
|
74
|
+
data: changes,
|
|
75
|
+
overrideAccess: true,
|
|
76
|
+
context: { skipAutomation: true },
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error('[support] Failed to apply automation rules:', err)
|
|
81
|
+
}
|
|
82
|
+
return doc
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/hooks/checkSLA.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
|
|
|
3
3
|
import { createAdminNotification } from '../utils/adminNotification'
|
|
4
4
|
import { emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
|
|
5
5
|
import { readSupportSettings } from '../utils/readSettings'
|
|
6
|
+
import { dbFindByID, dbFind, dbUpdate } from '../utils/db'
|
|
6
7
|
|
|
7
8
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
9
|
type AnyDoc = any
|
|
@@ -145,8 +146,7 @@ async function resolveSlaPolicy(
|
|
|
145
146
|
if (typeof policyRef === 'object' && (policyRef as AnyDoc).firstResponseTime) {
|
|
146
147
|
return policyRef
|
|
147
148
|
}
|
|
148
|
-
return await payload.
|
|
149
|
-
collection: slugs.slaPolicies as any,
|
|
149
|
+
return await dbFindByID(payload, slugs.slaPolicies, {
|
|
150
150
|
id: policyId as number,
|
|
151
151
|
depth: 0,
|
|
152
152
|
overrideAccess: true,
|
|
@@ -160,8 +160,7 @@ async function resolveSlaPolicy(
|
|
|
160
160
|
// Look for a default policy matching this ticket's priority
|
|
161
161
|
const priority = field(ticket, 'priority') || 'normal'
|
|
162
162
|
try {
|
|
163
|
-
const defaults = await payload.
|
|
164
|
-
collection: slugs.slaPolicies as any,
|
|
163
|
+
const defaults = await dbFind(payload, slugs.slaPolicies, {
|
|
165
164
|
where: {
|
|
166
165
|
and: [
|
|
167
166
|
{ isDefault: { equals: true } },
|
|
@@ -218,8 +217,7 @@ async function handleSlaBreach(
|
|
|
218
217
|
if (typeof policyRef === 'object' && (policyRef as AnyDoc).escalateOnBreach !== undefined) {
|
|
219
218
|
policy = policyRef
|
|
220
219
|
} else {
|
|
221
|
-
policy = await payload.
|
|
222
|
-
collection: slugs.slaPolicies as any,
|
|
220
|
+
policy = await dbFindByID(payload, slugs.slaPolicies, {
|
|
223
221
|
id: policyId as number,
|
|
224
222
|
depth: 0,
|
|
225
223
|
overrideAccess: true,
|
|
@@ -240,8 +238,7 @@ async function handleSlaBreach(
|
|
|
240
238
|
|
|
241
239
|
const escalateUser = typeof policy.escalateTo === 'object' && (policy.escalateTo as AnyDoc).email
|
|
242
240
|
? policy.escalateTo
|
|
243
|
-
: await payload.
|
|
244
|
-
collection: slugs.users as any,
|
|
241
|
+
: await dbFindByID(payload, slugs.users, {
|
|
245
242
|
id: escalateToId as number,
|
|
246
243
|
depth: 0,
|
|
247
244
|
overrideAccess: true,
|
|
@@ -312,8 +309,7 @@ export function createAssignSlaDeadlines(slugs: CollectionSlugs, notificationSlu
|
|
|
312
309
|
updateData.slaPolicy = policy.id
|
|
313
310
|
}
|
|
314
311
|
|
|
315
|
-
await payload.
|
|
316
|
-
collection: slugs.tickets as any,
|
|
312
|
+
await dbUpdate(payload, slugs.tickets, {
|
|
317
313
|
id: doc.id,
|
|
318
314
|
data: updateData,
|
|
319
315
|
overrideAccess: true,
|
|
@@ -346,8 +342,7 @@ export function createCheckSlaOnResolve(slugs: CollectionSlugs, notificationSlug
|
|
|
346
342
|
const deadline = new Date(doc.slaResolutionDue as string)
|
|
347
343
|
const breached = now > deadline
|
|
348
344
|
|
|
349
|
-
await payload.
|
|
350
|
-
collection: slugs.tickets as any,
|
|
345
|
+
await dbUpdate(payload, slugs.tickets, {
|
|
351
346
|
id: doc.id,
|
|
352
347
|
data: { slaResolutionBreached: breached },
|
|
353
348
|
overrideAccess: true,
|
|
@@ -364,6 +359,45 @@ export function createCheckSlaOnResolve(slugs: CollectionSlugs, notificationSlug
|
|
|
364
359
|
}
|
|
365
360
|
}
|
|
366
361
|
|
|
362
|
+
/**
|
|
363
|
+
* SLA pause-on-hold: time spent in `waiting_client` should not count against the
|
|
364
|
+
* resolution SLA (the agent is waiting on the customer). Entering hold stamps
|
|
365
|
+
* `slaPausedAt`; leaving it pushes `slaResolutionDue` forward by the paused span.
|
|
366
|
+
*/
|
|
367
|
+
export function createPauseSlaOnHold(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
368
|
+
return async ({ doc, previousDoc, operation, req }) => {
|
|
369
|
+
if (operation !== 'update' || !previousDoc) return doc
|
|
370
|
+
if (previousDoc.status === doc.status) return doc
|
|
371
|
+
if (!doc.slaResolutionDue) return doc
|
|
372
|
+
|
|
373
|
+
const wasWaiting = previousDoc.status === 'waiting_client'
|
|
374
|
+
const isWaiting = doc.status === 'waiting_client'
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
if (!wasWaiting && isWaiting && !doc.slaPausedAt && doc.status !== 'resolved') {
|
|
378
|
+
// Entering hold — stop the resolution clock.
|
|
379
|
+
await req.payload.update({
|
|
380
|
+
collection: slugs.tickets,
|
|
381
|
+
id: doc.id,
|
|
382
|
+
data: { slaPausedAt: new Date().toISOString() },
|
|
383
|
+
overrideAccess: true,
|
|
384
|
+
})
|
|
385
|
+
} else if (wasWaiting && !isWaiting && doc.slaPausedAt) {
|
|
386
|
+
// Leaving hold — extend the deadline by however long we were paused.
|
|
387
|
+
const pausedMs = Date.now() - new Date(doc.slaPausedAt as string).getTime()
|
|
388
|
+
const data: Record<string, unknown> = { slaPausedAt: null }
|
|
389
|
+
if (pausedMs > 0) {
|
|
390
|
+
data.slaResolutionDue = new Date(new Date(doc.slaResolutionDue as string).getTime() + pausedMs).toISOString()
|
|
391
|
+
}
|
|
392
|
+
await req.payload.update({ collection: slugs.tickets, id: doc.id, data, overrideAccess: true })
|
|
393
|
+
}
|
|
394
|
+
} catch (err) {
|
|
395
|
+
console.error('[sla] Failed to pause/resume SLA on hold:', err)
|
|
396
|
+
}
|
|
397
|
+
return doc
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
367
401
|
/**
|
|
368
402
|
* Factory: Check SLA breach on first admin response.
|
|
369
403
|
* Runs as afterChange on ticket-messages collection.
|
|
@@ -377,8 +411,7 @@ export function createCheckSlaOnReply(slugs: CollectionSlugs, notificationSlug =
|
|
|
377
411
|
const { payload } = req
|
|
378
412
|
const ticketId = typeof doc.ticket === 'object' ? (doc.ticket as AnyDoc).id : doc.ticket
|
|
379
413
|
|
|
380
|
-
const ticket = await payload.
|
|
381
|
-
collection: slugs.tickets as any,
|
|
414
|
+
const ticket = await dbFindByID(payload, slugs.tickets, {
|
|
382
415
|
id: ticketId as number,
|
|
383
416
|
depth: 1,
|
|
384
417
|
overrideAccess: true,
|
|
@@ -394,8 +427,7 @@ export function createCheckSlaOnReply(slugs: CollectionSlugs, notificationSlug =
|
|
|
394
427
|
const deadline = new Date(ticket.slaFirstResponseDue as string)
|
|
395
428
|
const breached = now > deadline
|
|
396
429
|
|
|
397
|
-
await payload.
|
|
398
|
-
collection: slugs.tickets as any,
|
|
430
|
+
await dbUpdate(payload, slugs.tickets, {
|
|
399
431
|
id: ticketId as number,
|
|
400
432
|
data: { slaFirstResponseBreached: breached },
|
|
401
433
|
overrideAccess: true,
|
package/src/plugin.ts
CHANGED
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
createClientSummariesCollection,
|
|
24
24
|
createTicketFeedbackCollection,
|
|
25
25
|
createTicketCollaboratorsCollection,
|
|
26
|
+
createNotificationQueueCollection,
|
|
27
|
+
createAutomationRulesCollection,
|
|
28
|
+
createSupportTeamCollection,
|
|
26
29
|
} from './collections'
|
|
27
30
|
|
|
28
31
|
function viewConfig(component: string, path: string): AdminViewConfig {
|
|
@@ -90,6 +93,9 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
90
93
|
createKnowledgeBaseCollection(slugs),
|
|
91
94
|
createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
|
|
92
95
|
createTicketCollaboratorsCollection(slugs),
|
|
96
|
+
createNotificationQueueCollection(slugs),
|
|
97
|
+
createAutomationRulesCollection(slugs),
|
|
98
|
+
createSupportTeamCollection(slugs),
|
|
93
99
|
]
|
|
94
100
|
|
|
95
101
|
// Auth logs (conditional)
|
package/src/portal/LiveChat.tsx
CHANGED
|
@@ -2,69 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
4
4
|
import { MessageCircle, Send, X, Minimize2, ArrowLeft } from 'lucide-react'
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
interface ChatMessage {
|
|
9
|
-
id: number | string
|
|
10
|
-
body: string
|
|
11
|
-
senderType: 'client' | 'agent' | 'system'
|
|
12
|
-
createdAt: string
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface LiveChatSession {
|
|
16
|
-
clientToken: string
|
|
17
|
-
email: string
|
|
18
|
-
name: string
|
|
19
|
-
sessionId?: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
type Screen = 'closed' | 'identify' | 'chat' | 'rating'
|
|
23
|
-
|
|
24
|
-
// ─── LocalStorage keys ──────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
const LS_KEY = 'support-livechat'
|
|
27
|
-
|
|
28
|
-
function loadSession(): LiveChatSession | null {
|
|
29
|
-
if (typeof window === 'undefined') return null
|
|
30
|
-
try {
|
|
31
|
-
const raw = localStorage.getItem(LS_KEY)
|
|
32
|
-
if (!raw) return null
|
|
33
|
-
const session = JSON.parse(raw) as LiveChatSession
|
|
34
|
-
|
|
35
|
-
// Validate token expiry (24h) — extract timestamp from token
|
|
36
|
-
if (session.clientToken) {
|
|
37
|
-
const parts = session.clientToken.split('_')
|
|
38
|
-
if (parts.length === 4) {
|
|
39
|
-
const timestamp = parseInt(parts[2], 10)
|
|
40
|
-
if (!isNaN(timestamp) && Date.now() - timestamp > 24 * 60 * 60 * 1000) {
|
|
41
|
-
localStorage.removeItem(LS_KEY)
|
|
42
|
-
return null
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return session
|
|
48
|
-
} catch {
|
|
49
|
-
return null
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function saveSession(session: LiveChatSession): void {
|
|
54
|
-
try {
|
|
55
|
-
localStorage.setItem(LS_KEY, JSON.stringify(session))
|
|
56
|
-
} catch {
|
|
57
|
-
// localStorage full or disabled
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function clearSession(): void {
|
|
62
|
-
try {
|
|
63
|
-
localStorage.removeItem(LS_KEY)
|
|
64
|
-
} catch {
|
|
65
|
-
// Ignore
|
|
66
|
-
}
|
|
67
|
-
}
|
|
5
|
+
import type { ChatMessage, LiveChatSession, Screen } from './types'
|
|
6
|
+
import { loadSession, saveSession, clearSession } from './helpers'
|
|
68
7
|
|
|
69
8
|
// ─── Component ───────────────────────────────────────────────
|
|
70
9
|
|
|
@@ -7,6 +7,7 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
|
|
|
7
7
|
const router = useRouter()
|
|
8
8
|
const [loading, setLoading] = useState(false)
|
|
9
9
|
const [confirming, setConfirming] = useState(false)
|
|
10
|
+
const [error, setError] = useState('')
|
|
10
11
|
|
|
11
12
|
const handleClose = async () => {
|
|
12
13
|
if (!confirming) {
|
|
@@ -15,6 +16,7 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
setLoading(true)
|
|
19
|
+
setError('')
|
|
18
20
|
try {
|
|
19
21
|
const res = await fetch(`/api/tickets/${ticketId}`, {
|
|
20
22
|
method: 'PATCH',
|
|
@@ -25,9 +27,12 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
|
|
|
25
27
|
|
|
26
28
|
if (res.ok) {
|
|
27
29
|
router.refresh()
|
|
30
|
+
} else {
|
|
31
|
+
setError('Impossible de fermer le ticket. Veuillez réessayer.')
|
|
28
32
|
}
|
|
29
33
|
} catch (err) {
|
|
30
34
|
console.warn('[CloseTicketButton] Failed to close ticket:', err)
|
|
35
|
+
setError('Erreur de connexion. Veuillez réessayer.')
|
|
31
36
|
} finally {
|
|
32
37
|
setLoading(false)
|
|
33
38
|
setConfirming(false)
|
|
@@ -35,6 +40,10 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
return (
|
|
43
|
+
<div className="flex flex-col items-end gap-1">
|
|
44
|
+
{error && (
|
|
45
|
+
<p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
|
|
46
|
+
)}
|
|
38
47
|
<button
|
|
39
48
|
onClick={handleClose}
|
|
40
49
|
disabled={loading}
|
|
@@ -70,5 +79,6 @@ export function CloseTicketButton({ ticketId }: { ticketId: number | string }) {
|
|
|
70
79
|
</>
|
|
71
80
|
)}
|
|
72
81
|
</button>
|
|
82
|
+
</div>
|
|
73
83
|
)
|
|
74
84
|
}
|
|
@@ -12,25 +12,33 @@ export function MarkSolutionButton({
|
|
|
12
12
|
}) {
|
|
13
13
|
const router = useRouter()
|
|
14
14
|
const [loading, setLoading] = useState(false)
|
|
15
|
+
const [error, setError] = useState('')
|
|
15
16
|
|
|
16
17
|
const handleToggle = async () => {
|
|
17
18
|
setLoading(true)
|
|
19
|
+
setError('')
|
|
18
20
|
try {
|
|
19
|
-
await fetch(`/api/ticket-messages/${messageId}`, {
|
|
21
|
+
const res = await fetch(`/api/ticket-messages/${messageId}`, {
|
|
20
22
|
method: 'PATCH',
|
|
21
23
|
headers: { 'Content-Type': 'application/json' },
|
|
22
24
|
credentials: 'include',
|
|
23
25
|
body: JSON.stringify({ isSolution: !isSolution }),
|
|
24
26
|
})
|
|
25
|
-
|
|
27
|
+
if (res.ok) {
|
|
28
|
+
router.refresh()
|
|
29
|
+
} else {
|
|
30
|
+
setError('Échec. Veuillez réessayer.')
|
|
31
|
+
}
|
|
26
32
|
} catch (err) {
|
|
27
33
|
console.warn('[MarkSolutionButton] Failed to toggle solution:', err)
|
|
34
|
+
setError('Erreur de connexion.')
|
|
28
35
|
} finally {
|
|
29
36
|
setLoading(false)
|
|
30
37
|
}
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
return (
|
|
41
|
+
<div className="flex flex-col items-start gap-0.5">
|
|
34
42
|
<button
|
|
35
43
|
onClick={handleToggle}
|
|
36
44
|
disabled={loading}
|
|
@@ -46,5 +54,9 @@ export function MarkSolutionButton({
|
|
|
46
54
|
</svg>
|
|
47
55
|
{isSolution ? 'Solution' : 'Marquer comme solution'}
|
|
48
56
|
</button>
|
|
57
|
+
{error && (
|
|
58
|
+
<p className="text-[10px] font-medium text-red-500 dark:text-red-400">{error}</p>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
49
61
|
)
|
|
50
62
|
}
|
|
@@ -19,6 +19,7 @@ export function MessageActions({
|
|
|
19
19
|
const [editText, setEditText] = useState(body)
|
|
20
20
|
const [saving, setSaving] = useState(false)
|
|
21
21
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
22
|
+
const [error, setError] = useState('')
|
|
22
23
|
|
|
23
24
|
const elapsed = Date.now() - new Date(createdAt).getTime()
|
|
24
25
|
const canEdit = elapsed < EDIT_WINDOW_MS
|
|
@@ -33,6 +34,7 @@ export function MessageActions({
|
|
|
33
34
|
return
|
|
34
35
|
}
|
|
35
36
|
setSaving(true)
|
|
37
|
+
setError('')
|
|
36
38
|
try {
|
|
37
39
|
const res = await fetch(`/api/ticket-messages/${messageId}`, {
|
|
38
40
|
method: 'PATCH',
|
|
@@ -46,13 +48,18 @@ export function MessageActions({
|
|
|
46
48
|
if (res.ok) {
|
|
47
49
|
setEditing(false)
|
|
48
50
|
router.refresh()
|
|
51
|
+
} else {
|
|
52
|
+
setError('Impossible de sauvegarder. Veuillez réessayer.')
|
|
49
53
|
}
|
|
50
|
-
} catch {
|
|
54
|
+
} catch {
|
|
55
|
+
setError('Erreur de connexion. Veuillez réessayer.')
|
|
56
|
+
}
|
|
51
57
|
setSaving(false)
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
const handleDelete = async () => {
|
|
55
61
|
setSaving(true)
|
|
62
|
+
setError('')
|
|
56
63
|
try {
|
|
57
64
|
const res = await fetch(`/api/ticket-messages/${messageId}`, {
|
|
58
65
|
method: 'PATCH',
|
|
@@ -67,8 +74,12 @@ export function MessageActions({
|
|
|
67
74
|
if (res.ok) {
|
|
68
75
|
setConfirmDelete(false)
|
|
69
76
|
router.refresh()
|
|
77
|
+
} else {
|
|
78
|
+
setError('Impossible de supprimer. Veuillez réessayer.')
|
|
70
79
|
}
|
|
71
|
-
} catch {
|
|
80
|
+
} catch {
|
|
81
|
+
setError('Erreur de connexion. Veuillez réessayer.')
|
|
82
|
+
}
|
|
72
83
|
setSaving(false)
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -90,33 +101,41 @@ export function MessageActions({
|
|
|
90
101
|
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
|
91
102
|
</button>
|
|
92
103
|
<button
|
|
93
|
-
onClick={() => { setEditing(false); setEditText(body) }}
|
|
104
|
+
onClick={() => { setEditing(false); setEditText(body); setError('') }}
|
|
94
105
|
className="rounded-md border border-slate-300 dark:border-slate-600 px-3 py-1 text-xs font-semibold text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800"
|
|
95
106
|
>
|
|
96
107
|
Annuler
|
|
97
108
|
</button>
|
|
98
109
|
</div>
|
|
110
|
+
{error && (
|
|
111
|
+
<p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
|
|
112
|
+
)}
|
|
99
113
|
</div>
|
|
100
114
|
)
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
if (confirmDelete) {
|
|
104
118
|
return (
|
|
105
|
-
<div className="mt-2
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
<div className="mt-2 space-y-1.5">
|
|
120
|
+
<div className="flex items-center gap-2">
|
|
121
|
+
<span className="text-xs text-red-500">Supprimer ce message ?</span>
|
|
122
|
+
<button
|
|
123
|
+
onClick={handleDelete}
|
|
124
|
+
disabled={saving}
|
|
125
|
+
className="rounded-md bg-red-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
|
126
|
+
>
|
|
127
|
+
{saving ? '...' : 'Confirmer'}
|
|
128
|
+
</button>
|
|
129
|
+
<button
|
|
130
|
+
onClick={() => { setConfirmDelete(false); setError('') }}
|
|
131
|
+
className="rounded-md border border-slate-300 dark:border-slate-600 px-2.5 py-1 text-xs text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800"
|
|
132
|
+
>
|
|
133
|
+
Annuler
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
{error && (
|
|
137
|
+
<p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
|
|
138
|
+
)}
|
|
120
139
|
</div>
|
|
121
140
|
)
|
|
122
141
|
}
|
|
@@ -7,6 +7,7 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
|
|
|
7
7
|
const router = useRouter()
|
|
8
8
|
const [loading, setLoading] = useState(false)
|
|
9
9
|
const [confirming, setConfirming] = useState(false)
|
|
10
|
+
const [error, setError] = useState('')
|
|
10
11
|
|
|
11
12
|
const handleReopen = async () => {
|
|
12
13
|
if (!confirming) {
|
|
@@ -15,6 +16,7 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
setLoading(true)
|
|
19
|
+
setError('')
|
|
18
20
|
try {
|
|
19
21
|
const res = await fetch(`/api/tickets/${ticketId}`, {
|
|
20
22
|
method: 'PATCH',
|
|
@@ -25,9 +27,12 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
|
|
|
25
27
|
|
|
26
28
|
if (res.ok) {
|
|
27
29
|
router.refresh()
|
|
30
|
+
} else {
|
|
31
|
+
setError('Impossible de rouvrir le ticket. Veuillez réessayer.')
|
|
28
32
|
}
|
|
29
33
|
} catch (err) {
|
|
30
34
|
console.warn('[ReopenTicketButton] Failed to reopen ticket:', err)
|
|
35
|
+
setError('Erreur de connexion. Veuillez réessayer.')
|
|
31
36
|
} finally {
|
|
32
37
|
setLoading(false)
|
|
33
38
|
setConfirming(false)
|
|
@@ -35,6 +40,10 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
return (
|
|
43
|
+
<div className="flex flex-col items-end gap-1">
|
|
44
|
+
{error && (
|
|
45
|
+
<p className="text-xs font-medium text-red-600 dark:text-red-400">{error}</p>
|
|
46
|
+
)}
|
|
38
47
|
<button
|
|
39
48
|
onClick={handleReopen}
|
|
40
49
|
disabled={loading}
|
|
@@ -70,5 +79,6 @@ export function ReopenTicketButton({ ticketId }: { ticketId: number | string })
|
|
|
70
79
|
</>
|
|
71
80
|
)}
|
|
72
81
|
</button>
|
|
82
|
+
</div>
|
|
73
83
|
)
|
|
74
84
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
4
|
|
|
5
5
|
type Toast = { type: 'success' | 'error'; message: string } | null
|
|
6
|
+
type ToastPayload = NonNullable<Toast>
|
|
6
7
|
type Modal = 'transfer' | 'invite' | null
|
|
7
8
|
|
|
8
9
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
@@ -116,7 +117,7 @@ function TransferModal({
|
|
|
116
117
|
}: {
|
|
117
118
|
ticketId: number | string
|
|
118
119
|
onClose: () => void
|
|
119
|
-
onResult: (t:
|
|
120
|
+
onResult: (t: ToastPayload) => void
|
|
120
121
|
}) {
|
|
121
122
|
const [email, setEmail] = useState('')
|
|
122
123
|
const [includeAttachments, setIncludeAttachments] = useState(true)
|
|
@@ -204,7 +205,7 @@ function InviteModal({
|
|
|
204
205
|
}: {
|
|
205
206
|
ticketId: number | string
|
|
206
207
|
onClose: () => void
|
|
207
|
-
onResult: (t:
|
|
208
|
+
onResult: (t: ToastPayload) => void
|
|
208
209
|
}) {
|
|
209
210
|
const [email, setEmail] = useState('')
|
|
210
211
|
const [role, setRole] = useState<'viewer' | 'collaborator'>('viewer')
|
|
@@ -748,7 +748,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
|
|
|
748
748
|
{/* Edit/delete actions for client messages */}
|
|
749
749
|
{isClient && msg.authorType === 'client' && !(msg as unknown as { deletedAt?: string }).deletedAt && (
|
|
750
750
|
<MessageActions
|
|
751
|
-
messageId={msg.id}
|
|
751
|
+
messageId={Number(msg.id)}
|
|
752
752
|
body={msg.body}
|
|
753
753
|
createdAt={msg.createdAt}
|
|
754
754
|
/>
|
|
@@ -786,7 +786,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
|
|
|
786
786
|
|
|
787
787
|
{/* Typing indicator inside conversation */}
|
|
788
788
|
<div className="px-4 sm:px-6 pb-2">
|
|
789
|
-
<TypingIndicator ticketId={ticket.id} />
|
|
789
|
+
<TypingIndicator ticketId={Number(ticket.id)} />
|
|
790
790
|
</div>
|
|
791
791
|
</div>
|
|
792
792
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { LiveChatSession } from './types'
|
|
2
|
+
|
|
3
|
+
// ─── LocalStorage keys ──────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export const LS_KEY = 'support-livechat'
|
|
6
|
+
|
|
7
|
+
export function loadSession(): LiveChatSession | null {
|
|
8
|
+
if (typeof window === 'undefined') return null
|
|
9
|
+
try {
|
|
10
|
+
const raw = localStorage.getItem(LS_KEY)
|
|
11
|
+
if (!raw) return null
|
|
12
|
+
const session = JSON.parse(raw) as LiveChatSession
|
|
13
|
+
|
|
14
|
+
// Validate token expiry (24h) — extract timestamp from token
|
|
15
|
+
if (session.clientToken) {
|
|
16
|
+
const parts = session.clientToken.split('_')
|
|
17
|
+
if (parts.length === 4) {
|
|
18
|
+
const timestamp = parseInt(parts[2], 10)
|
|
19
|
+
if (!isNaN(timestamp) && Date.now() - timestamp > 24 * 60 * 60 * 1000) {
|
|
20
|
+
localStorage.removeItem(LS_KEY)
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return session
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveSession(session: LiveChatSession): void {
|
|
33
|
+
try {
|
|
34
|
+
localStorage.setItem(LS_KEY, JSON.stringify(session))
|
|
35
|
+
} catch {
|
|
36
|
+
// localStorage full or disabled
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function clearSession(): void {
|
|
41
|
+
try {
|
|
42
|
+
localStorage.removeItem(LS_KEY)
|
|
43
|
+
} catch {
|
|
44
|
+
// Ignore
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -55,14 +55,9 @@ function SupportLoginContent() {
|
|
|
55
55
|
return
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
await fetch('/api/support-clients/logout', {
|
|
62
|
-
method: 'POST',
|
|
63
|
-
credentials: 'include',
|
|
64
|
-
})
|
|
65
|
-
|
|
58
|
+
// Server requires 2FA: credentials were valid but NO session was issued.
|
|
59
|
+
// Request the code and switch to the verification step.
|
|
60
|
+
if (data.requires2FA) {
|
|
66
61
|
// Send 2FA code
|
|
67
62
|
setSending2FA(true)
|
|
68
63
|
const codeRes = await fetch('/api/support/2fa', {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ChatMessage {
|
|
2
|
+
id: number | string
|
|
3
|
+
body: string
|
|
4
|
+
senderType: 'client' | 'agent' | 'system'
|
|
5
|
+
createdAt: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface LiveChatSession {
|
|
9
|
+
clientToken: string
|
|
10
|
+
email: string
|
|
11
|
+
name: string
|
|
12
|
+
sessionId?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Screen = 'closed' | 'identify' | 'chat' | 'rating'
|