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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +144 -0
- data/bin/wiq +22 -0
- data/docs/deferred.md +155 -0
- data/docs/wiq_api_notes.md +629 -0
- data/lib/wiq/cli.rb +89 -0
- data/lib/wiq/client.rb +127 -0
- data/lib/wiq/commands/auth.rb +257 -0
- data/lib/wiq/commands/base.rb +70 -0
- data/lib/wiq/commands/billing_profiles.rb +52 -0
- data/lib/wiq/commands/charges.rb +104 -0
- data/lib/wiq/commands/check_ins.rb +124 -0
- data/lib/wiq/commands/doctor.rb +93 -0
- data/lib/wiq/commands/events.rb +109 -0
- data/lib/wiq/commands/metrics.rb +118 -0
- data/lib/wiq/commands/paid_sessions.rb +81 -0
- data/lib/wiq/commands/prospect_families.rb +129 -0
- data/lib/wiq/commands/prospects.rb +153 -0
- data/lib/wiq/commands/registrations.rb +78 -0
- data/lib/wiq/commands/reports.rb +510 -0
- data/lib/wiq/commands/rosters.rb +81 -0
- data/lib/wiq/commands/setup.rb +82 -0
- data/lib/wiq/commands/workflows.rb +84 -0
- data/lib/wiq/commands/wrestlers.rb +140 -0
- data/lib/wiq/config.rb +165 -0
- data/lib/wiq/credentials.rb +95 -0
- data/lib/wiq/errors.rb +172 -0
- data/lib/wiq/introspection.rb +105 -0
- data/lib/wiq/output.rb +67 -0
- data/lib/wiq/pagination.rb +41 -0
- data/lib/wiq/season_resolver.rb +46 -0
- data/lib/wiq/version.rb +5 -0
- data/lib/wiq/workflows.rb +314 -0
- data/lib/wiq.rb +30 -0
- data/share/skills/wiq/SKILL.md +348 -0
- metadata +141 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wiq
|
|
4
|
+
module Commands
|
|
5
|
+
class Charges < Base
|
|
6
|
+
# Match Charge.statuses in app/models/charge.rb. Values are
|
|
7
|
+
# integer-backed via Rails enum (`enum status: { successful: 0, failed: 1 }`).
|
|
8
|
+
# Ransack 4.x does NOT auto-translate enum strings on integer columns,
|
|
9
|
+
# so the CLI translates here before sending q[status_eq]. Without this
|
|
10
|
+
# translation Ransack silently drops the predicate and returns every
|
|
11
|
+
# row — the kind of failure that's worse than a 422.
|
|
12
|
+
STATUS_TO_INT = { "successful" => 0, "failed" => 1 }.freeze
|
|
13
|
+
STATUSES = STATUS_TO_INT.keys.freeze
|
|
14
|
+
DEFAULT_PER_PAGE = 50
|
|
15
|
+
|
|
16
|
+
desc "list", "List charges (finance: admin coach only)"
|
|
17
|
+
long_desc <<~DESC
|
|
18
|
+
Returns charges (payment attempts) across the team. Useful for
|
|
19
|
+
answering "did this family pay X?" or "what's been failing
|
|
20
|
+
lately?"
|
|
21
|
+
|
|
22
|
+
Auth: admin-only (Pundit scope filters non-admin coach PATs to
|
|
23
|
+
empty results, parent/wrestler PATs return 403).
|
|
24
|
+
|
|
25
|
+
Filters translate to Ransack server-side:
|
|
26
|
+
--status successful | failed (enum is two-valued)
|
|
27
|
+
--billing-profile <id> q[billing_profile_id_eq] — the
|
|
28
|
+
parent/family who paid. Discover via
|
|
29
|
+
`wiq billing_profiles show <profile_id>
|
|
30
|
+
--profile-type ParentProfile`.
|
|
31
|
+
--chargeable-type <str> q[chargeable_type_eq] — the kind of
|
|
32
|
+
thing paid for. Common values:
|
|
33
|
+
BillingSubscriptionInvoice, Registration,
|
|
34
|
+
Donation, FundraiserContribution,
|
|
35
|
+
OnlineStoreOrder. (Verify against the
|
|
36
|
+
actual data — these are model class
|
|
37
|
+
names, not strings the API documents.)
|
|
38
|
+
--since <YYYY-MM-DD> q[created_at_gteq]
|
|
39
|
+
--until <YYYY-MM-DD> q[created_at_lteq]
|
|
40
|
+
|
|
41
|
+
Each row is rich — billing_profile.billable (the person paying),
|
|
42
|
+
chargeable (what they paid for), wrestlers[] (the kids the charge
|
|
43
|
+
is associated with), refunds, payout, coupon, category. Default
|
|
44
|
+
page size is 50 since charges accumulate fast; use --all for
|
|
45
|
+
exhaustive scans (paired with --since to bound the window).
|
|
46
|
+
|
|
47
|
+
**Critical caveat for failed-payment analysis.** A `failed` charge
|
|
48
|
+
is NOT actionable on its own — Stripe/Justifi retry subscriptions
|
|
49
|
+
automatically, and customers retry registrations after card
|
|
50
|
+
declines. Always cross-check that no `successful` charge exists
|
|
51
|
+
for the same (billing_profile_id, chargeable_id, chargeable_type)
|
|
52
|
+
tuple AFTER the failure timestamp. See `wiq workflows show
|
|
53
|
+
failed-payments-recent` for the canonical pattern.
|
|
54
|
+
DESC
|
|
55
|
+
method_option :status, type: :string, enum: STATUSES,
|
|
56
|
+
desc: "Filter to successful or failed charges"
|
|
57
|
+
method_option :billing_profile, type: :numeric,
|
|
58
|
+
desc: "Restrict to one billing_profile id"
|
|
59
|
+
method_option :chargeable_type, type: :string,
|
|
60
|
+
desc: "Filter by chargeable model name (e.g. BillingSubscriptionInvoice)"
|
|
61
|
+
method_option :since, type: :string, desc: "Earliest created_at (YYYY-MM-DD)"
|
|
62
|
+
method_option :until, type: :string, desc: "Latest created_at (YYYY-MM-DD)"
|
|
63
|
+
method_option :per_page, type: :numeric, default: DEFAULT_PER_PAGE,
|
|
64
|
+
desc: "Page size (default 50)"
|
|
65
|
+
method_option :all, type: :boolean, default: false,
|
|
66
|
+
desc: "Follow pagination until exhausted"
|
|
67
|
+
def list
|
|
68
|
+
params = build_list_params
|
|
69
|
+
records, total = fetch_index("/api/v1/charges", params, key: "charges")
|
|
70
|
+
render_index(
|
|
71
|
+
records, total: total,
|
|
72
|
+
summary: "Listed #{records.size} charges#{summary_filters_suffix}.",
|
|
73
|
+
breadcrumbs: [
|
|
74
|
+
{ "cmd" => "wiq billing_profiles show <id> --profile-type ParentProfile",
|
|
75
|
+
"description" => "Look up the family behind a billing_profile_id" },
|
|
76
|
+
{ "cmd" => "wiq workflows show failed-payments-recent",
|
|
77
|
+
"description" => "Canonical failed-payment cross-check workflow" }
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
no_commands do
|
|
83
|
+
def build_list_params
|
|
84
|
+
params = { "per_page" => options[:per_page] || DEFAULT_PER_PAGE }
|
|
85
|
+
params["q[status_eq]"] = STATUS_TO_INT.fetch(options[:status]) if options[:status]
|
|
86
|
+
params["q[billing_profile_id_eq]"] = options[:billing_profile] if options[:billing_profile]
|
|
87
|
+
params["q[chargeable_type_eq]"] = options[:chargeable_type] if options[:chargeable_type]
|
|
88
|
+
params["q[created_at_gteq]"] = options[:since] if options[:since]
|
|
89
|
+
params["q[created_at_lteq]"] = options[:until] if options[:until]
|
|
90
|
+
params
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def summary_filters_suffix
|
|
94
|
+
bits = []
|
|
95
|
+
bits << "status=#{options[:status]}" if options[:status]
|
|
96
|
+
bits << "billing_profile=#{options[:billing_profile]}" if options[:billing_profile]
|
|
97
|
+
bits << "since=#{options[:since]}" if options[:since]
|
|
98
|
+
bits << "until=#{options[:until]}" if options[:until]
|
|
99
|
+
bits.empty? ? "" : " (#{bits.join(", ")})"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wiq
|
|
4
|
+
module Commands
|
|
5
|
+
class CheckIns < Base
|
|
6
|
+
# Match CheckIn.statuses in app/models/check_in.rb (integer-backed
|
|
7
|
+
# Rails enum). Ransack 4.x does not translate enum strings on integer
|
|
8
|
+
# columns, so the CLI translates here. Without this, q[status_eq]
|
|
9
|
+
# silently drops and returns all check-ins regardless of status.
|
|
10
|
+
STATUS_TO_INT = {
|
|
11
|
+
"unknown" => 0,
|
|
12
|
+
"present" => 1,
|
|
13
|
+
"absent" => 2,
|
|
14
|
+
"excused" => 3,
|
|
15
|
+
"unexcused" => 4,
|
|
16
|
+
"late" => 5,
|
|
17
|
+
"injured" => 6,
|
|
18
|
+
"other" => 7
|
|
19
|
+
}.freeze
|
|
20
|
+
STATUSES = STATUS_TO_INT.keys.freeze
|
|
21
|
+
|
|
22
|
+
desc "event EVENT_ID", "List check-ins for an event"
|
|
23
|
+
long_desc <<~DESC
|
|
24
|
+
Every check-in row for a single event. Each row carries the
|
|
25
|
+
wrestler profile, the event reference, the status (Rails enum:
|
|
26
|
+
unknown, present, absent, excused, unexcused, late, injured, other),
|
|
27
|
+
and the registration_answers captured at check-in time.
|
|
28
|
+
|
|
29
|
+
Discover event ids via `wiq events list --start ... --end ...`.
|
|
30
|
+
DESC
|
|
31
|
+
method_option :status, type: :string, enum: STATUSES,
|
|
32
|
+
desc: "Filter by check-in status"
|
|
33
|
+
method_option :all, type: :boolean, default: false, desc: "Follow pagination until exhausted"
|
|
34
|
+
def event(event_id)
|
|
35
|
+
params = { "per_page" => 50 }
|
|
36
|
+
params["q[status_eq]"] = STATUS_TO_INT.fetch(options[:status]) if options[:status]
|
|
37
|
+
records, total = fetch_index("/api/v1/events/#{event_id}/check_ins", params, key: "check_ins")
|
|
38
|
+
render_index(
|
|
39
|
+
records, total: total,
|
|
40
|
+
summary: "Listed #{records.size} check-ins for event #{event_id}.",
|
|
41
|
+
breadcrumbs: [
|
|
42
|
+
{ "cmd" => "wiq events show #{event_id}", "description" => "See the event itself" }
|
|
43
|
+
]
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
desc "wrestler WRESTLER_ID", "List a wrestler's check-ins"
|
|
48
|
+
long_desc <<~DESC
|
|
49
|
+
Cross-event view: every check-in this wrestler has ever had,
|
|
50
|
+
sorted newest-first. Use --since YYYY-MM-DD to scope.
|
|
51
|
+
|
|
52
|
+
For roster-wide attendance use `wiq check_ins summary` or
|
|
53
|
+
`wiq reports run LastPracticeAttendedReport --roster <id>`.
|
|
54
|
+
DESC
|
|
55
|
+
method_option :since, type: :string, desc: "Earliest created_at date (YYYY-MM-DD)"
|
|
56
|
+
method_option :all, type: :boolean, default: false
|
|
57
|
+
def wrestler(wrestler_id)
|
|
58
|
+
params = { "per_page" => 50 }
|
|
59
|
+
params["q[created_at_gteq]"] = options[:since] if options[:since]
|
|
60
|
+
records, total = fetch_index("/api/v1/wrestlers/#{wrestler_id}/check_ins", params, key: "check_ins")
|
|
61
|
+
render_index(
|
|
62
|
+
records, total: total,
|
|
63
|
+
summary: "Listed #{records.size} check-ins for wrestler #{wrestler_id}.",
|
|
64
|
+
breadcrumbs: [
|
|
65
|
+
{ "cmd" => "wiq wrestlers show #{wrestler_id}",
|
|
66
|
+
"description" => "See the wrestler's profile" },
|
|
67
|
+
{ "cmd" => "wiq reports run LastPracticeAttendedReport --roster <id>",
|
|
68
|
+
"description" => "Find ghost wrestlers across a roster" }
|
|
69
|
+
]
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
desc "summary", "Run a CheckInSummaryReport (default) or CheckInFeedReport for a date range"
|
|
74
|
+
long_desc <<~DESC
|
|
75
|
+
Convenience wrapper around `wiq reports run`. Default report type
|
|
76
|
+
is CheckInSummaryReport — one row per wrestler with totals.
|
|
77
|
+
Pass --feed to switch to CheckInFeedReport (one row per check-in,
|
|
78
|
+
useful for "who came today" or capturing arrival times).
|
|
79
|
+
|
|
80
|
+
The CLI submits the report, then polls until status=ready (unless
|
|
81
|
+
--no-wait). Both reports are non-finance, so any CoachProfile PAT
|
|
82
|
+
on the team can run them.
|
|
83
|
+
|
|
84
|
+
For Q&A capture at check-in time, use
|
|
85
|
+
`wiq reports run CheckInReport` (the "with questions" variant).
|
|
86
|
+
DESC
|
|
87
|
+
method_option :start, type: :string, required: true, desc: "YYYY-MM-DD"
|
|
88
|
+
method_option :end, type: :string, required: true, desc: "YYYY-MM-DD"
|
|
89
|
+
method_option :roster, type: :numeric, desc: "Restrict to a single roster"
|
|
90
|
+
method_option :feed, type: :boolean, default: false,
|
|
91
|
+
desc: "Use CheckInFeedReport (raw rows) instead of the summary aggregate"
|
|
92
|
+
method_option :wait, type: :boolean, default: true
|
|
93
|
+
method_option :timeout, type: :numeric, default: 300, desc: "Max seconds to wait"
|
|
94
|
+
def summary
|
|
95
|
+
report_type = options[:feed] ? "CheckInFeedReport" : "CheckInSummaryReport"
|
|
96
|
+
args = {}
|
|
97
|
+
args["roster_id"] = options[:roster] if options[:roster]
|
|
98
|
+
|
|
99
|
+
body = {
|
|
100
|
+
report: {
|
|
101
|
+
type: report_type,
|
|
102
|
+
version: "v1",
|
|
103
|
+
name: "CLI #{report_type} #{options[:start]}..#{options[:end]}",
|
|
104
|
+
start_at: options[:start],
|
|
105
|
+
end_at: options[:end],
|
|
106
|
+
args: args
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
report = client.post("/api/v1/reports", body)
|
|
111
|
+
if options[:wait]
|
|
112
|
+
report = Reports.poll(client, report["id"], timeout: options[:timeout])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
render(report,
|
|
116
|
+
summary: "#{report_type} ##{report["id"]} status=#{report["status"]}.",
|
|
117
|
+
breadcrumbs: [
|
|
118
|
+
{ "cmd" => "wiq reports show #{report["id"]}", "description" => "Refetch later" },
|
|
119
|
+
{ "cmd" => "wiq reports types", "description" => "See other recommended report types" }
|
|
120
|
+
])
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wiq
|
|
4
|
+
module Commands
|
|
5
|
+
class Doctor < Base
|
|
6
|
+
desc "check", "Diagnose env, network, token, and config sources"
|
|
7
|
+
long_desc <<~DESC
|
|
8
|
+
Runs a sequence of diagnostic checks:
|
|
9
|
+
|
|
10
|
+
1. Ruby version (>= 3.1 required)
|
|
11
|
+
2. Credentials path (~/.config/wiq/credentials.json)
|
|
12
|
+
3. Resolved host + source (--host > env > config > store > prod)
|
|
13
|
+
4. Resolved alias + source
|
|
14
|
+
5. Token presence + source
|
|
15
|
+
6. Live reachability + auth probe via
|
|
16
|
+
`GET /api/v1/personal_access_tokens`
|
|
17
|
+
7. Bound profile (display_name, type, team) for the calling token
|
|
18
|
+
|
|
19
|
+
Exits non-zero if any check fails. Agents should run this first
|
|
20
|
+
when handed an unfamiliar shell to confirm they can actually call
|
|
21
|
+
the API.
|
|
22
|
+
DESC
|
|
23
|
+
def check_all
|
|
24
|
+
checks = []
|
|
25
|
+
|
|
26
|
+
checks << check("Ruby version", RUBY_VERSION,
|
|
27
|
+
ok: Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.1.0"),
|
|
28
|
+
hint: "wiq-cli requires Ruby >= 3.1")
|
|
29
|
+
|
|
30
|
+
cfg = Wiq::Config.load(symbolized_options)
|
|
31
|
+
trace = cfg.trace
|
|
32
|
+
checks << check("Credentials path", Wiq::Credentials.path, ok: true)
|
|
33
|
+
|
|
34
|
+
checks << check("Host", trace[:host] || "(unset)",
|
|
35
|
+
ok: !trace[:host].nil?,
|
|
36
|
+
hint: trace[:host_source] || "Run `wiq auth login` to configure.")
|
|
37
|
+
|
|
38
|
+
checks << check("Alias", trace[:alias] || "(unresolved)",
|
|
39
|
+
ok: !trace[:alias].nil?,
|
|
40
|
+
hint: trace[:alias_source])
|
|
41
|
+
|
|
42
|
+
checks << check("Token", trace[:token_present] ? "present" : "missing",
|
|
43
|
+
ok: trace[:token_present],
|
|
44
|
+
hint: trace[:token_source])
|
|
45
|
+
|
|
46
|
+
if trace[:host] && trace[:token_present]
|
|
47
|
+
begin
|
|
48
|
+
client = Wiq::Client.new(cfg)
|
|
49
|
+
rows, = client.collect_all(
|
|
50
|
+
"/api/v1/personal_access_tokens",
|
|
51
|
+
{ "per_page" => 100 },
|
|
52
|
+
key: "personal_access_tokens"
|
|
53
|
+
)
|
|
54
|
+
prefix = cfg.token[0, 12]
|
|
55
|
+
match = rows.find { |r| r["token_prefix"] == prefix }
|
|
56
|
+
if match
|
|
57
|
+
profile = match["profile"] || {}
|
|
58
|
+
checks << check("Reachability + auth", "200 OK", ok: true)
|
|
59
|
+
checks << check("Bound profile",
|
|
60
|
+
"#{profile["display_name"]} (#{profile["type"]}) @ #{profile["team_name"]}",
|
|
61
|
+
ok: !profile["display_name"].nil?)
|
|
62
|
+
else
|
|
63
|
+
checks << check("Reachability + auth", "200 OK", ok: true)
|
|
64
|
+
checks << check("Bound profile", "(token not in own user's PAT list?)",
|
|
65
|
+
ok: false,
|
|
66
|
+
hint: "Unexpected — the calling PAT should always appear in this list.")
|
|
67
|
+
end
|
|
68
|
+
rescue Wiq::APIError => e
|
|
69
|
+
checks << check("Reachability + auth", "#{e.status} #{e.code}", ok: false, hint: e.hint)
|
|
70
|
+
rescue Faraday::Error => e
|
|
71
|
+
checks << check("Reachability + auth", e.message, ok: false,
|
|
72
|
+
hint: "Check the host URL and your network.")
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
checks << check("Reachability + auth", "skipped", ok: false, hint: "Host or token missing.")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
all_ok = checks.all? { |c| c["ok"] }
|
|
79
|
+
render(checks, summary: all_ok ? "All checks passed." : "Some checks failed.")
|
|
80
|
+
exit(1) unless all_ok
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
map "check" => :check_all
|
|
84
|
+
default_task :check_all
|
|
85
|
+
|
|
86
|
+
no_commands do
|
|
87
|
+
def check(name, value, ok:, hint: nil)
|
|
88
|
+
{ "name" => name, "value" => value, "ok" => ok, "hint" => hint }.compact
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wiq
|
|
4
|
+
module Commands
|
|
5
|
+
class Events < Base
|
|
6
|
+
# Canonical event_type strings from app/models/event.rb. Surfaced via
|
|
7
|
+
# `wiq events types` so agents don't have to guess.
|
|
8
|
+
TYPES = {
|
|
9
|
+
"practice" => "Practice events. The default for most clubs.",
|
|
10
|
+
"dual_meet" => "Dual meet — match-style competition against one opposing team.",
|
|
11
|
+
"tournament" => "Tournament — bracket-style competition with many opponents.",
|
|
12
|
+
"scramble" => "Scramble — open mat / informal competition.",
|
|
13
|
+
"private_lesson" => "Private lesson — one-on-one or small-group paid session. " \
|
|
14
|
+
"Filtered out of the default `events list` unless you opt in.",
|
|
15
|
+
"other" => "Miscellaneous events that don't fit the other types."
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
desc "list", "List events in a date range"
|
|
19
|
+
long_desc <<~DESC
|
|
20
|
+
Fetches events between --start and --end (inclusive). Dates are parsed
|
|
21
|
+
in the team's default timezone server-side, so pass plain YYYY-MM-DD —
|
|
22
|
+
the CLI does not need timezone awareness.
|
|
23
|
+
|
|
24
|
+
Filters apply server-side:
|
|
25
|
+
--event-type One of `wiq events types` (practice, dual_meet, ...)
|
|
26
|
+
--roster One or more roster ids (the API joins through
|
|
27
|
+
roster_events)
|
|
28
|
+
--expand CSV of event_invites,event_bookings,private_lessons.
|
|
29
|
+
Drops nested data into each event row.
|
|
30
|
+
|
|
31
|
+
There is no server-side location/site filter today — `Event.location`
|
|
32
|
+
is free-text and not in `ransackable_attributes`. A location-aware
|
|
33
|
+
backend is on the WIQ roadmap; until then post-process client-side.
|
|
34
|
+
|
|
35
|
+
Use --all to walk every page; default is page 1, per_page=100.
|
|
36
|
+
DESC
|
|
37
|
+
method_option :start, type: :string, required: true, desc: "YYYY-MM-DD (team timezone)"
|
|
38
|
+
method_option :end, type: :string, required: true, desc: "YYYY-MM-DD (team timezone)"
|
|
39
|
+
method_option :roster, type: :array, desc: "One or more roster ids"
|
|
40
|
+
method_option :event_type, type: :string, enum: %w[practice dual_meet tournament scramble private_lesson other],
|
|
41
|
+
desc: "Filter to one event_type (see `wiq events types`)"
|
|
42
|
+
method_option :expand, type: :string,
|
|
43
|
+
desc: "CSV: event_invites, event_bookings, private_lessons"
|
|
44
|
+
method_option :all, type: :boolean, default: false
|
|
45
|
+
def list
|
|
46
|
+
params = {
|
|
47
|
+
"start" => options[:start],
|
|
48
|
+
"end" => options[:end],
|
|
49
|
+
"per_page" => 100
|
|
50
|
+
}
|
|
51
|
+
params["event_type"] = options[:event_type] if options[:event_type]
|
|
52
|
+
params["expand"] = options[:expand] if options[:expand]
|
|
53
|
+
if options[:roster]
|
|
54
|
+
options[:roster].each { |rid| (params["roster_ids[]"] ||= []) << rid }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
records, total = fetch_index("/api/v1/events", params, key: "events")
|
|
58
|
+
render_index(
|
|
59
|
+
records, total: total,
|
|
60
|
+
summary: "Listed #{records.size} events #{options[:start]}..#{options[:end]}.",
|
|
61
|
+
breadcrumbs: [
|
|
62
|
+
{ "cmd" => "wiq events show <id>", "description" => "Inspect a single event" },
|
|
63
|
+
{ "cmd" => "wiq check_ins event <id>", "description" => "See check-ins for an event" }
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
desc "show ID", "Fetch a single event"
|
|
69
|
+
long_desc <<~DESC
|
|
70
|
+
Single-event drill-down. The base payload covers all the obvious
|
|
71
|
+
fields (name, type, start/end, location, color, roster_events,
|
|
72
|
+
team_scores, paid_session).
|
|
73
|
+
|
|
74
|
+
Pass --expand to inline related collections:
|
|
75
|
+
event_invites Per-invitee RSVP status
|
|
76
|
+
event_bookings Per-bookee status (private lessons, paid drop-ins)
|
|
77
|
+
private_lessons Linked private lessons + nested bookings
|
|
78
|
+
|
|
79
|
+
Soft-deleted events are not returned.
|
|
80
|
+
DESC
|
|
81
|
+
method_option :expand, type: :string,
|
|
82
|
+
desc: "CSV: event_invites, event_bookings, private_lessons"
|
|
83
|
+
def show(id)
|
|
84
|
+
params = {}
|
|
85
|
+
params["expand"] = options[:expand] if options[:expand]
|
|
86
|
+
event = client.get("/api/v1/events/#{id}", params)
|
|
87
|
+
render(event,
|
|
88
|
+
summary: "Event #{event["id"]} — #{event["name"]}",
|
|
89
|
+
breadcrumbs: [
|
|
90
|
+
{ "cmd" => "wiq check_ins event #{event["id"]}", "description" => "See check-ins" },
|
|
91
|
+
{ "cmd" => "wiq reports run EventStatsReport --event #{event["id"]}",
|
|
92
|
+
"description" => "Run per-event stats" }
|
|
93
|
+
])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
desc "types", "Print the canonical event_type strings"
|
|
97
|
+
long_desc <<~DESC
|
|
98
|
+
Static enum sourced from app/models/event.rb. Use these values with
|
|
99
|
+
--event-type on `wiq events list`. Note that the API will accept
|
|
100
|
+
any string in the event_type column, but values outside this list
|
|
101
|
+
won't filter anything useful.
|
|
102
|
+
DESC
|
|
103
|
+
def types
|
|
104
|
+
rows = TYPES.map { |type, desc| { "event_type" => type, "description" => desc } }
|
|
105
|
+
render_index(rows, summary: "Canonical event_type values.")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wiq
|
|
4
|
+
module Commands
|
|
5
|
+
class Metrics < Base
|
|
6
|
+
NAMES = %w[
|
|
7
|
+
active_subscribers
|
|
8
|
+
cancelled_subscriptions
|
|
9
|
+
category_breakdown_net
|
|
10
|
+
charge_avg
|
|
11
|
+
charge_count
|
|
12
|
+
gross_volume
|
|
13
|
+
mrr
|
|
14
|
+
net_volume
|
|
15
|
+
new_subscriptions
|
|
16
|
+
renewed_subscriptions
|
|
17
|
+
revenue_per_subscriber
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
CURRENCY_METRICS = %w[
|
|
21
|
+
gross_volume net_volume mrr charge_avg revenue_per_subscriber
|
|
22
|
+
category_breakdown_net
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
VALID_RANGES = ["today", "7d", "4w", "3m", "12m", "year to date", "custom"].freeze
|
|
26
|
+
VALID_INTERVALS = %w[hourly daily weekly monthly].freeze
|
|
27
|
+
|
|
28
|
+
desc "list", "Print the supported metric names"
|
|
29
|
+
long_desc <<~DESC
|
|
30
|
+
Static enumeration of the dashboard metrics WIQ exposes at
|
|
31
|
+
/api/v1/metrics/<name>. The `currency` flag tells you whether
|
|
32
|
+
values are integer cents (true) or counts (false).
|
|
33
|
+
|
|
34
|
+
Auth: every metric requires `authorize :finances, :show?`, which
|
|
35
|
+
in practice means admin-coach. Non-admin PATs return 403.
|
|
36
|
+
DESC
|
|
37
|
+
def list
|
|
38
|
+
rows = NAMES.map do |n|
|
|
39
|
+
{ "name" => n, "currency" => CURRENCY_METRICS.include?(n) }
|
|
40
|
+
end
|
|
41
|
+
render_index(rows,
|
|
42
|
+
summary: "Use `wiq metrics show <name>` to fetch a specific metric. " \
|
|
43
|
+
"Currency values are returned in integer cents.",
|
|
44
|
+
breadcrumbs: [
|
|
45
|
+
{ "cmd" => "wiq metrics show mrr", "description" => "Example: monthly recurring revenue" }
|
|
46
|
+
])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
desc "show NAME", "Fetch a single dashboard metric"
|
|
50
|
+
long_desc <<~DESC
|
|
51
|
+
Pulls /api/v1/metrics/<name> with the standard range/interval
|
|
52
|
+
params. The server returns both a primary_series and a
|
|
53
|
+
comparison_series (the previous period of the same length), plus
|
|
54
|
+
primary_total and comparison_total. The CLI drops the
|
|
55
|
+
server-rendered `charts[]` blob entirely — it's HTML for
|
|
56
|
+
Highcharts and not useful headless.
|
|
57
|
+
|
|
58
|
+
Ranges: today, 7d, 4w, 3m, 12m, "year to date", custom.
|
|
59
|
+
With --range custom you must pass --start-date and --end-date
|
|
60
|
+
(YYYY-MM-DD); comparison_series will be empty for custom ranges.
|
|
61
|
+
|
|
62
|
+
Intervals: hourly, daily, weekly, monthly. Invalid values are
|
|
63
|
+
silently coerced to `daily` server-side.
|
|
64
|
+
|
|
65
|
+
Currency metrics return integer cents (divide by 100 for dollars).
|
|
66
|
+
Non-currency metrics return raw counts.
|
|
67
|
+
DESC
|
|
68
|
+
method_option :range, type: :string, default: "7d", desc: VALID_RANGES.join(" | ")
|
|
69
|
+
method_option :interval, type: :string, default: "daily",
|
|
70
|
+
enum: VALID_INTERVALS, desc: "interval_group"
|
|
71
|
+
method_option :start_date, type: :string, desc: "Required if --range=custom"
|
|
72
|
+
method_option :end_date, type: :string, desc: "Required if --range=custom"
|
|
73
|
+
method_option :no_comparison, type: :boolean, default: false,
|
|
74
|
+
desc: "Drop comparison_series/comparison_total from output"
|
|
75
|
+
def show(name)
|
|
76
|
+
unless VALID_RANGES.include?(options[:range])
|
|
77
|
+
raise Wiq::Error.new("Invalid --range #{options[:range].inspect}",
|
|
78
|
+
code: "invalid_range",
|
|
79
|
+
hint: "Valid: #{VALID_RANGES.join(", ")}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
params = {
|
|
83
|
+
"range" => options[:range],
|
|
84
|
+
"interval_group" => options[:interval]
|
|
85
|
+
}
|
|
86
|
+
if options[:range] == "custom"
|
|
87
|
+
unless options[:start_date] && options[:end_date]
|
|
88
|
+
raise Wiq::Error.new("--start-date and --end-date are required when --range=custom.",
|
|
89
|
+
code: "missing_custom_dates")
|
|
90
|
+
end
|
|
91
|
+
params["start_date"] = options[:start_date]
|
|
92
|
+
params["end_date"] = options[:end_date]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
payload = client.get("/api/v1/metrics/#{name}", params)
|
|
96
|
+
metrics = payload["metrics"] || {}
|
|
97
|
+
|
|
98
|
+
data = {
|
|
99
|
+
"name" => name,
|
|
100
|
+
"currency" => CURRENCY_METRICS.include?(name),
|
|
101
|
+
"primary_series" => metrics["primary_series"],
|
|
102
|
+
"primary_total" => metrics["primary_total"]
|
|
103
|
+
}
|
|
104
|
+
unless options[:no_comparison]
|
|
105
|
+
data["comparison_series"] = metrics["comparison_series"]
|
|
106
|
+
data["comparison_total"] = metrics["comparison_total"]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
render(data,
|
|
110
|
+
summary: "metric=#{name} range=#{options[:range]} interval=#{options[:interval]}",
|
|
111
|
+
meta: { "currency_unit" => CURRENCY_METRICS.include?(name) ? "cents" : "count" },
|
|
112
|
+
breadcrumbs: [
|
|
113
|
+
{ "cmd" => "wiq metrics list", "description" => "See all supported metrics" }
|
|
114
|
+
])
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wiq
|
|
4
|
+
module Commands
|
|
5
|
+
class PaidSessions < Base
|
|
6
|
+
PRESET_TYPES = %w[
|
|
7
|
+
registerable guest_registerable ends_in_future recurring_registerable
|
|
8
|
+
recurring not_recurring not_recurring_with_archived not_archived
|
|
9
|
+
dropin trial trial_or_dropin
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
desc "list", "List paid sessions"
|
|
13
|
+
long_desc <<~DESC
|
|
14
|
+
Paid sessions are WIQ's registration periods — recurring (monthly
|
|
15
|
+
subscriptions) and one-off (clinics, camps, drop-ins). They're the
|
|
16
|
+
thing you point most finance reports at.
|
|
17
|
+
|
|
18
|
+
--type accepts a preset server-side scope:
|
|
19
|
+
registerable, guest_registerable, ends_in_future, recurring,
|
|
20
|
+
recurring_registerable, not_recurring, not_recurring_with_archived,
|
|
21
|
+
not_archived, dropin, trial, trial_or_dropin
|
|
22
|
+
|
|
23
|
+
--season filters CLI-side to sessions whose [start_at, end_at] window
|
|
24
|
+
overlaps the given calendar year. Pair with --all to get an exhaustive
|
|
25
|
+
list.
|
|
26
|
+
|
|
27
|
+
Each row embeds a `stats` block with registration counts
|
|
28
|
+
(good_standing_registrations_count, overdue_registrations_count,
|
|
29
|
+
not_canceled_registrations_count, good_standing_members_count) so
|
|
30
|
+
you usually don't need a follow-up call.
|
|
31
|
+
DESC
|
|
32
|
+
method_option :type, type: :string, enum: PRESET_TYPES, desc: "Preset scope filter"
|
|
33
|
+
method_option :season, type: :numeric, desc: "Filter to sessions overlapping calendar year"
|
|
34
|
+
method_option :all, type: :boolean, default: false
|
|
35
|
+
def list
|
|
36
|
+
params = { "per_page" => 50 }
|
|
37
|
+
params[:type] = options[:type] if options[:type]
|
|
38
|
+
|
|
39
|
+
records, total = fetch_index("/api/v1/paid_sessions", params, key: "paid_sessions")
|
|
40
|
+
|
|
41
|
+
if options[:season]
|
|
42
|
+
year = Integer(options[:season])
|
|
43
|
+
y_start = "#{year}-01-01"
|
|
44
|
+
y_end = "#{year}-12-31"
|
|
45
|
+
records = records.select do |ps|
|
|
46
|
+
(ps["start_at"].to_s <= y_end) && ((ps["end_at"].to_s >= y_start) || ps["end_at"].nil?)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
render_index(
|
|
51
|
+
records, total: total,
|
|
52
|
+
summary: "Listed #{records.size} paid sessions#{options[:season] ? " for #{options[:season]}" : ""}.",
|
|
53
|
+
breadcrumbs: [
|
|
54
|
+
{ "cmd" => "wiq paid_sessions show <id>", "description" => "Inspect one session" },
|
|
55
|
+
{ "cmd" => "wiq reports run PaidSessionAccountingReport --paid-session <id>",
|
|
56
|
+
"description" => "Line-item accounting (admin only)" }
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
desc "show ID", "Fetch a single paid session"
|
|
62
|
+
long_desc <<~DESC
|
|
63
|
+
Full payload includes name, slug, start/end dates, session_type,
|
|
64
|
+
registration_open flag, capacity_limit, welcome/check-in/approval/denial
|
|
65
|
+
text, payment_options, roster_syncers, product + upsell_product,
|
|
66
|
+
and the `stats` count block.
|
|
67
|
+
DESC
|
|
68
|
+
def show(id)
|
|
69
|
+
ps = client.get("/api/v1/paid_sessions/#{id}")
|
|
70
|
+
render(ps,
|
|
71
|
+
summary: "Paid session #{ps["id"]} — #{ps["name"]}",
|
|
72
|
+
breadcrumbs: [
|
|
73
|
+
{ "cmd" => "wiq reports run PaidSessionAccountingReport --paid-session #{ps["id"]}",
|
|
74
|
+
"description" => "Line-item accounting (admin only)" },
|
|
75
|
+
{ "cmd" => "wiq reports run SessionRegistrationAnswerReport --paid-session #{ps["id"]}",
|
|
76
|
+
"description" => "Full Q&A export" }
|
|
77
|
+
])
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|