@playcademy/sandbox 0.4.1 → 0.4.2-beta.2

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
@@ -249,8 +249,10 @@ var init_timeback2 = __esm(() => {
249
249
  END_ACTIVITY: "/integrations/timeback/end-activity",
250
250
  GET_XP: "/integrations/timeback/xp",
251
251
  GET_MASTERY: "/integrations/timeback/mastery",
252
+ GET_HIGHEST_GRADE_MASTERED: "/integrations/timeback/highest-grade-mastered",
252
253
  HEARTBEAT: "/integrations/timeback/heartbeat",
253
- ADVANCE_COURSE: "/integrations/timeback/advance-course"
254
+ ADVANCE_COURSE: "/integrations/timeback/advance-course",
255
+ UNENROLL_COURSE: "/integrations/timeback/unenroll-course"
254
256
  };
255
257
  TIMEBACK_COURSE_DEFAULTS = {
256
258
  gradingScheme: "STANDARD",
@@ -1076,7 +1078,7 @@ var package_default;
1076
1078
  var init_package = __esm(() => {
1077
1079
  package_default = {
1078
1080
  name: "@playcademy/sandbox",
1079
- version: "0.4.1",
1081
+ version: "0.4.2-beta.2",
1080
1082
  description: "Local development server for Playcademy game development",
1081
1083
  type: "module",
1082
1084
  exports: {
@@ -28247,8 +28249,10 @@ var init_constants3 = __esm(() => {
28247
28249
  END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
28248
28250
  GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
28249
28251
  GET_MASTERY: `/api${TIMEBACK_ROUTES.GET_MASTERY}`,
28252
+ GET_HIGHEST_GRADE_MASTERED: `/api${TIMEBACK_ROUTES.GET_HIGHEST_GRADE_MASTERED}`,
28250
28253
  HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`,
28251
- ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`
28254
+ ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`,
28255
+ UNENROLL_COURSE: `/api${TIMEBACK_ROUTES.UNENROLL_COURSE}`
28252
28256
  }
28253
28257
  };
28254
28258
  });
@@ -29126,6 +29130,7 @@ var init_utils6 = __esm(() => {
29126
29130
  };
29127
29131
  });
29128
29132
  init_constants7();
29133
+ init_constants7();
29129
29134
  if (process.env.DEBUG === "true") {
29130
29135
  process.env.TERM = "dumb";
29131
29136
  }
@@ -29902,7 +29907,7 @@ function isValidAdminAttributionDate(value) {
29902
29907
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
29903
29908
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
29904
29909
  }
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;
29910
+ 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
29911
  var init_schemas4 = __esm(() => {
29907
29912
  init_drizzle_zod();
29908
29913
  init_esm();
@@ -30004,6 +30009,12 @@ var init_schemas4 = __esm(() => {
30004
30009
  studentId: exports_external.string().min(1),
30005
30010
  subject: TimebackSubjectSchema.optional()
30006
30011
  });
30012
+ UnenrollCourseRequestSchema = exports_external.object({
30013
+ gameId: exports_external.string().uuid(),
30014
+ studentId: exports_external.string().min(1),
30015
+ subject: TimebackSubjectSchema.optional(),
30016
+ force: exports_external.boolean().optional()
30017
+ });
30007
30018
  HeartbeatRequestSchema = exports_external.object({
30008
30019
  gameId: exports_external.string().uuid(),
30009
30020
  studentId: exports_external.string().min(1),
@@ -33208,15 +33219,14 @@ var init_timeback_service = __esm(() => {
33208
33219
  inProgress: result.inProgress
33209
33220
  };
33210
33221
  }
33211
- async advanceCourse({
33222
+ async resolveActiveGameCourse({
33212
33223
  gameId,
33213
33224
  studentId,
33214
33225
  subject,
33215
- user
33226
+ action
33216
33227
  }) {
33217
33228
  const client = this.requireClient();
33218
33229
  const db2 = this.deps.db;
33219
- await this.deps.validateDeveloperAccess(user, gameId);
33220
33230
  const integrations = await db2.query.gameTimebackIntegrations.findMany({
33221
33231
  where: subject ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.subject, subject)) : eq(gameTimebackIntegrations.gameId, gameId)
33222
33232
  });
@@ -33231,12 +33241,30 @@ var init_timeback_service = __esm(() => {
33231
33241
  }
33232
33242
  const subjectsInPlay = new Set(enrolledIntegrations.map((i2) => i2.subject));
33233
33243
  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`);
33244
+ throw new ValidationError(`Ambiguous Timeback ${action}: student is enrolled in ${subjectsInPlay.size} parallel ladders (${[...subjectsInPlay].join(", ")}); pass { subject } to disambiguate`);
33235
33245
  }
33236
33246
  const currentIntegration = enrolledIntegrations.toSorted((left, right) => left.grade - right.grade)[0];
33247
+ const currentEnrollment = enrollments.find((enrollment) => enrollment.course.id === currentIntegration.courseId);
33248
+ return { currentIntegration, currentEnrollment, enrollments };
33249
+ }
33250
+ async advanceCourse({
33251
+ gameId,
33252
+ studentId,
33253
+ subject,
33254
+ user
33255
+ }) {
33256
+ const client = this.requireClient();
33257
+ const db2 = this.deps.db;
33258
+ await this.deps.validateDeveloperAccess(user, gameId);
33259
+ const { currentIntegration, enrollments } = await this.resolveActiveGameCourse({
33260
+ gameId,
33261
+ studentId,
33262
+ subject,
33263
+ action: "advance"
33264
+ });
33237
33265
  const masteryStatus = await client.getMasteryStatus(currentIntegration.courseId, studentId);
33238
33266
  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().`);
33267
+ 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
33268
  }
33241
33269
  if (!masteryStatus.isComplete) {
33242
33270
  const promotion2 = {
@@ -33274,6 +33302,93 @@ var init_timeback_service = __esm(() => {
33274
33302
  });
33275
33303
  return { status: "ok", promotion };
33276
33304
  }
33305
+ async unenrollCourse({
33306
+ gameId,
33307
+ studentId,
33308
+ subject,
33309
+ force,
33310
+ user
33311
+ }) {
33312
+ const client = this.requireClient();
33313
+ await this.deps.validateDeveloperAccess(user, gameId);
33314
+ const { currentIntegration, currentEnrollment } = await this.resolveActiveGameCourse({
33315
+ gameId,
33316
+ studentId,
33317
+ subject,
33318
+ action: "unenroll"
33319
+ });
33320
+ const schoolId = currentEnrollment.school.id;
33321
+ const masteryStatus = await client.getMasteryStatus(currentIntegration.courseId, studentId);
33322
+ if (!masteryStatus && !force) {
33323
+ throw new ValidationError(`Cannot unenroll course: mastery status is unavailable for course ${currentIntegration.courseId}. Pass { force: true } to unenroll without mastery data.`);
33324
+ }
33325
+ if (masteryStatus && !masteryStatus.isComplete && !force) {
33326
+ const unenrollment = {
33327
+ status: "not-mastered",
33328
+ currentCourseId: currentIntegration.courseId,
33329
+ masteredUnits: masteryStatus.masteredUnits,
33330
+ masterableUnits: masteryStatus.masterableUnits
33331
+ };
33332
+ logger20.debug("Skipping game-initiated course unenroll because mastery is incomplete", {
33333
+ event: "timeback.course.unenroll",
33334
+ outcome: "not-mastered",
33335
+ gameId,
33336
+ studentId,
33337
+ courseId: currentIntegration.courseId,
33338
+ subject: currentIntegration.subject,
33339
+ grade: currentIntegration.grade,
33340
+ masteredUnits: masteryStatus.masteredUnits,
33341
+ masterableUnits: masteryStatus.masterableUnits,
33342
+ forced: false,
33343
+ requesterUserId: user.id
33344
+ });
33345
+ return { status: "ok", unenrollment };
33346
+ }
33347
+ const forcedEarlyExit = Boolean(force && (!masteryStatus || !masteryStatus.isComplete));
33348
+ if (forcedEarlyExit) {
33349
+ logger20.warn("Force-unenrolled student before mastery completion", {
33350
+ event: "timeback.course.unenroll",
33351
+ outcome: "force-unenrolled",
33352
+ gameId,
33353
+ studentId,
33354
+ courseId: currentIntegration.courseId,
33355
+ subject: currentIntegration.subject,
33356
+ grade: currentIntegration.grade,
33357
+ masteredUnits: masteryStatus?.masteredUnits,
33358
+ masterableUnits: masteryStatus?.masterableUnits,
33359
+ masteryStatusAvailable: Boolean(masteryStatus),
33360
+ forced: true,
33361
+ requesterUserId: user.id
33362
+ });
33363
+ }
33364
+ await client.edubridge.enrollments.unenroll(studentId, currentIntegration.courseId, schoolId ? { schoolId } : {});
33365
+ client.invalidateEnrollments(studentId);
33366
+ logger20.info("Game-initiated course unenroll completed", {
33367
+ event: "timeback.course.unenroll",
33368
+ outcome: "unenrolled",
33369
+ gameId,
33370
+ studentId,
33371
+ courseId: currentIntegration.courseId,
33372
+ subject: currentIntegration.subject,
33373
+ grade: currentIntegration.grade,
33374
+ masteredUnits: masteryStatus?.masteredUnits,
33375
+ masterableUnits: masteryStatus?.masterableUnits,
33376
+ forced: forcedEarlyExit,
33377
+ requesterUserId: user.id
33378
+ });
33379
+ return {
33380
+ status: "ok",
33381
+ unenrollment: {
33382
+ status: "unenrolled",
33383
+ currentCourseId: currentIntegration.courseId,
33384
+ ...masteryStatus ? {
33385
+ masteredUnits: masteryStatus.masteredUnits,
33386
+ masterableUnits: masteryStatus.masterableUnits
33387
+ } : {},
33388
+ ...forcedEarlyExit ? { forced: true } : {}
33389
+ }
33390
+ };
33391
+ }
33277
33392
  async recordHeartbeat({
33278
33393
  gameId,
33279
33394
  studentId,
@@ -33448,6 +33563,25 @@ var init_timeback_service = __esm(() => {
33448
33563
  });
33449
33564
  return result;
33450
33565
  }
33566
+ async getStudentHighestGradeMastered(timebackId, user, options) {
33567
+ const client = this.requireClient();
33568
+ const db2 = this.deps.db;
33569
+ await this.deps.validateDeveloperAccess(user, options.gameId);
33570
+ const integration = await db2.query.gameTimebackIntegrations.findFirst({
33571
+ where: and(eq(gameTimebackIntegrations.gameId, options.gameId), eq(gameTimebackIntegrations.subject, options.subject))
33572
+ });
33573
+ if (!integration) {
33574
+ throw new ValidationError(`Subject "${options.subject}" is not configured for game ${options.gameId}`);
33575
+ }
33576
+ const result = await client.getHighestGradeMastered(timebackId, options.subject);
33577
+ logger20.debug("Retrieved student highest grade mastered", {
33578
+ timebackId,
33579
+ gameId: options.gameId,
33580
+ subject: options.subject,
33581
+ highestGradeMastered: result.highestGradeMastered
33582
+ });
33583
+ return result;
33584
+ }
33451
33585
  };
33452
33586
  });
33453
33587
 
@@ -34677,6 +34811,19 @@ async function getTimebackTokenResponse(config2) {
34677
34811
  function getAuthUrl(environment = "production") {
34678
34812
  return TIMEBACK_AUTH_URLS5[environment];
34679
34813
  }
34814
+ function parseEduBridgeGrade(value) {
34815
+ if (value === null || value === undefined || value.trim() === "") {
34816
+ return null;
34817
+ }
34818
+ const parsed = Number(value);
34819
+ return isTimebackGrade3(parsed) ? parsed : null;
34820
+ }
34821
+ function normalizeHighestGradeMastered(response, subject) {
34822
+ return {
34823
+ subject,
34824
+ highestGradeMastered: parseEduBridgeGrade(response.grades.highestGradeOverall)
34825
+ };
34826
+ }
34680
34827
  function handleHttpError(res, errorBody, attempt, retries, url2) {
34681
34828
  const error = new TimebackApiError2(res.status, res.statusText, errorBody);
34682
34829
  if (res.status >= HTTP_STATUS5.CLIENT_ERROR_MIN && res.status < HTTP_STATUS5.CLIENT_ERROR_MAX) {
@@ -35021,7 +35168,8 @@ function createEduBridgeNamespace(client) {
35021
35168
  const analytics = {
35022
35169
  getEnrollmentFacts: async (enrollmentId, options) => client["request"](buildPath(`/edubridge/analytics/enrollment/${enrollmentId}`, {
35023
35170
  timezone: options?.timezone
35024
- }), "GET")
35171
+ }), "GET"),
35172
+ getHighestGradeMastered: async (studentId, subject) => client["request"](`/edubridge/analytics/highestGradeMastered/${encodeURIComponent(studentId)}/${encodeURIComponent(subject)}`, "GET")
35025
35173
  };
35026
35174
  return {
35027
35175
  enrollments,
@@ -36763,6 +36911,11 @@ class TimebackClient {
36763
36911
  ...options?.include?.perCourse && { courses }
36764
36912
  };
36765
36913
  }
36914
+ async getHighestGradeMastered(studentId, subject) {
36915
+ await this._ensureAuthenticated();
36916
+ const response = await this.edubridge.analytics.getHighestGradeMastered(studentId, subject);
36917
+ return normalizeHighestGradeMastered(response, subject);
36918
+ }
36766
36919
  async getStudentXp(studentId, options) {
36767
36920
  await this._ensureAuthenticated();
36768
36921
  const enrollments = await this.edubridge.enrollments.listByUser(studentId);
@@ -94085,7 +94238,7 @@ var init_session_controller = __esm(() => {
94085
94238
  });
94086
94239
 
94087
94240
  // ../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;
94241
+ var logger45, populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, updateIntegration, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, 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
94242
  var init_timeback_controller = __esm(() => {
94090
94243
  init_esm();
94091
94244
  init_schemas_index();
@@ -94323,6 +94476,20 @@ var init_timeback_controller = __esm(() => {
94323
94476
  user: ctx.user
94324
94477
  });
94325
94478
  });
94479
+ unenrollCourse = requireDeveloper(async (ctx) => {
94480
+ const body2 = await parseRequestBody(ctx.request, UnenrollCourseRequestSchema);
94481
+ logger45.debug("Unenrolling student from current course", {
94482
+ userId: ctx.user.id,
94483
+ gameId: body2.gameId,
94484
+ studentId: body2.studentId,
94485
+ subject: body2.subject,
94486
+ force: body2.force
94487
+ });
94488
+ return ctx.services.timeback.unenrollCourse({
94489
+ ...body2,
94490
+ user: ctx.user
94491
+ });
94492
+ });
94326
94493
  getStudentXp = requireDeveloper(async (ctx) => {
94327
94494
  const timebackId = ctx.params.timebackId;
94328
94495
  if (!timebackId) {
@@ -94415,6 +94582,33 @@ var init_timeback_controller = __esm(() => {
94415
94582
  include
94416
94583
  });
94417
94584
  });
94585
+ getStudentHighestGradeMastered = requireDeveloper(async (ctx) => {
94586
+ const timebackId = ctx.params.timebackId;
94587
+ if (!timebackId) {
94588
+ throw ApiError.badRequest("Missing timebackId parameter");
94589
+ }
94590
+ const gameId = ctx.url.searchParams.get("gameId");
94591
+ if (!gameId) {
94592
+ throw ApiError.badRequest("Missing required gameId query parameter");
94593
+ }
94594
+ const subjectParam = ctx.url.searchParams.get("subject");
94595
+ if (!subjectParam) {
94596
+ throw ApiError.badRequest("Missing required subject query parameter");
94597
+ }
94598
+ if (!isTimebackSubject2(subjectParam)) {
94599
+ throw ApiError.badRequest(`Invalid subject: ${subjectParam}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
94600
+ }
94601
+ logger45.debug("Getting student highest grade mastered", {
94602
+ requesterId: ctx.user.id,
94603
+ timebackId,
94604
+ gameId,
94605
+ subject: subjectParam
94606
+ });
94607
+ return ctx.services.timeback.getStudentHighestGradeMastered(timebackId, ctx.user, {
94608
+ gameId,
94609
+ subject: subjectParam
94610
+ });
94611
+ });
94418
94612
  getRoster = requireGameManagementAccess(async (ctx) => {
94419
94613
  const gameId = ctx.params.gameId;
94420
94614
  const courseId = ctx.params.courseId;
@@ -94750,8 +94944,10 @@ var init_timeback_controller = __esm(() => {
94750
94944
  endActivity,
94751
94945
  heartbeat,
94752
94946
  advanceCourse,
94947
+ unenrollCourse,
94753
94948
  getStudentXp,
94754
94949
  getStudentMastery,
94950
+ getStudentHighestGradeMastered,
94755
94951
  getRoster,
94756
94952
  getStudentOverview,
94757
94953
  getGameMetrics,
@@ -94960,6 +95156,10 @@ async function getMockTimebackUser(db2, gameId) {
94960
95156
  const timebackId = config.timeback.timebackId || "mock-student-00000001";
94961
95157
  return getMockTimebackData(db2, timebackId, gameId);
94962
95158
  }
95159
+ function getMockHighestGradeMastered(enrollments, subject) {
95160
+ const subjectGrades = enrollments.filter((enrollment) => enrollment.subject === subject).map((enrollment) => enrollment.grade);
95161
+ return subjectGrades.length > 0 ? Math.max(...subjectGrades) : null;
95162
+ }
94963
95163
  async function buildMockUserResponse(db2, user, gameId) {
94964
95164
  const timeback3 = user.timebackId ? await getMockTimebackData(db2, user.timebackId, gameId) : undefined;
94965
95165
  if (gameId) {
@@ -95654,6 +95854,7 @@ var init_timeback6 = __esm(() => {
95654
95854
  timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
95655
95855
  timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
95656
95856
  timebackRouter.post("/advance-course", handle2(timeback2.advanceCourse));
95857
+ timebackRouter.post("/unenroll-course", handle2(timeback2.unenrollCourse));
95657
95858
  timebackRouter.get("/user", async (c2) => {
95658
95859
  const user = c2.get("user");
95659
95860
  const gameId = c2.get("gameId");
@@ -95806,6 +96007,39 @@ var init_timeback6 = __esm(() => {
95806
96007
  }
95807
96008
  return handle2(timeback2.getStudentMastery)(c2);
95808
96009
  });
96010
+ timebackRouter.get("/student-highest-grade-mastered/:timebackId", async (c2) => {
96011
+ const user = c2.get("user");
96012
+ if (!user) {
96013
+ const error2 = ApiError.unauthorized("Must be logged in to get student highest grade mastered");
96014
+ return c2.json(createErrorResponse(error2), error2.status);
96015
+ }
96016
+ if (shouldMockTimeback()) {
96017
+ const url2 = new URL(c2.req.url);
96018
+ const subject = url2.searchParams.get("subject");
96019
+ const contextGameId = c2.get("gameId");
96020
+ const gameId = url2.searchParams.get("gameId") || (typeof contextGameId === "string" ? contextGameId : undefined);
96021
+ if (!subject) {
96022
+ const error2 = ApiError.badRequest("Missing required subject query parameter");
96023
+ return c2.json(createErrorResponse(error2), error2.status);
96024
+ }
96025
+ if (!gameId) {
96026
+ const error2 = ApiError.badRequest("Missing required gameId query parameter");
96027
+ return c2.json(createErrorResponse(error2), error2.status);
96028
+ }
96029
+ if (!isTimebackSubject2(subject)) {
96030
+ const error2 = ApiError.badRequest(`Invalid subject: ${subject}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
96031
+ return c2.json(createErrorResponse(error2), error2.status);
96032
+ }
96033
+ const db2 = c2.get("db");
96034
+ const enrollments = await getMockEnrollments(db2);
96035
+ const gameScopedEnrollments = filterEnrollmentsByGame(enrollments, gameId);
96036
+ return c2.json({
96037
+ subject,
96038
+ highestGradeMastered: getMockHighestGradeMastered(gameScopedEnrollments, subject)
96039
+ });
96040
+ }
96041
+ return handle2(timeback2.getStudentHighestGradeMastered)(c2);
96042
+ });
95809
96043
  });
95810
96044
 
95811
96045
  // src/routes/integrations/lti.ts
@@ -98183,7 +98417,8 @@ program2.name("playcademy-sandbox").description("Local development server for Pl
98183
98417
  port,
98184
98418
  url: `http://localhost:${port}/api`,
98185
98419
  startedAt: Date.now(),
98186
- projectRoot: process.cwd()
98420
+ projectRoot: process.cwd(),
98421
+ gameId: server.gameId
98187
98422
  });
98188
98423
  const totalCourses = project?.timebackCourses?.length ?? 0;
98189
98424
  const excludedCount = options.timebackExcludedCourses ? options.timebackExcludedCourses.split(",").filter(Boolean).length : 0;
package/dist/constants.js CHANGED
@@ -84,8 +84,10 @@ var init_timeback = __esm(() => {
84
84
  END_ACTIVITY: "/integrations/timeback/end-activity",
85
85
  GET_XP: "/integrations/timeback/xp",
86
86
  GET_MASTERY: "/integrations/timeback/mastery",
87
+ GET_HIGHEST_GRADE_MASTERED: "/integrations/timeback/highest-grade-mastered",
87
88
  HEARTBEAT: "/integrations/timeback/heartbeat",
88
- ADVANCE_COURSE: "/integrations/timeback/advance-course"
89
+ ADVANCE_COURSE: "/integrations/timeback/advance-course",
90
+ UNENROLL_COURSE: "/integrations/timeback/unenroll-course"
89
91
  };
90
92
  TIMEBACK_COURSE_DEFAULTS = {
91
93
  gradingScheme: "STANDARD",
@@ -30,6 +30,7 @@ export declare function getMockTimebackData(db: DatabaseInstance, timebackId: st
30
30
  * Uses the configured timebackId from sandbox config.
31
31
  */
32
32
  export declare function getMockTimebackUser(db: DatabaseInstance, gameId?: string): Promise<UserTimebackData>;
33
+ export declare function getMockHighestGradeMastered(enrollments: UserEnrollment[], subject: string): number | null;
33
34
  /**
34
35
  * Build a complete user response with mock timeback data.
35
36
  * Used to bypass api-core when in mock mode (avoids real API calls).
package/dist/server.js CHANGED
@@ -248,8 +248,10 @@ var init_timeback2 = __esm(() => {
248
248
  END_ACTIVITY: "/integrations/timeback/end-activity",
249
249
  GET_XP: "/integrations/timeback/xp",
250
250
  GET_MASTERY: "/integrations/timeback/mastery",
251
+ GET_HIGHEST_GRADE_MASTERED: "/integrations/timeback/highest-grade-mastered",
251
252
  HEARTBEAT: "/integrations/timeback/heartbeat",
252
- ADVANCE_COURSE: "/integrations/timeback/advance-course"
253
+ ADVANCE_COURSE: "/integrations/timeback/advance-course",
254
+ UNENROLL_COURSE: "/integrations/timeback/unenroll-course"
253
255
  };
254
256
  TIMEBACK_COURSE_DEFAULTS = {
255
257
  gradingScheme: "STANDARD",
@@ -1075,7 +1077,7 @@ var package_default;
1075
1077
  var init_package = __esm(() => {
1076
1078
  package_default = {
1077
1079
  name: "@playcademy/sandbox",
1078
- version: "0.4.1",
1080
+ version: "0.4.2-beta.2",
1079
1081
  description: "Local development server for Playcademy game development",
1080
1082
  type: "module",
1081
1083
  exports: {
@@ -28246,8 +28248,10 @@ var init_constants3 = __esm(() => {
28246
28248
  END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
28247
28249
  GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
28248
28250
  GET_MASTERY: `/api${TIMEBACK_ROUTES.GET_MASTERY}`,
28251
+ GET_HIGHEST_GRADE_MASTERED: `/api${TIMEBACK_ROUTES.GET_HIGHEST_GRADE_MASTERED}`,
28249
28252
  HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`,
28250
- ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`
28253
+ ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`,
28254
+ UNENROLL_COURSE: `/api${TIMEBACK_ROUTES.UNENROLL_COURSE}`
28251
28255
  }
28252
28256
  };
28253
28257
  });
@@ -29125,6 +29129,7 @@ var init_utils6 = __esm(() => {
29125
29129
  };
29126
29130
  });
29127
29131
  init_constants7();
29132
+ init_constants7();
29128
29133
  if (process.env.DEBUG === "true") {
29129
29134
  process.env.TERM = "dumb";
29130
29135
  }
@@ -29901,7 +29906,7 @@ function isValidAdminAttributionDate(value) {
29901
29906
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
29902
29907
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
29903
29908
  }
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;
29909
+ 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
29910
  var init_schemas4 = __esm(() => {
29906
29911
  init_drizzle_zod();
29907
29912
  init_esm();
@@ -30003,6 +30008,12 @@ var init_schemas4 = __esm(() => {
30003
30008
  studentId: exports_external.string().min(1),
30004
30009
  subject: TimebackSubjectSchema.optional()
30005
30010
  });
30011
+ UnenrollCourseRequestSchema = exports_external.object({
30012
+ gameId: exports_external.string().uuid(),
30013
+ studentId: exports_external.string().min(1),
30014
+ subject: TimebackSubjectSchema.optional(),
30015
+ force: exports_external.boolean().optional()
30016
+ });
30006
30017
  HeartbeatRequestSchema = exports_external.object({
30007
30018
  gameId: exports_external.string().uuid(),
30008
30019
  studentId: exports_external.string().min(1),
@@ -33207,15 +33218,14 @@ var init_timeback_service = __esm(() => {
33207
33218
  inProgress: result.inProgress
33208
33219
  };
33209
33220
  }
33210
- async advanceCourse({
33221
+ async resolveActiveGameCourse({
33211
33222
  gameId,
33212
33223
  studentId,
33213
33224
  subject,
33214
- user
33225
+ action
33215
33226
  }) {
33216
33227
  const client = this.requireClient();
33217
33228
  const db2 = this.deps.db;
33218
- await this.deps.validateDeveloperAccess(user, gameId);
33219
33229
  const integrations = await db2.query.gameTimebackIntegrations.findMany({
33220
33230
  where: subject ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.subject, subject)) : eq(gameTimebackIntegrations.gameId, gameId)
33221
33231
  });
@@ -33230,12 +33240,30 @@ var init_timeback_service = __esm(() => {
33230
33240
  }
33231
33241
  const subjectsInPlay = new Set(enrolledIntegrations.map((i2) => i2.subject));
33232
33242
  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`);
33243
+ throw new ValidationError(`Ambiguous Timeback ${action}: student is enrolled in ${subjectsInPlay.size} parallel ladders (${[...subjectsInPlay].join(", ")}); pass { subject } to disambiguate`);
33234
33244
  }
33235
33245
  const currentIntegration = enrolledIntegrations.toSorted((left, right) => left.grade - right.grade)[0];
33246
+ const currentEnrollment = enrollments.find((enrollment) => enrollment.course.id === currentIntegration.courseId);
33247
+ return { currentIntegration, currentEnrollment, enrollments };
33248
+ }
33249
+ async advanceCourse({
33250
+ gameId,
33251
+ studentId,
33252
+ subject,
33253
+ user
33254
+ }) {
33255
+ const client = this.requireClient();
33256
+ const db2 = this.deps.db;
33257
+ await this.deps.validateDeveloperAccess(user, gameId);
33258
+ const { currentIntegration, enrollments } = await this.resolveActiveGameCourse({
33259
+ gameId,
33260
+ studentId,
33261
+ subject,
33262
+ action: "advance"
33263
+ });
33236
33264
  const masteryStatus = await client.getMasteryStatus(currentIntegration.courseId, studentId);
33237
33265
  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().`);
33266
+ 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
33267
  }
33240
33268
  if (!masteryStatus.isComplete) {
33241
33269
  const promotion2 = {
@@ -33273,6 +33301,93 @@ var init_timeback_service = __esm(() => {
33273
33301
  });
33274
33302
  return { status: "ok", promotion };
33275
33303
  }
33304
+ async unenrollCourse({
33305
+ gameId,
33306
+ studentId,
33307
+ subject,
33308
+ force,
33309
+ user
33310
+ }) {
33311
+ const client = this.requireClient();
33312
+ await this.deps.validateDeveloperAccess(user, gameId);
33313
+ const { currentIntegration, currentEnrollment } = await this.resolveActiveGameCourse({
33314
+ gameId,
33315
+ studentId,
33316
+ subject,
33317
+ action: "unenroll"
33318
+ });
33319
+ const schoolId = currentEnrollment.school.id;
33320
+ const masteryStatus = await client.getMasteryStatus(currentIntegration.courseId, studentId);
33321
+ if (!masteryStatus && !force) {
33322
+ throw new ValidationError(`Cannot unenroll course: mastery status is unavailable for course ${currentIntegration.courseId}. Pass { force: true } to unenroll without mastery data.`);
33323
+ }
33324
+ if (masteryStatus && !masteryStatus.isComplete && !force) {
33325
+ const unenrollment = {
33326
+ status: "not-mastered",
33327
+ currentCourseId: currentIntegration.courseId,
33328
+ masteredUnits: masteryStatus.masteredUnits,
33329
+ masterableUnits: masteryStatus.masterableUnits
33330
+ };
33331
+ logger20.debug("Skipping game-initiated course unenroll because mastery is incomplete", {
33332
+ event: "timeback.course.unenroll",
33333
+ outcome: "not-mastered",
33334
+ gameId,
33335
+ studentId,
33336
+ courseId: currentIntegration.courseId,
33337
+ subject: currentIntegration.subject,
33338
+ grade: currentIntegration.grade,
33339
+ masteredUnits: masteryStatus.masteredUnits,
33340
+ masterableUnits: masteryStatus.masterableUnits,
33341
+ forced: false,
33342
+ requesterUserId: user.id
33343
+ });
33344
+ return { status: "ok", unenrollment };
33345
+ }
33346
+ const forcedEarlyExit = Boolean(force && (!masteryStatus || !masteryStatus.isComplete));
33347
+ if (forcedEarlyExit) {
33348
+ logger20.warn("Force-unenrolled student before mastery completion", {
33349
+ event: "timeback.course.unenroll",
33350
+ outcome: "force-unenrolled",
33351
+ gameId,
33352
+ studentId,
33353
+ courseId: currentIntegration.courseId,
33354
+ subject: currentIntegration.subject,
33355
+ grade: currentIntegration.grade,
33356
+ masteredUnits: masteryStatus?.masteredUnits,
33357
+ masterableUnits: masteryStatus?.masterableUnits,
33358
+ masteryStatusAvailable: Boolean(masteryStatus),
33359
+ forced: true,
33360
+ requesterUserId: user.id
33361
+ });
33362
+ }
33363
+ await client.edubridge.enrollments.unenroll(studentId, currentIntegration.courseId, schoolId ? { schoolId } : {});
33364
+ client.invalidateEnrollments(studentId);
33365
+ logger20.info("Game-initiated course unenroll completed", {
33366
+ event: "timeback.course.unenroll",
33367
+ outcome: "unenrolled",
33368
+ gameId,
33369
+ studentId,
33370
+ courseId: currentIntegration.courseId,
33371
+ subject: currentIntegration.subject,
33372
+ grade: currentIntegration.grade,
33373
+ masteredUnits: masteryStatus?.masteredUnits,
33374
+ masterableUnits: masteryStatus?.masterableUnits,
33375
+ forced: forcedEarlyExit,
33376
+ requesterUserId: user.id
33377
+ });
33378
+ return {
33379
+ status: "ok",
33380
+ unenrollment: {
33381
+ status: "unenrolled",
33382
+ currentCourseId: currentIntegration.courseId,
33383
+ ...masteryStatus ? {
33384
+ masteredUnits: masteryStatus.masteredUnits,
33385
+ masterableUnits: masteryStatus.masterableUnits
33386
+ } : {},
33387
+ ...forcedEarlyExit ? { forced: true } : {}
33388
+ }
33389
+ };
33390
+ }
33276
33391
  async recordHeartbeat({
33277
33392
  gameId,
33278
33393
  studentId,
@@ -33447,6 +33562,25 @@ var init_timeback_service = __esm(() => {
33447
33562
  });
33448
33563
  return result;
33449
33564
  }
33565
+ async getStudentHighestGradeMastered(timebackId, user, options) {
33566
+ const client = this.requireClient();
33567
+ const db2 = this.deps.db;
33568
+ await this.deps.validateDeveloperAccess(user, options.gameId);
33569
+ const integration = await db2.query.gameTimebackIntegrations.findFirst({
33570
+ where: and(eq(gameTimebackIntegrations.gameId, options.gameId), eq(gameTimebackIntegrations.subject, options.subject))
33571
+ });
33572
+ if (!integration) {
33573
+ throw new ValidationError(`Subject "${options.subject}" is not configured for game ${options.gameId}`);
33574
+ }
33575
+ const result = await client.getHighestGradeMastered(timebackId, options.subject);
33576
+ logger20.debug("Retrieved student highest grade mastered", {
33577
+ timebackId,
33578
+ gameId: options.gameId,
33579
+ subject: options.subject,
33580
+ highestGradeMastered: result.highestGradeMastered
33581
+ });
33582
+ return result;
33583
+ }
33450
33584
  };
33451
33585
  });
33452
33586
 
@@ -34676,6 +34810,19 @@ async function getTimebackTokenResponse(config2) {
34676
34810
  function getAuthUrl(environment = "production") {
34677
34811
  return TIMEBACK_AUTH_URLS5[environment];
34678
34812
  }
34813
+ function parseEduBridgeGrade(value) {
34814
+ if (value === null || value === undefined || value.trim() === "") {
34815
+ return null;
34816
+ }
34817
+ const parsed = Number(value);
34818
+ return isTimebackGrade3(parsed) ? parsed : null;
34819
+ }
34820
+ function normalizeHighestGradeMastered(response, subject) {
34821
+ return {
34822
+ subject,
34823
+ highestGradeMastered: parseEduBridgeGrade(response.grades.highestGradeOverall)
34824
+ };
34825
+ }
34679
34826
  function handleHttpError(res, errorBody, attempt, retries, url2) {
34680
34827
  const error = new TimebackApiError2(res.status, res.statusText, errorBody);
34681
34828
  if (res.status >= HTTP_STATUS5.CLIENT_ERROR_MIN && res.status < HTTP_STATUS5.CLIENT_ERROR_MAX) {
@@ -35020,7 +35167,8 @@ function createEduBridgeNamespace(client) {
35020
35167
  const analytics = {
35021
35168
  getEnrollmentFacts: async (enrollmentId, options) => client["request"](buildPath(`/edubridge/analytics/enrollment/${enrollmentId}`, {
35022
35169
  timezone: options?.timezone
35023
- }), "GET")
35170
+ }), "GET"),
35171
+ getHighestGradeMastered: async (studentId, subject) => client["request"](`/edubridge/analytics/highestGradeMastered/${encodeURIComponent(studentId)}/${encodeURIComponent(subject)}`, "GET")
35024
35172
  };
35025
35173
  return {
35026
35174
  enrollments,
@@ -36762,6 +36910,11 @@ class TimebackClient {
36762
36910
  ...options?.include?.perCourse && { courses }
36763
36911
  };
36764
36912
  }
36913
+ async getHighestGradeMastered(studentId, subject) {
36914
+ await this._ensureAuthenticated();
36915
+ const response = await this.edubridge.analytics.getHighestGradeMastered(studentId, subject);
36916
+ return normalizeHighestGradeMastered(response, subject);
36917
+ }
36765
36918
  async getStudentXp(studentId, options) {
36766
36919
  await this._ensureAuthenticated();
36767
36920
  const enrollments = await this.edubridge.enrollments.listByUser(studentId);
@@ -94084,7 +94237,7 @@ var init_session_controller = __esm(() => {
94084
94237
  });
94085
94238
 
94086
94239
  // ../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;
94240
+ var logger45, populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, updateIntegration, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, 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
94241
  var init_timeback_controller = __esm(() => {
94089
94242
  init_esm();
94090
94243
  init_schemas_index();
@@ -94322,6 +94475,20 @@ var init_timeback_controller = __esm(() => {
94322
94475
  user: ctx.user
94323
94476
  });
94324
94477
  });
94478
+ unenrollCourse = requireDeveloper(async (ctx) => {
94479
+ const body2 = await parseRequestBody(ctx.request, UnenrollCourseRequestSchema);
94480
+ logger45.debug("Unenrolling student from current course", {
94481
+ userId: ctx.user.id,
94482
+ gameId: body2.gameId,
94483
+ studentId: body2.studentId,
94484
+ subject: body2.subject,
94485
+ force: body2.force
94486
+ });
94487
+ return ctx.services.timeback.unenrollCourse({
94488
+ ...body2,
94489
+ user: ctx.user
94490
+ });
94491
+ });
94325
94492
  getStudentXp = requireDeveloper(async (ctx) => {
94326
94493
  const timebackId = ctx.params.timebackId;
94327
94494
  if (!timebackId) {
@@ -94414,6 +94581,33 @@ var init_timeback_controller = __esm(() => {
94414
94581
  include
94415
94582
  });
94416
94583
  });
94584
+ getStudentHighestGradeMastered = requireDeveloper(async (ctx) => {
94585
+ const timebackId = ctx.params.timebackId;
94586
+ if (!timebackId) {
94587
+ throw ApiError.badRequest("Missing timebackId parameter");
94588
+ }
94589
+ const gameId = ctx.url.searchParams.get("gameId");
94590
+ if (!gameId) {
94591
+ throw ApiError.badRequest("Missing required gameId query parameter");
94592
+ }
94593
+ const subjectParam = ctx.url.searchParams.get("subject");
94594
+ if (!subjectParam) {
94595
+ throw ApiError.badRequest("Missing required subject query parameter");
94596
+ }
94597
+ if (!isTimebackSubject2(subjectParam)) {
94598
+ throw ApiError.badRequest(`Invalid subject: ${subjectParam}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
94599
+ }
94600
+ logger45.debug("Getting student highest grade mastered", {
94601
+ requesterId: ctx.user.id,
94602
+ timebackId,
94603
+ gameId,
94604
+ subject: subjectParam
94605
+ });
94606
+ return ctx.services.timeback.getStudentHighestGradeMastered(timebackId, ctx.user, {
94607
+ gameId,
94608
+ subject: subjectParam
94609
+ });
94610
+ });
94417
94611
  getRoster = requireGameManagementAccess(async (ctx) => {
94418
94612
  const gameId = ctx.params.gameId;
94419
94613
  const courseId = ctx.params.courseId;
@@ -94749,8 +94943,10 @@ var init_timeback_controller = __esm(() => {
94749
94943
  endActivity,
94750
94944
  heartbeat,
94751
94945
  advanceCourse,
94946
+ unenrollCourse,
94752
94947
  getStudentXp,
94753
94948
  getStudentMastery,
94949
+ getStudentHighestGradeMastered,
94754
94950
  getRoster,
94755
94951
  getStudentOverview,
94756
94952
  getGameMetrics,
@@ -94959,6 +95155,10 @@ async function getMockTimebackUser(db2, gameId) {
94959
95155
  const timebackId = config.timeback.timebackId || "mock-student-00000001";
94960
95156
  return getMockTimebackData(db2, timebackId, gameId);
94961
95157
  }
95158
+ function getMockHighestGradeMastered(enrollments, subject) {
95159
+ const subjectGrades = enrollments.filter((enrollment) => enrollment.subject === subject).map((enrollment) => enrollment.grade);
95160
+ return subjectGrades.length > 0 ? Math.max(...subjectGrades) : null;
95161
+ }
94962
95162
  async function buildMockUserResponse(db2, user, gameId) {
94963
95163
  const timeback3 = user.timebackId ? await getMockTimebackData(db2, user.timebackId, gameId) : undefined;
94964
95164
  if (gameId) {
@@ -95653,6 +95853,7 @@ var init_timeback6 = __esm(() => {
95653
95853
  timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
95654
95854
  timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
95655
95855
  timebackRouter.post("/advance-course", handle2(timeback2.advanceCourse));
95856
+ timebackRouter.post("/unenroll-course", handle2(timeback2.unenrollCourse));
95656
95857
  timebackRouter.get("/user", async (c2) => {
95657
95858
  const user = c2.get("user");
95658
95859
  const gameId = c2.get("gameId");
@@ -95805,6 +96006,39 @@ var init_timeback6 = __esm(() => {
95805
96006
  }
95806
96007
  return handle2(timeback2.getStudentMastery)(c2);
95807
96008
  });
96009
+ timebackRouter.get("/student-highest-grade-mastered/:timebackId", async (c2) => {
96010
+ const user = c2.get("user");
96011
+ if (!user) {
96012
+ const error2 = ApiError.unauthorized("Must be logged in to get student highest grade mastered");
96013
+ return c2.json(createErrorResponse(error2), error2.status);
96014
+ }
96015
+ if (shouldMockTimeback()) {
96016
+ const url2 = new URL(c2.req.url);
96017
+ const subject = url2.searchParams.get("subject");
96018
+ const contextGameId = c2.get("gameId");
96019
+ const gameId = url2.searchParams.get("gameId") || (typeof contextGameId === "string" ? contextGameId : undefined);
96020
+ if (!subject) {
96021
+ const error2 = ApiError.badRequest("Missing required subject query parameter");
96022
+ return c2.json(createErrorResponse(error2), error2.status);
96023
+ }
96024
+ if (!gameId) {
96025
+ const error2 = ApiError.badRequest("Missing required gameId query parameter");
96026
+ return c2.json(createErrorResponse(error2), error2.status);
96027
+ }
96028
+ if (!isTimebackSubject2(subject)) {
96029
+ const error2 = ApiError.badRequest(`Invalid subject: ${subject}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
96030
+ return c2.json(createErrorResponse(error2), error2.status);
96031
+ }
96032
+ const db2 = c2.get("db");
96033
+ const enrollments = await getMockEnrollments(db2);
96034
+ const gameScopedEnrollments = filterEnrollmentsByGame(enrollments, gameId);
96035
+ return c2.json({
96036
+ subject,
96037
+ highestGradeMastered: getMockHighestGradeMastered(gameScopedEnrollments, subject)
96038
+ });
96039
+ }
96040
+ return handle2(timeback2.getStudentHighestGradeMastered)(c2);
96041
+ });
95808
96042
  });
95809
96043
 
95810
96044
  // src/routes/integrations/lti.ts
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.2",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {