@consilioweb/payload-support 0.10.0 → 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.
- package/dist/components/TicketConversation/locales/en.json +25 -1
- package/dist/components/TicketConversation/locales/fr.json +25 -1
- package/dist/index.cjs +799 -4
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +800 -6
- package/dist/styles/TicketDetail.module.scss +294 -0
- package/dist/views/TicketDetailView/client.cjs +455 -249
- package/dist/views/TicketDetailView/client.js +456 -250
- package/dist/views/TicketInboxView/client.cjs +10 -9
- package/dist/views/TicketInboxView/client.js +2 -1
- package/package.json +1 -1
- package/src/collections/ClientSummaries.ts +16 -0
- package/src/collections/TicketCollaborators.ts +93 -0
- package/src/collections/TicketFeedback.ts +116 -0
- package/src/collections/TicketMessages.ts +9 -0
- package/src/collections/Tickets.ts +31 -1
- package/src/collections/index.ts +2 -0
- package/src/components/TicketConversation/locales/en.json +25 -1
- package/src/components/TicketConversation/locales/fr.json +25 -1
- package/src/endpoints/escalate.ts +44 -0
- package/src/endpoints/index.ts +15 -0
- package/src/endpoints/invite-collaborator.ts +215 -0
- package/src/endpoints/kb-search.ts +156 -0
- package/src/endpoints/ticket-feedback.ts +104 -0
- package/src/endpoints/transfer-ticket.ts +248 -0
- package/src/index.ts +1 -0
- package/src/plugin.ts +4 -0
- package/src/portal/auth/ChatWidget.tsx +11 -0
- package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
- package/src/portal/auth/dashboard/page.tsx +11 -2
- package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
- package/src/portal/auth/tickets/detail/page.tsx +122 -23
- package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
- package/src/portal/auth/tickets/new/page.tsx +203 -100
- package/src/portal/locales/en.json +5 -0
- package/src/portal/locales/fr.json +5 -0
- package/src/styles/TicketDetail.module.scss +294 -0
- package/src/types.ts +19 -0
- package/src/utils/slugs.ts +2 -0
- package/src/views/TicketDetailView/client.tsx +346 -89
- package/src/views/TicketInboxView/client.tsx +2 -1
|
@@ -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
|
+
}
|