packetdial: add onboard-domain wrapper (GUI Add-a-Domain -> 3-call API flow)

onboard-domain runs POST /domains -> addresses/validate (gen E911 pidflo) -> addresses/create
from one JSON body (domain fields + optional `emergency` block), gated --confirm. Reverse-
engineered from the OITVOIP wizard screenshots; live-created the real client domain
vwp.91912.service (Valley Wide Plastering) + E911 address, and proved the wrapper with a
throwaway create->delete (no leftovers, vwp intact). Documented GUI->API mapping + the two
manual gaps (voicemail user-defaults, email-send-from-address pending the packetdial.com mailbox)
+ the domain-type "no"-on-create quirk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 09:34:48 -07:00
parent 4157fc6f1d
commit d1d1302d55
3 changed files with 89 additions and 0 deletions

View File

@@ -202,6 +202,44 @@ The live OpenAPI spec is **NetSapiens API v2 v44.4.10** — **239 paths / 354 op
`--confirm`). The local spec copy used for this map: `.claude/tmp/ns-openapi.json` (refetch
from `pbx.packetdial.com/ns-api/webroot/openapi/openapi.json`).
## Onboard a new client domain (`onboard-domain`)
One gated command that mirrors the OITVOIP "Add a Domain" GUI wizard (Basic + Defaults +
Limitations + Emergency) and runs the **3-call flow**: `POST /domains``POST
.../addresses/validate` (generates the E911 `pidflo`) → `POST .../addresses`. Body = the
`POST /domains` fields **plus an optional `emergency` sub-object** (the E911 address). Verified
end-to-end on the production reseller (created vwp.91912.service for real; throwaway
create→delete to prove the wrapper).
```bash
ns.py onboard-domain --body-file new-client.json --confirm # --body '<json>' also works
```
`new-client.json` (the vwp example — note `domain` is the FULL name incl. the reseller suffix):
```json
{
"domain": "vwp.91912.service", "reseller": "91912.service",
"description": "Valley Wide Plastering", "domain-type": "Standard",
"dial-policy": "US and Canada", "time-zone": "America/Phoenix", "area-code": 480,
"caller-id-name": "Valley Wide Plastering", "caller-id-number": 4807059500,
"caller-id-number-emergency": 4807059500,
"single-sign-on-enabled": "no", "music-on-hold-randomized-enabled": "no",
"emergency": {
"address-name": "Valley Wide Plastering", "caller-name": "ValleyWidePlastering",
"address-line-1": "301 N 56TH ST", "address-country-abbreviation": "US",
"address-state-province-abbreviation": "AZ", "address-city": "CHANDLER",
"address-postal-code": "85226", "address-location-description": "Main"
}
}
```
GUI→API mapping: Name+suffix → `domain`; Dial Permission → `dial-policy`; Limitations →
`limits-max-*` (omit for "unlimited"); Emergency tab → the `emergency` block. Omitting `dial-plan`
auto-generates one named after the domain. **Two known gaps vs. the GUI** (still manual): the
Voicemail user-defaults (Enable/Transcription/Message) aren't in `POST /domains`, and
`email-send-from-address` (e.g. `voicemail@packetdial.com`) is left blank until that mailbox
exists — set it after with `raw PUT domains/<domain> --body '{"email-send-from-address":"..."}'`.
`domain-type` occasionally stores as `"no"` on create — re-`PUT` it if so. Undo a bad onboard:
`raw DELETE domains/<domain> --confirm`.
## Standard provisioning flow (new customer)
1. `create-domain` -> dial plan auto-generates

View File

@@ -167,6 +167,10 @@ def main(argv=None) -> int:
sp = sub.add_parser("create-moh"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true")
sp = sub.add_parser("delete-moh"); sp.add_argument("domain"); sp.add_argument("index"); sp.add_argument("--confirm", action="store_true")
# onboard a new client domain (wizard -> 3-call flow): domain + optional E911 address
sp = sub.add_parser("onboard-domain", help="create a domain (+ optional E911 address) from one JSON body")
sp.add_argument("--body"); sp.add_argument("--body-file"); sp.add_argument("--confirm", action="store_true")
# --- raw escape hatch ---
sp = sub.add_parser("raw", help="raw request against any v2 path")
sp.add_argument("method", choices=["GET", "POST", "PUT", "PATCH", "DELETE"])
@@ -379,6 +383,20 @@ def main(argv=None) -> int:
_require_confirm(args, "DELETE MOH", f"{args.domain}/{args.index}")
_emit(client.delete_moh(args.domain, args.index))
elif args.cmd == "onboard-domain":
if args.body_file:
with open(args.body_file, encoding="utf-8") as fh:
body = json.load(fh)
else:
body = _parse_body(args.body)
if not isinstance(body, dict) or not body.get("domain"):
print("[ERROR] onboard-domain needs a JSON body with 'domain'", file=sys.stderr); sys.exit(2)
has_em = "emergency" in body
_require_confirm(args, "ONBOARD domain",
f"{body['domain']} (reseller {body.get('reseller','?')})"
+ (" + E911 address" if has_em else " (no E911 block)"))
_emit(client.onboard_domain(body))
elif args.cmd == "raw":
body = _parse_body(args.body)
if args.method != "GET":

View File

@@ -565,3 +565,36 @@ class NetSapiensClient:
return self.request(
"GET", f"domains/{urllib.parse.quote(domain)}/transcriptions", params=filters or None
)
# ======================================================================
# ORCHESTRATION — onboard a new client domain (mirrors the GUI "Add a Domain"
# wizard: Basic + Defaults + Limitations -> POST /domains; Emergency tab ->
# validate (generates the pidflo) -> create E911 address). Live-proven against
# vwp.91912.service 2026-06-22.
# ======================================================================
def onboard_domain(self, body: dict) -> dict:
"""Create a domain and (optionally) its E911 address in one flow.
body = the POST /domains fields PLUS an optional "emergency" sub-object
(the address fields). Returns {"domain":..., "address_validate":...,
"address":...}. Requires body["domain"] (full, e.g. vwp.91912.service).
"""
body = dict(body)
emergency = body.pop("emergency", None)
body.setdefault("synchronous", "yes")
domain = body.get("domain")
if not domain:
raise PacketDialError("onboard_domain: body must include 'domain' (e.g. acme.91912.service)")
result = {"domain": self.create_domain(body)}
if emergency:
d = urllib.parse.quote(domain)
v = self.request("POST", f"domains/{d}/addresses/validate", json_body=dict(emergency))
vobj = v[0] if isinstance(v, list) and v else v
addr = dict(emergency)
addr["address-formatted-pidflo"] = vobj.get("address-formatted-pidflo")
addr["emergency-address-id"] = vobj.get("emergency-address-id")
result["address_validate"] = {"status": (vobj or {}).get("ValidationStatus")
or (vobj.get("address-formatted-pidflo") or {}).get("ValidationStatus")
if isinstance(vobj, dict) else None}
result["address"] = self.create_address(domain, addr)
return result