@consilioweb/payload-support 6.0.1 → 6.0.3

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/index.cjs CHANGED
@@ -2102,6 +2102,7 @@ function createSlaCheckEndpoint(slugs) {
2102
2102
  slaResolutionDue: true,
2103
2103
  slaFirstResponseBreached: true,
2104
2104
  slaResolutionBreached: true,
2105
+ slaPausedAt: true,
2105
2106
  firstResponseAt: true,
2106
2107
  createdAt: true
2107
2108
  }
@@ -2136,7 +2137,8 @@ function createSlaCheckEndpoint(slugs) {
2136
2137
  }
2137
2138
  }
2138
2139
  if (t.slaResolutionDue) {
2139
- const deadline = new Date(t.slaResolutionDue);
2140
+ const pausedMs = t.slaPausedAt ? Math.max(0, now.getTime() - new Date(t.slaPausedAt).getTime()) : 0;
2141
+ const deadline = new Date(new Date(t.slaResolutionDue).getTime() + pausedMs);
2140
2142
  if (now > deadline) {
2141
2143
  ticketData.breachTypes.push("resolution");
2142
2144
  } else {
@@ -8057,7 +8059,8 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
8057
8059
  try {
8058
8060
  const { payload } = req;
8059
8061
  const now = /* @__PURE__ */ new Date();
8060
- const deadline = new Date(doc.slaResolutionDue);
8062
+ const pausedMs = doc.slaPausedAt ? Math.max(0, now.getTime() - new Date(doc.slaPausedAt).getTime()) : 0;
8063
+ const deadline = new Date(new Date(doc.slaResolutionDue).getTime() + pausedMs);
8061
8064
  const breached = now > deadline;
8062
8065
  await dbUpdate(payload, slugs.tickets, {
8063
8066
  id: doc.id,
package/dist/index.js CHANGED
@@ -2093,6 +2093,7 @@ function createSlaCheckEndpoint(slugs) {
2093
2093
  slaResolutionDue: true,
2094
2094
  slaFirstResponseBreached: true,
2095
2095
  slaResolutionBreached: true,
2096
+ slaPausedAt: true,
2096
2097
  firstResponseAt: true,
2097
2098
  createdAt: true
2098
2099
  }
@@ -2127,7 +2128,8 @@ function createSlaCheckEndpoint(slugs) {
2127
2128
  }
2128
2129
  }
2129
2130
  if (t.slaResolutionDue) {
2130
- const deadline = new Date(t.slaResolutionDue);
2131
+ const pausedMs = t.slaPausedAt ? Math.max(0, now.getTime() - new Date(t.slaPausedAt).getTime()) : 0;
2132
+ const deadline = new Date(new Date(t.slaResolutionDue).getTime() + pausedMs);
2131
2133
  if (now > deadline) {
2132
2134
  ticketData.breachTypes.push("resolution");
2133
2135
  } else {
@@ -8048,7 +8050,8 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
8048
8050
  try {
8049
8051
  const { payload } = req;
8050
8052
  const now = /* @__PURE__ */ new Date();
8051
- const deadline = new Date(doc.slaResolutionDue);
8053
+ const pausedMs = doc.slaPausedAt ? Math.max(0, now.getTime() - new Date(doc.slaPausedAt).getTime()) : 0;
8054
+ const deadline = new Date(new Date(doc.slaResolutionDue).getTime() + pausedMs);
8052
8055
  const breached = now > deadline;
8053
8056
  await dbUpdate(payload, slugs.tickets, {
8054
8057
  id: doc.id,
@@ -286,6 +286,10 @@
286
286
  .slaOk { color: var(--theme-elevation-400); }
287
287
  .slaWarn { color: var(--theme-warning-500, #c97a0a); font-weight: 600; }
288
288
  .slaBreach { color: var(--theme-error-500, #c4392c); font-weight: 700; }
289
+ // Paused: deliberately neutral and italic. The clock is stopped because the
290
+ // team is waiting on the client, so this is not a state anyone should act on —
291
+ // it must not compete with the warn and breach colours next to it.
292
+ .slaPaused { color: var(--theme-elevation-650); font-style: italic; }
289
293
 
290
294
  .timeAgo {
291
295
  font-size: 12px;
@@ -105,11 +105,13 @@ const TicketInboxClient = () => {
105
105
  `select[slaFirstResponseBreached]=true`,
106
106
  `select[slaResolutionDue]=true`,
107
107
  `select[slaResolutionBreached]=true`,
108
+ `select[slaPausedAt]=true`,
108
109
  `select[firstResponseAt]=true`
109
110
  ];
110
111
  if (tab === "sla_breach") {
111
112
  params.push(`where[or][0][slaFirstResponseBreached][equals]=true`);
112
113
  params.push(`where[or][1][slaResolutionBreached][equals]=true`);
114
+ params.push(`where[slaPausedAt][exists]=false`);
113
115
  } else if (tab !== "all") {
114
116
  params.push(`where[status][equals]=${tab}`);
115
117
  }
@@ -146,7 +148,7 @@ const TicketInboxClient = () => {
146
148
  fetch(`/api/tickets/count?where[status][equals]=open&${sn}`, { credentials: "include" }),
147
149
  fetch(`/api/tickets/count?where[status][equals]=waiting_client&${sn}`, { credentials: "include" }),
148
150
  fetch(`/api/tickets/count?where[status][equals]=resolved&${sn}`, { credentials: "include" }),
149
- fetch(`/api/tickets/count?where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&${sn}`, { credentials: "include" })
151
+ fetch(`/api/tickets/count?where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&where[slaPausedAt][exists]=false&${sn}`, { credentials: "include" })
150
152
  ]);
151
153
  const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()]);
152
154
  setCounts({
@@ -300,6 +302,7 @@ const TicketInboxClient = () => {
300
302
  const priorityColor = PRIORITY_COLORS[tk.priority] || "transparent";
301
303
  const sla = computeSlaState(tk);
302
304
  const isBreach = sla.state === "breach";
305
+ const isPaused = sla.state === "paused";
303
306
  const statusVariant = STATUS_VARIANT[tk.status] || "open";
304
307
  return /* @__PURE__ */ jsxs(
305
308
  "a",
@@ -347,10 +350,11 @@ const TicketInboxClient = () => {
347
350
  s.slaCell,
348
351
  sla.state === "breach" ? s.slaBreach : "",
349
352
  sla.state === "warn" ? s.slaWarn : "",
350
- sla.state === "ok" ? s.slaOk : ""
353
+ sla.state === "ok" ? s.slaOk : "",
354
+ isPaused ? s.slaPaused : ""
351
355
  ].filter(Boolean).join(" "),
352
- title: sla.due ? new Date(sla.due).toLocaleString(DATE_LOCALE) : "",
353
- children: formatSlaRemaining(sla.remainingMs)
356
+ title: isPaused ? t("inbox.slaPausedTitle", { since: formatSlaRemaining(sla.pausedMs ?? 0) }) : sla.due ? new Date(sla.due).toLocaleString(DATE_LOCALE) : "",
357
+ children: isPaused ? t("inbox.slaPaused") : formatSlaRemaining(sla.remainingMs)
354
358
  }
355
359
  ),
356
360
  /* @__PURE__ */ jsx("span", { className: s.timeAgo, children: relativeTime(tk.updatedAt, t) }),
@@ -348,7 +348,9 @@
348
348
  "responseAwaitedFor": "Awaiting for {{duration}}",
349
349
  "target": "Due: {{target}}",
350
350
  "escalate": "Escalate"
351
- }
351
+ },
352
+ "slaPaused": "paused",
353
+ "slaPausedTitle": "SLA paused for {since} — the clock resumes when the client replies"
352
354
  },
353
355
  "emailTracking": {
354
356
  "title": "Email tracking",
@@ -695,19 +697,58 @@
695
697
  "saved2": "✓ Saved",
696
698
  "saveChanges": "Save changes",
697
699
  "features": {
698
- "canned": { "label": "Quick replies", "description": "Pre-registered response templates with dynamic variables" },
699
- "scheduledReplies": { "label": "Scheduled replies", "description": "Send a reply at a future date/time" },
700
- "activityLog": { "label": "Activity log", "description": "Timeline of actions on each ticket (status changes, assignment...)" },
701
- "emailTracking": { "label": "Email tracking", "description": "Send and open tracking for email notifications" },
702
- "chat": { "label": "Live Chat", "description": "Real-time chat with ticket conversion" },
703
- "externalMessages": { "label": "External messages", "description": "Manually add messages received by email, SMS, WhatsApp..." },
704
- "ai": { "label": "Artificial Intelligence", "description": "Sentiment analysis, summary, reply suggestion, rewriting" },
705
- "timeTracking": { "label": "Time tracking", "description": "Timer, manual entries, billing" },
706
- "satisfaction": { "label": "Satisfaction surveys", "description": "CSAT score after ticket resolution" },
707
- "merge": { "label": "Ticket merge", "description": "Combine two tickets into one" },
708
- "splitTicket": { "label": "Message extraction", "description": "Extract a message into a new linked ticket" },
709
- "snooze": { "label": "Snooze", "description": "Temporarily hide a ticket with automatic reminder" },
710
- "clientHistory": { "label": "Client history", "description": "Past tickets, projects and internal notes" }
700
+ "canned": {
701
+ "label": "Quick replies",
702
+ "description": "Pre-registered response templates with dynamic variables"
703
+ },
704
+ "scheduledReplies": {
705
+ "label": "Scheduled replies",
706
+ "description": "Send a reply at a future date/time"
707
+ },
708
+ "activityLog": {
709
+ "label": "Activity log",
710
+ "description": "Timeline of actions on each ticket (status changes, assignment...)"
711
+ },
712
+ "emailTracking": {
713
+ "label": "Email tracking",
714
+ "description": "Send and open tracking for email notifications"
715
+ },
716
+ "chat": {
717
+ "label": "Live Chat",
718
+ "description": "Real-time chat with ticket conversion"
719
+ },
720
+ "externalMessages": {
721
+ "label": "External messages",
722
+ "description": "Manually add messages received by email, SMS, WhatsApp..."
723
+ },
724
+ "ai": {
725
+ "label": "Artificial Intelligence",
726
+ "description": "Sentiment analysis, summary, reply suggestion, rewriting"
727
+ },
728
+ "timeTracking": {
729
+ "label": "Time tracking",
730
+ "description": "Timer, manual entries, billing"
731
+ },
732
+ "satisfaction": {
733
+ "label": "Satisfaction surveys",
734
+ "description": "CSAT score after ticket resolution"
735
+ },
736
+ "merge": {
737
+ "label": "Ticket merge",
738
+ "description": "Combine two tickets into one"
739
+ },
740
+ "splitTicket": {
741
+ "label": "Message extraction",
742
+ "description": "Extract a message into a new linked ticket"
743
+ },
744
+ "snooze": {
745
+ "label": "Snooze",
746
+ "description": "Temporarily hide a ticket with automatic reminder"
747
+ },
748
+ "clientHistory": {
749
+ "label": "Client history",
750
+ "description": "Past tickets, projects and internal notes"
751
+ }
711
752
  },
712
753
  "categories": {
713
754
  "core": "Core features",
@@ -348,7 +348,9 @@
348
348
  "responseAwaitedFor": "En attente depuis {{duration}}",
349
349
  "target": "Échéance : {{target}}",
350
350
  "escalate": "Escalader"
351
- }
351
+ },
352
+ "slaPaused": "en pause",
353
+ "slaPausedTitle": "SLA en pause depuis {since} — le compteur repart à la réponse du client"
352
354
  },
353
355
  "emailTracking": {
354
356
  "title": "Suivi des emails",
@@ -695,19 +697,58 @@
695
697
  "saved2": "✓ Sauvegardé",
696
698
  "saveChanges": "Sauvegarder les modifications",
697
699
  "features": {
698
- "canned": { "label": "Réponses rapides", "description": "Templates de réponses pré-enregistrées avec variables dynamiques" },
699
- "scheduledReplies": { "label": "Réponses programmées", "description": "Envoyer une réponse à une date/heure future" },
700
- "activityLog": { "label": "Journal d'activité", "description": "Timeline des actions sur chaque ticket (changements de statut, assignation...)" },
701
- "emailTracking": { "label": "Suivi des emails", "description": "Tracking d'envoi et d'ouverture des notifications email" },
702
- "chat": { "label": "Live Chat", "description": "Chat en temps réel avec conversion en ticket" },
703
- "externalMessages": { "label": "Messages externes", "description": "Ajouter manuellement des messages reçus par email, SMS, WhatsApp..." },
704
- "ai": { "label": "Intelligence Artificielle", "description": "Analyse de sentiment, synthèse, suggestion de réponse, reformulation" },
705
- "timeTracking": { "label": "Suivi du temps", "description": "Timer, entrées manuelles, facturation" },
706
- "satisfaction": { "label": "Enquêtes satisfaction", "description": "Score CSAT après résolution du ticket" },
707
- "merge": { "label": "Fusion de tickets", "description": "Combiner deux tickets en un seul" },
708
- "splitTicket": { "label": "Extraction de message", "description": "Extraire un message en nouveau ticket lié" },
709
- "snooze": { "label": "Snooze", "description": "Masquer temporairement un ticket et rappel automatique" },
710
- "clientHistory": { "label": "Historique client", "description": "Tickets passés, projets et notes internes du client" }
700
+ "canned": {
701
+ "label": "Réponses rapides",
702
+ "description": "Templates de réponses pré-enregistrées avec variables dynamiques"
703
+ },
704
+ "scheduledReplies": {
705
+ "label": "Réponses programmées",
706
+ "description": "Envoyer une réponse à une date/heure future"
707
+ },
708
+ "activityLog": {
709
+ "label": "Journal d'activité",
710
+ "description": "Timeline des actions sur chaque ticket (changements de statut, assignation...)"
711
+ },
712
+ "emailTracking": {
713
+ "label": "Suivi des emails",
714
+ "description": "Tracking d'envoi et d'ouverture des notifications email"
715
+ },
716
+ "chat": {
717
+ "label": "Live Chat",
718
+ "description": "Chat en temps réel avec conversion en ticket"
719
+ },
720
+ "externalMessages": {
721
+ "label": "Messages externes",
722
+ "description": "Ajouter manuellement des messages reçus par email, SMS, WhatsApp..."
723
+ },
724
+ "ai": {
725
+ "label": "Intelligence Artificielle",
726
+ "description": "Analyse de sentiment, synthèse, suggestion de réponse, reformulation"
727
+ },
728
+ "timeTracking": {
729
+ "label": "Suivi du temps",
730
+ "description": "Timer, entrées manuelles, facturation"
731
+ },
732
+ "satisfaction": {
733
+ "label": "Enquêtes satisfaction",
734
+ "description": "Score CSAT après résolution du ticket"
735
+ },
736
+ "merge": {
737
+ "label": "Fusion de tickets",
738
+ "description": "Combiner deux tickets en un seul"
739
+ },
740
+ "splitTicket": {
741
+ "label": "Extraction de message",
742
+ "description": "Extraire un message en nouveau ticket lié"
743
+ },
744
+ "snooze": {
745
+ "label": "Snooze",
746
+ "description": "Masquer temporairement un ticket et rappel automatique"
747
+ },
748
+ "clientHistory": {
749
+ "label": "Historique client",
750
+ "description": "Tickets passés, projets et notes internes du client"
751
+ }
711
752
  },
712
753
  "categories": {
713
754
  "core": "Fonctionnalités de base",
@@ -1,14 +1,33 @@
1
- export type SlaState = 'ok' | 'warn' | 'breach' | 'none' | 'met';
1
+ export type SlaState = 'ok' | 'warn' | 'breach' | 'none' | 'met' | 'paused';
2
2
  export interface SlaInput {
3
3
  slaFirstResponseDue?: string | null;
4
4
  slaFirstResponseBreached?: boolean | null;
5
5
  firstResponseAt?: string | null;
6
6
  slaResolutionDue?: string | null;
7
7
  slaResolutionBreached?: boolean | null;
8
+ /** Set while the ticket sits in `waiting_client`; the resolution clock is stopped. */
9
+ slaPausedAt?: string | null;
8
10
  }
11
+ /**
12
+ * How long the resolution clock has been stopped, in milliseconds, or 0.
13
+ *
14
+ * `createPauseSlaOnHold` only stamps `slaPausedAt` when the ticket enters
15
+ * `waiting_client`; it pushes `slaResolutionDue` forward *on the way out*, once
16
+ * it knows how long the pause lasted. So for the whole duration of the pause the
17
+ * stored deadline is stale by exactly this much, and anything comparing it to
18
+ * `now` — this function's callers included — reads a breach that the server does
19
+ * not consider one.
20
+ *
21
+ * Rather than wait for the resume hook, we add the elapsed pause back here. The
22
+ * arithmetic is the same the hook performs later, so the badge during the pause
23
+ * and the stored deadline after it agree.
24
+ */
25
+ export declare function pausedForMs(t: SlaInput, now?: Date): number;
9
26
  export declare function computeSlaState(t: SlaInput, now?: Date): {
10
27
  state: SlaState;
11
28
  remainingMs: number | null;
12
29
  due: string | null;
30
+ /** Only set while paused: how long the clock has been stopped. */
31
+ pausedMs?: number;
13
32
  };
14
33
  export declare function formatSlaRemaining(remainingMs: number | null): string;
@@ -1,5 +1,11 @@
1
1
  const HOUR_MS = 36e5;
2
2
  const MINUTE_MS = 6e4;
3
+ function pausedForMs(t, now = /* @__PURE__ */ new Date()) {
4
+ if (!t.slaPausedAt) return 0;
5
+ const pausedAt = new Date(t.slaPausedAt);
6
+ if (Number.isNaN(pausedAt.getTime())) return 0;
7
+ return Math.max(0, now.getTime() - pausedAt.getTime());
8
+ }
3
9
  function computeSlaState(t, now = /* @__PURE__ */ new Date()) {
4
10
  const hasFirstResponse = !!t.firstResponseAt;
5
11
  const dueRaw = hasFirstResponse ? t.slaResolutionDue : t.slaFirstResponseDue;
@@ -7,7 +13,12 @@ function computeSlaState(t, now = /* @__PURE__ */ new Date()) {
7
13
  if (!dueRaw) return { state: "none", remainingMs: null, due: null };
8
14
  const dueDate = new Date(dueRaw);
9
15
  if (Number.isNaN(dueDate.getTime())) return { state: "none", remainingMs: null, due: null };
10
- const remaining = dueDate.getTime() - now.getTime();
16
+ const pausedMs = hasFirstResponse ? pausedForMs(t, now) : 0;
17
+ const remaining = dueDate.getTime() + pausedMs - now.getTime();
18
+ if (pausedMs > 0) {
19
+ if (breached) return { state: "breach", remainingMs: remaining, due: dueRaw, pausedMs };
20
+ return { state: "paused", remainingMs: remaining, due: dueRaw, pausedMs };
21
+ }
11
22
  if (breached || remaining < 0) {
12
23
  return { state: "breach", remainingMs: remaining, due: dueRaw };
13
24
  }
@@ -31,4 +42,4 @@ function formatSlaRemaining(remainingMs) {
31
42
  return `${sign}${d}j`;
32
43
  }
33
44
 
34
- export { computeSlaState, formatSlaRemaining };
45
+ export { computeSlaState, formatSlaRemaining, pausedForMs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "6.0.1",
3
+ "version": "6.0.3",
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",
@@ -41,7 +41,8 @@
41
41
  }
42
42
  },
43
43
  "bin": {
44
- "support-uninstall": "./scripts/uninstall.mjs"
44
+ "support-uninstall": "./scripts/uninstall.mjs",
45
+ "support-audit-sla": "./scripts/audit-sla-breaches.mjs"
45
46
  },
46
47
  "files": [
47
48
  "dist",
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Data remediation for `slaResolutionBreached` flags written in error.
5
+ *
6
+ * NOT meant to be run directly: it is spawned through `payload run`, which puts
7
+ * the TypeScript loader and the environment in place so the host's
8
+ * `payload.config.ts` can be imported. See the usage banner at the bottom.
9
+ *
10
+ * WHY THIS EXISTS
11
+ * ---------------
12
+ * Until 6.0.2, resolving a ticket straight out of `waiting_client` could store a
13
+ * breach that never happened.
14
+ *
15
+ * `createPauseSlaOnHold` and `createCheckSlaOnResolve` are both `afterChange`
16
+ * hooks on tickets, registered in that order. When a paused ticket is resolved,
17
+ * the first pushes `slaResolutionDue` forward by the paused span and writes it
18
+ * to the database — but the `doc` handed to the second was captured before that
19
+ * write. It therefore compared `now` against the UN-extended deadline and wrote
20
+ * `slaResolutionBreached: true`.
21
+ *
22
+ * 6.0.2 closes the cause. It does not sweep what came through, and nothing else
23
+ * does either: the flag is `admin: { readOnly: true }`, so it cannot be cleared
24
+ * from the admin panel.
25
+ *
26
+ * HOW A FALSE FLAG IS IDENTIFIED — and why this is provable rather than guessed
27
+ * ---------------------------------------------------------------------------
28
+ * The pause hook DID write the extended deadline; only the sibling hook read a
29
+ * stale copy. So the row ends up carrying a CORRECT `slaResolutionDue` next to
30
+ * an INCORRECT `slaResolutionBreached`, and re-comparing the two settles it:
31
+ *
32
+ * resolvedAt <= slaResolutionDue → the ticket met its SLA, flag is false
33
+ * resolvedAt > slaResolutionDue → it really did breach, flag is right
34
+ *
35
+ * A ticket that was never paused has an unmodified deadline, so the same
36
+ * comparison confirms its flag instead of clearing it. There is no window in
37
+ * which this test clears a genuine breach.
38
+ *
39
+ * WHAT IT CANNOT SEE
40
+ * ------------------
41
+ * If the resume hook itself failed — its body is wrapped in a `try`/`catch` that
42
+ * logs to `console.error` and swallows — the deadline was never extended, and
43
+ * the row looks exactly like an honest breach. Those are indistinguishable after
44
+ * the fact and are left alone. Check your server logs for
45
+ * `[sla] Failed to pause/resume SLA on hold` over the affected period.
46
+ *
47
+ * `slaFirstResponseBreached` is never touched: the pause stops the resolution
48
+ * clock only, so first-response flags were always computed correctly.
49
+ */
50
+
51
+ import { getPayload } from 'payload'
52
+ import { findConfig } from 'payload/node'
53
+ import { pathToFileURL } from 'node:url'
54
+
55
+ const args = process.argv.slice(2)
56
+ const APPLY = args.includes('--fix')
57
+ const slugArg = args.find((a) => a.startsWith('--tickets='))
58
+ const TICKETS_SLUG = slugArg ? slugArg.slice('--tickets='.length) : 'tickets'
59
+
60
+ const fmt = (d) => (d ? new Date(d).toISOString().replace('T', ' ').slice(0, 16) : '—')
61
+
62
+ /** Minutes between two dates, signed, for a human-readable margin. */
63
+ const minutesBetween = (a, b) => Math.round((new Date(a).getTime() - new Date(b).getTime()) / 60000)
64
+
65
+ async function main() {
66
+ const config = await import(pathToFileURL(findConfig()).href).then((m) => m.default)
67
+ const payload = await getPayload({ config })
68
+
69
+ let rows
70
+ try {
71
+ const res = await payload.find({
72
+ collection: TICKETS_SLUG,
73
+ where: { slaResolutionBreached: { equals: true } },
74
+ limit: 0,
75
+ depth: 0,
76
+ overrideAccess: true,
77
+ select: {
78
+ ticketNumber: true,
79
+ status: true,
80
+ resolvedAt: true,
81
+ slaResolutionDue: true,
82
+ slaResolutionBreached: true,
83
+ slaPausedAt: true,
84
+ },
85
+ })
86
+ rows = res.docs
87
+ } catch (err) {
88
+ console.error(`\n Could not read the '${TICKETS_SLUG}' collection: ${err.message}`)
89
+ console.error(' If your install renames it, pass --tickets=<slug>.\n')
90
+ process.exit(1)
91
+ }
92
+
93
+ console.log(`\n ${rows.length} ticket(s) carry slaResolutionBreached = true.\n`)
94
+
95
+ const falseFlags = []
96
+ const confirmed = []
97
+ const undecidable = []
98
+
99
+ for (const t of rows) {
100
+ if (!t.slaResolutionDue) {
101
+ // No deadline to compare against — the flag predates the SLA policy or the
102
+ // field was cleared. Not ours to judge.
103
+ undecidable.push({ t, why: 'no slaResolutionDue to compare against' })
104
+ continue
105
+ }
106
+ if (!t.resolvedAt) {
107
+ // Still open. The resolve race cannot have produced this flag, so it came
108
+ // from somewhere we are not modelling — report, never touch.
109
+ undecidable.push({ t, why: `flagged but not resolved (status: ${t.status})` })
110
+ continue
111
+ }
112
+ const margin = minutesBetween(t.slaResolutionDue, t.resolvedAt)
113
+ if (margin >= 0) falseFlags.push({ t, margin })
114
+ else confirmed.push({ t, margin })
115
+ }
116
+
117
+ if (confirmed.length) {
118
+ console.log(` ${confirmed.length} confirmed breach(es) — left untouched:`)
119
+ for (const { t, margin } of confirmed) {
120
+ console.log(` ${t.ticketNumber} resolved ${fmt(t.resolvedAt)}, ${-margin} min past its deadline`)
121
+ }
122
+ console.log('')
123
+ }
124
+
125
+ if (undecidable.length) {
126
+ console.log(` ${undecidable.length} row(s) this script will not judge:`)
127
+ for (const { t, why } of undecidable) console.log(` ${t.ticketNumber} ${why}`)
128
+ console.log('')
129
+ }
130
+
131
+ if (!falseFlags.length) {
132
+ console.log(' No false breach found. Nothing to repair.\n')
133
+ return
134
+ }
135
+
136
+ console.log(` ${falseFlags.length} FALSE breach(es) — resolved within the deadline:`)
137
+ for (const { t, margin } of falseFlags) {
138
+ console.log(
139
+ ` ${t.ticketNumber} resolved ${fmt(t.resolvedAt)}, ` +
140
+ `deadline ${fmt(t.slaResolutionDue)} — ${margin} min to spare`,
141
+ )
142
+ }
143
+ console.log('')
144
+
145
+ if (!APPLY) {
146
+ console.log(' Read-only run. Re-run with --fix to clear these flags.\n')
147
+ return
148
+ }
149
+
150
+ let repaired = 0
151
+ for (const { t } of falseFlags) {
152
+ try {
153
+ await payload.update({
154
+ collection: TICKETS_SLUG,
155
+ id: t.id,
156
+ data: { slaResolutionBreached: false },
157
+ overrideAccess: true,
158
+ // The hooks on this collection react to a status change; this write
159
+ // changes none, but skipping them keeps the repair inert either way.
160
+ context: { skipSlaHooks: true },
161
+ })
162
+ repaired += 1
163
+ } catch (err) {
164
+ console.error(` ${t.ticketNumber}: update failed — ${err.message}`)
165
+ }
166
+ }
167
+ console.log(`\n Cleared ${repaired} of ${falseFlags.length} false flag(s).\n`)
168
+ }
169
+
170
+ main()
171
+ .then(() => process.exit(0))
172
+ .catch((err) => {
173
+ console.error('\n audit-sla-breaches failed:', err)
174
+ process.exit(1)
175
+ })
@@ -47,6 +47,7 @@ export function createSlaCheckEndpoint(slugs: CollectionSlugs): Endpoint {
47
47
  slaResolutionDue: true,
48
48
  slaFirstResponseBreached: true,
49
49
  slaResolutionBreached: true,
50
+ slaPausedAt: true,
50
51
  firstResponseAt: true,
51
52
  createdAt: true,
52
53
  },
@@ -85,9 +86,19 @@ export function createSlaCheckEndpoint(slugs: CollectionSlugs): Endpoint {
85
86
  }
86
87
  }
87
88
 
88
- // Check resolution SLA
89
+ // Check resolution SLA.
90
+ //
91
+ // A ticket sitting in `waiting_client` has its resolution clock
92
+ // stopped, but `createPauseSlaOnHold` only pushes `slaResolutionDue`
93
+ // forward when the ticket LEAVES that status. During the pause the
94
+ // stored deadline is stale by exactly the elapsed pause, so comparing
95
+ // it to `now` reports a breach the server does not consider one — and
96
+ // this endpoint is what feeds the escalation mail.
89
97
  if (t.slaResolutionDue) {
90
- const deadline = new Date(t.slaResolutionDue)
98
+ const pausedMs = t.slaPausedAt
99
+ ? Math.max(0, now.getTime() - new Date(t.slaPausedAt).getTime())
100
+ : 0
101
+ const deadline = new Date(new Date(t.slaResolutionDue).getTime() + pausedMs)
91
102
  if (now > deadline) {
92
103
  ticketData.breachTypes.push('resolution')
93
104
  } else {
@@ -353,7 +353,20 @@ export function createCheckSlaOnResolve(slugs: CollectionSlugs, notificationSlug
353
353
  try {
354
354
  const { payload } = req
355
355
  const now = new Date()
356
- const deadline = new Date(doc.slaResolutionDue as string)
356
+
357
+ // `createPauseSlaOnHold` is registered just before this hook and, when the
358
+ // ticket is resolved straight out of `waiting_client`, it pushes
359
+ // `slaResolutionDue` forward by the paused span. That write lands in the
360
+ // database, but the `doc` handed to THIS hook was captured before it — so
361
+ // reading `doc.slaResolutionDue` here compares against the un-extended
362
+ // deadline and persists a breach that never happened.
363
+ //
364
+ // Re-adding the pause locally gives the same deadline the sibling hook
365
+ // just wrote, without depending on hook ordering or on re-reading the row.
366
+ const pausedMs = doc.slaPausedAt
367
+ ? Math.max(0, now.getTime() - new Date(doc.slaPausedAt as string).getTime())
368
+ : 0
369
+ const deadline = new Date(new Date(doc.slaResolutionDue as string).getTime() + pausedMs)
357
370
  const breached = now > deadline
358
371
 
359
372
  await dbUpdate(payload, slugs.tickets, {
@@ -286,6 +286,10 @@
286
286
  .slaOk { color: var(--theme-elevation-400); }
287
287
  .slaWarn { color: var(--theme-warning-500, #c97a0a); font-weight: 600; }
288
288
  .slaBreach { color: var(--theme-error-500, #c4392c); font-weight: 700; }
289
+ // Paused: deliberately neutral and italic. The clock is stopped because the
290
+ // team is waiting on the client, so this is not a state anyone should act on —
291
+ // it must not compete with the warn and breach colours next to it.
292
+ .slaPaused { color: var(--theme-elevation-650); font-style: italic; }
289
293
 
290
294
  .timeAgo {
291
295
  font-size: 12px;
@@ -26,6 +26,7 @@ interface Ticket {
26
26
  slaFirstResponseBreached?: boolean | null
27
27
  slaResolutionDue?: string | null
28
28
  slaResolutionBreached?: boolean | null
29
+ slaPausedAt?: string | null
29
30
  firstResponseAt?: string | null
30
31
  }
31
32
 
@@ -128,13 +129,21 @@ export const TicketInboxClient: React.FC = () => {
128
129
  `select[client]=true`, `select[updatedAt]=true`,
129
130
  `select[lastClientMessageAt]=true`, `select[lastAdminReadAt]=true`,
130
131
  `select[slaFirstResponseDue]=true`, `select[slaFirstResponseBreached]=true`,
131
- `select[slaResolutionDue]=true`, `select[slaResolutionBreached]=true`,
132
+ `select[slaResolutionDue]=true`, `select[slaResolutionBreached]=true`, `select[slaPausedAt]=true`,
132
133
  `select[firstResponseAt]=true`,
133
134
  ]
134
135
  if (tab === 'sla_breach') {
135
- // Either first-response breached or resolution breached
136
+ // Either first-response breached or resolution breached.
137
+ //
138
+ // A paused ticket is excluded. `createPauseSlaOnHold` stamps `slaPausedAt`
139
+ // on the way into `waiting_client` and only pushes `slaResolutionDue`
140
+ // forward on the way out, so during the pause the stored deadline is stale
141
+ // and this list would show a breach the server does not consider one.
142
+ // `computeSlaState` applies the same rule when it renders the row, so the
143
+ // tab and the badge cannot disagree.
136
144
  params.push(`where[or][0][slaFirstResponseBreached][equals]=true`)
137
145
  params.push(`where[or][1][slaResolutionBreached][equals]=true`)
146
+ params.push(`where[slaPausedAt][exists]=false`)
138
147
  } else if (tab !== 'all') {
139
148
  params.push(`where[status][equals]=${tab}`)
140
149
  }
@@ -178,7 +187,7 @@ export const TicketInboxClient: React.FC = () => {
178
187
  fetch(`/api/tickets/count?where[status][equals]=open&${sn}`, { credentials: 'include' }),
179
188
  fetch(`/api/tickets/count?where[status][equals]=waiting_client&${sn}`, { credentials: 'include' }),
180
189
  fetch(`/api/tickets/count?where[status][equals]=resolved&${sn}`, { credentials: 'include' }),
181
- fetch(`/api/tickets/count?where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&${sn}`, { credentials: 'include' }),
190
+ fetch(`/api/tickets/count?where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&where[slaPausedAt][exists]=false&${sn}`, { credentials: 'include' }),
182
191
  ])
183
192
  const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()])
184
193
  setCounts({
@@ -350,6 +359,7 @@ export const TicketInboxClient: React.FC = () => {
350
359
  const priorityColor = PRIORITY_COLORS[tk.priority] || 'transparent'
351
360
  const sla = computeSlaState(tk)
352
361
  const isBreach = sla.state === 'breach'
362
+ const isPaused = sla.state === 'paused'
353
363
  const statusVariant: 'open' | 'pending' | 'resolved' | 'closed' = STATUS_VARIANT[tk.status] || 'open'
354
364
 
355
365
  return (
@@ -388,10 +398,15 @@ export const TicketInboxClient: React.FC = () => {
388
398
  sla.state === 'breach' ? s.slaBreach : '',
389
399
  sla.state === 'warn' ? s.slaWarn : '',
390
400
  sla.state === 'ok' ? s.slaOk : '',
401
+ isPaused ? s.slaPaused : '',
391
402
  ].filter(Boolean).join(' ')}
392
- title={sla.due ? new Date(sla.due).toLocaleString(DATE_LOCALE) : ''}
403
+ title={
404
+ isPaused
405
+ ? t('inbox.slaPausedTitle', { since: formatSlaRemaining(sla.pausedMs ?? 0) })
406
+ : sla.due ? new Date(sla.due).toLocaleString(DATE_LOCALE) : ''
407
+ }
393
408
  >
394
- {formatSlaRemaining(sla.remainingMs)}
409
+ {isPaused ? t('inbox.slaPaused') : formatSlaRemaining(sla.remainingMs)}
395
410
  </span>
396
411
  <span className={s.timeAgo}>{relativeTime(tk.updatedAt, t)}</span>
397
412
  {isUnread ? <div className={s.unreadDot} /> : <span />}
@@ -348,7 +348,9 @@
348
348
  "responseAwaitedFor": "Awaiting for {{duration}}",
349
349
  "target": "Due: {{target}}",
350
350
  "escalate": "Escalate"
351
- }
351
+ },
352
+ "slaPaused": "paused",
353
+ "slaPausedTitle": "SLA paused for {since} — the clock resumes when the client replies"
352
354
  },
353
355
  "emailTracking": {
354
356
  "title": "Email tracking",
@@ -695,19 +697,58 @@
695
697
  "saved2": "✓ Saved",
696
698
  "saveChanges": "Save changes",
697
699
  "features": {
698
- "canned": { "label": "Quick replies", "description": "Pre-registered response templates with dynamic variables" },
699
- "scheduledReplies": { "label": "Scheduled replies", "description": "Send a reply at a future date/time" },
700
- "activityLog": { "label": "Activity log", "description": "Timeline of actions on each ticket (status changes, assignment...)" },
701
- "emailTracking": { "label": "Email tracking", "description": "Send and open tracking for email notifications" },
702
- "chat": { "label": "Live Chat", "description": "Real-time chat with ticket conversion" },
703
- "externalMessages": { "label": "External messages", "description": "Manually add messages received by email, SMS, WhatsApp..." },
704
- "ai": { "label": "Artificial Intelligence", "description": "Sentiment analysis, summary, reply suggestion, rewriting" },
705
- "timeTracking": { "label": "Time tracking", "description": "Timer, manual entries, billing" },
706
- "satisfaction": { "label": "Satisfaction surveys", "description": "CSAT score after ticket resolution" },
707
- "merge": { "label": "Ticket merge", "description": "Combine two tickets into one" },
708
- "splitTicket": { "label": "Message extraction", "description": "Extract a message into a new linked ticket" },
709
- "snooze": { "label": "Snooze", "description": "Temporarily hide a ticket with automatic reminder" },
710
- "clientHistory": { "label": "Client history", "description": "Past tickets, projects and internal notes" }
700
+ "canned": {
701
+ "label": "Quick replies",
702
+ "description": "Pre-registered response templates with dynamic variables"
703
+ },
704
+ "scheduledReplies": {
705
+ "label": "Scheduled replies",
706
+ "description": "Send a reply at a future date/time"
707
+ },
708
+ "activityLog": {
709
+ "label": "Activity log",
710
+ "description": "Timeline of actions on each ticket (status changes, assignment...)"
711
+ },
712
+ "emailTracking": {
713
+ "label": "Email tracking",
714
+ "description": "Send and open tracking for email notifications"
715
+ },
716
+ "chat": {
717
+ "label": "Live Chat",
718
+ "description": "Real-time chat with ticket conversion"
719
+ },
720
+ "externalMessages": {
721
+ "label": "External messages",
722
+ "description": "Manually add messages received by email, SMS, WhatsApp..."
723
+ },
724
+ "ai": {
725
+ "label": "Artificial Intelligence",
726
+ "description": "Sentiment analysis, summary, reply suggestion, rewriting"
727
+ },
728
+ "timeTracking": {
729
+ "label": "Time tracking",
730
+ "description": "Timer, manual entries, billing"
731
+ },
732
+ "satisfaction": {
733
+ "label": "Satisfaction surveys",
734
+ "description": "CSAT score after ticket resolution"
735
+ },
736
+ "merge": {
737
+ "label": "Ticket merge",
738
+ "description": "Combine two tickets into one"
739
+ },
740
+ "splitTicket": {
741
+ "label": "Message extraction",
742
+ "description": "Extract a message into a new linked ticket"
743
+ },
744
+ "snooze": {
745
+ "label": "Snooze",
746
+ "description": "Temporarily hide a ticket with automatic reminder"
747
+ },
748
+ "clientHistory": {
749
+ "label": "Client history",
750
+ "description": "Past tickets, projects and internal notes"
751
+ }
711
752
  },
712
753
  "categories": {
713
754
  "core": "Core features",
@@ -348,7 +348,9 @@
348
348
  "responseAwaitedFor": "En attente depuis {{duration}}",
349
349
  "target": "Échéance : {{target}}",
350
350
  "escalate": "Escalader"
351
- }
351
+ },
352
+ "slaPaused": "en pause",
353
+ "slaPausedTitle": "SLA en pause depuis {since} — le compteur repart à la réponse du client"
352
354
  },
353
355
  "emailTracking": {
354
356
  "title": "Suivi des emails",
@@ -695,19 +697,58 @@
695
697
  "saved2": "✓ Sauvegardé",
696
698
  "saveChanges": "Sauvegarder les modifications",
697
699
  "features": {
698
- "canned": { "label": "Réponses rapides", "description": "Templates de réponses pré-enregistrées avec variables dynamiques" },
699
- "scheduledReplies": { "label": "Réponses programmées", "description": "Envoyer une réponse à une date/heure future" },
700
- "activityLog": { "label": "Journal d'activité", "description": "Timeline des actions sur chaque ticket (changements de statut, assignation...)" },
701
- "emailTracking": { "label": "Suivi des emails", "description": "Tracking d'envoi et d'ouverture des notifications email" },
702
- "chat": { "label": "Live Chat", "description": "Chat en temps réel avec conversion en ticket" },
703
- "externalMessages": { "label": "Messages externes", "description": "Ajouter manuellement des messages reçus par email, SMS, WhatsApp..." },
704
- "ai": { "label": "Intelligence Artificielle", "description": "Analyse de sentiment, synthèse, suggestion de réponse, reformulation" },
705
- "timeTracking": { "label": "Suivi du temps", "description": "Timer, entrées manuelles, facturation" },
706
- "satisfaction": { "label": "Enquêtes satisfaction", "description": "Score CSAT après résolution du ticket" },
707
- "merge": { "label": "Fusion de tickets", "description": "Combiner deux tickets en un seul" },
708
- "splitTicket": { "label": "Extraction de message", "description": "Extraire un message en nouveau ticket lié" },
709
- "snooze": { "label": "Snooze", "description": "Masquer temporairement un ticket et rappel automatique" },
710
- "clientHistory": { "label": "Historique client", "description": "Tickets passés, projets et notes internes du client" }
700
+ "canned": {
701
+ "label": "Réponses rapides",
702
+ "description": "Templates de réponses pré-enregistrées avec variables dynamiques"
703
+ },
704
+ "scheduledReplies": {
705
+ "label": "Réponses programmées",
706
+ "description": "Envoyer une réponse à une date/heure future"
707
+ },
708
+ "activityLog": {
709
+ "label": "Journal d'activité",
710
+ "description": "Timeline des actions sur chaque ticket (changements de statut, assignation...)"
711
+ },
712
+ "emailTracking": {
713
+ "label": "Suivi des emails",
714
+ "description": "Tracking d'envoi et d'ouverture des notifications email"
715
+ },
716
+ "chat": {
717
+ "label": "Live Chat",
718
+ "description": "Chat en temps réel avec conversion en ticket"
719
+ },
720
+ "externalMessages": {
721
+ "label": "Messages externes",
722
+ "description": "Ajouter manuellement des messages reçus par email, SMS, WhatsApp..."
723
+ },
724
+ "ai": {
725
+ "label": "Intelligence Artificielle",
726
+ "description": "Analyse de sentiment, synthèse, suggestion de réponse, reformulation"
727
+ },
728
+ "timeTracking": {
729
+ "label": "Suivi du temps",
730
+ "description": "Timer, entrées manuelles, facturation"
731
+ },
732
+ "satisfaction": {
733
+ "label": "Enquêtes satisfaction",
734
+ "description": "Score CSAT après résolution du ticket"
735
+ },
736
+ "merge": {
737
+ "label": "Fusion de tickets",
738
+ "description": "Combiner deux tickets en un seul"
739
+ },
740
+ "splitTicket": {
741
+ "label": "Extraction de message",
742
+ "description": "Extraire un message en nouveau ticket lié"
743
+ },
744
+ "snooze": {
745
+ "label": "Snooze",
746
+ "description": "Masquer temporairement un ticket et rappel automatique"
747
+ },
748
+ "clientHistory": {
749
+ "label": "Historique client",
750
+ "description": "Tickets passés, projets et notes internes du client"
751
+ }
711
752
  },
712
753
  "categories": {
713
754
  "core": "Fonctionnalités de base",
@@ -1,7 +1,7 @@
1
1
  // SLA helpers — derive remaining time + visual state from ticket's
2
2
  // slaFirstResponseDue / slaFirstResponseBreached fields (already on Tickets).
3
3
 
4
- export type SlaState = 'ok' | 'warn' | 'breach' | 'none' | 'met'
4
+ export type SlaState = 'ok' | 'warn' | 'breach' | 'none' | 'met' | 'paused'
5
5
 
6
6
  export interface SlaInput {
7
7
  slaFirstResponseDue?: string | null
@@ -9,15 +9,40 @@ export interface SlaInput {
9
9
  firstResponseAt?: string | null
10
10
  slaResolutionDue?: string | null
11
11
  slaResolutionBreached?: boolean | null
12
+ /** Set while the ticket sits in `waiting_client`; the resolution clock is stopped. */
13
+ slaPausedAt?: string | null
12
14
  }
13
15
 
14
16
  const HOUR_MS = 3_600_000
15
17
  const MINUTE_MS = 60_000
16
18
 
19
+ /**
20
+ * How long the resolution clock has been stopped, in milliseconds, or 0.
21
+ *
22
+ * `createPauseSlaOnHold` only stamps `slaPausedAt` when the ticket enters
23
+ * `waiting_client`; it pushes `slaResolutionDue` forward *on the way out*, once
24
+ * it knows how long the pause lasted. So for the whole duration of the pause the
25
+ * stored deadline is stale by exactly this much, and anything comparing it to
26
+ * `now` — this function's callers included — reads a breach that the server does
27
+ * not consider one.
28
+ *
29
+ * Rather than wait for the resume hook, we add the elapsed pause back here. The
30
+ * arithmetic is the same the hook performs later, so the badge during the pause
31
+ * and the stored deadline after it agree.
32
+ */
33
+ export function pausedForMs(t: SlaInput, now: Date = new Date()): number {
34
+ if (!t.slaPausedAt) return 0
35
+ const pausedAt = new Date(t.slaPausedAt)
36
+ if (Number.isNaN(pausedAt.getTime())) return 0
37
+ return Math.max(0, now.getTime() - pausedAt.getTime())
38
+ }
39
+
17
40
  export function computeSlaState(t: SlaInput, now: Date = new Date()): {
18
41
  state: SlaState
19
42
  remainingMs: number | null
20
43
  due: string | null
44
+ /** Only set while paused: how long the clock has been stopped. */
45
+ pausedMs?: number
21
46
  } {
22
47
  // Once first-response is met, fall back to resolution SLA.
23
48
  const hasFirstResponse = !!t.firstResponseAt
@@ -29,7 +54,18 @@ export function computeSlaState(t: SlaInput, now: Date = new Date()): {
29
54
  const dueDate = new Date(dueRaw)
30
55
  if (Number.isNaN(dueDate.getTime())) return { state: 'none', remainingMs: null, due: null }
31
56
 
32
- const remaining = dueDate.getTime() - now.getTime()
57
+ // The pause only stops the RESOLUTION clock. A first-response SLA keeps
58
+ // running while the client is being waited on — the agent has still not
59
+ // answered, which is precisely what that target measures.
60
+ const pausedMs = hasFirstResponse ? pausedForMs(t, now) : 0
61
+ const remaining = dueDate.getTime() + pausedMs - now.getTime()
62
+
63
+ if (pausedMs > 0) {
64
+ // A stored breach flag is not overridden: if the clock had already run out
65
+ // before the client was asked, the ticket really did breach.
66
+ if (breached) return { state: 'breach', remainingMs: remaining, due: dueRaw, pausedMs }
67
+ return { state: 'paused', remainingMs: remaining, due: dueRaw, pausedMs }
68
+ }
33
69
 
34
70
  if (breached || remaining < 0) {
35
71
  return { state: 'breach', remainingMs: remaining, due: dueRaw }