Skip to content

Commit ff18ec0

Browse files
authored
Merge pull request #6 from Bandwidth/feat/number-activate
Add `band number activate`/`deactivate` for service-activation orders
2 parents 92a5327 + a72b9f3 commit ff18ec0

7 files changed

Lines changed: 408 additions & 6 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ auth login
9797
└─→ vcp create (links to app via --app-id)
9898
└─→ number search → number order
9999
└─→ vcp assign (attach numbers to VCP)
100-
└─→ call create (requires --from, --app-id, --answer-url)
100+
└─→ number activate --voice-inbound (required for inbound)
101+
└─→ call create (requires --from, --app-id, --answer-url)
101102
```
102103

103104
### Legacy
@@ -212,6 +213,7 @@ When `--wait` times out (exit code 5), the operation may have succeeded — the
212213
| Command | On timeout | Recovery |
213214
|---------|-----------|----------|
214215
| `number order --wait` | Number may be activating | Check `band number list --plain` — if the number appears, it completed. If not, retry the order. |
216+
| `number activate --wait` / `number deactivate --wait` | Service activation order may still be RECEIVED/PROCESSING | Check `band number get <number> --plain` — the `inboundActivated` / `outbound*Activated` flags reflect the terminal state. Re-running the same activate is idempotent. |
215217
| `call create --wait` | Call may still be active | Check `band call get <call-id> --plain` — look at the `state` field. |
216218
| `transcription create --wait` | Transcription may be processing | Check `band transcription get <call-id> <rec-id> --plain`. |
217219

@@ -260,7 +262,8 @@ account + auth
260262
└─→ vcp create (links to app)
261263
└─→ number search → number order
262264
└─→ vcp assign
263-
└─→ call create
265+
└─→ number activate --voice-inbound
266+
└─→ call create
264267
```
265268

266269
**Voice (Legacy):**
@@ -323,6 +326,7 @@ band number list --plain
323326
band number search --area-code 919 --quantity 1 --plain
324327
band number order <number> --wait # 5. order number
325328
band vcp assign <vcp-id> <number> # 6. assign number to VCP
329+
band number activate <number> --voice-inbound --wait # 7. enable inbound voice
326330
```
327331

328332
If step 2 fails with 409 "HTTP voice feature is required," or step 3 fails with 403, fall back to legacy.

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,10 +292,11 @@ A fresh UP account typically has one sub-account and one location already create
292292
### Numbers
293293

294294
```sh
295-
band number list # list your numbers
296-
band number search --area-code 919 --quantity 5 # search available numbers
297-
band number order +19195551234 --wait # order (blocks until active)
298-
band number release +19195551234 # release a number
295+
band number list # list your numbers
296+
band number search --area-code 919 --quantity 5 # search available numbers
297+
band number order +19195551234 --wait # order (blocks until active)
298+
band number activate +19195551234 --voice-inbound --wait # turn on inbound voice
299+
band number release +19195551234 # release a number
299300
```
300301

301302
### Messaging
@@ -405,6 +406,8 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
405406
| `band number search` | Search available numbers by area code |
406407
| `band number order <number...>` | Order numbers |
407408
| `band number get <number>` | Get voice config details (including VCP assignment) |
409+
| `band number activate <number...>` | Activate voice/messaging services (e.g. enable inbound) |
410+
| `band number deactivate <number...>` | Deactivate voice/messaging services |
408411
| `band number list` | List your in-service numbers |
409412
| `band number release <number>` | Release a number |
410413

cmd/number/activate.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package number
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
func init() {
8+
Cmd.AddCommand(activateCmd)
9+
registerServiceActivationFlags(activateCmd)
10+
}
11+
12+
var activateCmd = &cobra.Command{
13+
Use: "activate <number...>",
14+
Short: "Activate voice or messaging services on phone numbers",
15+
Long: `Creates a service activation order to enable voice and/or messaging
16+
services on one or more phone numbers via the Universal Platform.
17+
18+
At least one service flag must be provided. Use --dry-run to check
19+
eligibility (status per service) without creating an order. Use --wait
20+
to block until the order reaches a terminal status.
21+
22+
Underlying API: POST /api/v2/accounts/{accountId}/serviceActivation`,
23+
Example: ` # Enable inbound voice on a single number
24+
band number activate +19195551234 --voice-inbound
25+
26+
# Enable all voice services on multiple numbers and wait
27+
band number activate +19195551234 +19195551235 --voice-inbound \
28+
--voice-outbound-national --voice-outbound-international --wait
29+
30+
# Eligibility check only — no order created
31+
band number activate +19195551234 --voice-inbound --dry-run
32+
33+
# With a customer-supplied order ID for tracking
34+
band number activate +19195551234 --voice-inbound --customer-order-id my-order-123`,
35+
Args: cobra.MinimumNArgs(1),
36+
RunE: func(cmd *cobra.Command, args []string) error {
37+
return runServiceActivation(cmd, "ACTIVATE", args)
38+
},
39+
}

cmd/number/deactivate.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package number
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
func init() {
8+
Cmd.AddCommand(deactivateCmd)
9+
registerServiceActivationFlags(deactivateCmd)
10+
}
11+
12+
var deactivateCmd = &cobra.Command{
13+
Use: "deactivate <number...>",
14+
Short: "Deactivate voice or messaging services on phone numbers",
15+
Long: `Creates a service deactivation order to disable voice and/or messaging
16+
services on one or more phone numbers via the Universal Platform.
17+
18+
At least one service flag must be provided. Use --dry-run to inspect
19+
the eligibility matrix (which mirrors activate's). Use --wait to block
20+
until the order reaches a terminal status.
21+
22+
Underlying API: POST /api/v2/accounts/{accountId}/serviceActivation
23+
with action=DEACTIVATE`,
24+
Example: ` # Disable inbound voice on a number
25+
band number deactivate +19195551234 --voice-inbound
26+
27+
# Disable inbound voice and wait for the order to settle
28+
band number deactivate +19195551234 --voice-inbound --wait`,
29+
Args: cobra.MinimumNArgs(1),
30+
RunE: func(cmd *cobra.Command, args []string) error {
31+
return runServiceActivation(cmd, "DEACTIVATE", args)
32+
},
33+
}

cmd/number/number_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,131 @@ func TestWrapTNsError_500(t *testing.T) {
180180
t.Errorf("500 should not get the 403 message, got %q", err.Error())
181181
}
182182
}
183+
184+
// --- Service Activation ---
185+
186+
func TestBuildServiceActivationBody_VoiceInboundOnly(t *testing.T) {
187+
body, err := BuildServiceActivationBody(ServiceActivationOpts{
188+
Action: "ACTIVATE",
189+
PhoneNumbers: []string{"+19195551234"},
190+
VoiceInbound: true,
191+
})
192+
if err != nil {
193+
t.Fatalf("unexpected error: %v", err)
194+
}
195+
if body["action"] != "ACTIVATE" {
196+
t.Errorf("action = %v, want ACTIVATE", body["action"])
197+
}
198+
nums, _ := body["phoneNumbers"].([]string)
199+
if len(nums) != 1 || nums[0] != "+19195551234" {
200+
t.Errorf("phoneNumbers = %v, want [+19195551234]", nums)
201+
}
202+
services, _ := body["services"].(map[string]interface{})
203+
voice, _ := services["voice"].([]string)
204+
if len(voice) != 1 || voice[0] != "INBOUND" {
205+
t.Errorf("services.voice = %v, want [INBOUND]", voice)
206+
}
207+
if _, has := services["messaging"]; has {
208+
t.Errorf("messaging should not be set when --messaging is false")
209+
}
210+
if _, has := body["customerOrderId"]; has {
211+
t.Errorf("customerOrderId should be omitted when not provided")
212+
}
213+
}
214+
215+
func TestBuildServiceActivationBody_AllVoiceServices(t *testing.T) {
216+
body, err := BuildServiceActivationBody(ServiceActivationOpts{
217+
Action: "ACTIVATE",
218+
PhoneNumbers: []string{"+19195551234", "+19195551235"},
219+
VoiceInbound: true,
220+
VoiceOutNat: true,
221+
VoiceOutInt: true,
222+
})
223+
if err != nil {
224+
t.Fatalf("unexpected error: %v", err)
225+
}
226+
services := body["services"].(map[string]interface{})
227+
voice := services["voice"].([]string)
228+
want := []string{"INBOUND", "OUTBOUND_NATIONAL", "OUTBOUND_INTERNATIONAL"}
229+
if len(voice) != len(want) {
230+
t.Fatalf("expected %d voice services, got %d: %v", len(want), len(voice), voice)
231+
}
232+
for i, w := range want {
233+
if voice[i] != w {
234+
t.Errorf("voice[%d] = %q, want %q", i, voice[i], w)
235+
}
236+
}
237+
}
238+
239+
func TestBuildServiceActivationBody_VoiceAndMessaging(t *testing.T) {
240+
body, err := BuildServiceActivationBody(ServiceActivationOpts{
241+
Action: "ACTIVATE",
242+
PhoneNumbers: []string{"+19195551234"},
243+
VoiceInbound: true,
244+
Messaging: true,
245+
})
246+
if err != nil {
247+
t.Fatalf("unexpected error: %v", err)
248+
}
249+
services := body["services"].(map[string]interface{})
250+
if _, has := services["voice"]; !has {
251+
t.Error("voice should be present")
252+
}
253+
msg, _ := services["messaging"].([]string)
254+
if len(msg) != 1 || msg[0] != "ALL" {
255+
t.Errorf("messaging = %v, want [ALL]", msg)
256+
}
257+
}
258+
259+
func TestBuildServiceActivationBody_DeactivateAction(t *testing.T) {
260+
body, err := BuildServiceActivationBody(ServiceActivationOpts{
261+
Action: "DEACTIVATE",
262+
PhoneNumbers: []string{"+19195551234"},
263+
VoiceInbound: true,
264+
})
265+
if err != nil {
266+
t.Fatalf("unexpected error: %v", err)
267+
}
268+
if body["action"] != "DEACTIVATE" {
269+
t.Errorf("action = %v, want DEACTIVATE", body["action"])
270+
}
271+
}
272+
273+
func TestBuildServiceActivationBody_NoServicesIsError(t *testing.T) {
274+
_, err := BuildServiceActivationBody(ServiceActivationOpts{
275+
Action: "ACTIVATE",
276+
PhoneNumbers: []string{"+19195551234"},
277+
})
278+
if err == nil {
279+
t.Fatal("expected error when no services flagged, got nil")
280+
}
281+
if !strings.Contains(err.Error(), "--voice-inbound") {
282+
t.Errorf("error should hint at the available flags, got %q", err.Error())
283+
}
284+
}
285+
286+
func TestBuildServiceActivationBody_CustomerOrderIDIncluded(t *testing.T) {
287+
body, err := BuildServiceActivationBody(ServiceActivationOpts{
288+
Action: "ACTIVATE",
289+
PhoneNumbers: []string{"+19195551234"},
290+
VoiceInbound: true,
291+
CustomerOrderID: "my-order-123",
292+
})
293+
if err != nil {
294+
t.Fatalf("unexpected error: %v", err)
295+
}
296+
if body["customerOrderId"] != "my-order-123" {
297+
t.Errorf("customerOrderId = %v, want my-order-123", body["customerOrderId"])
298+
}
299+
}
300+
301+
func TestBuildCheckerBody(t *testing.T) {
302+
body := BuildCheckerBody([]string{"+19195551234", "+19195551235"})
303+
nums, ok := body["phoneNumbers"].([]string)
304+
if !ok {
305+
t.Fatalf("phoneNumbers wrong type: %T", body["phoneNumbers"])
306+
}
307+
if len(nums) != 2 {
308+
t.Errorf("expected 2 numbers, got %d", len(nums))
309+
}
310+
}

0 commit comments

Comments
 (0)