@dszp/netsapiens-lib 0.5.0 → 0.7.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 +44 -8
- package/dist/attribution.d.ts +13 -1
- package/dist/attribution.d.ts.map +1 -1
- package/dist/attribution.js +20 -12
- 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.map +1 -1
- package/dist/inventory.d.ts +64 -11
- package/dist/inventory.d.ts.map +1 -1
- package/dist/inventory.js +78 -18
- package/dist/inventory.js.map +1 -1
- package/package.json +1 -1
- package/src/attribution.selftest.ts +24 -3
- package/src/attribution.ts +31 -11
- package/src/index.ts +1 -1
- package/src/inventory.selftest.ts +133 -0
- package/src/inventory.ts +128 -28
package/src/inventory.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* A device record from NetSapiens carries the SIP registration password. `listDomainInventory` builds
|
|
10
10
|
* per-item lists — an extension's name and site, a number's kind and routing destination, an address's
|
|
11
11
|
* label — from an allowlist of named fields, and `countDomainInventory` is a fold over those same
|
|
12
|
-
* lists. The allowlist now includes a device's NAME (
|
|
13
|
-
* short id the portal shows) alongside its model; a device's MAC, SIP
|
|
14
|
-
* and nothing here should be added that carries one.
|
|
12
|
+
* lists. The allowlist now includes a device's NAME (the local part of its `device` SIP URI, falling back
|
|
13
|
+
* to `aor` — e.g. `101b`, the same short id the portal shows) alongside its model; a device's MAC, SIP
|
|
14
|
+
* password and email never appear, and nothing here should be added that carries one.
|
|
15
15
|
*
|
|
16
16
|
* ## Every countable dimension is a numeric leaf
|
|
17
17
|
*
|
|
@@ -24,6 +24,20 @@
|
|
|
24
24
|
* A user whose `service-code` is empty or does not begin with `system-`. NetSapiens models auto
|
|
25
25
|
* attendants, queues and time-of-day routers as users, and counting them as seats would overstate
|
|
26
26
|
* every domain that has any. They are counted separately, as information, and never compared.
|
|
27
|
+
*
|
|
28
|
+
* ## What counts as a fax line
|
|
29
|
+
*
|
|
30
|
+
* On the portal's "Fax Server" treatment a number is an ordinary phone number whose dial rule hands it
|
|
31
|
+
* to a fax server host — `dial-rule-application: to-connection` with
|
|
32
|
+
* `dial-rule-translation-destination-host` set to that host. There is no fax-account endpoint and the
|
|
33
|
+
* ATA is not a device on any user, so the host is the only thing in the API that says "this is a fax
|
|
34
|
+
* line", and nothing in the API can tell an analog fax from a digital one.
|
|
35
|
+
*
|
|
36
|
+
* The host is therefore the whole test, and it is the CALLER's: pass `{ faxServerHosts }` to
|
|
37
|
+
* {@link listDomainInventory} or {@link countDomainInventory}. Matching is on the trimmed host,
|
|
38
|
+
* case-insensitively, and on nothing else — not the `dial-rule-description`, which is a portal-written
|
|
39
|
+
* note an operator can edit. **With no hosts supplied nothing is a fax line**, because a library that
|
|
40
|
+
* hardcoded one deployment's fax server would be wrong everywhere else.
|
|
27
41
|
*/
|
|
28
42
|
import type { Rec, Snapshot } from './model.js';
|
|
29
43
|
|
|
@@ -47,13 +61,23 @@ export interface DomainInventory {
|
|
|
47
61
|
/** Extensions whose `voicemail-transcription-enabled` is anything but empty or `no`. */
|
|
48
62
|
transcriptionEnabled: number;
|
|
49
63
|
/**
|
|
50
|
-
* Extensions with a Microsoft Teams connector device — one whose
|
|
51
|
-
*
|
|
64
|
+
* Extensions with a Microsoft Teams connector device — one whose device NAME is the extension number
|
|
65
|
+
* followed by `t` (`1000t`), which is how the TeamMate connector registers. See {@link deviceName} for
|
|
66
|
+
* which field that name is read from, and why reading the wrong one silently miscounted this.
|
|
52
67
|
* That device is NOT counted under `devices`: it is a connector, not a handset.
|
|
53
68
|
*/
|
|
54
69
|
teamsConnected: number;
|
|
55
|
-
/**
|
|
56
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Phone numbers on the domain, split by NANP toll-free prefix — **fax lines excluded**.
|
|
72
|
+
*
|
|
73
|
+
* A number handed to the fax server is billed as a fax line, not as a DID, so `total`, `tollFree`
|
|
74
|
+
* and `local` all leave it out and `fax` counts it instead. `all` is every phone number the domain
|
|
75
|
+
* holds, fax lines included: `total + fax === all`.
|
|
76
|
+
*
|
|
77
|
+
* With no `faxServerHosts` supplied nothing is a fax line, `fax` is 0 and `total === all` — the
|
|
78
|
+
* numbers this returned before 0.7.0, unchanged.
|
|
79
|
+
*/
|
|
80
|
+
dids: { total: number; tollFree: number; local: number; fax: number; all: number };
|
|
57
81
|
/** E911 address records on the domain. */
|
|
58
82
|
e911Addresses: number;
|
|
59
83
|
/** SMS-enabled numbers on the domain. */
|
|
@@ -91,8 +115,9 @@ export interface ExtensionItem {
|
|
|
91
115
|
/** `device-models-model` per handset, `(unknown)` when blank. Never the MAC. */
|
|
92
116
|
deviceModels: string[];
|
|
93
117
|
/**
|
|
94
|
-
* Every device on this extension, in record order, connector included: `name` is the
|
|
95
|
-
*
|
|
118
|
+
* Every device on this extension, in record order, connector included: `name` is the local part of its
|
|
119
|
+
* `device` SIP URI, or of `aor` when that is all the record has (`sip:101b@acme.example` → `101b`) —
|
|
120
|
+
* the device NAME as the portal shows it — `model` is
|
|
96
121
|
* `device-models-model` (`(unknown)` when blank on a handset, `''` for the Teams connector, which
|
|
97
122
|
* has no model), and `teams` marks the connector entry itself. `deviceCount`/`deviceModels`/`teams`
|
|
98
123
|
* above stay handset-only; this list is the one place a connector's own row shows up. Never the MAC
|
|
@@ -106,6 +131,14 @@ export interface NumberItem {
|
|
|
106
131
|
key: string /* did:<phonenumber>, or did:~<hash> when the number is blank */;
|
|
107
132
|
number: string;
|
|
108
133
|
kind: 'local' | 'tollFree';
|
|
134
|
+
/**
|
|
135
|
+
* This number is handed to a fax server — see the module doc. `false` whenever the caller supplied no
|
|
136
|
+
* `faxServerHosts`, since without a host list nothing here can tell a fax line from any other number.
|
|
137
|
+
*
|
|
138
|
+
* `kind` is still set on a fax line (a fax number is local or toll-free like any other), but the
|
|
139
|
+
* COUNTS exclude it from `dids.total`/`local`/`tollFree` and count it under `dids.fax` instead.
|
|
140
|
+
*/
|
|
141
|
+
fax: boolean;
|
|
109
142
|
/** Where the number routes, for a person: see {@link destinationOf}. `''` when the record says nothing. */
|
|
110
143
|
destination: string;
|
|
111
144
|
/** `dial-rule-description` trimmed — the note the portal writes ("Portal Created: User - 1001"); `''` when blank. */
|
|
@@ -125,6 +158,20 @@ export interface DomainInventoryDetail {
|
|
|
125
158
|
smsNumbers: SmsItem[];
|
|
126
159
|
}
|
|
127
160
|
|
|
161
|
+
/**
|
|
162
|
+
* What the caller has to tell the counter that the API cannot. Optional in full: every option absent is
|
|
163
|
+
* the pre-0.7.0 behaviour, and no option changes what a snapshot has to contain.
|
|
164
|
+
*/
|
|
165
|
+
export interface InventoryOptions {
|
|
166
|
+
/**
|
|
167
|
+
* The hosts a fax line is handed to — an IP or a hostname, as it appears in
|
|
168
|
+
* `dial-rule-translation-destination-host`. Compared trimmed and case-insensitively; blanks are
|
|
169
|
+
* ignored. Absent or empty means NO number is a fax line. See the module doc for why this is the
|
|
170
|
+
* caller's to supply.
|
|
171
|
+
*/
|
|
172
|
+
faxServerHosts?: readonly string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
128
175
|
/** NANP toll-free area codes, 800 through 888. A number outside this set is counted local. */
|
|
129
176
|
const TOLL_FREE = new Set(['800', '833', '844', '855', '866', '877', '888']);
|
|
130
177
|
|
|
@@ -176,18 +223,46 @@ function isTollFree(raw: string): boolean {
|
|
|
176
223
|
return nanp.length === 10 && TOLL_FREE.has(nanp.slice(0, 3));
|
|
177
224
|
}
|
|
178
225
|
|
|
179
|
-
/** The
|
|
180
|
-
function
|
|
181
|
-
const
|
|
226
|
+
/** The fax-server hosts, trimmed, lower-cased and with blanks dropped — the shape {@link isFaxLine} tests against. */
|
|
227
|
+
function faxHosts(hosts: readonly string[] | undefined): Set<string> {
|
|
228
|
+
const out = new Set<string>();
|
|
229
|
+
for (const h of hosts ?? []) { const v = str(h).toLowerCase(); if (v) out.add(v); }
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Is this number handed to a fax server? The `dial-rule-translation-destination-host` alone, matched
|
|
235
|
+
* against the caller's list — never the `dial-rule-description`, which is a note the portal writes and
|
|
236
|
+
* an operator can edit. An empty host set answers `false` for everything, which is the point: this
|
|
237
|
+
* library knows no fax server of its own.
|
|
238
|
+
*/
|
|
239
|
+
function isFaxLine(p: Rec, hosts: Set<string>): boolean {
|
|
240
|
+
if (!hosts.size) return false;
|
|
241
|
+
return hosts.has(str(p['dial-rule-translation-destination-host']).toLowerCase());
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A device's NAME — the local part of its SIP URI (`sip:103t@acme.example` → `103t`), which is the short
|
|
246
|
+
* id the portal shows and the string the Teams test matches against.
|
|
247
|
+
*
|
|
248
|
+
* **`device` first, `aor` second.** A live `/users/<ext>/devices` record names the device in `device` and
|
|
249
|
+
* frequently carries no `aor` at all; reading `aor` alone therefore returned `''` on live data, which
|
|
250
|
+
* blanked every device name on the page AND broke the `<ext>t` Teams test — so a Teams connector read as
|
|
251
|
+
* `teams: false` and was counted as a handset in `deviceCount` and `devices.total`. Some records carry
|
|
252
|
+
* both, and then `device` wins, being the field the system actually names the device by. Neither, and the
|
|
253
|
+
* name is `''` rather than a guess.
|
|
254
|
+
*/
|
|
255
|
+
function deviceName(device: Rec): string {
|
|
256
|
+
const a = (str(device.device) || str(device.aor)).replace(/^sip:/i, '');
|
|
182
257
|
const at = a.indexOf('@');
|
|
183
258
|
return at === -1 ? a : a.slice(0, at);
|
|
184
259
|
}
|
|
185
260
|
|
|
186
261
|
function extensionItem(u: Rec, devices: Rec[]): ExtensionItem {
|
|
187
262
|
const ext = str(u.user);
|
|
188
|
-
// The Teams test is `<ext>t`, so a blank ext would read every device
|
|
189
|
-
//
|
|
190
|
-
const handsets = ext ? devices.filter((d) =>
|
|
263
|
+
// The Teams test is `<ext>t`, so a blank ext would read every device NAMED a bare `t` as a connector.
|
|
264
|
+
// No extension number, no Teams claim.
|
|
265
|
+
const handsets = ext ? devices.filter((d) => deviceName(d) !== `${ext}t`) : devices;
|
|
191
266
|
const transcription = str(u['voicemail-transcription-enabled']).toLowerCase();
|
|
192
267
|
const teams = handsets.length !== devices.length;
|
|
193
268
|
const name = `${str(u['name-first-name'])} ${str(u['name-last-name'])}`.trim();
|
|
@@ -208,8 +283,9 @@ function extensionItem(u: Rec, devices: Rec[]): ExtensionItem {
|
|
|
208
283
|
deviceModels: handsets.map((d) => str(d['device-models-model']) || '(unknown)'),
|
|
209
284
|
// Every device, including the Teams connector — this is a display list, not a seat count.
|
|
210
285
|
devices: devices.map((d) => {
|
|
211
|
-
const
|
|
212
|
-
|
|
286
|
+
const name = deviceName(d);
|
|
287
|
+
const isTeams = ext ? name === `${ext}t` : false;
|
|
288
|
+
return { name, model: isTeams ? '' : str(d['device-models-model']) || '(unknown)', teams: isTeams };
|
|
213
289
|
}),
|
|
214
290
|
anyDevice: handsets.length > 0 || teams,
|
|
215
291
|
};
|
|
@@ -249,8 +325,14 @@ export function usersByExt(users: Rec[]): Map<string, Rec> {
|
|
|
249
325
|
* - No destination but an application is set → `to <application>` (`to-connection` → `to connection`,
|
|
250
326
|
* `to-voicemail` → `to voicemail`).
|
|
251
327
|
* - Neither is set → `''`.
|
|
328
|
+
*
|
|
329
|
+
* A FAX LINE — a number whose destination host is one of `faxServerHosts` — short-circuits all of that
|
|
330
|
+
* and reads `to fax server`, host omitted. Otherwise it would render as `to connection` (which names
|
|
331
|
+
* plumbing, not a destination) or, on a rule that also carries a destination user, as a bare IP address
|
|
332
|
+
* beside a customer's phone number. Nobody reading this line needs the fax server's address.
|
|
252
333
|
*/
|
|
253
|
-
export function destinationOf(p: Rec, userByExt: Map<string, Rec
|
|
334
|
+
export function destinationOf(p: Rec, userByExt: Map<string, Rec>, faxServerHosts?: readonly string[]): string {
|
|
335
|
+
if (isFaxLine(p, faxHosts(faxServerHosts))) return 'to fax server';
|
|
254
336
|
const dest = str(p['dial-rule-translation-destination-user']);
|
|
255
337
|
const app = str(p['dial-rule-application']).replace(/^to-/i, '');
|
|
256
338
|
const host = str(p['dial-rule-translation-destination-host']);
|
|
@@ -288,7 +370,7 @@ export function destinationOf(p: Rec, userByExt: Map<string, Rec>): string {
|
|
|
288
370
|
* countable thing, and one derived row is more honest than two that shuffle. A blank id is a
|
|
289
371
|
* provisioning fault to fix; the fallback only keeps the distinguishable ones apart until it is.
|
|
290
372
|
*/
|
|
291
|
-
export function listDomainInventory(snapshot: Snapshot): DomainInventoryDetail {
|
|
373
|
+
export function listDomainInventory(snapshot: Snapshot, opts?: InventoryOptions): DomainInventoryDetail {
|
|
292
374
|
const users: Rec[] = Array.isArray(snapshot.users) ? snapshot.users : [];
|
|
293
375
|
const devicesByUser: Record<string, Rec[]> = (snapshot.devicesByUser ?? {}) as Record<string, Rec[]>;
|
|
294
376
|
const phonenumbers: Rec[] = Array.isArray(snapshot.phonenumbers) ? snapshot.phonenumbers : [];
|
|
@@ -310,12 +392,17 @@ export function listDomainInventory(snapshot: Snapshot): DomainInventoryDetail {
|
|
|
310
392
|
(isSystemUser(u) ? systemUsers : extensions).push(item);
|
|
311
393
|
}
|
|
312
394
|
const userByExt = usersByExt(users);
|
|
395
|
+
// Normalised once, not per number: the host list is the caller's and does not change mid-fold.
|
|
396
|
+
const hosts = faxHosts(opts?.faxServerHosts);
|
|
313
397
|
const dids: NumberItem[] = phonenumbers.map((p) => {
|
|
314
398
|
const number = str(p.phonenumber);
|
|
315
399
|
const kind: 'local' | 'tollFree' = isTollFree(number) ? 'tollFree' : 'local';
|
|
316
|
-
const
|
|
400
|
+
const fax = isFaxLine(p, hosts);
|
|
401
|
+
// The KEY does not carry `fax`. It is a fact about how the number is routed today, and routing a
|
|
402
|
+
// number to the fax server must not orphan every decision a consumer recorded against it.
|
|
403
|
+
const destination = fax ? 'to fax server' : destinationOf(p, userByExt);
|
|
317
404
|
const description = str(p['dial-rule-description']);
|
|
318
|
-
return { key: identityKey('did', number, JSON.stringify({ number, kind })), number, kind, destination, description };
|
|
405
|
+
return { key: identityKey('did', number, JSON.stringify({ number, kind })), number, kind, fax, destination, description };
|
|
319
406
|
});
|
|
320
407
|
const e911Addresses: AddressItem[] = addresses.map((a, i) => {
|
|
321
408
|
const id = str(a['emergency-address-id']);
|
|
@@ -339,8 +426,8 @@ export function listDomainInventory(snapshot: Snapshot): DomainInventoryDetail {
|
|
|
339
426
|
}
|
|
340
427
|
|
|
341
428
|
/** The counts, as a fold over {@link listDomainInventory} so the two can never disagree. */
|
|
342
|
-
export function countDomainInventory(snapshot: Snapshot): DomainInventory {
|
|
343
|
-
return countInventoryDetail(listDomainInventory(snapshot));
|
|
429
|
+
export function countDomainInventory(snapshot: Snapshot, opts?: InventoryOptions): DomainInventory {
|
|
430
|
+
return countInventoryDetail(listDomainInventory(snapshot, opts));
|
|
344
431
|
}
|
|
345
432
|
|
|
346
433
|
/**
|
|
@@ -353,7 +440,7 @@ export function countInventoryDetail(d: DomainInventoryDetail): DomainInventory
|
|
|
353
440
|
systemUsers: { total: d.systemUsers.length, byServiceCode: {} },
|
|
354
441
|
transcriptionEnabled: 0,
|
|
355
442
|
teamsConnected: 0,
|
|
356
|
-
dids: { total:
|
|
443
|
+
dids: { total: 0, tollFree: 0, local: 0, fax: 0, all: d.dids.length },
|
|
357
444
|
e911Addresses: d.e911Addresses.length,
|
|
358
445
|
smsNumbers: d.smsNumbers.length,
|
|
359
446
|
devices: { total: 0, byModel: {} },
|
|
@@ -371,7 +458,15 @@ export function countInventoryDetail(d: DomainInventoryDetail): DomainInventory
|
|
|
371
458
|
inv.devices.total += x.deviceCount;
|
|
372
459
|
for (const m of x.deviceModels) bump(inv.devices.byModel, m);
|
|
373
460
|
}
|
|
374
|
-
|
|
461
|
+
// A fax line is billed as a fax line, so it lands in `fax` and in NEITHER of the two DID buckets —
|
|
462
|
+
// counting it as both would bill one number twice on a rulebook that has a rule for each. `fax` is
|
|
463
|
+
// read off the item rather than recomputed: an item list a consumer FILTERED still carries it, and a
|
|
464
|
+
// list built by a pre-0.7.0 lib has no `fax` at all, which reads as false and counts as it always did.
|
|
465
|
+
for (const n of d.dids) {
|
|
466
|
+
if (n.fax) { inv.dids.fax++; continue; }
|
|
467
|
+
inv.dids.total++;
|
|
468
|
+
if (n.kind === 'tollFree') inv.dids.tollFree++; else inv.dids.local++;
|
|
469
|
+
}
|
|
375
470
|
return inv;
|
|
376
471
|
}
|
|
377
472
|
|
|
@@ -393,9 +488,14 @@ export function itemsFor(detail: DomainInventoryDetail, path: string): Inventory
|
|
|
393
488
|
if (path === 'extensions.withNoDevice') return ex.filter((x) => !x.anyDevice);
|
|
394
489
|
if (path === 'transcriptionEnabled') return ex.filter((x) => x.transcription);
|
|
395
490
|
if (path === 'teamsConnected') return ex.filter((x) => x.teams);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
491
|
+
// The three DID paths exclude fax lines, exactly as the counts do — a `counts: "dids.total"` rule
|
|
492
|
+
// whose observed number left the fax lines out but whose item list showed them would offer an
|
|
493
|
+
// operator rows to accept that the number above them does not count.
|
|
494
|
+
if (path === 'dids.total') return detail.dids.filter((n) => !n.fax);
|
|
495
|
+
if (path === 'dids.tollFree') return detail.dids.filter((n) => !n.fax && n.kind === 'tollFree');
|
|
496
|
+
if (path === 'dids.local') return detail.dids.filter((n) => !n.fax && n.kind === 'local');
|
|
497
|
+
if (path === 'dids.fax') return detail.dids.filter((n) => n.fax);
|
|
498
|
+
if (path === 'dids.all') return detail.dids;
|
|
399
499
|
if (path === 'e911Addresses') return detail.e911Addresses;
|
|
400
500
|
if (path === 'smsNumbers') return detail.smsNumbers;
|
|
401
501
|
return undefined;
|