@playcademy/sandbox 0.3.17-beta.20 → 0.3.17-beta.22

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 +125 -15
  2. package/dist/server.js +125 -15
  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.20",
1333
+ version: "0.3.17-beta.22",
1334
1334
  description: "Local development server for Playcademy game development",
1335
1335
  type: "module",
1336
1336
  exports: {
@@ -30922,21 +30922,25 @@ class TimebackAdminService {
30922
30922
  });
30923
30923
  return new Map(results);
30924
30924
  }
30925
+ async fetchCaliperEventsForStudent(client, studentId, source, limit) {
30926
+ const actorId = `${client.getBaseUrl().replace(/\/$/, "")}/ims/oneroster/rostering/v1p2/users/${studentId}`;
30927
+ const { events } = await client.caliper.events.list({
30928
+ limit,
30929
+ actorId,
30930
+ ...source.sourceMode === "production" ? { sensor: source.sensorUrl } : {},
30931
+ extensions: {
30932
+ gameId: source.gameId
30933
+ }
30934
+ });
30935
+ return events;
30936
+ }
30925
30937
  async listRecentActivityForStudent(client, studentId, source, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
30926
30938
  if (relevantCourseIds.size === 0) {
30927
30939
  return [];
30928
30940
  }
30929
30941
  try {
30930
- const actorId = `${client.getBaseUrl().replace(/\/$/, "")}/ims/oneroster/rostering/v1p2/users/${studentId}`;
30931
30942
  const eventLimit = Math.min(Math.max(200, maxResults * 20), TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
30932
- const { events } = await client.caliper.events.list({
30933
- limit: eventLimit,
30934
- actorId,
30935
- ...source.sourceMode === "production" ? { sensor: source.sensorUrl } : {},
30936
- extensions: {
30937
- gameId: source.gameId
30938
- }
30939
- });
30943
+ const events = await this.fetchCaliperEventsForStudent(client, studentId, source, eventLimit);
30940
30944
  return TimebackAdminService.mapRecentActivityItems(events, relevantCourseIds).slice(0, maxResults);
30941
30945
  } catch (error) {
30942
30946
  logger16.warn("Failed to load recent Caliper activity", {
@@ -31060,6 +31064,42 @@ class TimebackAdminService {
31060
31064
  const hasMore = allActivities.length > safeOffset + safeLimit;
31061
31065
  return { activities, hasMore };
31062
31066
  }
31067
+ async getActivityDetail(user, options) {
31068
+ const { gameId, studentId, courseId, activityId, runId } = options;
31069
+ const client = this.requireClient();
31070
+ await this.deps.validateGameManagementAccess(user, gameId);
31071
+ const [integration, gameSource] = await Promise.all([
31072
+ this.deps.db.query.gameTimebackIntegrations.findFirst({
31073
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
31074
+ }),
31075
+ this.getGameActivitySource(gameId)
31076
+ ]);
31077
+ if (!integration) {
31078
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
31079
+ }
31080
+ await this.assertStudentEnrolledInCourse(client, studentId, courseId);
31081
+ const events = await this.fetchCaliperEventsForStudent(client, studentId, gameSource, TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
31082
+ const relevantCourseIds = new Set([courseId]);
31083
+ let matchedEvents;
31084
+ let activity;
31085
+ if (runId) {
31086
+ const gameplayEvents = events.filter((event) => (event.type === "ActivityEvent" || event.type === "TimeSpentEvent") && !isCaliperRemediationOrCompletionEvent(event));
31087
+ const groups = groupCaliperEventsByRun(gameplayEvents);
31088
+ matchedEvents = [...groups.values()].find((group) => group.some((event) => getCanonicalRunId(event.session) === runId && event.externalId === activityId)) ?? [];
31089
+ activity = mapCaliperEventGroupToActivity(matchedEvents, relevantCourseIds);
31090
+ } else {
31091
+ matchedEvents = events.filter((event) => event.externalId === activityId);
31092
+ if (matchedEvents.length > 0) {
31093
+ activity = mapCaliperEventToRemediationActivity(matchedEvents[0], relevantCourseIds) ?? mapCaliperEventGroupToActivity(matchedEvents, relevantCourseIds);
31094
+ } else {
31095
+ activity = null;
31096
+ }
31097
+ }
31098
+ if (!activity) {
31099
+ throw new NotFoundError("Activity", activityId);
31100
+ }
31101
+ return { activity, rawEvents: matchedEvents };
31102
+ }
31063
31103
  async grantManualXp(data, user) {
31064
31104
  const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
31065
31105
  await client.recordAdminXpAdjustment({
@@ -36277,7 +36317,7 @@ class MasteryTracker {
36277
36317
  }
36278
36318
  async checkProgress(input) {
36279
36319
  const { studentId, courseId, resourceId, masteredUnits } = input;
36280
- if (typeof masteredUnits !== "number" || masteredUnits <= 0) {
36320
+ if (typeof masteredUnits !== "number" || masteredUnits === 0) {
36281
36321
  return;
36282
36322
  }
36283
36323
  const status = await this.calculateStatus({
@@ -36289,9 +36329,11 @@ class MasteryTracker {
36289
36329
  if (!status) {
36290
36330
  return;
36291
36331
  }
36332
+ const wasComplete = status.historicalMasteredUnits >= status.masterableUnits;
36292
36333
  return {
36293
36334
  pctCompleteApp: status.pctCompleteApp,
36294
- masteryAchieved: status.historicalMasteredUnits < status.masterableUnits && status.isComplete
36335
+ masteryAchieved: !wasComplete && status.isComplete,
36336
+ masteryRevoked: wasComplete && !status.isComplete
36295
36337
  };
36296
36338
  }
36297
36339
  async getStatus(input) {
@@ -36332,7 +36374,7 @@ class MasteryTracker {
36332
36374
  return;
36333
36375
  }
36334
36376
  const historicalMasteredUnits = this.sumAnalyticsMetric(facts, "masteredUnits");
36335
- const totalMastered = historicalMasteredUnits + additionalMasteredUnits;
36377
+ const totalMastered = Math.max(0, historicalMasteredUnits + additionalMasteredUnits);
36336
36378
  const rawPct = totalMastered / masterableUnits * 100;
36337
36379
  const pctCompleteApp = Math.min(100, Math.max(0, Math.round(rawPct)));
36338
36380
  return {
@@ -36382,6 +36424,45 @@ class MasteryTracker {
36382
36424
  });
36383
36425
  }
36384
36426
  }
36427
+ async revokeCompletionEntry(studentId, courseId, classId, appName) {
36428
+ const ids = deriveSourcedIds2(courseId);
36429
+ const lineItemId = `${ids.course}-mastery-completion-assessment`;
36430
+ const resultId = `${lineItemId}:${studentId}:completion`;
36431
+ try {
36432
+ await this.onerosterNamespace.assessmentLineItems.findOrCreate(lineItemId, {
36433
+ sourcedId: lineItemId,
36434
+ title: "Mastery Completion",
36435
+ status: ONEROSTER_STATUS4.active,
36436
+ ...classId ? { class: { sourcedId: classId } } : { course: { sourcedId: ids.course } },
36437
+ ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
36438
+ });
36439
+ await this.onerosterNamespace.assessmentResults.upsert(resultId, {
36440
+ sourcedId: resultId,
36441
+ status: ONEROSTER_STATUS4.active,
36442
+ assessmentLineItem: { sourcedId: lineItemId },
36443
+ student: { sourcedId: studentId },
36444
+ score: 0,
36445
+ scoreDate: new Date().toISOString(),
36446
+ scoreStatus: SCORE_STATUS4.notSubmitted,
36447
+ inProgress: "true",
36448
+ metadata: {
36449
+ isMasteryCompletion: true,
36450
+ appName
36451
+ }
36452
+ });
36453
+ log.info("[MasteryTracker] Revoked mastery completion entry", {
36454
+ studentId,
36455
+ lineItemId,
36456
+ resultId
36457
+ });
36458
+ } catch (error) {
36459
+ log.error("[MasteryTracker] Failed to revoke mastery completion entry", {
36460
+ studentId,
36461
+ lineItemId,
36462
+ error
36463
+ });
36464
+ }
36465
+ }
36385
36466
  async resolveMasterableUnits(resourceId) {
36386
36467
  if (!resourceId) {
36387
36468
  return;
@@ -36591,6 +36672,9 @@ class ProgressRecorder {
36591
36672
  sensorUrl: progressData.sensorUrl
36592
36673
  });
36593
36674
  }
36675
+ if (masteryProgress?.masteryRevoked) {
36676
+ await this.masteryTracker.revokeCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
36677
+ }
36594
36678
  await this.emitCaliperEvent({
36595
36679
  studentId,
36596
36680
  studentEmail,
@@ -94183,7 +94267,7 @@ var init_schemas11 = __esm(() => {
94183
94267
  inactiveSeconds: exports_external.number().nonnegative().optional()
94184
94268
  }).optional(),
94185
94269
  xpEarned: exports_external.number().optional(),
94186
- masteredUnits: exports_external.number().nonnegative().optional(),
94270
+ masteredUnits: exports_external.number().optional(),
94187
94271
  extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
94188
94272
  });
94189
94273
  AdvanceCourseRequestSchema = exports_external.object({
@@ -96478,7 +96562,7 @@ var init_sprite_controller = __esm(() => {
96478
96562
  });
96479
96563
 
96480
96564
  // ../api-core/src/controllers/timeback.controller.ts
96481
- var logger64, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, heartbeat, advanceCourse, getStudentXp, getRoster, getStudentOverview, getStudentActivity, grantXp, adjustTime, adjustMastery, toggleCompletion, searchStudents, enrollStudent, unenrollStudent, timeback2;
96565
+ var logger64, 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, timeback2;
96482
96566
  var init_timeback_controller = __esm(() => {
96483
96567
  init_esm();
96484
96568
  init_schemas_index();
@@ -96814,6 +96898,31 @@ var init_timeback_controller = __esm(() => {
96814
96898
  offset
96815
96899
  });
96816
96900
  });
96901
+ getActivityDetail = requireGameManagementAccess(async (ctx) => {
96902
+ const timebackId = ctx.params.timebackId;
96903
+ const courseId = ctx.params.courseId;
96904
+ const activityId = ctx.params.activityId;
96905
+ const gameId = ctx.url.searchParams.get("gameId") || undefined;
96906
+ const runId = ctx.url.searchParams.get("runId") || undefined;
96907
+ if (!timebackId || !courseId || !activityId || !gameId) {
96908
+ throw ApiError.badRequest("Missing timebackId, courseId, or activityId path parameter, or gameId query parameter");
96909
+ }
96910
+ logger64.debug("Getting activity detail", {
96911
+ requesterId: ctx.user.id,
96912
+ timebackId,
96913
+ courseId,
96914
+ activityId,
96915
+ gameId,
96916
+ runId
96917
+ });
96918
+ return ctx.services.timebackAdmin.getActivityDetail(ctx.user, {
96919
+ gameId,
96920
+ studentId: timebackId,
96921
+ courseId,
96922
+ activityId,
96923
+ runId
96924
+ });
96925
+ });
96817
96926
  grantXp = requireDeveloper(async (ctx) => {
96818
96927
  const body2 = await parseRequestBody(ctx.request, GrantTimebackXpRequestSchema);
96819
96928
  logger64.debug("Granting manual XP", {
@@ -96916,6 +97025,7 @@ var init_timeback_controller = __esm(() => {
96916
97025
  getRoster,
96917
97026
  getStudentOverview,
96918
97027
  getStudentActivity,
97028
+ getActivityDetail,
96919
97029
  grantXp,
96920
97030
  adjustTime,
96921
97031
  adjustMastery,
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.20",
1332
+ version: "0.3.17-beta.22",
1333
1333
  description: "Local development server for Playcademy game development",
1334
1334
  type: "module",
1335
1335
  exports: {
@@ -30921,21 +30921,25 @@ class TimebackAdminService {
30921
30921
  });
30922
30922
  return new Map(results);
30923
30923
  }
30924
+ async fetchCaliperEventsForStudent(client, studentId, source, limit) {
30925
+ const actorId = `${client.getBaseUrl().replace(/\/$/, "")}/ims/oneroster/rostering/v1p2/users/${studentId}`;
30926
+ const { events } = await client.caliper.events.list({
30927
+ limit,
30928
+ actorId,
30929
+ ...source.sourceMode === "production" ? { sensor: source.sensorUrl } : {},
30930
+ extensions: {
30931
+ gameId: source.gameId
30932
+ }
30933
+ });
30934
+ return events;
30935
+ }
30924
30936
  async listRecentActivityForStudent(client, studentId, source, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
30925
30937
  if (relevantCourseIds.size === 0) {
30926
30938
  return [];
30927
30939
  }
30928
30940
  try {
30929
- const actorId = `${client.getBaseUrl().replace(/\/$/, "")}/ims/oneroster/rostering/v1p2/users/${studentId}`;
30930
30941
  const eventLimit = Math.min(Math.max(200, maxResults * 20), TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
30931
- const { events } = await client.caliper.events.list({
30932
- limit: eventLimit,
30933
- actorId,
30934
- ...source.sourceMode === "production" ? { sensor: source.sensorUrl } : {},
30935
- extensions: {
30936
- gameId: source.gameId
30937
- }
30938
- });
30942
+ const events = await this.fetchCaliperEventsForStudent(client, studentId, source, eventLimit);
30939
30943
  return TimebackAdminService.mapRecentActivityItems(events, relevantCourseIds).slice(0, maxResults);
30940
30944
  } catch (error) {
30941
30945
  logger16.warn("Failed to load recent Caliper activity", {
@@ -31059,6 +31063,42 @@ class TimebackAdminService {
31059
31063
  const hasMore = allActivities.length > safeOffset + safeLimit;
31060
31064
  return { activities, hasMore };
31061
31065
  }
31066
+ async getActivityDetail(user, options) {
31067
+ const { gameId, studentId, courseId, activityId, runId } = options;
31068
+ const client = this.requireClient();
31069
+ await this.deps.validateGameManagementAccess(user, gameId);
31070
+ const [integration, gameSource] = await Promise.all([
31071
+ this.deps.db.query.gameTimebackIntegrations.findFirst({
31072
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
31073
+ }),
31074
+ this.getGameActivitySource(gameId)
31075
+ ]);
31076
+ if (!integration) {
31077
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
31078
+ }
31079
+ await this.assertStudentEnrolledInCourse(client, studentId, courseId);
31080
+ const events = await this.fetchCaliperEventsForStudent(client, studentId, gameSource, TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
31081
+ const relevantCourseIds = new Set([courseId]);
31082
+ let matchedEvents;
31083
+ let activity;
31084
+ if (runId) {
31085
+ const gameplayEvents = events.filter((event) => (event.type === "ActivityEvent" || event.type === "TimeSpentEvent") && !isCaliperRemediationOrCompletionEvent(event));
31086
+ const groups = groupCaliperEventsByRun(gameplayEvents);
31087
+ matchedEvents = [...groups.values()].find((group) => group.some((event) => getCanonicalRunId(event.session) === runId && event.externalId === activityId)) ?? [];
31088
+ activity = mapCaliperEventGroupToActivity(matchedEvents, relevantCourseIds);
31089
+ } else {
31090
+ matchedEvents = events.filter((event) => event.externalId === activityId);
31091
+ if (matchedEvents.length > 0) {
31092
+ activity = mapCaliperEventToRemediationActivity(matchedEvents[0], relevantCourseIds) ?? mapCaliperEventGroupToActivity(matchedEvents, relevantCourseIds);
31093
+ } else {
31094
+ activity = null;
31095
+ }
31096
+ }
31097
+ if (!activity) {
31098
+ throw new NotFoundError("Activity", activityId);
31099
+ }
31100
+ return { activity, rawEvents: matchedEvents };
31101
+ }
31062
31102
  async grantManualXp(data, user) {
31063
31103
  const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
31064
31104
  await client.recordAdminXpAdjustment({
@@ -36276,7 +36316,7 @@ class MasteryTracker {
36276
36316
  }
36277
36317
  async checkProgress(input) {
36278
36318
  const { studentId, courseId, resourceId, masteredUnits } = input;
36279
- if (typeof masteredUnits !== "number" || masteredUnits <= 0) {
36319
+ if (typeof masteredUnits !== "number" || masteredUnits === 0) {
36280
36320
  return;
36281
36321
  }
36282
36322
  const status = await this.calculateStatus({
@@ -36288,9 +36328,11 @@ class MasteryTracker {
36288
36328
  if (!status) {
36289
36329
  return;
36290
36330
  }
36331
+ const wasComplete = status.historicalMasteredUnits >= status.masterableUnits;
36291
36332
  return {
36292
36333
  pctCompleteApp: status.pctCompleteApp,
36293
- masteryAchieved: status.historicalMasteredUnits < status.masterableUnits && status.isComplete
36334
+ masteryAchieved: !wasComplete && status.isComplete,
36335
+ masteryRevoked: wasComplete && !status.isComplete
36294
36336
  };
36295
36337
  }
36296
36338
  async getStatus(input) {
@@ -36331,7 +36373,7 @@ class MasteryTracker {
36331
36373
  return;
36332
36374
  }
36333
36375
  const historicalMasteredUnits = this.sumAnalyticsMetric(facts, "masteredUnits");
36334
- const totalMastered = historicalMasteredUnits + additionalMasteredUnits;
36376
+ const totalMastered = Math.max(0, historicalMasteredUnits + additionalMasteredUnits);
36335
36377
  const rawPct = totalMastered / masterableUnits * 100;
36336
36378
  const pctCompleteApp = Math.min(100, Math.max(0, Math.round(rawPct)));
36337
36379
  return {
@@ -36381,6 +36423,45 @@ class MasteryTracker {
36381
36423
  });
36382
36424
  }
36383
36425
  }
36426
+ async revokeCompletionEntry(studentId, courseId, classId, appName) {
36427
+ const ids = deriveSourcedIds2(courseId);
36428
+ const lineItemId = `${ids.course}-mastery-completion-assessment`;
36429
+ const resultId = `${lineItemId}:${studentId}:completion`;
36430
+ try {
36431
+ await this.onerosterNamespace.assessmentLineItems.findOrCreate(lineItemId, {
36432
+ sourcedId: lineItemId,
36433
+ title: "Mastery Completion",
36434
+ status: ONEROSTER_STATUS4.active,
36435
+ ...classId ? { class: { sourcedId: classId } } : { course: { sourcedId: ids.course } },
36436
+ ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
36437
+ });
36438
+ await this.onerosterNamespace.assessmentResults.upsert(resultId, {
36439
+ sourcedId: resultId,
36440
+ status: ONEROSTER_STATUS4.active,
36441
+ assessmentLineItem: { sourcedId: lineItemId },
36442
+ student: { sourcedId: studentId },
36443
+ score: 0,
36444
+ scoreDate: new Date().toISOString(),
36445
+ scoreStatus: SCORE_STATUS4.notSubmitted,
36446
+ inProgress: "true",
36447
+ metadata: {
36448
+ isMasteryCompletion: true,
36449
+ appName
36450
+ }
36451
+ });
36452
+ log.info("[MasteryTracker] Revoked mastery completion entry", {
36453
+ studentId,
36454
+ lineItemId,
36455
+ resultId
36456
+ });
36457
+ } catch (error) {
36458
+ log.error("[MasteryTracker] Failed to revoke mastery completion entry", {
36459
+ studentId,
36460
+ lineItemId,
36461
+ error
36462
+ });
36463
+ }
36464
+ }
36384
36465
  async resolveMasterableUnits(resourceId) {
36385
36466
  if (!resourceId) {
36386
36467
  return;
@@ -36590,6 +36671,9 @@ class ProgressRecorder {
36590
36671
  sensorUrl: progressData.sensorUrl
36591
36672
  });
36592
36673
  }
36674
+ if (masteryProgress?.masteryRevoked) {
36675
+ await this.masteryTracker.revokeCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
36676
+ }
36593
36677
  await this.emitCaliperEvent({
36594
36678
  studentId,
36595
36679
  studentEmail,
@@ -94182,7 +94266,7 @@ var init_schemas11 = __esm(() => {
94182
94266
  inactiveSeconds: exports_external.number().nonnegative().optional()
94183
94267
  }).optional(),
94184
94268
  xpEarned: exports_external.number().optional(),
94185
- masteredUnits: exports_external.number().nonnegative().optional(),
94269
+ masteredUnits: exports_external.number().optional(),
94186
94270
  extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
94187
94271
  });
94188
94272
  AdvanceCourseRequestSchema = exports_external.object({
@@ -96477,7 +96561,7 @@ var init_sprite_controller = __esm(() => {
96477
96561
  });
96478
96562
 
96479
96563
  // ../api-core/src/controllers/timeback.controller.ts
96480
- var logger64, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, heartbeat, advanceCourse, getStudentXp, getRoster, getStudentOverview, getStudentActivity, grantXp, adjustTime, adjustMastery, toggleCompletion, searchStudents, enrollStudent, unenrollStudent, timeback2;
96564
+ var logger64, 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, timeback2;
96481
96565
  var init_timeback_controller = __esm(() => {
96482
96566
  init_esm();
96483
96567
  init_schemas_index();
@@ -96813,6 +96897,31 @@ var init_timeback_controller = __esm(() => {
96813
96897
  offset
96814
96898
  });
96815
96899
  });
96900
+ getActivityDetail = requireGameManagementAccess(async (ctx) => {
96901
+ const timebackId = ctx.params.timebackId;
96902
+ const courseId = ctx.params.courseId;
96903
+ const activityId = ctx.params.activityId;
96904
+ const gameId = ctx.url.searchParams.get("gameId") || undefined;
96905
+ const runId = ctx.url.searchParams.get("runId") || undefined;
96906
+ if (!timebackId || !courseId || !activityId || !gameId) {
96907
+ throw ApiError.badRequest("Missing timebackId, courseId, or activityId path parameter, or gameId query parameter");
96908
+ }
96909
+ logger64.debug("Getting activity detail", {
96910
+ requesterId: ctx.user.id,
96911
+ timebackId,
96912
+ courseId,
96913
+ activityId,
96914
+ gameId,
96915
+ runId
96916
+ });
96917
+ return ctx.services.timebackAdmin.getActivityDetail(ctx.user, {
96918
+ gameId,
96919
+ studentId: timebackId,
96920
+ courseId,
96921
+ activityId,
96922
+ runId
96923
+ });
96924
+ });
96816
96925
  grantXp = requireDeveloper(async (ctx) => {
96817
96926
  const body2 = await parseRequestBody(ctx.request, GrantTimebackXpRequestSchema);
96818
96927
  logger64.debug("Granting manual XP", {
@@ -96915,6 +97024,7 @@ var init_timeback_controller = __esm(() => {
96915
97024
  getRoster,
96916
97025
  getStudentOverview,
96917
97026
  getStudentActivity,
97027
+ getActivityDetail,
96918
97028
  grantXp,
96919
97029
  adjustTime,
96920
97030
  adjustMastery,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.3.17-beta.20",
3
+ "version": "0.3.17-beta.22",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {