wiq-cli 0.4.0 → 0.6.1

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.
@@ -4,6 +4,7 @@ module Wiq
4
4
  module Commands
5
5
  class ProspectFamilies < Base
6
6
  SORT_OPTIONS = %w[newest oldest_followup oldest_contact next_trial].freeze
7
+ ACTIVITY_TYPES = %w[phone_call sms email in_person other].freeze
7
8
 
8
9
  desc "list", "List prospect families (one row per household)"
9
10
  long_desc <<~DESC
@@ -124,6 +125,231 @@ module Wiq
124
125
  ]
125
126
  )
126
127
  end
128
+
129
+ desc "stage_changes FAMILY_ID", "Stage-transition audit log for every prospect in a family"
130
+ long_desc <<~DESC
131
+ Append-only history of every stage move for every prospect (kid)
132
+ in the family, newest first. Each row: prospect_id + child_name,
133
+ from_stage (null on the initial create), to_stage, changed_at,
134
+ changed_via, and changed_by (profile, or null for system moves).
135
+
136
+ changed_via values:
137
+ manual A person moved it (drawer, note shortcut, or a
138
+ PAT write — changed_by names the coach)
139
+ trial_registration Family bought a trial session
140
+ check_in Kid checked in to a trial practice
141
+ trial_expired Trial passes ran out
142
+ paid_registration Registered for a paid session (→ converted)
143
+ subscription Started a recurring membership (→ converted)
144
+ backfill Historical import
145
+ bulk_archive The stale-lead archive task
146
+
147
+ Use this to answer "did this lead actually trial or skip straight
148
+ to converted?" — the prospect row only carries its CURRENT stage —
149
+ and to review what an agent or coach did before attempting another
150
+ `wiq prospects advance` (moves are forward-only via PAT).
151
+ DESC
152
+ method_option :all, type: :boolean, default: false
153
+ def stage_changes(family_id)
154
+ records, total = fetch_index("/api/v1/prospect_families/#{family_id}/stage_changes",
155
+ { "per_page" => 50 },
156
+ key: "stage_changes")
157
+ render_index(
158
+ records, total: total,
159
+ summary: "Listed #{records.size} stage changes for family #{family_id}.",
160
+ breadcrumbs: [
161
+ { "cmd" => "wiq prospect_families show #{family_id}", "description" => "Back to the family" },
162
+ { "cmd" => "wiq prospect_families notes #{family_id}", "description" => "Contact log alongside the stage history" }
163
+ ]
164
+ )
165
+ end
166
+
167
+ desc "linked_answers FAMILY_ID", "Registration answers on the member profiles linked to a family"
168
+ long_desc <<~DESC
169
+ Trial-purchase leads skip the interest form, so their phone number
170
+ and other intake details usually exist only as registration answers
171
+ on the profiles the family is linked to: the guardian (parent or
172
+ coach) and each prospect's wrestler_profile. This returns one entry
173
+ per linked profile (profile_id, profile_type, full_name, relation =
174
+ guardian | wrestler) with its registration_answers, deduped to the
175
+ most recent answer per question and ordered by the question's
176
+ display order.
177
+
178
+ Not paginated. Coach visibility is enforced server-side, so
179
+ admin-only questions are omitted for non-admin tokens. A family
180
+ with no linked profiles returns an empty list.
181
+
182
+ Cheaper first stop: `wiq prospect_families show` already surfaces
183
+ `phone_suggestions` for blank-phone families. Use this when you
184
+ need everything known about the household before a call.
185
+ DESC
186
+ def linked_answers(family_id)
187
+ data = client.get("/api/v1/prospect_families/#{family_id}/linked_answers")
188
+ profiles = Array(data["linked_profiles"])
189
+ answer_count = profiles.sum { |p| Array(p["registration_answers"]).size }
190
+ render_index(
191
+ profiles,
192
+ summary: "#{profiles.size} linked profiles with #{answer_count} visible registration answers for family #{family_id}.",
193
+ breadcrumbs: [
194
+ { "cmd" => "wiq prospect_families show #{family_id}", "description" => "Back to the family" },
195
+ { "cmd" => "wiq prospect_families update #{family_id} --phone <number>",
196
+ "description" => "Copy a found phone onto the family record" }
197
+ ]
198
+ )
199
+ end
200
+
201
+ desc "create", "Create a prospect family (household) [prospects:write]"
202
+ long_desc <<~DESC
203
+ POSTs to /api/v1/prospect_families. Requires a coach PAT minted
204
+ with the prospects:write scope on a team that has enabled it
205
+ (Settings → API Access). Only --first-name is required; --email
206
+ must be a valid address when given.
207
+
208
+ A family is the household record — add the kid(s) afterwards with
209
+ `wiq prospects create <family_id> --first-name ...`. Check for an
210
+ existing household first with `wiq prospect_families list --query
211
+ <name or email or phone>` to avoid duplicates.
212
+
213
+ --source is the machine-readable origin (e.g. web_form, walk_in,
214
+ referral); --hear-about-us is the family's free-text answer to
215
+ "How did you hear about us?". --assigned-coach takes a
216
+ coach_profile id. --guardian-id/--guardian-type link an existing
217
+ ParentProfile or CoachProfile as the guardian.
218
+ DESC
219
+ method_option :first_name, type: :string, required: true, desc: "contact_first_name (required)"
220
+ method_option :last_name, type: :string, desc: "contact_last_name"
221
+ method_option :email, type: :string, desc: "contact_email"
222
+ method_option :phone, type: :string, desc: "contact_phone"
223
+ method_option :hear_about_us, type: :string, desc: "Free-text 'How did you hear about us?'"
224
+ method_option :source, type: :string, desc: "Lead source tag (free text)"
225
+ method_option :assigned_coach, type: :numeric, desc: "assigned_coach_id (coach_profile id)"
226
+ method_option :guardian_id, type: :numeric, desc: "Existing profile id to link as guardian"
227
+ method_option :guardian_type, type: :string, enum: %w[ParentProfile CoachProfile],
228
+ desc: "Profile type for --guardian-id"
229
+ def create
230
+ family = client.post("/api/v1/prospect_families", { "prospect_family" => build_family_attrs })
231
+ render(family,
232
+ summary: "Created prospect family #{family["id"]} — #{family["contact_name"]}.",
233
+ breadcrumbs: [
234
+ { "cmd" => "wiq prospects create #{family["id"]} --first-name <child>",
235
+ "description" => "Add the kid(s) to this household" },
236
+ { "cmd" => "wiq prospect_families note #{family["id"]} --activity-type phone_call --content \"...\"",
237
+ "description" => "Log the first contact" }
238
+ ])
239
+ end
240
+
241
+ desc "update ID", "Edit a prospect family's contact info or assignment [prospects:write]"
242
+ long_desc <<~DESC
243
+ PATCHes /api/v1/prospect_families/:id with only the flags you
244
+ pass. Requires the prospects:write scope (team-enabled + on the
245
+ token). Typical uses: fill in a missing phone (see
246
+ `phone_suggestions` on `wiq prospect_families show`), reassign a
247
+ coach, or correct a misspelled name.
248
+ DESC
249
+ method_option :first_name, type: :string, desc: "contact_first_name"
250
+ method_option :last_name, type: :string, desc: "contact_last_name"
251
+ method_option :email, type: :string, desc: "contact_email"
252
+ method_option :phone, type: :string, desc: "contact_phone"
253
+ method_option :hear_about_us, type: :string, desc: "Free-text 'How did you hear about us?'"
254
+ method_option :source, type: :string, desc: "Lead source tag (free text)"
255
+ method_option :assigned_coach, type: :numeric, desc: "assigned_coach_id (coach_profile id)"
256
+ method_option :guardian_id, type: :numeric, desc: "Existing profile id to link as guardian"
257
+ method_option :guardian_type, type: :string, enum: %w[ParentProfile CoachProfile],
258
+ desc: "Profile type for --guardian-id"
259
+ def update(id)
260
+ attrs = build_family_attrs
261
+ if attrs.empty?
262
+ raise Wiq::Error.new("Nothing to update — pass at least one field flag.",
263
+ code: "no_fields",
264
+ hint: "See `wiq prospect_families update --help` for the editable fields.")
265
+ end
266
+
267
+ family = client.patch("/api/v1/prospect_families/#{id}", { "prospect_family" => attrs })
268
+ render(family,
269
+ summary: "Updated prospect family #{family["id"]} — #{family["contact_name"]}.",
270
+ breadcrumbs: [
271
+ { "cmd" => "wiq prospect_families show #{family["id"]}", "description" => "Refetch the family" }
272
+ ])
273
+ end
274
+
275
+ desc "note FAMILY_ID", "Log a contact / add a note to a prospect family [prospects:write]"
276
+ long_desc <<~DESC
277
+ POSTs to /api/v1/prospect_families/:family_id/notes. Requires the
278
+ prospects:write scope (team-enabled + on the token). The note is
279
+ authored by the coach who minted the token.
280
+
281
+ --activity-type marks the note as a logged contact: the server
282
+ bumps last_contacted_at on every ACTIVE prospect in the family,
283
+ which is what clears "stale contact" follow-up flags. Omit it for
284
+ an internal note that shouldn't count as contact.
285
+
286
+ Side effects in the same call (prospect ids, comma-separated):
287
+ --clear-follow-up 12,34 Clear the needs_follow_up flag
288
+ --add-follow-up 56 Flag for follow-up (reason=manual)
289
+ Ids outside this family are ignored server-side.
290
+
291
+ Content is sent as plain text; @mentions are processed server-side.
292
+ DESC
293
+ method_option :content, type: :string, required: true, desc: "Note body (plain text)"
294
+ method_option :activity_type, type: :string, enum: ACTIVITY_TYPES,
295
+ desc: "phone_call | sms | email | in_person | other (omit for a plain note)"
296
+ method_option :clear_follow_up, type: :string,
297
+ desc: "Comma-separated prospect ids to un-flag for follow-up"
298
+ method_option :add_follow_up, type: :string,
299
+ desc: "Comma-separated prospect ids to flag for follow-up"
300
+ def note(family_id)
301
+ body = {
302
+ "note" => {
303
+ "content" => options[:content],
304
+ "plain_content" => options[:content]
305
+ }
306
+ }
307
+ body["note"]["activity_type"] = options[:activity_type] if options[:activity_type]
308
+ clear_ids = parse_id_list(options[:clear_follow_up])
309
+ add_ids = parse_id_list(options[:add_follow_up])
310
+ body["clear_follow_up_for"] = clear_ids unless clear_ids.empty?
311
+ body["add_follow_up_for"] = add_ids unless add_ids.empty?
312
+
313
+ note = client.post("/api/v1/prospect_families/#{family_id}/notes", body)
314
+ kind = options[:activity_type] ? "Logged #{options[:activity_type]} contact" : "Added note"
315
+ render(note,
316
+ summary: "#{kind} ##{note["id"]} on family #{family_id}.",
317
+ breadcrumbs: [
318
+ { "cmd" => "wiq prospect_families notes #{family_id}", "description" => "Full contact log" },
319
+ { "cmd" => "wiq prospect_families show #{family_id}", "description" => "Back to the family" }
320
+ ])
321
+ end
322
+
323
+ no_commands do
324
+ # Maps CLI flags → the `prospect_family` permit list on
325
+ # Api::V1::ProspectFamiliesController.
326
+ FAMILY_FIELD_MAP = {
327
+ first_name: "contact_first_name",
328
+ last_name: "contact_last_name",
329
+ email: "contact_email",
330
+ phone: "contact_phone",
331
+ hear_about_us: "hear_about_us",
332
+ source: "source",
333
+ assigned_coach: "assigned_coach_id",
334
+ guardian_id: "guardian_id",
335
+ guardian_type: "guardian_type"
336
+ }.freeze
337
+
338
+ def build_family_attrs
339
+ attrs = {}
340
+ FAMILY_FIELD_MAP.each do |flag, param|
341
+ value = options[flag]
342
+ attrs[param] = value unless value.nil?
343
+ end
344
+ attrs
345
+ end
346
+
347
+ def parse_id_list(raw)
348
+ return [] if raw.nil? || raw.to_s.strip.empty?
349
+
350
+ raw.to_s.split(",").map(&:strip).reject(&:empty?).map(&:to_i).reject(&:zero?)
351
+ end
352
+ end
127
353
  end
128
354
  end
129
355
  end
@@ -4,8 +4,14 @@ module Wiq
4
4
  module Commands
5
5
  class Prospects < Base
6
6
  STAGES = %w[inquiry trial_scheduled trialing trial_complete converted didnt_join archived].freeze
7
+ TERMINAL_STAGES = %w[converted didnt_join archived].freeze
7
8
  ATTENTION_MODES = %w[needs_attention handled].freeze
8
9
 
10
+ # Write capability every create/update/advance below needs. The server
11
+ # checks it per request: the team must have it enabled AND the token
12
+ # must have been minted with it. See `wiq auth status` for scopes.
13
+ WRITE_CAPABILITY = "prospects:write"
14
+
9
15
  desc "list", "List individual prospects (one row per kid)"
10
16
  long_desc <<~DESC
11
17
  Returns one row per prospect (kid), sorted newest-first.
@@ -21,15 +27,6 @@ module Wiq
21
27
  scope which unions both via a subquery.
22
28
  When set, ALL other filters are bypassed
23
29
  server-side.
24
-
25
- KNOWN BUG: `wiq prospects list --query …`
26
- currently returns HTTP 500 due to an
27
- ambiguous-column ORDER BY on the
28
- prospect↔prospect_family join. Workaround:
29
- use `wiq prospect_families list --query …`
30
- instead (same search scope, same matches,
31
- returns the family with its prospects
32
- nested inline).
33
30
  --attention needs_attention | handled
34
31
  --stage One funnel stage
35
32
  --assigned-to-me Only families assigned to the calling coach
@@ -92,6 +89,138 @@ module Wiq
92
89
  ])
93
90
  end
94
91
 
92
+ desc "create FAMILY_ID", "Add a prospect (kid) to an existing prospect family [prospects:write]"
93
+ long_desc <<~DESC
94
+ POSTs to /api/v1/prospect_families/:family_id/prospects. Requires a
95
+ coach PAT minted with the prospects:write scope on a team that has
96
+ enabled it (Settings → API Access). Only --first-name is required.
97
+
98
+ New prospects start at stage `inquiry` unless --stage is given.
99
+ The stage audit row is attributed to the coach who minted the token.
100
+
101
+ Create the household first with `wiq prospect_families create` if
102
+ the family doesn't exist yet; use `wiq prospect_families list
103
+ --query <name>` to check.
104
+
105
+ Errors you may see:
106
+ capability_disabled_for_team Team hasn't enabled prospects:write
107
+ token_missing_scope Token wasn't minted with it — mint a new one
108
+ validation_failed Server rejected a field (see details)
109
+ DESC
110
+ method_option :first_name, type: :string, required: true, desc: "child_first_name (required)"
111
+ method_option :last_name, type: :string, desc: "child_last_name"
112
+ method_option :dob, type: :string, desc: "child_date_of_birth (YYYY-MM-DD)"
113
+ method_option :academic_class, type: :string, desc: "child_academic_class (free text, e.g. 5th)"
114
+ method_option :experience_level, type: :string, desc: "experience_level (free text, e.g. none / 1 season)"
115
+ method_option :stage, type: :string, enum: STAGES, desc: "Initial stage (default: inquiry)"
116
+ method_option :paid_session, type: :numeric, desc: "paid_session_id of the trial session"
117
+ method_option :trial_event, type: :numeric, desc: "trial_event_id — the practice/event they'll try"
118
+ method_option :trial_scheduled_at, type: :string, desc: "ISO-8601 timestamp"
119
+ method_option :needs_follow_up, type: :boolean, desc: "Flag (or --no-needs-follow-up to clear) for follow-up"
120
+ def create(family_id)
121
+ body = { "prospect" => build_prospect_attrs }
122
+ prospect = client.post("/api/v1/prospect_families/#{family_id}/prospects", body)
123
+ render(prospect,
124
+ summary: "Created prospect #{prospect["id"]} — #{prospect["child_first_name"]} " \
125
+ "#{prospect["child_last_name"]} (stage=#{prospect["stage"]}) on family #{family_id}.",
126
+ breadcrumbs: [
127
+ { "cmd" => "wiq prospects show #{prospect["id"]}", "description" => "Refetch the prospect" },
128
+ { "cmd" => "wiq prospect_families note #{family_id} --activity-type phone_call --content \"...\"",
129
+ "description" => "Log the first contact" }
130
+ ])
131
+ end
132
+
133
+ desc "update ID", "Edit a prospect's details and/or move its stage [prospects:write]"
134
+ long_desc <<~DESC
135
+ PATCHes /api/v1/prospects/:id with only the flags you pass. Requires
136
+ the prospects:write scope (team-enabled + on the token).
137
+
138
+ Stage changes via a PAT are FORWARD-ONLY and cannot leave a terminal
139
+ stage (converted, didnt_join, archived). The server answers 422
140
+ (`stage_transition_refused`) rather than silently no-op'ing; a coach
141
+ can force the move in the web app. `wiq prospects advance` is the
142
+ same call with a clearer signature for stage-only moves.
143
+
144
+ --needs-follow-up / --no-needs-follow-up also syncs follow_up_set_at
145
+ and follow_up_reason (=manual) server-side so the flag stays
146
+ internally consistent.
147
+
148
+ Funnel timestamps (--trial-scheduled-at, --trial-completed-at,
149
+ --converted-at, --archived-at, --last-contacted-at) accept ISO-8601
150
+ and are normally stamped by the stage change itself — pass them
151
+ only to backfill history.
152
+ DESC
153
+ method_option :first_name, type: :string, desc: "child_first_name"
154
+ method_option :last_name, type: :string, desc: "child_last_name"
155
+ method_option :dob, type: :string, desc: "child_date_of_birth (YYYY-MM-DD)"
156
+ method_option :academic_class, type: :string, desc: "child_academic_class"
157
+ method_option :experience_level, type: :string, desc: "experience_level"
158
+ method_option :stage, type: :string, enum: STAGES, desc: "Move to this stage (forward-only via PAT)"
159
+ method_option :lost_reason, type: :string, desc: "Why they didn't join (pairs with --stage didnt_join)"
160
+ method_option :paid_session, type: :numeric, desc: "paid_session_id of the trial session"
161
+ method_option :trial_event, type: :numeric, desc: "trial_event_id"
162
+ method_option :wrestler_profile, type: :numeric, desc: "wrestler_profile_id to link (on conversion)"
163
+ method_option :needs_follow_up, type: :boolean, desc: "Flag (or --no-needs-follow-up to clear) for follow-up"
164
+ method_option :trial_scheduled_at, type: :string, desc: "ISO-8601 timestamp"
165
+ method_option :trial_completed_at, type: :string, desc: "ISO-8601 timestamp"
166
+ method_option :converted_at, type: :string, desc: "ISO-8601 timestamp"
167
+ method_option :archived_at, type: :string, desc: "ISO-8601 timestamp"
168
+ method_option :last_contacted_at, type: :string, desc: "ISO-8601 timestamp"
169
+ def update(id)
170
+ attrs = build_prospect_attrs
171
+ if attrs.empty?
172
+ raise Wiq::Error.new("Nothing to update — pass at least one field flag.",
173
+ code: "no_fields",
174
+ hint: "See `wiq prospects update --help` for the editable fields.")
175
+ end
176
+
177
+ prospect = client.patch("/api/v1/prospects/#{id}", { "prospect" => attrs })
178
+ render(prospect,
179
+ summary: "Updated prospect #{prospect["id"]} — #{prospect["child_first_name"]} " \
180
+ "#{prospect["child_last_name"]} (stage=#{prospect["stage"]}).",
181
+ breadcrumbs: [
182
+ { "cmd" => "wiq prospects show #{prospect["id"]}", "description" => "Refetch the prospect" },
183
+ { "cmd" => "wiq prospect_families show #{prospect["prospect_family_id"]}",
184
+ "description" => "Family this prospect belongs to" }
185
+ ])
186
+ end
187
+
188
+ desc "advance ID STAGE", "Move a prospect forward to STAGE [prospects:write]"
189
+ long_desc <<~DESC
190
+ Shorthand for `wiq prospects update ID --stage STAGE`. Stage order:
191
+
192
+ inquiry → trial_scheduled → trialing → trial_complete
193
+ → converted | didnt_join | archived (terminal)
194
+
195
+ Via a personal access token the move must go FORWARD in that order
196
+ and cannot start from a terminal stage. Skipping ahead is fine
197
+ (inquiry → converted). Anything else returns 422 with code
198
+ `stage_transition_refused`; a coach can override in the web app.
199
+
200
+ --lost-reason is stored alongside a move to didnt_join.
201
+ DESC
202
+ method_option :lost_reason, type: :string, desc: "Why they didn't join (with didnt_join)"
203
+ def advance(id, stage)
204
+ unless STAGES.include?(stage)
205
+ raise Wiq::Error.new("Unknown stage #{stage.inspect}.",
206
+ code: "invalid_stage",
207
+ hint: "Valid stages: #{STAGES.join(", ")}")
208
+ end
209
+
210
+ attrs = { "stage" => stage }
211
+ attrs["lost_reason"] = options[:lost_reason] if options[:lost_reason]
212
+ prospect = client.patch("/api/v1/prospects/#{id}", { "prospect" => attrs })
213
+ render(prospect,
214
+ summary: "Prospect #{prospect["id"]} — #{prospect["child_first_name"]} " \
215
+ "#{prospect["child_last_name"]} is now #{prospect["stage"]}.",
216
+ breadcrumbs: [
217
+ { "cmd" => "wiq prospect_families note #{prospect["prospect_family_id"]} " \
218
+ "--activity-type other --content \"...\"",
219
+ "description" => "Log why, so the contact history matches the stage" },
220
+ { "cmd" => "wiq prospects show #{prospect["id"]}", "description" => "Refetch the prospect" }
221
+ ])
222
+ end
223
+
95
224
  desc "summary", "Pipeline dashboard: counts per stage + conversion rate"
96
225
  long_desc <<~DESC
97
226
  Single-call dashboard. Returns an unwrapped object (not paginated)
@@ -147,6 +276,40 @@ module Wiq
147
276
  { "cmd" => "wiq prospect_families list", "description" => "List by family instead of by kid" }
148
277
  ]
149
278
  end
279
+
280
+ # Maps CLI flags → the `prospect` param permit list on
281
+ # Api::V1::ProspectsController. Only flags actually passed land in
282
+ # the body so PATCH stays a partial update.
283
+ FIELD_MAP = {
284
+ first_name: "child_first_name",
285
+ last_name: "child_last_name",
286
+ dob: "child_date_of_birth",
287
+ academic_class: "child_academic_class",
288
+ experience_level: "experience_level",
289
+ stage: "stage",
290
+ lost_reason: "lost_reason",
291
+ paid_session: "paid_session_id",
292
+ trial_event: "trial_event_id",
293
+ wrestler_profile: "wrestler_profile_id",
294
+ trial_scheduled_at: "trial_scheduled_at",
295
+ trial_completed_at: "trial_completed_at",
296
+ converted_at: "converted_at",
297
+ archived_at: "archived_at",
298
+ last_contacted_at: "last_contacted_at"
299
+ }.freeze
300
+
301
+ def build_prospect_attrs
302
+ attrs = {}
303
+ FIELD_MAP.each do |flag, param|
304
+ value = options[flag]
305
+ attrs[param] = value unless value.nil?
306
+ end
307
+ # Boolean: Thor sets nil when the flag isn't passed, true/false otherwise.
308
+ unless options[:needs_follow_up].nil?
309
+ attrs["needs_follow_up"] = options[:needs_follow_up]
310
+ end
311
+ attrs
312
+ end
150
313
  end
151
314
  end
152
315
  end
@@ -234,9 +234,29 @@ module Wiq
234
234
  "SessionRegistrationAnswerReport" => {
235
235
  args: %w[paid_session_id],
236
236
  dates: :optional,
237
- desc: "Full Q&A export of info submitted by parents at signup",
237
+ desc: "Full Q&A export of info submitted by parents at signup, one row per wrestler " \
238
+ "with stable ids + registration status",
238
239
  recommended: true,
239
- notes: "Pass --paid-session <id> — required."
240
+ notes: "Pass --paid-session <id> — required. The default (vrow) row shape " \
241
+ "leads with seven registration columns: \"WIQ ID #\" (wrestler_profile " \
242
+ "id — same header/value as RosterReport column A, so the two reports " \
243
+ "join on it), \"Registration ID\" (the stable key for external syncs), " \
244
+ "\"Registration status\" (paid | partially_paid | pending | overdue | " \
245
+ "canceled | awaiting_approval), \"Good standing\" (true/false: status in " \
246
+ "paid/partially_paid/pending), \"Registered at\", \"Registration updated " \
247
+ "at\", \"Registration canceled at\" (timestamps in the team's zone; blank " \
248
+ "unless canceled). Then Last/First name, Email, Account type, Academic " \
249
+ "class, Weight class, DOB, Age, created-at, USAW/AAU membership columns " \
250
+ "(unless disabled for the team), each wrestler registration question, " \
251
+ "and per-guardian name/email/type + guardian questions. One row per " \
252
+ "wrestler: when a wrestler has several registrations for the session " \
253
+ "(canceled then re-registered) the latest non-canceled one is " \
254
+ "reported, else the latest canceled one; blank registration columns " \
255
+ "mean no registration row exists. Anonymous sessions emit only " \
256
+ "Last name / First name / Email. --v1 returns structured JSON " \
257
+ "(wrestler_profiles + questions + answers) without the " \
258
+ "registration columns.",
259
+ example: "wiq reports run SessionRegistrationAnswerReport --paid-session 42"
240
260
  },
241
261
  "PaidSessionAccountingReport" => {
242
262
  args: %w[paid_session_id],
@@ -5,7 +5,9 @@ module Wiq
5
5
  class Rosters < Base
6
6
  desc "list", "List rosters"
7
7
  long_desc <<~DESC
8
- Returns every roster on the calling profile's team, paginated.
8
+ Returns the team's active rosters by default, paginated. Archived
9
+ rosters are hidden unless you pass --include-archived (returns both)
10
+ or --archived true (returns only archived).
9
11
 
10
12
  Season filtering is a CLI-side projection (WIQ has no first-class
11
13
  Season entity):
@@ -31,9 +33,12 @@ module Wiq
31
33
  method_option :season_tag, type: :string, desc: "Filter to rosters carrying this tag"
32
34
  method_option :location, type: :numeric, desc: "Filter to rosters at one location id"
33
35
  method_option :archived, type: :boolean, desc: "Show only archived (true) or active (false)"
36
+ method_option :include_archived, type: :boolean, default: false,
37
+ desc: "Include archived rosters alongside active ones"
34
38
  method_option :all, type: :boolean, default: false
35
39
  def list
36
40
  params = { "per_page" => 100 }
41
+ params["include_archived"] = true if options[:include_archived]
37
42
  unless options[:archived].nil?
38
43
  params["q[archived_eq]"] = options[:archived]
39
44
  end
@@ -23,8 +23,9 @@ module Wiq
23
23
  restart needed. Re-run with --force to overwrite an existing
24
24
  install (useful when upgrading the gem).
25
25
 
26
- The skill is read-only on the WIQ side: it teaches Claude how to
27
- drive `wiq`, but doesn't touch any WIQ data.
26
+ Installing the skill doesn't touch any WIQ data: it only teaches
27
+ Claude how to drive `wiq`. What Claude can then change is bounded
28
+ by the token's write scopes (see `wiq auth status`).
28
29
  DESC
29
30
  method_option :project, type: :boolean, default: false,
30
31
  desc: "Install per-project (./.claude/skills/) instead of user-global"
@@ -10,7 +10,8 @@ module Wiq
10
10
  # {
11
11
  # "<host>": {
12
12
  # "<alias>": { "token": "...", "token_prefix": "...", "name": "...",
13
- # "profile": {...}, "stored_at": "..." },
13
+ # "profile": {...}, "scopes": ["prospects:write"],
14
+ # "stored_at": "..." },
14
15
  # "<alias>": { ... }
15
16
  # }
16
17
  # }
@@ -55,13 +56,14 @@ module Wiq
55
56
  "token_prefix" => entry["token_prefix"],
56
57
  "name" => entry["name"],
57
58
  "profile" => entry["profile"],
59
+ "scopes" => entry["scopes"],
58
60
  "stored_at" => entry["stored_at"]
59
61
  }
60
62
  end
61
63
  end
62
64
  end
63
65
 
64
- def store(host:, alias_name:, token:, token_prefix: nil, name: nil, profile: nil)
66
+ def store(host:, alias_name:, token:, token_prefix: nil, name: nil, profile: nil, scopes: nil)
65
67
  data = load_all
66
68
  data[host] ||= {}
67
69
  data[host][alias_name] = {
@@ -69,6 +71,7 @@ module Wiq
69
71
  "token_prefix" => token_prefix,
70
72
  "name" => name,
71
73
  "profile" => profile,
74
+ "scopes" => scopes,
72
75
  "stored_at" => Time.now.utc.iso8601
73
76
  }.compact
74
77
  write(data)
data/lib/wiq/errors.rb CHANGED
@@ -76,13 +76,28 @@ module Wiq
76
76
  end
77
77
 
78
78
  # Mirror of the HTTP error envelope from /api/v1.
79
+ #
80
+ # PAT write policy (Api::V1::BaseController#enforce_pat_restrictions):
81
+ # a write goes through only when it maps to a capability in the server's
82
+ # ApiCapability registry that the team has enabled AND the token carries.
83
+ # The server emits three distinct 403 bodies for the three failure modes,
84
+ # naming the capability verbatim; we key off that wording to give each
85
+ # its own `code` + fix-it hint. Keep the regexes in sync with
86
+ # `pat_denial_message` in the Rails app.
79
87
  class APIError < Error
80
- attr_reader :status, :response_body, :request_id
88
+ attr_reader :status, :response_body, :request_id, :capability
89
+
90
+ PAT_NOT_WRITABLE = /not writable with a personal access token/i
91
+ PAT_TEAM_DISABLED = /team has not enabled (\S+) for API access/i
92
+ PAT_TOKEN_MISSING_SCOPE = /token lacks the (\S+) scope/i
93
+ PAT_LEGACY_READ_ONLY = /personal access tokens are read-only/i
94
+ PAT_STAGE_REFUSED = /stage cannot move from (\S+) to (\S+) with a personal access token/i
81
95
 
82
96
  def initialize(status:, body:, request_id: nil)
83
97
  @status = status
84
98
  @response_body = body
85
99
  @request_id = request_id
100
+ @capability = nil
86
101
 
87
102
  code, message, hint = derive(status, body)
88
103
  super(message, code: code, hint: hint, exit_code: 1, details: body)
@@ -97,12 +112,11 @@ module Wiq
97
112
  ["unauthorized", "Token rejected by server (401).",
98
113
  "Run `wiq auth status` to inspect the active token; `wiq auth login` to replace it."]
99
114
  when 403
100
- ["forbidden", "Server denied access (403): #{msgs}",
101
- "PATs inherit the user's permissions. Confirm the minting user can see this resource in the web app."]
115
+ derive_forbidden(msgs)
102
116
  when 404
103
117
  ["not_found", "Resource not found (404).", nil]
104
118
  when 422
105
- ["validation_failed", "Validation error (422): #{msgs}", nil]
119
+ derive_unprocessable(msgs)
106
120
  when 429
107
121
  ["rate_limited", "Rate limited (429): #{msgs}",
108
122
  "WIQ enforces 100 req/3s per IP. Back off and retry."]
@@ -111,6 +125,38 @@ module Wiq
111
125
  end
112
126
  end
113
127
 
128
+ def derive_forbidden(msgs)
129
+ if msgs =~ PAT_NOT_WRITABLE || msgs =~ PAT_LEGACY_READ_ONLY
130
+ ["pat_write_unsupported", "Server denied access (403): #{msgs}",
131
+ "This endpoint has no API write capability, so no personal access token can call it. " \
132
+ "Make the change in the WIQ web app."]
133
+ elsif (m = msgs.match(PAT_TEAM_DISABLED))
134
+ @capability = m[1]
135
+ ["capability_disabled_for_team", "Server denied access (403): #{msgs}",
136
+ "A team admin must enable #{@capability} under Settings → API Access " \
137
+ "(<host>/settings/team/api_access). Existing tokens minted with that scope start working immediately."]
138
+ elsif (m = msgs.match(PAT_TOKEN_MISSING_SCOPE))
139
+ @capability = m[1]
140
+ ["token_missing_scope", "Server denied access (403): #{msgs}",
141
+ "Token scopes are immutable. Mint a new token that includes #{@capability} at " \
142
+ "<host>/settings/personal_access_tokens, then `wiq auth login --force` to replace the stored one. " \
143
+ "Run `wiq auth status` to see the scopes on the current token."]
144
+ else
145
+ ["forbidden", "Server denied access (403): #{msgs}",
146
+ "PATs inherit the user's permissions. Confirm the minting user can see this resource in the web app."]
147
+ end
148
+ end
149
+
150
+ def derive_unprocessable(msgs)
151
+ if msgs =~ PAT_STAGE_REFUSED
152
+ ["stage_transition_refused", "Validation error (422): #{msgs}",
153
+ "Stage changes via a personal access token are forward-only and cannot leave a terminal stage " \
154
+ "(converted, didnt_join, archived). A coach can force the move in the WIQ web app."]
155
+ else
156
+ ["validation_failed", "Validation error (422): #{msgs}", nil]
157
+ end
158
+ end
159
+
114
160
  # /api/v1 always returns { "errors": <array|hash> }. Flatten to a human string.
115
161
  def extract_messages(body)
116
162
  return body.to_s unless body.is_a?(Hash)
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.4.0"
4
+ VERSION = "0.6.1"
5
5
  end
data/lib/wiq.rb CHANGED
@@ -29,4 +29,5 @@ require "wiq/commands/wrestlers"
29
29
  require "wiq/commands/parents"
30
30
  require "wiq/commands/charges"
31
31
  require "wiq/commands/billing_profiles"
32
+ require "wiq/commands/payouts"
32
33
  require "wiq/cli"