@consilioweb/payload-support 0.14.0 → 0.16.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/components/ActionPanels.cjs +72 -0
- package/dist/components/TicketConversation/components/ActionPanels.js +73 -2
- package/dist/components/TicketConversation/components/QuickActions.cjs +22 -1
- package/dist/components/TicketConversation/components/QuickActions.js +22 -1
- package/dist/components/TicketConversation/hooks/useTicketActions.cjs +59 -0
- package/dist/components/TicketConversation/hooks/useTicketActions.js +59 -0
- package/dist/components/TicketConversation/index.cjs +32 -2
- package/dist/components/TicketConversation/index.js +33 -3
- package/dist/index.cjs +213 -52
- package/dist/index.js +213 -52
- package/dist/styles/Layout.module.scss +8 -11
- package/dist/styles/TicketDetail.module.scss +7 -8
- package/package.json +1 -1
- package/src/collections/TicketMessages.ts +8 -0
- package/src/collections/Tickets.ts +1 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +82 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +25 -0
- package/src/components/TicketConversation/hooks/useTicketActions.ts +51 -0
- package/src/components/TicketConversation/index.tsx +25 -5
- package/src/endpoints/auto-close.ts +31 -7
- package/src/endpoints/index.ts +6 -1
- package/src/endpoints/send-reminder.ts +189 -0
- package/src/styles/Layout.module.scss +8 -11
- package/src/styles/TicketDetail.module.scss +7 -8
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
+
import { RateLimiter } from '../utils/rateLimiter'
|
|
5
|
+
import { escapeHtml, emailWrapper, emailParagraph, emailButton } from '../utils/emailTemplate'
|
|
6
|
+
import { readSupportSettings } from '../utils/readSettings'
|
|
7
|
+
|
|
8
|
+
// Manual reminders are a deliberate admin action, so the cap is generous —
|
|
9
|
+
// it only exists to stop a runaway script, not normal usage.
|
|
10
|
+
const reminderLimiter = new RateLimiter(60 * 60 * 1000, 30) // 30 per hour per admin
|
|
11
|
+
|
|
12
|
+
const MIN_HOURS = 1
|
|
13
|
+
const MAX_HOURS = 24 * 30 // 30 days
|
|
14
|
+
|
|
15
|
+
function formatFr(date: Date, withTime: boolean): string {
|
|
16
|
+
return date.toLocaleString('fr-FR', {
|
|
17
|
+
day: 'numeric',
|
|
18
|
+
month: 'long',
|
|
19
|
+
year: 'numeric',
|
|
20
|
+
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
|
|
21
|
+
timeZone: 'Europe/Paris',
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* POST /api/support/send-reminder
|
|
27
|
+
*
|
|
28
|
+
* Manually nudge a client whose ticket is awaiting their reply: sends a generic
|
|
29
|
+
* "your ticket will be closed if you don't respond" email, drops an internal
|
|
30
|
+
* note in the thread, and arms the ticket for automatic closure.
|
|
31
|
+
*
|
|
32
|
+
* Arming = set `status: waiting_client`, `autoCloseRemindedAt: now` (so the
|
|
33
|
+
* day-based cron reminder skips it) and `autoCloseScheduledAt: now + hours`.
|
|
34
|
+
* The existing `/api/support/auto-close` cron then resolves the ticket once the
|
|
35
|
+
* deadline passes — UNLESS the client replies first, which flips the status back
|
|
36
|
+
* to `open` and clears `autoCloseScheduledAt` (see TicketMessages auto-update hook).
|
|
37
|
+
*
|
|
38
|
+
* Admin-only, rate-limited.
|
|
39
|
+
*/
|
|
40
|
+
export function createSendReminderEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
41
|
+
return {
|
|
42
|
+
path: '/support/send-reminder',
|
|
43
|
+
method: 'post',
|
|
44
|
+
handler: async (req) => {
|
|
45
|
+
try {
|
|
46
|
+
const payload = req.payload
|
|
47
|
+
|
|
48
|
+
requireAdmin(req, slugs)
|
|
49
|
+
|
|
50
|
+
if (reminderLimiter.check(String(req.user.id))) {
|
|
51
|
+
return Response.json(
|
|
52
|
+
{ error: 'Trop de relances. Réessayez dans une heure.' },
|
|
53
|
+
{ status: 429 },
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let body: { ticketId?: string | number; hours?: number }
|
|
58
|
+
try {
|
|
59
|
+
body = await req.json!()
|
|
60
|
+
} catch {
|
|
61
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { ticketId } = body
|
|
65
|
+
if (!ticketId) {
|
|
66
|
+
return Response.json({ error: 'ticketId requis' }, { status: 400 })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const rawHours = Number(body.hours)
|
|
70
|
+
const hours = Number.isFinite(rawHours)
|
|
71
|
+
? Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.round(rawHours)))
|
|
72
|
+
: 24
|
|
73
|
+
|
|
74
|
+
const ticket = await payload.findByID({
|
|
75
|
+
collection: slugs.tickets as any,
|
|
76
|
+
id: ticketId,
|
|
77
|
+
depth: 1,
|
|
78
|
+
overrideAccess: true,
|
|
79
|
+
}) as any
|
|
80
|
+
|
|
81
|
+
if (!ticket) {
|
|
82
|
+
return Response.json({ error: 'Ticket introuvable' }, { status: 404 })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const client = typeof ticket.client === 'object' ? ticket.client : null
|
|
86
|
+
if (!client?.email) {
|
|
87
|
+
return Response.json({ error: 'Client sans email' }, { status: 400 })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const now = new Date()
|
|
91
|
+
const deadline = new Date(now.getTime() + hours * 60 * 60 * 1000)
|
|
92
|
+
|
|
93
|
+
const settings = await readSupportSettings(payload)
|
|
94
|
+
const ticketNumber = ticket.ticketNumber || 'TK-????'
|
|
95
|
+
const subject = ticket.subject || 'Support'
|
|
96
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
97
|
+
const portalUrl = `${baseUrl}/support/tickets/${ticketId}`
|
|
98
|
+
const replyTo = settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || ''
|
|
99
|
+
|
|
100
|
+
// "En attente depuis le …" — anchored on our last public reply (that's
|
|
101
|
+
// what the client owes a response to), falling back to the first
|
|
102
|
+
// response then to ticket creation.
|
|
103
|
+
let waitingSince: string | undefined = ticket.firstResponseAt || ticket.createdAt
|
|
104
|
+
try {
|
|
105
|
+
const lastAdminMsg = await payload.find({
|
|
106
|
+
collection: slugs.ticketMessages as any,
|
|
107
|
+
where: {
|
|
108
|
+
and: [
|
|
109
|
+
{ ticket: { equals: ticketId } },
|
|
110
|
+
{ authorType: { equals: 'admin' } },
|
|
111
|
+
{ isInternal: { equals: false } },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
sort: '-createdAt',
|
|
115
|
+
limit: 1,
|
|
116
|
+
depth: 0,
|
|
117
|
+
overrideAccess: true,
|
|
118
|
+
})
|
|
119
|
+
if (lastAdminMsg.docs.length > 0 && lastAdminMsg.docs[0].createdAt) {
|
|
120
|
+
waitingSince = lastAdminMsg.docs[0].createdAt as string
|
|
121
|
+
}
|
|
122
|
+
} catch { /* fallback already set */ }
|
|
123
|
+
|
|
124
|
+
const sinceLabel = waitingSince ? formatFr(new Date(waitingSince), false) : null
|
|
125
|
+
const deadlineLabel = formatFr(deadline, true)
|
|
126
|
+
|
|
127
|
+
await payload.sendEmail({
|
|
128
|
+
to: client.email,
|
|
129
|
+
...(replyTo ? { replyTo } : {}),
|
|
130
|
+
subject: `Rappel : [${ticketNumber}] ${subject} — votre réponse est attendue`,
|
|
131
|
+
html: emailWrapper(`Votre ticket attend votre réponse`, [
|
|
132
|
+
emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,`),
|
|
133
|
+
emailParagraph(
|
|
134
|
+
`Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(String(subject))}</em> — est en attente de votre réponse${sinceLabel ? ` depuis le ${escapeHtml(sinceLabel)}` : ''}.`,
|
|
135
|
+
),
|
|
136
|
+
emailParagraph(
|
|
137
|
+
`<strong>Sans retour de votre part, ce ticket sera automatiquement clôturé le ${escapeHtml(deadlineLabel)}.</strong>`,
|
|
138
|
+
),
|
|
139
|
+
emailParagraph(
|
|
140
|
+
`Si vous avez encore besoin d'assistance, il vous suffit de répondre à ce message — votre réponse maintiendra le ticket ouvert.`,
|
|
141
|
+
),
|
|
142
|
+
emailButton('Répondre au ticket', portalUrl, 'primary'),
|
|
143
|
+
].join(''), {
|
|
144
|
+
kind: 'alert',
|
|
145
|
+
preheader: `Sans réponse de votre part, votre ticket ${ticketNumber} sera clôturé le ${deadlineLabel}.`,
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Internal note for the conversation timeline (never sent to the client).
|
|
150
|
+
await payload.create({
|
|
151
|
+
collection: slugs.ticketMessages as any,
|
|
152
|
+
data: {
|
|
153
|
+
ticket: ticketId,
|
|
154
|
+
body: `Relance envoyée à ${client.email}. Fermeture automatique programmée le ${deadlineLabel} sans réponse du client.`,
|
|
155
|
+
authorType: 'admin',
|
|
156
|
+
isInternal: true,
|
|
157
|
+
skipNotification: true,
|
|
158
|
+
},
|
|
159
|
+
overrideAccess: true,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Arm the ticket. `autoCloseRemindedAt: now` keeps the day-based cron
|
|
163
|
+
// reminder from firing a second time; `autoCloseScheduledAt` is the hard
|
|
164
|
+
// 24h-style deadline the cron close step honours.
|
|
165
|
+
await payload.update({
|
|
166
|
+
collection: slugs.tickets as any,
|
|
167
|
+
id: ticketId,
|
|
168
|
+
data: {
|
|
169
|
+
status: 'waiting_client',
|
|
170
|
+
autoCloseRemindedAt: now.toISOString(),
|
|
171
|
+
autoCloseScheduledAt: deadline.toISOString(),
|
|
172
|
+
},
|
|
173
|
+
overrideAccess: true,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
return Response.json({
|
|
177
|
+
success: true,
|
|
178
|
+
sentTo: client.email,
|
|
179
|
+
scheduledCloseAt: deadline.toISOString(),
|
|
180
|
+
})
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const authResponse = handleAuthError(error)
|
|
183
|
+
if (authResponse) return authResponse
|
|
184
|
+
console.error('[send-reminder] Error:', error)
|
|
185
|
+
return Response.json({ error: 'Erreur interne' }, { status: 500 })
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -38,18 +38,15 @@
|
|
|
38
38
|
|
|
39
39
|
// RTE display overrides (scoped)
|
|
40
40
|
.rteDisplay {
|
|
41
|
-
:global {
|
|
42
|
-
blockquote {
|
|
43
|
-
border-left: 3px solid $blue;
|
|
41
|
+
:global(blockquote) { border-left: 3px solid $blue;
|
|
44
42
|
margin: 8px 0;
|
|
45
43
|
padding: 8px 16px;
|
|
46
44
|
background: $bg;
|
|
47
|
-
border-radius: 0 $radius-sm $radius-sm 0;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
45
|
+
border-radius: 0 $radius-sm $radius-sm 0; }
|
|
46
|
+
:global(img) { max-width: 100%; height: auto; border-radius: $radius; margin: 8px 0; }
|
|
47
|
+
:global(a) { color: $blue; text-decoration: underline; }
|
|
48
|
+
:global(ul), :global(ol) { margin: 8px 0; padding-left: 24px; }
|
|
49
|
+
:global(li) { margin: 2px 0; }
|
|
50
|
+
:global(p) { margin: 0 0 6px 0; }
|
|
51
|
+
|
|
55
52
|
}
|
|
@@ -977,14 +977,13 @@
|
|
|
977
977
|
|
|
978
978
|
// RTE
|
|
979
979
|
.rteDisplay {
|
|
980
|
-
:global {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
}
|
|
980
|
+
:global(blockquote) { border-left: 3px solid #2563eb; margin: 8px 0; padding: 8px 16px; background: var(--theme-elevation-50); border-radius: 0 6px 6px 0; }
|
|
981
|
+
:global(img) { max-width: 100%; height: auto; border-radius: 8px; margin: 8px 0; }
|
|
982
|
+
:global(a) { color: #2563eb; text-decoration: underline; }
|
|
983
|
+
:global(ul), :global(ol) { margin: 8px 0; padding-left: 24px; }
|
|
984
|
+
:global(li) { margin: 2px 0; }
|
|
985
|
+
:global(p) { margin: 0 0 6px 0; }
|
|
986
|
+
|
|
988
987
|
}
|
|
989
988
|
|
|
990
989
|
// ─── UNDO TOAST (#2) ───
|