From ddd0050504d0d8b1fec8e15b2c79ef17fea0d01c Mon Sep 17 00:00:00 2001 From: navin10sharma <3096611+navin10sharma@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:21:46 +0530 Subject: [PATCH 1/2] feat: add Performance Groups server resource --- lib/seatlayer.rb | 5 +- lib/seatlayer/performance_groups.rb | 105 ++++++++++++++++++++++++++++ spec/client_spec.rb | 65 +++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 lib/seatlayer/performance_groups.rb diff --git a/lib/seatlayer.rb b/lib/seatlayer.rb index 8facd0e..2eb7fc8 100644 --- a/lib/seatlayer.rb +++ b/lib/seatlayer.rb @@ -6,6 +6,7 @@ require_relative "seatlayer/resources" require_relative "seatlayer/inventory" require_relative "seatlayer/channels" +require_relative "seatlayer/performance_groups" require_relative "seatlayer/webhook" # Official Ruby server SDK for the SeatLayer reserved-seating API. @@ -19,7 +20,8 @@ module SeatLayer # The SeatLayer client. class Client - attr_reader :charts, :events, :inventory, :channels, :sessions, :webhooks, :workspaces, :templates + attr_reader :charts, :events, :inventory, :channels, :performance_groups, :sessions, + :webhooks, :workspaces, :templates def initialize(secret_key, base_url: HTTPClient::DEFAULT_BASE_URL, max_retries: HTTPClient::DEFAULT_MAX_RETRIES, @@ -31,6 +33,7 @@ def initialize(secret_key, base_url: HTTPClient::DEFAULT_BASE_URL, @events = Events.new(@http) @inventory = Inventory.new(@http) @channels = Channels.new(@http) + @performance_groups = PerformanceGroups.new(@http) @sessions = Sessions.new(@http) @templates = Templates.new(@http) @webhooks = Webhooks.new(@http) diff --git a/lib/seatlayer/performance_groups.rb b/lib/seatlayer/performance_groups.rb new file mode 100644 index 0000000..d949991 --- /dev/null +++ b/lib/seatlayer/performance_groups.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module SeatLayer + # Fixed multi-performance runs. This is a secret-key surface: mint the + # browser token here, then give that token (never this client) to the + # PerformanceGroupPicker in the browser SDK. + class PerformanceGroups < Resource + # One page of fixed runs. + def list(workspace_id: nil, external_ref: nil, state: nil, limit: nil, cursor: nil) + @client.get("/v1/performance-groups", compact({ "workspaceId" => workspace_id, + "externalRef" => external_ref, + "state" => state, "limit" => limit, + "cursor" => cursor })) + end + + # Create a draft from two to eight compatible assigned-seat events. The + # server validates compatibility and exactly replays a request with the + # same idempotency key. + def create(name:, event_keys:, external_ref: UNSET, idempotency_key: nil) + body = { "name" => name, "eventKeys" => event_keys } + body.merge!(supplied({ "externalRef" => external_ref })) + @client.post("/v1/performance-groups", body, idempotency_key: idempotency_key, + retry_policy: :header_replay) + end + + def retrieve(performance_group_key) + @client.get(path(performance_group_key)) + end + + # Only a draft can be deleted. Activated runs retain their audit identity. + def delete(performance_group_key) + @client.delete(path(performance_group_key)) + end + + # Starts lifecycle coordination. When its response contains a non-terminal + # lifecycle operation, poll +retrieve_lifecycle+ until it completes. + def activate(performance_group_key, expected_revision:) + @client.post(path(performance_group_key, "/activate"), + { "expectedRevision" => expected_revision }) + end + + # Stops new group sales. Poll +retrieve_lifecycle+ while the close remains pending. + def close(performance_group_key, expected_revision:) + @client.post(path(performance_group_key, "/close"), + { "expectedRevision" => expected_revision }) + end + + def retrieve_lifecycle(performance_group_key, operation_id) + @client.get(path(performance_group_key, "/lifecycle/#{encode(operation_id)}")) + end + + # Reveals a one-time, origin-bound browser bearer. It intentionally remains + # single-attempt: a retry could create a token whose only reveal is lost. + def create_buyer_access_session(performance_group_key, allowed_origin:, include_public:, + channel_ids_by_event: nil, expires_in_seconds: nil, + max_quantity: UNSET, buyer_ref: UNSET, partner_ref: UNSET) + body = compact({ "allowedOrigin" => allowed_origin, "includePublic" => include_public, + "channelIdsByEvent" => channel_ids_by_event, + "expiresInSeconds" => expires_in_seconds }) + body.merge!(supplied({ "maxQuantity" => max_quantity, "buyerRef" => buyer_ref, + "partnerRef" => partner_ref })) + @client.post(path(performance_group_key, "/buyer-access-sessions"), body) + end + + # Token records only: the bearer value is never returned again. + def list_buyer_access_sessions(performance_group_key, limit: nil) + @client.get(path(performance_group_key, "/buyer-access-sessions"), { "limit" => limit }) + end + + def revoke_buyer_access_session(performance_group_key, session_id) + @client.delete(path(performance_group_key, "/buyer-access-sessions/#{encode(session_id)}")) + end + + # Read the trusted server projection before charging; do not price from + # client input or the picker state. + def retrieve_hold(performance_group_key, operation_id) + @client.get(path(performance_group_key, "/holds/#{encode(operation_id)}")) + end + + # Confirm external payment for a committed hold. Keep both identifiers + # stable, and poll +retrieve_booking+ if the response is +book_pending+. + def book_hold(performance_group_key, operation_id, book_action_id:, booking_ref:) + @client.post(path(performance_group_key, "/holds/#{encode(operation_id)}/book"), + { "bookActionId" => book_action_id, + "bookingRef" => normalise_booking_ref(booking_ref) }) + end + + def retrieve_booking(performance_group_key, action_id) + @client.get(path(performance_group_key, "/bookings/#{encode(action_id)}")) + end + + private + + def path(performance_group_key, suffix = "") + "/v1/performance-groups/#{encode(performance_group_key)}#{suffix}" + end + + def normalise_booking_ref(booking_ref) + value = booking_ref&.strip + return value unless value.nil? || value.empty? + + raise ArgumentError, "booking_ref is required and must be a non-empty stable reference" + end + end +end diff --git a/spec/client_spec.rb b/spec/client_spec.rb index dd567ad..70a161e 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -80,6 +80,71 @@ body = JSON.parse(transport.calls[0].body) expect(body.keys).to eq(["chartId"]) end + + it "maps the complete trusted performance-groups workflow" do + client, transport = build_client([ + { status: 200, body: '{"performanceGroups":[]}' }, + { status: 201, body: '{"performanceGroup":{"key":"pg_1"}}' }, + { status: 200, body: '{"performanceGroup":{"key":"pg_1"}}' }, + { status: 204 }, + { status: 202, + body: '{"lifecycleOperation":{"operationId":"op_a"}}' }, + { status: 202, + body: '{"lifecycleOperation":{"operationId":"op_c"}}' }, + { status: 200, + body: '{"lifecycleOperation":{"operationId":"op_a"}}' }, + { status: 201, body: '{"buyerAccessSession":{"token":"one_time"}}' }, + { status: 200, body: '{"buyerAccessSessions":[]}' }, + { status: 200, body: '{"buyerAccessSession":{"id":"bas_1"}}' }, + { status: 200, body: '{"hold":{"operationId":"hold_1"}}' }, + { status: 202, body: '{"booking":{"actionId":"book_1"}}' }, + { status: 200, body: '{"booking":{"actionId":"book_1"}}' } + ]) + groups = client.performance_groups + + groups.list(workspace_id: "ws_1", external_ref: "bundle-42", state: "active", limit: 5, + cursor: "next_1") + groups.create(name: "Three-night run", event_keys: %w[ev_1 ev_2], external_ref: "bundle-42") + groups.retrieve("pg/a") + groups.delete("pg/a") + groups.activate("pg/a", expected_revision: 3) + groups.close("pg/a", expected_revision: 4) + groups.retrieve_lifecycle("pg/a", "op/activate") + groups.create_buyer_access_session( + "pg/a", allowed_origin: "https://tickets.example", include_public: false, + channel_ids_by_event: { "ev_1" => ["ch_1"] }, expires_in_seconds: 600, + max_quantity: 4, buyer_ref: "buyer_1", partner_ref: nil + ) + groups.list_buyer_access_sessions("pg/a", limit: 10) + groups.revoke_buyer_access_session("pg/a", "bas/1") + groups.retrieve_hold("pg/a", "hold/1") + groups.book_hold("pg/a", "hold/1", book_action_id: "book/1", booking_ref: "order-9") + groups.retrieve_booking("pg/a", "book/1") + + expect(transport.calls[0].url).to eq( + "https://api.seatlayer.io/v1/performance-groups?workspaceId=ws_1&externalRef=bundle-42&state=active&limit=5&cursor=next_1" + ) + expect(transport.calls[1].headers["Idempotency-Key"]) + .to match(/\A[A-Za-z0-9._:-]{1,128}\z/) + expect(transport.calls[2].url).to end_with("/v1/performance-groups/pg%2Fa") + expect(transport.calls[3].http_method).to eq("DELETE") + expect(transport.calls[4].body).to eq('{"expectedRevision":3}') + expect(transport.calls[5].body).to eq('{"expectedRevision":4}') + expect(transport.calls[6].url).to end_with("/lifecycle/op%2Factivate") + expect(transport.calls[7].headers).not_to have_key("Idempotency-Key") + expect(JSON.parse(transport.calls[7].body)).to include( + "includePublic" => false, "maxQuantity" => 4, "partnerRef" => nil + ) + expect(transport.calls[8].url).to end_with("/buyer-access-sessions?limit=10") + expect(transport.calls[9].url).to end_with("/buyer-access-sessions/bas%2F1") + expect(transport.calls[10].url).to end_with("/holds/hold%2F1") + expect(transport.calls[11].headers).not_to have_key("Idempotency-Key") + expect(transport.calls[11].url).to end_with("/holds/hold%2F1/book") + expect(JSON.parse(transport.calls[11].body)).to eq( + "bookActionId" => "book/1", "bookingRef" => "order-9" + ) + expect(transport.calls[12].url).to end_with("/bookings/book%2F1") + end end describe "errors" do From c007d5effa58f4a72872d62cb48ed5f5891d2d22 Mon Sep 17 00:00:00 2001 From: navin10sharma <3096611+navin10sharma@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:27:52 +0530 Subject: [PATCH 2/2] release: prepare 0.5.0 --- .github/workflows/release.yml | 4 ++-- CHANGELOG.md | 8 ++++++++ lib/seatlayer/version.rb | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e5808d..b22a47e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,8 +60,8 @@ jobs: exit 1 fi count=$(wc -l < /tmp/contents.txt) - if [ "$count" -ne 11 ]; then - echo "::error::expected 11 files in the gem, found $count" + if [ "$count" -ne 12 ]; then + echo "::error::expected 12 files in the gem, found $count" exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 52346ba..f9e776e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +## 0.5.0 — 2026-08-21 + +- Added `performance_groups`, the trusted server resource for fixed two-to-eight + performance runs. It creates and activates groups, mints one-time browser + access, retrieves authoritative group holds, and confirms bookings with + stable action and order references. Browser-only group routes remain outside + this secret-key SDK. + - Added `templates.instantiate_template` and the ticket-release lifecycle on `events` (`list_ticket_releases`, `update_ticket_releases`, and `close_ticket_release`). Template instantiation sends `{}` when no overrides diff --git a/lib/seatlayer/version.rb b/lib/seatlayer/version.rb index 50771a1..208ee73 100644 --- a/lib/seatlayer/version.rb +++ b/lib/seatlayer/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SeatLayer - VERSION = "0.4.0" + VERSION = "0.5.0" end