@playcademy/sandbox 0.12.2-beta.3 → 0.12.2-beta.5

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 +428 -99
  2. package/dist/server.js +428 -99
  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.3",
700
+ version: "0.12.2-beta.5",
701
701
  description: "Local development server for Playcademy game development",
702
702
  type: "module",
703
703
  exports: {
@@ -19097,6 +19097,7 @@ var init_table8 = __esm(() => {
19097
19097
  gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
19098
19098
  parentOrigin: text("parent_origin").notNull(),
19099
19099
  timebackCourseId: text("timeback_course_id"),
19100
+ contextId: text("context_id"),
19100
19101
  passHash: text("pass_hash").unique(),
19101
19102
  passExpiresAt: timestamp("pass_expires_at", { withTimezone: true }).notNull(),
19102
19103
  redeemedAt: timestamp("redeemed_at", { withTimezone: true }),
@@ -49003,13 +49004,16 @@ function isCanonicalOrigin(value) {
49003
49004
  }
49004
49005
  return url.protocol === "https:" || url.protocol === "http:" && isLocalHostname(url.hostname);
49005
49006
  }
49006
- var LOCAL_HOSTNAMES, EmbedOriginSchema, EmbedPartnerClientIdSchema, EnsureEmbedPartnerSchema, AddEmbedPartnerOriginSchema, CreateEmbedLaunchSchema, RedeemEmbedPassSchema;
49007
+ var LOCAL_HOSTNAMES, EmbedOriginSchema, EmbedContextIdSchema, EmbedPartnerClientIdSchema, EnsureEmbedPartnerSchema, AddEmbedPartnerOriginSchema, CreateEmbedLaunchSchema, RedeemEmbedPassSchema;
49007
49008
  var init_schemas5 = __esm(() => {
49008
49009
  init_esm();
49009
49010
  LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1"]);
49010
49011
  EmbedOriginSchema = stringType().refine(isCanonicalOrigin, {
49011
49012
  message: "Origin must be a bare https origin like https://example.com, with no path or slash"
49012
49013
  });
49014
+ EmbedContextIdSchema = stringType().regex(/^[A-Za-z0-9._~-]{1,128}$/, {
49015
+ message: "contextId must be 1-128 URL-safe characters (letters, digits, . _ ~ -)"
49016
+ });
49013
49017
  EmbedPartnerClientIdSchema = stringType().trim().min(1).max(200);
49014
49018
  EnsureEmbedPartnerSchema = objectType({
49015
49019
  name: stringType().trim().min(1).max(100)
@@ -49021,7 +49025,8 @@ var init_schemas5 = __esm(() => {
49021
49025
  timebackId: stringType().trim().min(1).max(200),
49022
49026
  gameSlug: stringType().trim().min(1).max(100),
49023
49027
  parentOrigin: EmbedOriginSchema,
49024
- timebackCourseId: stringType().trim().min(1).max(200).optional()
49028
+ timebackCourseId: stringType().trim().min(1).max(200).optional(),
49029
+ contextId: EmbedContextIdSchema.optional()
49025
49030
  }).strict();
49026
49031
  RedeemEmbedPassSchema = objectType({
49027
49032
  pass: stringType().min(1).max(200),
@@ -80045,6 +80050,9 @@ function normalizedIdentityCase2(value) {
80045
80050
  function frameworkAliasKey2(value) {
80046
80051
  return value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/g, "");
80047
80052
  }
80053
+ function isUuidShaped2(value) {
80054
+ return UUID_PATTERN2.test(value.trim());
80055
+ }
80048
80056
  function isCommonCoreMathIdentifier2(identifier) {
80049
80057
  const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
80050
80058
  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);
@@ -80076,6 +80084,14 @@ function canonicalIdentifier2(framework, authoredIdentifier) {
80076
80084
  }
80077
80085
  return identifier;
80078
80086
  }
80087
+ function readableAlignmentIdentifier2(alignment) {
80088
+ const identifier = alignment.identifier?.trim() ?? "";
80089
+ if (identifier && !isUuidShaped2(identifier)) {
80090
+ return identifier;
80091
+ }
80092
+ const id = alignment.id.trim();
80093
+ return id && !isUuidShaped2(id) ? id : "";
80094
+ }
80079
80095
  function canonicalAssessmentStandardRef2(standard) {
80080
80096
  const authoredFramework = normalizedWhitespace2(standard.framework);
80081
80097
  const framework = canonicalFramework2(authoredFramework, standard.identifier);
@@ -80182,7 +80198,23 @@ function qtiStandardsFromMetadata(metadata2) {
80182
80198
  standards.push(...qtiAlignmentStandardsFromMetadata2(metadata2));
80183
80199
  return dedupeStandards2(standards);
80184
80200
  }
80185
- 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;
80201
+ function qtiAssessmentStandardRefsFromMetadata2(metadata2) {
80202
+ const refs = new Map;
80203
+ for (const alignment of qtiAlignmentStandardsFromMetadata2(metadata2)) {
80204
+ const identifier = readableAlignmentIdentifier2(alignment);
80205
+ if (identifier) {
80206
+ const canonical = canonicalAssessmentStandardRef2({
80207
+ framework: alignment.source,
80208
+ identifier
80209
+ });
80210
+ if (canonical.framework && canonical.identifier) {
80211
+ refs.set(assessmentStandardRefKey2(canonical), canonical);
80212
+ }
80213
+ }
80214
+ }
80215
+ return [...refs.values()];
80216
+ }
80217
+ 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;
80186
80218
  var init_qti = __esm(() => {
80187
80219
  init_timeback4();
80188
80220
  init_timeback4();
@@ -80544,6 +80576,7 @@ var init_qti = __esm(() => {
80544
80576
  this.name = "QtiScoringArtifactValidationError";
80545
80577
  }
80546
80578
  };
80579
+ 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;
80547
80580
  COMMON_CORE_MATH_FRAMEWORK_ALIASES2 = new Set([
80548
80581
  "ccssm",
80549
80582
  "ccssmath",
@@ -80753,7 +80786,7 @@ function pruneUnreadableStandards(standards) {
80753
80786
  return standards.filter(hasReadableStandardLabel);
80754
80787
  }
80755
80788
  function parseQuestionIndex(title) {
80756
- const match = title.match(/\bquestion[\s_-]*(\d+)\b/i);
80789
+ const match = title?.match(/\bquestion[\s_-]*(\d+)\b/i);
80757
80790
  const index = match ? Number(match[1]) : Number.NaN;
80758
80791
  return Number.isFinite(index) && index > 0 ? index : null;
80759
80792
  }
@@ -80787,14 +80820,15 @@ function qtiQuestionsByIndex(qtiQuestions) {
80787
80820
  }
80788
80821
  return questionsByIndex;
80789
80822
  }
80823
+ function normalizedQuestionScore(score, scorePercentile) {
80824
+ const rawValue = numberField(score) ?? numberField(scorePercentile);
80825
+ return rawValue !== null && rawValue > 1 ? rawValue / 100 : rawValue;
80826
+ }
80790
80827
  function getQuestionCorrectness(result) {
80791
80828
  if (!result) {
80792
80829
  return "unknown";
80793
80830
  }
80794
- const score = numberField(result.score);
80795
- const scorePercentile = numberField(result.scorePercentile);
80796
- const rawValue = score ?? scorePercentile;
80797
- const value = rawValue !== null && rawValue > 1 ? rawValue / 100 : rawValue;
80831
+ const value = normalizedQuestionScore(result.score, result.scorePercentile);
80798
80832
  if (value === null) {
80799
80833
  return "unknown";
80800
80834
  }
@@ -81058,10 +81092,71 @@ var init_timeback_mastery_completion_util = __esm(async () => {
81058
81092
  await init_errors8();
81059
81093
  });
81060
81094
 
81095
+ // ../api-core/src/utils/bounded-map.util.ts
81096
+ function setBounded(map, key, value, maxSize) {
81097
+ map.delete(key);
81098
+ if (map.size >= maxSize) {
81099
+ const oldestKey = map.keys().next().value;
81100
+ if (oldestKey !== undefined) {
81101
+ map.delete(oldestKey);
81102
+ }
81103
+ }
81104
+ map.set(key, value);
81105
+ }
81106
+
81107
+ // ../api-core/src/utils/timeback-qti-test-lookup.util.ts
81108
+ async function resolveQtiTestId(client, testName, parentLineItem) {
81109
+ const cacheKey = `${parentLineItem.sourcedId ?? ""}:${testName}`;
81110
+ const cached = cache.get(cacheKey);
81111
+ if (cached) {
81112
+ setBounded(cache, cacheKey, cached, MAX_CACHE_SIZE);
81113
+ return cached;
81114
+ }
81115
+ let resource = null;
81116
+ const resourceId = sourceIdFromRef(parentLineItem.componentResource);
81117
+ if (resourceId) {
81118
+ resource = await client.api.oneroster.resources.get(resourceId).catch(() => null);
81119
+ }
81120
+ for (const candidate of qtiIdCandidates(parentLineItem, resource)) {
81121
+ const test = await client.qtiApi.assessmentTests.get(candidate).catch(() => null);
81122
+ if (test?.identifier) {
81123
+ setBounded(cache, cacheKey, test.identifier, MAX_CACHE_SIZE);
81124
+ return test.identifier;
81125
+ }
81126
+ }
81127
+ const normalizedTestName = testName.trim();
81128
+ if (!normalizedTestName) {
81129
+ return "";
81130
+ }
81131
+ for (let page = 1;page <= TITLE_LOOKUP_MAX_PAGES; page++) {
81132
+ const list = await client.qtiApi.assessmentTests.list({
81133
+ query: normalizedTestName,
81134
+ limit: TITLE_LOOKUP_LIMIT,
81135
+ page,
81136
+ sort: "title",
81137
+ order: "asc"
81138
+ }).catch(() => null);
81139
+ const qtiTestId = findQtiAssessmentTestIdByTitle(list?.items ?? [], normalizedTestName);
81140
+ if (qtiTestId) {
81141
+ setBounded(cache, cacheKey, qtiTestId, MAX_CACHE_SIZE);
81142
+ return qtiTestId;
81143
+ }
81144
+ const itemCount = list?.items?.length ?? 0;
81145
+ if (!list || itemCount < TITLE_LOOKUP_LIMIT || typeof list.pages === "number" && page >= list.pages) {
81146
+ break;
81147
+ }
81148
+ }
81149
+ return "";
81150
+ }
81151
+ var MAX_CACHE_SIZE = 500, TITLE_LOOKUP_LIMIT = 50, TITLE_LOOKUP_MAX_PAGES = 5, cache;
81152
+ var init_timeback_qti_test_lookup_util = __esm(() => {
81153
+ init_timeback_grade_level_results_util();
81154
+ cache = new Map;
81155
+ });
81156
+
81061
81157
  // ../api-core/src/services/timeback-admin.service.ts
81062
81158
  class TimebackAdminService {
81063
81159
  deps;
81064
- qtiTestIdCache = new Map;
81065
81160
  static XP_PRECISION_FACTOR = 10;
81066
81161
  static RECENT_ACTIVITY_LIMIT = 20;
81067
81162
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
@@ -81076,9 +81171,6 @@ class TimebackAdminService {
81076
81171
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
81077
81172
  static GAME_METRICS_LIST_FETCH_TIMEOUT_MS = 3000;
81078
81173
  static GAME_METRICS_RUN_IDS_PER_REQUEST = 50;
81079
- static MAX_QTI_TEST_ID_CACHE_SIZE = 500;
81080
- static QTI_TEST_TITLE_LOOKUP_LIMIT = 50;
81081
- static QTI_TEST_TITLE_LOOKUP_MAX_PAGES = 5;
81082
81174
  constructor(deps) {
81083
81175
  this.deps = deps;
81084
81176
  }
@@ -81938,68 +82030,6 @@ class TimebackAdminService {
81938
82030
  return { activities: activitiesWithGameMetrics, hasMore };
81939
82031
  });
81940
82032
  }
81941
- rememberQtiTestId(cacheKey, qtiTestId) {
81942
- if (!qtiTestId) {
81943
- this.qtiTestIdCache.delete(cacheKey);
81944
- return;
81945
- }
81946
- if (this.qtiTestIdCache.has(cacheKey)) {
81947
- this.qtiTestIdCache.delete(cacheKey);
81948
- } else if (this.qtiTestIdCache.size >= TimebackAdminService.MAX_QTI_TEST_ID_CACHE_SIZE) {
81949
- const oldestKey = this.qtiTestIdCache.keys().next().value;
81950
- if (oldestKey !== undefined) {
81951
- this.qtiTestIdCache.delete(oldestKey);
81952
- }
81953
- }
81954
- this.qtiTestIdCache.set(cacheKey, qtiTestId);
81955
- }
81956
- async resolveQtiTestIdForGradeLevelResult(client, testName, parentLineItem) {
81957
- const cacheKey = `${parentLineItem.sourcedId ?? ""}:${testName}`;
81958
- const cachedQtiTestId = cacheKey ? this.qtiTestIdCache.get(cacheKey) : undefined;
81959
- if (cachedQtiTestId) {
81960
- this.rememberQtiTestId(cacheKey, cachedQtiTestId);
81961
- return cachedQtiTestId;
81962
- }
81963
- let resource = null;
81964
- const resourceId = sourceIdFromRef(parentLineItem.componentResource);
81965
- if (resourceId) {
81966
- resource = await client.api.oneroster.resources.get(resourceId).catch(() => null);
81967
- }
81968
- for (const candidate of qtiIdCandidates(parentLineItem, resource)) {
81969
- const test = await client.qtiApi.assessmentTests.get(candidate).catch(() => null);
81970
- if (test?.identifier) {
81971
- if (cacheKey) {
81972
- this.rememberQtiTestId(cacheKey, test.identifier);
81973
- }
81974
- return test.identifier;
81975
- }
81976
- }
81977
- const normalizedTestName = testName.trim();
81978
- if (!normalizedTestName) {
81979
- return "";
81980
- }
81981
- for (let page = 1;page <= TimebackAdminService.QTI_TEST_TITLE_LOOKUP_MAX_PAGES; page++) {
81982
- const list = await client.qtiApi.assessmentTests.list({
81983
- query: normalizedTestName,
81984
- limit: TimebackAdminService.QTI_TEST_TITLE_LOOKUP_LIMIT,
81985
- page,
81986
- sort: "title",
81987
- order: "asc"
81988
- }).catch(() => null);
81989
- const qtiTestId = findQtiAssessmentTestIdByTitle(list?.items ?? [], normalizedTestName);
81990
- if (qtiTestId) {
81991
- if (cacheKey) {
81992
- this.rememberQtiTestId(cacheKey, qtiTestId);
81993
- }
81994
- return qtiTestId;
81995
- }
81996
- const itemCount = list?.items?.length ?? 0;
81997
- if (!list || itemCount < TimebackAdminService.QTI_TEST_TITLE_LOOKUP_LIMIT || typeof list.pages === "number" && page >= list.pages) {
81998
- break;
81999
- }
82000
- }
82001
- return "";
82002
- }
82003
82033
  async getGradeLevelTestResults(user, options) {
82004
82034
  return this.withClientTelemetry(async () => {
82005
82035
  const { gameId, courseId, studentId } = options;
@@ -82102,7 +82132,7 @@ class TimebackAdminService {
82102
82132
  }).then((page) => page.data) : [];
82103
82133
  const latestResultsByLineItem = latestAssessmentResultsByLineItem(questionResults);
82104
82134
  const testName = resolveGradeLevelTestName(parentResult, parentLineItem);
82105
- const qtiTestId = await this.resolveQtiTestIdForGradeLevelResult(client, testName, parentLineItem);
82135
+ const qtiTestId = await resolveQtiTestId(client, testName, parentLineItem);
82106
82136
  const qtiQuestions = qtiTestId ? await client.qtiApi.assessmentTests.getQuestions(qtiTestId).then((response) => response.questions ?? []).catch(() => []) : [];
82107
82137
  const questions = mapGradeLevelReviewQuestions({
82108
82138
  questionLineItems,
@@ -82796,6 +82826,7 @@ var init_timeback_admin_service = __esm(async () => {
82796
82826
  init_timeback_discrepancy_queue_util();
82797
82827
  init_timeback_game_metrics_comparison_util();
82798
82828
  init_timeback_grade_level_results_util();
82829
+ init_timeback_qti_test_lookup_util();
82799
82830
  init_timeback_util();
82800
82831
  await __promiseAll([
82801
82832
  init_dist5(),
@@ -90802,6 +90833,128 @@ var init_timeback_create_integration_util = __esm(() => {
90802
90833
  init_timeback_util();
90803
90834
  });
90804
90835
 
90836
+ // ../api-core/src/utils/timeback-hole-filling-context.util.ts
90837
+ function buildAssignmentResultWhere(options) {
90838
+ return {
90839
+ status: "active",
90840
+ "student.sourcedId": options.studentId,
90841
+ "metadata.resultType": options.resultType,
90842
+ "metadata.assignmentId": options.assignmentId
90843
+ };
90844
+ }
90845
+ function assignmentIdOf(record) {
90846
+ const value = metadataOf(record).assignmentId;
90847
+ if (typeof value === "number" && Number.isFinite(value)) {
90848
+ return String(value);
90849
+ }
90850
+ const text = stringField3(value).trim();
90851
+ return text || undefined;
90852
+ }
90853
+ function toTriggeringResult(result) {
90854
+ const metadata2 = metadataOf(result);
90855
+ const testType = normalizeTestType(metadata2.testType);
90856
+ const assignmentId = assignmentIdOf(result);
90857
+ const lineItemId = sourceIdFromRef(result.assessmentLineItem);
90858
+ if (!assignmentId || !lineItemId || result.scoreStatus !== SCORE_STATUS2.fullyGraded || !isAllowedGradeLevelTestType(testType)) {
90859
+ return null;
90860
+ }
90861
+ return {
90862
+ resultId: result.sourcedId ?? "",
90863
+ assignmentId,
90864
+ lineItemId,
90865
+ testName: stringField3(metadata2.testName) || lineItemId,
90866
+ testType,
90867
+ score: numberField(result.score),
90868
+ scoreDate: result.scoreDate ?? null,
90869
+ totalQuestions: numberField(metadata2.totalQuestions),
90870
+ correctQuestions: numberField(metadata2.correctQuestions)
90871
+ };
90872
+ }
90873
+ function isMissed(resultText, score, scorePercentile) {
90874
+ const marking = stringField3(resultText).trim().toLowerCase();
90875
+ if (marking) {
90876
+ return marking !== "correct";
90877
+ }
90878
+ const value = normalizedQuestionScore(score, scorePercentile);
90879
+ return value !== null && value < 1;
90880
+ }
90881
+ function missedQuestionsFromInlineResponses(result) {
90882
+ const responses = metadataOf(result).responses;
90883
+ const questions = isRecord6(responses) && Array.isArray(responses.questions) ? responses.questions : undefined;
90884
+ if (!questions?.length) {
90885
+ return;
90886
+ }
90887
+ return questions.flatMap((question) => {
90888
+ if (!isRecord6(question) || !isMissed(question.result, question.score)) {
90889
+ return [];
90890
+ }
90891
+ const questionNumber = numberField(question.questionNumber);
90892
+ const qtiItemId = stringField3(question.questionQtiIdentifier).trim();
90893
+ return questionNumber === null ? [] : [{ questionNumber, ...qtiItemId && { qtiItemId } }];
90894
+ });
90895
+ }
90896
+ function questionNumberFromLineItemId(lineItemId) {
90897
+ const match = lineItemId.match(/_q(\d+)$/);
90898
+ const value = match ? Number(match[1]) : Number.NaN;
90899
+ return Number.isInteger(value) && value > 0 ? value : null;
90900
+ }
90901
+ function missedQuestionsFromQuestionResults(questionResults) {
90902
+ return [...latestAssessmentResultsByLineItem(questionResults)].flatMap(([lineItemId, result]) => {
90903
+ const questionNumber = questionNumberFromLineItemId(lineItemId);
90904
+ const responses = metadataOf(result).responses;
90905
+ const response = isRecord6(responses) && isRecord6(responses.response) ? responses.response : undefined;
90906
+ if (questionNumber === null || !isMissed(response?.result, result.score, result.scorePercentile)) {
90907
+ return [];
90908
+ }
90909
+ const qtiItemId = stringField3(response?.questionQtiIdentifier).trim();
90910
+ return [{ questionNumber, ...qtiItemId && { qtiItemId } }];
90911
+ }).toSorted((a, b) => a.questionNumber - b.questionNumber);
90912
+ }
90913
+ function qtiItemIdForQuestionNumber(qtiQuestions) {
90914
+ const byNumber = qtiQuestionsByIndex(qtiQuestions);
90915
+ return (questionNumber) => {
90916
+ const question = byNumber.get(questionNumber) ?? qtiQuestions[questionNumber - 1];
90917
+ return question ? question.question.identifier || question.reference.identifier : undefined;
90918
+ };
90919
+ }
90920
+ function groupMissedStandards(missed, standardsByItemId, itemIdFor) {
90921
+ const byKey = new Map;
90922
+ const untagged = [];
90923
+ for (const question of missed) {
90924
+ const itemId = question.qtiItemId ?? itemIdFor(question.questionNumber);
90925
+ const standards = itemId ? standardsByItemId.get(itemId) ?? [] : [];
90926
+ if (!standards.length) {
90927
+ untagged.push(question.questionNumber);
90928
+ }
90929
+ for (const standard of standards) {
90930
+ const key = `${standard.framework}\x00${standard.identifier}`;
90931
+ const entry = byKey.get(key) ?? {
90932
+ framework: standard.framework,
90933
+ code: standard.identifier,
90934
+ questionNumbers: [],
90935
+ qtiItemIds: []
90936
+ };
90937
+ entry.questionNumbers.push(question.questionNumber);
90938
+ if (itemId && !entry.qtiItemIds.includes(itemId)) {
90939
+ entry.qtiItemIds.push(itemId);
90940
+ }
90941
+ byKey.set(key, entry);
90942
+ }
90943
+ }
90944
+ return {
90945
+ missedStandards: [...byKey.values()].toSorted((a, b) => b.questionNumbers.length - a.questionNumbers.length || a.code.localeCompare(b.code, undefined, { numeric: true })),
90946
+ untaggedQuestionNumbers: untagged.toSorted((a, b) => a - b)
90947
+ };
90948
+ }
90949
+ function standardsFromQtiItem(item) {
90950
+ return qtiAssessmentStandardRefsFromMetadata2(metadataOf(item));
90951
+ }
90952
+ var init_timeback_hole_filling_context_util = __esm(() => {
90953
+ init_constants2();
90954
+ init_qti();
90955
+ init_timeback_grade_level_results_util();
90956
+ });
90957
+
90805
90958
  // ../api-core/src/utils/timeback-promotion.util.ts
90806
90959
  async function promoteCompletedCourse({
90807
90960
  db,
@@ -90931,14 +91084,20 @@ var init_timeback_service = __esm(async () => {
90931
91084
  init_helpers_index();
90932
91085
  init_tables_index();
90933
91086
  init_spans();
91087
+ init_constants2();
90934
91088
  init_types2();
90935
91089
  init_errors2();
90936
91090
  init_timeback_create_integration_util();
91091
+ init_timeback_grade_level_results_util();
91092
+ init_timeback_hole_filling_context_util();
90937
91093
  init_timeback_promotion_util();
91094
+ init_timeback_qti_test_lookup_util();
90938
91095
  init_timeback_removed_integration_util();
90939
91096
  init_timeback_util();
90940
91097
  await init_dist5();
90941
91098
  TimebackService = class TimebackService {
91099
+ static HOLE_FILLING_CONTEXT_TTL_MS = 10 * 60 * 1000;
91100
+ static HOLE_FILLING_CONTEXT_CACHE_SIZE = 1000;
90942
91101
  static HEARTBEAT_DEDUPE_TTL_MS = 5 * 60 * 1000;
90943
91102
  static processedHeartbeatWindows = new Map;
90944
91103
  static inFlightHeartbeatWindows = new Map;
@@ -90946,6 +91105,7 @@ var init_timeback_service = __esm(async () => {
90946
91105
  static COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS = 400;
90947
91106
  static COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS = 5;
90948
91107
  deps;
91108
+ holeFillingContextCache = new Map;
90949
91109
  static cleanHeartbeatDedupeCache(now = Date.now()) {
90950
91110
  for (const [key, timestamp] of this.processedHeartbeatWindows) {
90951
91111
  if (now - timestamp > this.HEARTBEAT_DEDUPE_TTL_MS) {
@@ -91319,19 +91479,163 @@ var init_timeback_service = __esm(async () => {
91319
91479
  }
91320
91480
  async getUserEnrollments(userId, gameId) {
91321
91481
  return this.withClientTelemetry(async () => {
91322
- const userData = await this.deps.db.query.users.findFirst({
91323
- where: eq(users.id, userId),
91324
- columns: { id: true, timebackId: true }
91325
- });
91326
- if (!userData) {
91327
- throw new NotFoundError("User", userId);
91482
+ const enrollments = await this.fetchEnrollments(await this.requireTimebackId(userId));
91483
+ return gameId ? filterEnrollmentsByGame(enrollments, gameId) : enrollments;
91484
+ });
91485
+ }
91486
+ async requireTimebackId(userId) {
91487
+ const userData = await this.deps.db.query.users.findFirst({
91488
+ where: eq(users.id, userId),
91489
+ columns: { id: true, timebackId: true }
91490
+ });
91491
+ if (!userData) {
91492
+ throw new NotFoundError("User", userId);
91493
+ }
91494
+ if (!userData.timebackId) {
91495
+ throw new NotFoundError("Timeback account not found for user");
91496
+ }
91497
+ return userData.timebackId;
91498
+ }
91499
+ async getHoleFillingContext(userId, gameId) {
91500
+ return this.withClientTelemetry(async () => {
91501
+ setAttribute("app.timeback.operation", "get_hole_filling_context");
91502
+ const studentId = await this.requireTimebackId(userId);
91503
+ const enrollments = filterEnrollmentsByGame(await this.fetchEnrollments(studentId), gameId);
91504
+ const holeFilling = enrollments.filter((enrollment) => enrollment.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND);
91505
+ setAttribute("app.timeback.hole_filling_enrollment_count", holeFilling.length);
91506
+ if (holeFilling.length === 0) {
91507
+ return { contexts: [] };
91328
91508
  }
91329
- if (!userData.timebackId) {
91330
- throw new NotFoundError("Timeback account not found for user");
91509
+ const contexts = await Promise.all(holeFilling.map((enrollment) => this.buildHoleFillingContext(studentId, enrollment)));
91510
+ return { contexts };
91511
+ });
91512
+ }
91513
+ async buildHoleFillingContext(studentId, enrollment) {
91514
+ const client = this.requireClient();
91515
+ const grade = enrollment.grade;
91516
+ const subject = enrollment.subject;
91517
+ const enrollmentContext = {
91518
+ courseId: enrollment.courseId,
91519
+ baseCourseId: enrollment.baseCourseId ?? "",
91520
+ ...enrollment.id && { enrollmentId: enrollment.id },
91521
+ grade,
91522
+ subject,
91523
+ beginDate: enrollment.beginDate ?? null
91524
+ };
91525
+ const stampedAssignmentId = assignmentIdOf({ metadata: enrollment.enrollmentMetadata });
91526
+ let parent = null;
91527
+ if (stampedAssignmentId) {
91528
+ const where = {
91529
+ ...buildAssignmentResultWhere({
91530
+ studentId,
91531
+ assignmentId: stampedAssignmentId,
91532
+ resultType: "assessment"
91533
+ }),
91534
+ scoreStatus: SCORE_STATUS2.fullyGraded
91535
+ };
91536
+ const page = await client.api.oneroster.assessmentResults.list({
91537
+ limit: 1,
91538
+ where,
91539
+ sort: "scoreDate",
91540
+ orderBy: "desc"
91541
+ });
91542
+ parent = page.data[0] ?? null;
91543
+ }
91544
+ const summary = parent ? toTriggeringResult(parent) : null;
91545
+ let trigger = "not_stamped";
91546
+ if (stampedAssignmentId) {
91547
+ trigger = summary ? "found" : "not_found";
91548
+ }
91549
+ setAttributes({
91550
+ "app.timeback.hole_filling_trigger": trigger,
91551
+ "app.timeback.hole_filling_assignment_id": stampedAssignmentId
91552
+ });
91553
+ if (!parent || !summary) {
91554
+ return {
91555
+ enrollment: enrollmentContext,
91556
+ triggeringResult: null,
91557
+ missedStandards: [],
91558
+ untaggedMissedQuestionNumbers: []
91559
+ };
91560
+ }
91561
+ const cacheKey = `${enrollment.courseId}:${studentId}:${summary.resultId}`;
91562
+ const cached = this.holeFillingContextCache.get(cacheKey);
91563
+ if (cached && Date.now() - cached.at < TimebackService.HOLE_FILLING_CONTEXT_TTL_MS) {
91564
+ return { ...cached.context, enrollment: enrollmentContext };
91565
+ }
91566
+ const { context, complete } = await this.resolveMissedStandards(studentId, parent, summary, enrollmentContext);
91567
+ setAttribute("app.timeback.hole_filling_standards_complete", complete);
91568
+ if (complete) {
91569
+ setBounded(this.holeFillingContextCache, cacheKey, { at: Date.now(), context }, TimebackService.HOLE_FILLING_CONTEXT_CACHE_SIZE);
91570
+ }
91571
+ return context;
91572
+ }
91573
+ async resolveMissedStandards(studentId, parent, summary, enrollmentContext) {
91574
+ const client = this.requireClient();
91575
+ let complete = true;
91576
+ let missed = missedQuestionsFromInlineResponses(parent);
91577
+ if (!missed) {
91578
+ const where = buildAssignmentResultWhere({
91579
+ studentId,
91580
+ assignmentId: summary.assignmentId,
91581
+ resultType: "question"
91582
+ });
91583
+ const questionResults = await client.api.oneroster.assessmentResults.listAll({
91584
+ limit: 200,
91585
+ where,
91586
+ sort: "scoreDate",
91587
+ orderBy: "desc"
91588
+ });
91589
+ missed = missedQuestionsFromQuestionResults(questionResults);
91590
+ }
91591
+ let qtiTestId = "";
91592
+ let qtiQuestions = [];
91593
+ const standardsByItemId = new Map;
91594
+ if (missed.length > 0 && !client.hasQti) {
91595
+ complete = false;
91596
+ missed = missed.map(({ questionNumber }) => ({ questionNumber }));
91597
+ } else if (missed.some((question) => !question.qtiItemId)) {
91598
+ const lineItem = await client.api.oneroster.assessmentLineItems.get(summary.lineItemId).catch(() => null);
91599
+ qtiTestId = lineItem ? await resolveQtiTestId(client, resolveGradeLevelTestName(parent, lineItem), lineItem) : "";
91600
+ complete &&= Boolean(qtiTestId);
91601
+ if (qtiTestId) {
91602
+ qtiQuestions = await client.qtiApi.assessmentTests.getQuestions(qtiTestId).then((response) => response.questions ?? []).catch(() => {
91603
+ complete = false;
91604
+ return [];
91605
+ });
91606
+ for (const question of qtiQuestions) {
91607
+ const id = question.question.identifier || question.reference.identifier;
91608
+ if (id) {
91609
+ standardsByItemId.set(id, standardsFromQtiItem(question.question));
91610
+ }
91611
+ }
91331
91612
  }
91332
- const enrollments = await this.fetchEnrollments(userData.timebackId);
91333
- return gameId ? filterEnrollmentsByGame(enrollments, gameId) : enrollments;
91613
+ }
91614
+ const itemIdsToFetch = [
91615
+ ...new Set(missed.flatMap((question) => question.qtiItemId && !standardsByItemId.has(question.qtiItemId) ? [question.qtiItemId] : []))
91616
+ ];
91617
+ const items = await Promise.all(itemIdsToFetch.map((id) => client.qtiApi.assessmentItems.get(id).then((item) => [id, standardsFromQtiItem(item)]).catch(() => {
91618
+ complete = false;
91619
+ return [id, []];
91620
+ })));
91621
+ for (const [id, standards] of items) {
91622
+ standardsByItemId.set(id, standards);
91623
+ }
91624
+ const { missedStandards, untaggedQuestionNumbers } = groupMissedStandards(missed, standardsByItemId, qtiItemIdForQuestionNumber(qtiQuestions));
91625
+ setAttributes({
91626
+ "app.timeback.hole_filling_missed_question_count": missed.length,
91627
+ "app.timeback.hole_filling_missed_standard_count": missedStandards.length,
91628
+ "app.timeback.hole_filling_untagged_question_count": untaggedQuestionNumbers.length
91334
91629
  });
91630
+ return {
91631
+ context: {
91632
+ enrollment: enrollmentContext,
91633
+ triggeringResult: { ...summary, ...qtiTestId && { qtiTestId } },
91634
+ missedStandards,
91635
+ untaggedMissedQuestionNumbers: untaggedQuestionNumbers
91636
+ },
91637
+ complete
91638
+ };
91335
91639
  }
91336
91640
  async getUserDataByTimebackId(timebackId) {
91337
91641
  return this.withClientTelemetry(async () => {
@@ -93564,6 +93868,13 @@ var init_embed_util = __esm(() => {
93564
93868
  });
93565
93869
 
93566
93870
  // ../api-core/src/services/embed.service.ts
93871
+ async function scopedContextId(partnerId, contextId) {
93872
+ return sha256Hex(`${partnerId}:${contextId}`);
93873
+ }
93874
+ function launchTokenClaims(launch) {
93875
+ return launch.contextId ? { contextId: launch.contextId } : {};
93876
+ }
93877
+
93567
93878
  class EmbedService {
93568
93879
  deps;
93569
93880
  constructor(deps) {
@@ -93607,6 +93918,7 @@ class EmbedService {
93607
93918
  gameId: game.id,
93608
93919
  parentOrigin: input.parentOrigin,
93609
93920
  timebackCourseId: input.timebackCourseId ?? null,
93921
+ contextId: input.contextId ? await scopedContextId(partner.id, input.contextId) : null,
93610
93922
  passHash: await sha256Hex(pass),
93611
93923
  passExpiresAt,
93612
93924
  expiresAt: new Date(now + EMBED_LAUNCH_TTL_MS)
@@ -93614,7 +93926,11 @@ class EmbedService {
93614
93926
  if (!launch) {
93615
93927
  throw new InternalError("Failed to create embed launch");
93616
93928
  }
93617
- setAttributes({ "app.launch.id": launch.id, "app.user.id": user.id });
93929
+ setAttributes({
93930
+ "app.launch.id": launch.id,
93931
+ "app.user.id": user.id,
93932
+ "app.embed.has_context": input.contextId !== undefined
93933
+ });
93618
93934
  return { launchId: launch.id, pass, passExpiresAt, gameSlug: game.slug };
93619
93935
  }
93620
93936
  async redeemLaunch(input, timeZone) {
@@ -93629,7 +93945,7 @@ class EmbedService {
93629
93945
  const [game, profile, token] = await Promise.all([
93630
93946
  this.deps.getGameForStudent(launch.gameId, user),
93631
93947
  this.deps.getGameProfile(user, launch.gameId, timeZone),
93632
- this.deps.mintGameToken(launch.gameId, user.id)
93948
+ this.deps.mintGameToken(launch.gameId, user.id, launchTokenClaims(launch))
93633
93949
  ]);
93634
93950
  await this.deps.db.update(embedLaunches).set({ redeemedAt: new Date }).where(eq(embedLaunches.id, launch.id));
93635
93951
  return {
@@ -93661,7 +93977,7 @@ class EmbedService {
93661
93977
  if (outcome) {
93662
93978
  throw new AccessDeniedError("This launch has ended");
93663
93979
  }
93664
- return this.deps.mintGameToken(launch.gameId, launch.userId);
93980
+ return this.deps.mintGameToken(launch.gameId, launch.userId, launchTokenClaims(launch));
93665
93981
  }
93666
93982
  async endLaunch(launchId) {
93667
93983
  await this.deps.db.update(embedLaunches).set({ endedAt: sql`coalesce(${embedLaunches.endedAt}, now())`, passHash: null }).where(eq(embedLaunches.id, launchId));
@@ -94763,10 +95079,10 @@ function createSandboxAuthProvider() {
94763
95079
  }
94764
95080
  return deleted;
94765
95081
  },
94766
- async mintGameToken(gameId, userId) {
95082
+ async mintGameToken(gameId, userId, claims) {
94767
95083
  const header = btoa(JSON.stringify({ alg: "none", typ: "sandbox" }));
94768
95084
  const exp = Math.floor(Date.now() / 1000) + 15 * 60;
94769
- const payload = btoa(JSON.stringify({ sub: gameId, uid: userId, exp }));
95085
+ const payload = btoa(JSON.stringify({ ...claims, sub: gameId, uid: userId, exp }));
94770
95086
  const token = `${header}.${payload}.sandbox`;
94771
95087
  return { token, exp: exp * 1000 };
94772
95088
  },
@@ -94891,33 +95207,33 @@ var init_auth_provider = __esm(async () => {
94891
95207
  function createSandboxCacheProvider() {
94892
95208
  return {
94893
95209
  async get(key) {
94894
- const entry = cache.get(key);
95210
+ const entry = cache2.get(key);
94895
95211
  if (!entry) {
94896
95212
  return null;
94897
95213
  }
94898
95214
  if (entry.expiresAt < Date.now()) {
94899
- cache.delete(key);
95215
+ cache2.delete(key);
94900
95216
  return null;
94901
95217
  }
94902
95218
  return entry.value;
94903
95219
  },
94904
95220
  async set(key, value, ttlSeconds = 300) {
94905
- cache.set(key, {
95221
+ cache2.set(key, {
94906
95222
  value,
94907
95223
  expiresAt: Date.now() + ttlSeconds * 1000
94908
95224
  });
94909
95225
  },
94910
95226
  async delete(key) {
94911
- cache.delete(key);
95227
+ cache2.delete(key);
94912
95228
  }
94913
95229
  };
94914
95230
  }
94915
95231
  function clearSandboxCache() {
94916
- cache.clear();
95232
+ cache2.clear();
94917
95233
  }
94918
- var cache;
95234
+ var cache2;
94919
95235
  var init_cache_provider = __esm(() => {
94920
- cache = new Map;
95236
+ cache2 = new Map;
94921
95237
  });
94922
95238
 
94923
95239
  // ../utils/src/keys.ts
@@ -151145,7 +151461,7 @@ function parseQtiLibraryParams(searchParams) {
151145
151461
  limit
151146
151462
  };
151147
151463
  }
151148
- 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;
151464
+ 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;
151149
151465
  var init_timeback_controller = __esm(() => {
151150
151466
  init_esm();
151151
151467
  init_src();
@@ -151171,6 +151487,12 @@ var init_timeback_controller = __esm(() => {
151171
151487
  });
151172
151488
  getUser = requireNonAnonymous(async (ctx) => ctx.services.timeback.getUserData(ctx.user.id, ctx.gameId));
151173
151489
  getUserEnrollments = requireNonAnonymous(async (ctx) => ctx.services.timeback.getUserEnrollments(ctx.user.id, ctx.gameId));
151490
+ getUserHoleFillingContext = requireNonAnonymous(async (ctx) => {
151491
+ if (!ctx.gameId) {
151492
+ throw ApiError.forbidden("Hole-filling context requires a game token");
151493
+ }
151494
+ return ctx.services.timeback.getHoleFillingContext(ctx.user.id, ctx.gameId);
151495
+ });
151174
151496
  getUserById = requireNonAnonymous(async (ctx) => {
151175
151497
  const timebackId = ctx.params.timebackId;
151176
151498
  if (!timebackId) {
@@ -152024,6 +152346,7 @@ var init_timeback_controller = __esm(() => {
152024
152346
  populateStudent,
152025
152347
  getUser,
152026
152348
  getUserEnrollments,
152349
+ getUserHoleFillingContext,
152027
152350
  getUserById,
152028
152351
  setupIntegration,
152029
152352
  getIntegrations,
@@ -153086,6 +153409,12 @@ var init_timeback8 = __esm(async () => {
153086
153409
  }
153087
153410
  return handle2(timeback2.getUserEnrollments)(c);
153088
153411
  });
153412
+ timebackRouter.get("/user/hole-filling-context", async (c) => {
153413
+ if (shouldMockTimeback()) {
153414
+ return c.json({ contexts: [] });
153415
+ }
153416
+ return handle2(timeback2.getUserHoleFillingContext)(c);
153417
+ });
153089
153418
  timebackRouter.get("/user/:timebackId", async (c) => {
153090
153419
  const user = c.get("user");
153091
153420
  if (!user) {