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

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 +149 -33
  2. package/dist/server.js +149 -33
  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.32",
1333
+ version: "0.3.17-beta.34",
1334
1334
  description: "Local development server for Playcademy game development",
1335
1335
  type: "module",
1336
1336
  exports: {
@@ -30433,6 +30433,13 @@ function resolveAdminEventTime(data) {
30433
30433
  }
30434
30434
  return toAttributionEventTime(data.date);
30435
30435
  }
30436
+ function compareEnrollmentsByRecency(a, b) {
30437
+ const dateCompare = (b.beginDate ?? "").localeCompare(a.beginDate ?? "");
30438
+ if (dateCompare !== 0) {
30439
+ return dateCompare;
30440
+ }
30441
+ return (b.dateLastModified ?? "").localeCompare(a.dateLastModified ?? "");
30442
+ }
30436
30443
  var init_timeback_admin_util = __esm(() => {
30437
30444
  init_errors();
30438
30445
  });
@@ -30947,6 +30954,7 @@ class TimebackAdminService {
30947
30954
  }
30948
30955
  async getStudentEnrollmentsByCourseId(client, studentId, courseIds, options) {
30949
30956
  const enrollments = new Map;
30957
+ const allEnrollments = new Map;
30950
30958
  const entries = await Promise.all(courseIds.map(async (courseId) => {
30951
30959
  const roster = await client.oneroster.enrollments.listByCourse(courseId, {
30952
30960
  includeInactive: options?.includeInactive,
@@ -30958,28 +30966,30 @@ class TimebackAdminService {
30958
30966
  if (aActive !== bActive) {
30959
30967
  return aActive ? -1 : 1;
30960
30968
  }
30961
- return (b.enrollment.dateLastModified ?? "").localeCompare(a.enrollment.dateLastModified ?? "");
30969
+ return compareEnrollmentsByRecency(a.enrollment, b.enrollment);
30962
30970
  });
30963
- return { courseId, match: matches[0] ?? null };
30971
+ return { courseId, matches };
30964
30972
  }));
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
- });
30973
+ for (const { courseId, matches } of entries) {
30974
+ const records = matches.map((match) => ({
30975
+ id: match.enrollment.sourcedId,
30976
+ status: match.enrollment.status ?? "active",
30977
+ role: match.enrollment.role ?? "student",
30978
+ beginDate: match.enrollment.beginDate ?? null,
30979
+ endDate: match.enrollment.endDate ?? null,
30980
+ course: {
30981
+ id: courseId,
30982
+ title: match.class?.title ?? "",
30983
+ subjects: null,
30984
+ grades: null
30985
+ }
30986
+ }));
30987
+ if (records.length > 0) {
30988
+ enrollments.set(courseId, records[0]);
30980
30989
  }
30990
+ allEnrollments.set(courseId, records);
30981
30991
  }
30982
- return { enrollments };
30992
+ return { enrollments, allEnrollments };
30983
30993
  }
30984
30994
  async assertStudentEnrolledInCourse(client, studentId, courseId) {
30985
30995
  const enrollments = await client.edubridge.enrollments.listByUser(studentId);
@@ -31066,7 +31076,7 @@ class TimebackAdminService {
31066
31076
  const enrollmentId = rosterEntry.enrollment.sourcedId || null;
31067
31077
  const summary = enrollmentId ? analyticsByEnrollmentId.get(enrollmentId) : undefined;
31068
31078
  const analyticsUnavailable = Boolean(enrollmentId) && summary?.analyticsAvailable !== true;
31069
- const name3 = rosterEntry.user ? `${rosterEntry.user.givenName} ${rosterEntry.user.familyName}`.trim() : rosterEntry.enrollment.user.sourcedId;
31079
+ const name3 = rosterEntry.user ? `${rosterEntry.user.givenName} ${rosterEntry.user.familyName}`.trim() || "No name specified" : "No name specified";
31070
31080
  const inactive = rosterEntry.enrollment.status === "tobedeleted";
31071
31081
  return {
31072
31082
  studentId: rosterEntry.enrollment.user.sourcedId,
@@ -31111,14 +31121,15 @@ class TimebackAdminService {
31111
31121
  throw new NotFoundError("Timeback integration", gameId);
31112
31122
  }
31113
31123
  const courseIds = new Set(integrations.map((integration) => integration.courseId));
31114
- const { enrollments: enrollmentsByCourseId } = await this.getStudentEnrollmentsByCourseId(client, studentId, [...courseIds], {
31124
+ const { enrollments: enrollmentsByCourseId, allEnrollments: allEnrollmentsByCourseId } = await this.getStudentEnrollmentsByCourseId(client, studentId, [...courseIds], {
31115
31125
  includeInactive: true
31116
31126
  });
31117
31127
  if (enrollmentsByCourseId.size === 0) {
31118
31128
  throw new NotFoundError("Student enrollment", courseId ? `${studentId}:${courseId}` : `${studentId}:${gameId}`);
31119
31129
  }
31130
+ const allEnrollmentIds = [...allEnrollmentsByCourseId.values()].flat().map((enrollment) => enrollment.id);
31120
31131
  const studentProfile = await client.oneroster.users.get(studentId);
31121
- const analyticsByEnrollmentId = await this.loadEnrollmentAnalyticsSummaries([...enrollmentsByCourseId.values()].map((enrollment) => enrollment.id));
31132
+ const analyticsByEnrollmentId = await this.loadEnrollmentAnalyticsSummaries(allEnrollmentIds);
31122
31133
  const [masterableUnitsByCourse, completionStatusByCourse] = await Promise.all([
31123
31134
  this.getMasterableUnitsByCourse(integrations.map((integration) => integration.courseId)),
31124
31135
  this.getCompletionStatusByCourse(client, integrations.map((integration) => integration.courseId), studentId)
@@ -31129,6 +31140,24 @@ class TimebackAdminService {
31129
31140
  const masterableUnits = masterableUnitsByCourse.get(integration.courseId);
31130
31141
  const analyticsUnavailable = Boolean(enrollment?.id) && summary?.analyticsAvailable !== true;
31131
31142
  const inactive = enrollment?.status === "tobedeleted";
31143
+ const courseEnrollments = allEnrollmentsByCourseId.get(integration.courseId) ?? [];
31144
+ const enrollmentSummaries = courseEnrollments.length > 1 ? courseEnrollments.map((record) => {
31145
+ const recordSummary = analyticsByEnrollmentId.get(record.id);
31146
+ const recordAnalyticsUnavailable = recordSummary?.analyticsAvailable !== true;
31147
+ return {
31148
+ enrollmentId: record.id,
31149
+ status: record.status === "tobedeleted" ? "tobedeleted" : "active",
31150
+ beginDate: record.beginDate,
31151
+ endDate: record.endDate,
31152
+ analyticsUnavailable: recordAnalyticsUnavailable,
31153
+ totalXp: recordSummary?.totalXp ?? 0,
31154
+ todayXp: recordSummary?.todayXp ?? 0,
31155
+ activeTimeSeconds: recordSummary?.activeTimeSeconds ?? 0,
31156
+ masteredUnits: recordSummary?.masteredUnits ?? 0,
31157
+ pctCompleteApp: TimebackAdminService.computeCompletionPct(recordSummary?.masteredUnits ?? 0, masterableUnits),
31158
+ history: recordSummary?.history ?? []
31159
+ };
31160
+ }) : undefined;
31132
31161
  return {
31133
31162
  courseId: integration.courseId,
31134
31163
  title: enrollment?.course.title || `${integration.subject} Grade ${integration.grade}`,
@@ -31144,7 +31173,8 @@ class TimebackAdminService {
31144
31173
  pctCompleteApp: TimebackAdminService.computeCompletionPct(summary?.masteredUnits ?? 0, masterableUnits),
31145
31174
  completionStatus: completionStatusByCourse.get(integration.courseId) ?? "none",
31146
31175
  history: summary?.history ?? [],
31147
- ...inactive ? { inactive } : {}
31176
+ ...inactive ? { inactive } : {},
31177
+ ...enrollmentSummaries ? { enrollments: enrollmentSummaries } : {}
31148
31178
  };
31149
31179
  });
31150
31180
  return {
@@ -31386,17 +31416,34 @@ class TimebackAdminService {
31386
31416
  });
31387
31417
  return { students: [] };
31388
31418
  }
31389
- const roster = await client.oneroster.enrollments.listByCourse(courseId, {
31419
+ const fullRoster = await client.oneroster.enrollments.listByCourse(courseId, {
31390
31420
  role: "student",
31421
+ includeInactive: true,
31391
31422
  includeUsers: false
31392
31423
  });
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
- }));
31424
+ const enrolledStudentIds = new Set(fullRoster.filter((entry) => entry.enrollment.status === "active").map((entry) => entry.enrollment.user.sourcedId));
31425
+ const pastEnrollmentsByStudent = new Map;
31426
+ const inactiveEntries = fullRoster.filter((entry) => entry.enrollment.status === "tobedeleted").toSorted((a, b) => compareEnrollmentsByRecency(a.enrollment, b.enrollment));
31427
+ for (const entry of inactiveEntries) {
31428
+ const studentId = entry.enrollment.user.sourcedId;
31429
+ const list = pastEnrollmentsByStudent.get(studentId) ?? [];
31430
+ list.push({
31431
+ enrollmentId: entry.enrollment.sourcedId,
31432
+ beginDate: entry.enrollment.beginDate ?? null,
31433
+ endDate: entry.enrollment.endDate ?? null
31434
+ });
31435
+ pastEnrollmentsByStudent.set(studentId, list);
31436
+ }
31437
+ const students = allUsers.filter((entry) => Boolean(entry.sourcedId) && entry.roles?.some((role) => role.role === "student") === true).map((entry) => {
31438
+ const past = pastEnrollmentsByStudent.get(entry.sourcedId) ?? [];
31439
+ return {
31440
+ studentId: entry.sourcedId,
31441
+ name: `${entry.givenName || ""} ${entry.familyName || ""}`.trim() || "No name specified",
31442
+ email: entry.email || null,
31443
+ alreadyEnrolled: enrolledStudentIds.has(entry.sourcedId),
31444
+ ...past.length > 0 ? { pastEnrollments: past } : {}
31445
+ };
31446
+ });
31400
31447
  return { students };
31401
31448
  }
31402
31449
  async enrollStudent(data, user) {
@@ -31427,6 +31474,48 @@ class TimebackAdminService {
31427
31474
  client.invalidateEnrollments(data.studentId);
31428
31475
  return { status: "ok" };
31429
31476
  }
31477
+ async reactivateEnrollment(data, user) {
31478
+ const client = this.requireClient();
31479
+ await this.deps.validateGameManagementAccess(user, data.gameId);
31480
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
31481
+ where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
31482
+ });
31483
+ if (!integration) {
31484
+ throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
31485
+ }
31486
+ const enrollment = await client.oneroster.enrollments.get(data.enrollmentId);
31487
+ if (!enrollment) {
31488
+ throw new NotFoundError("Enrollment", data.enrollmentId);
31489
+ }
31490
+ if (enrollment.user.sourcedId !== data.studentId) {
31491
+ throw new ValidationError("Enrollment does not belong to the specified student");
31492
+ }
31493
+ if (enrollment.status === "active") {
31494
+ throw new ValidationError("Enrollment is already active");
31495
+ }
31496
+ const { allEnrollments } = await this.getStudentEnrollmentsByCourseId(client, data.studentId, [data.courseId], { includeInactive: true });
31497
+ const courseEnrollmentIds = new Set((allEnrollments.get(data.courseId) ?? []).map((e) => e.id));
31498
+ if (!courseEnrollmentIds.has(data.enrollmentId)) {
31499
+ throw new ValidationError("Enrollment does not belong to the specified course");
31500
+ }
31501
+ const activeEnrollments = await client.edubridge.enrollments.listByUser(data.studentId);
31502
+ if (activeEnrollments.some((e) => e.course.id === data.courseId)) {
31503
+ throw new ValidationError("Student already has an active enrollment for this course. Unenroll from the current enrollment before reactivating a past one.");
31504
+ }
31505
+ await client.oneroster.enrollments.update(data.enrollmentId, {
31506
+ role: enrollment.role,
31507
+ primary: enrollment.primary,
31508
+ beginDate: enrollment.beginDate,
31509
+ endDate: enrollment.endDate,
31510
+ user: enrollment.user,
31511
+ class: enrollment.class,
31512
+ school: enrollment.school,
31513
+ sourcedId: data.enrollmentId,
31514
+ status: "active"
31515
+ });
31516
+ client.invalidateEnrollments(data.studentId);
31517
+ return { status: "ok" };
31518
+ }
31430
31519
  async getCompletionStatus(client, courseId, studentId) {
31431
31520
  const ids = deriveSourcedIds(courseId);
31432
31521
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
@@ -36517,6 +36606,10 @@ function createOneRosterNamespace(client) {
36517
36606
  }
36518
36607
  },
36519
36608
  enrollments: {
36609
+ get: async (sourcedId) => {
36610
+ const response = await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "GET");
36611
+ return response.enrollment;
36612
+ },
36520
36613
  listByClass: async (classSourcedId, options) => {
36521
36614
  const queryParams = new URLSearchParams;
36522
36615
  const filters = [`class.sourcedId='${escapeFilterValue2(classSourcedId)}'`];
@@ -36594,6 +36687,11 @@ function createOneRosterNamespace(client) {
36594
36687
  }
36595
36688
  },
36596
36689
  create: async (data) => client["request"](ONEROSTER_ENDPOINTS5.enrollments, "POST", { enrollment: data }),
36690
+ update: async (sourcedId, data) => {
36691
+ await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "PUT", {
36692
+ enrollment: data
36693
+ });
36694
+ },
36597
36695
  delete: async (sourcedId) => {
36598
36696
  await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "DELETE");
36599
36697
  }
@@ -95107,7 +95205,7 @@ function isValidAdminAttributionDate(value) {
95107
95205
  const date4 = new Date(Date.UTC(year3, month - 1, day, 12, 0, 0));
95108
95206
  return date4.getUTCFullYear() === year3 && date4.getUTCMonth() + 1 === month && date4.getUTCDate() === day;
95109
95207
  }
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;
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;
95111
95209
  var init_schemas11 = __esm(() => {
95112
95210
  init_drizzle_zod();
95113
95211
  init_esm();
@@ -95294,6 +95392,12 @@ var init_schemas11 = __esm(() => {
95294
95392
  courseId: exports_external.string().min(1),
95295
95393
  studentId: exports_external.string().min(1)
95296
95394
  });
95395
+ ReactivateEnrollmentRequestSchema = exports_external.object({
95396
+ gameId: exports_external.string().uuid(),
95397
+ courseId: exports_external.string().min(1),
95398
+ studentId: exports_external.string().min(1),
95399
+ enrollmentId: exports_external.string().min(1)
95400
+ });
95297
95401
  InsertAssessmentTestSchema = createInsertSchema(gameTimebackAssessmentTests).omit({
95298
95402
  id: true,
95299
95403
  createdAt: true
@@ -97473,7 +97577,7 @@ var init_sprite_controller = __esm(() => {
97473
97577
  });
97474
97578
 
97475
97579
  // ../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;
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;
97477
97581
  var init_timeback_controller = __esm(() => {
97478
97582
  init_esm();
97479
97583
  init_schemas_index();
@@ -97920,6 +98024,17 @@ var init_timeback_controller = __esm(() => {
97920
98024
  });
97921
98025
  return ctx.services.timebackAdmin.unenrollStudent(body2, ctx.user);
97922
98026
  });
98027
+ reactivateEnrollment = requireGameManagementAccess(async (ctx) => {
98028
+ const body2 = await parseRequestBody(ctx.request, ReactivateEnrollmentRequestSchema);
98029
+ logger65.debug("Reactivating enrollment", {
98030
+ requesterId: ctx.user.id,
98031
+ gameId: body2.gameId,
98032
+ courseId: body2.courseId,
98033
+ studentId: body2.studentId,
98034
+ enrollmentId: body2.enrollmentId
98035
+ });
98036
+ return ctx.services.timebackAdmin.reactivateEnrollment(body2, ctx.user);
98037
+ });
97923
98038
  listAssessments = requireGameManagementAccess(async (ctx) => {
97924
98039
  const { gameId, courseId } = ctx.params;
97925
98040
  if (!gameId || !courseId) {
@@ -98069,6 +98184,7 @@ var init_timeback_controller = __esm(() => {
98069
98184
  searchStudents,
98070
98185
  enrollStudent,
98071
98186
  unenrollStudent,
98187
+ reactivateEnrollment,
98072
98188
  listAssessments,
98073
98189
  createAssessment,
98074
98190
  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.32",
1332
+ version: "0.3.17-beta.34",
1333
1333
  description: "Local development server for Playcademy game development",
1334
1334
  type: "module",
1335
1335
  exports: {
@@ -30432,6 +30432,13 @@ function resolveAdminEventTime(data) {
30432
30432
  }
30433
30433
  return toAttributionEventTime(data.date);
30434
30434
  }
30435
+ function compareEnrollmentsByRecency(a, b) {
30436
+ const dateCompare = (b.beginDate ?? "").localeCompare(a.beginDate ?? "");
30437
+ if (dateCompare !== 0) {
30438
+ return dateCompare;
30439
+ }
30440
+ return (b.dateLastModified ?? "").localeCompare(a.dateLastModified ?? "");
30441
+ }
30435
30442
  var init_timeback_admin_util = __esm(() => {
30436
30443
  init_errors();
30437
30444
  });
@@ -30946,6 +30953,7 @@ class TimebackAdminService {
30946
30953
  }
30947
30954
  async getStudentEnrollmentsByCourseId(client, studentId, courseIds, options) {
30948
30955
  const enrollments = new Map;
30956
+ const allEnrollments = new Map;
30949
30957
  const entries = await Promise.all(courseIds.map(async (courseId) => {
30950
30958
  const roster = await client.oneroster.enrollments.listByCourse(courseId, {
30951
30959
  includeInactive: options?.includeInactive,
@@ -30957,28 +30965,30 @@ class TimebackAdminService {
30957
30965
  if (aActive !== bActive) {
30958
30966
  return aActive ? -1 : 1;
30959
30967
  }
30960
- return (b.enrollment.dateLastModified ?? "").localeCompare(a.enrollment.dateLastModified ?? "");
30968
+ return compareEnrollmentsByRecency(a.enrollment, b.enrollment);
30961
30969
  });
30962
- return { courseId, match: matches[0] ?? null };
30970
+ return { courseId, matches };
30963
30971
  }));
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
- });
30972
+ for (const { courseId, matches } of entries) {
30973
+ const records = matches.map((match) => ({
30974
+ id: match.enrollment.sourcedId,
30975
+ status: match.enrollment.status ?? "active",
30976
+ role: match.enrollment.role ?? "student",
30977
+ beginDate: match.enrollment.beginDate ?? null,
30978
+ endDate: match.enrollment.endDate ?? null,
30979
+ course: {
30980
+ id: courseId,
30981
+ title: match.class?.title ?? "",
30982
+ subjects: null,
30983
+ grades: null
30984
+ }
30985
+ }));
30986
+ if (records.length > 0) {
30987
+ enrollments.set(courseId, records[0]);
30979
30988
  }
30989
+ allEnrollments.set(courseId, records);
30980
30990
  }
30981
- return { enrollments };
30991
+ return { enrollments, allEnrollments };
30982
30992
  }
30983
30993
  async assertStudentEnrolledInCourse(client, studentId, courseId) {
30984
30994
  const enrollments = await client.edubridge.enrollments.listByUser(studentId);
@@ -31065,7 +31075,7 @@ class TimebackAdminService {
31065
31075
  const enrollmentId = rosterEntry.enrollment.sourcedId || null;
31066
31076
  const summary = enrollmentId ? analyticsByEnrollmentId.get(enrollmentId) : undefined;
31067
31077
  const analyticsUnavailable = Boolean(enrollmentId) && summary?.analyticsAvailable !== true;
31068
- const name3 = rosterEntry.user ? `${rosterEntry.user.givenName} ${rosterEntry.user.familyName}`.trim() : rosterEntry.enrollment.user.sourcedId;
31078
+ const name3 = rosterEntry.user ? `${rosterEntry.user.givenName} ${rosterEntry.user.familyName}`.trim() || "No name specified" : "No name specified";
31069
31079
  const inactive = rosterEntry.enrollment.status === "tobedeleted";
31070
31080
  return {
31071
31081
  studentId: rosterEntry.enrollment.user.sourcedId,
@@ -31110,14 +31120,15 @@ class TimebackAdminService {
31110
31120
  throw new NotFoundError("Timeback integration", gameId);
31111
31121
  }
31112
31122
  const courseIds = new Set(integrations.map((integration) => integration.courseId));
31113
- const { enrollments: enrollmentsByCourseId } = await this.getStudentEnrollmentsByCourseId(client, studentId, [...courseIds], {
31123
+ const { enrollments: enrollmentsByCourseId, allEnrollments: allEnrollmentsByCourseId } = await this.getStudentEnrollmentsByCourseId(client, studentId, [...courseIds], {
31114
31124
  includeInactive: true
31115
31125
  });
31116
31126
  if (enrollmentsByCourseId.size === 0) {
31117
31127
  throw new NotFoundError("Student enrollment", courseId ? `${studentId}:${courseId}` : `${studentId}:${gameId}`);
31118
31128
  }
31129
+ const allEnrollmentIds = [...allEnrollmentsByCourseId.values()].flat().map((enrollment) => enrollment.id);
31119
31130
  const studentProfile = await client.oneroster.users.get(studentId);
31120
- const analyticsByEnrollmentId = await this.loadEnrollmentAnalyticsSummaries([...enrollmentsByCourseId.values()].map((enrollment) => enrollment.id));
31131
+ const analyticsByEnrollmentId = await this.loadEnrollmentAnalyticsSummaries(allEnrollmentIds);
31121
31132
  const [masterableUnitsByCourse, completionStatusByCourse] = await Promise.all([
31122
31133
  this.getMasterableUnitsByCourse(integrations.map((integration) => integration.courseId)),
31123
31134
  this.getCompletionStatusByCourse(client, integrations.map((integration) => integration.courseId), studentId)
@@ -31128,6 +31139,24 @@ class TimebackAdminService {
31128
31139
  const masterableUnits = masterableUnitsByCourse.get(integration.courseId);
31129
31140
  const analyticsUnavailable = Boolean(enrollment?.id) && summary?.analyticsAvailable !== true;
31130
31141
  const inactive = enrollment?.status === "tobedeleted";
31142
+ const courseEnrollments = allEnrollmentsByCourseId.get(integration.courseId) ?? [];
31143
+ const enrollmentSummaries = courseEnrollments.length > 1 ? courseEnrollments.map((record) => {
31144
+ const recordSummary = analyticsByEnrollmentId.get(record.id);
31145
+ const recordAnalyticsUnavailable = recordSummary?.analyticsAvailable !== true;
31146
+ return {
31147
+ enrollmentId: record.id,
31148
+ status: record.status === "tobedeleted" ? "tobedeleted" : "active",
31149
+ beginDate: record.beginDate,
31150
+ endDate: record.endDate,
31151
+ analyticsUnavailable: recordAnalyticsUnavailable,
31152
+ totalXp: recordSummary?.totalXp ?? 0,
31153
+ todayXp: recordSummary?.todayXp ?? 0,
31154
+ activeTimeSeconds: recordSummary?.activeTimeSeconds ?? 0,
31155
+ masteredUnits: recordSummary?.masteredUnits ?? 0,
31156
+ pctCompleteApp: TimebackAdminService.computeCompletionPct(recordSummary?.masteredUnits ?? 0, masterableUnits),
31157
+ history: recordSummary?.history ?? []
31158
+ };
31159
+ }) : undefined;
31131
31160
  return {
31132
31161
  courseId: integration.courseId,
31133
31162
  title: enrollment?.course.title || `${integration.subject} Grade ${integration.grade}`,
@@ -31143,7 +31172,8 @@ class TimebackAdminService {
31143
31172
  pctCompleteApp: TimebackAdminService.computeCompletionPct(summary?.masteredUnits ?? 0, masterableUnits),
31144
31173
  completionStatus: completionStatusByCourse.get(integration.courseId) ?? "none",
31145
31174
  history: summary?.history ?? [],
31146
- ...inactive ? { inactive } : {}
31175
+ ...inactive ? { inactive } : {},
31176
+ ...enrollmentSummaries ? { enrollments: enrollmentSummaries } : {}
31147
31177
  };
31148
31178
  });
31149
31179
  return {
@@ -31385,17 +31415,34 @@ class TimebackAdminService {
31385
31415
  });
31386
31416
  return { students: [] };
31387
31417
  }
31388
- const roster = await client.oneroster.enrollments.listByCourse(courseId, {
31418
+ const fullRoster = await client.oneroster.enrollments.listByCourse(courseId, {
31389
31419
  role: "student",
31420
+ includeInactive: true,
31390
31421
  includeUsers: false
31391
31422
  });
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
- }));
31423
+ const enrolledStudentIds = new Set(fullRoster.filter((entry) => entry.enrollment.status === "active").map((entry) => entry.enrollment.user.sourcedId));
31424
+ const pastEnrollmentsByStudent = new Map;
31425
+ const inactiveEntries = fullRoster.filter((entry) => entry.enrollment.status === "tobedeleted").toSorted((a, b) => compareEnrollmentsByRecency(a.enrollment, b.enrollment));
31426
+ for (const entry of inactiveEntries) {
31427
+ const studentId = entry.enrollment.user.sourcedId;
31428
+ const list = pastEnrollmentsByStudent.get(studentId) ?? [];
31429
+ list.push({
31430
+ enrollmentId: entry.enrollment.sourcedId,
31431
+ beginDate: entry.enrollment.beginDate ?? null,
31432
+ endDate: entry.enrollment.endDate ?? null
31433
+ });
31434
+ pastEnrollmentsByStudent.set(studentId, list);
31435
+ }
31436
+ const students = allUsers.filter((entry) => Boolean(entry.sourcedId) && entry.roles?.some((role) => role.role === "student") === true).map((entry) => {
31437
+ const past = pastEnrollmentsByStudent.get(entry.sourcedId) ?? [];
31438
+ return {
31439
+ studentId: entry.sourcedId,
31440
+ name: `${entry.givenName || ""} ${entry.familyName || ""}`.trim() || "No name specified",
31441
+ email: entry.email || null,
31442
+ alreadyEnrolled: enrolledStudentIds.has(entry.sourcedId),
31443
+ ...past.length > 0 ? { pastEnrollments: past } : {}
31444
+ };
31445
+ });
31399
31446
  return { students };
31400
31447
  }
31401
31448
  async enrollStudent(data, user) {
@@ -31426,6 +31473,48 @@ class TimebackAdminService {
31426
31473
  client.invalidateEnrollments(data.studentId);
31427
31474
  return { status: "ok" };
31428
31475
  }
31476
+ async reactivateEnrollment(data, user) {
31477
+ const client = this.requireClient();
31478
+ await this.deps.validateGameManagementAccess(user, data.gameId);
31479
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
31480
+ where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
31481
+ });
31482
+ if (!integration) {
31483
+ throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
31484
+ }
31485
+ const enrollment = await client.oneroster.enrollments.get(data.enrollmentId);
31486
+ if (!enrollment) {
31487
+ throw new NotFoundError("Enrollment", data.enrollmentId);
31488
+ }
31489
+ if (enrollment.user.sourcedId !== data.studentId) {
31490
+ throw new ValidationError("Enrollment does not belong to the specified student");
31491
+ }
31492
+ if (enrollment.status === "active") {
31493
+ throw new ValidationError("Enrollment is already active");
31494
+ }
31495
+ const { allEnrollments } = await this.getStudentEnrollmentsByCourseId(client, data.studentId, [data.courseId], { includeInactive: true });
31496
+ const courseEnrollmentIds = new Set((allEnrollments.get(data.courseId) ?? []).map((e) => e.id));
31497
+ if (!courseEnrollmentIds.has(data.enrollmentId)) {
31498
+ throw new ValidationError("Enrollment does not belong to the specified course");
31499
+ }
31500
+ const activeEnrollments = await client.edubridge.enrollments.listByUser(data.studentId);
31501
+ if (activeEnrollments.some((e) => e.course.id === data.courseId)) {
31502
+ throw new ValidationError("Student already has an active enrollment for this course. Unenroll from the current enrollment before reactivating a past one.");
31503
+ }
31504
+ await client.oneroster.enrollments.update(data.enrollmentId, {
31505
+ role: enrollment.role,
31506
+ primary: enrollment.primary,
31507
+ beginDate: enrollment.beginDate,
31508
+ endDate: enrollment.endDate,
31509
+ user: enrollment.user,
31510
+ class: enrollment.class,
31511
+ school: enrollment.school,
31512
+ sourcedId: data.enrollmentId,
31513
+ status: "active"
31514
+ });
31515
+ client.invalidateEnrollments(data.studentId);
31516
+ return { status: "ok" };
31517
+ }
31429
31518
  async getCompletionStatus(client, courseId, studentId) {
31430
31519
  const ids = deriveSourcedIds(courseId);
31431
31520
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
@@ -36516,6 +36605,10 @@ function createOneRosterNamespace(client) {
36516
36605
  }
36517
36606
  },
36518
36607
  enrollments: {
36608
+ get: async (sourcedId) => {
36609
+ const response = await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "GET");
36610
+ return response.enrollment;
36611
+ },
36519
36612
  listByClass: async (classSourcedId, options) => {
36520
36613
  const queryParams = new URLSearchParams;
36521
36614
  const filters = [`class.sourcedId='${escapeFilterValue2(classSourcedId)}'`];
@@ -36593,6 +36686,11 @@ function createOneRosterNamespace(client) {
36593
36686
  }
36594
36687
  },
36595
36688
  create: async (data) => client["request"](ONEROSTER_ENDPOINTS5.enrollments, "POST", { enrollment: data }),
36689
+ update: async (sourcedId, data) => {
36690
+ await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "PUT", {
36691
+ enrollment: data
36692
+ });
36693
+ },
36596
36694
  delete: async (sourcedId) => {
36597
36695
  await client["request"](`${ONEROSTER_ENDPOINTS5.enrollments}/${sourcedId}`, "DELETE");
36598
36696
  }
@@ -95106,7 +95204,7 @@ function isValidAdminAttributionDate(value) {
95106
95204
  const date4 = new Date(Date.UTC(year3, month - 1, day, 12, 0, 0));
95107
95205
  return date4.getUTCFullYear() === year3 && date4.getUTCMonth() + 1 === month && date4.getUTCDate() === day;
95108
95206
  }
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;
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;
95110
95208
  var init_schemas11 = __esm(() => {
95111
95209
  init_drizzle_zod();
95112
95210
  init_esm();
@@ -95293,6 +95391,12 @@ var init_schemas11 = __esm(() => {
95293
95391
  courseId: exports_external.string().min(1),
95294
95392
  studentId: exports_external.string().min(1)
95295
95393
  });
95394
+ ReactivateEnrollmentRequestSchema = exports_external.object({
95395
+ gameId: exports_external.string().uuid(),
95396
+ courseId: exports_external.string().min(1),
95397
+ studentId: exports_external.string().min(1),
95398
+ enrollmentId: exports_external.string().min(1)
95399
+ });
95296
95400
  InsertAssessmentTestSchema = createInsertSchema(gameTimebackAssessmentTests).omit({
95297
95401
  id: true,
95298
95402
  createdAt: true
@@ -97472,7 +97576,7 @@ var init_sprite_controller = __esm(() => {
97472
97576
  });
97473
97577
 
97474
97578
  // ../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;
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;
97476
97580
  var init_timeback_controller = __esm(() => {
97477
97581
  init_esm();
97478
97582
  init_schemas_index();
@@ -97919,6 +98023,17 @@ var init_timeback_controller = __esm(() => {
97919
98023
  });
97920
98024
  return ctx.services.timebackAdmin.unenrollStudent(body2, ctx.user);
97921
98025
  });
98026
+ reactivateEnrollment = requireGameManagementAccess(async (ctx) => {
98027
+ const body2 = await parseRequestBody(ctx.request, ReactivateEnrollmentRequestSchema);
98028
+ logger65.debug("Reactivating enrollment", {
98029
+ requesterId: ctx.user.id,
98030
+ gameId: body2.gameId,
98031
+ courseId: body2.courseId,
98032
+ studentId: body2.studentId,
98033
+ enrollmentId: body2.enrollmentId
98034
+ });
98035
+ return ctx.services.timebackAdmin.reactivateEnrollment(body2, ctx.user);
98036
+ });
97922
98037
  listAssessments = requireGameManagementAccess(async (ctx) => {
97923
98038
  const { gameId, courseId } = ctx.params;
97924
98039
  if (!gameId || !courseId) {
@@ -98068,6 +98183,7 @@ var init_timeback_controller = __esm(() => {
98068
98183
  searchStudents,
98069
98184
  enrollStudent,
98070
98185
  unenrollStudent,
98186
+ reactivateEnrollment,
98071
98187
  listAssessments,
98072
98188
  createAssessment,
98073
98189
  deleteAssessment,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.3.17-beta.32",
3
+ "version": "0.3.17-beta.34",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {