@consilioweb/payload-support 6.0.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "6.0.2",
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
+ })