@dszp/netsapiens-lib 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -6
- package/dist/attribution.d.ts.map +1 -1
- package/dist/attribution.js +65 -28
- package/dist/attribution.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/inventory.d.ts +149 -2
- package/dist/inventory.d.ts.map +1 -1
- package/dist/inventory.js +143 -1
- package/dist/inventory.js.map +1 -1
- package/dist/model.d.ts +13 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/nsClient.d.ts +8 -2
- package/dist/nsClient.d.ts.map +1 -1
- package/dist/nsClient.js +6 -1
- package/dist/nsClient.js.map +1 -1
- package/package.json +1 -1
- package/src/attribution.selftest.ts +75 -0
- package/src/attribution.ts +60 -18
- package/src/index.ts +1 -1
- package/src/inventory.selftest.ts +187 -1
- package/src/inventory.ts +263 -3
- package/src/model.ts +13 -0
- package/src/nsClient.ts +14 -3
package/src/index.ts
CHANGED
|
@@ -37,7 +37,7 @@ export {
|
|
|
37
37
|
} from './html.js';
|
|
38
38
|
export { resolveSvgSize, rasterizerScript } from './raster.js';
|
|
39
39
|
export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray, type NsClientConfig, type FetchSnapshotOptions } from './nsClient.js';
|
|
40
|
-
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';
|
|
40
|
+
export { countDomainInventory, countInventoryDetail, listDomainInventory, itemsFor, itemLabel, destinationOf, emergencyDigits, legacyEmergencyNumber, resolveEmergency, usersByExt, DEFAULT_DEVICE_SUFFIXES, type DeviceSuffixLegend, type DomainInventory, type DomainInventoryDetail, type EmergencyModel, type InventoryOptions, type ExtensionItem, type NumberItem, type AddressItem, type EndpointItem, type LegacyE911Item, type SmsItem, type InventoryItem } from './inventory.js';
|
|
41
41
|
export { attributeDomainInventory, type DomainAttribution, type ItemAttribution } from './attribution.js';
|
|
42
42
|
export { NsWriteClient, type NsWriteClientConfig } from './nsWriteClient.js';
|
|
43
43
|
export {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Offline test for the domain inventory counter. pnpm test:inventory */
|
|
2
|
-
import { countDomainInventory, listDomainInventory, itemsFor, itemLabel, destinationOf, usersByExt, DEFAULT_DEVICE_SUFFIXES } from './inventory.js';
|
|
2
|
+
import { countDomainInventory, emergencyDigits, legacyEmergencyNumber, listDomainInventory, itemsFor, itemLabel, destinationOf, resolveEmergency, usersByExt, DEFAULT_DEVICE_SUFFIXES } from './inventory.js';
|
|
3
3
|
import type { Rec, Snapshot } from './model.js';
|
|
4
4
|
|
|
5
5
|
let pass = 0, fail = 0;
|
|
@@ -474,5 +474,191 @@ ok(sysDev.devices.total === 0, 'a system user device is not counted');
|
|
|
474
474
|
'[suffix] the exported default marks only t as the Teams connector');
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
+
// ── E911: endpoints bill, addresses locate, legacy numbers are derived ──────────────────────────────
|
|
478
|
+
{
|
|
479
|
+
// One domain default address, one endpoint bound to it by name, and a second endpoint that nothing
|
|
480
|
+
// defaults to. `emergency-address-id` on an ENDPOINT is the callback number; on an ADDRESS it is the
|
|
481
|
+
// `a-…` id — the same field name meaning two things is the trap this fixture pins down.
|
|
482
|
+
const e911: Snapshot = {
|
|
483
|
+
meta: { domain: 'acme.example' },
|
|
484
|
+
users: [
|
|
485
|
+
{ user: '100', 'service-code': '', site: 'North', 'emergency-address-id': 'a-1', 'caller-id-number-emergency': '3175550100' },
|
|
486
|
+
// Eleven digits where the endpoint says ten — the same endpoint, and the point of `emergencyDigits`.
|
|
487
|
+
{ user: '101', 'service-code': '', site: 'North', 'emergency-address-id': 'a-2', 'caller-id-number-emergency': '13175550101' },
|
|
488
|
+
// Both fields blank: inherits the domain default address AND its endpoint. Not legacy.
|
|
489
|
+
{ user: '102', 'service-code': '', site: 'South' },
|
|
490
|
+
// The wildcard is "not set", not a value.
|
|
491
|
+
{ user: '103', 'service-code': '', site: 'South', 'caller-id-number-emergency': '[*]' },
|
|
492
|
+
{ user: '700', 'service-code': 'system-aa', 'caller-id-number-emergency': '3175559999' },
|
|
493
|
+
],
|
|
494
|
+
addresses: [
|
|
495
|
+
{ 'emergency-address-id': 'a-1', 'address-name': 'HQ', 'address-line-1': '1 Main St', 'address-city': 'Springfield', domain_default: true },
|
|
496
|
+
{ 'emergency-address-id': 'a-2', 'address-name': 'Annex', 'address-line-1': '2 Side St', 'address-city': 'Springfield' },
|
|
497
|
+
],
|
|
498
|
+
addressEndpoints: [
|
|
499
|
+
{ 'emergency-address-id': '3175550100', 'address-name': 'HQ', 'caller-name': 'Acme HQ', 'address-line-1': '1 Main St', 'address-city': 'Springfield', 'count-users-configured': 3, sub_count_total: 9 },
|
|
500
|
+
{ 'emergency-address-id': '3175550101', 'address-name': 'Annex', 'caller-name': '', 'address-line-1': '', 'address-city': '' },
|
|
501
|
+
],
|
|
502
|
+
} as Snapshot;
|
|
503
|
+
|
|
504
|
+
const inv = countDomainInventory(e911);
|
|
505
|
+
const d = listDomainInventory(e911);
|
|
506
|
+
ok(inv.e911Endpoints === 2, '[e911] two provisioned endpoints');
|
|
507
|
+
ok(inv.e911Addresses === 2, '[e911] and the two addresses are still counted, as information');
|
|
508
|
+
ok(inv.e911Legacy === 0, '[e911] a domain on the endpoint model has no legacy numbers');
|
|
509
|
+
|
|
510
|
+
const hq = d.e911Endpoints[0]!;
|
|
511
|
+
ok(hq.key === 'e911:3175550100', '[e911] the key is the callback, taken from the endpoint’s emergency-address-id');
|
|
512
|
+
ok(hq.callback === '3175550100' && hq.callerName === 'Acme HQ', '[e911] the callback and the caller name come off the record');
|
|
513
|
+
ok(hq.billingAddress === '1 Main St, Springfield', '[e911] the billing address is one line, no wider than the address list');
|
|
514
|
+
ok(hq.users === 3, '[e911] users is count-users-configured, not sub_count_total');
|
|
515
|
+
ok(itemLabel(hq) === '3175550100 — Acme HQ, 1 Main St, Springfield', '[e911] the label names the number, then who and where');
|
|
516
|
+
ok(itemLabel(d.e911Endpoints[1]!) === '3175550101', '[e911] and an endpoint with neither is just its number — no dangling dash');
|
|
517
|
+
{
|
|
518
|
+
// NO CALLBACK. The label is what `applyAssignment` writes into acceptance history, so a leading
|
|
519
|
+
// dash is bad and an empty string is worse — it records a decision about a thing it cannot name.
|
|
520
|
+
const [named, blank] = listDomainInventory({ ...e911, addressEndpoints: [
|
|
521
|
+
{ 'caller-name': 'Acme Dock', 'address-line-1': '9 Dock Rd', 'address-city': 'Springfield' },
|
|
522
|
+
{ 'address-name': 'Nothing' },
|
|
523
|
+
] } as Snapshot).e911Endpoints;
|
|
524
|
+
ok(itemLabel(named!) === 'Acme Dock, 9 Dock Rd, Springfield', '[e911] an endpoint with no callback leads with what it does have, never a dash');
|
|
525
|
+
ok(itemLabel(blank!) === `(endpoint ${blank!.key.slice('e911:'.length)})`,
|
|
526
|
+
'[e911] and one with nothing at all is named by its derived key - never the empty string');
|
|
527
|
+
ok(itemLabel(blank!) !== '', '[e911] which is the fact that matters: an acceptance row can always name its item');
|
|
528
|
+
}
|
|
529
|
+
ok(itemsFor(d, 'e911Endpoints')!.length === 2, '[e911] itemsFor answers the endpoints path');
|
|
530
|
+
ok(itemsFor(d, 'e911Addresses')!.length === 2, '[e911] and the addresses path still answers separately');
|
|
531
|
+
|
|
532
|
+
const em = resolveEmergency(e911);
|
|
533
|
+
ok(em.defaultAddressId === 'a-1', '[e911] the domain default is the address flagged domain_default');
|
|
534
|
+
ok(em.defaultCallback === '3175550100', '[e911] whose callback is joined through the endpoint naming the same address');
|
|
535
|
+
ok(em.addressIdFor(e911.users![2]!) === 'a-1', '[e911] a user with a blank address id inherits the domain default');
|
|
536
|
+
ok(em.addressIdFor(e911.users![1]!) === 'a-2', '[e911] and one that sets its own keeps it');
|
|
537
|
+
ok(em.callbackFor(e911.users![2]!) === '3175550100', '[e911] a user with a blank caller ID inherits the default address’s callback');
|
|
538
|
+
ok(em.callbackFor(e911.users![1]!) === '3175550101', '[e911] an 11-digit caller ID resolves to the 10-digit endpoint');
|
|
539
|
+
ok(em.setCallbackFor(e911.users![3]!) === '', '[e911] the [*] wildcard is not set');
|
|
540
|
+
ok(em.callbackFor(e911.users![3]!) === '3175550100', '[e911] so that user inherits the default too');
|
|
541
|
+
|
|
542
|
+
// An endpoint whose record names no callback still has to be a distinguishable row.
|
|
543
|
+
const blank = listDomainInventory({ ...e911, addressEndpoints: [{ 'address-name': 'Dock', 'caller-name': 'Acme Dock' }] } as Snapshot).e911Endpoints[0]!;
|
|
544
|
+
ok(blank.key.startsWith('e911:~'), '[e911] an endpoint with no callback falls back to a derived key');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
{
|
|
548
|
+
// A LEGACY domain: no endpoints at all, every user with a blank emergency-address-id and one of two
|
|
549
|
+
// numbers set by hand. Measured shape — a 100-user domain with exactly two such numbers.
|
|
550
|
+
const legacy: Snapshot = {
|
|
551
|
+
meta: { domain: 'demo.12345.service' },
|
|
552
|
+
users: [
|
|
553
|
+
{ user: '100', 'service-code': '', site: 'North', 'caller-id-number-emergency': '3175550200' },
|
|
554
|
+
{ user: '101', 'service-code': '', site: 'North', 'caller-id-number-emergency': '13175550200' },
|
|
555
|
+
{ user: '102', 'service-code': '', site: 'South', 'caller-id-number-emergency': '3175550201' },
|
|
556
|
+
// Nothing set anywhere and no domain default to inherit: not legacy, and not anything else.
|
|
557
|
+
{ user: '103', 'service-code': '', site: 'South' },
|
|
558
|
+
// On its device rather than on the user.
|
|
559
|
+
{ user: '104', 'service-code': '', site: 'South' },
|
|
560
|
+
// A system user is not a seat and does not make a number legacy.
|
|
561
|
+
{ user: '700', 'service-code': 'system-queue', 'caller-id-number-emergency': '3175550299' },
|
|
562
|
+
],
|
|
563
|
+
devicesByUser: { '104': [{ device: 'sip:104@demo.12345.service', 'caller-id-number-emergency': '3175550201' }] },
|
|
564
|
+
// READ, and there are none — which is what a legacy domain looks like. An ABSENT list means the
|
|
565
|
+
// fetch never asked, and then no legacy count is derivable at all; that pair is tested below.
|
|
566
|
+
addressEndpoints: [],
|
|
567
|
+
} as Snapshot;
|
|
568
|
+
|
|
569
|
+
const inv = countDomainInventory(legacy);
|
|
570
|
+
const d = listDomainInventory(legacy);
|
|
571
|
+
ok(inv.e911Legacy === 2, '[legacy] two DISTINCT numbers across five seats, matching the carrier’s two lines');
|
|
572
|
+
ok(inv.e911Endpoints === 0 && inv.e911Addresses === 0, '[legacy] and no endpoints or addresses to count');
|
|
573
|
+
ok(d.e911Legacy.map((x) => x.key).join() === 'e911legacy:3175550200,e911legacy:3175550201', '[legacy] keyed by the digits');
|
|
574
|
+
ok(d.e911Legacy[0]!.users === 2, '[legacy] the 10- and 11-digit spellings are one number');
|
|
575
|
+
ok(d.e911Legacy[1]!.users === 2, '[legacy] and a device’s number counts when the user sets none');
|
|
576
|
+
ok(itemLabel(d.e911Legacy[0]!) === '3175550200 — legacy E911 (2 users)', '[legacy] the label says why a bare number is on an E911 row');
|
|
577
|
+
ok(itemLabel({ key: 'e911legacy:3175550299', number: '3175550299', users: 1 }) === '3175550299 — legacy E911 (1 user)',
|
|
578
|
+
'[legacy] and it agrees with itself on one — a label that reads "(1 users)" reads as a rendering fault');
|
|
579
|
+
ok(itemsFor(d, 'e911Legacy')!.length === 2, '[legacy] itemsFor answers the legacy path');
|
|
580
|
+
|
|
581
|
+
// HALF-MIGRATED: one of the two numbers is now a provisioned endpoint. It must be counted once, as an
|
|
582
|
+
// endpoint, or the domain pays for the same place twice.
|
|
583
|
+
const half = { ...legacy, addressEndpoints: [{ 'emergency-address-id': '3175550200', 'address-name': 'HQ', 'caller-name': 'Demo HQ' }] } as Snapshot;
|
|
584
|
+
const hi = countDomainInventory(half);
|
|
585
|
+
ok(hi.e911Endpoints === 1 && hi.e911Legacy === 1, '[legacy] a number that became an endpoint leaves the legacy count');
|
|
586
|
+
ok(listDomainInventory(half).e911Legacy[0]!.number === '3175550201', '[legacy] and the one still on the old model stays');
|
|
587
|
+
|
|
588
|
+
// A user with BOTH fields blank on a domain that HAS a default is on the new model, not the old one.
|
|
589
|
+
const withDefault = {
|
|
590
|
+
...legacy,
|
|
591
|
+
users: [{ user: '110', 'service-code': '', site: 'North' }],
|
|
592
|
+
devicesByUser: {},
|
|
593
|
+
addresses: [{ 'emergency-address-id': 'a-9', 'address-name': 'HQ', domain_default: true }],
|
|
594
|
+
addressEndpoints: [{ 'emergency-address-id': '3175550300', 'address-name': 'HQ', 'caller-name': 'Demo HQ' }],
|
|
595
|
+
} as Snapshot;
|
|
596
|
+
ok(countDomainInventory(withDefault).e911Legacy === 0, '[legacy] both fields blank is the domain default, not a legacy number');
|
|
597
|
+
const em = resolveEmergency(withDefault);
|
|
598
|
+
ok(legacyEmergencyNumber(withDefault.users![0]!, em) === '', '[legacy] and the predicate says so directly');
|
|
599
|
+
|
|
600
|
+
// A user who SETS an emergency address is on the new model whatever their caller ID says — even when
|
|
601
|
+
// that number matches no endpoint this snapshot can see. Without the address-id clause of
|
|
602
|
+
// `legacyEmergencyNumber` this reads as a legacy line, and the account is billed for one.
|
|
603
|
+
const addressed = {
|
|
604
|
+
...legacy,
|
|
605
|
+
users: [{ user: '120', 'service-code': '', site: 'North', 'emergency-address-id': 'a-9', 'caller-id-number-emergency': '3175550777' }],
|
|
606
|
+
devicesByUser: {},
|
|
607
|
+
addresses: [{ 'emergency-address-id': 'a-9', 'address-name': 'HQ', domain_default: true }],
|
|
608
|
+
addressEndpoints: [{ 'emergency-address-id': '3175550300', 'address-name': 'HQ', 'caller-name': 'Demo HQ' }],
|
|
609
|
+
} as Snapshot;
|
|
610
|
+
ok(countDomainInventory(addressed).e911Legacy === 0,
|
|
611
|
+
'[legacy] a user with a SET emergency address is never legacy, whatever its caller ID matches');
|
|
612
|
+
ok(legacyEmergencyNumber(addressed.users![0]!, resolveEmergency(addressed)) === '',
|
|
613
|
+
'[legacy] and the predicate refuses it on the address id alone, not on the number');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
{
|
|
617
|
+
// ── the USER's own caller ID wins over its devices' ────────────────────────────────────────────
|
|
618
|
+
// The device is consulted only where the user sets nothing. Reversing the two would attribute the
|
|
619
|
+
// seat to whichever handset happened to be first in the record, which is not what the portal does.
|
|
620
|
+
const both = {
|
|
621
|
+
meta: { domain: 'acme.example' },
|
|
622
|
+
users: [{ user: '100', 'service-code': '', 'caller-id-number-emergency': '3175550100' }],
|
|
623
|
+
devicesByUser: { '100': [{ device: 'sip:100@acme.example', 'caller-id-number-emergency': '3175550101' }] },
|
|
624
|
+
addressEndpoints: [],
|
|
625
|
+
} as Snapshot;
|
|
626
|
+
ok(resolveEmergency(both).setCallbackFor(both.users![0]!) === '3175550100',
|
|
627
|
+
'[e911] a user carrying its own caller ID wins over a device carrying a different one');
|
|
628
|
+
const deviceOnly = { ...both, users: [{ user: '100', 'service-code': '' }] } as Snapshot;
|
|
629
|
+
ok(resolveEmergency(deviceOnly).setCallbackFor(deviceOnly.users![0]!) === '3175550101',
|
|
630
|
+
'[e911] and the device is read only where the user sets nothing');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
{
|
|
634
|
+
// ── the ENDPOINTS list was never READ ──────────────────────────────────────────────────────────
|
|
635
|
+
// `undefined` (the fetch never asked) and `[]` (asked, and the domain has none) are different facts,
|
|
636
|
+
// and only the second one can support a legacy count: without the endpoint list there is nothing to
|
|
637
|
+
// exclude against, so every emergency caller ID on a fully-migrated domain would read as a legacy
|
|
638
|
+
// line. An under-count is safe here; a confident over-count on a billing page is not.
|
|
639
|
+
const users = [
|
|
640
|
+
{ user: '100', 'service-code': '', site: 'North', 'emergency-address-id': '', 'caller-id-number-emergency': '3175550100' },
|
|
641
|
+
{ user: '101', 'service-code': '', site: 'North', 'emergency-address-id': '', 'caller-id-number-emergency': '3175550101' },
|
|
642
|
+
];
|
|
643
|
+
const never = { meta: { domain: 'acme.example' }, users } as Snapshot;
|
|
644
|
+
const read = { ...never, addressEndpoints: [] } as Snapshot;
|
|
645
|
+
const provisioned = { ...never, addressEndpoints: [
|
|
646
|
+
{ 'emergency-address-id': '3175550100', 'address-name': 'HQ', 'caller-name': 'Acme HQ' },
|
|
647
|
+
{ 'emergency-address-id': '3175550101', 'address-name': 'Annex', 'caller-name': 'Acme Annex' },
|
|
648
|
+
] } as Snapshot;
|
|
649
|
+
ok(countDomainInventory(never).e911Legacy === 0 && listDomainInventory(never).e911Legacy.length === 0,
|
|
650
|
+
'[legacy] no endpoint read at all answers 0 legacy numbers rather than inventing two');
|
|
651
|
+
ok(countDomainInventory(read).e911Legacy === 2,
|
|
652
|
+
'[legacy] while an endpoint list that was read and is EMPTY is a real legacy domain');
|
|
653
|
+
ok(countDomainInventory(provisioned).e911Legacy === 0 && countDomainInventory(provisioned).e911Endpoints === 2,
|
|
654
|
+
'[legacy] and the same two users on the endpoint model are two endpoints and no legacy numbers');
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
ok(emergencyDigits('+1 (317) 555-0100') === '3175550100' && emergencyDigits('13175550100') === '3175550100',
|
|
658
|
+
'[e911] a caller ID normalises to ten digits however it is punctuated');
|
|
659
|
+
ok(emergencyDigits('[*]') === '' && emergencyDigits('') === '' && emergencyDigits(undefined) === '',
|
|
660
|
+
'[e911] and the three ways of saying "not set" all answer empty');
|
|
661
|
+
|
|
662
|
+
|
|
477
663
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
478
664
|
if (fail) process.exit(1);
|
package/src/inventory.ts
CHANGED
|
@@ -38,6 +38,35 @@
|
|
|
38
38
|
* case-insensitively, and on nothing else — not the `dial-rule-description`, which is a portal-written
|
|
39
39
|
* note an operator can edit. **With no hosts supplied nothing is a fax line**, because a library that
|
|
40
40
|
* hardcoded one deployment's fax server would be wrong everywhere else.
|
|
41
|
+
*
|
|
42
|
+
* ## E911: the ENDPOINT is the billable unit, the address is a location
|
|
43
|
+
*
|
|
44
|
+
* An **Emergency Endpoint** is a callback number, a caller name, a billing address and a vendor. It is
|
|
45
|
+
* what the E911 carrier routes on and what it bills per, and it is counted as `e911Endpoints`. An
|
|
46
|
+
* **Emergency Address** is a dispatchable location forwarded to responders; several of them can sit
|
|
47
|
+
* under one endpoint, and nobody bills them. `e911Addresses` stays, as information.
|
|
48
|
+
*
|
|
49
|
+
* Users, devices and sites point at an endpoint through their Emergency Caller ID
|
|
50
|
+
* (`caller-id-number-emergency`) matching the endpoint's callback number. This library then INFERS two
|
|
51
|
+
* fallbacks for a blank field — a blank `emergency-address-id` reads as the domain default address, and
|
|
52
|
+
* a blank caller ID reads as that address's endpoint. **Neither is a measured platform behaviour**; see
|
|
53
|
+
* the ⚠️ on {@link EmergencyModel} for what is known and what is assumed. They live in
|
|
54
|
+
* {@link resolveEmergency} alone, so the counter and `attribution.ts` cannot disagree about them, and
|
|
55
|
+
* they fail closed: nothing to inherit leaves a user referencing nothing rather than referencing a
|
|
56
|
+
* guess.
|
|
57
|
+
*
|
|
58
|
+
* ## Legacy emergency numbers, which have no API object at all
|
|
59
|
+
*
|
|
60
|
+
* A domain still on the legacy provisioning model has no endpoint records. Every one of its users
|
|
61
|
+
* carries an EMPTY `emergency-address-id` and a `caller-id-number-emergency` set to one of a handful of
|
|
62
|
+
* DIDs — and the carrier bills per one of those DIDs, exactly as it bills per endpoint on the new
|
|
63
|
+
* model. `e911Legacy` is therefore the count of DISTINCT such numbers, and a rulebook can count the two
|
|
64
|
+
* dimensions together so one retail E911 line pays for either model.
|
|
65
|
+
*
|
|
66
|
+
* A number that IS an endpoint callback is excluded, because a half-migrated domain that counted it in
|
|
67
|
+
* both dimensions would bill the same place twice. That exclusion is also the precondition: with no
|
|
68
|
+
* endpoint list read there is nothing to exclude against, so `e911Legacy` is 0 whenever
|
|
69
|
+
* `snapshot.addressEndpoints` is `undefined` rather than an array. See {@link DomainInventory.e911Legacy}.
|
|
41
70
|
*/
|
|
42
71
|
import type { Rec, Snapshot } from './model.js';
|
|
43
72
|
|
|
@@ -80,8 +109,25 @@ export interface DomainInventory {
|
|
|
80
109
|
* numbers this returned before 0.7.0, unchanged.
|
|
81
110
|
*/
|
|
82
111
|
dids: { total: number; tollFree: number; local: number; fax: number; all: number };
|
|
83
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* E911 address records on the domain — dispatchable LOCATIONS, and information only. The billable
|
|
114
|
+
* unit is {@link DomainInventory.e911Endpoints}; see the module doc.
|
|
115
|
+
*/
|
|
84
116
|
e911Addresses: number;
|
|
117
|
+
/** Provisioned Emergency Endpoints — the unit the E911 carrier bills per. */
|
|
118
|
+
e911Endpoints: number;
|
|
119
|
+
/**
|
|
120
|
+
* Distinct legacy emergency numbers — the pre-endpoint model, which has no API object of its own.
|
|
121
|
+
* Comparable with {@link DomainInventory.e911Endpoints}, and never overlapping it **provided the
|
|
122
|
+
* snapshot carries an endpoint list**: the two are kept apart by excluding numbers that are already
|
|
123
|
+
* endpoint callbacks, which needs the endpoints to have been read.
|
|
124
|
+
*
|
|
125
|
+
* So this is **0 whenever `snapshot.addressEndpoints` is `undefined`** — the fetch never asked, and a
|
|
126
|
+
* count derived from the users alone would report a fully-migrated domain's every emergency caller ID
|
|
127
|
+
* as a line to bill for. An empty ARRAY is the other fact — asked, and the domain has none — and that
|
|
128
|
+
* one does support a count. Fetch with `includeAddresses: true` (see `fetchDomainSnapshot`).
|
|
129
|
+
*/
|
|
130
|
+
e911Legacy: number;
|
|
85
131
|
/** SMS-enabled numbers on the domain. */
|
|
86
132
|
smsNumbers: number;
|
|
87
133
|
/** Devices belonging to real extensions only — a system user's device is not a seat. */
|
|
@@ -154,8 +200,40 @@ export interface NumberItem {
|
|
|
154
200
|
description: string;
|
|
155
201
|
}
|
|
156
202
|
export interface AddressItem { key: string /* addr:<emergency-address-id>, or addr:~<hash> when the id is blank */; label: string }
|
|
203
|
+
/**
|
|
204
|
+
* One provisioned Emergency Endpoint — the thing the E911 carrier bills per.
|
|
205
|
+
*
|
|
206
|
+
* An allowlist like every other item: the callback, the caller name, and the billing address reduced to
|
|
207
|
+
* the ONE line {@link AddressItem} already exposes. The endpoint record also carries a geolocation XML
|
|
208
|
+
* and a public IP, and neither belongs in front of a billing operator.
|
|
209
|
+
*/
|
|
210
|
+
export interface EndpointItem {
|
|
211
|
+
/** `e911:<callback>` — the digits, so a device's 11-digit form and the record's 10-digit form are one
|
|
212
|
+
* key. `e911:~<hash>` when the record names no callback at all. */
|
|
213
|
+
key: string;
|
|
214
|
+
/** The callback number, digits only ({@link emergencyDigits}); `''` when the record has none. */
|
|
215
|
+
callback: string;
|
|
216
|
+
/** `caller-name` — who the carrier announces; `''` when blank. */
|
|
217
|
+
callerName: string;
|
|
218
|
+
/** Street and city, one line — no more of the billing address than the address list already shows. */
|
|
219
|
+
billingAddress: string;
|
|
220
|
+
/** How many users the RECORD says are configured on it (`count-users-configured`), 0 when it says nothing. */
|
|
221
|
+
users: number;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* One legacy emergency number: a `caller-id-number-emergency` in use on a domain that has no endpoint
|
|
225
|
+
* records for it. Derived from the users, because the legacy model has no object of its own to read.
|
|
226
|
+
*/
|
|
227
|
+
export interface LegacyE911Item {
|
|
228
|
+
/** `e911legacy:<digits>`. Never a hash fallback — a blank number is not one of these. */
|
|
229
|
+
key: string;
|
|
230
|
+
/** The number, digits only ({@link emergencyDigits}). */
|
|
231
|
+
number: string;
|
|
232
|
+
/** How many real extensions reference it — counted here, not read off a record. */
|
|
233
|
+
users: number;
|
|
234
|
+
}
|
|
157
235
|
export interface SmsItem { key: string /* sms:<number>, or sms:~<hash> when the number is blank */; number: string }
|
|
158
|
-
export type InventoryItem = ExtensionItem | NumberItem | AddressItem | SmsItem;
|
|
236
|
+
export type InventoryItem = ExtensionItem | NumberItem | AddressItem | EndpointItem | LegacyE911Item | SmsItem;
|
|
159
237
|
|
|
160
238
|
export interface DomainInventoryDetail {
|
|
161
239
|
/** Real seats only, same rule as the count. */
|
|
@@ -164,6 +242,8 @@ export interface DomainInventoryDetail {
|
|
|
164
242
|
systemUsers: ExtensionItem[];
|
|
165
243
|
dids: NumberItem[];
|
|
166
244
|
e911Addresses: AddressItem[];
|
|
245
|
+
e911Endpoints: EndpointItem[];
|
|
246
|
+
e911Legacy: LegacyE911Item[];
|
|
167
247
|
smsNumbers: SmsItem[];
|
|
168
248
|
}
|
|
169
249
|
|
|
@@ -279,6 +359,25 @@ function isFaxLine(p: Rec, hosts: Set<string>): boolean {
|
|
|
279
359
|
return hosts.has(str(p['dial-rule-translation-destination-host']).toLowerCase());
|
|
280
360
|
}
|
|
281
361
|
|
|
362
|
+
/**
|
|
363
|
+
* An Emergency Caller ID reduced to what two records can be compared on: its digits, with an 11-digit
|
|
364
|
+
* `1NXXNXXXXXX` collapsed to its 10-digit form so a device's spelling matches an endpoint's.
|
|
365
|
+
*
|
|
366
|
+
* `''` for "not set", which the API says three ways: empty, absent, and the `[*]` wildcard the portal
|
|
367
|
+
* renders as "Select a Caller ID for 911 calls". Treating `[*]` as a value would give every unset
|
|
368
|
+
* device on a domain one shared fake endpoint.
|
|
369
|
+
*/
|
|
370
|
+
export function emergencyDigits(v: unknown): string {
|
|
371
|
+
const raw = str(v);
|
|
372
|
+
if (!raw || raw === '[*]') return '';
|
|
373
|
+
const digits = raw.replace(/\D+/g, '');
|
|
374
|
+
if (!digits) return '';
|
|
375
|
+
return digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** A NetSapiens boolean, which arrives as a JSON `true` from one endpoint and as `"yes"` from another. */
|
|
379
|
+
const flag = (v: unknown): boolean => v === true || ['yes', 'true', '1'].includes(str(v).toLowerCase());
|
|
380
|
+
|
|
282
381
|
/**
|
|
283
382
|
* A device's NAME — the local part of its SIP URI (`sip:103t@acme.example` → `103t`), which is the short
|
|
284
383
|
* id the portal shows and the string the Teams test matches against.
|
|
@@ -380,6 +479,107 @@ export function usersByExt(users: Rec[]): Map<string, Rec> {
|
|
|
380
479
|
return map;
|
|
381
480
|
}
|
|
382
481
|
|
|
482
|
+
/**
|
|
483
|
+
* How a domain's E911 records join up, and the two INHERITANCES this library reads into a blank field.
|
|
484
|
+
*
|
|
485
|
+
* Both the counter and `attribution.ts` need to answer "which endpoint does this user reference?" and
|
|
486
|
+
* "which address?". Resolved in one place so the count and the site attribution cannot disagree about
|
|
487
|
+
* who references what.
|
|
488
|
+
*
|
|
489
|
+
* ⚠️ **Both inheritances are this library's inference, not a measured platform behaviour.** Two
|
|
490
|
+
* separate assumptions sit under them, and a consumer relying on the placement should know which:
|
|
491
|
+
*
|
|
492
|
+
* 1. **That a blank field falls back to the domain default at all.** A user with a blank
|
|
493
|
+
* `caller-id-number-emergency` is read here as referencing the default address's endpoint, and one
|
|
494
|
+
* with a blank `emergency-address-id` as referencing the default address. That is a plausible
|
|
495
|
+
* reading of a record the portal badges "Domain Default" — but it has not been confirmed against a
|
|
496
|
+
* live 911 call, and the same state can be read as an E911 GAP rather than an inheritance.
|
|
497
|
+
* 2. **That the default address's callback is joined by `address-name`.** An address record carries no
|
|
498
|
+
* callback field of its own (checked against a live domain and 34 captured snapshots), so the only
|
|
499
|
+
* thing tying the domain default to an endpoint is that the endpoint names the same address. Every
|
|
500
|
+
* captured domain carrying both agreed on that name.
|
|
501
|
+
*
|
|
502
|
+
* Both fail CLOSED. No default address, no endpoint naming it, or a name that does not match, and
|
|
503
|
+
* `defaultCallback` is `''` — the users who would have inherited it reference nothing and are left
|
|
504
|
+
* unattributed, rather than being attached to a guess. The COUNTS are unaffected either way
|
|
505
|
+
* (`e911Endpoints` is a record count, and a user with both fields blank is correctly not legacy); what
|
|
506
|
+
* these assumptions move is PLACEMENT, which on a split domain decides which accounts are told they
|
|
507
|
+
* need an E911 line.
|
|
508
|
+
*/
|
|
509
|
+
export interface EmergencyModel {
|
|
510
|
+
/** The `emergency-address-id` of the record marked `domain_default`; `''` when the domain has none. */
|
|
511
|
+
defaultAddressId: string;
|
|
512
|
+
/** The callback of the endpoint bound to the DEFAULT address, digits only; `''` when there is none. */
|
|
513
|
+
defaultCallback: string;
|
|
514
|
+
/** Every provisioned endpoint's callback, digits only — the "is this number already an endpoint?" test. */
|
|
515
|
+
endpointCallbacks: Set<string>;
|
|
516
|
+
/** Which address a user references: their own field, the domain default when it is blank. */
|
|
517
|
+
addressIdFor: (user: Rec) => string;
|
|
518
|
+
/** The callback a user SETS: their own field, else any of their devices'; `''` when neither does. */
|
|
519
|
+
setCallbackFor: (user: Rec) => string;
|
|
520
|
+
/** Which endpoint a user references: {@link EmergencyModel.setCallbackFor}, else the domain default's. */
|
|
521
|
+
callbackFor: (user: Rec) => string;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function resolveEmergency(snapshot: Snapshot): EmergencyModel {
|
|
525
|
+
const addresses: Rec[] = Array.isArray(snapshot.addresses) ? snapshot.addresses : [];
|
|
526
|
+
const endpoints: Rec[] = Array.isArray(snapshot.addressEndpoints) ? snapshot.addressEndpoints : [];
|
|
527
|
+
const devicesByUser: Record<string, Rec[]> = (snapshot.devicesByUser ?? {}) as Record<string, Rec[]>;
|
|
528
|
+
|
|
529
|
+
// First one wins. Two records marked default is a provisioning fault, and picking one of them
|
|
530
|
+
// silently is better than resolving every blank user to nothing because two records disagree.
|
|
531
|
+
const def = addresses.find((a) => flag(a['domain_default']));
|
|
532
|
+
const defaultAddressId = def ? str(def['emergency-address-id']) : '';
|
|
533
|
+
const defName = def ? str(def['address-name']).toLowerCase() : '';
|
|
534
|
+
const boundToDefault = defName ? endpoints.find((e) => str(e['address-name']).toLowerCase() === defName) : undefined;
|
|
535
|
+
// NB: on an ENDPOINT record `emergency-address-id` holds the callback NUMBER, not an address id.
|
|
536
|
+
const defaultCallback = boundToDefault ? emergencyDigits(boundToDefault['emergency-address-id']) : '';
|
|
537
|
+
const endpointCallbacks = new Set(endpoints.map((e) => emergencyDigits(e['emergency-address-id'])).filter(Boolean));
|
|
538
|
+
|
|
539
|
+
const setCallbackFor = (user: Rec): string => {
|
|
540
|
+
const own = emergencyDigits(user['caller-id-number-emergency']);
|
|
541
|
+
if (own) return own;
|
|
542
|
+
// A user who sets none can still have a handset that does — the portal reads the device's own
|
|
543
|
+
// setting first and only then the user's, so a domain whose numbers live on the devices is
|
|
544
|
+
// invisible to a rule that reads the user record alone.
|
|
545
|
+
for (const d of devicesByUser[str(user.user)] ?? []) {
|
|
546
|
+
const dev = emergencyDigits(d['caller-id-number-emergency']);
|
|
547
|
+
if (dev) return dev;
|
|
548
|
+
}
|
|
549
|
+
return '';
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
defaultAddressId,
|
|
554
|
+
defaultCallback,
|
|
555
|
+
endpointCallbacks,
|
|
556
|
+
addressIdFor: (user) => str(user['emergency-address-id']) || defaultAddressId,
|
|
557
|
+
setCallbackFor,
|
|
558
|
+
callbackFor: (user) => setCallbackFor(user) || defaultCallback,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Is this user on the LEGACY emergency model — a caller ID set by hand, with no address record behind
|
|
564
|
+
* it and no endpoint provisioned for the number?
|
|
565
|
+
*
|
|
566
|
+
* All three clauses matter. A blank `emergency-address-id` alone is not legacy: a user with BOTH fields
|
|
567
|
+
* blank inherits the domain default address, which is the new model working as designed. And a number
|
|
568
|
+
* that IS an endpoint callback is the new model too — counting it here as well would bill a
|
|
569
|
+
* half-migrated domain twice for one place.
|
|
570
|
+
*
|
|
571
|
+
* ⚠️ **The `em` must come from a snapshot whose endpoints were READ.** The third clause tests against
|
|
572
|
+
* `em.endpointCallbacks`, which is empty both when the domain has no endpoints and when nobody asked
|
|
573
|
+
* for them — so on a snapshot fetched without `includeAddresses` this answers "legacy" for every user
|
|
574
|
+
* on a fully-migrated domain. {@link listDomainInventory} refuses to derive the list at all in that
|
|
575
|
+
* state; a caller using this predicate directly has to make the same check.
|
|
576
|
+
*/
|
|
577
|
+
export function legacyEmergencyNumber(user: Rec, em: EmergencyModel): string {
|
|
578
|
+
if (str(user['emergency-address-id'])) return '';
|
|
579
|
+
const n = em.setCallbackFor(user);
|
|
580
|
+
return n && !em.endpointCallbacks.has(n) ? n : '';
|
|
581
|
+
}
|
|
582
|
+
|
|
383
583
|
/**
|
|
384
584
|
* Where a phone number routes, in words a person reads at a glance — not the raw NetSapiens dial
|
|
385
585
|
* rule fields. Pure; looks the destination user up in `userByExt` ({@link usersByExt}) so it can
|
|
@@ -495,11 +695,49 @@ export function listDomainInventory(snapshot: Snapshot, opts?: InventoryOptions)
|
|
|
495
695
|
label: label || id || `(address ${i + 1})`,
|
|
496
696
|
};
|
|
497
697
|
});
|
|
698
|
+
// The two E911 lists share one resolution of the domain's inheritance — see `resolveEmergency`.
|
|
699
|
+
const em = resolveEmergency(snapshot);
|
|
700
|
+
const endpoints: Rec[] = Array.isArray(snapshot.addressEndpoints) ? snapshot.addressEndpoints : [];
|
|
701
|
+
const e911Endpoints: EndpointItem[] = endpoints.map((e) => {
|
|
702
|
+
// NB: `emergency-address-id` on an ENDPOINT record is the callback NUMBER. See `Snapshot`.
|
|
703
|
+
const callback = emergencyDigits(e['emergency-address-id']);
|
|
704
|
+
const callerName = str(e['caller-name']);
|
|
705
|
+
const line1 = str(e['address-line-1']);
|
|
706
|
+
const city = str(e['address-city']);
|
|
707
|
+
return {
|
|
708
|
+
key: identityKey('e911', callback, `${callerName} ${line1} ${city}`),
|
|
709
|
+
callback,
|
|
710
|
+
callerName,
|
|
711
|
+
billingAddress: [line1, city].filter(Boolean).join(', '),
|
|
712
|
+
// `count-users-configured` and NOT `sub_count_total`: the two disagree on live records (a
|
|
713
|
+
// captured endpoint had 0 and 16), and only the first one names what it counts.
|
|
714
|
+
users: Number(e['count-users-configured'] ?? 0) || 0,
|
|
715
|
+
};
|
|
716
|
+
});
|
|
717
|
+
// Legacy numbers are DERIVED — there is no record to map over. One entry per distinct number, in the
|
|
718
|
+
// order the users first name it, so the list does not reshuffle between two reads of one domain.
|
|
719
|
+
//
|
|
720
|
+
// ⚠️ ONLY when the endpoint list was actually READ. `snapshot.addressEndpoints` is `undefined` when
|
|
721
|
+
// the fetch never asked for it and `[]` when it asked and the domain has none, and the difference
|
|
722
|
+
// decides whether this list can exist at all: the legacy test excludes numbers that are already
|
|
723
|
+
// endpoint callbacks, and with no endpoint list there is nothing to exclude against — so a domain
|
|
724
|
+
// fully on the ENDPOINT model, read with `includeAddresses` off, would report every distinct
|
|
725
|
+
// emergency caller ID as a legacy line the carrier bills for. `e911Addresses` answering 0 in that
|
|
726
|
+
// state is a safe under-count; this answering N is a confident over-count that looks like real data.
|
|
727
|
+
const legacyUsers = new Map<string, number>();
|
|
728
|
+
if (Array.isArray(snapshot.addressEndpoints)) {
|
|
729
|
+
for (const u of users) {
|
|
730
|
+
if (isSystemUser(u)) continue;
|
|
731
|
+
const n = legacyEmergencyNumber(u, em);
|
|
732
|
+
if (n) legacyUsers.set(n, (legacyUsers.get(n) ?? 0) + 1);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const e911Legacy: LegacyE911Item[] = [...legacyUsers].map(([number, count]) => ({ key: `e911legacy:${number}`, number, users: count }));
|
|
498
736
|
const smsNumbers: SmsItem[] = smsnumbers.map((s) => {
|
|
499
737
|
const number = str(s.number);
|
|
500
738
|
return { key: identityKey('sms', number, JSON.stringify({ number })), number };
|
|
501
739
|
});
|
|
502
|
-
return { extensions, systemUsers, dids, e911Addresses, smsNumbers };
|
|
740
|
+
return { extensions, systemUsers, dids, e911Addresses, e911Endpoints, e911Legacy, smsNumbers };
|
|
503
741
|
}
|
|
504
742
|
|
|
505
743
|
/** The counts, as a fold over {@link listDomainInventory} so the two can never disagree. */
|
|
@@ -519,6 +757,11 @@ export function countInventoryDetail(d: DomainInventoryDetail): DomainInventory
|
|
|
519
757
|
teamsConnected: 0,
|
|
520
758
|
dids: { total: 0, tollFree: 0, local: 0, fax: 0, all: d.dids.length },
|
|
521
759
|
e911Addresses: d.e911Addresses.length,
|
|
760
|
+
// `?? []` on the two newest lists alone: a detail object cached or serialised by a consumer running
|
|
761
|
+
// an older version of this library has neither field, and a count that threw on it would take out a
|
|
762
|
+
// whole page over a dimension that did not exist when the entry was written.
|
|
763
|
+
e911Endpoints: (d.e911Endpoints ?? []).length,
|
|
764
|
+
e911Legacy: (d.e911Legacy ?? []).length,
|
|
522
765
|
smsNumbers: d.smsNumbers.length,
|
|
523
766
|
devices: { total: 0, byModel: {} },
|
|
524
767
|
};
|
|
@@ -574,6 +817,8 @@ export function itemsFor(detail: DomainInventoryDetail, path: string): Inventory
|
|
|
574
817
|
if (path === 'dids.fax') return detail.dids.filter((n) => n.fax);
|
|
575
818
|
if (path === 'dids.all') return detail.dids;
|
|
576
819
|
if (path === 'e911Addresses') return detail.e911Addresses;
|
|
820
|
+
if (path === 'e911Endpoints') return detail.e911Endpoints ?? [];
|
|
821
|
+
if (path === 'e911Legacy') return detail.e911Legacy ?? [];
|
|
577
822
|
if (path === 'smsNumbers') return detail.smsNumbers;
|
|
578
823
|
return undefined;
|
|
579
824
|
}
|
|
@@ -586,5 +831,20 @@ export function itemLabel(item: InventoryItem): string {
|
|
|
586
831
|
}
|
|
587
832
|
if ('kind' in item) return item.kind === 'tollFree' ? `${item.number} (toll-free)` : item.number;
|
|
588
833
|
if ('label' in item) return item.label;
|
|
834
|
+
// An ENDPOINT is named by the number the carrier bills, then by who it announces and where it sends
|
|
835
|
+
// responders. Either half is dropped when blank rather than printed against a dangling dash, and a
|
|
836
|
+
// record with NEITHER falls back to its derived key — the same shape an address with nothing to name
|
|
837
|
+
// it by gets, except the id here is the key rather than a position, so two blank-callback endpoints
|
|
838
|
+
// stay apart. Never the empty string: this label is what a consumer writes into its acceptance
|
|
839
|
+
// history, and a row that cannot name its own item is worse than an ugly one.
|
|
840
|
+
if ('callback' in item) {
|
|
841
|
+
const who = [item.callerName, item.billingAddress].filter(Boolean).join(', ');
|
|
842
|
+
if (item.callback) return who ? `${item.callback} — ${who}` : item.callback;
|
|
843
|
+
return who || `(endpoint ${item.key.slice('e911:'.length)})`;
|
|
844
|
+
}
|
|
845
|
+
// A LEGACY number says so on its own line: it looks like a DID, and nothing else on the page would
|
|
846
|
+
// tell a reader why a bare number is sitting on an E911 row. Singular is written out: a label that
|
|
847
|
+
// does not agree with itself reads as a rendering fault, and this one is frozen into history rows.
|
|
848
|
+
if ('users' in item) return `${item.number} — legacy E911 (${item.users} user${item.users === 1 ? '' : 's'})`;
|
|
589
849
|
return item.number;
|
|
590
850
|
}
|
package/src/model.ts
CHANGED
|
@@ -96,6 +96,19 @@ export interface Snapshot {
|
|
|
96
96
|
* `undefined` means "not read", which is not the same fact as an empty array.
|
|
97
97
|
*/
|
|
98
98
|
addresses?: Rec[];
|
|
99
|
+
/**
|
|
100
|
+
* Emergency ENDPOINT records — GET /domains/{d}/addresses/endpoints. Read alongside `addresses`,
|
|
101
|
+
* and `undefined` means "not read" the same way.
|
|
102
|
+
*
|
|
103
|
+
* An endpoint is what the E911 carrier routes on and bills per: a callback number, a caller name, a
|
|
104
|
+
* billing address and a vendor. An ADDRESS is a dispatchable location forwarded to responders, and
|
|
105
|
+
* several of them can sit under one endpoint — so the two lists count different things and neither
|
|
106
|
+
* substitutes for the other.
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ An endpoint record holds its callback NUMBER in `emergency-address-id` — the same field name an
|
|
109
|
+
* address record uses for its own `a-…` id. Reading it as an address id joins nothing.
|
|
110
|
+
*/
|
|
111
|
+
addressEndpoints?: Rec[];
|
|
99
112
|
/**
|
|
100
113
|
* SMS-enabled numbers — GET /domains/{d}/smsnumbers?dest=*. The endpoint is documented with no
|
|
101
114
|
* parameters, but a live server answers 400 without `dest` or `number`; `dest=*` is the wildcard
|
package/src/nsClient.ts
CHANGED
|
@@ -165,8 +165,14 @@ export interface FetchSnapshotOptions {
|
|
|
165
165
|
*/
|
|
166
166
|
includeDidDestRules?: boolean;
|
|
167
167
|
/**
|
|
168
|
-
* Also read the domain's E911 addresses into `snapshot.addresses
|
|
169
|
-
*
|
|
168
|
+
* Also read the domain's E911 addresses into `snapshot.addresses` AND its emergency ENDPOINTS into
|
|
169
|
+
* `snapshot.addressEndpoints`. Two extra calls. Default false — the resolver uses neither; an
|
|
170
|
+
* inventory count uses both.
|
|
171
|
+
*
|
|
172
|
+
* ONE flag for the two because they are one subject read two ways: the endpoint is what the E911
|
|
173
|
+
* carrier bills per, the address is the location responders are sent to, and a caller given the
|
|
174
|
+
* addresses alone would count the dispatchable locations and bill for them. That was the bug this
|
|
175
|
+
* option grew to fix, so the two are not separable here.
|
|
170
176
|
*/
|
|
171
177
|
includeAddresses?: boolean;
|
|
172
178
|
/**
|
|
@@ -212,13 +218,16 @@ export async function fetchDomainSnapshot(client: NsClient, domain: string, opts
|
|
|
212
218
|
};
|
|
213
219
|
|
|
214
220
|
const domainRec = asArray(await client.get(base))[0] ?? { domain };
|
|
215
|
-
const [timeframes, users, callqueues, phonenumbers, autoattendants, addresses, smsnumbers] = await Promise.all([
|
|
221
|
+
const [timeframes, users, callqueues, phonenumbers, autoattendants, addresses, addressEndpoints, smsnumbers] = await Promise.all([
|
|
216
222
|
soft(`${base}/timeframes`),
|
|
217
223
|
soft(`${base}/users`),
|
|
218
224
|
soft(`${base}/callqueues`),
|
|
219
225
|
soft(`${base}/phonenumbers`),
|
|
220
226
|
soft(`${base}/autoattendants`),
|
|
221
227
|
opts.includeAddresses ? soft(`${base}/addresses`) : Promise.resolve(undefined),
|
|
228
|
+
// The BILLABLE half of E911, softened like the rest: a domain still on the legacy provisioning
|
|
229
|
+
// model has no endpoints at all and answers 404, which is "none" rather than a failure.
|
|
230
|
+
opts.includeAddresses ? soft(`${base}/addresses/endpoints`) : Promise.resolve(undefined),
|
|
222
231
|
// `dest=*`: see includeSmsNumbers. A 404 is already softened to []; a 400 from a server that wants
|
|
223
232
|
// a different parameter throws, which is right — a silent empty list would read as "no SMS numbers".
|
|
224
233
|
opts.includeSmsNumbers ? soft(`${base}/smsnumbers?dest=*`) : Promise.resolve(undefined),
|
|
@@ -240,6 +249,7 @@ export async function fetchDomainSnapshot(client: NsClient, domain: string, opts
|
|
|
240
249
|
meta: { domain }, domain: domainRec, timeframes, users, callqueues, phonenumbers, autoattendants,
|
|
241
250
|
...(answerrulesByUser ? { answerrulesByUser } : {}),
|
|
242
251
|
...(addresses ? { addresses } : {}),
|
|
252
|
+
...(addressEndpoints ? { addressEndpoints } : {}),
|
|
243
253
|
...(smsnumbers ? { smsnumbers } : {}),
|
|
244
254
|
};
|
|
245
255
|
}
|
|
@@ -343,6 +353,7 @@ export async function fetchDomainSnapshot(client: NsClient, domain: string, opts
|
|
|
343
353
|
answerrulesByUser,
|
|
344
354
|
agentsByQueue,
|
|
345
355
|
...(addresses ? { addresses } : {}),
|
|
356
|
+
...(addressEndpoints ? { addressEndpoints } : {}),
|
|
346
357
|
...(smsnumbers ? { smsnumbers } : {}),
|
|
347
358
|
...(devicesByUser ? { devicesByUser } : {}),
|
|
348
359
|
...(deviceReadFailures ? { deviceReadFailures } : {}),
|