@playcademy/sandbox 0.4.2-beta.2 → 0.4.2-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +1033 -202
  2. package/dist/server.js +1033 -202
  3. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -1077,7 +1077,7 @@ var package_default;
1077
1077
  var init_package = __esm(() => {
1078
1078
  package_default = {
1079
1079
  name: "@playcademy/sandbox",
1080
- version: "0.4.2-beta.2",
1080
+ version: "0.4.2-beta.4",
1081
1081
  description: "Local development server for Playcademy game development",
1082
1082
  type: "module",
1083
1083
  exports: {
@@ -11319,10 +11319,11 @@ var init_table6 = __esm(() => {
11319
11319
  });
11320
11320
 
11321
11321
  // ../data/src/domains/timeback/table.ts
11322
- var gameTimebackIntegrations, gameTimebackAssessmentTests;
11322
+ var gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications;
11323
11323
  var init_table7 = __esm(() => {
11324
11324
  init_pg_core();
11325
11325
  init_table5();
11326
+ init_table3();
11326
11327
  gameTimebackIntegrations = pgTable("game_timeback_integrations", {
11327
11328
  id: uuid("id").primaryKey().defaultRandom(),
11328
11329
  gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
@@ -11347,6 +11348,21 @@ var init_table7 = __esm(() => {
11347
11348
  }, (table3) => [
11348
11349
  uniqueIndex("game_timeback_assessment_tests_integration_qti_idx").on(table3.integrationId, table3.qtiTestIdentifier)
11349
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
+ ]);
11350
11366
  });
11351
11367
 
11352
11368
  // ../data/src/tables.index.ts
@@ -11360,6 +11376,7 @@ __export(exports_tables_index, {
11360
11376
  games: () => games,
11361
11377
  gameVisibilityEnum: () => gameVisibilityEnum,
11362
11378
  gameTypeEnum: () => gameTypeEnum,
11379
+ gameTimebackMetricDiscrepancyVerifications: () => gameTimebackMetricDiscrepancyVerifications,
11363
11380
  gameTimebackIntegrations: () => gameTimebackIntegrations,
11364
11381
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
11365
11382
  gameScoresRelations: () => gameScoresRelations,
@@ -29363,6 +29380,75 @@ function formatDateYMDInTimezone(timeZone, date3 = new Date) {
29363
29380
  const d = parts2.find((p) => p.type === "day").value;
29364
29381
  return `${y}-${m}-${d}`;
29365
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
+ }
29366
29452
  // ../utils/src/url.ts
29367
29453
  function buildPath(path, params) {
29368
29454
  const url = new URL(path, "http://n");
@@ -29406,6 +29492,42 @@ function formatGradeLabel(grade) {
29406
29492
  }
29407
29493
  }
29408
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
+ });
29409
29531
 
29410
29532
  // ../../node_modules/.bun/drizzle-zod@0.7.1+e9f18f9688af15ce/node_modules/drizzle-zod/index.mjs
29411
29533
  function isColumnType(column2, columnTypes) {
@@ -29906,7 +30028,7 @@ function isValidAdminAttributionDate(value) {
29906
30028
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
29907
30029
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
29908
30030
  }
29909
- 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;
29910
30032
  var init_schemas4 = __esm(() => {
29911
30033
  init_drizzle_zod();
29912
30034
  init_esm();
@@ -30147,6 +30269,11 @@ var init_schemas4 = __esm(() => {
30147
30269
  studentId: exports_external.string().min(1),
30148
30270
  enrollmentId: exports_external.string().min(1)
30149
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
+ });
30150
30277
  InsertAssessmentTestSchema = createInsertSchema(gameTimebackAssessmentTests).omit({
30151
30278
  id: true,
30152
30279
  createdAt: true
@@ -30279,183 +30406,6 @@ var init_timeback_admin_util = __esm(() => {
30279
30406
  init_errors();
30280
30407
  });
30281
30408
 
30282
- // ../api-core/src/utils/timeback-game-metrics-comparison.util.ts
30283
- function createMetricRow(definition) {
30284
- const { gameValue, kind, metric, timebackValue, tolerance } = definition;
30285
- if (timebackValue === undefined && gameValue === undefined) {
30286
- return null;
30287
- }
30288
- if (gameValue === undefined) {
30289
- return {
30290
- metric,
30291
- kind,
30292
- status: "not_reported_by_game",
30293
- ...timebackValue !== undefined ? { timebackValue } : {}
30294
- };
30295
- }
30296
- if (timebackValue === undefined) {
30297
- return {
30298
- metric,
30299
- kind,
30300
- status: "not_recorded_by_timeback",
30301
- gameValue
30302
- };
30303
- }
30304
- const delta = gameValue - timebackValue;
30305
- const isDiscrepant = tolerance === 0 ? delta !== 0 : Math.abs(delta) >= tolerance;
30306
- return {
30307
- metric,
30308
- kind,
30309
- status: isDiscrepant ? "discrepant" : "matched",
30310
- timebackValue,
30311
- gameValue,
30312
- delta
30313
- };
30314
- }
30315
- function createRunComparison(activity, gameRun) {
30316
- const runId = activity.runId ?? "";
30317
- if (!gameRun) {
30318
- return {
30319
- runId,
30320
- status: "not_reported",
30321
- discrepancyCount: 0,
30322
- rows: []
30323
- };
30324
- }
30325
- const rows = [
30326
- createMetricRow({
30327
- metric: "xp",
30328
- kind: "number",
30329
- timebackValue: activity.xpDelta,
30330
- gameValue: gameRun.totalXp,
30331
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.xp
30332
- }),
30333
- createMetricRow({
30334
- metric: "mastery",
30335
- kind: "number",
30336
- timebackValue: activity.masteredUnitsDelta,
30337
- gameValue: gameRun.masteredUnits,
30338
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.mastery
30339
- }),
30340
- createMetricRow({
30341
- metric: "time",
30342
- kind: "time",
30343
- timebackValue: activity.timeDeltaSeconds,
30344
- gameValue: gameRun.activeTimeSeconds,
30345
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.time
30346
- }),
30347
- createMetricRow({
30348
- metric: "score",
30349
- kind: "percent",
30350
- timebackValue: activity.score,
30351
- gameValue: gameRun.score,
30352
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.score
30353
- })
30354
- ].filter((row) => row !== null);
30355
- const discrepancyCount = rows.filter((row) => row.status === "discrepant").length;
30356
- return {
30357
- runId,
30358
- status: discrepancyCount > 0 ? "discrepant" : "matched",
30359
- discrepancyCount,
30360
- rows
30361
- };
30362
- }
30363
- function summarizeGameRunMetricsComparison(comparison) {
30364
- return {
30365
- runId: comparison.runId,
30366
- status: comparison.status,
30367
- discrepancyCount: comparison.discrepancyCount,
30368
- ...comparison.reason ? { reason: comparison.reason } : {}
30369
- };
30370
- }
30371
- function buildGameRunMetricComparisons(activities, course, response) {
30372
- const activitiesWithRunIds = activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0);
30373
- const comparisons = new Map;
30374
- if (activitiesWithRunIds.length === 0) {
30375
- return comparisons;
30376
- }
30377
- if (!response.supported) {
30378
- for (const activity of activitiesWithRunIds) {
30379
- comparisons.set(activity.runId, {
30380
- runId: activity.runId,
30381
- status: "unavailable",
30382
- discrepancyCount: 0,
30383
- reason: response.reason,
30384
- rows: []
30385
- });
30386
- }
30387
- return comparisons;
30388
- }
30389
- const gameCourseMetrics = response.metrics.courses.find((gameCourse) => gameCourse.grade === course.grade && gameCourse.subject === course.subject);
30390
- const gameRunsById = new Map(gameCourseMetrics?.activities?.map((gameRun) => [gameRun.runId.toLowerCase(), gameRun]));
30391
- for (const activity of activitiesWithRunIds) {
30392
- comparisons.set(activity.runId, createRunComparison(activity, gameRunsById.get(activity.runId.toLowerCase())));
30393
- }
30394
- return comparisons;
30395
- }
30396
- var init_timeback_game_metrics_comparison_util = __esm(() => {
30397
- init_src();
30398
- });
30399
-
30400
- // ../api-core/src/utils/timeback-mastery-completion.util.ts
30401
- async function upsertMasteryCompletionEntry(params) {
30402
- const { client, courseId, studentId, appName, action } = params;
30403
- const ids = deriveSourcedIds(courseId);
30404
- const lineItemId = `${ids.course}-mastery-completion-assessment`;
30405
- const resultId = `${lineItemId}:${studentId}:completion`;
30406
- if (action === "complete") {
30407
- await client.oneroster.assessmentLineItems.findOrCreate(lineItemId, {
30408
- sourcedId: lineItemId,
30409
- title: "Mastery Completion",
30410
- status: ONEROSTER_STATUS.active,
30411
- course: { sourcedId: ids.course },
30412
- ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
30413
- });
30414
- await client.oneroster.assessmentResults.upsert(resultId, {
30415
- sourcedId: resultId,
30416
- status: ONEROSTER_STATUS.active,
30417
- assessmentLineItem: { sourcedId: lineItemId },
30418
- student: { sourcedId: studentId },
30419
- score: 100,
30420
- scoreDate: new Date().toISOString(),
30421
- scoreStatus: SCORE_STATUS.fullyGraded,
30422
- inProgress: "false",
30423
- metadata: {
30424
- isMasteryCompletion: true,
30425
- adminAction: true,
30426
- appName
30427
- }
30428
- });
30429
- } else {
30430
- try {
30431
- await client.oneroster.assessmentResults.upsert(resultId, {
30432
- sourcedId: resultId,
30433
- status: ONEROSTER_STATUS.active,
30434
- assessmentLineItem: { sourcedId: lineItemId },
30435
- student: { sourcedId: studentId },
30436
- score: 0,
30437
- scoreDate: new Date().toISOString(),
30438
- scoreStatus: SCORE_STATUS.notSubmitted,
30439
- inProgress: "true",
30440
- metadata: {
30441
- isMasteryCompletion: true,
30442
- adminAction: true,
30443
- appName
30444
- }
30445
- });
30446
- } catch {
30447
- logger16.debug("No completion entry to revoke", { studentId, courseId });
30448
- }
30449
- }
30450
- }
30451
- var logger16;
30452
- var init_timeback_mastery_completion_util = __esm(() => {
30453
- init_src2();
30454
- init_constants4();
30455
- init_utils6();
30456
- logger16 = log.scope("timeback-mastery-completion");
30457
- });
30458
-
30459
30409
  // ../api-core/src/utils/timeback.util.ts
30460
30410
  function isRecord2(value) {
30461
30411
  return typeof value === "object" && value !== null;
@@ -30603,6 +30553,13 @@ function groupCaliperEventsByRun(events) {
30603
30553
  }
30604
30554
  return groups;
30605
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
+ }
30606
30563
  function mapCaliperEventGroupToActivity(events, relevantCourseIds) {
30607
30564
  if (events.length === 0) {
30608
30565
  return null;
@@ -30776,6 +30733,371 @@ var init_timeback_util = __esm(() => {
30776
30733
  ]);
30777
30734
  });
30778
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
+
30779
31101
  // ../api-core/src/services/timeback-admin.service.ts
30780
31102
  class TimebackAdminService {
30781
31103
  deps;
@@ -30784,6 +31106,10 @@ class TimebackAdminService {
30784
31106
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
30785
31107
  static MAX_STUDENT_ACTIVITY_OFFSET = 1000;
30786
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;
30787
31113
  static ANALYTICS_CONCURRENCY = 8;
30788
31114
  static MASTERABLE_UNITS_CONCURRENCY = 4;
30789
31115
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
@@ -31031,16 +31357,53 @@ class TimebackAdminService {
31031
31357
  }));
31032
31358
  return comparisons;
31033
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
+ }
31034
31384
  async attachGameMetricSummariesToActivities(user, options) {
31035
31385
  const comparisons = await this.getGameMetricComparisonsForActivities(user, options);
31036
31386
  if (comparisons.size === 0) {
31037
31387
  return [...options.activities];
31038
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
+ });
31039
31395
  return options.activities.map((activity) => {
31040
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
+ });
31041
31403
  return comparison ? {
31042
31404
  ...activity,
31043
- gameMetricsComparison: summarizeGameRunMetricsComparison(comparison)
31405
+ gameMetricsComparison: summarizeGameRunMetricsComparison(comparison),
31406
+ ...verification2 ? { gameMetricsVerification: verification2 } : {}
31044
31407
  } : activity;
31045
31408
  });
31046
31409
  }
@@ -31131,6 +31494,165 @@ class TimebackAdminService {
31131
31494
  });
31132
31495
  return events;
31133
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
+ }
31134
31656
  async listRecentActivityForStudent(client, studentId, source, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
31135
31657
  if (relevantCourseIds.size === 0) {
31136
31658
  return [];
@@ -31380,12 +31902,215 @@ class TimebackAdminService {
31380
31902
  const activitiesWithGameMetrics = await this.attachGameMetricSummariesToActivities(user, {
31381
31903
  gameId,
31382
31904
  studentId,
31905
+ courseId,
31383
31906
  course: { grade: integration.grade, subject: integration.subject },
31384
31907
  activities,
31385
31908
  timeoutMs: TimebackAdminService.GAME_METRICS_LIST_FETCH_TIMEOUT_MS
31386
31909
  });
31387
31910
  return { activities: activitiesWithGameMetrics, hasMore };
31388
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
+ }
31389
32114
  async getActivityDetail(user, options) {
31390
32115
  const { gameId, studentId, courseId, activityId, runId } = options;
31391
32116
  const client = this.requireClient();
@@ -31400,16 +32125,37 @@ class TimebackAdminService {
31400
32125
  throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
31401
32126
  }
31402
32127
  await this.assertStudentHasEnrollmentInCourse(client, studentId, courseId);
31403
- const events = await this.fetchCaliperEventsForStudent(client, studentId, gameSource, TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
31404
32128
  const relevantCourseIds = new Set([courseId]);
31405
32129
  let matchedEvents;
31406
32130
  let activity;
31407
32131
  if (runId) {
31408
- const gameplayEvents = events.filter((event) => (event.type === "ActivityEvent" || event.type === "TimeSpentEvent") && !isCaliperRemediationOrCompletionEvent(event));
31409
- const groups = groupCaliperEventsByRun(gameplayEvents);
31410
- matchedEvents = [...groups.values()].find((group) => group.some((event) => getCanonicalRunId(event.session) === runId && event.externalId === activityId)) ?? [];
31411
- 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
+ }
31412
32157
  } else {
32158
+ const events = await this.fetchCaliperEventsForStudent(client, studentId, gameSource, TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
31413
32159
  matchedEvents = events.filter((event) => event.externalId === activityId);
31414
32160
  if (matchedEvents.length > 0) {
31415
32161
  activity = mapCaliperEventToRemediationActivity(matchedEvents[0], relevantCourseIds) ?? mapCaliperEventGroupToActivity(matchedEvents, relevantCourseIds);
@@ -31427,9 +32173,22 @@ class TimebackAdminService {
31427
32173
  activities: [activity]
31428
32174
  });
31429
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
+ });
31430
32188
  const activityWithGameMetrics = gameMetricsComparison ? {
31431
32189
  ...activity,
31432
- gameMetricsComparison: summarizeGameRunMetricsComparison(gameMetricsComparison)
32190
+ gameMetricsComparison: summarizeGameRunMetricsComparison(gameMetricsComparison),
32191
+ ...verification2 ? { gameMetricsVerification: verification2 } : {}
31433
32192
  } : activity;
31434
32193
  return {
31435
32194
  activity: activityWithGameMetrics,
@@ -31720,9 +32479,11 @@ var init_timeback_admin_service = __esm(() => {
31720
32479
  init_types4();
31721
32480
  init_utils6();
31722
32481
  init_src4();
32482
+ init_timeback3();
31723
32483
  init_errors();
31724
32484
  init_timeback_admin_metrics_util();
31725
32485
  init_timeback_admin_util();
32486
+ init_timeback_discrepancy_queue_util();
31726
32487
  init_timeback_game_metrics_comparison_util();
31727
32488
  init_timeback_mastery_completion_util();
31728
32489
  init_timeback_util();
@@ -33636,6 +34397,7 @@ function createPlatformServices(deps) {
33636
34397
  config: config2,
33637
34398
  cloudflare: cloudflare2,
33638
34399
  storage,
34400
+ r2Storage,
33639
34401
  timebackClient,
33640
34402
  alerts,
33641
34403
  mintPlatformServiceToken,
@@ -33645,7 +34407,7 @@ function createPlatformServices(deps) {
33645
34407
  } = deps;
33646
34408
  const bucket = new BucketService({
33647
34409
  uploadBucket: config2.uploadBucket,
33648
- storage,
34410
+ storage: r2Storage,
33649
34411
  validateDeveloperAccessBySlug,
33650
34412
  validateDeveloperAccess
33651
34413
  });
@@ -34294,7 +35056,7 @@ var init_standalone = __esm(() => {
34294
35056
  // ../api-core/src/services/factory/index.ts
34295
35057
  function createServices(ctx) {
34296
35058
  const { db: db2, config: config2, providers, cloudflare: cloudflare2, timeback: timeback2, discord } = ctx;
34297
- const { auth: auth2, storage, cache } = providers;
35059
+ const { auth: auth2, storage, r2Storage, cache } = providers;
34298
35060
  const infra2 = createInfraServices({
34299
35061
  db: db2,
34300
35062
  discord,
@@ -34317,6 +35079,7 @@ function createServices(ctx) {
34317
35079
  config: config2,
34318
35080
  cloudflare: cloudflare2,
34319
35081
  storage,
35082
+ r2Storage,
34320
35083
  timebackClient: timeback2,
34321
35084
  alerts: infra2.alerts,
34322
35085
  mintPlatformServiceToken: auth2.mintPlatformServiceToken.bind(auth2),
@@ -34995,6 +35758,9 @@ function createCaliperNamespace(client) {
34995
35758
  if (params.actorEmail) {
34996
35759
  query.set("actorEmail", params.actorEmail);
34997
35760
  }
35761
+ if (params.sessionId) {
35762
+ query.set("sessionId", params.sessionId);
35763
+ }
34998
35764
  if (params.extensions) {
34999
35765
  for (const [key, value] of Object.entries(params.extensions)) {
35000
35766
  query.set(`extensions.${key}`, value);
@@ -37363,7 +38129,7 @@ function buildTimebackClient() {
37363
38129
  }
37364
38130
  return;
37365
38131
  }
37366
- var init_timeback3 = __esm(() => {
38132
+ var init_timeback4 = __esm(() => {
37367
38133
  init_src2();
37368
38134
  init_dist3();
37369
38135
  init_config();
@@ -37371,7 +38137,7 @@ var init_timeback3 = __esm(() => {
37371
38137
 
37372
38138
  // src/infrastructure/api/clients/index.ts
37373
38139
  var init_clients = __esm(() => {
37374
- init_timeback3();
38140
+ init_timeback4();
37375
38141
  });
37376
38142
 
37377
38143
  // src/infrastructure/api/providers/auth.provider.ts
@@ -37562,7 +38328,7 @@ function createSandboxStorageProvider() {
37562
38328
  files.push({
37563
38329
  key,
37564
38330
  size: data.body.length,
37565
- lastModified: new Date,
38331
+ lastModified: new Date().toISOString(),
37566
38332
  contentType: data.contentType
37567
38333
  });
37568
38334
  }
@@ -37634,9 +38400,11 @@ function buildConfig(options) {
37634
38400
  });
37635
38401
  }
37636
38402
  function buildProviders() {
38403
+ const storage2 = createSandboxStorageProvider();
37637
38404
  return {
37638
38405
  auth: createSandboxAuthProvider(),
37639
- storage: createSandboxStorageProvider(),
38406
+ storage: storage2,
38407
+ r2Storage: storage2,
37640
38408
  cache: createSandboxCacheProvider()
37641
38409
  };
37642
38410
  }
@@ -92417,7 +93185,7 @@ async function seedTimebackIntegrations(db2, gameId, courses) {
92417
93185
  }
92418
93186
  return seededCount;
92419
93187
  }
92420
- var init_timeback4 = __esm(() => {
93188
+ var init_timeback5 = __esm(() => {
92421
93189
  init_tables_index();
92422
93190
  init_config();
92423
93191
  });
@@ -92508,7 +93276,7 @@ var init_games = __esm(() => {
92508
93276
  init_tables_index();
92509
93277
  init_constants();
92510
93278
  init_logging();
92511
- init_timeback4();
93279
+ init_timeback5();
92512
93280
  });
92513
93281
 
92514
93282
  // src/database/seed/index.ts
@@ -92534,7 +93302,7 @@ var init_seed = __esm(() => {
92534
93302
  init_tables_index();
92535
93303
  init_constants();
92536
93304
  init_games();
92537
- init_timeback4();
93305
+ init_timeback5();
92538
93306
  init_games();
92539
93307
  });
92540
93308
 
@@ -94237,12 +95005,13 @@ var init_session_controller = __esm(() => {
94237
95005
  });
94238
95006
 
94239
95007
  // ../api-core/src/controllers/timeback.controller.ts
94240
- 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, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
95008
+ 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;
94241
95009
  var init_timeback_controller = __esm(() => {
94242
95010
  init_esm();
94243
95011
  init_schemas_index();
94244
95012
  init_src2();
94245
95013
  init_src4();
95014
+ init_timeback3();
94246
95015
  init_errors();
94247
95016
  init_utils11();
94248
95017
  logger45 = log.scope("TimebackController");
@@ -94709,6 +95478,65 @@ var init_timeback_controller = __esm(() => {
94709
95478
  runId
94710
95479
  });
94711
95480
  });
95481
+ listMetricDiscrepancies = requireGameManagementAccess(async (ctx) => {
95482
+ const gameId = ctx.params.gameId;
95483
+ const courseId = ctx.params.courseId;
95484
+ const requestedEventOffset = Number(ctx.url.searchParams.get("eventOffset"));
95485
+ const requestedWindow = ctx.url.searchParams.get("window");
95486
+ const startDate = ctx.url.searchParams.get("startDate") ?? undefined;
95487
+ const endDate = ctx.url.searchParams.get("endDate") ?? undefined;
95488
+ const studentId = ctx.url.searchParams.get("studentId")?.trim() || undefined;
95489
+ const discrepancyMetricScopes = parseTimebackDiscrepancyQueueMetrics(ctx.url.searchParams.getAll("discrepancyMetric"));
95490
+ const eventOffset = Number.isFinite(requestedEventOffset) && requestedEventOffset > 0 ? Math.trunc(requestedEventOffset) : 0;
95491
+ const window2 = parseTimebackDiscrepancyQueueWindow(requestedWindow);
95492
+ const includeVerified = ctx.url.searchParams.get("includeVerified") === "true";
95493
+ if (!gameId || !courseId) {
95494
+ throw ApiError.badRequest("Missing gameId or courseId path parameter");
95495
+ }
95496
+ logger45.debug("Listing metric discrepancies", {
95497
+ requesterId: ctx.user.id,
95498
+ gameId,
95499
+ courseId,
95500
+ window: window2,
95501
+ startDate,
95502
+ endDate,
95503
+ studentId,
95504
+ discrepancyMetricScopes,
95505
+ includeVerified,
95506
+ eventOffset
95507
+ });
95508
+ return ctx.services.timebackAdmin.listMetricDiscrepancies(ctx.user, {
95509
+ gameId,
95510
+ courseId,
95511
+ window: window2,
95512
+ startDate,
95513
+ endDate,
95514
+ studentId,
95515
+ discrepancyMetricScopes,
95516
+ includeVerified,
95517
+ eventOffset
95518
+ });
95519
+ });
95520
+ verifyMetricDiscrepancy = requireDeveloper(async (ctx) => {
95521
+ const gameId = ctx.params.gameId;
95522
+ const courseId = ctx.params.courseId;
95523
+ const body2 = await parseRequestBody(ctx.request, VerifyTimebackMetricDiscrepancyRequestSchema);
95524
+ if (!gameId || !courseId) {
95525
+ throw ApiError.badRequest("Missing gameId or courseId path parameter");
95526
+ }
95527
+ logger45.debug("Verifying metric discrepancy", {
95528
+ requesterId: ctx.user.id,
95529
+ gameId,
95530
+ courseId,
95531
+ studentId: body2.studentId,
95532
+ runId: body2.runId
95533
+ });
95534
+ return ctx.services.timebackAdmin.verifyMetricDiscrepancy(ctx.user, {
95535
+ gameId,
95536
+ courseId,
95537
+ data: body2
95538
+ });
95539
+ });
94712
95540
  grantXp = requireDeveloper(async (ctx) => {
94713
95541
  const body2 = await parseRequestBody(ctx.request, GrantTimebackXpRequestSchema);
94714
95542
  logger45.debug("Granting manual XP", {
@@ -94952,6 +95780,8 @@ var init_timeback_controller = __esm(() => {
94952
95780
  getGameMetrics,
94953
95781
  getStudentActivity,
94954
95782
  getActivityDetail,
95783
+ listMetricDiscrepancies,
95784
+ verifyMetricDiscrepancy,
94955
95785
  grantXp,
94956
95786
  adjustTime,
94957
95787
  adjustMastery,
@@ -95189,7 +96019,7 @@ async function buildMockUserResponse(db2, user, gameId) {
95189
96019
  timeback: timeback3
95190
96020
  };
95191
96021
  }
95192
- var init_timeback5 = __esm(() => {
96022
+ var init_timeback6 = __esm(() => {
95193
96023
  init_drizzle_orm();
95194
96024
  init_utils11();
95195
96025
  init_tables_index();
@@ -95207,7 +96037,7 @@ var init_users = __esm(() => {
95207
96037
  init_tables_index();
95208
96038
  init_api();
95209
96039
  init_error_handler();
95210
- init_timeback5();
96040
+ init_timeback6();
95211
96041
  usersRouter = new Hono2;
95212
96042
  usersRouter.get("/me", async (c2) => {
95213
96043
  const user = c2.get("user");
@@ -95832,15 +96662,16 @@ function hashCode(str) {
95832
96662
  return Math.abs(hash);
95833
96663
  }
95834
96664
  var timebackRouter;
95835
- var init_timeback6 = __esm(() => {
96665
+ var init_timeback7 = __esm(() => {
95836
96666
  init_dist4();
95837
96667
  init_controllers();
95838
96668
  init_errors();
95839
96669
  init_utils11();
95840
96670
  init_schemas_index();
96671
+ init_timeback3();
95841
96672
  init_api();
95842
96673
  init_error_handler();
95843
- init_timeback5();
96674
+ init_timeback6();
95844
96675
  timebackRouter = new Hono2;
95845
96676
  timebackRouter.post("/populate-student", async (c2) => c2.json({
95846
96677
  status: "no_record"
@@ -96163,7 +96994,7 @@ var init_routes = __esm(() => {
96163
96994
  init_users();
96164
96995
  init_games2();
96165
96996
  init_leaderboard();
96166
- init_timeback6();
96997
+ init_timeback7();
96167
96998
  init_lti();
96168
96999
  });
96169
97000