@playcademy/sandbox 0.4.1 → 0.4.2-beta.1

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 CHANGED
@@ -250,7 +250,8 @@ var init_timeback2 = __esm(() => {
250
250
  GET_XP: "/integrations/timeback/xp",
251
251
  GET_MASTERY: "/integrations/timeback/mastery",
252
252
  HEARTBEAT: "/integrations/timeback/heartbeat",
253
- ADVANCE_COURSE: "/integrations/timeback/advance-course"
253
+ ADVANCE_COURSE: "/integrations/timeback/advance-course",
254
+ UNENROLL_COURSE: "/integrations/timeback/unenroll-course"
254
255
  };
255
256
  TIMEBACK_COURSE_DEFAULTS = {
256
257
  gradingScheme: "STANDARD",
@@ -1076,7 +1077,7 @@ var package_default;
1076
1077
  var init_package = __esm(() => {
1077
1078
  package_default = {
1078
1079
  name: "@playcademy/sandbox",
1079
- version: "0.4.1",
1080
+ version: "0.4.2-beta.1",
1080
1081
  description: "Local development server for Playcademy game development",
1081
1082
  type: "module",
1082
1083
  exports: {
@@ -28248,7 +28249,8 @@ var init_constants3 = __esm(() => {
28248
28249
  GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
28249
28250
  GET_MASTERY: `/api${TIMEBACK_ROUTES.GET_MASTERY}`,
28250
28251
  HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`,
28251
- ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`
28252
+ ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`,
28253
+ UNENROLL_COURSE: `/api${TIMEBACK_ROUTES.UNENROLL_COURSE}`
28252
28254
  }
28253
28255
  };
28254
28256
  });
@@ -29902,7 +29904,7 @@ function isValidAdminAttributionDate(value) {
29902
29904
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
29903
29905
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
29904
29906
  }
29905
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29907
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29906
29908
  var init_schemas4 = __esm(() => {
29907
29909
  init_drizzle_zod();
29908
29910
  init_esm();
@@ -30004,6 +30006,12 @@ var init_schemas4 = __esm(() => {
30004
30006
  studentId: exports_external.string().min(1),
30005
30007
  subject: TimebackSubjectSchema.optional()
30006
30008
  });
30009
+ UnenrollCourseRequestSchema = exports_external.object({
30010
+ gameId: exports_external.string().uuid(),
30011
+ studentId: exports_external.string().min(1),
30012
+ subject: TimebackSubjectSchema.optional(),
30013
+ force: exports_external.boolean().optional()
30014
+ });
30007
30015
  HeartbeatRequestSchema = exports_external.object({
30008
30016
  gameId: exports_external.string().uuid(),
30009
30017
  studentId: exports_external.string().min(1),
@@ -33208,15 +33216,14 @@ var init_timeback_service = __esm(() => {
33208
33216
  inProgress: result.inProgress
33209
33217
  };
33210
33218
  }
33211
- async advanceCourse({
33219
+ async resolveActiveGameCourse({
33212
33220
  gameId,
33213
33221
  studentId,
33214
33222
  subject,
33215
- user
33223
+ action
33216
33224
  }) {
33217
33225
  const client = this.requireClient();
33218
33226
  const db2 = this.deps.db;
33219
- await this.deps.validateDeveloperAccess(user, gameId);
33220
33227
  const integrations = await db2.query.gameTimebackIntegrations.findMany({
33221
33228
  where: subject ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.subject, subject)) : eq(gameTimebackIntegrations.gameId, gameId)
33222
33229
  });
@@ -33231,12 +33238,30 @@ var init_timeback_service = __esm(() => {
33231
33238
  }
33232
33239
  const subjectsInPlay = new Set(enrolledIntegrations.map((i2) => i2.subject));
33233
33240
  if (subjectsInPlay.size > 1) {
33234
- throw new ValidationError(`Ambiguous Timeback advance: student is enrolled in ${subjectsInPlay.size} parallel ladders (${[...subjectsInPlay].join(", ")}); pass { subject } to disambiguate`);
33241
+ throw new ValidationError(`Ambiguous Timeback ${action}: student is enrolled in ${subjectsInPlay.size} parallel ladders (${[...subjectsInPlay].join(", ")}); pass { subject } to disambiguate`);
33235
33242
  }
33236
33243
  const currentIntegration = enrolledIntegrations.toSorted((left, right) => left.grade - right.grade)[0];
33244
+ const currentEnrollment = enrollments.find((enrollment) => enrollment.course.id === currentIntegration.courseId);
33245
+ return { currentIntegration, currentEnrollment, enrollments };
33246
+ }
33247
+ async advanceCourse({
33248
+ gameId,
33249
+ studentId,
33250
+ subject,
33251
+ user
33252
+ }) {
33253
+ const client = this.requireClient();
33254
+ const db2 = this.deps.db;
33255
+ await this.deps.validateDeveloperAccess(user, gameId);
33256
+ const { currentIntegration, enrollments } = await this.resolveActiveGameCourse({
33257
+ gameId,
33258
+ studentId,
33259
+ subject,
33260
+ action: "advance"
33261
+ });
33237
33262
  const masteryStatus = await client.getMasteryStatus(currentIntegration.courseId, studentId);
33238
33263
  if (!masteryStatus) {
33239
- throw new ValidationError(`Cannot advance course: mastery status is unavailable for course ${currentIntegration.courseId}. Ensure the course has mastery configuration and the student has enrollment analytics before calling client.timeback.advanceCourse().`);
33264
+ throw new ValidationError(`Cannot advance course: mastery status is unavailable for course ${currentIntegration.courseId}. Ensure the course has mastery configuration and the student has enrollment analytics before calling client.timeback.course.advance().`);
33240
33265
  }
33241
33266
  if (!masteryStatus.isComplete) {
33242
33267
  const promotion2 = {
@@ -33274,6 +33299,93 @@ var init_timeback_service = __esm(() => {
33274
33299
  });
33275
33300
  return { status: "ok", promotion };
33276
33301
  }
33302
+ async unenrollCourse({
33303
+ gameId,
33304
+ studentId,
33305
+ subject,
33306
+ force,
33307
+ user
33308
+ }) {
33309
+ const client = this.requireClient();
33310
+ await this.deps.validateDeveloperAccess(user, gameId);
33311
+ const { currentIntegration, currentEnrollment } = await this.resolveActiveGameCourse({
33312
+ gameId,
33313
+ studentId,
33314
+ subject,
33315
+ action: "unenroll"
33316
+ });
33317
+ const schoolId = currentEnrollment.school.id;
33318
+ const masteryStatus = await client.getMasteryStatus(currentIntegration.courseId, studentId);
33319
+ if (!masteryStatus && !force) {
33320
+ throw new ValidationError(`Cannot unenroll course: mastery status is unavailable for course ${currentIntegration.courseId}. Pass { force: true } to unenroll without mastery data.`);
33321
+ }
33322
+ if (masteryStatus && !masteryStatus.isComplete && !force) {
33323
+ const unenrollment = {
33324
+ status: "not-mastered",
33325
+ currentCourseId: currentIntegration.courseId,
33326
+ masteredUnits: masteryStatus.masteredUnits,
33327
+ masterableUnits: masteryStatus.masterableUnits
33328
+ };
33329
+ logger20.debug("Skipping game-initiated course unenroll because mastery is incomplete", {
33330
+ event: "timeback.course.unenroll",
33331
+ outcome: "not-mastered",
33332
+ gameId,
33333
+ studentId,
33334
+ courseId: currentIntegration.courseId,
33335
+ subject: currentIntegration.subject,
33336
+ grade: currentIntegration.grade,
33337
+ masteredUnits: masteryStatus.masteredUnits,
33338
+ masterableUnits: masteryStatus.masterableUnits,
33339
+ forced: false,
33340
+ requesterUserId: user.id
33341
+ });
33342
+ return { status: "ok", unenrollment };
33343
+ }
33344
+ const forcedEarlyExit = Boolean(force && (!masteryStatus || !masteryStatus.isComplete));
33345
+ if (forcedEarlyExit) {
33346
+ logger20.warn("Force-unenrolled student before mastery completion", {
33347
+ event: "timeback.course.unenroll",
33348
+ outcome: "force-unenrolled",
33349
+ gameId,
33350
+ studentId,
33351
+ courseId: currentIntegration.courseId,
33352
+ subject: currentIntegration.subject,
33353
+ grade: currentIntegration.grade,
33354
+ masteredUnits: masteryStatus?.masteredUnits,
33355
+ masterableUnits: masteryStatus?.masterableUnits,
33356
+ masteryStatusAvailable: Boolean(masteryStatus),
33357
+ forced: true,
33358
+ requesterUserId: user.id
33359
+ });
33360
+ }
33361
+ await client.edubridge.enrollments.unenroll(studentId, currentIntegration.courseId, schoolId ? { schoolId } : {});
33362
+ client.invalidateEnrollments(studentId);
33363
+ logger20.info("Game-initiated course unenroll completed", {
33364
+ event: "timeback.course.unenroll",
33365
+ outcome: "unenrolled",
33366
+ gameId,
33367
+ studentId,
33368
+ courseId: currentIntegration.courseId,
33369
+ subject: currentIntegration.subject,
33370
+ grade: currentIntegration.grade,
33371
+ masteredUnits: masteryStatus?.masteredUnits,
33372
+ masterableUnits: masteryStatus?.masterableUnits,
33373
+ forced: forcedEarlyExit,
33374
+ requesterUserId: user.id
33375
+ });
33376
+ return {
33377
+ status: "ok",
33378
+ unenrollment: {
33379
+ status: "unenrolled",
33380
+ currentCourseId: currentIntegration.courseId,
33381
+ ...masteryStatus ? {
33382
+ masteredUnits: masteryStatus.masteredUnits,
33383
+ masterableUnits: masteryStatus.masterableUnits
33384
+ } : {},
33385
+ ...forcedEarlyExit ? { forced: true } : {}
33386
+ }
33387
+ };
33388
+ }
33277
33389
  async recordHeartbeat({
33278
33390
  gameId,
33279
33391
  studentId,
@@ -94085,7 +94197,7 @@ var init_session_controller = __esm(() => {
94085
94197
  });
94086
94198
 
94087
94199
  // ../api-core/src/controllers/timeback.controller.ts
94088
- var logger45, populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, updateIntegration, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, getStudentXp, getStudentMastery, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getActivityDetail, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
94200
+ var logger45, populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, updateIntegration, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, getStudentXp, getStudentMastery, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getActivityDetail, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
94089
94201
  var init_timeback_controller = __esm(() => {
94090
94202
  init_esm();
94091
94203
  init_schemas_index();
@@ -94323,6 +94435,20 @@ var init_timeback_controller = __esm(() => {
94323
94435
  user: ctx.user
94324
94436
  });
94325
94437
  });
94438
+ unenrollCourse = requireDeveloper(async (ctx) => {
94439
+ const body2 = await parseRequestBody(ctx.request, UnenrollCourseRequestSchema);
94440
+ logger45.debug("Unenrolling student from current course", {
94441
+ userId: ctx.user.id,
94442
+ gameId: body2.gameId,
94443
+ studentId: body2.studentId,
94444
+ subject: body2.subject,
94445
+ force: body2.force
94446
+ });
94447
+ return ctx.services.timeback.unenrollCourse({
94448
+ ...body2,
94449
+ user: ctx.user
94450
+ });
94451
+ });
94326
94452
  getStudentXp = requireDeveloper(async (ctx) => {
94327
94453
  const timebackId = ctx.params.timebackId;
94328
94454
  if (!timebackId) {
@@ -94750,6 +94876,7 @@ var init_timeback_controller = __esm(() => {
94750
94876
  endActivity,
94751
94877
  heartbeat,
94752
94878
  advanceCourse,
94879
+ unenrollCourse,
94753
94880
  getStudentXp,
94754
94881
  getStudentMastery,
94755
94882
  getRoster,
@@ -95654,6 +95781,7 @@ var init_timeback6 = __esm(() => {
95654
95781
  timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
95655
95782
  timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
95656
95783
  timebackRouter.post("/advance-course", handle2(timeback2.advanceCourse));
95784
+ timebackRouter.post("/unenroll-course", handle2(timeback2.unenrollCourse));
95657
95785
  timebackRouter.get("/user", async (c2) => {
95658
95786
  const user = c2.get("user");
95659
95787
  const gameId = c2.get("gameId");
package/dist/constants.js CHANGED
@@ -85,7 +85,8 @@ var init_timeback = __esm(() => {
85
85
  GET_XP: "/integrations/timeback/xp",
86
86
  GET_MASTERY: "/integrations/timeback/mastery",
87
87
  HEARTBEAT: "/integrations/timeback/heartbeat",
88
- ADVANCE_COURSE: "/integrations/timeback/advance-course"
88
+ ADVANCE_COURSE: "/integrations/timeback/advance-course",
89
+ UNENROLL_COURSE: "/integrations/timeback/unenroll-course"
89
90
  };
90
91
  TIMEBACK_COURSE_DEFAULTS = {
91
92
  gradingScheme: "STANDARD",
package/dist/server.js CHANGED
@@ -249,7 +249,8 @@ var init_timeback2 = __esm(() => {
249
249
  GET_XP: "/integrations/timeback/xp",
250
250
  GET_MASTERY: "/integrations/timeback/mastery",
251
251
  HEARTBEAT: "/integrations/timeback/heartbeat",
252
- ADVANCE_COURSE: "/integrations/timeback/advance-course"
252
+ ADVANCE_COURSE: "/integrations/timeback/advance-course",
253
+ UNENROLL_COURSE: "/integrations/timeback/unenroll-course"
253
254
  };
254
255
  TIMEBACK_COURSE_DEFAULTS = {
255
256
  gradingScheme: "STANDARD",
@@ -1075,7 +1076,7 @@ var package_default;
1075
1076
  var init_package = __esm(() => {
1076
1077
  package_default = {
1077
1078
  name: "@playcademy/sandbox",
1078
- version: "0.4.1",
1079
+ version: "0.4.2-beta.1",
1079
1080
  description: "Local development server for Playcademy game development",
1080
1081
  type: "module",
1081
1082
  exports: {
@@ -28247,7 +28248,8 @@ var init_constants3 = __esm(() => {
28247
28248
  GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
28248
28249
  GET_MASTERY: `/api${TIMEBACK_ROUTES.GET_MASTERY}`,
28249
28250
  HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`,
28250
- ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`
28251
+ ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`,
28252
+ UNENROLL_COURSE: `/api${TIMEBACK_ROUTES.UNENROLL_COURSE}`
28251
28253
  }
28252
28254
  };
28253
28255
  });
@@ -29901,7 +29903,7 @@ function isValidAdminAttributionDate(value) {
29901
29903
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
29902
29904
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
29903
29905
  }
29904
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29906
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29905
29907
  var init_schemas4 = __esm(() => {
29906
29908
  init_drizzle_zod();
29907
29909
  init_esm();
@@ -30003,6 +30005,12 @@ var init_schemas4 = __esm(() => {
30003
30005
  studentId: exports_external.string().min(1),
30004
30006
  subject: TimebackSubjectSchema.optional()
30005
30007
  });
30008
+ UnenrollCourseRequestSchema = exports_external.object({
30009
+ gameId: exports_external.string().uuid(),
30010
+ studentId: exports_external.string().min(1),
30011
+ subject: TimebackSubjectSchema.optional(),
30012
+ force: exports_external.boolean().optional()
30013
+ });
30006
30014
  HeartbeatRequestSchema = exports_external.object({
30007
30015
  gameId: exports_external.string().uuid(),
30008
30016
  studentId: exports_external.string().min(1),
@@ -33207,15 +33215,14 @@ var init_timeback_service = __esm(() => {
33207
33215
  inProgress: result.inProgress
33208
33216
  };
33209
33217
  }
33210
- async advanceCourse({
33218
+ async resolveActiveGameCourse({
33211
33219
  gameId,
33212
33220
  studentId,
33213
33221
  subject,
33214
- user
33222
+ action
33215
33223
  }) {
33216
33224
  const client = this.requireClient();
33217
33225
  const db2 = this.deps.db;
33218
- await this.deps.validateDeveloperAccess(user, gameId);
33219
33226
  const integrations = await db2.query.gameTimebackIntegrations.findMany({
33220
33227
  where: subject ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.subject, subject)) : eq(gameTimebackIntegrations.gameId, gameId)
33221
33228
  });
@@ -33230,12 +33237,30 @@ var init_timeback_service = __esm(() => {
33230
33237
  }
33231
33238
  const subjectsInPlay = new Set(enrolledIntegrations.map((i2) => i2.subject));
33232
33239
  if (subjectsInPlay.size > 1) {
33233
- throw new ValidationError(`Ambiguous Timeback advance: student is enrolled in ${subjectsInPlay.size} parallel ladders (${[...subjectsInPlay].join(", ")}); pass { subject } to disambiguate`);
33240
+ throw new ValidationError(`Ambiguous Timeback ${action}: student is enrolled in ${subjectsInPlay.size} parallel ladders (${[...subjectsInPlay].join(", ")}); pass { subject } to disambiguate`);
33234
33241
  }
33235
33242
  const currentIntegration = enrolledIntegrations.toSorted((left, right) => left.grade - right.grade)[0];
33243
+ const currentEnrollment = enrollments.find((enrollment) => enrollment.course.id === currentIntegration.courseId);
33244
+ return { currentIntegration, currentEnrollment, enrollments };
33245
+ }
33246
+ async advanceCourse({
33247
+ gameId,
33248
+ studentId,
33249
+ subject,
33250
+ user
33251
+ }) {
33252
+ const client = this.requireClient();
33253
+ const db2 = this.deps.db;
33254
+ await this.deps.validateDeveloperAccess(user, gameId);
33255
+ const { currentIntegration, enrollments } = await this.resolveActiveGameCourse({
33256
+ gameId,
33257
+ studentId,
33258
+ subject,
33259
+ action: "advance"
33260
+ });
33236
33261
  const masteryStatus = await client.getMasteryStatus(currentIntegration.courseId, studentId);
33237
33262
  if (!masteryStatus) {
33238
- throw new ValidationError(`Cannot advance course: mastery status is unavailable for course ${currentIntegration.courseId}. Ensure the course has mastery configuration and the student has enrollment analytics before calling client.timeback.advanceCourse().`);
33263
+ throw new ValidationError(`Cannot advance course: mastery status is unavailable for course ${currentIntegration.courseId}. Ensure the course has mastery configuration and the student has enrollment analytics before calling client.timeback.course.advance().`);
33239
33264
  }
33240
33265
  if (!masteryStatus.isComplete) {
33241
33266
  const promotion2 = {
@@ -33273,6 +33298,93 @@ var init_timeback_service = __esm(() => {
33273
33298
  });
33274
33299
  return { status: "ok", promotion };
33275
33300
  }
33301
+ async unenrollCourse({
33302
+ gameId,
33303
+ studentId,
33304
+ subject,
33305
+ force,
33306
+ user
33307
+ }) {
33308
+ const client = this.requireClient();
33309
+ await this.deps.validateDeveloperAccess(user, gameId);
33310
+ const { currentIntegration, currentEnrollment } = await this.resolveActiveGameCourse({
33311
+ gameId,
33312
+ studentId,
33313
+ subject,
33314
+ action: "unenroll"
33315
+ });
33316
+ const schoolId = currentEnrollment.school.id;
33317
+ const masteryStatus = await client.getMasteryStatus(currentIntegration.courseId, studentId);
33318
+ if (!masteryStatus && !force) {
33319
+ throw new ValidationError(`Cannot unenroll course: mastery status is unavailable for course ${currentIntegration.courseId}. Pass { force: true } to unenroll without mastery data.`);
33320
+ }
33321
+ if (masteryStatus && !masteryStatus.isComplete && !force) {
33322
+ const unenrollment = {
33323
+ status: "not-mastered",
33324
+ currentCourseId: currentIntegration.courseId,
33325
+ masteredUnits: masteryStatus.masteredUnits,
33326
+ masterableUnits: masteryStatus.masterableUnits
33327
+ };
33328
+ logger20.debug("Skipping game-initiated course unenroll because mastery is incomplete", {
33329
+ event: "timeback.course.unenroll",
33330
+ outcome: "not-mastered",
33331
+ gameId,
33332
+ studentId,
33333
+ courseId: currentIntegration.courseId,
33334
+ subject: currentIntegration.subject,
33335
+ grade: currentIntegration.grade,
33336
+ masteredUnits: masteryStatus.masteredUnits,
33337
+ masterableUnits: masteryStatus.masterableUnits,
33338
+ forced: false,
33339
+ requesterUserId: user.id
33340
+ });
33341
+ return { status: "ok", unenrollment };
33342
+ }
33343
+ const forcedEarlyExit = Boolean(force && (!masteryStatus || !masteryStatus.isComplete));
33344
+ if (forcedEarlyExit) {
33345
+ logger20.warn("Force-unenrolled student before mastery completion", {
33346
+ event: "timeback.course.unenroll",
33347
+ outcome: "force-unenrolled",
33348
+ gameId,
33349
+ studentId,
33350
+ courseId: currentIntegration.courseId,
33351
+ subject: currentIntegration.subject,
33352
+ grade: currentIntegration.grade,
33353
+ masteredUnits: masteryStatus?.masteredUnits,
33354
+ masterableUnits: masteryStatus?.masterableUnits,
33355
+ masteryStatusAvailable: Boolean(masteryStatus),
33356
+ forced: true,
33357
+ requesterUserId: user.id
33358
+ });
33359
+ }
33360
+ await client.edubridge.enrollments.unenroll(studentId, currentIntegration.courseId, schoolId ? { schoolId } : {});
33361
+ client.invalidateEnrollments(studentId);
33362
+ logger20.info("Game-initiated course unenroll completed", {
33363
+ event: "timeback.course.unenroll",
33364
+ outcome: "unenrolled",
33365
+ gameId,
33366
+ studentId,
33367
+ courseId: currentIntegration.courseId,
33368
+ subject: currentIntegration.subject,
33369
+ grade: currentIntegration.grade,
33370
+ masteredUnits: masteryStatus?.masteredUnits,
33371
+ masterableUnits: masteryStatus?.masterableUnits,
33372
+ forced: forcedEarlyExit,
33373
+ requesterUserId: user.id
33374
+ });
33375
+ return {
33376
+ status: "ok",
33377
+ unenrollment: {
33378
+ status: "unenrolled",
33379
+ currentCourseId: currentIntegration.courseId,
33380
+ ...masteryStatus ? {
33381
+ masteredUnits: masteryStatus.masteredUnits,
33382
+ masterableUnits: masteryStatus.masterableUnits
33383
+ } : {},
33384
+ ...forcedEarlyExit ? { forced: true } : {}
33385
+ }
33386
+ };
33387
+ }
33276
33388
  async recordHeartbeat({
33277
33389
  gameId,
33278
33390
  studentId,
@@ -94084,7 +94196,7 @@ var init_session_controller = __esm(() => {
94084
94196
  });
94085
94197
 
94086
94198
  // ../api-core/src/controllers/timeback.controller.ts
94087
- var logger45, populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, updateIntegration, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, getStudentXp, getStudentMastery, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getActivityDetail, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
94199
+ var logger45, populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, updateIntegration, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, getStudentXp, getStudentMastery, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getActivityDetail, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
94088
94200
  var init_timeback_controller = __esm(() => {
94089
94201
  init_esm();
94090
94202
  init_schemas_index();
@@ -94322,6 +94434,20 @@ var init_timeback_controller = __esm(() => {
94322
94434
  user: ctx.user
94323
94435
  });
94324
94436
  });
94437
+ unenrollCourse = requireDeveloper(async (ctx) => {
94438
+ const body2 = await parseRequestBody(ctx.request, UnenrollCourseRequestSchema);
94439
+ logger45.debug("Unenrolling student from current course", {
94440
+ userId: ctx.user.id,
94441
+ gameId: body2.gameId,
94442
+ studentId: body2.studentId,
94443
+ subject: body2.subject,
94444
+ force: body2.force
94445
+ });
94446
+ return ctx.services.timeback.unenrollCourse({
94447
+ ...body2,
94448
+ user: ctx.user
94449
+ });
94450
+ });
94325
94451
  getStudentXp = requireDeveloper(async (ctx) => {
94326
94452
  const timebackId = ctx.params.timebackId;
94327
94453
  if (!timebackId) {
@@ -94749,6 +94875,7 @@ var init_timeback_controller = __esm(() => {
94749
94875
  endActivity,
94750
94876
  heartbeat,
94751
94877
  advanceCourse,
94878
+ unenrollCourse,
94752
94879
  getStudentXp,
94753
94880
  getStudentMastery,
94754
94881
  getRoster,
@@ -95653,6 +95780,7 @@ var init_timeback6 = __esm(() => {
95653
95780
  timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
95654
95781
  timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
95655
95782
  timebackRouter.post("/advance-course", handle2(timeback2.advanceCourse));
95783
+ timebackRouter.post("/unenroll-course", handle2(timeback2.unenrollCourse));
95656
95784
  timebackRouter.get("/user", async (c2) => {
95657
95785
  const user = c2.get("user");
95658
95786
  const gameId = c2.get("gameId");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.4.1",
3
+ "version": "0.4.2-beta.1",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {