@playcademy/sandbox 0.3.17-beta.34 → 0.3.17-beta.36

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.
Files changed (3) hide show
  1. package/dist/cli.js +96 -152
  2. package/dist/server.js +96 -152
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1330,7 +1330,7 @@ var package_default;
1330
1330
  var init_package = __esm(() => {
1331
1331
  package_default = {
1332
1332
  name: "@playcademy/sandbox",
1333
- version: "0.3.17-beta.34",
1333
+ version: "0.3.17-beta.36",
1334
1334
  description: "Local development server for Playcademy game development",
1335
1335
  type: "module",
1336
1336
  exports: {
@@ -30433,6 +30433,24 @@ function resolveAdminEventTime(data) {
30433
30433
  }
30434
30434
  return toAttributionEventTime(data.date);
30435
30435
  }
30436
+ function validateMasteryAdjustment(delta, currentMastered, masterableUnits) {
30437
+ if (delta < 0 && currentMastered + delta < 0) {
30438
+ throw new ValidationError(`Adjustment would go below 0. Current: ${currentMastered}, adjustment: ${delta}`);
30439
+ }
30440
+ if (delta < 0 && typeof masterableUnits === "number" && masterableUnits > 0 && currentMastered > masterableUnits && currentMastered + delta > masterableUnits) {
30441
+ const minDelta = masterableUnits - currentMastered;
30442
+ throw new ValidationError(`Adjustment must reduce mastery to at most ${masterableUnits}. Current: ${currentMastered}/${masterableUnits}, minimum adjustment: ${minDelta}`);
30443
+ }
30444
+ if (delta > 0 && typeof masterableUnits === "number" && masterableUnits > 0) {
30445
+ if (currentMastered >= masterableUnits) {
30446
+ throw new ValidationError(`Mastery is already at maximum (${currentMastered}/${masterableUnits}). Only negative adjustments are allowed.`);
30447
+ }
30448
+ if (currentMastered + delta > masterableUnits) {
30449
+ const remaining = masterableUnits - currentMastered;
30450
+ throw new ValidationError(`Adjustment would exceed maximum. Current: ${currentMastered}/${masterableUnits}, max adjustment: +${remaining}`);
30451
+ }
30452
+ }
30453
+ }
30436
30454
  function compareEnrollmentsByRecency(a, b) {
30437
30455
  const dateCompare = (b.beginDate ?? "").localeCompare(a.beginDate ?? "");
30438
30456
  if (dateCompare !== 0) {
@@ -30792,17 +30810,6 @@ class TimebackAdminService {
30792
30810
  }
30793
30811
  return this.deps.timeback;
30794
30812
  }
30795
- async recordCourseCompletionHistory(client, data) {
30796
- await client.recordAdminCourseCompletionChange(data).catch((error) => {
30797
- logger16.error("Failed to record admin course completion history event", {
30798
- gameId: data.gameId,
30799
- courseId: data.courseId,
30800
- studentId: data.studentId,
30801
- action: data.action,
30802
- error: error instanceof Error ? error.message : String(error)
30803
- });
30804
- });
30805
- }
30806
30813
  async resolveAdminMutationContext(gameId, courseId, user, studentId, accessLevel = "developer") {
30807
30814
  const client = this.requireClient();
30808
30815
  if (accessLevel === "dashboard") {
@@ -30901,7 +30908,7 @@ class TimebackAdminService {
30901
30908
  if (typeof masterableUnits !== "number" || masterableUnits <= 0) {
30902
30909
  return;
30903
30910
  }
30904
- return Math.min(100, Math.round(masteredUnits / masterableUnits * 100));
30911
+ return Math.round(masteredUnits / masterableUnits * 100);
30905
30912
  }
30906
30913
  async getMasterableUnits(courseId) {
30907
30914
  const client = this.requireClient();
@@ -31177,6 +31184,7 @@ class TimebackAdminService {
31177
31184
  ...enrollmentSummaries ? { enrollments: enrollmentSummaries } : {}
31178
31185
  };
31179
31186
  });
31187
+ courses.sort((a, b) => a.grade - b.grade);
31180
31188
  return {
31181
31189
  student: {
31182
31190
  studentId,
@@ -31276,111 +31284,87 @@ class TimebackAdminService {
31276
31284
  return { status: "ok" };
31277
31285
  }
31278
31286
  async adjustMasteredUnits(data, user) {
31279
- const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
31287
+ const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user);
31288
+ let currentMastered = 0;
31289
+ const masterableUnits = await this.getMasterableUnits(data.courseId);
31290
+ if (data.units !== 0) {
31291
+ const enrollment = await this.assertStudentEnrolledInCourse(client, data.studentId, data.courseId);
31292
+ try {
31293
+ const analytics = await client.edubridge.analytics.getEnrollmentFacts(enrollment.id, { timezone: PLATFORM_TIMEZONE });
31294
+ currentMastered = this.summarizeAnalyticsFacts(analytics.facts).masteredUnits;
31295
+ } catch {
31296
+ throw new ValidationError("Unable to validate mastery bounds — analytics unavailable. Please retry.");
31297
+ }
31298
+ validateMasteryAdjustment(data.units, currentMastered, masterableUnits);
31299
+ }
31300
+ const pctCompleteApp = typeof masterableUnits === "number" && masterableUnits > 0 ? Math.min(100, Math.max(0, Math.round((currentMastered + data.units) / masterableUnits * 100))) : undefined;
31280
31301
  await client.recordAdminMasteryAdjustment({
31281
31302
  gameId: data.gameId,
31282
31303
  courseId: data.courseId,
31283
31304
  studentId: data.studentId,
31284
31305
  masteredUnits: data.units,
31306
+ pctCompleteApp,
31285
31307
  eventTime: resolveAdminEventTime(data),
31286
31308
  reason: data.reason,
31287
31309
  actor,
31288
31310
  appName,
31289
31311
  sensorUrl
31290
31312
  });
31291
- return { status: "ok" };
31292
- }
31293
- async toggleCourseCompletion(data, user) {
31294
- const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId, "dashboard");
31295
- const historyClient = client;
31296
- const ids = deriveSourcedIds(data.courseId);
31297
- const lineItemId = `${ids.course}-mastery-completion-assessment`;
31298
- const resultId = `${lineItemId}:${data.studentId}:completion`;
31299
- await client.oneroster.assessmentLineItems.findOrCreate(lineItemId, {
31300
- sourcedId: lineItemId,
31301
- title: "Mastery Completion",
31302
- status: ONEROSTER_STATUS.active,
31303
- course: { sourcedId: ids.course },
31304
- ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
31305
- });
31306
- if (data.action === "complete") {
31307
- const masterableUnits = await this.getMasterableUnits(data.courseId);
31308
- if (masterableUnits && masterableUnits > 0) {
31309
- const enrollment = await this.assertStudentEnrolledInCourse(client, data.studentId, data.courseId);
31310
- let currentMastered = 0;
31311
- try {
31312
- const analytics = await client.edubridge.analytics.getEnrollmentFacts(enrollment.id, { timezone: PLATFORM_TIMEZONE });
31313
- const summary = this.summarizeAnalyticsFacts(analytics.facts);
31314
- currentMastered = summary.masteredUnits;
31315
- } catch {
31316
- logger16.warn("Failed to load analytics for mastery gap calculation", {
31317
- studentId: data.studentId,
31318
- courseId: data.courseId
31313
+ if (typeof masterableUnits === "number" && masterableUnits > 0) {
31314
+ const wasMastered = currentMastered >= masterableUnits;
31315
+ const willBeMastered = currentMastered + data.units >= masterableUnits;
31316
+ if (wasMastered !== willBeMastered) {
31317
+ const ids = deriveSourcedIds(data.courseId);
31318
+ const lineItemId = `${ids.course}-mastery-completion-assessment`;
31319
+ const resultId = `${lineItemId}:${data.studentId}:completion`;
31320
+ if (willBeMastered) {
31321
+ await client.oneroster.assessmentLineItems.findOrCreate(lineItemId, {
31322
+ sourcedId: lineItemId,
31323
+ title: "Mastery Completion",
31324
+ status: ONEROSTER_STATUS.active,
31325
+ course: { sourcedId: ids.course },
31326
+ ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
31319
31327
  });
31320
- }
31321
- const gap = masterableUnits - currentMastered;
31322
- if (gap > 0) {
31323
- await client.recordAdminMasteryAdjustment({
31324
- gameId: data.gameId,
31325
- courseId: data.courseId,
31326
- studentId: data.studentId,
31327
- masteredUnits: gap,
31328
- reason: "Admin completed course",
31329
- actor,
31330
- appName,
31331
- sensorUrl
31328
+ await client.oneroster.assessmentResults.upsert(resultId, {
31329
+ sourcedId: resultId,
31330
+ status: ONEROSTER_STATUS.active,
31331
+ assessmentLineItem: { sourcedId: lineItemId },
31332
+ student: { sourcedId: data.studentId },
31333
+ score: 100,
31334
+ scoreDate: new Date().toISOString(),
31335
+ scoreStatus: SCORE_STATUS.fullyGraded,
31336
+ inProgress: "false",
31337
+ metadata: {
31338
+ isMasteryCompletion: true,
31339
+ adminAction: true,
31340
+ appName
31341
+ }
31332
31342
  });
31343
+ } else {
31344
+ try {
31345
+ await client.oneroster.assessmentResults.upsert(resultId, {
31346
+ sourcedId: resultId,
31347
+ status: ONEROSTER_STATUS.active,
31348
+ assessmentLineItem: { sourcedId: lineItemId },
31349
+ student: { sourcedId: data.studentId },
31350
+ score: 0,
31351
+ scoreDate: new Date().toISOString(),
31352
+ scoreStatus: SCORE_STATUS.notSubmitted,
31353
+ inProgress: "true",
31354
+ metadata: {
31355
+ isMasteryCompletion: true,
31356
+ adminAction: true,
31357
+ appName
31358
+ }
31359
+ });
31360
+ } catch {
31361
+ logger16.debug("No completion entry to revoke", {
31362
+ studentId: data.studentId,
31363
+ courseId: data.courseId
31364
+ });
31365
+ }
31333
31366
  }
31334
31367
  }
31335
- await client.oneroster.assessmentResults.upsert(resultId, {
31336
- sourcedId: resultId,
31337
- status: ONEROSTER_STATUS.active,
31338
- assessmentLineItem: { sourcedId: lineItemId },
31339
- student: { sourcedId: data.studentId },
31340
- score: 100,
31341
- scoreDate: new Date().toISOString(),
31342
- scoreStatus: SCORE_STATUS.fullyGraded,
31343
- inProgress: "false",
31344
- metadata: {
31345
- isMasteryCompletion: true,
31346
- adminAction: true,
31347
- appName
31348
- }
31349
- });
31350
- await this.recordCourseCompletionHistory(historyClient, {
31351
- gameId: data.gameId,
31352
- courseId: data.courseId,
31353
- studentId: data.studentId,
31354
- action: "complete",
31355
- actor,
31356
- appName,
31357
- sensorUrl
31358
- });
31359
- } else {
31360
- await client.oneroster.assessmentResults.upsert(resultId, {
31361
- sourcedId: resultId,
31362
- status: ONEROSTER_STATUS.active,
31363
- assessmentLineItem: { sourcedId: lineItemId },
31364
- student: { sourcedId: data.studentId },
31365
- score: 0,
31366
- scoreDate: new Date().toISOString(),
31367
- scoreStatus: SCORE_STATUS.notSubmitted,
31368
- inProgress: "true",
31369
- metadata: {
31370
- isMasteryCompletion: true,
31371
- adminAction: true,
31372
- appName
31373
- }
31374
- });
31375
- await this.recordCourseCompletionHistory(historyClient, {
31376
- gameId: data.gameId,
31377
- courseId: data.courseId,
31378
- studentId: data.studentId,
31379
- action: "resume",
31380
- actor,
31381
- appName,
31382
- sensorUrl
31383
- });
31384
31368
  }
31385
31369
  return { status: "ok" };
31386
31370
  }
@@ -37041,6 +37025,7 @@ class AdminEventRecorder {
37041
37025
  defaultActivityId: "playcademy-admin-mastery-adjustment",
37042
37026
  eventKind: "remediation-mastery"
37043
37027
  });
37028
+ const courseUrl = createOneRosterUrls(TIMEBACK_API_URLS5[this.environment]).course(data.courseId);
37044
37029
  await this.caliper.emitActivityEvent({
37045
37030
  studentId: ctx.student.id,
37046
37031
  studentEmail: ctx.student.email,
@@ -37055,32 +37040,13 @@ class AdminEventRecorder {
37055
37040
  appName: ctx.appName,
37056
37041
  sensorUrl: ctx.sensorUrl,
37057
37042
  process: true,
37058
- generatedExtensions: ctx.metadata,
37059
- eventExtensions: ctx.metadata
37060
- });
37061
- }
37062
- async recordCourseCompletionChange(data) {
37063
- const isResume = data.action === "resume";
37064
- const ctx = await this.prepareAdminEvent({
37065
- ...data,
37066
- defaultActivityId: isResume ? "playcademy-admin-course-resumed" : "playcademy-admin-course-completed",
37067
- reason: "Admin action",
37068
- eventKind: isResume ? "course-resumed" : "course-completed"
37069
- });
37070
- await this.caliper.emitActivityEvent({
37071
- studentId: ctx.student.id,
37072
- studentEmail: ctx.student.email,
37073
- gameId: data.gameId,
37074
- activityId: ctx.activityId,
37075
- activityName: isResume ? "Course resumed" : "Course marked complete",
37076
- courseId: data.courseId,
37077
- courseName: ctx.courseContext.courseName,
37078
- subject: ctx.courseContext.subject,
37079
- appName: ctx.appName,
37080
- sensorUrl: ctx.sensorUrl,
37081
- process: false,
37082
37043
  includeAttempt: false,
37083
- generatedExtensions: ctx.metadata,
37044
+ objectId: `${ctx.sensorUrl.replace(/\/$/, "")}/urn:uuid:${crypto.randomUUID()}`,
37045
+ generatedId: courseUrl,
37046
+ generatedExtensions: {
37047
+ ...ctx.metadata,
37048
+ ...data.pctCompleteApp !== undefined ? { pctCompleteApp: data.pctCompleteApp } : {}
37049
+ },
37084
37050
  eventExtensions: ctx.metadata
37085
37051
  });
37086
37052
  }
@@ -38271,10 +38237,6 @@ class TimebackClient {
38271
38237
  await this._ensureAuthenticated();
38272
38238
  return this.adminEventRecorder.recordMasteryAdjustment(data);
38273
38239
  }
38274
- async recordAdminCourseCompletionChange(data) {
38275
- await this._ensureAuthenticated();
38276
- return this.adminEventRecorder.recordCourseCompletionChange(data);
38277
- }
38278
38240
  clearCaches() {
38279
38241
  this.cacheManager.clearAll();
38280
38242
  }
@@ -95205,7 +95167,7 @@ function isValidAdminAttributionDate(value) {
95205
95167
  const date4 = new Date(Date.UTC(year3, month - 1, day, 12, 0, 0));
95206
95168
  return date4.getUTCFullYear() === year3 && date4.getUTCMonth() + 1 === month && date4.getUTCDate() === day;
95207
95169
  }
95208
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS6, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
95170
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS6, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
95209
95171
  var init_schemas11 = __esm(() => {
95210
95172
  init_drizzle_zod();
95211
95173
  init_esm();
@@ -95376,12 +95338,6 @@ var init_schemas11 = __esm(() => {
95376
95338
  date: AdminAttributionDateSchema.optional(),
95377
95339
  useCurrentTime: exports_external.boolean().optional()
95378
95340
  });
95379
- ToggleCourseCompletionRequestSchema = exports_external.object({
95380
- gameId: exports_external.string().uuid(),
95381
- courseId: exports_external.string().min(1),
95382
- studentId: exports_external.string().min(1),
95383
- action: exports_external.enum(["complete", "resume"])
95384
- });
95385
95341
  EnrollStudentRequestSchema = exports_external.object({
95386
95342
  gameId: exports_external.string().uuid(),
95387
95343
  courseId: exports_external.string().min(1),
@@ -97577,7 +97533,7 @@ var init_sprite_controller = __esm(() => {
97577
97533
  });
97578
97534
 
97579
97535
  // ../api-core/src/controllers/timeback.controller.ts
97580
- var logger65, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, heartbeat, advanceCourse, getStudentXp, getRoster, getStudentOverview, getStudentActivity, getActivityDetail, grantXp, adjustTime, adjustMastery, toggleCompletion, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
97536
+ var logger65, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, heartbeat, advanceCourse, getStudentXp, getRoster, getStudentOverview, getStudentActivity, getActivityDetail, grantXp, adjustTime, adjustMastery, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
97581
97537
  var init_timeback_controller = __esm(() => {
97582
97538
  init_esm();
97583
97539
  init_schemas_index();
@@ -97978,17 +97934,6 @@ var init_timeback_controller = __esm(() => {
97978
97934
  });
97979
97935
  return ctx.services.timebackAdmin.adjustMasteredUnits(body2, ctx.user);
97980
97936
  });
97981
- toggleCompletion = requireGameManagementAccess(async (ctx) => {
97982
- const body2 = await parseRequestBody(ctx.request, ToggleCourseCompletionRequestSchema);
97983
- logger65.debug("Toggling course completion", {
97984
- requesterId: ctx.user.id,
97985
- gameId: body2.gameId,
97986
- courseId: body2.courseId,
97987
- studentId: body2.studentId,
97988
- action: body2.action
97989
- });
97990
- return ctx.services.timebackAdmin.toggleCourseCompletion(body2, ctx.user);
97991
- });
97992
97937
  searchStudents = requireGameManagementAccess(async (ctx) => {
97993
97938
  const gameId = ctx.params.gameId;
97994
97939
  const courseId = ctx.params.courseId;
@@ -98180,7 +98125,6 @@ var init_timeback_controller = __esm(() => {
98180
98125
  grantXp,
98181
98126
  adjustTime,
98182
98127
  adjustMastery,
98183
- toggleCompletion,
98184
98128
  searchStudents,
98185
98129
  enrollStudent,
98186
98130
  unenrollStudent,
package/dist/server.js CHANGED
@@ -1329,7 +1329,7 @@ var package_default;
1329
1329
  var init_package = __esm(() => {
1330
1330
  package_default = {
1331
1331
  name: "@playcademy/sandbox",
1332
- version: "0.3.17-beta.34",
1332
+ version: "0.3.17-beta.36",
1333
1333
  description: "Local development server for Playcademy game development",
1334
1334
  type: "module",
1335
1335
  exports: {
@@ -30432,6 +30432,24 @@ function resolveAdminEventTime(data) {
30432
30432
  }
30433
30433
  return toAttributionEventTime(data.date);
30434
30434
  }
30435
+ function validateMasteryAdjustment(delta, currentMastered, masterableUnits) {
30436
+ if (delta < 0 && currentMastered + delta < 0) {
30437
+ throw new ValidationError(`Adjustment would go below 0. Current: ${currentMastered}, adjustment: ${delta}`);
30438
+ }
30439
+ if (delta < 0 && typeof masterableUnits === "number" && masterableUnits > 0 && currentMastered > masterableUnits && currentMastered + delta > masterableUnits) {
30440
+ const minDelta = masterableUnits - currentMastered;
30441
+ throw new ValidationError(`Adjustment must reduce mastery to at most ${masterableUnits}. Current: ${currentMastered}/${masterableUnits}, minimum adjustment: ${minDelta}`);
30442
+ }
30443
+ if (delta > 0 && typeof masterableUnits === "number" && masterableUnits > 0) {
30444
+ if (currentMastered >= masterableUnits) {
30445
+ throw new ValidationError(`Mastery is already at maximum (${currentMastered}/${masterableUnits}). Only negative adjustments are allowed.`);
30446
+ }
30447
+ if (currentMastered + delta > masterableUnits) {
30448
+ const remaining = masterableUnits - currentMastered;
30449
+ throw new ValidationError(`Adjustment would exceed maximum. Current: ${currentMastered}/${masterableUnits}, max adjustment: +${remaining}`);
30450
+ }
30451
+ }
30452
+ }
30435
30453
  function compareEnrollmentsByRecency(a, b) {
30436
30454
  const dateCompare = (b.beginDate ?? "").localeCompare(a.beginDate ?? "");
30437
30455
  if (dateCompare !== 0) {
@@ -30791,17 +30809,6 @@ class TimebackAdminService {
30791
30809
  }
30792
30810
  return this.deps.timeback;
30793
30811
  }
30794
- async recordCourseCompletionHistory(client, data) {
30795
- await client.recordAdminCourseCompletionChange(data).catch((error) => {
30796
- logger16.error("Failed to record admin course completion history event", {
30797
- gameId: data.gameId,
30798
- courseId: data.courseId,
30799
- studentId: data.studentId,
30800
- action: data.action,
30801
- error: error instanceof Error ? error.message : String(error)
30802
- });
30803
- });
30804
- }
30805
30812
  async resolveAdminMutationContext(gameId, courseId, user, studentId, accessLevel = "developer") {
30806
30813
  const client = this.requireClient();
30807
30814
  if (accessLevel === "dashboard") {
@@ -30900,7 +30907,7 @@ class TimebackAdminService {
30900
30907
  if (typeof masterableUnits !== "number" || masterableUnits <= 0) {
30901
30908
  return;
30902
30909
  }
30903
- return Math.min(100, Math.round(masteredUnits / masterableUnits * 100));
30910
+ return Math.round(masteredUnits / masterableUnits * 100);
30904
30911
  }
30905
30912
  async getMasterableUnits(courseId) {
30906
30913
  const client = this.requireClient();
@@ -31176,6 +31183,7 @@ class TimebackAdminService {
31176
31183
  ...enrollmentSummaries ? { enrollments: enrollmentSummaries } : {}
31177
31184
  };
31178
31185
  });
31186
+ courses.sort((a, b) => a.grade - b.grade);
31179
31187
  return {
31180
31188
  student: {
31181
31189
  studentId,
@@ -31275,111 +31283,87 @@ class TimebackAdminService {
31275
31283
  return { status: "ok" };
31276
31284
  }
31277
31285
  async adjustMasteredUnits(data, user) {
31278
- const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
31286
+ const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user);
31287
+ let currentMastered = 0;
31288
+ const masterableUnits = await this.getMasterableUnits(data.courseId);
31289
+ if (data.units !== 0) {
31290
+ const enrollment = await this.assertStudentEnrolledInCourse(client, data.studentId, data.courseId);
31291
+ try {
31292
+ const analytics = await client.edubridge.analytics.getEnrollmentFacts(enrollment.id, { timezone: PLATFORM_TIMEZONE });
31293
+ currentMastered = this.summarizeAnalyticsFacts(analytics.facts).masteredUnits;
31294
+ } catch {
31295
+ throw new ValidationError("Unable to validate mastery bounds — analytics unavailable. Please retry.");
31296
+ }
31297
+ validateMasteryAdjustment(data.units, currentMastered, masterableUnits);
31298
+ }
31299
+ const pctCompleteApp = typeof masterableUnits === "number" && masterableUnits > 0 ? Math.min(100, Math.max(0, Math.round((currentMastered + data.units) / masterableUnits * 100))) : undefined;
31279
31300
  await client.recordAdminMasteryAdjustment({
31280
31301
  gameId: data.gameId,
31281
31302
  courseId: data.courseId,
31282
31303
  studentId: data.studentId,
31283
31304
  masteredUnits: data.units,
31305
+ pctCompleteApp,
31284
31306
  eventTime: resolveAdminEventTime(data),
31285
31307
  reason: data.reason,
31286
31308
  actor,
31287
31309
  appName,
31288
31310
  sensorUrl
31289
31311
  });
31290
- return { status: "ok" };
31291
- }
31292
- async toggleCourseCompletion(data, user) {
31293
- const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId, "dashboard");
31294
- const historyClient = client;
31295
- const ids = deriveSourcedIds(data.courseId);
31296
- const lineItemId = `${ids.course}-mastery-completion-assessment`;
31297
- const resultId = `${lineItemId}:${data.studentId}:completion`;
31298
- await client.oneroster.assessmentLineItems.findOrCreate(lineItemId, {
31299
- sourcedId: lineItemId,
31300
- title: "Mastery Completion",
31301
- status: ONEROSTER_STATUS.active,
31302
- course: { sourcedId: ids.course },
31303
- ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
31304
- });
31305
- if (data.action === "complete") {
31306
- const masterableUnits = await this.getMasterableUnits(data.courseId);
31307
- if (masterableUnits && masterableUnits > 0) {
31308
- const enrollment = await this.assertStudentEnrolledInCourse(client, data.studentId, data.courseId);
31309
- let currentMastered = 0;
31310
- try {
31311
- const analytics = await client.edubridge.analytics.getEnrollmentFacts(enrollment.id, { timezone: PLATFORM_TIMEZONE });
31312
- const summary = this.summarizeAnalyticsFacts(analytics.facts);
31313
- currentMastered = summary.masteredUnits;
31314
- } catch {
31315
- logger16.warn("Failed to load analytics for mastery gap calculation", {
31316
- studentId: data.studentId,
31317
- courseId: data.courseId
31312
+ if (typeof masterableUnits === "number" && masterableUnits > 0) {
31313
+ const wasMastered = currentMastered >= masterableUnits;
31314
+ const willBeMastered = currentMastered + data.units >= masterableUnits;
31315
+ if (wasMastered !== willBeMastered) {
31316
+ const ids = deriveSourcedIds(data.courseId);
31317
+ const lineItemId = `${ids.course}-mastery-completion-assessment`;
31318
+ const resultId = `${lineItemId}:${data.studentId}:completion`;
31319
+ if (willBeMastered) {
31320
+ await client.oneroster.assessmentLineItems.findOrCreate(lineItemId, {
31321
+ sourcedId: lineItemId,
31322
+ title: "Mastery Completion",
31323
+ status: ONEROSTER_STATUS.active,
31324
+ course: { sourcedId: ids.course },
31325
+ ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
31318
31326
  });
31319
- }
31320
- const gap = masterableUnits - currentMastered;
31321
- if (gap > 0) {
31322
- await client.recordAdminMasteryAdjustment({
31323
- gameId: data.gameId,
31324
- courseId: data.courseId,
31325
- studentId: data.studentId,
31326
- masteredUnits: gap,
31327
- reason: "Admin completed course",
31328
- actor,
31329
- appName,
31330
- sensorUrl
31327
+ await client.oneroster.assessmentResults.upsert(resultId, {
31328
+ sourcedId: resultId,
31329
+ status: ONEROSTER_STATUS.active,
31330
+ assessmentLineItem: { sourcedId: lineItemId },
31331
+ student: { sourcedId: data.studentId },
31332
+ score: 100,
31333
+ scoreDate: new Date().toISOString(),
31334
+ scoreStatus: SCORE_STATUS.fullyGraded,
31335
+ inProgress: "false",
31336
+ metadata: {
31337
+ isMasteryCompletion: true,
31338
+ adminAction: true,
31339
+ appName
31340
+ }
31331
31341
  });
31342
+ } else {
31343
+ try {
31344
+ await client.oneroster.assessmentResults.upsert(resultId, {
31345
+ sourcedId: resultId,
31346
+ status: ONEROSTER_STATUS.active,
31347
+ assessmentLineItem: { sourcedId: lineItemId },
31348
+ student: { sourcedId: data.studentId },
31349
+ score: 0,
31350
+ scoreDate: new Date().toISOString(),
31351
+ scoreStatus: SCORE_STATUS.notSubmitted,
31352
+ inProgress: "true",
31353
+ metadata: {
31354
+ isMasteryCompletion: true,
31355
+ adminAction: true,
31356
+ appName
31357
+ }
31358
+ });
31359
+ } catch {
31360
+ logger16.debug("No completion entry to revoke", {
31361
+ studentId: data.studentId,
31362
+ courseId: data.courseId
31363
+ });
31364
+ }
31332
31365
  }
31333
31366
  }
31334
- await client.oneroster.assessmentResults.upsert(resultId, {
31335
- sourcedId: resultId,
31336
- status: ONEROSTER_STATUS.active,
31337
- assessmentLineItem: { sourcedId: lineItemId },
31338
- student: { sourcedId: data.studentId },
31339
- score: 100,
31340
- scoreDate: new Date().toISOString(),
31341
- scoreStatus: SCORE_STATUS.fullyGraded,
31342
- inProgress: "false",
31343
- metadata: {
31344
- isMasteryCompletion: true,
31345
- adminAction: true,
31346
- appName
31347
- }
31348
- });
31349
- await this.recordCourseCompletionHistory(historyClient, {
31350
- gameId: data.gameId,
31351
- courseId: data.courseId,
31352
- studentId: data.studentId,
31353
- action: "complete",
31354
- actor,
31355
- appName,
31356
- sensorUrl
31357
- });
31358
- } else {
31359
- await client.oneroster.assessmentResults.upsert(resultId, {
31360
- sourcedId: resultId,
31361
- status: ONEROSTER_STATUS.active,
31362
- assessmentLineItem: { sourcedId: lineItemId },
31363
- student: { sourcedId: data.studentId },
31364
- score: 0,
31365
- scoreDate: new Date().toISOString(),
31366
- scoreStatus: SCORE_STATUS.notSubmitted,
31367
- inProgress: "true",
31368
- metadata: {
31369
- isMasteryCompletion: true,
31370
- adminAction: true,
31371
- appName
31372
- }
31373
- });
31374
- await this.recordCourseCompletionHistory(historyClient, {
31375
- gameId: data.gameId,
31376
- courseId: data.courseId,
31377
- studentId: data.studentId,
31378
- action: "resume",
31379
- actor,
31380
- appName,
31381
- sensorUrl
31382
- });
31383
31367
  }
31384
31368
  return { status: "ok" };
31385
31369
  }
@@ -37040,6 +37024,7 @@ class AdminEventRecorder {
37040
37024
  defaultActivityId: "playcademy-admin-mastery-adjustment",
37041
37025
  eventKind: "remediation-mastery"
37042
37026
  });
37027
+ const courseUrl = createOneRosterUrls(TIMEBACK_API_URLS5[this.environment]).course(data.courseId);
37043
37028
  await this.caliper.emitActivityEvent({
37044
37029
  studentId: ctx.student.id,
37045
37030
  studentEmail: ctx.student.email,
@@ -37054,32 +37039,13 @@ class AdminEventRecorder {
37054
37039
  appName: ctx.appName,
37055
37040
  sensorUrl: ctx.sensorUrl,
37056
37041
  process: true,
37057
- generatedExtensions: ctx.metadata,
37058
- eventExtensions: ctx.metadata
37059
- });
37060
- }
37061
- async recordCourseCompletionChange(data) {
37062
- const isResume = data.action === "resume";
37063
- const ctx = await this.prepareAdminEvent({
37064
- ...data,
37065
- defaultActivityId: isResume ? "playcademy-admin-course-resumed" : "playcademy-admin-course-completed",
37066
- reason: "Admin action",
37067
- eventKind: isResume ? "course-resumed" : "course-completed"
37068
- });
37069
- await this.caliper.emitActivityEvent({
37070
- studentId: ctx.student.id,
37071
- studentEmail: ctx.student.email,
37072
- gameId: data.gameId,
37073
- activityId: ctx.activityId,
37074
- activityName: isResume ? "Course resumed" : "Course marked complete",
37075
- courseId: data.courseId,
37076
- courseName: ctx.courseContext.courseName,
37077
- subject: ctx.courseContext.subject,
37078
- appName: ctx.appName,
37079
- sensorUrl: ctx.sensorUrl,
37080
- process: false,
37081
37042
  includeAttempt: false,
37082
- generatedExtensions: ctx.metadata,
37043
+ objectId: `${ctx.sensorUrl.replace(/\/$/, "")}/urn:uuid:${crypto.randomUUID()}`,
37044
+ generatedId: courseUrl,
37045
+ generatedExtensions: {
37046
+ ...ctx.metadata,
37047
+ ...data.pctCompleteApp !== undefined ? { pctCompleteApp: data.pctCompleteApp } : {}
37048
+ },
37083
37049
  eventExtensions: ctx.metadata
37084
37050
  });
37085
37051
  }
@@ -38270,10 +38236,6 @@ class TimebackClient {
38270
38236
  await this._ensureAuthenticated();
38271
38237
  return this.adminEventRecorder.recordMasteryAdjustment(data);
38272
38238
  }
38273
- async recordAdminCourseCompletionChange(data) {
38274
- await this._ensureAuthenticated();
38275
- return this.adminEventRecorder.recordCourseCompletionChange(data);
38276
- }
38277
38239
  clearCaches() {
38278
38240
  this.cacheManager.clearAll();
38279
38241
  }
@@ -95204,7 +95166,7 @@ function isValidAdminAttributionDate(value) {
95204
95166
  const date4 = new Date(Date.UTC(year3, month - 1, day, 12, 0, 0));
95205
95167
  return date4.getUTCFullYear() === year3 && date4.getUTCMonth() + 1 === month && date4.getUTCDate() === day;
95206
95168
  }
95207
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS6, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
95169
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS6, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
95208
95170
  var init_schemas11 = __esm(() => {
95209
95171
  init_drizzle_zod();
95210
95172
  init_esm();
@@ -95375,12 +95337,6 @@ var init_schemas11 = __esm(() => {
95375
95337
  date: AdminAttributionDateSchema.optional(),
95376
95338
  useCurrentTime: exports_external.boolean().optional()
95377
95339
  });
95378
- ToggleCourseCompletionRequestSchema = exports_external.object({
95379
- gameId: exports_external.string().uuid(),
95380
- courseId: exports_external.string().min(1),
95381
- studentId: exports_external.string().min(1),
95382
- action: exports_external.enum(["complete", "resume"])
95383
- });
95384
95340
  EnrollStudentRequestSchema = exports_external.object({
95385
95341
  gameId: exports_external.string().uuid(),
95386
95342
  courseId: exports_external.string().min(1),
@@ -97576,7 +97532,7 @@ var init_sprite_controller = __esm(() => {
97576
97532
  });
97577
97533
 
97578
97534
  // ../api-core/src/controllers/timeback.controller.ts
97579
- var logger65, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, heartbeat, advanceCourse, getStudentXp, getRoster, getStudentOverview, getStudentActivity, getActivityDetail, grantXp, adjustTime, adjustMastery, toggleCompletion, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
97535
+ var logger65, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, heartbeat, advanceCourse, getStudentXp, getRoster, getStudentOverview, getStudentActivity, getActivityDetail, grantXp, adjustTime, adjustMastery, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
97580
97536
  var init_timeback_controller = __esm(() => {
97581
97537
  init_esm();
97582
97538
  init_schemas_index();
@@ -97977,17 +97933,6 @@ var init_timeback_controller = __esm(() => {
97977
97933
  });
97978
97934
  return ctx.services.timebackAdmin.adjustMasteredUnits(body2, ctx.user);
97979
97935
  });
97980
- toggleCompletion = requireGameManagementAccess(async (ctx) => {
97981
- const body2 = await parseRequestBody(ctx.request, ToggleCourseCompletionRequestSchema);
97982
- logger65.debug("Toggling course completion", {
97983
- requesterId: ctx.user.id,
97984
- gameId: body2.gameId,
97985
- courseId: body2.courseId,
97986
- studentId: body2.studentId,
97987
- action: body2.action
97988
- });
97989
- return ctx.services.timebackAdmin.toggleCourseCompletion(body2, ctx.user);
97990
- });
97991
97936
  searchStudents = requireGameManagementAccess(async (ctx) => {
97992
97937
  const gameId = ctx.params.gameId;
97993
97938
  const courseId = ctx.params.courseId;
@@ -98179,7 +98124,6 @@ var init_timeback_controller = __esm(() => {
98179
98124
  grantXp,
98180
98125
  adjustTime,
98181
98126
  adjustMastery,
98182
- toggleCompletion,
98183
98127
  searchStudents,
98184
98128
  enrollStudent,
98185
98129
  unenrollStudent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.3.17-beta.34",
3
+ "version": "0.3.17-beta.36",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {