@consilioweb/payload-support 0.10.1 → 0.11.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.
Files changed (39) hide show
  1. package/dist/components/TicketConversation/locales/en.json +25 -1
  2. package/dist/components/TicketConversation/locales/fr.json +25 -1
  3. package/dist/index.cjs +799 -4
  4. package/dist/index.d.cts +15 -1
  5. package/dist/index.d.ts +15 -1
  6. package/dist/index.js +800 -6
  7. package/dist/styles/TicketDetail.module.scss +294 -0
  8. package/dist/views/TicketDetailView/client.cjs +455 -249
  9. package/dist/views/TicketDetailView/client.js +456 -250
  10. package/package.json +1 -1
  11. package/src/collections/ClientSummaries.ts +16 -0
  12. package/src/collections/TicketCollaborators.ts +93 -0
  13. package/src/collections/TicketFeedback.ts +116 -0
  14. package/src/collections/TicketMessages.ts +9 -0
  15. package/src/collections/Tickets.ts +31 -1
  16. package/src/collections/index.ts +2 -0
  17. package/src/components/TicketConversation/locales/en.json +25 -1
  18. package/src/components/TicketConversation/locales/fr.json +25 -1
  19. package/src/endpoints/escalate.ts +44 -0
  20. package/src/endpoints/index.ts +15 -0
  21. package/src/endpoints/invite-collaborator.ts +215 -0
  22. package/src/endpoints/kb-search.ts +156 -0
  23. package/src/endpoints/ticket-feedback.ts +104 -0
  24. package/src/endpoints/transfer-ticket.ts +248 -0
  25. package/src/index.ts +1 -0
  26. package/src/plugin.ts +4 -0
  27. package/src/portal/auth/ChatWidget.tsx +11 -0
  28. package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
  29. package/src/portal/auth/dashboard/page.tsx +11 -2
  30. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
  31. package/src/portal/auth/tickets/detail/page.tsx +122 -23
  32. package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
  33. package/src/portal/auth/tickets/new/page.tsx +203 -100
  34. package/src/portal/locales/en.json +5 -0
  35. package/src/portal/locales/fr.json +5 -0
  36. package/src/styles/TicketDetail.module.scss +294 -0
  37. package/src/types.ts +19 -0
  38. package/src/utils/slugs.ts +2 -0
  39. package/src/views/TicketDetailView/client.tsx +346 -89
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "0.10.1",
3
+ "version": "0.11.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",
@@ -59,6 +59,22 @@ export function createClientSummariesCollection(slugs: CollectionSlugs): Collect
59
59
  admin: { readOnly: true },
60
60
  // Array of strings: "Hébergé chez OVH", "Site WordPress", etc.
61
61
  },
62
+ {
63
+ name: 'nextActions',
64
+ type: 'array',
65
+ label: 'Prochaines actions suggérées',
66
+ admin: { description: 'Liste des actions suggérées par l\'IA' },
67
+ fields: [
68
+ { name: 'label', type: 'text', required: true },
69
+ {
70
+ name: 'priority',
71
+ type: 'select',
72
+ options: ['now', 'today', 'this-week'],
73
+ defaultValue: 'today',
74
+ },
75
+ { name: 'done', type: 'checkbox', defaultValue: false },
76
+ ],
77
+ },
62
78
  // ── Stats ──
63
79
  {
64
80
  name: 'ticketCount',
@@ -0,0 +1,93 @@
1
+ import type { CollectionConfig } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+
4
+ /**
5
+ * Collaborators (viewers/contributors) added to a ticket by the primary client owner.
6
+ *
7
+ * Created via the `/api/support/tickets/:id/invite` endpoint. Each row links a
8
+ * ticket to a `support-clients` (the invitee) with a role: `viewer` (read-only)
9
+ * or `collaborator` (can post messages, when wired up later).
10
+ *
11
+ * Access logic on Tickets/TicketMessages should OR this table to grant access
12
+ * to invited clients without losing existing ownership-based rules.
13
+ */
14
+ export function createTicketCollaboratorsCollection(slugs: CollectionSlugs): CollectionConfig {
15
+ return {
16
+ slug: 'ticket-collaborators',
17
+ labels: {
18
+ singular: 'Collaborateur ticket',
19
+ plural: 'Collaborateurs tickets',
20
+ },
21
+ admin: {
22
+ useAsTitle: 'email',
23
+ group: 'Support',
24
+ defaultColumns: ['email', 'ticket', 'client', 'role', 'invitedBy', 'createdAt'],
25
+ },
26
+ access: {
27
+ // Admins always; clients only see rows that reference them.
28
+ read: ({ req }) => {
29
+ if (req.user?.collection === slugs.users) return true
30
+ if (req.user?.collection === slugs.supportClients) {
31
+ return { client: { equals: req.user.id } }
32
+ }
33
+ return false
34
+ },
35
+ create: ({ req }) => !!req.user,
36
+ update: () => false,
37
+ delete: ({ req }) => req.user?.collection === slugs.users,
38
+ },
39
+ fields: [
40
+ {
41
+ name: 'ticket',
42
+ type: 'relationship',
43
+ relationTo: slugs.tickets,
44
+ required: true,
45
+ hasMany: false,
46
+ label: 'Ticket',
47
+ },
48
+ {
49
+ name: 'client',
50
+ type: 'relationship',
51
+ relationTo: slugs.supportClients,
52
+ required: true,
53
+ label: 'Client invité',
54
+ },
55
+ {
56
+ name: 'email',
57
+ type: 'email',
58
+ index: true,
59
+ label: 'Email invité',
60
+ admin: { description: 'Cache de l\'email du client invité au moment de l\'invitation' },
61
+ },
62
+ {
63
+ name: 'role',
64
+ type: 'select',
65
+ defaultValue: 'viewer',
66
+ options: [
67
+ { label: 'Lecteur', value: 'viewer' },
68
+ { label: 'Collaborateur', value: 'collaborator' },
69
+ ],
70
+ label: 'Rôle',
71
+ },
72
+ {
73
+ name: 'invitedBy',
74
+ type: 'relationship',
75
+ relationTo: [slugs.users, slugs.supportClients],
76
+ required: true,
77
+ label: 'Invité par',
78
+ },
79
+ {
80
+ name: 'acceptedAt',
81
+ type: 'date',
82
+ label: 'Accepté le',
83
+ },
84
+ {
85
+ name: 'invitationToken',
86
+ type: 'text',
87
+ index: true,
88
+ admin: { hidden: true },
89
+ },
90
+ ],
91
+ timestamps: true,
92
+ }
93
+ }
@@ -0,0 +1,116 @@
1
+ import type { CollectionConfig, CollectionAfterChangeHook } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { createAdminNotification } from '../utils/adminNotification'
4
+
5
+ // ─── Hooks ───────────────────────────────────────────────
6
+
7
+ function createAlertOnLowRating(slugs: CollectionSlugs, notificationSlug: string): CollectionAfterChangeHook {
8
+ return async ({ doc, operation, req }) => {
9
+ if (operation !== 'create') return doc
10
+ const rating = typeof doc.rating === 'number' ? doc.rating : Number(doc.rating)
11
+ if (!Number.isFinite(rating) || rating > 2) return doc
12
+
13
+ try {
14
+ const ticketId = typeof doc.ticket === 'object' ? doc.ticket?.id : doc.ticket
15
+ const ticketNumber = typeof doc.ticket === 'object' ? doc.ticket?.ticketNumber : undefined
16
+ const ticketLabel = ticketNumber || (ticketId ? `#${ticketId}` : '?')
17
+
18
+ await createAdminNotification(req.payload, {
19
+ title: `Client mecontent : ${ticketLabel}`,
20
+ message: `Note ${rating}/5${doc.comment ? ` — ${String(doc.comment).slice(0, 200)}` : ''}`,
21
+ type: 'satisfaction',
22
+ link: ticketId ? `/admin/collections/${slugs.tickets}/${ticketId}` : undefined,
23
+ }, notificationSlug)
24
+ } catch (err) {
25
+ console.error('[support] Failed to alert on low rating:', err)
26
+ }
27
+ return doc
28
+ }
29
+ }
30
+
31
+ // ─── Collection factory ──────────────────────────────────
32
+
33
+ export function createTicketFeedbackCollection(slugs: CollectionSlugs, options?: {
34
+ notificationSlug?: string
35
+ }): CollectionConfig {
36
+ const notificationSlug = options?.notificationSlug || 'admin-notifications'
37
+
38
+ return {
39
+ slug: 'ticket-feedback',
40
+ labels: {
41
+ singular: 'Feedback ticket',
42
+ plural: 'Feedbacks tickets',
43
+ },
44
+ admin: {
45
+ useAsTitle: 'rating',
46
+ group: 'Support',
47
+ defaultColumns: ['ticket', 'rating', 'client', 'submittedFrom', 'createdAt'],
48
+ },
49
+ access: {
50
+ // Creation is gated by the dedicated endpoint (signed token / portal session).
51
+ // We accept admins and authenticated support clients here; anonymous creation
52
+ // must go through the endpoint which uses overrideAccess.
53
+ create: ({ req }) => {
54
+ if (req.user?.collection === slugs.users) return true
55
+ if (req.user?.collection === slugs.supportClients) return true
56
+ return false
57
+ },
58
+ read: ({ req }) => {
59
+ // Admins only — keeps feedback private from clients
60
+ return req.user?.collection === slugs.users
61
+ },
62
+ update: () => false,
63
+ delete: ({ req }) => req.user?.collection === slugs.users,
64
+ },
65
+ fields: [
66
+ {
67
+ name: 'ticket',
68
+ type: 'relationship',
69
+ relationTo: slugs.tickets,
70
+ required: true,
71
+ hasMany: false,
72
+ unique: true,
73
+ label: 'Ticket',
74
+ admin: { readOnly: true },
75
+ },
76
+ {
77
+ name: 'rating',
78
+ type: 'number',
79
+ required: true,
80
+ min: 1,
81
+ max: 5,
82
+ label: 'Note (1-5)',
83
+ admin: { description: '1 = tres insatisfait, 5 = tres satisfait' },
84
+ },
85
+ {
86
+ name: 'comment',
87
+ type: 'textarea',
88
+ label: 'Commentaire',
89
+ admin: { description: 'Commentaire optionnel du client' },
90
+ },
91
+ {
92
+ name: 'client',
93
+ type: 'relationship',
94
+ relationTo: slugs.supportClients,
95
+ label: 'Client',
96
+ admin: { readOnly: true },
97
+ },
98
+ {
99
+ name: 'submittedFrom',
100
+ type: 'select',
101
+ defaultValue: 'portal',
102
+ label: 'Source',
103
+ options: [
104
+ { label: 'Portail', value: 'portal' },
105
+ { label: 'Email', value: 'email' },
106
+ { label: 'Admin', value: 'admin' },
107
+ ],
108
+ admin: { readOnly: true },
109
+ },
110
+ ],
111
+ hooks: {
112
+ afterChange: [createAlertOnLowRating(slugs, notificationSlug)],
113
+ },
114
+ timestamps: true,
115
+ }
116
+ }
@@ -311,6 +311,15 @@ export function createTicketMessagesCollection(slugs: CollectionSlugs, options?:
311
311
  name: 'attachments', type: 'array', label: 'Pieces jointes',
312
312
  fields: [{ name: 'file', type: 'upload', relationTo: slugs.media, required: true, label: 'Fichier' }],
313
313
  },
314
+ {
315
+ name: 'fromAlias',
316
+ type: 'text',
317
+ label: 'Alias d\'expéditeur',
318
+ admin: {
319
+ description: 'Si défini, le message est envoyé au nom de cet alias plutôt que de l\'agent authentifié',
320
+ position: 'sidebar',
321
+ },
322
+ },
314
323
  { name: 'isInternal', type: 'checkbox', defaultValue: false, label: 'Note interne', admin: { position: 'sidebar' } },
315
324
  { name: 'isSolution', type: 'checkbox', defaultValue: false, label: 'Reponse solution', admin: { position: 'sidebar' } },
316
325
  { name: 'skipNotification', type: 'checkbox', defaultValue: false, label: 'Sans notification', admin: { position: 'sidebar', condition: (data) => data?.skipNotification === true } },
@@ -4,6 +4,7 @@ import type {
4
4
  CollectionAfterChangeHook,
5
5
  CollectionBeforeDeleteHook,
6
6
  Field,
7
+ Where,
7
8
  } from 'payload'
8
9
  import type { CollectionSlugs } from '../utils/slugs'
9
10
  import { escapeHtml } from '../utils/emailTemplate'
@@ -604,6 +605,8 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
604
605
  { label: 'Ouvert', value: 'open' },
605
606
  { label: 'En attente client', value: 'waiting_client' },
606
607
  { label: 'Resolu', value: 'resolved' },
608
+ { label: 'Ferme', value: 'closed' },
609
+ { label: 'Escalade', value: 'escalated' },
607
610
  ],
