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.
- checksums.yaml +4 -4
- data/README.md +16 -5
- data/docs/deferred.md +12 -21
- data/docs/wiq_api_notes.md +41 -5
- data/lib/wiq/cli.rb +5 -2
- data/lib/wiq/client.rb +6 -1
- data/lib/wiq/commands/auth.rb +35 -11
- data/lib/wiq/commands/doctor.rb +9 -0
- data/lib/wiq/commands/payouts.rb +124 -0
- data/lib/wiq/commands/prospect_families.rb +226 -0
- data/lib/wiq/commands/prospects.rb +172 -9
- data/lib/wiq/commands/reports.rb +22 -2
- data/lib/wiq/commands/rosters.rb +6 -1
- data/lib/wiq/commands/setup.rb +3 -2
- data/lib/wiq/credentials.rb +5 -2
- data/lib/wiq/errors.rb +50 -4
- data/lib/wiq/version.rb +1 -1
- data/lib/wiq.rb +1 -0
- data/share/skills/wiq/SKILL.md +104 -17
- metadata +4 -6
|
@@ -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
|
data/lib/wiq/commands/reports.rb
CHANGED
|
@@ -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],
|
data/lib/wiq/commands/rosters.rb
CHANGED
|
@@ -5,7 +5,9 @@ module Wiq
|
|
|
5
5
|
class Rosters < Base
|
|
6
6
|
desc "list", "List rosters"
|
|
7
7
|
long_desc <<~DESC
|
|
8
|
-
Returns
|
|
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
|
data/lib/wiq/commands/setup.rb
CHANGED
|
@@ -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
|
-
|
|
27
|
-
drive `wiq
|
|
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"
|
data/lib/wiq/credentials.rb
CHANGED
|
@@ -10,7 +10,8 @@ module Wiq
|
|
|
10
10
|
# {
|
|
11
11
|
# "<host>": {
|
|
12
12
|
# "<alias>": { "token": "...", "token_prefix": "...", "name": "...",
|
|
13
|
-
# "profile": {...}, "
|
|
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
|
-
|
|
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
|
-
|
|
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