recess-cli 2.1.0 → 2.3.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.
@@ -37,6 +37,86 @@ const LIFECYCLE_PROMPT_KINDS = [
37
37
  "TWO_WEEK_CHECK_IN",
38
38
  "TWO_MONTH_TESTIMONIAL",
39
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
+ /**
52
+ * The mission-control queue rule → the CLI command(s) that perform it, with
53
+ * real ids substituted where the action carries them. Placeholders stay in
54
+ * angle brackets where a human choice remains (a template, a cohort, a
55
+ * transcript). Suggestions only: every write below still previews and demands
56
+ * its own --confirm when actually run.
57
+ */
58
+ function suggestedCommandsForQueueAction(action, familyId) {
59
+ const slug = action.partnerSlug ?? "<institution-slug>";
60
+ const invite = action.inviteId ?? "<code-id>";
61
+ const kid = (action.kids.find((row) => row.activeGoalCount === 0) ?? action.kids[0])
62
+ ?.id ?? "<kid-id>";
63
+ switch (action.key) {
64
+ case "invite_waiting":
65
+ return [
66
+ `recess school codes get ${invite} --school ${slug}`,
67
+ `recess school codes resend ${invite} --school ${slug}`,
68
+ ];
69
+ case "invite_expired":
70
+ return [
71
+ `recess school codes replace ${invite} --school ${slug} --data-file <invite.json>`,
72
+ ];
73
+ case "parked":
74
+ return [`recess onboarding set-account-state ${familyId} --state ACTIVE`];
75
+ case "start_intake":
76
+ return [`recess onboarding intake-session-create ${familyId}`];
77
+ case "fill_intake":
78
+ return [
79
+ `recess onboarding extract ${familyId} --session <session-id> --granola <ref>`,
80
+ `recess onboarding set-intake ${familyId} --session <session-id> --data <json>`,
81
+ ];
82
+ case "send_welcome":
83
+ return [`recess onboarding send-welcome ${familyId}`];
84
+ case "nudge_confirm":
85
+ return [
86
+ `recess onboarding send-comms --subject family:${familyId} --kind confirm_nudge_1`,
87
+ ];
88
+ case "load_goals":
89
+ return [
90
+ `recess goal-templates list --starter-only`,
91
+ `recess goal-templates apply-starter <template-id> --student ${kid}`,
92
+ ];
93
+ case "assign_tutor":
94
+ return [
95
+ `recess onboarding active-tutors ${familyId}`,
96
+ `recess onboarding set-primary-tutor ${familyId} --tutor <user-id>`,
97
+ ];
98
+ case "attest":
99
+ return [
100
+ `recess onboarding attest ${familyId} --condition <app_downloaded|tutor_met|goals_loaded|ma_diagnostic>`,
101
+ ];
102
+ case "clear_for_cohort":
103
+ return [`recess onboarding clear-for-cohort ${familyId}`];
104
+ case "register_cohort":
105
+ return [
106
+ `recess onboarding cohort-options ${familyId} --kid ${kid}`,
107
+ `recess onboarding register-cohort ${familyId} --kid ${kid} --cohort <event-series-id>`,
108
+ ];
109
+ case "at_risk":
110
+ return [
111
+ `recess onboarding readiness ${familyId}`,
112
+ `recess onboarding timeline ${familyId}`,
113
+ ];
114
+ case "mark_complete":
115
+ return [`recess onboarding set-stage ${familyId} --stage COMPLETE`];
116
+ default:
117
+ return [];
118
+ }
119
+ }
40
120
  export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
41
121
  const noun = parsed.positionals[0] ?? "";
42
122
  const verb = parsed.positionals[1] ?? "";
@@ -86,6 +166,123 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
86
166
  const familyId = positional(parsed, 2, "family ID");
87
167
  return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/active-tutors", { params: { path: { familyId } } }));
88
168
  }
169
+ if (verb === "family") {
170
+ const familyId = positional(parsed, 2, "family ID");
171
+ // The whole family on one screen: status + readiness + the queue's next
172
+ // action + the current intake session + active tutors, in parallel.
173
+ // `status` is the required backbone (a missing family fails the command);
174
+ // the flag-gated reads degrade to a labeled `unavailable` instead of
175
+ // failing the view — the doctor command is the tool for "why".
176
+ const soft = async (read) => {
177
+ try {
178
+ return await read();
179
+ }
180
+ catch (error) {
181
+ return {
182
+ unavailable: error instanceof CliError ? error.message : String(error),
183
+ };
184
+ }
185
+ };
186
+ const [status, readiness, nextAction, intakeSession, activeTutors] = await Promise.all([
187
+ api.client
188
+ .GET("/admin/onboarding/families/{familyId}/status", {
189
+ params: { path: { familyId } },
190
+ })
191
+ .then(unwrap),
192
+ soft(() => api.client
193
+ .GET("/admin/onboarding/families/{familyId}/readiness", {
194
+ params: { path: { familyId } },
195
+ })
196
+ .then(unwrap)),
197
+ soft(() => api.client
198
+ .GET("/admin/onboarding/families/{familyId}/next-action", {
199
+ params: { path: { familyId } },
200
+ })
201
+ .then(unwrap)),
202
+ soft(async () => {
203
+ try {
204
+ return await api.rawGet(`/admin/onboarding/families/${encodeURIComponent(familyId)}/intake-session`);
205
+ }
206
+ catch (error) {
207
+ if (error instanceof CliError &&
208
+ typeof error.details === "object" &&
209
+ error.details !== null &&
210
+ error.details.status === 404) {
211
+ return { intakeSession: null };
212
+ }
213
+ throw error;
214
+ }
215
+ }),
216
+ soft(() => api.client
217
+ .GET("/admin/onboarding/families/{familyId}/active-tutors", {
218
+ params: { path: { familyId } },
219
+ })
220
+ .then(unwrap)),
221
+ ]);
222
+ return {
223
+ familyId,
224
+ status,
225
+ readiness,
226
+ nextAction,
227
+ intakeSession,
228
+ activeTutors,
229
+ };
230
+ }
231
+ if (verb === "next") {
232
+ const familyId = positional(parsed, 2, "family ID");
233
+ const result = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/next-action", { params: { path: { familyId } } }));
234
+ const action = result.action;
235
+ return {
236
+ ...result,
237
+ // The queue rule mapped to the command that performs it, with real ids
238
+ // substituted where the action carries them — so "what's next" arrives
239
+ // holding "and here is how", instead of making the agent re-derive the
240
+ // funnel from documentation. Every suggested write still previews and
241
+ // requires its own --confirm.
242
+ suggestedCommands: action === null
243
+ ? []
244
+ : suggestedCommandsForQueueAction(action, familyId),
245
+ };
246
+ }
247
+ if (verb === "doctor") {
248
+ // --family is a flag rather than an optional positional: the schema
249
+ // derives positional bounds from the usage line, which cannot express
250
+ // optionality.
251
+ const familyId = flagString(parsed, "family");
252
+ return unwrap(await api.client.GET("/admin/onboarding/doctor", {
253
+ params: { query: familyId ? { familyId } : {} },
254
+ }));
255
+ }
256
+ if (verb === "starter-coverage") {
257
+ // The read-only pre-flip gate (same evaluation as the runbook's
258
+ // check-starter-coverage script). `ok: false` = do not flip the flag.
259
+ return unwrap(await api.client.GET("/admin/onboarding/starter-coverage"));
260
+ }
261
+ if (verb === "backfill-trackers") {
262
+ // WS-H over the wire. Without --apply this is the read-only census
263
+ // (dry-run POST, same precedent as `goal-templates apply --dry-run`).
264
+ // With --apply, the census is re-taken as the preview — the runbook's
265
+ // "fresh dry run on the identical predicate immediately before
266
+ // applying" — and the confirmed write re-derives every conjunct under a
267
+ // per-guardian lock server-side anyway.
268
+ if (!hasFlag(parsed, "apply")) {
269
+ return unwrap(await api.client.POST("/admin/onboarding/backfill-guardian-trackers", {
270
+ body: { apply: false },
271
+ }));
272
+ }
273
+ const census = unwrap(await api.client.POST("/admin/onboarding/backfill-guardian-trackers", {
274
+ body: { apply: false },
275
+ }));
276
+ return writeCommand(parsed, {
277
+ action: "apply the WS-H guardian-tracker backfill: stamp parentOnboardingCompletedAt for the arm-1 (crash-between-commits) school guardians below",
278
+ target: { repairableCount: census.repairable.length },
279
+ request: { apply: true },
280
+ details: {
281
+ freshCensus: census,
282
+ authority: "The census is a photograph; the server re-derives every conjunct under a per-guardian lock at write time and skips anyone who no longer qualifies. Exact-ADMIN only.",
283
+ },
284
+ }, async () => unwrap(await api.client.POST("/admin/onboarding/backfill-guardian-trackers", { body: { apply: true } })));
285
+ }
89
286
  if (verb === "meetings") {
90
287
  const familyId = positional(parsed, 2, "family ID");
91
288
  return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/meetings", {
@@ -156,6 +353,26 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
156
353
  throw error;
157
354
  }
