@dszp/netsapiens-lib 0.6.0 → 0.8.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 +69 -11
- 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 +102 -12
- package/dist/inventory.d.ts.map +1 -1
- package/dist/inventory.js +135 -23
- package/dist/inventory.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/index.ts +1 -1
- package/src/inventory.selftest.ts +226 -5
- package/src/inventory.ts +212 -35
- package/src/resolver.selftest.ts +22 -1
- package/src/resolver.ts +42 -19
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,25 @@ 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
|
-
*
|
|
52
|
-
*
|
|
64
|
+
* Extensions with a Microsoft Teams connector device — one whose device-name SUFFIX the legend marks
|
|
65
|
+
* `teams: true`, which under {@link DEFAULT_DEVICE_SUFFIXES} is the extension number followed by `t`
|
|
66
|
+
* (`1000t`), how the TeamMate connector registers. See {@link deviceName} for which field that name is
|
|
67
|
+
* read from, and why reading the wrong one silently miscounted this. That device is NOT counted under
|
|
68
|
+
* `devices`: it is a connector, not a handset. A supplied legend with no `teams` suffix — a deployment
|
|
69
|
+
* without TeamMate — leaves this 0 and counts every device as a handset.
|
|
53
70
|
*/
|
|
54
71
|
teamsConnected: number;
|
|
55
|
-
/**
|
|
56
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Phone numbers on the domain, split by NANP toll-free prefix — **fax lines excluded**.
|
|
74
|
+
*
|
|
75
|
+
* A number handed to the fax server is billed as a fax line, not as a DID, so `total`, `tollFree`
|
|
76
|
+
* and `local` all leave it out and `fax` counts it instead. `all` is every phone number the domain
|
|
77
|
+
* holds, fax lines included: `total + fax === all`.
|
|
78
|
+
*
|
|
79
|
+
* With no `faxServerHosts` supplied nothing is a fax line, `fax` is 0 and `total === all` — the
|
|
80
|
+
* numbers this returned before 0.7.0, unchanged.
|
|
81
|
+
*/
|
|
82
|
+
dids: { total: number; tollFree: number; local: number; fax: number; all: number };
|
|
57
83
|
/** E911 address records on the domain. */
|
|
58
84
|
e911Addresses: number;
|
|
59
85
|
/** SMS-enabled numbers on the domain. */
|
|
@@ -91,14 +117,22 @@ export interface ExtensionItem {
|
|
|
91
117
|
/** `device-models-model` per handset, `(unknown)` when blank. Never the MAC. */
|
|
92
118
|
deviceModels: string[];
|
|
93
119
|
/**
|
|
94
|
-
* Every device on this extension, in record order, connector included: `name` is the
|
|
95
|
-
*
|
|
120
|
+
* Every device on this extension, in record order, connector included: `name` is the local part of its
|
|
121
|
+
* `device` SIP URI, or of `aor` when that is all the record has (`sip:101b@acme.example` → `101b`) —
|
|
122
|
+
* the device NAME as the portal shows it — `model` is
|
|
96
123
|
* `device-models-model` (`(unknown)` when blank on a handset, `''` for the Teams connector, which
|
|
97
124
|
* has no model), and `teams` marks the connector entry itself. `deviceCount`/`deviceModels`/`teams`
|
|
98
125
|
* above stay handset-only; this list is the one place a connector's own row shows up. Never the MAC
|
|
99
126
|
* or the SIP password.
|
|
127
|
+
*
|
|
128
|
+
* `suffix` is what the name carries AFTER the extension number (`1001wp` on ext `1001` → `wp`; a bare
|
|
129
|
+
* `1001` → `''`), lower-cased; a name that does not start with the extension has no suffix, and neither
|
|
130
|
+
* does a device on an extension with no number. `kind` is that suffix's label from the legend in
|
|
131
|
+
* {@link InventoryOptions.deviceSuffixes} — `''` when the suffix is empty OR when the legend does not
|
|
132
|
+
* carry it, because an unlisted suffix is a name this deployment has not explained, not a device type
|
|
133
|
+
* to guess at.
|
|
100
134
|
*/
|
|
101
|
-
devices: Array<{ name: string; model: string; teams: boolean }>;
|
|
135
|
+
devices: Array<{ name: string; model: string; teams: boolean; suffix: string; kind: string }>;
|
|
102
136
|
/** `deviceCount > 0 || teams` — has a device of any kind, handset or connector. */
|
|
103
137
|
anyDevice: boolean;
|
|
104
138
|
}
|
|
@@ -106,6 +140,14 @@ export interface NumberItem {
|
|
|
106
140
|
key: string /* did:<phonenumber>, or did:~<hash> when the number is blank */;
|
|
107
141
|
number: string;
|
|
108
142
|
kind: 'local' | 'tollFree';
|
|
143
|
+
/**
|
|
144
|
+
* This number is handed to a fax server — see the module doc. `false` whenever the caller supplied no
|
|
145
|
+
* `faxServerHosts`, since without a host list nothing here can tell a fax line from any other number.
|
|
146
|
+
*
|
|
147
|
+
* `kind` is still set on a fax line (a fax number is local or toll-free like any other), but the
|
|
148
|
+
* COUNTS exclude it from `dids.total`/`local`/`tollFree` and count it under `dids.fax` instead.
|
|
149
|
+
*/
|
|
150
|
+
fax: boolean;
|
|
109
151
|
/** Where the number routes, for a person: see {@link destinationOf}. `''` when the record says nothing. */
|
|
110
152
|
destination: string;
|
|
111
153
|
/** `dial-rule-description` trimmed — the note the portal writes ("Portal Created: User - 1001"); `''` when blank. */
|
|
@@ -125,6 +167,49 @@ export interface DomainInventoryDetail {
|
|
|
125
167
|
smsNumbers: SmsItem[];
|
|
126
168
|
}
|
|
127
169
|
|
|
170
|
+
/** A device-name suffix legend: `suffix → { label, teams? }`. See {@link InventoryOptions.deviceSuffixes}. */
|
|
171
|
+
export type DeviceSuffixLegend = Record<string, { label: string; teams?: boolean }>;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The three device-name suffixes NetSapiens itself ships — SNAPmobile Web, SNAPmobile, and the TeamMate
|
|
175
|
+
* Microsoft Teams connector. Used whenever a caller supplies no `deviceSuffixes`, and it is the same
|
|
176
|
+
* table `resolver.ts` names a simultaneous-ring device by, so a suffix means one thing in this library.
|
|
177
|
+
*
|
|
178
|
+
* A deployment's OWN suffixes (a white-labelled app, say) are not here and never will be: they belong to
|
|
179
|
+
* the operator, who supplies them through {@link InventoryOptions.deviceSuffixes}.
|
|
180
|
+
*/
|
|
181
|
+
export const DEFAULT_DEVICE_SUFFIXES: Readonly<DeviceSuffixLegend> = Object.freeze({
|
|
182
|
+
wp: { label: 'SNAPmobile Web' },
|
|
183
|
+
m: { label: 'SNAPmobile' },
|
|
184
|
+
t: { label: 'Teams', teams: true },
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What the caller has to tell the counter that the API cannot. Optional in full: every option absent is
|
|
189
|
+
* the pre-0.7.0 behaviour, and no option changes what a snapshot has to contain.
|
|
190
|
+
*/
|
|
191
|
+
export interface InventoryOptions {
|
|
192
|
+
/**
|
|
193
|
+
* The hosts a fax line is handed to — an IP or a hostname, as it appears in
|
|
194
|
+
* `dial-rule-translation-destination-host`. Compared trimmed and case-insensitively; blanks are
|
|
195
|
+
* ignored. Absent or empty means NO number is a fax line. See the module doc for why this is the
|
|
196
|
+
* caller's to supply.
|
|
197
|
+
*/
|
|
198
|
+
faxServerHosts?: readonly string[];
|
|
199
|
+
/**
|
|
200
|
+
* What a device-name SUFFIX means on this deployment: `suffix → { label, teams? }`. Compared
|
|
201
|
+
* case-insensitively, and it REPLACES {@link DEFAULT_DEVICE_SUFFIXES} wholesale rather than merging
|
|
202
|
+
* with it — a deployment that has no TeamMate omits `t` and Teams detection is then off entirely,
|
|
203
|
+
* which a merge could not express.
|
|
204
|
+
*
|
|
205
|
+
* `teams: true` marks the suffix that names a Microsoft Teams CONNECTOR rather than a handset: it is
|
|
206
|
+
* what {@link ExtensionItem.teams} and {@link DomainInventory.teamsConnected} test, and what keeps the
|
|
207
|
+
* connector out of `deviceCount`/`deviceModels`. At most one suffix normally carries it, but nothing
|
|
208
|
+
* here requires that.
|
|
209
|
+
*/
|
|
210
|
+
deviceSuffixes?: DeviceSuffixLegend;
|
|
211
|
+
}
|
|
212
|
+
|
|
128
213
|
/** NANP toll-free area codes, 800 through 888. A number outside this set is counted local. */
|
|
129
214
|
const TOLL_FREE = new Set(['800', '833', '844', '855', '866', '877', '888']);
|
|
130
215
|
|
|
@@ -176,18 +261,81 @@ function isTollFree(raw: string): boolean {
|
|
|
176
261
|
return nanp.length === 10 && TOLL_FREE.has(nanp.slice(0, 3));
|
|
177
262
|
}
|
|
178
263
|
|
|
179
|
-
/** The
|
|
180
|
-
function
|
|
181
|
-
const
|
|
264
|
+
/** The fax-server hosts, trimmed, lower-cased and with blanks dropped — the shape {@link isFaxLine} tests against. */
|
|
265
|
+
function faxHosts(hosts: readonly string[] | undefined): Set<string> {
|
|
266
|
+
const out = new Set<string>();
|
|
267
|
+
for (const h of hosts ?? []) { const v = str(h).toLowerCase(); if (v) out.add(v); }
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Is this number handed to a fax server? The `dial-rule-translation-destination-host` alone, matched
|
|
273
|
+
* against the caller's list — never the `dial-rule-description`, which is a note the portal writes and
|
|
274
|
+
* an operator can edit. An empty host set answers `false` for everything, which is the point: this
|
|
275
|
+
* library knows no fax server of its own.
|
|
276
|
+
*/
|
|
277
|
+
function isFaxLine(p: Rec, hosts: Set<string>): boolean {
|
|
278
|
+
if (!hosts.size) return false;
|
|
279
|
+
return hosts.has(str(p['dial-rule-translation-destination-host']).toLowerCase());
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A device's NAME — the local part of its SIP URI (`sip:103t@acme.example` → `103t`), which is the short
|
|
284
|
+
* id the portal shows and the string the Teams test matches against.
|
|
285
|
+
*
|
|
286
|
+
* **`device` first, `aor` second.** A live `/users/<ext>/devices` record names the device in `device` and
|
|
287
|
+
* frequently carries no `aor` at all; reading `aor` alone therefore returned `''` on live data, which
|
|
288
|
+
* blanked every device name on the page AND broke the `<ext>t` Teams test — so a Teams connector read as
|
|
289
|
+
* `teams: false` and was counted as a handset in `deviceCount` and `devices.total`. Some records carry
|
|
290
|
+
* both, and then `device` wins, being the field the system actually names the device by. Neither, and the
|
|
291
|
+
* name is `''` rather than a guess.
|
|
292
|
+
*/
|
|
293
|
+
function deviceName(device: Rec): string {
|
|
294
|
+
const a = (str(device.device) || str(device.aor)).replace(/^sip:/i, '');
|
|
182
295
|
const at = a.indexOf('@');
|
|
183
296
|
return at === -1 ? a : a.slice(0, at);
|
|
184
297
|
}
|
|
185
298
|
|
|
186
|
-
|
|
299
|
+
/**
|
|
300
|
+
* The legend, lower-cased once so every lookup is a case-insensitive hit rather than a scan. A caller's
|
|
301
|
+
* legend REPLACES the default; two keys differing only in case collapse, last one wins, which is the
|
|
302
|
+
* only sane reading of a case-insensitive table.
|
|
303
|
+
*/
|
|
304
|
+
function suffixLegend(opts: InventoryOptions | undefined): DeviceSuffixLegend {
|
|
305
|
+
const src = opts?.deviceSuffixes ?? DEFAULT_DEVICE_SUFFIXES;
|
|
306
|
+
// Prototype-free: the key is a device-name suffix off a snapshot, so `constructor`, `toString` and
|
|
307
|
+
// `hasOwnProperty` are all reachable keys, and on a plain object each would answer with something
|
|
308
|
+
// inherited. `Object.entries` copies own enumerable keys only, so nothing inherited gets in either.
|
|
309
|
+
const out: DeviceSuffixLegend = Object.create(null) as DeviceSuffixLegend;
|
|
310
|
+
for (const [k, v] of Object.entries(src)) out[k.trim().toLowerCase()] = v;
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* What a device's name carries AFTER the extension number, lower-cased: `1001wp` on ext `1001` → `wp`,
|
|
316
|
+
* a bare `1001` → `''`. A name that does not start with the extension has no suffix at all — `sales1` on
|
|
317
|
+
* ext `1001` is a differently-named device, not a device of kind `sales1` — and neither does anything on
|
|
318
|
+
* an extension with no number, which is what keeps a device NAMED a bare `t` off the Teams legend.
|
|
319
|
+
*/
|
|
320
|
+
function deviceSuffix(name: string, ext: string): string {
|
|
321
|
+
if (!ext || !name.startsWith(ext)) return '';
|
|
322
|
+
return name.slice(ext.length).toLowerCase();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function extensionItem(u: Rec, devices: Rec[], legend: DeviceSuffixLegend): ExtensionItem {
|
|
187
326
|
const ext = str(u.user);
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
const
|
|
327
|
+
// Every device, once: its name, its suffix, and what the legend says that suffix is. Computed here and
|
|
328
|
+
// read three times below, so the Teams test, the handset filter and the display list cannot disagree.
|
|
329
|
+
const rows = devices.map((d) => {
|
|
330
|
+
const name = deviceName(d);
|
|
331
|
+
const suffix = deviceSuffix(name, ext);
|
|
332
|
+
const entry = suffix ? legend[suffix] : undefined;
|
|
333
|
+
return { d, name, suffix, kind: entry?.label ?? '', teams: entry?.teams === true };
|
|
334
|
+
});
|
|
335
|
+
// A CONNECTOR is a device whose suffix the legend marks `teams` — under the default legend that is
|
|
336
|
+
// `<ext>t` and nothing else, which is exactly the test this replaced. A legend without a `teams`
|
|
337
|
+
// suffix has no connectors, and every device on the extension is a handset.
|
|
338
|
+
const handsets = rows.filter((r) => !r.teams);
|
|
191
339
|
const transcription = str(u['voicemail-transcription-enabled']).toLowerCase();
|
|
192
340
|
const teams = handsets.length !== devices.length;
|
|
193
341
|
const name = `${str(u['name-first-name'])} ${str(u['name-last-name'])}`.trim();
|
|
@@ -205,12 +353,15 @@ function extensionItem(u: Rec, devices: Rec[]): ExtensionItem {
|
|
|
205
353
|
deviceCount: handsets.length,
|
|
206
354
|
// A device whose model is blank is listed under a named bucket rather than dropped: a missing
|
|
207
355
|
// model is a provisioning gap worth seeing, and a silently smaller total hides it.
|
|
208
|
-
deviceModels: handsets.map((
|
|
356
|
+
deviceModels: handsets.map((r) => str(r.d['device-models-model']) || '(unknown)'),
|
|
209
357
|
// Every device, including the Teams connector — this is a display list, not a seat count.
|
|
210
|
-
devices:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
358
|
+
devices: rows.map((r) => ({
|
|
359
|
+
name: r.name,
|
|
360
|
+
model: r.teams ? '' : str(r.d['device-models-model']) || '(unknown)',
|
|
361
|
+
teams: r.teams,
|
|
362
|
+
suffix: r.suffix,
|
|
363
|
+
kind: r.kind,
|
|
364
|
+
})),
|
|
214
365
|
anyDevice: handsets.length > 0 || teams,
|
|
215
366
|
};
|
|
216
367
|
}
|
|
@@ -249,8 +400,14 @@ export function usersByExt(users: Rec[]): Map<string, Rec> {
|
|
|
249
400
|
* - No destination but an application is set → `to <application>` (`to-connection` → `to connection`,
|
|
250
401
|
* `to-voicemail` → `to voicemail`).
|
|
251
402
|
* - Neither is set → `''`.
|
|
403
|
+
*
|
|
404
|
+
* A FAX LINE — a number whose destination host is one of `faxServerHosts` — short-circuits all of that
|
|
405
|
+
* and reads `to fax server`, host omitted. Otherwise it would render as `to connection` (which names
|
|
406
|
+
* plumbing, not a destination) or, on a rule that also carries a destination user, as a bare IP address
|
|
407
|
+
* beside a customer's phone number. Nobody reading this line needs the fax server's address.
|
|
252
408
|
*/
|
|
253
|
-
export function destinationOf(p: Rec, userByExt: Map<string, Rec
|
|
409
|
+
export function destinationOf(p: Rec, userByExt: Map<string, Rec>, faxServerHosts?: readonly string[]): string {
|
|
410
|
+
if (isFaxLine(p, faxHosts(faxServerHosts))) return 'to fax server';
|
|
254
411
|
const dest = str(p['dial-rule-translation-destination-user']);
|
|
255
412
|
const app = str(p['dial-rule-application']).replace(/^to-/i, '');
|
|
256
413
|
const host = str(p['dial-rule-translation-destination-host']);
|
|
@@ -288,7 +445,7 @@ export function destinationOf(p: Rec, userByExt: Map<string, Rec>): string {
|
|
|
288
445
|
* countable thing, and one derived row is more honest than two that shuffle. A blank id is a
|
|
289
446
|
* provisioning fault to fix; the fallback only keeps the distinguishable ones apart until it is.
|
|
290
447
|
*/
|
|
291
|
-
export function listDomainInventory(snapshot: Snapshot): DomainInventoryDetail {
|
|
448
|
+
export function listDomainInventory(snapshot: Snapshot, opts?: InventoryOptions): DomainInventoryDetail {
|
|
292
449
|
const users: Rec[] = Array.isArray(snapshot.users) ? snapshot.users : [];
|
|
293
450
|
const devicesByUser: Record<string, Rec[]> = (snapshot.devicesByUser ?? {}) as Record<string, Rec[]>;
|
|
294
451
|
const phonenumbers: Rec[] = Array.isArray(snapshot.phonenumbers) ? snapshot.phonenumbers : [];
|
|
@@ -297,6 +454,8 @@ export function listDomainInventory(snapshot: Snapshot): DomainInventoryDetail {
|
|
|
297
454
|
|
|
298
455
|
const extensions: ExtensionItem[] = [];
|
|
299
456
|
const systemUsers: ExtensionItem[] = [];
|
|
457
|
+
// Normalised once, not per extension: the legend is the caller's and does not change mid-fold.
|
|
458
|
+
const legend = suffixLegend(opts);
|
|
300
459
|
for (let i = 0; i < users.length; i++) {
|
|
301
460
|
const u = users[i]!;
|
|
302
461
|
const ext = str(u.user);
|
|
@@ -306,16 +465,21 @@ export function listDomainInventory(snapshot: Snapshot): DomainInventoryDetail {
|
|
|
306
465
|
// from a backup or a fixture. Dropping it would read as a clean match on a domain that has
|
|
307
466
|
// handsets nobody can see; two blank users sharing one list overcount instead, which is a
|
|
308
467
|
// visible drift an operator investigates, and that is the failure worth having.
|
|
309
|
-
const item = extensionItem(u, devicesByUser[ext] ?? []);
|
|
468
|
+
const item = extensionItem(u, devicesByUser[ext] ?? [], legend);
|
|
310
469
|
(isSystemUser(u) ? systemUsers : extensions).push(item);
|
|
311
470
|
}
|
|
312
471
|
const userByExt = usersByExt(users);
|
|
472
|
+
// Normalised once, not per number: the host list is the caller's and does not change mid-fold.
|
|
473
|
+
const hosts = faxHosts(opts?.faxServerHosts);
|
|
313
474
|
const dids: NumberItem[] = phonenumbers.map((p) => {
|
|
314
475
|
const number = str(p.phonenumber);
|
|
315
476
|
const kind: 'local' | 'tollFree' = isTollFree(number) ? 'tollFree' : 'local';
|
|
316
|
-
const
|
|
477
|
+
const fax = isFaxLine(p, hosts);
|
|
478
|
+
// The KEY does not carry `fax`. It is a fact about how the number is routed today, and routing a
|
|
479
|
+
// number to the fax server must not orphan every decision a consumer recorded against it.
|
|
480
|
+
const destination = fax ? 'to fax server' : destinationOf(p, userByExt);
|
|
317
481
|
const description = str(p['dial-rule-description']);
|
|
318
|
-
return { key: identityKey('did', number, JSON.stringify({ number, kind })), number, kind, destination, description };
|
|
482
|
+
return { key: identityKey('did', number, JSON.stringify({ number, kind })), number, kind, fax, destination, description };
|
|
319
483
|
});
|
|
320
484
|
const e911Addresses: AddressItem[] = addresses.map((a, i) => {
|
|
321
485
|
const id = str(a['emergency-address-id']);
|
|
@@ -339,8 +503,8 @@ export function listDomainInventory(snapshot: Snapshot): DomainInventoryDetail {
|
|
|
339
503
|
}
|
|
340
504
|
|
|
341
505
|
/** 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));
|
|
506
|
+
export function countDomainInventory(snapshot: Snapshot, opts?: InventoryOptions): DomainInventory {
|
|
507
|
+
return countInventoryDetail(listDomainInventory(snapshot, opts));
|
|
344
508
|
}
|
|
345
509
|
|
|
346
510
|
/**
|
|
@@ -353,7 +517,7 @@ export function countInventoryDetail(d: DomainInventoryDetail): DomainInventory
|
|
|
353
517
|
systemUsers: { total: d.systemUsers.length, byServiceCode: {} },
|
|
354
518
|
transcriptionEnabled: 0,
|
|
355
519
|
teamsConnected: 0,
|
|
356
|
-
dids: { total:
|
|
520
|
+
dids: { total: 0, tollFree: 0, local: 0, fax: 0, all: d.dids.length },
|
|
357
521
|
e911Addresses: d.e911Addresses.length,
|
|
358
522
|
smsNumbers: d.smsNumbers.length,
|
|
359
523
|
devices: { total: 0, byModel: {} },
|
|
@@ -371,7 +535,15 @@ export function countInventoryDetail(d: DomainInventoryDetail): DomainInventory
|
|
|
371
535
|
inv.devices.total += x.deviceCount;
|
|
372
536
|
for (const m of x.deviceModels) bump(inv.devices.byModel, m);
|
|
373
537
|
}
|
|
374
|
-
|
|
538
|
+
// A fax line is billed as a fax line, so it lands in `fax` and in NEITHER of the two DID buckets —
|
|
539
|
+
// counting it as both would bill one number twice on a rulebook that has a rule for each. `fax` is
|
|
540
|
+
// read off the item rather than recomputed: an item list a consumer FILTERED still carries it, and a
|
|
541
|
+
// list built by a pre-0.7.0 lib has no `fax` at all, which reads as false and counts as it always did.
|
|
542
|
+
for (const n of d.dids) {
|
|
543
|
+
if (n.fax) { inv.dids.fax++; continue; }
|
|
544
|
+
inv.dids.total++;
|
|
545
|
+
if (n.kind === 'tollFree') inv.dids.tollFree++; else inv.dids.local++;
|
|
546
|
+
}
|
|
375
547
|
return inv;
|
|
376
548
|
}
|
|
377
549
|
|
|
@@ -393,9 +565,14 @@ export function itemsFor(detail: DomainInventoryDetail, path: string): Inventory
|
|
|
393
565
|
if (path === 'extensions.withNoDevice') return ex.filter((x) => !x.anyDevice);
|
|
394
566
|
if (path === 'transcriptionEnabled') return ex.filter((x) => x.transcription);
|
|
395
567
|
if (path === 'teamsConnected') return ex.filter((x) => x.teams);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
568
|
+
// The three DID paths exclude fax lines, exactly as the counts do — a `counts: "dids.total"` rule
|
|
569
|
+
// whose observed number left the fax lines out but whose item list showed them would offer an
|
|
570
|
+
// operator rows to accept that the number above them does not count.
|
|
571
|
+
if (path === 'dids.total') return detail.dids.filter((n) => !n.fax);
|
|
572
|
+
if (path === 'dids.tollFree') return detail.dids.filter((n) => !n.fax && n.kind === 'tollFree');
|
|
573
|
+
if (path === 'dids.local') return detail.dids.filter((n) => !n.fax && n.kind === 'local');
|
|
574
|
+
if (path === 'dids.fax') return detail.dids.filter((n) => n.fax);
|
|
575
|
+
if (path === 'dids.all') return detail.dids;
|
|
399
576
|
if (path === 'e911Addresses') return detail.e911Addresses;
|
|
400
577
|
if (path === 'smsNumbers') return detail.smsNumbers;
|
|
401
578
|
return undefined;
|
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`);
|
package/src/resolver.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import type { EdgeKind, FlowGraph, FlowNode, NodeKind, Rec, Snapshot } from './model.js';
|
|
27
|
+
import { DEFAULT_DEVICE_SUFFIXES } from './inventory.js';
|
|
27
28
|
|
|
28
29
|
export interface EntityRef {
|
|
29
30
|
kind: 'did' | 'user' | 'queue' | 'attendant';
|
|
@@ -42,27 +43,49 @@ const GREET_MAX = 90;
|
|
|
42
43
|
const trim = (v: string, max = GREET_MAX): string => (v.length > max ? `${v.slice(0, max - 1).trimEnd()}…` : v);
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* A diagram needs a glyph, and a suffix legend carries none — so the icons are the resolver's own,
|
|
47
|
+
* keyed by the same suffixes. `r` is here without being in the legend on purpose: it is the conventional
|
|
48
|
+
* NetSapiens+Ringotel app suffix, common enough to draw with a phone rather than a desk handset, but not
|
|
49
|
+
* something NetSapiens ships, so it has no place in a table this library states as fact.
|
|
50
|
+
*
|
|
51
|
+
* **Both tables are prototype-free.** The key is a device-name suffix off a snapshot, so `constructor`,
|
|
52
|
+
* `toString` and `hasOwnProperty` are all reachable keys; on a plain object literal each of them answers
|
|
53
|
+
* with something inherited and truthy, and `?? 'desk phone'` then never fires. A `Map` would do as well;
|
|
54
|
+
* `Object.create(null)` keeps the lookup a subscript.
|
|
55
|
+
*/
|
|
56
|
+
const SUFFIX_ICONS: Record<string, string> = Object.assign(Object.create(null) as Record<string, string>,
|
|
57
|
+
{ wp: '🌐', t: '💻', m: '📱', r: '📱' });
|
|
58
|
+
/** Kinds for suffixes {@link DEFAULT_DEVICE_SUFFIXES} does not name. `r` for the reason above; anything
|
|
59
|
+
* else lower-cased (`b`, and the rest) is a desk phone, which is what an unadorned device usually is.
|
|
60
|
+
* Prototype-free for the reason above. */
|
|
61
|
+
const SUFFIX_FALLBACK_KINDS: Record<string, string> = Object.assign(Object.create(null) as Record<string, string>,
|
|
62
|
+
{ r: 'app' });
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Endpoint type by the agent-id / extension letter suffix (heuristic). The KIND comes from
|
|
66
|
+
* {@link DEFAULT_DEVICE_SUFFIXES} — the same legend `listDomainInventory` labels a device with, so a
|
|
67
|
+
* suffix means one thing across this library and the two cannot drift — with the icons above supplying
|
|
68
|
+
* what the legend does not.
|
|
69
|
+
*
|
|
48
70
|
* Exact device info (model, MAC, transport) IS available via the device API but isn't pulled yet —
|
|
49
|
-
* see ARCHITECTURE.md → NetSapiens routing model; this suffix guess is the cheap approximation.
|
|
71
|
+
* see ARCHITECTURE.md → NetSapiens routing model; this suffix guess is the cheap approximation. Note the
|
|
72
|
+
* resolver reads the DEFAULT legend and takes no options: a call flow is drawn from a snapshot alone, and
|
|
73
|
+
* a deployment's own suffixes reach the inventory through `InventoryOptions.deviceSuffixes` instead.
|
|
74
|
+
*
|
|
75
|
+
* Exported for its own test. Not on the package surface (`index.ts`) — it is an internal heuristic, and
|
|
76
|
+
* a consumer wanting the legend should read `DEFAULT_DEVICE_SUFFIXES`, which is the thing being stated.
|
|
50
77
|
*/
|
|
51
|
-
function deviceKindBySuffix(suffix: string): { icon: string; kind: string } {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return { icon: '', kind: '' };
|
|
63
|
-
default:
|
|
64
|
-
return { icon: '📞', kind: 'desk phone' }; // b and other letters
|
|
65
|
-
}
|
|
78
|
+
export function deviceKindBySuffix(suffix: string): { icon: string; kind: string } {
|
|
79
|
+
const k = suffix.toLowerCase();
|
|
80
|
+
if (!k) return { icon: '', kind: '' };
|
|
81
|
+
// `Object.hasOwn` rather than a truthiness test: `DEFAULT_DEVICE_SUFFIXES` is a plain object, so
|
|
82
|
+
// `[k]` on an inherited name answers a function whose `.label` is undefined — which happens to fall
|
|
83
|
+
// through correctly, and would stop doing so the day an entry gained an optional field read here.
|
|
84
|
+
const entry = Object.hasOwn(DEFAULT_DEVICE_SUFFIXES, k) ? DEFAULT_DEVICE_SUFFIXES[k] : undefined;
|
|
85
|
+
return {
|
|
86
|
+
icon: SUFFIX_ICONS[k] ?? '📞',
|
|
87
|
+
kind: entry?.label ?? SUFFIX_FALLBACK_KINDS[k] ?? 'desk phone',
|
|
88
|
+
};
|
|
66
89
|
}
|
|
67
90
|
|
|
68
91
|
/** Compact agent queue-priority badge: "P" + a keycap digit (e.g. P2️⃣). Priority is a cross-queue
|