wiq-cli 0.1.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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class Workflows < Base
6
+ desc "list", "List all documented workflows"
7
+ long_desc <<~DESC
8
+ Workflows are named recipes that map a human question to a CLI
9
+ command sequence. Use them to bootstrap agent flows for common
10
+ club-admin tasks.
11
+
12
+ Each workflow carries:
13
+ name kebab-case slug (the lookup key)
14
+ category attendance | leads | roster | finance |
15
+ memberships | fundraising | store
16
+ question natural-language framing — match against this
17
+ parameters structured list of {name, type, required, ...}
18
+ recipe ordered command strings with <placeholder> and
19
+ [optional] brackets
20
+ admin_only true means non-admin coach PATs will 403
21
+ notes when present, agent-facing guidance
22
+
23
+ Placeholder syntax in recipes:
24
+ <name> required substitution (must match a parameter)
25
+ [--flag <name>] optional bracket — drop the whole thing when
26
+ the parameter isn't provided
27
+
28
+ Run `wiq workflows show <name>` for a single workflow's full detail.
29
+ DESC
30
+ def list
31
+ rows = Wiq::Workflows::ALL.values.map { |w| summary_row(w) }
32
+ render_index(
33
+ rows,
34
+ summary: "#{rows.size} documented workflows across #{Wiq::Workflows::CATEGORIES.size} categories.",
35
+ breadcrumbs: [
36
+ { "cmd" => "wiq workflows show <name>", "description" => "Detail for one workflow" },
37
+ { "cmd" => "wiq commands", "description" => "Full command tree (for ad-hoc composition)" }
38
+ ]
39
+ )
40
+ end
41
+
42
+ desc "show NAME", "Show a single workflow's full detail"
43
+ long_desc <<~DESC
44
+ Returns the complete workflow definition: question, parameters
45
+ (with types and required/default), the recipe (ordered command
46
+ list), admin_only flag, and notes.
47
+
48
+ Use this after `wiq workflows list` narrows you to a candidate.
49
+ DESC
50
+ def show(name)
51
+ workflow = Wiq::Workflows::ALL[name]
52
+ unless workflow
53
+ available = Wiq::Workflows::ALL.keys.sort.join(", ")
54
+ raise Wiq::Error.new(
55
+ "Unknown workflow #{name.inspect}.",
56
+ code: "workflow_not_found",
57
+ hint: "Available: #{available}"
58
+ )
59
+ end
60
+
61
+ render(workflow,
62
+ summary: "#{workflow[:name]} — #{workflow[:question]}",
63
+ breadcrumbs: workflow[:recipe].map do |cmd|
64
+ { "cmd" => cmd, "description" => "Run this step" }
65
+ end)
66
+ end
67
+
68
+ default_task :list
69
+
70
+ no_commands do
71
+ def summary_row(workflow)
72
+ {
73
+ "name" => workflow[:name],
74
+ "category" => workflow[:category],
75
+ "question" => workflow[:question],
76
+ "admin_only" => workflow[:admin_only] == true,
77
+ "step_count" => workflow[:recipe].size,
78
+ "parameter_count" => workflow[:parameters].size
79
+ }
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class Wrestlers < Base
6
+ # Match WrestlerProfile constants in app/models/wrestler_profile.rb.
7
+ PROFILE_TYPES = %w[teammate alumnus guest all].freeze
8
+ DEFAULT_PER_PAGE = 20
9
+
10
+ desc "list", "Search wrestlers — narrow ID-discovery surface"
11
+ long_desc <<~DESC
12
+ Narrow listing + filtering, primarily for ID discovery before
13
+ running another command (e.g. `wiq check_ins wrestler <id>`).
14
+ Default page size is 20; there's no --all flag. For exhaustive
15
+ exports use `wiq reports run RosterReport [--append-properties …]`
16
+ or `wiq reports run FullExportWrestlerReport`.
17
+
18
+ Filters translate to Ransack params server-side:
19
+ --query free-text name search (legacy `?query=`)
20
+ --first-name q[first_name_cont]
21
+ --last-name q[last_name_cont]
22
+ --roster <id> q[rosters_id_eq] — filter to one roster
23
+ --weight-class q[weight_class_numeric_eq] — exact numeric
24
+ --academic-class q[academic_class_eq] (senior, junior, …)
25
+ --age q[age_eq]
26
+ --profile-type teammate (default) | alumnus | guest | all
27
+
28
+ Multi-roster intersection ("kids on both A AND B") is supported
29
+ by the API but not yet exposed in the CLI — use the web UI for
30
+ now or compose two `--roster <id>` calls and intersect
31
+ client-side.
32
+
33
+ --expand opts into a wider payload:
34
+ rosters Adds full roster details per wrestler
35
+ registration_answers Adds intake-form answers per wrestler
36
+
37
+ Base payload is already wider than most index endpoints (parents,
38
+ coach_guardians, profile_photos, basic roster refs all render by
39
+ default). For agents on a tight context window, prefer
40
+ `wiq wrestlers show <id>` once you've narrowed to a candidate.
41
+ DESC
42
+ method_option :query, type: :string, desc: "Free-text name search"
43
+ method_option :first_name, type: :string, desc: "First name (contains)"
44
+ method_option :last_name, type: :string, desc: "Last name (contains)"
45
+ method_option :roster, type: :numeric, desc: "Filter to a single roster id"
46
+ method_option :weight_class, type: :string,
47
+ desc: "Weight class (exact numeric, e.g. 132)"
48
+ method_option :academic_class, type: :string,
49
+ desc: "senior, junior, sophomore, freshman, …"
50
+ method_option :age, type: :numeric, desc: "Age in years (exact)"
51
+ method_option :profile_type, type: :string, enum: PROFILE_TYPES, default: "teammate",
52
+ desc: "Default 'teammate' matches the WIQ web UI default; " \
53
+ "'all' removes the filter entirely"
54
+ method_option :expand, type: :string,
55
+ desc: "CSV: rosters, registration_answers"
56
+ method_option :per_page, type: :numeric, default: DEFAULT_PER_PAGE,
57
+ desc: "Page size (default 20; narrow surface)"
58
+ def list
59
+ params = build_list_params
60
+
61
+ records, total = fetch_index("/api/v1/wrestlers", params, key: "wrestlers")
62
+ render_index(
63
+ records, total: total,
64
+ summary: "Listed #{records.size} wrestlers#{summary_filters_suffix}.",
65
+ breadcrumbs: [
66
+ { "cmd" => "wiq wrestlers show <id>", "description" => "Drill into one wrestler" },
67
+ { "cmd" => "wiq check_ins wrestler <id>", "description" => "See a wrestler's check-in history" },
68
+ { "cmd" => "wiq reports run RosterReport --roster 0 --append-properties <q_ids>",
69
+ "description" => "For exhaustive exports, use a report instead" }
70
+ ]
71
+ )
72
+ end
73
+
74
+ desc "show ID", "Fetch a single wrestler profile"
75
+ long_desc <<~DESC
76
+ Single-wrestler drill-down. Returns the full base payload
77
+ (basic profile, parents, coach_guardians, profile_photos,
78
+ rosters refs).
79
+
80
+ Pass --expand for additional sections:
81
+ rosters Full roster details with tags
82
+ registration_answers Intake-form answers
83
+ DESC
84
+ method_option :expand, type: :string,
85
+ desc: "CSV: rosters, registration_answers"
86
+ def show(id)
87
+ params = {}
88
+ params["expand_rosters"] = true if expand_includes?("rosters")
89
+ params["expand_registration_answers"] = true if expand_includes?("registration_answers")
90
+ wrestler = client.get("/api/v1/wrestlers/#{id}", params)
91
+ render(wrestler,
92
+ summary: "Wrestler #{wrestler["id"]} — #{wrestler["full_name"] || wrestler["display_name"]}",
93
+ breadcrumbs: [
94
+ { "cmd" => "wiq check_ins wrestler #{wrestler["id"]}",
95
+ "description" => "Check-in history for this wrestler" },
96
+ { "cmd" => "wiq registrations answers --profile #{wrestler["id"]} --profile-type WrestlerProfile",
97
+ "description" => "Registration answers (subject to visibility gating)" }
98
+ ])
99
+ end
100
+
101
+ no_commands do
102
+ # Extracted so the spec can verify the option → query-param mapping
103
+ # without round-tripping a real Faraday connection.
104
+ def build_list_params
105
+ params = { "per_page" => options[:per_page] || DEFAULT_PER_PAGE }
106
+ params["query"] = options[:query] if options[:query]
107
+ params["q[first_name_cont]"] = options[:first_name] if options[:first_name]
108
+ params["q[last_name_cont]"] = options[:last_name] if options[:last_name]
109
+ params["q[rosters_id_eq]"] = options[:roster] if options[:roster]
110
+ params["q[weight_class_numeric_eq]"] = options[:weight_class] if options[:weight_class]
111
+ params["q[academic_class_eq]"] = options[:academic_class] if options[:academic_class]
112
+ params["q[age_eq]"] = options[:age] if options[:age]
113
+ params["q[profile_type_eq]"] = options[:profile_type] if profile_type_filter?
114
+ params["expand_rosters"] = true if expand_includes?("rosters")
115
+ params["expand_registration_answers"] = true if expand_includes?("registration_answers")
116
+ params
117
+ end
118
+
119
+ def profile_type_filter?
120
+ options[:profile_type] && options[:profile_type] != "all"
121
+ end
122
+
123
+ def expand_includes?(part)
124
+ return false unless options[:expand]
125
+
126
+ options[:expand].split(",").map(&:strip).include?(part)
127
+ end
128
+
129
+ def summary_filters_suffix
130
+ bits = []
131
+ bits << "matching #{options[:query].inspect}" if options[:query]
132
+ bits << "in roster #{options[:roster]}" if options[:roster]
133
+ bits << "weight class #{options[:weight_class]}" if options[:weight_class]
134
+ bits << "profile_type=#{options[:profile_type]}" if profile_type_filter? && options[:profile_type] != "teammate"
135
+ bits.empty? ? "" : " (#{bits.join(", ")})"
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
data/lib/wiq/config.rb ADDED
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Wiq
6
+ # Resolves host + alias + token from the documented precedence chain.
7
+ #
8
+ # Host:
9
+ # --host > WIQ_HOST > .wiq/config.json:host > sole stored host
10
+ # > PRODUCTION_HOST
11
+ #
12
+ # Alias (only meaningful once host is known):
13
+ # --as > WIQ_ALIAS > .wiq/config.json:alias > sole alias for host > "default"
14
+ #
15
+ # Token:
16
+ # WIQ_TOKEN (direct override) > credentials store (host, alias)
17
+ class Config
18
+ PRODUCTION_HOST = "https://www.wrestlingiq.com"
19
+
20
+ attr_reader :host, :alias_name, :token, :sources
21
+
22
+ def self.load(options = {})
23
+ new(options).tap(&:resolve!)
24
+ end
25
+
26
+ def initialize(options = {})
27
+ @options = options || {}
28
+ @sources = {}
29
+ end
30
+
31
+ def resolve!
32
+ @host = resolve_host
33
+ @alias_name = resolve_alias if @host
34
+ @token = resolve_token
35
+ self
36
+ end
37
+
38
+ def require_host!
39
+ raise HostUnsetError unless @host
40
+ end
41
+
42
+ def require_token!
43
+ require_host!
44
+ return if @token
45
+
46
+ # No env override, no usable alias-keyed entry — decide which error to raise.
47
+ aliases = Credentials.aliases_for(@host)
48
+ if aliases.empty?
49
+ raise NotAuthenticatedError
50
+ elsif @alias_name && !Credentials.for_host(@host, @alias_name)
51
+ raise AliasNotFoundError.new(@host, @alias_name, aliases)
52
+ elsif @alias_name.nil?
53
+ raise AmbiguousAliasError.new(@host, aliases)
54
+ else
55
+ raise NotAuthenticatedError
56
+ end
57
+ end
58
+
59
+ def trace
60
+ {
61
+ host: @host,
62
+ host_source: @sources[:host],
63
+ alias: @alias_name,
64
+ alias_source: @sources[:alias],
65
+ token_present: !@token.nil?,
66
+ token_source: @sources[:token],
67
+ credentials_path: Credentials.path,
68
+ repo_config_path: repo_config_path
69
+ }
70
+ end
71
+
72
+ private
73
+
74
+ def resolve_host
75
+ if (val = @options[:host])
76
+ @sources[:host] = "--host flag"
77
+ return val
78
+ end
79
+ if (val = ENV["WIQ_HOST"])
80
+ @sources[:host] = "WIQ_HOST env"
81
+ return val
82
+ end
83
+ if (val = repo_config["host"])
84
+ @sources[:host] = "repo config (#{repo_config_path})"
85
+ return val
86
+ end
87
+ hosts = Credentials.hosts
88
+ if hosts.size == 1
89
+ @sources[:host] = "credentials store (sole host)"
90
+ return hosts.first
91
+ end
92
+ @sources[:host] = "production default"
93
+ PRODUCTION_HOST
94
+ end
95
+
96
+ def resolve_alias
97
+ if (val = @options[:as])
98
+ @sources[:alias] = "--as flag"
99
+ return val
100
+ end
101
+ if (val = ENV["WIQ_ALIAS"])
102
+ @sources[:alias] = "WIQ_ALIAS env"
103
+ return val
104
+ end
105
+ if (val = repo_config["alias"])
106
+ @sources[:alias] = "repo config (#{repo_config_path})"
107
+ return val
108
+ end
109
+ aliases = Credentials.aliases_for(@host)
110
+ if aliases.size == 1
111
+ @sources[:alias] = "credentials store (sole alias)"
112
+ return aliases.first
113
+ end
114
+ if aliases.include?(Credentials::DEFAULT_ALIAS)
115
+ @sources[:alias] = "credentials store (default)"
116
+ return Credentials::DEFAULT_ALIAS
117
+ end
118
+ @sources[:alias] = aliases.empty? ? "no credentials stored" : "ambiguous (#{aliases.join(", ")})"
119
+ nil
120
+ end
121
+
122
+ def resolve_token
123
+ return nil unless @host
124
+
125
+ if (val = ENV["WIQ_TOKEN"])
126
+ @sources[:token] = "WIQ_TOKEN env"
127
+ return val
128
+ end
129
+ return nil unless @alias_name
130
+
131
+ entry = Credentials.for_host(@host, @alias_name)
132
+ return nil unless entry && entry["token"]
133
+
134
+ @sources[:token] = "credentials store (alias=#{@alias_name})"
135
+ entry["token"]
136
+ end
137
+
138
+ def repo_config
139
+ @repo_config ||= load_repo_config
140
+ end
141
+
142
+ def repo_config_path
143
+ @repo_config_path
144
+ end
145
+
146
+ def load_repo_config
147
+ dir = Dir.pwd
148
+ while dir != "/" && !dir.empty?
149
+ candidate = File.join(dir, ".wiq", "config.json")
150
+ if File.exist?(candidate)
151
+ @repo_config_path = candidate
152
+ begin
153
+ return JSON.parse(File.read(candidate))
154
+ rescue JSON::ParserError
155
+ return {}
156
+ end
157
+ end
158
+ parent = File.dirname(dir)
159
+ break if parent == dir
160
+ dir = parent
161
+ end
162
+ {}
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+
7
+ module Wiq
8
+ # File-backed credential store. ~/.config/wiq/credentials.json, mode 0600.
9
+ # Shape:
10
+ # {
11
+ # "<host>": {
12
+ # "<alias>": { "token": "...", "token_prefix": "...", "name": "...",
13
+ # "profile": {...}, "stored_at": "..." },
14
+ # "<alias>": { ... }
15
+ # }
16
+ # }
17
+ module Credentials
18
+ DEFAULT_PATH = File.join(Dir.home, ".config", "wiq", "credentials.json")
19
+ DEFAULT_ALIAS = "default"
20
+
21
+ module_function
22
+
23
+ def path
24
+ ENV["WIQ_CREDENTIALS_PATH"] || DEFAULT_PATH
25
+ end
26
+
27
+ def load_all
28
+ return {} unless File.exist?(path)
29
+
30
+ JSON.parse(File.read(path))
31
+ rescue JSON::ParserError
32
+ {}
33
+ end
34
+
35
+ def for_host(host, alias_name)
36
+ load_all.dig(host, alias_name)
37
+ end
38
+
39
+ def aliases_for(host)
40
+ Array((load_all[host] || {}).keys)
41
+ end
42
+
43
+ def hosts
44
+ load_all.keys
45
+ end
46
+
47
+ # Flat list across hosts × aliases. Useful for `wiq auth list`. Excludes the
48
+ # raw token; callers should never log tokens.
49
+ def all_entries
50
+ load_all.flat_map do |host, by_alias|
51
+ by_alias.map do |alias_name, entry|
52
+ {
53
+ "host" => host,
54
+ "alias" => alias_name,
55
+ "token_prefix" => entry["token_prefix"],
56
+ "name" => entry["name"],
57
+ "profile" => entry["profile"],
58
+ "stored_at" => entry["stored_at"]
59
+ }
60
+ end
61
+ end
62
+ end
63
+
64
+ def store(host:, alias_name:, token:, token_prefix: nil, name: nil, profile: nil)
65
+ data = load_all
66
+ data[host] ||= {}
67
+ data[host][alias_name] = {
68
+ "token" => token,
69
+ "token_prefix" => token_prefix,
70
+ "name" => name,
71
+ "profile" => profile,
72
+ "stored_at" => Time.now.utc.iso8601
73
+ }.compact
74
+ write(data)
75
+ end
76
+
77
+ def remove(host, alias_name)
78
+ data = load_all
79
+ bucket = data[host]
80
+ return false unless bucket
81
+ return false unless bucket.delete(alias_name)
82
+
83
+ data.delete(host) if bucket.empty?
84
+ write(data)
85
+ true
86
+ end
87
+
88
+ def write(data)
89
+ FileUtils.mkdir_p(File.dirname(path))
90
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
91
+ f.write(JSON.pretty_generate(data))
92
+ end
93
+ end
94
+ end
95
+ end
data/lib/wiq/errors.rb ADDED
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ class Error < StandardError
5
+ attr_reader :code, :hint, :exit_code, :details
6
+
7
+ def initialize(message, code: "error", hint: nil, exit_code: 1, details: nil)
8
+ super(message)
9
+ @code = code
10
+ @hint = hint
11
+ @exit_code = exit_code
12
+ @details = details
13
+ end
14
+ end
15
+
16
+ class ConfigError < Error
17
+ def initialize(message, code: "config_error", **opts)
18
+ super(message, code: code, **opts)
19
+ end
20
+ end
21
+
22
+ class HostUnsetError < ConfigError
23
+ def initialize
24
+ super(
25
+ "No WIQ host configured.",
26
+ code: "host_unset",
27
+ hint: "Run `wiq auth login` to configure a host and token, or pass --host."
28
+ )
29
+ end
30
+ end
31
+
32
+ class NotAuthenticatedError < ConfigError
33
+ def initialize
34
+ super(
35
+ "No personal access token configured.",
36
+ code: "not_authenticated",
37
+ hint: "Run `wiq auth login` to store a PAT for this host."
38
+ )
39
+ end
40
+ end
41
+
42
+ class AmbiguousAliasError < ConfigError
43
+ def initialize(host, aliases)
44
+ super(
45
+ "Multiple credential aliases stored for #{host}: #{aliases.join(", ")}.",
46
+ code: "ambiguous_alias",
47
+ hint: "Pass --as <alias>, set WIQ_ALIAS, or add `alias` to .wiq/config.json. " \
48
+ "Run `wiq auth list` to see what's stored."
49
+ )
50
+ end
51
+ end
52
+
53
+ class AliasNotFoundError < ConfigError
54
+ def initialize(host, alias_name, available)
55
+ hint = if available.empty?
56
+ "No credentials stored for this host. Run `wiq auth login`."
57
+ else
58
+ "Available aliases for #{host}: #{available.join(", ")}."
59
+ end
60
+ super(
61
+ "No credential stored for #{host} under alias #{alias_name.inspect}.",
62
+ code: "alias_not_found",
63
+ hint: hint
64
+ )
65
+ end
66
+ end
67
+
68
+ class AliasConflictError < ConfigError
69
+ def initialize(host, alias_name)
70
+ super(
71
+ "An entry already exists for #{host} under alias #{alias_name.inspect}.",
72
+ code: "alias_conflict",
73
+ hint: "Pass --as <other-name> to store under a different slot, or --force to overwrite."
74
+ )
75
+ end
76
+ end
77
+
78
+ # Mirror of the HTTP error envelope from /api/v1.
79
+ class APIError < Error
80
+ attr_reader :status, :response_body, :request_id
81
+
82
+ def initialize(status:, body:, request_id: nil)
83
+ @status = status
84
+ @response_body = body
85
+ @request_id = request_id
86
+
87
+ code, message, hint = derive(status, body)
88
+ super(message, code: code, hint: hint, exit_code: 1, details: body)
89
+ end
90
+
91
+ private
92
+
93
+ def derive(status, body)
94
+ msgs = extract_messages(body)
95
+ case status
96
+ when 401
97
+ ["unauthorized", "Token rejected by server (401).",
98
+ "Run `wiq auth status` to inspect the active token; `wiq auth login` to replace it."]
99
+ when 403
100
+ ["forbidden", "Server denied access (403): #{msgs}",
101
+ "PATs inherit the user's permissions. Confirm the minting user can see this resource in the web app."]
102
+ when 404
103
+ ["not_found", "Resource not found (404).", nil]
104
+ when 422
105
+ ["validation_failed", "Validation error (422): #{msgs}", nil]
106
+ when 429
107
+ ["rate_limited", "Rate limited (429): #{msgs}",
108
+ "WIQ enforces 100 req/3s per IP. Back off and retry."]
109
+ else
110
+ ["http_#{status}", "HTTP #{status}: #{msgs}", nil]
111
+ end
112
+ end
113
+
114
+ # /api/v1 always returns { "errors": <array|hash> }. Flatten to a human string.
115
+ def extract_messages(body)
116
+ return body.to_s unless body.is_a?(Hash)
117
+
118
+ errs = body["errors"] || body[:errors]
119
+ return "no error body" if errs.nil?
120
+
121
+ case errs
122
+ when Array
123
+ errs.join("; ")
124
+ when Hash
125
+ errs.flat_map { |field, msgs| Array(msgs).map { |m| "#{field}: #{m}" } }.join("; ")
126
+ else
127
+ errs.to_s
128
+ end
129
+ end
130
+ end
131
+
132
+ class ReportFailedError < Error
133
+ def initialize(report)
134
+ super(
135
+ "Report ##{report["id"]} (#{report["type"]}) finished with status `failed`.",
136
+ code: "report_failed",
137
+ details: report["result"]
138
+ )
139
+ end
140
+ end
141
+
142
+ class ReportTimeoutError < Error
143
+ def initialize(report, timeout)
144
+ super(
145
+ "Report ##{report["id"]} did not finish within #{timeout}s (last status: #{report["status"]}).",
146
+ code: "report_timeout",
147
+ hint: "Pass --timeout to wait longer, or re-check later with `wiq reports show #{report["id"]}`.",
148
+ details: report
149
+ )
150
+ end
151
+ end
152
+
153
+ class SeasonNotFoundError < Error
154
+ def initialize(year)
155
+ super(
156
+ "No paid sessions overlap year #{year}.",
157
+ code: "season_not_found",
158
+ hint: "Run `wiq paid_sessions list` to see configured registration periods."
159
+ )
160
+ end
161
+ end
162
+
163
+ class SeasonUnsupportedError < Error
164
+ def initialize(type, matches)
165
+ super(
166
+ "Season #{matches.size > 1 ? "is ambiguous" : "cannot be applied automatically"} for report type #{type}.",
167
+ code: "season_unsupported_for_type",
168
+ hint: "Pass --paid-session <id> explicitly. Matching paid sessions: #{matches.map { |m| m["id"] }.join(", ")}"
169
+ )
170
+ end
171
+ end
172
+ end