wiq-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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.