@dszp/netsapiens-lib 0.7.0 → 0.9.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/README.md +95 -11
- package/dist/attribution.d.ts.map +1 -1
- package/dist/attribution.js +65 -28
- package/dist/attribution.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/inventory.d.ts +190 -6
- package/dist/inventory.d.ts.map +1 -1
- package/dist/inventory.js +206 -12
- package/dist/inventory.js.map +1 -1
- package/dist/model.d.ts +13 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/nsClient.d.ts +8 -2
- package/dist/nsClient.d.ts.map +1 -1
- package/dist/nsClient.js +6 -1
- package/dist/nsClient.js.map +1 -1
- package/dist/resolver.d.ts +18 -0
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +40 -19
- package/dist/resolver.js.map +1 -1
- package/package.json +1 -1
- package/src/attribution.selftest.ts +75 -0
- package/src/attribution.ts +60 -18
- package/src/index.ts +1 -1
- package/src/inventory.selftest.ts +279 -5
- package/src/inventory.ts +356 -19
- package/src/model.ts +13 -0
- package/src/nsClient.ts +14 -3
- package/src/resolver.selftest.ts +22 -1
- package/src/resolver.ts +42 -19
package/src/inventory.ts
CHANGED
|
@@ -38,6 +38,35 @@
|
|
|
38
38
|
* case-insensitively, and on nothing else — not the `dial-rule-description`, which is a portal-written
|
|
39
39
|
* note an operator can edit. **With no hosts supplied nothing is a fax line**, because a library that
|
|
40
40
|
* hardcoded one deployment's fax server would be wrong everywhere else.
|
|
41
|
+
*
|
|
42
|
+
* ## E911: the ENDPOINT is the billable unit, the address is a location
|
|
43
|
+
*
|
|
44
|
+
* An **Emergency Endpoint** is a callback number, a caller name, a billing address and a vendor. It is
|
|
45
|
+
* what the E911 carrier routes on and what it bills per, and it is counted as `e911Endpoints`. An
|
|
46
|
+
* **Emergency Address** is a dispatchable location forwarded to responders; several of them can sit
|
|
47
|
+
* under one endpoint, and nobody bills them. `e911Addresses` stays, as information.
|
|
48
|
+
*
|
|
49
|
+
* Users, devices and sites point at an endpoint through their Emergency Caller ID
|
|
50
|
+
* (`caller-id-number-emergency`) matching the endpoint's callback number. This library then INFERS two
|
|
51
|
+
* fallbacks for a blank field — a blank `emergency-address-id` reads as the domain default address, and
|
|
52
|
+
* a blank caller ID reads as that address's endpoint. **Neither is a measured platform behaviour**; see
|
|
53
|
+
* the ⚠️ on {@link EmergencyModel} for what is known and what is assumed. They live in
|
|
54
|
+
* {@link resolveEmergency} alone, so the counter and `attribution.ts` cannot disagree about them, and
|
|
55
|
+
* they fail closed: nothing to inherit leaves a user referencing nothing rather than referencing a
|
|
56
|
+
* guess.
|
|
57
|
+
*
|
|
58
|
+
* ## Legacy emergency numbers, which have no API object at all
|
|
59
|
+
*
|
|
60
|
+
* A domain still on the legacy provisioning model has no endpoint records. Every one of its users
|
|
61
|
+
* carries an EMPTY `emergency-address-id` and a `caller-id-number-emergency` set to one of a handful of
|
|
62
|
+
* DIDs — and the carrier bills per one of those DIDs, exactly as it bills per endpoint on the new
|
|
63
|
+
* model. `e911Legacy` is therefore the count of DISTINCT such numbers, and a rulebook can count the two
|
|
64
|
+
* dimensions together so one retail E911 line pays for either model.
|
|
65
|
+
*
|
|
66
|
+
* A number that IS an endpoint callback is excluded, because a half-migrated domain that counted it in
|
|
67
|
+
* both dimensions would bill the same place twice. That exclusion is also the precondition: with no
|
|
68
|
+
* endpoint list read there is nothing to exclude against, so `e911Legacy` is 0 whenever
|
|
69
|
+
* `snapshot.addressEndpoints` is `undefined` rather than an array. See {@link DomainInventory.e911Legacy}.
|
|
41
70
|
*/
|
|
42
71
|
import type { Rec, Snapshot } from './model.js';
|
|
43
72
|
|
|
@@ -61,10 +90,12 @@ export interface DomainInventory {
|
|
|
61
90
|
/** Extensions whose `voicemail-transcription-enabled` is anything but empty or `no`. */
|
|
62
91
|
transcriptionEnabled: number;
|
|
63
92
|
/**
|
|
64
|
-
* Extensions with a Microsoft Teams connector device — one whose device
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
93
|
+
* Extensions with a Microsoft Teams connector device — one whose device-name SUFFIX the legend marks
|
|
94
|
+
* `teams: true`, which under {@link DEFAULT_DEVICE_SUFFIXES} is the extension number followed by `t`
|
|
95
|
+
* (`1000t`), how the TeamMate connector registers. See {@link deviceName} for which field that name is
|
|
96
|
+
* read from, and why reading the wrong one silently miscounted this. That device is NOT counted under
|
|
97
|
+
* `devices`: it is a connector, not a handset. A supplied legend with no `teams` suffix — a deployment
|
|
98
|
+
* without TeamMate — leaves this 0 and counts every device as a handset.
|
|
68
99
|
*/
|
|
69
100
|
teamsConnected: number;
|
|
70
101
|
/**
|
|
@@ -78,8 +109,25 @@ export interface DomainInventory {
|
|
|
78
109
|
* numbers this returned before 0.7.0, unchanged.
|
|
79
110
|
*/
|
|
80
111
|
dids: { total: number; tollFree: number; local: number; fax: number; all: number };
|
|
81
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* E911 address records on the domain — dispatchable LOCATIONS, and information only. The billable
|
|
114
|
+
* unit is {@link DomainInventory.e911Endpoints}; see the module doc.
|
|
115
|
+
*/
|
|
82
116
|
e911Addresses: number;
|
|
117
|
+
/** Provisioned Emergency Endpoints — the unit the E911 carrier bills per. */
|
|
118
|
+
e911Endpoints: number;
|
|
119
|
+
/**
|
|
120
|
+
* Distinct legacy emergency numbers — the pre-endpoint model, which has no API object of its own.
|
|
121
|
+
* Comparable with {@link DomainInventory.e911Endpoints}, and never overlapping it **provided the
|
|
122
|
+
* snapshot carries an endpoint list**: the two are kept apart by excluding numbers that are already
|
|
123
|
+
* endpoint callbacks, which needs the endpoints to have been read.
|
|
124
|
+
*
|
|
125
|
+
* So this is **0 whenever `snapshot.addressEndpoints` is `undefined`** — the fetch never asked, and a
|
|
126
|
+
* count derived from the users alone would report a fully-migrated domain's every emergency caller ID
|
|
127
|
+
* as a line to bill for. An empty ARRAY is the other fact — asked, and the domain has none — and that
|
|
128
|
+
* one does support a count. Fetch with `includeAddresses: true` (see `fetchDomainSnapshot`).
|
|
129
|
+
*/
|
|
130
|
+
e911Legacy: number;
|
|
83
131
|
/** SMS-enabled numbers on the domain. */
|
|
84
132
|
smsNumbers: number;
|
|
85
133
|
/** Devices belonging to real extensions only — a system user's device is not a seat. */
|
|
@@ -122,8 +170,15 @@ export interface ExtensionItem {
|
|
|
122
170
|
* has no model), and `teams` marks the connector entry itself. `deviceCount`/`deviceModels`/`teams`
|
|
123
171
|
* above stay handset-only; this list is the one place a connector's own row shows up. Never the MAC
|
|
124
172
|
* or the SIP password.
|
|
173
|
+
*
|
|
174
|
+
* `suffix` is what the name carries AFTER the extension number (`1001wp` on ext `1001` → `wp`; a bare
|
|
175
|
+
* `1001` → `''`), lower-cased; a name that does not start with the extension has no suffix, and neither
|
|
176
|
+
* does a device on an extension with no number. `kind` is that suffix's label from the legend in
|
|
177
|
+
* {@link InventoryOptions.deviceSuffixes} — `''` when the suffix is empty OR when the legend does not
|
|
178
|
+
* carry it, because an unlisted suffix is a name this deployment has not explained, not a device type
|
|
179
|
+
* to guess at.
|
|
125
180
|
*/
|
|
126
|
-
devices: Array<{ name: string; model: string; teams: boolean }>;
|
|
181
|
+
devices: Array<{ name: string; model: string; teams: boolean; suffix: string; kind: string }>;
|
|
127
182
|
/** `deviceCount > 0 || teams` — has a device of any kind, handset or connector. */
|
|
128
183
|
anyDevice: boolean;
|
|
129
184
|
}
|
|
@@ -145,8 +200,40 @@ export interface NumberItem {
|
|
|
145
200
|
description: string;
|
|
146
201
|
}
|
|
147
202
|
export interface AddressItem { key: string /* addr:<emergency-address-id>, or addr:~<hash> when the id is blank */; label: string }
|
|
203
|
+
/**
|
|
204
|
+
* One provisioned Emergency Endpoint — the thing the E911 carrier bills per.
|
|
205
|
+
*
|
|
206
|
+
* An allowlist like every other item: the callback, the caller name, and the billing address reduced to
|
|
207
|
+
* the ONE line {@link AddressItem} already exposes. The endpoint record also carries a geolocation XML
|
|
208
|
+
* and a public IP, and neither belongs in front of a billing operator.
|
|
209
|
+
*/
|
|
210
|
+
export interface EndpointItem {
|
|
211
|
+
/** `e911:<callback>` — the digits, so a device's 11-digit form and the record's 10-digit form are one
|
|
212
|
+
* key. `e911:~<hash>` when the record names no callback at all. */
|
|
213
|
+
key: string;
|
|
214
|
+
/** The callback number, digits only ({@link emergencyDigits}); `''` when the record has none. */
|
|
215
|
+
callback: string;
|
|
216
|
+
/** `caller-name` — who the carrier announces; `''` when blank. */
|
|
217
|
+
callerName: string;
|
|
218
|
+
/** Street and city, one line — no more of the billing address than the address list already shows. */
|
|
219
|
+
billingAddress: string;
|
|
220
|
+
/** How many users the RECORD says are configured on it (`count-users-configured`), 0 when it says nothing. */
|
|
221
|
+
users: number;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* One legacy emergency number: a `caller-id-number-emergency` in use on a domain that has no endpoint
|
|
225
|
+
* records for it. Derived from the users, because the legacy model has no object of its own to read.
|
|
226
|
+
*/
|
|
227
|
+
export interface LegacyE911Item {
|
|
228
|
+
/** `e911legacy:<digits>`. Never a hash fallback — a blank number is not one of these. */
|
|
229
|
+
key: string;
|
|
230
|
+
/** The number, digits only ({@link emergencyDigits}). */
|
|
231
|
+
number: string;
|
|
232
|
+
/** How many real extensions reference it — counted here, not read off a record. */
|
|
233
|
+
users: number;
|
|
234
|
+
}
|
|
148
235
|
export interface SmsItem { key: string /* sms:<number>, or sms:~<hash> when the number is blank */; number: string }
|
|
149
|
-
export type InventoryItem = ExtensionItem | NumberItem | AddressItem | SmsItem;
|
|
236
|
+
export type InventoryItem = ExtensionItem | NumberItem | AddressItem | EndpointItem | LegacyE911Item | SmsItem;
|
|
150
237
|
|
|
151
238
|
export interface DomainInventoryDetail {
|
|
152
239
|
/** Real seats only, same rule as the count. */
|
|
@@ -155,9 +242,28 @@ export interface DomainInventoryDetail {
|
|
|
155
242
|
systemUsers: ExtensionItem[];
|
|
156
243
|
dids: NumberItem[];
|
|
157
244
|
e911Addresses: AddressItem[];
|
|
245
|
+
e911Endpoints: EndpointItem[];
|
|
246
|
+
e911Legacy: LegacyE911Item[];
|
|
158
247
|
smsNumbers: SmsItem[];
|
|
159
248
|
}
|
|
160
249
|
|
|
250
|
+
/** A device-name suffix legend: `suffix → { label, teams? }`. See {@link InventoryOptions.deviceSuffixes}. */
|
|
251
|
+
export type DeviceSuffixLegend = Record<string, { label: string; teams?: boolean }>;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The three device-name suffixes NetSapiens itself ships — SNAPmobile Web, SNAPmobile, and the TeamMate
|
|
255
|
+
* Microsoft Teams connector. Used whenever a caller supplies no `deviceSuffixes`, and it is the same
|
|
256
|
+
* table `resolver.ts` names a simultaneous-ring device by, so a suffix means one thing in this library.
|
|
257
|
+
*
|
|
258
|
+
* A deployment's OWN suffixes (a white-labelled app, say) are not here and never will be: they belong to
|
|
259
|
+
* the operator, who supplies them through {@link InventoryOptions.deviceSuffixes}.
|
|
260
|
+
*/
|
|
261
|
+
export const DEFAULT_DEVICE_SUFFIXES: Readonly<DeviceSuffixLegend> = Object.freeze({
|
|
262
|
+
wp: { label: 'SNAPmobile Web' },
|
|
263
|
+
m: { label: 'SNAPmobile' },
|
|
264
|
+
t: { label: 'Teams', teams: true },
|
|
265
|
+
});
|
|
266
|
+
|
|
161
267
|
/**
|
|
162
268
|
* What the caller has to tell the counter that the API cannot. Optional in full: every option absent is
|
|
163
269
|
* the pre-0.7.0 behaviour, and no option changes what a snapshot has to contain.
|
|
@@ -170,6 +276,18 @@ export interface InventoryOptions {
|
|
|
170
276
|
* caller's to supply.
|
|
171
277
|
*/
|
|
172
278
|
faxServerHosts?: readonly string[];
|
|
279
|
+
/**
|
|
280
|
+
* What a device-name SUFFIX means on this deployment: `suffix → { label, teams? }`. Compared
|
|
281
|
+
* case-insensitively, and it REPLACES {@link DEFAULT_DEVICE_SUFFIXES} wholesale rather than merging
|
|
282
|
+
* with it — a deployment that has no TeamMate omits `t` and Teams detection is then off entirely,
|
|
283
|
+
* which a merge could not express.
|
|
284
|
+
*
|
|
285
|
+
* `teams: true` marks the suffix that names a Microsoft Teams CONNECTOR rather than a handset: it is
|
|
286
|
+
* what {@link ExtensionItem.teams} and {@link DomainInventory.teamsConnected} test, and what keeps the
|
|
287
|
+
* connector out of `deviceCount`/`deviceModels`. At most one suffix normally carries it, but nothing
|
|
288
|
+
* here requires that.
|
|
289
|
+
*/
|
|
290
|
+
deviceSuffixes?: DeviceSuffixLegend;
|
|
173
291
|
}
|
|
174
292
|
|
|
175
293
|
/** NANP toll-free area codes, 800 through 888. A number outside this set is counted local. */
|
|
@@ -241,6 +359,25 @@ function isFaxLine(p: Rec, hosts: Set<string>): boolean {
|
|
|
241
359
|
return hosts.has(str(p['dial-rule-translation-destination-host']).toLowerCase());
|
|
242
360
|
}
|
|
243
361
|
|
|
362
|
+
/**
|
|
363
|
+
* An Emergency Caller ID reduced to what two records can be compared on: its digits, with an 11-digit
|
|
364
|
+
* `1NXXNXXXXXX` collapsed to its 10-digit form so a device's spelling matches an endpoint's.
|
|
365
|
+
*
|
|
366
|
+
* `''` for "not set", which the API says three ways: empty, absent, and the `[*]` wildcard the portal
|
|
367
|
+
* renders as "Select a Caller ID for 911 calls". Treating `[*]` as a value would give every unset
|
|
368
|
+
* device on a domain one shared fake endpoint.
|
|
369
|
+
*/
|
|
370
|
+
export function emergencyDigits(v: unknown): string {
|
|
371
|
+
const raw = str(v);
|
|
372
|
+
if (!raw || raw === '[*]') return '';
|
|
373
|
+
const digits = raw.replace(/\D+/g, '');
|
|
374
|
+
if (!digits) return '';
|
|
375
|
+
return digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** A NetSapiens boolean, which arrives as a JSON `true` from one endpoint and as `"yes"` from another. */
|
|
379
|
+
const flag = (v: unknown): boolean => v === true || ['yes', 'true', '1'].includes(str(v).toLowerCase());
|
|
380
|
+
|
|
244
381
|
/**
|
|
245
382
|
* A device's NAME — the local part of its SIP URI (`sip:103t@acme.example` → `103t`), which is the short
|
|
246
383
|
* id the portal shows and the string the Teams test matches against.
|
|
@@ -258,11 +395,46 @@ function deviceName(device: Rec): string {
|
|
|
258
395
|
return at === -1 ? a : a.slice(0, at);
|
|
259
396
|
}
|
|
260
397
|
|
|
261
|
-
|
|
398
|
+
/**
|
|
399
|
+
* The legend, lower-cased once so every lookup is a case-insensitive hit rather than a scan. A caller's
|
|
400
|
+
* legend REPLACES the default; two keys differing only in case collapse, last one wins, which is the
|
|
401
|
+
* only sane reading of a case-insensitive table.
|
|
402
|
+
*/
|
|
403
|
+
function suffixLegend(opts: InventoryOptions | undefined): DeviceSuffixLegend {
|
|
404
|
+
const src = opts?.deviceSuffixes ?? DEFAULT_DEVICE_SUFFIXES;
|
|
405
|
+
// Prototype-free: the key is a device-name suffix off a snapshot, so `constructor`, `toString` and
|
|
406
|
+
// `hasOwnProperty` are all reachable keys, and on a plain object each would answer with something
|
|
407
|
+
// inherited. `Object.entries` copies own enumerable keys only, so nothing inherited gets in either.
|
|
408
|
+
const out: DeviceSuffixLegend = Object.create(null) as DeviceSuffixLegend;
|
|
409
|
+
for (const [k, v] of Object.entries(src)) out[k.trim().toLowerCase()] = v;
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* What a device's name carries AFTER the extension number, lower-cased: `1001wp` on ext `1001` → `wp`,
|
|
415
|
+
* a bare `1001` → `''`. A name that does not start with the extension has no suffix at all — `sales1` on
|
|
416
|
+
* ext `1001` is a differently-named device, not a device of kind `sales1` — and neither does anything on
|
|
417
|
+
* an extension with no number, which is what keeps a device NAMED a bare `t` off the Teams legend.
|
|
418
|
+
*/
|
|
419
|
+
function deviceSuffix(name: string, ext: string): string {
|
|
420
|
+
if (!ext || !name.startsWith(ext)) return '';
|
|
421
|
+
return name.slice(ext.length).toLowerCase();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function extensionItem(u: Rec, devices: Rec[], legend: DeviceSuffixLegend): ExtensionItem {
|
|
262
425
|
const ext = str(u.user);
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
const
|
|
426
|
+
// Every device, once: its name, its suffix, and what the legend says that suffix is. Computed here and
|
|
427
|
+
// read three times below, so the Teams test, the handset filter and the display list cannot disagree.
|
|
428
|
+
const rows = devices.map((d) => {
|
|
429
|
+
const name = deviceName(d);
|
|
430
|
+
const suffix = deviceSuffix(name, ext);
|
|
431
|
+
const entry = suffix ? legend[suffix] : undefined;
|
|
432
|
+
return { d, name, suffix, kind: entry?.label ?? '', teams: entry?.teams === true };
|
|
433
|
+
});
|
|
434
|
+
// A CONNECTOR is a device whose suffix the legend marks `teams` — under the default legend that is
|
|
435
|
+
// `<ext>t` and nothing else, which is exactly the test this replaced. A legend without a `teams`
|
|
436
|
+
// suffix has no connectors, and every device on the extension is a handset.
|
|
437
|
+
const handsets = rows.filter((r) => !r.teams);
|
|
266
438
|
const transcription = str(u['voicemail-transcription-enabled']).toLowerCase();
|
|
267
439
|
const teams = handsets.length !== devices.length;
|
|
268
440
|
const name = `${str(u['name-first-name'])} ${str(u['name-last-name'])}`.trim();
|
|
@@ -280,13 +452,15 @@ function extensionItem(u: Rec, devices: Rec[]): ExtensionItem {
|
|
|
280
452
|
deviceCount: handsets.length,
|
|
281
453
|
// A device whose model is blank is listed under a named bucket rather than dropped: a missing
|
|
282
454
|
// model is a provisioning gap worth seeing, and a silently smaller total hides it.
|
|
283
|
-
deviceModels: handsets.map((
|
|
455
|
+
deviceModels: handsets.map((r) => str(r.d['device-models-model']) || '(unknown)'),
|
|
284
456
|
// Every device, including the Teams connector — this is a display list, not a seat count.
|
|
285
|
-
devices:
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
457
|
+
devices: rows.map((r) => ({
|
|
458
|
+
name: r.name,
|
|
459
|
+
model: r.teams ? '' : str(r.d['device-models-model']) || '(unknown)',
|
|
460
|
+
teams: r.teams,
|
|
461
|
+
suffix: r.suffix,
|
|
462
|
+
kind: r.kind,
|
|
463
|
+
})),
|
|
290
464
|
anyDevice: handsets.length > 0 || teams,
|
|
291
465
|
};
|
|
292
466
|
}
|
|
@@ -305,6 +479,107 @@ export function usersByExt(users: Rec[]): Map<string, Rec> {
|
|
|
305
479
|
return map;
|
|
306
480
|
}
|
|
307
481
|
|
|
482
|
+
/**
|
|
483
|
+
* How a domain's E911 records join up, and the two INHERITANCES this library reads into a blank field.
|
|
484
|
+
*
|
|
485
|
+
* Both the counter and `attribution.ts` need to answer "which endpoint does this user reference?" and
|
|
486
|
+
* "which address?". Resolved in one place so the count and the site attribution cannot disagree about
|
|
487
|
+
* who references what.
|
|
488
|
+
*
|
|
489
|
+
* ⚠️ **Both inheritances are this library's inference, not a measured platform behaviour.** Two
|
|
490
|
+
* separate assumptions sit under them, and a consumer relying on the placement should know which:
|
|
491
|
+
*
|
|
492
|
+
* 1. **That a blank field falls back to the domain default at all.** A user with a blank
|
|
493
|
+
* `caller-id-number-emergency` is read here as referencing the default address's endpoint, and one
|
|
494
|
+
* with a blank `emergency-address-id` as referencing the default address. That is a plausible
|
|
495
|
+
* reading of a record the portal badges "Domain Default" — but it has not been confirmed against a
|
|
496
|
+
* live 911 call, and the same state can be read as an E911 GAP rather than an inheritance.
|
|
497
|
+
* 2. **That the default address's callback is joined by `address-name`.** An address record carries no
|
|
498
|
+
* callback field of its own (checked against a live domain and 34 captured snapshots), so the only
|
|
499
|
+
* thing tying the domain default to an endpoint is that the endpoint names the same address. Every
|
|
500
|
+
* captured domain carrying both agreed on that name.
|
|
501
|
+
*
|
|
502
|
+
* Both fail CLOSED. No default address, no endpoint naming it, or a name that does not match, and
|
|
503
|
+
* `defaultCallback` is `''` — the users who would have inherited it reference nothing and are left
|
|
504
|
+
* unattributed, rather than being attached to a guess. The COUNTS are unaffected either way
|
|
505
|
+
* (`e911Endpoints` is a record count, and a user with both fields blank is correctly not legacy); what
|
|
506
|
+
* these assumptions move is PLACEMENT, which on a split domain decides which accounts are told they
|
|
507
|
+
* need an E911 line.
|
|
508
|
+
*/
|
|
509
|
+
export interface EmergencyModel {
|
|
510
|
+
/** The `emergency-address-id` of the record marked `domain_default`; `''` when the domain has none. */
|
|
511
|
+
defaultAddressId: string;
|
|
512
|
+
/** The callback of the endpoint bound to the DEFAULT address, digits only; `''` when there is none. */
|
|
513
|
+
defaultCallback: string;
|
|
514
|
+
/** Every provisioned endpoint's callback, digits only — the "is this number already an endpoint?" test. */
|
|
515
|
+
endpointCallbacks: Set<string>;
|
|
516
|
+
/** Which address a user references: their own field, the domain default when it is blank. */
|
|
517
|
+
addressIdFor: (user: Rec) => string;
|
|
518
|
+
/** The callback a user SETS: their own field, else any of their devices'; `''` when neither does. */
|
|
519
|
+
setCallbackFor: (user: Rec) => string;
|
|
520
|
+
/** Which endpoint a user references: {@link EmergencyModel.setCallbackFor}, else the domain default's. */
|
|
521
|
+
callbackFor: (user: Rec) => string;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function resolveEmergency(snapshot: Snapshot): EmergencyModel {
|
|
525
|
+
const addresses: Rec[] = Array.isArray(snapshot.addresses) ? snapshot.addresses : [];
|
|
526
|
+
const endpoints: Rec[] = Array.isArray(snapshot.addressEndpoints) ? snapshot.addressEndpoints : [];
|
|
527
|
+
const devicesByUser: Record<string, Rec[]> = (snapshot.devicesByUser ?? {}) as Record<string, Rec[]>;
|
|
528
|
+
|
|
529
|
+
// First one wins. Two records marked default is a provisioning fault, and picking one of them
|
|
530
|
+
// silently is better than resolving every blank user to nothing because two records disagree.
|
|
531
|
+
const def = addresses.find((a) => flag(a['domain_default']));
|
|
532
|
+
const defaultAddressId = def ? str(def['emergency-address-id']) : '';
|
|
533
|
+
const defName = def ? str(def['address-name']).toLowerCase() : '';
|
|
534
|
+
const boundToDefault = defName ? endpoints.find((e) => str(e['address-name']).toLowerCase() === defName) : undefined;
|
|
535
|
+
// NB: on an ENDPOINT record `emergency-address-id` holds the callback NUMBER, not an address id.
|
|
536
|
+
const defaultCallback = boundToDefault ? emergencyDigits(boundToDefault['emergency-address-id']) : '';
|
|
537
|
+
const endpointCallbacks = new Set(endpoints.map((e) => emergencyDigits(e['emergency-address-id'])).filter(Boolean));
|
|
538
|
+
|
|
539
|
+
const setCallbackFor = (user: Rec): string => {
|
|
540
|
+
const own = emergencyDigits(user['caller-id-number-emergency']);
|
|
541
|
+
if (own) return own;
|
|
542
|
+
// A user who sets none can still have a handset that does — the portal reads the device's own
|
|
543
|
+
// setting first and only then the user's, so a domain whose numbers live on the devices is
|
|
544
|
+
// invisible to a rule that reads the user record alone.
|
|
545
|
+
for (const d of devicesByUser[str(user.user)] ?? []) {
|
|
546
|
+
const dev = emergencyDigits(d['caller-id-number-emergency']);
|
|
547
|
+
if (dev) return dev;
|
|
548
|
+
}
|
|
549
|
+
return '';
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
defaultAddressId,
|
|
554
|
+
defaultCallback,
|
|
555
|
+
endpointCallbacks,
|
|
556
|
+
addressIdFor: (user) => str(user['emergency-address-id']) || defaultAddressId,
|
|
557
|
+
setCallbackFor,
|
|
558
|
+
callbackFor: (user) => setCallbackFor(user) || defaultCallback,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Is this user on the LEGACY emergency model — a caller ID set by hand, with no address record behind
|
|
564
|
+
* it and no endpoint provisioned for the number?
|
|
565
|
+
*
|
|
566
|
+
* All three clauses matter. A blank `emergency-address-id` alone is not legacy: a user with BOTH fields
|
|
567
|
+
* blank inherits the domain default address, which is the new model working as designed. And a number
|
|
568
|
+
* that IS an endpoint callback is the new model too — counting it here as well would bill a
|
|
569
|
+
* half-migrated domain twice for one place.
|
|
570
|
+
*
|
|
571
|
+
* ⚠️ **The `em` must come from a snapshot whose endpoints were READ.** The third clause tests against
|
|
572
|
+
* `em.endpointCallbacks`, which is empty both when the domain has no endpoints and when nobody asked
|
|
573
|
+
* for them — so on a snapshot fetched without `includeAddresses` this answers "legacy" for every user
|
|
574
|
+
* on a fully-migrated domain. {@link listDomainInventory} refuses to derive the list at all in that
|
|
575
|
+
* state; a caller using this predicate directly has to make the same check.
|
|
576
|
+
*/
|
|
577
|
+
export function legacyEmergencyNumber(user: Rec, em: EmergencyModel): string {
|
|
578
|
+
if (str(user['emergency-address-id'])) return '';
|
|
579
|
+
const n = em.setCallbackFor(user);
|
|
580
|
+
return n && !em.endpointCallbacks.has(n) ? n : '';
|
|
581
|
+
}
|
|
582
|
+
|
|
308
583
|
/**
|
|
309
584
|
* Where a phone number routes, in words a person reads at a glance — not the raw NetSapiens dial
|
|
310
585
|
* rule fields. Pure; looks the destination user up in `userByExt` ({@link usersByExt}) so it can
|
|
@@ -379,6 +654,8 @@ export function listDomainInventory(snapshot: Snapshot, opts?: InventoryOptions)
|
|
|
379
654
|
|
|
380
655
|
const extensions: ExtensionItem[] = [];
|
|
381
656
|
const systemUsers: ExtensionItem[] = [];
|
|
657
|
+
// Normalised once, not per extension: the legend is the caller's and does not change mid-fold.
|
|
658
|
+
const legend = suffixLegend(opts);
|
|
382
659
|
for (let i = 0; i < users.length; i++) {
|
|
383
660
|
const u = users[i]!;
|
|
384
661
|
const ext = str(u.user);
|
|
@@ -388,7 +665,7 @@ export function listDomainInventory(snapshot: Snapshot, opts?: InventoryOptions)
|
|
|
388
665
|
// from a backup or a fixture. Dropping it would read as a clean match on a domain that has
|
|
389
666
|
// handsets nobody can see; two blank users sharing one list overcount instead, which is a
|
|
390
667
|
// visible drift an operator investigates, and that is the failure worth having.
|
|
391
|
-
const item = extensionItem(u, devicesByUser[ext] ?? []);
|
|
668
|
+
const item = extensionItem(u, devicesByUser[ext] ?? [], legend);
|
|
392
669
|
(isSystemUser(u) ? systemUsers : extensions).push(item);
|
|
393
670
|
}
|
|
394
671
|
const userByExt = usersByExt(users);
|
|
@@ -418,11 +695,49 @@ export function listDomainInventory(snapshot: Snapshot, opts?: InventoryOptions)
|
|
|
418
695
|
label: label || id || `(address ${i + 1})`,
|
|
419
696
|
};
|
|
420
697
|
});
|
|
698
|
+
// The two E911 lists share one resolution of the domain's inheritance — see `resolveEmergency`.
|
|
699
|
+
const em = resolveEmergency(snapshot);
|
|
700
|
+
const endpoints: Rec[] = Array.isArray(snapshot.addressEndpoints) ? snapshot.addressEndpoints : [];
|
|
701
|
+
const e911Endpoints: EndpointItem[] = endpoints.map((e) => {
|
|
702
|
+
// NB: `emergency-address-id` on an ENDPOINT record is the callback NUMBER. See `Snapshot`.
|
|
703
|
+
const callback = emergencyDigits(e['emergency-address-id']);
|
|
704
|
+
const callerName = str(e['caller-name']);
|
|
705
|
+
const line1 = str(e['address-line-1']);
|
|
706
|
+
const city = str(e['address-city']);
|
|
707
|
+
return {
|
|
708
|
+
key: identityKey('e911', callback, `${callerName} ${line1} ${city}`),
|
|
709
|
+
callback,
|
|
710
|
+
callerName,
|
|
711
|
+
billingAddress: [line1, city].filter(Boolean).join(', '),
|
|
712
|
+
// `count-users-configured` and NOT `sub_count_total`: the two disagree on live records (a
|
|
713
|
+
// captured endpoint had 0 and 16), and only the first one names what it counts.
|
|
714
|
+
users: Number(e['count-users-configured'] ?? 0) || 0,
|
|
715
|
+
};
|
|
716
|
+
});
|
|
717
|
+
// Legacy numbers are DERIVED — there is no record to map over. One entry per distinct number, in the
|
|
718
|
+
// order the users first name it, so the list does not reshuffle between two reads of one domain.
|
|
719
|
+
//
|
|
720
|
+
// ⚠️ ONLY when the endpoint list was actually READ. `snapshot.addressEndpoints` is `undefined` when
|
|
721
|
+
// the fetch never asked for it and `[]` when it asked and the domain has none, and the difference
|
|
722
|
+
// decides whether this list can exist at all: the legacy test excludes numbers that are already
|
|
723
|
+
// endpoint callbacks, and with no endpoint list there is nothing to exclude against — so a domain
|
|
724
|
+
// fully on the ENDPOINT model, read with `includeAddresses` off, would report every distinct
|
|
725
|
+
// emergency caller ID as a legacy line the carrier bills for. `e911Addresses` answering 0 in that
|
|
726
|
+
// state is a safe under-count; this answering N is a confident over-count that looks like real data.
|
|
727
|
+
const legacyUsers = new Map<string, number>();
|
|
728
|
+
if (Array.isArray(snapshot.addressEndpoints)) {
|
|
729
|
+
for (const u of users) {
|
|
730
|
+
if (isSystemUser(u)) continue;
|
|
731
|
+
const n = legacyEmergencyNumber(u, em);
|
|
732
|
+
if (n) legacyUsers.set(n, (legacyUsers.get(n) ?? 0) + 1);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const e911Legacy: LegacyE911Item[] = [...legacyUsers].map(([number, count]) => ({ key: `e911legacy:${number}`, number, users: count }));
|
|
421
736
|
const smsNumbers: SmsItem[] = smsnumbers.map((s) => {
|
|
422
737
|
const number = str(s.number);
|
|
423
738
|
return { key: identityKey('sms', number, JSON.stringify({ number })), number };
|
|
424
739
|
});
|
|
425
|
-
return { extensions, systemUsers, dids, e911Addresses, smsNumbers };
|
|
740
|
+
return { extensions, systemUsers, dids, e911Addresses, e911Endpoints, e911Legacy, smsNumbers };
|
|
426
741
|
}
|
|
427
742
|
|
|
428
743
|
/** The counts, as a fold over {@link listDomainInventory} so the two can never disagree. */
|
|
@@ -442,6 +757,11 @@ export function countInventoryDetail(d: DomainInventoryDetail): DomainInventory
|
|
|
442
757
|
teamsConnected: 0,
|
|
443
758
|
dids: { total: 0, tollFree: 0, local: 0, fax: 0, all: d.dids.length },
|
|
444
759
|
e911Addresses: d.e911Addresses.length,
|
|
760
|
+
// `?? []` on the two newest lists alone: a detail object cached or serialised by a consumer running
|
|
761
|
+
// an older version of this library has neither field, and a count that threw on it would take out a
|
|
762
|
+
// whole page over a dimension that did not exist when the entry was written.
|
|
763
|
+
e911Endpoints: (d.e911Endpoints ?? []).length,
|
|
764
|
+
e911Legacy: (d.e911Legacy ?? []).length,
|
|
445
765
|
smsNumbers: d.smsNumbers.length,
|
|
446
766
|
devices: { total: 0, byModel: {} },
|
|
447
767
|
};
|
|
@@ -497,6 +817,8 @@ export function itemsFor(detail: DomainInventoryDetail, path: string): Inventory
|
|
|
497
817
|
if (path === 'dids.fax') return detail.dids.filter((n) => n.fax);
|
|
498
818
|
if (path === 'dids.all') return detail.dids;
|
|
499
819
|
if (path === 'e911Addresses') return detail.e911Addresses;
|
|
820
|
+
if (path === 'e911Endpoints') return detail.e911Endpoints ?? [];
|
|
821
|
+
if (path === 'e911Legacy') return detail.e911Legacy ?? [];
|
|
500
822
|
if (path === 'smsNumbers') return detail.smsNumbers;
|
|
501
823
|
return undefined;
|
|
502
824
|
}
|
|
@@ -509,5 +831,20 @@ export function itemLabel(item: InventoryItem): string {
|
|
|
509
831
|
}
|
|
510
832
|
if ('kind' in item) return item.kind === 'tollFree' ? `${item.number} (toll-free)` : item.number;
|
|
511
833
|
if ('label' in item) return item.label;
|
|
834
|
+
// An ENDPOINT is named by the number the carrier bills, then by who it announces and where it sends
|
|
835
|
+
// responders. Either half is dropped when blank rather than printed against a dangling dash, and a
|
|
836
|
+
// record with NEITHER falls back to its derived key — the same shape an address with nothing to name
|
|
837
|
+
// it by gets, except the id here is the key rather than a position, so two blank-callback endpoints
|
|
838
|
+
// stay apart. Never the empty string: this label is what a consumer writes into its acceptance
|
|
839
|
+
// history, and a row that cannot name its own item is worse than an ugly one.
|
|
840
|
+
if ('callback' in item) {
|
|
841
|
+
const who = [item.callerName, item.billingAddress].filter(Boolean).join(', ');
|
|
842
|
+
if (item.callback) return who ? `${item.callback} — ${who}` : item.callback;
|
|
843
|
+
return who || `(endpoint ${item.key.slice('e911:'.length)})`;
|
|
844
|
+
}
|
|
845
|
+
// A LEGACY number says so on its own line: it looks like a DID, and nothing else on the page would
|
|
846
|
+
// tell a reader why a bare number is sitting on an E911 row. Singular is written out: a label that
|
|
847
|
+
// does not agree with itself reads as a rendering fault, and this one is frozen into history rows.
|
|
848
|
+
if ('users' in item) return `${item.number} — legacy E911 (${item.users} user${item.users === 1 ? '' : 's'})`;
|
|
512
849
|
return item.number;
|
|
513
850
|
}
|
package/src/model.ts
CHANGED
|
@@ -96,6 +96,19 @@ export interface Snapshot {
|
|
|
96
96
|
* `undefined` means "not read", which is not the same fact as an empty array.
|
|
97
97
|
*/
|
|
98
98
|
addresses?: Rec[];
|
|
99
|
+
/**
|
|
100
|
+
* Emergency ENDPOINT records — GET /domains/{d}/addresses/endpoints. Read alongside `addresses`,
|
|
101
|
+
* and `undefined` means "not read" the same way.
|
|
102
|
+
*
|
|
103
|
+
* An endpoint is what the E911 carrier routes on and bills per: a callback number, a caller name, a
|
|
104
|
+
* billing address and a vendor. An ADDRESS is a dispatchable location forwarded to responders, and
|
|
105
|
+
* several of them can sit under one endpoint — so the two lists count different things and neither
|
|
106
|
+
* substitutes for the other.
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ An endpoint record holds its callback NUMBER in `emergency-address-id` — the same field name an
|
|
109
|
+
* address record uses for its own `a-…` id. Reading it as an address id joins nothing.
|
|
110
|
+
*/
|
|
111
|
+
addressEndpoints?: Rec[];
|
|
99
112
|
/**
|
|
100
113
|
* SMS-enabled numbers — GET /domains/{d}/smsnumbers?dest=*. The endpoint is documented with no
|
|
101
114
|
* parameters, but a live server answers 400 without `dest` or `number`; `dest=*` is the wildcard
|
package/src/nsClient.ts
CHANGED
|
@@ -165,8 +165,14 @@ export interface FetchSnapshotOptions {
|
|
|
165
165
|
*/
|
|
166
166
|
includeDidDestRules?: boolean;
|
|
167
167
|
/**
|
|
168
|
-
* Also read the domain's E911 addresses into `snapshot.addresses
|
|
169
|
-
*
|
|
168
|
+
* Also read the domain's E911 addresses into `snapshot.addresses` AND its emergency ENDPOINTS into
|
|
169
|
+
* `snapshot.addressEndpoints`. Two extra calls. Default false — the resolver uses neither; an
|
|
170
|
+
* inventory count uses both.
|
|
171
|
+
*
|
|
172
|
+
* ONE flag for the two because they are one subject read two ways: the endpoint is what the E911
|
|
173
|
+
* carrier bills per, the address is the location responders are sent to, and a caller given the
|
|
174
|
+
* addresses alone would count the dispatchable locations and bill for them. That was the bug this
|
|
175
|
+
* option grew to fix, so the two are not separable here.
|
|
170
176
|
*/
|
|
171
177
|
includeAddresses?: boolean;
|
|
172
178
|
/**
|
|
@@ -212,13 +218,16 @@ export async function fetchDomainSnapshot(client: NsClient, domain: string, opts
|
|
|
212
218
|
};
|
|
213
219
|
|
|
214
220
|
const domainRec = asArray(await client.get(base))[0] ?? { domain };
|
|
215
|
-
const [timeframes, users, callqueues, phonenumbers, autoattendants, addresses, smsnumbers] = await Promise.all([
|
|
221
|
+
const [timeframes, users, callqueues, phonenumbers, autoattendants, addresses, addressEndpoints, smsnumbers] = await Promise.all([
|
|
216
222
|
soft(`${base}/timeframes`),
|
|
217
223
|
soft(`${base}/users`),
|
|
218
224
|
soft(`${base}/callqueues`),
|
|
219
225
|
soft(`${base}/phonenumbers`),
|
|
220
226
|
soft(`${base}/autoattendants`),
|
|
221
227
|
opts.includeAddresses ? soft(`${base}/addresses`) : Promise.resolve(undefined),
|
|
228
|
+
// The BILLABLE half of E911, softened like the rest: a domain still on the legacy provisioning
|
|
229
|
+
// model has no endpoints at all and answers 404, which is "none" rather than a failure.
|
|
230
|
+
opts.includeAddresses ? soft(`${base}/addresses/endpoints`) : Promise.resolve(undefined),
|
|
222
231
|
// `dest=*`: see includeSmsNumbers. A 404 is already softened to []; a 400 from a server that wants
|
|
223
232
|
// a different parameter throws, which is right — a silent empty list would read as "no SMS numbers".
|
|
224
233
|
opts.includeSmsNumbers ? soft(`${base}/smsnumbers?dest=*`) : Promise.resolve(undefined),
|
|
@@ -240,6 +249,7 @@ export async function fetchDomainSnapshot(client: NsClient, domain: string, opts
|
|
|
240
249
|
meta: { domain }, domain: domainRec, timeframes, users, callqueues, phonenumbers, autoattendants,
|
|
241
250
|
...(answerrulesByUser ? { answerrulesByUser } : {}),
|
|
242
251
|
...(addresses ? { addresses } : {}),
|
|
252
|
+
...(addressEndpoints ? { addressEndpoints } : {}),
|
|
243
253
|
...(smsnumbers ? { smsnumbers } : {}),
|
|
244
254
|
};
|
|
245
255
|
}
|
|
@@ -343,6 +353,7 @@ export async function fetchDomainSnapshot(client: NsClient, domain: string, opts
|
|
|
343
353
|
answerrulesByUser,
|
|
344
354
|
agentsByQueue,
|
|
345
355
|
...(addresses ? { addresses } : {}),
|
|
356
|
+
...(addressEndpoints ? { addressEndpoints } : {}),
|
|
346
357
|
...(smsnumbers ? { smsnumbers } : {}),
|
|
347
358
|
...(devicesByUser ? { devicesByUser } : {}),
|
|
348
359
|
...(deviceReadFailures ? { deviceReadFailures } : {}),
|
package/src/resolver.selftest.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* cross-queue priority. Queue priority (lower = higher) is shown once when the queue
|
|
7
7
|
* shares one non-default value, per-agent when it varies, and hidden at the default (0).
|
|
8
8
|
*/
|
|
9
|
-
import { resolveFlow } from './resolver.js';
|
|
9
|
+
import { deviceKindBySuffix, resolveFlow } from './resolver.js';
|
|
10
10
|
import type { Snapshot, FlowGraph, NodeKind } from './model.js';
|
|
11
11
|
|
|
12
12
|
let pass = 0,
|
|
@@ -221,5 +221,26 @@ const linesOf = (g: FlowGraph, kind: NodeKind) => (g.nodes.find((n) => n.kind ==
|
|
|
221
221
|
check('announce: no message node is left without an outgoing edge', ['aaannounce_9000_900013', 'aaannounce_9000_900014'].every((id) => g.edges.some((e) => e.from === id)));
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// ── the device-suffix legend, as the resolver reads it ──────────────────────────────────────────────
|
|
225
|
+
// The KINDS come from DEFAULT_DEVICE_SUFFIXES so a suffix means one thing across the library; the icons
|
|
226
|
+
// and two fallbacks are the resolver's own. Asserted here because a diagram is where the labels are SEEN,
|
|
227
|
+
// and a drift between this and the inventory's legend would show up nowhere else.
|
|
228
|
+
{
|
|
229
|
+
const k = (s: string) => deviceKindBySuffix(s);
|
|
230
|
+
check('suffix wp reads the legend label SNAPmobile Web, with the resolver\'s globe', k('wp').kind === 'SNAPmobile Web' && k('wp').icon === '🌐');
|
|
231
|
+
check('suffix m reads SNAPmobile', k('m').kind === 'SNAPmobile' && k('m').icon === '📱');
|
|
232
|
+
check('suffix t reads Teams', k('t').kind === 'Teams' && k('t').icon === '💻');
|
|
233
|
+
check('an upper-case suffix reads the same', k('WP').kind === 'SNAPmobile Web' && k('T').kind === 'Teams');
|
|
234
|
+
check('suffix r is the resolver\'s own fallback, not the legend\'s', k('r').kind === 'app' && k('r').icon === '📱');
|
|
235
|
+
check('any other letter is a desk phone', k('b').kind === 'desk phone' && k('b').icon === '📞');
|
|
236
|
+
check('no suffix is no kind and no icon', k('').kind === '' && k('').icon === '');
|
|
237
|
+
// The key is a device-name suffix off a snapshot, so an inherited Object name is a reachable key. On a
|
|
238
|
+
// plain object literal `SUFFIX_ICONS['constructor']` answers a function and `?? '📞'` never fires.
|
|
239
|
+
for (const evil of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) {
|
|
240
|
+
check(`a suffix named ${evil} falls to the defaults rather than an inherited value`,
|
|
241
|
+
k(evil).kind === 'desk phone' && k(evil).icon === '📞');
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
224
245
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
225
246
|
if (fail) throw new Error(`${fail} check(s) failed`);
|