wiq-cli 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8732206776c88ba1c9d5630407e3a7a4ba6386e528cb5a7a36b2b7c8bcda3dc
4
- data.tar.gz: 499116e3d7977c22e0f44759ea59406c49265e865def76300353de0052dbf5b0
3
+ metadata.gz: ee99cfc6ad08abdf7e1318883bcfa3ecc45635d2d521de150ed1622d867b0ebc
4
+ data.tar.gz: d9b6f3d18c731b4ddfe1060a03a40310e4d58b940d1f00f97dd3ef480f22dd74
5
5
  SHA512:
6
- metadata.gz: 34f253c3bfcf4a7702348287da3a3e8c2458dbc818d20a73849e1b14574b9d7b38767113dd099ea065142d80e1415669813ca54199e9d61326452a6daf6f6b04
7
- data.tar.gz: 80df40a51028efe1926961e2c556a8fab8a46d28757f28360ef12d258003fbdaea031ea2933690e07a33f7729f6e165cea4db33a5491722d44eaa8e7d323498e
6
+ metadata.gz: 89f8f34ae2184fab2069c8a0d51eaf53936dcb69e538e2fd7c0f659900e1be6ea3ee2f43101726ecb25bcd933a77beb20b2b954af4facbbfb248f4f0e40ae914
7
+ data.tar.gz: b63afbb5f6755e1821f268d0a797fc1d1f12d97148f790aa340ad0ec55bdf86df91ee649d003caaabe33351e91a25f848217fd6e26da09c3cf6dcc136b173d15
data/docs/deferred.md CHANGED
@@ -68,9 +68,16 @@ Last updated: 2026-05-12 (after L1 ships).
68
68
  --all flag, base payload kept tight. Filters translate to Ransack
69
69
  (--first-name, --last-name, --weight-class, --academic-class, --age,
70
70
  --roster, --profile-type) + legacy free-text --query. --expand opts
71
- into rosters / registration_answers per row. Multi-roster
71
+ into rosters / registration_answers / notification_preferences
72
+ (coach-only, Aug 2026 — see wiq_api_notes.md) per row. Multi-roster
72
73
  intersection deferred (API supports it; CLI surface didn't justify
73
74
  the complexity for v1). `wiq wrestlers show <id>` paired.
