@playcademy/sandbox 0.12.2-beta.2 → 0.12.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 +535 -133
  2. package/dist/server.js +535 -133
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2517,7 +2517,7 @@ var package_default;
2517
2517
  var init_package = __esm(() => {
2518
2518
  package_default = {
2519
2519
  name: "@playcademy/sandbox",
2520
- version: "0.12.2-beta.2",
2520
+ version: "0.12.2-beta.4",
2521
2521
  description: "Local development server for Playcademy game development",
2522
2522
  type: "module",
2523
2523
  exports: {
@@ -22888,6 +22888,9 @@ var init_helpers = __esm(() => {
22888
22888
  function isBaseGameTimebackIntegration() {
22889
22889
  return sql`${gameTimebackIntegrations.kind} = ${BASE_GAME_TIMEBACK_INTEGRATION_KIND}`;
22890
22890
  }
22891
+ function isBaseGameTimebackIntegrationRow(integration) {
22892
+ return integration.kind === BASE_GAME_TIMEBACK_INTEGRATION_KIND;
22893
+ }
22891
22894
  function isActiveGameTimebackIntegrationStatus(statusColumn = gameTimebackIntegrations.status) {
22892
22895
  return sql`${statusColumn} IS DISTINCT FROM ${DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS}`;
22893
22896
  }
@@ -50496,6 +50499,7 @@ var init_schemas4 = __esm(() => {
50496
50499
  appName: stringType().optional(),
50497
50500
  sensorUrl: stringType().url().optional(),
50498
50501
  courseId: stringType().min(1).optional(),
50502
+ enrollmentId: stringType().min(1).optional(),
50499
50503
  courseName: stringType().optional(),
50500
50504
  studentEmail: stringType().email().optional()
50501
50505
  });
@@ -81861,6 +81865,9 @@ function normalizedIdentityCase2(value) {
81861
81865
  function frameworkAliasKey2(value) {
81862
81866
  return value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/g, "");
81863
81867
  }
81868
+ function isUuidShaped2(value) {
81869
+ return UUID_PATTERN2.test(value.trim());
81870
+ }
81864
81871
  function isCommonCoreMathIdentifier2(identifier) {
81865
81872
  const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
81866
81873
  return canonical.startsWith("CCSS.MATH.") || /^(?:K|[1-8])\.(?:CC|OA|NBT|NF|MD|RP|NS|EE|F|G|SP)\./.test(canonical) || /^(?:HS)?(?:N|A|F|G|S)-[A-Z]+\./.test(canonical) || /^MP\.?\d/.test(canonical);
@@ -81892,6 +81899,14 @@ function canonicalIdentifier2(framework, authoredIdentifier) {
81892
81899
  }
81893
81900
  return identifier;
81894
81901
  }
81902
+ function readableAlignmentIdentifier2(alignment) {
81903
+ const identifier = alignment.identifier?.trim() ?? "";
81904
+ if (identifier && !isUuidShaped2(identifier)) {
81905
+ return identifier;
81906
+ }
81907
+ const id = alignment.id.trim();
81908
+ return id && !isUuidShaped2(id) ? id : "";
81909
+ }
81895
81910
  function canonicalAssessmentStandardRef2(standard) {
81896
81911
  const authoredFramework = normalizedWhitespace2(standard.framework);
81897
81912
  const framework = canonicalFramework2(authoredFramework, standard.identifier);
@@ -81998,7 +82013,23 @@ function qtiStandardsFromMetadata(metadata2) {
81998
82013
  standards.push(...qtiAlignmentStandardsFromMetadata2(metadata2));
81999
82014
  return dedupeStandards2(standards);
82000
82015
  }
82001
- var EVENT_HANDLER_ATTRIBUTE, SCRIPT_SCHEMES2, DATA_RASTER_IMAGE_PATTERN2, QTI_MATHML_ALLOWED_TAGS2, metadataSymbol2, parser2, SUPPORTED_QTI_INTERACTION_TYPES2, URL_ATTRIBUTES3, IMAGE_URL_ATTRIBUTES3, INTERACTION_TYPES2, XML_NAMED_ENTITIES, EMPHASIS_TAGS2, STRONG_TAGS2, CONTENT_SKIPPED_TAGS2, BLOCK_CONTENT_KINDS2, GAP_MATCH_TOKEN_TAGS2, STAGE_GRAPHIC_EXCLUDED_CONTAINERS2, BLANK_SENTINEL2 = "￿", MARKED_BLANK_PATTERN2, MARKED_BLANK_GLOBAL_PATTERN2, REGION_SHAPE_COORDS, QTI_NAMESPACE = "http://www.imsglobal.org/xsd/imsqtiasi_v3p0", QTI_AUTHORING_BLANK_MARKER = "___", MATCH_CORRECT_TEMPLATE2 = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE2 = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES2, RESPONSE_PROCESSING_TEMPLATES2, GLOBAL_BODY_ATTRIBUTES, BODY_ELEMENT_ATTRIBUTES, MATHML_ATTRIBUTES, VOID_BODY_ELEMENTS2, URL_ATTRIBUTES22, IMAGE_URL_ATTRIBUTES22, INTERACTION_ATTRIBUTES, CHOICE_ATTRIBUTES, CHOICE_ELEMENT_ATTRIBUTES, VOID_CHOICE_ELEMENTS2, NUMERIC_COMPARISON_ATTRIBUTES2, RESPONSE_CARDINALITIES2, RESPONSE_BASE_TYPES2, BLANK_SENTINEL22 = "￿", QTI_FLOAT_VALUE, PLAYABLE_POINT_VALUE, POINT_INTERACTION_TYPES2, INDEPENDENT_PROCESSING_TAGS2, PRIVATE_PLAYABLE_KEYS2, trimmedNonEmptyString2, finiteNumber22, positiveFiniteNumber2, IDENTIFIER_VALUE2, DIRECTED_PAIR_VALUE2, scoringMatchComparisonSchema2, scoringNumericEqualComparisonSchema2, scoringNumericEqualRoundedComparisonSchema2, scoringRuleSchema2, scoringRulesSchema2, scoringItemSchema2, assessmentScoringArtifactItemsSchema2, assessmentScoringArtifactSchema2, SUPPORTED_BASE_TYPES2, SUPPORTED_CARDINALITIES2, MATCH_CORRECT_TEMPLATES2, QtiScoringArtifactValidationError, COMMON_CORE_MATH_FRAMEWORK_ALIASES2, COMMON_CORE_ELA_FRAMEWORK_ALIASES2, COMMON_CORE_FRAMEWORK_ALIASES2;
82016
+ function qtiAssessmentStandardRefsFromMetadata2(metadata2) {
82017
+ const refs = new Map;
82018
+ for (const alignment of qtiAlignmentStandardsFromMetadata2(metadata2)) {
82019
+ const identifier = readableAlignmentIdentifier2(alignment);
82020
+ if (identifier) {
82021
+ const canonical = canonicalAssessmentStandardRef2({
82022
+ framework: alignment.source,
82023
+ identifier
82024
+ });
82025
+ if (canonical.framework && canonical.identifier) {
82026
+ refs.set(assessmentStandardRefKey2(canonical), canonical);
82027
+ }
82028
+ }
82029
+ }
82030
+ return [...refs.values()];
82031
+ }
82032
+ var EVENT_HANDLER_ATTRIBUTE, SCRIPT_SCHEMES2, DATA_RASTER_IMAGE_PATTERN2, QTI_MATHML_ALLOWED_TAGS2, metadataSymbol2, parser2, SUPPORTED_QTI_INTERACTION_TYPES2, URL_ATTRIBUTES3, IMAGE_URL_ATTRIBUTES3, INTERACTION_TYPES2, XML_NAMED_ENTITIES, EMPHASIS_TAGS2, STRONG_TAGS2, CONTENT_SKIPPED_TAGS2, BLOCK_CONTENT_KINDS2, GAP_MATCH_TOKEN_TAGS2, STAGE_GRAPHIC_EXCLUDED_CONTAINERS2, BLANK_SENTINEL2 = "￿", MARKED_BLANK_PATTERN2, MARKED_BLANK_GLOBAL_PATTERN2, REGION_SHAPE_COORDS, QTI_NAMESPACE = "http://www.imsglobal.org/xsd/imsqtiasi_v3p0", QTI_AUTHORING_BLANK_MARKER = "___", MATCH_CORRECT_TEMPLATE2 = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE2 = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES2, RESPONSE_PROCESSING_TEMPLATES2, GLOBAL_BODY_ATTRIBUTES, BODY_ELEMENT_ATTRIBUTES, MATHML_ATTRIBUTES, VOID_BODY_ELEMENTS2, URL_ATTRIBUTES22, IMAGE_URL_ATTRIBUTES22, INTERACTION_ATTRIBUTES, CHOICE_ATTRIBUTES, CHOICE_ELEMENT_ATTRIBUTES, VOID_CHOICE_ELEMENTS2, NUMERIC_COMPARISON_ATTRIBUTES2, RESPONSE_CARDINALITIES2, RESPONSE_BASE_TYPES2, BLANK_SENTINEL22 = "￿", QTI_FLOAT_VALUE, PLAYABLE_POINT_VALUE, POINT_INTERACTION_TYPES2, INDEPENDENT_PROCESSING_TAGS2, PRIVATE_PLAYABLE_KEYS2, trimmedNonEmptyString2, finiteNumber22, positiveFiniteNumber2, IDENTIFIER_VALUE2, DIRECTED_PAIR_VALUE2, scoringMatchComparisonSchema2, scoringNumericEqualComparisonSchema2, scoringNumericEqualRoundedComparisonSchema2, scoringRuleSchema2, scoringRulesSchema2, scoringItemSchema2, assessmentScoringArtifactItemsSchema2, assessmentScoringArtifactSchema2, SUPPORTED_BASE_TYPES2, SUPPORTED_CARDINALITIES2, MATCH_CORRECT_TEMPLATES2, QtiScoringArtifactValidationError, UUID_PATTERN2, COMMON_CORE_MATH_FRAMEWORK_ALIASES2, COMMON_CORE_ELA_FRAMEWORK_ALIASES2, COMMON_CORE_FRAMEWORK_ALIASES2;
82002
82033
  var init_qti = __esm(() => {
82003
82034
  init_timeback4();
82004
82035
  init_timeback4();
@@ -82360,6 +82391,7 @@ var init_qti = __esm(() => {
82360
82391
  this.name = "QtiScoringArtifactValidationError";
82361
82392
  }
82362
82393
  };
82394
+ UUID_PATTERN2 = /^(?:urn:uuid:)?\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?$/i;
82363
82395
  COMMON_CORE_MATH_FRAMEWORK_ALIASES2 = new Set([
82364
82396
  "ccssm",
82365
82397
  "ccssmath",
@@ -82569,7 +82601,7 @@ function pruneUnreadableStandards(standards) {
82569
82601
  return standards.filter(hasReadableStandardLabel);
82570
82602
  }
82571
82603
  function parseQuestionIndex(title) {
82572
- const match = title.match(/\bquestion[\s_-]*(\d+)\b/i);
82604
+ const match = title?.match(/\bquestion[\s_-]*(\d+)\b/i);
82573
82605
  const index = match ? Number(match[1]) : Number.NaN;
82574
82606
  return Number.isFinite(index) && index > 0 ? index : null;
82575
82607
  }
@@ -82603,14 +82635,15 @@ function qtiQuestionsByIndex(qtiQuestions) {
82603
82635
  }
82604
82636
  return questionsByIndex;
82605
82637
  }
82638
+ function normalizedQuestionScore(score, scorePercentile) {
82639
+ const rawValue = numberField(score) ?? numberField(scorePercentile);
82640
+ return rawValue !== null && rawValue > 1 ? rawValue / 100 : rawValue;
82641
+ }
82606
82642
  function getQuestionCorrectness(result) {
82607
82643
  if (!result) {
82608
82644
  return "unknown";
82609
82645
  }
82610
- const score = numberField(result.score);
82611
- const scorePercentile = numberField(result.scorePercentile);
82612
- const rawValue = score ?? scorePercentile;
82613
- const value = rawValue !== null && rawValue > 1 ? rawValue / 100 : rawValue;
82646
+ const value = normalizedQuestionScore(result.score, result.scorePercentile);
82614
82647
  if (value === null) {
82615
82648
  return "unknown";
82616
82649
  }
@@ -82874,10 +82907,71 @@ var init_timeback_mastery_completion_util = __esm(async () => {
82874
82907
  await init_errors8();
82875
82908
  });
82876
82909
 
82910
+ // ../api-core/src/utils/bounded-map.util.ts
82911
+ function setBounded(map, key, value, maxSize) {
82912
+ map.delete(key);
82913
+ if (map.size >= maxSize) {
82914
+ const oldestKey = map.keys().next().value;
82915
+ if (oldestKey !== undefined) {
82916
+ map.delete(oldestKey);
82917
+ }
82918
+ }
82919
+ map.set(key, value);
82920
+ }
82921
+
82922
+ // ../api-core/src/utils/timeback-qti-test-lookup.util.ts
82923
+ async function resolveQtiTestId(client, testName, parentLineItem) {
82924
+ const cacheKey = `${parentLineItem.sourcedId ?? ""}:${testName}`;
82925
+ const cached = cache.get(cacheKey);
82926
+ if (cached) {
82927
+ setBounded(cache, cacheKey, cached, MAX_CACHE_SIZE);
82928
+ return cached;
82929
+ }
82930
+ let resource = null;
82931
+ const resourceId = sourceIdFromRef(parentLineItem.componentResource);
82932
+ if (resourceId) {
82933
+ resource = await client.api.oneroster.resources.get(resourceId).catch(() => null);
82934
+ }
82935
+ for (const candidate of qtiIdCandidates(parentLineItem, resource)) {
82936
+ const test = await client.qtiApi.assessmentTests.get(candidate).catch(() => null);
82937
+ if (test?.identifier) {
82938
+ setBounded(cache, cacheKey, test.identifier, MAX_CACHE_SIZE);
82939
+ return test.identifier;
82940
+ }
82941
+ }
82942
+ const normalizedTestName = testName.trim();
82943
+ if (!normalizedTestName) {
82944
+ return "";
82945
+ }
82946
+ for (let page = 1;page <= TITLE_LOOKUP_MAX_PAGES; page++) {
82947
+ const list = await client.qtiApi.assessmentTests.list({
82948
+ query: normalizedTestName,
82949
+ limit: TITLE_LOOKUP_LIMIT,
82950
+ page,
82951
+ sort: "title",
82952
+ order: "asc"
82953
+ }).catch(() => null);
82954
+ const qtiTestId = findQtiAssessmentTestIdByTitle(list?.items ?? [], normalizedTestName);
82955
+ if (qtiTestId) {
82956
+ setBounded(cache, cacheKey, qtiTestId, MAX_CACHE_SIZE);
82957
+ return qtiTestId;
82958
+ }
82959
+ const itemCount = list?.items?.length ?? 0;
82960
+ if (!list || itemCount < TITLE_LOOKUP_LIMIT || typeof list.pages === "number" && page >= list.pages) {
82961
+ break;
82962
+ }
82963
+ }
82964
+ return "";
82965
+ }
82966
+ var MAX_CACHE_SIZE = 500, TITLE_LOOKUP_LIMIT = 50, TITLE_LOOKUP_MAX_PAGES = 5, cache;
82967
+ var init_timeback_qti_test_lookup_util = __esm(() => {
82968
+ init_timeback_grade_level_results_util();
82969
+ cache = new Map;
82970
+ });
82971
+
82877
82972
  // ../api-core/src/services/timeback-admin.service.ts
82878
82973
  class TimebackAdminService {
82879
82974
  deps;
82880
- qtiTestIdCache = new Map;
82881
82975
  static XP_PRECISION_FACTOR = 10;
82882
82976
  static RECENT_ACTIVITY_LIMIT = 20;
82883
82977
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
@@ -82892,9 +82986,6 @@ class TimebackAdminService {
82892
82986
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
82893
82987
  static GAME_METRICS_LIST_FETCH_TIMEOUT_MS = 3000;
82894
82988
  static GAME_METRICS_RUN_IDS_PER_REQUEST = 50;
82895
- static MAX_QTI_TEST_ID_CACHE_SIZE = 500;
82896
- static QTI_TEST_TITLE_LOOKUP_LIMIT = 50;
82897
- static QTI_TEST_TITLE_LOOKUP_MAX_PAGES = 5;
82898
82989
  constructor(deps) {
82899
82990
  this.deps = deps;
82900
82991
  }
@@ -83754,68 +83845,6 @@ class TimebackAdminService {
83754
83845
  return { activities: activitiesWithGameMetrics, hasMore };
83755
83846
  });
83756
83847
  }
83757
- rememberQtiTestId(cacheKey, qtiTestId) {
83758
- if (!qtiTestId) {
83759
- this.qtiTestIdCache.delete(cacheKey);
83760
- return;
83761
- }
83762
- if (this.qtiTestIdCache.has(cacheKey)) {
83763
- this.qtiTestIdCache.delete(cacheKey);
83764
- } else if (this.qtiTestIdCache.size >= TimebackAdminService.MAX_QTI_TEST_ID_CACHE_SIZE) {
83765
- const oldestKey = this.qtiTestIdCache.keys().next().value;
83766
- if (oldestKey !== undefined) {
83767
- this.qtiTestIdCache.delete(oldestKey);
83768
- }
83769
- }
83770
- this.qtiTestIdCache.set(cacheKey, qtiTestId);
83771
- }
83772
- async resolveQtiTestIdForGradeLevelResult(client, testName, parentLineItem) {
83773
- const cacheKey = `${parentLineItem.sourcedId ?? ""}:${testName}`;
83774
- const cachedQtiTestId = cacheKey ? this.qtiTestIdCache.get(cacheKey) : undefined;
83775
- if (cachedQtiTestId) {
83776
- this.rememberQtiTestId(cacheKey, cachedQtiTestId);
83777
- return cachedQtiTestId;
83778
- }
83779
- let resource = null;
83780
- const resourceId = sourceIdFromRef(parentLineItem.componentResource);
83781
- if (resourceId) {
83782
- resource = await client.api.oneroster.resources.get(resourceId).catch(() => null);
83783
- }
83784
- for (const candidate of qtiIdCandidates(parentLineItem, resource)) {
83785
- const test = await client.qtiApi.assessmentTests.get(candidate).catch(() => null);
83786
- if (test?.identifier) {
83787
- if (cacheKey) {
83788
- this.rememberQtiTestId(cacheKey, test.identifier);
83789
- }
83790
- return test.identifier;
83791
- }
83792
- }
83793
- const normalizedTestName = testName.trim();
83794
- if (!normalizedTestName) {
83795
- return "";
83796
- }
83797
- for (let page = 1;page <= TimebackAdminService.QTI_TEST_TITLE_LOOKUP_MAX_PAGES; page++) {
83798
- const list = await client.qtiApi.assessmentTests.list({
83799
- query: normalizedTestName,
83800
- limit: TimebackAdminService.QTI_TEST_TITLE_LOOKUP_LIMIT,
83801
- page,
83802
- sort: "title",
83803
- order: "asc"
83804
- }).catch(() => null);
83805
- const qtiTestId = findQtiAssessmentTestIdByTitle(list?.items ?? [], normalizedTestName);
83806
- if (qtiTestId) {
83807
- if (cacheKey) {
83808
- this.rememberQtiTestId(cacheKey, qtiTestId);
83809
- }
83810
- return qtiTestId;
83811
- }
83812
- const itemCount = list?.items?.length ?? 0;
83813
- if (!list || itemCount < TimebackAdminService.QTI_TEST_TITLE_LOOKUP_LIMIT || typeof list.pages === "number" && page >= list.pages) {
83814
- break;
83815
- }
83816
- }
83817
- return "";
83818
- }
83819
83848
  async getGradeLevelTestResults(user, options) {
83820
83849
  return this.withClientTelemetry(async () => {
83821
83850
  const { gameId, courseId, studentId } = options;
@@ -83918,7 +83947,7 @@ class TimebackAdminService {
83918
83947
  }).then((page) => page.data) : [];
83919
83948
  const latestResultsByLineItem = latestAssessmentResultsByLineItem(questionResults);
83920
83949
  const testName = resolveGradeLevelTestName(parentResult, parentLineItem);
83921
- const qtiTestId = await this.resolveQtiTestIdForGradeLevelResult(client, testName, parentLineItem);
83950
+ const qtiTestId = await resolveQtiTestId(client, testName, parentLineItem);
83922
83951
  const qtiQuestions = qtiTestId ? await client.qtiApi.assessmentTests.getQuestions(qtiTestId).then((response) => response.questions ?? []).catch(() => []) : [];
83923
83952
  const questions = mapGradeLevelReviewQuestions({
83924
83953
  questionLineItems,
@@ -84612,6 +84641,7 @@ var init_timeback_admin_service = __esm(async () => {
84612
84641
  init_timeback_discrepancy_queue_util();
84613
84642
  init_timeback_game_metrics_comparison_util();
84614
84643
  init_timeback_grade_level_results_util();
84644
+ init_timeback_qti_test_lookup_util();
84615
84645
  init_timeback_util();
84616
84646
  await __promiseAll([
84617
84647
  init_dist5(),
@@ -92510,6 +92540,22 @@ var init_timeback_assessments_service = __esm(async () => {
92510
92540
  await init_errors8();
92511
92541
  });
92512
92542
 
92543
+ // ../api-core/src/utils/timeback-attribution.util.ts
92544
+ function resolveRuntimeAttribution(base, holeFilling, requestedCourseId, recordedCourseId) {
92545
+ const recorded = [base, ...holeFilling].find((integration) => integration.courseId === recordedCourseId);
92546
+ if (recorded) {
92547
+ return { integration: recorded, requestedCourseIdStatus: "pinned_by_run" };
92548
+ }
92549
+ if (requestedCourseId === undefined) {
92550
+ return { integration: base, requestedCourseIdStatus: "absent" };
92551
+ }
92552
+ if (requestedCourseId === base.courseId) {
92553
+ return { integration: base, requestedCourseIdStatus: "accepted" };
92554
+ }
92555
+ const requestedHoleFilling = holeFilling.find((integration) => integration.courseId === requestedCourseId);
92556
+ return requestedHoleFilling ? { integration: requestedHoleFilling, requestedCourseIdStatus: "accepted_hole_filling" } : { integration: base, requestedCourseIdStatus: "ignored_mismatch" };
92557
+ }
92558
+
92513
92559
  // ../api-core/src/utils/timeback-create-integration.util.ts
92514
92560
  function buildTimebackBaseConfigFromExistingConfig(config) {
92515
92561
  return {
@@ -92602,6 +92648,128 @@ var init_timeback_create_integration_util = __esm(() => {
92602
92648
  init_timeback_util();
92603
92649
  });
92604
92650
 
92651
+ // ../api-core/src/utils/timeback-hole-filling-context.util.ts
92652
+ function buildAssignmentResultWhere(options) {
92653
+ return {
92654
+ status: "active",
92655
+ "student.sourcedId": options.studentId,
92656
+ "metadata.resultType": options.resultType,
92657
+ "metadata.assignmentId": options.assignmentId
92658
+ };
92659
+ }
92660
+ function assignmentIdOf(record) {
92661
+ const value = metadataOf(record).assignmentId;
92662
+ if (typeof value === "number" && Number.isFinite(value)) {
92663
+ return String(value);
92664
+ }
92665
+ const text = stringField3(value).trim();
92666
+ return text || undefined;
92667
+ }
92668
+ function toTriggeringResult(result) {
92669
+ const metadata2 = metadataOf(result);
92670
+ const testType = normalizeTestType(metadata2.testType);
92671
+ const assignmentId = assignmentIdOf(result);
92672
+ const lineItemId = sourceIdFromRef(result.assessmentLineItem);
92673
+ if (!assignmentId || !lineItemId || result.scoreStatus !== SCORE_STATUS2.fullyGraded || !isAllowedGradeLevelTestType(testType)) {
92674
+ return null;
92675
+ }
92676
+ return {
92677
+ resultId: result.sourcedId ?? "",
92678
+ assignmentId,
92679
+ lineItemId,
92680
+ testName: stringField3(metadata2.testName) || lineItemId,
92681
+ testType,
92682
+ score: numberField(result.score),
92683
+ scoreDate: result.scoreDate ?? null,
92684
+ totalQuestions: numberField(metadata2.totalQuestions),
92685
+ correctQuestions: numberField(metadata2.correctQuestions)
92686
+ };
92687
+ }
92688
+ function isMissed(resultText, score, scorePercentile) {
92689
+ const marking = stringField3(resultText).trim().toLowerCase();
92690
+ if (marking) {
92691
+ return marking !== "correct";
92692
+ }
92693
+ const value = normalizedQuestionScore(score, scorePercentile);
92694
+ return value !== null && value < 1;
92695
+ }
92696
+ function missedQuestionsFromInlineResponses(result) {
92697
+ const responses = metadataOf(result).responses;
92698
+ const questions = isRecord6(responses) && Array.isArray(responses.questions) ? responses.questions : undefined;
92699
+ if (!questions?.length) {
92700
+ return;
92701
+ }
92702
+ return questions.flatMap((question) => {
92703
+ if (!isRecord6(question) || !isMissed(question.result, question.score)) {
92704
+ return [];
92705
+ }
92706
+ const questionNumber = numberField(question.questionNumber);
92707
+ const qtiItemId = stringField3(question.questionQtiIdentifier).trim();
92708
+ return questionNumber === null ? [] : [{ questionNumber, ...qtiItemId && { qtiItemId } }];
92709
+ });
92710
+ }
92711
+ function questionNumberFromLineItemId(lineItemId) {
92712
+ const match = lineItemId.match(/_q(\d+)$/);
92713
+ const value = match ? Number(match[1]) : Number.NaN;
92714
+ return Number.isInteger(value) && value > 0 ? value : null;
92715
+ }
92716
+ function missedQuestionsFromQuestionResults(questionResults) {
92717
+ return [...latestAssessmentResultsByLineItem(questionResults)].flatMap(([lineItemId, result]) => {
92718
+ const questionNumber = questionNumberFromLineItemId(lineItemId);
92719
+ const responses = metadataOf(result).responses;
92720
+ const response = isRecord6(responses) && isRecord6(responses.response) ? responses.response : undefined;
92721
+ if (questionNumber === null || !isMissed(response?.result, result.score, result.scorePercentile)) {
92722
+ return [];
92723
+ }
92724
+ const qtiItemId = stringField3(response?.questionQtiIdentifier).trim();
92725
+ return [{ questionNumber, ...qtiItemId && { qtiItemId } }];
92726
+ }).toSorted((a, b) => a.questionNumber - b.questionNumber);
92727
+ }
92728
+ function qtiItemIdForQuestionNumber(qtiQuestions) {
92729
+ const byNumber = qtiQuestionsByIndex(qtiQuestions);
92730
+ return (questionNumber) => {
92731
+ const question = byNumber.get(questionNumber) ?? qtiQuestions[questionNumber - 1];
92732
+ return question ? question.question.identifier || question.reference.identifier : undefined;
92733
+ };
92734
+ }
92735
+ function groupMissedStandards(missed, standardsByItemId, itemIdFor) {
92736
+ const byKey = new Map;
92737
+ const untagged = [];
92738
+ for (const question of missed) {
92739
+ const itemId = question.qtiItemId ?? itemIdFor(question.questionNumber);
92740
+ const standards = itemId ? standardsByItemId.get(itemId) ?? [] : [];
92741
+ if (!standards.length) {
92742
+ untagged.push(question.questionNumber);
92743
+ }
92744
+ for (const standard of standards) {
92745
+ const key = `${standard.framework}\x00${standard.identifier}`;
92746
+ const entry = byKey.get(key) ?? {
92747
+ framework: standard.framework,
92748
+ code: standard.identifier,
92749
+ questionNumbers: [],
92750
+ qtiItemIds: []
92751
+ };
92752
+ entry.questionNumbers.push(question.questionNumber);
92753
+ if (itemId && !entry.qtiItemIds.includes(itemId)) {
92754
+ entry.qtiItemIds.push(itemId);
92755
+ }
92756
+ byKey.set(key, entry);
92757
+ }
92758
+ }
92759
+ return {
92760
+ missedStandards: [...byKey.values()].toSorted((a, b) => b.questionNumbers.length - a.questionNumbers.length || a.code.localeCompare(b.code, undefined, { numeric: true })),
92761
+ untaggedQuestionNumbers: untagged.toSorted((a, b) => a - b)
92762
+ };
92763
+ }
92764
+ function standardsFromQtiItem(item) {
92765
+ return qtiAssessmentStandardRefsFromMetadata2(metadataOf(item));
92766
+ }
92767
+ var init_timeback_hole_filling_context_util = __esm(() => {
92768
+ init_constants2();
92769
+ init_qti();
92770
+ init_timeback_grade_level_results_util();
92771
+ });
92772
+
92605
92773
  // ../api-core/src/utils/timeback-promotion.util.ts
92606
92774
  async function promoteCompletedCourse({
92607
92775
  db,
@@ -92731,14 +92899,20 @@ var init_timeback_service = __esm(async () => {
92731
92899
  init_helpers_index();
92732
92900
  init_tables_index();
92733
92901
  init_spans();
92902
+ init_constants2();
92734
92903
  init_types2();
92735
92904
  init_errors2();
92736
92905
  init_timeback_create_integration_util();
92906
+ init_timeback_grade_level_results_util();
92907
+ init_timeback_hole_filling_context_util();
92737
92908
  init_timeback_promotion_util();
92909
+ init_timeback_qti_test_lookup_util();
92738
92910
  init_timeback_removed_integration_util();
92739
92911
  init_timeback_util();
92740
92912
  await init_dist5();
92741
92913
  TimebackService = class TimebackService {
92914
+ static HOLE_FILLING_CONTEXT_TTL_MS = 10 * 60 * 1000;
92915
+ static HOLE_FILLING_CONTEXT_CACHE_SIZE = 1000;
92742
92916
  static HEARTBEAT_DEDUPE_TTL_MS = 5 * 60 * 1000;
92743
92917
  static processedHeartbeatWindows = new Map;
92744
92918
  static inFlightHeartbeatWindows = new Map;
@@ -92746,6 +92920,7 @@ var init_timeback_service = __esm(async () => {
92746
92920
  static COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS = 400;
92747
92921
  static COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS = 5;
92748
92922
  deps;
92923
+ holeFillingContextCache = new Map;
92749
92924
  static cleanHeartbeatDedupeCache(now = Date.now()) {
92750
92925
  for (const [key, timestamp] of this.processedHeartbeatWindows) {
92751
92926
  if (now - timestamp > this.HEARTBEAT_DEDUPE_TTL_MS) {
@@ -92769,13 +92944,13 @@ var init_timeback_service = __esm(async () => {
92769
92944
  static clearInFlightHeartbeatWindow(key) {
92770
92945
  this.inFlightHeartbeatWindows.delete(key);
92771
92946
  }
92772
- static buildCompletionCaliperEventId(key, kind, discriminator = "completion") {
92947
+ static buildCompletionCaliperEventId(key, kind, baseCourseId, discriminator = "completion") {
92773
92948
  const hash = createHash3("sha256").update([
92774
92949
  "playcademy",
92775
92950
  "timeback",
92776
92951
  kind,
92777
92952
  key.gameId,
92778
- key.courseId,
92953
+ baseCourseId,
92779
92954
  key.studentId,
92780
92955
  key.runId,
92781
92956
  key.activityId,
@@ -92841,11 +93016,14 @@ var init_timeback_service = __esm(async () => {
92841
93016
  } : null
92842
93017
  };
92843
93018
  }
92844
- static async acquireCompletionReservation(db, key) {
92845
- let reserveOutcome = await this.reserveCompletion(db, key);
93019
+ static async acquireCompletionReservation(db, key, otherCourseIds = []) {
93020
+ function reserve() {
93021
+ return otherCourseIds.length > 0 ? TimebackService.reserveCompletionAcrossCourses(db, key, otherCourseIds) : TimebackService.reserveCompletion(db, key);
93022
+ }
93023
+ let reserveOutcome = await reserve();
92846
93024
  for (let settleAttempt = 0;reserveOutcome.status === "in_flight" && settleAttempt < this.COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS; settleAttempt++) {
92847
93025
  await sleep(this.COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS);
92848
- reserveOutcome = await this.reserveCompletion(db, key);
93026
+ reserveOutcome = await reserve();
92849
93027
  }
92850
93028
  if (reserveOutcome.status === "duplicate") {
92851
93029
  setAttribute("app.timeback.end_activity_status", "blocked_by_dedupe_guard");
@@ -92864,8 +93042,24 @@ var init_timeback_service = __esm(async () => {
92864
93042
  }
92865
93043
  return { status: "reserved", token: reserveOutcome.token };
92866
93044
  }
92867
- static async runWithCompletionReservation(db, key, emit, awardFromResult) {
92868
- const acquired = await this.acquireCompletionReservation(db, key);
93045
+ static async reserveCompletionAcrossCourses(db, key, otherCourseIds) {
93046
+ return db.transaction(async (tx) => {
93047
+ const database = tx;
93048
+ const runKey = `timeback-completion:${key.gameId.toLowerCase()}:${key.studentId}:${key.runId.toLowerCase()}:${key.activityId}`;
93049
+ await database.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${runKey}, 0))`);
93050
+ const onOtherCourse = await database.query.gameTimebackActivityCompletions.findFirst({
93051
+ columns: { id: true },
93052
+ where: and(eq(gameTimebackActivityCompletions.gameId, key.gameId), eq(gameTimebackActivityCompletions.studentId, key.studentId), eq(gameTimebackActivityCompletions.runId, key.runId), eq(gameTimebackActivityCompletions.activityId, key.activityId), inArray(gameTimebackActivityCompletions.courseId, [...otherCourseIds]))
93053
+ });
93054
+ if (onOtherCourse) {
93055
+ setAttribute("app.timeback.end_activity_status", "completion_reserved_on_other_course");
93056
+ throw new ServiceUnavailableError("An end-activity submission for this run is already in flight — retry shortly");
93057
+ }
93058
+ return this.reserveCompletion(database, key);
93059
+ });
93060
+ }
93061
+ static async runWithCompletionReservation(db, key, emit, awardFromResult, otherCourseIds = []) {
93062
+ const acquired = await this.acquireCompletionReservation(db, key, otherCourseIds);
92869
93063
  if (acquired.status === "duplicate") {
92870
93064
  return acquired;
92871
93065
  }
@@ -92900,6 +93094,9 @@ var init_timeback_service = __esm(async () => {
92900
93094
  await db.delete(gameTimebackActivityCompletions).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
92901
93095
  }
92902
93096
  static addResumeIdToExtensions(extensions, resumeId) {
93097
+ return TimebackService.addPlaycademyExtensions(extensions, { resumeId });
93098
+ }
93099
+ static addPlaycademyExtensions(extensions, fields) {
92903
93100
  const base = extensions ?? {};
92904
93101
  const existingPlaycademy = base.playcademy;
92905
93102
  const playcademy = typeof existingPlaycademy === "object" && existingPlaycademy !== null && !Array.isArray(existingPlaycademy) ? existingPlaycademy : {};
@@ -92907,28 +93104,71 @@ var init_timeback_service = __esm(async () => {
92907
93104
  ...base,
92908
93105
  playcademy: {
92909
93106
  ...playcademy,
92910
- resumeId
93107
+ ...fields
92911
93108
  }
92912
93109
  };
92913
93110
  }
92914
- static resolveRuntimeCourse(resolvedCourseId, requestedCourseId, courseName) {
92915
- let requestedCourseIdStatus = "absent";
92916
- if (requestedCourseId === resolvedCourseId) {
92917
- requestedCourseIdStatus = "accepted";
92918
- } else if (requestedCourseId !== undefined) {
92919
- requestedCourseIdStatus = "ignored_mismatch";
93111
+ async resolveRuntimeCourse({
93112
+ gameId,
93113
+ studentId,
93114
+ runId,
93115
+ activityData,
93116
+ extensions
93117
+ }) {
93118
+ const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
93119
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveGameTimebackIntegrationStatus())
93120
+ });
93121
+ const base = integrations.find(isBaseGameTimebackIntegrationRow);
93122
+ if (!base) {
93123
+ throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
92920
93124
  }
93125
+ const holeFilling = integrations.filter((integration) => !isBaseGameTimebackIntegrationRow(integration));
93126
+ const recordedCourseId = holeFilling.length > 0 && runId ? await this.findRecordedRunCourse({
93127
+ gameId,
93128
+ studentId,
93129
+ runId,
93130
+ activityId: activityData.activityId,
93131
+ courseIds: integrations.map((i2) => i2.courseId)
93132
+ }) : undefined;
93133
+ const { integration, requestedCourseIdStatus } = resolveRuntimeAttribution(base, holeFilling, activityData.courseId, recordedCourseId);
93134
+ const hintUsed = activityData.courseId === integration.courseId;
93135
+ const enrollmentId = hintUsed ? activityData.enrollmentId : undefined;
93136
+ const isHoleFilling = !isBaseGameTimebackIntegrationRow(integration);
92921
93137
  return {
92922
- courseId: resolvedCourseId,
92923
- courseName: requestedCourseIdStatus === "ignored_mismatch" ? undefined : courseName,
93138
+ integration,
93139
+ courseId: integration.courseId,
93140
+ baseCourseId: base.courseId,
93141
+ otherCourseIds: holeFilling.length > 0 ? integrations.map((other) => other.courseId).filter((courseId) => courseId !== integration.courseId) : [],
93142
+ courseName: activityData.courseId === undefined || hintUsed ? activityData.courseName : undefined,
93143
+ extensions: isHoleFilling ? TimebackService.addPlaycademyExtensions(extensions, {
93144
+ courseKind: HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND,
93145
+ baseCourseId: base.courseId,
93146
+ ...enrollmentId && { enrollmentId }
93147
+ }) : extensions,
92924
93148
  attributes: {
92925
- "app.timeback.course_id": resolvedCourseId,
92926
- "app.timeback.resolved_course_id": resolvedCourseId,
92927
- "app.timeback.requested_course_id": requestedCourseId,
92928
- "app.timeback.requested_course_id_status": requestedCourseIdStatus
93149
+ "app.timeback.course_id": integration.courseId,
93150
+ "app.timeback.resolved_course_id": integration.courseId,
93151
+ "app.timeback.requested_course_id": activityData.courseId,
93152
+ "app.timeback.requested_course_id_status": requestedCourseIdStatus,
93153
+ "app.timeback.course_kind": integration.kind,
93154
+ "app.timeback.enrollment_id": enrollmentId
92929
93155
  }
92930
93156
  };
92931
93157
  }
93158
+ async findRecordedRunCourse({
93159
+ gameId,
93160
+ studentId,
93161
+ runId,
93162
+ activityId,
93163
+ courseIds
93164
+ }) {
93165
+ const recorded = await this.deps.db.query.gameTimebackActivityCompletions.findFirst({
93166
+ columns: { courseId: true },
93167
+ where: and(eq(gameTimebackActivityCompletions.gameId, gameId), eq(gameTimebackActivityCompletions.studentId, studentId), eq(gameTimebackActivityCompletions.runId, runId), eq(gameTimebackActivityCompletions.activityId, activityId), inArray(gameTimebackActivityCompletions.courseId, courseIds)),
93168
+ orderBy: (table, { asc }) => [asc(table.createdAt)]
93169
+ });
93170
+ return recorded?.courseId;
93171
+ }
92932
93172
  static recordRuntimeContext({
92933
93173
  operation,
92934
93174
  studentId,
@@ -93054,19 +93294,163 @@ var init_timeback_service = __esm(async () => {
93054
93294
  }
93055
93295
  async getUserEnrollments(userId, gameId) {
93056
93296
  return this.withClientTelemetry(async () => {
93057
- const userData = await this.deps.db.query.users.findFirst({
93058
- where: eq(users.id, userId),
93059
- columns: { id: true, timebackId: true }
93060
- });
93061
- if (!userData) {
93062
- throw new NotFoundError("User", userId);
93297
+ const enrollments = await this.fetchEnrollments(await this.requireTimebackId(userId));
93298
+ return gameId ? filterEnrollmentsByGame(enrollments, gameId) : enrollments;
93299
+ });
93300
+ }
93301
+ async requireTimebackId(userId) {
93302
+ const userData = await this.deps.db.query.users.findFirst({
93303
+ where: eq(users.id, userId),
93304
+ columns: { id: true, timebackId: true }
93305
+ });
93306
+ if (!userData) {
93307
+ throw new NotFoundError("User", userId);
93308
+ }
93309
+ if (!userData.timebackId) {
93310
+ throw new NotFoundError("Timeback account not found for user");
93311
+ }
93312
+ return userData.timebackId;
93313
+ }
93314
+ async getHoleFillingContext(userId, gameId) {
93315
+ return this.withClientTelemetry(async () => {
93316
+ setAttribute("app.timeback.operation", "get_hole_filling_context");
93317
+ const studentId = await this.requireTimebackId(userId);
93318
+ const enrollments = filterEnrollmentsByGame(await this.fetchEnrollments(studentId), gameId);
93319
+ const holeFilling = enrollments.filter((enrollment) => enrollment.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND);
93320
+ setAttribute("app.timeback.hole_filling_enrollment_count", holeFilling.length);
93321
+ if (holeFilling.length === 0) {
93322
+ return { contexts: [] };
93063
93323
  }
93064
- if (!userData.timebackId) {
93065
- throw new NotFoundError("Timeback account not found for user");
93324
+ const contexts = await Promise.all(holeFilling.map((enrollment) => this.buildHoleFillingContext(studentId, enrollment)));
93325
+ return { contexts };
93326
+ });
93327
+ }
93328
+ async buildHoleFillingContext(studentId, enrollment) {
93329
+ const client = this.requireClient();
93330
+ const grade = enrollment.grade;
93331
+ const subject = enrollment.subject;
93332
+ const enrollmentContext = {
93333
+ courseId: enrollment.courseId,
93334
+ baseCourseId: enrollment.baseCourseId ?? "",
93335
+ ...enrollment.id && { enrollmentId: enrollment.id },
93336
+ grade,
93337
+ subject,
93338
+ beginDate: enrollment.beginDate ?? null
93339
+ };
93340
+ const stampedAssignmentId = assignmentIdOf({ metadata: enrollment.enrollmentMetadata });
93341
+ let parent = null;
93342
+ if (stampedAssignmentId) {
93343
+ const where = {
93344
+ ...buildAssignmentResultWhere({
93345
+ studentId,
93346
+ assignmentId: stampedAssignmentId,
93347
+ resultType: "assessment"
93348
+ }),
93349
+ scoreStatus: SCORE_STATUS2.fullyGraded
93350
+ };
93351
+ const page = await client.api.oneroster.assessmentResults.list({
93352
+ limit: 1,
93353
+ where,
93354
+ sort: "scoreDate",
93355
+ orderBy: "desc"
93356
+ });
93357
+ parent = page.data[0] ?? null;
93358
+ }
93359
+ const summary = parent ? toTriggeringResult(parent) : null;
93360
+ let trigger = "not_stamped";
93361
+ if (stampedAssignmentId) {
93362
+ trigger = summary ? "found" : "not_found";
93363
+ }
93364
+ setAttributes({
93365
+ "app.timeback.hole_filling_trigger": trigger,
93366
+ "app.timeback.hole_filling_assignment_id": stampedAssignmentId
93367
+ });
93368
+ if (!parent || !summary) {
93369
+ return {
93370
+ enrollment: enrollmentContext,
93371
+ triggeringResult: null,
93372
+ missedStandards: [],
93373
+ untaggedMissedQuestionNumbers: []
93374
+ };
93375
+ }
93376
+ const cacheKey = `${enrollment.courseId}:${studentId}:${summary.resultId}`;
93377
+ const cached = this.holeFillingContextCache.get(cacheKey);
93378
+ if (cached && Date.now() - cached.at < TimebackService.HOLE_FILLING_CONTEXT_TTL_MS) {
93379
+ return { ...cached.context, enrollment: enrollmentContext };
93380
+ }
93381
+ const { context, complete } = await this.resolveMissedStandards(studentId, parent, summary, enrollmentContext);
93382
+ setAttribute("app.timeback.hole_filling_standards_complete", complete);
93383
+ if (complete) {
93384
+ setBounded(this.holeFillingContextCache, cacheKey, { at: Date.now(), context }, TimebackService.HOLE_FILLING_CONTEXT_CACHE_SIZE);
93385
+ }
93386
+ return context;
93387
+ }
93388
+ async resolveMissedStandards(studentId, parent, summary, enrollmentContext) {
93389
+ const client = this.requireClient();
93390
+ let complete = true;
93391
+ let missed = missedQuestionsFromInlineResponses(parent);
93392
+ if (!missed) {
93393
+ const where = buildAssignmentResultWhere({
93394
+ studentId,
93395
+ assignmentId: summary.assignmentId,
93396
+ resultType: "question"
93397
+ });
93398
+ const questionResults = await client.api.oneroster.assessmentResults.listAll({
93399
+ limit: 200,
93400
+ where,
93401
+ sort: "scoreDate",
93402
+ orderBy: "desc"
93403
+ });
93404
+ missed = missedQuestionsFromQuestionResults(questionResults);
93405
+ }
93406
+ let qtiTestId = "";
93407
+ let qtiQuestions = [];
93408
+ const standardsByItemId = new Map;
93409
+ if (missed.length > 0 && !client.hasQti) {
93410
+ complete = false;
93411
+ missed = missed.map(({ questionNumber }) => ({ questionNumber }));
93412
+ } else if (missed.some((question) => !question.qtiItemId)) {
93413
+ const lineItem = await client.api.oneroster.assessmentLineItems.get(summary.lineItemId).catch(() => null);
93414
+ qtiTestId = lineItem ? await resolveQtiTestId(client, resolveGradeLevelTestName(parent, lineItem), lineItem) : "";
93415
+ complete &&= Boolean(qtiTestId);
93416
+ if (qtiTestId) {
93417
+ qtiQuestions = await client.qtiApi.assessmentTests.getQuestions(qtiTestId).then((response) => response.questions ?? []).catch(() => {
93418
+ complete = false;
93419
+ return [];
93420
+ });
93421
+ for (const question of qtiQuestions) {
93422
+ const id = question.question.identifier || question.reference.identifier;
93423
+ if (id) {
93424
+ standardsByItemId.set(id, standardsFromQtiItem(question.question));
93425
+ }
93426
+ }
93066
93427
  }
93067
- const enrollments = await this.fetchEnrollments(userData.timebackId);
93068
- return gameId ? filterEnrollmentsByGame(enrollments, gameId) : enrollments;
93428
+ }
93429
+ const itemIdsToFetch = [
93430
+ ...new Set(missed.flatMap((question) => question.qtiItemId && !standardsByItemId.has(question.qtiItemId) ? [question.qtiItemId] : []))
93431
+ ];
93432
+ const items = await Promise.all(itemIdsToFetch.map((id) => client.qtiApi.assessmentItems.get(id).then((item) => [id, standardsFromQtiItem(item)]).catch(() => {
93433
+ complete = false;
93434
+ return [id, []];
93435
+ })));
93436
+ for (const [id, standards] of items) {
93437
+ standardsByItemId.set(id, standards);
93438
+ }
93439
+ const { missedStandards, untaggedQuestionNumbers } = groupMissedStandards(missed, standardsByItemId, qtiItemIdForQuestionNumber(qtiQuestions));
93440
+ setAttributes({
93441
+ "app.timeback.hole_filling_missed_question_count": missed.length,
93442
+ "app.timeback.hole_filling_missed_standard_count": missedStandards.length,
93443
+ "app.timeback.hole_filling_untagged_question_count": untaggedQuestionNumbers.length
93069
93444
  });
93445
+ return {
93446
+ context: {
93447
+ enrollment: enrollmentContext,
93448
+ triggeringResult: { ...summary, ...qtiTestId && { qtiTestId } },
93449
+ missedStandards,
93450
+ untaggedMissedQuestionNumbers: untaggedQuestionNumbers
93451
+ },
93452
+ complete
93453
+ };
93070
93454
  }
93071
93455
  async getUserDataByTimebackId(timebackId) {
93072
93456
  return this.withClientTelemetry(async () => {
@@ -93910,17 +94294,21 @@ var init_timeback_service = __esm(async () => {
93910
94294
  activityData
93911
94295
  });
93912
94296
  await this.deps.validateDeveloperAccess(user, gameId);
93913
- const integration = await db.query.gameTimebackIntegrations.findFirst({
93914
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveBaseGameTimebackIntegration())
93915
- });
93916
- if (!integration) {
93917
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
93918
- }
93919
94297
  const {
94298
+ integration,
93920
94299
  courseId: runtimeCourseId,
94300
+ baseCourseId,
94301
+ otherCourseIds,
93921
94302
  courseName: runtimeCourseName,
94303
+ extensions: runtimeExtensions,
93922
94304
  attributes: courseAttributes
93923
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
94305
+ } = await this.resolveRuntimeCourse({
94306
+ gameId,
94307
+ studentId,
94308
+ runId,
94309
+ activityData,
94310
+ extensions: extensionsWithResumeId
94311
+ });
93924
94312
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
93925
94313
  const dedupeKey = runId !== undefined ? {
93926
94314
  gameId,
@@ -93949,12 +94337,12 @@ var init_timeback_service = __esm(async () => {
93949
94337
  courseId: runtimeCourseId,
93950
94338
  courseName: runtimeCourseName,
93951
94339
  studentEmail: activityData.studentEmail,
93952
- extensions: extensionsWithResumeId,
94340
+ extensions: runtimeExtensions,
93953
94341
  ...runId ? { runId } : {}
93954
94342
  };
93955
- const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion") : undefined;
93956
- const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", effectiveResumeId) : undefined;
93957
- const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed") : undefined;
94343
+ const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion", baseCourseId) : undefined;
94344
+ const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", baseCourseId, effectiveResumeId) : undefined;
94345
+ const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed", baseCourseId) : undefined;
93958
94346
  function emitRecord() {
93959
94347
  return client.activity.record(runtimeCourseId, studentId, {
93960
94348
  ...activityEventContext,
@@ -93978,7 +94366,7 @@ var init_timeback_service = __esm(async () => {
93978
94366
  xpAwarded: emitted.xpAwarded,
93979
94367
  masteredUnitsApplied: emitted.masteredUnitsApplied,
93980
94368
  pctCompleteApp: emitted.pctCompleteApp ?? null
93981
- }));
94369
+ }), otherCourseIds);
93982
94370
  if (outcome.status === "duplicate") {
93983
94371
  blockedByDedupeGuard = true;
93984
94372
  duplicateAward = outcome.award;
@@ -94026,6 +94414,7 @@ var init_timeback_service = __esm(async () => {
94026
94414
  return {
94027
94415
  status: "ok",
94028
94416
  courseId: runtimeCourseId,
94417
+ courseKind: integration.kind,
94029
94418
  xpAwarded: duplicateAward?.xpAwarded ?? 0,
94030
94419
  masteredUnits: duplicateAward?.masteredUnitsApplied ?? 0,
94031
94420
  ...duplicateAward?.pctCompleteApp != null ? { pctCompleteApp: duplicateAward.pctCompleteApp } : {},
@@ -94038,6 +94427,7 @@ var init_timeback_service = __esm(async () => {
94038
94427
  return {
94039
94428
  status: "ok",
94040
94429
  courseId: runtimeCourseId,
94430
+ courseKind: integration.kind,
94041
94431
  xpAwarded: result.xpAwarded,
94042
94432
  masteredUnits: result.masteredUnitsApplied,
94043
94433
  pctCompleteApp: result.pctCompleteApp,
@@ -94214,7 +94604,6 @@ var init_timeback_service = __esm(async () => {
94214
94604
  }) {
94215
94605
  return this.withClientTelemetry(async () => {
94216
94606
  const client = this.requireClient();
94217
- const db = this.deps.db;
94218
94607
  const hasWindowStartedAtMs = windowStartedAtMs !== undefined;
94219
94608
  const hasWindowSequence = windowSequence !== undefined;
94220
94609
  if (hasWindowStartedAtMs === hasWindowSequence) {
@@ -94242,17 +94631,17 @@ var init_timeback_service = __esm(async () => {
94242
94631
  return inFlightHeartbeat;
94243
94632
  }
94244
94633
  const pendingHeartbeat = (async () => {
94245
- const integration = await db.query.gameTimebackIntegrations.findFirst({
94246
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveBaseGameTimebackIntegration())
94247
- });
94248
- if (!integration) {
94249
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
94250
- }
94251
94634
  const {
94252
94635
  courseId: runtimeCourseId,
94253
94636
  courseName: runtimeCourseName,
94637
+ extensions: runtimeExtensions,
94254
94638
  attributes: courseAttributes
94255
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
94639
+ } = await this.resolveRuntimeCourse({
94640
+ gameId,
94641
+ studentId,
94642
+ activityData,
94643
+ extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId)
94644
+ });
94256
94645
  const activeTimeSeconds = timingData.activeMs / 1000;
94257
94646
  const inactiveTimeSeconds = timingData.pausedMs / 1000;
94258
94647
  if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
@@ -94268,7 +94657,7 @@ var init_timeback_service = __esm(async () => {
94268
94657
  courseId: runtimeCourseId,
94269
94658
  courseName: runtimeCourseName,
94270
94659
  studentEmail: activityData.studentEmail,
94271
- extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId),
94660
+ extensions: runtimeExtensions,
94272
94661
  ...runId ? { runId } : {}
94273
94662
  });
94274
94663
  }
@@ -96621,33 +97010,33 @@ var init_auth_provider = __esm(async () => {
96621
97010
  function createSandboxCacheProvider() {
96622
97011
  return {
96623
97012
  async get(key) {
96624
- const entry = cache.get(key);
97013
+ const entry = cache2.get(key);
96625
97014
  if (!entry) {
96626
97015
  return null;
96627
97016
  }
96628
97017
  if (entry.expiresAt < Date.now()) {
96629
- cache.delete(key);
97018
+ cache2.delete(key);
96630
97019
  return null;
96631
97020
  }
96632
97021
  return entry.value;
96633
97022
  },
96634
97023
  async set(key, value, ttlSeconds = 300) {
96635
- cache.set(key, {
97024
+ cache2.set(key, {
96636
97025
  value,
96637
97026
  expiresAt: Date.now() + ttlSeconds * 1000
96638
97027
  });
96639
97028
  },
96640
97029
  async delete(key) {
96641
- cache.delete(key);
97030
+ cache2.delete(key);
96642
97031
  }
96643
97032
  };
96644
97033
  }
96645
97034
  function clearSandboxCache() {
96646
- cache.clear();
97035
+ cache2.clear();
96647
97036
  }
96648
- var cache;
97037
+ var cache2;
96649
97038
  var init_cache_provider = __esm(() => {
96650
- cache = new Map;
97039
+ cache2 = new Map;
96651
97040
  });
96652
97041
 
96653
97042
  // ../utils/src/keys.ts
@@ -152875,7 +153264,7 @@ function parseQtiLibraryParams(searchParams) {
152875
153264
  limit
152876
153265
  };
152877
153266
  }
152878
- var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, getHoleFillingCourses, createHoleFillingCourse, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, prepareRuntimeAssessment, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, resumeRuntimeAssessment, saveRuntimeAssessment, recordRuntimeAssessmentProgress, submitRuntimeAssessmentItem, submitRuntimeAssessment, finalizeRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, attachExistingAssessments, updateAssessment, reviseAssessment, synchronizeReviewBank, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
153267
+ var populateStudent, getUser, getUserEnrollments, getUserHoleFillingContext, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, getHoleFillingCourses, createHoleFillingCourse, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, prepareRuntimeAssessment, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, resumeRuntimeAssessment, saveRuntimeAssessment, recordRuntimeAssessmentProgress, submitRuntimeAssessmentItem, submitRuntimeAssessment, finalizeRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, attachExistingAssessments, updateAssessment, reviseAssessment, synchronizeReviewBank, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
152879
153268
  var init_timeback_controller = __esm(() => {
152880
153269
  init_esm();
152881
153270
  init_src();
@@ -152901,6 +153290,12 @@ var init_timeback_controller = __esm(() => {
152901
153290
  });
152902
153291
  getUser = requireNonAnonymous(async (ctx) => ctx.services.timeback.getUserData(ctx.user.id, ctx.gameId));
152903
153292
  getUserEnrollments = requireNonAnonymous(async (ctx) => ctx.services.timeback.getUserEnrollments(ctx.user.id, ctx.gameId));
153293
+ getUserHoleFillingContext = requireNonAnonymous(async (ctx) => {
153294
+ if (!ctx.gameId) {
153295
+ throw ApiError.forbidden("Hole-filling context requires a game token");
153296
+ }
153297
+ return ctx.services.timeback.getHoleFillingContext(ctx.user.id, ctx.gameId);
153298
+ });
152904
153299
  getUserById = requireNonAnonymous(async (ctx) => {
152905
153300
  const timebackId = ctx.params.timebackId;
152906
153301
  if (!timebackId) {
@@ -153754,6 +154149,7 @@ var init_timeback_controller = __esm(() => {
153754
154149
  populateStudent,
153755
154150
  getUser,
153756
154151
  getUserEnrollments,
154152
+ getUserHoleFillingContext,
153757
154153
  getUserById,
153758
154154
  setupIntegration,
153759
154155
  getIntegrations,
@@ -154816,6 +155212,12 @@ var init_timeback8 = __esm(async () => {
154816
155212
  }
154817
155213
  return handle2(timeback2.getUserEnrollments)(c);
154818
155214
  });
155215
+ timebackRouter.get("/user/hole-filling-context", async (c) => {
155216
+ if (shouldMockTimeback()) {
155217
+ return c.json({ contexts: [] });
155218
+ }
155219
+ return handle2(timeback2.getUserHoleFillingContext)(c);
155220
+ });
154819
155221
  timebackRouter.get("/user/:timebackId", async (c) => {
154820
155222
  const user = c.get("user");
154821
155223
  if (!user) {