@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 +3 -2
- package/scripts/audit-sla-breaches.mjs +175 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consilioweb/payload-support",
|
|
3
|
-
"version": "6.0.
|
|
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
|
+
})
|