flexops 1.1.0 → 2.0.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.
@@ -0,0 +1,63 @@
1
+ # ***********************************************************************
2
+ # Package : flexops
3
+ # Author : FlexOps, LLC
4
+ # Created : 2026-10-19
5
+ #
6
+ # Copyright (c) 2021-2026 by FlexOps, LLC. All rights reserved.
7
+ # ***********************************************************************
8
+
9
+ module FlexOps
10
+ module Resources
11
+ # Scheduled report emails. Answers use the { success, data, message } envelope, except
12
+ # delete, which returns nil (204). Ids are GUID strings.
13
+ #
14
+ # Reads work with any API key or a user token. Writes (create, update, delete, toggle) need a
15
+ # user token (auth.login) or an API key granted the opt-in "report-schedules:write" scope; a
16
+ # key without it gets 403 API_KEY_SCOPE_REQUIRED.
17
+ #
18
+ # Numbers on the wire: +format+ Csv 0, Json 1; +frequencyType+ Daily 0, Weekly 1, Monthly 2;
19
+ # +dayOfWeek+ 0-6 (Sunday 0, for Weekly); +dayOfMonth+ 1-28 (for Monthly).
20
+ class ReportSchedules
21
+ def initialize(http, ws_id_proc)
22
+ @http = http
23
+ @ws_id = ws_id_proc
24
+ end
25
+
26
+ def list
27
+ @http.get(ws_path)
28
+ end
29
+
30
+ def get(id)
31
+ @http.get("#{ws_path}/#{id}")
32
+ end
33
+
34
+ # request:: { reportType: (e.g. "shipments"), format:, frequencyType:, timeOfDayUtc: ("HH:mm:ss"),
35
+ # recipientEmails: (comma-separated), dayOfWeek:, dayOfMonth:, filterParametersJson:,
36
+ # maxFailures: (1-100, default 3) }.
37
+ def create(request)
38
+ @http.post(ws_path, body: request)
39
+ end
40
+
41
+ # request:: any of the create fields.
42
+ def update(id, request)
43
+ @http.put("#{ws_path}/#{id}", body: request)
44
+ end
45
+
46
+ # Returns nil (204).
47
+ def delete(id)
48
+ @http.delete("#{ws_path}/#{id}")
49
+ end
50
+
51
+ # Flips isEnabled. The envelope's data is nil; read the schedule to see the new state.
52
+ def toggle(id)
53
+ @http.post("#{ws_path}/#{id}/toggle")
54
+ end
55
+
56
+ private
57
+
58
+ def ws_path
59
+ "/api/workspaces/#{@ws_id.call}/report-schedules"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -14,8 +14,8 @@ module FlexOps
14
14
  @ws_id = ws_id_proc
15
15
  end
16
16
 
17
- def list(page: nil, page_size: nil, status: nil)
18
- query = { page: page, pageSize: page_size, status: status }.compact
17
+ def list(page: nil, page_size: nil, start_date: nil, end_date: nil)
18
+ query = { page: page, pageSize: page_size, startDate: start_date, endDate: end_date }.compact
19
19
  @http.get("#{ws_path}/returns", query: query.empty? ? nil : query)
20
20
  end
21
21
 
@@ -23,32 +23,46 @@ module FlexOps
23
23
  @http.get("#{ws_path}/returns/#{return_id}")
24
24
  end
25
25
 
26
+ # Creates an RMA and returns the enveloped RMA (201).
27
+ #
28
+ # request:: { orderId: (a number), orderNumber: (optional), reason:,
29
+ # items: [{ sku:, quantity:, reason: (optional) }], notes: (optional),
30
+ # customerEmail: (optional), reasonId: (optional) }
26
31
  def create(request)
27
- @http.post("#{ws_path}/returns", body: request)
32
+ @http.post("#{ws_path}/returns/rma", body: request)
28
33
  end
29
34
 
30
- def approve(return_id)
31
- @http.post("#{ws_path}/returns/#{return_id}/approve")
35
+ # Generates a return label for an RMA.
36
+ #
37
+ # The carrier needs the package weight and all three dimensions: pass weight_oz, length_in,
38
+ # width_in and height_in. Anything left out comes from the workspace's default shipping
39
+ # preset; with no preset the Gateway answers 400 PackageMeasurementsRequired naming the
40
+ # missing fields.
41
+ #
42
+ # Only what you set is sent; the Gateway uses "usps" when carrier_code is left out.
43
+ # hazmat_classification (a HazmatClassification) declares what the return holds.
44
+ def generate_label(rma_number, weight_oz: nil, length_in: nil, width_in: nil, height_in: nil,
45
+ carrier_code: nil, service_type: nil, payment_confirmed: nil, hazmat_classification: nil,
46
+ rate_indicator: nil)
47
+ # Always a JSON object: the Gateway binds a required GenerateReturnLabelRequest body and refuses an empty one with 400.
48
+ body = {
49
+ carrierCode: carrier_code, serviceType: service_type, weightOz: weight_oz, lengthIn: length_in,
50
+ widthIn: width_in, heightIn: height_in, paymentConfirmed: payment_confirmed,
51
+ hazmatClassification: hazmat_classification, rateIndicator: rate_indicator
52
+ }.compact
53
+ @http.post("#{ws_path}/returns/#{rma_number}/label", body: body)
32
54
  end
33
55
 
34
- def reject(return_id, reason)
35
- @http.post("#{ws_path}/returns/#{return_id}/reject", body: { reason: reason })
36
- end
37
-
38
- def cancel(return_id)
39
- @http.post("#{ws_path}/returns/#{return_id}/cancel")
40
- end
41
-
42
- def generate_label(return_id)
43
- @http.post("#{ws_path}/returns/#{return_id}/label")
44
- end
45
-
46
- def mark_received(return_id, items)
47
- @http.post("#{ws_path}/returns/#{return_id}/receive", body: { items: items })
48
- end
49
-
50
- def process_refund(return_id)
51
- @http.post("#{ws_path}/returns/#{return_id}/refund")
56
+ # Records the inspected items of a received RMA.
57
+ #
58
+ # rma_id:: the numeric RMA id (the route segment)
59
+ # rma_number:: the RMA number, for example "RMA-1001"
60
+ # inspected_items:: [{ partNumber:, partDescription: (optional), quantity:,
61
+ # disposition: 0 restock, 1 dispose or 2 forward, inspectionNotes: (optional) }]
62
+ # warehouse_id:: the receiving warehouse; the Gateway uses 1 when it is left out
63
+ def mark_received(rma_id, rma_number:, inspected_items:, warehouse_id: nil)
64
+ body = { rmaNumber: rma_number, warehouseId: warehouse_id, inspectedItems: inspected_items }.compact
65
+ @http.post("#{ws_path}/returns/#{rma_id}/receive", body: body)
52
66
  end
53
67
 
54
68
  private
@@ -22,10 +22,13 @@ module FlexOps
22
22
  @http.get("#{ws_path}/shipping-rules/#{rule_id}")
23
23
  end
24
24
 
25
+ # rule:: { name:, priority:, isEnabled:, warehouseId: (optional),
26
+ # conditions: [{ field:, operator:, value:, group: (optional) }], actions: [{ type:, value: }] }
25
27
  def create(rule)
26
28
  @http.post("#{ws_path}/shipping-rules", body: rule)
27
29
  end
28
30
 
31
+ # rule:: the fields to change, named as for #create.
29
32
  def update(rule_id, rule)
30
33
  @http.put("#{ws_path}/shipping-rules/#{rule_id}", body: rule)
31
34
  end
@@ -34,8 +37,21 @@ module FlexOps
34
37
  @http.delete("#{ws_path}/shipping-rules/#{rule_id}")
35
38
  end
36
39
 
37
- def reorder(rule_ids)
38
- @http.put("#{ws_path}/shipping-rules/reorder", body: rule_ids)
40
+ # Sets rule priorities. +rules+ is an array of { ruleId:, priority: } hashes, or of rule ids
41
+ # in the order to apply (the first gets priority 0).
42
+ def reorder(rules)
43
+ items = rules.each_with_index.map do |rule, index|
44
+ rule.is_a?(Hash) ? rule : { ruleId: rule, priority: index }
45
+ end
46
+ @http.post("#{ws_path}/shipping-rules/reorder", body: { rules: items })
47
+ end
48
+
49
+ # Evaluates the workspace's rules against a sample shipment without changing anything.
50
+ #
51
+ # request:: { weightOz:, declaredValue:, destinationCountry:, destinationState:,
52
+ # destinationPostalCode:, orderSource:, itemCount:, shipMethod:, destinationZone:, tags: }
53
+ def test(request)
54
+ @http.post("#{ws_path}/shipping-rules/test", body: request)
39
55
  end
40
56
 
41
57
  private
@@ -6,6 +6,8 @@
6
6
  # Copyright (c) 2021-2026 by FlexOps, LLC. All rights reserved.
7
7
  # ***********************************************************************
8
8
 
9
+ require "erb"
10
+
9
11
  module FlexOps
10
12
  module Resources
11
13
  class Shipping
@@ -27,24 +29,38 @@ module FlexOps
27
29
  end
28
30
 
29
31
  # Returns a raw preview or label; confirmation always belongs to the caller.
32
+ # A label without an orderId must declare hazmatClassification (a HazmatClassification;
33
+ # HazmatClassification::NONE for an ordinary parcel). The SDK never fills it in.
30
34
  def create_label(request = {}, idempotency_key: nil, **fields)
31
35
  request = request.merge(fields)
32
- @http.post("#{ws_path}/shipping/labels", body: request,
36
+ @http.post("/api/shipping/labels", body: request,
33
37
  headers: idempotency_key.nil? ? {} : { "Idempotency-Key" => idempotency_key })
34
38
  end
35
39
 
36
- def cancel_label(label_id)
37
- @http.delete("#{ws_path}/shipping/labels/#{label_id}")
40
+ # The Gateway requires the carrier code (for example "USPS"). It is a keyword with no
41
+ # default value so older call sites fail with a clear ArgumentError before any request.
42
+ def cancel_label(label_id, carrier_code: nil)
43
+ raise ArgumentError, "carrier_code is required to cancel a label (for example \"USPS\")" if carrier_code.to_s.empty?
44
+
45
+ @http.delete("/api/shipping/labels/#{escape(label_id)}?carrierCode=#{escape(carrier_code)}")
38
46
  end
39
47
 
40
48
  def track(tracking_number)
41
- @http.get("#{ws_path}/shipping/track/#{tracking_number}")
49
+ @http.get("/api/shipping/track/#{escape(tracking_number)}")
42
50
  end
43
51
 
52
+ # Validates an address with POST /api/addresses/validate and returns the bare result
53
+ # ("isValid", "validationUnavailable", "suggestedAddress", "isResidential", "warnings",
54
+ # "validatedBy", "checkedAt").
55
+ #
56
+ # The address may use the rate and label field names (addressLine1, stateProvince,
57
+ # countryCode, ...), the legacy street1/state/zip names, or the Gateway's own
58
+ # (line1, line2, city, state, postalCode, country). Only those fields and name are sent.
44
59
  def validate_address(address)
45
- @http.post("#{ws_path}/shipping/addresses/validate", body: address)
60
+ @http.post("/api/addresses/validate", body: to_validation_address(address))
46
61
  end
47
62
 
63
+ # The payload is sent as given. Set hazmatClassification on an item only when you have a declaration.
48
64
  def create_batch(request)
49
65
  @http.post("#{ws_path}/labels/batch", body: request)
50
66
  end
@@ -62,19 +78,19 @@ module FlexOps
62
78
  end
63
79
 
64
80
  def get_carriers
65
- @http.get("#{ws_path}/shipping/carriers")
81
+ @http.get("/api/shipping/carriers")
66
82
  end
67
83
 
68
84
  def get_recommendations(request)
69
- @http.post("#{ws_path}/shipping/recommendations", body: request)
85
+ @http.post("/api/shipping/recommendations", body: request)
70
86
  end
71
87
 
72
88
  def predict_delivery(request)
73
- @http.post("#{ws_path}/shipping/predictions/delivery", body: request)
89
+ @http.post("/api/shipping/predictions/delivery", body: request)
74
90
  end
75
91
 
76
92
  def get_savings
77
- @http.get("#{ws_path}/shipping/savings")
93
+ @http.get("/api/shipping/savings")
78
94
  end
79
95
 
80
96
  private
@@ -82,6 +98,28 @@ module FlexOps
82
98
  def ws_path
83
99
  "/api/workspaces/#{@ws_id.call}"
84
100
  end
101
+
102
+ def escape(segment)
103
+ ERB::Util.url_encode(segment.to_s)
104
+ end
105
+
106
+ # Gateway AddressValidationRequest field for each accepted address field name.
107
+ VALIDATION_ADDRESS_FIELDS = {
108
+ "name" => "name",
109
+ "line1" => "line1", "addressLine1" => "line1", "street1" => "line1",
110
+ "line2" => "line2", "addressLine2" => "line2", "street2" => "line2",
111
+ "city" => "city",
112
+ "state" => "state", "stateProvince" => "state",
113
+ "postalCode" => "postalCode", "zip" => "postalCode",
114
+ "country" => "country", "countryCode" => "country"
115
+ }.freeze
116
+
117
+ def to_validation_address(address)
118
+ address.each_with_object({}) do |(key, value), mapped|
119
+ field = VALIDATION_ADDRESS_FIELDS[key.to_s]
120
+ mapped[field] = value unless field.nil? || value.nil?
121
+ end
122
+ end
85
123
  end
86
124
  end
87
125
  end
@@ -14,27 +14,11 @@ module FlexOps
14
14
  @ws_id = ws_id_proc
15
15
  end
16
16
 
17
+ # Returns the configured workspace's balance, bare: { "hasWallet", "balanceCents", "balance" }.
18
+ # Funding and transaction history are managed in the FlexOps portal.
17
19
  def get_balance
18
- @http.get("#{ws_path}/wallet/balance")
19
- end
20
-
21
- def add_funds(amount)
22
- @http.post("#{ws_path}/wallet/add-funds", body: { amount: amount })
23
- end
24
-
25
- def list_transactions(page: nil, page_size: nil, start_date: nil, end_date: nil)
26
- query = { page: page, pageSize: page_size, startDate: start_date, endDate: end_date }.compact
27
- @http.get("#{ws_path}/wallet/transactions", query: query.empty? ? nil : query)
28
- end
29
-
30
- def configure_auto_reload(enabled:, threshold: nil, amount: nil)
31
- @http.put("#{ws_path}/wallet/auto-reload", body: { enabled: enabled, threshold: threshold, amount: amount }.compact)
32
- end
33
-
34
- private
35
-
36
- def ws_path
37
- "/api/workspaces/#{@ws_id.call}"
20
+ ws_id = @ws_id.call
21
+ @http.get("/api/wallet/my-balance", headers: ws_id.to_s.empty? ? {} : { "X-Workspace-Id" => ws_id.to_s })
38
22
  end
39
23
  end
40
24
  end
@@ -20,16 +20,30 @@ module FlexOps
20
20
  @http.get("#{ws_path}/webhooks")
21
21
  end
22
22
 
23
- def get(webhook_id)
24
- @http.get("#{ws_path}/webhooks/#{webhook_id}")
25
- end
26
-
23
+ # Creates a webhook subscription.
24
+ #
25
+ # request:: a hash with +url+, optional +description+ and optional +events+, an
26
+ # array of FlexOps::WebhookEventType names (strings or symbols). +events+
27
+ # is sent as the Gateway's comma-separated +eventTypes+; leave it out to
28
+ # receive every event. A raw +eventTypes+ string ("*" or comma-separated
29
+ # names, no spaces) is sent unchanged instead.
30
+ #
31
+ # Raises ArgumentError, before any request, when +events+ is empty, is not an
32
+ # array, names an event FlexOps does not send, or is given with +eventTypes+.
33
+ #
34
+ # Returns the new subscription itself ("id", "url", "eventTypes", "status",
35
+ # "secret", ...), not a success/data envelope: the Gateway answers 201 with the
36
+ # bare subscription. An enveloped answer is unwrapped, so "secret" (the whsec_...
37
+ # signing secret, returned only here) is always at the top level.
27
38
  def create(request)
28
- @http.post("#{ws_path}/webhooks", body: request)
39
+ unwrap_subscription(@http.post("#{ws_path}/webhooks", body: to_wire_request(request)))
29
40
  end
30
41
 
42
+ # Updates a webhook subscription. +data+ takes +url+, +description+, +status+
43
+ # ("Active", "Paused" or "Disabled") and +events+, which replaces the subscribed
44
+ # events and is sent as +eventTypes+. Raises ArgumentError as #create does.
31
45
  def update(webhook_id, data)
32
- @http.put("#{ws_path}/webhooks/#{webhook_id}", body: data)
46
+ @http.put("#{ws_path}/webhooks/#{webhook_id}", body: to_wire_request(data))
33
47
  end
34
48
 
35
49
  def delete(webhook_id)
@@ -44,9 +58,74 @@ module FlexOps
44
58
  @http.get("#{ws_path}/webhooks/#{webhook_id}/deliveries")
45
59
  end
46
60
 
47
- def self.verify_signature(payload, signature, secret)
48
- expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
49
- OpenSSL.secure_compare(expected, signature)
61
+ # Sends a test event (X-FlexOps-Event "test") to the subscription's URL.
62
+ def test(webhook_id)
63
+ @http.post("#{ws_path}/webhooks/#{webhook_id}/test")
64
+ end
65
+
66
+ # Headers FlexOps sends with every webhook delivery.
67
+ EVENT_HEADER = "X-FlexOps-Event" # event name; "test" for a test event
68
+ EVENT_ID_HEADER = "X-FlexOps-Event-Id"
69
+ TIMESTAMP_HEADER = "X-FlexOps-Timestamp" # Unix seconds, part of the signed string
70
+ SIGNATURE_HEADER = "X-FlexOps-Signature" # "sha256=" + lowercase hex HMAC-SHA256
71
+ DELIVERY_HEADER = "X-FlexOps-Delivery"
72
+
73
+ # Seconds the X-FlexOps-Timestamp may be from now, either way.
74
+ DEFAULT_TOLERANCE = 300
75
+
76
+ SIGNATURE_PREFIX = "sha256="
77
+ private_constant :SIGNATURE_PREFIX
78
+
79
+ # Returns true when a FlexOps webhook delivery is authentic and fresh,
80
+ # false otherwise. See verify_signature! for the arguments.
81
+ def self.verify_signature(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
82
+ verify_signature!(payload: payload, signature: signature, timestamp: timestamp,
83
+ secret: secret, tolerance: tolerance, now: now)
84
+ rescue FlexOps::WebhookSignatureError
85
+ false
86
+ end
87
+
88
+ # Verifies a FlexOps webhook delivery and returns true, or raises
89
+ # FlexOps::WebhookSignatureError saying why.
90
+ #
91
+ # payload:: the raw request body exactly as received (verify before parsing it)
92
+ # signature:: the X-FlexOps-Signature value, "sha256=<hex>" (bare hex is accepted)
93
+ # timestamp:: the X-FlexOps-Timestamp value, Unix seconds
94
+ # secret:: the subscription secret ("whsec_..."), used as-is
95
+ # tolerance:: seconds the timestamp may be from +now+; non-positive keeps the default
96
+ # now:: the current time (a Time or Unix seconds), for tests
97
+ #
98
+ # FlexOps signs "{timestamp}.{body}" with HMAC-SHA256; the comparison is
99
+ # constant time.
100
+ def self.verify_signature!(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
101
+ if secret.nil? || secret.to_s.empty?
102
+ raise FlexOps::WebhookSignatureError.new("Webhook secret is empty", code: "WEBHOOK_MISSING_SECRET")
103
+ end
104
+ if signature.nil? || signature.to_s.empty?
105
+ raise FlexOps::WebhookSignatureError.new("Webhook signature header is missing", code: "WEBHOOK_MISSING_SIGNATURE")
106
+ end
107
+
108
+ timestamp = timestamp.to_s
109
+ unless timestamp.match?(/\A\d+\z/)
110
+ raise FlexOps::WebhookSignatureError.new("Webhook timestamp header is missing or not Unix seconds",
111
+ code: "WEBHOOK_INVALID_TIMESTAMP")
112
+ end
113
+
114
+ tolerance = DEFAULT_TOLERANCE unless tolerance.is_a?(Numeric) && tolerance.positive?
115
+ if (now.to_i - timestamp.to_i).abs > tolerance
116
+ raise FlexOps::WebhookSignatureError.new("Webhook timestamp is outside the tolerance",
117
+ code: "WEBHOOK_TIMESTAMP_OUTSIDE_TOLERANCE")
118
+ end
119
+
120
+ signed = "#{timestamp}.".b << payload.to_s.b
121
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, signed)
122
+ received = signature.to_s.delete_prefix(SIGNATURE_PREFIX).downcase
123
+ # OpenSSL.secure_compare hashes both sides first, so unequal lengths are safe.
124
+ unless OpenSSL.secure_compare(expected, received)
125
+ raise FlexOps::WebhookSignatureError.new("Webhook signature does not match", code: "WEBHOOK_SIGNATURE_MISMATCH")
126
+ end
127
+
128
+ true
50
129
  end
51
130
 
52
131
  private
@@ -54,6 +133,50 @@ module FlexOps
54
133
  def ws_path
55
134
  "/api/workspaces/#{@ws_id.call}"
56
135
  end
136
+
137
+ # The Gateway reads a comma-separated eventTypes string, ignores an events field
138
+ # (subscribing to every event) and matches each name exactly, so a name it never
139
+ # sends would leave the subscription silent; it is refused here instead.
140
+ def to_wire_request(request)
141
+ return request unless request.is_a?(Hash)
142
+
143
+ events_key = [:events, "events"].find { |key| request.key?(key) }
144
+ return request if events_key.nil?
145
+
146
+ body = request.reject { |key, _| key == events_key }
147
+ events = request[events_key]
148
+ return body if events.nil?
149
+
150
+ if [:eventTypes, "eventTypes"].any? { |key| body.key?(key) }
151
+ raise ArgumentError, "Pass events or eventTypes, not both."
152
+ end
153
+
154
+ body.merge(eventTypes: to_event_types(events))
155
+ end
156
+
157
+ def unwrap_subscription(response)
158
+ return response unless response.is_a?(Hash) && response.key?("success") && response["data"].is_a?(Hash)
159
+
160
+ response["data"]
161
+ end
162
+
163
+ def to_event_types(events)
164
+ unless events.is_a?(Array)
165
+ raise ArgumentError, "events must be an array of event names, not #{events.class}."
166
+ end
167
+ if events.empty?
168
+ raise ArgumentError, "events must name at least one event; leave it out to receive every event."
169
+ end
170
+
171
+ names = events.map { |event| event.is_a?(String) || event.is_a?(Symbol) ? event.to_s : event.inspect }.uniq
172
+ unknown = names - FlexOps::WebhookEventType::ALL
173
+ unless unknown.empty?
174
+ raise ArgumentError, "Unknown webhook event: #{unknown.join(", ")}. " \
175
+ "FlexOps sends #{FlexOps::WebhookEventType::ALL.join(", ")}."
176
+ end
177
+
178
+ names.join(",")
179
+ end
57
180
  end
58
181
  end
59
182
  end
@@ -23,30 +23,25 @@ module FlexOps
23
23
  @http.get("/api/workspaces/#{id}")
24
24
  end
25
25
 
26
- def create(request)
27
- @http.post("/api/workspaces", body: request)
28
- end
29
-
30
- def update(data)
31
- @http.put("/api/workspaces/#{@ws_id.call}", body: data)
32
- end
33
-
34
26
  def list_members
35
27
  @http.get("/api/workspaces/#{@ws_id.call}/members")
36
28
  end
37
29
 
38
- def invite_member(email:, role: nil)
39
- body = { email: email }
40
- body[:role] = role if role
41
- @http.post("/api/workspaces/#{@ws_id.call}/members/invite", body: body)
30
+ # Invites a member. +role+ is a FlexOps::WorkspaceRole number or name (for example
31
+ # :member); it is required because the Gateway reads a missing role as Owner.
32
+ def invite_member(email:, role:)
33
+ @http.post("/api/workspaces/#{@ws_id.call}/members/invite",
34
+ body: { email: email, role: FlexOps::WorkspaceRole.to_wire(role) })
42
35
  end
43
36
 
44
37
  def remove_member(user_id)
45
38
  @http.delete("/api/workspaces/#{@ws_id.call}/members/#{user_id}")
46
39
  end
47
40
 
41
+ # Changes a member's role; +role+ is a FlexOps::WorkspaceRole number or name.
48
42
  def update_member_role(user_id, role)
49
- @http.put("/api/workspaces/#{@ws_id.call}/members/#{user_id}/role", body: { role: role })
43
+ @http.put("/api/workspaces/#{@ws_id.call}/members/#{user_id}/role",
44
+ body: { newRole: FlexOps::WorkspaceRole.to_wire(role) })
50
45
  end
51
46
  end
52
47
  end
@@ -7,5 +7,5 @@
7
7
  # ***********************************************************************
8
8
 
9
9
  module FlexOps
10
- VERSION = "1.1.0"
10
+ VERSION = "2.0.0"
11
11
  end
@@ -0,0 +1,25 @@
1
+ # ***********************************************************************
2
+ # Package : flexops
3
+ # Author : FlexOps, LLC
4
+ # Created : 2026-10-16
5
+ #
6
+ # Copyright (c) 2021-2026 by FlexOps, LLC. All rights reserved.
7
+ # ***********************************************************************
8
+
9
+ module FlexOps
10
+ # Event names FlexOps sends in the X-FlexOps-Event header and matches webhook
11
+ # subscriptions against.
12
+ #
13
+ # Names are PascalCase and matched exactly: a dotted name such as "label.created"
14
+ # matches nothing. TRACKING_UPDATED and TRACKING_DELIVERED are sent only to
15
+ # workspaces in the USPS push-tracking pilot.
16
+ module WebhookEventType
17
+ LABEL_CREATED = "LabelCreated"
18
+ LABEL_CANCELED = "LabelCanceled"
19
+ BATCH_COMPLETED = "BatchCompleted"
20
+ TRACKING_UPDATED = "TrackingUpdated"
21
+ TRACKING_DELIVERED = "TrackingDelivered"
22
+
23
+ ALL = [LABEL_CREATED, LABEL_CANCELED, BATCH_COMPLETED, TRACKING_UPDATED, TRACKING_DELIVERED].freeze
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ # ***********************************************************************
2
+ # Package : flexops
3
+ # Author : FlexOps, LLC
4
+ # Created : 2026-10-19
5
+ #
6
+ # Copyright (c) 2021-2026 by FlexOps, LLC. All rights reserved.
7
+ # ***********************************************************************
8
+
9
+ module FlexOps
10
+ # Workspace member role, sent as the Gateway WorkspaceRole number.
11
+ module WorkspaceRole
12
+ OWNER = 0
13
+ ADMIN = 1
14
+ MEMBER = 2
15
+ MERCHANT = 3
16
+
17
+ ALL = { OWNER: OWNER, ADMIN: ADMIN, MEMBER: MEMBER, MERCHANT: MERCHANT }.freeze
18
+
19
+ # Returns the wire number for a role given as a number or a name (:member, "Admin").
20
+ # Raises ArgumentError, before any request, for anything else.
21
+ def self.to_wire(role)
22
+ return role if role.is_a?(Integer) && ALL.value?(role)
23
+
24
+ if role.is_a?(String) || role.is_a?(Symbol)
25
+ number = ALL[role.to_s.upcase.to_sym]
26
+ return number unless number.nil?
27
+ end
28
+
29
+ raise ArgumentError, "Unknown workspace role: #{role.inspect}. Use one of #{ALL.keys.join(", ")} or 0-3."
30
+ end
31
+ end
32
+ end
data/lib/flexops.rb CHANGED
@@ -8,6 +8,9 @@
8
8
 
9
9
  require_relative "flexops/version"
10
10
  require_relative "flexops/errors"
11
+ require_relative "flexops/hazmat_classification"
12
+ require_relative "flexops/webhook_event_type"
13
+ require_relative "flexops/workspace_role"
11
14
  require_relative "flexops/http"
12
15
  require_relative "flexops/client"
13
16
  require_relative "flexops/resources/auth"
@@ -18,18 +21,17 @@ require_relative "flexops/resources/webhooks"
18
21
  require_relative "flexops/resources/wallet"
19
22
  require_relative "flexops/resources/insurance"
20
23
  require_relative "flexops/resources/returns"
21
- require_relative "flexops/resources/api_keys"
22
24
  require_relative "flexops/resources/analytics"
23
25
  require_relative "flexops/resources/orders"
24
26
  require_relative "flexops/resources/inventory"
25
27
  require_relative "flexops/resources/pickups"
26
28
  require_relative "flexops/resources/scan_forms"
27
29
  require_relative "flexops/resources/rules"
28
- require_relative "flexops/resources/offsets"
29
30
  require_relative "flexops/resources/hs_codes"
31
+ require_relative "flexops/resources/carbon_offsets"
30
32
  require_relative "flexops/resources/recurring_shipments"
33
+ require_relative "flexops/resources/report_schedules"
31
34
  require_relative "flexops/resources/email_templates"
32
- require_relative "flexops/resources/reports"
33
35
 
34
36
  module FlexOps
35
37
  end