@classytic/primitives 0.4.0 → 0.6.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/CHANGELOG.md +162 -0
- package/dist/address.d.mts +1 -1
- package/dist/address.mjs +1 -1
- package/dist/approval.d.mts +1 -1
- package/dist/approval.mjs +1 -1
- package/dist/bank-transaction.d.mts +167 -0
- package/dist/bank-transaction.mjs +1 -0
- package/dist/{brand-DApaegoR.d.mts → brand-LLXqoh5J.d.mts} +1 -1
- package/dist/brand.d.mts +1 -1
- package/dist/cadence.d.mts +1 -1
- package/dist/cadence.mjs +1 -1
- package/dist/condition.d.mts +76 -0
- package/dist/condition.mjs +131 -0
- package/dist/context.d.mts +1 -1
- package/dist/currency-B4rSdN7D.d.mts +110 -0
- package/dist/currency.d.mts +1 -109
- package/dist/currency.mjs +1 -1
- package/dist/events.d.mts +1 -1
- package/dist/events.mjs +1 -1
- package/dist/hold.d.mts +1 -1
- package/dist/hold.mjs +1 -1
- package/dist/mixin.d.mts +73 -0
- package/dist/mixin.mjs +47 -0
- package/dist/money.d.mts +2 -2
- package/dist/money.mjs +1 -1
- package/dist/outbox.d.mts +1 -1
- package/dist/outbox.mjs +1 -1
- package/dist/payment-gateway.d.mts +168 -0
- package/dist/payment-gateway.mjs +1 -0
- package/dist/period.d.mts +1 -1
- package/dist/period.mjs +1 -1
- package/dist/person.d.mts +1 -1
- package/dist/person.mjs +1 -1
- package/dist/phone.d.mts +47 -0
- package/dist/phone.mjs +310 -0
- package/dist/reference.d.mts +1 -1
- package/dist/reference.mjs +1 -1
- package/dist/result.d.mts +1 -1
- package/dist/result.mjs +1 -1
- package/dist/sla-policy.d.mts +116 -0
- package/dist/sla-policy.mjs +120 -0
- package/dist/sla.d.mts +1 -1
- package/dist/sla.mjs +1 -1
- package/dist/split-allocation.d.mts +1 -1
- package/dist/split-allocation.mjs +1 -1
- package/dist/state-machine.d.mts +1 -1
- package/dist/state-machine.mjs +1 -1
- package/dist/status-history.d.mts +86 -0
- package/dist/status-history.mjs +48 -0
- package/package.json +29 -1
package/dist/phone.mjs
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { err, ok } from "./result.mjs";
|
|
2
|
+
//#region src/identity/phone.ts
|
|
3
|
+
/**
|
|
4
|
+
* Phone number value object.
|
|
5
|
+
*
|
|
6
|
+
* `PersonName` and `Address` are typed; phones have been a plain `string` in
|
|
7
|
+
* `ContactInfo` so far. This primitive closes that gap with a tiny,
|
|
8
|
+
* zero-dependency wrapper that:
|
|
9
|
+
* 1. Normalizes E.164 input (strips formatting, keeps the leading `+`).
|
|
10
|
+
* 2. Captures the country calling code as a discrete field for routing /
|
|
11
|
+
* regional analytics.
|
|
12
|
+
* 3. Surfaces parse failures via `Result<PhoneNumber, PhoneError>` so
|
|
13
|
+
* callers don't deal with thrown errors at value-object construction.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately NOT a full libphonenumber dep — that ships ~500 KB of metadata
|
|
16
|
+
* and is per-locale. We accept E.164 input and validate format; deeper
|
|
17
|
+
* validation (region-of-issue, mobile-vs-landline) is the host's call to
|
|
18
|
+
* layer on top. This keeps `@classytic/primitives` zero-dep and tree-shakeable.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* import { parsePhone, formatPhone, type PhoneNumber } from '@classytic/primitives/phone';
|
|
22
|
+
*
|
|
23
|
+
* const parsed = parsePhone('+1 (415) 555-0182');
|
|
24
|
+
* if (parsed.ok) {
|
|
25
|
+
* formatPhone(parsed.value); // '+14155550182'
|
|
26
|
+
* parsed.value.callingCode; // '1'
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
var PhoneError = class extends Error {
|
|
30
|
+
name = "PhoneError";
|
|
31
|
+
code;
|
|
32
|
+
constructor(code, message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.code = code;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* E.164 country calling codes by length. We accept 1–3 digits and let the
|
|
39
|
+
* longest-prefix-match decide. Source: ITU-T E.164 assignments (snapshot —
|
|
40
|
+
* stable across decades).
|
|
41
|
+
*
|
|
42
|
+
* Kept inline (not exported) because it's an implementation detail of the
|
|
43
|
+
* parser. Length is the only thing we actually need for the split.
|
|
44
|
+
*/
|
|
45
|
+
const E164_PREFIXES_3 = new Set([
|
|
46
|
+
"210",
|
|
47
|
+
"211",
|
|
48
|
+
"212",
|
|
49
|
+
"213",
|
|
50
|
+
"216",
|
|
51
|
+
"218",
|
|
52
|
+
"220",
|
|
53
|
+
"221",
|
|
54
|
+
"222",
|
|
55
|
+
"223",
|
|
56
|
+
"224",
|
|
57
|
+
"225",
|
|
58
|
+
"226",
|
|
59
|
+
"227",
|
|
60
|
+
"228",
|
|
61
|
+
"229",
|
|
62
|
+
"230",
|
|
63
|
+
"231",
|
|
64
|
+
"232",
|
|
65
|
+
"233",
|
|
66
|
+
"234",
|
|
67
|
+
"235",
|
|
68
|
+
"236",
|
|
69
|
+
"237",
|
|
70
|
+
"238",
|
|
71
|
+
"239",
|
|
72
|
+
"240",
|
|
73
|
+
"241",
|
|
74
|
+
"242",
|
|
75
|
+
"243",
|
|
76
|
+
"244",
|
|
77
|
+
"245",
|
|
78
|
+
"246",
|
|
79
|
+
"247",
|
|
80
|
+
"248",
|
|
81
|
+
"249",
|
|
82
|
+
"250",
|
|
83
|
+
"251",
|
|
84
|
+
"252",
|
|
85
|
+
"253",
|
|
86
|
+
"254",
|
|
87
|
+
"255",
|
|
88
|
+
"256",
|
|
89
|
+
"257",
|
|
90
|
+
"258",
|
|
91
|
+
"260",
|
|
92
|
+
"261",
|
|
93
|
+
"262",
|
|
94
|
+
"263",
|
|
95
|
+
"264",
|
|
96
|
+
"265",
|
|
97
|
+
"266",
|
|
98
|
+
"267",
|
|
99
|
+
"268",
|
|
100
|
+
"269",
|
|
101
|
+
"290",
|
|
102
|
+
"291",
|
|
103
|
+
"297",
|
|
104
|
+
"298",
|
|
105
|
+
"299",
|
|
106
|
+
"350",
|
|
107
|
+
"351",
|
|
108
|
+
"352",
|
|
109
|
+
"353",
|
|
110
|
+
"354",
|
|
111
|
+
"355",
|
|
112
|
+
"356",
|
|
113
|
+
"357",
|
|
114
|
+
"358",
|
|
115
|
+
"359",
|
|
116
|
+
"370",
|
|
117
|
+
"371",
|
|
118
|
+
"372",
|
|
119
|
+
"373",
|
|
120
|
+
"374",
|
|
121
|
+
"375",
|
|
122
|
+
"376",
|
|
123
|
+
"377",
|
|
124
|
+
"378",
|
|
125
|
+
"379",
|
|
126
|
+
"380",
|
|
127
|
+
"381",
|
|
128
|
+
"382",
|
|
129
|
+
"383",
|
|
130
|
+
"385",
|
|
131
|
+
"386",
|
|
132
|
+
"387",
|
|
133
|
+
"389",
|
|
134
|
+
"420",
|
|
135
|
+
"421",
|
|
136
|
+
"423",
|
|
137
|
+
"500",
|
|
138
|
+
"501",
|
|
139
|
+
"502",
|
|
140
|
+
"503",
|
|
141
|
+
"504",
|
|
142
|
+
"505",
|
|
143
|
+
"506",
|
|
144
|
+
"507",
|
|
145
|
+
"508",
|
|
146
|
+
"509",
|
|
147
|
+
"590",
|
|
148
|
+
"591",
|
|
149
|
+
"592",
|
|
150
|
+
"593",
|
|
151
|
+
"594",
|
|
152
|
+
"595",
|
|
153
|
+
"596",
|
|
154
|
+
"597",
|
|
155
|
+
"598",
|
|
156
|
+
"599",
|
|
157
|
+
"670",
|
|
158
|
+
"672",
|
|
159
|
+
"673",
|
|
160
|
+
"674",
|
|
161
|
+
"675",
|
|
162
|
+
"676",
|
|
163
|
+
"677",
|
|
164
|
+
"678",
|
|
165
|
+
"679",
|
|
166
|
+
"680",
|
|
167
|
+
"681",
|
|
168
|
+
"682",
|
|
169
|
+
"683",
|
|
170
|
+
"685",
|
|
171
|
+
"686",
|
|
172
|
+
"687",
|
|
173
|
+
"688",
|
|
174
|
+
"689",
|
|
175
|
+
"690",
|
|
176
|
+
"691",
|
|
177
|
+
"692",
|
|
178
|
+
"800",
|
|
179
|
+
"808",
|
|
180
|
+
"850",
|
|
181
|
+
"852",
|
|
182
|
+
"853",
|
|
183
|
+
"855",
|
|
184
|
+
"856",
|
|
185
|
+
"870",
|
|
186
|
+
"878",
|
|
187
|
+
"880",
|
|
188
|
+
"881",
|
|
189
|
+
"882",
|
|
190
|
+
"883",
|
|
191
|
+
"886",
|
|
192
|
+
"888",
|
|
193
|
+
"960",
|
|
194
|
+
"961",
|
|
195
|
+
"962",
|
|
196
|
+
"963",
|
|
197
|
+
"964",
|
|
198
|
+
"965",
|
|
199
|
+
"966",
|
|
200
|
+
"967",
|
|
201
|
+
"968",
|
|
202
|
+
"970",
|
|
203
|
+
"971",
|
|
204
|
+
"972",
|
|
205
|
+
"973",
|
|
206
|
+
"974",
|
|
207
|
+
"975",
|
|
208
|
+
"976",
|
|
209
|
+
"977",
|
|
210
|
+
"979",
|
|
211
|
+
"992",
|
|
212
|
+
"993",
|
|
213
|
+
"994",
|
|
214
|
+
"995",
|
|
215
|
+
"996",
|
|
216
|
+
"998"
|
|
217
|
+
]);
|
|
218
|
+
const E164_PREFIXES_2 = new Set([
|
|
219
|
+
"20",
|
|
220
|
+
"27",
|
|
221
|
+
"30",
|
|
222
|
+
"31",
|
|
223
|
+
"32",
|
|
224
|
+
"33",
|
|
225
|
+
"34",
|
|
226
|
+
"36",
|
|
227
|
+
"39",
|
|
228
|
+
"40",
|
|
229
|
+
"41",
|
|
230
|
+
"43",
|
|
231
|
+
"44",
|
|
232
|
+
"45",
|
|
233
|
+
"46",
|
|
234
|
+
"47",
|
|
235
|
+
"48",
|
|
236
|
+
"49",
|
|
237
|
+
"51",
|
|
238
|
+
"52",
|
|
239
|
+
"53",
|
|
240
|
+
"54",
|
|
241
|
+
"55",
|
|
242
|
+
"56",
|
|
243
|
+
"57",
|
|
244
|
+
"58",
|
|
245
|
+
"60",
|
|
246
|
+
"61",
|
|
247
|
+
"62",
|
|
248
|
+
"63",
|
|
249
|
+
"64",
|
|
250
|
+
"65",
|
|
251
|
+
"66",
|
|
252
|
+
"81",
|
|
253
|
+
"82",
|
|
254
|
+
"84",
|
|
255
|
+
"86",
|
|
256
|
+
"90",
|
|
257
|
+
"91",
|
|
258
|
+
"92",
|
|
259
|
+
"93",
|
|
260
|
+
"94",
|
|
261
|
+
"95",
|
|
262
|
+
"98"
|
|
263
|
+
]);
|
|
264
|
+
const E164_PREFIXES_1 = new Set(["1", "7"]);
|
|
265
|
+
/**
|
|
266
|
+
* Parse free-form phone input into a normalized `PhoneNumber`. Accepts:
|
|
267
|
+
* - `'+14155550182'` → e164 unchanged
|
|
268
|
+
* - `'+1 (415) 555-0182'` → strips spaces / punctuation
|
|
269
|
+
* - `'+1.415.555.0182'` → strips dots
|
|
270
|
+
*
|
|
271
|
+
* Rejects anything without a leading `+` — we don't guess the country.
|
|
272
|
+
* If the host knows the user's region, prepend the code before calling.
|
|
273
|
+
*/
|
|
274
|
+
function parsePhone(input) {
|
|
275
|
+
if (typeof input !== "string" || input.trim().length === 0) return err(new PhoneError("EMPTY", "phone input is empty"));
|
|
276
|
+
const trimmed = input.trim();
|
|
277
|
+
if (!trimmed.startsWith("+")) return err(new PhoneError("MISSING_PLUS", `phone must be E.164 (start with '+'): got ${JSON.stringify(input)}`));
|
|
278
|
+
const digits = trimmed.slice(1).replace(/[^0-9]/g, "");
|
|
279
|
+
if (digits.length === 0) return err(new PhoneError("NOT_E164", "phone has no digits"));
|
|
280
|
+
if (digits.length > 15) return err(new PhoneError("TOO_LONG", `phone exceeds E.164 15-digit limit: ${digits.length} digits`));
|
|
281
|
+
if (digits.length < 7) return err(new PhoneError("TOO_SHORT", `phone too short to be E.164: ${digits.length} digits`));
|
|
282
|
+
const callingCode = E164_PREFIXES_3.has(digits.slice(0, 3)) ? digits.slice(0, 3) : E164_PREFIXES_2.has(digits.slice(0, 2)) ? digits.slice(0, 2) : E164_PREFIXES_1.has(digits.slice(0, 1)) ? digits.slice(0, 1) : null;
|
|
283
|
+
if (callingCode === null) return err(new PhoneError("NOT_E164", `unrecognized country calling code in ${JSON.stringify(input)}`));
|
|
284
|
+
return ok({
|
|
285
|
+
e164: `+${digits}`,
|
|
286
|
+
callingCode,
|
|
287
|
+
nationalNumber: digits.slice(callingCode.length)
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/** Convenience: returns the E.164 string. Useful when piping to a transport. */
|
|
291
|
+
function formatPhone(phone) {
|
|
292
|
+
return phone.e164;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Best-effort national-format render: groups the national number into 3-digit
|
|
296
|
+
* blocks. Not locale-aware — for display polish, hosts should format per
|
|
297
|
+
* region. Useful as a default in admin UIs.
|
|
298
|
+
*
|
|
299
|
+
* @example formatNational(parsePhone('+14155550182').value!) → '+1 415 555 0182'
|
|
300
|
+
*/
|
|
301
|
+
function formatNational(phone) {
|
|
302
|
+
const groups = phone.nationalNumber.match(/.{1,3}/g) ?? [];
|
|
303
|
+
return `+${phone.callingCode} ${groups.join(" ")}`.trim();
|
|
304
|
+
}
|
|
305
|
+
/** Type guard. */
|
|
306
|
+
function isPhoneNumber(value) {
|
|
307
|
+
return typeof value === "object" && value !== null && typeof value.e164 === "string" && typeof value.callingCode === "string" && typeof value.nationalNumber === "string" && value.e164.startsWith("+");
|
|
308
|
+
}
|
|
309
|
+
//#endregion
|
|
310
|
+
export { PhoneError, formatNational, formatPhone, isPhoneNumber, parsePhone };
|
package/dist/reference.d.mts
CHANGED
package/dist/reference.mjs
CHANGED
package/dist/result.d.mts
CHANGED
package/dist/result.mjs
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { IsoWeekday } from "./cadence.mjs";
|
|
2
|
+
import { SLA } from "./sla.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/scheduling/sla-policy.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Tier of severity. Hosts decide the keys ('urgent' / 'p0' / 'gold' / etc.) —
|
|
7
|
+
* keep them stable across the org so reports and routing stay consistent.
|
|
8
|
+
*/
|
|
9
|
+
type PriorityKey = string;
|
|
10
|
+
interface PriorityRule {
|
|
11
|
+
/** Time allotted for the FIRST reply. */
|
|
12
|
+
readonly firstResponseMs: number;
|
|
13
|
+
/** Time allotted for EACH subsequent reply (after first response was made). */
|
|
14
|
+
readonly rollingResponseMs: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Working-hours window. Times are minutes-since-midnight in the org's
|
|
18
|
+
* timezone — same convention as `cadence` for ISO weekdays.
|
|
19
|
+
*
|
|
20
|
+
* For simple "always on" SLAs, omit this field entirely.
|
|
21
|
+
*/
|
|
22
|
+
interface WorkingHours {
|
|
23
|
+
/** ISO weekdays when work happens (1=Mon … 7=Sun). */
|
|
24
|
+
readonly weekdays: readonly IsoWeekday[];
|
|
25
|
+
readonly startMinute: number;
|
|
26
|
+
readonly endMinute: number;
|
|
27
|
+
/**
|
|
28
|
+
* IANA timezone (e.g. `'Asia/Dhaka'`). Optional — pure math here is UTC,
|
|
29
|
+
* but the host can localize when displaying.
|
|
30
|
+
*/
|
|
31
|
+
readonly timezone?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Dates to skip entirely (holidays). Stored as YYYY-MM-DD strings so
|
|
34
|
+
* round-trips through JSON stay lossless.
|
|
35
|
+
*/
|
|
36
|
+
readonly holidays?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
interface SLAPolicy {
|
|
39
|
+
readonly id?: string;
|
|
40
|
+
readonly name: string;
|
|
41
|
+
/** Map of priority → durations. Must include `defaultPriority`. */
|
|
42
|
+
readonly priorities: Readonly<Record<PriorityKey, PriorityRule>>;
|
|
43
|
+
readonly defaultPriority: PriorityKey;
|
|
44
|
+
/** Optional — omit for 24/7 SLAs. */
|
|
45
|
+
readonly workingHours?: WorkingHours;
|
|
46
|
+
/** Optional human label for dashboards. */
|
|
47
|
+
readonly label?: string;
|
|
48
|
+
}
|
|
49
|
+
type SLAPolicyErrorCode = 'EMPTY_PRIORITIES' | 'UNKNOWN_DEFAULT' | 'INVALID_DURATION' | 'INVALID_HOURS';
|
|
50
|
+
declare class SLAPolicyError extends Error {
|
|
51
|
+
readonly name = "SLAPolicyError";
|
|
52
|
+
readonly code: SLAPolicyErrorCode;
|
|
53
|
+
constructor(code: SLAPolicyErrorCode, message: string);
|
|
54
|
+
}
|
|
55
|
+
/** Validate + freeze. Use this when registering a policy at app boot. */
|
|
56
|
+
declare function defineSLAPolicy(spec: SLAPolicy): SLAPolicy;
|
|
57
|
+
/**
|
|
58
|
+
* Convert a `SLAPolicy + priority` into the simpler `SLA` value object the
|
|
59
|
+
* `./sla` module already knows how to evaluate. The host stores the policy
|
|
60
|
+
* once; per-entity it persists `{ slaPolicyId, priority, startedAt }` and
|
|
61
|
+
* derives the SLA on demand.
|
|
62
|
+
*/
|
|
63
|
+
declare function deriveFirstResponseSLA(policy: SLAPolicy, priority?: PriorityKey): SLA;
|
|
64
|
+
declare function deriveRollingResponseSLA(policy: SLAPolicy, priority?: PriorityKey): SLA;
|
|
65
|
+
/**
|
|
66
|
+
* Materialized SLA state at evaluation time. Hosts persist the inputs
|
|
67
|
+
* (`startedAt`, `firstRespondedAt`, `lastRespondedAt`); the output is derived
|
|
68
|
+
* on read so changes to the policy take effect immediately.
|
|
69
|
+
*/
|
|
70
|
+
type SLAStatusKind = 'FirstResponseDue' | 'FirstResponseFulfilled' | 'RollingResponseDue' | 'Failed';
|
|
71
|
+
interface SLAStatus {
|
|
72
|
+
readonly kind: SLAStatusKind;
|
|
73
|
+
readonly responseBy: Date;
|
|
74
|
+
readonly remainingMs: number;
|
|
75
|
+
readonly breached: boolean;
|
|
76
|
+
/** Which SLA was driving the deadline calculation. */
|
|
77
|
+
readonly underlying: SLA;
|
|
78
|
+
}
|
|
79
|
+
interface SLAInputs {
|
|
80
|
+
readonly priority?: PriorityKey;
|
|
81
|
+
readonly startedAt: Date;
|
|
82
|
+
/** Set when the first agent reply has been recorded. */
|
|
83
|
+
readonly firstRespondedAt: Date | null;
|
|
84
|
+
/**
|
|
85
|
+
* Set to the most recent agent reply that closed the rolling window.
|
|
86
|
+
* Falls back to `firstRespondedAt` when no rolling cycle has elapsed.
|
|
87
|
+
*/
|
|
88
|
+
readonly lastRespondedAt: Date | null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Compute current SLA status against a policy. Pure — no clock side effects
|
|
92
|
+
* beyond the explicit `now` argument.
|
|
93
|
+
*
|
|
94
|
+
* Decision table:
|
|
95
|
+
* - firstRespondedAt == null → FirstResponseDue/Failed against firstResponseMs
|
|
96
|
+
* - firstRespondedAt set, no further outstanding cycle → FirstResponseFulfilled
|
|
97
|
+
* - firstRespondedAt set + a rolling cycle has started → RollingResponseDue/Failed
|
|
98
|
+
*
|
|
99
|
+
* The "rolling cycle has started" half is host-driven: when the customer
|
|
100
|
+
* replies again, the host sets `lastRespondedAt` to the prior agent reply
|
|
101
|
+
* and the SLA clock resumes from there. Until that happens, the status is
|
|
102
|
+
* `FirstResponseFulfilled`.
|
|
103
|
+
*/
|
|
104
|
+
declare function evaluateSLAStatus(policy: SLAPolicy, inputs: SLAInputs, now?: Date): SLAStatus;
|
|
105
|
+
/**
|
|
106
|
+
* Is `instant` within the policy's working hours? Returns `true` when the
|
|
107
|
+
* policy has no `workingHours` defined (always-on SLA).
|
|
108
|
+
*
|
|
109
|
+
* NOTE: math here is UTC-based for determinism — if the host needs the
|
|
110
|
+
* policy in local time, normalize `instant` to the policy's `timezone`
|
|
111
|
+
* before passing in. Bringing a tz lib into `@classytic/primitives` is a
|
|
112
|
+
* dep we don't want.
|
|
113
|
+
*/
|
|
114
|
+
declare function isWithinWorkingHours(policy: SLAPolicy, instant: Date): boolean;
|
|
115
|
+
//#endregion
|
|
116
|
+
export { PriorityKey, PriorityRule, SLAInputs, SLAPolicy, SLAPolicyError, SLAPolicyErrorCode, SLAStatus, SLAStatusKind, WorkingHours, defineSLAPolicy, deriveFirstResponseSLA, deriveRollingResponseSLA, evaluateSLAStatus, isWithinWorkingHours };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { isBreached, remainingMs } from "./sla.mjs";
|
|
2
|
+
//#region src/scheduling/sla-policy.ts
|
|
3
|
+
var SLAPolicyError = class extends Error {
|
|
4
|
+
name = "SLAPolicyError";
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
/** Validate + freeze. Use this when registering a policy at app boot. */
|
|
12
|
+
function defineSLAPolicy(spec) {
|
|
13
|
+
const keys = Object.keys(spec.priorities);
|
|
14
|
+
if (keys.length === 0) throw new SLAPolicyError("EMPTY_PRIORITIES", "priorities map is empty");
|
|
15
|
+
if (!keys.includes(spec.defaultPriority)) throw new SLAPolicyError("UNKNOWN_DEFAULT", `defaultPriority '${spec.defaultPriority}' is not present in priorities map`);
|
|
16
|
+
for (const [key, rule] of Object.entries(spec.priorities)) if (!Number.isFinite(rule.firstResponseMs) || rule.firstResponseMs <= 0 || !Number.isFinite(rule.rollingResponseMs) || rule.rollingResponseMs <= 0) throw new SLAPolicyError("INVALID_DURATION", `priority '${key}' has non-positive duration`);
|
|
17
|
+
if (spec.workingHours !== void 0) {
|
|
18
|
+
const wh = spec.workingHours;
|
|
19
|
+
if (wh.startMinute < 0 || wh.startMinute >= 1440 || wh.endMinute <= wh.startMinute || wh.endMinute > 1440 || wh.weekdays.length === 0) throw new SLAPolicyError("INVALID_HOURS", "workingHours bounds are invalid");
|
|
20
|
+
}
|
|
21
|
+
return spec;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Convert a `SLAPolicy + priority` into the simpler `SLA` value object the
|
|
25
|
+
* `./sla` module already knows how to evaluate. The host stores the policy
|
|
26
|
+
* once; per-entity it persists `{ slaPolicyId, priority, startedAt }` and
|
|
27
|
+
* derives the SLA on demand.
|
|
28
|
+
*/
|
|
29
|
+
function deriveFirstResponseSLA(policy, priority) {
|
|
30
|
+
return {
|
|
31
|
+
targetDurationMs: priorityRule(policy, priority).firstResponseMs,
|
|
32
|
+
breachPolicy: "escalate",
|
|
33
|
+
label: `${policy.name} · first response · ${priority ?? policy.defaultPriority}`
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function deriveRollingResponseSLA(policy, priority) {
|
|
37
|
+
return {
|
|
38
|
+
targetDurationMs: priorityRule(policy, priority).rollingResponseMs,
|
|
39
|
+
breachPolicy: "escalate",
|
|
40
|
+
label: `${policy.name} · rolling response · ${priority ?? policy.defaultPriority}`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function priorityRule(policy, priority) {
|
|
44
|
+
const key = priority ?? policy.defaultPriority;
|
|
45
|
+
const rule = policy.priorities[key] ?? policy.priorities[policy.defaultPriority];
|
|
46
|
+
if (rule === void 0) throw new SLAPolicyError("UNKNOWN_DEFAULT", `priority '${key}' not found and defaultPriority resolution failed`);
|
|
47
|
+
return rule;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Compute current SLA status against a policy. Pure — no clock side effects
|
|
51
|
+
* beyond the explicit `now` argument.
|
|
52
|
+
*
|
|
53
|
+
* Decision table:
|
|
54
|
+
* - firstRespondedAt == null → FirstResponseDue/Failed against firstResponseMs
|
|
55
|
+
* - firstRespondedAt set, no further outstanding cycle → FirstResponseFulfilled
|
|
56
|
+
* - firstRespondedAt set + a rolling cycle has started → RollingResponseDue/Failed
|
|
57
|
+
*
|
|
58
|
+
* The "rolling cycle has started" half is host-driven: when the customer
|
|
59
|
+
* replies again, the host sets `lastRespondedAt` to the prior agent reply
|
|
60
|
+
* and the SLA clock resumes from there. Until that happens, the status is
|
|
61
|
+
* `FirstResponseFulfilled`.
|
|
62
|
+
*/
|
|
63
|
+
function evaluateSLAStatus(policy, inputs, now = /* @__PURE__ */ new Date()) {
|
|
64
|
+
if (inputs.firstRespondedAt === null) {
|
|
65
|
+
const sla = deriveFirstResponseSLA(policy, inputs.priority);
|
|
66
|
+
const responseBy = new Date(inputs.startedAt.getTime() + sla.targetDurationMs);
|
|
67
|
+
const breached = isBreached(sla, inputs.startedAt, now);
|
|
68
|
+
return {
|
|
69
|
+
kind: breached ? "Failed" : "FirstResponseDue",
|
|
70
|
+
responseBy,
|
|
71
|
+
remainingMs: remainingMs(sla, inputs.startedAt, now),
|
|
72
|
+
breached,
|
|
73
|
+
underlying: sla
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const rollingBase = inputs.lastRespondedAt ?? inputs.firstRespondedAt;
|
|
77
|
+
if (rollingBase === inputs.firstRespondedAt && inputs.lastRespondedAt === null) {
|
|
78
|
+
const sla = deriveFirstResponseSLA(policy, inputs.priority);
|
|
79
|
+
return {
|
|
80
|
+
kind: "FirstResponseFulfilled",
|
|
81
|
+
responseBy: new Date(inputs.firstRespondedAt.getTime()),
|
|
82
|
+
remainingMs: 0,
|
|
83
|
+
breached: false,
|
|
84
|
+
underlying: sla
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const sla = deriveRollingResponseSLA(policy, inputs.priority);
|
|
88
|
+
const responseBy = new Date(rollingBase.getTime() + sla.targetDurationMs);
|
|
89
|
+
const breached = isBreached(sla, rollingBase, now);
|
|
90
|
+
return {
|
|
91
|
+
kind: breached ? "Failed" : "RollingResponseDue",
|
|
92
|
+
responseBy,
|
|
93
|
+
remainingMs: remainingMs(sla, rollingBase, now),
|
|
94
|
+
breached,
|
|
95
|
+
underlying: sla
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Is `instant` within the policy's working hours? Returns `true` when the
|
|
100
|
+
* policy has no `workingHours` defined (always-on SLA).
|
|
101
|
+
*
|
|
102
|
+
* NOTE: math here is UTC-based for determinism — if the host needs the
|
|
103
|
+
* policy in local time, normalize `instant` to the policy's `timezone`
|
|
104
|
+
* before passing in. Bringing a tz lib into `@classytic/primitives` is a
|
|
105
|
+
* dep we don't want.
|
|
106
|
+
*/
|
|
107
|
+
function isWithinWorkingHours(policy, instant) {
|
|
108
|
+
if (!policy.workingHours) return true;
|
|
109
|
+
const wh = policy.workingHours;
|
|
110
|
+
const weekday = (instant.getUTCDay() + 6) % 7 + 1;
|
|
111
|
+
if (!wh.weekdays.includes(weekday)) return false;
|
|
112
|
+
if (wh.holidays !== void 0 && wh.holidays.length > 0) {
|
|
113
|
+
const isoDate = instant.toISOString().slice(0, 10);
|
|
114
|
+
if (wh.holidays.includes(isoDate)) return false;
|
|
115
|
+
}
|
|
116
|
+
const minuteOfDay = instant.getUTCHours() * 60 + instant.getUTCMinutes();
|
|
117
|
+
return minuteOfDay >= wh.startMinute && minuteOfDay < wh.endMinute;
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
export { SLAPolicyError, defineSLAPolicy, deriveFirstResponseSLA, deriveRollingResponseSLA, evaluateSLAStatus, isWithinWorkingHours };
|
package/dist/sla.d.mts
CHANGED
package/dist/sla.mjs
CHANGED
package/dist/state-machine.d.mts
CHANGED
package/dist/state-machine.mjs
CHANGED