@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/server.js CHANGED
@@ -697,7 +697,7 @@ var package_default;
697
697
  var init_package = __esm(() => {
698
698
  package_default = {
699
699
  name: "@playcademy/sandbox",
700
- version: "0.12.2-beta.2",
700
+ version: "0.12.2-beta.4",
701
701
  description: "Local development server for Playcademy game development",
702
702
  type: "module",
703
703
  exports: {
@@ -21068,6 +21068,9 @@ var init_helpers = __esm(() => {
21068
21068
  function isBaseGameTimebackIntegration() {
21069
21069
  return sql`${gameTimebackIntegrations.kind} = ${BASE_GAME_TIMEBACK_INTEGRATION_KIND}`;
21070
21070
  }
21071
+ function isBaseGameTimebackIntegrationRow(integration) {
21072
+ return integration.kind === BASE_GAME_TIMEBACK_INTEGRATION_KIND;
21073
+ }
21071
21074
  function isActiveGameTimebackIntegrationStatus(statusColumn = gameTimebackIntegrations.status) {
21072
21075
  return sql`${statusColumn} IS DISTINCT FROM ${DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS}`;
21073
21076
  }
@@ -48676,6 +48679,7 @@ var init_schemas4 = __esm(() => {
48676
48679
  appName: stringType().optional(),
48677
48680
  sensorUrl: stringType().url().optional(),
48678
48681
  courseId: stringType().min(1).optional(),
48682
+ enrollmentId: stringType().min(1).optional(),
48679
48683
  courseName: stringType().optional(),
48680
48684
  studentEmail: stringType().email().optional()
48681
48685
  });
@@ -80041,6 +80045,9 @@ function normalizedIdentityCase2(value) {
80041
80045
  function frameworkAliasKey2(value) {
80042
80046
  return value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/g, "");
80043
80047
  }
80048
+ function isUuidShaped2(value) {
80049
+ return UUID_PATTERN2.test(value.trim());
80050
+ }
80044
80051
  function isCommonCoreMathIdentifier2(identifier) {
80045
80052
  const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
80046
80053
  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);
@@ -80072,6 +80079,14 @@ function canonicalIdentifier2(framework, authoredIdentifier) {
80072
80079
  }
80073
80080
  return identifier;
80074
80081
  }
80082
+ function readableAlignmentIdentifier2(alignment) {
80083
+ const identifier = alignment.identifier?.trim() ?? "";
80084
+ if (identifier && !isUuidShaped2(identifier)) {
80085
+ return identifier;
80086
+ }
80087
+ const id = alignment.id.trim();
80088
+ return id && !isUuidShaped2(id) ? id : "";
80089
+ }
80075
80090
  function canonicalAssessmentStandardRef2(standard) {
80076
80091
  const authoredFramework = normalizedWhitespace2(standard.framework);
80077
80092
  const framework = canonicalFramework2(authoredFramework, standard.identifier);
@@ -80178,7 +80193,23 @@ function qtiStandardsFromMetadata(metadata2) {
80178
80193
  standards.push(...qtiAlignmentStandardsFromMetadata2(metadata2));
80179
80194
  return dedupeStandards2(standards);
80180
80195
  }
80181
- 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;
80196
+ function qtiAssessmentStandardRefsFromMetadata2(metadata2) {
80197
+ const refs = new Map;
80198
+ for (const alignment of qtiAlignmentStandardsFromMetadata2(metadata2)) {
80199
+ const identifier = readableAlignmentIdentifier2(alignment);
80200
+ if (identifier) {
80201
+ const canonical = canonicalAssessmentStandardRef2({
80202
+ framework: alignment.source,
80203
+ identifier
80204
+ });
80205
+ if (canonical.framework && canonical.identifier) {
80206
+ refs.set(assessmentStandardRefKey2(canonical), canonical);
80207
+ }
80208
+ }
80209
+ }
80210
+ return [...refs.values()];
80211
+ }
80212
+ 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;
80182
80213
  var init_qti = __esm(() => {
80183
80214
  init_timeback4();
80184
80215
  init_timeback4();
@@ -80540,6 +80571,7 @@ var init_qti = __esm(() => {
80540
80571
  this.name = "QtiScoringArtifactValidationError";
80541
80572
  }
80542
80573
  };
80574
+ 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;
80543
80575
  COMMON_CORE_MATH_FRAMEWORK_ALIASES2 = new Set([
80544
80576
  "ccssm",
80545
80577
  "ccssmath",
@@ -80749,7 +80781,7 @@ function pruneUnreadableStandards(standards) {
80749
80781
  return standards.filter(hasReadableStandardLabel);
80750
80782
  }
80751
80783
  function parseQuestionIndex(title) {
80752
- const match = title.match(/\bquestion[\s_-]*(\d+)\b/i);
80784
+ const match = title?.match(/\bquestion[\s_-]*(\d+)\b/i);
80753
80785
  const index = match ? Number(match[1]) : Number.NaN;
80754
80786
  return Number.isFinite(index) && index > 0 ? index : null;
80755
80787
  }
@@ -80783,14 +80815,15 @@ function qtiQuestionsByIndex(qtiQuestions) {
80783
80815
  }
80784
80816
  return questionsByIndex;
80785
80817
  }
80818
+ function normalizedQuestionScore(score, scorePercentile) {
80819
+ const rawValue = numberField(score) ?? numberField(scorePercentile);
80820
+ return rawValue !== null && rawValue > 1 ? rawValue / 100 : rawValue;
80821
+ }
80786
80822
  function getQuestionCorrectness(result) {
80787
80823
  if (!result) {
80788
80824
  return "unknown";
80789
80825
  }
80790
- const score = numberField(result.score);
80791
- const scorePercentile = numberField(result.scorePercentile);
80792
- const rawValue = score ?? scorePercentile;
80793
- const value = rawValue !== null && rawValue > 1 ? rawValue / 100 : rawValue;
80826
+ const value = normalizedQuestionScore(result.score, result.scorePercentile);
80794
80827
  if (value === null) {
80795
80828
  return "unknown";
80796
80829
  }
@@ -81054,10 +81087,71 @@ var init_timeback_mastery_completion_util = __esm(async () => {
81054
81087
  await init_errors8();
81055
81088
  });
81056
81089
 
81090
+ // ../api-core/src/utils/bounded-map.util.ts
81091
+ function setBounded(map, key, value, maxSize) {
81092
+ map.delete(key);
81093
+ if (map.size >= maxSize) {
81094
+ const oldestKey = map.keys().next().value;
81095
+ if (oldestKey !== undefined) {
81096
+ map.delete(oldestKey);
81097
+ }
81098
+ }
81099
+ map.set(key, value);
81100
+ }
81101
+
81102
+ // ../api-core/src/utils/timeback-qti-test-lookup.util.ts
81103
+ async function resolveQtiTestId(client, testName, parentLineItem) {
81104
+ const cacheKey = `${parentLineItem.sourcedId ?? ""}:${testName}`;
81105
+ const cached = cache.get(cacheKey);
81106
+ if (cached) {
81107
+ setBounded(cache, cacheKey, cached, MAX_CACHE_SIZE);
81108
+ return cached;
81109
+ }
81110
+ let resource = null;
81111
+ const resourceId = sourceIdFromRef(parentLineItem.componentResource);
81112
+ if (resourceId) {
81113
+ resource = await client.api.oneroster.resources.get(resourceId).catch(() => null);
81114
+ }
81115
+ for (const candidate of qtiIdCandidates(parentLineItem, resource)) {
81116
+ const test = await client.qtiApi.assessmentTests.get(candidate).catch(() => null);
81117
+ if (test?.identifier) {
81118
+ setBounded(cache, cacheKey, test.identifier, MAX_CACHE_SIZE);
81119
+ return test.identifier;
81120
+ }
81121
+ }
81122
+ const normalizedTestName = testName.trim();
81123
+ if (!normalizedTestName) {
81124
+ return "";
81125
+ }
81126
+ for (let page = 1;page <= TITLE_LOOKUP_MAX_PAGES; page++) {
81127
+ const list = await client.qtiApi.assessmentTests.list({
81128
+ query: normalizedTestName,
81129
+ limit: TITLE_LOOKUP_LIMIT,
81130
+ page,
81131
+ sort: "title",
81132
+ order: "asc"
81133
+ }).catch(() => null);
81134
+ const qtiTestId = findQtiAssessmentTestIdByTitle(list?.items ?? [], normalizedTestName);
81135
+ if (qtiTestId) {
81136
+ setBounded(cache, cacheKey, qtiTestId, MAX_CACHE_SIZE);
81137
+ return qtiTestId;
81138
+ }
81139
+ const itemCount = list?.items?.length ?? 0;
81140
+ if (!list || itemCount < TITLE_LOOKUP_LIMIT || typeof list.pages === "number" && page >= list.pages) {
81141
+ break;
81142
+ }
81143
+ }
81144
+ return "";
81145
+ }
81146
+ var MAX_CACHE_SIZE = 500, TITLE_LOOKUP_LIMIT = 50, TITLE_LOOKUP_MAX_PAGES = 5, cache;
81147
+ var init_timeback_qti_test_lookup_util = __esm(() => {
81148
+ init_timeback_grade_level_results_util();
81149
+ cache = new Map;
81150
+ });
81151
+
81057
81152
  // ../api-core/src/services/timeback-admin.service.ts
81058
81153
  class TimebackAdminService {
81059
81154
  deps;
81060
- qtiTestIdCache = new Map;
81061
81155
  static XP_PRECISION_FACTOR = 10;
81062
81156
  static RECENT_ACTIVITY_LIMIT = 20;
81063
81157
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
@@ -81072,9 +81166,6 @@ class TimebackAdminService {
81072
81166
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
81073
81167
  static GAME_METRICS_LIST_FETCH_TIMEOUT_MS = 3000;
81074
81168
  static GAME_METRICS_RUN_IDS_PER_REQUEST = 50;
81075
- static MAX_QTI_TEST_ID_CACHE_SIZE = 500;
81076
- static QTI_TEST_TITLE_LOOKUP_LIMIT = 50;
81077
- static QTI_TEST_TITLE_LOOKUP_MAX_PAGES = 5;
81078
81169
  constructor(deps) {
81079
81170
  this.deps = deps;
81080
81171
  }
@@ -81934,68 +82025,6 @@ class TimebackAdminService {
81934
82025
  return { activities: activitiesWithGameMetrics, hasMore };
81935
82026
  });
81936
82027
  }
81937
- rememberQtiTestId(cacheKey, qtiTestId) {
81938
- if (!qtiTestId) {
81939
- this.qtiTestIdCache.delete(cacheKey);
81940
- return;
81941
- }
81942
- if (this.qtiTestIdCache.has(cacheKey)) {
81943
- this.qtiTestIdCache.delete(cacheKey);
81944
- } else if (this.qtiTestIdCache.size >= TimebackAdminService.MAX_QTI_TEST_ID_CACHE_SIZE) {
81945
- const oldestKey = this.qtiTestIdCache.keys().next().value;
81946
- if (oldestKey !== undefined) {
81947
- this.qtiTestIdCache.delete(oldestKey);
81948
- }
81949
- }
81950
- this.qtiTestIdCache.set(cacheKey, qtiTestId);
81951
- }
81952
- async resolveQtiTestIdForGradeLevelResult(client, testName, parentLineItem) {
81953
- const cacheKey = `${parentLineItem.sourcedId ?? ""}:${testName}`;
81954
- const cachedQtiTestId = cacheKey ? this.qtiTestIdCache.get(cacheKey) : undefined;
81955
- if (cachedQtiTestId) {
81956
- this.rememberQtiTestId(cacheKey, cachedQtiTestId);
81957
- return cachedQtiTestId;
81958
- }
81959
- let resource = null;
81960
- const resourceId = sourceIdFromRef(parentLineItem.componentResource);
81961
- if (resourceId) {
81962
- resource = await client.api.oneroster.resources.get(resourceId).catch(() => null);
81963
- }
81964
- for (const candidate of qtiIdCandidates(parentLineItem, resource)) {
81965
- const test = await client.qtiApi.assessmentTests.get(candidate).catch(() => null);
81966
- if (test?.identifier) {
81967
- if (cacheKey) {
81968
- this.rememberQtiTestId(cacheKey, test.identifier);
81969
- }
81970
- return test.identifier;
81971
- }
81972
- }
81973
- const normalizedTestName = testName.trim();
81974
- if (!normalizedTestName) {
81975
- return "";
81976
- }
81977
- for (let page = 1;page <= TimebackAdminService.QTI_TEST_TITLE_LOOKUP_MAX_PAGES; page++) {
81978
- const list = await client.qtiApi.assessmentTests.list({
81979
- query: normalizedTestName,
81980
- limit: TimebackAdminService.QTI_TEST_TITLE_LOOKUP_LIMIT,
81981
- page,
81982
- sort: "title",
81983
- order: "asc"
81984
- }).catch(() => null);
81985
- const qtiTestId = findQtiAssessmentTestIdByTitle(list?.items ?? [], normalizedTestName);
81986
- if (qtiTestId) {
81987
- if (cacheKey) {
81988
- this.rememberQtiTestId(cacheKey, qtiTestId);
81989
- }
81990
- return qtiTestId;
81991
- }
81992
- const itemCount = list?.items?.length ?? 0;
81993
- if (!list || itemCount < TimebackAdminService.QTI_TEST_TITLE_LOOKUP_LIMIT || typeof list.pages === "number" && page >= list.pages) {
81994
- break;
81995
- }
81996
- }
81997
- return "";
81998
- }
81999
82028
  async getGradeLevelTestResults(user, options) {
82000
82029
  return this.withClientTelemetry(async () => {
82001
82030
  const { gameId, courseId, studentId } = options;
@@ -82098,7 +82127,7 @@ class TimebackAdminService {
82098
82127
  }).then((page) => page.data) : [];
82099
82128
  const latestResultsByLineItem = latestAssessmentResultsByLineItem(questionResults);
82100
82129
  const testName = resolveGradeLevelTestName(parentResult, parentLineItem);
82101
- const qtiTestId = await this.resolveQtiTestIdForGradeLevelResult(client, testName, parentLineItem);
82130
+ const qtiTestId = await resolveQtiTestId(client, testName, parentLineItem);
82102
82131
  const qtiQuestions = qtiTestId ? await client.qtiApi.assessmentTests.getQuestions(qtiTestId).then((response) => response.questions ?? []).catch(() => []) : [];
82103
82132
  const questions = mapGradeLevelReviewQuestions({
82104
82133
  questionLineItems,
@@ -82792,6 +82821,7 @@ var init_timeback_admin_service = __esm(async () => {
82792
82821
  init_timeback_discrepancy_queue_util();
82793
82822
  init_timeback_game_metrics_comparison_util();
82794
82823
  init_timeback_grade_level_results_util();
82824
+ init_timeback_qti_test_lookup_util();
82795
82825
  init_timeback_util();
82796
82826
  await __promiseAll([
82797
82827
  init_dist5(),
@@ -90690,6 +90720,22 @@ var init_timeback_assessments_service = __esm(async () => {
90690
90720
  await init_errors8();
90691
90721
  });
90692
90722
 
90723
+ // ../api-core/src/utils/timeback-attribution.util.ts
90724
+ function resolveRuntimeAttribution(base, holeFilling, requestedCourseId, recordedCourseId) {
90725
+ const recorded = [base, ...holeFilling].find((integration) => integration.courseId === recordedCourseId);
90726
+ if (recorded) {
90727
+ return { integration: recorded, requestedCourseIdStatus: "pinned_by_run" };
90728
+ }
90729
+ if (requestedCourseId === undefined) {
90730
+ return { integration: base, requestedCourseIdStatus: "absent" };
90731
+ }
90732
+ if (requestedCourseId === base.courseId) {
90733
+ return { integration: base, requestedCourseIdStatus: "accepted" };
90734
+ }
90735
+ const requestedHoleFilling = holeFilling.find((integration) => integration.courseId === requestedCourseId);
90736
+ return requestedHoleFilling ? { integration: requestedHoleFilling, requestedCourseIdStatus: "accepted_hole_filling" } : { integration: base, requestedCourseIdStatus: "ignored_mismatch" };
90737
+ }
90738
+
90693
90739
  // ../api-core/src/utils/timeback-create-integration.util.ts
90694
90740
  function buildTimebackBaseConfigFromExistingConfig(config) {
90695
90741
  return {
@@ -90782,6 +90828,128 @@ var init_timeback_create_integration_util = __esm(() => {
90782
90828
  init_timeback_util();
90783
90829
  });
90784
90830
 
90831
+ // ../api-core/src/utils/timeback-hole-filling-context.util.ts
90832
+ function buildAssignmentResultWhere(options) {
90833
+ return {
90834
+ status: "active",
90835
+ "student.sourcedId": options.studentId,
90836
+ "metadata.resultType": options.resultType,
90837
+ "metadata.assignmentId": options.assignmentId
90838
+ };
90839
+ }
90840
+ function assignmentIdOf(record) {
90841
+ const value = metadataOf(record).assignmentId;
90842
+ if (typeof value === "number" && Number.isFinite(value)) {
90843
+ return String(value);
90844
+ }
90845
+ const text = stringField3(value).trim();
90846
+ return text || undefined;
90847
+ }
90848
+ function toTriggeringResult(result) {
90849
+ const metadata2 = metadataOf(result);
90850
+ const testType = normalizeTestType(metadata2.testType);
90851
+ const assignmentId = assignmentIdOf(result);
90852
+ const lineItemId = sourceIdFromRef(result.assessmentLineItem);
90853
+ if (!assignmentId || !lineItemId || result.scoreStatus !== SCORE_STATUS2.fullyGraded || !isAllowedGradeLevelTestType(testType)) {
90854
+ return null;
90855
+ }
90856
+ return {
90857
+ resultId: result.sourcedId ?? "",
90858
+ assignmentId,
90859
+ lineItemId,
90860
+ testName: stringField3(metadata2.testName) || lineItemId,
90861
+ testType,
90862
+ score: numberField(result.score),
90863
+ scoreDate: result.scoreDate ?? null,
90864
+ totalQuestions: numberField(metadata2.totalQuestions),
90865
+ correctQuestions: numberField(metadata2.correctQuestions)
90866
+ };
90867
+ }
90868
+ function isMissed(resultText, score, scorePercentile) {
90869
+ const marking = stringField3(resultText).trim().toLowerCase();
90870
+ if (marking) {
90871
+ return marking !== "correct";
90872
+ }
90873
+ const value = normalizedQuestionScore(score, scorePercentile);
90874
+ return value !== null && value < 1;
90875
+ }
90876
+ function missedQuestionsFromInlineResponses(result) {
90877
+ const responses = metadataOf(result).responses;
90878
+ const questions = isRecord6(responses) && Array.isArray(responses.questions) ? responses.questions : undefined;
90879
+ if (!questions?.length) {
90880
+ return;
90881
+ }
90882
+ return questions.flatMap((question) => {
90883
+ if (!isRecord6(question) || !isMissed(question.result, question.score)) {
90884
+ return [];
90885
+ }
90886
+ const questionNumber = numberField(question.questionNumber);
90887
+ const qtiItemId = stringField3(question.questionQtiIdentifier).trim();
90888
+ return questionNumber === null ? [] : [{ questionNumber, ...qtiItemId && { qtiItemId } }];
90889
+ });
90890
+ }
90891
+ function questionNumberFromLineItemId(lineItemId) {
90892
+ const match = lineItemId.match(/_q(\d+)$/);
90893
+ const value = match ? Number(match[1]) : Number.NaN;
90894
+ return Number.isInteger(value) && value > 0 ? value : null;
90895
+ }
90896
+ function missedQuestionsFromQuestionResults(questionResults) {
90897
+ return [...latestAssessmentResultsByLineItem(questionResults)].flatMap(([lineItemId, result]) => {
90898
+ const questionNumber = questionNumberFromLineItemId(lineItemId);
90899
+ const responses = metadataOf(result).responses;
90900
+ const response = isRecord6(responses) && isRecord6(responses.response) ? responses.response : undefined;
90901
+ if (questionNumber === null || !isMissed(response?.result, result.score, result.scorePercentile)) {
90902
+ return [];
90903
+ }
90904
+ const qtiItemId = stringField3(response?.questionQtiIdentifier).trim();
90905
+ return [{ questionNumber, ...qtiItemId && { qtiItemId } }];
90906
+ }).toSorted((a, b) => a.questionNumber - b.questionNumber);
90907
+ }
90908
+ function qtiItemIdForQuestionNumber(qtiQuestions) {
90909
+ const byNumber = qtiQuestionsByIndex(qtiQuestions);
90910
+ return (questionNumber) => {
90911
+ const question = byNumber.get(questionNumber) ?? qtiQuestions[questionNumber - 1];
90912
+ return question ? question.question.identifier || question.reference.identifier : undefined;
90913
+ };
90914
+ }
90915
+ function groupMissedStandards(missed, standardsByItemId, itemIdFor) {
90916
+ const byKey = new Map;
90917
+ const untagged = [];
90918
+ for (const question of missed) {
90919
+ const itemId = question.qtiItemId ?? itemIdFor(question.questionNumber);
90920
+ const standards = itemId ? standardsByItemId.get(itemId) ?? [] : [];
90921
+ if (!standards.length) {
90922
+ untagged.push(question.questionNumber);
90923
+ }
90924
+ for (const standard of standards) {
90925
+ const key = `${standard.framework}\x00${standard.identifier}`;
90926
+ const entry = byKey.get(key) ?? {
90927
+ framework: standard.framework,
90928
+ code: standard.identifier,
90929
+ questionNumbers: [],
90930
+ qtiItemIds: []
90931
+ };
90932
+ entry.questionNumbers.push(question.questionNumber);
90933
+ if (itemId && !entry.qtiItemIds.includes(itemId)) {
90934
+ entry.qtiItemIds.push(itemId);
90935
+ }
90936
+ byKey.set(key, entry);
90937
+ }
90938
+ }
90939
+ return {
90940
+ missedStandards: [...byKey.values()].toSorted((a, b) => b.questionNumbers.length - a.questionNumbers.length || a.code.localeCompare(b.code, undefined, { numeric: true })),
90941
+ untaggedQuestionNumbers: untagged.toSorted((a, b) => a - b)
90942
+ };
90943
+ }
90944
+ function standardsFromQtiItem(item) {
90945
+ return qtiAssessmentStandardRefsFromMetadata2(metadataOf(item));
90946
+ }
90947
+ var init_timeback_hole_filling_context_util = __esm(() => {
90948
+ init_constants2();
90949
+ init_qti();
90950
+ init_timeback_grade_level_results_util();
90951
+ });
90952
+
90785
90953
  // ../api-core/src/utils/timeback-promotion.util.ts
90786
90954
  async function promoteCompletedCourse({
90787
90955
  db,
@@ -90911,14 +91079,20 @@ var init_timeback_service = __esm(async () => {
90911
91079
  init_helpers_index();
90912
91080
  init_tables_index();
90913
91081
  init_spans();
91082
+ init_constants2();
90914
91083
  init_types2();
90915
91084
  init_errors2();
90916
91085
  init_timeback_create_integration_util();
91086
+ init_timeback_grade_level_results_util();
91087
+ init_timeback_hole_filling_context_util();
90917
91088
  init_timeback_promotion_util();
91089
+ init_timeback_qti_test_lookup_util();
90918
91090
  init_timeback_removed_integration_util();
90919
91091
  init_timeback_util();
90920
91092
  await init_dist5();
90921
91093
  TimebackService = class TimebackService {
91094
+ static HOLE_FILLING_CONTEXT_TTL_MS = 10 * 60 * 1000;
91095
+ static HOLE_FILLING_CONTEXT_CACHE_SIZE = 1000;
90922
91096
  static HEARTBEAT_DEDUPE_TTL_MS = 5 * 60 * 1000;
90923
91097
  static processedHeartbeatWindows = new Map;
90924
91098
  static inFlightHeartbeatWindows = new Map;
@@ -90926,6 +91100,7 @@ var init_timeback_service = __esm(async () => {
90926
91100
  static COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS = 400;
90927
91101
  static COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS = 5;
90928
91102
  deps;
91103
+ holeFillingContextCache = new Map;
90929
91104
  static cleanHeartbeatDedupeCache(now = Date.now()) {
90930
91105
  for (const [key, timestamp] of this.processedHeartbeatWindows) {
90931
91106
  if (now - timestamp > this.HEARTBEAT_DEDUPE_TTL_MS) {
@@ -90949,13 +91124,13 @@ var init_timeback_service = __esm(async () => {
90949
91124
  static clearInFlightHeartbeatWindow(key) {
90950
91125
  this.inFlightHeartbeatWindows.delete(key);
90951
91126
  }
90952
- static buildCompletionCaliperEventId(key, kind, discriminator = "completion") {
91127
+ static buildCompletionCaliperEventId(key, kind, baseCourseId, discriminator = "completion") {
90953
91128
  const hash = createHash3("sha256").update([
90954
91129
  "playcademy",
90955
91130
  "timeback",
90956
91131
  kind,
90957
91132
  key.gameId,
90958
- key.courseId,
91133
+ baseCourseId,
90959
91134
  key.studentId,
90960
91135
  key.runId,
90961
91136
  key.activityId,
@@ -91021,11 +91196,14 @@ var init_timeback_service = __esm(async () => {
91021
91196
  } : null
91022
91197
  };
91023
91198
  }
91024
- static async acquireCompletionReservation(db, key) {
91025
- let reserveOutcome = await this.reserveCompletion(db, key);
91199
+ static async acquireCompletionReservation(db, key, otherCourseIds = []) {
91200
+ function reserve() {
91201
+ return otherCourseIds.length > 0 ? TimebackService.reserveCompletionAcrossCourses(db, key, otherCourseIds) : TimebackService.reserveCompletion(db, key);
91202
+ }
91203
+ let reserveOutcome = await reserve();
91026
91204
  for (let settleAttempt = 0;reserveOutcome.status === "in_flight" && settleAttempt < this.COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS; settleAttempt++) {
91027
91205
  await sleep(this.COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS);
91028
- reserveOutcome = await this.reserveCompletion(db, key);
91206
+ reserveOutcome = await reserve();
91029
91207
  }
91030
91208
  if (reserveOutcome.status === "duplicate") {
91031
91209
  setAttribute("app.timeback.end_activity_status", "blocked_by_dedupe_guard");
@@ -91044,8 +91222,24 @@ var init_timeback_service = __esm(async () => {
91044
91222
  }
91045
91223
  return { status: "reserved", token: reserveOutcome.token };
91046
91224
  }
91047
- static async runWithCompletionReservation(db, key, emit, awardFromResult) {
91048
- const acquired = await this.acquireCompletionReservation(db, key);
91225
+ static async reserveCompletionAcrossCourses(db, key, otherCourseIds) {
91226
+ return db.transaction(async (tx) => {
91227
+ const database = tx;
91228
+ const runKey = `timeback-completion:${key.gameId.toLowerCase()}:${key.studentId}:${key.runId.toLowerCase()}:${key.activityId}`;
91229
+ await database.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${runKey}, 0))`);
91230
+ const onOtherCourse = await database.query.gameTimebackActivityCompletions.findFirst({
91231
+ columns: { id: true },
91232
+ 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]))
91233
+ });
91234
+ if (onOtherCourse) {
91235
+ setAttribute("app.timeback.end_activity_status", "completion_reserved_on_other_course");
91236
+ throw new ServiceUnavailableError("An end-activity submission for this run is already in flight — retry shortly");
91237
+ }
91238
+ return this.reserveCompletion(database, key);
91239
+ });
91240
+ }
91241
+ static async runWithCompletionReservation(db, key, emit, awardFromResult, otherCourseIds = []) {
91242
+ const acquired = await this.acquireCompletionReservation(db, key, otherCourseIds);
91049
91243
  if (acquired.status === "duplicate") {
91050
91244
  return acquired;
91051
91245
  }
@@ -91080,6 +91274,9 @@ var init_timeback_service = __esm(async () => {
91080
91274
  await db.delete(gameTimebackActivityCompletions).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
91081
91275
  }
91082
91276
  static addResumeIdToExtensions(extensions, resumeId) {
91277
+ return TimebackService.addPlaycademyExtensions(extensions, { resumeId });
91278
+ }
91279
+ static addPlaycademyExtensions(extensions, fields) {
91083
91280
  const base = extensions ?? {};
91084
91281
  const existingPlaycademy = base.playcademy;
91085
91282
  const playcademy = typeof existingPlaycademy === "object" && existingPlaycademy !== null && !Array.isArray(existingPlaycademy) ? existingPlaycademy : {};
@@ -91087,28 +91284,71 @@ var init_timeback_service = __esm(async () => {
91087
91284
  ...base,
91088
91285
  playcademy: {
91089
91286
  ...playcademy,
91090
- resumeId
91287
+ ...fields
91091
91288
  }
91092
91289
  };
91093
91290
  }
91094
- static resolveRuntimeCourse(resolvedCourseId, requestedCourseId, courseName) {
91095
- let requestedCourseIdStatus = "absent";
91096
- if (requestedCourseId === resolvedCourseId) {
91097
- requestedCourseIdStatus = "accepted";
91098
- } else if (requestedCourseId !== undefined) {
91099
- requestedCourseIdStatus = "ignored_mismatch";
91291
+ async resolveRuntimeCourse({
91292
+ gameId,
91293
+ studentId,
91294
+ runId,
91295
+ activityData,
91296
+ extensions
91297
+ }) {
91298
+ const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
91299
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveGameTimebackIntegrationStatus())
91300
+ });
91301
+ const base = integrations.find(isBaseGameTimebackIntegrationRow);
91302
+ if (!base) {
91303
+ throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
91100
91304
  }
91305
+ const holeFilling = integrations.filter((integration) => !isBaseGameTimebackIntegrationRow(integration));
91306
+ const recordedCourseId = holeFilling.length > 0 && runId ? await this.findRecordedRunCourse({
91307
+ gameId,
91308
+ studentId,
91309
+ runId,
91310
+ activityId: activityData.activityId,
91311
+ courseIds: integrations.map((i2) => i2.courseId)
91312
+ }) : undefined;
91313
+ const { integration, requestedCourseIdStatus } = resolveRuntimeAttribution(base, holeFilling, activityData.courseId, recordedCourseId);
91314
+ const hintUsed = activityData.courseId === integration.courseId;
91315
+ const enrollmentId = hintUsed ? activityData.enrollmentId : undefined;
91316
+ const isHoleFilling = !isBaseGameTimebackIntegrationRow(integration);
91101
91317
  return {
91102
- courseId: resolvedCourseId,
91103
- courseName: requestedCourseIdStatus === "ignored_mismatch" ? undefined : courseName,
91318
+ integration,
91319
+ courseId: integration.courseId,
91320
+ baseCourseId: base.courseId,
91321
+ otherCourseIds: holeFilling.length > 0 ? integrations.map((other) => other.courseId).filter((courseId) => courseId !== integration.courseId) : [],
91322
+ courseName: activityData.courseId === undefined || hintUsed ? activityData.courseName : undefined,
91323
+ extensions: isHoleFilling ? TimebackService.addPlaycademyExtensions(extensions, {
91324
+ courseKind: HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND,
91325
+ baseCourseId: base.courseId,
91326
+ ...enrollmentId && { enrollmentId }
91327
+ }) : extensions,
91104
91328
  attributes: {
91105
- "app.timeback.course_id": resolvedCourseId,
91106
- "app.timeback.resolved_course_id": resolvedCourseId,
91107
- "app.timeback.requested_course_id": requestedCourseId,
91108
- "app.timeback.requested_course_id_status": requestedCourseIdStatus
91329
+ "app.timeback.course_id": integration.courseId,
91330
+ "app.timeback.resolved_course_id": integration.courseId,
91331
+ "app.timeback.requested_course_id": activityData.courseId,
91332
+ "app.timeback.requested_course_id_status": requestedCourseIdStatus,
91333
+ "app.timeback.course_kind": integration.kind,
91334
+ "app.timeback.enrollment_id": enrollmentId
91109
91335
  }
91110
91336
  };
91111
91337
  }
91338
+ async findRecordedRunCourse({
91339
+ gameId,
91340
+ studentId,
91341
+ runId,
91342
+ activityId,
91343
+ courseIds
91344
+ }) {
91345
+ const recorded = await this.deps.db.query.gameTimebackActivityCompletions.findFirst({
91346
+ columns: { courseId: true },
91347
+ where: and(eq(gameTimebackActivityCompletions.gameId, gameId), eq(gameTimebackActivityCompletions.studentId, studentId), eq(gameTimebackActivityCompletions.runId, runId), eq(gameTimebackActivityCompletions.activityId, activityId), inArray(gameTimebackActivityCompletions.courseId, courseIds)),
91348
+ orderBy: (table, { asc }) => [asc(table.createdAt)]
91349
+ });
91350
+ return recorded?.courseId;
91351
+ }
91112
91352
  static recordRuntimeContext({
91113
91353
  operation,
91114
91354
  studentId,
@@ -91234,19 +91474,163 @@ var init_timeback_service = __esm(async () => {
91234
91474
  }
91235
91475
  async getUserEnrollments(userId, gameId) {
91236
91476
  return this.withClientTelemetry(async () => {
91237
- const userData = await this.deps.db.query.users.findFirst({
91238
- where: eq(users.id, userId),
91239
- columns: { id: true, timebackId: true }
91240
- });
91241
- if (!userData) {
91242
- throw new NotFoundError("User", userId);
91477
+ const enrollments = await this.fetchEnrollments(await this.requireTimebackId(userId));
91478
+ return gameId ? filterEnrollmentsByGame(enrollments, gameId) : enrollments;
91479
+ });
91480
+ }
91481
+ async requireTimebackId(userId) {
91482
+ const userData = await this.deps.db.query.users.findFirst({
91483
+ where: eq(users.id, userId),
91484
+ columns: { id: true, timebackId: true }
91485
+ });
91486
+ if (!userData) {
91487
+ throw new NotFoundError("User", userId);
91488
+ }
91489
+ if (!userData.timebackId) {
91490
+ throw new NotFoundError("Timeback account not found for user");
91491
+ }
91492
+ return userData.timebackId;
91493
+ }
91494
+ async getHoleFillingContext(userId, gameId) {
91495
+ return this.withClientTelemetry(async () => {
91496
+ setAttribute("app.timeback.operation", "get_hole_filling_context");
91497
+ const studentId = await this.requireTimebackId(userId);
91498
+ const enrollments = filterEnrollmentsByGame(await this.fetchEnrollments(studentId), gameId);
91499
+ const holeFilling = enrollments.filter((enrollment) => enrollment.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND);
91500
+ setAttribute("app.timeback.hole_filling_enrollment_count", holeFilling.length);
91501
+ if (holeFilling.length === 0) {
91502
+ return { contexts: [] };
91243
91503
  }
91244
- if (!userData.timebackId) {
91245
- throw new NotFoundError("Timeback account not found for user");
91504
+ const contexts = await Promise.all(holeFilling.map((enrollment) => this.buildHoleFillingContext(studentId, enrollment)));
91505
+ return { contexts };
91506
+ });
91507
+ }
91508
+ async buildHoleFillingContext(studentId, enrollment) {
91509
+ const client = this.requireClient();
91510
+ const grade = enrollment.grade;
91511
+ const subject = enrollment.subject;
91512
+ const enrollmentContext = {
91513
+ courseId: enrollment.courseId,
91514
+ baseCourseId: enrollment.baseCourseId ?? "",
91515
+ ...enrollment.id && { enrollmentId: enrollment.id },
91516
+ grade,
91517
+ subject,
91518
+ beginDate: enrollment.beginDate ?? null
91519
+ };
91520
+ const stampedAssignmentId = assignmentIdOf({ metadata: enrollment.enrollmentMetadata });
91521
+ let parent = null;
91522
+ if (stampedAssignmentId) {
91523
+ const where = {
91524
+ ...buildAssignmentResultWhere({
91525
+ studentId,
91526
+ assignmentId: stampedAssignmentId,
91527
+ resultType: "assessment"
91528
+ }),
91529
+ scoreStatus: SCORE_STATUS2.fullyGraded
91530
+ };
91531
+ const page = await client.api.oneroster.assessmentResults.list({
91532
+ limit: 1,
91533
+ where,
91534
+ sort: "scoreDate",
91535
+ orderBy: "desc"
91536
+ });
91537
+ parent = page.data[0] ?? null;
91538
+ }
91539
+ const summary = parent ? toTriggeringResult(parent) : null;
91540
+ let trigger = "not_stamped";
91541
+ if (stampedAssignmentId) {
91542
+ trigger = summary ? "found" : "not_found";
91543
+ }
91544
+ setAttributes({
91545
+ "app.timeback.hole_filling_trigger": trigger,
91546
+ "app.timeback.hole_filling_assignment_id": stampedAssignmentId
91547
+ });
91548
+ if (!parent || !summary) {
91549
+ return {
91550
+ enrollment: enrollmentContext,
91551
+ triggeringResult: null,
91552
+ missedStandards: [],
91553
+ untaggedMissedQuestionNumbers: []
91554
+ };
91555
+ }
91556
+ const cacheKey = `${enrollment.courseId}:${studentId}:${summary.resultId}`;
91557
+ const cached = this.holeFillingContextCache.get(cacheKey);
91558
+ if (cached && Date.now() - cached.at < TimebackService.HOLE_FILLING_CONTEXT_TTL_MS) {
91559
+ return { ...cached.context, enrollment: enrollmentContext };
91560
+ }
91561
+ const { context, complete } = await this.resolveMissedStandards(studentId, parent, summary, enrollmentContext);
91562
+ setAttribute("app.timeback.hole_filling_standards_complete", complete);
91563
+ if (complete) {
91564
+ setBounded(this.holeFillingContextCache, cacheKey, { at: Date.now(), context }, TimebackService.HOLE_FILLING_CONTEXT_CACHE_SIZE);
91565
+ }
91566
+ return context;
91567
+ }
91568
+ async resolveMissedStandards(studentId, parent, summary, enrollmentContext) {
91569
+ const client = this.requireClient();
91570
+ let complete = true;
91571
+ let missed = missedQuestionsFromInlineResponses(parent);
91572
+ if (!missed) {
91573
+ const where = buildAssignmentResultWhere({
91574
+ studentId,
91575
+ assignmentId: summary.assignmentId,
91576
+ resultType: "question"
91577
+ });
91578
+ const questionResults = await client.api.oneroster.assessmentResults.listAll({
91579
+ limit: 200,
91580
+ where,
91581
+ sort: "scoreDate",
91582
+ orderBy: "desc"
91583
+ });
91584
+ missed = missedQuestionsFromQuestionResults(questionResults);
91585
+ }
91586
+ let qtiTestId = "";
91587
+ let qtiQuestions = [];
91588
+ const standardsByItemId = new Map;
91589
+ if (missed.length > 0 && !client.hasQti) {
91590
+ complete = false;
91591
+ missed = missed.map(({ questionNumber }) => ({ questionNumber }));
91592
+ } else if (missed.some((question) => !question.qtiItemId)) {
91593
+ const lineItem = await client.api.oneroster.assessmentLineItems.get(summary.lineItemId).catch(() => null);
91594
+ qtiTestId = lineItem ? await resolveQtiTestId(client, resolveGradeLevelTestName(parent, lineItem), lineItem) : "";
91595
+ complete &&= Boolean(qtiTestId);
91596
+ if (qtiTestId) {
91597
+ qtiQuestions = await client.qtiApi.assessmentTests.getQuestions(qtiTestId).then((response) => response.questions ?? []).catch(() => {
91598
+ complete = false;
91599
+ return [];
91600
+ });
91601
+ for (const question of qtiQuestions) {
91602
+ const id = question.question.identifier || question.reference.identifier;
91603
+ if (id) {
91604
+ standardsByItemId.set(id, standardsFromQtiItem(question.question));
91605
+ }
91606
+ }
91246
91607
  }
91247
- const enrollments = await this.fetchEnrollments(userData.timebackId);
91248
- return gameId ? filterEnrollmentsByGame(enrollments, gameId) : enrollments;
91608
+ }
91609
+ const itemIdsToFetch = [
91610
+ ...new Set(missed.flatMap((question) => question.qtiItemId && !standardsByItemId.has(question.qtiItemId) ? [question.qtiItemId] : []))
91611
+ ];
91612
+ const items = await Promise.all(itemIdsToFetch.map((id) => client.qtiApi.assessmentItems.get(id).then((item) => [id, standardsFromQtiItem(item)]).catch(() => {
91613
+ complete = false;
91614
+ return [id, []];
91615
+ })));
91616
+ for (const [id, standards] of items) {
91617
+ standardsByItemId.set(id, standards);
91618
+ }
91619
+ const { missedStandards, untaggedQuestionNumbers } = groupMissedStandards(missed, standardsByItemId, qtiItemIdForQuestionNumber(qtiQuestions));
91620
+ setAttributes({
91621
+ "app.timeback.hole_filling_missed_question_count": missed.length,
91622
+ "app.timeback.hole_filling_missed_standard_count": missedStandards.length,
91623
+ "app.timeback.hole_filling_untagged_question_count": untaggedQuestionNumbers.length
91249
91624
  });
91625
+ return {
91626
+ context: {
91627
+ enrollment: enrollmentContext,
91628
+ triggeringResult: { ...summary, ...qtiTestId && { qtiTestId } },
91629
+ missedStandards,
91630
+ untaggedMissedQuestionNumbers: untaggedQuestionNumbers
91631
+ },
91632
+ complete
91633
+ };
91250
91634
  }
91251
91635
  async getUserDataByTimebackId(timebackId) {
91252
91636
  return this.withClientTelemetry(async () => {
@@ -92090,17 +92474,21 @@ var init_timeback_service = __esm(async () => {
92090
92474
  activityData
92091
92475
  });
92092
92476
  await this.deps.validateDeveloperAccess(user, gameId);
92093
- const integration = await db.query.gameTimebackIntegrations.findFirst({
92094
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveBaseGameTimebackIntegration())
92095
- });
92096
- if (!integration) {
92097
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
92098
- }
92099
92477
  const {
92478
+ integration,
92100
92479
  courseId: runtimeCourseId,
92480
+ baseCourseId,
92481
+ otherCourseIds,
92101
92482
  courseName: runtimeCourseName,
92483
+ extensions: runtimeExtensions,
92102
92484
  attributes: courseAttributes
92103
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
92485
+ } = await this.resolveRuntimeCourse({
92486
+ gameId,
92487
+ studentId,
92488
+ runId,
92489
+ activityData,
92490
+ extensions: extensionsWithResumeId
92491
+ });
92104
92492
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
92105
92493
  const dedupeKey = runId !== undefined ? {
92106
92494
  gameId,
@@ -92129,12 +92517,12 @@ var init_timeback_service = __esm(async () => {
92129
92517
  courseId: runtimeCourseId,
92130
92518
  courseName: runtimeCourseName,
92131
92519
  studentEmail: activityData.studentEmail,
92132
- extensions: extensionsWithResumeId,
92520
+ extensions: runtimeExtensions,
92133
92521
  ...runId ? { runId } : {}
92134
92522
  };
92135
- const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion") : undefined;
92136
- const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", effectiveResumeId) : undefined;
92137
- const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed") : undefined;
92523
+ const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion", baseCourseId) : undefined;
92524
+ const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", baseCourseId, effectiveResumeId) : undefined;
92525
+ const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed", baseCourseId) : undefined;
92138
92526
  function emitRecord() {
92139
92527
  return client.activity.record(runtimeCourseId, studentId, {
92140
92528
  ...activityEventContext,
@@ -92158,7 +92546,7 @@ var init_timeback_service = __esm(async () => {
92158
92546
  xpAwarded: emitted.xpAwarded,
92159
92547
  masteredUnitsApplied: emitted.masteredUnitsApplied,
92160
92548
  pctCompleteApp: emitted.pctCompleteApp ?? null
92161
- }));
92549
+ }), otherCourseIds);
92162
92550
  if (outcome.status === "duplicate") {
92163
92551
  blockedByDedupeGuard = true;
92164
92552
  duplicateAward = outcome.award;
@@ -92206,6 +92594,7 @@ var init_timeback_service = __esm(async () => {
92206
92594
  return {
92207
92595
  status: "ok",
92208
92596
  courseId: runtimeCourseId,
92597
+ courseKind: integration.kind,
92209
92598
  xpAwarded: duplicateAward?.xpAwarded ?? 0,
92210
92599
  masteredUnits: duplicateAward?.masteredUnitsApplied ?? 0,
92211
92600
  ...duplicateAward?.pctCompleteApp != null ? { pctCompleteApp: duplicateAward.pctCompleteApp } : {},
@@ -92218,6 +92607,7 @@ var init_timeback_service = __esm(async () => {
92218
92607
  return {
92219
92608
  status: "ok",
92220
92609
  courseId: runtimeCourseId,
92610
+ courseKind: integration.kind,
92221
92611
  xpAwarded: result.xpAwarded,
92222
92612
  masteredUnits: result.masteredUnitsApplied,
92223
92613
  pctCompleteApp: result.pctCompleteApp,
@@ -92394,7 +92784,6 @@ var init_timeback_service = __esm(async () => {
92394
92784
  }) {
92395
92785
  return this.withClientTelemetry(async () => {
92396
92786
  const client = this.requireClient();
92397
- const db = this.deps.db;
92398
92787
  const hasWindowStartedAtMs = windowStartedAtMs !== undefined;
92399
92788
  const hasWindowSequence = windowSequence !== undefined;
92400
92789
  if (hasWindowStartedAtMs === hasWindowSequence) {
@@ -92422,17 +92811,17 @@ var init_timeback_service = __esm(async () => {
92422
92811
  return inFlightHeartbeat;
92423
92812
  }
92424
92813
  const pendingHeartbeat = (async () => {
92425
- const integration = await db.query.gameTimebackIntegrations.findFirst({
92426
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveBaseGameTimebackIntegration())
92427
- });
92428
- if (!integration) {
92429
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
92430
- }
92431
92814
  const {
92432
92815
  courseId: runtimeCourseId,
92433
92816
  courseName: runtimeCourseName,
92817
+ extensions: runtimeExtensions,
92434
92818
  attributes: courseAttributes
92435
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
92819
+ } = await this.resolveRuntimeCourse({
92820
+ gameId,
92821
+ studentId,
92822
+ activityData,
92823
+ extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId)
92824
+ });
92436
92825
  const activeTimeSeconds = timingData.activeMs / 1000;
92437
92826
  const inactiveTimeSeconds = timingData.pausedMs / 1000;
92438
92827
  if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
@@ -92448,7 +92837,7 @@ var init_timeback_service = __esm(async () => {
92448
92837
  courseId: runtimeCourseId,
92449
92838
  courseName: runtimeCourseName,
92450
92839
  studentEmail: activityData.studentEmail,
92451
- extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId),
92840
+ extensions: runtimeExtensions,
92452
92841
  ...runId ? { runId } : {}
92453
92842
  });
92454
92843
  }
@@ -94801,33 +95190,33 @@ var init_auth_provider = __esm(async () => {
94801
95190
  function createSandboxCacheProvider() {
94802
95191
  return {
94803
95192
  async get(key) {
94804
- const entry = cache.get(key);
95193
+ const entry = cache2.get(key);
94805
95194
  if (!entry) {
94806
95195
  return null;
94807
95196
  }
94808
95197
  if (entry.expiresAt < Date.now()) {
94809
- cache.delete(key);
95198
+ cache2.delete(key);
94810
95199
  return null;
94811
95200
  }
94812
95201
  return entry.value;
94813
95202
  },
94814
95203
  async set(key, value, ttlSeconds = 300) {
94815
- cache.set(key, {
95204
+ cache2.set(key, {
94816
95205
  value,
94817
95206
  expiresAt: Date.now() + ttlSeconds * 1000
94818
95207
  });
94819
95208
  },
94820
95209
  async delete(key) {
94821
- cache.delete(key);
95210
+ cache2.delete(key);
94822
95211
  }
94823
95212
  };
94824
95213
  }
94825
95214
  function clearSandboxCache() {
94826
- cache.clear();
95215
+ cache2.clear();
94827
95216
  }
94828
- var cache;
95217
+ var cache2;
94829
95218
  var init_cache_provider = __esm(() => {
94830
- cache = new Map;
95219
+ cache2 = new Map;
94831
95220
  });
94832
95221
 
94833
95222
  // ../utils/src/keys.ts
@@ -151055,7 +151444,7 @@ function parseQtiLibraryParams(searchParams) {
151055
151444
  limit
151056
151445
  };
151057
151446
  }
151058
- 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;
151447
+ 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;
151059
151448
  var init_timeback_controller = __esm(() => {
151060
151449
  init_esm();
151061
151450
  init_src();
@@ -151081,6 +151470,12 @@ var init_timeback_controller = __esm(() => {
151081
151470
  });
151082
151471
  getUser = requireNonAnonymous(async (ctx) => ctx.services.timeback.getUserData(ctx.user.id, ctx.gameId));
151083
151472
  getUserEnrollments = requireNonAnonymous(async (ctx) => ctx.services.timeback.getUserEnrollments(ctx.user.id, ctx.gameId));
151473
+ getUserHoleFillingContext = requireNonAnonymous(async (ctx) => {
151474
+ if (!ctx.gameId) {
151475
+ throw ApiError.forbidden("Hole-filling context requires a game token");
151476
+ }
151477
+ return ctx.services.timeback.getHoleFillingContext(ctx.user.id, ctx.gameId);
151478
+ });
151084
151479
  getUserById = requireNonAnonymous(async (ctx) => {
151085
151480
  const timebackId = ctx.params.timebackId;
151086
151481
  if (!timebackId) {
@@ -151934,6 +152329,7 @@ var init_timeback_controller = __esm(() => {
151934
152329
  populateStudent,
151935
152330
  getUser,
151936
152331
  getUserEnrollments,
152332
+ getUserHoleFillingContext,
151937
152333
  getUserById,
151938
152334
  setupIntegration,
151939
152335
  getIntegrations,
@@ -152996,6 +153392,12 @@ var init_timeback8 = __esm(async () => {
152996
153392
  }
152997
153393
  return handle2(timeback2.getUserEnrollments)(c);
152998
153394
  });
153395
+ timebackRouter.get("/user/hole-filling-context", async (c) => {
153396
+ if (shouldMockTimeback()) {
153397
+ return c.json({ contexts: [] });
153398
+ }
153399
+ return handle2(timeback2.getUserHoleFillingContext)(c);
153400
+ });
152999
153401
  timebackRouter.get("/user/:timebackId", async (c) => {
153000
153402
  const user = c.get("user");
153001
153403
  if (!user) {