608
611
  admin: { width: '50%' },
609
612
  },
@@ -807,9 +810,36 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
807
810
  if (req.user?.collection === slugs.supportClients) return true
808
811
  return false
809
812
  },
810
- read: ({ req }) => {
813
+ read: async ({ req }) => {
811
814
  if (req.user?.collection === slugs.users) return true
812
815
  if (req.user?.collection === slugs.supportClients) {
816
+ // Owner OR collaborator (via ticket-collaborators table).
817
+ // We resolve the collaborator ticket-ids upfront so we keep a single Where
818
+ // expression — Payload's access fns support async returns.
819
+ let collabTicketIds: Array<number | string> = []
820
+ try {
821
+ const rows = await req.payload.find({
822
+ collection: 'ticket-collaborators' as any,
823
+ where: { client: { equals: req.user.id } },
824
+ limit: 1000,
825
+ depth: 0,
826
+ overrideAccess: true,
827
+ })
828
+ collabTicketIds = rows.docs
829
+ .map((r: any) => (typeof r.ticket === 'object' ? r.ticket?.id : r.ticket))
830
+ .filter((v: unknown): v is number | string => v !== undefined && v !== null)
831
+ } catch {
832
+ // ticket-collaborators collection may not yet exist on older deployments — fall back to owner-only.
833
+ }
834
+ if (collabTicketIds.length > 0) {
835
+ const where: Where = {
836
+ or: [
837
+ { client: { equals: req.user.id } },
838
+ { id: { in: collabTicketIds } },
839
+ ],
840
+ }
841
+ return where
842
+ }
813
843
  return { client: { equals: req.user.id } }
814
844
  }
815
845
  return false
@@ -15,3 +15,5 @@ export { createSlaPoliciesCollection } from './SlaPolicies'
15
15
  export { createMacrosCollection } from './Macros'
16
16
  export { createTicketStatusesCollection } from './TicketStatuses'
17
17
  export { createClientSummariesCollection } from './ClientSummaries'
18
+ export { createTicketFeedbackCollection } from './TicketFeedback'
19
+ export { createTicketCollaboratorsCollection } from './TicketCollaborators'
@@ -176,6 +176,12 @@
176
176
  }
177
177
  },