158
355
  }
356
+ if (verb === "reviews") {
357
+ const familyId = positional(parsed, 2, "family ID");
358
+ // The family workspace's review card: the handoff session(s) for this
359
+ // family. get.reviews.ts binds the family scope to the canonical (oldest)
360
+ // guardian — the same one the intake commands operate on — so a
361
+ // two-guardian family never surfaces the other guardian's session.
362
+ // `guardianMissing: true` with an empty list means the family has no
363
+ // guardian at all, not that no session was ever started.
364
+ return unwrap(await api.client.GET("/ai/onboarding/reviews", {
365
+ params: { query: { familyId } },
366
+ }));
367
+ }
368
+ if (verb === "review") {
369
+ const sessionId = positional(parsed, 2, "onboarding session ID");
370
+ // The full handoff record behind the workspace's handoff page: summary,
371
+ // collected data, artifacts, progress. Read this before mark-reviewed.
372
+ return unwrap(await api.client.GET("/ai/onboarding/reviews/{id}", {
373
+ params: { path: { id: sessionId } },
374
+ }));
375
+ }
159
376
  if (verb === "intake-session-create") {
160
377
  const familyId = positional(parsed, 2, "family ID");
161
378
  // The explicit create: get-or-create the intake session. Mints a blank
@@ -616,6 +833,149 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
616
833
  request: previewRequest,
617
834
  }, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-extract", { params: { path: { familyId } }, body })));
618
835
  }
836
+ if (verb === "contracts") {
837
+ const kidUserId = flagString(parsed, "kid");
838
+ // The route takes no filter: it returns the newest 100 contracts across
839
+ // ALL families (get.contracts.ts), so --kid narrows client-side and a
840
+ // contract older than that window is simply not in the window.
841
+ const result = unwrap(await api.client.GET("/admin/enrollment-contracts/"));
842
+ if (!kidUserId)
843
+ return result;
844
+ return {
845
+ ...result,
846
+ contracts: result.contracts.filter((contract) => contract.studentUserId === kidUserId),
847
+ scope: "newest 100 contracts across all families, filtered by --kid",
848
+ };
849
+ }
850
+ if (verb === "send-contract") {
851
+ const kidUserId = positional(parsed, 2, "kid user ID");
852
+ const tuitionCents = flagInteger(parsed, "tuition-cents", { min: 0 });
853
+ const parentEmail = flagString(parsed, "parent-email");
854
+ const partnerName = flagString(parsed, "partner-name");
855
+ const partnerEmail = flagString(parsed, "partner-email");
856
+ // Read-only preflight. Three things the approver must see before a legal
857
+ // document goes out under someone's name: that this id is the intended
858
+ // KID, that a live contract is not already out with the parent (the
859
+ // workspace hides its button in that case — here --resend is the explicit
860
+ // override), and who the signature request will actually reach.
861
+ const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
862
+ params: { path: { userId: kidUserId } },
863
+ }));
864
+ if (user.role !== "KID") {
865
+ throw new CliError("invalid_arguments", `User ${kidUserId} has role ${user.role}; an enrollment contract is sent for a kid.`);
866
+ }
867
+ if (!user.familyId) {
868
+ throw new CliError("invalid_arguments", `Kid ${kidUserId} has no family, so there is no guardian to sign.`);
869
+ }
870
+ const familyId = user.familyId;
871
+ const [contracts, status] = await Promise.all([
872
+ unwrap(await api.client.GET("/admin/enrollment-contracts/")),
873
+ unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
874
+ params: { path: { familyId } },
875
+ })),
876
+ ]);
877
+ const live = contracts.contracts.find((contract) => contract.studentUserId === kidUserId &&
878
+ contract.status !== "DECLINED" &&
879
+ contract.status !== "VOIDED");
880
+ if (live && !hasFlag(parsed, "resend")) {
881
+ 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.`);
882
+ }
883
+ const body = {
884
+ studentUserId: kidUserId,
885
+ ...(tuitionCents !== undefined ? { tuitionCents } : {}),
886
+ ...(parentEmail ? { parentEmail } : {}),
887
+ ...(partnerName ? { partnerName } : {}),
888
+ ...(partnerEmail ? { partnerEmail } : {}),
889
+ };
890
+ return writeCommand(parsed, {
891
+ action: "originate the two-signer WonderED enrollment contract (partner signs first, then the parent) and email the signature request",
892
+ target: { kidUserId, familyId, familyName: status.familyName },
893
+ request: body,
894
+ details: {
895
+ student: personName(user.firstName, user.lastName, kidUserId),
896
+ // The send resolves the family's OLDEST guardian; this is the
897
+ // family's predicted parent email (the oldest EMAIL-BEARING
898
+ // guardian), so the two can differ when the oldest guardian has no
899
+ // email — which is the case the route 400s on. --parent-email pins
900
+ // the signer either way.
901
+ parentEmailPredicted: status.welcome.recipientEmail,
902
+ ...(parentEmail ? { parentEmailOverride: parentEmail } : {}),
903
+ tuition: tuitionCents === undefined
904
+ ? "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"
905
+ : `$${(tuitionCents / 100).toFixed(2)}`,
906
+ partnerSigner: partnerName || partnerEmail
907
+ ? { partnerName, partnerEmail }
908
+ : "the WONDERED_SIGNATORY_* env pair",
909
+ ...(live
910
+ ? {
911
+ resend: `Overriding live contract ${live.id} (${live.status}); this creates a SECOND document, it does not replace the first.`,
912
+ }
913
+ : {}),
914
+ 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.",
915
+ },
916
+ }, async () => unwrap(await api.client.POST("/admin/enrollment-contracts/send/", { body })));
917
+ }
918
+ if (verb === "mark-reviewed") {
919
+ const sessionId = positional(parsed, 2, "onboarding session ID");
920
+ // Read-only preflight: the human approving this signs their name to a
921
+ // specific family's handoff, so the preview must carry the summary they
922
+ // are attesting to — and post.review.ts accepts ONLY HANDED_OFF, so
923
+ // refuse the states it would reject before the gate, not after approval.
924
+ const session = unwrap(await api.client.GET("/ai/onboarding/reviews/{id}", {
925
+ params: { path: { id: sessionId } },
926
+ }));
927
+ if (session.status !== "HANDED_OFF" && session.status !== "REVIEWED") {
928
+ 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.`);
929
+ }
930
+ const reviewer = session.reviewer
931
+ ? personName(session.reviewer.firstName, session.reviewer.lastName, session.reviewer.id)
932
+ : null;
933
+ return writeCommand(parsed, {
934
+ action: "mark the parent onboarding handoff as reviewed by you",
935
+ target: { sessionId, familyId: session.familyId },
936
+ request: {},
937
+ details: {
938
+ status: session.status,
939
+ parent: personName(session.parent.firstName, session.parent.lastName, session.parent.email ?? session.parent.id),
940
+ handoffSentAt: session.handoffSentAt,
941
+ // The stamp attests to THIS text; show it with the approval ask.
942
+ handoffSummary: summaryPreview(session.handoffSummary),
943
+ fullRecord: `recess --json onboarding review ${sessionId}`,
944
+ ...(session.status === "REVIEWED"
945
+ ? {
946
+ 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.`,
947
+ }
948
+ : {}),
949
+ },
950
+ }, async () => unwrap(await api.client.POST("/ai/onboarding/reviews/{id}", {
951
+ params: { path: { id: sessionId } },
952
+ })));
953
+ }
954
+ if (verb === "pairing-code") {
955
+ const kidUserId = positional(parsed, 2, "kid user ID");
956
+ // Read-only preflight: name the exact kid whose account this code signs
957
+ // in, and refuse a non-KID before the gate (the backend enforces the
958
+ // same rule, and an admin can pair ANY kid — there is no family fence to
959
+ // catch a mistyped id).
960
+ const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
961
+ params: { path: { userId: kidUserId } },
962
+ }));
963
+ if (user.role !== "KID") {
964
+ throw new CliError("invalid_arguments", `User ${kidUserId} has role ${user.role}; pairing codes can only be issued for kids.`);
965
+ }
966
+ return writeCommand(parsed, {
967
+ action: "issue a single-use device-pairing code that signs this kid into a new device",
968
+ target: { kidUserId, familyId: user.familyId },
969
+ request: { kidUserId },
970
+ details: {
971
+ kid: personName(user.firstName, user.lastName, kidUserId),
972
+ expiresInMinutes: PAIRING_CODE_TTL_MINUTES,
973
+ 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.",
974
+ },
975
+ }, async () => unwrap(await api.client.POST("/auth/device-pairing/generate/", {
976
+ body: { kidUserId },
977
+ })));
978
+ }
619
979
  throw new CliError("invalid_arguments", "Unknown onboarding command. Run `recess onboarding --help` for the current command list.");
620
980
  }
621
981
  throw new CliError("invalid_arguments", "Unknown onboarding command.");