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.
@@ -0,0 +1,727 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { unwrap } from "../api.js";
5
+ import { flagList, flagString, hasFlag } from "../args.js";
6
+ import { CliError } from "../errors.js";
7
+ import { assertChoice, flagInteger, flagIsoInstant, positional, readJsonFile, } from "./shared.js";
8
+ const SCHOOL_TIERS = [
9
+ "social",
10
+ "academics",
11
+ "lite",
12
+ "complete",
13
+ "platform",
14
+ ];
15
+ const PROGRAM_TYPES = ["PARTNER", "SCHOOL"];
16
+ const RESOLVE_ACTIONS = ["waive", "cancel"];
17
+ const RECONCILIATION_OUTCOMES = ["refunded", "kept", "written_off"];
18
+ /** post.start-memberships.ts caps the body at ten kids per call. */
19
+ const MEMBERSHIP_KID_LIMIT = 10;
20
+ function dollars(cents) {
21
+ return `$${(cents / 100).toFixed(2)}`;
22
+ }
23
+ /** The exact type set post.partner-logo-upload.ts accepts, keyed by extension. */
24
+ const LOGO_MIME_BY_EXT = {
25
+ ".jpg": "image/jpeg",
26
+ ".jpeg": "image/jpeg",
27
+ ".png": "image/png",
28
+ ".gif": "image/gif",
29
+ ".webp": "image/webp",
30
+ ".svg": "image/svg+xml",
31
+ };
32
+ async function readLogoFile(filePath) {
33
+ const absolutePath = path.resolve(filePath);
34
+ const mimeType = LOGO_MIME_BY_EXT[path.extname(absolutePath).toLowerCase()];
35
+ if (!mimeType) {
36
+ throw new CliError("invalid_arguments", "--file must be a .png, .jpg, .jpeg, .gif, .webp, or .svg image.");
37
+ }
38
+ let bytes;
39
+ try {
40
+ bytes = await fs.readFile(absolutePath);
41
+ }
42
+ catch (error) {
43
+ if (error.code === "ENOENT") {
44
+ throw new CliError("invalid_arguments", `Logo file does not exist: ${absolutePath}`);
45
+ }
46
+ throw error;
47
+ }
48
+ return {
49
+ bytes,
50
+ fileName: path.basename(absolutePath),
51
+ mimeType,
52
+ sha256: createHash("sha256").update(bytes).digest("hex"),
53
+ };
54
+ }
55
+ function kidName(firstName, lastName, fallback) {
56
+ return [firstName, lastName].filter(Boolean).join(" ") || fallback;
57
+ }
58
+ /**
59
+ * `"<kid-id>:<tier>[:<slots>[:<enrollment-cents>]]"` — the same
60
+ * colon-delimited per-kid spec `applications enroll --kid` uses. The 4th field
61
+ * is the child's own first-month charge (":0" = free); OMITTING it inherits the
62
+ * family-level default, and when neither is given the server refuses the whole
63
+ * conversion as PRICE_UNDECIDED rather than guessing a price.
64
+ */
65
+ function parseKidTierSpec(spec) {
66
+ const [kidId, tier, slots, cents, ...rest] = spec.split(":");
67
+ if (!kidId || !tier || rest.length > 0) {
68
+ throw new CliError("invalid_arguments", `--kid "${spec}" must be "<kid-id>:<tier>[:<slots>[:<enrollment-cents>]]".`);
69
+ }
70
+ const number = (value, label) => {
71
+ const parsed = Number(value);
72
+ if (!Number.isInteger(parsed) || parsed < 0) {
73
+ throw new CliError("invalid_arguments", `--kid "${spec}": ${label} must be a nonnegative integer.`);
74
+ }
75
+ return parsed;
76
+ };
77
+ return {
78
+ kidId,
79
+ tierId: assertChoice(tier, SCHOOL_TIERS, `--kid "${spec}" tier`),
80
+ ...(slots ? { slotsPerKid: number(slots, "slots") } : {}),
81
+ ...(cents
82
+ ? { enrollmentPaymentCents: number(cents, "enrollment cents") }
83
+ : {}),
84
+ };
85
+ }
86
+ export async function runSchoolCommand({ parsed, api, writeCommand, }) {
87
+ const verb = parsed.positionals[1] ?? "";
88
+ /**
89
+ * The school roster is the ONE read behind every command here: it carries the
90
+ * institution, each family's stage/account state and kids, the live
91
+ * enrollment payment, and the open reconciliation items. Every write below
92
+ * previews from this same response, so the approval names the same facts the
93
+ * dashboard shows rather than a second, differently-shaped guess.
94
+ */
95
+ const roster = async (slug) => unwrap(await api.client.GET("/admin/partner/{slug}/families", {
96
+ params: { path: { slug } },
97
+ }));
98
+ const requireSlug = () => flagString(parsed, "school", { required: true });
99
+ if (verb === "families") {
100
+ return roster(requireSlug());
101
+ }
102
+ if (verb === "list") {
103
+ return unwrap(await api.client.GET("/admin/partner/"));
104
+ }
105
+ if (verb === "family-search") {
106
+ const search = flagString(parsed, "query", { required: true });
107
+ const limit = flagInteger(parsed, "limit", { min: 1, max: 100 });
108
+ return unwrap(await api.client.GET("/admin/partner/{slug}/family-search", {
109
+ params: {
110
+ path: { slug: requireSlug() },
111
+ query: { search, ...(limit !== undefined ? { limit } : {}) },
112
+ },
113
+ }));
114
+ }
115
+ if (verb === "convert") {
116
+ const familyId = positional(parsed, 2, "family ID");
117
+ const slug = requireSlug();
118
+ const kids = flagList(parsed, "kid").map(parseKidTierSpec);
119
+ if (kids.length === 0) {
120
+ 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.');
121
+ }
122
+ const creditAmountCents = flagInteger(parsed, "credit-cents", {
123
+ min: 0,
124
+ max: 1_000_000,
125
+ });
126
+ const enrollmentPaymentCentsPerKid = flagInteger(parsed, "enrollment-payment-cents", { min: 0 });
127
+ const note = flagString(parsed, "note");
128
+ const body = {
129
+ familyId,
130
+ kids,
131
+ ...(creditAmountCents !== undefined ? { creditAmountCents } : {}),
132
+ ...(enrollmentPaymentCentsPerKid !== undefined
133
+ ? { enrollmentPaymentCentsPerKid }
134
+ : {}),
135
+ ...(note ? { note } : {}),
136
+ };
137
+ const unpriced = kids.filter((kid) => kid.enrollmentPaymentCents === undefined &&
138
+ enrollmentPaymentCentsPerKid === undefined);
139
+ if (unpriced.length > 0) {
140
+ throw new CliError("invalid_arguments", `No first-month price decided for ${unpriced
141
+ .map((kid) => kid.kidId)
142
+ .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).`);
143
+ }
144
+ // Read-only preflight on the family's own state — the roster cannot help
145
+ // here, because a family being converted INTO the program is not on it yet.
146
+ const status = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
147
+ params: { path: { familyId } },
148
+ }));
149
+ const familyKidIds = new Set(status.kids.map((kid) => kid.id));
150
+ const foreign = kids
151
+ .map((kid) => kid.kidId)
152
+ .filter((id) => !familyKidIds.has(id));
153
+ if (foreign.length > 0) {
154
+ throw new CliError("invalid_arguments", `Not kids in this family: ${foreign.join(", ")}.`);
155
+ }
156
+ const missing = status.kids.filter((kid) => !kids.some((k) => k.kidId === kid.id));
157
+ return writeCommand(parsed, {
158
+ action: "convert this marketplace family INTO the school program (links the institution, funds tokens, cancels live memberships)",
159
+ target: { familyId, slug, familyName: status.familyName },
160
+ request: body,
161
+ details: {
162
+ kids: kids.map((kid) => ({
163
+ kid: status.kids.find((row) => row.id === kid.kidId)?.firstName ??
164
+ kid.kidId,
165
+ tier: kid.tierId,
166
+ slots: kid.slotsPerKid ?? "the tier default",
167
+ firstMonth: kid.enrollmentPaymentCents === undefined
168
+ ? enrollmentPaymentCentsPerKid === undefined
169
+ ? "undecided"
170
+ : `${dollars(enrollmentPaymentCentsPerKid)} (family default)`
171
+ : dollars(kid.enrollmentPaymentCents),
172
+ })),
173
+ ...(missing.length > 0
174
+ ? {
175
+ unassignedKids: missing.map((kid) => kid.firstName ?? kid.id),
176
+ unassignedWarning: "These kids of the family were NOT given a tier here. They come out unassigned with 0 slots until an admin picks one.",
177
+ }
178
+ : {}),
179
+ consequences: [
180
+ "Cancels live membership subscriptions at period end and turns off membership credits.",
181
+ "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).",
182
+ "Existing cohort-enrollment subscriptions are deliberately left running — they migrate to token rails at their next renewal.",
183
+ "Any nonzero total holds the family at PENDING_PAYMENT and gates them out of the parent app until they pay at /school/pay.",
184
+ "ADMIN-only (exact-admin): a GUIDE or PROGRAM session gets 403.",
185
+ ],
186
+ reverse: `The inverse is \`school revert ${familyId} --school ${slug}\`, which is fenced off in production.`,
187
+ },
188
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/convert-family", {
189
+ params: { path: { slug } },
190
+ body,
191
+ })));
192
+ }
193
+ if (verb === "revert") {
194
+ const familyId = positional(parsed, 2, "family ID");
195
+ const slug = requireSlug();
196
+ const revokeTokens = !hasFlag(parsed, "keep-tokens");
197
+ const note = flagString(parsed, "note");
198
+ const { institution, families } = await roster(slug);
199
+ const family = families.find((row) => row.id === familyId);
200
+ if (!family) {
201
+ throw new CliError("invalid_arguments", `Family ${familyId} is not in ${institution.name} (${slug}). Revert only applies to a family currently in that school program.`);
202
+ }
203
+ return writeCommand(parsed, {
204
+ action: "convert this family OUT of the school program and back to marketplace billing",
205
+ target: { familyId, slug, familyName: family.name },
206
+ request: { familyId, revokeTokens, ...(note ? { note } : {}) },
207
+ details: {
208
+ institution: institution.name,
209
+ accountState: family.accountState,
210
+ onboardingStage: family.onboardingStage,
211
+ kids: family.kids.map((kid) => ({
212
+ id: kid.id,
213
+ name: kidName(kid.firstName, kid.lastName, kid.id),
214
+ creditBalance: kid.creditBalance,
215
+ })),
216
+ revokeTokens,
217
+ liveEnrollmentPayment: family.liveEnrollmentPayment
218
+ ? {
219
+ id: family.liveEnrollmentPayment.id,
220
+ status: family.liveEnrollmentPayment.status,
221
+ amountDue: dollars(family.liveEnrollmentPayment.amountDueCents),
222
+ }
223
+ : null,
224
+ consequences: [
225
+ revokeTokens
226
+ ? "Revokes each kid's remaining school token balance (shown above)."
227
+ : "LEAVES each kid's school token balance in place (--keep-tokens).",
228
+ "Cancels the family's program membership; kids come out with NO membership. The follow-up is `school start-memberships`, which charges their card.",
229
+ "Class registrations keep running on purpose — they start billing the family's own card at renewal.",
230
+ "ADMIN-only (exact-admin): a GUIDE or PROGRAM session gets 403.",
231
+ ],
232
+ 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.",
233
+ 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.",
234
+ },
235
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/revert-family", {
236
+ params: { path: { slug } },
237
+ // Announce that this client reads the two-phase union; without the
238
+ // header the server sends the pre-union façade, which reports a
239
+ // still-settling revert as finished (revert-wire.ts).
240
+ headers: { "x-recess-revert-union": "1" },
241
+ body: { familyId, revokeTokens, ...(note ? { note } : {}) },
242
+ })));
243
+ }
244
+ if (verb === "start-memberships") {
245
+ const familyId = positional(parsed, 2, "family ID");
246
+ // --kid is REPEATABLE: read every occurrence, not just the last one.
247
+ const kidIds = flagList(parsed, "kid");
248
+ if (kidIds.length === 0) {
249
+ throw new CliError("invalid_arguments", "--kid is required: name each kid whose membership should start.");
250
+ }
251
+ if (kidIds.length > MEMBERSHIP_KID_LIMIT) {
252
+ throw new CliError("invalid_arguments", `--kid accepts at most ${MEMBERSHIP_KID_LIMIT} kids per call.`);
253
+ }
254
+ // Family-scoped, not slug-scoped: by the time this runs the family is no
255
+ // longer linked to a partner institution, so the roster read above cannot
256
+ // see it. The onboarding status is the read that still resolves.
257
+ const status = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
258
+ params: { path: { familyId } },
259
+ }));
260
+ const known = new Map(status.kids.map((kid) => [kid.id, kid]));
261
+ const unknown = kidIds.filter((id) => !known.has(id));
262
+ if (unknown.length > 0) {
263
+ throw new CliError("invalid_arguments", `Not kids in this family: ${unknown.join(", ")}. The server refuses the whole call when one id is foreign.`);
264
+ }
265
+ return writeCommand(parsed, {
266
+ action: "start a paid membership subscription for these kids, CHARGING the family's card on file",
267
+ target: { familyId, familyName: status.familyName },
268
+ request: { familyId, kidIds },
269
+ details: {
270
+ kids: kidIds.map((id) => kidName(known.get(id)?.firstName ?? null, null, id)),
271
+ 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.",
272
+ 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.",
273
+ },
274
+ }, async () => unwrap(await api.client.POST("/admin/memberships/start", {
275
+ body: { familyId, kidIds },
276
+ })));
277
+ }
278
+ if (verb === "resolve-payment") {
279
+ const paymentId = positional(parsed, 2, "enrollment payment ID");
280
+ const slug = requireSlug();
281
+ const action = assertChoice(flagString(parsed, "action", { required: true }), RESOLVE_ACTIONS, "--action");
282
+ const note = flagString(parsed, "note");
283
+ const { institution, families } = await roster(slug);
284
+ const family = families.find((row) => row.liveEnrollmentPayment?.id === paymentId);
285
+ const payment = family?.liveEnrollmentPayment;
286
+ if (!family || !payment) {
287
+ 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.`);
288
+ }
289
+ return writeCommand(parsed, {
290
+ action: action === "waive"
291
+ ? "WAIVE this enrollment charge — record it as collected off-platform and lift the paywall"
292
+ : "CANCEL this enrollment charge — drop the requirement entirely and lift the paywall",
293
+ target: {
294
+ paymentId,
295
+ slug,
296
+ familyId: family.id,
297
+ familyName: family.name,
298
+ },
299
+ request: { action, ...(note ? { note } : {}) },
300
+ details: {
301
+ institution: institution.name,
302
+ status: payment.status,
303
+ amountDue: dollars(payment.amountDueCents),
304
+ lines: payment.lines.map((line) => ({
305
+ kid: line.firstName ?? line.kidId ?? "unassigned",
306
+ amount: dollars(line.amountCents),
307
+ })),
308
+ stripeInvoiceId: payment.stripeInvoiceId,
309
+ 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.",
310
+ accountState: "PENDING_PAYMENT is walked back; a family separately set to PAUSED or BOOTED keeps that state.",
311
+ ...(action === "cancel"
312
+ ? {
313
+ 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.",
314
+ }
315
+ : {}),
316
+ },
317
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/enrollment-payments/{paymentId}/resolve", {
318
+ params: { path: { slug, paymentId } },
319
+ body: { action, ...(note ? { note } : {}) },
320
+ })));
321
+ }
322
+ if (verb === "close-reconciliation") {
323
+ const paymentId = positional(parsed, 2, "enrollment payment ID");
324
+ const slug = requireSlug();
325
+ const outcome = assertChoice(flagString(parsed, "outcome", { required: true }), RECONCILIATION_OUTCOMES, "--outcome");
326
+ const note = flagString(parsed, "note");
327
+ const { institution, families } = await roster(slug);
328
+ const family = families.find((row) => row.reconciliationItems.some((item) => item.paymentId === paymentId));
329
+ const item = family?.reconciliationItems.find((row) => row.paymentId === paymentId);
330
+ if (!family || !item) {
331
+ throw new CliError("invalid_arguments", `No reconciliation item for payment ${paymentId} under ${institution.name} (${slug}). Run \`school families --school ${slug}\` and read \`reconciliationItems\`.`);
332
+ }
333
+ if (item.resolution) {
334
+ 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.`);
335
+ }
336
+ return writeCommand(parsed, {
337
+ action: `close this reconciliation item as ${outcome} (a money decision, and irreversible)`,
338
+ target: {
339
+ paymentId,
340
+ slug,
341
+ familyId: family.id,
342
+ familyName: family.name,
343
+ },
344
+ request: { outcome, ...(note ? { note } : {}) },
345
+ details: {
346
+ institution: institution.name,
347
+ amountPaid: dollars(item.amountPaidCents),
348
+ openedBecause: item.reason,
349
+ openedAt: item.openedAt,
350
+ paymentStatus: item.status,
351
+ invoiceId: item.invoiceId,
352
+ meaning: {
353
+ refunded: "the money was returned to the family",
354
+ kept: "the family genuinely owed it, so the charge stands",
355
+ written_off: "neither — the business absorbed it",
356
+ }[outcome],
357
+ 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.",
358
+ irreversible: "The write is conditional on the item still being open, so every later attempt 409s and the item drops out of triage.",
359
+ },
360
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/enrollment-payments/{paymentId}/close-reconciliation", {
361
+ params: { path: { slug, paymentId } },
362
+ body: { outcome, ...(note ? { note } : {}) },
363
+ })));
364
+ }
365
+ if (verb === "codes") {
366
+ const subverb = parsed.positionals[2] ?? "";
367
+ const slug = requireSlug();
368
+ const codeId = () => positional(parsed, 3, "invite code ID");
369
+ if (subverb === "list") {
370
+ return unwrap(await api.client.GET("/admin/partner/{slug}/codes", {
371
+ params: { path: { slug } },
372
+ }));
373
+ }
374
+ if (subverb === "get") {
375
+ return unwrap(await api.client.GET("/admin/partner/{slug}/codes/{id}", {
376
+ params: { path: { slug, id: codeId() } },
377
+ }));
378
+ }
379
+ if (subverb === "create") {
380
+ const count = flagInteger(parsed, "count", { min: 1 });
381
+ const expiresAt = flagIsoInstant(parsed, "expires-at");
382
+ const note = flagString(parsed, "note");
383
+ const creditAmountCents = flagInteger(parsed, "credit-cents", { min: 0 });
384
+ const tierRaw = flagString(parsed, "tier");
385
+ const slotsPerKid = flagInteger(parsed, "slots", { min: 0, max: 20 });
386
+ const enrollmentPaymentCentsPerKid = flagInteger(parsed, "enrollment-payment-cents", { min: 0 });
387
+ const dataFile = flagString(parsed, "data-file");
388
+ // The preconfigured-invite `family` block (parent identity + the kid
389
+ // roster with per-kid tier and price) is a nested object, so it arrives
390
+ // as JSON the way `quotes create` takes its body. Without it this mints
391
+ // blank codes; with it, one addressed invite that emails the parent.
392
+ const family = dataFile
393
+ ? (await readJsonFile(dataFile, "Preconfigured invite file"))
394
+ : undefined;
395
+ const body = {
396
+ ...(count !== undefined ? { count } : {}),
397
+ ...(expiresAt ? { expiresAt } : {}),
398
+ ...(note ? { note } : {}),
399
+ ...(creditAmountCents !== undefined ? { creditAmountCents } : {}),
400
+ ...(tierRaw
401
+ ? { tierId: assertChoice(tierRaw, SCHOOL_TIERS, "--tier") }
402
+ : {}),
403
+ ...(slotsPerKid !== undefined ? { slotsPerKid } : {}),
404
+ ...(enrollmentPaymentCentsPerKid !== undefined
405
+ ? { enrollmentPaymentCentsPerKid }
406
+ : {}),
407
+ ...(family ? { family } : {}),
408
+ };
409
+ return writeCommand(parsed, {
410
+ action: family
411
+ ? "mint a preconfigured school invite for this family and EMAIL the parent their claim link"
412
+ : `mint ${count ?? 1} blank school invite code(s)`,
413
+ target: { slug },
414
+ request: body,
415
+ details: {
416
+ expiresAt: expiresAt ?? "the institution default",
417
+ firstMonthPerKid: enrollmentPaymentCentsPerKid === undefined
418
+ ? "not set at the family level — each kid in --data-file must carry its own price"
419
+ : dollars(enrollmentPaymentCentsPerKid),
420
+ ...(family
421
+ ? {
422
+ 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.",
423
+ }
424
+ : {
425
+ blank: "No --data-file, so these are blank codes: nobody is emailed and no roster is preconfigured.",
426
+ }),
427
+ },
428
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/codes", {
429
+ params: { path: { slug } },
430
+ body,
431
+ })));
432
+ }
433
+ if (subverb === "set-kids") {
434
+ const id = codeId();
435
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Invite roster file"));
436
+ return writeCommand(parsed, {
437
+ action: "edit an UNREDEEMED invite's roster in place — the parent's existing link keeps working",
438
+ target: { slug, codeId: id },
439
+ request: body,
440
+ details: {
441
+ 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.",
442
+ outOfScope: "Parent identity, note, credit and expiry are not editable here — changing WHO the invite is for is `codes replace`.",
443
+ refusal: "Editing after redemption is refused outright; the accounts and the charge already exist.",
444
+ },
445
+ }, async () => unwrap(await api.client.PATCH("/admin/partner/{slug}/codes/{id}", {
446
+ params: { path: { slug, id } },
447
+ body,
448
+ })));
449
+ }
450
+ if (subverb === "replace") {
451
+ const id = codeId();
452
+ const body = (await readJsonFile(flagString(parsed, "data-file", { required: true }), "Replacement invite file"));
453
+ return writeCommand(parsed, {
454
+ action: "REPLACE this unredeemed invite: delete the old code and mint a new one, emailing the parent a fresh link",
455
+ target: { slug, codeId: id },
456
+ request: body,
457
+ details: {
458
+ 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.",
459
+ 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.",
460
+ inherits: "Fields omitted from the body (note, credit) inherit from the old row read inside the transaction.",
461
+ },
462
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/codes/{id}/replace", {
463
+ params: { path: { slug, id } },
464
+ body,
465
+ })));
466
+ }
467
+ if (subverb === "resend") {
468
+ const id = codeId();
469
+ return writeCommand(parsed, {
470
+ action: "re-email this invite's claim link to the parent on file",
471
+ target: { slug, codeId: id },
472
+ request: {},
473
+ details: {
474
+ outwardEmail: "This sends mail to a real family. The code itself is unchanged — use `codes replace` to mint a new one.",
475
+ },
476
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/codes/{id}/resend", {
477
+ params: { path: { slug, id } },
478
+ })));
479
+ }
480
+ if (subverb === "revoke") {
481
+ const id = codeId();
482
+ const existing = unwrap(await api.client.GET("/admin/partner/{slug}/codes/{id}", {
483
+ params: { path: { slug, id } },
484
+ }));
485
+ return writeCommand(parsed, {
486
+ action: "HARD-DELETE this unredeemed invite code",
487
+ target: { slug, codeId: id },
488
+ request: {},
489
+ details: {
490
+ code: existing,
491
+ irreversible: "The row is REMOVED, not soft-deleted: there is no deletedAt on TalentSchoolInviteCode and no undo. The parent's link stops working immediately.",
492
+ },
493
+ }, async () => unwrap(await api.client.DELETE("/admin/partner/{slug}/codes/{id}", {
494
+ params: { path: { slug, id } },
495
+ })));
496
+ }
497
+ throw new CliError("invalid_arguments", "Use school codes list|get|create|set-kids|replace|resend|revoke.");
498
+ }
499
+ if (verb === "create") {
500
+ const slug = flagString(parsed, "slug", { required: true });
501
+ const name = flagString(parsed, "name", { required: true });
502
+ const logoUrl = flagString(parsed, "logo-url");
503
+ const creditGrant = flagInteger(parsed, "credit-grant", {
504
+ required: true,
505
+ min: 0,
506
+ });
507
+ const programTypeRaw = flagString(parsed, "program-type");
508
+ const programType = programTypeRaw
509
+ ? assertChoice(programTypeRaw, PROGRAM_TYPES, "--program-type")
510
+ : undefined;
511
+ const tokenTopUpCents = flagInteger(parsed, "token-topup-cents", {
512
+ min: 0,
513
+ max: 10_000_000,
514
+ });
515
+ if (programType === "SCHOOL" && tokenTopUpCents !== creditGrant) {
516
+ throw new CliError("invalid_arguments", "SCHOOL programs require --token-topup-cents equal to --credit-grant (the server enforces the same rule).");
517
+ }
518
+ const body = {
519
+ slug,
520
+ name,
521
+ defaultInitialCreditGrant: creditGrant,
522
+ ...(logoUrl ? { logoUrl } : {}),
523
+ ...(programType ? { programType } : {}),
524
+ ...(tokenTopUpCents !== undefined
525
+ ? { monthlyTokenTopUpCents: tokenTopUpCents }
526
+ : {}),
527
+ };
528
+ return writeCommand(parsed, {
529
+ action: `create the ${programType ?? "PARTNER"}-program institution "${name}" (${slug})`,
530
+ target: { slug },
531
+ request: body,
532
+ details: {
533
+ moneyLevers: {
534
+ defaultInitialCreditGrant: creditGrant,
535
+ monthlyTokenTopUpCents: tokenTopUpCents ?? null,
536
+ warning: "For SCHOOL programs the monthly target tops EVERY kid in the institution up to it on the next cron run — these numbers spend real money at scale.",
537
+ },
538
+ duplicateGuard: "A taken slug 409s; pick another rather than retrying.",
539
+ },
540
+ }, async () => unwrap(await api.client.POST("/admin/partner/", { body })));
541
+ }
542
+ if (verb === "update") {
543
+ const institutionId = positional(parsed, 2, "institution ID");
544
+ const slug = flagString(parsed, "slug");
545
+ const name = flagString(parsed, "name");
546
+ const logoUrl = flagString(parsed, "logo-url");
547
+ const creditGrant = flagInteger(parsed, "credit-grant", { min: 0 });
548
+ const programTypeRaw = flagString(parsed, "program-type");
549
+ const programType = programTypeRaw
550
+ ? assertChoice(programTypeRaw, PROGRAM_TYPES, "--program-type")
551
+ : undefined;
552
+ const tokenTopUpRaw = flagString(parsed, "token-topup-cents");
553
+ const tokenTopUpCents = tokenTopUpRaw === undefined
554
+ ? undefined
555
+ : tokenTopUpRaw === "none"
556
+ ? null
557
+ : flagInteger(parsed, "token-topup-cents", {
558
+ min: 0,
559
+ max: 10_000_000,
560
+ });
561
+ const body = {
562
+ ...(slug ? { slug } : {}),
563
+ ...(name ? { name } : {}),
564
+ ...(logoUrl ? { logoUrl } : {}),
565
+ ...(creditGrant !== undefined
566
+ ? { defaultInitialCreditGrant: creditGrant }
567
+ : {}),
568
+ ...(programType ? { programType } : {}),
569
+ ...(tokenTopUpCents !== undefined
570
+ ? { monthlyTokenTopUpCents: tokenTopUpCents }
571
+ : {}),
572
+ };
573
+ if (Object.keys(body).length === 0) {
574
+ throw new CliError("invalid_arguments", "Pass at least one field to change (--slug, --name, --logo-url, --credit-grant, --program-type, --token-topup-cents).");
575
+ }
576
+ const touchesMoney = creditGrant !== undefined ||
577
+ tokenTopUpCents !== undefined ||
578
+ programType !== undefined;
579
+ return writeCommand(parsed, {
580
+ action: touchesMoney
581
+ ? "edit this institution INCLUDING a money lever or the program type (exact-ADMIN only)"
582
+ : "edit this institution's descriptive metadata (name/slug/logo)",
583
+ target: { institutionId },
584
+ request: body,
585
+ details: {
586
+ ...(touchesMoney
587
+ ? {
588
+ moneyWarning: "defaultInitialCreditGrant / monthlyTokenTopUpCents / programType are money levers: the monthly target tops EVERY kid up to it on the next cron. A GUIDE or PROGRAM session gets 403 for these fields.",
589
+ }
590
+ : {}),
591
+ ...(programType
592
+ ? {
593
+ programTypeGuard: "A type flip is refused while families are attached or unused invite codes exist — resolve those first.",
594
+ }
595
+ : {}),
596
+ },
597
+ }, async () => unwrap(await api.client.PATCH("/admin/partner/{id}", {
598
+ params: { path: { id: institutionId } },
599
+ body,
600
+ })));
601
+ }
602
+ if (verb === "representatives") {
603
+ const subverb = parsed.positionals[2] ?? "";
604
+ if (subverb === "list") {
605
+ return unwrap(await api.client.GET("/admin/partner/{slug}/representatives", {
606
+ params: { path: { slug: requireSlug() } },
607
+ }));
608
+ }
609
+ if (subverb === "search") {
610
+ const q = flagString(parsed, "query", { required: true });
611
+ return unwrap(await api.client.GET("/admin/partner/representatives/search", {
612
+ params: { query: { q } },
613
+ }));
614
+ }
615
+ if (subverb === "add") {
616
+ const slug = requireSlug();
617
+ const userId = flagString(parsed, "user", { required: true });
618
+ // Resolve the exact person first so the approval names who gains the
619
+ // representative surface, not a bare id.
620
+ const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
621
+ params: { path: { userId } },
622
+ }));
623
+ return writeCommand(parsed, {
624
+ action: "add this GUIDE/ADMIN as a representative of the institution",
625
+ target: {
626
+ slug,
627
+ userId,
628
+ name: kidName(user.firstName, user.lastName, userId),
629
+ role: user.role,
630
+ },
631
+ request: { userId },
632
+ details: {
633
+ refusal: "Non-GUIDE/ADMIN users are refused by the server.",
634
+ },
635
+ }, async () => unwrap(await api.client.POST("/admin/partner/{slug}/representatives", {
636
+ params: { path: { slug } },
637
+ body: { userId },
638
+ })));
639
+ }
640
+ if (subverb === "remove") {
641
+ const slug = requireSlug();
642
+ const userId = flagString(parsed, "user", { required: true });
643
+ return writeCommand(parsed, {
644
+ action: "remove this representative from the institution",
645
+ target: { slug, userId },
646
+ request: {},
647
+ }, async () => unwrap(await api.client.DELETE("/admin/partner/{slug}/representatives/{userId}", { params: { path: { slug, userId } } })));
648
+ }
649
+ throw new CliError("invalid_arguments", "Use school representatives list|search|add|remove.");
650
+ }
651
+ if (verb === "credit-transactions") {
652
+ const slug = requireSlug();
653
+ const page = flagInteger(parsed, "page", { min: 0 });
654
+ const limit = flagInteger(parsed, "limit", { min: 1, max: 100 });
655
+ return unwrap(await api.client.GET("/admin/partner/{slug}/credit-transactions", {
656
+ params: {
657
+ path: { slug },
658
+ query: {
659
+ ...(page !== undefined ? { page } : {}),
660
+ ...(limit !== undefined ? { limit } : {}),
661
+ },
662
+ },
663
+ }));
664
+ }
665
+ if (verb === "kid-slots") {
666
+ const kidId = positional(parsed, 2, "kid ID");
667
+ const slug = requireSlug();
668
+ const slotsRaw = flagString(parsed, "slots", { required: true });
669
+ const concurrentClassSlots = slotsRaw === "unlimited"
670
+ ? null
671
+ : flagInteger(parsed, "slots", { min: 0 });
672
+ const premiumClassSlots = flagInteger(parsed, "premium-slots", {
673
+ min: 0,
674
+ max: 20,
675
+ });
676
+ const body = {
677
+ concurrentClassSlots,
678
+ ...(premiumClassSlots !== undefined ? { premiumClassSlots } : {}),
679
+ };
680
+ return writeCommand(parsed, {
681
+ action: "set this school kid's concurrent class slots directly (the raw slot override — `users tier set` is the tier-driven path)",
682
+ target: { kidId, slug },
683
+ request: body,
684
+ details: {
685
+ slots: concurrentClassSlots ?? "unlimited",
686
+ ...(premiumClassSlots !== undefined
687
+ ? { premiumSlots: premiumClassSlots }
688
+ : {}),
689
+ note: "SCHOOL-program kids only; the write re-checks membership under the family lock so it cannot race a concurrent revert.",
690
+ },
691
+ }, async () => unwrap(await api.client.PATCH("/admin/partner/{slug}/kids/{kidId}/slots", {
692
+ params: { path: { slug, kidId } },
693
+ body,
694
+ })));
695
+ }
696
+ if (verb === "partner-family") {
697
+ const familyId = positional(parsed, 2, "family ID");
698
+ const slug = requireSlug();
699
+ const enabledRaw = flagString(parsed, "enabled", { required: true });
700
+ const enabled = assertChoice(enabledRaw, ["true", "false"], "--enabled") ===
701
+ "true";
702
+ return writeCommand(parsed, {
703
+ action: enabled
704
+ ? "attach this family to the PARTNER institution"
705
+ : "detach this family from the PARTNER institution",
706
+ target: { familyId, slug },
707
+ request: { enabled },
708
+ details: {
709
+ scope: "PARTNER programs only — the server refuses SCHOOL institutions here, because school families carry entitlements this generic toggle cannot safely provision or remove (use `school convert`/`school revert`).",
710
+ },
711
+ }, async () => unwrap(await api.client.PATCH("/admin/partner/{slug}/families/{familyId}", {
712
+ params: { path: { slug, familyId } },
713
+ body: { enabled },
714
+ })));
715
+ }
716
+ if (verb === "logo-upload") {
717
+ const filePath = flagString(parsed, "file", { required: true });
718
+ const { bytes, fileName, mimeType, sha256 } = await readLogoFile(filePath);
719
+ return writeCommand(parsed, {
720
+ action: "upload this image to the assets CDN as a partner-institution logo (returns a URL for `school create/update --logo-url`)",
721
+ target: { fileName },
722
+ request: { fileName, mimeType, bytes: bytes.byteLength, sha256 },
723
+ }, async () => api.uploadPartnerLogo(bytes, fileName, mimeType));
724
+ }
725
+ throw new CliError("invalid_arguments", "Unknown school command. Run `recess school --help` for the current command list.");
726
+ }
727
+ //# sourceMappingURL=school.js.map