75
+ - ~~**`wiq parents list/show`**~~ SHIPPED narrow (Aug 2026, alongside
76
+ the `expand_notification_preferences` param from wrestling PR #2460).
77
+ Filters: --query (legacy name search), --first-name / --last-name
78
+ (Ransack). --expand notification_preferences (coach-only). Slim
79
+ payload by design (id, user_id, type, names); no wrestler refs —
80
+ family linkage stays on the wrestlers side.
74
81
  - **`wiq url parse <url>`** — extract team/roster/event IDs from WIQ web
75
82
  URLs. Initial-plan item; useful for agents pasted URLs by humans.
76
83
  - **`wiq paid_sessions create/update`**, **`wiq rosters create/update`**,
@@ -144,10 +151,14 @@ Last updated: 2026-05-12 (after L1 ships).
144
151
  - **Echo `request_id`** in the response body or `X-Request-ID` header,
145
152
  for support correlation. Today the CLI can't give a customer a request
146
153
  ID to ship to support.
147
- - **Real location/site filter on events.** `Event.location` is free-text
148
- and not in `ransackable_attributes`. WIQ team has flagged a structured
149
- location concept as roadmap. Until then the CLI deliberately exposes
150
- no `--site` flag.
154
+ - ~~**Real location/site filter on events.**~~ SHIPPED (June 2026,
155
+ wre-506). The backend grew a structured Location model. The CLI now
156
+ exposes `wiq locations list|show` plus `--location` flags on
157
+ `events list` (repeatable → `location_ids[]`), `rosters list`
158
+ (`q[location_id_eq]`), `paid_sessions list`, `wrestlers list`,
159
+ `metrics show`, and `reports run` (args.location_id, honored by 13
160
+ report types). The legacy free-text `Event.location` remains for old
161
+ rows; serialized `location` is the display form.
151
162
  - **Subdomain discovery flow for `wiq auth login`.** PAT settings URL
152
163
  lives at `<team-subdomain>.wrestlingiq.com/settings/personal_access_tokens`.
153
164
  If a customer doesn't know their subdomain, the CLI can't deep-link
@@ -390,6 +390,60 @@ them. PATs minted by parents/wrestlers will 403 here.
390
390
  shape (`category`, `subcategory`, `detail_category`, `total_net_amount`)
391
391
  — special-case it in the formatter.
392
392
 
393
+ ## Wrestlers + parents: `expand_notification_preferences`
394
+
395
+ Shipped Aug 2026 (wrestling PR #2460). All four endpoints accept
396
+ `expand_notification_preferences=true`:
397
+
398
+ - `GET /api/v1/wrestlers` / `GET /api/v1/wrestlers/:id`
399
+ - `GET /api/v1/parents` / `GET /api/v1/parents/:id`
400
+
401
+ When set, each profile gains two fields, mirroring the "Notified via"
402
+ section on the web profile pages:
403
+
404
+ ```json
405
+ {
406
+ "wiq_app_installed": true,
407
+ "notification_preferences": {
408
+ "email": true, "sms": false, "push": true, "push_user_pref": true
409
+ }
410
+ }
411
+ ```
412
+
413
+ Things to know:
414
+
415
+ - **Coach-gated, silently.** `expand_notification_preferences?` in
416
+ `base_controller.rb` requires `current_profile.coach?`. A parent- or
417
+ wrestler-bound PAT gets a normal 200 with the fields simply absent —
418
+ no error. Don't diagnose missing fields as a bug; check the PAT's
419
+ profile type first (`wiq auth status`).
420
+ - **Nullable prefs.** `notification_preference` is a `has_one` that may
421
+ not exist; each key can be `null` (rendered via `np&.email` etc.).
422
+ Treat `null` as "no explicit preference recorded", not "off".
423
+ - The controller eager-loads `:notification_preference` only when the
424
+ flag is set, so the index cost is opt-in.
425
+
426
+ CLI exposure: `wiq wrestlers list|show --expand notification_preferences`
427
+ (composes with `rosters`, `registration_answers` in the same CSV) and
428
+ `wiq parents list|show --expand notification_preferences`.
429
+
430
+ While we're here, the parents endpoints themselves
431
+ (`app/controllers/api/v1/parents_controller.rb`):
432
+
433
+ - Index scope is `team.parent_profiles.teammates` ordered by first
434
+ name — guest parents (camp signups) are excluded. Policy scope:
435
+ coaches see the whole team (roster-restricted coaches see only their
436
+ allowed parents), wrestlers see their own guardians, parents see
437
+ nobody.
438
+ - Filters: legacy `?query=` name search, plus Ransack limited to
439
+ `id, first_name, last_name` (`ParentProfile.ransackable_attributes`).
440
+ - The jbuilder payload is slim: `id, user_id, type, first_name,
441
+ last_name, full_name` (+ the expand fields above). No wrestler refs —
442
+ walk the family from the wrestler side (`wiq wrestlers show <id>`
443
+ embeds parent refs).
444
+ - `show` uses `ParentProfile.with_deleted` — soft-deleted parents still
445
+ resolve by id.
446
+
393
447
  ## Check-ins (attendance)
394
448
 
395
449
  Two index endpoints + create/update on the event-scoped path.
@@ -468,13 +522,16 @@ Other registration surface:
468
522
  - `expand=event_invites,event_bookings,private_lessons` — comma-separated
469
523
  CSV; pulls in nested data on the events index/show.
470
524
  - `Event.ransackable_attributes` allows
471
- `id, name, start_at, end_at, event_type, paid_session_id`. **`location`
472
- is not ransackable today.** A real location filter is on the WIQ
473
- backend roadmap (it will replace the free-text `location` column with a
474
- structured concept); until that ships the CLI doesn't expose a
475
- location/site flag. Users who need it can fetch the date range, write
476
- the results to a file, and post-process — that's not the CLI's problem
477
- to solve in v1.
525
+ `id, name, start_at, end_at, event_type, paid_session_id`. The
526
+ free-text `location` column is still not ransackable, but the
527
+ structured location filter shipped (June 2026): pass `location_ids[]`
528
+ (repeatable, dedicated param handled in `apply_event_filters`, not
529
+ Ransack) to filter on the event's `location_id`. Events with no
530
+ `location_id` are excluded from a filtered listing. The serialized
531
+ `location` field is now `Event#display_location` — the structured
532
+ Location's name when set, else the legacy free text — and `location_id`
533
+ is serialized alongside it. The CLI exposes this as
534
+ `wiq events list --location <id> [<id>...]`.
478
535
  - Soft deletes: events use `acts_as_paranoid`; the index calls
479
536
  `.without_deleted`. No "include deleted" param exposed.
480
537
  - `POST /api/v1/events` — recurring practice creation:
@@ -642,9 +699,12 @@ PaidSession-overlap convention proves load-bearing for many customers.
642
699
  demand. Until then, jbuilders are the source of truth.
643
700
  2. `request_id` echoed in response body and/or `X-Request-ID` header for
644
701
  support correlation.
645
- 3. Real location/site filter on `GET /api/v1/events` (when the backend
646
- gets a structured location concept). Until then the CLI doesn't expose
647
- a flag.
702
+ 3. ~~Real location/site filter on `GET /api/v1/events`~~ SHIPPED
703
+ (June 2026, wre-506). Structured Location model + `location_ids[]` on
704
+ events, `location_id` on paid_sessions/wrestlers/metrics/reports,
705
+ `q[location_id_eq]` on rosters, and a `GET /api/v1/locations`
706
+ resource. All exposed in the CLI via `wiq locations` and `--location`
707
+ flags.
648
708
  4. Subdomain discovery for `wiq auth login`. The PAT settings URL lives at
649
709
  `<team-subdomain>.wrestlingiq.com/settings/personal_access_tokens`; if
650
710
  the customer doesn't know their subdomain, the CLI can't deep-link
data/lib/wiq/cli.rb CHANGED
@@ -65,6 +65,9 @@ module Wiq
65
65
  desc "rosters SUBCOMMAND", "Rosters (list, show)"
66
66
  subcommand "rosters", Wiq::Commands::Rosters
67
67
 
68
+ desc "locations SUBCOMMAND", "Team locations / sites — ID discovery for --location flags (list, show)"
69
+ subcommand "locations", Wiq::Commands::Locations
70
+
68
71
  desc "prospects SUBCOMMAND", "Prospect pipeline — individual kids (list, show, summary)"
69
72
  subcommand "prospects", Wiq::Commands::Prospects
70
73
 
@@ -80,6 +83,9 @@ module Wiq
80
83
  desc "wrestlers SUBCOMMAND", "Search wrestlers — narrow ID-discovery surface (list, show)"
81
84
  subcommand "wrestlers", Wiq::Commands::Wrestlers
82
85
 
86
+ desc "parents SUBCOMMAND", "Search parents — narrow ID-discovery surface (list, show)"
87
+ subcommand "parents", Wiq::Commands::Parents
88
+
83
89
  desc "charges SUBCOMMAND", "Payment history — list charges by family, status, type, date (admin only)"
84
90
  subcommand "charges", Wiq::Commands::Charges
85
91
 
@@ -25,18 +25,25 @@ module Wiq
25
25
  --event-type One of `wiq events types` (practice, dual_meet, ...)
26
26
  --roster One or more roster ids (the API joins through
27
27
  roster_events)
28
+ --location One or more location ids (structured Location
29
+ records; discover via `wiq locations list`)
28
30
  --expand CSV of event_invites,event_bookings,private_lessons.
29
31
  Drops nested data into each event row.
30
32
 
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.
33
+ Location caveat: --location matches the event's own structured
34
+ location_id. Events with NO location set are excluded from a
35
+ filtered listing they only appear when you don't filter. The
36
+ legacy free-text `location` field still exists on old events; the
37
+ serialized `location` value is the display form (structured record
38
+ when set, else the free text).
34
39
 
35
40
  Use --all to walk every page; default is page 1, per_page=100.
36
41
  DESC
37
42
  method_option :start, type: :string, required: true, desc: "YYYY-MM-DD (team timezone)"
38
43
  method_option :end, type: :string, required: true, desc: "YYYY-MM-DD (team timezone)"
39
44
  method_option :roster, type: :array, desc: "One or more roster ids"
45
+ method_option :location, type: :array,
46
+ desc: "One or more location ids (events with no location are excluded)"
40
47
  method_option :event_type, type: :string, enum: %w[practice dual_meet tournament scramble private_lesson other],
41
48
  desc: "Filter to one event_type (see `wiq events types`)"
42
49
  method_option :expand, type: :string,
@@ -53,6 +60,9 @@ module Wiq
53
60
  if options[:roster]
54
61
  options[:roster].each { |rid| (params["roster_ids[]"] ||= []) << rid }
55
62
  end
63
+ if options[:location]
64
+ options[:location].each { |lid| (params["location_ids[]"] ||= []) << lid }
65
+ end
56
66
 
57
67
  records, total = fetch_index("/api/v1/events", params, key: "events")
58
68
  render_index(
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class Locations < Base
6
+ desc "list", "List the team's structured locations (sites/gyms)"
7
+ long_desc <<~DESC
8
+ Locations are WIQ's structured multi-site concept — a named place
9
+ (with optional street address) that rosters, events, paid sessions,
10
+ wrestlers, metrics, and reports can be scoped to. Single-site clubs
11
+ typically have zero or one; multi-site clubs use them to slice
12
+ everything per gym.
13
+
14
+ Archived locations are hidden by default; pass --include-archived
15
+ to see them. Ordered by name server-side.
16
+
17
+ This is the ID-discovery surface for every --location flag in the
18
+ CLI:
19
+ wiq rosters list --location <id>
20
+ wiq events list --location <id> [...more ids]
21
+ wiq wrestlers list --location <id>
22
+ wiq paid_sessions list --location <id>
23
+ wiq metrics show <name> --location <id>
24
+ wiq reports run <Type> --location <id>
25
+ DESC
26
+ method_option :include_archived, type: :boolean, default: false,
27
+ desc: "Include archived locations"
28
+ method_option :all, type: :boolean, default: false
29
+ def list
30
+ params = { "per_page" => 100 }
31
+ params["include_archived"] = true if options[:include_archived]
32
+
33
+ records, total = fetch_index("/api/v1/locations", params, key: "locations")
34
+ render_index(
35
+ records, total: total,
36
+ summary: "Listed #{records.size} locations#{options[:include_archived] ? " (including archived)" : ""}.",
37
+ breadcrumbs: [
38
+ { "cmd" => "wiq locations show <id>", "description" => "Inspect a single location" },
39
+ { "cmd" => "wiq rosters list --location <id>", "description" => "Rosters at a location" },
40
+ { "cmd" => "wiq metrics show mrr --location <id>",
41
+ "description" => "Finance metrics scoped to a location (admin only)" }
42
+ ]
43
+ )
44
+ end
45
+
46
+ desc "show ID", "Fetch a single location"
47
+ long_desc <<~DESC
48
+ Full location payload: id, name, address_line1, address_line2,
49
+ city, state, postal_code, country, archived.
50
+ DESC
51
+ def show(id)
52
+ location = client.get("/api/v1/locations/#{id}")
53
+ render(location,
54
+ summary: "Location #{location["id"]} — #{location["name"]}",
55
+ breadcrumbs: [
56
+ { "cmd" => "wiq rosters list --location #{location["id"]}",
57
+ "description" => "Rosters at this location" },
58
+ { "cmd" => "wiq wrestlers list --location #{location["id"]}",
59
+ "description" => "Wrestlers on any roster at this location" }
60
+ ])
61
+ end
62
+ end
63
+ end
64
+ end
@@ -62,6 +62,13 @@ module Wiq
62
62
  Intervals: hourly, daily, weekly, monthly. Invalid values are
63
63
  silently coerced to `daily` server-side.
64
64
 
65
+ --location <id> scopes every metric to one structured location
66
+ (discover ids via `wiq locations list`). CAUTION: the server
67
+ validates the id against the team's own locations and silently
68
+ falls back to ALL locations when it doesn't match — a typo'd or
69
+ foreign id returns team-wide numbers, not an error. The CLI echoes
70
+ the requested location in `meta` so you can sanity-check.
71
+
65
72
  Currency metrics return integer cents (divide by 100 for dollars).
66
73
  Non-currency metrics return raw counts.
67
74
  DESC
@@ -70,6 +77,8 @@ module Wiq
70
77
  enum: VALID_INTERVALS, desc: "interval_group"
71
78
  method_option :start_date, type: :string, desc: "Required if --range=custom"
72
79
  method_option :end_date, type: :string, desc: "Required if --range=custom"
80
+ method_option :location, type: :numeric,
81
+ desc: "Scope to one location id (unknown ids silently mean ALL locations)"
73
82
  method_option :no_comparison, type: :boolean, default: false,
74
83
  desc: "Drop comparison_series/comparison_total from output"
75
84
  def show(name)
@@ -91,6 +100,7 @@ module Wiq
91
100
  params["start_date"] = options[:start_date]
92
101
  params["end_date"] = options[:end_date]
93
102
  end
103
+ params["location_id"] = options[:location] if options[:location]
94
104
 
95
105
  payload = client.get("/api/v1/metrics/#{name}", params)
96
106
  metrics = payload["metrics"] || {}
@@ -106,9 +116,13 @@ module Wiq
106
116
  data["comparison_total"] = metrics["comparison_total"]
107
117
  end
108
118
 
119
+ meta = { "currency_unit" => CURRENCY_METRICS.include?(name) ? "cents" : "count" }
120
+ meta["location_id"] = options[:location] if options[:location]
121
+
109
122
  render(data,
110
- summary: "metric=#{name} range=#{options[:range]} interval=#{options[:interval]}",
111
- meta: { "currency_unit" => CURRENCY_METRICS.include?(name) ? "cents" : "count" },
123
+ summary: "metric=#{name} range=#{options[:range]} interval=#{options[:interval]}" \
124
+ "#{options[:location] ? " location=#{options[:location]}" : ""}",
125
+ meta: meta,
112
126
  breadcrumbs: [
113
127
  { "cmd" => "wiq metrics list", "description" => "See all supported metrics" }
114
128
  ])
@@ -20,6 +20,10 @@ module Wiq
20
20
  recurring_registerable, not_recurring, not_recurring_with_archived,
21
21
  not_archived, dropin, trial, trial_or_dropin
22
22
 
23
+ --location <id> filters server-side to sessions stamped with that
24
+ structured location (discover ids via `wiq locations list`).
25
+ Sessions with no location are excluded when the filter is on.
26
+
23
27
  --season filters CLI-side to sessions whose [start_at, end_at] window
24
28
  overlaps the given calendar year. Pair with --all to get an exhaustive
25
29
  list.
@@ -30,11 +34,13 @@ module Wiq
30
34
  you usually don't need a follow-up call.
31
35
  DESC
32
36
  method_option :type, type: :string, enum: PRESET_TYPES, desc: "Preset scope filter"
37
+ method_option :location, type: :numeric, desc: "Filter to sessions at one location id"
33
38
  method_option :season, type: :numeric, desc: "Filter to sessions overlapping calendar year"
34
39
  method_option :all, type: :boolean, default: false
35
40
  def list
36
41
  params = { "per_page" => 50 }
37
42
  params[:type] = options[:type] if options[:type]
43
+ params["location_id"] = options[:location] if options[:location]
38
44
 
39
45
  records, total = fetch_index("/api/v1/paid_sessions", params, key: "paid_sessions")
40
46
 
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class Parents < Base
6
+ DEFAULT_PER_PAGE = 20
7
+
8
+ desc "list", "Search parents — narrow ID-discovery surface"
9
+ long_desc <<~DESC
10
+ Narrow listing over the team's parent profiles (teammates only —
11
+ guest parents from camp signups are excluded server-side). Rows
12
+ are slim: id, user_id, type, first_name, last_name, full_name.
13
+ Sorted by first name. Default page size is 20; there's no --all
14
+ flag.
15
+
16
+ Filters:
17
+ --query free-text name search (legacy `?query=`)
18
+ --first-name q[first_name_cont]
19
+ --last-name q[last_name_cont]
20
+
21
+ --expand opts into a wider payload:
22
+ notification_preferences Adds wiq_app_installed + a
23
+ notification_preferences object
24
+ (email, sms, push, push_user_pref)
25
+ per parent — "which parents can we
26
+ reach, and how?". Coach PATs only;
27
+ the server silently omits it for
28
+ parent/wrestler tokens.
29
+
30
+ The parent → wrestler linkage is NOT in this payload. To see a
31
+ family's kids, go the other direction: `wiq wrestlers show <id>`
32
+ embeds parent refs, or `wiq wrestlers list --query <last name>`.
33
+ DESC
34
+ method_option :query, type: :string, desc: "Free-text name search"
35
+ method_option :first_name, type: :string, desc: "First name (contains)"
36
+ method_option :last_name, type: :string, desc: "Last name (contains)"
37
+ method_option :expand, type: :string,
38
+ desc: "CSV: notification_preferences"
39
+ method_option :per_page, type: :numeric, default: DEFAULT_PER_PAGE,
40
+ desc: "Page size (default 20; narrow surface)"
41
+ def list
42
+ params = build_list_params
43
+
44
+ records, total = fetch_index("/api/v1/parents", params, key: "parent_profiles")
45
+ render_index(
46
+ records, total: total,
47
+ summary: "Listed #{records.size} parents#{summary_filters_suffix}.",
48
+ breadcrumbs: [
49
+ { "cmd" => "wiq parents show <id>", "description" => "Drill into one parent" },
50
+ { "cmd" => "wiq billing_profiles show <id> --profile-type ParentProfile",
51
+ "description" => "Billing profile for a parent (admin only)" },
52
+ { "cmd" => "wiq wrestlers list --last-name <name>",
53
+ "description" => "Find the family's wrestlers (parent refs embedded)" }
54
+ ]
55
+ )
56
+ end
57
+
58
+ desc "show ID", "Fetch a single parent profile"
59
+ long_desc <<~DESC
60
+ Single-parent drill-down. Same slim payload as the index (id,
61
+ user_id, type, names); soft-deleted parents still resolve here.
62
+
63
+ Pass --expand for additional sections:
64
+ notification_preferences wiq_app_installed + notification_preferences
65
+ (email, sms, push, push_user_pref) —
66
+ "can this parent be reached, and how?"
67
+ Coach PATs only; silently omitted for
68
+ parent/wrestler tokens.
69
+ DESC
70
+ method_option :expand, type: :string,
71
+ desc: "CSV: notification_preferences"
72
+ def show(id)
73
+ params = {}
74
+ params["expand_notification_preferences"] = true if expand_includes?("notification_preferences")
75
+ parent = client.get("/api/v1/parents/#{id}", params)
76
+ render(parent,
77
+ summary: "Parent #{parent["id"]} — #{parent["full_name"]}",
78
+ breadcrumbs: [
79
+ { "cmd" => "wiq billing_profiles show #{parent["id"]} --profile-type ParentProfile",
80
+ "description" => "Billing profile for this parent (admin only)" },
81
+ { "cmd" => "wiq wrestlers list --last-name #{parent["last_name"]}",
82
+ "description" => "Likely wrestlers for this family" }
83
+ ])
84
+ end
85
+
86
+ no_commands do
87
+ # Extracted so the spec can verify the option → query-param mapping
88
+ # without round-tripping a real Faraday connection.
89
+ def build_list_params
90
+ params = { "per_page" => options[:per_page] || DEFAULT_PER_PAGE }
91
+ params["query"] = options[:query] if options[:query]
92
+ params["q[first_name_cont]"] = options[:first_name] if options[:first_name]
93
+ params["q[last_name_cont]"] = options[:last_name] if options[:last_name]
94
+ params["expand_notification_preferences"] = true if expand_includes?("notification_preferences")
95
+ params
96
+ end
97
+
98
+ def expand_includes?(part)
99
+ return false unless options[:expand]
100
+
101
+ options[:expand].split(",").map(&:strip).include?(part)
102
+ end
103
+
104
+ def summary_filters_suffix
105
+ bits = []
106
+ bits << "matching #{options[:query].inspect}" if options[:query]
107
+ bits << "first name ~ #{options[:first_name].inspect}" if options[:first_name]
108
+ bits << "last name ~ #{options[:last_name].inspect}" if options[:last_name]
109
+ bits.empty? ? "" : " (#{bits.join(", ")})"
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -26,7 +26,7 @@ module Wiq
26
26
  TYPES = {
27
27
  # ── Roster tab ───────────────────────────────────────────────
28
28
  "RosterReport" => {
29
- args: %w[roster_id append_property_ids include_archived_roster_tags],
29
+ args: %w[roster_id location_id append_property_ids include_archived_roster_tags],
30
30
  dates: :optional,
31
31
  desc: "Roster snapshot — name, weight class, academic class, age",
32
32
  recommended: true,
@@ -41,8 +41,11 @@ module Wiq
41
41
  "to roster at\" column (roster_memberships.created_at — when the " \
42
42
  "wrestler landed on the roster, NOT their registration date), " \
43
43
  "populated only for a specific --roster <id> (id > 0), not " \
44
- "--roster 0. --v1 returns fuller per-wrestler objects but drops " \
45
- "that column.",
44
+ "--roster 0. With --location <id> instead, the report scopes to " \
45
+ "wrestlers on any roster at that location (one row each) and " \
46
+ "\"Added to roster at\" becomes their EARLIEST membership across " \
47
+ "that location's rosters. --v1 returns fuller per-wrestler " \
48
+ "objects but drops that column.",
46
49
  example: "wiq reports run RosterReport --roster 42"
47
50
  },
48
51
  "FullExportWrestlerReport" => {
@@ -67,52 +70,52 @@ module Wiq
67
70
 
68
71
  # ── USAW / AAU tab ───────────────────────────────────────────
69
72
  "UsawReport" => {
70
- args: %w[roster_id],
73
+ args: %w[roster_id location_id],
71
74
  dates: :optional,
72
75
  desc: "All USA Wrestling card info on file, one row per wrestler",
73
76
  recommended: true,
74
77
  notes: "UI hides this tab when USAW collection is off (team setting)."
75
78
  },
76
79
  "UsawExpiredReport" => {
77
- args: %w[roster_id],
80
+ args: %w[roster_id location_id],
78
81
  dates: :optional,
79
82
  desc: "Wrestlers missing or with expired USAW memberships",
80
83
  notes: "Output is also a bulk-purchase upload format for USAW's system."
81
84
  },
82
85
  "UsawExportReport" => {
83
- args: %w[roster_id paid_session_id],
86
+ args: %w[roster_id location_id paid_session_id],
84
87
  dates: :optional,
85
88
  desc: "USAW bulk-purchase upload format",
86
89
  notes: "Only report that accepts paid_session_id=0 to mean " \
87
90
  "\"all sessions\"."
88
91
  },
89
92
  "AauReport" => {
90
- args: %w[roster_id],
93
+ args: %w[roster_id location_id],
91
94
  dates: :optional,
92
95
  desc: "All AAU card info on file, one row per wrestler",
93
96
  recommended: true,
94
97
  notes: "UI hides this tab when AAU collection is off (team setting)."
95
98
  },
96
99
  "AauExpiredReport" => {
97
- args: %w[roster_id],
100
+ args: %w[roster_id location_id],
98
101
  dates: :optional,
99
102
  desc: "Wrestlers missing or with expired AAU memberships"
100
103
  },
101
104
  "AauExportReport" => {
102
- args: %w[roster_id],
105
+ args: %w[roster_id location_id],
103
106
  dates: :optional,
104
107
  desc: "AAU bulk-purchase upload format"
105
108
  },
106
109
 
107
110
  # ── Stats tab ────────────────────────────────────────────────
108
111
  "WinLossReport" => {
109
- args: %w[roster_id],
112
+ args: %w[roster_id location_id],
110
113
  dates: :required,
111
114
  desc: "Wins and losses per wrestler",
112
115
  recommended: true
113
116
  },
114
117
  "RosterStatsReport" => {
115
- args: %w[roster_id],
118
+ args: %w[roster_id location_id],
116
119
  dates: :required,
117
120
  desc: "Wrestling stats per wrestler (takedowns, nearfall, etc.)",
118
121
  recommended: true
@@ -133,8 +136,10 @@ module Wiq
133
136
  recommended: true,
134
137
  notes: "One row per wrestler over the date range — totals across all " \
135
138
  "their check-ins in the window. If you want one row per " \
136
- "check-in event use CheckInFeedReport. The UI doesn't expose " \
137
- "a roster picker; counts span the whole team.",
139
+ "check-in event use CheckInFeedReport. Always team-wide: the " \
140
+ "model ignores roster_id AND location_id args. For a " \
141
+ "location-scoped attendance report use CheckInReport " \
142
+ "--location <id> instead.",
138
143
  example: "wiq reports run CheckInSummaryReport --start 2026-05-01 --end 2026-05-31"
139
144
  },
140
145
  "CheckInFeedReport" => {
@@ -144,11 +149,12 @@ module Wiq
144
149
  recommended: true,
145
150
  notes: "Useful for \"who came to the club today and what registrations " \
146
151
  "did they have at check-in time.\" Larger payload than the " \
147
- "summary.",
152
+ "summary. Always team-wide: the model ignores roster_id AND " \
153
+ "location_id args.",
148
154
  example: "wiq reports run CheckInFeedReport --start 2026-05-01 --end 2026-05-31"
149
155
  },
150
156
  "PracticeAttendanceReport" => {
151
- args: %w[roster_id],
157
+ args: %w[roster_id location_id],
152
158
  dates: :required,
153
159
  desc: "Practice-event attendance roll-up across a date range",
154
160
  recommended: false,
@@ -159,7 +165,7 @@ module Wiq
159
165
  "use CheckInSummaryReport or CheckInFeedReport instead."
160
166
  },
161
167
  "LastPracticeAttendedReport" => {
162
- args: %w[roster_id],
168
+ args: %w[roster_id location_id],
163
169
  dates: :optional,
164
170
  desc: "Days since last practice attended, per wrestler",
165
171
  recommended: true,
@@ -167,7 +173,7 @@ module Wiq
167
173
  "practice in a while — particularly for seasonal clubs."
168
174
  },
169
175
  "ChurnRiskReport" => {
170
- args: %w[roster_id days_threshold],
176
+ args: %w[roster_id location_id days_threshold],
171
177
  dates: :optional,
172
178
  desc: "Active recurring subscribers who haven't checked in within a window",
173
179
  recommended: true,
@@ -178,7 +184,7 @@ module Wiq
178
184
  example: "wiq reports run ChurnRiskReport --roster 0 --days-threshold 30"
179
185
  },
180
186
  "CheckInReport" => {
181
- args: %w[roster_id],
187
+ args: %w[roster_id location_id],
182
188
  dates: :required,
183
189
  desc: "Extended attendance — one row per check-in INCLUDING Q&A responses",
184
190
  notes: "UI labels this \"Attendance Extended (with questions).\" Same " \
@@ -333,6 +339,14 @@ module Wiq
333
339
  Common args (only those documented in TYPES are honored
334
340
  server-side):
335
341
  --roster <id> roster_id (0 = all rosters)
342
+ --location <id> location_id — scope the wrestler set to
343
+ one location (wrestlers on ANY roster at
344
+ that location, deduped to one row each).
345
+ Precedence: a specific --roster <id> (> 0)
346
+ WINS over --location; pass --location
347
+ alone (or with --roster 0) to get
348
+ location scoping. Discover ids via
349
+ `wiq locations list`.
336
350
  --paid-session <id> paid_session_id (0 = all, UsawExport only)
337
351
  --event <id> event_id (EventStatsReport)
338
352
  --fundraiser <id> fundraiser_id (Fundraiser* reports)
@@ -369,6 +383,9 @@ module Wiq
369
383
  method_option :start, type: :string, desc: "YYYY-MM-DD"
370
384
  method_option :end, type: :string, desc: "YYYY-MM-DD"
371
385
  method_option :roster, type: :numeric, desc: "args.roster_id (0 = all rosters)"
386
+ method_option :location, type: :numeric,
387
+ desc: "args.location_id — scope wrestlers to one location " \
388
+ "(a specific --roster <id> > 0 takes precedence)"
372
389
  method_option :paid_session, type: :numeric, desc: "args.paid_session_id"
373
390
  method_option :event, type: :numeric, desc: "args.event_id"
374
391
  method_option :fundraiser, type: :numeric, desc: "args.fundraiser_id"
@@ -529,6 +546,7 @@ module Wiq
529
546
 
530
547
  args = {}
531
548
  args["roster_id"] = options[:roster] if options[:roster] || options[:roster] == 0
549
+ args["location_id"] = options[:location] if options[:location]
532
550
  args["paid_session_id"] = options[:paid_session] if options[:paid_session] || options[:paid_session] == 0
533
551
  args["event_id"] = options[:event] if options[:event]
534
552
  args["fundraiser_id"] = options[:fundraiser] if options[:fundraiser]
@@ -17,12 +17,19 @@ module Wiq
17
17
  exact name. Some teams tag rosters with
18
18
  conventions like "2025-26".
19
19
 
20
+ --location <id> filters server-side (Ransack q[location_id_eq]) to
21
+ rosters stamped with that structured location. Discover ids via
22
+ `wiq locations list`. Rosters with no location are excluded when
23
+ the filter is on. Each roster row embeds its location object (or
24
+ null) so you can also group client-side without the filter.
25
+
20
26
  Each roster row embeds roster_syncers and taggings, which is what
21
27
  --season uses to filter without needing extra calls.
22
28
  DESC
23
29
  method_option :season, type: :numeric,
24
30
  desc: "Filter to rosters whose syncers point at paid sessions overlapping this year"
25
31
  method_option :season_tag, type: :string, desc: "Filter to rosters carrying this tag"
32
+ method_option :location, type: :numeric, desc: "Filter to rosters at one location id"
26
33
  method_option :archived, type: :boolean, desc: "Show only archived (true) or active (false)"
27
34
  method_option :all, type: :boolean, default: false
28
35
  def list
@@ -30,6 +37,7 @@ module Wiq
30
37
  unless options[:archived].nil?
31
38
  params["q[archived_eq]"] = options[:archived]
32
39
  end
40
+ params["q[location_id_eq]"] = options[:location] if options[:location]
33
41
 
34
42
  records, total = fetch_index("/api/v1/rosters", params, key: "rosters")
35
43
 
@@ -20,6 +20,10 @@ module Wiq
20
20
  --first-name q[first_name_cont]
21
21
  --last-name q[last_name_cont]
22
22
  --roster <id> q[rosters_id_eq] — filter to one roster
23
+ --location <id> location_id (dedicated param, not Ransack) —
24
+ wrestlers on ANY roster at that location.
25
+ Composes with the other filters. Discover
26
+ ids via `wiq locations list`.
23
27
  --weight-class q[weight_class_numeric_eq] — exact numeric
24
28
  --academic-class q[academic_class_eq] (senior, junior, …)
25
29
  --age q[age_eq]
@@ -31,8 +35,14 @@ module Wiq
31
35
  client-side.
32
36
 
33
37
  --expand opts into a wider payload:
34
- rosters Adds full roster details per wrestler
35
- registration_answers Adds intake-form answers per wrestler
38
+ rosters Adds full roster details per wrestler
39
+ registration_answers Adds intake-form answers per wrestler
40
+ notification_preferences Adds wiq_app_installed + a
41
+ notification_preferences object
42
+ (email, sms, push, push_user_pref)
43
+ per wrestler. Coach PATs only — the
44
+ server silently omits it for
45
+ parent/wrestler tokens.
36
46
 
37
47
  Base payload is already wider than most index endpoints (parents,
38
48
  coach_guardians, profile_photos, basic roster refs all render by
@@ -43,6 +53,8 @@ module Wiq
43
53
  method_option :first_name, type: :string, desc: "First name (contains)"
44
54
  method_option :last_name, type: :string, desc: "Last name (contains)"
45
55
  method_option :roster, type: :numeric, desc: "Filter to a single roster id"
56
+ method_option :location, type: :numeric,
57
+ desc: "Filter to wrestlers on any roster at this location id"
46
58
  method_option :weight_class, type: :string,
47
59
  desc: "Weight class (exact numeric, e.g. 132)"
48
60
  method_option :academic_class, type: :string,
@@ -52,7 +64,7 @@ module Wiq
52
64
  desc: "Default 'teammate' matches the WIQ web UI default; " \
53
65
  "'all' removes the filter entirely"
54
66
  method_option :expand, type: :string,
55
- desc: "CSV: rosters, registration_answers"
67
+ desc: "CSV: rosters, registration_answers, notification_preferences"
56
68
  method_option :per_page, type: :numeric, default: DEFAULT_PER_PAGE,
57
69
  desc: "Page size (default 20; narrow surface)"
58
70
  def list
@@ -78,15 +90,21 @@ module Wiq
78
90
  rosters refs).
79
91
 
80
92
  Pass --expand for additional sections:
81
- rosters Full roster details with tags
82
- registration_answers Intake-form answers
93
+ rosters Full roster details with tags
94
+ registration_answers Intake-form answers
95
+ notification_preferences wiq_app_installed + notification_preferences
96
+ (email, sms, push, push_user_pref) —
97
+ "can this family be reached, and how?"
98
+ Coach PATs only; silently omitted for
99
+ parent/wrestler tokens.
83
100
  DESC
84
101
  method_option :expand, type: :string,
85
- desc: "CSV: rosters, registration_answers"
102
+ desc: "CSV: rosters, registration_answers, notification_preferences"
86
103
  def show(id)
87
104
  params = {}
88
105
  params["expand_rosters"] = true if expand_includes?("rosters")
89
106
  params["expand_registration_answers"] = true if expand_includes?("registration_answers")
107
+ params["expand_notification_preferences"] = true if expand_includes?("notification_preferences")
90
108
  wrestler = client.get("/api/v1/wrestlers/#{id}", params)
91
109
  render(wrestler,
92
110
  summary: "Wrestler #{wrestler["id"]} — #{wrestler["full_name"] || wrestler["display_name"]}",
@@ -107,12 +125,14 @@ module Wiq
107
125
  params["q[first_name_cont]"] = options[:first_name] if options[:first_name]
108
126
  params["q[last_name_cont]"] = options[:last_name] if options[:last_name]
109
127
  params["q[rosters_id_eq]"] = options[:roster] if options[:roster]
128
+ params["location_id"] = options[:location] if options[:location]
110
129
  params["q[weight_class_numeric_eq]"] = options[:weight_class] if options[:weight_class]
111
130
  params["q[academic_class_eq]"] = options[:academic_class] if options[:academic_class]
112
131
  params["q[age_eq]"] = options[:age] if options[:age]
113
132
  params["q[profile_type_eq]"] = options[:profile_type] if profile_type_filter?
114
133
  params["expand_rosters"] = true if expand_includes?("rosters")
115
134
  params["expand_registration_answers"] = true if expand_includes?("registration_answers")
135
+ params["expand_notification_preferences"] = true if expand_includes?("notification_preferences")
116
136
  params
117
137
  end
118
138
 
@@ -130,6 +150,7 @@ module Wiq
130
150
  bits = []
131
151
  bits << "matching #{options[:query].inspect}" if options[:query]
132
152
  bits << "in roster #{options[:roster]}" if options[:roster]
153
+ bits << "at location #{options[:location]}" if options[:location]
133
154
  bits << "weight class #{options[:weight_class]}" if options[:weight_class]
134
155
  bits << "profile_type=#{options[:profile_type]}" if profile_type_filter? && options[:profile_type] != "teammate"
135
156
  bits.empty? ? "" : " (#{bits.join(", ")})"
data/lib/wiq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wiq
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/wiq.rb CHANGED
@@ -20,11 +20,13 @@ require "wiq/commands/registrations"
20
20
  require "wiq/commands/metrics"
21
21
  require "wiq/commands/events"
22
22
  require "wiq/commands/rosters"
23
+ require "wiq/commands/locations"
23
24
  require "wiq/commands/prospects"
24
25
  require "wiq/commands/prospect_families"
25
26
  require "wiq/commands/workflows"
26
27
  require "wiq/commands/setup"
27
28
  require "wiq/commands/wrestlers"
29
+ require "wiq/commands/parents"
28
30
  require "wiq/commands/charges"
29
31
  require "wiq/commands/billing_profiles"
30
32
  require "wiq/cli"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: wiq
3
- description: Use this skill when the user asks about their WrestlingIQ data — rosters, attendance, check-ins, paid sessions and registrations, the prospects/leads pipeline, financial metrics, reports, USAW/AAU memberships, fundraising, or online store orders. The `wiq` CLI provides read-only access to /api/v1 via personal access tokens. Recognize phrasings like "how is our pipeline?", "who came to practice this week?", "show me the roster", "what's our MRR?", "which kids need USAW renewal?", or anything that maps to a wrestling club's admin workflows.
3
+ description: Use this skill when the user asks about their WrestlingIQ data — rosters, attendance, check-ins, paid sessions and registrations, the prospects/leads pipeline, financial metrics, reports, USAW/AAU memberships, fundraising, online store orders, or per-location/site breakdowns for multi-gym clubs. The `wiq` CLI provides read-only access to /api/v1 via personal access tokens. Recognize phrasings like "how is our pipeline?", "who came to practice this week?", "show me the roster", "what's our MRR?", "which kids need USAW renewal?", "how is the Eastside gym doing?", or anything that maps to a wrestling club's admin workflows.
4
4
  ---
5
5
 
6
6
  # WrestlingIQ CLI Skill
@@ -223,9 +223,14 @@ join date is in the default output.
223
223
  resolves to paid_session ids whose date range overlaps the calendar
224
224
  year, then filters client-side. There's no first-class Season entity
225
225
  in WIQ.
226
- - **`Event.location` is free-text and not filterable.** No `--site` or
227
- `--location` flag exists yet. A structured location concept is on the
228
- WIQ roadmap.
226
+ - **Legacy free-text `Event.location` vs structured locations.** Old
227
+ events carry a free-text `location` string; the serialized `location`
228
+ field is the display form (structured Location name/address when
229
+ `location_id` is set, else the legacy text). `--location` filters only
230
+ match the structured `location_id` — events, rosters, and paid sessions
231
+ with NO location set are excluded from filtered listings, so an empty
232
+ filtered result doesn't mean "nothing happened", it may mean "nothing
233
+ is stamped with that location yet."
229
234
  - **Index responses are wrapped.** Every `/api/v1` index returns
230
235
  `{"<resource>": [...]}` — the CLI unwraps internally, but if you ever
231
236
  hit the API directly remember to unwrap.
@@ -338,13 +343,77 @@ For exhaustive exports go through `wiq reports run RosterReport`
338
343
  `profile_type=teammate` (matching the WIQ web UI default); pass
339
344
  `--profile-type alumnus|guest|all` to widen.
340
345
 
346
+ For "can this family be reached, and how?" questions, add
347
+ `--expand notification_preferences` to `wrestlers list|show` or
348
+ `parents list|show`. Each row gains `wiq_app_installed` plus a
349
+ `notification_preferences` object (`email`, `sms`, `push`,
350
+ `push_user_pref`; `null` means no explicit preference recorded). Coach
351
+ PATs only — with a parent/wrestler token the server returns 200 with
352
+ the fields silently absent, so a missing field means "check the
353
+ token's profile type", not "API bug".
354
+
355
+ ### Parents
356
+
357
+ `wiq parents list [--query <name>] [--first-name X] [--last-name Y]`
358
+ and `wiq parents show <id>` — slim payload (id, user_id, names) over
359
+ the team's teammate parents. There are no wrestler refs on a parent
360
+ row; to walk a family, start from the wrestler side
361
+ (`wiq wrestlers show <id>` embeds parent refs). Main uses: parent-first
362
+ ID discovery for `wiq billing_profiles show <id> --profile-type
363
+ ParentProfile`, and the notification-reachability expand above.
364
+
365
+ ## Locations (multi-site clubs)
366
+
367
+ WIQ has a structured Location model (name + street address, per team).
368
+ Multi-gym clubs stamp rosters, events, and paid sessions with a
369
+ location; single-site clubs usually have none, and every `--location`
370
+ flag is simply irrelevant for them.
371
+
372
+ Discover ids first — this is the anchor for everything below:
373
+
374
+ ```bash
375
+ wiq locations list # id, name, address, archived
376
+ wiq locations list --include-archived
377
+ ```
378
+
379
+ Then scope any of these surfaces:
380
+
381
+ | Command | Flag | Semantics |
382
+ | --- | --- | --- |
383
+ | `wiq rosters list` | `--location <id>` | Rosters stamped with that location (`q[location_id_eq]`) |
384
+ | `wiq events list` | `--location <id> [<id>...]` | Events at those locations (repeatable; unset-location events excluded) |
385
+ | `wiq paid_sessions list` | `--location <id>` | Sessions stamped with that location |
386
+ | `wiq wrestlers list` | `--location <id>` | Wrestlers on ANY roster at that location; composes with other filters |
387
+ | `wiq metrics show <name>` | `--location <id>` | Per-location finance metrics (the payment-dashboard filter) |
388
+ | `wiq reports run <Type>` | `--location <id>` | Scopes the wrestler set for the 13 location-aware report types |
389
+
390
+ Three gotchas an agent must know:
391
+
392
+ 1. **Reports precedence:** a specific `--roster <id>` (> 0) WINS over
393
+ `--location` in report args. Pass `--location` alone (or with
394
+ `--roster 0`) to get location scoping. A wrestler on multiple rosters
395
+ at the location collapses to one row; RosterReport's "Added to roster
396
+ at" becomes their EARLIEST membership across that location's rosters.
397
+ `CheckInSummaryReport` / `CheckInFeedReport` ignore both args
398
+ entirely (always team-wide) — use `CheckInReport --location <id>` for
399
+ location-scoped attendance.
400
+ 2. **Metrics fail silent, not loud:** an unknown or foreign `--location`
401
+ id on `wiq metrics show` silently falls back to ALL locations —
402
+ team-wide numbers, no error. Verify the id against
403
+ `wiq locations list` before quoting per-site revenue to the user.
404
+ 3. **Nothing is auto-stamped retroactively.** Filters only match records
405
+ whose `location_id` is set. Empty filtered results on a club that
406
+ just adopted locations usually mean unstamped data, not zero
407
+ activity. Rosters/events/paid_sessions embed their `location` object
408
+ (or null) in list payloads, so you can check coverage cheaply.
409
+
341
410
  ## What's NOT available (yet)
342
411
 
343
412
  The CLI is read-only by design except for report submission. You
344
413
  cannot via this CLI:
345
414
 
346
415
  - Create/edit prospects, families, notes, check-ins, events, paid
347
- sessions, rosters, or any other resource
416
+ sessions, rosters, locations, or any other resource
348
417
  - Mint, list, or revoke PATs (use the web UI at
349
418
  `<host>/settings/personal_access_tokens`)
350
419
  - Mark attendance, advance prospect stages, log contact notes
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wiq-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - WrestlingIQ
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-29 00:00:00.000000000 Z
11
+ date: 2026-08-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -90,8 +90,10 @@ files:
90
90
  - lib/wiq/commands/check_ins.rb
91
91
  - lib/wiq/commands/doctor.rb
92
92
  - lib/wiq/commands/events.rb
93
+ - lib/wiq/commands/locations.rb
93
94
  - lib/wiq/commands/metrics.rb
94
95
  - lib/wiq/commands/paid_sessions.rb
96
+ - lib/wiq/commands/parents.rb
95
97
  - lib/wiq/commands/prospect_families.rb
96
98
  - lib/wiq/commands/prospects.rb
97
99
  - lib/wiq/commands/registrations.rb