@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 CHANGED
@@ -79,7 +79,7 @@ genuine zero from a read that never completed rather than silently undercounting
79
79
 
80
80
  ### Counting a domain: `countDomainInventory`
81
81
 
82
- `countDomainInventory(snapshot)` is pure — it fetches nothing, and turns a `Snapshot` (from
82
+ `countDomainInventory(snapshot, opts?)` is pure — it fetches nothing, and turns a `Snapshot` (from
83
83
  `fetchDomainSnapshot`, a backup, or a fixture) into a fixed tree of numeric leaves along the dimensions a
84
84
  VoIP operator actually sells on:
85
85
 
@@ -90,9 +90,11 @@ VoIP operator actually sells on:
90
90
  - `systemUsers` — `system-aa`, `system-queue`, `system-tod` and friends: `total` and `byServiceCode`.
91
91
  Informational, never compared against a seat count.
92
92
  - `transcriptionEnabled` — extensions with voicemail transcription on.
93
- - `teamsConnected` — extensions with a Microsoft Teams connector device (SIP `aor` local part
94
- `<ext>t`). That connector is excluded from `devices`/`deviceCount`: it is a connector, not a handset.
95
- - `dids` phone numbers, `total` / `tollFree` / `local`.
93
+ - `teamsConnected` — extensions with a Microsoft Teams connector device one whose device-name suffix
94
+ the legend marks `teams` (`<ext>t` by default). That connector is excluded from `devices`/`deviceCount`:
95
+ it is a connector, not a handset. See [Device suffixes](#device-suffixes).
96
+ - `dids` — phone numbers: `total` / `tollFree` / `local`, all three **excluding fax lines**, plus
97
+ `fax` and `all` (everything, `total + fax === all`). See [Fax lines](#fax-lines).
96
98
  - `e911Addresses`, `smsNumbers` — record counts.
97
99
  - `devices` — `total` and `byModel`, real extensions only (a system user's device is not a seat).
98
100
 
@@ -116,9 +118,61 @@ inventory.dids.tollFree; // e.g. 3
116
118
  inventory.devices.byModel; // e.g. { "Yealink T54W": 12, "(unknown)": 1 }
117
119
  ```
118
120
 
121
+ #### Fax lines
122
+
123
+ On the portal's **Fax Server** treatment a fax line is an ordinary phone number whose dial rule hands it
124
+ to a fax server: `dial-rule-application: to-connection` and `dial-rule-translation-destination-host` set
125
+ to that server's host. The API has no fax-account endpoint and the ATA is not a device on any user, so
126
+ that host is the only thing that says "fax line" — and nothing in the API distinguishes an analog fax
127
+ from a digital one.
128
+
129
+ The host belongs to your deployment, so you supply it; **with no hosts, nothing is a fax line** and the
130
+ counts are exactly what they were before 0.7.0:
131
+
132
+ ```ts
133
+ const inventory = countDomainInventory(snapshot, { faxServerHosts: ['203.0.113.7'] });
134
+ inventory.dids.fax; // 2 — billed as fax lines
135
+ inventory.dids.total; // the DIDs, those two NOT among them
136
+ inventory.dids.all; // total + fax
137
+ ```
138
+
139
+ Matching is on the trimmed host, case-insensitively, and on nothing else — never the
140
+ `dial-rule-description`, which is a note the portal writes and an operator can edit. A fax line's
141
+ `NumberItem.fax` is `true`, its `destination` reads `to fax server` (the host is deliberately not shown),
142
+ and it keeps its `kind`: a fax number is local or toll-free like any other, it is simply not counted as a
143
+ DID.
144
+
145
+ #### Device suffixes
146
+
147
+ A device's **suffix** is what its name carries after the extension number: `1001wp` on extension `1001`
148
+ has suffix `wp`, a bare `1001` has none, and a name that does not start with the extension has none
149
+ either. Three suffixes ship with NetSapiens, and `DEFAULT_DEVICE_SUFFIXES` is that table:
150
+
151
+ ```ts
152
+ DEFAULT_DEVICE_SUFFIXES; // { wp: {label:'SNAPmobile Web'}, m: {label:'SNAPmobile'}, t: {label:'Teams', teams:true} }
153
+ ```
154
+
155
+ Each device in `ExtensionItem.devices` carries its `suffix` (lower-cased) and the legend's label for it as
156
+ `kind` — `''` when the suffix is empty or the legend does not carry it, because an unlisted suffix is a
157
+ name the deployment has not explained, not a device type to guess at. The suffix marked `teams: true` is
158
+ what identifies a Microsoft Teams connector: that is the whole of the test, so `teamsConnected`,
159
+ `ExtensionItem.teams` and the handset-only `deviceCount`/`deviceModels` all follow from the legend.
160
+
161
+ Your own suffixes go in `deviceSuffixes`, which **replaces** the default rather than merging with it:
162
+
163
+ ```ts
164
+ listDomainInventory(snapshot, { deviceSuffixes: { r: { label: 'Acme App' }, t: { label: 'Teams', teams: true } } });
165
+ ```
166
+
167
+ Replace-wholesale is deliberate. A deployment without TeamMate omits `t`, and Teams detection is then off
168
+ entirely — every `<ext>t` device is a handset and is counted as one — which a merge could not express.
169
+ Comparison is case-insensitive on both sides. `resolveFlow` labels a simultaneous-ring device from the
170
+ same default table (it takes no options — a call flow is drawn from a snapshot alone), so a suffix means
171
+ one thing across this library.
172
+
119
173
  #### Listing a domain: `listDomainInventory`
120
174
 
121
- `countDomainInventory` is a fold over `listDomainInventory(snapshot)`, which returns the per-item lists
175
+ `countDomainInventory` is a fold over `listDomainInventory(snapshot, opts?)` (same options), which returns the per-item lists
122
176
  behind those counts — for anything that shows an operator *which* extension or number a count refers to,
123
177
  not just how many. Same allowlist discipline as the counts: a device's MAC, SIP credentials and email
124
178
  never appear, though names and sites now do (that's the point of a list). Each item carries a stable
@@ -131,11 +185,13 @@ never appear, though names and sites now do (that's the point of a list). Each i
131
185
  | `e911Addresses` | `addr:<emergency-address-id>` |
132
186
  | `smsNumbers` | `sms:<number>` |
133
187
 
134
- A number carries `destination` — where it routes, in words (`to user 100 — Ann Lee`, `to queue 701 —
135
- Sales`), built by the exported `destinationOf(p, userByExt)` over `usersByExt(users)` and `description`
136
- (`dial-rule-description`, trimmed); an extension carries `devices`, an `{ name, model, teams }` per
137
- device it has (handset and Teams connector alike), in record order still never the MAC, SIP password
138
- or email.
188
+ A number carries `fax` (see [Fax lines](#fax-lines)) and `destination` — where it routes, in words
189
+ (`to user 100 Ann Lee`, `to queue 701 — Sales`, `to fax server`), built by the exported
190
+ `destinationOf(p, userByExt, faxServerHosts?)` over `usersByExt(users)` and `description`
191
+ (`dial-rule-description`, trimmed); an extension carries `devices`, an
192
+ `{ name, model, teams, suffix, kind }` per device it has (handset and Teams connector alike), in record
193
+ order — still never the MAC, SIP password or email. See [Device suffixes](#device-suffixes) for `suffix`
194
+ and `kind`.
139
195
 
140
196
  `itemsFor(detail, path)` returns the items behind one of `countDomainInventory`'s dotted-path counts —
141
197
  the same vocabulary, so a UI that lets an operator drill from a count into the records behind it needs
@@ -150,7 +206,9 @@ no separate lookup table:
150
206
  | `extensions.withAnyDevice` / `extensions.withNoDevice` | extensions with / without any device (handset or Teams connector) |
151
207
  | `transcriptionEnabled` | extensions with transcription on |
152
208
  | `teamsConnected` | extensions with a Teams connector |
153
- | `dids.total` / `dids.tollFree` / `dids.local` | phone numbers |
209
+ | `dids.total` / `dids.tollFree` / `dids.local` | phone numbers, **fax lines excluded** |
210
+ | `dids.fax` | fax lines |
211
+ | `dids.all` | every phone number, fax lines included |
154
212
  | `e911Addresses` | E911 addresses |
155
213
  | `smsNumbers` | SMS numbers |
156
214
 
package/dist/index.d.ts CHANGED
@@ -16,7 +16,7 @@ export { THEMES, DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME, NODE_LIGHT, NODE_DARK,
16
16
  export { renderGalleryHtml, renderFlowCards, renderFlowCard, mermaidBootstrap, flowAnchorId, type GalleryOptions, type CardOptions, } from './html.js';
17
17
  export { resolveSvgSize, rasterizerScript } from './raster.js';
18
18
  export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray, type NsClientConfig, type FetchSnapshotOptions } from './nsClient.js';
19
- export { countDomainInventory, countInventoryDetail, listDomainInventory, itemsFor, itemLabel, destinationOf, usersByExt, type DomainInventory, type DomainInventoryDetail, type ExtensionItem, type NumberItem, type AddressItem, type SmsItem, type InventoryItem } from './inventory.js';
19
+ export { countDomainInventory, countInventoryDetail, listDomainInventory, itemsFor, itemLabel, destinationOf, usersByExt, DEFAULT_DEVICE_SUFFIXES, type DeviceSuffixLegend, type DomainInventory, type DomainInventoryDetail, type InventoryOptions, type ExtensionItem, type NumberItem, type AddressItem, type SmsItem, type InventoryItem } from './inventory.js';
20
20
  export { attributeDomainInventory, type DomainAttribution, type ItemAttribution } from './attribution.js';
21
21
  export { NsWriteClient, type NsWriteClientConfig } from './nsWriteClient.js';
22
22
  export { supportsSynchronous, SYNCHRONOUS_OPERATIONS, type SynchronousMethod, type SynchronousOperation, } from './nsSynchronous.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ export { THEMES, DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME, NODE_LIGHT, NODE_DARK,
15
15
  export { renderGalleryHtml, renderFlowCards, renderFlowCard, mermaidBootstrap, flowAnchorId, } from './html.js';
16
16
  export { resolveSvgSize, rasterizerScript } from './raster.js';
17
17
  export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray } from './nsClient.js';
18
- export { countDomainInventory, countInventoryDetail, listDomainInventory, itemsFor, itemLabel, destinationOf, usersByExt } from './inventory.js';
18
+ export { countDomainInventory, countInventoryDetail, listDomainInventory, itemsFor, itemLabel, destinationOf, usersByExt, DEFAULT_DEVICE_SUFFIXES } from './inventory.js';
19
19
  export { attributeDomainInventory } from './attribution.js';
20
20
  export { NsWriteClient } from './nsWriteClient.js';
21
21
  export { supportsSynchronous, SYNCHRONOUS_OPERATIONS, } from './nsSynchronous.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAkB,MAAM,eAAe,CAAC;AAC1E,OAAO,EAAE,SAAS,EAAuC,MAAM,cAAc,CAAC;AAC9E,OAAO,EACL,MAAM,EACN,mBAAmB,EACnB,kBAAkB,EAClB,UAAU,EACV,SAAS,EACT,UAAU,EACV,SAAS,GAKV,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,YAAY,GAGb,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,WAAW,EAAE,OAAO,EAAkD,MAAM,eAAe,CAAC;AAClK,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,QAAQ,EAAE,SAAS,EAAE,aAAa,EAAE,UAAU,EAA6I,MAAM,gBAAgB,CAAC;AAC5R,OAAO,EAAE,wBAAwB,EAAgD,MAAM,kBAAkB,CAAC;AAC1G,OAAO,EAAE,aAAa,EAA4B,MAAM,oBAAoB,CAAC;AAC7E,OAAO,EACL,mBAAmB,EACnB,sBAAsB,GAGvB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,cAAc,EACd,mBAAmB,EACnB,YAAY,GAIb,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,YAAY,EAAE,WAAW,EAAiD,MAAM,mBAAmB,CAAC;AAC7G,OAAO,EACL,qBAAqB,EACrB,2BAA2B,EAC3B,mBAAmB,EACnB,mBAAmB,EACnB,UAAU,EACV,eAAe,EACf,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,GAUlB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,MAAM,EACN,iBAAiB,EACjB,cAAc,EACd,YAAY,EACZ,oBAAoB,EACpB,cAAc,EACd,QAAQ,EACR,kBAAkB,GAOnB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAwB,cAAc,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAC1F,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,YAAY,GAIb,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,WAAW,EACX,SAAS,EACT,GAAG,GAIJ,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,mBAAmB,GAOpB,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAkB,MAAM,eAAe,CAAC;AAC1E,OAAO,EAAE,SAAS,EAAuC,MAAM,cAAc,CAAC;AAC9E,OAAO,EACL,MAAM,EACN,mBAAmB,EACnB,kBAAkB,EAClB,UAAU,EACV,SAAS,EACT,UAAU,EACV,SAAS,GAKV,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,YAAY,GAGb,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,WAAW,EAAE,OAAO,EAAkD,MAAM,eAAe,CAAC;AAClK,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,QAAQ,EAAE,SAAS,EAAE,aAAa,EAAE,UAAU,EAAE,uBAAuB,EAA6L,MAAM,gBAAgB,CAAC;AACrW,OAAO,EAAE,wBAAwB,EAAgD,MAAM,kBAAkB,CAAC;AAC1G,OAAO,EAAE,aAAa,EAA4B,MAAM,oBAAoB,CAAC;AAC7E,OAAO,EACL,mBAAmB,EACnB,sBAAsB,GAGvB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,cAAc,EACd,mBAAmB,EACnB,YAAY,GAIb,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,YAAY,EAAE,WAAW,EAAiD,MAAM,mBAAmB,CAAC;AAC7G,OAAO,EACL,qBAAqB,EACrB,2BAA2B,EAC3B,mBAAmB,EACnB,mBAAmB,EACnB,UAAU,EACV,eAAe,EACf,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,GAUlB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,MAAM,EACN,iBAAiB,EACjB,cAAc,EACd,YAAY,EACZ,oBAAoB,EACpB,cAAc,EACd,QAAQ,EACR,kBAAkB,GAOnB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAwB,cAAc,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAC1F,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,YAAY,GAIb,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,WAAW,EACX,SAAS,EACT,GAAG,GAIJ,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,mBAAmB,GAOpB,MAAM,kBAAkB,CAAC"}
@@ -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 (its `aor` local part, e.g. `101b` the same
13
- * short id the portal shows) alongside its model; a device's MAC, SIP password and email never appear,
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
  export interface DomainInventory {
@@ -49,16 +63,30 @@ export interface DomainInventory {
49
63
  /** Extensions whose `voicemail-transcription-enabled` is anything but empty or `no`. */
50
64
  transcriptionEnabled: number;
51
65
  /**
52
- * Extensions with a Microsoft Teams connector device — one whose SIP `aor` local part is the
53
- * extension number followed by `t` (`1000t`), which is how the TeamMate connector registers.
54
- * That device is NOT counted under `devices`: it is a connector, not a handset.
66
+ * Extensions with a Microsoft Teams connector device — one whose device-name SUFFIX the legend marks
67
+ * `teams: true`, which under {@link DEFAULT_DEVICE_SUFFIXES} is the extension number followed by `t`
68
+ * (`1000t`), how the TeamMate connector registers. See {@link deviceName} for which field that name is
69
+ * read from, and why reading the wrong one silently miscounted this. That device is NOT counted under
70
+ * `devices`: it is a connector, not a handset. A supplied legend with no `teams` suffix — a deployment
71
+ * without TeamMate — leaves this 0 and counts every device as a handset.
55
72
  */
56
73
  teamsConnected: number;
57
- /** Phone numbers on the domain, split by NANP toll-free prefix. */
74
+ /**
75
+ * Phone numbers on the domain, split by NANP toll-free prefix — **fax lines excluded**.
76
+ *
77
+ * A number handed to the fax server is billed as a fax line, not as a DID, so `total`, `tollFree`
78
+ * and `local` all leave it out and `fax` counts it instead. `all` is every phone number the domain
79
+ * holds, fax lines included: `total + fax === all`.
80
+ *
81
+ * With no `faxServerHosts` supplied nothing is a fax line, `fax` is 0 and `total === all` — the
82
+ * numbers this returned before 0.7.0, unchanged.
83
+ */
58
84
  dids: {
59
85
  total: number;
60
86
  tollFree: number;
61
87
  local: number;
88
+ fax: number;
89
+ all: number;
62
90
  };
63
91
  /** E911 address records on the domain. */
64
92
  e911Addresses: number;
@@ -99,17 +127,27 @@ export interface ExtensionItem {
99
127
  /** `device-models-model` per handset, `(unknown)` when blank. Never the MAC. */
100
128
  deviceModels: string[];
101
129
  /**
102
- * Every device on this extension, in record order, connector included: `name` is the `aor` local
103
- * part (`sip:101b@acme.example` → `101b`) — the device NAME as the portal shows it — `model` is
130
+ * Every device on this extension, in record order, connector included: `name` is the local part of its
131
+ * `device` SIP URI, or of `aor` when that is all the record has (`sip:101b@acme.example` → `101b`) —
132
+ * the device NAME as the portal shows it — `model` is
104
133
  * `device-models-model` (`(unknown)` when blank on a handset, `''` for the Teams connector, which
105
134
  * has no model), and `teams` marks the connector entry itself. `deviceCount`/`deviceModels`/`teams`
106
135
  * above stay handset-only; this list is the one place a connector's own row shows up. Never the MAC
107
136
  * or the SIP password.
137
+ *
138
+ * `suffix` is what the name carries AFTER the extension number (`1001wp` on ext `1001` → `wp`; a bare
139
+ * `1001` → `''`), lower-cased; a name that does not start with the extension has no suffix, and neither
140
+ * does a device on an extension with no number. `kind` is that suffix's label from the legend in
141
+ * {@link InventoryOptions.deviceSuffixes} — `''` when the suffix is empty OR when the legend does not
142
+ * carry it, because an unlisted suffix is a name this deployment has not explained, not a device type
143
+ * to guess at.
108
144
  */
109
145
  devices: Array<{
110
146
  name: string;
111
147
  model: string;
112
148
  teams: boolean;
149
+ suffix: string;
150
+ kind: string;
113
151
  }>;
114
152
  /** `deviceCount > 0 || teams` — has a device of any kind, handset or connector. */
115
153
  anyDevice: boolean;
@@ -118,6 +156,14 @@ export interface NumberItem {
118
156
  key: string;
119
157
  number: string;
120
158
  kind: 'local' | 'tollFree';
159
+ /**
160
+ * This number is handed to a fax server — see the module doc. `false` whenever the caller supplied no
161
+ * `faxServerHosts`, since without a host list nothing here can tell a fax line from any other number.
162
+ *
163
+ * `kind` is still set on a fax line (a fax number is local or toll-free like any other), but the
164
+ * COUNTS exclude it from `dids.total`/`local`/`tollFree` and count it under `dids.fax` instead.
165
+ */
166
+ fax: boolean;
121
167
  /** Where the number routes, for a person: see {@link destinationOf}. `''` when the record says nothing. */
122
168
  destination: string;
123
169
  /** `dial-rule-description` trimmed — the note the portal writes ("Portal Created: User - 1001"); `''` when blank. */
@@ -141,6 +187,45 @@ export interface DomainInventoryDetail {
141
187
  e911Addresses: AddressItem[];
142
188
  smsNumbers: SmsItem[];
143
189
  }
190
+ /** A device-name suffix legend: `suffix → { label, teams? }`. See {@link InventoryOptions.deviceSuffixes}. */
191
+ export type DeviceSuffixLegend = Record<string, {
192
+ label: string;
193
+ teams?: boolean;
194
+ }>;
195
+ /**
196
+ * The three device-name suffixes NetSapiens itself ships — SNAPmobile Web, SNAPmobile, and the TeamMate
197
+ * Microsoft Teams connector. Used whenever a caller supplies no `deviceSuffixes`, and it is the same
198
+ * table `resolver.ts` names a simultaneous-ring device by, so a suffix means one thing in this library.
199
+ *
200
+ * A deployment's OWN suffixes (a white-labelled app, say) are not here and never will be: they belong to
201
+ * the operator, who supplies them through {@link InventoryOptions.deviceSuffixes}.
202
+ */
203
+ export declare const DEFAULT_DEVICE_SUFFIXES: Readonly<DeviceSuffixLegend>;
204
+ /**
205
+ * What the caller has to tell the counter that the API cannot. Optional in full: every option absent is
206
+ * the pre-0.7.0 behaviour, and no option changes what a snapshot has to contain.
207
+ */
208
+ export interface InventoryOptions {
209
+ /**
210
+ * The hosts a fax line is handed to — an IP or a hostname, as it appears in
211
+ * `dial-rule-translation-destination-host`. Compared trimmed and case-insensitively; blanks are
212
+ * ignored. Absent or empty means NO number is a fax line. See the module doc for why this is the
213
+ * caller's to supply.
214
+ */
215
+ faxServerHosts?: readonly string[];
216
+ /**
217
+ * What a device-name SUFFIX means on this deployment: `suffix → { label, teams? }`. Compared
218
+ * case-insensitively, and it REPLACES {@link DEFAULT_DEVICE_SUFFIXES} wholesale rather than merging
219
+ * with it — a deployment that has no TeamMate omits `t` and Teams detection is then off entirely,
220
+ * which a merge could not express.
221
+ *
222
+ * `teams: true` marks the suffix that names a Microsoft Teams CONNECTOR rather than a handset: it is
223
+ * what {@link ExtensionItem.teams} and {@link DomainInventory.teamsConnected} test, and what keeps the
224
+ * connector out of `deviceCount`/`deviceModels`. At most one suffix normally carries it, but nothing
225
+ * here requires that.
226
+ */
227
+ deviceSuffixes?: DeviceSuffixLegend;
228
+ }
144
229
  export declare const str: (v: unknown) => string;
145
230
  /** Is this user one of NetSapiens' internal routing objects rather than a seat? */
146
231
  export declare function isSystemUser(user: Rec): boolean;
@@ -170,8 +255,13 @@ export declare function usersByExt(users: Rec[]): Map<string, Rec>;
170
255
  * - No destination but an application is set → `to <application>` (`to-connection` → `to connection`,
171
256
  * `to-voicemail` → `to voicemail`).
172
257
  * - Neither is set → `''`.
258
+ *
259
+ * A FAX LINE — a number whose destination host is one of `faxServerHosts` — short-circuits all of that
260
+ * and reads `to fax server`, host omitted. Otherwise it would render as `to connection` (which names
261
+ * plumbing, not a destination) or, on a rule that also carries a destination user, as a bare IP address
262
+ * beside a customer's phone number. Nobody reading this line needs the fax server's address.
173
263
  */
174
- export declare function destinationOf(p: Rec, userByExt: Map<string, Rec>): string;
264
+ export declare function destinationOf(p: Rec, userByExt: Map<string, Rec>, faxServerHosts?: readonly string[]): string;
175
265
  /**
176
266
  * The items behind every count. Pure. Fields are copied by name from an allowlist; no record passes
177
267
  * through, so a device's MAC or SIP password cannot reach a consumer by accident.
@@ -189,9 +279,9 @@ export declare function destinationOf(p: Rec, userByExt: Map<string, Rec>): stri
189
279
  * countable thing, and one derived row is more honest than two that shuffle. A blank id is a
190
280
  * provisioning fault to fix; the fallback only keeps the distinguishable ones apart until it is.
191
281
  */
192
- export declare function listDomainInventory(snapshot: Snapshot): DomainInventoryDetail;
282
+ export declare function listDomainInventory(snapshot: Snapshot, opts?: InventoryOptions): DomainInventoryDetail;
193
283
  /** The counts, as a fold over {@link listDomainInventory} so the two can never disagree. */
194
- export declare function countDomainInventory(snapshot: Snapshot): DomainInventory;
284
+ export declare function countDomainInventory(snapshot: Snapshot, opts?: InventoryOptions): DomainInventory;
195
285
  /**
196
286
  * Count an item list. Exposed separately so a consumer that has FILTERED the lists — to one site, to
197
287
  * one billing account — gets counts that agree with what it kept, rather than re-counting the snapshot.
@@ -1 +1 @@
1
- {"version":3,"file":"inventory.d.ts","sourceRoot":"","sources":["../src/inventory.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"inventory.d.ts","sourceRoot":"","sources":["../src/inventory.ts"],"names":[],"mappings":"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"}
package/dist/inventory.js CHANGED
@@ -1,3 +1,16 @@
1
+ /**
2
+ * The three device-name suffixes NetSapiens itself ships — SNAPmobile Web, SNAPmobile, and the TeamMate
3
+ * Microsoft Teams connector. Used whenever a caller supplies no `deviceSuffixes`, and it is the same
4
+ * table `resolver.ts` names a simultaneous-ring device by, so a suffix means one thing in this library.
5
+ *
6
+ * A deployment's OWN suffixes (a white-labelled app, say) are not here and never will be: they belong to
7
+ * the operator, who supplies them through {@link InventoryOptions.deviceSuffixes}.
8
+ */
9
+ export const DEFAULT_DEVICE_SUFFIXES = Object.freeze({
10
+ wp: { label: 'SNAPmobile Web' },
11
+ m: { label: 'SNAPmobile' },
12
+ t: { label: 'Teams', teams: true },
13
+ });
1
14
  /** NANP toll-free area codes, 800 through 888. A number outside this set is counted local. */
2
15
  const TOLL_FREE = new Set(['800', '833', '844', '855', '866', '877', '888']);
3
16
  export const str = (v) => (typeof v === 'string' ? v.trim() : v == null ? '' : String(v).trim());
@@ -43,17 +56,83 @@ function isTollFree(raw) {
43
56
  const nanp = digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits;
44
57
  return nanp.length === 10 && TOLL_FREE.has(nanp.slice(0, 3));
45
58
  }
46
- /** The local part of a device's `aor` (`sip:103t@acme.example` `103t`), read only to test for Teams. */
47
- function aorLocal(device) {
48
- const a = str(device.aor).replace(/^sip:/i, '');
59
+ /** The fax-server hosts, trimmed, lower-cased and with blanks dropped the shape {@link isFaxLine} tests against. */
60
+ function faxHosts(hosts) {
61
+ const out = new Set();
62
+ for (const h of hosts ?? []) {
63
+ const v = str(h).toLowerCase();
64
+ if (v)
65
+ out.add(v);
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * Is this number handed to a fax server? The `dial-rule-translation-destination-host` alone, matched
71
+ * against the caller's list — never the `dial-rule-description`, which is a note the portal writes and
72
+ * an operator can edit. An empty host set answers `false` for everything, which is the point: this
73
+ * library knows no fax server of its own.
74
+ */
75
+ function isFaxLine(p, hosts) {
76
+ if (!hosts.size)
77
+ return false;
78
+ return hosts.has(str(p['dial-rule-translation-destination-host']).toLowerCase());
79
+ }
80
+ /**
81
+ * A device's NAME — the local part of its SIP URI (`sip:103t@acme.example` → `103t`), which is the short
82
+ * id the portal shows and the string the Teams test matches against.
83
+ *
84
+ * **`device` first, `aor` second.** A live `/users/<ext>/devices` record names the device in `device` and
85
+ * frequently carries no `aor` at all; reading `aor` alone therefore returned `''` on live data, which
86
+ * blanked every device name on the page AND broke the `<ext>t` Teams test — so a Teams connector read as
87
+ * `teams: false` and was counted as a handset in `deviceCount` and `devices.total`. Some records carry
88
+ * both, and then `device` wins, being the field the system actually names the device by. Neither, and the
89
+ * name is `''` rather than a guess.
90
+ */
91
+ function deviceName(device) {
92
+ const a = (str(device.device) || str(device.aor)).replace(/^sip:/i, '');
49
93
  const at = a.indexOf('@');
50
94
  return at === -1 ? a : a.slice(0, at);
51
95
  }
52
- function extensionItem(u, devices) {
96
+ /**
97
+ * The legend, lower-cased once so every lookup is a case-insensitive hit rather than a scan. A caller's
98
+ * legend REPLACES the default; two keys differing only in case collapse, last one wins, which is the
99
+ * only sane reading of a case-insensitive table.
100
+ */
101
+ function suffixLegend(opts) {
102
+ const src = opts?.deviceSuffixes ?? DEFAULT_DEVICE_SUFFIXES;
103
+ // Prototype-free: the key is a device-name suffix off a snapshot, so `constructor`, `toString` and
104
+ // `hasOwnProperty` are all reachable keys, and on a plain object each would answer with something
105
+ // inherited. `Object.entries` copies own enumerable keys only, so nothing inherited gets in either.
106
+ const out = Object.create(null);
107
+ for (const [k, v] of Object.entries(src))
108
+ out[k.trim().toLowerCase()] = v;
109
+ return out;
110
+ }
111
+ /**
112
+ * What a device's name carries AFTER the extension number, lower-cased: `1001wp` on ext `1001` → `wp`,
113
+ * a bare `1001` → `''`. A name that does not start with the extension has no suffix at all — `sales1` on
114
+ * ext `1001` is a differently-named device, not a device of kind `sales1` — and neither does anything on
115
+ * an extension with no number, which is what keeps a device NAMED a bare `t` off the Teams legend.
116
+ */
117
+ function deviceSuffix(name, ext) {
118
+ if (!ext || !name.startsWith(ext))
119
+ return '';
120
+ return name.slice(ext.length).toLowerCase();
121
+ }
122
+ function extensionItem(u, devices, legend) {
53
123
  const ext = str(u.user);
54
- // The Teams test is `<ext>t`, so a blank ext would read every device whose aor local part is a
55
- // bare `t` as a connector. No extension number, no Teams claim.
56
- const handsets = ext ? devices.filter((d) => aorLocal(d) !== `${ext}t`) : devices;
124
+ // Every device, once: its name, its suffix, and what the legend says that suffix is. Computed here and
125
+ // read three times below, so the Teams test, the handset filter and the display list cannot disagree.
126
+ const rows = devices.map((d) => {
127
+ const name = deviceName(d);
128
+ const suffix = deviceSuffix(name, ext);
129
+ const entry = suffix ? legend[suffix] : undefined;
130
+ return { d, name, suffix, kind: entry?.label ?? '', teams: entry?.teams === true };
131
+ });
132
+ // A CONNECTOR is a device whose suffix the legend marks `teams` — under the default legend that is
133
+ // `<ext>t` and nothing else, which is exactly the test this replaced. A legend without a `teams`
134
+ // suffix has no connectors, and every device on the extension is a handset.
135
+ const handsets = rows.filter((r) => !r.teams);
57
136
  const transcription = str(u['voicemail-transcription-enabled']).toLowerCase();
58
137
  const teams = handsets.length !== devices.length;
59
138
  const name = `${str(u['name-first-name'])} ${str(u['name-last-name'])}`.trim();
@@ -71,12 +150,15 @@ function extensionItem(u, devices) {
71
150
  deviceCount: handsets.length,
72
151
  // A device whose model is blank is listed under a named bucket rather than dropped: a missing
73
152
  // model is a provisioning gap worth seeing, and a silently smaller total hides it.
74
- deviceModels: handsets.map((d) => str(d['device-models-model']) || '(unknown)'),
153
+ deviceModels: handsets.map((r) => str(r.d['device-models-model']) || '(unknown)'),
75
154
  // Every device, including the Teams connector — this is a display list, not a seat count.
76
- devices: devices.map((d) => {
77
- const isTeams = ext ? aorLocal(d) === `${ext}t` : false;
78
- return { name: aorLocal(d), model: isTeams ? '' : str(d['device-models-model']) || '(unknown)', teams: isTeams };
79
- }),
155
+ devices: rows.map((r) => ({
156
+ name: r.name,
157
+ model: r.teams ? '' : str(r.d['device-models-model']) || '(unknown)',
158
+ teams: r.teams,
159
+ suffix: r.suffix,
160
+ kind: r.kind,
161
+ })),
80
162
  anyDevice: handsets.length > 0 || teams,
81
163
  };
82
164
  }
@@ -114,8 +196,15 @@ export function usersByExt(users) {
114
196
  * - No destination but an application is set → `to <application>` (`to-connection` → `to connection`,
115
197
  * `to-voicemail` → `to voicemail`).
116
198
  * - Neither is set → `''`.
199
+ *
200
+ * A FAX LINE — a number whose destination host is one of `faxServerHosts` — short-circuits all of that
201
+ * and reads `to fax server`, host omitted. Otherwise it would render as `to connection` (which names
202
+ * plumbing, not a destination) or, on a rule that also carries a destination user, as a bare IP address
203
+ * beside a customer's phone number. Nobody reading this line needs the fax server's address.
117
204
  */
118
- export function destinationOf(p, userByExt) {
205
+ export function destinationOf(p, userByExt, faxServerHosts) {
206
+ if (isFaxLine(p, faxHosts(faxServerHosts)))
207
+ return 'to fax server';
119
208
  const dest = str(p['dial-rule-translation-destination-user']);
120
209
  const app = str(p['dial-rule-application']).replace(/^to-/i, '');
121
210
  const host = str(p['dial-rule-translation-destination-host']);
@@ -151,7 +240,7 @@ export function destinationOf(p, userByExt) {
151
240
  * countable thing, and one derived row is more honest than two that shuffle. A blank id is a
152
241
  * provisioning fault to fix; the fallback only keeps the distinguishable ones apart until it is.
153
242
  */
154
- export function listDomainInventory(snapshot) {
243
+ export function listDomainInventory(snapshot, opts) {
155
244
  const users = Array.isArray(snapshot.users) ? snapshot.users : [];
156
245
  const devicesByUser = (snapshot.devicesByUser ?? {});
157
246
  const phonenumbers = Array.isArray(snapshot.phonenumbers) ? snapshot.phonenumbers : [];
@@ -159,6 +248,8 @@ export function listDomainInventory(snapshot) {
159
248
  const smsnumbers = Array.isArray(snapshot.smsnumbers) ? snapshot.smsnumbers : [];
160
249
  const extensions = [];
161
250
  const systemUsers = [];
251
+ // Normalised once, not per extension: the legend is the caller's and does not change mid-fold.
252
+ const legend = suffixLegend(opts);
162
253
  for (let i = 0; i < users.length; i++) {
163
254
  const u = users[i];
164
255
  const ext = str(u.user);
@@ -168,16 +259,21 @@ export function listDomainInventory(snapshot) {
168
259
  // from a backup or a fixture. Dropping it would read as a clean match on a domain that has
169
260
  // handsets nobody can see; two blank users sharing one list overcount instead, which is a
170
261
  // visible drift an operator investigates, and that is the failure worth having.
171
- const item = extensionItem(u, devicesByUser[ext] ?? []);
262
+ const item = extensionItem(u, devicesByUser[ext] ?? [], legend);
172
263
  (isSystemUser(u) ? systemUsers : extensions).push(item);
173
264
  }
174
265
  const userByExt = usersByExt(users);
266
+ // Normalised once, not per number: the host list is the caller's and does not change mid-fold.
267
+ const hosts = faxHosts(opts?.faxServerHosts);
175
268
  const dids = phonenumbers.map((p) => {
176
269
  const number = str(p.phonenumber);
177
270
  const kind = isTollFree(number) ? 'tollFree' : 'local';
178
- const destination = destinationOf(p, userByExt);
271
+ const fax = isFaxLine(p, hosts);
272
+ // The KEY does not carry `fax`. It is a fact about how the number is routed today, and routing a
273
+ // number to the fax server must not orphan every decision a consumer recorded against it.
274
+ const destination = fax ? 'to fax server' : destinationOf(p, userByExt);
179
275
  const description = str(p['dial-rule-description']);
180
- return { key: identityKey('did', number, JSON.stringify({ number, kind })), number, kind, destination, description };
276
+ return { key: identityKey('did', number, JSON.stringify({ number, kind })), number, kind, fax, destination, description };
181
277
  });
182
278
  const e911Addresses = addresses.map((a, i) => {
183
279
  const id = str(a['emergency-address-id']);
@@ -200,8 +296,8 @@ export function listDomainInventory(snapshot) {
200
296
  return { extensions, systemUsers, dids, e911Addresses, smsNumbers };
201
297
  }
202
298
  /** The counts, as a fold over {@link listDomainInventory} so the two can never disagree. */
203
- export function countDomainInventory(snapshot) {
204
- return countInventoryDetail(listDomainInventory(snapshot));
299
+ export function countDomainInventory(snapshot, opts) {
300
+ return countInventoryDetail(listDomainInventory(snapshot, opts));
205
301
  }
206
302
  /**
207
303
  * Count an item list. Exposed separately so a consumer that has FILTERED the lists — to one site, to
@@ -213,7 +309,7 @@ export function countInventoryDetail(d) {
213
309
  systemUsers: { total: d.systemUsers.length, byServiceCode: {} },
214
310
  transcriptionEnabled: 0,
215
311
  teamsConnected: 0,
216
- dids: { total: d.dids.length, tollFree: 0, local: 0 },
312
+ dids: { total: 0, tollFree: 0, local: 0, fax: 0, all: d.dids.length },
217
313
  e911Addresses: d.e911Addresses.length,
218
314
  smsNumbers: d.smsNumbers.length,
219
315
  devices: { total: 0, byModel: {} },
@@ -239,7 +335,16 @@ export function countInventoryDetail(d) {
239
335
  for (const m of x.deviceModels)
240
336
  bump(inv.devices.byModel, m);
241
337
  }
338
+ // A fax line is billed as a fax line, so it lands in `fax` and in NEITHER of the two DID buckets —
339
+ // counting it as both would bill one number twice on a rulebook that has a rule for each. `fax` is
340
+ // read off the item rather than recomputed: an item list a consumer FILTERED still carries it, and a
341
+ // list built by a pre-0.7.0 lib has no `fax` at all, which reads as false and counts as it always did.
242
342
  for (const n of d.dids) {
343
+ if (n.fax) {
344
+ inv.dids.fax++;
345
+ continue;
346
+ }
347
+ inv.dids.total++;
243
348
  if (n.kind === 'tollFree')
244
349
  inv.dids.tollFree++;
245
350
  else
@@ -276,12 +381,19 @@ export function itemsFor(detail, path) {
276
381
  return ex.filter((x) => x.transcription);
277
382
  if (path === 'teamsConnected')
278
383
  return ex.filter((x) => x.teams);
384
+ // The three DID paths exclude fax lines, exactly as the counts do — a `counts: "dids.total"` rule
385
+ // whose observed number left the fax lines out but whose item list showed them would offer an
386
+ // operator rows to accept that the number above them does not count.
279
387
  if (path === 'dids.total')
280
- return detail.dids;
388
+ return detail.dids.filter((n) => !n.fax);
281
389
  if (path === 'dids.tollFree')
282
- return detail.dids.filter((n) => n.kind === 'tollFree');
390
+ return detail.dids.filter((n) => !n.fax && n.kind === 'tollFree');
283
391
  if (path === 'dids.local')
284
- return detail.dids.filter((n) => n.kind === 'local');
392
+ return detail.dids.filter((n) => !n.fax && n.kind === 'local');
393
+ if (path === 'dids.fax')
394
+ return detail.dids.filter((n) => n.fax);
395
+ if (path === 'dids.all')
396
+ return detail.dids;
285
397
  if (path === 'e911Addresses')
286
398
  return detail.e911Addresses;
287
399
  if (path === 'smsNumbers')