@consilioweb/payload-support 0.13.1 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -47,6 +47,14 @@ export function createKnowledgeBaseCollection(slugs: CollectionSlugs): Collectio
47
47
  { label: 'Général', value: 'general' },
48
48
  ],
49
49
  },
50
+ {
51
+ name: 'excerpt',
52
+ type: 'textarea',
53
+ label: 'Résumé',
54
+ admin: {
55
+ description: 'Court résumé affiché dans les listes',
56
+ },
57
+ },
50
58
  {
51
59
  name: 'body',
52
60
  type: 'richText',
@@ -60,6 +68,13 @@ export function createKnowledgeBaseCollection(slugs: CollectionSlugs): Collectio
60
68
  label: 'Publié',
61
69
  admin: { position: 'sidebar' },
62
70
  },
71
+ {
72
+ name: 'popular',
73
+ type: 'checkbox',
74
+ defaultValue: false,
75
+ label: 'Populaire',
76
+ admin: { position: 'sidebar' },
77
+ },
63
78
  {
64
79
  name: 'sortOrder',
65
80
  type: 'number',
@@ -67,6 +82,20 @@ export function createKnowledgeBaseCollection(slugs: CollectionSlugs): Collectio
67
82
  label: 'Ordre',
68
83
  admin: { position: 'sidebar' },
69
84
  },
85
+ {
86
+ name: 'helpfulYes',
87
+ type: 'number',
88
+ defaultValue: 0,
89
+ label: 'Votes utiles',
90
+ admin: { position: 'sidebar', readOnly: true },
91
+ },
92
+ {
93
+ name: 'helpfulNo',
94
+ type: 'number',
95
+ defaultValue: 0,
96
+ label: 'Votes non utiles',
97
+ admin: { position: 'sidebar', readOnly: true },
98
+ },
70
99
  ],
71
100
  hooks: {
72
101
  beforeChange: [
@@ -134,6 +134,79 @@ export function createSupportClientsCollection(slugs: CollectionSlugs): Collecti
134
134
  },
135
135
  ],
136
136
  },
137
+ {
138
+ type: 'row',
139
+ fields: [
140
+ {
141
+ name: 'city',
142
+ type: 'text',
143
+ label: 'Ville',
144
+ admin: { width: '50%' },
145
+ },
146
+ {
147
+ name: 'country',
148
+ type: 'text',
149
+ label: 'Pays',
150
+ admin: { width: '50%' },
151
+ },
152
+ ],
153
+ },
154
+ {
155
+ name: 'additionalContacts',
156
+ type: 'array',
157
+ label: 'Contacts additionnels',
158
+ labels: {
159
+ singular: 'Contact',
160
+ plural: 'Contacts',
161
+ },
162
+ fields: [
163
+ {
164
+ type: 'row',
165
+ fields: [
166
+ {
167
+ name: 'email',
168
+ type: 'email',
169
+ required: true,
170
+ label: 'Email',
171
+ admin: { width: '50%' },
172
+ },
173
+ {
174
+ name: 'name',
175
+ type: 'text',
176
+ label: 'Nom',
177
+ admin: { width: '50%' },
178
+ },
179
+ ],
180
+ },
181
+ ],
182
+ },
183
+ {
184
+ name: 'opportunities',
185
+ type: 'textarea',
186
+ label: 'Opportunites commerciales',
187
+ admin: {
188
+ description: 'Notes commerciales (visible uniquement par les admins)',
189
+ },
190
+ },
191
+ {
192
+ name: 'tier',
193
+ type: 'select',
194
+ label: 'Plan',
195
+ options: [
196
+ { label: 'Free', value: 'free' },
197
+ { label: 'Basic', value: 'basic' },
198
+ { label: 'Pro', value: 'pro' },
199
+ { label: 'Enterprise', value: 'enterprise' },
200
+ ],
201
+ admin: { position: 'sidebar' },
202
+ },
203
+ {
204
+ name: 'accountManager',
205
+ type: 'relationship',
206
+ relationTo: slugs.users,
207
+ label: 'Agent referent',
208
+ admin: { position: 'sidebar' },
209
+ },
137
210
  {
138
211
  name: 'twoFactorEnabled',
139
212
  type: 'checkbox',
@@ -174,6 +247,48 @@ export function createSupportClientsCollection(slugs: CollectionSlugs): Collecti
174
247
  position: 'sidebar',
175
248
  },
176
249
  },
250
+ {
251
+ name: 'notifyReminders',
252
+ type: 'checkbox',
253
+ defaultValue: true,
254
+ label: 'Notifications rappels',
255
+ admin: {
256
+ description: 'Recevoir un email pour les rappels',
257
+ position: 'sidebar',
258
+ },
259
+ },
260
+ {
261
+ name: 'notifyResolution',
262
+ type: 'checkbox',
263
+ defaultValue: true,
264
+ label: 'Notifications resolution',
265
+ admin: {
266
+ description: 'Recevoir un email a la resolution d\'un ticket',
267
+ position: 'sidebar',
268
+ },
269
+ },
270
+ {
271
+ name: 'notifyNews',
272
+ type: 'checkbox',
273
+ defaultValue: false,
274
+ label: 'Notifications actualites',
275
+ admin: {
276
+ description: 'Recevoir les actualites et nouveautes',
277
+ position: 'sidebar',
278
+ },
279
+ },
280
+ {
281
+ name: 'notificationFrequency',
282
+ type: 'select',
283
+ defaultValue: 'immediate',
284
+ label: 'Frequence des notifications',
285
+ options: [
286
+ { label: 'Immediate', value: 'immediate' },
287
+ { label: 'Quotidienne', value: 'daily' },
288
+ { label: 'Hebdomadaire', value: 'weekly' },
289
+ ],
290
+ admin: { position: 'sidebar' },
291
+ },
177
292
  {
178
293
  name: 'notes',
179
294
  type: 'textarea',
@@ -67,14 +67,25 @@ function createNotifyClient(slugs: CollectionSlugs): CollectionAfterChangeHook {
67
67
  const supportEmail = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || ''
68
68
  const portalUrl = `${baseUrl}/support/tickets/${ticketId}`
69
69
 
70
+ // Internal archive copy — this email goes TO the client, so any internal
71
+ // address MUST be bcc (never cc): the client must not see internal
72
+ // addresses, and an exposed internal cc/bcc reply chain leaks routing.
73
+ // Use a dedicated archive mailbox via env, NOT support@/noreply@ — those
74
+ // are filtered by the inbound-email anti-loop guard and would re-create a
75
+ // ticket from the copy.
76
+ const internalBcc = process.env.SUPPORT_CLIENT_BCC || undefined
77
+
70
78
  // Use rich HTML content if available, otherwise plain text preview
71
79
  const rawContent = doc.bodyHtml
72
80
  ? emailRichContent(doc.bodyHtml)
73
81
  : emailQuote(doc.body?.length > 500 ? doc.body.slice(0, 500) + '...' : doc.body)
74
82
 
83
+ const replyPreview = (doc.body || '').replace(/\s+/g, ' ').trim().slice(0, 90)
84
+
75
85
  await req.payload.sendEmail({
76
86
  to: client.email,
77
87
  ...(supportEmail ? { replyTo: supportEmail } : {}),
88
+ ...(internalBcc ? { bcc: internalBcc } : {}),
78
89
  subject: `Re: [${ticketNumber}] ${subject}`,
79
90
  html: emailWrapper(`Nouvelle reponse — ${ticketNumber}`, [
80
91
  emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,`),
@@ -83,7 +94,10 @@ function createNotifyClient(slugs: CollectionSlugs): CollectionAfterChangeHook {
83
94
  emailButton('Consulter le ticket', portalUrl),
84
95
  emailParagraph('<span style="font-size: 13px; color: #6b7280;">Vous pouvez egalement repondre directement a cet email. Votre message sera automatiquement ajoute au ticket.</span>'),
85
96
  emailTrackingPixel(ticketId, doc.id),
86
- ].join('')),
97
+ ].join(''), {
98
+ kind: 'reply',
99
+ preheader: replyPreview ? `${replyPreview}…` : `Nouvelle reponse a votre ticket ${ticketNumber}`,
100
+ }),
87
101
  })
88
102
 
89
103
  await req.payload.update({
@@ -216,6 +230,10 @@ function createNotifyAdminOnClientMessage(slugs: CollectionSlugs, notificationSl
216
230
 
217
231
  if (primaryEmail) {
218
232
  await payload.sendEmail({
233
+ // INTERNAL email (to the support team) — recipients are the contact
234
+ // inbox + assigned agent. The cc here is internal→internal and stays
235
+ // a cc on purpose. Do NOT confuse with the client reply email
236
+ // (createNotifyClient), where internal copies must be bcc.
219
237
  to: primaryEmail,
220
238
  ...(ccEmail ? { cc: ccEmail } : {}),
221
239
  ...(clientEmail !== 'inconnu' ? { replyTo: clientEmail } : (supportEmail ? { replyTo: supportEmail } : {})),
@@ -223,9 +241,9 @@ function createNotifyAdminOnClientMessage(slugs: CollectionSlugs, notificationSl
223
241
  html: emailWrapper(headerTitle, [
224
242
  emailParagraph(`<strong>${escapeHtml(clientName)}</strong> (${escapeHtml(clientEmail)}) a ${isNewTicket ? 'ouvert un nouveau ticket' : 'repondu au ticket'} <strong>${escapeHtml(ticketNumber)}</strong> :`),
225
243
  `<p style="margin: 0 0 4px 0; font-size: 14px; font-weight: 600; color: #374151;">Sujet : ${escapeHtml(subject)}</p>`,
226
- emailQuote(preview, isNewTicket ? '#FFD600' : '#00E5FF'),
244
+ emailQuote(preview, isNewTicket ? '#1d2b4d' : '#17807c'),
227
245
  emailButton('Ouvrir dans l\'admin', adminUrl, 'dark'),
228
- ].join(''), { headerColor: isNewTicket ? 'secondary' : 'primary' }),
246
+ ].join(''), { kind: 'system' }),
229
247
  })
230
248
  }
231
249
 
@@ -7,7 +7,7 @@ import type {
7
7
  Where,
8
8
  } from 'payload'
9
9
  import type { CollectionSlugs } from '../utils/slugs'
10
- import { escapeHtml } from '../utils/emailTemplate'
10
+ import { escapeHtml, emailWrapper, emailParagraph, emailButton } from '../utils/emailTemplate'
11
11
  import { fireWebhooks } from '../utils/fireWebhooks'
12
12
  import { createAdminNotification } from '../utils/adminNotification'
13
13
  import { dispatchWebhook } from '../utils/webhookDispatcher'
@@ -277,16 +277,35 @@ function createNotifyClientOnResolve(slugs: CollectionSlugs): CollectionAfterCha
277
277
  depth: 0,
278
278
  overrideAccess: true,
279
279
  }) : null)
280
- if (!clientData?.email || clientData.notifyOnStatusChange === false) return doc
280
+ // Respect client notification preferences: the dedicated `notifyResolution`
281
+ // toggle, and the legacy global `notifyOnStatusChange`. Strict `=== false`
282
+ // so clients not yet migrated (undefined) keep the default-on behaviour.
283
+ if (!clientData?.email) return doc
284
+ if (clientData.notifyResolution === false || clientData.notifyOnStatusChange === false) return doc
285
+
286
+ // TODO digest/batching: if clientData.notificationFrequency !== 'immediate',
287
+ // this resolution should be queued and folded into a daily/weekly digest
288
+ // instead of sending now. Out of scope here — sent immediately for all.
289
+
281
290
  const settings = await readSupportSettings(req.payload)
282
291
  const ticketNumber = doc.ticketNumber || 'TK-????'
292
+ const subject = doc.subject || 'Support'
283
293
  const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
284
294
  const replyTo = settings.email.replyToAddress || process.env.SUPPORT_EMAIL || ''
295
+ const ticketUrl = `${baseUrl}/support/tickets/${doc.id}`
285
296
  await req.payload.sendEmail({
286
297
  to: clientData.email,
287
298
  ...(replyTo ? { replyTo } : {}),
288
299
  subject: `[${ticketNumber}] Ticket resolu`,
289
- html: `<p>Bonjour ${escapeHtml(clientData.firstName || '')},</p><p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> a ete resolu.</p><p><a href="${baseUrl}/support/tickets/${doc.id}">Consulter le ticket</a></p>`,
300
+ html: emailWrapper(`Votre ticket a ete resolu`, [
301
+ emailParagraph(`Bonjour <strong>${escapeHtml(clientData.firstName || '')}</strong>,`),
302
+ emailParagraph(`Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(String(subject))}</em> — a ete resolu.`),
303
+ emailParagraph('Si le probleme persiste ou si vous avez une question, repondez simplement a cet email pour rouvrir le ticket.'),
304
+ emailButton('Consulter le ticket', ticketUrl),
305
+ ].join(''), {
306
+ kind: 'system',
307
+ preheader: `Votre ticket ${ticketNumber} a ete resolu.`,
308
+ }),
290
309
  })
291
310
  } catch (err) {
292
311
  console.error('[support] Failed to notify client on resolve:', err)
@@ -90,6 +90,18 @@ export function createTimeEntriesCollection(slugs: CollectionSlugs): CollectionC
90
90
  type: 'textarea',
91
91
  label: 'Description du travail',
92
92
  },
93
+ {
94
+ name: 'billable',
95
+ type: 'checkbox',
96
+ defaultValue: true,
97
+ label: 'Facturable',
98
+ },
99
+ {
100
+ name: 'agent',
101
+ type: 'relationship',
102
+ relationTo: slugs.users,
103
+ label: 'Agent',
104
+ },
93
105
  ],
94
106
  hooks: {
95
107
  afterChange: [createRecalculateTicketTime(slugs)],
@@ -9,9 +9,22 @@ import { readSupportSettings } from '../utils/readSettings'
9
9
  const STATUS_LABELS: Record<string, string> = {
10
10
  open: 'Ouvert',
11
11
  waiting_client: 'En attente client',
12
+ waiting_support: 'En attente support',
12
13
  resolved: 'Resolu',
14
+ closed: 'Ferme',
15
+ escalated: 'Escalade',
13
16
  }
14
17
 
18
+ /**
19
+ * Status transitions for which we DON'T email the client.
20
+ * - waiting_client / waiting_support: these are the automatic ping-pong
21
+ * toggles. When the agent replies the ticket flips to waiting_client — but
22
+ * the reply email already covers it. When the client replies it flips back
23
+ * to waiting_support — emailing the client about their own action is noise.
24
+ * - resolved: handled separately by notifyClientOnResolve (Tickets.ts).
25
+ */
26
+ const SILENT_STATUSES = new Set(['waiting_client', 'waiting_support', 'resolved'])
27
+
15
28
  /**
16
29
  * Resolve the client's email and first name from the ticket's `client` relationship.
17
30
  * Returns null if the client cannot be resolved or has no email.
@@ -90,21 +103,24 @@ export function createTicketStatusEmail(slugs: CollectionSlugs): CollectionAfter
90
103
  emailParagraph(`Nous avons bien recu votre demande de support. Voici les details :`),
91
104
  `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom: 20px;">
92
105
  <tr>
93
- <td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">N&deg; Ticket</td>
94
- <td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;"><strong>${escapeHtml(String(ticketNumber))}</strong></td>
106
+ <td style="padding: 8px 0; font-size: 11px; font-weight: 700; color: #6b7280; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.04em;">N&deg; Ticket</td>
107
+ <td style="padding: 8px 0; font-size: 15px; color: #2a3142; line-height: 1.5;"><strong>${escapeHtml(String(ticketNumber))}</strong></td>
95
108
  </tr>
96
109
  <tr>
97
- <td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">Sujet</td>
98
- <td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;">${escapeHtml(String(subject))}</td>
110
+ <td style="padding: 8px 0; font-size: 11px; font-weight: 700; color: #6b7280; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.04em;">Sujet</td>
111
+ <td style="padding: 8px 0; font-size: 15px; color: #2a3142; line-height: 1.5;">${escapeHtml(String(subject))}</td>
99
112
  </tr>
100
113
  <tr>
101
- <td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">Statut</td>
102
- <td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;">${STATUS_LABELS[doc.status] || doc.status}</td>
114
+ <td style="padding: 8px 0; font-size: 11px; font-weight: 700; color: #6b7280; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.04em;">Statut</td>
115
+ <td style="padding: 8px 0; font-size: 15px; color: #2a3142; line-height: 1.5;">${escapeHtml(STATUS_LABELS[doc.status] || String(doc.status))}</td>
103
116
  </tr>
104
117
  </table>`,
105
118
  emailParagraph('Notre equipe vous repondra dans les meilleurs delais.'),
106
119
  emailButton('Consulter le ticket', ticketUrl),
107
- ].join('')),
120
+ ].join(''), {
121
+ kind: 'system',
122
+ preheader: `Votre demande ${ticketNumber} est bien enregistree. Nous revenons vers vous rapidement.`,
123
+ }),
108
124
  })
109
125
 
110
126
  payload.logger.info(`[tickets] Creation notification sent to ${client.email} for ${ticketNumber}`)
@@ -117,8 +133,9 @@ export function createTicketStatusEmail(slugs: CollectionSlugs): CollectionAfter
117
133
 
118
134
  // --- Status change on update ---
119
135
  if (operation === 'update' && previousDoc?.status !== doc.status) {
120
- // Skip resolved handled by notifyClientOnResolve hook
121
- if (doc.status === 'resolved') return doc
136
+ // Skip the noisy/automatic transitions (ping-pong toggles + resolved).
137
+ // Only meaningful transitions (e.g. open, closed, escalated) reach the client.
138
+ if (SILENT_STATUSES.has(doc.status)) return doc
122
139
 
123
140
  try {
124
141
  const client = await resolveClient(doc, payload, slugs.supportClients)
@@ -133,14 +150,6 @@ export function createTicketStatusEmail(slugs: CollectionSlugs): CollectionAfter
133
150
  const previousStatusLabel = STATUS_LABELS[previousDoc?.status] || previousDoc?.status || 'N/A'
134
151
  const ticketUrl = `${baseUrl}/support/tickets/${doc.id}`
135
152
 
136
- // Contextual message based on new status
137
- let contextMessage = ''
138
- if (doc.status === 'waiting_client') {
139
- contextMessage = emailParagraph(
140
- 'Nous avons besoin d\'informations supplementaires de votre part pour continuer le traitement de votre demande. Merci de consulter le ticket et de nous repondre.',
141
- )
142
- }
143
-
144
153
  await payload.sendEmail({
145
154
  to: client.email,
146
155
  ...(supportEmail ? { replyTo: supportEmail } : {}),
@@ -150,25 +159,27 @@ export function createTicketStatusEmail(slugs: CollectionSlugs): CollectionAfter
150
159
  emailParagraph(`Le statut de votre ticket a ete mis a jour :`),
151
160
  `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin-bottom: 20px;">
152
161
  <tr>
153
- <td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">N&deg; Ticket</td>
154
- <td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;"><strong>${escapeHtml(String(ticketNumber))}</strong></td>
162
+ <td style="padding: 8px 0; font-size: 11px; font-weight: 700; color: #6b7280; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.04em;">N&deg; Ticket</td>
163
+ <td style="padding: 8px 0; font-size: 15px; color: #2a3142; line-height: 1.5;"><strong>${escapeHtml(String(ticketNumber))}</strong></td>
155
164
  </tr>
156
165
  <tr>
157
- <td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">Sujet</td>
158
- <td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;">${escapeHtml(String(subject))}</td>
166
+ <td style="padding: 8px 0; font-size: 11px; font-weight: 700; color: #6b7280; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.04em;">Sujet</td>
167
+ <td style="padding: 8px 0; font-size: 15px; color: #2a3142; line-height: 1.5;">${escapeHtml(String(subject))}</td>
159
168
  </tr>
160
169
  <tr>
161
- <td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">Ancien statut</td>
162
- <td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;">${escapeHtml(String(previousStatusLabel))}</td>
170
+ <td style="padding: 8px 0; font-size: 11px; font-weight: 700; color: #6b7280; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.04em;">Ancien statut</td>
171
+ <td style="padding: 8px 0; font-size: 15px; color: #2a3142; line-height: 1.5;">${escapeHtml(String(previousStatusLabel))}</td>
163
172
  </tr>
164
173
  <tr>
165
- <td style="padding: 8px 0; font-size: 12px; font-weight: 700; color: #333333; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.03em;">Nouveau statut</td>
166
- <td style="padding: 8px 0; font-size: 15px; color: #1f2937; line-height: 1.5;"><strong>${escapeHtml(String(statusLabel))}</strong></td>
174
+ <td style="padding: 8px 0; font-size: 11px; font-weight: 700; color: #6b7280; width: 150px; vertical-align: top; text-transform: uppercase; letter-spacing: 0.04em;">Nouveau statut</td>
175
+ <td style="padding: 8px 0; font-size: 15px; color: #2a3142; line-height: 1.5;"><strong>${escapeHtml(String(statusLabel))}</strong></td>
167
176
  </tr>
168
177
  </table>`,
169
- contextMessage,
170
178
  emailButton('Consulter le ticket', ticketUrl),
171
- ].join('')),
179
+ ].join(''), {
180
+ kind: 'system',
181
+ preheader: `Ticket ${ticketNumber} — nouveau statut : ${statusLabel}`,
182
+ }),
172
183
  })
173
184
 
174
185
  payload.logger.info(`[tickets] Status change notification sent to ${client.email} for ${ticketNumber} (${previousDoc?.status} -> ${doc.status})`)