@playcademy/sandbox 0.4.2-beta.1 → 0.4.2-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -249,6 +249,7 @@ var init_timeback2 = __esm(() => {
249
249
  END_ACTIVITY: "/integrations/timeback/end-activity",
250
250
  GET_XP: "/integrations/timeback/xp",
251
251
  GET_MASTERY: "/integrations/timeback/mastery",
252
+ GET_HIGHEST_GRADE_MASTERED: "/integrations/timeback/highest-grade-mastered",
252
253
  HEARTBEAT: "/integrations/timeback/heartbeat",
253
254
  ADVANCE_COURSE: "/integrations/timeback/advance-course",
254
255
  UNENROLL_COURSE: "/integrations/timeback/unenroll-course"
@@ -1077,7 +1078,7 @@ var package_default;
1077
1078
  var init_package = __esm(() => {
1078
1079
  package_default = {
1079
1080
  name: "@playcademy/sandbox",
1080
- version: "0.4.2-beta.1",
1081
+ version: "0.4.2-beta.3",
1081
1082
  description: "Local development server for Playcademy game development",
1082
1083
  type: "module",
1083
1084
  exports: {
@@ -11319,10 +11320,11 @@ var init_table6 = __esm(() => {
11319
11320
  });
11320
11321
 
11321
11322
  // ../data/src/domains/timeback/table.ts
11322
- var gameTimebackIntegrations, gameTimebackAssessmentTests;
11323
+ var gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications;
11323
11324
  var init_table7 = __esm(() => {
11324
11325
  init_pg_core();
11325
11326
  init_table5();
11327
+ init_table3();
11326
11328
  gameTimebackIntegrations = pgTable("game_timeback_integrations", {
11327
11329
  id: uuid("id").primaryKey().defaultRandom(),
11328
11330
  gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
@@ -11347,6 +11349,21 @@ var init_table7 = __esm(() => {
11347
11349
  }, (table3) => [
11348
11350
  uniqueIndex("game_timeback_assessment_tests_integration_qti_idx").on(table3.integrationId, table3.qtiTestIdentifier)
11349
11351
  ]);
11352
+ gameTimebackMetricDiscrepancyVerifications = pgTable("game_timeback_metric_discrepancy_verifications", {
11353
+ id: uuid("id").primaryKey().defaultRandom(),
11354
+ gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
11355
+ courseId: text("course_id").notNull(),
11356
+ studentId: text("student_id").notNull(),
11357
+ runId: uuid("run_id").notNull(),
11358
+ activityId: text("activity_id"),
11359
+ verifiedByUserId: text("verified_by_user_id").references(() => users.id, {
11360
+ onDelete: "set null"
11361
+ }),
11362
+ verifiedAt: timestamp("verified_at", { withTimezone: true }).notNull().defaultNow()
11363
+ }, (table3) => [
11364
+ uniqueIndex("game_timeback_metric_discrepancy_verifications_run_idx").on(table3.gameId, table3.courseId, table3.studentId, table3.runId),
11365
+ index("game_timeback_metric_discrepancy_verifications_course_idx").on(table3.gameId, table3.courseId, table3.verifiedAt)
11366
+ ]);
11350
11367
  });
11351
11368
 
11352
11369
  // ../data/src/tables.index.ts
@@ -11360,6 +11377,7 @@ __export(exports_tables_index, {
11360
11377
  games: () => games,
11361
11378
  gameVisibilityEnum: () => gameVisibilityEnum,
11362
11379
  gameTypeEnum: () => gameTypeEnum,
11380
+ gameTimebackMetricDiscrepancyVerifications: () => gameTimebackMetricDiscrepancyVerifications,
11363
11381
  gameTimebackIntegrations: () => gameTimebackIntegrations,
11364
11382
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
11365
11383
  gameScoresRelations: () => gameScoresRelations,
@@ -28248,6 +28266,7 @@ var init_constants3 = __esm(() => {
28248
28266
  END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
28249
28267
  GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
28250
28268
  GET_MASTERY: `/api${TIMEBACK_ROUTES.GET_MASTERY}`,
28269
+ GET_HIGHEST_GRADE_MASTERED: `/api${TIMEBACK_ROUTES.GET_HIGHEST_GRADE_MASTERED}`,
28251
28270
  HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`,
28252
28271
  ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`,
28253
28272
  UNENROLL_COURSE: `/api${TIMEBACK_ROUTES.UNENROLL_COURSE}`
@@ -29128,6 +29147,7 @@ var init_utils6 = __esm(() => {
29128
29147
  };
29129
29148
  });
29130
29149
  init_constants7();
29150
+ init_constants7();
29131
29151
  if (process.env.DEBUG === "true") {
29132
29152
  process.env.TERM = "dumb";
29133
29153
  }
@@ -29361,6 +29381,75 @@ function formatDateYMDInTimezone(timeZone, date3 = new Date) {
29361
29381
  const d = parts2.find((p) => p.type === "day").value;
29362
29382
  return `${y}-${m}-${d}`;
29363
29383
  }
29384
+ function getUtcInstantForMidnight(date3, timeZone) {
29385
+ const parts2 = new Intl.DateTimeFormat("en-US", {
29386
+ timeZone,
29387
+ year: "numeric",
29388
+ month: "2-digit",
29389
+ day: "2-digit"
29390
+ }).formatToParts(date3).reduce((acc, p) => {
29391
+ if (p.type !== "literal") {
29392
+ acc[p.type] = p.value;
29393
+ }
29394
+ return acc;
29395
+ }, {});
29396
+ const year = Number(parts2.year);
29397
+ const month = Number(parts2.month);
29398
+ const day = Number(parts2.day);
29399
+ for (let dayOffset = -1;dayOffset <= 1; dayOffset++) {
29400
+ const testYear = year;
29401
+ const testMonth = month;
29402
+ const testDay = day + dayOffset;
29403
+ for (let utcHour = 0;utcHour < 24; utcHour++) {
29404
+ const testDate = new Date(Date.UTC(testYear, testMonth - 1, testDay, utcHour, 0, 0, 0));
29405
+ const testParts = new Intl.DateTimeFormat("en-US", {
29406
+ timeZone,
29407
+ year: "numeric",
29408
+ month: "2-digit",
29409
+ day: "2-digit",
29410
+ hour: "2-digit",
29411
+ minute: "2-digit",
29412
+ hour12: false
29413
+ }).formatToParts(testDate).reduce((acc, p) => {
29414
+ if (p.type !== "literal") {
29415
+ acc[p.type] = p.value;
29416
+ }
29417
+ return acc;
29418
+ }, {});
29419
+ const yearMatch = Number(testParts.year) === year;
29420
+ const monthMatch = Number(testParts.month) === month;
29421
+ const dayMatch = Number(testParts.day) === day;
29422
+ const hourValue = Number(testParts.hour);
29423
+ const hourMatch = hourValue === 0 || hourValue === 24;
29424
+ const minuteMatch = Number(testParts.minute) === 0;
29425
+ if (yearMatch && monthMatch && dayMatch && hourMatch && minuteMatch) {
29426
+ return testDate;
29427
+ }
29428
+ }
29429
+ }
29430
+ throw new Error(`Could not find midnight for ${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")} in timezone ${timeZone}`);
29431
+ }
29432
+ function getDayBoundariesInTimezone(date3, timezone) {
29433
+ const startOfDay = getUtcInstantForMidnight(date3, timezone);
29434
+ const formatter = new Intl.DateTimeFormat("en-US", {
29435
+ timeZone: timezone,
29436
+ year: "numeric",
29437
+ month: "2-digit",
29438
+ day: "2-digit"
29439
+ });
29440
+ const parts2 = formatter.formatToParts(date3).reduce((acc, p) => {
29441
+ if (p.type !== "literal") {
29442
+ acc[p.type] = p.value;
29443
+ }
29444
+ return acc;
29445
+ }, {});
29446
+ const year = Number(parts2.year);
29447
+ const month = Number(parts2.month);
29448
+ const day = Number(parts2.day);
29449
+ const nextDayNoon = new Date(Date.UTC(year, month - 1, day + 1, 12, 0, 0, 0));
29450
+ const endOfDay = getUtcInstantForMidnight(nextDayNoon, timezone);
29451
+ return { startOfDay, endOfDay };
29452
+ }
29364
29453
  // ../utils/src/url.ts
29365
29454
  function buildPath(path, params) {
29366
29455
  const url = new URL(path, "http://n");
@@ -29404,6 +29493,42 @@ function formatGradeLabel(grade) {
29404
29493
  }
29405
29494
  }
29406
29495
  }
29496
+ function isTimebackDiscrepancyQueueWindow(value) {
29497
+ return typeof value === "string" && TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES.has(value);
29498
+ }
29499
+ function isTimebackDiscrepancyQueueMetric(value) {
29500
+ return typeof value === "string" && TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES.has(value);
29501
+ }
29502
+ function parseTimebackDiscrepancyQueueWindow(value) {
29503
+ const window2 = value?.trim();
29504
+ return isTimebackDiscrepancyQueueWindow(window2) ? window2 : DEFAULT_TIMEBACK_DISCREPANCY_QUEUE_WINDOW;
29505
+ }
29506
+ function parseTimebackDiscrepancyQueueMetrics(values) {
29507
+ const metrics = [];
29508
+ const seen = new Set;
29509
+ for (const value of values) {
29510
+ const metric = value.trim();
29511
+ if (isTimebackDiscrepancyQueueMetric(metric) && !seen.has(metric)) {
29512
+ seen.add(metric);
29513
+ metrics.push(metric);
29514
+ }
29515
+ }
29516
+ return metrics;
29517
+ }
29518
+ var TIMEBACK_DISCREPANCY_QUEUE_WINDOWS, TIMEBACK_DISCREPANCY_QUEUE_METRICS, DEFAULT_TIMEBACK_DISCREPANCY_QUEUE_WINDOW = "this-week", TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES, TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES;
29519
+ var init_timeback3 = __esm(() => {
29520
+ TIMEBACK_DISCREPANCY_QUEUE_WINDOWS = [
29521
+ "today",
29522
+ "yesterday",
29523
+ "this-week",
29524
+ "last-week",
29525
+ "all",
29526
+ "custom"
29527
+ ];
29528
+ TIMEBACK_DISCREPANCY_QUEUE_METRICS = ["xp", "mastery", "time", "score"];
29529
+ TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_WINDOWS);
29530
+ TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_METRICS);
29531
+ });
29407
29532
 
29408
29533
  // ../../node_modules/.bun/drizzle-zod@0.7.1+e9f18f9688af15ce/node_modules/drizzle-zod/index.mjs
29409
29534
  function isColumnType(column2, columnTypes) {
@@ -29904,7 +30029,7 @@ function isValidAdminAttributionDate(value) {
29904
30029
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
29905
30030
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
29906
30031
  }
29907
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
30032
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29908
30033
  var init_schemas4 = __esm(() => {
29909
30034
  init_drizzle_zod();
29910
30035
  init_esm();
@@ -30145,6 +30270,11 @@ var init_schemas4 = __esm(() => {
30145
30270
  studentId: exports_external.string().min(1),
30146
30271
  enrollmentId: exports_external.string().min(1)
30147
30272
  });
30273
+ VerifyTimebackMetricDiscrepancyRequestSchema = exports_external.object({
30274
+ studentId: exports_external.string().min(1),
30275
+ runId: exports_external.string().uuid(),
30276
+ activityId: exports_external.string().min(1).optional()
30277
+ });
30148
30278
  InsertAssessmentTestSchema = createInsertSchema(gameTimebackAssessmentTests).omit({
30149
30279
  id: true,
30150
30280
  createdAt: true
@@ -30277,183 +30407,6 @@ var init_timeback_admin_util = __esm(() => {
30277
30407
  init_errors();
30278
30408
  });
30279
30409
 
30280
- // ../api-core/src/utils/timeback-game-metrics-comparison.util.ts
30281
- function createMetricRow(definition) {
30282
- const { gameValue, kind, metric, timebackValue, tolerance } = definition;
30283
- if (timebackValue === undefined && gameValue === undefined) {
30284
- return null;
30285
- }
30286
- if (gameValue === undefined) {
30287
- return {
30288
- metric,
30289
- kind,
30290
- status: "not_reported_by_game",
30291
- ...timebackValue !== undefined ? { timebackValue } : {}
30292
- };
30293
- }
30294
- if (timebackValue === undefined) {
30295
- return {
30296
- metric,
30297
- kind,
30298
- status: "not_recorded_by_timeback",
30299
- gameValue
30300
- };
30301
- }
30302
- const delta = gameValue - timebackValue;
30303
- const isDiscrepant = tolerance === 0 ? delta !== 0 : Math.abs(delta) >= tolerance;
30304
- return {
30305
- metric,
30306
- kind,
30307
- status: isDiscrepant ? "discrepant" : "matched",
30308
- timebackValue,
30309
- gameValue,
30310
- delta
30311
- };
30312
- }
30313
- function createRunComparison(activity, gameRun) {
30314
- const runId = activity.runId ?? "";
30315
- if (!gameRun) {
30316
- return {
30317
- runId,
30318
- status: "not_reported",
30319
- discrepancyCount: 0,
30320
- rows: []
30321
- };
30322
- }
30323
- const rows = [
30324
- createMetricRow({
30325
- metric: "xp",
30326
- kind: "number",
30327
- timebackValue: activity.xpDelta,
30328
- gameValue: gameRun.totalXp,
30329
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.xp
30330
- }),
30331
- createMetricRow({
30332
- metric: "mastery",
30333
- kind: "number",
30334
- timebackValue: activity.masteredUnitsDelta,
30335
- gameValue: gameRun.masteredUnits,
30336
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.mastery
30337
- }),
30338
- createMetricRow({
30339
- metric: "time",
30340
- kind: "time",
30341
- timebackValue: activity.timeDeltaSeconds,
30342
- gameValue: gameRun.activeTimeSeconds,
30343
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.time
30344
- }),
30345
- createMetricRow({
30346
- metric: "score",
30347
- kind: "percent",
30348
- timebackValue: activity.score,
30349
- gameValue: gameRun.score,
30350
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.score
30351
- })
30352
- ].filter((row) => row !== null);
30353
- const discrepancyCount = rows.filter((row) => row.status === "discrepant").length;
30354
- return {
30355
- runId,
30356
- status: discrepancyCount > 0 ? "discrepant" : "matched",
30357
- discrepancyCount,
30358
- rows
30359
- };
30360
- }
30361
- function summarizeGameRunMetricsComparison(comparison) {
30362
- return {
30363
- runId: comparison.runId,
30364
- status: comparison.status,
30365
- discrepancyCount: comparison.discrepancyCount,
30366
- ...comparison.reason ? { reason: comparison.reason } : {}
30367
- };
30368
- }
30369
- function buildGameRunMetricComparisons(activities, course, response) {
30370
- const activitiesWithRunIds = activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0);
30371
- const comparisons = new Map;
30372
- if (activitiesWithRunIds.length === 0) {
30373
- return comparisons;
30374
- }
30375
- if (!response.supported) {
30376
- for (const activity of activitiesWithRunIds) {
30377
- comparisons.set(activity.runId, {
30378
- runId: activity.runId,
30379
- status: "unavailable",
30380
- discrepancyCount: 0,
30381
- reason: response.reason,
30382
- rows: []
30383
- });
30384
- }
30385
- return comparisons;
30386
- }
30387
- const gameCourseMetrics = response.metrics.courses.find((gameCourse) => gameCourse.grade === course.grade && gameCourse.subject === course.subject);
30388
- const gameRunsById = new Map(gameCourseMetrics?.activities?.map((gameRun) => [gameRun.runId.toLowerCase(), gameRun]));
30389
- for (const activity of activitiesWithRunIds) {
30390
- comparisons.set(activity.runId, createRunComparison(activity, gameRunsById.get(activity.runId.toLowerCase())));
30391
- }
30392
- return comparisons;
30393
- }
30394
- var init_timeback_game_metrics_comparison_util = __esm(() => {
30395
- init_src();
30396
- });
30397
-
30398
- // ../api-core/src/utils/timeback-mastery-completion.util.ts
30399
- async function upsertMasteryCompletionEntry(params) {
30400
- const { client, courseId, studentId, appName, action } = params;
30401
- const ids = deriveSourcedIds(courseId);
30402
- const lineItemId = `${ids.course}-mastery-completion-assessment`;
30403
- const resultId = `${lineItemId}:${studentId}:completion`;
30404
- if (action === "complete") {
30405
- await client.oneroster.assessmentLineItems.findOrCreate(lineItemId, {
30406
- sourcedId: lineItemId,
30407
- title: "Mastery Completion",
30408
- status: ONEROSTER_STATUS.active,
30409
- course: { sourcedId: ids.course },
30410
- ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
30411
- });
30412
- await client.oneroster.assessmentResults.upsert(resultId, {
30413
- sourcedId: resultId,
30414
- status: ONEROSTER_STATUS.active,
30415
- assessmentLineItem: { sourcedId: lineItemId },
30416
- student: { sourcedId: studentId },
30417
- score: 100,
30418
- scoreDate: new Date().toISOString(),
30419
- scoreStatus: SCORE_STATUS.fullyGraded,
30420
- inProgress: "false",
30421
- metadata: {
30422
- isMasteryCompletion: true,
30423
- adminAction: true,
30424
- appName
30425
- }
30426
- });
30427
- } else {
30428
- try {
30429
- await client.oneroster.assessmentResults.upsert(resultId, {
30430
- sourcedId: resultId,
30431
- status: ONEROSTER_STATUS.active,
30432
- assessmentLineItem: { sourcedId: lineItemId },
30433
- student: { sourcedId: studentId },
30434
- score: 0,
30435
- scoreDate: new Date().toISOString(),
30436
- scoreStatus: SCORE_STATUS.notSubmitted,
30437
- inProgress: "true",
30438
- metadata: {
30439
- isMasteryCompletion: true,
30440
- adminAction: true,
30441
- appName
30442
- }
30443
- });
30444
- } catch {
30445
- logger16.debug("No completion entry to revoke", { studentId, courseId });
30446
- }
30447
- }
30448
- }
30449
- var logger16;
30450
- var init_timeback_mastery_completion_util = __esm(() => {
30451
- init_src2();
30452
- init_constants4();
30453
- init_utils6();
30454
- logger16 = log.scope("timeback-mastery-completion");
30455
- });
30456
-
30457
30410
  // ../api-core/src/utils/timeback.util.ts
30458
30411
  function isRecord2(value) {
30459
30412
  return typeof value === "object" && value !== null;
@@ -30601,6 +30554,13 @@ function groupCaliperEventsByRun(events) {
30601
30554
  }
30602
30555
  return groups;
30603
30556
  }
30557
+ function findCaliperEventGroupContainingExternalId(events, externalId) {
30558
+ const targetExternalId = externalId.trim();
30559
+ if (!targetExternalId) {
30560
+ return;
30561
+ }
30562
+ return [...groupCaliperEventsByRun(events).values()].find((group) => group.some((event) => event.externalId === targetExternalId));
30563
+ }
30604
30564
  function mapCaliperEventGroupToActivity(events, relevantCourseIds) {
30605
30565
  if (events.length === 0) {
30606
30566
  return null;
@@ -30774,6 +30734,371 @@ var init_timeback_util = __esm(() => {
30774
30734
  ]);
30775
30735
  });
30776
30736
 
30737
+ // ../api-core/src/utils/timeback-discrepancy-queue.util.ts
30738
+ function parseDateInputParts(value) {
30739
+ const parts2 = value.split("-");
30740
+ return {
30741
+ year: Number(parts2[0]),
30742
+ month: Number(parts2[1]),
30743
+ day: Number(parts2[2])
30744
+ };
30745
+ }
30746
+ function addDateInputDays(value, days) {
30747
+ const { year, month, day } = parseDateInputParts(value);
30748
+ const date3 = new Date(Date.UTC(year, month - 1, day + days, 12));
30749
+ return [
30750
+ String(date3.getUTCFullYear()),
30751
+ String(date3.getUTCMonth() + 1).padStart(2, "0"),
30752
+ String(date3.getUTCDate()).padStart(2, "0")
30753
+ ].join("-");
30754
+ }
30755
+ function getDateInputDayOfWeek(value) {
30756
+ const { year, month, day } = parseDateInputParts(value);
30757
+ return new Date(Date.UTC(year, month - 1, day, 12)).getUTCDay();
30758
+ }
30759
+ function getDateInputBoundary(value, boundary, timezone2) {
30760
+ const { year, month, day } = parseDateInputParts(value);
30761
+ const anchor = new Date(Date.UTC(year, month - 1, day, 12));
30762
+ const { startOfDay, endOfDay } = getDayBoundariesInTimezone(anchor, timezone2);
30763
+ return boundary === "end" ? new Date(endOfDay.getTime() - 1) : startOfDay;
30764
+ }
30765
+ function getWeekStartDateInput(date3, timezone2) {
30766
+ const today = formatDateYMDInTimezone(timezone2, date3);
30767
+ const dayOfWeek = getDateInputDayOfWeek(today);
30768
+ return addDateInputDays(today, -dayOfWeek);
30769
+ }
30770
+ function parseDateBoundary(value, boundary, timezone2) {
30771
+ if (!value) {
30772
+ return null;
30773
+ }
30774
+ if (DATE_INPUT_RE.test(value)) {
30775
+ return getDateInputBoundary(value, boundary, timezone2);
30776
+ }
30777
+ const parsed = new Date(value);
30778
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
30779
+ }
30780
+ function getTimebackDiscrepancyQueueDateRange(options) {
30781
+ const now2 = options.now ?? new Date;
30782
+ const timezone2 = options.timezone ?? "UTC";
30783
+ if (options.window === "all") {
30784
+ return {};
30785
+ }
30786
+ if (options.window === "today") {
30787
+ return { startDate: getDayBoundariesInTimezone(now2, timezone2).startOfDay.toISOString() };
30788
+ }
30789
+ if (options.window === "yesterday") {
30790
+ const yesterdayInput = addDateInputDays(formatDateYMDInTimezone(timezone2, now2), -1);
30791
+ return {
30792
+ startDate: getDateInputBoundary(yesterdayInput, "start", timezone2).toISOString(),
30793
+ endDate: getDateInputBoundary(yesterdayInput, "end", timezone2).toISOString()
30794
+ };
30795
+ }
30796
+ if (options.window === "this-week") {
30797
+ return {
30798
+ startDate: getDateInputBoundary(getWeekStartDateInput(now2, timezone2), "start", timezone2).toISOString()
30799
+ };
30800
+ }
30801
+ if (options.window === "last-week") {
30802
+ const thisWeekStartInput = getWeekStartDateInput(now2, timezone2);
30803
+ const lastWeekStartInput = addDateInputDays(thisWeekStartInput, -7);
30804
+ const lastWeekEndInput = addDateInputDays(thisWeekStartInput, -1);
30805
+ return {
30806
+ startDate: getDateInputBoundary(lastWeekStartInput, "start", timezone2).toISOString(),
30807
+ endDate: getDateInputBoundary(lastWeekEndInput, "end", timezone2).toISOString()
30808
+ };
30809
+ }
30810
+ const startDate = parseDateBoundary(options.startDate, "start", timezone2);
30811
+ const endDate = parseDateBoundary(options.endDate, "end", timezone2);
30812
+ return {
30813
+ ...startDate ? { startDate: startDate.toISOString() } : {},
30814
+ ...endDate ? { endDate: endDate.toISOString() } : {}
30815
+ };
30816
+ }
30817
+ function getCaliperActorSourcedId(event) {
30818
+ const actorId = typeof event.actor.id === "string" ? event.actor.id.trim() : "";
30819
+ if (!actorId) {
30820
+ return;
30821
+ }
30822
+ const normalized = actorId.replace(/\/$/, "");
30823
+ const segment = normalized.split("/").at(-1);
30824
+ if (!segment) {
30825
+ return;
30826
+ }
30827
+ try {
30828
+ return decodeURIComponent(segment);
30829
+ } catch {
30830
+ return segment;
30831
+ }
30832
+ }
30833
+ function getTimebackDiscrepancyVerificationKey(params) {
30834
+ return `${params.studentId}:${params.runId.toLowerCase()}`;
30835
+ }
30836
+ function getTimebackMetricDiscrepancyVerificationForActivity(params) {
30837
+ if (params.comparison?.status !== "discrepant" || !params.activity.runId) {
30838
+ return;
30839
+ }
30840
+ return params.verificationsByKey.get(getTimebackDiscrepancyVerificationKey({
30841
+ studentId: params.studentId,
30842
+ runId: params.activity.runId
30843
+ }));
30844
+ }
30845
+ function mapCaliperEventsToStudentGameplayActivities(events, relevantCourseIds) {
30846
+ if (relevantCourseIds.size === 0) {
30847
+ return [];
30848
+ }
30849
+ const gameplayEventsByStudent = new Map;
30850
+ for (const event of events) {
30851
+ const isGameplayEvent = event.type === "ActivityEvent" || event.type === "TimeSpentEvent";
30852
+ const studentId = isGameplayEvent && !isCaliperRemediationOrCompletionEvent(event) ? getCaliperActorSourcedId(event) : undefined;
30853
+ if (studentId) {
30854
+ const existing = gameplayEventsByStudent.get(studentId);
30855
+ if (existing) {
30856
+ existing.push(event);
30857
+ } else {
30858
+ gameplayEventsByStudent.set(studentId, [event]);
30859
+ }
30860
+ }
30861
+ }
30862
+ const groups = [];
30863
+ for (const [studentId, studentEvents] of gameplayEventsByStudent) {
30864
+ const activities = [...groupCaliperEventsByRun(studentEvents).values()].map((group) => mapCaliperEventGroupToActivity(group, relevantCourseIds)).filter((activity) => Boolean(activity)).toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt));
30865
+ if (activities.length > 0) {
30866
+ groups.push({ studentId, activities });
30867
+ }
30868
+ }
30869
+ return groups.toSorted((a, b) => {
30870
+ const aLatest = a.activities[0]?.occurredAt ?? "";
30871
+ const bLatest = b.activities[0]?.occurredAt ?? "";
30872
+ return bLatest.localeCompare(aLatest);
30873
+ });
30874
+ }
30875
+ function mapCaliperEventsToCompletedStudentGameplayActivities(events, relevantCourseIds, options = {}) {
30876
+ const runIds = options.runIds ? new Set([...options.runIds].map((runId) => runId.toLowerCase())) : undefined;
30877
+ return mapCaliperEventsToStudentGameplayActivities(events, relevantCourseIds).filter((group) => !options.studentId || group.studentId === options.studentId).map((group) => ({
30878
+ studentId: group.studentId,
30879
+ activities: group.activities.filter((activity) => {
30880
+ const runId = activity.runId?.toLowerCase();
30881
+ return activity.kind === "activity" && runId !== undefined && isValidUUID(runId) && (!runIds || runIds.has(runId));
30882
+ })
30883
+ })).filter((group) => group.activities.length > 0);
30884
+ }
30885
+ function getTimebackActivityRunIds(groups) {
30886
+ const runIds = [];
30887
+ const seen = new Set;
30888
+ for (const group of groups) {
30889
+ for (const activity of group.activities) {
30890
+ const runId = activity.runId?.toLowerCase();
30891
+ if (runId && isValidUUID(runId) && !seen.has(runId)) {
30892
+ seen.add(runId);
30893
+ runIds.push(runId);
30894
+ }
30895
+ }
30896
+ }
30897
+ return runIds;
30898
+ }
30899
+ function mergeHydratedMetricDiscrepancyQueueItems(options) {
30900
+ const hydratedRunIds = new Set([...options.hydratedRunIds].map((runId) => runId.toLowerCase()));
30901
+ const hydratedItems = options.hydratedItems.map((item) => ({
30902
+ ...item,
30903
+ comparisonSource: "hydrated"
30904
+ }));
30905
+ const preliminaryFallbackItems = options.preliminaryItems.filter((item) => {
30906
+ const runId = item.activity.runId?.toLowerCase();
30907
+ return runId === undefined || !hydratedRunIds.has(runId);
30908
+ }).map((item) => ({
30909
+ ...item,
30910
+ comparisonSource: "preliminary"
30911
+ }));
30912
+ return [...hydratedItems, ...preliminaryFallbackItems].toSorted((a, b) => b.activity.occurredAt.localeCompare(a.activity.occurredAt));
30913
+ }
30914
+ function selectTimebackMetricDiscrepancyQueueItems(candidates, options) {
30915
+ const discrepancyMetricScopesSet = new Set(options.discrepancyMetricScopes);
30916
+ return candidates.filter((candidate) => candidate.activity.kind === "activity").filter((candidate) => candidate.gameMetricsComparison.status === "discrepant").filter((candidate) => !options.studentId || candidate.student.studentId === options.studentId).filter((candidate) => discrepancyMetricScopesSet.size === 0 || candidate.gameMetricsComparison.rows.some((row) => row.status === "discrepant" && discrepancyMetricScopesSet.has(row.metric))).filter((candidate) => options.includeVerified || !candidate.verification).toSorted((a, b) => b.activity.occurredAt.localeCompare(a.activity.occurredAt));
30917
+ }
30918
+ var DATE_INPUT_RE;
30919
+ var init_timeback_discrepancy_queue_util = __esm(() => {
30920
+ init_src4();
30921
+ init_timeback_util();
30922
+ DATE_INPUT_RE = /^\d{4}-\d{2}-\d{2}$/;
30923
+ });
30924
+
30925
+ // ../api-core/src/utils/timeback-game-metrics-comparison.util.ts
30926
+ function createMetricRow(definition) {
30927
+ const { gameValue, kind, metric, timebackValue, tolerance } = definition;
30928
+ if (timebackValue === undefined && gameValue === undefined) {
30929
+ return null;
30930
+ }
30931
+ if (gameValue === undefined) {
30932
+ return {
30933
+ metric,
30934
+ kind,
30935
+ status: "not_reported_by_game",
30936
+ ...timebackValue !== undefined ? { timebackValue } : {}
30937
+ };
30938
+ }
30939
+ if (timebackValue === undefined) {
30940
+ return {
30941
+ metric,
30942
+ kind,
30943
+ status: "not_recorded_by_timeback",
30944
+ gameValue
30945
+ };
30946
+ }
30947
+ const delta = gameValue - timebackValue;
30948
+ const isDiscrepant = tolerance === 0 ? delta !== 0 : Math.abs(delta) >= tolerance;
30949
+ return {
30950
+ metric,
30951
+ kind,
30952
+ status: isDiscrepant ? "discrepant" : "matched",
30953
+ timebackValue,
30954
+ gameValue,
30955
+ delta
30956
+ };
30957
+ }
30958
+ function createRunComparison(activity, gameRun) {
30959
+ const runId = activity.runId ?? "";
30960
+ if (!gameRun) {
30961
+ return {
30962
+ runId,
30963
+ status: "not_reported",
30964
+ discrepancyCount: 0,
30965
+ rows: []
30966
+ };
30967
+ }
30968
+ const rows = [
30969
+ createMetricRow({
30970
+ metric: "xp",
30971
+ kind: "number",
30972
+ timebackValue: activity.xpDelta,
30973
+ gameValue: gameRun.totalXp,
30974
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.xp
30975
+ }),
30976
+ createMetricRow({
30977
+ metric: "time",
30978
+ kind: "time",
30979
+ timebackValue: activity.timeDeltaSeconds,
30980
+ gameValue: gameRun.activeTimeSeconds,
30981
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.time
30982
+ }),
30983
+ createMetricRow({
30984
+ metric: "score",
30985
+ kind: "percent",
30986
+ timebackValue: activity.score,
30987
+ gameValue: gameRun.score,
30988
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.score
30989
+ }),
30990
+ createMetricRow({
30991
+ metric: "mastery",
30992
+ kind: "number",
30993
+ timebackValue: activity.masteredUnitsDelta ?? 0,
30994
+ gameValue: gameRun.masteredUnits,
30995
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.mastery
30996
+ })
30997
+ ].filter((row) => row !== null);
30998
+ const discrepancyCount = rows.filter((row) => row.status === "discrepant").length;
30999
+ return {
31000
+ runId,
31001
+ status: discrepancyCount > 0 ? "discrepant" : "matched",
31002
+ discrepancyCount,
31003
+ rows
31004
+ };
31005
+ }
31006
+ function summarizeGameRunMetricsComparison(comparison) {
31007
+ return {
31008
+ runId: comparison.runId,
31009
+ status: comparison.status,
31010
+ discrepancyCount: comparison.discrepancyCount,
31011
+ ...comparison.reason ? { reason: comparison.reason } : {}
31012
+ };
31013
+ }
31014
+ function buildGameRunMetricComparisons(activities, course, response) {
31015
+ const activitiesWithRunIds = activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0);
31016
+ const comparisons = new Map;
31017
+ if (activitiesWithRunIds.length === 0) {
31018
+ return comparisons;
31019
+ }
31020
+ if (!response.supported) {
31021
+ for (const activity of activitiesWithRunIds) {
31022
+ comparisons.set(activity.runId, {
31023
+ runId: activity.runId,
31024
+ status: "unavailable",
31025
+ discrepancyCount: 0,
31026
+ reason: response.reason,
31027
+ rows: []
31028
+ });
31029
+ }
31030
+ return comparisons;
31031
+ }
31032
+ const gameCourseMetrics = response.metrics.courses.find((gameCourse) => gameCourse.grade === course.grade && gameCourse.subject === course.subject);
31033
+ const gameRunsById = new Map(gameCourseMetrics?.activities?.map((gameRun) => [gameRun.runId.toLowerCase(), gameRun]));
31034
+ for (const activity of activitiesWithRunIds) {
31035
+ comparisons.set(activity.runId, createRunComparison(activity, gameRunsById.get(activity.runId.toLowerCase())));
31036
+ }
31037
+ return comparisons;
31038
+ }
31039
+ var init_timeback_game_metrics_comparison_util = __esm(() => {
31040
+ init_src();
31041
+ });
31042
+
31043
+ // ../api-core/src/utils/timeback-mastery-completion.util.ts
31044
+ async function upsertMasteryCompletionEntry(params) {
31045
+ const { client, courseId, studentId, appName, action } = params;
31046
+ const ids = deriveSourcedIds(courseId);
31047
+ const lineItemId = `${ids.course}-mastery-completion-assessment`;
31048
+ const resultId = `${lineItemId}:${studentId}:completion`;
31049
+ if (action === "complete") {
31050
+ await client.oneroster.assessmentLineItems.findOrCreate(lineItemId, {
31051
+ sourcedId: lineItemId,
31052
+ title: "Mastery Completion",
31053
+ status: ONEROSTER_STATUS.active,
31054
+ course: { sourcedId: ids.course },
31055
+ ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
31056
+ });
31057
+ await client.oneroster.assessmentResults.upsert(resultId, {
31058
+ sourcedId: resultId,
31059
+ status: ONEROSTER_STATUS.active,
31060
+ assessmentLineItem: { sourcedId: lineItemId },
31061
+ student: { sourcedId: studentId },
31062
+ score: 100,
31063
+ scoreDate: new Date().toISOString(),
31064
+ scoreStatus: SCORE_STATUS.fullyGraded,
31065
+ inProgress: "false",
31066
+ metadata: {
31067
+ isMasteryCompletion: true,
31068
+ adminAction: true,
31069
+ appName
31070
+ }
31071
+ });
31072
+ } else {
31073
+ try {
31074
+ await client.oneroster.assessmentResults.upsert(resultId, {
31075
+ sourcedId: resultId,
31076
+ status: ONEROSTER_STATUS.active,
31077
+ assessmentLineItem: { sourcedId: lineItemId },
31078
+ student: { sourcedId: studentId },
31079
+ score: 0,
31080
+ scoreDate: new Date().toISOString(),
31081
+ scoreStatus: SCORE_STATUS.notSubmitted,
31082
+ inProgress: "true",
31083
+ metadata: {
31084
+ isMasteryCompletion: true,
31085
+ adminAction: true,
31086
+ appName
31087
+ }
31088
+ });
31089
+ } catch {
31090
+ logger16.debug("No completion entry to revoke", { studentId, courseId });
31091
+ }
31092
+ }
31093
+ }
31094
+ var logger16;
31095
+ var init_timeback_mastery_completion_util = __esm(() => {
31096
+ init_src2();
31097
+ init_constants4();
31098
+ init_utils6();
31099
+ logger16 = log.scope("timeback-mastery-completion");
31100
+ });
31101
+
30777
31102
  // ../api-core/src/services/timeback-admin.service.ts
30778
31103
  class TimebackAdminService {
30779
31104
  deps;
@@ -30782,6 +31107,10 @@ class TimebackAdminService {
30782
31107
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
30783
31108
  static MAX_STUDENT_ACTIVITY_OFFSET = 1000;
30784
31109
  static MAX_RECENT_ACTIVITY_EVENT_FETCH = 4000;
31110
+ static DISCREPANCY_QUEUE_EVENT_FETCH_LIMIT = 4000;
31111
+ static DISCREPANCY_QUEUE_RUN_EVENT_FETCH_LIMIT = 4000;
31112
+ static DISCREPANCY_QUEUE_RUN_HYDRATION_CONCURRENCY = 4;
31113
+ static DISCREPANCY_QUEUE_COMPARISON_CONCURRENCY = 4;
30785
31114
  static ANALYTICS_CONCURRENCY = 8;
30786
31115
  static MASTERABLE_UNITS_CONCURRENCY = 4;
30787
31116
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
@@ -31029,16 +31358,53 @@ class TimebackAdminService {
31029
31358
  }));
31030
31359
  return comparisons;
31031
31360
  }
31361
+ async getMetricDiscrepancyVerificationsByKey(options) {
31362
+ const runIds = TimebackAdminService.normalizeRunIds(options.runIds);
31363
+ if (runIds.length === 0) {
31364
+ return new Map;
31365
+ }
31366
+ const conditions2 = [
31367
+ eq(gameTimebackMetricDiscrepancyVerifications.gameId, options.gameId),
31368
+ eq(gameTimebackMetricDiscrepancyVerifications.courseId, options.courseId),
31369
+ inArray(gameTimebackMetricDiscrepancyVerifications.runId, runIds)
31370
+ ];
31371
+ if (options.studentId) {
31372
+ conditions2.push(eq(gameTimebackMetricDiscrepancyVerifications.studentId, options.studentId));
31373
+ }
31374
+ const rows = await this.deps.db.query.gameTimebackMetricDiscrepancyVerifications.findMany({
31375
+ where: and(...conditions2)
31376
+ });
31377
+ return new Map(rows.map((row) => [
31378
+ getTimebackDiscrepancyVerificationKey({
31379
+ studentId: row.studentId,
31380
+ runId: row.runId
31381
+ }),
31382
+ TimebackAdminService.mapMetricDiscrepancyVerification(row)
31383
+ ]));
31384
+ }
31032
31385
  async attachGameMetricSummariesToActivities(user, options) {
31033
31386
  const comparisons = await this.getGameMetricComparisonsForActivities(user, options);
31034
31387
  if (comparisons.size === 0) {
31035
31388
  return [...options.activities];
31036
31389
  }
31390
+ const verificationsByKey = await this.getMetricDiscrepancyVerificationsByKey({
31391
+ gameId: options.gameId,
31392
+ courseId: options.courseId,
31393
+ studentId: options.studentId,
31394
+ runIds: [...comparisons.values()].filter((comparison) => comparison.status === "discrepant").map((comparison) => comparison.runId)
31395
+ });
31037
31396
  return options.activities.map((activity) => {
31038
31397
  const comparison = activity.runId ? comparisons.get(activity.runId) : undefined;
31398
+ const verification2 = getTimebackMetricDiscrepancyVerificationForActivity({
31399
+ studentId: options.studentId,
31400
+ activity,
31401
+ comparison,
31402
+ verificationsByKey
31403
+ });
31039
31404
  return comparison ? {
31040
31405
  ...activity,
31041
- gameMetricsComparison: summarizeGameRunMetricsComparison(comparison)
31406
+ gameMetricsComparison: summarizeGameRunMetricsComparison(comparison),
31407
+ ...verification2 ? { gameMetricsVerification: verification2 } : {}
31042
31408
  } : activity;
31043
31409
  });
31044
31410
  }
@@ -31129,6 +31495,165 @@ class TimebackAdminService {
31129
31495
  });
31130
31496
  return events;
31131
31497
  }
31498
+ async fetchCaliperEventsForGame(client, source, options) {
31499
+ const actorId = options.studentId ? `${client.getBaseUrl().replace(/\/$/, "")}/ims/oneroster/rostering/v1p2/users/${options.studentId}` : undefined;
31500
+ return client.caliper.events.list({
31501
+ limit: options.limit,
31502
+ offset: options.offset,
31503
+ ...actorId ? { actorId } : {},
31504
+ ...options.startDate ? { startDate: options.startDate } : {},
31505
+ ...options.endDate ? { endDate: options.endDate } : {},
31506
+ ...options.sessionId ? { sessionId: options.sessionId } : {},
31507
+ ...source.sourceMode === "production" ? { sensor: source.sensorUrl } : {},
31508
+ extensions: {
31509
+ gameId: source.gameId
31510
+ }
31511
+ });
31512
+ }
31513
+ static getActivityRunOwners(activityGroups) {
31514
+ const runOwnersById = new Map;
31515
+ for (const group of activityGroups) {
31516
+ for (const activity of group.activities) {
31517
+ const runId = activity.runId?.toLowerCase();
31518
+ if (runId && !runOwnersById.has(runId)) {
31519
+ runOwnersById.set(runId, group.studentId);
31520
+ }
31521
+ }
31522
+ }
31523
+ return runOwnersById;
31524
+ }
31525
+ static dedupeCaliperEvents(events) {
31526
+ const deduped = [];
31527
+ const seen = new Set;
31528
+ for (const event of events) {
31529
+ const key = `${event.id}:${event.externalId}`;
31530
+ if (!seen.has(key)) {
31531
+ seen.add(key);
31532
+ deduped.push(event);
31533
+ }
31534
+ }
31535
+ return deduped;
31536
+ }
31537
+ async fetchCaliperEventsForRun(client, source, options) {
31538
+ const runId = options.runId.toLowerCase();
31539
+ const events = [];
31540
+ let offset = 0;
31541
+ while (true) {
31542
+ const result = await this.fetchCaliperEventsForGame(client, source, {
31543
+ limit: TimebackAdminService.DISCREPANCY_QUEUE_RUN_EVENT_FETCH_LIMIT,
31544
+ offset,
31545
+ studentId: options.studentId
31546
+ });
31547
+ const runEvents = result.events.filter((event) => getCanonicalRunId(event.session)?.toLowerCase() === runId);
31548
+ events.push(...runEvents);
31549
+ const pageStep = result.pagination.limit > 0 ? result.pagination.limit : result.events.length;
31550
+ const nextOffset = offset + pageStep;
31551
+ if (pageStep === 0 || result.events.length === 0 || nextOffset >= result.pagination.total) {
31552
+ break;
31553
+ }
31554
+ offset = nextOffset;
31555
+ }
31556
+ return TimebackAdminService.dedupeCaliperEvents(events);
31557
+ }
31558
+ async hydrateDiscrepancyQueueRunEvents(client, source, options) {
31559
+ if (options.runOwnersById.size === 0) {
31560
+ return { events: [], runIds: new Set };
31561
+ }
31562
+ const hydratedRuns = await TimebackAdminService.runWithConcurrency([...options.runOwnersById.entries()], TimebackAdminService.DISCREPANCY_QUEUE_RUN_HYDRATION_CONCURRENCY, async ([runId, studentId]) => {
31563
+ try {
31564
+ return {
31565
+ runId,
31566
+ events: await this.fetchCaliperEventsForRun(client, source, {
31567
+ runId,
31568
+ studentId
31569
+ })
31570
+ };
31571
+ } catch (error) {
31572
+ logger17.warn("Failed to hydrate Caliper events for discrepancy queue run", {
31573
+ runId,
31574
+ studentId,
31575
+ error: error instanceof Error ? error.message : String(error)
31576
+ });
31577
+ return { runId, events: [] };
31578
+ }
31579
+ });
31580
+ const hydratedRunIds = new Set(hydratedRuns.filter((result) => result.events.length > 0).map((result) => result.runId.toLowerCase()));
31581
+ const baseEventsForHydratedRuns = options.baseEvents.filter((event) => {
31582
+ const runId = getCanonicalRunId(event.session)?.toLowerCase();
31583
+ return runId !== undefined && hydratedRunIds.has(runId);
31584
+ });
31585
+ return {
31586
+ events: TimebackAdminService.dedupeCaliperEvents([
31587
+ ...baseEventsForHydratedRuns,
31588
+ ...hydratedRuns.flatMap((result) => result.events)
31589
+ ]),
31590
+ runIds: hydratedRunIds
31591
+ };
31592
+ }
31593
+ async buildMetricDiscrepancyQueueCandidates(user, options) {
31594
+ const comparisonResults = await TimebackAdminService.runWithConcurrency(options.activityGroups, TimebackAdminService.DISCREPANCY_QUEUE_COMPARISON_CONCURRENCY, async (group) => {
31595
+ try {
31596
+ return {
31597
+ group,
31598
+ comparisons: await this.getGameMetricComparisonsForActivities(user, {
31599
+ gameId: options.gameId,
31600
+ studentId: group.studentId,
31601
+ course: options.course,
31602
+ activities: group.activities,
31603
+ timeoutMs: TimebackAdminService.GAME_METRICS_LIST_FETCH_TIMEOUT_MS
31604
+ })
31605
+ };
31606
+ } catch (error) {
31607
+ logger17.warn("Failed to compare game metrics for discrepancy queue student", {
31608
+ gameId: options.gameId,
31609
+ courseId: options.courseId,
31610
+ studentId: group.studentId,
31611
+ error: error instanceof Error ? error.message : String(error)
31612
+ });
31613
+ return { group, comparisons: new Map };
31614
+ }
31615
+ });
31616
+ return comparisonResults.flatMap(({ group, comparisons }) => {
31617
+ const student = options.studentsById.get(group.studentId) ?? {
31618
+ studentId: group.studentId,
31619
+ name: "No name specified",
31620
+ email: null
31621
+ };
31622
+ return group.activities.flatMap((activity) => {
31623
+ const runId = activity.runId;
31624
+ if (!runId) {
31625
+ return [];
31626
+ }
31627
+ const gameMetricsComparison = comparisons.get(runId);
31628
+ if (!gameMetricsComparison) {
31629
+ return [];
31630
+ }
31631
+ const verification2 = options.verificationsByKey.get(getTimebackDiscrepancyVerificationKey({
31632
+ studentId: group.studentId,
31633
+ runId
31634
+ }));
31635
+ return [
31636
+ {
31637
+ student,
31638
+ activity,
31639
+ gameMetricsComparison,
31640
+ ...verification2 ? { verification: verification2 } : {}
31641
+ }
31642
+ ];
31643
+ });
31644
+ });
31645
+ }
31646
+ static mapMetricDiscrepancyVerification(row) {
31647
+ return {
31648
+ gameId: row.gameId,
31649
+ courseId: row.courseId,
31650
+ studentId: row.studentId,
31651
+ runId: row.runId,
31652
+ activityId: row.activityId,
31653
+ verifiedAt: row.verifiedAt.toISOString(),
31654
+ verifiedByUserId: row.verifiedByUserId
31655
+ };
31656
+ }
31132
31657
  async listRecentActivityForStudent(client, studentId, source, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
31133
31658
  if (relevantCourseIds.size === 0) {
31134
31659
  return [];
@@ -31378,12 +31903,215 @@ class TimebackAdminService {
31378
31903
  const activitiesWithGameMetrics = await this.attachGameMetricSummariesToActivities(user, {
31379
31904
  gameId,
31380
31905
  studentId,
31906
+ courseId,
31381
31907
  course: { grade: integration.grade, subject: integration.subject },
31382
31908
  activities,
31383
31909
  timeoutMs: TimebackAdminService.GAME_METRICS_LIST_FETCH_TIMEOUT_MS
31384
31910
  });
31385
31911
  return { activities: activitiesWithGameMetrics, hasMore };
31386
31912
  }
31913
+ async listMetricDiscrepancies(user, options) {
31914
+ const { gameId, courseId, window: window2, includeVerified } = options;
31915
+ const selectedStudentId = options.studentId?.trim() || undefined;
31916
+ const client = this.requireClient();
31917
+ const dateRange = getTimebackDiscrepancyQueueDateRange({
31918
+ window: window2,
31919
+ startDate: options.startDate,
31920
+ endDate: options.endDate,
31921
+ timezone: PLATFORM_TIMEZONE
31922
+ });
31923
+ const safeEventOffset = Math.max(0, Math.trunc(options.eventOffset));
31924
+ await this.deps.validateGameManagementAccess(user, gameId);
31925
+ const [integration, gameSource, roster] = await Promise.all([
31926
+ this.deps.db.query.gameTimebackIntegrations.findFirst({
31927
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
31928
+ }),
31929
+ this.getGameActivitySource(gameId),
31930
+ client.oneroster.enrollments.listByCourse(courseId, {
31931
+ role: "student",
31932
+ includeInactive: true
31933
+ })
31934
+ ]);
31935
+ if (!integration) {
31936
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
31937
+ }
31938
+ const studentsById = new Map;
31939
+ for (const entry of roster) {
31940
+ const studentId = entry.enrollment.user.sourcedId;
31941
+ if (studentId && !studentsById.has(studentId)) {
31942
+ const name3 = entry.user ? `${entry.user.givenName} ${entry.user.familyName}`.trim() || "No name specified" : "No name specified";
31943
+ studentsById.set(studentId, {
31944
+ studentId,
31945
+ name: name3,
31946
+ email: entry.user?.email || null
31947
+ });
31948
+ }
31949
+ }
31950
+ const students = [...studentsById.values()].toSorted((a, b) => a.name.localeCompare(b.name) || a.studentId.localeCompare(b.studentId));
31951
+ if (selectedStudentId && !studentsById.has(selectedStudentId)) {
31952
+ students.push({
31953
+ studentId: selectedStudentId,
31954
+ name: selectedStudentId,
31955
+ email: null
31956
+ });
31957
+ }
31958
+ const eventResult = await this.fetchCaliperEventsForGame(client, gameSource, {
31959
+ limit: TimebackAdminService.DISCREPANCY_QUEUE_EVENT_FETCH_LIMIT,
31960
+ offset: safeEventOffset,
31961
+ ...selectedStudentId ? { studentId: selectedStudentId } : {},
31962
+ ...dateRange
31963
+ });
31964
+ const { events, pagination } = eventResult;
31965
+ let effectiveEventPageLimit = pagination.limit;
31966
+ if (effectiveEventPageLimit <= 0) {
31967
+ effectiveEventPageLimit = events.length > 0 ? events.length : TimebackAdminService.DISCREPANCY_QUEUE_EVENT_FETCH_LIMIT;
31968
+ }
31969
+ const relevantCourseIds = new Set([courseId]);
31970
+ const preliminaryActivityGroups = mapCaliperEventsToCompletedStudentGameplayActivities(events, relevantCourseIds, selectedStudentId ? { studentId: selectedStudentId } : {});
31971
+ const preliminaryRunIds = getTimebackActivityRunIds(preliminaryActivityGroups);
31972
+ const verificationsByKey = await this.getMetricDiscrepancyVerificationsByKey({
31973
+ gameId,
31974
+ courseId,
31975
+ runIds: preliminaryRunIds
31976
+ });
31977
+ const queueFilterOptions = {
31978
+ ...selectedStudentId ? { studentId: selectedStudentId } : {},
31979
+ discrepancyMetricScopes: options.discrepancyMetricScopes,
31980
+ includeVerified
31981
+ };
31982
+ const preliminaryCandidates = await this.buildMetricDiscrepancyQueueCandidates(user, {
31983
+ gameId,
31984
+ courseId,
31985
+ course: { grade: integration.grade, subject: integration.subject },
31986
+ activityGroups: preliminaryActivityGroups,
31987
+ studentsById,
31988
+ verificationsByKey
31989
+ });
31990
+ const preliminaryItems = selectTimebackMetricDiscrepancyQueueItems(preliminaryCandidates, queueFilterOptions);
31991
+ const runOwnersById = TimebackAdminService.getActivityRunOwners(preliminaryItems.map((item) => ({
31992
+ studentId: item.student.studentId,
31993
+ activities: [item.activity]
31994
+ })));
31995
+ const hydratedRunEvents = await this.hydrateDiscrepancyQueueRunEvents(client, gameSource, {
31996
+ baseEvents: events,
31997
+ runOwnersById
31998
+ });
31999
+ const finalActivityGroups = mapCaliperEventsToCompletedStudentGameplayActivities(hydratedRunEvents.events, relevantCourseIds, {
32000
+ ...selectedStudentId ? { studentId: selectedStudentId } : {},
32001
+ runIds: hydratedRunEvents.runIds
32002
+ });
32003
+ const finalCandidates = await this.buildMetricDiscrepancyQueueCandidates(user, {
32004
+ gameId,
32005
+ courseId,
32006
+ course: { grade: integration.grade, subject: integration.subject },
32007
+ activityGroups: finalActivityGroups,
32008
+ studentsById,
32009
+ verificationsByKey
32010
+ });
32011
+ const hydratedItems = selectTimebackMetricDiscrepancyQueueItems(finalCandidates, queueFilterOptions);
32012
+ const items = mergeHydratedMetricDiscrepancyQueueItems({
32013
+ preliminaryItems,
32014
+ hydratedItems,
32015
+ hydratedRunIds: hydratedRunEvents.runIds
32016
+ });
32017
+ const eventPage = {
32018
+ offset: safeEventOffset,
32019
+ limit: effectiveEventPageLimit,
32020
+ total: pagination.total,
32021
+ hasPrevious: safeEventOffset > 0,
32022
+ hasNext: safeEventOffset + effectiveEventPageLimit < pagination.total
32023
+ };
32024
+ return {
32025
+ gameId,
32026
+ courseId,
32027
+ window: window2,
32028
+ dateRange,
32029
+ ...selectedStudentId ? { studentId: selectedStudentId } : {},
32030
+ students,
32031
+ discrepancyMetricScopes: [...options.discrepancyMetricScopes ?? []],
32032
+ eventPage,
32033
+ includeVerified,
32034
+ items
32035
+ };
32036
+ }
32037
+ async verifyMetricDiscrepancy(user, options) {
32038
+ const { gameId, courseId, data } = options;
32039
+ const client = this.requireClient();
32040
+ await this.deps.validateDeveloperAccess(user, gameId);
32041
+ const [integration, gameSource] = await Promise.all([
32042
+ this.deps.db.query.gameTimebackIntegrations.findFirst({
32043
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
32044
+ }),
32045
+ this.getGameActivitySource(gameId)
32046
+ ]);
32047
+ if (!integration) {
32048
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
32049
+ }
32050
+ await this.assertStudentHasEnrollmentInCourse(client, data.studentId, courseId);
32051
+ const runId = data.runId.toLowerCase();
32052
+ let runEvents;
32053
+ try {
32054
+ runEvents = await this.fetchCaliperEventsForRun(client, gameSource, {
32055
+ runId,
32056
+ studentId: data.studentId
32057
+ });
32058
+ } catch (error) {
32059
+ logger17.warn("Failed to hydrate Caliper events for discrepancy verification", {
32060
+ gameId,
32061
+ courseId,
32062
+ studentId: data.studentId,
32063
+ runId,
32064
+ error: error instanceof Error ? error.message : String(error)
32065
+ });
32066
+ throw new ValidationError("Cannot verify discrepancy until full run data is available");
32067
+ }
32068
+ const activity = mapCaliperEventsToCompletedStudentGameplayActivities(runEvents, new Set([courseId]), {
32069
+ studentId: data.studentId,
32070
+ runIds: new Set([runId])
32071
+ }).flatMap((group) => group.activities).find((runActivity) => runActivity.runId?.toLowerCase() === runId);
32072
+ if (!activity) {
32073
+ throw new ValidationError("Cannot verify discrepancy until full run data is available");
32074
+ }
32075
+ const comparisons = await this.getGameMetricComparisonsForActivities(user, {
32076
+ gameId,
32077
+ studentId: data.studentId,
32078
+ course: { grade: integration.grade, subject: integration.subject },
32079
+ activities: [activity]
32080
+ });
32081
+ const comparison = comparisons.get(activity.runId ?? runId);
32082
+ if (comparison?.status !== "discrepant") {
32083
+ throw new ValidationError("Cannot verify discrepancy because the hydrated run is not discrepant");
32084
+ }
32085
+ const verifiedAt = new Date;
32086
+ const [row] = await this.deps.db.insert(gameTimebackMetricDiscrepancyVerifications).values({
32087
+ gameId,
32088
+ courseId,
32089
+ studentId: data.studentId,
32090
+ runId,
32091
+ activityId: data.activityId ?? null,
32092
+ verifiedByUserId: user.id,
32093
+ verifiedAt
32094
+ }).onConflictDoUpdate({
32095
+ target: [
32096
+ gameTimebackMetricDiscrepancyVerifications.gameId,
32097
+ gameTimebackMetricDiscrepancyVerifications.courseId,
32098
+ gameTimebackMetricDiscrepancyVerifications.studentId,
32099
+ gameTimebackMetricDiscrepancyVerifications.runId
32100
+ ],
32101
+ set: {
32102
+ activityId: data.activityId ?? null,
32103
+ verifiedByUserId: user.id,
32104
+ verifiedAt
32105
+ }
32106
+ }).returning();
32107
+ if (!row) {
32108
+ throw new ValidationError("Failed to verify metric discrepancy");
32109
+ }
32110
+ return {
32111
+ status: "ok",
32112
+ verification: TimebackAdminService.mapMetricDiscrepancyVerification(row)
32113
+ };
32114
+ }
31387
32115
  async getActivityDetail(user, options) {
31388
32116
  const { gameId, studentId, courseId, activityId, runId } = options;
31389
32117
  const client = this.requireClient();
@@ -31398,16 +32126,37 @@ class TimebackAdminService {
31398
32126
  throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
31399
32127
  }
31400
32128
  await this.assertStudentHasEnrollmentInCourse(client, studentId, courseId);
31401
- const events = await this.fetchCaliperEventsForStudent(client, studentId, gameSource, TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
31402
32129
  const relevantCourseIds = new Set([courseId]);
31403
32130
  let matchedEvents;
31404
32131
  let activity;
31405
32132
  if (runId) {
31406
- const gameplayEvents = events.filter((event) => (event.type === "ActivityEvent" || event.type === "TimeSpentEvent") && !isCaliperRemediationOrCompletionEvent(event));
31407
- const groups = groupCaliperEventsByRun(gameplayEvents);
31408
- matchedEvents = [...groups.values()].find((group) => group.some((event) => getCanonicalRunId(event.session) === runId && event.externalId === activityId)) ?? [];
31409
- activity = mapCaliperEventGroupToActivity(matchedEvents, relevantCourseIds);
32133
+ const normalizedRunId = TimebackAdminService.normalizeRunIds([runId], 1)[0];
32134
+ if (!normalizedRunId) {
32135
+ matchedEvents = [];
32136
+ activity = null;
32137
+ } else {
32138
+ let runEvents;
32139
+ try {
32140
+ runEvents = await this.fetchCaliperEventsForRun(client, gameSource, {
32141
+ runId: normalizedRunId,
32142
+ studentId
32143
+ });
32144
+ } catch (error) {
32145
+ logger17.warn("Failed to load Caliper events for activity detail run", {
32146
+ runId: normalizedRunId,
32147
+ studentId,
32148
+ activityId,
32149
+ error: error instanceof Error ? error.message : String(error)
32150
+ });
32151
+ runEvents = [];
32152
+ }
32153
+ const gameplayEvents = runEvents.filter((event) => (event.type === "ActivityEvent" || event.type === "TimeSpentEvent") && !isCaliperRemediationOrCompletionEvent(event));
32154
+ const matchingActivityEvents = findCaliperEventGroupContainingExternalId(gameplayEvents, activityId);
32155
+ matchedEvents = matchingActivityEvents ?? [];
32156
+ activity = mapCaliperEventGroupToActivity(matchedEvents, relevantCourseIds);
32157
+ }
31410
32158
  } else {
32159
+ const events = await this.fetchCaliperEventsForStudent(client, studentId, gameSource, TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
31411
32160
  matchedEvents = events.filter((event) => event.externalId === activityId);
31412
32161
  if (matchedEvents.length > 0) {
31413
32162
  activity = mapCaliperEventToRemediationActivity(matchedEvents[0], relevantCourseIds) ?? mapCaliperEventGroupToActivity(matchedEvents, relevantCourseIds);
@@ -31425,9 +32174,22 @@ class TimebackAdminService {
31425
32174
  activities: [activity]
31426
32175
  });
31427
32176
  const gameMetricsComparison = activity.runId ? comparisons.get(activity.runId) : undefined;
32177
+ const verificationsByKey = gameMetricsComparison?.status === "discrepant" && activity.runId ? await this.getMetricDiscrepancyVerificationsByKey({
32178
+ gameId,
32179
+ courseId,
32180
+ studentId,
32181
+ runIds: [activity.runId]
32182
+ }) : new Map;
32183
+ const verification2 = getTimebackMetricDiscrepancyVerificationForActivity({
32184
+ studentId,
32185
+ activity,
32186
+ comparison: gameMetricsComparison,
32187
+ verificationsByKey
32188
+ });
31428
32189
  const activityWithGameMetrics = gameMetricsComparison ? {
31429
32190
  ...activity,
31430
- gameMetricsComparison: summarizeGameRunMetricsComparison(gameMetricsComparison)
32191
+ gameMetricsComparison: summarizeGameRunMetricsComparison(gameMetricsComparison),
32192
+ ...verification2 ? { gameMetricsVerification: verification2 } : {}
31431
32193
  } : activity;
31432
32194
  return {
31433
32195
  activity: activityWithGameMetrics,
@@ -31718,9 +32480,11 @@ var init_timeback_admin_service = __esm(() => {
31718
32480
  init_types4();
31719
32481
  init_utils6();
31720
32482
  init_src4();
32483
+ init_timeback3();
31721
32484
  init_errors();
31722
32485
  init_timeback_admin_metrics_util();
31723
32486
  init_timeback_admin_util();
32487
+ init_timeback_discrepancy_queue_util();
31724
32488
  init_timeback_game_metrics_comparison_util();
31725
32489
  init_timeback_mastery_completion_util();
31726
32490
  init_timeback_util();
@@ -33560,6 +34324,25 @@ var init_timeback_service = __esm(() => {
33560
34324
  });
33561
34325
  return result;
33562
34326
  }
34327
+ async getStudentHighestGradeMastered(timebackId, user, options) {
34328
+ const client = this.requireClient();
34329
+ const db2 = this.deps.db;
34330
+ await this.deps.validateDeveloperAccess(user, options.gameId);
34331
+ const integration = await db2.query.gameTimebackIntegrations.findFirst({
34332
+ where: and(eq(gameTimebackIntegrations.gameId, options.gameId), eq(gameTimebackIntegrations.subject, options.subject))
34333
+ });
34334
+ if (!integration) {
34335
+ throw new ValidationError(`Subject "${options.subject}" is not configured for game ${options.gameId}`);
34336
+ }
34337
+ const result = await client.getHighestGradeMastered(timebackId, options.subject);
34338
+ logger20.debug("Retrieved student highest grade mastered", {
34339
+ timebackId,
34340
+ gameId: options.gameId,
34341
+ subject: options.subject,
34342
+ highestGradeMastered: result.highestGradeMastered
34343
+ });
34344
+ return result;
34345
+ }
33563
34346
  };
33564
34347
  });
33565
34348
 
@@ -34789,6 +35572,19 @@ async function getTimebackTokenResponse(config2) {
34789
35572
  function getAuthUrl(environment = "production") {
34790
35573
  return TIMEBACK_AUTH_URLS5[environment];
34791
35574
  }
35575
+ function parseEduBridgeGrade(value) {
35576
+ if (value === null || value === undefined || value.trim() === "") {
35577
+ return null;
35578
+ }
35579
+ const parsed = Number(value);
35580
+ return isTimebackGrade3(parsed) ? parsed : null;
35581
+ }
35582
+ function normalizeHighestGradeMastered(response, subject) {
35583
+ return {
35584
+ subject,
35585
+ highestGradeMastered: parseEduBridgeGrade(response.grades.highestGradeOverall)
35586
+ };
35587
+ }
34792
35588
  function handleHttpError(res, errorBody, attempt, retries, url2) {
34793
35589
  const error = new TimebackApiError2(res.status, res.statusText, errorBody);
34794
35590
  if (res.status >= HTTP_STATUS5.CLIENT_ERROR_MIN && res.status < HTTP_STATUS5.CLIENT_ERROR_MAX) {
@@ -34961,6 +35757,9 @@ function createCaliperNamespace(client) {
34961
35757
  if (params.actorEmail) {
34962
35758
  query.set("actorEmail", params.actorEmail);
34963
35759
  }
35760
+ if (params.sessionId) {
35761
+ query.set("sessionId", params.sessionId);
35762
+ }
34964
35763
  if (params.extensions) {
34965
35764
  for (const [key, value] of Object.entries(params.extensions)) {
34966
35765
  query.set(`extensions.${key}`, value);
@@ -35133,7 +35932,8 @@ function createEduBridgeNamespace(client) {
35133
35932
  const analytics = {
35134
35933
  getEnrollmentFacts: async (enrollmentId, options) => client["request"](buildPath(`/edubridge/analytics/enrollment/${enrollmentId}`, {
35135
35934
  timezone: options?.timezone
35136
- }), "GET")
35935
+ }), "GET"),
35936
+ getHighestGradeMastered: async (studentId, subject) => client["request"](`/edubridge/analytics/highestGradeMastered/${encodeURIComponent(studentId)}/${encodeURIComponent(subject)}`, "GET")
35137
35937
  };
35138
35938
  return {
35139
35939
  enrollments,
@@ -36875,6 +37675,11 @@ class TimebackClient {
36875
37675
  ...options?.include?.perCourse && { courses }
36876
37676
  };
36877
37677
  }
37678
+ async getHighestGradeMastered(studentId, subject) {
37679
+ await this._ensureAuthenticated();
37680
+ const response = await this.edubridge.analytics.getHighestGradeMastered(studentId, subject);
37681
+ return normalizeHighestGradeMastered(response, subject);
37682
+ }
36878
37683
  async getStudentXp(studentId, options) {
36879
37684
  await this._ensureAuthenticated();
36880
37685
  const enrollments = await this.edubridge.enrollments.listByUser(studentId);
@@ -37323,7 +38128,7 @@ function buildTimebackClient() {
37323
38128
  }
37324
38129
  return;
37325
38130
  }
37326
- var init_timeback3 = __esm(() => {
38131
+ var init_timeback4 = __esm(() => {
37327
38132
  init_src2();
37328
38133
  init_dist3();
37329
38134
  init_config();
@@ -37331,7 +38136,7 @@ var init_timeback3 = __esm(() => {
37331
38136
 
37332
38137
  // src/infrastructure/api/clients/index.ts
37333
38138
  var init_clients = __esm(() => {
37334
- init_timeback3();
38139
+ init_timeback4();
37335
38140
  });
37336
38141
 
37337
38142
  // src/infrastructure/api/providers/auth.provider.ts
@@ -92377,7 +93182,7 @@ async function seedTimebackIntegrations(db2, gameId, courses) {
92377
93182
  }
92378
93183
  return seededCount;
92379
93184
  }
92380
- var init_timeback4 = __esm(() => {
93185
+ var init_timeback5 = __esm(() => {
92381
93186
  init_tables_index();
92382
93187
  init_config();
92383
93188
  });
@@ -92468,7 +93273,7 @@ var init_games = __esm(() => {
92468
93273
  init_tables_index();
92469
93274
  init_constants();
92470
93275
  init_logging();
92471
- init_timeback4();
93276
+ init_timeback5();
92472
93277
  });
92473
93278
 
92474
93279
  // src/database/seed/index.ts
@@ -92494,7 +93299,7 @@ var init_seed = __esm(() => {
92494
93299
  init_tables_index();
92495
93300
  init_constants();
92496
93301
  init_games();
92497
- init_timeback4();
93302
+ init_timeback5();
92498
93303
  init_games();
92499
93304
  });
92500
93305
 
@@ -94197,12 +95002,13 @@ var init_session_controller = __esm(() => {
94197
95002
  });
94198
95003
 
94199
95004
  // ../api-core/src/controllers/timeback.controller.ts
94200
- var logger45, populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, updateIntegration, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, getStudentXp, getStudentMastery, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getActivityDetail, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
95005
+ var logger45, populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, updateIntegration, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
94201
95006
  var init_timeback_controller = __esm(() => {
94202
95007
  init_esm();
94203
95008
  init_schemas_index();
94204
95009
  init_src2();
94205
95010
  init_src4();
95011
+ init_timeback3();
94206
95012
  init_errors();
94207
95013
  init_utils11();
94208
95014
  logger45 = log.scope("TimebackController");
@@ -94541,6 +95347,33 @@ var init_timeback_controller = __esm(() => {
94541
95347
  include
94542
95348
  });
94543
95349
  });
95350
+ getStudentHighestGradeMastered = requireDeveloper(async (ctx) => {
95351
+ const timebackId = ctx.params.timebackId;
95352
+ if (!timebackId) {
95353
+ throw ApiError.badRequest("Missing timebackId parameter");
95354
+ }
95355
+ const gameId = ctx.url.searchParams.get("gameId");
95356
+ if (!gameId) {
95357
+ throw ApiError.badRequest("Missing required gameId query parameter");
95358
+ }
95359
+ const subjectParam = ctx.url.searchParams.get("subject");
95360
+ if (!subjectParam) {
95361
+ throw ApiError.badRequest("Missing required subject query parameter");
95362
+ }
95363
+ if (!isTimebackSubject2(subjectParam)) {
95364
+ throw ApiError.badRequest(`Invalid subject: ${subjectParam}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
95365
+ }
95366
+ logger45.debug("Getting student highest grade mastered", {
95367
+ requesterId: ctx.user.id,
95368
+ timebackId,
95369
+ gameId,
95370
+ subject: subjectParam
95371
+ });
95372
+ return ctx.services.timeback.getStudentHighestGradeMastered(timebackId, ctx.user, {
95373
+ gameId,
95374
+ subject: subjectParam
95375
+ });
95376
+ });
94544
95377
  getRoster = requireGameManagementAccess(async (ctx) => {
94545
95378
  const gameId = ctx.params.gameId;
94546
95379
  const courseId = ctx.params.courseId;
@@ -94642,6 +95475,65 @@ var init_timeback_controller = __esm(() => {
94642
95475
  runId
94643
95476
  });
94644
95477
  });
95478
+ listMetricDiscrepancies = requireGameManagementAccess(async (ctx) => {
95479
+ const gameId = ctx.params.gameId;
95480
+ const courseId = ctx.params.courseId;
95481
+ const requestedEventOffset = Number(ctx.url.searchParams.get("eventOffset"));
95482
+ const requestedWindow = ctx.url.searchParams.get("window");
95483
+ const startDate = ctx.url.searchParams.get("startDate") ?? undefined;
95484
+ const endDate = ctx.url.searchParams.get("endDate") ?? undefined;
95485
+ const studentId = ctx.url.searchParams.get("studentId")?.trim() || undefined;
95486
+ const discrepancyMetricScopes = parseTimebackDiscrepancyQueueMetrics(ctx.url.searchParams.getAll("discrepancyMetric"));
95487
+ const eventOffset = Number.isFinite(requestedEventOffset) && requestedEventOffset > 0 ? Math.trunc(requestedEventOffset) : 0;
95488
+ const window2 = parseTimebackDiscrepancyQueueWindow(requestedWindow);
95489
+ const includeVerified = ctx.url.searchParams.get("includeVerified") === "true";
95490
+ if (!gameId || !courseId) {
95491
+ throw ApiError.badRequest("Missing gameId or courseId path parameter");
95492
+ }
95493
+ logger45.debug("Listing metric discrepancies", {
95494
+ requesterId: ctx.user.id,
95495
+ gameId,
95496
+ courseId,
95497
+ window: window2,
95498
+ startDate,
95499
+ endDate,
95500
+ studentId,
95501
+ discrepancyMetricScopes,
95502
+ includeVerified,
95503
+ eventOffset
95504
+ });
95505
+ return ctx.services.timebackAdmin.listMetricDiscrepancies(ctx.user, {
95506
+ gameId,
95507
+ courseId,
95508
+ window: window2,
95509
+ startDate,
95510
+ endDate,
95511
+ studentId,
95512
+ discrepancyMetricScopes,
95513
+ includeVerified,
95514
+ eventOffset
95515
+ });
95516
+ });
95517
+ verifyMetricDiscrepancy = requireDeveloper(async (ctx) => {
95518
+ const gameId = ctx.params.gameId;
95519
+ const courseId = ctx.params.courseId;
95520
+ const body2 = await parseRequestBody(ctx.request, VerifyTimebackMetricDiscrepancyRequestSchema);
95521
+ if (!gameId || !courseId) {
95522
+ throw ApiError.badRequest("Missing gameId or courseId path parameter");
95523
+ }
95524
+ logger45.debug("Verifying metric discrepancy", {
95525
+ requesterId: ctx.user.id,
95526
+ gameId,
95527
+ courseId,
95528
+ studentId: body2.studentId,
95529
+ runId: body2.runId
95530
+ });
95531
+ return ctx.services.timebackAdmin.verifyMetricDiscrepancy(ctx.user, {
95532
+ gameId,
95533
+ courseId,
95534
+ data: body2
95535
+ });
95536
+ });
94645
95537
  grantXp = requireDeveloper(async (ctx) => {
94646
95538
  const body2 = await parseRequestBody(ctx.request, GrantTimebackXpRequestSchema);
94647
95539
  logger45.debug("Granting manual XP", {
@@ -94879,11 +95771,14 @@ var init_timeback_controller = __esm(() => {
94879
95771
  unenrollCourse,
94880
95772
  getStudentXp,
94881
95773
  getStudentMastery,
95774
+ getStudentHighestGradeMastered,
94882
95775
  getRoster,
94883
95776
  getStudentOverview,
94884
95777
  getGameMetrics,
94885
95778
  getStudentActivity,
94886
95779
  getActivityDetail,
95780
+ listMetricDiscrepancies,
95781
+ verifyMetricDiscrepancy,
94887
95782
  grantXp,
94888
95783
  adjustTime,
94889
95784
  adjustMastery,
@@ -95087,6 +95982,10 @@ async function getMockTimebackUser(db2, gameId) {
95087
95982
  const timebackId = config.timeback.timebackId || "mock-student-00000001";
95088
95983
  return getMockTimebackData(db2, timebackId, gameId);
95089
95984
  }
95985
+ function getMockHighestGradeMastered(enrollments, subject) {
95986
+ const subjectGrades = enrollments.filter((enrollment) => enrollment.subject === subject).map((enrollment) => enrollment.grade);
95987
+ return subjectGrades.length > 0 ? Math.max(...subjectGrades) : null;
95988
+ }
95090
95989
  async function buildMockUserResponse(db2, user, gameId) {
95091
95990
  const timeback3 = user.timebackId ? await getMockTimebackData(db2, user.timebackId, gameId) : undefined;
95092
95991
  if (gameId) {
@@ -95117,7 +96016,7 @@ async function buildMockUserResponse(db2, user, gameId) {
95117
96016
  timeback: timeback3
95118
96017
  };
95119
96018
  }
95120
- var init_timeback5 = __esm(() => {
96019
+ var init_timeback6 = __esm(() => {
95121
96020
  init_drizzle_orm();
95122
96021
  init_utils11();
95123
96022
  init_tables_index();
@@ -95135,7 +96034,7 @@ var init_users = __esm(() => {
95135
96034
  init_tables_index();
95136
96035
  init_api();
95137
96036
  init_error_handler();
95138
- init_timeback5();
96037
+ init_timeback6();
95139
96038
  usersRouter = new Hono2;
95140
96039
  usersRouter.get("/me", async (c2) => {
95141
96040
  const user = c2.get("user");
@@ -95760,15 +96659,16 @@ function hashCode(str) {
95760
96659
  return Math.abs(hash);
95761
96660
  }
95762
96661
  var timebackRouter;
95763
- var init_timeback6 = __esm(() => {
96662
+ var init_timeback7 = __esm(() => {
95764
96663
  init_dist4();
95765
96664
  init_controllers();
95766
96665
  init_errors();
95767
96666
  init_utils11();
95768
96667
  init_schemas_index();
96668
+ init_timeback3();
95769
96669
  init_api();
95770
96670
  init_error_handler();
95771
- init_timeback5();
96671
+ init_timeback6();
95772
96672
  timebackRouter = new Hono2;
95773
96673
  timebackRouter.post("/populate-student", async (c2) => c2.json({
95774
96674
  status: "no_record"
@@ -95934,6 +96834,39 @@ var init_timeback6 = __esm(() => {
95934
96834
  }
95935
96835
  return handle2(timeback2.getStudentMastery)(c2);
95936
96836
  });
96837
+ timebackRouter.get("/student-highest-grade-mastered/:timebackId", async (c2) => {
96838
+ const user = c2.get("user");
96839
+ if (!user) {
96840
+ const error2 = ApiError.unauthorized("Must be logged in to get student highest grade mastered");
96841
+ return c2.json(createErrorResponse(error2), error2.status);
96842
+ }
96843
+ if (shouldMockTimeback()) {
96844
+ const url2 = new URL(c2.req.url);
96845
+ const subject = url2.searchParams.get("subject");
96846
+ const contextGameId = c2.get("gameId");
96847
+ const gameId = url2.searchParams.get("gameId") || (typeof contextGameId === "string" ? contextGameId : undefined);
96848
+ if (!subject) {
96849
+ const error2 = ApiError.badRequest("Missing required subject query parameter");
96850
+ return c2.json(createErrorResponse(error2), error2.status);
96851
+ }
96852
+ if (!gameId) {
96853
+ const error2 = ApiError.badRequest("Missing required gameId query parameter");
96854
+ return c2.json(createErrorResponse(error2), error2.status);
96855
+ }
96856
+ if (!isTimebackSubject2(subject)) {
96857
+ const error2 = ApiError.badRequest(`Invalid subject: ${subject}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
96858
+ return c2.json(createErrorResponse(error2), error2.status);
96859
+ }
96860
+ const db2 = c2.get("db");
96861
+ const enrollments = await getMockEnrollments(db2);
96862
+ const gameScopedEnrollments = filterEnrollmentsByGame(enrollments, gameId);
96863
+ return c2.json({
96864
+ subject,
96865
+ highestGradeMastered: getMockHighestGradeMastered(gameScopedEnrollments, subject)
96866
+ });
96867
+ }
96868
+ return handle2(timeback2.getStudentHighestGradeMastered)(c2);
96869
+ });
95937
96870
  });
95938
96871
 
95939
96872
  // src/routes/integrations/lti.ts
@@ -96058,7 +96991,7 @@ var init_routes = __esm(() => {
96058
96991
  init_users();
96059
96992
  init_games2();
96060
96993
  init_leaderboard();
96061
- init_timeback6();
96994
+ init_timeback7();
96062
96995
  init_lti();
96063
96996
  });
96064
96997
 
@@ -98311,7 +99244,8 @@ program2.name("playcademy-sandbox").description("Local development server for Pl
98311
99244
  port,
98312
99245
  url: `http://localhost:${port}/api`,
98313
99246
  startedAt: Date.now(),
98314
- projectRoot: process.cwd()
99247
+ projectRoot: process.cwd(),
99248
+ gameId: server.gameId
98315
99249
  });
98316
99250
  const totalCourses = project?.timebackCourses?.length ?? 0;
98317
99251
  const excludedCount = options.timebackExcludedCourses ? options.timebackExcludedCourses.split(",").filter(Boolean).length : 0;