recess-cli 2.1.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/dist/cli.js +84 -4
- package/dist/command-schema.js +1 -0
- package/dist/commands/onboarding.js +174 -0
- package/dist/commands/school.js +465 -0
- package/dist/help.js +41 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, savePro
|
|
|
9
9
|
import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
|
|
10
10
|
import { runApplicationsCommand } from "./commands/applications.js";
|
|
11
11
|
import { runOnboardingCommand } from "./commands/onboarding.js";
|
|
12
|
+
import { runSchoolCommand } from "./commands/school.js";
|
|
12
13
|
import { assertChoice, flagIdList, positional, readJsonFile, readJsonValue, } from "./commands/shared.js";
|
|
13
14
|
import { CliError } from "./errors.js";
|
|
14
15
|
import { listFeedback, submitFeedback } from "./feedback.js";
|
|
@@ -1156,6 +1157,14 @@ function parseGoalTemplateDocument(doc) {
|
|
|
1156
1157
|
(typeof rawSortOrder !== "number" || !Number.isInteger(rawSortOrder))) {
|
|
1157
1158
|
throw new CliError("invalid_arguments", 'Template file field "sortOrder" must be an integer.');
|
|
1158
1159
|
}
|
|
1160
|
+
const rawCoinAmount = doc.coinAmount;
|
|
1161
|
+
if (rawCoinAmount !== undefined &&
|
|
1162
|
+
rawCoinAmount !== null &&
|
|
1163
|
+
(typeof rawCoinAmount !== "number" ||
|
|
1164
|
+
!Number.isInteger(rawCoinAmount) ||
|
|
1165
|
+
rawCoinAmount < 1)) {
|
|
1166
|
+
throw new CliError("invalid_arguments", 'Template file field "coinAmount" must be a positive integer.');
|
|
1167
|
+
}
|
|
1159
1168
|
const kind = assertChoice(optionalDocString(doc, "kind") ?? "SIMPLE", GOAL_TEMPLATE_KINDS, "kind");
|
|
1160
1169
|
const setupAudience = assertChoice(optionalDocString(doc, "setupAudience") ?? "KID_FRIENDLY", GOAL_TEMPLATE_SETUP_AUDIENCES, "setupAudience");
|
|
1161
1170
|
const starterTierRaw = optionalDocString(doc, "starterTier");
|
|
@@ -1175,6 +1184,9 @@ function parseGoalTemplateDocument(doc) {
|
|
|
1175
1184
|
...(optionalDocString(doc, "imageUrl") === undefined
|
|
1176
1185
|
? {}
|
|
1177
1186
|
: { imageUrl: optionalDocString(doc, "imageUrl") }),
|
|
1187
|
+
...(rawCoinAmount === undefined || rawCoinAmount === null
|
|
1188
|
+
? {}
|
|
1189
|
+
: { coinAmount: rawCoinAmount }),
|
|
1178
1190
|
...(optionalDocString(doc, "category") === undefined
|
|
1179
1191
|
? {}
|
|
1180
1192
|
: { category: optionalDocString(doc, "category") }),
|
|
@@ -2248,6 +2260,9 @@ export async function runCommand(argv) {
|
|
|
2248
2260
|
if (noun === "applications" || noun === "quotes") {
|
|
2249
2261
|
return runApplicationsCommand({ parsed, api, writeCommand });
|
|
2250
2262
|
}
|
|
2263
|
+
if (noun === "school") {
|
|
2264
|
+
return runSchoolCommand({ parsed, api, writeCommand });
|
|
2265
|
+
}
|
|
2251
2266
|
if (noun === "cohorts" && verb === "search") {
|
|
2252
2267
|
const search = parsed.positionals.slice(2).join(" ").trim();
|
|
2253
2268
|
if (!search)
|
|
@@ -3084,7 +3099,18 @@ export async function runCommand(argv) {
|
|
|
3084
3099
|
}
|
|
3085
3100
|
if (verb === "create") {
|
|
3086
3101
|
const filePath = flagString(parsed, "file", { required: true });
|
|
3087
|
-
const
|
|
3102
|
+
const authoredDocument = parseGoalTemplateDocument(await readJsonFile(filePath, "Template file"));
|
|
3103
|
+
const coinAmountOverride = flagNumber(parsed, "coin-amount");
|
|
3104
|
+
if (coinAmountOverride !== undefined &&
|
|
3105
|
+
(!Number.isInteger(coinAmountOverride) || coinAmountOverride < 1)) {
|
|
3106
|
+
throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
|
|
3107
|
+
}
|
|
3108
|
+
const document = {
|
|
3109
|
+
...authoredDocument,
|
|
3110
|
+
...(coinAmountOverride === undefined
|
|
3111
|
+
? {}
|
|
3112
|
+
: { coinAmount: coinAmountOverride }),
|
|
3113
|
+
};
|
|
3088
3114
|
// C3 read-only preflight, for the same reason `enrollments create` has
|
|
3089
3115
|
// one: the consequences an approver must weigh are resolved SERVER-side.
|
|
3090
3116
|
// Which setupHandler runs, what shape of goal students get, and which
|
|
@@ -3110,6 +3136,7 @@ export async function runCommand(argv) {
|
|
|
3110
3136
|
category: document.category ?? null,
|
|
3111
3137
|
tags: document.tags,
|
|
3112
3138
|
isStarter: document.isStarter ?? false,
|
|
3139
|
+
coinAmount: document.coinAmount ?? null,
|
|
3113
3140
|
},
|
|
3114
3141
|
details: {
|
|
3115
3142
|
resolvedSetupHandler: validation.setupHandler,
|
|
@@ -3206,6 +3233,11 @@ export async function runCommand(argv) {
|
|
|
3206
3233
|
const tags = flagString(parsed, "tags");
|
|
3207
3234
|
const kind = flagString(parsed, "kind");
|
|
3208
3235
|
const sortOrder = flagNumber(parsed, "sort-order");
|
|
3236
|
+
const coinAmount = flagNumber(parsed, "coin-amount");
|
|
3237
|
+
if (coinAmount !== undefined &&
|
|
3238
|
+
(!Number.isInteger(coinAmount) || coinAmount < 1)) {
|
|
3239
|
+
throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
|
|
3240
|
+
}
|
|
3209
3241
|
const isStarterRaw = flagString(parsed, "is-starter");
|
|
3210
3242
|
const setupAudienceRaw = flagString(parsed, "setup-audience");
|
|
3211
3243
|
const isStarter = isStarterRaw === undefined
|
|
@@ -3222,6 +3254,10 @@ export async function runCommand(argv) {
|
|
|
3222
3254
|
...(flagString(parsed, "emoji")
|
|
3223
3255
|
? { emoji: flagString(parsed, "emoji") }
|
|
3224
3256
|
: {}),
|
|
3257
|
+
...(flagString(parsed, "image-url")
|
|
3258
|
+
? { imageUrl: flagString(parsed, "image-url") }
|
|
3259
|
+
: {}),
|
|
3260
|
+
...(coinAmount === undefined ? {} : { coinAmount }),
|
|
3225
3261
|
...(flagString(parsed, "category")
|
|
3226
3262
|
? { category: flagString(parsed, "category") }
|
|
3227
3263
|
: {}),
|
|
@@ -3259,7 +3295,7 @@ export async function runCommand(argv) {
|
|
|
3259
3295
|
// shape that caused the template incident; editing an existing spec goes
|
|
3260
3296
|
// through the guarded /ai patch path with its destructive-change token.
|
|
3261
3297
|
if (Object.keys(body).length === 1) {
|
|
3262
|
-
throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
|
|
3298
|
+
throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --image-url, --coin-amount, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
|
|
3263
3299
|
}
|
|
3264
3300
|
return writeCommand(parsed, {
|
|
3265
3301
|
action: "update goal template metadata (never its setupWorkflowSpec)",
|
|
@@ -3270,6 +3306,28 @@ export async function runCommand(argv) {
|
|
|
3270
3306
|
body,
|
|
3271
3307
|
})));
|
|
3272
3308
|
}
|
|
3309
|
+
if (verb === "generate-image") {
|
|
3310
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3311
|
+
const current = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
|
|
3312
|
+
params: { path: { id } },
|
|
3313
|
+
}));
|
|
3314
|
+
const prompt = flagString(parsed, "prompt");
|
|
3315
|
+
return writeCommand(parsed, {
|
|
3316
|
+
action: "queue paid GPT-Image-2 art regeneration",
|
|
3317
|
+
target: {
|
|
3318
|
+
templateId: id,
|
|
3319
|
+
slug: current.slug,
|
|
3320
|
+
title: current.title,
|
|
3321
|
+
},
|
|
3322
|
+
request: { prompt: prompt ?? null },
|
|
3323
|
+
details: {
|
|
3324
|
+
costNote: "This queues one paid 1024×1024 GPT-Image-2 generation and replaces the template image when it finishes. New templates already generate art automatically.",
|
|
3325
|
+
},
|
|
3326
|
+
}, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/generate-image", {
|
|
3327
|
+
params: { path: { id } },
|
|
3328
|
+
body: prompt ? { prompt } : {},
|
|
3329
|
+
})));
|
|
3330
|
+
}
|
|
3273
3331
|
if (verb === "delete") {
|
|
3274
3332
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3275
3333
|
const expectedVersion = requiredExpectedVersion(parsed);
|
|
@@ -3476,9 +3534,31 @@ export async function runCommand(argv) {
|
|
|
3476
3534
|
body: { studentUserId, answers },
|
|
3477
3535
|
})));
|
|
3478
3536
|
}
|
|
3479
|
-
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
|
|
3537
|
+
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|generate-image|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
|
|
3480
3538
|
}
|
|
3481
3539
|
if (noun === "goals" && verb !== "files" && verb !== "pdf") {
|
|
3540
|
+
if (verb === "complete" || verb === "undo-completion") {
|
|
3541
|
+
const goalId = positional(parsed, 2, "goal ID");
|
|
3542
|
+
const undo = verb === "undo-completion";
|
|
3543
|
+
return writeCommand(parsed, {
|
|
3544
|
+
action: undo
|
|
3545
|
+
? "undo goal completion and reverse its coin reward"
|
|
3546
|
+
: "complete a goal and grant its one-time coin reward",
|
|
3547
|
+
target: { goalId },
|
|
3548
|
+
request: { source: "STAFF" },
|
|
3549
|
+
details: undo
|
|
3550
|
+
? {
|
|
3551
|
+
note: "Undo is refused if the student has already spent enough coins that the reward cannot be reversed.",
|
|
3552
|
+
}
|
|
3553
|
+
: undefined,
|
|
3554
|
+
}, async () => unwrap(undo
|
|
3555
|
+
? await api.client.POST("/ai/goals/{goalId}/undo-completion", {
|
|
3556
|
+
params: { path: { goalId } },
|
|
3557
|
+
})
|
|
3558
|
+
: await api.client.POST("/ai/goals/{goalId}/complete", {
|
|
3559
|
+
params: { path: { goalId } },
|
|
3560
|
+
})));
|
|
3561
|
+
}
|
|
3482
3562
|
if (verb === "list") {
|
|
3483
3563
|
const userId = flagString(parsed, "student", { required: true });
|
|
3484
3564
|
return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
@@ -3789,7 +3869,7 @@ export async function runCommand(argv) {
|
|
|
3789
3869
|
}
|
|
3790
3870
|
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
3791
3871
|
}
|
|
3792
|
-
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|queue|files|pdf.");
|
|
3872
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|complete|undo-completion|queue|files|pdf.");
|
|
3793
3873
|
}
|
|
3794
3874
|
if (noun === "students") {
|
|
3795
3875
|
if (verb === "list") {
|
package/dist/command-schema.js
CHANGED
|
@@ -37,6 +37,17 @@ 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
|
+
}
|
|
40
51
|
export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
41
52
|
const noun = parsed.positionals[0] ?? "";
|
|
42
53
|
const verb = parsed.positionals[1] ?? "";
|
|
@@ -156,6 +167,26 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
|
156
167
|
throw error;
|
|
157
168
|
}
|
|
158
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
|
+
}
|
|
159
190
|
if (verb === "intake-session-create") {
|
|
160
191
|
const familyId = positional(parsed, 2, "family ID");
|
|
161
192
|
// The explicit create: get-or-create the intake session. Mints a blank
|
|
@@ -616,6 +647,149 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
|
616
647
|
request: previewRequest,
|
|
617
648
|
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-extract", { params: { path: { familyId } }, body })));
|
|
618
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
|
+
}
|
|
619
793
|
throw new CliError("invalid_arguments", "Unknown onboarding command. Run `recess onboarding --help` for the current command list.");
|
|
620
794
|
}
|
|
621
795
|
throw new CliError("invalid_arguments", "Unknown onboarding command.");
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { unwrap } from "../api.js";
|
|
2
|
+
import { flagList, flagString, hasFlag } from "../args.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { assertChoice, flagInteger, flagIsoInstant, positional, readJsonFile, } from "./shared.js";
|
|
5
|
+
const SCHOOL_TIERS = [
|
|
6
|
+
"social",
|
|
7
|
+
"academics",
|
|
8
|
+
"lite",
|
|
9
|
+
"complete",
|
|
10
|
+
"platform",
|
|
11
|
+
];
|
|
12
|
+
const RESOLVE_ACTIONS = ["waive", "cancel"];
|
|
13
|
+
const RECONCILIATION_OUTCOMES = ["refunded", "kept", "written_off"];
|
|
14
|
+
/** post.start-memberships.ts caps the body at ten kids per call. */
|
|
15
|
+
const MEMBERSHIP_KID_LIMIT = 10;
|
|
16
|
+
function dollars(cents) {
|
|
17
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
18
|
+
}
|
|
19
|
+
function kidName(firstName, lastName, fallback) {
|
|
20
|
+
return [firstName, lastName].filter(Boolean).join(" ") || fallback;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `"<kid-id>:<tier>[:<slots>[:<enrollment-cents>]]"` — the same
|
|
24
|
+
* colon-delimited per-kid spec `applications enroll --kid` uses. The 4th field
|
|
25
|
+
* is the child's own first-month charge (":0" = free); OMITTING it inherits the
|
|
26
|
+
* family-level default, and when neither is given the server refuses the whole
|
|
27
|
+
* conversion as PRICE_UNDECIDED rather than guessing a price.
|
|
28
|
+
*/
|
|
29
|
+
function parseKidTierSpec(spec) {
|
|
30
|
+
const [kidId, tier, slots, cents, ...rest] = spec.split(":");
|
|
31
|
+
if (!kidId || !tier || rest.length > 0) {
|
|
32
|
+
throw new CliError("invalid_arguments", `--kid "${spec}" must be "<kid-id>:<tier>[:<slots>[:<enrollment-cents>]]".`);
|
|
33
|
+
}
|
|
34
|
+
const number = (value, label) => {
|
|
35
|
+
const parsed = Number(value);
|
|
36
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
37
|
+
throw new CliError("invalid_arguments", `--kid "${spec}": ${label} must be a nonnegative integer.`);
|
|
38
|
+
}
|
|
39
|
+
return parsed;
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
kidId,
|
|
43
|
+
tierId: assertChoice(tier, SCHOOL_TIERS, `--kid "${spec}" tier`),
|
|
44
|
+
...(slots ? { slotsPerKid: number(slots, "slots") } : {}),
|
|
45
|
+
...(cents
|
|
46
|
+
? { enrollmentPaymentCents: number(cents, "enrollment cents") }
|
|
47
|
+
: {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export async function runSchoolCommand({ parsed, api, writeCommand, }) {
|
|
51
|
+
const verb = parsed.positionals[1] ?? "";
|
|
52
|
+
/**
|
|
53
|
+
* The school roster is the ONE read behind every command here: it carries the
|
|
54
|
+
* institution, each family's stage/account state and kids, the live
|
|
55
|
+
* enrollment payment, and the open reconciliation items. Every write below
|
|
56
|
+
* previews from this same response, so the approval names the same facts the
|
|
57
|
+
* dashboard shows rather than a second, differently-shaped guess.
|
|
58
|
+
*/
|
|
59
|
+
const roster = async (slug) => unwrap(await api.client.GET("/admin/partner/{slug}/families", {
|
|
60
|
+
params: { path: { slug } },
|
|
61
|
+
}));
|
|
62
|
+
const requireSlug = () => flagString(parsed, "school", { required: true });
|
|
63
|
+
if (verb === "families") {
|
|
64
|
+
return roster(requireSlug());
|
|
65
|
+
}
|
|
66
|
+
if (verb === "list") {
|
|
67
|
+
return unwrap(await api.client.GET("/admin/partner/"));
|
|
68
|
+
}
|
|
69
|
+
if (verb === "family-search") {
|
|
70
|
+
const search = flagString(parsed, "query", { required: true });
|
|
71
|
+
const limit = flagInteger(parsed, "limit", { min: 1, max: 100 });
|
|
72
|
+
return unwrap(await api.client.GET("/admin/partner/{slug}/family-search", {
|
|
73
|
+
params: {
|
|
74
|
+
path: { slug: requireSlug() },
|
|
75
|
+
query: { search, ...(limit !== undefined ? { limit } : {}) },
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
if (verb === "convert") {
|
|
80
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
81
|
+
const slug = requireSlug();
|
|
82
|
+
const kids = flagList(parsed, "kid").map(parseKidTierSpec);
|
|
83
|
+
if (kids.length === 0) {
|
|
84
|
+
throw new CliError("invalid_arguments", '--kid is required, once per kid: "<kid-id>:<tier>[:<slots>[:<enrollment-cents>]]". Every kid in the family must be assigned a tier, same rule as enrollment.');
|
|
85
|
+
}
|
|
86
|
+
const creditAmountCents = flagInteger(parsed, "credit-cents", {
|
|
87
|
+
min: 0,
|
|
88
|
+
max: 1_000_000,
|
|
89
|
+
});
|
|
90
|
+
const enrollmentPaymentCentsPerKid = flagInteger(parsed, "enrollment-payment-cents", { min: 0 });
|
|
91
|
+
const note = flagString(parsed, "note");
|
|
92
|
+
const body = {
|
|
93
|
+
familyId,
|
|
94
|
+
kids,
|
|
95
|
+
...(creditAmountCents !== undefined ? { creditAmountCents } : {}),
|
|
96
|
+
...(enrollmentPaymentCentsPerKid !== undefined
|
|
97
|
+
? { enrollmentPaymentCentsPerKid }
|
|
98
|
+
: {}),
|
|
99
|
+
...(note ? { note } : {}),
|
|
100
|
+
};
|
|
101
|
+
const unpriced = kids.filter((kid) => kid.enrollmentPaymentCents === undefined &&
|
|
102
|
+
enrollmentPaymentCentsPerKid === undefined);
|
|
103
|
+
if (unpriced.length > 0) {
|
|
104
|
+
throw new CliError("invalid_arguments", `No first-month price decided for ${unpriced
|
|
105
|
+
.map((kid) => kid.kidId)
|
|
106
|
+
.join(", ")}. Give each kid a 4th spec field (":0" for free) or pass --enrollment-payment-cents for the family; the server refuses an undecided price (PRICE_UNDECIDED).`);
|
|
107
|
+
}
|
|
108
|
+
// Read-only preflight on the family's own state — the roster cannot help
|
|
109
|
+
// here, because a family being converted INTO the program is not on it yet.
|
|
110
|
+
const status = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
|
|
111
|
+
params: { path: { familyId } },
|
|
112
|
+
}));
|
|
113
|
+
const familyKidIds = new Set(status.kids.map((kid) => kid.id));
|
|
114
|
+
const foreign = kids
|
|
115
|
+
.map((kid) => kid.kidId)
|
|
116
|
+
.filter((id) => !familyKidIds.has(id));
|
|
117
|
+
if (foreign.length > 0) {
|
|
118
|
+
throw new CliError("invalid_arguments", `Not kids in this family: ${foreign.join(", ")}.`);
|
|
119
|
+
}
|
|
120
|
+
const missing = status.kids.filter((kid) => !kids.some((k) => k.kidId === kid.id));
|
|
121
|
+
return writeCommand(parsed, {
|
|
122
|
+
action: "convert this marketplace family INTO the school program (links the institution, funds tokens, cancels live memberships)",
|
|
123
|
+
target: { familyId, slug, familyName: status.familyName },
|
|
124
|
+
request: body,
|
|
125
|
+
details: {
|
|
126
|
+
kids: kids.map((kid) => ({
|
|
127
|
+
kid: status.kids.find((row) => row.id === kid.kidId)?.firstName ??
|
|
128
|
+
kid.kidId,
|
|
129
|
+
tier: kid.tierId,
|
|
130
|
+
slots: kid.slotsPerKid ?? "the tier default",
|
|
131
|
+
firstMonth: kid.enrollmentPaymentCents === undefined
|
|
132
|
+
? enrollmentPaymentCentsPerKid === undefined
|
|
133
|
+
? "undecided"
|
|
134
|
+
: `${dollars(enrollmentPaymentCentsPerKid)} (family default)`
|
|
135
|
+
: dollars(kid.enrollmentPaymentCents),
|
|
136
|
+
})),
|
|
137
|
+
...(missing.length > 0
|
|
138
|
+
? {
|
|
139
|
+
unassignedKids: missing.map((kid) => kid.firstName ?? kid.id),
|
|
140
|
+
unassignedWarning: "These kids of the family were NOT given a tier here. They come out unassigned with 0 slots until an admin picks one.",
|
|
141
|
+
}
|
|
142
|
+
: {}),
|
|
143
|
+
consequences: [
|
|
144
|
+
"Cancels live membership subscriptions at period end and turns off membership credits.",
|
|
145
|
+
"Funds each kid top-up-to-target for the month; a lower --credit-cents is NOT topped back up until the next UTC month (it shares the cron's idempotency key).",
|
|
146
|
+
"Existing cohort-enrollment subscriptions are deliberately left running — they migrate to token rails at their next renewal.",
|
|
147
|
+
"Any nonzero total holds the family at PENDING_PAYMENT and gates them out of the parent app until they pay at /school/pay.",
|
|
148
|
+
"ADMIN-only (exact-admin): a GUIDE or PROGRAM session gets 403.",
|
|
149
|
+
],
|
|
150
|
+
reverse: `The inverse is \`school revert ${familyId} --school ${slug}\`, which is fenced off in production.`,
|
|
151
|
+
},
|
|
152
|
+
}, async () => unwrap(await api.client.POST("/admin/partner/{slug}/convert-family", {
|
|
153
|
+
params: { path: { slug } },
|
|
154
|
+
body,
|
|
155
|
+
})));
|
|
156
|
+
}
|
|
157
|
+
if (verb === "revert") {
|
|
158
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
159
|
+
const slug = requireSlug();
|
|
160
|
+
const revokeTokens = !hasFlag(parsed, "keep-tokens");
|
|
161
|
+
const note = flagString(parsed, "note");
|
|
162
|
+
const { institution, families } = await roster(slug);
|
|
163
|
+
const family = families.find((row) => row.id === familyId);
|
|
164
|
+
if (!family) {
|
|
165
|
+
throw new CliError("invalid_arguments", `Family ${familyId} is not in ${institution.name} (${slug}). Revert only applies to a family currently in that school program.`);
|
|
166
|
+
}
|
|
167
|
+
return writeCommand(parsed, {
|
|
168
|
+
action: "convert this family OUT of the school program and back to marketplace billing",
|
|
169
|
+
target: { familyId, slug, familyName: family.name },
|
|
170
|
+
request: { familyId, revokeTokens, ...(note ? { note } : {}) },
|
|
171
|
+
details: {
|
|
172
|
+
institution: institution.name,
|
|
173
|
+
accountState: family.accountState,
|
|
174
|
+
onboardingStage: family.onboardingStage,
|
|
175
|
+
kids: family.kids.map((kid) => ({
|
|
176
|
+
id: kid.id,
|
|
177
|
+
name: kidName(kid.firstName, kid.lastName, kid.id),
|
|
178
|
+
creditBalance: kid.creditBalance,
|
|
179
|
+
})),
|
|
180
|
+
revokeTokens,
|
|
181
|
+
liveEnrollmentPayment: family.liveEnrollmentPayment
|
|
182
|
+
? {
|
|
183
|
+
id: family.liveEnrollmentPayment.id,
|
|
184
|
+
status: family.liveEnrollmentPayment.status,
|
|
185
|
+
amountDue: dollars(family.liveEnrollmentPayment.amountDueCents),
|
|
186
|
+
}
|
|
187
|
+
: null,
|
|
188
|
+
consequences: [
|
|
189
|
+
revokeTokens
|
|
190
|
+
? "Revokes each kid's remaining school token balance (shown above)."
|
|
191
|
+
: "LEAVES each kid's school token balance in place (--keep-tokens).",
|
|
192
|
+
"Cancels the family's program membership; kids come out with NO membership. The follow-up is `school start-memberships`, which charges their card.",
|
|
193
|
+
"Class registrations keep running on purpose — they start billing the family's own card at renewal.",
|
|
194
|
+
"ADMIN-only (exact-admin): a GUIDE or PROGRAM session gets 403.",
|
|
195
|
+
],
|
|
196
|
+
fence: "Gated on SCHOOL_REVERT_ENABLED, which is fail-closed and unset in every deployed environment — production refuses until settlement ships. A 409 here is that door, not a bad request.",
|
|
197
|
+
retry: "A 409 `revert_in_flight` means an earlier attempt is still settling; rerun the unchanged command with the SAME operation key rather than minting a new one.",
|
|
198
|
+
},
|
|
199
|
+
}, async () => unwrap(await api.client.POST("/admin/partner/{slug}/revert-family", {
|
|
200
|
+
params: { path: { slug } },
|
|
201
|
+
// Announce that this client reads the two-phase union; without the
|
|
202
|
+
// header the server sends the pre-union façade, which reports a
|
|
203
|
+
// still-settling revert as finished (revert-wire.ts).
|
|
204
|
+
headers: { "x-recess-revert-union": "1" },
|
|
205
|
+
body: { familyId, revokeTokens, ...(note ? { note } : {}) },
|
|
206
|
+
})));
|
|
207
|
+
}
|
|
208
|
+
if (verb === "start-memberships") {
|
|
209
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
210
|
+
// --kid is REPEATABLE: read every occurrence, not just the last one.
|
|
211
|
+
const kidIds = flagList(parsed, "kid");
|
|
212
|
+
if (kidIds.length === 0) {
|
|
213
|
+
throw new CliError("invalid_arguments", "--kid is required: name each kid whose membership should start.");
|
|
214
|
+
}
|
|
215
|
+
if (kidIds.length > MEMBERSHIP_KID_LIMIT) {
|
|
216
|
+
throw new CliError("invalid_arguments", `--kid accepts at most ${MEMBERSHIP_KID_LIMIT} kids per call.`);
|
|
217
|
+
}
|
|
218
|
+
// Family-scoped, not slug-scoped: by the time this runs the family is no
|
|
219
|
+
// longer linked to a partner institution, so the roster read above cannot
|
|
220
|
+
// see it. The onboarding status is the read that still resolves.
|
|
221
|
+
const status = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
|
|
222
|
+
params: { path: { familyId } },
|
|
223
|
+
}));
|
|
224
|
+
const known = new Map(status.kids.map((kid) => [kid.id, kid]));
|
|
225
|
+
const unknown = kidIds.filter((id) => !known.has(id));
|
|
226
|
+
if (unknown.length > 0) {
|
|
227
|
+
throw new CliError("invalid_arguments", `Not kids in this family: ${unknown.join(", ")}. The server refuses the whole call when one id is foreign.`);
|
|
228
|
+
}
|
|
229
|
+
return writeCommand(parsed, {
|
|
230
|
+
action: "start a paid membership subscription for these kids, CHARGING the family's card on file",
|
|
231
|
+
target: { familyId, familyName: status.familyName },
|
|
232
|
+
request: { familyId, kidIds },
|
|
233
|
+
details: {
|
|
234
|
+
kids: kidIds.map((id) => kidName(known.get(id)?.firstName ?? null, null, id)),
|
|
235
|
+
billing: "Default membership price unless the family is grandfathered onto one, with a 1-day trial, confirmed against the customer's saved default payment method.",
|
|
236
|
+
note: "A kid whose membership is already ACTIVE comes back `skipped_active` rather than being charged twice; per-kid failures are reported in `results`, not thrown.",
|
|
237
|
+
},
|
|
238
|
+
}, async () => unwrap(await api.client.POST("/admin/memberships/start", {
|
|
239
|
+
body: { familyId, kidIds },
|
|
240
|
+
})));
|
|
241
|
+
}
|
|
242
|
+
if (verb === "resolve-payment") {
|
|
243
|
+
const paymentId = positional(parsed, 2, "enrollment payment ID");
|
|
244
|
+
const slug = requireSlug();
|
|
245
|
+
const action = assertChoice(flagString(parsed, "action", { required: true }), RESOLVE_ACTIONS, "--action");
|
|
246
|
+
const note = flagString(parsed, "note");
|
|
247
|
+
const { institution, families } = await roster(slug);
|
|
248
|
+
const family = families.find((row) => row.liveEnrollmentPayment?.id === paymentId);
|
|
249
|
+
const payment = family?.liveEnrollmentPayment;
|
|
250
|
+
if (!family || !payment) {
|
|
251
|
+
throw new CliError("invalid_arguments", `No live enrollment payment ${paymentId} under ${institution.name} (${slug}). Run \`school families --school ${slug}\` and read \`liveEnrollmentPayment\`; a payment already resolved is not resolvable again.`);
|
|
252
|
+
}
|
|
253
|
+
return writeCommand(parsed, {
|
|
254
|
+
action: action === "waive"
|
|
255
|
+
? "WAIVE this enrollment charge — record it as collected off-platform and lift the paywall"
|
|
256
|
+
: "CANCEL this enrollment charge — drop the requirement entirely and lift the paywall",
|
|
257
|
+
target: {
|
|
258
|
+
paymentId,
|
|
259
|
+
slug,
|
|
260
|
+
familyId: family.id,
|
|
261
|
+
familyName: family.name,
|
|
262
|
+
},
|
|
263
|
+
request: { action, ...(note ? { note } : {}) },
|
|
264
|
+
details: {
|
|
265
|
+
institution: institution.name,
|
|
266
|
+
status: payment.status,
|
|
267
|
+
amountDue: dollars(payment.amountDueCents),
|
|
268
|
+
lines: payment.lines.map((line) => ({
|
|
269
|
+
kid: line.firstName ?? line.kidId ?? "unassigned",
|
|
270
|
+
amount: dollars(line.amountCents),
|
|
271
|
+
})),
|
|
272
|
+
stripeInvoiceId: payment.stripeInvoiceId,
|
|
273
|
+
effect: "The family stops being redirected to /school/pay and class registration reopens — but ONLY if no other PENDING enrollment payment remains on the family.",
|
|
274
|
+
accountState: "PENDING_PAYMENT is walked back; a family separately set to PAUSED or BOOTED keeps that state.",
|
|
275
|
+
...(action === "cancel"
|
|
276
|
+
? {
|
|
277
|
+
cancelCaveat: "If a charge already landed, compensation is unproven and the wire status comes back CANCEL_REQUESTED — the row is still live and still blocking. That is not a failed call.",
|
|
278
|
+
}
|
|
279
|
+
: {}),
|
|
280
|
+
},
|
|
281
|
+
}, async () => unwrap(await api.client.POST("/admin/partner/{slug}/enrollment-payments/{paymentId}/resolve", {
|
|
282
|
+
params: { path: { slug, paymentId } },
|
|
283
|
+
body: { action, ...(note ? { note } : {}) },
|
|
284
|
+
})));
|
|
285
|
+
}
|
|
286
|
+
if (verb === "close-reconciliation") {
|
|
287
|
+
const paymentId = positional(parsed, 2, "enrollment payment ID");
|
|
288
|
+
const slug = requireSlug();
|
|
289
|
+
const outcome = assertChoice(flagString(parsed, "outcome", { required: true }), RECONCILIATION_OUTCOMES, "--outcome");
|
|
290
|
+
const note = flagString(parsed, "note");
|
|
291
|
+
const { institution, families } = await roster(slug);
|
|
292
|
+
const family = families.find((row) => row.reconciliationItems.some((item) => item.paymentId === paymentId));
|
|
293
|
+
const item = family?.reconciliationItems.find((row) => row.paymentId === paymentId);
|
|
294
|
+
if (!family || !item) {
|
|
295
|
+
throw new CliError("invalid_arguments", `No reconciliation item for payment ${paymentId} under ${institution.name} (${slug}). Run \`school families --school ${slug}\` and read \`reconciliationItems\`.`);
|
|
296
|
+
}
|
|
297
|
+
if (item.resolution) {
|
|
298
|
+
throw new CliError("invalid_arguments", `That reconciliation item was already closed as ${item.resolution.outcome} at ${item.resolution.resolvedAt}. The close is conditional and irreversible — a second one 409s.`);
|
|
299
|
+
}
|
|
300
|
+
return writeCommand(parsed, {
|
|
301
|
+
action: `close this reconciliation item as ${outcome} (a money decision, and irreversible)`,
|
|
302
|
+
target: {
|
|
303
|
+
paymentId,
|
|
304
|
+
slug,
|
|
305
|
+
familyId: family.id,
|
|
306
|
+
familyName: family.name,
|
|
307
|
+
},
|
|
308
|
+
request: { outcome, ...(note ? { note } : {}) },
|
|
309
|
+
details: {
|
|
310
|
+
institution: institution.name,
|
|
311
|
+
amountPaid: dollars(item.amountPaidCents),
|
|
312
|
+
openedBecause: item.reason,
|
|
313
|
+
openedAt: item.openedAt,
|
|
314
|
+
paymentStatus: item.status,
|
|
315
|
+
invoiceId: item.invoiceId,
|
|
316
|
+
meaning: {
|
|
317
|
+
refunded: "the money was returned to the family",
|
|
318
|
+
kept: "the family genuinely owed it, so the charge stands",
|
|
319
|
+
written_off: "neither — the business absorbed it",
|
|
320
|
+
}[outcome],
|
|
321
|
+
scope: "Bookkeeping only: this annotates the item, never deletes it, and does NOT settle a debt, unblock a family, or move the payment's status.",
|
|
322
|
+
irreversible: "The write is conditional on the item still being open, so every later attempt 409s and the item drops out of triage.",
|
|
323
|
+
},
|
|
324
|
+
}, async () => unwrap(await api.client.POST("/admin/partner/{slug}/enrollment-payments/{paymentId}/close-reconciliation", {
|
|
325
|
+
params: { path: { slug, paymentId } },
|
|
326
|
+
body: { outcome, ...(note ? { note } : {}) },
|
|
327
|
+
})));
|
|
328
|
+
}
|
|
329
|
+
if (verb === "codes") {
|
|
330
|
+
const subverb = parsed.positionals[2] ?? "";
|
|
331
|
+
const slug = requireSlug();
|
|
332
|
+
const codeId = () => positional(parsed, 3, "invite code ID");
|
|
333
|
+
if (subverb === "list") {
|
|
334
|
+
return unwrap(await api.client.GET("/admin/partner/{slug}/codes", {
|
|
335
|
+
params: { path: { slug } },
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
if (subverb === "get") {
|
|
339
|
+
return unwrap(await api.client.GET("/admin/partner/{slug}/codes/{id}", {
|
|
340
|
+
params: { path: { slug, id: codeId() } },
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
if (subverb === "create") {
|
|
344
|
+
const count = flagInteger(parsed, "count", { min: 1 });
|
|
345
|
+
const expiresAt = flagIsoInstant(parsed, "expires-at");
|
|
346
|
+
const note = flagString(parsed, "note");
|
|
347
|
+
const creditAmountCents = flagInteger(parsed, "credit-cents", { min: 0 });
|
|
348
|
+
const tierRaw = flagString(parsed, "tier");
|
|
349
|
+
const slotsPerKid = flagInteger(parsed, "slots", { min: 0, max: 20 });
|
|
350
|
+
const enrollmentPaymentCentsPerKid = flagInteger(parsed, "enrollment-payment-cents", { min: 0 });
|
|
351
|
+
const dataFile = flagString(parsed, "data-file");
|
|
352
|
+
// The preconfigured-invite `family` block (parent identity + the kid
|
|
353
|
+
// roster with per-kid tier and price) is a nested object, so it arrives
|
|
354
|
+
// as JSON the way `quotes create` takes its body. Without it this mints
|
|
355
|
+
// blank codes; with it, one addressed invite that emails the parent.
|
|
356
|
+
const family = dataFile
|
|
357
|
+
? (await readJsonFile(dataFile, "Preconfigured invite file"))
|
|
358
|
+
: undefined;
|
|
359
|
+
const body = {
|
|
360
|
+
...(count !== undefined ? { count } : {}),
|
|
361
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
362
|
+
...(note ? { note } : {}),
|
|
363
|
+
...(creditAmountCents !== undefined ? { creditAmountCents } : {}),
|
|
364
|
+
...(tierRaw
|
|
365
|
+
? { tierId: assertChoice(tierRaw, SCHOOL_TIERS, "--tier") }
|
|
366
|
+
: {}),
|
|
367
|
+
...(slotsPerKid !== undefined ? { slotsPerKid } : {}),
|
|
368
|
+
...(enrollmentPaymentCentsPerKid !== undefined
|
|
369
|
+
? { enrollmentPaymentCentsPerKid }
|
|
370
|
+
: {}),
|
|
371
|
+
...(family ? { family } : {}),
|
|
372
|
+
};
|
|
373
|
+
return writeCommand(parsed, {
|
|
374
|
+
action: family
|
|
375
|
+
? "mint a preconfigured school invite for this family and EMAIL the parent their claim link"
|
|
376
|
+
: `mint ${count ?? 1} blank school invite code(s)`,
|
|
377
|
+
target: { slug },
|
|
378
|
+
request: body,
|
|
379
|
+
details: {
|
|
380
|
+
expiresAt: expiresAt ?? "the institution default",
|
|
381
|
+
firstMonthPerKid: enrollmentPaymentCentsPerKid === undefined
|
|
382
|
+
? "not set at the family level — each kid in --data-file must carry its own price"
|
|
383
|
+
: dollars(enrollmentPaymentCentsPerKid),
|
|
384
|
+
...(family
|
|
385
|
+
? {
|
|
386
|
+
duplicateGuard: "The create 409s when the parent email already has a Recess account or another live invite. That is the guard, not a transient failure.",
|
|
387
|
+
}
|
|
388
|
+
: {
|
|
389
|
+
blank: "No --data-file, so these are blank codes: nobody is emailed and no roster is preconfigured.",
|
|
390
|
+
}),
|
|
391
|
+
},
|
|
392
|
+
}, async () => unwrap(await api.client.POST("/admin/partner/{slug}/codes", {
|
|
393
|
+
params: { path: { slug } },
|
|
394
|
+
body,
|
|
395
|
+
})));
|
|
396
|
+
}
|
|
397
|
+
if (subverb === "set-kids") {
|
|
398
|
+
const id = codeId();
|
|
399
|
+
const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Invite roster file"));
|
|
400
|
+
return writeCommand(parsed, {
|
|
401
|
+
action: "edit an UNREDEEMED invite's roster in place — the parent's existing link keeps working",
|
|
402
|
+
target: { slug, codeId: id },
|
|
403
|
+
request: body,
|
|
404
|
+
details: {
|
|
405
|
+
semantics: "The body is the FULL roster, not a diff: a student absent from the list is removed. `enrollmentPaymentCents` is required per student (null or 0 = that student enrolls free) so the paywall cannot be dropped by omission.",
|
|
406
|
+
outOfScope: "Parent identity, note, credit and expiry are not editable here — changing WHO the invite is for is `codes replace`.",
|
|
407
|
+
refusal: "Editing after redemption is refused outright; the accounts and the charge already exist.",
|
|
408
|
+
},
|
|
409
|
+
}, async () => unwrap(await api.client.PATCH("/admin/partner/{slug}/codes/{id}", {
|
|
410
|
+
params: { path: { slug, id } },
|
|
411
|
+
body,
|
|
412
|
+
})));
|
|
413
|
+
}
|
|
414
|
+
if (subverb === "replace") {
|
|
415
|
+
const id = codeId();
|
|
416
|
+
const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Replacement invite file"));
|
|
417
|
+
return writeCommand(parsed, {
|
|
418
|
+
action: "REPLACE this unredeemed invite: delete the old code and mint a new one, emailing the parent a fresh link",
|
|
419
|
+
target: { slug, codeId: id },
|
|
420
|
+
request: body,
|
|
421
|
+
details: {
|
|
422
|
+
atomicity: "Delete and create run in ONE transaction, so a failure rolls back and the OLD invite survives — a failed replace is plainly retryable and never leaves the family with no invite.",
|
|
423
|
+
whenToUse: "Use replace when the parent's identity or email changes; use `codes set-kids` to edit the roster without invalidating the link the parent already holds.",
|
|
424
|
+
inherits: "Fields omitted from the body (note, credit) inherit from the old row read inside the transaction.",
|
|
425
|
+
},
|
|
426
|
+
}, async () => unwrap(await api.client.POST("/admin/partner/{slug}/codes/{id}/replace", {
|
|
427
|
+
params: { path: { slug, id } },
|
|
428
|
+
body,
|
|
429
|
+
})));
|
|
430
|
+
}
|
|
431
|
+
if (subverb === "resend") {
|
|
432
|
+
const id = codeId();
|
|
433
|
+
return writeCommand(parsed, {
|
|
434
|
+
action: "re-email this invite's claim link to the parent on file",
|
|
435
|
+
target: { slug, codeId: id },
|
|
436
|
+
request: {},
|
|
437
|
+
details: {
|
|
438
|
+
outwardEmail: "This sends mail to a real family. The code itself is unchanged — use `codes replace` to mint a new one.",
|
|
439
|
+
},
|
|
440
|
+
}, async () => unwrap(await api.client.POST("/admin/partner/{slug}/codes/{id}/resend", {
|
|
441
|
+
params: { path: { slug, id } },
|
|
442
|
+
})));
|
|
443
|
+
}
|
|
444
|
+
if (subverb === "revoke") {
|
|
445
|
+
const id = codeId();
|
|
446
|
+
const existing = unwrap(await api.client.GET("/admin/partner/{slug}/codes/{id}", {
|
|
447
|
+
params: { path: { slug, id } },
|
|
448
|
+
}));
|
|
449
|
+
return writeCommand(parsed, {
|
|
450
|
+
action: "HARD-DELETE this unredeemed invite code",
|
|
451
|
+
target: { slug, codeId: id },
|
|
452
|
+
request: {},
|
|
453
|
+
details: {
|
|
454
|
+
code: existing,
|
|
455
|
+
irreversible: "The row is REMOVED, not soft-deleted: there is no deletedAt on TalentSchoolInviteCode and no undo. The parent's link stops working immediately.",
|
|
456
|
+
},
|
|
457
|
+
}, async () => unwrap(await api.client.DELETE("/admin/partner/{slug}/codes/{id}", {
|
|
458
|
+
params: { path: { slug, id } },
|
|
459
|
+
})));
|
|
460
|
+
}
|
|
461
|
+
throw new CliError("invalid_arguments", "Use school codes list|get|create|set-kids|replace|resend|revoke.");
|
|
462
|
+
}
|
|
463
|
+
throw new CliError("invalid_arguments", "Unknown school command. Run `recess school --help` for the current command list.");
|
|
464
|
+
}
|
|
465
|
+
//# sourceMappingURL=school.js.map
|
package/dist/help.js
CHANGED
|
@@ -142,6 +142,8 @@ Usage:
|
|
|
142
142
|
recess [--json] onboarding orientation-sessions
|
|
143
143
|
recess [--json] onboarding ixl-preview <family-id> --kid <kid-id>
|
|
144
144
|
recess [--json] onboarding intake-session <family-id>
|
|
145
|
+
recess [--json] onboarding reviews <family-id>
|
|
146
|
+
recess [--json] onboarding review <session-id>
|
|
145
147
|
recess [--json] onboarding intake-session-create <family-id> [--confirm]
|
|
146
148
|
recess [--json] onboarding set-stage <family-id>
|
|
147
149
|
--stage LEGACY|PROVISIONED|PARENT_CONFIRMED|CLEARED_FOR_COHORT|COMPLETE [--confirm]
|
|
@@ -165,6 +167,9 @@ Usage:
|
|
|
165
167
|
--kid <kid-id> [--credentials-file <credentials.json>] [--confirm]
|
|
166
168
|
recess [--json] onboarding remove-ixl <family-id> --kid <kid-id> [--confirm]
|
|
167
169
|
recess [--json] onboarding ixl-sync [--apply] [--confirm]
|
|
170
|
+
recess [--json] onboarding contracts [--kid <kid-id>]
|
|
171
|
+
recess [--json] onboarding send-contract <kid-id> [--tuition-cents N]
|
|
172
|
+
[--parent-email <email>] [--partner-name TEXT] [--partner-email <email>] [--confirm]
|
|
168
173
|
recess [--json] onboarding send-comms --subject <family:id|invite:id>
|
|
169
174
|
--kind <kind> [--confirm]
|
|
170
175
|
recess [--json] onboarding send-welcome <family-id> [--resend] [--confirm]
|
|
@@ -189,6 +194,35 @@ Usage:
|
|
|
189
194
|
--data <json> [--expected-updated-at <iso>] [--confirm]
|
|
190
195
|
recess [--json] onboarding extract <family-id> --session <id>
|
|
191
196
|
(--transcript-file <path> | --granola <ref>) [--confirm]
|
|
197
|
+
recess [--json] onboarding mark-reviewed <session-id> [--confirm]
|
|
198
|
+
recess [--json] onboarding pairing-code <kid-id> [--confirm]
|
|
199
|
+
recess [--json] school list
|
|
200
|
+
recess [--json] school families --school <institution-slug>
|
|
201
|
+
recess [--json] school family-search --school <institution-slug> --query TEXT
|
|
202
|
+
[--limit N]
|
|
203
|
+
recess [--json] school convert <family-id> --school <institution-slug>
|
|
204
|
+
--kid "<kid-id>:<tier>[:<slots>[:<enrollment-cents>]]" [--kid "..."]
|
|
205
|
+
[--credit-cents N] [--enrollment-payment-cents N] [--note TEXT] [--confirm]
|
|
206
|
+
recess [--json] school revert <family-id> --school <institution-slug>
|
|
207
|
+
[--keep-tokens] [--note TEXT] [--confirm]
|
|
208
|
+
recess [--json] school start-memberships <family-id> --kid <kid-id> [--kid <kid-id>]
|
|
209
|
+
[--confirm]
|
|
210
|
+
recess [--json] school resolve-payment <payment-id> --school <institution-slug>
|
|
211
|
+
--action waive|cancel [--note TEXT] [--confirm]
|
|
212
|
+
recess [--json] school close-reconciliation <payment-id> --school <institution-slug>
|
|
213
|
+
--outcome refunded|kept|written_off [--note TEXT] [--confirm]
|
|
214
|
+
recess [--json] school codes list --school <institution-slug>
|
|
215
|
+
recess [--json] school codes get <code-id> --school <institution-slug>
|
|
216
|
+
recess [--json] school codes create --school <institution-slug> [--count N]
|
|
217
|
+
[--expires-at <iso>] [--note TEXT] [--credit-cents N]
|
|
218
|
+
[--tier social|academics|lite|complete|platform] [--slots N]
|
|
219
|
+
[--enrollment-payment-cents N] [--data-file <preconfigured-family.json>] [--confirm]
|
|
220
|
+
recess [--json] school codes set-kids <code-id> --school <institution-slug>
|
|
221
|
+
--data-file <roster.json> [--confirm]
|
|
222
|
+
recess [--json] school codes replace <code-id> --school <institution-slug>
|
|
223
|
+
--data-file <invite.json> [--confirm]
|
|
224
|
+
recess [--json] school codes resend <code-id> --school <institution-slug> [--confirm]
|
|
225
|
+
recess [--json] school codes revoke <code-id> --school <institution-slug> [--confirm]
|
|
192
226
|
recess [--json] village models list [--world village-1] [--query TEXT] [--archived]
|
|
193
227
|
recess [--json] village models upload --file </path/model.glb>
|
|
194
228
|
[--world village-1] [--name TEXT] [--id ID] [--description TEXT] [--tags A,B]
|
|
@@ -240,17 +274,20 @@ Usage:
|
|
|
240
274
|
recess [--json] goal-templates get <template-id|slug> [--spec-only]
|
|
241
275
|
recess [--json] goal-templates versions <template-id> [--version N]
|
|
242
276
|
recess [--json] goal-templates validate-spec --file <path/template.json>
|
|
243
|
-
recess [--json] goal-templates create --file <path/template.json>
|
|
277
|
+
recess [--json] goal-templates create --file <path/template.json>
|
|
278
|
+
[--coin-amount N] [--confirm]
|
|
244
279
|
recess [--json] goal-templates patch-spec <template-id|slug> --expected-version N
|
|
245
280
|
--patches-file <path/patches.json> [--confirm --approval-token TOKEN]
|
|
246
281
|
[--confirm-destructive-changes --destructive-change-token TOKEN]
|
|
247
282
|
recess [--json] goal-templates set-metadata <template-id> --expected-version N
|
|
248
283
|
[--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
|
|
249
|
-
[--sort-order N] [--is-starter true|false]
|
|
284
|
+
[--image-url URL] [--coin-amount N] [--sort-order N] [--is-starter true|false]
|
|
250
285
|
[--setup-audience KID_FRIENDLY|PARENT_SETUP] [--kind SIMPLE|BLUEPRINT]
|
|
251
286
|
[--agent-instructions-file <path>]
|
|
252
287
|
[--output-template-file <path>] [--confirm]
|
|
253
288
|
recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
|
|
289
|
+
recess [--json] goal-templates generate-image <template-id|slug>
|
|
290
|
+
[--prompt TEXT] [--confirm]
|
|
254
291
|
recess [--json] goal-templates snapshot-files <template-id> [--path P]
|
|
255
292
|
recess [--json] goal-templates capture-snapshot <template-id|slug>
|
|
256
293
|
(--source-goal <goal-id> | --source-draft <draft-slug> --student <goal-owner-id>
|
|
@@ -270,6 +307,8 @@ Usage:
|
|
|
270
307
|
--delta TEXT [--confirm --approval-token TOKEN]
|
|
271
308
|
recess [--json] goals delete <goal-id> --student <kid-id>
|
|
272
309
|
[--confirm --approval-token TOKEN]
|
|
310
|
+
recess [--json] goals complete <goal-id> [--confirm]
|
|
311
|
+
recess [--json] goals undo-completion <goal-id> [--confirm]
|
|
273
312
|
recess [--json] goals queue get <goal-id> --student <kid-id>
|
|
274
313
|
recess [--json] goals queue set <goal-id> --student <kid-id>
|
|
275
314
|
--entries-file <path.json> --delta TEXT [--replace-description-pointer]
|