@dszp/netsapiens-lib 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,35 @@
38
38
  * case-insensitively, and on nothing else — not the `dial-rule-description`, which is a portal-written
39
39
  * note an operator can edit. **With no hosts supplied nothing is a fax line**, because a library that
40
40
  * hardcoded one deployment's fax server would be wrong everywhere else.
41
+ *
42
+ * ## E911: the ENDPOINT is the billable unit, the address is a location
43
+ *
44
+ * An **Emergency Endpoint** is a callback number, a caller name, a billing address and a vendor. It is
45
+ * what the E911 carrier routes on and what it bills per, and it is counted as `e911Endpoints`. An
46
+ * **Emergency Address** is a dispatchable location forwarded to responders; several of them can sit
47
+ * under one endpoint, and nobody bills them. `e911Addresses` stays, as information.
48
+ *
49
+ * Users, devices and sites point at an endpoint through their Emergency Caller ID
50
+ * (`caller-id-number-emergency`) matching the endpoint's callback number. This library then INFERS two
51
+ * fallbacks for a blank field — a blank `emergency-address-id` reads as the domain default address, and
52
+ * a blank caller ID reads as that address's endpoint. **Neither is a measured platform behaviour**; see
53
+ * the ⚠️ on {@link EmergencyModel} for what is known and what is assumed. They live in
54
+ * {@link resolveEmergency} alone, so the counter and `attribution.ts` cannot disagree about them, and
55
+ * they fail closed: nothing to inherit leaves a user referencing nothing rather than referencing a
56
+ * guess.
57
+ *
58
+ * ## Legacy emergency numbers, which have no API object at all
59
+ *
60
+ * A domain still on the legacy provisioning model has no endpoint records. Every one of its users
61
+ * carries an EMPTY `emergency-address-id` and a `caller-id-number-emergency` set to one of a handful of
62
+ * DIDs — and the carrier bills per one of those DIDs, exactly as it bills per endpoint on the new
63
+ * model. `e911Legacy` is therefore the count of DISTINCT such numbers, and a rulebook can count the two
64
+ * dimensions together so one retail E911 line pays for either model.
65
+ *
66
+ * A number that IS an endpoint callback is excluded, because a half-migrated domain that counted it in
67
+ * both dimensions would bill the same place twice. That exclusion is also the precondition: with no
68
+ * endpoint list read there is nothing to exclude against, so `e911Legacy` is 0 whenever
69
+ * `snapshot.addressEndpoints` is `undefined` rather than an array. See {@link DomainInventory.e911Legacy}.
41
70
  */
42
71
  import type { Rec, Snapshot } from './model.js';
43
72
  export interface DomainInventory {
@@ -63,10 +92,12 @@ export interface DomainInventory {
63
92
  /** Extensions whose `voicemail-transcription-enabled` is anything but empty or `no`. */
64
93
  transcriptionEnabled: number;
65
94
  /**
66
- * Extensions with a Microsoft Teams connector device — one whose device NAME is the extension number
67
- * followed by `t` (`1000t`), which is how the TeamMate connector registers. See {@link deviceName} for
68
- * which field that name is read from, and why reading the wrong one silently miscounted this.
69
- * That device is NOT counted under `devices`: it is a connector, not a handset.
95
+ * Extensions with a Microsoft Teams connector device — one whose device-name SUFFIX the legend marks
96
+ * `teams: true`, which under {@link DEFAULT_DEVICE_SUFFIXES} is the extension number followed by `t`
97
+ * (`1000t`), how the TeamMate connector registers. See {@link deviceName} for which field that name is
98
+ * read from, and why reading the wrong one silently miscounted this. That device is NOT counted under
99
+ * `devices`: it is a connector, not a handset. A supplied legend with no `teams` suffix — a deployment
100
+ * without TeamMate — leaves this 0 and counts every device as a handset.
70
101
  */
71
102
  teamsConnected: number;
72
103
  /**
@@ -86,8 +117,25 @@ export interface DomainInventory {
86
117
  fax: number;
87
118
  all: number;
88
119
  };
89
- /** E911 address records on the domain. */
120
+ /**
121
+ * E911 address records on the domain — dispatchable LOCATIONS, and information only. The billable
122
+ * unit is {@link DomainInventory.e911Endpoints}; see the module doc.
123
+ */
90
124
  e911Addresses: number;
125
+ /** Provisioned Emergency Endpoints — the unit the E911 carrier bills per. */
126
+ e911Endpoints: number;
127
+ /**
128
+ * Distinct legacy emergency numbers — the pre-endpoint model, which has no API object of its own.
129
+ * Comparable with {@link DomainInventory.e911Endpoints}, and never overlapping it **provided the
130
+ * snapshot carries an endpoint list**: the two are kept apart by excluding numbers that are already
131
+ * endpoint callbacks, which needs the endpoints to have been read.
132
+ *
133
+ * So this is **0 whenever `snapshot.addressEndpoints` is `undefined`** — the fetch never asked, and a
134
+ * count derived from the users alone would report a fully-migrated domain's every emergency caller ID
135
+ * as a line to bill for. An empty ARRAY is the other fact — asked, and the domain has none — and that
136
+ * one does support a count. Fetch with `includeAddresses: true` (see `fetchDomainSnapshot`).
137
+ */
138
+ e911Legacy: number;
91
139
  /** SMS-enabled numbers on the domain. */
92
140
  smsNumbers: number;
93
141
  /** Devices belonging to real extensions only — a system user's device is not a seat. */
@@ -132,11 +180,20 @@ export interface ExtensionItem {
132
180
  * has no model), and `teams` marks the connector entry itself. `deviceCount`/`deviceModels`/`teams`
133
181
  * above stay handset-only; this list is the one place a connector's own row shows up. Never the MAC
134
182
  * or the SIP password.
183
+ *
184
+ * `suffix` is what the name carries AFTER the extension number (`1001wp` on ext `1001` → `wp`; a bare
185
+ * `1001` → `''`), lower-cased; a name that does not start with the extension has no suffix, and neither
186
+ * does a device on an extension with no number. `kind` is that suffix's label from the legend in
187
+ * {@link InventoryOptions.deviceSuffixes} — `''` when the suffix is empty OR when the legend does not
188
+ * carry it, because an unlisted suffix is a name this deployment has not explained, not a device type
189
+ * to guess at.
135
190
  */
136
191
  devices: Array<{
137
192
  name: string;
138
193
  model: string;
139
194
  teams: boolean;
195
+ suffix: string;
196
+ kind: string;
140
197
  }>;
141
198
  /** `deviceCount > 0 || teams` — has a device of any kind, handset or connector. */
142
199
  anyDevice: boolean;
@@ -162,11 +219,43 @@ export interface AddressItem {
162
219
  key: string;
163
220
  label: string;
164
221
  }
222
+ /**
223
+ * One provisioned Emergency Endpoint — the thing the E911 carrier bills per.
224
+ *
225
+ * An allowlist like every other item: the callback, the caller name, and the billing address reduced to
226
+ * the ONE line {@link AddressItem} already exposes. The endpoint record also carries a geolocation XML
227
+ * and a public IP, and neither belongs in front of a billing operator.
228
+ */
229
+ export interface EndpointItem {
230
+ /** `e911:<callback>` — the digits, so a device's 11-digit form and the record's 10-digit form are one
231
+ * key. `e911:~<hash>` when the record names no callback at all. */
232
+ key: string;
233
+ /** The callback number, digits only ({@link emergencyDigits}); `''` when the record has none. */
234
+ callback: string;
235
+ /** `caller-name` — who the carrier announces; `''` when blank. */
236
+ callerName: string;
237
+ /** Street and city, one line — no more of the billing address than the address list already shows. */
238
+ billingAddress: string;
239
+ /** How many users the RECORD says are configured on it (`count-users-configured`), 0 when it says nothing. */
240
+ users: number;
241
+ }
242
+ /**
243
+ * One legacy emergency number: a `caller-id-number-emergency` in use on a domain that has no endpoint
244
+ * records for it. Derived from the users, because the legacy model has no object of its own to read.
245
+ */
246
+ export interface LegacyE911Item {
247
+ /** `e911legacy:<digits>`. Never a hash fallback — a blank number is not one of these. */
248
+ key: string;
249
+ /** The number, digits only ({@link emergencyDigits}). */
250
+ number: string;
251
+ /** How many real extensions reference it — counted here, not read off a record. */
252
+ users: number;
253
+ }
165
254
  export interface SmsItem {
166
255
  key: string;
167
256
  number: string;
168
257
  }
169
- export type InventoryItem = ExtensionItem | NumberItem | AddressItem | SmsItem;
258
+ export type InventoryItem = ExtensionItem | NumberItem | AddressItem | EndpointItem | LegacyE911Item | SmsItem;
170
259
  export interface DomainInventoryDetail {
171
260
  /** Real seats only, same rule as the count. */
172
261
  extensions: ExtensionItem[];
@@ -174,8 +263,24 @@ export interface DomainInventoryDetail {
174
263
  systemUsers: ExtensionItem[];
175
264
  dids: NumberItem[];
176
265
  e911Addresses: AddressItem[];
266
+ e911Endpoints: EndpointItem[];
267
+ e911Legacy: LegacyE911Item[];
177
268
  smsNumbers: SmsItem[];
178
269
  }
270
+ /** A device-name suffix legend: `suffix → { label, teams? }`. See {@link InventoryOptions.deviceSuffixes}. */
271
+ export type DeviceSuffixLegend = Record<string, {
272
+ label: string;
273
+ teams?: boolean;
274
+ }>;
275
+ /**
276
+ * The three device-name suffixes NetSapiens itself ships — SNAPmobile Web, SNAPmobile, and the TeamMate
277
+ * Microsoft Teams connector. Used whenever a caller supplies no `deviceSuffixes`, and it is the same
278
+ * table `resolver.ts` names a simultaneous-ring device by, so a suffix means one thing in this library.
279
+ *
280
+ * A deployment's OWN suffixes (a white-labelled app, say) are not here and never will be: they belong to
281
+ * the operator, who supplies them through {@link InventoryOptions.deviceSuffixes}.
282
+ */
283
+ export declare const DEFAULT_DEVICE_SUFFIXES: Readonly<DeviceSuffixLegend>;
179
284
  /**
180
285
  * What the caller has to tell the counter that the API cannot. Optional in full: every option absent is
181
286
  * the pre-0.7.0 behaviour, and no option changes what a snapshot has to contain.
@@ -188,16 +293,95 @@ export interface InventoryOptions {
188
293
  * caller's to supply.
189
294
  */
190
295
  faxServerHosts?: readonly string[];
296
+ /**
297
+ * What a device-name SUFFIX means on this deployment: `suffix → { label, teams? }`. Compared
298
+ * case-insensitively, and it REPLACES {@link DEFAULT_DEVICE_SUFFIXES} wholesale rather than merging
299
+ * with it — a deployment that has no TeamMate omits `t` and Teams detection is then off entirely,
300
+ * which a merge could not express.
301
+ *
302
+ * `teams: true` marks the suffix that names a Microsoft Teams CONNECTOR rather than a handset: it is
303
+ * what {@link ExtensionItem.teams} and {@link DomainInventory.teamsConnected} test, and what keeps the
304
+ * connector out of `deviceCount`/`deviceModels`. At most one suffix normally carries it, but nothing
305
+ * here requires that.
306
+ */
307
+ deviceSuffixes?: DeviceSuffixLegend;
191
308
  }
192
309
  export declare const str: (v: unknown) => string;
193
310
  /** Is this user one of NetSapiens' internal routing objects rather than a seat? */
194
311
  export declare function isSystemUser(user: Rec): boolean;
312
+ /**
313
+ * An Emergency Caller ID reduced to what two records can be compared on: its digits, with an 11-digit
314
+ * `1NXXNXXXXXX` collapsed to its 10-digit form so a device's spelling matches an endpoint's.
315
+ *
316
+ * `''` for "not set", which the API says three ways: empty, absent, and the `[*]` wildcard the portal
317
+ * renders as "Select a Caller ID for 911 calls". Treating `[*]` as a value would give every unset
318
+ * device on a domain one shared fake endpoint.
319
+ */
320
+ export declare function emergencyDigits(v: unknown): string;
195
321
  /**
196
322
  * One record per non-blank `user`, first one wins. The same rule `attribution.ts` needs to join a
197
323
  * number's `dial-rule-translation-destination-user` (or an SMS number, or an address) back to the
198
324
  * user it belongs to — extracted here so there is exactly one copy of it in the library.
199
325
  */
200
326
  export declare function usersByExt(users: Rec[]): Map<string, Rec>;
327
+ /**
328
+ * How a domain's E911 records join up, and the two INHERITANCES this library reads into a blank field.
329
+ *
330
+ * Both the counter and `attribution.ts` need to answer "which endpoint does this user reference?" and
331
+ * "which address?". Resolved in one place so the count and the site attribution cannot disagree about
332
+ * who references what.
333
+ *
334
+ * ⚠️ **Both inheritances are this library's inference, not a measured platform behaviour.** Two
335
+ * separate assumptions sit under them, and a consumer relying on the placement should know which:
336
+ *
337
+ * 1. **That a blank field falls back to the domain default at all.** A user with a blank
338
+ * `caller-id-number-emergency` is read here as referencing the default address's endpoint, and one
339
+ * with a blank `emergency-address-id` as referencing the default address. That is a plausible
340
+ * reading of a record the portal badges "Domain Default" — but it has not been confirmed against a
341
+ * live 911 call, and the same state can be read as an E911 GAP rather than an inheritance.
342
+ * 2. **That the default address's callback is joined by `address-name`.** An address record carries no
343
+ * callback field of its own (checked against a live domain and 34 captured snapshots), so the only
344
+ * thing tying the domain default to an endpoint is that the endpoint names the same address. Every
345
+ * captured domain carrying both agreed on that name.
346
+ *
347
+ * Both fail CLOSED. No default address, no endpoint naming it, or a name that does not match, and
348
+ * `defaultCallback` is `''` — the users who would have inherited it reference nothing and are left
349
+ * unattributed, rather than being attached to a guess. The COUNTS are unaffected either way
350
+ * (`e911Endpoints` is a record count, and a user with both fields blank is correctly not legacy); what
351
+ * these assumptions move is PLACEMENT, which on a split domain decides which accounts are told they
352
+ * need an E911 line.
353
+ */
354
+ export interface EmergencyModel {
355
+ /** The `emergency-address-id` of the record marked `domain_default`; `''` when the domain has none. */
356
+ defaultAddressId: string;
357
+ /** The callback of the endpoint bound to the DEFAULT address, digits only; `''` when there is none. */
358
+ defaultCallback: string;
359
+ /** Every provisioned endpoint's callback, digits only — the "is this number already an endpoint?" test. */
360
+ endpointCallbacks: Set<string>;
361
+ /** Which address a user references: their own field, the domain default when it is blank. */
362
+ addressIdFor: (user: Rec) => string;
363
+ /** The callback a user SETS: their own field, else any of their devices'; `''` when neither does. */
364
+ setCallbackFor: (user: Rec) => string;
365
+ /** Which endpoint a user references: {@link EmergencyModel.setCallbackFor}, else the domain default's. */
366
+ callbackFor: (user: Rec) => string;
367
+ }
368
+ export declare function resolveEmergency(snapshot: Snapshot): EmergencyModel;
369
+ /**
370
+ * Is this user on the LEGACY emergency model — a caller ID set by hand, with no address record behind
371
+ * it and no endpoint provisioned for the number?
372
+ *
373
+ * All three clauses matter. A blank `emergency-address-id` alone is not legacy: a user with BOTH fields
374
+ * blank inherits the domain default address, which is the new model working as designed. And a number
375
+ * that IS an endpoint callback is the new model too — counting it here as well would bill a
376
+ * half-migrated domain twice for one place.
377
+ *
378
+ * ⚠️ **The `em` must come from a snapshot whose endpoints were READ.** The third clause tests against
379
+ * `em.endpointCallbacks`, which is empty both when the domain has no endpoints and when nobody asked
380
+ * for them — so on a snapshot fetched without `includeAddresses` this answers "legacy" for every user
381
+ * on a fully-migrated domain. {@link listDomainInventory} refuses to derive the list at all in that
382
+ * state; a caller using this predicate directly has to make the same check.
383
+ */
384
+ export declare function legacyEmergencyNumber(user: Rec, em: EmergencyModel): string;
201
385
  /**
202
386
  * Where a phone number routes, in words a person reads at a glance — not the raw NetSapiens dial
203
387
  * rule fields. Pure; looks the destination user up in `userByExt` ({@link usersByExt}) so it can
@@ -1 +1 @@
1
- {"version":3,"file":"inventory.d.ts","sourceRoot":"","sources":["../src/inventory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,OAAO,KAAK,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,UAAU,EAAE;QACV,KAAK,EAAE,MAAM,CAAC;QACd,8DAA8D;QAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAChC,kEAAkE;QAClE,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACtC,sGAAsG;QACtG,aAAa,EAAE,MAAM,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC;QACtD,oFAAoF;QACpF,aAAa,EAAE,MAAM,CAAC;QACtB,6FAA6F;QAC7F,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC;IACF,8FAA8F;IAC9F,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAC;IACtE,wFAAwF;IACxF,oBAAoB,EAAE,MAAM,CAAC;IAC7B;;;;;OAKG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB;;;;;;;;;OASG;IACH,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IACnF,0CAA0C;IAC1C,aAAa,EAAE,MAAM,CAAC;IACtB,yCAAyC;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB,wFAAwF;IACxF,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAC;CAC7D;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,qCAAqC;IACrC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,OAAO,CAAC;IACvB,kDAAkD;IAClD,KAAK,EAAE,OAAO,CAAC;IACf,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB;;;;;;;;OAQG;IACH,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IAChE,mFAAmF;IACnF,SAAS,EAAE,OAAO,CAAC;CACpB;AACD,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAkE;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,GAAG,UAAU,CAAC;IAC3B;;;;;;OAMG;IACH,GAAG,EAAE,OAAO,CAAC;IACb,2GAA2G;IAC3G,WAAW,EAAE,MAAM,CAAC;IACpB,qHAAqH;IACrH,WAAW,EAAE,MAAM,CAAC;CACrB;AACD,MAAM,WAAW,WAAW;IAAG,GAAG,EAAE,MAAM,CAAyE;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACnI,MAAM,WAAW,OAAO;IAAG,GAAG,EAAE,MAAM,CAA6D;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE;AACpH,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG,UAAU,GAAG,WAAW,GAAG,OAAO,CAAC;AAE/E,MAAM,WAAW,qBAAqB;IACpC,+CAA+C;IAC/C,UAAU,EAAE,aAAa,EAAE,CAAC;IAC5B,qCAAqC;IACrC,WAAW,EAAE,aAAa,EAAE,CAAC;IAC7B,IAAI,EAAE,UAAU,EAAE,CAAC;IACnB,aAAa,EAAE,WAAW,EAAE,CAAC;IAC7B,UAAU,EAAE,OAAO,EAAE,CAAC;CACvB;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAKD,eAAO,MAAM,GAAG,GAAI,GAAG,OAAO,KAAG,MAAgF,CAAC;AAiClH,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,IAAI,EAAE,GAAG,GAAG,OAAO,CAE/C;AAgFD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,CAOzD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAoB7G;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,qBAAqB,CAqDtG;AAED,4FAA4F;AAC5F,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,eAAe,CAEjG;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,qBAAqB,GAAG,eAAe,CAkC9E;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,qBAAqB,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,EAAE,GAAG,SAAS,CAwBjG;AAED,8GAA8G;AAC9G,wBAAgB,SAAS,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CAQrD"}
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());
@@ -64,6 +77,25 @@ function isFaxLine(p, hosts) {
64
77
  return false;
65
78
  return hosts.has(str(p['dial-rule-translation-destination-host']).toLowerCase());
66
79
  }
80
+ /**
81
+ * An Emergency Caller ID reduced to what two records can be compared on: its digits, with an 11-digit
82
+ * `1NXXNXXXXXX` collapsed to its 10-digit form so a device's spelling matches an endpoint's.
83
+ *
84
+ * `''` for "not set", which the API says three ways: empty, absent, and the `[*]` wildcard the portal
85
+ * renders as "Select a Caller ID for 911 calls". Treating `[*]` as a value would give every unset
86
+ * device on a domain one shared fake endpoint.
87
+ */
88
+ export function emergencyDigits(v) {
89
+ const raw = str(v);
90
+ if (!raw || raw === '[*]')
91
+ return '';
92
+ const digits = raw.replace(/\D+/g, '');
93
+ if (!digits)
94
+ return '';
95
+ return digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits;
96
+ }
97
+ /** A NetSapiens boolean, which arrives as a JSON `true` from one endpoint and as `"yes"` from another. */
98
+ const flag = (v) => v === true || ['yes', 'true', '1'].includes(str(v).toLowerCase());
67
99
  /**
68
100
  * A device's NAME — the local part of its SIP URI (`sip:103t@acme.example` → `103t`), which is the short
69
101
  * id the portal shows and the string the Teams test matches against.
@@ -80,11 +112,46 @@ function deviceName(device) {
80
112
  const at = a.indexOf('@');
81
113
  return at === -1 ? a : a.slice(0, at);
82
114
  }
83
- function extensionItem(u, devices) {
115
+ /**
116
+ * The legend, lower-cased once so every lookup is a case-insensitive hit rather than a scan. A caller's
117
+ * legend REPLACES the default; two keys differing only in case collapse, last one wins, which is the
118
+ * only sane reading of a case-insensitive table.
119
+ */
120
+ function suffixLegend(opts) {
121
+ const src = opts?.deviceSuffixes ?? DEFAULT_DEVICE_SUFFIXES;
122
+ // Prototype-free: the key is a device-name suffix off a snapshot, so `constructor`, `toString` and
123
+ // `hasOwnProperty` are all reachable keys, and on a plain object each would answer with something
124
+ // inherited. `Object.entries` copies own enumerable keys only, so nothing inherited gets in either.
125
+ const out = Object.create(null);
126
+ for (const [k, v] of Object.entries(src))
127
+ out[k.trim().toLowerCase()] = v;
128
+ return out;
129
+ }
130
+ /**
131
+ * What a device's name carries AFTER the extension number, lower-cased: `1001wp` on ext `1001` → `wp`,
132
+ * a bare `1001` → `''`. A name that does not start with the extension has no suffix at all — `sales1` on
133
+ * ext `1001` is a differently-named device, not a device of kind `sales1` — and neither does anything on
134
+ * an extension with no number, which is what keeps a device NAMED a bare `t` off the Teams legend.
135
+ */
136
+ function deviceSuffix(name, ext) {
137
+ if (!ext || !name.startsWith(ext))
138
+ return '';
139
+ return name.slice(ext.length).toLowerCase();
140
+ }
141
+ function extensionItem(u, devices, legend) {
84
142
  const ext = str(u.user);
85
- // The Teams test is `<ext>t`, so a blank ext would read every device NAMED a bare `t` as a connector.
86
- // No extension number, no Teams claim.
87
- const handsets = ext ? devices.filter((d) => deviceName(d) !== `${ext}t`) : devices;
143
+ // Every device, once: its name, its suffix, and what the legend says that suffix is. Computed here and
144
+ // read three times below, so the Teams test, the handset filter and the display list cannot disagree.
145
+ const rows = devices.map((d) => {
146
+ const name = deviceName(d);
147
+ const suffix = deviceSuffix(name, ext);
148
+ const entry = suffix ? legend[suffix] : undefined;
149
+ return { d, name, suffix, kind: entry?.label ?? '', teams: entry?.teams === true };
150
+ });
151
+ // A CONNECTOR is a device whose suffix the legend marks `teams` — under the default legend that is
152
+ // `<ext>t` and nothing else, which is exactly the test this replaced. A legend without a `teams`
153
+ // suffix has no connectors, and every device on the extension is a handset.
154
+ const handsets = rows.filter((r) => !r.teams);
88
155
  const transcription = str(u['voicemail-transcription-enabled']).toLowerCase();
89
156
  const teams = handsets.length !== devices.length;
90
157
  const name = `${str(u['name-first-name'])} ${str(u['name-last-name'])}`.trim();
@@ -102,13 +169,15 @@ function extensionItem(u, devices) {
102
169
  deviceCount: handsets.length,
103
170
  // A device whose model is blank is listed under a named bucket rather than dropped: a missing
104
171
  // model is a provisioning gap worth seeing, and a silently smaller total hides it.
105
- deviceModels: handsets.map((d) => str(d['device-models-model']) || '(unknown)'),
172
+ deviceModels: handsets.map((r) => str(r.d['device-models-model']) || '(unknown)'),
106
173
  // Every device, including the Teams connector — this is a display list, not a seat count.
107
- devices: devices.map((d) => {
108
- const name = deviceName(d);
109
- const isTeams = ext ? name === `${ext}t` : false;
110
- return { name, model: isTeams ? '' : str(d['device-models-model']) || '(unknown)', teams: isTeams };
111
- }),
174
+ devices: rows.map((r) => ({
175
+ name: r.name,
176
+ model: r.teams ? '' : str(r.d['device-models-model']) || '(unknown)',
177
+ teams: r.teams,
178
+ suffix: r.suffix,
179
+ kind: r.kind,
180
+ })),
112
181
  anyDevice: handsets.length > 0 || teams,
113
182
  };
114
183
  }
@@ -126,6 +195,63 @@ export function usersByExt(users) {
126
195
  }
127
196
  return map;
128
197
  }
198
+ export function resolveEmergency(snapshot) {
199
+ const addresses = Array.isArray(snapshot.addresses) ? snapshot.addresses : [];
200
+ const endpoints = Array.isArray(snapshot.addressEndpoints) ? snapshot.addressEndpoints : [];
201
+ const devicesByUser = (snapshot.devicesByUser ?? {});
202
+ // First one wins. Two records marked default is a provisioning fault, and picking one of them
203
+ // silently is better than resolving every blank user to nothing because two records disagree.
204
+ const def = addresses.find((a) => flag(a['domain_default']));
205
+ const defaultAddressId = def ? str(def['emergency-address-id']) : '';
206
+ const defName = def ? str(def['address-name']).toLowerCase() : '';
207
+ const boundToDefault = defName ? endpoints.find((e) => str(e['address-name']).toLowerCase() === defName) : undefined;
208
+ // NB: on an ENDPOINT record `emergency-address-id` holds the callback NUMBER, not an address id.
209
+ const defaultCallback = boundToDefault ? emergencyDigits(boundToDefault['emergency-address-id']) : '';
210
+ const endpointCallbacks = new Set(endpoints.map((e) => emergencyDigits(e['emergency-address-id'])).filter(Boolean));
211
+ const setCallbackFor = (user) => {
212
+ const own = emergencyDigits(user['caller-id-number-emergency']);
213
+ if (own)
214
+ return own;
215
+ // A user who sets none can still have a handset that does — the portal reads the device's own
216
+ // setting first and only then the user's, so a domain whose numbers live on the devices is
217
+ // invisible to a rule that reads the user record alone.
218
+ for (const d of devicesByUser[str(user.user)] ?? []) {
219
+ const dev = emergencyDigits(d['caller-id-number-emergency']);
220
+ if (dev)
221
+ return dev;
222
+ }
223
+ return '';
224
+ };
225
+ return {
226
+ defaultAddressId,
227
+ defaultCallback,
228
+ endpointCallbacks,
229
+ addressIdFor: (user) => str(user['emergency-address-id']) || defaultAddressId,
230
+ setCallbackFor,
231
+ callbackFor: (user) => setCallbackFor(user) || defaultCallback,
232
+ };
233
+ }
234
+ /**
235
+ * Is this user on the LEGACY emergency model — a caller ID set by hand, with no address record behind
236
+ * it and no endpoint provisioned for the number?
237
+ *
238
+ * All three clauses matter. A blank `emergency-address-id` alone is not legacy: a user with BOTH fields
239
+ * blank inherits the domain default address, which is the new model working as designed. And a number
240
+ * that IS an endpoint callback is the new model too — counting it here as well would bill a
241
+ * half-migrated domain twice for one place.
242
+ *
243
+ * ⚠️ **The `em` must come from a snapshot whose endpoints were READ.** The third clause tests against
244
+ * `em.endpointCallbacks`, which is empty both when the domain has no endpoints and when nobody asked
245
+ * for them — so on a snapshot fetched without `includeAddresses` this answers "legacy" for every user
246
+ * on a fully-migrated domain. {@link listDomainInventory} refuses to derive the list at all in that
247
+ * state; a caller using this predicate directly has to make the same check.
248
+ */
249
+ export function legacyEmergencyNumber(user, em) {
250
+ if (str(user['emergency-address-id']))
251
+ return '';
252
+ const n = em.setCallbackFor(user);
253
+ return n && !em.endpointCallbacks.has(n) ? n : '';
254
+ }
129
255
  /**
130
256
  * Where a phone number routes, in words a person reads at a glance — not the raw NetSapiens dial
131
257
  * rule fields. Pure; looks the destination user up in `userByExt` ({@link usersByExt}) so it can
@@ -198,6 +324,8 @@ export function listDomainInventory(snapshot, opts) {
198
324
  const smsnumbers = Array.isArray(snapshot.smsnumbers) ? snapshot.smsnumbers : [];
199
325
  const extensions = [];
200
326
  const systemUsers = [];
327
+ // Normalised once, not per extension: the legend is the caller's and does not change mid-fold.
328
+ const legend = suffixLegend(opts);
201
329
  for (let i = 0; i < users.length; i++) {
202
330
  const u = users[i];
203
331
  const ext = str(u.user);
@@ -207,7 +335,7 @@ export function listDomainInventory(snapshot, opts) {
207
335
  // from a backup or a fixture. Dropping it would read as a clean match on a domain that has
208
336
  // handsets nobody can see; two blank users sharing one list overcount instead, which is a
209
337
  // visible drift an operator investigates, and that is the failure worth having.
210
- const item = extensionItem(u, devicesByUser[ext] ?? []);
338
+ const item = extensionItem(u, devicesByUser[ext] ?? [], legend);
211
339
  (isSystemUser(u) ? systemUsers : extensions).push(item);
212
340
  }
213
341
  const userByExt = usersByExt(users);
@@ -237,11 +365,51 @@ export function listDomainInventory(snapshot, opts) {
237
365
  label: label || id || `(address ${i + 1})`,
238
366
  };
239
367
  });
368
+ // The two E911 lists share one resolution of the domain's inheritance — see `resolveEmergency`.
369
+ const em = resolveEmergency(snapshot);
370
+ const endpoints = Array.isArray(snapshot.addressEndpoints) ? snapshot.addressEndpoints : [];
371
+ const e911Endpoints = endpoints.map((e) => {
372
+ // NB: `emergency-address-id` on an ENDPOINT record is the callback NUMBER. See `Snapshot`.
373
+ const callback = emergencyDigits(e['emergency-address-id']);
374
+ const callerName = str(e['caller-name']);
375
+ const line1 = str(e['address-line-1']);
376
+ const city = str(e['address-city']);
377
+ return {
378
+ key: identityKey('e911', callback, `${callerName} ${line1} ${city}`),
379
+ callback,
380
+ callerName,
381
+ billingAddress: [line1, city].filter(Boolean).join(', '),
382
+ // `count-users-configured` and NOT `sub_count_total`: the two disagree on live records (a
383
+ // captured endpoint had 0 and 16), and only the first one names what it counts.
384
+ users: Number(e['count-users-configured'] ?? 0) || 0,
385
+ };
386
+ });
387
+ // Legacy numbers are DERIVED — there is no record to map over. One entry per distinct number, in the
388
+ // order the users first name it, so the list does not reshuffle between two reads of one domain.
389
+ //
390
+ // ⚠️ ONLY when the endpoint list was actually READ. `snapshot.addressEndpoints` is `undefined` when
391
+ // the fetch never asked for it and `[]` when it asked and the domain has none, and the difference
392
+ // decides whether this list can exist at all: the legacy test excludes numbers that are already
393
+ // endpoint callbacks, and with no endpoint list there is nothing to exclude against — so a domain
394
+ // fully on the ENDPOINT model, read with `includeAddresses` off, would report every distinct
395
+ // emergency caller ID as a legacy line the carrier bills for. `e911Addresses` answering 0 in that
396
+ // state is a safe under-count; this answering N is a confident over-count that looks like real data.
397
+ const legacyUsers = new Map();
398
+ if (Array.isArray(snapshot.addressEndpoints)) {
399
+ for (const u of users) {
400
+ if (isSystemUser(u))
401
+ continue;
402
+ const n = legacyEmergencyNumber(u, em);
403
+ if (n)
404
+ legacyUsers.set(n, (legacyUsers.get(n) ?? 0) + 1);
405
+ }
406
+ }
407
+ const e911Legacy = [...legacyUsers].map(([number, count]) => ({ key: `e911legacy:${number}`, number, users: count }));
240
408
  const smsNumbers = smsnumbers.map((s) => {
241
409
  const number = str(s.number);
242
410
  return { key: identityKey('sms', number, JSON.stringify({ number })), number };
243
411
  });
244
- return { extensions, systemUsers, dids, e911Addresses, smsNumbers };
412
+ return { extensions, systemUsers, dids, e911Addresses, e911Endpoints, e911Legacy, smsNumbers };
245
413
  }
246
414
  /** The counts, as a fold over {@link listDomainInventory} so the two can never disagree. */
247
415
  export function countDomainInventory(snapshot, opts) {
@@ -259,6 +427,11 @@ export function countInventoryDetail(d) {
259
427
  teamsConnected: 0,
260
428
  dids: { total: 0, tollFree: 0, local: 0, fax: 0, all: d.dids.length },
261
429
  e911Addresses: d.e911Addresses.length,
430
+ // `?? []` on the two newest lists alone: a detail object cached or serialised by a consumer running
431
+ // an older version of this library has neither field, and a count that threw on it would take out a
432
+ // whole page over a dimension that did not exist when the entry was written.
433
+ e911Endpoints: (d.e911Endpoints ?? []).length,
434
+ e911Legacy: (d.e911Legacy ?? []).length,
262
435
  smsNumbers: d.smsNumbers.length,
263
436
  devices: { total: 0, byModel: {} },
264
437
  };
@@ -344,6 +517,10 @@ export function itemsFor(detail, path) {
344
517
  return detail.dids;
345
518
  if (path === 'e911Addresses')
346
519
  return detail.e911Addresses;
520
+ if (path === 'e911Endpoints')
521
+ return detail.e911Endpoints ?? [];
522
+ if (path === 'e911Legacy')
523
+ return detail.e911Legacy ?? [];
347
524
  if (path === 'smsNumbers')
348
525
  return detail.smsNumbers;
349
526
  return undefined;
@@ -358,5 +535,22 @@ export function itemLabel(item) {
358
535
  return item.kind === 'tollFree' ? `${item.number} (toll-free)` : item.number;
359
536
  if ('label' in item)
360
537
  return item.label;
538
+ // An ENDPOINT is named by the number the carrier bills, then by who it announces and where it sends
539
+ // responders. Either half is dropped when blank rather than printed against a dangling dash, and a
540
+ // record with NEITHER falls back to its derived key — the same shape an address with nothing to name
541
+ // it by gets, except the id here is the key rather than a position, so two blank-callback endpoints
542
+ // stay apart. Never the empty string: this label is what a consumer writes into its acceptance
543
+ // history, and a row that cannot name its own item is worse than an ugly one.
544
+ if ('callback' in item) {
545
+ const who = [item.callerName, item.billingAddress].filter(Boolean).join(', ');
546
+ if (item.callback)
547
+ return who ? `${item.callback} — ${who}` : item.callback;
548
+ return who || `(endpoint ${item.key.slice('e911:'.length)})`;
549
+ }
550
+ // A LEGACY number says so on its own line: it looks like a DID, and nothing else on the page would
551
+ // tell a reader why a bare number is sitting on an E911 row. Singular is written out: a label that
552
+ // does not agree with itself reads as a rendering fault, and this one is frozen into history rows.
553
+ if ('users' in item)
554
+ return `${item.number} — legacy E911 (${item.users} user${item.users === 1 ? '' : 's'})`;
361
555
  return item.number;
362
556
  }