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,629 @@
|
|
|
1
|
+
# WIQ API Notes for the CLI
|
|
2
|
+
|
|
3
|
+
Reference for what the CLI can assume about `/api/v1`. The WIQ Rails app uses
|
|
4
|
+
**jbuilder partials** as the source of truth for response shapes; the
|
|
5
|
+
committed OpenAPI spec at `wrestling/doc/openapi.yaml` is out of date (last
|
|
6
|
+
regen ~1 year ago, partial coverage). Don't trust the YAML — read jbuilders
|
|
7
|
+
or rerun `OPENAPI=1 bundle exec rspec` against current specs before relying
|
|
8
|
+
on a shape.
|
|
9
|
+
|
|
10
|
+
Citations below point at the WIQ PAT worktree
|
|
11
|
+
(`/Users/msencenb/conductor/wrestling/.conductor/beirut`,
|
|
12
|
+
branch `msencenb/wiq-agent-cli-research`, commits `00d1dbf08` PAT,
|
|
13
|
+
`f4006b7dd` audit fixes). PAT support is in that branch and not yet in
|
|
14
|
+
`develop`.
|
|
15
|
+
|
|
16
|
+
## Host + alias configuration (no hard-coded URLs)
|
|
17
|
+
|
|
18
|
+
The CLI is going to be public source code. It must **not** ship a default
|
|
19
|
+
that points at WIQ infrastructure URLs in committed code.
|
|
20
|
+
|
|
21
|
+
The credentials store at `~/.config/wiq/credentials.json` (mode 0600) is a
|
|
22
|
+
two-level map: `host → alias → entry`. This is what makes the multi-club
|
|
23
|
+
case work — a single user with PATs for several clubs on the same host
|
|
24
|
+
stores each PAT under a different alias.
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"https://www.wrestlingiq.com": {
|
|
29
|
+
"default": { "token": "...", "profile": { "team_name": "Springfield" } },
|
|
30
|
+
"westside": { "token": "...", "profile": { "team_name": "Westside" } }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Host resolution order:**
|
|
36
|
+
1. `--host <url>` CLI flag.
|
|
37
|
+
2. `WIQ_HOST` env var.
|
|
38
|
+
3. `host` key in `.wiq/config.json` (walked from cwd upward).
|
|
39
|
+
4. Sole host in the credentials store (if exactly one).
|
|
40
|
+
5. **Production default** (`https://www.wrestlingiq.com`, hardcoded as
|
|
41
|
+
`Wiq::Config::PRODUCTION_HOST`). The 99% case is production; testing
|
|
42
|
+
against staging requires an explicit override via flag/env/config.
|
|
43
|
+
|
|
44
|
+
**Alias resolution order** (only consulted once the host is known):
|
|
45
|
+
1. `--as <alias>` CLI flag.
|
|
46
|
+
2. `WIQ_ALIAS` env var.
|
|
47
|
+
3. `alias` key in `.wiq/config.json`.
|
|
48
|
+
4. Sole alias for this host in the credentials store.
|
|
49
|
+
5. Literal `"default"` alias if it exists.
|
|
50
|
+
6. Otherwise unresolved → commands needing a token fail with
|
|
51
|
+
`code: "ambiguous_alias"` and a hint listing what's stored.
|
|
52
|
+
|
|
53
|
+
**Token resolution:**
|
|
54
|
+
1. `WIQ_TOKEN` env var (highest priority — direct bypass of the store).
|
|
55
|
+
2. `Credentials[host][alias].token`.
|
|
56
|
+
|
|
57
|
+
Auth surface:
|
|
58
|
+
|
|
59
|
+
- `wiq auth login [--as <alias>] [--force]` — stores into the slot. If
|
|
60
|
+
`--as` is omitted: uses `"default"` if free, otherwise refuses with
|
|
61
|
+
`code: "alias_required"` and points at `--as`.
|
|
62
|
+
- `wiq auth status [--as <alias>]` — resolves the chain and shows which
|
|
63
|
+
source won, plus the bound profile (display_name @ team_name (type)).
|
|
64
|
+
The live `/api/v1/personal_access_tokens` probe is best-effort; failures
|
|
65
|
+
are reported in `live_error` rather than aborting the command.
|
|
66
|
+
- `wiq auth logout [--as <alias>]` — removes one slot.
|
|
67
|
+
- `wiq auth list` — dumps every stored credential (token_prefix only, no
|
|
68
|
+
raw tokens) across all hosts and aliases.
|
|
69
|
+
|
|
70
|
+
Multi-club mechanics in practice:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
# First club — stored under "default" automatically.
|
|
74
|
+
wiq auth login --host https://www.wrestlingiq.com --token wiq_pat_aaa...
|
|
75
|
+
|
|
76
|
+
# Second club — explicit alias required because "default" is taken.
|
|
77
|
+
wiq auth login --as westside --token wiq_pat_bbb...
|
|
78
|
+
|
|
79
|
+
# Run a command against the non-default club.
|
|
80
|
+
wiq --as westside rosters list
|
|
81
|
+
WIQ_ALIAS=westside wiq rosters list
|
|
82
|
+
echo '{"alias":"westside"}' > .wiq/config.json # pins the cwd to westside
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Routes are constrained `format: "json"` (`config/routes.rb` ll. 92, 153) —
|
|
86
|
+
send `Accept: application/json` and append `.json` to be safe.
|
|
87
|
+
|
|
88
|
+
## Authentication
|
|
89
|
+
|
|
90
|
+
PATs are the only auth mode the CLI uses.
|
|
91
|
+
|
|
92
|
+
- Header: `Authorization: Bearer wiq_pat_<32 lowercase hex chars>` (token
|
|
93
|
+
length 40; the `wiq_pat_` prefix is literal and case-sensitive).
|
|
94
|
+
- The `Bearer` scheme is matched case-insensitively server-side
|
|
95
|
+
(RFC 7235 conformance), with `\s+` between scheme and token. Send
|
|
96
|
+
`Bearer wiq_pat_…` with a single space and you're fine.
|
|
97
|
+
- Server logic (`app/controllers/concerns/token_authenticable.rb`):
|
|
98
|
+
- Parses the header with `/\ABearer\s+(.+)\z/i` and strips whitespace.
|
|
99
|
+
- If the token starts with `wiq_pat_`, looks up its SHA-256 digest in
|
|
100
|
+
`personal_access_tokens` (scoped `revoked_at IS NULL`). On hit,
|
|
101
|
+
stashes `pat.profile` on the request and returns `pat.user`. On
|
|
102
|
+
miss, returns `nil` → 401 `{"errors":["Unauthorized"]}`.
|
|
103
|
+
- Otherwise falls through to a legacy `User.find_by(auth_token: …)`
|
|
104
|
+
lookup (mobile webview only — the CLI never sees this path).
|
|
105
|
+
- `last_used_at` is debounced via a Sidekiq job (5-minute window). Don't
|
|
106
|
+
use it for sub-minute liveness signals.
|
|
107
|
+
- PATs inherit the full permission set of their bound profile. No scopes.
|
|
108
|
+
|
|
109
|
+
### PATs are profile-bound (this matters)
|
|
110
|
+
|
|
111
|
+
A WIQ user can carry multiple profiles — `CoachProfile`, `ParentProfile`,
|
|
112
|
+
`WrestlerProfile` — sometimes on different teams. Authorization is keyed on
|
|
113
|
+
`current_profile`, not `current_user`. **Each PAT is bound to one specific
|
|
114
|
+
profile at creation time**, and that binding is immutable. The token *is*
|
|
115
|
+
the profile context.
|
|
116
|
+
|
|
117
|
+
Implications for the CLI:
|
|
118
|
+
|
|
119
|
+
- **Do not send `X-WIQ-Profile-Id` / `X-WIQ-Profile-Type` headers.** They
|
|
120
|
+
are ignored on PAT-authenticated requests (`@authenticated_pat_profile`
|
|
121
|
+
short-circuits the header path in `token_authenticable.rb`).
|
|
122
|
+
- No `--profile` flag, no profile-switching command, no stored profile
|
|
123
|
+
state on the CLI side. The customer mints one PAT per role.
|
|
124
|
+
- If a customer switches profiles in the web UI, their cron behavior is
|
|
125
|
+
unaffected.
|
|
126
|
+
|
|
127
|
+
### Identity via `GET /api/v1/personal_access_tokens`
|
|
128
|
+
|
|
129
|
+
This endpoint doubles as the "who am I" probe. Response is wrapped:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"personal_access_tokens": [
|
|
134
|
+
{
|
|
135
|
+
"id": 42,
|
|
136
|
+
"name": "Monthly reporting cron",
|
|
137
|
+
"token_prefix": "wiq_pat_a1",
|
|
138
|
+
"created_at": "2026-05-11T12:00:00Z",
|
|
139
|
+
"updated_at": "2026-05-11T12:00:00Z",
|
|
140
|
+
"last_used_at": "2026-05-11T14:30:00Z",
|
|
141
|
+
"revoked_at": null,
|
|
142
|
+
"profile": {
|
|
143
|
+
"id": 17,
|
|
144
|
+
"type": "CoachProfile",
|
|
145
|
+
"display_name": "Matt Sencenbaugh",
|
|
146
|
+
"team_name": "Springfield Wrestling"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The calling PAT is always present in this list (the row whose
|
|
154
|
+
`token_prefix` matches the first 12 chars of the local plaintext). The
|
|
155
|
+
embedded `profile` object gives the CLI enough identity info that **no
|
|
156
|
+
separate `/api/v1/me` endpoint is needed.** What was a follow-up
|
|
157
|
+
in the previous draft of this doc is closed.
|
|
158
|
+
|
|
159
|
+
CLI behavior for `wiq auth login` and `wiq auth status`:
|
|
160
|
+
|
|
161
|
+
1. `GET /api/v1/personal_access_tokens` — confirms 200 (auth probe) and
|
|
162
|
+
provides identity in the same call.
|
|
163
|
+
2. Find the row whose `token_prefix` matches the local token's prefix.
|
|
164
|
+
3. Store `{host, token, token_prefix, name, profile}` in
|
|
165
|
+
`~/.config/wiq/credentials.json` (mode 0600).
|
|
166
|
+
4. `wiq auth status` displays
|
|
167
|
+
`<display_name> @ <team_name> (<profile_type>)`.
|
|
168
|
+
|
|
169
|
+
## Index responses are wrapped (not bare arrays)
|
|
170
|
+
|
|
171
|
+
Every `/api/v1` index endpoint wraps its records under the resource name:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{ "rosters": [ { "id": 1, "name": "Varsity" }, ... ] }
|
|
175
|
+
{ "events": [ ... ] }
|
|
176
|
+
{ "paid_sessions": [ ... ] }
|
|
177
|
+
{ "reports": [ ... ] }
|
|
178
|
+
{ "check_ins": [ ... ] }
|
|
179
|
+
{ "personal_access_tokens":[ ... ] }
|
|
180
|
+
{ "registration_questions":[ ... ] }
|
|
181
|
+
{ "registration_answers": [ ... ] }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Confirmed from the jbuilders (e.g. `app/views/api/v1/rosters/index.json.jbuilder`
|
|
185
|
+
does `json.rosters @rosters do |r| ... end`). **Show**, **create**, and
|
|
186
|
+
**update** responses are NOT wrapped — they return the resource object at
|
|
187
|
+
the top level. Metrics endpoints follow a third shape
|
|
188
|
+
(`{ metrics: {...}, charts: [...] }`); see the dashboard section.
|
|
189
|
+
|
|
190
|
+
The CLI's HTTP client takes the resource-name key as an argument to
|
|
191
|
+
`paginate` / `collect_all` and unwraps before yielding records — index
|
|
192
|
+
callers must specify it explicitly.
|
|
193
|
+
|
|
194
|
+
## Pagination contract
|
|
195
|
+
|
|
196
|
+
`app/controllers/concerns/pageable.rb`.
|
|
197
|
+
|
|
198
|
+
- Query: `?page=<n>&per_page=<m>`. Defaults `page=1`, `per_page=30`. No
|
|
199
|
+
documented `per_page` ceiling; treat 100 as safe.
|
|
200
|
+
- Response headers:
|
|
201
|
+
- `Link` — RFC 5988-ish, comma-separated, e.g.
|
|
202
|
+
`<https://host/api/v1/rosters?page=2>; rel="next", <...>; rel="last"`.
|
|
203
|
+
Only `rel=first|last|next|prev` are emitted; `prev`/`first` omitted on
|
|
204
|
+
page 1, `next`/`last` on the last. Single-page responses emit no rels.
|
|
205
|
+
- `TotalCount` — bare integer total record count for the filtered query.
|
|
206
|
+
**Note the unusual capitalized name (not `X-Total-Count`).** Faraday's
|
|
207
|
+
case-insensitive access handles it; remember when writing `--all` logic.
|
|
208
|
+
- Filters via `filter_and_page`:
|
|
209
|
+
- `?query=<string>` — legacy substring search via per-model `.search`
|
|
210
|
+
scope (mostly name-based).
|
|
211
|
+
- `?q[<attr>_<predicate>]=<val>` — Ransack. Each model has a
|
|
212
|
+
`ransackable_attributes` allowlist; an unlisted attribute is silently
|
|
213
|
+
ignored by Ransack. `q[s]=<attr>+<direction>` sets sort
|
|
214
|
+
(default `id desc`).
|
|
215
|
+
|
|
216
|
+
Implementation note: implement `--all` by following `rel=next` until
|
|
217
|
+
absent. Do not divide `TotalCount / per_page` and parallelize — several
|
|
218
|
+
endpoints distinct-join and the page count can drift.
|
|
219
|
+
|
|
220
|
+
## Error response shapes
|
|
221
|
+
|
|
222
|
+
`app/controllers/concerns/json_api.rb`. All errors come back as
|
|
223
|
+
`{"errors": <payload>}`.
|
|
224
|
+
|
|
225
|
+
| Status | Trigger | Payload shape |
|
|
226
|
+
| --- | --- | --- |
|
|
227
|
+
| 400 | `Client version expired` (mobile-only; CLI won't trip it) | `{"errors": ["Client version expired"]}` |
|
|
228
|
+
| 401 | Missing/invalid/revoked PAT | `{"errors": ["Unauthorized"]}` |
|
|
229
|
+
| 403 | Pundit denial | `{"errors": ["Not Authorized To View Content"]}` |
|
|
230
|
+
| 404 | `ActiveRecord::RecordNotFound` or explicit | `{"errors": ["Not Found"]}` |
|
|
231
|
+
| 422 | `validation_error(@model.errors)` | `{"errors": { "<field>": ["msg", ...], ... }}` — **ActiveModel::Errors hash** |
|
|
232
|
+
| 422 | `validation_error(["msg"])` (rare; e.g. roster delete restriction) | `{"errors": ["msg", ...]}` |
|
|
233
|
+
| 500 | Fallback | `{"errors": [...]}` |
|
|
234
|
+
|
|
235
|
+
The CLI error mapper must handle both 422 variants — same key (`errors`)
|
|
236
|
+
but value is **either an array of strings or a hash of field→messages**.
|
|
237
|
+
Normalize to a flat list (with field prefix when present) for agent output.
|
|
238
|
+
|
|
239
|
+
There is no `request_id` echoed by the server today; another follow-up to
|
|
240
|
+
file with the WIQ team.
|
|
241
|
+
|
|
242
|
+
## Report polling contract
|
|
243
|
+
|
|
244
|
+
`app/controllers/api/v1/reports_controller.rb`, `app/models/report.rb`,
|
|
245
|
+
`app/models/concerns/queueable.rb`.
|
|
246
|
+
|
|
247
|
+
- `POST /api/v1/reports`
|
|
248
|
+
- Body: `{ "report": { "type": "<ReportClass>", "version": "v1"|"vrow", "name": "...", "start_at": "YYYY-MM-DD", "end_at": "YYYY-MM-DD", "args": { ... } } }`
|
|
249
|
+
- Permitted `args` keys (`reports_controller.rb#report_params`):
|
|
250
|
+
`paid_session_id, roster_id, fundraiser_id, online_store_id, event_id,
|
|
251
|
+
include_archived_roster_tags, append_property_ids[]`.
|
|
252
|
+
- **Special values** worth knowing for any agent:
|
|
253
|
+
- `roster_id: 0` → "all rosters" (UI convention from `defaultRoster` in
|
|
254
|
+
`report_form.vue`).
|
|
255
|
+
- `paid_session_id: 0` → "all sessions" — accepted **only** by
|
|
256
|
+
`UsawExportReport` (the only report where `allowAllPaidSessionsInPicker`
|
|
257
|
+
is true). Anywhere else it'll 404 on `PaidSession.find(0)`.
|
|
258
|
+
- `append_property_ids: [<reg_question_id>, ...]` → custom-column append.
|
|
259
|
+
In practice **only `RosterReport` consumes this**; other reports
|
|
260
|
+
accept the param but ignore it. Discover question ids via
|
|
261
|
+
`GET /api/v1/registration_questions`.
|
|
262
|
+
- `include_archived_roster_tags: true` → also `RosterReport`-only in
|
|
263
|
+
the UI, paired with the column-add feature.
|
|
264
|
+
- `days_threshold` (used by `ChurnRiskReport`, 7/14/30/60/90) — in the
|
|
265
|
+
permit list as of the May 2026 WIQ-app fix. The CLI's
|
|
266
|
+
`--days-threshold` flag lands unchanged on the model.
|
|
267
|
+
|
|
268
|
+
**Auth model (POST /api/v1/reports):**
|
|
269
|
+
|
|
270
|
+
- Every report POST requires a `CoachProfile`-bound PAT on the same team
|
|
271
|
+
(`coach_belongs_to_team?` in `ReportPolicy#create?`). Parent/wrestler
|
|
272
|
+
PATs return 403 with body `"Personal access tokens are read-only..."`
|
|
273
|
+
via `enforce_pat_restrictions` — reports is the one write currently on
|
|
274
|
+
the PAT allowlist.
|
|
275
|
+
- A subset of report types — the 9 listed in `Report.finance_types`
|
|
276
|
+
(Membership, PaidSessionAccounting, RecurringDonor, DonationTransaction,
|
|
277
|
+
FundraiserSummary, FundraiserAccounting, InProgressRegistration,
|
|
278
|
+
OverdueRegistration, ScholarshipAudit) — additionally require
|
|
279
|
+
`is_admin?`. Pundit raises before save, so a denied call never
|
|
280
|
+
enqueues a denatured report.
|
|
281
|
+
- **`elite` and `payments_enabled` are NOT API gates.** Both are
|
|
282
|
+
team-level attributes that affect UI tab rendering only. A coach on a
|
|
283
|
+
non-elite team CAN successfully POST a `MembershipSummaryReport` via
|
|
284
|
+
the API as long as they're admin.
|
|
285
|
+
- Response: `201 Created` with the full report jbuilder
|
|
286
|
+
(`id, created_at, updated_at, type, processed_at, start_at, end_at, name,
|
|
287
|
+
version, status, result, args`). `status` is `"requested"` momentarily;
|
|
288
|
+
`after_save_commit` flips it to `"queued"` and enqueues `ReportJob`,
|
|
289
|
+
so a follow-up GET will usually see `queued` or later.
|
|
290
|
+
- `GET /api/v1/reports/:id` — same payload. Poll this.
|
|
291
|
+
- Status state machine (`Queueable`):
|
|
292
|
+
- `requested` → `queued` → `processing` → `ready` (terminal success) or
|
|
293
|
+
`failed` (terminal error). `potential` exists but is unused for reports.
|
|
294
|
+
- Completion: `status == "ready"`. `processed_at` and populated `result`
|
|
295
|
+
are corollaries.
|
|
296
|
+
- `result` is jsonb, type-specific. Treat as opaque JSON in the CLI;
|
|
297
|
+
pretty-print or write to file.
|
|
298
|
+
- `GET /api/v1/reports` — paginated index of completed/known reports for
|
|
299
|
+
the team, policy-scoped.
|
|
300
|
+
- CLI polling: start at 2s, exponential backoff to 30s cap, default
|
|
301
|
+
timeout 5 min (`--timeout` override). On `failed`, surface `result`
|
|
302
|
+
(most subclasses stash an error there) and exit non-zero
|
|
303
|
+
(`code: "report_failed"`).
|
|
304
|
+
|
|
305
|
+
### Report `type` values
|
|
306
|
+
|
|
307
|
+
All STI subclasses of `Report` in `app/models/`. Club-admin-relevant ones
|
|
308
|
+
bolded; the rest exist but aren't on a club-admin's daily path:
|
|
309
|
+
|
|
310
|
+
`AauExpiredReport, AauExportReport, AauReport, CancelledSubscriptionsReport,
|
|
311
|
+
CheckInFeedReport, **CheckInReport**, **CheckInSummaryReport**,
|
|
312
|
+
ChurnRiskReport, CurrentlyPausedSubscriptionsReport,
|
|
313
|
+
DiscountedSubscriptionsReport, **DonationTransactionReport**,
|
|
314
|
+
**EventStatsReport**, ExpiringSubscriptionsReport,
|
|
315
|
+
**FullExportWrestlerReport**, **FundraiserAccountingReport**,
|
|
316
|
+
**FundraiserSummaryReport**, InProgressRegistrationReport, InviteStatusReport,
|
|
317
|
+
**LastPracticeAttendedReport**, **MembershipSummaryReport**,
|
|
318
|
+
**OnlineStoreDetailReport**, **OnlineStoreSummaryReport**,
|
|
319
|
+
**OverdueRegistrationReport**, **PaidSessionAccountingReport**,
|
|
320
|
+
PaidSessionAddOnDetailReport, PaidSessionAddOnSummaryReport,
|
|
321
|
+
**PracticeAttendanceReport**, **RecurringDonorReport**, RegistrationAnswerReport,
|
|
322
|
+
**RegistrationFinanceSummaryReport**, **RosterReport**, **RosterStatsReport**,
|
|
323
|
+
**ScholarshipAuditReport**, SessionRegistrationAnswerReport,
|
|
324
|
+
TeamRegistrationRosterReport, UsawExpiredReport, UsawExportReport, UsawReport,
|
|
325
|
+
**WinLossReport**, **WrestlersWithoutSubscriptionsReport**`
|
|
326
|
+
|
|
327
|
+
`wiq reports types` should print descriptions + required args for the
|
|
328
|
+
bolded set; users can still pass any type string, but help only documents
|
|
329
|
+
the curated list.
|
|
330
|
+
|
|
331
|
+
## Dashboard / metrics endpoints
|
|
332
|
+
|
|
333
|
+
`app/controllers/api/v1/metrics_controller.rb`.
|
|
334
|
+
|
|
335
|
+
All metrics endpoints `authorize :finances, :show?` — only users with the
|
|
336
|
+
`finances#show` Pundit grant (typically full-access coaches) can call
|
|
337
|
+
them. PATs minted by parents/wrestlers will 403 here.
|
|
338
|
+
|
|
339
|
+
- Routes (all GET): `/api/v1/metrics/<name>` where `<name>` ∈
|
|
340
|
+
`active_subscribers, category_breakdown_net, charge_avg, charge_count,
|
|
341
|
+
gross_volume, mrr, net_volume, new_subscriptions (alias: new_subscribers),
|
|
342
|
+
cancelled_subscriptions, renewed_subscriptions, revenue_per_subscriber`.
|
|
343
|
+
- Query params (uniform across all metrics):
|
|
344
|
+
- `range` — one of `today, 7d, 4w, 3m, 12m, "year to date", custom`. Default `7d`. (Note the literal space in `"year to date"` — URL-encode as `year%20to%20date`.) Unknown values fall back to `7d` silently.
|
|
345
|
+
- `interval_group` — one of `hourly, daily, weekly, monthly`. Default `daily`. Invalid values fall back to `daily` silently.
|
|
346
|
+
- `start_date`, `end_date` (YYYY-MM-DD) — required when `range=custom`. If invalid or missing, server silently downgrades to `range=7d`. CLI should validate before sending.
|
|
347
|
+
- Response shape (uniform):
|
|
348
|
+
```json
|
|
349
|
+
{
|
|
350
|
+
"metrics": {
|
|
351
|
+
"primary_series": [{ "interval": "...", "metric": <number>, ... }],
|
|
352
|
+
"primary_total": <number>,
|
|
353
|
+
"comparison_series": [...],
|
|
354
|
+
"comparison_total": <number>
|
|
355
|
+
},
|
|
356
|
+
"charts": [ /* Highcharts-shaped, ignore in CLI */ ]
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
- **All currency values are in integer cents.** Divide by 100 for dollars.
|
|
360
|
+
Applies to `gross_volume, net_volume, charge_avg, mrr, revenue_per_subscriber, category_breakdown_net`.
|
|
361
|
+
- `comparison_series` is empty when `range=custom`. For all other ranges,
|
|
362
|
+
it's the prior period of the same length (e.g. `7d` → previous 7 days).
|
|
363
|
+
- The `charts[]` blob is server-rendered HTML/Highcharts config; the CLI
|
|
364
|
+
should ignore it entirely and project `metrics.primary_series` /
|
|
365
|
+
`primary_total` for agent output.
|
|
366
|
+
- `category_breakdown_net` has a different `metrics.primary_series` row
|
|
367
|
+
shape (`category`, `subcategory`, `detail_category`, `total_net_amount`)
|
|
368
|
+
— special-case it in the formatter.
|
|
369
|
+
|
|
370
|
+
## Check-ins (attendance)
|
|
371
|
+
|
|
372
|
+
Two index endpoints + create/update on the event-scoped path.
|
|
373
|
+
|
|
374
|
+
- `GET /api/v1/events/:event_id/check_ins` —
|
|
375
|
+
`event.check_ins` paginated, sorted `id desc`. Ransackable on
|
|
376
|
+
`created_at, status` (`CheckIn.ransackable_attributes`). Includes
|
|
377
|
+
`registration_answers, event, wrestler_profile`.
|
|
378
|
+
- `GET /api/v1/wrestlers/:wrestler_id/check_ins` —
|
|
379
|
+
the wrestler's check-ins across all events; paginated.
|
|
380
|
+
- `POST /api/v1/events/:event_id/check_ins` —
|
|
381
|
+
body `{ check_in: { wrestler_profile_id, status, notes } }`. Status is a
|
|
382
|
+
free-text string column (no enum); current UI values are `"checked_in"`,
|
|
383
|
+
`"absent"`, etc. — confirm with the team before exposing creation.
|
|
384
|
+
- `PUT /api/v1/events/:event_id/check_ins/:id` — same body.
|
|
385
|
+
|
|
386
|
+
Check-in jbuilder (`app/views/api/v1/shared/_check_in.json.jbuilder`):
|
|
387
|
+
`id, created_at, updated_at, event_id, event_name, notes, status, rosters,
|
|
388
|
+
wrestler_profile_id, profile, registration_answers, class_pass_id,
|
|
389
|
+
class_pass`.
|
|
390
|
+
|
|
391
|
+
For attendance analytics, `PracticeAttendanceReport` (date range +
|
|
392
|
+
optional `roster_id`) is the right primitive — it aggregates server-side
|
|
393
|
+
and is cheaper than paginating raw check-ins. Use the raw endpoints when
|
|
394
|
+
the client needs the individual rows (e.g., late-arrival lookups).
|
|
395
|
+
|
|
396
|
+
## Paid sessions (registration data)
|
|
397
|
+
|
|
398
|
+
`app/controllers/api/v1/paid_sessions_controller.rb`.
|
|
399
|
+
|
|
400
|
+
- `GET /api/v1/paid_sessions[?type=...]` — heavy preset-scope filter.
|
|
401
|
+
Recognized `type` values:
|
|
402
|
+
`registerable, guest_registerable, ends_in_future, recurring_registerable,
|
|
403
|
+
recurring, not_recurring, not_recurring_with_archived, not_archived,
|
|
404
|
+
dropin, trial, trial_or_dropin`. Without `type`, returns all team
|
|
405
|
+
paid_sessions.
|
|
406
|
+
- Plus Ransack on `name, start_at, end_at, slug, session_type`
|
|
407
|
+
(`PaidSession.ransackable_attributes`).
|
|
408
|
+
- `GET /api/v1/paid_sessions/:id` — full payload.
|
|
409
|
+
- `POST` / `PUT` — supported (params include `roster_syncers_attributes`,
|
|
410
|
+
`team_registration_divisions_attributes` for nested updates).
|
|
411
|
+
|
|
412
|
+
Jbuilder embeds `stats` inline:
|
|
413
|
+
`good_standing_registrations_count, not_canceled_registrations_count,
|
|
414
|
+
overdue_registrations_count, registrations_count,
|
|
415
|
+
good_standing_membership_count`. These are server-counted aggregates — a
|
|
416
|
+
CLI `paid-sessions list --stats` doesn't need extra calls.
|
|
417
|
+
|
|
418
|
+
Roster ↔ paid-session linkage runs through `roster_syncers` (joined
|
|
419
|
+
in the response). A `RosterSyncer` row carries `paid_session_id` and
|
|
420
|
+
`roster_id`; this is how to walk "what rosters does this season feed?"
|
|
421
|
+
without a separate query.
|
|
422
|
+
|
|
423
|
+
Other registration surface:
|
|
424
|
+
|
|
425
|
+
- `GET /api/v1/registration_questions` — team's question definitions
|
|
426
|
+
(`prompt, type, for_type, required, is_public, coach_visibility, …`).
|
|
427
|
+
`RegAddressQuestion` is the type used to collect address+zip.
|
|
428
|
+
- `GET /api/v1/registration_answers?profile_id=X&profile_type=WrestlerProfile`
|
|
429
|
+
— answers for a specific profile, optionally scoped to a session via
|
|
430
|
+
`session_id`. Visibility filterable with `visibility=public|private`.
|
|
431
|
+
- `POST /api/v1/memberships` + `POST /api/v1/memberships/preview` — create
|
|
432
|
+
a paid-session signup. Out of CLI v1 scope (write path, more involved
|
|
433
|
+
payment validation).
|
|
434
|
+
|
|
435
|
+
## Calendar / events
|
|
436
|
+
|
|
437
|
+
`app/controllers/api/v1/events_controller.rb` +
|
|
438
|
+
`app/controllers/concerns/calendar_event_helpers.rb`.
|
|
439
|
+
|
|
440
|
+
- `GET /api/v1/events?start=YYYY-MM-DD&end=YYYY-MM-DD[&roster_ids[]=...&event_type=practice]`
|
|
441
|
+
— date range params parsed in the team's `default_time_zone` and
|
|
442
|
+
converted to UTC server-side. Use ISO dates, let the server handle TZ.
|
|
443
|
+
- Additional filters in the helper: `private_lesson_filter, invited,
|
|
444
|
+
invite_status, event_booking_status_in, coach_ids`.
|
|
445
|
+
- `expand=event_invites,event_bookings,private_lessons` — comma-separated
|
|
446
|
+
CSV; pulls in nested data on the events index/show.
|
|
447
|
+
- `Event.ransackable_attributes` allows
|
|
448
|
+
`id, name, start_at, end_at, event_type, paid_session_id`. **`location`
|
|
449
|
+
is not ransackable today.** A real location filter is on the WIQ
|
|
450
|
+
backend roadmap (it will replace the free-text `location` column with a
|
|
451
|
+
structured concept); until that ships the CLI doesn't expose a
|
|
452
|
+
location/site flag. Users who need it can fetch the date range, write
|
|
453
|
+
the results to a file, and post-process — that's not the CLI's problem
|
|
454
|
+
to solve in v1.
|
|
455
|
+
- Soft deletes: events use `acts_as_paranoid`; the index calls
|
|
456
|
+
`.without_deleted`. No "include deleted" param exposed.
|
|
457
|
+
- `POST /api/v1/events` — recurring practice creation:
|
|
458
|
+
```json
|
|
459
|
+
{"event": {
|
|
460
|
+
"name": "...", "event_type": "practice",
|
|
461
|
+
"start_at": "2026-09-01T17:00:00Z", "end_at": "2026-09-01T18:30:00Z",
|
|
462
|
+
"repeat": {"mon": true, "wed": true, "fri": true, "until": "2027-05-31"},
|
|
463
|
+
"roster_events_attributes": [{"roster_id": 42}]
|
|
464
|
+
}}
|
|
465
|
+
```
|
|
466
|
+
Each day in `repeat` fans out into separate event rows server-side. The
|
|
467
|
+
response only returns the first event — there's no `batch_id`. To
|
|
468
|
+
delete a whole series later: `DELETE /api/v1/events/:id?delete_recurring=true`.
|
|
469
|
+
- `POST /api/v1/events/:id/notify` — pushes change notifications. CLI
|
|
470
|
+
should never call this implicitly; gate behind an explicit `--notify`
|
|
471
|
+
flag on event edits.
|
|
472
|
+
|
|
473
|
+
## Prospects (lead-pipeline) endpoints
|
|
474
|
+
|
|
475
|
+
Three controllers under `/api/v1`:
|
|
476
|
+
|
|
477
|
+
- `Api::V1::ProspectsController` — `index, show, update, destroy` plus a
|
|
478
|
+
collection `GET /prospects/summary` dashboard endpoint.
|
|
479
|
+
- `Api::V1::ProspectFamiliesController` — `index, show, create, update,
|
|
480
|
+
destroy`. Family = household; a family has many prospects (one per kid).
|
|
481
|
+
- `Api::V1::ProspectFamilyNotesController` — nested under
|
|
482
|
+
`/prospect_families/:id/notes`, `index` + `create`. Notes are
|
|
483
|
+
`Note` rows with `noteable_type: "ProspectFamily"`.
|
|
484
|
+
|
|
485
|
+
### Prospect index — filters
|
|
486
|
+
|
|
487
|
+
`GET /api/v1/prospects` (wrapped under `"prospects"`):
|
|
488
|
+
|
|
489
|
+
| Param | Values | Notes |
|
|
490
|
+
| --- | --- | --- |
|
|
491
|
+
| `query` | free text | Searches across family contact name/email/phone. **Bypasses all other filters when present.** |
|
|
492
|
+
| `attention_mode` | `needs_attention` \| `handled` | `needs_attention` = `needs_follow_up=true`; `handled` = active stage + not flagged. |
|
|
493
|
+
| `stage` | `inquiry, trial_scheduled, trialing, trial_complete, converted, didnt_join, archived` | One funnel stage. |
|
|
494
|
+
| `assigned_to` | `me` | Restricts to families assigned to the calling coach. |
|
|
495
|
+
| `assigned_coach_id` | `<id>` | Restricts to families assigned to that coach. |
|
|
496
|
+
| `page`, `per_page` | standard | |
|
|
497
|
+
|
|
498
|
+
### Prospect summary — dashboard endpoint
|
|
499
|
+
|
|
500
|
+
`GET /api/v1/prospects/summary` returns an **unwrapped object** (no
|
|
501
|
+
pagination, no resource-name key). Shape:
|
|
502
|
+
|
|
503
|
+
```json
|
|
504
|
+
{
|
|
505
|
+
"by_stage": { "inquiry": 12, "trial_scheduled": 3, ... },
|
|
506
|
+
"by_stage_needs_attention": { "inquiry": 4, ... },
|
|
507
|
+
"by_stage_handled": { "inquiry": 8, ... },
|
|
508
|
+
"needs_action_count": N,
|
|
509
|
+
"needs_attention_count": N,
|
|
510
|
+
"active_trials_count": N,
|
|
511
|
+
"families_total_count": N,
|
|
512
|
+
"families_needs_attention_count": N,
|
|
513
|
+
"families_handled_count": N,
|
|
514
|
+
"conversion_rate": 42.7
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Params:
|
|
519
|
+
- `start_date` + `end_date` (YYYY-MM-DD) — explicit cohort window. Must
|
|
520
|
+
be passed together.
|
|
521
|
+
- `conversion_days` (integer; only `30, 60, 90, 180` accepted, default
|
|
522
|
+
`90`) — look-back window when no explicit cohort dates.
|
|
523
|
+
|
|
524
|
+
Cohort semantics: numerator and denominator are pinned to the same
|
|
525
|
+
cohort (prospects created in the window). Without this pin, a prospect
|
|
526
|
+
created before the window but converting inside it would push the rate
|
|
527
|
+
past 100%.
|
|
528
|
+
|
|
529
|
+
### ProspectFamily index — filters + sort
|
|
530
|
+
|
|
531
|
+
`GET /api/v1/prospect_families` (wrapped under `"prospect_families"`):
|
|
532
|
+
|
|
533
|
+
Filters: `query, attention_mode, stage` (via `with_any_prospect_in_stage`
|
|
534
|
+
scope), `assigned_to=me`, `assigned_coach_id`, and a registration-answer
|
|
535
|
+
filter `question_id` + `answer_value` (both required).
|
|
536
|
+
|
|
537
|
+
`sort` parameter (one of `newest, oldest_followup, oldest_contact,
|
|
538
|
+
next_trial`):
|
|
539
|
+
- `newest` — default for All Leads view; `families.created_at DESC`.
|
|
540
|
+
- `oldest_followup` — default when `attention_mode=needs_attention`;
|
|
541
|
+
oldest pending follow-up first.
|
|
542
|
+
- `oldest_contact` — by `MAX(notes.created_at WHERE activity_type IS NOT NULL)`,
|
|
543
|
+
`NULLS FIRST` so never-contacted leads surface (most actionable, not
|
|
544
|
+
least).
|
|
545
|
+
- `next_trial` — soonest future-scheduled trial first.
|
|
546
|
+
|
|
547
|
+
Family jbuilder embeds an `prospects[]` array (each kid's full row) plus
|
|
548
|
+
the `last_contact` summary `{ activity_type, author_name, occurred_at }`
|
|
549
|
+
preloaded as a `has_one` so the index payload doesn't scan a family's
|
|
550
|
+
full note history. Also surfaces `registration_answers` so a coach sees
|
|
551
|
+
the intake form on the row card.
|
|
552
|
+
|
|
553
|
+
### ProspectFamily notes
|
|
554
|
+
|
|
555
|
+
`GET /api/v1/prospect_families/:id/notes` (wrapped under `"notes"`)
|
|
556
|
+
returns one row per `Note`. Fields per the standard note jbuilder:
|
|
557
|
+
`id, created_at, activity_type, content, plain_content, author{id, type,
|
|
558
|
+
display_name}, noteable{id, type}`. Sorted `id DESC` (newest first).
|
|
559
|
+
|
|
560
|
+
CLI surface for the above (all reads):
|
|
561
|
+
- `wiq prospects list/show/summary`
|
|
562
|
+
- `wiq prospect_families list/show/notes`
|
|
563
|
+
|
|
564
|
+
Writes (create family, advance stage, log a note) are deliberately
|
|
565
|
+
deferred — see `docs/deferred.md`.
|
|
566
|
+
|
|
567
|
+
## How `--season <year>` resolves
|
|
568
|
+
|
|
569
|
+
No first-class `Season` model exists. The CLI treats "season" as a
|
|
570
|
+
client-side projection over `PaidSession`:
|
|
571
|
+
|
|
572
|
+
1. `GET /api/v1/paid_sessions?q[start_at_lteq]=<year>-12-31&q[end_at_gteq]=<year>-01-01`
|
|
573
|
+
to find paid sessions overlapping the calendar year. (Both `start_at`
|
|
574
|
+
and `end_at` are in the Ransack allowlist.) Paid sessions per team are
|
|
575
|
+
usually <50, so a full pull and client-side filter is also fine.
|
|
576
|
+
2. For roster-scoped commands: `GET /api/v1/rosters` (full list is
|
|
577
|
+
cheap; the jbuilder already embeds `roster_syncers`), then filter
|
|
578
|
+
rosters whose `roster_syncers[].paid_session_id` is in the matched set.
|
|
579
|
+
3. Some teams tag rosters with strings like `2025-26`. The roster
|
|
580
|
+
jbuilder exposes `taggings[].tag`; offer `--season-tag <tag>` as a
|
|
581
|
+
secondary path for teams that use that convention. Don't default to
|
|
582
|
+
tag-based resolution — hygiene varies.
|
|
583
|
+
|
|
584
|
+
Documented behavior:
|
|
585
|
+
|
|
586
|
+
- `wiq rosters list --season 2026` → resolve to paid_session_ids, filter
|
|
587
|
+
the rosters response client-side.
|
|
588
|
+
- `wiq reports run … --season 2026` → if the report type accepts
|
|
589
|
+
`paid_session_id` in `args`, pass each matching id (or error if the
|
|
590
|
+
type wants exactly one). Otherwise error with
|
|
591
|
+
`code: "season_unsupported_for_type"`.
|
|
592
|
+
- Zero paid sessions match → exit with `code: "season_not_found"`, hint
|
|
593
|
+
pointing at `wiq paid-sessions list`.
|
|
594
|
+
|
|
595
|
+
Long-term: push for a first-class `Season` resource on the backend if the
|
|
596
|
+
PaidSession-overlap convention proves load-bearing for many customers.
|
|
597
|
+
|
|
598
|
+
## Other things worth knowing
|
|
599
|
+
|
|
600
|
+
- **CORS:** Allowlist is `localhost:{3000,5002}`, the ngrok dev host, prod,
|
|
601
|
+
qa (`config/application.rb:100`). Irrelevant for the CLI (no browser
|
|
602
|
+
origin) but explains the absence of CORS pain.
|
|
603
|
+
- **Rate limit:** existing `api/ip` rule, 100 req / 3 sec per source IP.
|
|
604
|
+
No per-PAT throttle yet.
|
|
605
|
+
- **`include`s by default:** controllers pre-include heavily; the CLI
|
|
606
|
+
doesn't need to think about N+1s.
|
|
607
|
+
- **Side-effect endpoints to gate behind explicit flags:** roster sync
|
|
608
|
+
(`POST /api/v1/rosters/:id/sync`), event-change notifications
|
|
609
|
+
(`POST /api/v1/events/:id/notify`), message-group read
|
|
610
|
+
(`POST /api/v1/message_groups/:id/read`). These work, but they kick
|
|
611
|
+
off jobs / send pushes / move read state. v1 either doesn't expose
|
|
612
|
+
them or wraps them in `--confirm`.
|
|
613
|
+
|
|
614
|
+
## Open questions to file back with the WIQ app team
|
|
615
|
+
|
|
616
|
+
1. The committed `doc/openapi.yaml` is ~1 year stale. Either (a) wire
|
|
617
|
+
`OPENAPI=1 bundle exec rspec` into CI so it regenerates on PRs that
|
|
618
|
+
touch `api/v1`, or (b) drop the committed copy and regenerate on
|
|
619
|
+
demand. Until then, jbuilders are the source of truth.
|
|
620
|
+
2. `request_id` echoed in response body and/or `X-Request-ID` header for
|
|
621
|
+
support correlation.
|
|
622
|
+
3. Real location/site filter on `GET /api/v1/events` (when the backend
|
|
623
|
+
gets a structured location concept). Until then the CLI doesn't expose
|
|
624
|
+
a flag.
|
|
625
|
+
4. Subdomain discovery for `wiq auth login`. The PAT settings URL lives at
|
|
626
|
+
`<team-subdomain>.wrestlingiq.com/settings/personal_access_tokens`; if
|
|
627
|
+
the customer doesn't know their subdomain, the CLI can't deep-link
|
|
628
|
+
them. Either accept the host URL interactively (current plan), or
|
|
629
|
+
document a discovery flow on the marketing site.
|