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,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
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { flagNumber, flagString } from "../args.js";
|
|
4
|
+
import { CliError } from "../errors.js";
|
|
5
|
+
export function positional(parsed, index, label) {
|
|
6
|
+
const value = parsed.positionals[index];
|
|
7
|
+
if (!value) {
|
|
8
|
+
throw new CliError("invalid_arguments", `Missing ${label}.`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
export function assertChoice(value, choices, label) {
|
|
13
|
+
if (!choices.includes(value)) {
|
|
14
|
+
throw new CliError("invalid_arguments", `${label} must be one of: ${choices.join(", ")}.`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
export function flagIdList(parsed, name) {
|
|
19
|
+
const raw = flagString(parsed, name);
|
|
20
|
+
if (raw === undefined)
|
|
21
|
+
return [];
|
|
22
|
+
const ids = raw
|
|
23
|
+
.split(",")
|
|
24
|
+
.map((value) => value.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
if (ids.length === 0) {
|
|
27
|
+
throw new CliError("invalid_arguments", `--${name} requires a value.`);
|
|
28
|
+
}
|
|
29
|
+
return ids;
|
|
30
|
+
}
|
|
31
|
+
export function flagBooleanValue(parsed, name) {
|
|
32
|
+
const raw = flagString(parsed, name);
|
|
33
|
+
if (raw === undefined)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (raw === "true")
|
|
36
|
+
return true;
|
|
37
|
+
if (raw === "false")
|
|
38
|
+
return false;
|
|
39
|
+
throw new CliError("invalid_arguments", `--${name} must be true or false.`);
|
|
40
|
+
}
|
|
41
|
+
export function flagInteger(parsed, name, options = {}) {
|
|
42
|
+
const value = flagNumber(parsed, name);
|
|
43
|
+
if (value === undefined) {
|
|
44
|
+
if (options.required) {
|
|
45
|
+
throw new CliError("invalid_arguments", `Missing required --${name}.`);
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (!Number.isInteger(value) ||
|
|
50
|
+
(options.min !== undefined && value < options.min) ||
|
|
51
|
+
(options.max !== undefined && value > options.max)) {
|
|
52
|
+
const bounds = options.min !== undefined && options.max !== undefined
|
|
53
|
+
? ` from ${options.min} through ${options.max}`
|
|
54
|
+
: options.min !== undefined
|
|
55
|
+
? ` of at least ${options.min}`
|
|
56
|
+
: options.max !== undefined
|
|
57
|
+
? ` no greater than ${options.max}`
|
|
58
|
+
: "";
|
|
59
|
+
throw new CliError("invalid_arguments", `--${name} must be an integer${bounds}.`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
export function flagIsoInstant(parsed, name, options = {}) {
|
|
64
|
+
const raw = flagString(parsed, name, options);
|
|
65
|
+
if (raw === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(raw) || Number.isNaN(Date.parse(raw))) {
|
|
68
|
+
throw new CliError("invalid_arguments", `--${name} must be a full ISO-8601 datetime with a timezone.`);
|
|
69
|
+
}
|
|
70
|
+
return raw;
|
|
71
|
+
}
|
|
72
|
+
export async function readJsonValue(filePath, label) {
|
|
73
|
+
const absolutePath = path.resolve(filePath);
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = await fs.readFile(absolutePath, "utf8");
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code === "ENOENT") {
|
|
80
|
+
throw new CliError("invalid_arguments", `${label} does not exist: ${absolutePath}`);
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(raw);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
throw new CliError("invalid_arguments", `${label} is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
|
|
90
|
+
}
|
|
91
|
+
return { absolutePath, raw, parsed };
|
|
92
|
+
}
|
|
93
|
+
export async function readJsonFile(filePath, label) {
|
|
94
|
+
const { absolutePath, parsed } = await readJsonValue(filePath, label);
|
|
95
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
96
|
+
throw new CliError("invalid_arguments", `${label} must be a JSON object (${absolutePath}).`);
|
|
97
|
+
}
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=shared.js.map
|