178
178
  "composer": {
179
+ "aiSuggestion": {
180
+ "label": "Contextual suggestion",
181
+ "ignore": "Dismiss",
182
+ "insert": "Insert",
183
+ "loading": "Generating…"
184
+ },
179
185
  "placeholder": "Write a reply to the client...",
180
186
  "placeholderInternal": "Internal note...",
181
187
  "placeholderReplyTo": "Reply to {{name}}...",
@@ -209,6 +215,11 @@
209
215
  "shortcuts": {
210
216
  "sendHint": "\u2318Enter to send",
211
217
  "noteHint": "\u2318\u21e7N internal note"
218
+ },
219
+ "aliases": {
220
+ "label": "Send as",
221
+ "tooltip": "Choose a sender alias for this message",
222
+ "self": "(default)"
212
223
  }
213
224
  },
214
225
  "settings": {
@@ -788,7 +799,20 @@
788
799
  "statusWaiting": "Waiting",
789
800
  "statusResolved": "Resolved",
790
801
  "selectTicket": "Select a ticket from the list",
791
- "notFound": "Ticket not found"
802
+ "notFound": "Ticket not found",
803
+ "sidebar": {
804
+ "tabs": {
805
+ "overview": "Overview",
806
+ "client": "Client",
807
+ "activity": "Activity"
808
+ },
809
+ "previousTickets": "Previous tickets"
810
+ },
811
+ "aiCard": {
812
+ "nextActions": {
813
+ "title": "Suggested next actions"
814
+ }
815
+ }
792
816
  },
793
817
  "settingsView": {
794
818
  "configTitle": "Support Module Configuration",
@@ -176,6 +176,12 @@
176
176
  }
177
177
  },
178
178
  "composer": {
179
+ "aiSuggestion": {
180
+ "label": "Suggestion contextuelle",
181
+ "ignore": "Ignorer",
182
+ "insert": "Insérer",
183
+ "loading": "Génération…"
184
+ },
179
185
  "placeholder": "Écrire une réponse au client...",
180
186
  "placeholderInternal": "Note interne...",
181
187
  "placeholderReplyTo": "Répondre à {{name}}...",
@@ -209,6 +215,11 @@
209
215
  "shortcuts": {
210
216
  "sendHint": "\u2318Enter pour envoyer",
211
217
  "noteHint": "\u2318\u21e7N note interne"
218
+ },
219
+ "aliases": {
220
+ "label": "Envoyer en tant que",
221
+ "tooltip": "Choisir un alias d'exp\u00e9diteur pour ce message",
222
+ "self": "(par d\u00e9faut)"
212
223
  }
213
224
  },
214
225
  "settings": {
@@ -788,7 +799,20 @@
788
799
  "statusWaiting": "En attente",
789
800
  "statusResolved": "Resolu",
790
801
  "selectTicket": "Selectionnez un ticket depuis la liste",
791
- "notFound": "Ticket introuvable"
802
+ "notFound": "Ticket introuvable",
803
+ "sidebar": {
804
+ "tabs": {
805
+ "overview": "Vue d'ensemble",
806
+ "client": "Client",
807
+ "activity": "Activite"
808
+ },
809
+ "previousTickets": "Tickets precedents"
810
+ },
811
+ "aiCard": {
812
+ "nextActions": {
813
+ "title": "Prochaines actions suggérées"
814
+ }
815
+ }
792
816
  },
793
817
  "settingsView": {
794
818
  "configTitle": "Configuration du module Support",
@@ -0,0 +1,44 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+
5
+ /**
6
+ * POST /api/support/tickets/:id/escalate
7
+ *
8
+ * Admin-only. Transitions a ticket to the "escalated" status.
9
+ * Used when a ticket needs senior attention or is breaching SLA.
10
+ */
11
+ export function createEscalateEndpoint(slugs: CollectionSlugs): Endpoint {
12
+ return {
13
+ path: '/support/tickets/:id/escalate',
14
+ method: 'post',
15
+ handler: async (req) => {
16
+ try {
17
+ requireAdmin(req, slugs)
18
+
19
+ const idRaw = req.routeParams?.id as string | undefined
20
+ if (!idRaw) {
21
+ return Response.json({ error: 'missing-id' }, { status: 400 })
22
+ }
23
+ const ticketId = Number(idRaw)
24
+ if (Number.isNaN(ticketId)) {
25
+ return Response.json({ error: 'invalid-id' }, { status: 400 })
26
+ }
27
+
28
+ const ticket = await req.payload.update({
29
+ collection: slugs.tickets as any,
30
+ id: ticketId,
31
+ data: { status: 'escalated' },
32
+ overrideAccess: true,
33
+ })
34
+
35
+ return Response.json({ ok: true, ticket })
36
+ } catch (err) {
37
+ const authResponse = handleAuthError(err)
38
+ if (authResponse) return authResponse
39
+ console.error('[support/escalate] Error:', err)
40
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
41
+ }
42
+ },
43
+ }
44
+ }
@@ -5,6 +5,7 @@ import type { SupportFeatures } from '../types'
5
5
  import { createAiEndpoint } from './ai'
6
6
  import { createClientIntelligenceEndpoint } from './client-intelligence'
7
7
  import { createSearchEndpoint } from './search'
8
+ import { createKbSearchEndpoint } from './kb-search'
8
9
  import { createBulkActionEndpoint } from './bulk-action'
9
10
  import { createMergeTicketsEndpoint } from './merge-tickets'
10
11
  import { createSplitTicketEndpoint } from './split-ticket'
@@ -42,10 +43,15 @@ import { createMergeClientsEndpoint } from './merge-clients'
42
43
  import { createImportConversationEndpoint } from './import-conversation'
43
44
  import { createProcessScheduledEndpoint } from './process-scheduled'
44
45
  import { createUserPrefsGetEndpoint, createUserPrefsPostEndpoint } from './user-prefs'
46
+ import { createEscalateEndpoint } from './escalate'
47
+ import { createTicketFeedbackEndpoint } from './ticket-feedback'
48
+ import { createTransferTicketEndpoint } from './transfer-ticket'
49
+ import { createInviteCollaboratorEndpoint } from './invite-collaborator'
45
50
 
46
51
  // Re-export all individual endpoint creators
47
52
  export { createAiEndpoint } from './ai'
48
53
  export { createSearchEndpoint } from './search'
54
+ export { createKbSearchEndpoint } from './kb-search'
49
55
  export { createBulkActionEndpoint } from './bulk-action'
50
56
  export { createMergeTicketsEndpoint } from './merge-tickets'
51
57
  export { createSplitTicketEndpoint } from './split-ticket'
@@ -84,6 +90,10 @@ export { createImportConversationEndpoint } from './import-conversation'
84
90
  export { createProcessScheduledEndpoint } from './process-scheduled'
85
91
  export { createUserPrefsGetEndpoint, createUserPrefsPostEndpoint } from './user-prefs'
86
92
  export type { UserPrefs } from './user-prefs'
93
+ export { createEscalateEndpoint } from './escalate'
94
+ export { createTicketFeedbackEndpoint } from './ticket-feedback'
95
+ export { createTransferTicketEndpoint } from './transfer-ticket'
96
+ export { createInviteCollaboratorEndpoint } from './invite-collaborator'
87
97
 
88
98
  export interface SupportEndpointOptions {
89
99
  oauth?: OAuthGoogleOptions
@@ -101,6 +111,7 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
101
111
  // Core endpoints (always present)
102
112
  const endpoints: Endpoint[] = [
103
113
  createSearchEndpoint(slugs),
114
+ createKbSearchEndpoint(slugs),
104
115
  createSettingsGetEndpoint(slugs),
105
116
  createSettingsPostEndpoint(slugs),
106
117
  createAdminStatsEndpoint(slugs),
@@ -117,6 +128,10 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
117
128
  createResendNotificationEndpoint(slugs),
118
129
  createUserPrefsGetEndpoint(slugs),
119
130
  createUserPrefsPostEndpoint(slugs),
131
+ createEscalateEndpoint(slugs),
132
+ createTicketFeedbackEndpoint(slugs),
133
+ createTransferTicketEndpoint(slugs),
134
+ createInviteCollaboratorEndpoint(slugs),
120
135
  ]
121
136
 
122
137
  // Conditional endpoints based on feature flags