Skip to content

Commit 7b3552c

Browse files
committed
feat(skus): add set-linking-limit support endpoint and CLI command
Adds a support operator endpoint and CLI command to set the per-order TLV2 credential batch limit (max_active_batches_tlv2_creds), enabling operators to increase the linked device cap for individual orders. Backend: - PATCH /v1/orders/{orderID}/credentials/batches/limit (supportMwr) - SetMaxActiveBatches repo method on order_items - SetLinkingLimit service method (validates paid + TLV2, runs in tx) - SetLinkingLimitReq model type CLI: - `bat-go skus set-linking-limit` with --order-id or --email lookup, --max, optional --item-id, --private-key, and subscriptions service flags when using email; shows current limit/active count before confirming
1 parent 03575ad commit 7b3552c

9 files changed

Lines changed: 330 additions & 3 deletions

File tree

services/skus/controllers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ func Router(
125125
cr.Method(http.MethodGet, "/batches/count", metricsMwr("CountBatches", authMwr(handlers.AppHandler(credh.CountBatches))))
126126
cr.Method(http.MethodGet, "/batches", metricsMwr("ListActiveBatches", supportMwr(handlers.AppHandler(credh.ListActiveBatches))))
127127
cr.Method(http.MethodDelete, "/batches", metricsMwr("DeleteBatches", supportMwr(handlers.AppHandler(credh.DeleteBatches))))
128+
cr.Method(http.MethodPatch, "/batches/limit", metricsMwr("SetLinkingLimit", supportMwr(handlers.AppHandler(credh.SetLinkingLimit))))
128129

129130
// Handle the old endpoint while the new is being rolled out:
130131
// - true: the handler uses itemID as the request id, which is the old mode;

services/skus/datastore.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ type orderItemStore interface {
114114
Get(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (*model.OrderItem, error)
115115
FindByOrderID(ctx context.Context, dbi sqlx.QueryerContext, orderID uuid.UUID) ([]model.OrderItem, error)
116116
InsertMany(ctx context.Context, dbi sqlx.ExtContext, items ...model.OrderItem) ([]model.OrderItem, error)
117+
SetMaxActiveBatches(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, max int) error
117118
}
118119

119120
type orderPayHistoryStore interface {

services/skus/handler/cred.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type tlv2Svc interface {
1919
UniqBatches(ctx context.Context, orderID, itemID uuid.UUID) (int, int, error)
2020
ListActiveBatches(ctx context.Context, orderID, itemID uuid.UUID) ([]model.TLV2ActiveBatch, error)
2121
DeleteBatches(ctx context.Context, orderID, itemID uuid.UUID, seats int) error
22+
SetLinkingLimit(ctx context.Context, orderID, itemID uuid.UUID, max int) error
2223
}
2324

2425
type Cred struct {
@@ -187,3 +188,65 @@ func (h *Cred) DeleteBatches(w http.ResponseWriter, r *http.Request) *handlers.A
187188

188189
return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK)
189190
}
191+
192+
// SetLinkingLimit updates the maximum number of active TLV2 credential batches (linked devices)
193+
// for an order. An optional item_id in the request body scopes the change to a specific item.
194+
//
195+
// PATCH /v1/orders/{orderID}/credentials/batches/limit
196+
func (h *Cred) SetLinkingLimit(w http.ResponseWriter, r *http.Request) *handlers.AppError {
197+
ctx := r.Context()
198+
199+
orderID, err := uuid.FromString(chi.URLParamFromCtx(ctx, "orderID"))
200+
if err != nil {
201+
return handlers.ValidationError("request", map[string]interface{}{"orderID": err.Error()})
202+
}
203+
204+
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, reqBodyLimit10MB))
205+
if err != nil {
206+
return handlers.WrapError(err, "failed to read request body", http.StatusBadRequest)
207+
}
208+
209+
var req model.SetLinkingLimitReq
210+
if err := json.Unmarshal(body, &req); err != nil {
211+
return handlers.WrapError(err, "failed to parse request body", http.StatusBadRequest)
212+
}
213+
214+
if req.Max <= 0 {
215+
return handlers.ValidationError("request", map[string]interface{}{"max": "must be a positive integer"})
216+
}
217+
218+
itemID := uuid.Nil
219+
if req.ItemID != "" {
220+
itemID, err = uuid.FromString(req.ItemID)
221+
if err != nil {
222+
return handlers.ValidationError("request", map[string]interface{}{"item_id": err.Error()})
223+
}
224+
}
225+
226+
if err := h.tlv2.SetLinkingLimit(ctx, orderID, itemID, req.Max); err != nil {
227+
lg := logging.Logger(ctx, "skus").With().Str("func", "SetLinkingLimit").Logger()
228+
229+
switch {
230+
case errors.Is(err, context.Canceled):
231+
return handlers.WrapError(err, "client ended request", model.StatusClientClosedConn)
232+
233+
case errors.Is(err, context.DeadlineExceeded):
234+
return handlers.WrapError(err, "request timed out", http.StatusGatewayTimeout)
235+
236+
case errors.Is(err, model.ErrOrderNotFound), errors.Is(err, model.ErrInvalidOrderNoItems), errors.Is(err, model.ErrOrderItemNotFound):
237+
return handlers.WrapError(err, "order not found", http.StatusNotFound)
238+
239+
case errors.Is(err, model.ErrOrderNotPaid):
240+
return handlers.WrapError(err, "order not paid", http.StatusPaymentRequired)
241+
242+
case errors.Is(err, model.ErrUnsupportedCredType):
243+
return handlers.WrapError(err, "credential type not supported", http.StatusBadRequest)
244+
245+
default:
246+
lg.Error().Err(err).Msg("failed to set linking limit")
247+
return handlers.WrapError(model.ErrSomethingWentWrong, "something went wrong", http.StatusInternalServerError)
248+
}
249+
}
250+
251+
return handlers.RenderContent(ctx, struct{}{}, w, http.StatusOK)
252+
}

services/skus/handler/cred_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type mockTLV2Svc struct {
2424
FnUniqBatches func(ctx context.Context, orderID, itemID uuid.UUID) (int, int, error)
2525
FnListActiveBatches func(ctx context.Context, orderID, itemID uuid.UUID) ([]model.TLV2ActiveBatch, error)
2626
FnDeleteBatches func(ctx context.Context, orderID, itemID uuid.UUID, seats int) error
27+
FnSetLinkingLimit func(ctx context.Context, orderID, itemID uuid.UUID, max int) error
2728
}
2829

2930
func (s *mockTLV2Svc) UniqBatches(ctx context.Context, orderID, itemID uuid.UUID) (int, int, error) {
@@ -50,6 +51,14 @@ func (s *mockTLV2Svc) DeleteBatches(ctx context.Context, orderID, itemID uuid.UU
5051
return s.FnDeleteBatches(ctx, orderID, itemID, seats)
5152
}
5253

54+
func (s *mockTLV2Svc) SetLinkingLimit(ctx context.Context, orderID, itemID uuid.UUID, max int) error {
55+
if s.FnSetLinkingLimit == nil {
56+
return nil
57+
}
58+
59+
return s.FnSetLinkingLimit(ctx, orderID, itemID, max)
60+
}
61+
5362
func TestCred_CountBatches(t *testing.T) {
5463
type tcGiven struct {
5564
ctx context.Context

services/skus/model/model.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,12 @@ type DeleteBatchesReq struct {
779779
ItemID string `json:"item_id"`
780780
}
781781

782+
// SetLinkingLimitReq is the request body for the set linking limit endpoint.
783+
type SetLinkingLimitReq struct {
784+
Max int `json:"max"`
785+
ItemID string `json:"item_id,omitempty"`
786+
}
787+
782788
// ReceiptRequest represents a receipt submitted by a mobile or web client.
783789
type ReceiptRequest struct {
784790
Type Vendor `json:"type" validate:"required,oneof=ios android"`

services/skus/service.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,6 +1374,48 @@ func (s *Service) deleteBatchesTx(ctx context.Context, dbi sqlx.ExecerContext, o
13741374
return s.tlv2Repo.DeleteOutboxByRequestIDs(ctx, dbi, orderID, requestIDs)
13751375
}
13761376

1377+
// SetLinkingLimit sets the maximum number of active TLV2 credential batches (linked devices)
1378+
// allowed for an order item. When itemID is uuid.Nil, the first item in the order is used.
1379+
func (s *Service) SetLinkingLimit(ctx context.Context, orderID, itemID uuid.UUID, max int) error {
1380+
ord, err := s.getOrderFullTx(ctx, s.Datastore.RawDB(), orderID)
1381+
if err != nil {
1382+
return err
1383+
}
1384+
1385+
if !ord.IsPaid() {
1386+
return model.ErrOrderNotPaid
1387+
}
1388+
1389+
if len(ord.Items) == 0 {
1390+
return model.ErrInvalidOrderNoItems
1391+
}
1392+
1393+
item := &ord.Items[0]
1394+
if !uuid.Equal(itemID, uuid.Nil) {
1395+
var ok bool
1396+
item, ok = ord.HasItem(itemID)
1397+
if !ok {
1398+
return model.ErrOrderItemNotFound
1399+
}
1400+
}
1401+
1402+
if !item.IsCredTLV2() {
1403+
return model.ErrUnsupportedCredType
1404+
}
1405+
1406+
tx, err := s.Datastore.RawDB().BeginTxx(ctx, nil)
1407+
if err != nil {
1408+
return err
1409+
}
1410+
defer s.Datastore.RollbackTx(tx)
1411+
1412+
if err := s.orderItemRepo.SetMaxActiveBatches(ctx, tx, item.ID, max); err != nil {
1413+
return err
1414+
}
1415+
1416+
return tx.Commit()
1417+
}
1418+
13771419
// isValidBatchReq validates that the order contains TLV2 credentials. When itemID is
13781420
// non-nil it checks that the specific item exists and is TLV2; otherwise it requires
13791421
// at least one TLV2 item in the order.

services/skus/storage/repository/mock.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,10 @@ func (r *MockOrder) IncrementNumPayFailed(ctx context.Context, dbi sqlx.ExecerCo
152152
}
153153

154154
type MockOrderItem struct {
155-
FnGet func(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (*model.OrderItem, error)
156-
FnFindByOrderID func(ctx context.Context, dbi sqlx.QueryerContext, orderID uuid.UUID) ([]model.OrderItem, error)
157-
FnInsertMany func(ctx context.Context, dbi sqlx.ExtContext, items ...model.OrderItem) ([]model.OrderItem, error)
155+
FnGet func(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (*model.OrderItem, error)
156+
FnFindByOrderID func(ctx context.Context, dbi sqlx.QueryerContext, orderID uuid.UUID) ([]model.OrderItem, error)
157+
FnInsertMany func(ctx context.Context, dbi sqlx.ExtContext, items ...model.OrderItem) ([]model.OrderItem, error)
158+
FnSetMaxActiveBatches func(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, max int) error
158159
}
159160

160161
func (r *MockOrderItem) Get(ctx context.Context, dbi sqlx.QueryerContext, id uuid.UUID) (*model.OrderItem, error) {
@@ -181,6 +182,14 @@ func (r *MockOrderItem) InsertMany(ctx context.Context, dbi sqlx.ExtContext, ite
181182
return r.FnInsertMany(ctx, dbi, items...)
182183
}
183184

185+
func (r *MockOrderItem) SetMaxActiveBatches(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, max int) error {
186+
if r.FnSetMaxActiveBatches == nil {
187+
return nil
188+
}
189+
190+
return r.FnSetMaxActiveBatches(ctx, dbi, id, max)
191+
}
192+
184193
type MockIssuer struct {
185194
FnGetByMerchID func(ctx context.Context, dbi sqlx.QueryerContext, merchID string) (*model.Issuer, error)
186195
FnGetByPubKey func(ctx context.Context, dbi sqlx.QueryerContext, pubKey string) (*model.Issuer, error)

services/skus/storage/repository/order_item.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ func (r *OrderItem) FindByOrderID(ctx context.Context, dbi sqlx.QueryerContext,
5353
return result, nil
5454
}
5555

56+
// SetMaxActiveBatches updates the max_active_batches_tlv2_creds column for the given order item.
57+
func (r *OrderItem) SetMaxActiveBatches(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, max int) error {
58+
const q = `UPDATE order_items SET max_active_batches_tlv2_creds = $2 WHERE id = $1`
59+
60+
if _, err := dbi.ExecContext(ctx, q, id, max); err != nil {
61+
return err
62+
}
63+
64+
return nil
65+
}
66+
5667
// InsertMany inserts given items and returns the result.
5768
func (r *OrderItem) InsertMany(ctx context.Context, dbi sqlx.ExtContext, items ...model.OrderItem) ([]model.OrderItem, error) {
5869
if len(items) == 0 {

0 commit comments

Comments
 (0)