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