@playcademy/sandbox 0.3.17-beta.33 → 0.3.17-beta.35

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 +242 -182
  2. package/dist/server.js +242 -182
  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.33",
1333
+ version: "0.3.17-beta.35",
1334
1334
  description: "Local development server for Playcademy game development",
1335
1335
  type: "module",
1336
1336
  exports: {
@@ -30433,6 +30433,31 @@ 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
+ }
30454
+ function compareEnrollmentsByRecency(a, b) {
30455
+ const dateCompare = (b.beginDate ?? "").localeCompare(a.beginDate ?? "");
30456
+ if (dateCompare !== 0) {
30457
+ return dateCompare;
30458
+ }
30459
+ return (b.dateLastModified ?? "").localeCompare(a.dateLastModified ?? "");
30460
+ }
30436
30461
  var init_timeback_admin_util = __esm(() => {
30437
30462
  init_errors();
30438
30463
  });
@@ -30785,17 +30810,6 @@ class TimebackAdminService {
30785
30810
  }
30786
30811
  return this.deps.timeback;
30787
30812
  }
30788
- async recordCourseCompletionHistory(client, data) {
30789
- await client.recordAdminCourseCompletionChange(data).catch((error) => {
30790
- logger16.error("Failed to record admin course completion history event", {
30791
- gameId: data.gameId,
30792
- courseId: data.courseId,
30793
- studentId: data.studentId,
30794
- action: data.action,
30795
- error: error instanceof Error ? error.message : String(error)
30796
- });
30797
- });
30798
- }
30799
30813
  async resolveAdminMutationContext(gameId, courseId, user, studentId, accessLevel = "developer") {
30800
30814
  const client = this.requireClient();
30801
30815
  if (accessLevel === "dashboard") {
@@ -30894,7 +30908,7 @@ class TimebackAdminService {
30894
30908
  if (typeof masterableUnits !== "number" || masterableUnits <= 0) {
30895
30909
  return;
30896
30910
  }
30897
- return Math.min(100, Math.round(masteredUnits / masterableUnits * 100));
30911
+ return Math.round(masteredUnits / masterableUnits * 100);
30898
30912
  }
30899
30913
  async getMasterableUnits(courseId) {
30900
30914
  const client = this.requireClient();
@@ -30947,6 +30961,7 @@ class TimebackAdminService {
30947
30961
  }
30948
30962
  async getStudentEnrollmentsByCourseId(client, studentId, courseIds, options) {
30949
30963
  const enrollments = new Map;
30964
+ const allEnrollments = new Map;
30950
30965
  const entries = await Promise.all(courseIds.map(async (courseId) => {
30951
30966
  const roster = await client.oneroster.enrollments.listByCourse(courseId, {
30952
30967
  includeInactive: options?.includeInactive,
@@ -30958,28 +30973,30 @@ class TimebackAdminService {
30958
30973
  if (aActive !== bActive) {
30959
30974
  return aActive ? -1 : 1;
30960
30975
  }
30961
- return (b.enrollment.dateLastModified ?? "").localeCompare(a.enrollment.dateLastModified ?? "");
30976
+ return compareEnrollmentsByRecency(a.enrollment, b.enrollment);
30962
30977
  });
30963
- return { courseId, match: matches[0] ?? null };
30978
+ return { courseId, matches };
30964
30979
  }));
30965
- for (const { courseId, match } of entries) {
30966
- if (match) {
30967
- enrollments.set(courseId, {
30968
- id: match.enrollment.sourcedId,
30969
- status: match.enrollment.status ?? "active",
30970
- role: match.enrollment.role ?? "student",
30971
- beginDate: match.enrollment.beginDate ?? null,
30972
- endDate: match.enrollment.endDate ?? null,
30973
- course: {
30974
- id: courseId,
30975
- title: match.class?.title ?? "",
30976
- subjects: null,
30977
- grades: null
30978
- }
30979
- });
30980
+ for (const { courseId, matches } of entries) {
30981
+ const records = matches.map((match) => ({
30982
+ id: match.enrollment.sourcedId,
30983
+ status: match.enrollment.status ?? "active",
30984
+ role: match.enrollment.role ?? "student",
30985
+ beginDate: match.enrollment.beginDate ?? null,
30986
+ endDate: match.enrollment.endDate ?? null,
30987
+ course: {
30988
+ id: courseId,
30989
+ title: match.class?.title ?? "",
30990
+ subjects: null,
30991
+ grades: null
30992
+ }
30993
+ }));
30994
+ if (records.length > 0) {
30995
+ enrollments.set(courseId, records[0]);
30980
30996
  }
30997
+ allEnrollments.set(courseId, records);
30981
30998
  }
30982
- return { enrollments };
30999
+ return { enrollments, allEnrollments };
30983
31000
  }
30984
31001
  async assertStudentEnrolledInCourse(client, studentId, courseId) {
30985
31002
  const enrollments = await client.edubridge.enrollments.listByUser(studentId);
@@ -31066,7 +31083,7 @@ class TimebackAdminService {
31066
31083
  const enrollmentId = rosterEntry.enrollment.sourcedId || null;
31067
31084
  const summary = enrollmentId ? analyticsByEnrollmentId.get(enrollmentId) : undefined;
31068
31085
  const analyticsUnavailable = Boolean(enrollmentId) && summary?.analyticsAvailable !== true;
31069
- const name3 = rosterEntry.user ? `${rosterEntry.user.givenName} ${rosterEntry.user.familyName}`.trim() : rosterEntry.enrollment.user.sourcedId;
31086
+ const name3 = rosterEntry.user ? `${rosterEntry.user.givenName} ${rosterEntry.user.familyName}`.trim() || "No name specified" : "No name specified";
31070
31087
  const inactive = rosterEntry.enrollment.status === "tobedeleted";
31071
31088
  return {
31072
31089
  studentId: rosterEntry.enrollment.user.sourcedId,
@@ -31111,14 +31128,15 @@ class TimebackAdminService {
31111
31128
  throw new NotFoundError("Timeback integration", gameId);
31112
31129
  }
31113
31130
  const courseIds = new Set(integrations.map((integration) => integration.courseId));
31114
- const { enrollments: enrollmentsByCourseId } = await this.getStudentEnrollmentsByCourseId(client, studentId, [...courseIds], {
31131
+ const { enrollments: enrollmentsByCourseId, allEnrollments: allEnrollmentsByCourseId } = await this.getStudentEnrollmentsByCourseId(client, studentId, [...courseIds], {
31115
31132
  includeInactive: true
31116
31133
  });
31117
31134
  if (enrollmentsByCourseId.size === 0) {
31118
31135
  throw new NotFoundError("Student enrollment", courseId ? `${studentId}:${courseId}` : `${studentId}:${gameId}`);
31119
31136
  }
31137
+ const allEnrollmentIds = [...allEnrollmentsByCourseId.values()].flat().map((enrollment) => enrollment.id);
31120
31138
  const studentProfile = await client.oneroster.users.get(studentId);
31121
- const analyticsByEnrollmentId = await this.loadEnrollmentAnalyticsSummaries([...enrollmentsByCourseId.values()].map((enrollment) => enrollment.id));
31139
+ const analyticsByEnrollmentId = await this.loadEnrollmentAnalyticsSummaries(allEnrollmentIds);
31122
31140
  const [masterableUnitsByCourse, completionStatusByCourse] = await Promise.all([
31123
31141
  this.getMasterableUnitsByCourse(integrations.map((integration) => integration.courseId)),
31124
31142
  this.getCompletionStatusByCourse(client, integrations.map((integration) => integration.courseId), studentId)
@@ -31129,6 +31147,24 @@ class TimebackAdminService {
31129
31147
  const masterableUnits = masterableUnitsByCourse.get(integration.courseId);
31130
31148
  const analyticsUnavailable = Boolean(enrollment?.id) && summary?.analyticsAvailable !== true;
31131
31149
  const inactive = enrollment?.status === "tobedeleted";
31150
+ const courseEnrollments = allEnrollmentsByCourseId.get(integration.courseId) ?? [];
31151
+ const enrollmentSummaries = courseEnrollments.length > 1 ? courseEnrollments.map((record) => {
31152
+ const recordSummary = analyticsByEnrollmentId.get(record.id);
31153
+ const recordAnalyticsUnavailable = recordSummary?.analyticsAvailable !== true;
31154
+ return {
31155
+ enrollmentId: record.id,
31156
+ status: record.status === "tobedeleted" ? "tobedeleted" : "active",
31157
+ beginDate: record.beginDate,
31158
+ endDate: record.endDate,
31159
+ analyticsUnavailable: recordAnalyticsUnavailable,
31160
+ totalXp: recordSummary?.totalXp ?? 0,
31161
+ todayXp: recordSummary?.todayXp ?? 0,
31162
+ activeTimeSeconds: recordSummary?.activeTimeSeconds ?? 0,
31163
+ masteredUnits: recordSummary?.masteredUnits ?? 0,
31164
+ pctCompleteApp: TimebackAdminService.computeCompletionPct(recordSummary?.masteredUnits ?? 0, masterableUnits),
31165
+ history: recordSummary?.history ?? []
31166
+ };
31167
+ }) : undefined;
31132
31168
  return {
31133
31169
  courseId: integration.courseId,
31134
31170
  title: enrollment?.course.title || `${integration.subject} Grade ${integration.grade}`,
@@ -31144,9 +31180,11 @@ class TimebackAdminService {
31144
31180
  pctCompleteApp: TimebackAdminService.computeCompletionPct(summary?.masteredUnits ?? 0, masterableUnits),
31145
31181
  completionStatus: completionStatusByCourse.get(integration.courseId) ?? "none",
31146
31182
  history: summary?.history ?? [],
31147
- ...inactive ? { inactive } : {}
31183
+ ...inactive ? { inactive } : {},
31184
+ ...enrollmentSummaries ? { enrollments: enrollmentSummaries } : {}
31148
31185
  };
31149
31186
  });
31187
+ courses.sort((a, b) => a.grade - b.grade);
31150
31188
  return {
31151
31189
  student: {
31152
31190
  studentId,
@@ -31246,111 +31284,87 @@ class TimebackAdminService {
31246
31284
  return { status: "ok" };
31247
31285
  }
31248
31286
  async adjustMasteredUnits(data, user) {
31249
- 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;
31250
31301
  await client.recordAdminMasteryAdjustment({
31251
31302
  gameId: data.gameId,
31252
31303
  courseId: data.courseId,
31253
31304
  studentId: data.studentId,
31254
31305
  masteredUnits: data.units,
31306
+ pctCompleteApp,
31255
31307
  eventTime: resolveAdminEventTime(data),
31256
31308
  reason: data.reason,
31257
31309
  actor,
31258
31310
  appName,
31259
31311
  sensorUrl
31260
31312
  });
31261
- return { status: "ok" };
31262
- }
31263
- async toggleCourseCompletion(data, user) {
31264
- const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId, "dashboard");
31265
- const historyClient = client;
31266
- const ids = deriveSourcedIds(data.courseId);
31267
- const lineItemId = `${ids.course}-mastery-completion-assessment`;
31268
- const resultId = `${lineItemId}:${data.studentId}:completion`;
31269
- await client.oneroster.assessmentLineItems.findOrCreate(lineItemId, {
31270
- sourcedId: lineItemId,
31271
- title: "Mastery Completion",
31272
- status: ONEROSTER_STATUS.active,
31273
- course: { sourcedId: ids.course },
31274
- ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
31275
- });
31276
- if (data.action === "complete") {
31277
- const masterableUnits = await this.getMasterableUnits(data.courseId);
31278
- if (masterableUnits && masterableUnits > 0) {
31279
- const enrollment = await this.assertStudentEnrolledInCourse(client, data.studentId, data.courseId);
31280
- let currentMastered = 0;
31281
- try {
31282
- const analytics = await client.edubridge.analytics.getEnrollmentFacts(enrollment.id, { timezone: PLATFORM_TIMEZONE });
31283
- const summary = this.summarizeAnalyticsFacts(analytics.facts);
31284
- currentMastered = summary.masteredUnits;
31285
- } catch {
31286
- logger16.warn("Failed to load analytics for mastery gap calculation", {
31287
- studentId: data.studentId,
31288
- 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 } } : {}
31289
31327
  });
31290
- }
31291
- const gap = masterableUnits - currentMastered;
31292
- if (gap > 0) {
31293
- await client.recordAdminMasteryAdjustment({
31294
- gameId: data.gameId,
31295
- courseId: data.courseId,
31296
- studentId: data.studentId,
31297
- masteredUnits: gap,
31298
- reason: "Admin completed course",
31299
- actor,
31300
- appName,
31301
- 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
+ }
31302
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
+ }
31303
31366
  }
31304
31367
  }
31305
- await client.oneroster.assessmentResults.upsert(resultId, {
31306
- sourcedId: resultId,
31307
- status: ONEROSTER_STATUS.active,
31308
- assessmentLineItem: { sourcedId: lineItemId },
31309
- student: { sourcedId: data.studentId },
31310
- score: 100,
31311
- scoreDate: new Date().toISOString(),
31312
- scoreStatus: SCORE_STATUS.fullyGraded,
31313
- inProgress: "false",
31314
- metadata: {
31315
- isMasteryCompletion: true,
31316
- adminAction: true,
31317
- appName
31318
- }
31319
- });
31320
- await this.recordCourseCompletionHistory(historyClient, {
31321
- gameId: data.gameId,
31322
- courseId: data.courseId,
31323
- studentId: data.studentId,
31324
- action: "complete",
31325
- actor,
31326
- appName,
31327
- sensorUrl
31328
- });
31329
- } else {
31330
- await client.oneroster.assessmentResults.upsert(resultId, {
31331
- sourcedId: resultId,
31332
- status: ONEROSTER_STATUS.active,
31333
- assessmentLineItem: { sourcedId: lineItemId },
31334
- student: { sourcedId: data.studentId },
31335
- score: 0,
31336
- scoreDate: new Date().toISOString(),
31337
- scoreStatus: SCORE_STATUS.notSubmitted,
31338
- inProgress: "true",
31339
- metadata: {
31340
- isMasteryCompletion: true,
31341
- adminAction: true,
31342
- appName
31343
- }
31344
- });
31345
- await this.recordCourseCompletionHistory(historyClient, {
31346
- gameId: data.gameId,
31347
- courseId: data.courseId,
31348
- studentId: data.studentId,
31349
- action: "resume",
31350
- actor,
31351
- appName,
31352
- sensorUrl
31353
- });
31354
31368
  }
31355
31369
  return { status: "ok" };
31356
31370
  }
@@ -31386,17 +31400,34 @@ class TimebackAdminService {
31386
31400
  });
31387
31401
  return { students: [] };
31388
31402
  }
31389
- const roster = await client.oneroster.enrollments.listByCourse(courseId, {
31403
+ const fullRoster = await client.oneroster.enrollments.listByCourse(courseId, {
31390
31404
  role: "student",
31405
+ includeInactive: true,
31391
31406
  includeUsers: false
31392
31407
  });
31393
- const enrolledStudentIds = new Set(roster.map((entry) => entry.enrollment.user.sourcedId));
31394
- const students = allUsers.filter((entry) => Boolean(entry.sourcedId) && entry.roles?.some((role) => role.role === "student") === true).map((entry) => ({
31395
- studentId: entry.sourcedId,
31396
- name: `${entry.givenName || ""} ${entry.familyName || ""}`.trim() || entry.sourcedId,
31397
- email: entry.email || null,
31398
- alreadyEnrolled: enrolledStudentIds.has(entry.sourcedId)
31399
- }));
31408
+ const enrolledStudentIds = new Set(fullRoster.filter((entry) => entry.enrollment.status === "active").map((entry) => entry.enrollment.user.sourcedId));
31409
+ const pastEnrollmentsByStudent = new Map;
31410
+ const inactiveEntries = fullRoster.filter((entry) => entry.enrollment.status === "tobedeleted").toSorted((a, b) => compareEnrollmentsByRecency(a.enrollment, b.enrollment));
31411
+ for (const entry of inactiveEntries) {
31412
+ const studentId = entry.enrollment.user.sourcedId;
31413
+ const list = pastEnrollmentsByStudent.get(studentId) ?? [];
31414
+ list.push({
31415
+ enrollmentId: entry.enrollment.sourcedId,
31416
+ beginDate: entry.enrollment.beginDate ?? null,
31417
+ endDate: entry.enrollment.endDate ?? null
31418
+ });
31419
+ pastEnrollmentsByStudent.set(studentId, list);
31420
+ }
31421
+ const students = allUsers.filter((entry) => Boolean(entry.sourcedId) && entry.roles?.some((role) => role.role === "student") === true).map((entry) => {
31422
+ const past = pastEnrollmentsByStudent.get(entry.sourcedId) ?? [];
31423
+ return {
31424
+ studentId: entry.sourcedId,
31425
+ name: `${entry.givenName || ""} ${entry.familyName || ""}`.trim() || "No name specified",
31426
+ email: entry.email || null,
31427
+ alreadyEnrolled: enrolledStudentIds.has(entry.sourcedId),
31428
+ ...past.length > 0 ? { pastEnrollments: past } : {}
31429
+ };
31430
+ });
31400
31431
  return { students };
31401
31432
  }
31402
31433
  async enrollStudent(data, user) {
@@ -31427,6 +31458,48 @@ class TimebackAdminService {
31427
31458
  client.invalidateEnrollments(data.studentId);
31428
31459
  return { status: "ok" };
31429
31460
  }
31461
+ async reactivateEnrollment(data, user) {
31462
+ const client = this.requireClient();
31463
+ await this.deps.validateGameManagementAccess(user, data.gameId);
31464
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
31465
+ where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
31466
+ });
31467
+ if (!integration) {
31468
+ throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
31469
+ }
31470
+ const enrollment = await client.oneroster.enrollments.get(data.enrollmentId);
31471
+ if (!enrollment) {
31472
+ throw new NotFoundError("Enrollment", data.enrollmentId);
31473
+ }
31474
+ if (enrollment.user.sourcedId !== data.studentId) {
31475
+ throw new ValidationError("Enrollment does not belong to the specified student");
31476
+ }
31477
+ if (enrollment.status === "active") {
31478
+ throw new ValidationError("Enrollment is already active");
31479
+ }
31480
+ const { allEnrollments } = await this.getStudentEnrollmentsByCourseId(client, data.studentId, [data.courseId], { includeInactive: true });
31481
+ const courseEnrollmentIds = new Set((allEnrollments.get(data.courseId) ?? []).map((e) => e.id));
31482
+ if (!courseEnrollmentIds.has(data.enrollmentId)) {
31483
+ throw new ValidationError("Enrollment does not belong to the specified course");
31484
+ }
31485
+ const activeEnrollments = await client.edubridge.enrollments.listByUser(data.studentId);
31486
+ if (activeEnrollments.some((e) => e.course.id === data.courseId)) {
31487
+ throw new ValidationError("Student already has an active enrollment for this course. Unenroll from the current enrollment before reactivating a past one.");
31488
+ }
31489
+ await client.oneroster.enrollments.update(data.enrollmentId, {
31490
+ role: enrollment.role,
31491
+ primary: enrollment.primary,
31492
+ beginDate: enrollment.beginDate,
31493
+ endDate: enrollment.endDate,
31494
+ user: enrollment.user,
31495
+ class: enrollment.class,
31496
+ school: enrollment.school,
31497
+ sourcedId: data.enrollmentId,
31498
+ status: "active"
31499
+ });
31500
+ client.invalidateEnrollments(data.studentId);
31501
+ return { status: "ok" };
31502
+ }
31430
31503
  async getCompletionStatus(client, courseId, studentId) {
31431
31504
  const ids = deriveSourcedIds(courseId);
31432
31505
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
@@ -36517,6 +36590,10 @@ function createOneRosterNamespace(client) {
36517
36590
  }
36518
36591
  },
36519
36592
  enrollments: {
36593
+ get: async (sourcedId) => {
36594
+ const response = await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "GET");
36595
+ return response.enrollment;
36596
+ },
36520
36597
  listByClass: async (classSourcedId, options) => {
36521
36598
  const queryParams = new URLSearchParams;
36522
36599
  const filters = [`class.sourcedId='${escapeFilterValue2(classSourcedId)}'`];
@@ -36594,6 +36671,11 @@ function createOneRosterNamespace(client) {
36594
36671
  }
36595
36672
  },
36596
36673
  create: async (data) => client["request"](ONEROSTER_ENDPOINTS5.enrollments, "POST", { enrollment: data }),
36674
+ update: async (sourcedId, data) => {
36675
+ await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "PUT", {
36676
+ enrollment: data
36677
+ });
36678
+ },
36597
36679
  delete: async (sourcedId) => {
36598
36680
  await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "DELETE");
36599
36681
  }
@@ -36943,6 +37025,7 @@ class AdminEventRecorder {
36943
37025
  defaultActivityId: "playcademy-admin-mastery-adjustment",
36944
37026
  eventKind: "remediation-mastery"
36945
37027
  });
37028
+ const courseUrl = createOneRosterUrls(TIMEBACK_API_URLS5[this.environment]).course(data.courseId);
36946
37029
  await this.caliper.emitActivityEvent({
36947
37030
  studentId: ctx.student.id,
36948
37031
  studentEmail: ctx.student.email,
@@ -36957,32 +37040,13 @@ class AdminEventRecorder {
36957
37040
  appName: ctx.appName,
36958
37041
  sensorUrl: ctx.sensorUrl,
36959
37042
  process: true,
36960
- generatedExtensions: ctx.metadata,
36961
- eventExtensions: ctx.metadata
36962
- });
36963
- }
36964
- async recordCourseCompletionChange(data) {
36965
- const isResume = data.action === "resume";
36966
- const ctx = await this.prepareAdminEvent({
36967
- ...data,
36968
- defaultActivityId: isResume ? "playcademy-admin-course-resumed" : "playcademy-admin-course-completed",
36969
- reason: "Admin action",
36970
- eventKind: isResume ? "course-resumed" : "course-completed"
36971
- });
36972
- await this.caliper.emitActivityEvent({
36973
- studentId: ctx.student.id,
36974
- studentEmail: ctx.student.email,
36975
- gameId: data.gameId,
36976
- activityId: ctx.activityId,
36977
- activityName: isResume ? "Course resumed" : "Course marked complete",
36978
- courseId: data.courseId,
36979
- courseName: ctx.courseContext.courseName,
36980
- subject: ctx.courseContext.subject,
36981
- appName: ctx.appName,
36982
- sensorUrl: ctx.sensorUrl,
36983
- process: false,
36984
37043
  includeAttempt: false,
36985
- 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
+ },
36986
37050
  eventExtensions: ctx.metadata
36987
37051
  });
36988
37052
  }
@@ -38173,10 +38237,6 @@ class TimebackClient {
38173
38237
  await this._ensureAuthenticated();
38174
38238
  return this.adminEventRecorder.recordMasteryAdjustment(data);
38175
38239
  }
38176
- async recordAdminCourseCompletionChange(data) {
38177
- await this._ensureAuthenticated();
38178
- return this.adminEventRecorder.recordCourseCompletionChange(data);
38179
- }
38180
38240
  clearCaches() {
38181
38241
  this.cacheManager.clearAll();
38182
38242
  }
@@ -95107,7 +95167,7 @@ function isValidAdminAttributionDate(value) {
95107
95167
  const date4 = new Date(Date.UTC(year3, month - 1, day, 12, 0, 0));
95108
95168
  return date4.getUTCFullYear() === year3 && date4.getUTCMonth() + 1 === month && date4.getUTCDate() === day;
95109
95169
  }
95110
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS6, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, 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;
95111
95171
  var init_schemas11 = __esm(() => {
95112
95172
  init_drizzle_zod();
95113
95173
  init_esm();
@@ -95278,12 +95338,6 @@ var init_schemas11 = __esm(() => {
95278
95338
  date: AdminAttributionDateSchema.optional(),
95279
95339
  useCurrentTime: exports_external.boolean().optional()
95280
95340
  });
95281
- ToggleCourseCompletionRequestSchema = exports_external.object({
95282
- gameId: exports_external.string().uuid(),
95283
- courseId: exports_external.string().min(1),
95284
- studentId: exports_external.string().min(1),
95285
- action: exports_external.enum(["complete", "resume"])
95286
- });
95287
95341
  EnrollStudentRequestSchema = exports_external.object({
95288
95342
  gameId: exports_external.string().uuid(),
95289
95343
  courseId: exports_external.string().min(1),
@@ -95294,6 +95348,12 @@ var init_schemas11 = __esm(() => {
95294
95348
  courseId: exports_external.string().min(1),
95295
95349
  studentId: exports_external.string().min(1)
95296
95350
  });
95351
+ ReactivateEnrollmentRequestSchema = exports_external.object({
95352
+ gameId: exports_external.string().uuid(),
95353
+ courseId: exports_external.string().min(1),
95354
+ studentId: exports_external.string().min(1),
95355
+ enrollmentId: exports_external.string().min(1)
95356
+ });
95297
95357
  InsertAssessmentTestSchema = createInsertSchema(gameTimebackAssessmentTests).omit({
95298
95358
  id: true,
95299
95359
  createdAt: true
@@ -97473,7 +97533,7 @@ var init_sprite_controller = __esm(() => {
97473
97533
  });
97474
97534
 
97475
97535
  // ../api-core/src/controllers/timeback.controller.ts
97476
- 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, 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;
97477
97537
  var init_timeback_controller = __esm(() => {
97478
97538
  init_esm();
97479
97539
  init_schemas_index();
@@ -97874,17 +97934,6 @@ var init_timeback_controller = __esm(() => {
97874
97934
  });
97875
97935
  return ctx.services.timebackAdmin.adjustMasteredUnits(body2, ctx.user);
97876
97936
  });
97877
- toggleCompletion = requireGameManagementAccess(async (ctx) => {
97878
- const body2 = await parseRequestBody(ctx.request, ToggleCourseCompletionRequestSchema);
97879
- logger65.debug("Toggling course completion", {
97880
- requesterId: ctx.user.id,
97881
- gameId: body2.gameId,
97882
- courseId: body2.courseId,
97883
- studentId: body2.studentId,
97884
- action: body2.action
97885
- });
97886
- return ctx.services.timebackAdmin.toggleCourseCompletion(body2, ctx.user);
97887
- });
97888
97937
  searchStudents = requireGameManagementAccess(async (ctx) => {
97889
97938
  const gameId = ctx.params.gameId;
97890
97939
  const courseId = ctx.params.courseId;
@@ -97920,6 +97969,17 @@ var init_timeback_controller = __esm(() => {
97920
97969
  });
97921
97970
  return ctx.services.timebackAdmin.unenrollStudent(body2, ctx.user);
97922
97971
  });
97972
+ reactivateEnrollment = requireGameManagementAccess(async (ctx) => {
97973
+ const body2 = await parseRequestBody(ctx.request, ReactivateEnrollmentRequestSchema);
97974
+ logger65.debug("Reactivating enrollment", {
97975
+ requesterId: ctx.user.id,
97976
+ gameId: body2.gameId,
97977
+ courseId: body2.courseId,
97978
+ studentId: body2.studentId,
97979
+ enrollmentId: body2.enrollmentId
97980
+ });
97981
+ return ctx.services.timebackAdmin.reactivateEnrollment(body2, ctx.user);
97982
+ });
97923
97983
  listAssessments = requireGameManagementAccess(async (ctx) => {
97924
97984
  const { gameId, courseId } = ctx.params;
97925
97985
  if (!gameId || !courseId) {
@@ -98065,10 +98125,10 @@ var init_timeback_controller = __esm(() => {
98065
98125
  grantXp,
98066
98126
  adjustTime,
98067
98127
  adjustMastery,
98068
- toggleCompletion,
98069
98128
  searchStudents,
98070
98129
  enrollStudent,
98071
98130
  unenrollStudent,
98131
+ reactivateEnrollment,
98072
98132
  listAssessments,
98073
98133
  createAssessment,
98074
98134
  deleteAssessment,
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.33",
1332
+ version: "0.3.17-beta.35",
1333
1333
  description: "Local development server for Playcademy game development",
1334
1334
  type: "module",
1335
1335
  exports: {
@@ -30432,6 +30432,31 @@ 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
+ }
30453
+ function compareEnrollmentsByRecency(a, b) {
30454
+ const dateCompare = (b.beginDate ?? "").localeCompare(a.beginDate ?? "");
30455
+ if (dateCompare !== 0) {
30456
+ return dateCompare;
30457
+ }
30458
+ return (b.dateLastModified ?? "").localeCompare(a.dateLastModified ?? "");
30459
+ }
30435
30460
  var init_timeback_admin_util = __esm(() => {
30436
30461
  init_errors();
30437
30462
  });
@@ -30784,17 +30809,6 @@ class TimebackAdminService {
30784
30809
  }
30785
30810
  return this.deps.timeback;
30786
30811
  }
30787
- async recordCourseCompletionHistory(client, data) {
30788
- await client.recordAdminCourseCompletionChange(data).catch((error) => {
30789
- logger16.error("Failed to record admin course completion history event", {
30790
- gameId: data.gameId,
30791
- courseId: data.courseId,
30792
- studentId: data.studentId,
30793
- action: data.action,
30794
- error: error instanceof Error ? error.message : String(error)
30795
- });
30796
- });
30797
- }
30798
30812
  async resolveAdminMutationContext(gameId, courseId, user, studentId, accessLevel = "developer") {
30799
30813
  const client = this.requireClient();
30800
30814
  if (accessLevel === "dashboard") {
@@ -30893,7 +30907,7 @@ class TimebackAdminService {
30893
30907
  if (typeof masterableUnits !== "number" || masterableUnits <= 0) {
30894
30908
  return;
30895
30909
  }
30896
- return Math.min(100, Math.round(masteredUnits / masterableUnits * 100));
30910
+ return Math.round(masteredUnits / masterableUnits * 100);
30897
30911
  }
30898
30912
  async getMasterableUnits(courseId) {
30899
30913
  const client = this.requireClient();
@@ -30946,6 +30960,7 @@ class TimebackAdminService {
30946
30960
  }
30947
30961
  async getStudentEnrollmentsByCourseId(client, studentId, courseIds, options) {
30948
30962
  const enrollments = new Map;
30963
+ const allEnrollments = new Map;
30949
30964
  const entries = await Promise.all(courseIds.map(async (courseId) => {
30950
30965
  const roster = await client.oneroster.enrollments.listByCourse(courseId, {
30951
30966
  includeInactive: options?.includeInactive,
@@ -30957,28 +30972,30 @@ class TimebackAdminService {
30957
30972
  if (aActive !== bActive) {
30958
30973
  return aActive ? -1 : 1;
30959
30974
  }
30960
- return (b.enrollment.dateLastModified ?? "").localeCompare(a.enrollment.dateLastModified ?? "");
30975
+ return compareEnrollmentsByRecency(a.enrollment, b.enrollment);
30961
30976
  });
30962
- return { courseId, match: matches[0] ?? null };
30977
+ return { courseId, matches };
30963
30978
  }));
30964
- for (const { courseId, match } of entries) {
30965
- if (match) {
30966
- enrollments.set(courseId, {
30967
- id: match.enrollment.sourcedId,
30968
- status: match.enrollment.status ?? "active",
30969
- role: match.enrollment.role ?? "student",
30970
- beginDate: match.enrollment.beginDate ?? null,
30971
- endDate: match.enrollment.endDate ?? null,
30972
- course: {
30973
- id: courseId,
30974
- title: match.class?.title ?? "",
30975
- subjects: null,
30976
- grades: null
30977
- }
30978
- });
30979
+ for (const { courseId, matches } of entries) {
30980
+ const records = matches.map((match) => ({
30981
+ id: match.enrollment.sourcedId,
30982
+ status: match.enrollment.status ?? "active",
30983
+ role: match.enrollment.role ?? "student",
30984
+ beginDate: match.enrollment.beginDate ?? null,
30985
+ endDate: match.enrollment.endDate ?? null,
30986
+ course: {
30987
+ id: courseId,
30988
+ title: match.class?.title ?? "",
30989
+ subjects: null,
30990
+ grades: null
30991
+ }
30992
+ }));
30993
+ if (records.length > 0) {
30994
+ enrollments.set(courseId, records[0]);
30979
30995
  }
30996
+ allEnrollments.set(courseId, records);
30980
30997
  }
30981
- return { enrollments };
30998
+ return { enrollments, allEnrollments };
30982
30999
  }
30983
31000
  async assertStudentEnrolledInCourse(client, studentId, courseId) {
30984
31001
  const enrollments = await client.edubridge.enrollments.listByUser(studentId);
@@ -31065,7 +31082,7 @@ class TimebackAdminService {
31065
31082
  const enrollmentId = rosterEntry.enrollment.sourcedId || null;
31066
31083
  const summary = enrollmentId ? analyticsByEnrollmentId.get(enrollmentId) : undefined;
31067
31084
  const analyticsUnavailable = Boolean(enrollmentId) && summary?.analyticsAvailable !== true;
31068
- const name3 = rosterEntry.user ? `${rosterEntry.user.givenName} ${rosterEntry.user.familyName}`.trim() : rosterEntry.enrollment.user.sourcedId;
31085
+ const name3 = rosterEntry.user ? `${rosterEntry.user.givenName} ${rosterEntry.user.familyName}`.trim() || "No name specified" : "No name specified";
31069
31086
  const inactive = rosterEntry.enrollment.status === "tobedeleted";
31070
31087
  return {
31071
31088
  studentId: rosterEntry.enrollment.user.sourcedId,
@@ -31110,14 +31127,15 @@ class TimebackAdminService {
31110
31127
  throw new NotFoundError("Timeback integration", gameId);
31111
31128
  }
31112
31129
  const courseIds = new Set(integrations.map((integration) => integration.courseId));
31113
- const { enrollments: enrollmentsByCourseId } = await this.getStudentEnrollmentsByCourseId(client, studentId, [...courseIds], {
31130
+ const { enrollments: enrollmentsByCourseId, allEnrollments: allEnrollmentsByCourseId } = await this.getStudentEnrollmentsByCourseId(client, studentId, [...courseIds], {
31114
31131
  includeInactive: true
31115
31132
  });
31116
31133
  if (enrollmentsByCourseId.size === 0) {
31117
31134
  throw new NotFoundError("Student enrollment", courseId ? `${studentId}:${courseId}` : `${studentId}:${gameId}`);
31118
31135
  }
31136
+ const allEnrollmentIds = [...allEnrollmentsByCourseId.values()].flat().map((enrollment) => enrollment.id);
31119
31137
  const studentProfile = await client.oneroster.users.get(studentId);
31120
- const analyticsByEnrollmentId = await this.loadEnrollmentAnalyticsSummaries([...enrollmentsByCourseId.values()].map((enrollment) => enrollment.id));
31138
+ const analyticsByEnrollmentId = await this.loadEnrollmentAnalyticsSummaries(allEnrollmentIds);
31121
31139
  const [masterableUnitsByCourse, completionStatusByCourse] = await Promise.all([
31122
31140
  this.getMasterableUnitsByCourse(integrations.map((integration) => integration.courseId)),
31123
31141
  this.getCompletionStatusByCourse(client, integrations.map((integration) => integration.courseId), studentId)
@@ -31128,6 +31146,24 @@ class TimebackAdminService {
31128
31146
  const masterableUnits = masterableUnitsByCourse.get(integration.courseId);
31129
31147
  const analyticsUnavailable = Boolean(enrollment?.id) && summary?.analyticsAvailable !== true;
31130
31148
  const inactive = enrollment?.status === "tobedeleted";
31149
+ const courseEnrollments = allEnrollmentsByCourseId.get(integration.courseId) ?? [];
31150
+ const enrollmentSummaries = courseEnrollments.length > 1 ? courseEnrollments.map((record) => {
31151
+ const recordSummary = analyticsByEnrollmentId.get(record.id);
31152
+ const recordAnalyticsUnavailable = recordSummary?.analyticsAvailable !== true;
31153
+ return {
31154
+ enrollmentId: record.id,
31155
+ status: record.status === "tobedeleted" ? "tobedeleted" : "active",
31156
+ beginDate: record.beginDate,
31157
+ endDate: record.endDate,
31158
+ analyticsUnavailable: recordAnalyticsUnavailable,
31159
+ totalXp: recordSummary?.totalXp ?? 0,
31160
+ todayXp: recordSummary?.todayXp ?? 0,
31161
+ activeTimeSeconds: recordSummary?.activeTimeSeconds ?? 0,
31162
+ masteredUnits: recordSummary?.masteredUnits ?? 0,
31163
+ pctCompleteApp: TimebackAdminService.computeCompletionPct(recordSummary?.masteredUnits ?? 0, masterableUnits),
31164
+ history: recordSummary?.history ?? []
31165
+ };
31166
+ }) : undefined;
31131
31167
  return {
31132
31168
  courseId: integration.courseId,
31133
31169
  title: enrollment?.course.title || `${integration.subject} Grade ${integration.grade}`,
@@ -31143,9 +31179,11 @@ class TimebackAdminService {
31143
31179
  pctCompleteApp: TimebackAdminService.computeCompletionPct(summary?.masteredUnits ?? 0, masterableUnits),
31144
31180
  completionStatus: completionStatusByCourse.get(integration.courseId) ?? "none",
31145
31181
  history: summary?.history ?? [],
31146
- ...inactive ? { inactive } : {}
31182
+ ...inactive ? { inactive } : {},
31183
+ ...enrollmentSummaries ? { enrollments: enrollmentSummaries } : {}
31147
31184
  };
31148
31185
  });
31186
+ courses.sort((a, b) => a.grade - b.grade);
31149
31187
  return {
31150
31188
  student: {
31151
31189
  studentId,
@@ -31245,111 +31283,87 @@ class TimebackAdminService {
31245
31283
  return { status: "ok" };
31246
31284
  }
31247
31285
  async adjustMasteredUnits(data, user) {
31248
- 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;
31249
31300
  await client.recordAdminMasteryAdjustment({
31250
31301
  gameId: data.gameId,
31251
31302
  courseId: data.courseId,
31252
31303
  studentId: data.studentId,
31253
31304
  masteredUnits: data.units,
31305
+ pctCompleteApp,
31254
31306
  eventTime: resolveAdminEventTime(data),
31255
31307
  reason: data.reason,
31256
31308
  actor,
31257
31309
  appName,
31258
31310
  sensorUrl
31259
31311
  });
31260
- return { status: "ok" };
31261
- }
31262
- async toggleCourseCompletion(data, user) {
31263
- const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId, "dashboard");
31264
- const historyClient = client;
31265
- const ids = deriveSourcedIds(data.courseId);
31266
- const lineItemId = `${ids.course}-mastery-completion-assessment`;
31267
- const resultId = `${lineItemId}:${data.studentId}:completion`;
31268
- await client.oneroster.assessmentLineItems.findOrCreate(lineItemId, {
31269
- sourcedId: lineItemId,
31270
- title: "Mastery Completion",
31271
- status: ONEROSTER_STATUS.active,
31272
- course: { sourcedId: ids.course },
31273
- ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
31274
- });
31275
- if (data.action === "complete") {
31276
- const masterableUnits = await this.getMasterableUnits(data.courseId);
31277
- if (masterableUnits && masterableUnits > 0) {
31278
- const enrollment = await this.assertStudentEnrolledInCourse(client, data.studentId, data.courseId);
31279
- let currentMastered = 0;
31280
- try {
31281
- const analytics = await client.edubridge.analytics.getEnrollmentFacts(enrollment.id, { timezone: PLATFORM_TIMEZONE });
31282
- const summary = this.summarizeAnalyticsFacts(analytics.facts);
31283
- currentMastered = summary.masteredUnits;
31284
- } catch {
31285
- logger16.warn("Failed to load analytics for mastery gap calculation", {
31286
- studentId: data.studentId,
31287
- 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 } } : {}
31288
31326
  });
31289
- }
31290
- const gap = masterableUnits - currentMastered;
31291
- if (gap > 0) {
31292
- await client.recordAdminMasteryAdjustment({
31293
- gameId: data.gameId,
31294
- courseId: data.courseId,
31295
- studentId: data.studentId,
31296
- masteredUnits: gap,
31297
- reason: "Admin completed course",
31298
- actor,
31299
- appName,
31300
- 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
+ }
31301
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
+ }
31302
31365
  }
31303
31366
  }
31304
- await client.oneroster.assessmentResults.upsert(resultId, {
31305
- sourcedId: resultId,
31306
- status: ONEROSTER_STATUS.active,
31307
- assessmentLineItem: { sourcedId: lineItemId },
31308
- student: { sourcedId: data.studentId },
31309
- score: 100,
31310
- scoreDate: new Date().toISOString(),
31311
- scoreStatus: SCORE_STATUS.fullyGraded,
31312
- inProgress: "false",
31313
- metadata: {
31314
- isMasteryCompletion: true,
31315
- adminAction: true,
31316
- appName
31317
- }
31318
- });
31319
- await this.recordCourseCompletionHistory(historyClient, {
31320
- gameId: data.gameId,
31321
- courseId: data.courseId,
31322
- studentId: data.studentId,
31323
- action: "complete",
31324
- actor,
31325
- appName,
31326
- sensorUrl
31327
- });
31328
- } else {
31329
- await client.oneroster.assessmentResults.upsert(resultId, {
31330
- sourcedId: resultId,
31331
- status: ONEROSTER_STATUS.active,
31332
- assessmentLineItem: { sourcedId: lineItemId },
31333
- student: { sourcedId: data.studentId },
31334
- score: 0,
31335
- scoreDate: new Date().toISOString(),
31336
- scoreStatus: SCORE_STATUS.notSubmitted,
31337
- inProgress: "true",
31338
- metadata: {
31339
- isMasteryCompletion: true,
31340
- adminAction: true,
31341
- appName
31342
- }
31343
- });
31344
- await this.recordCourseCompletionHistory(historyClient, {
31345
- gameId: data.gameId,
31346
- courseId: data.courseId,
31347
- studentId: data.studentId,
31348
- action: "resume",
31349
- actor,
31350
- appName,
31351
- sensorUrl
31352
- });
31353
31367
  }
31354
31368
  return { status: "ok" };
31355
31369
  }
@@ -31385,17 +31399,34 @@ class TimebackAdminService {
31385
31399
  });
31386
31400
  return { students: [] };
31387
31401
  }
31388
- const roster = await client.oneroster.enrollments.listByCourse(courseId, {
31402
+ const fullRoster = await client.oneroster.enrollments.listByCourse(courseId, {
31389
31403
  role: "student",
31404
+ includeInactive: true,
31390
31405
  includeUsers: false
31391
31406
  });
31392
- const enrolledStudentIds = new Set(roster.map((entry) => entry.enrollment.user.sourcedId));
31393
- const students = allUsers.filter((entry) => Boolean(entry.sourcedId) && entry.roles?.some((role) => role.role === "student") === true).map((entry) => ({
31394
- studentId: entry.sourcedId,
31395
- name: `${entry.givenName || ""} ${entry.familyName || ""}`.trim() || entry.sourcedId,
31396
- email: entry.email || null,
31397
- alreadyEnrolled: enrolledStudentIds.has(entry.sourcedId)
31398
- }));
31407
+ const enrolledStudentIds = new Set(fullRoster.filter((entry) => entry.enrollment.status === "active").map((entry) => entry.enrollment.user.sourcedId));
31408
+ const pastEnrollmentsByStudent = new Map;
31409
+ const inactiveEntries = fullRoster.filter((entry) => entry.enrollment.status === "tobedeleted").toSorted((a, b) => compareEnrollmentsByRecency(a.enrollment, b.enrollment));
31410
+ for (const entry of inactiveEntries) {
31411
+ const studentId = entry.enrollment.user.sourcedId;
31412
+ const list = pastEnrollmentsByStudent.get(studentId) ?? [];
31413
+ list.push({
31414
+ enrollmentId: entry.enrollment.sourcedId,
31415
+ beginDate: entry.enrollment.beginDate ?? null,
31416
+ endDate: entry.enrollment.endDate ?? null
31417
+ });
31418
+ pastEnrollmentsByStudent.set(studentId, list);
31419
+ }
31420
+ const students = allUsers.filter((entry) => Boolean(entry.sourcedId) && entry.roles?.some((role) => role.role === "student") === true).map((entry) => {
31421
+ const past = pastEnrollmentsByStudent.get(entry.sourcedId) ?? [];
31422
+ return {
31423
+ studentId: entry.sourcedId,
31424
+ name: `${entry.givenName || ""} ${entry.familyName || ""}`.trim() || "No name specified",
31425
+ email: entry.email || null,
31426
+ alreadyEnrolled: enrolledStudentIds.has(entry.sourcedId),
31427
+ ...past.length > 0 ? { pastEnrollments: past } : {}
31428
+ };
31429
+ });
31399
31430
  return { students };
31400
31431
  }
31401
31432
  async enrollStudent(data, user) {
@@ -31426,6 +31457,48 @@ class TimebackAdminService {
31426
31457
  client.invalidateEnrollments(data.studentId);
31427
31458
  return { status: "ok" };
31428
31459
  }
31460
+ async reactivateEnrollment(data, user) {
31461
+ const client = this.requireClient();
31462
+ await this.deps.validateGameManagementAccess(user, data.gameId);
31463
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
31464
+ where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
31465
+ });
31466
+ if (!integration) {
31467
+ throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
31468
+ }
31469
+ const enrollment = await client.oneroster.enrollments.get(data.enrollmentId);
31470
+ if (!enrollment) {
31471
+ throw new NotFoundError("Enrollment", data.enrollmentId);
31472
+ }
31473
+ if (enrollment.user.sourcedId !== data.studentId) {
31474
+ throw new ValidationError("Enrollment does not belong to the specified student");
31475
+ }
31476
+ if (enrollment.status === "active") {
31477
+ throw new ValidationError("Enrollment is already active");
31478
+ }
31479
+ const { allEnrollments } = await this.getStudentEnrollmentsByCourseId(client, data.studentId, [data.courseId], { includeInactive: true });
31480
+ const courseEnrollmentIds = new Set((allEnrollments.get(data.courseId) ?? []).map((e) => e.id));
31481
+ if (!courseEnrollmentIds.has(data.enrollmentId)) {
31482
+ throw new ValidationError("Enrollment does not belong to the specified course");
31483
+ }
31484
+ const activeEnrollments = await client.edubridge.enrollments.listByUser(data.studentId);
31485
+ if (activeEnrollments.some((e) => e.course.id === data.courseId)) {
31486
+ throw new ValidationError("Student already has an active enrollment for this course. Unenroll from the current enrollment before reactivating a past one.");
31487
+ }
31488
+ await client.oneroster.enrollments.update(data.enrollmentId, {
31489
+ role: enrollment.role,
31490
+ primary: enrollment.primary,
31491
+ beginDate: enrollment.beginDate,
31492
+ endDate: enrollment.endDate,
31493
+ user: enrollment.user,
31494
+ class: enrollment.class,
31495
+ school: enrollment.school,
31496
+ sourcedId: data.enrollmentId,
31497
+ status: "active"
31498
+ });
31499
+ client.invalidateEnrollments(data.studentId);
31500
+ return { status: "ok" };
31501
+ }
31429
31502
  async getCompletionStatus(client, courseId, studentId) {
31430
31503
  const ids = deriveSourcedIds(courseId);
31431
31504
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
@@ -36516,6 +36589,10 @@ function createOneRosterNamespace(client) {
36516
36589
  }
36517
36590
  },
36518
36591
  enrollments: {
36592
+ get: async (sourcedId) => {
36593
+ const response = await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "GET");
36594
+ return response.enrollment;
36595
+ },
36519
36596
  listByClass: async (classSourcedId, options) => {
36520
36597
  const queryParams = new URLSearchParams;
36521
36598
  const filters = [`class.sourcedId='${escapeFilterValue2(classSourcedId)}'`];
@@ -36593,6 +36670,11 @@ function createOneRosterNamespace(client) {
36593
36670
  }
36594
36671
  },
36595
36672
  create: async (data) => client["request"](ONEROSTER_ENDPOINTS5.enrollments, "POST", { enrollment: data }),
36673
+ update: async (sourcedId, data) => {
36674
+ await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "PUT", {
36675
+ enrollment: data
36676
+ });
36677
+ },
36596
36678
  delete: async (sourcedId) => {
36597
36679
  await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "DELETE");
36598
36680
  }
@@ -36942,6 +37024,7 @@ class AdminEventRecorder {
36942
37024
  defaultActivityId: "playcademy-admin-mastery-adjustment",
36943
37025
  eventKind: "remediation-mastery"
36944
37026
  });
37027
+ const courseUrl = createOneRosterUrls(TIMEBACK_API_URLS5[this.environment]).course(data.courseId);
36945
37028
  await this.caliper.emitActivityEvent({
36946
37029
  studentId: ctx.student.id,
36947
37030
  studentEmail: ctx.student.email,
@@ -36956,32 +37039,13 @@ class AdminEventRecorder {
36956
37039
  appName: ctx.appName,
36957
37040
  sensorUrl: ctx.sensorUrl,
36958
37041
  process: true,
36959
- generatedExtensions: ctx.metadata,
36960
- eventExtensions: ctx.metadata
36961
- });
36962
- }
36963
- async recordCourseCompletionChange(data) {
36964
- const isResume = data.action === "resume";
36965
- const ctx = await this.prepareAdminEvent({
36966
- ...data,
36967
- defaultActivityId: isResume ? "playcademy-admin-course-resumed" : "playcademy-admin-course-completed",
36968
- reason: "Admin action",
36969
- eventKind: isResume ? "course-resumed" : "course-completed"
36970
- });
36971
- await this.caliper.emitActivityEvent({
36972
- studentId: ctx.student.id,
36973
- studentEmail: ctx.student.email,
36974
- gameId: data.gameId,
36975
- activityId: ctx.activityId,
36976
- activityName: isResume ? "Course resumed" : "Course marked complete",
36977
- courseId: data.courseId,
36978
- courseName: ctx.courseContext.courseName,
36979
- subject: ctx.courseContext.subject,
36980
- appName: ctx.appName,
36981
- sensorUrl: ctx.sensorUrl,
36982
- process: false,
36983
37042
  includeAttempt: false,
36984
- 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
+ },
36985
37049
  eventExtensions: ctx.metadata
36986
37050
  });
36987
37051
  }
@@ -38172,10 +38236,6 @@ class TimebackClient {
38172
38236
  await this._ensureAuthenticated();
38173
38237
  return this.adminEventRecorder.recordMasteryAdjustment(data);
38174
38238
  }
38175
- async recordAdminCourseCompletionChange(data) {
38176
- await this._ensureAuthenticated();
38177
- return this.adminEventRecorder.recordCourseCompletionChange(data);
38178
- }
38179
38239
  clearCaches() {
38180
38240
  this.cacheManager.clearAll();
38181
38241
  }
@@ -95106,7 +95166,7 @@ function isValidAdminAttributionDate(value) {
95106
95166
  const date4 = new Date(Date.UTC(year3, month - 1, day, 12, 0, 0));
95107
95167
  return date4.getUTCFullYear() === year3 && date4.getUTCMonth() + 1 === month && date4.getUTCDate() === day;
95108
95168
  }
95109
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS6, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, 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;
95110
95170
  var init_schemas11 = __esm(() => {
95111
95171
  init_drizzle_zod();
95112
95172
  init_esm();
@@ -95277,12 +95337,6 @@ var init_schemas11 = __esm(() => {
95277
95337
  date: AdminAttributionDateSchema.optional(),
95278
95338
  useCurrentTime: exports_external.boolean().optional()
95279
95339
  });
95280
- ToggleCourseCompletionRequestSchema = exports_external.object({
95281
- gameId: exports_external.string().uuid(),
95282
- courseId: exports_external.string().min(1),
95283
- studentId: exports_external.string().min(1),
95284
- action: exports_external.enum(["complete", "resume"])
95285
- });
95286
95340
  EnrollStudentRequestSchema = exports_external.object({
95287
95341
  gameId: exports_external.string().uuid(),
95288
95342
  courseId: exports_external.string().min(1),
@@ -95293,6 +95347,12 @@ var init_schemas11 = __esm(() => {
95293
95347
  courseId: exports_external.string().min(1),
95294
95348
  studentId: exports_external.string().min(1)
95295
95349
  });
95350
+ ReactivateEnrollmentRequestSchema = exports_external.object({
95351
+ gameId: exports_external.string().uuid(),
95352
+ courseId: exports_external.string().min(1),
95353
+ studentId: exports_external.string().min(1),
95354
+ enrollmentId: exports_external.string().min(1)
95355
+ });
95296
95356
  InsertAssessmentTestSchema = createInsertSchema(gameTimebackAssessmentTests).omit({
95297
95357
  id: true,
95298
95358
  createdAt: true
@@ -97472,7 +97532,7 @@ var init_sprite_controller = __esm(() => {
97472
97532
  });
97473
97533
 
97474
97534
  // ../api-core/src/controllers/timeback.controller.ts
97475
- 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, 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;
97476
97536
  var init_timeback_controller = __esm(() => {
97477
97537
  init_esm();
97478
97538
  init_schemas_index();
@@ -97873,17 +97933,6 @@ var init_timeback_controller = __esm(() => {
97873
97933
  });
97874
97934
  return ctx.services.timebackAdmin.adjustMasteredUnits(body2, ctx.user);
97875
97935
  });
97876
- toggleCompletion = requireGameManagementAccess(async (ctx) => {
97877
- const body2 = await parseRequestBody(ctx.request, ToggleCourseCompletionRequestSchema);
97878
- logger65.debug("Toggling course completion", {
97879
- requesterId: ctx.user.id,
97880
- gameId: body2.gameId,
97881
- courseId: body2.courseId,
97882
- studentId: body2.studentId,
97883
- action: body2.action
97884
- });
97885
- return ctx.services.timebackAdmin.toggleCourseCompletion(body2, ctx.user);
97886
- });
97887
97936
  searchStudents = requireGameManagementAccess(async (ctx) => {
97888
97937
  const gameId = ctx.params.gameId;
97889
97938
  const courseId = ctx.params.courseId;
@@ -97919,6 +97968,17 @@ var init_timeback_controller = __esm(() => {
97919
97968
  });
97920
97969
  return ctx.services.timebackAdmin.unenrollStudent(body2, ctx.user);
97921
97970
  });
97971
+ reactivateEnrollment = requireGameManagementAccess(async (ctx) => {
97972
+ const body2 = await parseRequestBody(ctx.request, ReactivateEnrollmentRequestSchema);
97973
+ logger65.debug("Reactivating enrollment", {
97974
+ requesterId: ctx.user.id,
97975
+ gameId: body2.gameId,
97976
+ courseId: body2.courseId,
97977
+ studentId: body2.studentId,
97978
+ enrollmentId: body2.enrollmentId
97979
+ });
97980
+ return ctx.services.timebackAdmin.reactivateEnrollment(body2, ctx.user);
97981
+ });
97922
97982
  listAssessments = requireGameManagementAccess(async (ctx) => {
97923
97983
  const { gameId, courseId } = ctx.params;
97924
97984
  if (!gameId || !courseId) {
@@ -98064,10 +98124,10 @@ var init_timeback_controller = __esm(() => {
98064
98124
  grantXp,
98065
98125
  adjustTime,
98066
98126
  adjustMastery,
98067
- toggleCompletion,
98068
98127
  searchStudents,
98069
98128
  enrollStudent,
98070
98129
  unenrollStudent,
98130
+ reactivateEnrollment,
98071
98131
  listAssessments,
98072
98132
  createAssessment,
98073
98133
  deleteAssessment,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.3.17-beta.33",
3
+ "version": "0.3.17-beta.35",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {