@consilioweb/payload-support 0.10.1 → 0.12.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 +899 -353
  8. package/dist/views/TicketDetailView/client.cjs +504 -282
  9. package/dist/views/TicketDetailView/client.js +505 -283
  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 +899 -353
  37. package/src/types.ts +19 -0
  38. package/src/utils/slugs.ts +2 -0
  39. package/src/views/TicketDetailView/client.tsx +404 -121
@@ -0,0 +1,215 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { handleAuthError, AuthError } from '../utils/auth'
4
+ import { escapeHtml, emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
5
+ import { readSupportSettings } from '../utils/readSettings'
6
+ import { randomBytes } from 'crypto'
7
+
8
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
9
+
10
+ /**
11
+ * POST /api/support/tickets/:id/invite
12
+ *
13
+ * Authenticated endpoint — admin OR ticket owner can invite an additional
14
+ * `support-clients` to view or collaborate on the ticket.
15
+ *
16
+ * Body: { email: string, role?: 'viewer' | 'collaborator' }
17
+ *
18
+ * Behaviour:
19
+ * - Verifies access (ownership or admin)
20
+ * - Looks up an existing `support-clients` with this email; creates one if missing.
21
+ * Newly-created clients are minimal (firstName="Invité", lastName="", company="—")
22
+ * so they can be enriched later via the standard portal flow.
23
+ * - Upserts a row into `ticket-collaborators` (one per (ticket, client)).
24
+ * - Sends an invitation email with a magic link `/support/tickets/:id?inviteToken=...`.
25
+ * - Returns { ok, invitedTo, role }.
26
+ */
27
+ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoint {
28
+ return {
29
+ path: '/support/tickets/:id/invite',
30
+ method: 'post',
31
+ handler: async (req) => {
32
+ try {
33
+ if (!req.user) throw new AuthError('Authentication required', 401)
34
+
35
+ const idRaw = req.routeParams?.id as string | undefined
36
+ if (!idRaw) return Response.json({ error: 'missing-id' }, { status: 400 })
37
+ const ticketId = Number(idRaw)
38
+ if (Number.isNaN(ticketId)) return Response.json({ error: 'invalid-id' }, { status: 400 })
39
+
40
+ let body: { email?: string; role?: 'viewer' | 'collaborator' }
41
+ try {
42
+ body = await req.json!()
43
+ } catch {
44
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
45
+ }
46
+
47
+ const email = (body.email || '').trim().toLowerCase()
48
+ if (!email || !EMAIL_RE.test(email) || email.length > 254) {
49
+ return Response.json({ error: 'Email invalide' }, { status: 400 })
50
+ }
51
+ const role = body.role === 'collaborator' ? 'collaborator' : 'viewer'
52
+
53
+ const payload = req.payload
54
+
55
+ const ticket = await payload.findByID({
56
+ collection: slugs.tickets as any,
57
+ id: ticketId,
58
+ depth: 1,
59
+ overrideAccess: true,
60
+ }) as any
61
+ if (!ticket) return Response.json({ error: 'Ticket introuvable' }, { status: 404 })
62
+
63
+ const isAdmin = req.user.collection === slugs.users
64
+ const ownerId = typeof ticket.client === 'object' ? ticket.client?.id : ticket.client
65
+ const isOwner =
66
+ req.user.collection === slugs.supportClients && ownerId === req.user.id
67
+ if (!isAdmin && !isOwner) {
68
+ return Response.json({ error: 'Forbidden' }, { status: 403 })
69
+ }
70
+
71
+ // Prevent inviting the primary owner (no-op)
72
+ const ownerEmail =
73
+ typeof ticket.client === 'object' ? (ticket.client?.email as string | undefined) : undefined
74
+ if (ownerEmail && ownerEmail.toLowerCase() === email) {
75
+ return Response.json({ error: 'Ce client est déjà propriétaire du ticket' }, { status: 400 })
76
+ }
77
+
78
+ // Find-or-create the invited support-client
79
+ let inviteeId: number | string | null = null
80
+ const existing = await payload.find({
81
+ collection: slugs.supportClients as any,
82
+ where: { email: { equals: email } },
83
+ limit: 1,
84
+ depth: 0,
85
+ overrideAccess: true,
86
+ })
87
+ if (existing.docs.length > 0) {
88
+ inviteeId = (existing.docs[0] as any).id
89
+ } else {
90
+ // Create a placeholder client; password is random — the invitee will reset
91
+ // via the forgotPassword flow triggered by the SupportClients afterChange hook.
92
+ const tempPassword = randomBytes(16).toString('hex')
93
+ const created = await payload.create({
94
+ collection: slugs.supportClients as any,
95
+ data: {
96
+ email,
97
+ firstName: 'Invité',
98
+ lastName: email.split('@')[0]?.slice(0, 40) || '',
99
+ company: '—',
100
+ password: tempPassword,
101
+ } as any,
102
+ overrideAccess: true,
103
+ // Skip the "welcome" hook firing — let invitation email do the welcome instead
104
+ context: { skipInviteEmail: true } as any,
105
+ })
106
+ inviteeId = (created as any).id
107
+ }
108
+
109
+ if (!inviteeId) {
110
+ return Response.json({ error: 'Impossible de créer le client invité' }, { status: 500 })
111
+ }
112
+
113
+ // Upsert ticket-collaborator row (one per (ticket, client))
114
+ const dupe = await payload.find({
115
+ collection: 'ticket-collaborators' as any,
116
+ where: {
117
+ and: [
118
+ { ticket: { equals: ticketId } },
119
+ { client: { equals: inviteeId } },
120
+ ],
121
+ },
122
+ limit: 1,
123
+ depth: 0,
124
+ overrideAccess: true,
125
+ })
126
+
127
+ const invitationToken = randomBytes(24).toString('hex')
128
+
129
+ if (dupe.docs.length > 0) {
130
+ await payload.update({
131
+ collection: 'ticket-collaborators' as any,
132
+ id: (dupe.docs[0] as any).id,
133
+ data: { role, invitationToken },
134
+ overrideAccess: true,
135
+ })
136
+ } else {
137
+ await payload.create({
138
+ collection: 'ticket-collaborators' as any,
139
+ data: {
140
+ ticket: ticketId,
141
+ client: inviteeId,
142
+ email,
143
+ role,
144
+ invitedBy: {
145
+ relationTo: req.user.collection,
146
+ value: req.user.id,
147
+ },
148
+ invitationToken,
149
+ } as any,
150
+ overrideAccess: true,
151
+ })
152
+ }
153
+
154
+ // Send invitation email
155
+ const settings = await readSupportSettings(payload).catch(() => null)
156
+ const replyTo = settings?.email?.replyToAddress || process.env.SUPPORT_REPLY_TO || ''
157
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
158
+ const ticketNumber = (ticket.ticketNumber as string) || `#${ticketId}`
159
+ const subject = (ticket.subject as string) || 'Ticket'
160
+ const inviteUrl = `${baseUrl}/support/tickets/${ticketId}?inviteToken=${invitationToken}`
161
+ const inviterLabel =
162
+ req.user.collection === slugs.users
163
+ ? 'L\'équipe support'
164
+ : `${(req.user as any).firstName || ''} ${(req.user as any).lastName || ''}`.trim() || 'Un collègue'
165
+
166
+ const roleLabel = role === 'collaborator' ? 'collaborateur (peut répondre)' : 'lecteur (consultation)'
167
+
168
+ await payload.sendEmail({
169
+ to: email,
170
+ ...(replyTo ? { replyTo } : {}),
171
+ subject: `Invitation au ticket ${ticketNumber}`,
172
+ html: emailWrapper(
173
+ `Invitation à un ticket support`,
174
+ [
175
+ emailParagraph(`Bonjour,`),
176
+ emailParagraph(
177
+ `<strong>${escapeHtml(inviterLabel)}</strong> vous invite à consulter le ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em>.`,
178
+ ),
179
+ emailParagraph(`Rôle attribué : <strong>${escapeHtml(roleLabel)}</strong>.`),
180
+ baseUrl ? emailButton('Accéder au ticket', inviteUrl) : '',
181
+ emailParagraph(
182
+ `<span style="font-size: 12px; color: #94a3b8;">Si vous n'avez pas encore de compte support, vous serez invité à définir un mot de passe pour activer votre accès.</span>`,
183
+ ),
184
+ ].join(''),
185
+ ),
186
+ })
187
+
188
+ // Best-effort email log
189
+ try {
190
+ await payload.create({
191
+ collection: slugs.emailLogs as any,
192
+ data: {
193
+ status: 'success',
194
+ action: 'invite',
195
+ senderEmail: (req.user as any).email,
196
+ recipientEmail: email,
197
+ subject: `Invitation ${ticketNumber}`,
198
+ ticketNumber,
199
+ },
200
+ overrideAccess: true,
201
+ })
202
+ } catch {
203
+ // emailLogs not enabled — skip
204
+ }
205
+
206
+ return Response.json({ ok: true, invitedTo: email, role })
207
+ } catch (err) {
208
+ const authResponse = handleAuthError(err)
209
+ if (authResponse) return authResponse
210
+ console.error('[support/invite-collaborator] Error:', err)
211
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
212
+ }
213
+ },
214
+ }
215
+ }
@@ -0,0 +1,156 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+
4
+ // Combining diacritics range used to strip accents after NFD normalization.
5
+ const DIACRITICS = /[̀-ͯ]/g
6
+
7
+ /**
8
+ * Recursively extract plain text from a Lexical (Payload richText) JSON tree.
9
+ * Returns a single string with nodes' text content joined by spaces.
10
+ *
11
+ * The shape we expect is roughly:
12
+ * { root: { children: [ { children: [ { type: 'text', text: '...' } ] } ] } }
13
+ * but we walk anything that has a `children` array or a `text` field so it
14
+ * also tolerates the older Slate format.
15
+ */
16
+ function lexicalToPlainText(richText: unknown): string {
17
+ if (!richText) return ''
18
+
19
+ const parts: string[] = []
20
+
21
+ const visit = (node: any): void => {
22
+ if (!node || typeof node !== 'object') return
23
+ if (typeof node.text === 'string') {
24
+ parts.push(node.text)
25
+ }
26
+ if (Array.isArray(node.children)) {
27
+ for (const child of node.children) visit(child)
28
+ }
29
+ if (node.root) visit(node.root)
30
+ }
31
+
32
+ visit(richText)
33
+ return parts.join(' ').replace(/\s+/g, ' ').trim()
34
+ }
35
+
36
+ /**
37
+ * Build a short excerpt (~maxChars) cutting on a word boundary.
38
+ */
39
+ function buildExcerpt(plain: string, maxChars = 160): string {
40
+ if (!plain) return ''
41
+ if (plain.length <= maxChars) return plain
42
+ const slice = plain.slice(0, maxChars + 1)
43
+ const lastSpace = slice.lastIndexOf(' ')
44
+ const cut = lastSpace > 60 ? slice.slice(0, lastSpace) : slice.slice(0, maxChars)
45
+ return `${cut.trim()}…`
46
+ }
47
+
48
+ /**
49
+ * Tokenize a query into lowercase words ≥ 2 chars (no diacritics).
50
+ */
51
+ function tokenize(q: string): string[] {
52
+ return q
53
+ .toLowerCase()
54
+ .normalize('NFD')
55
+ .replace(DIACRITICS, '')
56
+ .split(/[^a-z0-9]+/)
57
+ .filter((t) => t.length >= 2)
58
+ }
59
+
60
+ /**
61
+ * Normalize a string for case/diacritics-insensitive comparison.
62
+ */
63
+ function normalize(s: string): string {
64
+ return s.toLowerCase().normalize('NFD').replace(DIACRITICS, '')
65
+ }
66
+
67
+ /**
68
+ * GET /api/support/kb/search?q=<query>&limit=5
69
+ *
70
+ * Public deflection endpoint: returns published KB articles whose `title` or
71
+ * `category` matches the query. No auth required (used on the public portal
72
+ * before/while a client writes a ticket — and also from the chatbot widget).
73
+ *
74
+ * Response shape:
75
+ * { results: [{ id, title, slug, excerpt, category, score }], total }
76
+ */
77
+ export function createKbSearchEndpoint(slugs: CollectionSlugs): Endpoint {
78
+ return {
79
+ path: '/support/kb/search',
80
+ method: 'get',
81
+ handler: async (req) => {
82
+ try {
83
+ const payload = req.payload
84
+
85
+ const url = new URL(req.url!)
86
+ const q = (url.searchParams.get('q') || '').trim()
87
+ const limitRaw = parseInt(url.searchParams.get('limit') || '5', 10)
88
+ const limit = Math.min(Math.max(Number.isNaN(limitRaw) ? 5 : limitRaw, 1), 10)
89
+
90
+ if (!q || q.length < 3) {
91
+ return Response.json({ results: [], total: 0 })
92
+ }
93
+
94
+ const tokens = tokenize(q)
95
+ if (tokens.length === 0) {
96
+ return Response.json({ results: [], total: 0 })
97
+ }
98
+
99
+ // Build an OR over (title contains token) ∪ (category contains token)
100
+ // for each significant token so multi-word queries still match docs
101
+ // matching any token.
102
+ const orClauses: any[] = []
103
+ for (const t of tokens) {
104
+ orClauses.push({ title: { contains: t } })
105
+ orClauses.push({ category: { contains: t } })
106
+ }
107
+ // Always include the raw query for short single-word matches.
108
+ orClauses.push({ title: { contains: q } })
109
+
110
+ const res = await payload.find({
111
+ collection: slugs.knowledgeBase as any,
112
+ where: {
113
+ and: [
114
+ { published: { equals: true } },
115
+ { or: orClauses },
116
+ ],
117
+ } as any,
118
+ // Over-fetch so we can re-rank and trim ourselves.
119
+ limit: Math.max(limit * 3, 10),
120
+ depth: 0,
121
+ overrideAccess: true,
122
+ })
123
+
124
+ const results = res.docs
125
+ .map((a: any) => {
126
+ const title: string = a.title || ''
127
+ const category: string = a.category || ''
128
+ const titleN = normalize(title)
129
+ const categoryN = normalize(category)
130
+ let score = 0
131
+ for (const t of tokens) {
132
+ if (titleN.includes(t)) score += 2
133
+ if (categoryN.includes(t)) score += 1
134
+ }
135
+ const plain = lexicalToPlainText(a.body)
136
+ return {
137
+ id: a.id,
138
+ title,
139
+ slug: a.slug,
140
+ category,
141
+ excerpt: buildExcerpt(plain, 160),
142
+ score,
143
+ }
144
+ })
145
+ .filter((r) => r.score > 0)
146
+ .sort((a, b) => b.score - a.score)
147
+ .slice(0, limit)
148
+
149
+ return Response.json({ results, total: results.length })
150
+ } catch (error) {
151
+ console.error('[kb-search] Error:', error)
152
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
153
+ }
154
+ },
155
+ }
156
+ }
@@ -0,0 +1,104 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireClient, handleAuthError } from '../utils/auth'
4
+
5
+ /**
6
+ * POST /api/support/tickets/:id/feedback
7
+ *
8
+ * Authenticated portal client submits a feedback (rating + optional comment)
9
+ * for one of their tickets. Only one feedback per ticket is allowed.
10
+ *
11
+ * Body: { rating: number (1-5), comment?: string }
12
+ */
13
+ export function createTicketFeedbackEndpoint(slugs: CollectionSlugs): Endpoint {
14
+ return {
15
+ path: '/support/tickets/:id/feedback',
16
+ method: 'post',
17
+ handler: async (req) => {
18
+ try {
19
+ requireClient(req, slugs)
20
+ const payload = req.payload
21
+
22
+ const idRaw = req.routeParams?.id as string | undefined
23
+ if (!idRaw) {
24
+ return Response.json({ error: 'missing-id' }, { status: 400 })
25
+ }
26
+ const ticketId = Number(idRaw)
27
+ if (Number.isNaN(ticketId)) {
28
+ return Response.json({ error: 'invalid-id' }, { status: 400 })
29
+ }
30
+
31
+ const body = (await req.json!()) as { rating?: unknown; comment?: unknown }
32
+ const ratingNum = Number(body?.rating)
33
+ if (!Number.isInteger(ratingNum) || ratingNum < 1 || ratingNum > 5) {
34
+ return Response.json(
35
+ { error: 'rating (entier 1-5) requis.' },
36
+ { status: 400 },
37
+ )
38
+ }
39
+ const comment =
40
+ typeof body?.comment === 'string' ? body.comment.trim() : undefined
41
+ if (comment && comment.length > 5000) {
42
+ return Response.json(
43
+ { error: 'Le commentaire ne peut pas depasser 5000 caracteres.' },
44
+ { status: 400 },
45
+ )
46
+ }
47
+
48
+ // Find ticket and check it belongs to the requesting client
49
+ const ticket = (await payload.findByID({
50
+ collection: slugs.tickets as any,
51
+ id: ticketId,
52
+ depth: 0,
53
+ overrideAccess: true,
54
+ })) as { id: number; client?: number | { id: number } } | null
55
+
56
+ if (!ticket) {
57
+ return Response.json({ error: 'Ticket introuvable.' }, { status: 404 })
58
+ }
59
+
60
+ const ticketClientId =
61
+ typeof ticket.client === 'object' && ticket.client !== null
62
+ ? ticket.client.id
63
+ : ticket.client
64
+ if (ticketClientId !== req.user.id) {
65
+ return Response.json({ error: 'forbidden' }, { status: 403 })
66
+ }
67
+
68
+ // Check no feedback already exists for this ticket
69
+ const existing = await payload.find({
70
+ collection: 'ticket-feedback' as any,
71
+ where: { ticket: { equals: ticketId } },
72
+ limit: 1,
73
+ depth: 0,
74
+ overrideAccess: true,
75
+ })
76
+ if (existing.docs.length > 0) {
77
+ return Response.json(
78
+ { error: 'Feedback deja soumis pour ce ticket.' },
79
+ { status: 409 },
80
+ )
81
+ }
82
+
83
+ const feedback = await payload.create({
84
+ collection: 'ticket-feedback' as any,
85
+ data: {
86
+ ticket: ticketId,
87
+ client: req.user.id,
88
+ rating: ratingNum,
89
+ ...(comment ? { comment } : {}),
90
+ submittedFrom: 'portal',
91
+ },
92
+ overrideAccess: true,
93
+ })
94
+
95
+ return Response.json({ success: true, feedback })
96
+ } catch (err) {
97
+ const authResponse = handleAuthError(err)
98
+ if (authResponse) return authResponse
99
+ console.error('[support/ticket-feedback] Error:', err)
100
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
101
+ }
102
+ },
103
+ }
104
+ }