@consilioweb/payload-support 0.15.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.
@@ -30,8 +30,16 @@ function createAutoUpdateStatus(slugs: CollectionSlugs): CollectionAfterChangeHo
30
30
  if (!doc.isInternal) {
31
31
  if (doc.authorType === 'admin') {
32
32
  updateData.status = 'waiting_client'
33
+ // A fresh public reply starts a new waiting window: drop any pending
34
+ // auto-close arming (manual deadline + day-based reminder anchor) so
35
+ // reminders re-arm cleanly from here.
36
+ updateData.autoCloseScheduledAt = null
37
+ updateData.autoCloseRemindedAt = null
33
38
  } else if (doc.authorType === 'client' || doc.authorType === 'email') {
34
39
  updateData.lastClientMessageAt = new Date().toISOString()
40
+ // The client answered — cancel any pending automatic closure.
41
+ updateData.autoCloseScheduledAt = null
42
+ updateData.autoCloseRemindedAt = null
35
43
  if (ticket.status && ['waiting_client', 'resolved'].includes(ticket.status as string)) {
36
44
  updateData.status = 'open'
37
45
  }
@@ -739,6 +739,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
739
739
  },
740
740
  { name: 'mergedInto', type: 'relationship', relationTo: slugs.tickets, label: 'Fusionne dans', admin: { readOnly: true } },
741
741
  { name: 'autoCloseRemindedAt', type: 'date', label: 'Rappel auto-close envoye', admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
742
+ { name: 'autoCloseScheduledAt', type: 'date', label: 'Fermeture auto programmee', admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' }, description: 'Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client.' } },
742
743
  ],
743
744
  },
744
745
  // Sidebar
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import React, { useRef } from 'react'
4
- import { s } from '../constants'
4
+ import { C, s } from '../constants'
5
5
 
6
6
  // ========== MERGE PANEL ==========
7
7
  interface MergePanelProps {
@@ -115,6 +115,87 @@ export function ExtMessagePanel({
115
115
  )
116
116
  }
117
117
 
118
+ // ========== REMINDER PANEL ==========
119
+ interface ReminderPanelProps {
120
+ clientFirstName: string
121
+ clientEmail: string
122
+ reminderHours: number
123
+ setReminderHours: (v: number) => void
124
+ reminderSending: boolean
125
+ reminderError: string
126
+ handleSendReminder: () => void
127
+ }
128
+
129
+ const REMINDER_DELAYS = [
130
+ { hours: 24, label: '24 h' },
131
+ { hours: 48, label: '48 h' },
132
+ { hours: 72, label: '72 h' },
133
+ ]
134
+
135
+ export function ReminderPanel({
136
+ clientFirstName, clientEmail, reminderHours, setReminderHours,
137
+ reminderSending, reminderError, handleSendReminder,
138
+ }: ReminderPanelProps) {
139
+ const deadline = new Date(Date.now() + reminderHours * 60 * 60 * 1000)
140
+ const deadlineLabel = deadline.toLocaleString('fr-FR', {
141
+ weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit',
142
+ })
143
+ const name = clientFirstName?.trim() || 'le client'
144
+
145
+ return (
146
+ <div style={{ padding: '14px 18px', borderRadius: '8px', backgroundColor: '#fff7ed', border: '1px solid #fed7aa', marginBottom: '14px' }}>
147
+ <h4 style={{ fontSize: '13px', fontWeight: 600, marginBottom: '8px', color: '#9a3412' }}>
148
+ Relancer {name} — fermeture automatique sans réponse
149
+ </h4>
150
+ {clientEmail ? (
151
+ <p style={{ fontSize: '12px', color: '#7c2d12', margin: '0 0 12px 0', lineHeight: 1.5 }}>
152
+ Un email de relance générique sera envoyé à <strong>{clientEmail}</strong>. Sans retour de sa part avant l&apos;échéance, le ticket sera automatiquement résolu (via le cron auto-close).
153
+ </p>
154
+ ) : (
155
+ <p style={{ fontSize: '12px', color: '#b91c1c', margin: '0 0 12px 0', fontWeight: 600 }}>
156
+ Ce client n&apos;a pas d&apos;adresse email : impossible d&apos;envoyer une relance.
157
+ </p>
158
+ )}
159
+
160
+ <div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap', marginBottom: '12px' }}>
161
+ <span style={{ fontSize: '12px', fontWeight: 600, color: '#9a3412' }}>Fermer dans :</span>
162
+ {REMINDER_DELAYS.map((d) => {
163
+ const active = reminderHours === d.hours
164
+ return (
165
+ <button
166
+ key={d.hours}
167
+ type="button"
168
+ onClick={() => setReminderHours(d.hours)}
169
+ style={{
170
+ ...(active ? s.btn(C.orange) : s.outlineBtn('#ea580c')),
171
+ fontSize: '12px', padding: '6px 14px',
172
+ }}
173
+ >
174
+ {d.label}
175
+ </button>
176
+ )
177
+ })}
178
+ </div>
179
+
180
+ <div style={{ fontSize: '12px', color: '#7c2d12', marginBottom: '12px' }}>
181
+ Fermeture automatique prévue le <strong>{deadlineLabel}</strong> sans réponse.
182
+ </div>
183
+
184
+ {reminderError && (
185
+ <div style={{ fontSize: '12px', color: '#b91c1c', fontWeight: 600, marginBottom: '10px' }}>{reminderError}</div>
186
+ )}
187
+
188
+ <button
189
+ onClick={handleSendReminder}
190
+ disabled={reminderSending || !clientEmail}
191
+ style={{ ...s.btn(C.orange, reminderSending || !clientEmail), color: '#fff', fontSize: '13px', padding: '8px 18px' }}
192
+ >
193
+ {reminderSending ? 'Envoi...' : 'Confirmer la relance'}
194
+ </button>
195
+ </div>
196
+ )
197
+ }
198
+
118
199
  // ========== SNOOZE PANEL ==========
119
200
  interface SnoozePanelProps {
120
201
  snoozeSaving: boolean
@@ -18,6 +18,13 @@ interface QuickActionsProps {
18
18
  onToggleExtMsg: () => void
19
19
  onToggleSnooze: () => void
20
20
  onNextTicket: () => void
21
+ // Reminder (relance + auto-close)
22
+ showReminderButton: boolean
23
+ onToggleReminder: () => void
24
+ autoCloseScheduledAt: string | null
25
+ onCancelScheduledClose: () => void
26
+ cancelingClose: boolean
27
+ reminderSuccess: boolean
21
28
  // Next ticket banner
22
29
  showNextTicket: boolean
23
30
  nextTicketId: number | null
@@ -30,8 +37,12 @@ export function QuickActions({
30
37
  snoozeUntil, snoozeSaving, onCancelSnooze,
31
38
  showMerge: _showMerge, showExtMsg: _showExtMsg, showSnooze: _showSnooze,
32
39
  onToggleMerge, onToggleExtMsg, onToggleSnooze, onNextTicket,
40
+ showReminderButton, onToggleReminder, autoCloseScheduledAt, onCancelScheduledClose, cancelingClose, reminderSuccess,
33
41
  showNextTicket, nextTicketId, nextTicketInfo, onCloseNextTicket,
34
42
  }: QuickActionsProps) {
43
+ const scheduledClose = autoCloseScheduledAt && new Date(autoCloseScheduledAt) > new Date()
44
+ ? new Date(autoCloseScheduledAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
45
+ : null
35
46
  return (
36
47
  <>
37
48
  <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '16px', alignItems: 'center' }}>
@@ -54,7 +65,21 @@ export function QuickActions({
54
65
  <button onClick={onToggleMerge} style={s.ghostBtn('#be185d')}>Fusionner</button>
55
66
  <button onClick={onToggleExtMsg} style={s.ghostBtn('#4f46e5')}>+ Message reçu</button>
56
67
  <button onClick={onToggleSnooze} style={s.ghostBtn('#7c3aed')}>Snooze</button>
68
+ {showReminderButton && (
69
+ <button onClick={onToggleReminder} style={s.ghostBtn('#ea580c')}>{'⏰'} Relancer</button>
70
+ )}
57
71
  <button onClick={onNextTicket} style={s.ghostBtn('#16a34a')}>Ticket suivant</button>
72
+ {scheduledClose && (
73
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '5px 10px', borderRadius: '6px', backgroundColor: '#fff7ed', border: '1px solid #fed7aa', fontSize: '12px', color: '#9a3412', fontWeight: 600 }}>
74
+ {'⏰'} Fermeture auto le {scheduledClose}
75
+ <button onClick={onCancelScheduledClose} disabled={cancelingClose} style={{ border: 'none', background: 'none', color: '#b91c1c', cursor: cancelingClose ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '12px', textDecoration: 'underline', padding: 0, opacity: cancelingClose ? 0.5 : 1 }}>
76
+ {cancelingClose ? '...' : 'Annuler'}
77
+ </button>
78
+ </span>
79
+ )}
80
+ {reminderSuccess && (
81
+ <span style={{ ...s.badge('#f0fdf4', '#16a34a'), fontSize: '12px', padding: '5px 10px' }}>Relance envoyée {'✓'}</span>
82
+ )}
58
83
  </div>
59
84
 
60
85
  {showNextTicket && (
@@ -23,6 +23,13 @@ export function useTicketActions(
23
23
  const [showSnooze, setShowSnooze] = useState(false)
24
24
  const [snoozeUntil, setSnoozeUntil] = useState<string | null>(null)
25
25
  const [snoozeSaving, setSnoozeSaving] = useState(false)
26
+ // Reminder (relance + auto-close arming)
27
+ const [showReminder, setShowReminder] = useState(false)
28
+ const [reminderHours, setReminderHours] = useState(24)
29
+ const [reminderSending, setReminderSending] = useState(false)
30
+ const [reminderError, setReminderError] = useState('')
31
+ const [reminderSuccess, setReminderSuccess] = useState(false)
32
+ const [cancelingClose, setCancelingClose] = useState(false)
26
33
  // Next ticket
27
34
  const [showNextTicket, setShowNextTicket] = useState(false)
28
35
  const [nextTicketId, setNextTicketId] = useState<number | null>(null)
@@ -178,6 +185,49 @@ export function useTicketActions(
178
185
  }
179
186
  }
180
187
 
188
+ const handleSendReminder = async () => {
189
+ if (!id) return
190
+ setReminderSending(true)
191
+ setReminderError('')
192
+ try {
193
+ const res = await fetch('/api/support/send-reminder', {
194
+ method: 'POST',
195
+ headers: { 'Content-Type': 'application/json' },
196
+ credentials: 'include',
197
+ body: JSON.stringify({ ticketId: id, hours: reminderHours }),
198
+ })
199
+ const d = await res.json().catch(() => ({}))
200
+ if (res.ok) {
201
+ setShowReminder(false)
202
+ setReminderSuccess(true)
203
+ setTimeout(() => setReminderSuccess(false), 5000)
204
+ fetchAll()
205
+ } else {
206
+ setReminderError(d.error || 'Erreur lors de la relance')
207
+ }
208
+ } catch {
209
+ setReminderError('Erreur réseau')
210
+ } finally {
211
+ setReminderSending(false)
212
+ }
213
+ }
214
+
215
+ const handleCancelScheduledClose = async () => {
216
+ if (!id) return
217
+ setCancelingClose(true)
218
+ try {
219
+ await fetch(`/api/tickets/${id}`, {
220
+ method: 'PATCH',
221
+ headers: { 'Content-Type': 'application/json' },
222
+ credentials: 'include',
223
+ body: JSON.stringify({ autoCloseScheduledAt: null, autoCloseRemindedAt: null }),
224
+ })
225
+ fetchAll()
226
+ } catch { /* ignore */ } finally {
227
+ setCancelingClose(false)
228
+ }
229
+ }
230
+
181
231
  const handleNextTicket = async () => {
182
232
  try {
183
233
  const res = await fetch('/api/tickets?where[status][equals]=open&sort=updatedAt&limit=1&depth=0', { credentials: 'include' })
@@ -200,6 +250,7 @@ export function useTicketActions(
200
250
  showMerge, setShowMerge, mergeTarget, setMergeTarget, mergeTargetInfo, setMergeTargetInfo, mergeError, setMergeError, merging, handleMergeLookup, handleMerge,
201
251
  showExtMsg, setShowExtMsg, extMsgBody, setExtMsgBody, extMsgAuthor, setExtMsgAuthor, extMsgDate, setExtMsgDate, extMsgFiles, setExtMsgFiles, sendingExtMsg, handleExtFileChange, handleSendExtMsg,
202
252
  showSnooze, setShowSnooze, snoozeUntil, setSnoozeUntil, snoozeSaving, handleSnooze,
253
+ showReminder, setShowReminder, reminderHours, setReminderHours, reminderSending, reminderError, setReminderError, reminderSuccess, handleSendReminder, cancelingClose, handleCancelScheduledClose,
203
254
  showNextTicket, setShowNextTicket, nextTicketId, nextTicketInfo, handleNextTicket,
204
255
  }
205
256
  }
@@ -31,7 +31,7 @@ import { TicketHeader } from './components/TicketHeader'
31
31
  import { ClientBar } from './components/ClientBar'
32
32
  import { QuickActions } from './components/QuickActions'
33
33
  import { AISummaryPanel } from './components/AISummaryPanel'
34
- import { MergePanel, ExtMessagePanel, SnoozePanel } from './components/ActionPanels'
34
+ import { MergePanel, ExtMessagePanel, SnoozePanel, ReminderPanel } from './components/ActionPanels'
35
35
  import { ActivityLog } from './components/ActivityLog'
36
36
  import { ClientHistory } from './components/ClientHistory'
37
37
  import { TimeTrackingPanel } from './components/TimeTrackingPanel'
@@ -201,6 +201,7 @@ const TicketConversation: React.FC = () => {
201
201
  const [ticketSubject, setTicketSubject] = useState<string>('')
202
202
  const [ticketSource, setTicketSource] = useState<string>('')
203
203
  const [chatSession, setChatSession] = useState<string>('')
204
+ const [autoCloseScheduledAt, setAutoCloseScheduledAt] = useState<string | null>(null)
204
205
  // Client history
205
206
  const [clientTickets, setClientTickets] = useState<Array<{ id: number; ticketNumber: string; subject: string; status: string; createdAt: string }>>([])
206
207
  const [clientProjects, setClientProjects] = useState<Array<{ id: number; name: string; status: string }>>([])
@@ -236,6 +237,7 @@ const TicketConversation: React.FC = () => {
236
237
  setClient(d.client)
237
238
  }
238
239
  setSnoozeUntil(d.snoozeUntil || null)
240
+ setAutoCloseScheduledAt(d.autoCloseScheduledAt || null)
239
241
  setLastClientReadAt(d.lastClientReadAt || null)
240
242
  setCurrentStatus(d.status || '')
241
243
  setTicketNumber(d.ticketNumber || '')
@@ -384,7 +386,7 @@ const TicketConversation: React.FC = () => {
384
386
 
385
387
  // Ticket actions (status, merge, snooze, ext msg, next ticket)
386
388
  const ta = useTicketActions(id, fetchAll)
387
- const { statusUpdating, handleStatusChange, showMerge, setShowMerge, mergeTarget, setMergeTarget, mergeTargetInfo, setMergeTargetInfo, mergeError, setMergeError, merging, handleMergeLookup, handleMerge, showExtMsg, setShowExtMsg, extMsgBody, setExtMsgBody, extMsgAuthor, setExtMsgAuthor, extMsgDate, setExtMsgDate, extMsgFiles, setExtMsgFiles, sendingExtMsg, handleExtFileChange, handleSendExtMsg, showSnooze, setShowSnooze, snoozeUntil, setSnoozeUntil, snoozeSaving, handleSnooze, showNextTicket, setShowNextTicket, nextTicketId, nextTicketInfo, handleNextTicket } = ta
389
+ const { statusUpdating, handleStatusChange, showMerge, setShowMerge, mergeTarget, setMergeTarget, mergeTargetInfo, setMergeTargetInfo, mergeError, setMergeError, merging, handleMergeLookup, handleMerge, showExtMsg, setShowExtMsg, extMsgBody, setExtMsgBody, extMsgAuthor, setExtMsgAuthor, extMsgDate, setExtMsgDate, extMsgFiles, setExtMsgFiles, sendingExtMsg, handleExtFileChange, handleSendExtMsg, showSnooze, setShowSnooze, snoozeUntil, setSnoozeUntil, snoozeSaving, handleSnooze, showReminder, setShowReminder, reminderHours, setReminderHours, reminderSending, reminderError, setReminderError, reminderSuccess, handleSendReminder, cancelingClose, handleCancelScheduledClose, showNextTicket, setShowNextTicket, nextTicketId, nextTicketInfo, handleNextTicket } = ta
388
390
 
389
391
  // Reply composer
390
392
  const replyEditorRef = useRef<RichTextEditorHandle>(null)
@@ -419,6 +421,7 @@ const TicketConversation: React.FC = () => {
419
421
  const d = await ticketRes.json()
420
422
  setCurrentStatus(d.status || '')
421
423
  setSnoozeUntil(d.snoozeUntil || null)
424
+ setAutoCloseScheduledAt(d.autoCloseScheduledAt || null)
422
425
  setLastClientReadAt(d.lastClientReadAt || null)
423
426
  }
424
427
  if (activityRes.ok) {
@@ -1094,10 +1097,16 @@ const TicketConversation: React.FC = () => {
1094
1097
  showMerge={showMerge}
1095
1098
  showExtMsg={showExtMsg}
1096
1099
  showSnooze={showSnooze}
1097
- onToggleMerge={() => { setShowMerge(!showMerge); setShowExtMsg(false); setShowSnooze(false) }}
1098
- onToggleExtMsg={() => { setShowExtMsg(!showExtMsg); setShowMerge(false); setShowSnooze(false) }}
1099
- onToggleSnooze={() => { setShowSnooze(!showSnooze); setShowMerge(false); setShowExtMsg(false) }}
1100
+ onToggleMerge={() => { setShowMerge(!showMerge); setShowExtMsg(false); setShowSnooze(false); setShowReminder(false) }}
1101
+ onToggleExtMsg={() => { setShowExtMsg(!showExtMsg); setShowMerge(false); setShowSnooze(false); setShowReminder(false) }}
1102
+ onToggleSnooze={() => { setShowSnooze(!showSnooze); setShowMerge(false); setShowExtMsg(false); setShowReminder(false) }}
1100
1103
  onNextTicket={handleNextTicket}
1104
+ showReminderButton={features.autoClose}
1105
+ onToggleReminder={() => { setShowReminder(!showReminder); setReminderError(''); setShowMerge(false); setShowExtMsg(false); setShowSnooze(false) }}
1106
+ autoCloseScheduledAt={autoCloseScheduledAt}
1107
+ onCancelScheduledClose={handleCancelScheduledClose}
1108
+ cancelingClose={cancelingClose}
1109
+ reminderSuccess={reminderSuccess}
1101
1110
  showNextTicket={showNextTicket}
1102
1111
  nextTicketId={nextTicketId}
1103
1112
  nextTicketInfo={nextTicketInfo}
@@ -1139,6 +1148,17 @@ const TicketConversation: React.FC = () => {
1139
1148
  {features.snooze && showSnooze && (
1140
1149
  <SnoozePanel snoozeSaving={snoozeSaving} handleSnooze={handleSnooze} />
1141
1150
  )}
1151
+ {features.autoClose && showReminder && (
1152
+ <ReminderPanel
1153
+ clientFirstName={client?.firstName || ''}
1154
+ clientEmail={client?.email || ''}
1155
+ reminderHours={reminderHours}
1156
+ setReminderHours={setReminderHours}
1157
+ reminderSending={reminderSending}
1158
+ reminderError={reminderError}
1159
+ handleSendReminder={handleSendReminder}
1160
+ />
1161
+ )}
1142
1162
 
1143
1163
 
1144
1164
  </div>
@@ -90,7 +90,13 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
90
90
  }
91
91
  }
92
92
 
93
- // Step 2: Close tickets that were reminded > CLOSE_AFTER_REMIND_DAYS ago
93
+ // Step 2: Close tickets that are due. Two independent paths:
94
+ // - day-based: reminded > CLOSE_AFTER_REMIND_DAYS ago and the client
95
+ // stayed silent (the legacy automatic flow).
96
+ // - explicit deadline: a manual reminder armed `autoCloseScheduledAt`
97
+ // (e.g. "close in 24h"). When the client replies, that field is
98
+ // cleared and the status flips to `open`, so a still-set deadline in
99
+ // the past means no reply was received.
94
100
  const closeCutoff = new Date(now.getTime() - CLOSE_AFTER_REMIND_DAYS * 24 * 60 * 60 * 1000)
95
101
 
96
102
  const ticketsToClose = await payload.find({
@@ -98,11 +104,20 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
98
104
  where: {
99
105
  and: [
100
106
  { status: { equals: 'waiting_client' } },
101
- { autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
102
107
  {
103
108
  or: [
104
- { lastClientMessageAt: { exists: false } },
105
- { lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } },
109
+ {
110
+ and: [
111
+ { autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
112
+ {
113
+ or: [
114
+ { lastClientMessageAt: { exists: false } },
115
+ { lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } },
116
+ ],
117
+ },
118
+ ],
119
+ },
120
+ { autoCloseScheduledAt: { less_than_equal: now.toISOString() } },
106
121
  ],
107
122
  },
108
123
  ],
@@ -119,12 +134,19 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
119
134
  const ticketNumber = t.ticketNumber || 'TK-????'
120
135
  const subject = t.subject || 'Support'
121
136
  const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS
137
+ // Was this ticket closed via a manual reminder's explicit deadline,
138
+ // or via the legacy day-based flow? Drives the wording below.
139
+ const viaSchedule = !!t.autoCloseScheduledAt
140
+ && new Date(t.autoCloseScheduledAt).getTime() <= now.getTime()
141
+ const noteBody = viaSchedule
142
+ ? 'Ticket résolu automatiquement — relance manuelle restée sans réponse du client'
143
+ : `Ticket résolu automatiquement — sans réponse client depuis ${closeTotalDays} jours`
122
144
 
123
145
  await payload.create({
124
146
  collection: slugs.ticketMessages as any,
125
147
  data: {
126
148
  ticket: t.id,
127
- body: `Ticket résolu automatiquement — sans réponse client depuis ${closeTotalDays} jours`,
149
+ body: noteBody,
128
150
  authorType: 'admin',
129
151
  isInternal: true,
130
152
  skipNotification: true,
@@ -135,7 +157,9 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
135
157
  await payload.update({
136
158
  collection: slugs.tickets as any,
137
159
  id: t.id,
138
- data: { status: 'resolved' },
160
+ // Clear the armed deadline so a later manual reopen can't be
161
+ // closed instantly by a stale timestamp.
162
+ data: { status: 'resolved', autoCloseScheduledAt: null },
139
163
  overrideAccess: true,
140
164
  })
141
165
 
@@ -147,7 +171,7 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
147
171
  subject: `[${ticketNumber}] Ticket résolu — ${subject}`,
148
172
  html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
149
173
  <p>Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,</p>
150
- <p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em> — a été résolu automatiquement après ${closeTotalDays} jours sans réponse.</p>
174
+ <p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em> — a été résolu automatiquement ${viaSchedule ? 'faute de réponse de votre part' : `après ${closeTotalDays} jours sans réponse`}.</p>
151
175
  <p>Si vous avez encore besoin d'aide, n'hésitez pas à rouvrir ce ticket ou à en créer un nouveau.</p>
152
176
  <p><a href="${portalUrl}">Consulter le ticket</a></p>
153
177
  </div>`,
@@ -16,6 +16,7 @@ import { createSignatureGetEndpoint, createSignaturePostEndpoint } from './signa
16
16
  import { createRoundRobinConfigGetEndpoint, createRoundRobinConfigPostEndpoint } from './round-robin-config'
17
17
  import { createSlaCheckEndpoint } from './sla-check'
18
18
  import { createAutoCloseEndpoint } from './auto-close'
19
+ import { createSendReminderEndpoint } from './send-reminder'
19
20
  import { createStatusesEndpoint } from './statuses'
20
21
  import { createApplyMacroEndpoint } from './apply-macro'
21
22
  import { createPurgeLogsEndpoint } from './purge-logs'
@@ -62,6 +63,7 @@ export { createSignatureGetEndpoint, createSignaturePostEndpoint } from './signa
62
63
  export { createRoundRobinConfigGetEndpoint, createRoundRobinConfigPostEndpoint } from './round-robin-config'
63
64
  export { createSlaCheckEndpoint } from './sla-check'
64
65
  export { createAutoCloseEndpoint } from './auto-close'
66
+ export { createSendReminderEndpoint } from './send-reminder'
65
67
  export { createStatusesEndpoint } from './statuses'
66
68
  export { createApplyMacroEndpoint } from './apply-macro'
67
69
  export { createPurgeLogsEndpoint } from './purge-logs'
@@ -151,7 +153,10 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
151
153
  endpoints.push(createSignatureGetEndpoint(slugs), createSignaturePostEndpoint(slugs))
152
154
  }
153
155
  if (!f || f.sla !== false) endpoints.push(createSlaCheckEndpoint(slugs))
154
- if (!f || f.autoClose !== false) endpoints.push(createAutoCloseEndpoint(slugs))
156
+ if (!f || f.autoClose !== false) {
157
+ endpoints.push(createAutoCloseEndpoint(slugs))
158
+ endpoints.push(createSendReminderEndpoint(slugs))
159
+ }
155
160
  if (!f || f.customStatuses !== false) endpoints.push(createStatusesEndpoint(slugs))
156
161
  if (!f || f.macros !== false) endpoints.push(createApplyMacroEndpoint(slugs))
157
162
  if (!f || f.roundRobin !== false) {
@@ -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
+ }