@playcademy/sandbox 0.4.2-beta.2 → 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.
Files changed (3) hide show
  1. package/dist/cli.js +1025 -198
  2. package/dist/server.js +1025 -198
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1078,7 +1078,7 @@ var package_default;
1078
1078
  var init_package = __esm(() => {
1079
1079
  package_default = {
1080
1080
  name: "@playcademy/sandbox",
1081
- version: "0.4.2-beta.2",
1081
+ version: "0.4.2-beta.3",
1082
1082
  description: "Local development server for Playcademy game development",
1083
1083
  type: "module",
1084
1084
  exports: {
@@ -11320,10 +11320,11 @@ var init_table6 = __esm(() => {
11320
11320
  });
11321
11321
 
11322
11322
  // ../data/src/domains/timeback/table.ts
11323
- var gameTimebackIntegrations, gameTimebackAssessmentTests;
11323
+ var gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications;
11324
11324
  var init_table7 = __esm(() => {
11325
11325
  init_pg_core();
11326
11326
  init_table5();
11327
+ init_table3();
11327
11328
  gameTimebackIntegrations = pgTable("game_timeback_integrations", {
11328
11329
  id: uuid("id").primaryKey().defaultRandom(),
11329
11330
  gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
@@ -11348,6 +11349,21 @@ var init_table7 = __esm(() => {
11348
11349
  }, (table3) => [
11349
11350
  uniqueIndex("game_timeback_assessment_tests_integration_qti_idx").on(table3.integrationId, table3.qtiTestIdentifier)
11350
11351
  ]);
11352
+ gameTimebackMetricDiscrepancyVerifications = pgTable("game_timeback_metric_discrepancy_verifications", {
11353
+ id: uuid("id").primaryKey().defaultRandom(),
11354
+ gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
11355
+ courseId: text("course_id").notNull(),
11356
+ studentId: text("student_id").notNull(),
11357
+ runId: uuid("run_id").notNull(),
11358
+ activityId: text("activity_id"),
11359
+ verifiedByUserId: text("verified_by_user_id").references(() => users.id, {
11360
+ onDelete: "set null"
11361
+ }),
11362
+ verifiedAt: timestamp("verified_at", { withTimezone: true }).notNull().defaultNow()
11363
+ }, (table3) => [
11364
+ uniqueIndex("game_timeback_metric_discrepancy_verifications_run_idx").on(table3.gameId, table3.courseId, table3.studentId, table3.runId),
11365
+ index("game_timeback_metric_discrepancy_verifications_course_idx").on(table3.gameId, table3.courseId, table3.verifiedAt)
11366
+ ]);
11351
11367
  });
11352
11368
 
11353
11369
  // ../data/src/tables.index.ts
@@ -11361,6 +11377,7 @@ __export(exports_tables_index, {
11361
11377
  games: () => games,
11362
11378
  gameVisibilityEnum: () => gameVisibilityEnum,
11363
11379
  gameTypeEnum: () => gameTypeEnum,
11380
+ gameTimebackMetricDiscrepancyVerifications: () => gameTimebackMetricDiscrepancyVerifications,
11364
11381
  gameTimebackIntegrations: () => gameTimebackIntegrations,
11365
11382
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
11366
11383
  gameScoresRelations: () => gameScoresRelations,
@@ -29364,6 +29381,75 @@ function formatDateYMDInTimezone(timeZone, date3 = new Date) {
29364
29381
  const d = parts2.find((p) => p.type === "day").value;
29365
29382
  return `${y}-${m}-${d}`;
29366
29383
  }
29384
+ function getUtcInstantForMidnight(date3, timeZone) {
29385
+ const parts2 = new Intl.DateTimeFormat("en-US", {
29386
+ timeZone,
29387
+ year: "numeric",
29388
+ month: "2-digit",
29389
+ day: "2-digit"
29390
+ }).formatToParts(date3).reduce((acc, p) => {
29391
+ if (p.type !== "literal") {
29392
+ acc[p.type] = p.value;
29393
+ }
29394
+ return acc;
29395
+ }, {});
29396
+ const year = Number(parts2.year);
29397
+ const month = Number(parts2.month);
29398
+ const day = Number(parts2.day);
29399
+ for (let dayOffset = -1;dayOffset <= 1; dayOffset++) {
29400
+ const testYear = year;
29401
+ const testMonth = month;
29402
+ const testDay = day + dayOffset;
29403
+ for (let utcHour = 0;utcHour < 24; utcHour++) {
29404
+ const testDate = new Date(Date.UTC(testYear, testMonth - 1, testDay, utcHour, 0, 0, 0));
29405
+ const testParts = new Intl.DateTimeFormat("en-US", {
29406
+ timeZone,
29407
+ year: "numeric",
29408
+ month: "2-digit",
29409
+ day: "2-digit",
29410
+ hour: "2-digit",
29411
+ minute: "2-digit",
29412
+ hour12: false
29413
+ }).formatToParts(testDate).reduce((acc, p) => {
29414
+ if (p.type !== "literal") {
29415
+ acc[p.type] = p.value;
29416
+ }
29417
+ return acc;
29418
+ }, {});
29419
+ const yearMatch = Number(testParts.year) === year;
29420
+ const monthMatch = Number(testParts.month) === month;
29421
+ const dayMatch = Number(testParts.day) === day;
29422
+ const hourValue = Number(testParts.hour);
29423
+ const hourMatch = hourValue === 0 || hourValue === 24;
29424
+ const minuteMatch = Number(testParts.minute) === 0;
29425
+ if (yearMatch && monthMatch && dayMatch && hourMatch && minuteMatch) {
29426
+ return testDate;
29427
+ }
29428
+ }
29429
+ }
29430
+ throw new Error(`Could not find midnight for ${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")} in timezone ${timeZone}`);
29431
+ }
29432
+ function getDayBoundariesInTimezone(date3, timezone) {
29433
+ const startOfDay = getUtcInstantForMidnight(date3, timezone);
29434
+ const formatter = new Intl.DateTimeFormat("en-US", {
29435
+ timeZone: timezone,
29436
+ year: "numeric",
29437
+ month: "2-digit",
29438
+ day: "2-digit"
29439
+ });
29440
+ const parts2 = formatter.formatToParts(date3).reduce((acc, p) => {
29441
+ if (p.type !== "literal") {
29442
+ acc[p.type] = p.value;
29443
+ }
29444
+ return acc;
29445
+ }, {});
29446
+ const year = Number(parts2.year);
29447
+ const month = Number(parts2.month);
29448
+ const day = Number(parts2.day);
29449
+ const nextDayNoon = new Date(Date.UTC(year, month - 1, day + 1, 12, 0, 0, 0));
29450
+ const endOfDay = getUtcInstantForMidnight(nextDayNoon, timezone);
29451
+ return { startOfDay, endOfDay };
29452
+ }
29367
29453
  // ../utils/src/url.ts
29368
29454
  function buildPath(path, params) {
29369
29455
  const url = new URL(path, "http://n");
@@ -29407,6 +29493,42 @@ function formatGradeLabel(grade) {
29407
29493
  }
29408
29494
  }
29409
29495
  }
29496
+ function isTimebackDiscrepancyQueueWindow(value) {
29497
+ return typeof value === "string" && TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES.has(value);
29498
+ }
29499
+ function isTimebackDiscrepancyQueueMetric(value) {
29500
+ return typeof value === "string" && TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES.has(value);
29501
+ }
29502
+ function parseTimebackDiscrepancyQueueWindow(value) {
29503
+ const window2 = value?.trim();
29504
+ return isTimebackDiscrepancyQueueWindow(window2) ? window2 : DEFAULT_TIMEBACK_DISCREPANCY_QUEUE_WINDOW;
29505
+ }
29506
+ function parseTimebackDiscrepancyQueueMetrics(values) {
29507
+ const metrics = [];
29508
+ const seen = new Set;
29509
+ for (const value of values) {
29510
+ const metric = value.trim();
29511
+ if (isTimebackDiscrepancyQueueMetric(metric) && !seen.has(metric)) {
29512
+ seen.add(metric);
29513
+ metrics.push(metric);
29514
+ }
29515
+ }
29516
+ return metrics;
29517
+ }
29518
+ var TIMEBACK_DISCREPANCY_QUEUE_WINDOWS, TIMEBACK_DISCREPANCY_QUEUE_METRICS, DEFAULT_TIMEBACK_DISCREPANCY_QUEUE_WINDOW = "this-week", TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES, TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES;
29519
+ var init_timeback3 = __esm(() => {
29520
+ TIMEBACK_DISCREPANCY_QUEUE_WINDOWS = [
29521
+ "today",
29522
+ "yesterday",
29523
+ "this-week",
29524
+ "last-week",
29525
+ "all",
29526
+ "custom"
29527
+ ];
29528
+ TIMEBACK_DISCREPANCY_QUEUE_METRICS = ["xp", "mastery", "time", "score"];
29529
+ TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_WINDOWS);
29530
+ TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_METRICS);
29531
+ });
29410
29532
 
29411
29533
  // ../../node_modules/.bun/drizzle-zod@0.7.1+e9f18f9688af15ce/node_modules/drizzle-zod/index.mjs
29412
29534
  function isColumnType(column2, columnTypes) {
@@ -29907,7 +30029,7 @@ function isValidAdminAttributionDate(value) {
29907
30029
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
29908
30030
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
29909
30031
  }
29910
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
30032
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29911
30033
  var init_schemas4 = __esm(() => {
29912
30034
  init_drizzle_zod();
29913
30035
  init_esm();
@@ -30148,6 +30270,11 @@ var init_schemas4 = __esm(() => {
30148
30270
  studentId: exports_external.string().min(1),
30149
30271
  enrollmentId: exports_external.string().min(1)
30150
30272
  });
30273
+ VerifyTimebackMetricDiscrepancyRequestSchema = exports_external.object({
30274
+ studentId: exports_external.string().min(1),
30275
+ runId: exports_external.string().uuid(),
30276
+ activityId: exports_external.string().min(1).optional()
30277
+ });
30151
30278
  InsertAssessmentTestSchema = createInsertSchema(gameTimebackAssessmentTests).omit({
30152
30279
  id: true,
30153
30280
  createdAt: true
@@ -30280,183 +30407,6 @@ var init_timeback_admin_util = __esm(() => {
30280
30407
  init_errors();
30281
30408
  });
30282
30409
 
30283
- // ../api-core/src/utils/timeback-game-metrics-comparison.util.ts
30284
- function createMetricRow(definition) {
30285
- const { gameValue, kind, metric, timebackValue, tolerance } = definition;
30286
- if (timebackValue === undefined && gameValue === undefined) {
30287
- return null;
30288
- }
30289
- if (gameValue === undefined) {
30290
- return {
30291
- metric,
30292
- kind,
30293
- status: "not_reported_by_game",
30294
- ...timebackValue !== undefined ? { timebackValue } : {}
30295
- };
30296
- }
30297
- if (timebackValue === undefined) {
30298
- return {
30299
- metric,
30300
- kind,
30301
- status: "not_recorded_by_timeback",
30302
- gameValue
30303
- };
30304
- }
30305
- const delta = gameValue - timebackValue;
30306
- const isDiscrepant = tolerance === 0 ? delta !== 0 : Math.abs(delta) >= tolerance;
30307
- return {
30308
- metric,
30309
- kind,
30310
- status: isDiscrepant ? "discrepant" : "matched",
30311
- timebackValue,
30312
- gameValue,
30313
- delta
30314
- };
30315
- }
30316
- function createRunComparison(activity, gameRun) {
30317
- const runId = activity.runId ?? "";
30318
- if (!gameRun) {
30319
- return {
30320
- runId,
30321
- status: "not_reported",
30322
- discrepancyCount: 0,
30323
- rows: []
30324
- };
30325
- }
30326
- const rows = [
30327
- createMetricRow({
30328
- metric: "xp",
30329
- kind: "number",
30330
- timebackValue: activity.xpDelta,
30331
- gameValue: gameRun.totalXp,
30332
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.xp
30333
- }),
30334
- createMetricRow({
30335
- metric: "mastery",
30336
- kind: "number",
30337
- timebackValue: activity.masteredUnitsDelta,
30338
- gameValue: gameRun.masteredUnits,
30339
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.mastery
30340
- }),
30341
- createMetricRow({
30342
- metric: "time",
30343
- kind: "time",
30344
- timebackValue: activity.timeDeltaSeconds,
30345
- gameValue: gameRun.activeTimeSeconds,
30346
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.time
30347
- }),
30348
- createMetricRow({
30349
- metric: "score",
30350
- kind: "percent",
30351
- timebackValue: activity.score,
30352
- gameValue: gameRun.score,
30353
- tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.score
30354
- })
30355
- ].filter((row) => row !== null);
30356
- const discrepancyCount = rows.filter((row) => row.status === "discrepant").length;
30357
- return {
30358
- runId,
30359
- status: discrepancyCount > 0 ? "discrepant" : "matched",
30360
- discrepancyCount,
30361
- rows
30362
- };
30363
- }
30364
- function summarizeGameRunMetricsComparison(comparison) {
30365
- return {
30366
- runId: comparison.runId,
30367
- status: comparison.status,
30368
- discrepancyCount: comparison.discrepancyCount,
30369
- ...comparison.reason ? { reason: comparison.reason } : {}
30370
- };
30371
- }
30372
- function buildGameRunMetricComparisons(activities, course, response) {
30373
- const activitiesWithRunIds = activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0);
30374
- const comparisons = new Map;
30375
- if (activitiesWithRunIds.length === 0) {
30376
- return comparisons;
30377
- }
30378
- if (!response.supported) {
30379
- for (const activity of activitiesWithRunIds) {
30380
- comparisons.set(activity.runId, {
30381
- runId: activity.runId,
30382
- status: "unavailable",
30383
- discrepancyCount: 0,
30384
- reason: response.reason,
30385
- rows: []
30386
- });
30387
- }
30388
- return comparisons;
30389
- }
30390
- const gameCourseMetrics = response.metrics.courses.find((gameCourse) => gameCourse.grade === course.grade && gameCourse.subject === course.subject);
30391
- const gameRunsById = new Map(gameCourseMetrics?.activities?.map((gameRun) => [gameRun.runId.toLowerCase(), gameRun]));
30392
- for (const activity of activitiesWithRunIds) {
30393
- comparisons.set(activity.runId, createRunComparison(activity, gameRunsById.get(activity.runId.toLowerCase())));
30394
- }
30395
- return comparisons;
30396
- }
30397
- var init_timeback_game_metrics_comparison_util = __esm(() => {
30398
- init_src();
30399
- });
30400
-
30401
- // ../api-core/src/utils/timeback-mastery-completion.util.ts
30402
- async function upsertMasteryCompletionEntry(params) {
30403
- const { client, courseId, studentId, appName, action } = params;
30404
- const ids = deriveSourcedIds(courseId);
30405
- const lineItemId = `${ids.course}-mastery-completion-assessment`;
30406
- const resultId = `${lineItemId}:${studentId}:completion`;
30407
- if (action === "complete") {
30408
- await client.oneroster.assessmentLineItems.findOrCreate(lineItemId, {
30409
- sourcedId: lineItemId,
30410
- title: "Mastery Completion",
30411
- status: ONEROSTER_STATUS.active,
30412
- course: { sourcedId: ids.course },
30413
- ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
30414
- });
30415
- await client.oneroster.assessmentResults.upsert(resultId, {
30416
- sourcedId: resultId,
30417
- status: ONEROSTER_STATUS.active,
30418
- assessmentLineItem: { sourcedId: lineItemId },
30419
- student: { sourcedId: studentId },
30420
- score: 100,
30421
- scoreDate: new Date().toISOString(),
30422
- scoreStatus: SCORE_STATUS.fullyGraded,
30423
- inProgress: "false",
30424
- metadata: {
30425
- isMasteryCompletion: true,
30426
- adminAction: true,
30427
- appName
30428
- }
30429
- });
30430
- } else {
30431
- try {
30432
- await client.oneroster.assessmentResults.upsert(resultId, {
30433
- sourcedId: resultId,
30434
- status: ONEROSTER_STATUS.active,
30435
- assessmentLineItem: { sourcedId: lineItemId },
30436
- student: { sourcedId: studentId },
30437
- score: 0,
30438
- scoreDate: new Date().toISOString(),
30439
- scoreStatus: SCORE_STATUS.notSubmitted,
30440
- inProgress: "true",
30441
- metadata: {
30442
- isMasteryCompletion: true,
30443
- adminAction: true,
30444
- appName
30445
- }
30446
- });
30447
- } catch {
30448
- logger16.debug("No completion entry to revoke", { studentId, courseId });
30449
- }
30450
- }
30451
- }
30452
- var logger16;
30453
- var init_timeback_mastery_completion_util = __esm(() => {
30454
- init_src2();
30455
- init_constants4();
30456
- init_utils6();
30457
- logger16 = log.scope("timeback-mastery-completion");
30458
- });
30459
-
30460
30410
  // ../api-core/src/utils/timeback.util.ts
30461
30411
  function isRecord2(value) {
30462
30412
  return typeof value === "object" && value !== null;
@@ -30604,6 +30554,13 @@ function groupCaliperEventsByRun(events) {
30604
30554
  }
30605
30555
  return groups;
30606
30556
  }
30557
+ function findCaliperEventGroupContainingExternalId(events, externalId) {
30558
+ const targetExternalId = externalId.trim();
30559
+ if (!targetExternalId) {
30560
+ return;
30561
+ }
30562
+ return [...groupCaliperEventsByRun(events).values()].find((group) => group.some((event) => event.externalId === targetExternalId));
30563
+ }
30607
30564
  function mapCaliperEventGroupToActivity(events, relevantCourseIds) {
30608
30565
  if (events.length === 0) {
30609
30566
  return null;
@@ -30777,6 +30734,371 @@ var init_timeback_util = __esm(() => {
30777
30734
  ]);
30778
30735
  });
30779
30736
 
30737
+ // ../api-core/src/utils/timeback-discrepancy-queue.util.ts
30738
+ function parseDateInputParts(value) {
30739
+ const parts2 = value.split("-");
30740
+ return {
30741
+ year: Number(parts2[0]),
30742
+ month: Number(parts2[1]),
30743
+ day: Number(parts2[2])
30744
+ };
30745
+ }
30746
+ function addDateInputDays(value, days) {
30747
+ const { year, month, day } = parseDateInputParts(value);
30748
+ const date3 = new Date(Date.UTC(year, month - 1, day + days, 12));
30749
+ return [
30750
+ String(date3.getUTCFullYear()),
30751
+ String(date3.getUTCMonth() + 1).padStart(2, "0"),
30752
+ String(date3.getUTCDate()).padStart(2, "0")
30753
+ ].join("-");
30754
+ }
30755
+ function getDateInputDayOfWeek(value) {
30756
+ const { year, month, day } = parseDateInputParts(value);
30757
+ return new Date(Date.UTC(year, month - 1, day, 12)).getUTCDay();
30758
+ }
30759
+ function getDateInputBoundary(value, boundary, timezone2) {
30760
+ const { year, month, day } = parseDateInputParts(value);
30761
+ const anchor = new Date(Date.UTC(year, month - 1, day, 12));
30762
+ const { startOfDay, endOfDay } = getDayBoundariesInTimezone(anchor, timezone2);
30763
+ return boundary === "end" ? new Date(endOfDay.getTime() - 1) : startOfDay;
30764
+ }
30765
+ function getWeekStartDateInput(date3, timezone2) {
30766
+ const today = formatDateYMDInTimezone(timezone2, date3);
30767
+ const dayOfWeek = getDateInputDayOfWeek(today);
30768
+ return addDateInputDays(today, -dayOfWeek);
30769
+ }
30770
+ function parseDateBoundary(value, boundary, timezone2) {
30771
+ if (!value) {
30772
+ return null;
30773
+ }
30774
+ if (DATE_INPUT_RE.test(value)) {
30775
+ return getDateInputBoundary(value, boundary, timezone2);
30776
+ }
30777
+ const parsed = new Date(value);
30778
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
30779
+ }
30780
+ function getTimebackDiscrepancyQueueDateRange(options) {
30781
+ const now2 = options.now ?? new Date;
30782
+ const timezone2 = options.timezone ?? "UTC";
30783
+ if (options.window === "all") {
30784
+ return {};
30785
+ }
30786
+ if (options.window === "today") {
30787
+ return { startDate: getDayBoundariesInTimezone(now2, timezone2).startOfDay.toISOString() };
30788
+ }
30789
+ if (options.window === "yesterday") {
30790
+ const yesterdayInput = addDateInputDays(formatDateYMDInTimezone(timezone2, now2), -1);
30791
+ return {
30792
+ startDate: getDateInputBoundary(yesterdayInput, "start", timezone2).toISOString(),
30793
+ endDate: getDateInputBoundary(yesterdayInput, "end", timezone2).toISOString()
30794
+ };
30795
+ }
30796
+ if (options.window === "this-week") {
30797
+ return {
30798
+ startDate: getDateInputBoundary(getWeekStartDateInput(now2, timezone2), "start", timezone2).toISOString()
30799
+ };
30800
+ }
30801
+ if (options.window === "last-week") {
30802
+ const thisWeekStartInput = getWeekStartDateInput(now2, timezone2);
30803
+ const lastWeekStartInput = addDateInputDays(thisWeekStartInput, -7);
30804
+ const lastWeekEndInput = addDateInputDays(thisWeekStartInput, -1);
30805
+ return {
30806
+ startDate: getDateInputBoundary(lastWeekStartInput, "start", timezone2).toISOString(),
30807
+ endDate: getDateInputBoundary(lastWeekEndInput, "end", timezone2).toISOString()
30808
+ };
30809
+ }
30810
+ const startDate = parseDateBoundary(options.startDate, "start", timezone2);
30811
+ const endDate = parseDateBoundary(options.endDate, "end", timezone2);
30812
+ return {
30813
+ ...startDate ? { startDate: startDate.toISOString() } : {},
30814
+ ...endDate ? { endDate: endDate.toISOString() } : {}
30815
+ };
30816
+ }
30817
+ function getCaliperActorSourcedId(event) {
30818
+ const actorId = typeof event.actor.id === "string" ? event.actor.id.trim() : "";
30819
+ if (!actorId) {
30820
+ return;
30821
+ }
30822
+ const normalized = actorId.replace(/\/$/, "");
30823
+ const segment = normalized.split("/").at(-1);
30824
+ if (!segment) {
30825
+ return;
30826
+ }
30827
+ try {
30828
+ return decodeURIComponent(segment);
30829
+ } catch {
30830
+ return segment;
30831
+ }
30832
+ }
30833
+ function getTimebackDiscrepancyVerificationKey(params) {
30834
+ return `${params.studentId}:${params.runId.toLowerCase()}`;
30835
+ }
30836
+ function getTimebackMetricDiscrepancyVerificationForActivity(params) {
30837
+ if (params.comparison?.status !== "discrepant" || !params.activity.runId) {
30838
+ return;
30839
+ }
30840
+ return params.verificationsByKey.get(getTimebackDiscrepancyVerificationKey({
30841
+ studentId: params.studentId,
30842
+ runId: params.activity.runId
30843
+ }));
30844
+ }
30845
+ function mapCaliperEventsToStudentGameplayActivities(events, relevantCourseIds) {
30846
+ if (relevantCourseIds.size === 0) {
30847
+ return [];
30848
+ }
30849
+ const gameplayEventsByStudent = new Map;
30850
+ for (const event of events) {
30851
+ const isGameplayEvent = event.type === "ActivityEvent" || event.type === "TimeSpentEvent";
30852
+ const studentId = isGameplayEvent && !isCaliperRemediationOrCompletionEvent(event) ? getCaliperActorSourcedId(event) : undefined;
30853
+ if (studentId) {
30854
+ const existing = gameplayEventsByStudent.get(studentId);
30855
+ if (existing) {
30856
+ existing.push(event);
30857
+ } else {
30858
+ gameplayEventsByStudent.set(studentId, [event]);
30859
+ }
30860
+ }
30861
+ }
30862
+ const groups = [];
30863
+ for (const [studentId, studentEvents] of gameplayEventsByStudent) {
30864
+ const activities = [...groupCaliperEventsByRun(studentEvents).values()].map((group) => mapCaliperEventGroupToActivity(group, relevantCourseIds)).filter((activity) => Boolean(activity)).toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt));
30865
+ if (activities.length > 0) {
30866
+ groups.push({ studentId, activities });
30867
+ }
30868
+ }
30869
+ return groups.toSorted((a, b) => {
30870
+ const aLatest = a.activities[0]?.occurredAt ?? "";
30871
+ const bLatest = b.activities[0]?.occurredAt ?? "";
30872
+ return bLatest.localeCompare(aLatest);
30873
+ });
30874
+ }
30875
+ function mapCaliperEventsToCompletedStudentGameplayActivities(events, relevantCourseIds, options = {}) {
30876
+ const runIds = options.runIds ? new Set([...options.runIds].map((runId) => runId.toLowerCase())) : undefined;
30877
+ return mapCaliperEventsToStudentGameplayActivities(events, relevantCourseIds).filter((group) => !options.studentId || group.studentId === options.studentId).map((group) => ({
30878
+ studentId: group.studentId,
30879
+ activities: group.activities.filter((activity) => {
30880
+ const runId = activity.runId?.toLowerCase();
30881
+ return activity.kind === "activity" && runId !== undefined && isValidUUID(runId) && (!runIds || runIds.has(runId));
30882
+ })
30883
+ })).filter((group) => group.activities.length > 0);
30884
+ }
30885
+ function getTimebackActivityRunIds(groups) {
30886
+ const runIds = [];
30887
+ const seen = new Set;
30888
+ for (const group of groups) {
30889
+ for (const activity of group.activities) {
30890
+ const runId = activity.runId?.toLowerCase();
30891
+ if (runId && isValidUUID(runId) && !seen.has(runId)) {
30892
+ seen.add(runId);
30893
+ runIds.push(runId);
30894
+ }
30895
+ }
30896
+ }
30897
+ return runIds;
30898
+ }
30899
+ function mergeHydratedMetricDiscrepancyQueueItems(options) {
30900
+ const hydratedRunIds = new Set([...options.hydratedRunIds].map((runId) => runId.toLowerCase()));
30901
+ const hydratedItems = options.hydratedItems.map((item) => ({
30902
+ ...item,
30903
+ comparisonSource: "hydrated"
30904
+ }));
30905
+ const preliminaryFallbackItems = options.preliminaryItems.filter((item) => {
30906
+ const runId = item.activity.runId?.toLowerCase();
30907
+ return runId === undefined || !hydratedRunIds.has(runId);
30908
+ }).map((item) => ({
30909
+ ...item,
30910
+ comparisonSource: "preliminary"
30911
+ }));
30912
+ return [...hydratedItems, ...preliminaryFallbackItems].toSorted((a, b) => b.activity.occurredAt.localeCompare(a.activity.occurredAt));
30913
+ }
30914
+ function selectTimebackMetricDiscrepancyQueueItems(candidates, options) {
30915
+ const discrepancyMetricScopesSet = new Set(options.discrepancyMetricScopes);
30916
+ return candidates.filter((candidate) => candidate.activity.kind === "activity").filter((candidate) => candidate.gameMetricsComparison.status === "discrepant").filter((candidate) => !options.studentId || candidate.student.studentId === options.studentId).filter((candidate) => discrepancyMetricScopesSet.size === 0 || candidate.gameMetricsComparison.rows.some((row) => row.status === "discrepant" && discrepancyMetricScopesSet.has(row.metric))).filter((candidate) => options.includeVerified || !candidate.verification).toSorted((a, b) => b.activity.occurredAt.localeCompare(a.activity.occurredAt));
30917
+ }
30918
+ var DATE_INPUT_RE;
30919
+ var init_timeback_discrepancy_queue_util = __esm(() => {
30920
+ init_src4();
30921
+ init_timeback_util();
30922
+ DATE_INPUT_RE = /^\d{4}-\d{2}-\d{2}$/;
30923
+ });
30924
+
30925
+ // ../api-core/src/utils/timeback-game-metrics-comparison.util.ts
30926
+ function createMetricRow(definition) {
30927
+ const { gameValue, kind, metric, timebackValue, tolerance } = definition;
30928
+ if (timebackValue === undefined && gameValue === undefined) {
30929
+ return null;
30930
+ }
30931
+ if (gameValue === undefined) {
30932
+ return {
30933
+ metric,
30934
+ kind,
30935
+ status: "not_reported_by_game",
30936
+ ...timebackValue !== undefined ? { timebackValue } : {}
30937
+ };
30938
+ }
30939
+ if (timebackValue === undefined) {
30940
+ return {
30941
+ metric,
30942
+ kind,
30943
+ status: "not_recorded_by_timeback",
30944
+ gameValue
30945
+ };
30946
+ }
30947
+ const delta = gameValue - timebackValue;
30948
+ const isDiscrepant = tolerance === 0 ? delta !== 0 : Math.abs(delta) >= tolerance;
30949
+ return {
30950
+ metric,
30951
+ kind,
30952
+ status: isDiscrepant ? "discrepant" : "matched",
30953
+ timebackValue,
30954
+ gameValue,
30955
+ delta
30956
+ };
30957
+ }
30958
+ function createRunComparison(activity, gameRun) {
30959
+ const runId = activity.runId ?? "";
30960
+ if (!gameRun) {
30961
+ return {
30962
+ runId,
30963
+ status: "not_reported",
30964
+ discrepancyCount: 0,
30965
+ rows: []
30966
+ };
30967
+ }
30968
+ const rows = [
30969
+ createMetricRow({
30970
+ metric: "xp",
30971
+ kind: "number",
30972
+ timebackValue: activity.xpDelta,
30973
+ gameValue: gameRun.totalXp,
30974
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.xp
30975
+ }),
30976
+ createMetricRow({
30977
+ metric: "time",
30978
+ kind: "time",
30979
+ timebackValue: activity.timeDeltaSeconds,
30980
+ gameValue: gameRun.activeTimeSeconds,
30981
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.time
30982
+ }),
30983
+ createMetricRow({
30984
+ metric: "score",
30985
+ kind: "percent",
30986
+ timebackValue: activity.score,
30987
+ gameValue: gameRun.score,
30988
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.score
30989
+ }),
30990
+ createMetricRow({
30991
+ metric: "mastery",
30992
+ kind: "number",
30993
+ timebackValue: activity.masteredUnitsDelta ?? 0,
30994
+ gameValue: gameRun.masteredUnits,
30995
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.mastery
30996
+ })
30997
+ ].filter((row) => row !== null);
30998
+ const discrepancyCount = rows.filter((row) => row.status === "discrepant").length;
30999
+ return {
31000
+ runId,
31001
+ status: discrepancyCount > 0 ? "discrepant" : "matched",
31002
+ discrepancyCount,
31003
+ rows
31004
+ };
31005
+ }
31006
+ function summarizeGameRunMetricsComparison(comparison) {
31007
+ return {
31008
+ runId: comparison.runId,
31009
+ status: comparison.status,
31010
+ discrepancyCount: comparison.discrepancyCount,
31011
+ ...comparison.reason ? { reason: comparison.reason } : {}
31012
+ };
31013
+ }
31014
+ function buildGameRunMetricComparisons(activities, course, response) {
31015
+ const activitiesWithRunIds = activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0);
31016
+ const comparisons = new Map;
31017
+ if (activitiesWithRunIds.length === 0) {
31018
+ return comparisons;
31019
+ }
31020
+ if (!response.supported) {
31021
+ for (const activity of activitiesWithRunIds) {
31022
+ comparisons.set(activity.runId, {
31023
+ runId: activity.runId,
31024
+ status: "unavailable",
31025
+ discrepancyCount: 0,
31026
+ reason: response.reason,
31027
+ rows: []
31028
+ });
31029
+ }
31030
+ return comparisons;
31031
+ }
31032
+ const gameCourseMetrics = response.metrics.courses.find((gameCourse) => gameCourse.grade === course.grade && gameCourse.subject === course.subject);
31033
+ const gameRunsById = new Map(gameCourseMetrics?.activities?.map((gameRun) => [gameRun.runId.toLowerCase(), gameRun]));
31034
+ for (const activity of activitiesWithRunIds) {
31035
+ comparisons.set(activity.runId, createRunComparison(activity, gameRunsById.get(activity.runId.toLowerCase())));
31036
+ }
31037
+ return comparisons;
31038
+ }
31039
+ var init_timeback_game_metrics_comparison_util = __esm(() => {
31040
+ init_src();
31041
+ });
31042
+
31043
+ // ../api-core/src/utils/timeback-mastery-completion.util.ts
31044
+ async function upsertMasteryCompletionEntry(params) {
31045
+ const { client, courseId, studentId, appName, action } = params;
31046
+ const ids = deriveSourcedIds(courseId);
31047
+ const lineItemId = `${ids.course}-mastery-completion-assessment`;
31048
+ const resultId = `${lineItemId}:${studentId}:completion`;
31049
+ if (action === "complete") {
31050
+ await client.oneroster.assessmentLineItems.findOrCreate(lineItemId, {
31051
+ sourcedId: lineItemId,
31052
+ title: "Mastery Completion",
31053
+ status: ONEROSTER_STATUS.active,
31054
+ course: { sourcedId: ids.course },
31055
+ ...ids.componentResource ? { componentResource: { sourcedId: ids.componentResource } } : {}
31056
+ });
31057
+ await client.oneroster.assessmentResults.upsert(resultId, {
31058
+ sourcedId: resultId,
31059
+ status: ONEROSTER_STATUS.active,
31060
+ assessmentLineItem: { sourcedId: lineItemId },
31061
+ student: { sourcedId: studentId },
31062
+ score: 100,
31063
+ scoreDate: new Date().toISOString(),
31064
+ scoreStatus: SCORE_STATUS.fullyGraded,
31065
+ inProgress: "false",
31066
+ metadata: {
31067
+ isMasteryCompletion: true,
31068
+ adminAction: true,
31069
+ appName
31070
+ }
31071
+ });
31072
+ } else {
31073
+ try {
31074
+ await client.oneroster.assessmentResults.upsert(resultId, {
31075
+ sourcedId: resultId,
31076
+ status: ONEROSTER_STATUS.active,
31077
+ assessmentLineItem: { sourcedId: lineItemId },
31078
+ student: { sourcedId: studentId },
31079
+ score: 0,
31080
+ scoreDate: new Date().toISOString(),
31081
+ scoreStatus: SCORE_STATUS.notSubmitted,
31082
+ inProgress: "true",
31083
+ metadata: {
31084
+ isMasteryCompletion: true,
31085
+ adminAction: true,
31086
+ appName
31087
+ }
31088
+ });
31089
+ } catch {
31090
+ logger16.debug("No completion entry to revoke", { studentId, courseId });
31091
+ }
31092
+ }
31093
+ }
31094
+ var logger16;
31095
+ var init_timeback_mastery_completion_util = __esm(() => {
31096
+ init_src2();
31097
+ init_constants4();
31098
+ init_utils6();
31099
+ logger16 = log.scope("timeback-mastery-completion");
31100
+ });
31101
+
30780
31102
  // ../api-core/src/services/timeback-admin.service.ts
30781
31103
  class TimebackAdminService {
30782
31104
  deps;
@@ -30785,6 +31107,10 @@ class TimebackAdminService {
30785
31107
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
30786
31108
  static MAX_STUDENT_ACTIVITY_OFFSET = 1000;
30787
31109
  static MAX_RECENT_ACTIVITY_EVENT_FETCH = 4000;
31110
+ static DISCREPANCY_QUEUE_EVENT_FETCH_LIMIT = 4000;
31111
+ static DISCREPANCY_QUEUE_RUN_EVENT_FETCH_LIMIT = 4000;
31112
+ static DISCREPANCY_QUEUE_RUN_HYDRATION_CONCURRENCY = 4;
31113
+ static DISCREPANCY_QUEUE_COMPARISON_CONCURRENCY = 4;
30788
31114
  static ANALYTICS_CONCURRENCY = 8;
30789
31115
  static MASTERABLE_UNITS_CONCURRENCY = 4;
30790
31116
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
@@ -31032,16 +31358,53 @@ class TimebackAdminService {
31032
31358
  }));
31033
31359
  return comparisons;
31034
31360
  }
31361
+ async getMetricDiscrepancyVerificationsByKey(options) {
31362
+ const runIds = TimebackAdminService.normalizeRunIds(options.runIds);
31363
+ if (runIds.length === 0) {
31364
+ return new Map;
31365
+ }
31366
+ const conditions2 = [
31367
+ eq(gameTimebackMetricDiscrepancyVerifications.gameId, options.gameId),
31368
+ eq(gameTimebackMetricDiscrepancyVerifications.courseId, options.courseId),
31369
+ inArray(gameTimebackMetricDiscrepancyVerifications.runId, runIds)
31370
+ ];
31371
+ if (options.studentId) {
31372
+ conditions2.push(eq(gameTimebackMetricDiscrepancyVerifications.studentId, options.studentId));
31373
+ }
31374
+ const rows = await this.deps.db.query.gameTimebackMetricDiscrepancyVerifications.findMany({
31375
+ where: and(...conditions2)
31376
+ });
31377
+ return new Map(rows.map((row) => [
31378
+ getTimebackDiscrepancyVerificationKey({
31379
+ studentId: row.studentId,
31380
+ runId: row.runId
31381
+ }),
31382
+ TimebackAdminService.mapMetricDiscrepancyVerification(row)
31383
+ ]));
31384
+ }
31035
31385
  async attachGameMetricSummariesToActivities(user, options) {
31036
31386
  const comparisons = await this.getGameMetricComparisonsForActivities(user, options);
31037
31387
  if (comparisons.size === 0) {
31038
31388
  return [...options.activities];
31039
31389
  }
31390
+ const verificationsByKey = await this.getMetricDiscrepancyVerificationsByKey({
31391
+ gameId: options.gameId,
31392
+ courseId: options.courseId,
31393
+ studentId: options.studentId,
31394
+ runIds: [...comparisons.values()].filter((comparison) => comparison.status === "discrepant").map((comparison) => comparison.runId)
31395
+ });
31040
31396
  return options.activities.map((activity) => {
31041
31397
  const comparison = activity.runId ? comparisons.get(activity.runId) : undefined;
31398
+ const verification2 = getTimebackMetricDiscrepancyVerificationForActivity({
31399
+ studentId: options.studentId,
31400
+ activity,
31401
+ comparison,
31402
+ verificationsByKey
31403
+ });
31042
31404
  return comparison ? {
31043
31405
  ...activity,
31044
- gameMetricsComparison: summarizeGameRunMetricsComparison(comparison)
31406
+ gameMetricsComparison: summarizeGameRunMetricsComparison(comparison),
31407
+ ...verification2 ? { gameMetricsVerification: verification2 } : {}
31045
31408
  } : activity;
31046
31409
  });
31047
31410
  }
@@ -31132,6 +31495,165 @@ class TimebackAdminService {
31132
31495
  });
31133
31496
  return events;
31134
31497
  }
31498
+ async fetchCaliperEventsForGame(client, source, options) {
31499
+ const actorId = options.studentId ? `${client.getBaseUrl().replace(/\/$/, "")}/ims/oneroster/rostering/v1p2/users/${options.studentId}` : undefined;
31500
+ return client.caliper.events.list({
31501
+ limit: options.limit,
31502
+ offset: options.offset,
31503
+ ...actorId ? { actorId } : {},
31504
+ ...options.startDate ? { startDate: options.startDate } : {},
31505
+ ...options.endDate ? { endDate: options.endDate } : {},
31506
+ ...options.sessionId ? { sessionId: options.sessionId } : {},
31507
+ ...source.sourceMode === "production" ? { sensor: source.sensorUrl } : {},
31508
+ extensions: {
31509
+ gameId: source.gameId
31510
+ }
31511
+ });
31512
+ }
31513
+ static getActivityRunOwners(activityGroups) {
31514
+ const runOwnersById = new Map;
31515
+ for (const group of activityGroups) {
31516
+ for (const activity of group.activities) {
31517
+ const runId = activity.runId?.toLowerCase();
31518
+ if (runId && !runOwnersById.has(runId)) {
31519
+ runOwnersById.set(runId, group.studentId);
31520
+ }
31521
+ }
31522
+ }
31523
+ return runOwnersById;
31524
+ }
31525
+ static dedupeCaliperEvents(events) {
31526
+ const deduped = [];
31527
+ const seen = new Set;
31528
+ for (const event of events) {
31529
+ const key = `${event.id}:${event.externalId}`;
31530
+ if (!seen.has(key)) {
31531
+ seen.add(key);
31532
+ deduped.push(event);
31533
+ }
31534
+ }
31535
+ return deduped;
31536
+ }
31537
+ async fetchCaliperEventsForRun(client, source, options) {
31538
+ const runId = options.runId.toLowerCase();
31539
+ const events = [];
31540
+ let offset = 0;
31541
+ while (true) {
31542
+ const result = await this.fetchCaliperEventsForGame(client, source, {
31543
+ limit: TimebackAdminService.DISCREPANCY_QUEUE_RUN_EVENT_FETCH_LIMIT,
31544
+ offset,
31545
+ studentId: options.studentId
31546
+ });
31547
+ const runEvents = result.events.filter((event) => getCanonicalRunId(event.session)?.toLowerCase() === runId);
31548
+ events.push(...runEvents);
31549
+ const pageStep = result.pagination.limit > 0 ? result.pagination.limit : result.events.length;
31550
+ const nextOffset = offset + pageStep;
31551
+ if (pageStep === 0 || result.events.length === 0 || nextOffset >= result.pagination.total) {
31552
+ break;
31553
+ }
31554
+ offset = nextOffset;
31555
+ }
31556
+ return TimebackAdminService.dedupeCaliperEvents(events);
31557
+ }
31558
+ async hydrateDiscrepancyQueueRunEvents(client, source, options) {
31559
+ if (options.runOwnersById.size === 0) {
31560
+ return { events: [], runIds: new Set };
31561
+ }
31562
+ const hydratedRuns = await TimebackAdminService.runWithConcurrency([...options.runOwnersById.entries()], TimebackAdminService.DISCREPANCY_QUEUE_RUN_HYDRATION_CONCURRENCY, async ([runId, studentId]) => {
31563
+ try {
31564
+ return {
31565
+ runId,
31566
+ events: await this.fetchCaliperEventsForRun(client, source, {
31567
+ runId,
31568
+ studentId
31569
+ })
31570
+ };
31571
+ } catch (error) {
31572
+ logger17.warn("Failed to hydrate Caliper events for discrepancy queue run", {
31573
+ runId,
31574
+ studentId,
31575
+ error: error instanceof Error ? error.message : String(error)
31576
+ });
31577
+ return { runId, events: [] };
31578
+ }
31579
+ });
31580
+ const hydratedRunIds = new Set(hydratedRuns.filter((result) => result.events.length > 0).map((result) => result.runId.toLowerCase()));
31581
+ const baseEventsForHydratedRuns = options.baseEvents.filter((event) => {
31582
+ const runId = getCanonicalRunId(event.session)?.toLowerCase();
31583
+ return runId !== undefined && hydratedRunIds.has(runId);
31584
+ });
31585
+ return {
31586
+ events: TimebackAdminService.dedupeCaliperEvents([
31587
+ ...baseEventsForHydratedRuns,
31588
+ ...hydratedRuns.flatMap((result) => result.events)
31589
+ ]),
31590
+ runIds: hydratedRunIds
31591
+ };
31592
+ }
31593
+ async buildMetricDiscrepancyQueueCandidates(user, options) {
31594
+ const comparisonResults = await TimebackAdminService.runWithConcurrency(options.activityGroups, TimebackAdminService.DISCREPANCY_QUEUE_COMPARISON_CONCURRENCY, async (group) => {
31595
+ try {
31596
+ return {
31597
+ group,
31598
+ comparisons: await this.getGameMetricComparisonsForActivities(user, {
31599
+ gameId: options.gameId,
31600
+ studentId: group.studentId,
31601
+ course: options.course,
31602
+ activities: group.activities,
31603
+ timeoutMs: TimebackAdminService.GAME_METRICS_LIST_FETCH_TIMEOUT_MS
31604
+ })
31605
+ };
31606
+ } catch (error) {
31607
+ logger17.warn("Failed to compare game metrics for discrepancy queue student", {
31608
+ gameId: options.gameId,
31609
+ courseId: options.courseId,
31610
+ studentId: group.studentId,
31611
+ error: error instanceof Error ? error.message : String(error)
31612
+ });
31613
+ return { group, comparisons: new Map };
31614
+ }
31615
+ });
31616
+ return comparisonResults.flatMap(({ group, comparisons }) => {
31617
+ const student = options.studentsById.get(group.studentId) ?? {
31618
+ studentId: group.studentId,
31619
+ name: "No name specified",
31620
+ email: null
31621
+ };
31622
+ return group.activities.flatMap((activity) => {
31623
+ const runId = activity.runId;
31624
+ if (!runId) {
31625
+ return [];
31626
+ }
31627
+ const gameMetricsComparison = comparisons.get(runId);
31628
+ if (!gameMetricsComparison) {
31629
+ return [];
31630
+ }
31631
+ const verification2 = options.verificationsByKey.get(getTimebackDiscrepancyVerificationKey({
31632
+ studentId: group.studentId,
31633
+ runId
31634
+ }));
31635
+ return [
31636
+ {
31637
+ student,
31638
+ activity,
31639
+ gameMetricsComparison,
31640
+ ...verification2 ? { verification: verification2 } : {}
31641
+ }
31642
+ ];
31643
+ });
31644
+ });
31645
+ }
31646
+ static mapMetricDiscrepancyVerification(row) {
31647
+ return {
31648
+ gameId: row.gameId,
31649
+ courseId: row.courseId,
31650
+ studentId: row.studentId,
31651
+ runId: row.runId,
31652
+ activityId: row.activityId,
31653
+ verifiedAt: row.verifiedAt.toISOString(),
31654
+ verifiedByUserId: row.verifiedByUserId
31655
+ };
31656
+ }
31135
31657
  async listRecentActivityForStudent(client, studentId, source, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
31136
31658
  if (relevantCourseIds.size === 0) {
31137
31659
  return [];
@@ -31381,12 +31903,215 @@ class TimebackAdminService {
31381
31903
  const activitiesWithGameMetrics = await this.attachGameMetricSummariesToActivities(user, {
31382
31904
  gameId,
31383
31905
  studentId,
31906
+ courseId,
31384
31907
  course: { grade: integration.grade, subject: integration.subject },
31385
31908
  activities,
31386
31909
  timeoutMs: TimebackAdminService.GAME_METRICS_LIST_FETCH_TIMEOUT_MS
31387
31910
  });
31388
31911
  return { activities: activitiesWithGameMetrics, hasMore };
31389
31912
  }
31913
+ async listMetricDiscrepancies(user, options) {
31914
+ const { gameId, courseId, window: window2, includeVerified } = options;
31915
+ const selectedStudentId = options.studentId?.trim() || undefined;
31916
+ const client = this.requireClient();
31917
+ const dateRange = getTimebackDiscrepancyQueueDateRange({
31918
+ window: window2,
31919
+ startDate: options.startDate,
31920
+ endDate: options.endDate,
31921
+ timezone: PLATFORM_TIMEZONE
31922
+ });
31923
+ const safeEventOffset = Math.max(0, Math.trunc(options.eventOffset));
31924
+ await this.deps.validateGameManagementAccess(user, gameId);
31925
+ const [integration, gameSource, roster] = await Promise.all([
31926
+ this.deps.db.query.gameTimebackIntegrations.findFirst({
31927
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
31928
+ }),
31929
+ this.getGameActivitySource(gameId),
31930
+ client.oneroster.enrollments.listByCourse(courseId, {
31931
+ role: "student",
31932
+ includeInactive: true
31933
+ })
31934
+ ]);
31935
+ if (!integration) {
31936
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
31937
+ }
31938
+ const studentsById = new Map;
31939
+ for (const entry of roster) {
31940
+ const studentId = entry.enrollment.user.sourcedId;
31941
+ if (studentId && !studentsById.has(studentId)) {
31942
+ const name3 = entry.user ? `${entry.user.givenName} ${entry.user.familyName}`.trim() || "No name specified" : "No name specified";
31943
+ studentsById.set(studentId, {
31944
+ studentId,
31945
+ name: name3,
31946
+ email: entry.user?.email || null
31947
+ });
31948
+ }
31949
+ }
31950
+ const students = [...studentsById.values()].toSorted((a, b) => a.name.localeCompare(b.name) || a.studentId.localeCompare(b.studentId));
31951
+ if (selectedStudentId && !studentsById.has(selectedStudentId)) {
31952
+ students.push({
31953
+ studentId: selectedStudentId,
31954
+ name: selectedStudentId,
31955
+ email: null
31956
+ });
31957
+ }
31958
+ const eventResult = await this.fetchCaliperEventsForGame(client, gameSource, {
31959
+ limit: TimebackAdminService.DISCREPANCY_QUEUE_EVENT_FETCH_LIMIT,
31960
+ offset: safeEventOffset,
31961
+ ...selectedStudentId ? { studentId: selectedStudentId } : {},
31962
+ ...dateRange
31963
+ });
31964
+ const { events, pagination } = eventResult;
31965
+ let effectiveEventPageLimit = pagination.limit;
31966
+ if (effectiveEventPageLimit <= 0) {
31967
+ effectiveEventPageLimit = events.length > 0 ? events.length : TimebackAdminService.DISCREPANCY_QUEUE_EVENT_FETCH_LIMIT;
31968
+ }
31969
+ const relevantCourseIds = new Set([courseId]);
31970
+ const preliminaryActivityGroups = mapCaliperEventsToCompletedStudentGameplayActivities(events, relevantCourseIds, selectedStudentId ? { studentId: selectedStudentId } : {});
31971
+ const preliminaryRunIds = getTimebackActivityRunIds(preliminaryActivityGroups);
31972
+ const verificationsByKey = await this.getMetricDiscrepancyVerificationsByKey({
31973
+ gameId,
31974
+ courseId,
31975
+ runIds: preliminaryRunIds
31976
+ });
31977
+ const queueFilterOptions = {
31978
+ ...selectedStudentId ? { studentId: selectedStudentId } : {},
31979
+ discrepancyMetricScopes: options.discrepancyMetricScopes,
31980
+ includeVerified
31981
+ };
31982
+ const preliminaryCandidates = await this.buildMetricDiscrepancyQueueCandidates(user, {
31983
+ gameId,
31984
+ courseId,
31985
+ course: { grade: integration.grade, subject: integration.subject },
31986
+ activityGroups: preliminaryActivityGroups,
31987
+ studentsById,
31988
+ verificationsByKey
31989
+ });
31990
+ const preliminaryItems = selectTimebackMetricDiscrepancyQueueItems(preliminaryCandidates, queueFilterOptions);
31991
+ const runOwnersById = TimebackAdminService.getActivityRunOwners(preliminaryItems.map((item) => ({
31992
+ studentId: item.student.studentId,
31993
+ activities: [item.activity]
31994
+ })));
31995
+ const hydratedRunEvents = await this.hydrateDiscrepancyQueueRunEvents(client, gameSource, {
31996
+ baseEvents: events,
31997
+ runOwnersById
31998
+ });
31999
+ const finalActivityGroups = mapCaliperEventsToCompletedStudentGameplayActivities(hydratedRunEvents.events, relevantCourseIds, {
32000
+ ...selectedStudentId ? { studentId: selectedStudentId } : {},
32001
+ runIds: hydratedRunEvents.runIds
32002
+ });
32003
+ const finalCandidates = await this.buildMetricDiscrepancyQueueCandidates(user, {
32004
+ gameId,
32005
+ courseId,
32006
+ course: { grade: integration.grade, subject: integration.subject },
32007
+ activityGroups: finalActivityGroups,
32008
+ studentsById,
32009
+ verificationsByKey
32010
+ });
32011
+ const hydratedItems = selectTimebackMetricDiscrepancyQueueItems(finalCandidates, queueFilterOptions);
32012
+ const items = mergeHydratedMetricDiscrepancyQueueItems({
32013
+ preliminaryItems,
32014
+ hydratedItems,
32015
+ hydratedRunIds: hydratedRunEvents.runIds
32016
+ });
32017
+ const eventPage = {
32018
+ offset: safeEventOffset,
32019
+ limit: effectiveEventPageLimit,
32020
+ total: pagination.total,
32021
+ hasPrevious: safeEventOffset > 0,
32022
+ hasNext: safeEventOffset + effectiveEventPageLimit < pagination.total
32023
+ };
32024
+ return {
32025
+ gameId,
32026
+ courseId,
32027
+ window: window2,
32028
+ dateRange,
32029
+ ...selectedStudentId ? { studentId: selectedStudentId } : {},
32030
+ students,
32031
+ discrepancyMetricScopes: [...options.discrepancyMetricScopes ?? []],
32032
+ eventPage,
32033
+ includeVerified,
32034
+ items
32035
+ };
32036
+ }
32037
+ async verifyMetricDiscrepancy(user, options) {
32038
+ const { gameId, courseId, data } = options;
32039
+ const client = this.requireClient();
32040
+ await this.deps.validateDeveloperAccess(user, gameId);
32041
+ const [integration, gameSource] = await Promise.all([
32042
+ this.deps.db.query.gameTimebackIntegrations.findFirst({
32043
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
32044
+ }),
32045
+ this.getGameActivitySource(gameId)
32046
+ ]);
32047
+ if (!integration) {
32048
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
32049
+ }
32050
+ await this.assertStudentHasEnrollmentInCourse(client, data.studentId, courseId);
32051
+ const runId = data.runId.toLowerCase();
32052
+ let runEvents;
32053
+ try {
32054
+ runEvents = await this.fetchCaliperEventsForRun(client, gameSource, {
32055
+ runId,
32056
+ studentId: data.studentId
32057
+ });
32058
+ } catch (error) {
32059
+ logger17.warn("Failed to hydrate Caliper events for discrepancy verification", {
32060
+ gameId,
32061
+ courseId,
32062
+ studentId: data.studentId,
32063
+ runId,
32064
+ error: error instanceof Error ? error.message : String(error)
32065
+ });
32066
+ throw new ValidationError("Cannot verify discrepancy until full run data is available");
32067
+ }
32068
+ const activity = mapCaliperEventsToCompletedStudentGameplayActivities(runEvents, new Set([courseId]), {
32069
+ studentId: data.studentId,
32070
+ runIds: new Set([runId])
32071
+ }).flatMap((group) => group.activities).find((runActivity) => runActivity.runId?.toLowerCase() === runId);
32072
+ if (!activity) {
32073
+ throw new ValidationError("Cannot verify discrepancy until full run data is available");
32074
+ }
32075
+ const comparisons = await this.getGameMetricComparisonsForActivities(user, {
32076
+ gameId,
32077
+ studentId: data.studentId,
32078
+ course: { grade: integration.grade, subject: integration.subject },
32079
+ activities: [activity]
32080
+ });
32081
+ const comparison = comparisons.get(activity.runId ?? runId);
32082
+ if (comparison?.status !== "discrepant") {
32083
+ throw new ValidationError("Cannot verify discrepancy because the hydrated run is not discrepant");
32084
+ }
32085
+ const verifiedAt = new Date;
32086
+ const [row] = await this.deps.db.insert(gameTimebackMetricDiscrepancyVerifications).values({
32087
+ gameId,
32088
+ courseId,
32089
+ studentId: data.studentId,
32090
+ runId,
32091
+ activityId: data.activityId ?? null,
32092
+ verifiedByUserId: user.id,
32093
+ verifiedAt
32094
+ }).onConflictDoUpdate({
32095
+ target: [
32096
+ gameTimebackMetricDiscrepancyVerifications.gameId,
32097
+ gameTimebackMetricDiscrepancyVerifications.courseId,
32098
+ gameTimebackMetricDiscrepancyVerifications.studentId,
32099
+ gameTimebackMetricDiscrepancyVerifications.runId
32100
+ ],
32101
+ set: {
32102
+ activityId: data.activityId ?? null,
32103
+ verifiedByUserId: user.id,
32104
+ verifiedAt
32105
+ }
32106
+ }).returning();
32107
+ if (!row) {
32108
+ throw new ValidationError("Failed to verify metric discrepancy");
32109
+ }
32110
+ return {
32111
+ status: "ok",
32112
+ verification: TimebackAdminService.mapMetricDiscrepancyVerification(row)
32113
+ };
32114
+ }
31390
32115
  async getActivityDetail(user, options) {
31391
32116
  const { gameId, studentId, courseId, activityId, runId } = options;
31392
32117
  const client = this.requireClient();
@@ -31401,16 +32126,37 @@ class TimebackAdminService {
31401
32126
  throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
31402
32127
  }
31403
32128
  await this.assertStudentHasEnrollmentInCourse(client, studentId, courseId);
31404
- const events = await this.fetchCaliperEventsForStudent(client, studentId, gameSource, TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
31405
32129
  const relevantCourseIds = new Set([courseId]);
31406
32130
  let matchedEvents;
31407
32131
  let activity;
31408
32132
  if (runId) {
31409
- const gameplayEvents = events.filter((event) => (event.type === "ActivityEvent" || event.type === "TimeSpentEvent") && !isCaliperRemediationOrCompletionEvent(event));
31410
- const groups = groupCaliperEventsByRun(gameplayEvents);
31411
- matchedEvents = [...groups.values()].find((group) => group.some((event) => getCanonicalRunId(event.session) === runId && event.externalId === activityId)) ?? [];
31412
- activity = mapCaliperEventGroupToActivity(matchedEvents, relevantCourseIds);
32133
+ const normalizedRunId = TimebackAdminService.normalizeRunIds([runId], 1)[0];
32134
+ if (!normalizedRunId) {
32135
+ matchedEvents = [];
32136
+ activity = null;
32137
+ } else {
32138
+ let runEvents;
32139
+ try {
32140
+ runEvents = await this.fetchCaliperEventsForRun(client, gameSource, {
32141
+ runId: normalizedRunId,
32142
+ studentId
32143
+ });
32144
+ } catch (error) {
32145
+ logger17.warn("Failed to load Caliper events for activity detail run", {
32146
+ runId: normalizedRunId,
32147
+ studentId,
32148
+ activityId,
32149
+ error: error instanceof Error ? error.message : String(error)
32150
+ });
32151
+ runEvents = [];
32152
+ }
32153
+ const gameplayEvents = runEvents.filter((event) => (event.type === "ActivityEvent" || event.type === "TimeSpentEvent") && !isCaliperRemediationOrCompletionEvent(event));
32154
+ const matchingActivityEvents = findCaliperEventGroupContainingExternalId(gameplayEvents, activityId);
32155
+ matchedEvents = matchingActivityEvents ?? [];
32156
+ activity = mapCaliperEventGroupToActivity(matchedEvents, relevantCourseIds);
32157
+ }
31413
32158
  } else {
32159
+ const events = await this.fetchCaliperEventsForStudent(client, studentId, gameSource, TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
31414
32160
  matchedEvents = events.filter((event) => event.externalId === activityId);
31415
32161
  if (matchedEvents.length > 0) {
31416
32162
  activity = mapCaliperEventToRemediationActivity(matchedEvents[0], relevantCourseIds) ?? mapCaliperEventGroupToActivity(matchedEvents, relevantCourseIds);
@@ -31428,9 +32174,22 @@ class TimebackAdminService {
31428
32174
  activities: [activity]
31429
32175
  });
31430
32176
  const gameMetricsComparison = activity.runId ? comparisons.get(activity.runId) : undefined;
32177
+ const verificationsByKey = gameMetricsComparison?.status === "discrepant" && activity.runId ? await this.getMetricDiscrepancyVerificationsByKey({
32178
+ gameId,
32179
+ courseId,
32180
+ studentId,
32181
+ runIds: [activity.runId]
32182
+ }) : new Map;
32183
+ const verification2 = getTimebackMetricDiscrepancyVerificationForActivity({
32184
+ studentId,
32185
+ activity,
32186
+ comparison: gameMetricsComparison,
32187
+ verificationsByKey
32188
+ });
31431
32189
  const activityWithGameMetrics = gameMetricsComparison ? {
31432
32190
  ...activity,
31433
- gameMetricsComparison: summarizeGameRunMetricsComparison(gameMetricsComparison)
32191
+ gameMetricsComparison: summarizeGameRunMetricsComparison(gameMetricsComparison),
32192
+ ...verification2 ? { gameMetricsVerification: verification2 } : {}
31434
32193
  } : activity;
31435
32194
  return {
31436
32195
  activity: activityWithGameMetrics,
@@ -31721,9 +32480,11 @@ var init_timeback_admin_service = __esm(() => {
31721
32480
  init_types4();
31722
32481
  init_utils6();
31723
32482
  init_src4();
32483
+ init_timeback3();
31724
32484
  init_errors();
31725
32485
  init_timeback_admin_metrics_util();
31726
32486
  init_timeback_admin_util();
32487
+ init_timeback_discrepancy_queue_util();
31727
32488
  init_timeback_game_metrics_comparison_util();
31728
32489
  init_timeback_mastery_completion_util();
31729
32490
  init_timeback_util();
@@ -34996,6 +35757,9 @@ function createCaliperNamespace(client) {
34996
35757
  if (params.actorEmail) {
34997
35758
  query.set("actorEmail", params.actorEmail);
34998
35759
  }
35760
+ if (params.sessionId) {
35761
+ query.set("sessionId", params.sessionId);
35762
+ }
34999
35763
  if (params.extensions) {
35000
35764
  for (const [key, value] of Object.entries(params.extensions)) {
35001
35765
  query.set(`extensions.${key}`, value);
@@ -37364,7 +38128,7 @@ function buildTimebackClient() {
37364
38128
  }
37365
38129
  return;
37366
38130
  }
37367
- var init_timeback3 = __esm(() => {
38131
+ var init_timeback4 = __esm(() => {
37368
38132
  init_src2();
37369
38133
  init_dist3();
37370
38134
  init_config();
@@ -37372,7 +38136,7 @@ var init_timeback3 = __esm(() => {
37372
38136
 
37373
38137
  // src/infrastructure/api/clients/index.ts
37374
38138
  var init_clients = __esm(() => {
37375
- init_timeback3();
38139
+ init_timeback4();
37376
38140
  });
37377
38141
 
37378
38142
  // src/infrastructure/api/providers/auth.provider.ts
@@ -92418,7 +93182,7 @@ async function seedTimebackIntegrations(db2, gameId, courses) {
92418
93182
  }
92419
93183
  return seededCount;
92420
93184
  }
92421
- var init_timeback4 = __esm(() => {
93185
+ var init_timeback5 = __esm(() => {
92422
93186
  init_tables_index();
92423
93187
  init_config();
92424
93188
  });
@@ -92509,7 +93273,7 @@ var init_games = __esm(() => {
92509
93273
  init_tables_index();
92510
93274
  init_constants();
92511
93275
  init_logging();
92512
- init_timeback4();
93276
+ init_timeback5();
92513
93277
  });
92514
93278
 
92515
93279
  // src/database/seed/index.ts
@@ -92535,7 +93299,7 @@ var init_seed = __esm(() => {
92535
93299
  init_tables_index();
92536
93300
  init_constants();
92537
93301
  init_games();
92538
- init_timeback4();
93302
+ init_timeback5();
92539
93303
  init_games();
92540
93304
  });
92541
93305
 
@@ -94238,12 +95002,13 @@ var init_session_controller = __esm(() => {
94238
95002
  });
94239
95003
 
94240
95004
  // ../api-core/src/controllers/timeback.controller.ts
94241
- 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;
95005
+ var logger45, populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, updateIntegration, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
94242
95006
  var init_timeback_controller = __esm(() => {
94243
95007
  init_esm();
94244
95008
  init_schemas_index();
94245
95009
  init_src2();
94246
95010
  init_src4();
95011
+ init_timeback3();
94247
95012
  init_errors();
94248
95013
  init_utils11();
94249
95014
  logger45 = log.scope("TimebackController");
@@ -94710,6 +95475,65 @@ var init_timeback_controller = __esm(() => {
94710
95475
  runId
94711
95476
  });
94712
95477
  });
95478
+ listMetricDiscrepancies = requireGameManagementAccess(async (ctx) => {
95479
+ const gameId = ctx.params.gameId;
95480
+ const courseId = ctx.params.courseId;
95481
+ const requestedEventOffset = Number(ctx.url.searchParams.get("eventOffset"));
95482
+ const requestedWindow = ctx.url.searchParams.get("window");
95483
+ const startDate = ctx.url.searchParams.get("startDate") ?? undefined;
95484
+ const endDate = ctx.url.searchParams.get("endDate") ?? undefined;
95485
+ const studentId = ctx.url.searchParams.get("studentId")?.trim() || undefined;
95486
+ const discrepancyMetricScopes = parseTimebackDiscrepancyQueueMetrics(ctx.url.searchParams.getAll("discrepancyMetric"));
95487
+ const eventOffset = Number.isFinite(requestedEventOffset) && requestedEventOffset > 0 ? Math.trunc(requestedEventOffset) : 0;
95488
+ const window2 = parseTimebackDiscrepancyQueueWindow(requestedWindow);
95489
+ const includeVerified = ctx.url.searchParams.get("includeVerified") === "true";
95490
+ if (!gameId || !courseId) {
95491
+ throw ApiError.badRequest("Missing gameId or courseId path parameter");
95492
+ }
95493
+ logger45.debug("Listing metric discrepancies", {
95494
+ requesterId: ctx.user.id,
95495
+ gameId,
95496
+ courseId,
95497
+ window: window2,
95498
+ startDate,
95499
+ endDate,
95500
+ studentId,
95501
+ discrepancyMetricScopes,
95502
+ includeVerified,
95503
+ eventOffset
95504
+ });
95505
+ return ctx.services.timebackAdmin.listMetricDiscrepancies(ctx.user, {
95506
+ gameId,
95507
+ courseId,
95508
+ window: window2,
95509
+ startDate,
95510
+ endDate,
95511
+ studentId,
95512
+ discrepancyMetricScopes,
95513
+ includeVerified,
95514
+ eventOffset
95515
+ });
95516
+ });
95517
+ verifyMetricDiscrepancy = requireDeveloper(async (ctx) => {
95518
+ const gameId = ctx.params.gameId;
95519
+ const courseId = ctx.params.courseId;
95520
+ const body2 = await parseRequestBody(ctx.request, VerifyTimebackMetricDiscrepancyRequestSchema);
95521
+ if (!gameId || !courseId) {
95522
+ throw ApiError.badRequest("Missing gameId or courseId path parameter");
95523
+ }
95524
+ logger45.debug("Verifying metric discrepancy", {
95525
+ requesterId: ctx.user.id,
95526
+ gameId,
95527
+ courseId,
95528
+ studentId: body2.studentId,
95529
+ runId: body2.runId
95530
+ });
95531
+ return ctx.services.timebackAdmin.verifyMetricDiscrepancy(ctx.user, {
95532
+ gameId,
95533
+ courseId,
95534
+ data: body2
95535
+ });
95536
+ });
94713
95537
  grantXp = requireDeveloper(async (ctx) => {
94714
95538
  const body2 = await parseRequestBody(ctx.request, GrantTimebackXpRequestSchema);
94715
95539
  logger45.debug("Granting manual XP", {
@@ -94953,6 +95777,8 @@ var init_timeback_controller = __esm(() => {
94953
95777
  getGameMetrics,
94954
95778
  getStudentActivity,
94955
95779
  getActivityDetail,
95780
+ listMetricDiscrepancies,
95781
+ verifyMetricDiscrepancy,
94956
95782
  grantXp,
94957
95783
  adjustTime,
94958
95784
  adjustMastery,
@@ -95190,7 +96016,7 @@ async function buildMockUserResponse(db2, user, gameId) {
95190
96016
  timeback: timeback3
95191
96017
  };
95192
96018
  }
95193
- var init_timeback5 = __esm(() => {
96019
+ var init_timeback6 = __esm(() => {
95194
96020
  init_drizzle_orm();
95195
96021
  init_utils11();
95196
96022
  init_tables_index();
@@ -95208,7 +96034,7 @@ var init_users = __esm(() => {
95208
96034
  init_tables_index();
95209
96035
  init_api();
95210
96036
  init_error_handler();
95211
- init_timeback5();
96037
+ init_timeback6();
95212
96038
  usersRouter = new Hono2;
95213
96039
  usersRouter.get("/me", async (c2) => {
95214
96040
  const user = c2.get("user");
@@ -95833,15 +96659,16 @@ function hashCode(str) {
95833
96659
  return Math.abs(hash);
95834
96660
  }
95835
96661
  var timebackRouter;
95836
- var init_timeback6 = __esm(() => {
96662
+ var init_timeback7 = __esm(() => {
95837
96663
  init_dist4();
95838
96664
  init_controllers();
95839
96665
  init_errors();
95840
96666
  init_utils11();
95841
96667
  init_schemas_index();
96668
+ init_timeback3();
95842
96669
  init_api();
95843
96670
  init_error_handler();
95844
- init_timeback5();
96671
+ init_timeback6();
95845
96672
  timebackRouter = new Hono2;
95846
96673
  timebackRouter.post("/populate-student", async (c2) => c2.json({
95847
96674
  status: "no_record"
@@ -96164,7 +96991,7 @@ var init_routes = __esm(() => {
96164
96991
  init_users();
96165
96992
  init_games2();
96166
96993
  init_leaderboard();
96167
- init_timeback6();
96994
+ init_timeback7();
96168
96995
  init_lti();
96169
96996
  });
96170
96997