recess-cli 2.0.0 → 2.2.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.
- package/README.md +13 -2
- package/dist/api.js +80 -0
- package/dist/cli.js +259 -663
- package/dist/command-schema.js +5 -0
- package/dist/commands/applications.js +336 -0
- package/dist/commands/onboarding.js +797 -0
- package/dist/commands/school.js +465 -0
- package/dist/commands/shared.js +100 -0
- package/dist/help.js +415 -0
- package/package.json +3 -3
- package/skill/recess-cli/SKILL.md +2 -0
- package/skill/recess-cli/agents/version.json +2 -2
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { unwrap } from "../api.js";
|
|
5
|
+
import { flagNumber, flagString, hasFlag } from "../args.js";
|
|
6
|
+
import { CliError } from "../errors.js";
|
|
7
|
+
import { assertChoice, flagBooleanValue, flagIdList, flagInteger, flagIsoInstant, positional, readJsonFile, } from "./shared.js";
|
|
8
|
+
const ONBOARDING_STAGES = [
|
|
9
|
+
"LEGACY",
|
|
10
|
+
"PROVISIONED",
|
|
11
|
+
"PARENT_CONFIRMED",
|
|
12
|
+
"CLEARED_FOR_COHORT",
|
|
13
|
+
"COMPLETE",
|
|
14
|
+
];
|
|
15
|
+
const ONBOARDING_STAGE_SIDE_EFFECTS = {
|
|
16
|
+
LEGACY: "the pre-onboarding baseline",
|
|
17
|
+
PROVISIONED: "kid accounts provisioned, awaiting parent confirmation",
|
|
18
|
+
PARENT_CONFIRMED: "parent has confirmed setup",
|
|
19
|
+
CLEARED_FOR_COHORT: "unlocks cohort registration for school families",
|
|
20
|
+
COMPLETE: "onboarding finished",
|
|
21
|
+
};
|
|
22
|
+
const ONBOARDING_ACCOUNT_STATES = [
|
|
23
|
+
"ACTIVE",
|
|
24
|
+
"PENDING_PAYMENT",
|
|
25
|
+
"PAUSED",
|
|
26
|
+
"BOOTED",
|
|
27
|
+
];
|
|
28
|
+
const ONBOARDING_CONDITIONS = [
|
|
29
|
+
"app_downloaded",
|
|
30
|
+
"tutor_met",
|
|
31
|
+
"goals_loaded",
|
|
32
|
+
"ma_diagnostic",
|
|
33
|
+
];
|
|
34
|
+
const ONBOARDING_COMMS_STATUSES = ["RESERVED", "SENT", "FAILED"];
|
|
35
|
+
const LIFECYCLE_PROMPT_KINDS = [
|
|
36
|
+
"TWO_WEEK_REVIEW",
|
|
37
|
+
"TWO_WEEK_CHECK_IN",
|
|
38
|
+
"TWO_MONTH_TESTIMONIAL",
|
|
39
|
+
];
|
|
40
|
+
// device-pairing.ts PAIRING_TTL_MS — the code dies 5 minutes after it is
|
|
41
|
+
// minted, so the confirm step is what starts the clock, not the preview.
|
|
42
|
+
const PAIRING_CODE_TTL_MINUTES = 5;
|
|
43
|
+
function personName(firstName, lastName, fallback) {
|
|
44
|
+
return [firstName, lastName].filter(Boolean).join(" ") || fallback;
|
|
45
|
+
}
|
|
46
|
+
function summaryPreview(text, max = 600) {
|
|
47
|
+
if (!text)
|
|
48
|
+
return null;
|
|
49
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
50
|
+
}
|
|
51
|
+
export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
52
|
+
const noun = parsed.positionals[0] ?? "";
|
|
53
|
+
const verb = parsed.positionals[1] ?? "";
|
|
54
|
+
if (noun === "onboarding") {
|
|
55
|
+
if (verb === "status") {
|
|
56
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
57
|
+
return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
|
|
58
|
+
params: { path: { familyId } },
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
if (verb === "queue") {
|
|
62
|
+
const partnerSlug = flagString(parsed, "school");
|
|
63
|
+
return unwrap(await api.client.GET("/admin/onboarding/queue", {
|
|
64
|
+
params: { query: partnerSlug ? { partnerSlug } : {} },
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
if (verb === "kids") {
|
|
68
|
+
const timePeriodDays = flagNumber(parsed, "time-period-days");
|
|
69
|
+
const cohortId = flagString(parsed, "cohort");
|
|
70
|
+
const limit = flagNumber(parsed, "limit");
|
|
71
|
+
const stageFilterRaw = flagString(parsed, "stage-filter");
|
|
72
|
+
const stageFilter = stageFilterRaw
|
|
73
|
+
? assertChoice(stageFilterRaw, ["all", "scheduled", "oriented", "course", "converted", "lost"], "--stage-filter")
|
|
74
|
+
: undefined;
|
|
75
|
+
return unwrap(await api.client.GET("/admin/onboarding/kids/", {
|
|
76
|
+
params: {
|
|
77
|
+
query: {
|
|
78
|
+
...(timePeriodDays !== undefined ? { timePeriodDays } : {}),
|
|
79
|
+
...(cohortId ? { cohortId } : {}),
|
|
80
|
+
...(limit !== undefined ? { limit } : {}),
|
|
81
|
+
...(stageFilter ? { stageFilter } : {}),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
if (verb === "timeline") {
|
|
87
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
88
|
+
return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/timeline", {
|
|
89
|
+
params: { path: { familyId } },
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
if (verb === "readiness") {
|
|
93
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
94
|
+
return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/readiness", { params: { path: { familyId } } }));
|
|
95
|
+
}
|
|
96
|
+
if (verb === "active-tutors") {
|
|
97
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
98
|
+
return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/active-tutors", { params: { path: { familyId } } }));
|
|
99
|
+
}
|
|
100
|
+
if (verb === "meetings") {
|
|
101
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
102
|
+
return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/meetings", {
|
|
103
|
+
params: { path: { familyId } },
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
if (verb === "cohort-options") {
|
|
107
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
108
|
+
const kidUserId = flagString(parsed, "kid");
|
|
109
|
+
return unwrap(await api.client.GET("/admin/onboarding/cohort-options", {
|
|
110
|
+
params: {
|
|
111
|
+
query: { familyId, ...(kidUserId ? { kidUserId } : {}) },
|
|
112
|
+
},
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
if (verb === "comms") {
|
|
116
|
+
const partnerSlug = flagString(parsed, "school");
|
|
117
|
+
const statusRaw = flagString(parsed, "status");
|
|
118
|
+
const status = statusRaw
|
|
119
|
+
? assertChoice(statusRaw, ONBOARDING_COMMS_STATUSES, "--status")
|
|
120
|
+
: undefined;
|
|
121
|
+
const kind = flagString(parsed, "kind");
|
|
122
|
+
const limit = flagInteger(parsed, "limit", { min: 1, max: 200 });
|
|
123
|
+
const cursor = flagString(parsed, "cursor");
|
|
124
|
+
return unwrap(await api.client.GET("/admin/onboarding/comms", {
|
|
125
|
+
params: {
|
|
126
|
+
query: {
|
|
127
|
+
...(partnerSlug ? { partnerSlug } : {}),
|
|
128
|
+
...(status ? { status } : {}),
|
|
129
|
+
...(kind ? { kind } : {}),
|
|
130
|
+
...(limit !== undefined ? { limit } : {}),
|
|
131
|
+
...(cursor ? { cursor } : {}),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
if (verb === "lifecycle-prompts") {
|
|
137
|
+
return unwrap(await api.client.GET("/admin/onboarding/lifecycle-prompts"));
|
|
138
|
+
}
|
|
139
|
+
if (verb === "orientation-sessions") {
|
|
140
|
+
return unwrap(await api.client.GET("/admin/onboarding/orientation-sessions/"));
|
|
141
|
+
}
|
|
142
|
+
if (verb === "ixl-preview") {
|
|
143
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
144
|
+
const kidUserId = flagString(parsed, "kid", { required: true });
|
|
145
|
+
return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/kids/{kidUserId}/provision/ixl/preview", { params: { path: { familyId, kidUserId } } }));
|
|
146
|
+
}
|
|
147
|
+
if (verb === "intake-session") {
|
|
148
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
149
|
+
// A true read: look up the family's current IN_PROGRESS intake session
|
|
150
|
+
// WITHOUT minting one (get.family-intake-session.ts). Merely viewing a
|
|
151
|
+
// family must not create a blank session — use intake-session-create to
|
|
152
|
+
// mint one. A 404 means "none yet" and is a clean result, not an error.
|
|
153
|
+
try {
|
|
154
|
+
return await api.rawGet(`/admin/onboarding/families/${encodeURIComponent(familyId)}/intake-session`);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (error instanceof CliError &&
|
|
158
|
+
typeof error.details === "object" &&
|
|
159
|
+
error.details !== null &&
|
|
160
|
+
error.details.status === 404) {
|
|
161
|
+
return {
|
|
162
|
+
intakeSession: null,
|
|
163
|
+
familyId,
|
|
164
|
+
message: "No intake session for this family yet.",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (verb === "reviews") {
|
|
171
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
172
|
+
// The family workspace's review card: the handoff session(s) for this
|
|
173
|
+
// family. get.reviews.ts binds the family scope to the canonical (oldest)
|
|
174
|
+
// guardian — the same one the intake commands operate on — so a
|
|
175
|
+
// two-guardian family never surfaces the other guardian's session.
|
|
176
|
+
// `guardianMissing: true` with an empty list means the family has no
|
|
177
|
+
// guardian at all, not that no session was ever started.
|
|
178
|
+
return unwrap(await api.client.GET("/ai/onboarding/reviews", {
|
|
179
|
+
params: { query: { familyId } },
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
if (verb === "review") {
|
|
183
|
+
const sessionId = positional(parsed, 2, "onboarding session ID");
|
|
184
|
+
// The full handoff record behind the workspace's handoff page: summary,
|
|
185
|
+
// collected data, artifacts, progress. Read this before mark-reviewed.
|
|
186
|
+
return unwrap(await api.client.GET("/ai/onboarding/reviews/{id}", {
|
|
187
|
+
params: { path: { id: sessionId } },
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
if (verb === "intake-session-create") {
|
|
191
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
192
|
+
// The explicit create: get-or-create the intake session. Mints a blank
|
|
193
|
+
// IN_PROGRESS session when none exists (post.family-intake-session.ts,
|
|
194
|
+
// freshIfFinished), so it is a write and goes through the confirmation
|
|
195
|
+
// gate like every other mutating command.
|
|
196
|
+
return writeCommand(parsed, {
|
|
197
|
+
action: "create the family's parent intake session (mints a blank IN_PROGRESS session if none exists)",
|
|
198
|
+
target: { familyId },
|
|
199
|
+
request: {},
|
|
200
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } })));
|
|
201
|
+
}
|
|
202
|
+
if (verb === "set-stage") {
|
|
203
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
204
|
+
const stage = assertChoice(flagString(parsed, "stage", { required: true }), ONBOARDING_STAGES, "--stage");
|
|
205
|
+
// Read the current stage first so the preview can name the transition and
|
|
206
|
+
// warn when it moves progress backward (re-locks capabilities).
|
|
207
|
+
const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
|
|
208
|
+
params: { path: { familyId } },
|
|
209
|
+
}));
|
|
210
|
+
const from = current.onboardingStage;
|
|
211
|
+
const backward = ONBOARDING_STAGES.indexOf(stage) < ONBOARDING_STAGES.indexOf(from);
|
|
212
|
+
const body = { stage };
|
|
213
|
+
return writeCommand(parsed, {
|
|
214
|
+
action: `${backward ? "MOVE BACKWARD — re-locks progress: " : ""}set onboarding stage to ${stage} — ${ONBOARDING_STAGE_SIDE_EFFECTS[stage]}`,
|
|
215
|
+
target: { familyId, from },
|
|
216
|
+
request: body,
|
|
217
|
+
}, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/stage", { params: { path: { familyId } }, body })));
|
|
218
|
+
}
|
|
219
|
+
if (verb === "set-account-state") {
|
|
220
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
221
|
+
const state = assertChoice(flagString(parsed, "state", { required: true }), ONBOARDING_ACCOUNT_STATES, "--state");
|
|
222
|
+
const note = flagString(parsed, "note");
|
|
223
|
+
const lockout = state === "PAUSED" || state === "BOOTED"
|
|
224
|
+
? " — locks the family out of paid capabilities"
|
|
225
|
+
: "";
|
|
226
|
+
const body = { state, ...(note !== undefined ? { note } : {}) };
|
|
227
|
+
return writeCommand(parsed, {
|
|
228
|
+
action: `set account state to ${state}${lockout}`,
|
|
229
|
+
target: { familyId },
|
|
230
|
+
request: body,
|
|
231
|
+
}, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/account-state", { params: { path: { familyId } }, body })));
|
|
232
|
+
}
|
|
233
|
+
if (verb === "set-primary-tutor") {
|
|
234
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
235
|
+
const tutorUserId = flagString(parsed, "tutor");
|
|
236
|
+
const clear = hasFlag(parsed, "clear");
|
|
237
|
+
if ((tutorUserId && clear) || (!tutorUserId && !clear)) {
|
|
238
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --tutor <user-id> or --clear.");
|
|
239
|
+
}
|
|
240
|
+
const body = { tutorUserId: clear ? null : tutorUserId };
|
|
241
|
+
return writeCommand(parsed, {
|
|
242
|
+
action: clear
|
|
243
|
+
? "clear the family's primary tutor"
|
|
244
|
+
: "set the family's primary tutor (requires an active assignment in this family)",
|
|
245
|
+
target: { familyId },
|
|
246
|
+
request: body,
|
|
247
|
+
}, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/primary-tutor", { params: { path: { familyId } }, body })));
|
|
248
|
+
}
|
|
249
|
+
if (verb === "attest") {
|
|
250
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
251
|
+
const condition = assertChoice(flagString(parsed, "condition", { required: true }), ONBOARDING_CONDITIONS, "--condition");
|
|
252
|
+
const revoke = hasFlag(parsed, "revoke");
|
|
253
|
+
const note = flagString(parsed, "note");
|
|
254
|
+
const body = {
|
|
255
|
+
condition,
|
|
256
|
+
attested: !revoke,
|
|
257
|
+
...(note !== undefined ? { note } : {}),
|
|
258
|
+
};
|
|
259
|
+
return writeCommand(parsed, {
|
|
260
|
+
action: revoke
|
|
261
|
+
? `REVOKE attestation ${condition}`
|
|
262
|
+
: `attest ${condition}`,
|
|
263
|
+
target: { familyId },
|
|
264
|
+
request: body,
|
|
265
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/attest", { params: { path: { familyId } }, body })));
|
|
266
|
+
}
|
|
267
|
+
if (verb === "clear-for-cohort") {
|
|
268
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
269
|
+
return writeCommand(parsed, {
|
|
270
|
+
action: "clear the family for cohort placement after the server rechecks stage, account, kids, goals, tutor, and attestations",
|
|
271
|
+
target: { familyId },
|
|
272
|
+
request: {},
|
|
273
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/clear-for-cohort", { params: { path: { familyId } } })));
|
|
274
|
+
}
|
|
275
|
+
if (verb === "register-cohort") {
|
|
276
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
277
|
+
const kidUserId = flagString(parsed, "kid", { required: true });
|
|
278
|
+
const eventSeriesId = flagString(parsed, "cohort", { required: true });
|
|
279
|
+
const body = { kidUserId, eventSeriesId };
|
|
280
|
+
return writeCommand(parsed, {
|
|
281
|
+
action: "register the kid in an eligible school cohort (staff placement may occur before the normal family-stage gate)",
|
|
282
|
+
target: { familyId, kidUserId, eventSeriesId },
|
|
283
|
+
request: body,
|
|
284
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/register-cohort", { params: { path: { familyId } }, body })));
|
|
285
|
+
}
|
|
286
|
+
if (verb === "set-kid-grade") {
|
|
287
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
288
|
+
const kidUserId = flagString(parsed, "kid", { required: true });
|
|
289
|
+
const grade = flagInteger(parsed, "grade", { min: 1, max: 12 });
|
|
290
|
+
const clear = hasFlag(parsed, "clear");
|
|
291
|
+
if ((grade !== undefined && clear) || (grade === undefined && !clear)) {
|
|
292
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --grade 1..12 or --clear.");
|
|
293
|
+
}
|
|
294
|
+
const body = { currentGrade: clear ? null : grade };
|
|
295
|
+
return writeCommand(parsed, {
|
|
296
|
+
action: clear
|
|
297
|
+
? "clear the kid's current grade back to uncollected"
|
|
298
|
+
: `set the kid's current grade to ${grade}`,
|
|
299
|
+
target: { familyId, kidUserId },
|
|
300
|
+
request: body,
|
|
301
|
+
}, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/kids/{kidUserId}/grade", { params: { path: { familyId, kidUserId } }, body })));
|
|
302
|
+
}
|
|
303
|
+
if (verb === "seed-feed") {
|
|
304
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
305
|
+
const kidUserId = flagString(parsed, "kid", { required: true });
|
|
306
|
+
return writeCommand(parsed, {
|
|
307
|
+
action: "seed the kid's feed interests from the newest populated family intake that contains this kid",
|
|
308
|
+
target: { familyId, kidUserId },
|
|
309
|
+
request: {},
|
|
310
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/kids/{kidUserId}/seed-feed", { params: { path: { familyId, kidUserId } } })));
|
|
311
|
+
}
|
|
312
|
+
if (verb === "generate-summaries") {
|
|
313
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
314
|
+
return writeCommand(parsed, {
|
|
315
|
+
action: "regenerate the current intake's parent narrative summaries with AI (can take about 30 seconds)",
|
|
316
|
+
target: { familyId },
|
|
317
|
+
request: {},
|
|
318
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake/generate-summaries", { params: { path: { familyId } } })));
|
|
319
|
+
}
|
|
320
|
+
if (verb === "provision-math-academy") {
|
|
321
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
322
|
+
const kidUserId = flagString(parsed, "kid", { required: true });
|
|
323
|
+
const grade = flagInteger(parsed, "grade", { min: 1, max: 12 });
|
|
324
|
+
const body = grade === undefined ? {} : { grade };
|
|
325
|
+
return writeCommand(parsed, {
|
|
326
|
+
action: "provision or reconnect the kid's Math Academy account (vendor write; cannot be automatically undone)",
|
|
327
|
+
target: { familyId, kidUserId },
|
|
328
|
+
request: body,
|
|
329
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/kids/{kidUserId}/provision/math-academy", { params: { path: { familyId, kidUserId } }, body })));
|
|
330
|
+
}
|
|
331
|
+
if (verb === "provision-ixl") {
|
|
332
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
333
|
+
const kidUserId = flagString(parsed, "kid", { required: true });
|
|
334
|
+
const credentialsFile = flagString(parsed, "credentials-file");
|
|
335
|
+
const body = credentialsFile
|
|
336
|
+
? await readJsonFile(credentialsFile, "IXL credentials file")
|
|
337
|
+
: undefined;
|
|
338
|
+
const password = typeof body?.password === "string" ? body.password : undefined;
|
|
339
|
+
const safeRequest = body
|
|
340
|
+
? {
|
|
341
|
+
...body,
|
|
342
|
+
...(password
|
|
343
|
+
? {
|
|
344
|
+
password: "[redacted]",
|
|
345
|
+
passwordSha256: createHash("sha256")
|
|
346
|
+
.update(password)
|
|
347
|
+
.digest("hex"),
|
|
348
|
+
}
|
|
349
|
+
: {}),
|
|
350
|
+
}
|
|
351
|
+
: {};
|
|
352
|
+
return writeCommand(parsed, {
|
|
353
|
+
action: "add or restore the kid in the IXL roster and optionally store the supplied login",
|
|
354
|
+
target: { familyId, kidUserId },
|
|
355
|
+
request: safeRequest,
|
|
356
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/kids/{kidUserId}/provision/ixl", {
|
|
357
|
+
params: { path: { familyId, kidUserId } },
|
|
358
|
+
...(body ? { body } : {}),
|
|
359
|
+
})));
|
|
360
|
+
}
|
|
361
|
+
if (verb === "remove-ixl") {
|
|
362
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
363
|
+
const kidUserId = flagString(parsed, "kid", { required: true });
|
|
364
|
+
return writeCommand(parsed, {
|
|
365
|
+
action: "remove the kid from the next IXL roster snapshot (stored login is retained)",
|
|
366
|
+
target: { familyId, kidUserId },
|
|
367
|
+
request: {},
|
|
368
|
+
}, async () => unwrap(await api.client.DELETE("/admin/onboarding/families/{familyId}/kids/{kidUserId}/provision/ixl", { params: { path: { familyId, kidUserId } } })));
|
|
369
|
+
}
|
|
370
|
+
if (verb === "ixl-sync") {
|
|
371
|
+
const apply = hasFlag(parsed, "apply");
|
|
372
|
+
if (!apply) {
|
|
373
|
+
return unwrap(await api.client.POST("/admin/onboarding/ixl/sync", {
|
|
374
|
+
body: { dryRun: true },
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
return writeCommand(parsed, {
|
|
378
|
+
action: "UPLOAD the complete current IXL roster snapshot (learners omitted from it are deactivated by the vendor)",
|
|
379
|
+
target: { vendor: "IXL" },
|
|
380
|
+
request: { dryRun: false },
|
|
381
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/ixl/sync", {
|
|
382
|
+
body: { dryRun: false },
|
|
383
|
+
})));
|
|
384
|
+
}
|
|
385
|
+
if (verb === "send-comms") {
|
|
386
|
+
const subjectKey = flagString(parsed, "subject", { required: true });
|
|
387
|
+
const kind = flagString(parsed, "kind", { required: true });
|
|
388
|
+
const body = { subjectKey, kind };
|
|
389
|
+
return writeCommand(parsed, {
|
|
390
|
+
action: "send the selected onboarding lifecycle communication now (timing bypass only; rollout and preference gates still apply)",
|
|
391
|
+
target: { subjectKey },
|
|
392
|
+
request: body,
|
|
393
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/comms/send-now", {
|
|
394
|
+
body,
|
|
395
|
+
})));
|
|
396
|
+
}
|
|
397
|
+
if (verb === "send-welcome" || verb === "send-payment-email") {
|
|
398
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
399
|
+
const resend = hasFlag(parsed, "resend");
|
|
400
|
+
const payment = verb === "send-payment-email";
|
|
401
|
+
return writeCommand(parsed, {
|
|
402
|
+
action: `${resend ? "RE-SEND" : "send"} the family's ${payment ? "enrollment payment" : "plan-ready welcome"} email`,
|
|
403
|
+
target: { familyId },
|
|
404
|
+
request: { resend },
|
|
405
|
+
}, async () => {
|
|
406
|
+
if (payment) {
|
|
407
|
+
return unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/send-payment-email", { params: { path: { familyId } }, body: { resend } }));
|
|
408
|
+
}
|
|
409
|
+
return unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/send-welcome", { params: { path: { familyId } }, body: { resend } }));
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (verb === "send-recommendations") {
|
|
413
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
414
|
+
const courseIds = flagIdList(parsed, "courses");
|
|
415
|
+
if (courseIds.length < 1 || courseIds.length > 8) {
|
|
416
|
+
throw new CliError("invalid_arguments", "--courses must contain from 1 through 8 comma-separated course IDs.");
|
|
417
|
+
}
|
|
418
|
+
const resend = hasFlag(parsed, "resend");
|
|
419
|
+
const body = { courseIds, resend };
|
|
420
|
+
return writeCommand(parsed, {
|
|
421
|
+
action: `${resend ? "RE-SEND" : "send"} the family's class recommendations email and unlock Explore`,
|
|
422
|
+
target: { familyId },
|
|
423
|
+
request: body,
|
|
424
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/send-recommendations", { params: { path: { familyId } }, body })));
|
|
425
|
+
}
|
|
426
|
+
if (verb === "set-lifecycle-prompt") {
|
|
427
|
+
const kind = assertChoice(positional(parsed, 2, "lifecycle prompt kind"), LIFECYCLE_PROMPT_KINDS, "prompt kind");
|
|
428
|
+
const enabled = flagBooleanValue(parsed, "enabled");
|
|
429
|
+
const delayDays = flagInteger(parsed, "delay-days", {
|
|
430
|
+
min: 0,
|
|
431
|
+
max: 365,
|
|
432
|
+
});
|
|
433
|
+
const windowDays = flagInteger(parsed, "window-days", {
|
|
434
|
+
min: 1,
|
|
435
|
+
max: 730,
|
|
436
|
+
});
|
|
437
|
+
const snoozeDays = flagInteger(parsed, "snooze-days", {
|
|
438
|
+
min: 0,
|
|
439
|
+
max: 90,
|
|
440
|
+
});
|
|
441
|
+
const maxPrompts = flagInteger(parsed, "max-prompts", {
|
|
442
|
+
min: 1,
|
|
443
|
+
max: 20,
|
|
444
|
+
});
|
|
445
|
+
const body = {
|
|
446
|
+
...(enabled !== undefined ? { enabled } : {}),
|
|
447
|
+
...(delayDays !== undefined ? { delayDays } : {}),
|
|
448
|
+
...(windowDays !== undefined ? { windowDays } : {}),
|
|
449
|
+
...(snoozeDays !== undefined ? { snoozeDays } : {}),
|
|
450
|
+
...(maxPrompts !== undefined ? { maxPrompts } : {}),
|
|
451
|
+
};
|
|
452
|
+
if (Object.keys(body).length === 0) {
|
|
453
|
+
throw new CliError("invalid_arguments", "Pass at least one lifecycle prompt field to update.");
|
|
454
|
+
}
|
|
455
|
+
return writeCommand(parsed, {
|
|
456
|
+
action: "change the live family lifecycle prompt schedule (exact-admin; affects future prompts)",
|
|
457
|
+
target: { kind },
|
|
458
|
+
request: body,
|
|
459
|
+
}, async () => unwrap(await api.client.PATCH("/admin/onboarding/lifecycle-prompts/{kind}", { params: { path: { kind } }, body })));
|
|
460
|
+
}
|
|
461
|
+
if (verb === "override-lifecycle-prompt") {
|
|
462
|
+
const kind = assertChoice(positional(parsed, 2, "lifecycle prompt kind"), LIFECYCLE_PROMPT_KINDS, "prompt kind");
|
|
463
|
+
const familyId = positional(parsed, 3, "family ID");
|
|
464
|
+
const action = assertChoice(flagString(parsed, "action", { required: true }), ["delay", "skip", "clear"], "--action");
|
|
465
|
+
const days = flagInteger(parsed, "days", { min: 1, max: 365 });
|
|
466
|
+
if ((action === "delay" && days === undefined) ||
|
|
467
|
+
(action !== "delay" && days !== undefined)) {
|
|
468
|
+
throw new CliError("invalid_arguments", action === "delay"
|
|
469
|
+
? "--days is required when --action is delay."
|
|
470
|
+
: "--days is accepted only when --action is delay.");
|
|
471
|
+
}
|
|
472
|
+
const body = { action, ...(days !== undefined ? { days } : {}) };
|
|
473
|
+
return writeCommand(parsed, {
|
|
474
|
+
action: `${action} the ${kind} lifecycle prompt for this family${days === undefined ? "" : ` by ${days} day(s)`}`,
|
|
475
|
+
target: { kind, familyId },
|
|
476
|
+
request: body,
|
|
477
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/lifecycle-prompts/{kind}/families/{familyId}/override", { params: { path: { kind, familyId } }, body })));
|
|
478
|
+
}
|
|
479
|
+
if (verb === "orientation-create") {
|
|
480
|
+
const startsAt = flagIsoInstant(parsed, "starts-at", {
|
|
481
|
+
required: true,
|
|
482
|
+
});
|
|
483
|
+
const endsAt = flagIsoInstant(parsed, "ends-at");
|
|
484
|
+
const capacity = flagInteger(parsed, "capacity", { min: 0, max: 1000 });
|
|
485
|
+
if (endsAt && Date.parse(endsAt) <= Date.parse(startsAt)) {
|
|
486
|
+
throw new CliError("invalid_arguments", "--ends-at must be later than --starts-at.");
|
|
487
|
+
}
|
|
488
|
+
const body = {
|
|
489
|
+
startsAt,
|
|
490
|
+
...(endsAt ? { endsAt } : {}),
|
|
491
|
+
...(capacity !== undefined ? { capacity } : {}),
|
|
492
|
+
};
|
|
493
|
+
return writeCommand(parsed, {
|
|
494
|
+
action: "schedule a new family orientation session",
|
|
495
|
+
target: { startsAt },
|
|
496
|
+
request: body,
|
|
497
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/orientation-sessions/", {
|
|
498
|
+
body,
|
|
499
|
+
})));
|
|
500
|
+
}
|
|
501
|
+
if (verb === "orientation-set-status") {
|
|
502
|
+
const sessionId = positional(parsed, 2, "orientation session ID");
|
|
503
|
+
const status = assertChoice(flagString(parsed, "status", { required: true }), ["CANCELED", "COMPLETED"], "--status");
|
|
504
|
+
const body = { status };
|
|
505
|
+
return writeCommand(parsed, {
|
|
506
|
+
action: `${status === "CANCELED" ? "cancel" : "complete"} the orientation session (canceled sessions cannot be reopened)`,
|
|
507
|
+
target: { sessionId },
|
|
508
|
+
request: body,
|
|
509
|
+
}, async () => unwrap(await api.client.PATCH("/admin/onboarding/orientation-sessions/{sessionId}/", { params: { path: { sessionId } }, body })));
|
|
510
|
+
}
|
|
511
|
+
if (verb === "orientation-attendance") {
|
|
512
|
+
const sessionId = positional(parsed, 2, "orientation session ID");
|
|
513
|
+
const familyId = flagString(parsed, "family", { required: true });
|
|
514
|
+
const attended = flagBooleanValue(parsed, "attended");
|
|
515
|
+
if (attended === undefined) {
|
|
516
|
+
throw new CliError("invalid_arguments", "Missing required --attended true|false.");
|
|
517
|
+
}
|
|
518
|
+
const body = { familyId, attended };
|
|
519
|
+
return writeCommand(parsed, {
|
|
520
|
+
action: attended
|
|
521
|
+
? "mark the family as having attended this orientation session"
|
|
522
|
+
: "clear the family's orientation attendance mark",
|
|
523
|
+
target: { sessionId, familyId },
|
|
524
|
+
request: body,
|
|
525
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/orientation-sessions/{sessionId}/attendance/", { params: { path: { sessionId } }, body })));
|
|
526
|
+
}
|
|
527
|
+
if (verb === "meeting-reschedule") {
|
|
528
|
+
const meetingId = positional(parsed, 2, "meeting ID");
|
|
529
|
+
const start = flagIsoInstant(parsed, "starts-at", { required: true });
|
|
530
|
+
const reason = flagString(parsed, "note");
|
|
531
|
+
const body = { start, ...(reason ? { reason } : {}) };
|
|
532
|
+
return writeCommand(parsed, {
|
|
533
|
+
action: "reschedule the Cal.com booking (provisional until its webhook settles; do not blindly retry an ambiguous outcome)",
|
|
534
|
+
target: { meetingId },
|
|
535
|
+
request: body,
|
|
536
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/meetings/{meetingId}/reschedule", { params: { path: { meetingId } }, body })));
|
|
537
|
+
}
|
|
538
|
+
if (verb === "meeting-cancel") {
|
|
539
|
+
const meetingId = positional(parsed, 2, "meeting ID");
|
|
540
|
+
const reason = flagString(parsed, "reason", { required: true }).trim();
|
|
541
|
+
if (!reason) {
|
|
542
|
+
throw new CliError("invalid_arguments", "--reason must not be empty.");
|
|
543
|
+
}
|
|
544
|
+
const body = { reason };
|
|
545
|
+
return writeCommand(parsed, {
|
|
546
|
+
action: "cancel the Cal.com booking and email the family the supplied reason (provisional until its webhook settles)",
|
|
547
|
+
target: { meetingId },
|
|
548
|
+
request: body,
|
|
549
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/meetings/{meetingId}/cancel", { params: { path: { meetingId } }, body })));
|
|
550
|
+
}
|
|
551
|
+
if (verb === "set-intake") {
|
|
552
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
553
|
+
const sessionId = flagString(parsed, "session", { required: true });
|
|
554
|
+
const dataRaw = flagString(parsed, "data", { required: true });
|
|
555
|
+
const expectedUpdatedAt = flagString(parsed, "expected-updated-at");
|
|
556
|
+
let collectedData;
|
|
557
|
+
try {
|
|
558
|
+
collectedData = JSON.parse(dataRaw);
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
throw new CliError("invalid_arguments", "--data must be valid JSON for the intake collectedData object.");
|
|
562
|
+
}
|
|
563
|
+
if (expectedUpdatedAt !== undefined &&
|
|
564
|
+
Number.isNaN(Date.parse(expectedUpdatedAt))) {
|
|
565
|
+
throw new CliError("invalid_arguments", "--expected-updated-at must be an ISO-8601 timestamp (the intake-session read's updatedAt).");
|
|
566
|
+
}
|
|
567
|
+
const collected = collectedData;
|
|
568
|
+
// The preview must do ZERO network so an offline preview stays honest
|
|
569
|
+
// (admin-cli offline-preview contract). The optimistic-concurrency token
|
|
570
|
+
// is resolved only on --confirm, inside the execute closure below — so a
|
|
571
|
+
// plain preview never reaches out. Strict mode (--expected-updated-at)
|
|
572
|
+
// shows the pinned token in the preview; the default fetches it at confirm.
|
|
573
|
+
const previewRequest = {
|
|
574
|
+
sessionId,
|
|
575
|
+
collectedData: collected,
|
|
576
|
+
...(expectedUpdatedAt !== undefined ? { expectedUpdatedAt } : {}),
|
|
577
|
+
};
|
|
578
|
+
return writeCommand(parsed, {
|
|
579
|
+
action: "write structured parent intake data for the family",
|
|
580
|
+
target: { familyId, sessionId },
|
|
581
|
+
request: previewRequest,
|
|
582
|
+
}, async () => {
|
|
583
|
+
// Resolve the CAS token now (only reached on --confirm):
|
|
584
|
+
// - strict mode: pin the operator-supplied token. A stale one 409s
|
|
585
|
+
// (unwrap → CliError → non-zero exit), protecting a parent's
|
|
586
|
+
// concurrent confirm-flow autosave.
|
|
587
|
+
// - default: fetch the CURRENT token and warn that edits made since
|
|
588
|
+
// the preview are unprotected. The CLI is a last-resort admin tool;
|
|
589
|
+
// pragmatic default + honest warning + strict opt-in.
|
|
590
|
+
let token = expectedUpdatedAt;
|
|
591
|
+
if (token === undefined) {
|
|
592
|
+
const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } }));
|
|
593
|
+
token = current.updatedAt;
|
|
594
|
+
process.stderr.write("Warning: using current session token; edits made since your preview are not protected — pass --expected-updated-at for strict CAS.\n");
|
|
595
|
+
}
|
|
596
|
+
const body = {
|
|
597
|
+
sessionId,
|
|
598
|
+
collectedData: collected,
|
|
599
|
+
expectedUpdatedAt: token,
|
|
600
|
+
};
|
|
601
|
+
return unwrap(await api.client.PUT("/admin/onboarding/families/{familyId}/intake", { params: { path: { familyId } }, body }));
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
if (verb === "extract") {
|
|
605
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
606
|
+
const sessionId = flagString(parsed, "session", { required: true });
|
|
607
|
+
const transcriptFile = flagString(parsed, "transcript-file");
|
|
608
|
+
const granolaRef = flagString(parsed, "granola");
|
|
609
|
+
if ((transcriptFile && granolaRef) || (!transcriptFile && !granolaRef)) {
|
|
610
|
+
throw new CliError("invalid_arguments", "Pass exactly one source: --transcript-file <path> or --granola <ref>.");
|
|
611
|
+
}
|
|
612
|
+
let transcript;
|
|
613
|
+
if (transcriptFile) {
|
|
614
|
+
try {
|
|
615
|
+
transcript = await fs.readFile(path.resolve(transcriptFile), "utf8");
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
throw new CliError("invalid_arguments", `Could not read transcript file ${transcriptFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const body = {
|
|
622
|
+
sessionId,
|
|
623
|
+
...(transcript !== undefined ? { transcript } : {}),
|
|
624
|
+
...(granolaRef ? { granolaRef } : {}),
|
|
625
|
+
};
|
|
626
|
+
// Keep raw transcript PII out of the confirmation preview (it prints to
|
|
627
|
+
// stderr, agent logs, and approval records); the full body still goes to
|
|
628
|
+
// the API on --confirm.
|
|
629
|
+
const previewRequest = transcript !== undefined && transcriptFile
|
|
630
|
+
? {
|
|
631
|
+
sessionId,
|
|
632
|
+
path: path.resolve(transcriptFile),
|
|
633
|
+
byteCount: Buffer.byteLength(transcript, "utf8"),
|
|
634
|
+
sha256Prefix: createHash("sha256")
|
|
635
|
+
.update(transcript)
|
|
636
|
+
.digest("hex")
|
|
637
|
+
.slice(0, 12),
|
|
638
|
+
}
|
|
639
|
+
: { sessionId, ...(granolaRef ? { granolaRef } : {}) };
|
|
640
|
+
return writeCommand(parsed, {
|
|
641
|
+
action: "extract intake from transcript via Claude and merge into session",
|
|
642
|
+
target: {
|
|
643
|
+
familyId,
|
|
644
|
+
sessionId,
|
|
645
|
+
source: transcriptFile ? "transcript" : "granola",
|
|
646
|
+
},
|
|
647
|
+
request: previewRequest,
|
|
648
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-extract", { params: { path: { familyId } }, body })));
|
|
649
|
+
}
|
|
650
|
+
if (verb === "contracts") {
|
|
651
|
+
const kidUserId = flagString(parsed, "kid");
|
|
652
|
+
// The route takes no filter: it returns the newest 100 contracts across
|
|
653
|
+
// ALL families (get.contracts.ts), so --kid narrows client-side and a
|
|
654
|
+
// contract older than that window is simply not in the window.
|
|
655
|
+
const result = unwrap(await api.client.GET("/admin/enrollment-contracts/"));
|
|
656
|
+
if (!kidUserId)
|
|
657
|
+
return result;
|
|
658
|
+
return {
|
|
659
|
+
...result,
|
|
660
|
+
contracts: result.contracts.filter((contract) => contract.studentUserId === kidUserId),
|
|
661
|
+
scope: "newest 100 contracts across all families, filtered by --kid",
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
if (verb === "send-contract") {
|
|
665
|
+
const kidUserId = positional(parsed, 2, "kid user ID");
|
|
666
|
+
const tuitionCents = flagInteger(parsed, "tuition-cents", { min: 0 });
|
|
667
|
+
const parentEmail = flagString(parsed, "parent-email");
|
|
668
|
+
const partnerName = flagString(parsed, "partner-name");
|
|
669
|
+
const partnerEmail = flagString(parsed, "partner-email");
|
|
670
|
+
// Read-only preflight. Three things the approver must see before a legal
|
|
671
|
+
// document goes out under someone's name: that this id is the intended
|
|
672
|
+
// KID, that a live contract is not already out with the parent (the
|
|
673
|
+
// workspace hides its button in that case — here --resend is the explicit
|
|
674
|
+
// override), and who the signature request will actually reach.
|
|
675
|
+
const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
|
|
676
|
+
params: { path: { userId: kidUserId } },
|
|
677
|
+
}));
|
|
678
|
+
if (user.role !== "KID") {
|
|
679
|
+
throw new CliError("invalid_arguments", `User ${kidUserId} has role ${user.role}; an enrollment contract is sent for a kid.`);
|
|
680
|
+
}
|
|
681
|
+
if (!user.familyId) {
|
|
682
|
+
throw new CliError("invalid_arguments", `Kid ${kidUserId} has no family, so there is no guardian to sign.`);
|
|
683
|
+
}
|
|
684
|
+
const familyId = user.familyId;
|
|
685
|
+
const [contracts, status] = await Promise.all([
|
|
686
|
+
unwrap(await api.client.GET("/admin/enrollment-contracts/")),
|
|
687
|
+
unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
|
|
688
|
+
params: { path: { familyId } },
|
|
689
|
+
})),
|
|
690
|
+
]);
|
|
691
|
+
const live = contracts.contracts.find((contract) => contract.studentUserId === kidUserId &&
|
|
692
|
+
contract.status !== "DECLINED" &&
|
|
693
|
+
contract.status !== "VOIDED");
|
|
694
|
+
if (live && !hasFlag(parsed, "resend")) {
|
|
695
|
+
throw new CliError("invalid_arguments", `Contract ${live.id} for this kid is already ${live.status} (sent ${live.sentAt}). Sending again originates a SECOND Documenso document to the parent — pass --resend if that is what you mean.`);
|
|
696
|
+
}
|
|
697
|
+
const body = {
|
|
698
|
+
studentUserId: kidUserId,
|
|
699
|
+
...(tuitionCents !== undefined ? { tuitionCents } : {}),
|
|
700
|
+
...(parentEmail ? { parentEmail } : {}),
|
|
701
|
+
...(partnerName ? { partnerName } : {}),
|
|
702
|
+
...(partnerEmail ? { partnerEmail } : {}),
|
|
703
|
+
};
|
|
704
|
+
return writeCommand(parsed, {
|
|
705
|
+
action: "originate the two-signer WonderED enrollment contract (partner signs first, then the parent) and email the signature request",
|
|
706
|
+
target: { kidUserId, familyId, familyName: status.familyName },
|
|
707
|
+
request: body,
|
|
708
|
+
details: {
|
|
709
|
+
student: personName(user.firstName, user.lastName, kidUserId),
|
|
710
|
+
// The send resolves the family's OLDEST guardian; this is the
|
|
711
|
+
// family's predicted parent email (the oldest EMAIL-BEARING
|
|
712
|
+
// guardian), so the two can differ when the oldest guardian has no
|
|
713
|
+
// email — which is the case the route 400s on. --parent-email pins
|
|
714
|
+
// the signer either way.
|
|
715
|
+
parentEmailPredicted: status.welcome.recipientEmail,
|
|
716
|
+
...(parentEmail ? { parentEmailOverride: parentEmail } : {}),
|
|
717
|
+
tuition: tuitionCents === undefined
|
|
718
|
+
? "resolved server-side from the family's newest ACCEPTED quote (the send 400s when no accepted quote covers this kid) — pass --tuition-cents to pin the amount in this approval"
|
|
719
|
+
: `$${(tuitionCents / 100).toFixed(2)}`,
|
|
720
|
+
partnerSigner: partnerName || partnerEmail
|
|
721
|
+
? { partnerName, partnerEmail }
|
|
722
|
+
: "the WONDERED_SIGNATORY_* env pair",
|
|
723
|
+
...(live
|
|
724
|
+
? {
|
|
725
|
+
resend: `Overriding live contract ${live.id} (${live.status}); this creates a SECOND document, it does not replace the first.`,
|
|
726
|
+
}
|
|
727
|
+
: {}),
|
|
728
|
+
note: "Contracts normally auto-send when onboarding finalizes (enrollment-contract-autosend.ts). This command is for families priced after the fact, or whose auto-send was skipped for want of an accepted quote.",
|
|
729
|
+
},
|
|
730
|
+
}, async () => unwrap(await api.client.POST("/admin/enrollment-contracts/send/", { body })));
|
|
731
|
+
}
|
|
732
|
+
if (verb === "mark-reviewed") {
|
|
733
|
+
const sessionId = positional(parsed, 2, "onboarding session ID");
|
|
734
|
+
// Read-only preflight: the human approving this signs their name to a
|
|
735
|
+
// specific family's handoff, so the preview must carry the summary they
|
|
736
|
+
// are attesting to — and post.review.ts accepts ONLY HANDED_OFF, so
|
|
737
|
+
// refuse the states it would reject before the gate, not after approval.
|
|
738
|
+
const session = unwrap(await api.client.GET("/ai/onboarding/reviews/{id}", {
|
|
739
|
+
params: { path: { id: sessionId } },
|
|
740
|
+
}));
|
|
741
|
+
if (session.status !== "HANDED_OFF" && session.status !== "REVIEWED") {
|
|
742
|
+
throw new CliError("invalid_arguments", `Onboarding session ${sessionId} is ${session.status}; only a HANDED_OFF session can be marked reviewed. A COMPLETED session still needs the parent confirmation that creates the kid accounts.`);
|
|
743
|
+
}
|
|
744
|
+
const reviewer = session.reviewer
|
|
745
|
+
? personName(session.reviewer.firstName, session.reviewer.lastName, session.reviewer.id)
|
|
746
|
+
: null;
|
|
747
|
+
return writeCommand(parsed, {
|
|
748
|
+
action: "mark the parent onboarding handoff as reviewed by you",
|
|
749
|
+
target: { sessionId, familyId: session.familyId },
|
|
750
|
+
request: {},
|
|
751
|
+
details: {
|
|
752
|
+
status: session.status,
|
|
753
|
+
parent: personName(session.parent.firstName, session.parent.lastName, session.parent.email ?? session.parent.id),
|
|
754
|
+
handoffSentAt: session.handoffSentAt,
|
|
755
|
+
// The stamp attests to THIS text; show it with the approval ask.
|
|
756
|
+
handoffSummary: summaryPreview(session.handoffSummary),
|
|
757
|
+
fullRecord: `recess --json onboarding review ${sessionId}`,
|
|
758
|
+
...(session.status === "REVIEWED"
|
|
759
|
+
? {
|
|
760
|
+
note: `Already reviewed by ${reviewer ?? "another admin"} at ${session.handoffReviewedAt ?? "an unrecorded time"}. Confirming is a first-reviewer-wins no-op that preserves that attribution.`,
|
|
761
|
+
}
|
|
762
|
+
: {}),
|
|
763
|
+
},
|
|
764
|
+
}, async () => unwrap(await api.client.POST("/ai/onboarding/reviews/{id}", {
|
|
765
|
+
params: { path: { id: sessionId } },
|
|
766
|
+
})));
|
|
767
|
+
}
|
|
768
|
+
if (verb === "pairing-code") {
|
|
769
|
+
const kidUserId = positional(parsed, 2, "kid user ID");
|
|
770
|
+
// Read-only preflight: name the exact kid whose account this code signs
|
|
771
|
+
// in, and refuse a non-KID before the gate (the backend enforces the
|
|
772
|
+
// same rule, and an admin can pair ANY kid — there is no family fence to
|
|
773
|
+
// catch a mistyped id).
|
|
774
|
+
const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
|
|
775
|
+
params: { path: { userId: kidUserId } },
|
|
776
|
+
}));
|
|
777
|
+
if (user.role !== "KID") {
|
|
778
|
+
throw new CliError("invalid_arguments", `User ${kidUserId} has role ${user.role}; pairing codes can only be issued for kids.`);
|
|
779
|
+
}
|
|
780
|
+
return writeCommand(parsed, {
|
|
781
|
+
action: "issue a single-use device-pairing code that signs this kid into a new device",
|
|
782
|
+
target: { kidUserId, familyId: user.familyId },
|
|
783
|
+
request: { kidUserId },
|
|
784
|
+
details: {
|
|
785
|
+
kid: personName(user.firstName, user.lastName, kidUserId),
|
|
786
|
+
expiresInMinutes: PAIRING_CODE_TTL_MINUTES,
|
|
787
|
+
credential: "The result is a live sign-in credential (token, shortCode, pairingUrl). Read it to the family for the device in front of them; never paste it into a ticket, log, or shared channel.",
|
|
788
|
+
},
|
|
789
|
+
}, async () => unwrap(await api.client.POST("/auth/device-pairing/generate/", {
|
|
790
|
+
body: { kidUserId },
|
|
791
|
+
})));
|
|
792
|
+
}
|
|
793
|
+
throw new CliError("invalid_arguments", "Unknown onboarding command. Run `recess onboarding --help` for the current command list.");
|
|
794
|
+
}
|
|
795
|
+
throw new CliError("invalid_arguments", "Unknown onboarding command.");
|
|
796
|
+
}
|
|
797
|
+
//# sourceMappingURL=onboarding.js.map
|