@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.
@@ -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
- img { max-width: 100%; height: auto; border-radius: $radius; margin: 8px 0; }
50
- a { color: $blue; text-decoration: underline; }
51
- ul, ol { margin: 8px 0; padding-left: 24px; }
52
- li { margin: 2px 0; }
53
- p { margin: 0 0 6px 0; }
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
- blockquote { border-left: 3px solid #2563eb; margin: 8px 0; padding: 8px 16px; background: var(--theme-elevation-50); border-radius: 0 6px 6px 0; }
982
- img { max-width: 100%; height: auto; border-radius: 8px; margin: 8px 0; }
983
- a { color: #2563eb; text-decoration: underline; }
984
- ul, ol { margin: 8px 0; padding-left: 24px; }
985
- li { margin: 2px 0; }
986
- p { margin: 0 0 6px 0; }
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) ───