From d1d1302d55194820117b849222310df9b2858e63 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Mon, 22 Jun 2026 09:34:48 -0700 Subject: [PATCH] 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) --- .claude/skills/packetdial/SKILL.md | 38 +++++++++++++++++++ .claude/skills/packetdial/scripts/ns.py | 18 +++++++++ .../skills/packetdial/scripts/ns_client.py | 33 ++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/.claude/skills/packetdial/SKILL.md b/.claude/skills/packetdial/SKILL.md index d7152201..ffa11d1d 100644 --- a/.claude/skills/packetdial/SKILL.md +++ b/.claude/skills/packetdial/SKILL.md @@ -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 '' 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/ --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/ --confirm`. + ## Standard provisioning flow (new customer) 1. `create-domain` -> dial plan auto-generates diff --git a/.claude/skills/packetdial/scripts/ns.py b/.claude/skills/packetdial/scripts/ns.py index 9d9b9007..74d5c769 100644 --- a/.claude/skills/packetdial/scripts/ns.py +++ b/.claude/skills/packetdial/scripts/ns.py @@ -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": diff --git a/.claude/skills/packetdial/scripts/ns_client.py b/.claude/skills/packetdial/scripts/ns_client.py index c1138398..7786f8ab 100644 --- a/.claude/skills/packetdial/scripts/ns_client.py +++ b/.claude/skills/packetdial/scripts/ns_client.py @@ -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