@playcademy/sandbox 0.8.1-beta.1 → 0.8.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +1132 -493
  2. package/dist/server.js +1132 -493
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1128,7 +1128,7 @@ var package_default;
1128
1128
  var init_package = __esm(() => {
1129
1129
  package_default = {
1130
1130
  name: "@playcademy/sandbox",
1131
- version: "0.8.1-beta.1",
1131
+ version: "0.8.1-beta.3",
1132
1132
  description: "Local development server for Playcademy game development",
1133
1133
  type: "module",
1134
1134
  exports: {
@@ -1498,6 +1498,9 @@ async function runWithConcurrency(items, concurrency, worker) {
1498
1498
  }
1499
1499
 
1500
1500
  // ../utils/src/timeback.ts
1501
+ function reviewBankSinkIdentifier(integrationId) {
1502
+ return `playcademy-review-bank.${integrationId}`;
1503
+ }
1501
1504
  function playcademySupportedQtiInteractionType(value) {
1502
1505
  if (typeof value !== "string") {
1503
1506
  return;
@@ -9481,9 +9484,12 @@ function classifyAssessmentSubmission(attempt, submissionId) {
9481
9484
  }
9482
9485
  return isAssessmentAttemptOpen(attempt) ? "submit" : "reject";
9483
9486
  }
9487
+ function isSameAssessmentAward(recorded, input) {
9488
+ return recorded.xpAwarded === input.xpAwarded && recorded.masteredUnits === input.masteredUnits && recorded.masteredUnitsAbsolute === input.masteredUnitsAbsolute;
9489
+ }
9484
9490
  function classifyAssessmentFinalization(attempt, input) {
9485
9491
  if (isAssessmentAttemptCompleted(attempt)) {
9486
- return attempt.submissionId === input.submissionId && attempt.xpAwarded === input.xpAwarded ? "replay" : "conflict";
9492
+ return attempt.submissionId === input.submissionId && attempt.award !== undefined && isSameAssessmentAward(attempt.award, input) ? "replay" : "conflict";
9487
9493
  }
9488
9494
  if (!isAssessmentAttemptAwaitingAward(attempt) || attempt.submissionId !== input.submissionId) {
9489
9495
  return "reject";
@@ -10761,39 +10767,21 @@ function compareNewestCompletedAssessmentAttempt(left, right) {
10761
10767
  return newestAttempt(left, right, "scoreDate");
10762
10768
  }
10763
10769
  function compareAssessmentCatalogOrder(left, right) {
10764
- if (left.sortOrder !== null || right.sortOrder !== null) {
10765
- if (left.sortOrder === null) {
10766
- return 1;
10767
- }
10768
- if (right.sortOrder === null) {
10769
- return -1;
10770
- }
10771
- const orderDifference = left.sortOrder - right.sortOrder;
10772
- if (orderDifference) {
10773
- return orderDifference;
10774
- }
10775
- }
10776
- const leftUpdatedAt = Date.parse(left.updatedAt);
10777
- const rightUpdatedAt = Date.parse(right.updatedAt);
10778
- const leftHasValidDate = Number.isFinite(leftUpdatedAt);
10779
- const rightHasValidDate = Number.isFinite(rightUpdatedAt);
10780
- if (leftHasValidDate && rightHasValidDate) {
10781
- const updatedDifference = leftUpdatedAt - rightUpdatedAt;
10782
- if (updatedDifference) {
10783
- return updatedDifference;
10784
- }
10785
- } else if (leftHasValidDate !== rightHasValidDate) {
10786
- return leftHasValidDate ? -1 : 1;
10787
- }
10788
10770
  if (left.assessmentKey !== null && right.assessmentKey !== null) {
10789
- const keyDifference = left.assessmentKey.localeCompare(right.assessmentKey);
10771
+ const keyDifference = compareCodePoints(left.assessmentKey, right.assessmentKey);
10790
10772
  if (keyDifference) {
10791
10773
  return keyDifference;
10792
10774
  }
10793
10775
  } else if (left.assessmentKey !== null || right.assessmentKey !== null) {
10794
10776
  return left.assessmentKey === null ? 1 : -1;
10795
10777
  }
10796
- return left.qtiTestIdentifier.localeCompare(right.qtiTestIdentifier);
10778
+ return compareCodePoints(left.qtiTestIdentifier, right.qtiTestIdentifier);
10779
+ }
10780
+ function compareCodePoints(left, right) {
10781
+ if (left === right) {
10782
+ return 0;
10783
+ }
10784
+ return left < right ? -1 : 1;
10797
10785
  }
10798
10786
  function orderRuntimeAssessmentTests(tests) {
10799
10787
  return [...tests].toSorted(compareAssessmentCatalogOrder);
@@ -10964,7 +10952,7 @@ async function reviewBankRevision(input) {
10964
10952
  })}`);
10965
10953
  return `review-bank-v1:${bankRevisionUuid}`;
10966
10954
  }
10967
- async function reviewBankIndex(input) {
10955
+ async function buildReviewBankIndexFromReferences(input) {
10968
10956
  return {
10969
10957
  ...input,
10970
10958
  bankRevision: await reviewBankRevision(input)
@@ -10993,7 +10981,7 @@ async function buildReviewBankIndex(assessment, questions, options = {}) {
10993
10981
  standards
10994
10982
  };
10995
10983
  });
10996
- return reviewBankIndex({
10984
+ return buildReviewBankIndexFromReferences({
10997
10985
  bankIdentifier: assessment.identifier,
10998
10986
  sourceContentRevision: assessment.contentRevision,
10999
10987
  items
@@ -11098,7 +11086,7 @@ async function reviewBankIndexFromManifest(metadata2, input) {
11098
11086
  }
11099
11087
  }
11100
11088
  }
11101
- const index = await reviewBankIndex({
11089
+ const index = await buildReviewBankIndexFromReferences({
11102
11090
  bankIdentifier,
11103
11091
  sourceContentRevision,
11104
11092
  items: input.membershipItemIdentifiers.map((itemIdentifier) => ({
@@ -11453,6 +11441,18 @@ function isRoutedDiagnosticAttemptMetadata(value, itemSubmissions, responseVersi
11453
11441
  function isReviewAttemptMetadata(value) {
11454
11442
  return isRecord(value) && typeof value.requestFingerprint === "string" && typeof value.bankRevision === "string" && Array.isArray(value.standards) && value.standards.every(isAssessmentStandardRef) && Number.isInteger(value.candidateItemsPerStandard) && Array.isArray(value.selections) && value.selections.every((selection) => isRecord(selection) && isAssessmentStandardRef(selection.standard) && typeof selection.itemIdentifier === "string") && isReviewFulfillment(value.fulfillment);
11455
11443
  }
11444
+ function isOptionalInteger(value) {
11445
+ return value === undefined || Number.isInteger(value);
11446
+ }
11447
+ function isMasteryWriteWarning(value) {
11448
+ return isRecord(value) && value.code === "MASTERY_WRITE_CAPPED" && typeof value.message === "string" && Number.isFinite(value.currentMasteredUnits) && Number.isFinite(value.attemptedMasteredUnits) && Number.isFinite(value.appliedMasteredUnits) && Number.isFinite(value.storedMasteredUnits) && Number.isFinite(value.masterableUnits);
11449
+ }
11450
+ function isAssessmentAwardRecord(value) {
11451
+ if (value === undefined) {
11452
+ return true;
11453
+ }
11454
+ return isRecord(value) && typeof value.xpAwarded === "number" && Number.isFinite(value.xpAwarded) && value.xpAwarded >= 0 && (value.masteredUnits === undefined || Number.isFinite(value.masteredUnits)) && isOptionalInteger(value.masteredUnitsAbsolute) && Number.isFinite(value.masteredUnitsApplied) && isOptionalInteger(value.pctCompleteApp) && (value.warnings === undefined || Array.isArray(value.warnings) && value.warnings.every(isMasteryWriteWarning));
11455
+ }
11456
11456
  function isMasteryAttemptMetadata(value) {
11457
11457
  return isRecord(value) && typeof value.requestFingerprint === "string" && isAssessmentStandardRef(value.standard);
11458
11458
  }
@@ -11477,7 +11477,7 @@ function isPlaycademyAssessmentResultMetadataV1(value) {
11477
11477
  const review = metadata2.review;
11478
11478
  const diagnostic = metadata2.diagnostic;
11479
11479
  const routedDiagnostic = metadata2.purpose === "diagnostic" && diagnostic !== undefined && Array.isArray(metadata2.itemSubmissions) && Number.isInteger(metadata2.responseVersion) && isRoutedDiagnosticAttemptMetadata(diagnostic, metadata2.itemSubmissions, metadata2.responseVersion);
11480
- return metadata2.version === 1 && typeof metadata2.activityId === "string" && isAssessmentPurpose(metadata2.purpose) && typeof metadata2.courseId === "string" && typeof metadata2.integrationId === "string" && typeof metadata2.enrollmentId === "string" && Boolean(selectedTest) && (selectedTest?.assessmentKey === undefined || isNonEmptyString(selectedTest.assessmentKey)) && typeof selectedTest?.identifier === "string" && typeof selectedTest.contentRevision === "string" && Number.isInteger(metadata2.attemptNumber) && Number.isInteger(metadata2.responseVersion) && Boolean(metadata2.responses) && typeof metadata2.responses === "object" && Array.isArray(metadata2.itemSubmissions) && metadata2.itemSubmissions.every(isAssessmentItemSubmission) && typeof metadata2.startedAt === "string" && typeof metadata2.updatedAt === "string" && (metadata2.finalizedAt === undefined || typeof metadata2.finalizedAt === "string") && (metadata2.purpose === "mastery" ? isMasteryAttemptMetadata(mastery) : mastery === undefined) && (metadata2.purpose === "review" ? isReviewAttemptMetadata(review) : review === undefined) && (metadata2.purpose === "diagnostic" ? diagnostic === undefined || routedDiagnostic : diagnostic === undefined) && (metadata2.submissionId === undefined || typeof metadata2.submissionId === "string") && (metadata2.score === undefined || isAssessmentScore(metadata2.score)) && isCompletionMetadata(metadata2.completion, metadata2.purpose, routedDiagnostic);
11480
+ return metadata2.version === 1 && typeof metadata2.activityId === "string" && isAssessmentPurpose(metadata2.purpose) && typeof metadata2.courseId === "string" && typeof metadata2.integrationId === "string" && typeof metadata2.enrollmentId === "string" && Boolean(selectedTest) && (selectedTest?.assessmentKey === undefined || isNonEmptyString(selectedTest.assessmentKey)) && typeof selectedTest?.identifier === "string" && typeof selectedTest.contentRevision === "string" && Number.isInteger(metadata2.attemptNumber) && Number.isInteger(metadata2.responseVersion) && Boolean(metadata2.responses) && typeof metadata2.responses === "object" && Array.isArray(metadata2.itemSubmissions) && metadata2.itemSubmissions.every(isAssessmentItemSubmission) && typeof metadata2.startedAt === "string" && typeof metadata2.updatedAt === "string" && (metadata2.finalizedAt === undefined || typeof metadata2.finalizedAt === "string") && isAssessmentAwardRecord(metadata2.award) && (metadata2.purpose === "mastery" ? isMasteryAttemptMetadata(mastery) : mastery === undefined) && (metadata2.purpose === "review" ? isReviewAttemptMetadata(review) : review === undefined) && (metadata2.purpose === "diagnostic" ? diagnostic === undefined || routedDiagnostic : diagnostic === undefined) && (metadata2.submissionId === undefined || typeof metadata2.submissionId === "string") && (metadata2.score === undefined || isAssessmentScore(metadata2.score)) && isCompletionMetadata(metadata2.completion, metadata2.purpose, routedDiagnostic);
11481
11481
  }
11482
11482
  function isPlaycademyReviewAssessmentItemResultMetadataV1(value) {
11483
11483
  if (!isRecord(value) || !isRecord(value.responses)) {
@@ -11565,7 +11565,7 @@ function playcademyDiagnosticAssessmentItemResultMetadata(value) {
11565
11565
  const normalized = metadata2.responses === undefined ? { ...metadata2, responses: {} } : metadata2;
11566
11566
  return isPlaycademyDiagnosticAssessmentItemResultMetadataV1(normalized) ? normalized : null;
11567
11567
  }
11568
- var SCRIPT_SCHEMES, DATA_RASTER_IMAGE_PATTERN, QTI_MATHML_ALLOWED_TAGS, metadataSymbol, parser, SUPPORTED_QTI_INTERACTION_TYPES, URL_ATTRIBUTES, IMAGE_URL_ATTRIBUTES, INTERACTION_TYPES, EMPHASIS_TAGS, STRONG_TAGS, CONTENT_SKIPPED_TAGS, BLOCK_CONTENT_KINDS, GAP_MATCH_TOKEN_TAGS, STAGE_GRAPHIC_EXCLUDED_CONTAINERS, BLANK_SENTINEL = "￿", MARKED_BLANK_PATTERN, MARKED_BLANK_GLOBAL_PATTERN, MATCH_CORRECT_TEMPLATE = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES, RESPONSE_PROCESSING_TEMPLATES, VOID_BODY_ELEMENTS, URL_ATTRIBUTES2, IMAGE_URL_ATTRIBUTES2, VOID_CHOICE_ELEMENTS, NUMERIC_COMPARISON_ATTRIBUTES, RESPONSE_CARDINALITIES, RESPONSE_BASE_TYPES, POINT_INTERACTION_TYPES, INDEPENDENT_PROCESSING_TAGS, UUID_PATTERN, COMMON_CORE_MATH_FRAMEWORK_ALIASES, COMMON_CORE_ELA_FRAMEWORK_ALIASES, COMMON_CORE_FRAMEWORK_ALIASES, ASSESSMENT_ATTEMPT_OPEN, ASSESSMENT_ATTEMPT_COMPLETED, ASSESSMENT_ATTEMPT_AWAITING_AWARD, ASSESSMENT_ATTEMPT_SUPERSEDED, ASSESSMENT_RUNTIME_ERROR_STATUS, ROUTING_KEY_MAX_LENGTH = 128, ROUTING_RESULT_KEY_MAX_LENGTH = 256, ROUTING_STAGE_LIMIT = 32, ROUTING_TRACK_LIMIT = 128, ROUTING_NODE_LIMIT = 1000, ROUTING_TERMINAL_LIMIT = 1000, ROUTING_GROUP_LIMIT = 128, ROUTING_PREDICATE_DEPTH_LIMIT = 8, ROUTING_EXPLORATION_STATE_LIMIT = 200000, RoutingKeySchema, RoutingResultKeySchema, DiagnosticTrackOutcomeSchema, DiagnosticActivationPredicateSchema, DiagnosticRoutingTransitionSchema, DiagnosticRoutingManifestV1Schema, GRADE_VALUES, POINT_RESPONSE_PATTERN, REVIEW_SELECTION_POLICY_VERSION = "unseen-first-least-recently-delivered-v1", PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY = "playcademyReviewBank", PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION = 1, DEFAULT_REVIEW_SELECTION_POLICY, ASSESSMENT_RUNTIME_FIXTURE_BUNDLE_VERSION = 7, RuntimeSubjectSchema, RuntimeGradeSchema, OptionalQueryGradeSchema, AssessmentResponseValueSchema, AssessmentRuntimeIdentitySchema, ResponseKeySchema, StartAssessmentBaseSchema, AssessmentStandardRefSchema, LatestAssessmentFilterBaseSchema, LatestAssessmentFiltersSchema, LatestRuntimeAssessmentQuerySchema, StartAssessmentBodySchema, SaveAssessmentBodySchema, SubmitAssessmentItemBodySchema, SubmitAssessmentBodySchema, FinalizeAssessmentBodySchema, StartRuntimeAssessmentRequestSchema, SaveRuntimeAssessmentRequestSchema, SubmitAssessmentItemRuntimeRequestSchema, SubmitRuntimeAssessmentRequestSchema, FinalizeRuntimeAssessmentRequestSchema;
11568
+ var SCRIPT_SCHEMES, DATA_RASTER_IMAGE_PATTERN, QTI_MATHML_ALLOWED_TAGS, metadataSymbol, parser, SUPPORTED_QTI_INTERACTION_TYPES, URL_ATTRIBUTES, IMAGE_URL_ATTRIBUTES, INTERACTION_TYPES, EMPHASIS_TAGS, STRONG_TAGS, CONTENT_SKIPPED_TAGS, BLOCK_CONTENT_KINDS, GAP_MATCH_TOKEN_TAGS, STAGE_GRAPHIC_EXCLUDED_CONTAINERS, BLANK_SENTINEL = "￿", MARKED_BLANK_PATTERN, MARKED_BLANK_GLOBAL_PATTERN, MATCH_CORRECT_TEMPLATE = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES, RESPONSE_PROCESSING_TEMPLATES, VOID_BODY_ELEMENTS, URL_ATTRIBUTES2, IMAGE_URL_ATTRIBUTES2, VOID_CHOICE_ELEMENTS, NUMERIC_COMPARISON_ATTRIBUTES, RESPONSE_CARDINALITIES, RESPONSE_BASE_TYPES, POINT_INTERACTION_TYPES, INDEPENDENT_PROCESSING_TAGS, UUID_PATTERN, COMMON_CORE_MATH_FRAMEWORK_ALIASES, COMMON_CORE_ELA_FRAMEWORK_ALIASES, COMMON_CORE_FRAMEWORK_ALIASES, ASSESSMENT_ATTEMPT_OPEN, ASSESSMENT_ATTEMPT_COMPLETED, ASSESSMENT_ATTEMPT_AWAITING_AWARD, ASSESSMENT_ATTEMPT_SUPERSEDED, ASSESSMENT_RUNTIME_ERROR_STATUS, ROUTING_KEY_MAX_LENGTH = 128, ROUTING_RESULT_KEY_MAX_LENGTH = 256, ROUTING_STAGE_LIMIT = 32, ROUTING_TRACK_LIMIT = 128, ROUTING_NODE_LIMIT = 1000, ROUTING_TERMINAL_LIMIT = 1000, ROUTING_GROUP_LIMIT = 128, ROUTING_PREDICATE_DEPTH_LIMIT = 8, ROUTING_EXPLORATION_STATE_LIMIT = 200000, RoutingKeySchema, RoutingResultKeySchema, DiagnosticTrackOutcomeSchema, DiagnosticActivationPredicateSchema, DiagnosticRoutingTransitionSchema, DiagnosticRoutingManifestV1Schema, GRADE_VALUES, POINT_RESPONSE_PATTERN, REVIEW_SELECTION_POLICY_VERSION = "unseen-first-least-recently-delivered-v1", PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY = "playcademyReviewBank", PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION = 1, DEFAULT_REVIEW_SELECTION_POLICY, ASSESSMENT_RUNTIME_FIXTURE_BUNDLE_VERSION = 8, RuntimeSubjectSchema, RuntimeGradeSchema, OptionalQueryGradeSchema, AssessmentResponseValueSchema, AssessmentRuntimeIdentitySchema, ResponseKeySchema, StartAssessmentBaseSchema, AssessmentStandardRefSchema, LatestAssessmentFilterBaseSchema, LatestAssessmentFiltersSchema, LatestRuntimeAssessmentQuerySchema, StartAssessmentBodySchema, SaveAssessmentBodySchema, SubmitAssessmentItemBodySchema, SubmitAssessmentBodySchema, FinalizeAssessmentBodySchema, StartRuntimeAssessmentRequestSchema, SaveRuntimeAssessmentRequestSchema, SubmitAssessmentItemRuntimeRequestSchema, SubmitRuntimeAssessmentRequestSchema, FinalizeRuntimeAssessmentRequestSchema;
11569
11569
  var init_assessment_runtime2 = __esm(() => {
11570
11570
  init_timeback3();
11571
11571
  init_timeback3();
@@ -11915,7 +11915,12 @@ var init_assessment_runtime2 = __esm(() => {
11915
11915
  });
11916
11916
  FinalizeAssessmentBodySchema = exports_external.object({
11917
11917
  submissionId: exports_external.string().trim().min(1),
11918
- xpAwarded: exports_external.number().finite().nonnegative()
11918
+ xpAwarded: exports_external.number().finite().nonnegative(),
11919
+ masteredUnits: exports_external.number().finite().optional(),
11920
+ masteredUnitsAbsolute: exports_external.number().finite().int().optional()
11921
+ }).refine((body2) => body2.masteredUnits === undefined || body2.masteredUnitsAbsolute === undefined, {
11922
+ message: "Provide either masteredUnits or masteredUnitsAbsolute, not both",
11923
+ path: ["masteredUnits"]
11919
11924
  });
11920
11925
  StartRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.and(StartAssessmentBodySchema);
11921
11926
  SaveRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend(SaveAssessmentBodySchema.shape);
@@ -11924,10 +11929,10 @@ var init_assessment_runtime2 = __esm(() => {
11924
11929
  appName: exports_external.string().trim().min(1),
11925
11930
  sensorUrl: exports_external.string().url()
11926
11931
  });
11927
- FinalizeRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend(FinalizeAssessmentBodySchema.shape).extend({
11932
+ FinalizeRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend({
11928
11933
  appName: exports_external.string().trim().min(1),
11929
11934
  sensorUrl: exports_external.string().url()
11930
- });
11935
+ }).and(FinalizeAssessmentBodySchema);
11931
11936
  });
11932
11937
 
11933
11938
  // ../api-core/src/errors/assessment-runtime.error.ts
@@ -47110,7 +47115,16 @@ function requireMasteryStandard(input, context2) {
47110
47115
  });
47111
47116
  }
47112
47117
  }
47113
- var TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, AssessmentPurposeSchema, AssessmentStatusSchema, AssessmentKeySchema, DiagnosticKeySchema, DiagnosticRoutingManifestSchema, DiagnosticAssessmentDefinitionInputSchema, AssessmentStandardRefSchema2, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, AssessmentAssociationImportEntrySchema, AttachExistingAssessmentsRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
47118
+ function rejectSystemManagedReview(input, context2) {
47119
+ if (input.purpose === "review") {
47120
+ context2.addIssue({
47121
+ code: "custom",
47122
+ path: ["purpose"],
47123
+ message: "The review bank is system-managed and cannot be managed manually"
47124
+ });
47125
+ }
47126
+ }
47127
+ var TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, AssessmentPurposeSchema, AssessmentStatusSchema, AssessmentKeySchema, DiagnosticKeySchema, DiagnosticRoutingManifestSchema, DiagnosticAssessmentDefinitionInputSchema, AssessmentStandardRefSchema2, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, AssessmentAssociationImportEntrySchema, AttachExistingAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
47114
47128
  var init_schemas4 = __esm(() => {
47115
47129
  init_esm();
47116
47130
  init_src();
@@ -47371,7 +47385,10 @@ var init_schemas4 = __esm(() => {
47371
47385
  title: exports_external.string().min(1, "Assessment title is required"),
47372
47386
  purpose: AssessmentPurposeSchema,
47373
47387
  standard: AssessmentStandardRefSchema2.optional()
47374
- }).superRefine(requireMasteryStandard);
47388
+ }).superRefine((input, context2) => {
47389
+ requireMasteryStandard(input, context2);
47390
+ rejectSystemManagedReview(input, context2);
47391
+ });
47375
47392
  UpdateAssessmentRequestSchema = exports_external.object({
47376
47393
  title: exports_external.string().trim().min(1, "Assessment title is required").optional(),
47377
47394
  purpose: AssessmentPurposeSchema.optional(),
@@ -47380,21 +47397,24 @@ var init_schemas4 = __esm(() => {
47380
47397
  status: AssessmentStatusSchema.optional()
47381
47398
  }).refine((input) => input.title !== undefined || input.purpose !== undefined || input.standard !== undefined || input.diagnostic !== undefined || input.status !== undefined, {
47382
47399
  message: "Title, purpose, standard, diagnostic, or status is required"
47383
- });
47400
+ }).superRefine(rejectSystemManagedReview);
47384
47401
  CopyAssessmentRequestSchema = exports_external.object({
47385
47402
  assessmentKey: AssessmentKeySchema,
47386
47403
  testIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
47387
47404
  purpose: AssessmentPurposeSchema,
47388
47405
  standard: AssessmentStandardRefSchema2.optional()
47389
- }).superRefine(requireMasteryStandard);
47406
+ }).superRefine((input, context2) => {
47407
+ requireMasteryStandard(input, context2);
47408
+ rejectSystemManagedReview(input, context2);
47409
+ });
47390
47410
  AssessmentAssociationImportEntrySchema = exports_external.object({
47391
47411
  assessmentKey: AssessmentKeySchema,
47392
47412
  qtiTestIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
47393
47413
  purpose: AssessmentPurposeSchema,
47394
- standard: AssessmentStandardRefSchema2.optional(),
47395
- sortOrder: exports_external.number().int().nonnegative().nullable()
47414
+ standard: AssessmentStandardRefSchema2.optional()
47396
47415
  }).superRefine((input, context2) => {
47397
47416
  requireMasteryStandard(input, context2);
47417
+ rejectSystemManagedReview(input, context2);
47398
47418
  if (input.purpose !== "mastery" && input.standard !== undefined) {
47399
47419
  context2.addIssue({
47400
47420
  code: "custom",
@@ -47441,10 +47461,6 @@ var init_schemas4 = __esm(() => {
47441
47461
  seenKeys.add(assessment.assessmentKey);
47442
47462
  });
47443
47463
  });
47444
- ReorderAssessmentsRequestSchema = exports_external.object({
47445
- purpose: AssessmentPurposeSchema,
47446
- testIdentifiers: exports_external.array(exports_external.string().trim().min(1, "Assessment identifier is required")).min(1, "At least one assessment is required")
47447
- });
47448
47464
  ReorderQuestionsRequestSchema = exports_external.object({
47449
47465
  items: exports_external.array(exports_external.object({
47450
47466
  identifier: exports_external.string().min(1),
@@ -90873,6 +90889,7 @@ function buildAssessmentCompletionEvent(input) {
90873
90889
  totalQuestions: input.totalQuestions,
90874
90890
  correctQuestions: input.correctQuestions,
90875
90891
  xpEarned: input.xpAwarded,
90892
+ ...input.masteredUnits ? { masteredUnits: input.masteredUnits } : {},
90876
90893
  attemptNumber: input.attemptNumber,
90877
90894
  process: false,
90878
90895
  subject: input.subject,
@@ -90881,7 +90898,10 @@ function buildAssessmentCompletionEvent(input) {
90881
90898
  eventId: input.eventId,
90882
90899
  eventTime: input.submittedAt,
90883
90900
  runId: input.attemptId,
90884
- generatedExtensions: metadata2,
90901
+ generatedExtensions: {
90902
+ ...metadata2,
90903
+ ...input.pctCompleteApp !== undefined ? { pctCompleteApp: input.pctCompleteApp } : {}
90904
+ },
90885
90905
  eventExtensions: {
90886
90906
  playcademy: {
90887
90907
  assessmentPurpose: input.purpose,
@@ -90935,6 +90955,85 @@ function deriveSourcedIds(courseId) {
90935
90955
  componentResource: `${courseId}-cr`
90936
90956
  };
90937
90957
  }
90958
+ function hasMasteryAwardRequest(request) {
90959
+ return request.masteredUnits !== undefined || request.masteredUnitsAbsolute !== undefined;
90960
+ }
90961
+
90962
+ class ActivityMasteryAward {
90963
+ mastery;
90964
+ events;
90965
+ constructor(mastery, events) {
90966
+ this.mastery = mastery;
90967
+ this.events = events;
90968
+ }
90969
+ async evaluate(input) {
90970
+ const ids = deriveSourcedIds(input.courseId);
90971
+ const progress = await this.mastery.checkProgress({
90972
+ studentId: input.studentId,
90973
+ courseId: input.courseId,
90974
+ resourceId: ids.resource,
90975
+ masteredUnits: input.masteredUnits ?? 0,
90976
+ masteredUnitsAbsolute: input.masteredUnitsAbsolute
90977
+ });
90978
+ if (!progress) {
90979
+ return {
90980
+ masteredUnitsApplied: input.masteredUnits ?? 0,
90981
+ masteryAchieved: false,
90982
+ masteryRevoked: false
90983
+ };
90984
+ }
90985
+ return {
90986
+ masteredUnitsApplied: progress.effectiveDelta,
90987
+ pctCompleteApp: progress.pctCompleteApp,
90988
+ masteryAchieved: progress.masteryAchieved,
90989
+ masteryRevoked: progress.masteryRevoked,
90990
+ ...progress.writeWarning ? { warnings: [progress.writeWarning] } : {}
90991
+ };
90992
+ }
90993
+ async settle(evaluation, input) {
90994
+ let completionEntryWritten = false;
90995
+ if (evaluation.masteryAchieved) {
90996
+ setAttributes({ "app.timeback.mastery_achieved": true });
90997
+ completionEntryWritten = await this.mastery.createCompletionEntry(input.studentId, input.courseId, input.classId, input.appName);
90998
+ await this.emitCourseCompletionHistoryEvent(input);
90999
+ }
91000
+ if (evaluation.masteryRevoked) {
91001
+ await this.mastery.revokeCompletionEntry(input.studentId, input.courseId, input.classId, input.appName);
91002
+ }
91003
+ return { completionEntryWritten };
91004
+ }
91005
+ async emitCourseCompletionHistoryEvent(input) {
91006
+ const ids = deriveSourcedIds(input.courseId);
91007
+ await this.events.emitActivityEvent({
91008
+ eventId: input.completionHistoryEventId,
91009
+ studentId: input.studentId,
91010
+ studentEmail: input.studentEmail,
91011
+ gameId: input.gameId,
91012
+ activityId: input.activityId,
91013
+ activityName: "Course completed",
91014
+ courseId: ids.course,
91015
+ courseName: input.courseName,
91016
+ subject: input.subject,
91017
+ appName: input.appName,
91018
+ sensorUrl: input.sensorUrl,
91019
+ process: false,
91020
+ includeAttempt: false,
91021
+ eventExtensions: {
91022
+ playcademy: {
91023
+ eventKind: "course-completed",
91024
+ source: "gameplay"
91025
+ }
91026
+ },
91027
+ generatedExtensions: {
91028
+ playcademy: {
91029
+ eventKind: "course-completed",
91030
+ source: "gameplay",
91031
+ activityId: input.activityId
91032
+ }
91033
+ }
91034
+ }).catch(catchEvent("timeback.caliper_completion_event_failed"));
91035
+ }
91036
+ }
90938
91037
  function validateProgressData(progressData) {
90939
91038
  if (!progressData.subject) {
90940
91039
  throw new ConfigurationError("subject", "Subject is required for Caliper events. Provide it in progressData.subject");
@@ -90950,12 +91049,12 @@ function validateProgressData(progressData) {
90950
91049
  class ActivityRecord {
90951
91050
  core;
90952
91051
  students;
90953
- mastery;
91052
+ masteryAward;
90954
91053
  events;
90955
- constructor(core3, students, mastery, events) {
91054
+ constructor(core3, students, masteryAward, events) {
90956
91055
  this.core = core3;
90957
91056
  this.students = students;
90958
- this.mastery = mastery;
91057
+ this.masteryAward = masteryAward;
90959
91058
  this.events = events;
90960
91059
  }
90961
91060
  async record(courseId, studentIdentifier, progressData) {
@@ -90981,52 +91080,32 @@ class ActivityRecord {
90981
91080
  throw error88;
90982
91081
  }
90983
91082
  const legacyLineItemId = `${ids.course}-${activityId}-assessment`;
90984
- const [currentAttemptNumber, masteryProgress] = await Promise.all([
91083
+ const [currentAttemptNumber, mastery] = await Promise.all([
90985
91084
  this.resolveAttemptNumber(attemptNumber, studentId, caliperLineItemId, legacyLineItemId),
90986
- this.mastery.checkProgress({
91085
+ this.masteryAward.evaluate({
90987
91086
  studentId,
90988
91087
  courseId,
90989
- resourceId: ids.resource,
90990
- masteredUnits: progressData.masteredUnits ?? 0,
91088
+ masteredUnits: progressData.masteredUnits,
90991
91089
  masteredUnitsAbsolute: progressData.masteredUnitsAbsolute
90992
91090
  })
90993
91091
  ]);
90994
91092
  setAttributes({ "app.timeback.attempt_number": currentAttemptNumber });
90995
- let extensions = progressData.extensions;
90996
- const effectiveMasteredUnits = masteryProgress ? masteryProgress.effectiveDelta : progressData.masteredUnits ?? 0;
90997
- let pctCompleteApp;
90998
- let masteryAchieved = false;
90999
- let completionEntryWritten = false;
91000
- const warnings = masteryProgress?.writeWarning ? [masteryProgress.writeWarning] : undefined;
91001
- if (masteryProgress) {
91002
- masteryAchieved = masteryProgress.masteryAchieved;
91003
- pctCompleteApp = masteryProgress.pctCompleteApp;
91004
- extensions = {
91005
- ...extensions,
91006
- ...pctCompleteApp !== undefined ? { pctCompleteApp } : {}
91007
- };
91008
- }
91009
- if (masteryAchieved) {
91010
- setAttributes({ "app.timeback.mastery_achieved": true });
91011
- }
91012
- if (masteryAchieved) {
91013
- completionEntryWritten = await this.mastery.createCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
91014
- await this.emitCourseCompletionHistoryEvent({
91015
- studentId,
91016
- studentEmail,
91017
- gameId: progressData.gameId,
91018
- activityId,
91019
- courseId: ids.course,
91020
- courseName,
91021
- subject: progressData.subject,
91022
- appName: progressData.appName,
91023
- sensorUrl: progressData.sensorUrl,
91024
- eventId: progressData.completionHistoryEventId
91025
- });
91026
- }
91027
- if (masteryProgress?.masteryRevoked) {
91028
- await this.mastery.revokeCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
91029
- }
91093
+ const effectiveMasteredUnits = mastery.masteredUnitsApplied;
91094
+ const { pctCompleteApp, warnings } = mastery;
91095
+ const extensions = pctCompleteApp !== undefined ? { ...progressData.extensions, pctCompleteApp } : progressData.extensions;
91096
+ const { completionEntryWritten } = await this.masteryAward.settle(mastery, {
91097
+ studentId,
91098
+ studentEmail,
91099
+ courseId,
91100
+ classId: progressData.classId,
91101
+ gameId: progressData.gameId,
91102
+ activityId,
91103
+ courseName,
91104
+ subject: progressData.subject,
91105
+ appName: progressData.appName,
91106
+ sensorUrl: progressData.sensorUrl,
91107
+ completionHistoryEventId: progressData.completionHistoryEventId
91108
+ });
91030
91109
  try {
91031
91110
  await this.events.emitActivityEvent({
91032
91111
  studentId,
@@ -91047,7 +91126,7 @@ class ActivityRecord {
91047
91126
  appName: progressData.appName,
91048
91127
  sensorUrl: progressData.sensorUrl,
91049
91128
  eventId: progressData.eventId,
91050
- extensions: extensions || progressData.extensions,
91129
+ extensions,
91051
91130
  ...progressData.runId ? { runId: progressData.runId } : {}
91052
91131
  }).catch((error88) => {
91053
91132
  setAttributes({ "app.timeback.caliper_emit_failed": true });
@@ -91068,36 +91147,6 @@ class ActivityRecord {
91068
91147
  ...warnings ? { warnings } : {}
91069
91148
  };
91070
91149
  }
91071
- async emitCourseCompletionHistoryEvent(data) {
91072
- await this.events.emitActivityEvent({
91073
- eventId: data.eventId,
91074
- studentId: data.studentId,
91075
- studentEmail: data.studentEmail,
91076
- gameId: data.gameId,
91077
- activityId: data.activityId,
91078
- activityName: "Course completed",
91079
- courseId: data.courseId,
91080
- courseName: data.courseName,
91081
- subject: data.subject,
91082
- appName: data.appName,
91083
- sensorUrl: data.sensorUrl,
91084
- process: false,
91085
- includeAttempt: false,
91086
- eventExtensions: {
91087
- playcademy: {
91088
- eventKind: "course-completed",
91089
- source: "gameplay"
91090
- }
91091
- },
91092
- generatedExtensions: {
91093
- playcademy: {
91094
- eventKind: "course-completed",
91095
- source: "gameplay",
91096
- activityId: data.activityId
91097
- }
91098
- }
91099
- }).catch(catchEvent("timeback.caliper_completion_event_failed"));
91100
- }
91101
91150
  async resolveAttemptNumber(providedAttemptNumber, studentId, caliperLineItemId, legacyLineItemId) {
91102
91151
  if (providedAttemptNumber) {
91103
91152
  setAttributes({ "app.timeback.attempt_source": "provided" });
@@ -91495,9 +91544,12 @@ class ActivityEvents {
91495
91544
  }
91496
91545
  }
91497
91546
  function createActivityNamespace(core3, deps) {
91498
- const record3 = new ActivityRecord(core3, deps.students, deps.mastery, deps.events);
91547
+ const masteryAward = new ActivityMasteryAward(deps.mastery, deps.events);
91548
+ const record3 = new ActivityRecord(core3, deps.students, masteryAward, deps.events);
91499
91549
  const session2 = new ActivitySession(deps.students, deps.events);
91500
91550
  return {
91551
+ evaluateMasteryAward: (input) => masteryAward.evaluate(input),
91552
+ settleMasteryAward: (evaluation, input) => masteryAward.settle(evaluation, input),
91501
91553
  record: (courseId, studentIdentifier, progressData) => record3.record(courseId, studentIdentifier, progressData),
91502
91554
  session: (courseId, studentIdentifier, sessionData) => session2.record(courseId, studentIdentifier, sessionData),
91503
91555
  listEvents: (params) => deps.events.listEvents(params),
@@ -92781,8 +92833,9 @@ class TimebackClient2 {
92781
92833
  this.core.caches.clearAll();
92782
92834
  }
92783
92835
  }
92784
- var TimebackError, TimebackAuthenticationError, StudentNotFoundError, ConfigurationError, UUID_REGEX2, ONEROSTER_PATHS, TIMEBACK_API_URLS, QTI_API_URL = "https://qti.alpha-1edtech.ai/api", TIMEBACK_AUTH_URLS, CALIPER_API_URLS, ENV_VARS, ONEROSTER_STATUS, SCORE_STATUS, CACHE_DEFAULTS, PLAYCADEMY_DEFAULTS, RESOURCE_DEFAULTS, DEFAULT_TIMEOUT = 30000, SUBJECT_VALUES2, GRADE_VALUES3, MASTERY_WRITE_CAPPED_WARNING_CODE = "MASTERY_WRITE_CAPPED", EmailSchema;
92836
+ var TimebackError, TimebackAuthenticationError, StudentNotFoundError, ConfigurationError, UUID_REGEX2, ONEROSTER_PATHS, NO_MASTERY_AWARD, TIMEBACK_API_URLS, QTI_API_URL = "https://qti.alpha-1edtech.ai/api", TIMEBACK_AUTH_URLS, CALIPER_API_URLS, ENV_VARS, ONEROSTER_STATUS, SCORE_STATUS, CACHE_DEFAULTS, PLAYCADEMY_DEFAULTS, RESOURCE_DEFAULTS, DEFAULT_TIMEOUT = 30000, SUBJECT_VALUES2, GRADE_VALUES3, MASTERY_WRITE_CAPPED_WARNING_CODE = "MASTERY_WRITE_CAPPED", EmailSchema;
92785
92837
  var init_dist5 = __esm(async () => {
92838
+ init_spans();
92786
92839
  init_spans();
92787
92840
  init_src();
92788
92841
  init_spans();
@@ -92843,6 +92896,11 @@ var init_dist5 = __esm(async () => {
92843
92896
  courses: "/ims/oneroster/rostering/v1p2/courses",
92844
92897
  componentResources: "/ims/oneroster/rostering/v1p2/courses/component-resources"
92845
92898
  };
92899
+ NO_MASTERY_AWARD = {
92900
+ masteredUnitsApplied: 0,
92901
+ masteryAchieved: false,
92902
+ masteryRevoked: false
92903
+ };
92846
92904
  TIMEBACK_API_URLS = {
92847
92905
  production: "https://api.alpha-1edtech.ai",
92848
92906
  staging: "https://api.staging.alpha-1edtech.com"
@@ -95608,6 +95666,62 @@ function stringField2(value) {
95608
95666
  function firstStringField2(...values) {
95609
95667
  return values.map(stringField2).find(Boolean) ?? "";
95610
95668
  }
95669
+ function normalizedWhitespace2(value) {
95670
+ return value.normalize("NFKC").trim().replace(/\s+/g, " ");
95671
+ }
95672
+ function normalizedIdentityCase2(value) {
95673
+ return value.toLocaleUpperCase("en-US");
95674
+ }
95675
+ function frameworkAliasKey2(value) {
95676
+ return value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/g, "");
95677
+ }
95678
+ function isCommonCoreMathIdentifier2(identifier) {
95679
+ const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
95680
+ 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);
95681
+ }
95682
+ function isCommonCoreElaIdentifier2(identifier) {
95683
+ const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
95684
+ return canonical.startsWith("CCSS.ELA-LITERACY.") || /^(?:RL|RI|RF|W|SL|L)\.(?:K|[1-9]|1[0-2])\./.test(canonical) || /^(?:RH|RST|WHST)\.(?:6-8|9-10|11-12)\./.test(canonical) || /^CCRA\.(?:R|W|SL|L)\./.test(canonical);
95685
+ }
95686
+ function canonicalFramework2(authoredFramework, identifier) {
95687
+ const aliasKey = frameworkAliasKey2(authoredFramework);
95688
+ if (COMMON_CORE_MATH_FRAMEWORK_ALIASES2.has(aliasKey) || COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey) && isCommonCoreMathIdentifier2(identifier)) {
95689
+ return "CCSS.Math";
95690
+ }
95691
+ if (COMMON_CORE_ELA_FRAMEWORK_ALIASES2.has(aliasKey) || COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey) && isCommonCoreElaIdentifier2(identifier)) {
95692
+ return "CCSS.ELA-Literacy";
95693
+ }
95694
+ if (COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey)) {
95695
+ return "CCSS";
95696
+ }
95697
+ return normalizedIdentityCase2(authoredFramework);
95698
+ }
95699
+ function canonicalIdentifier2(framework, authoredIdentifier) {
95700
+ const identifier = normalizedIdentityCase2(normalizedWhitespace2(authoredIdentifier));
95701
+ if (framework === "CCSS.Math") {
95702
+ return identifier.replace(/^CCSS\.MATH\.CONTENT\./, "").replace(/^CCSS\.MATH\.PRACTICE\./, "").replace(/^CCSS\.MATH\./, "");
95703
+ }
95704
+ if (framework === "CCSS.ELA-Literacy") {
95705
+ return identifier.replace(/^CCSS\.ELA-LITERACY\./, "");
95706
+ }
95707
+ return identifier;
95708
+ }
95709
+ function canonicalAssessmentStandardRef2(standard) {
95710
+ const authoredFramework = normalizedWhitespace2(standard.framework);
95711
+ const framework = canonicalFramework2(authoredFramework, standard.identifier);
95712
+ const identifier = canonicalIdentifier2(framework, standard.identifier);
95713
+ return {
95714
+ framework,
95715
+ identifier
95716
+ };
95717
+ }
95718
+ function assessmentStandardRefKey2(standard) {
95719
+ const canonical = canonicalAssessmentStandardRef2(standard);
95720
+ return JSON.stringify([
95721
+ normalizedIdentityCase2(canonical.framework),
95722
+ normalizedIdentityCase2(canonical.identifier)
95723
+ ]);
95724
+ }
95611
95725
  function dedupeStandards2(standards) {
95612
95726
  const deduped = new Map;
95613
95727
  for (const standard of standards) {
@@ -98341,6 +98455,22 @@ var init_locks = __esm(() => {
98341
98455
  });
98342
98456
 
98343
98457
  // ../api-core/src/utils/assessment-runtime-lock.util.ts
98458
+ async function crossAssessmentAttemptLockBarrier(lock, db2, attemptId) {
98459
+ const maxAttempts = 5;
98460
+ for (let attempt = 1;attempt <= maxAttempts; attempt += 1) {
98461
+ try {
98462
+ await lock(db2, [assessmentRuntimeAttemptLockKey(attemptId)], async () => {
98463
+ return;
98464
+ });
98465
+ return;
98466
+ } catch (error88) {
98467
+ if (!(error88 instanceof ServiceUnavailableError) || attempt === maxAttempts) {
98468
+ throw error88;
98469
+ }
98470
+ await sleep(25);
98471
+ }
98472
+ }
98473
+ }
98344
98474
  function normalizeKeys(keys) {
98345
98475
  return [...new Set(keys)].toSorted();
98346
98476
  }
@@ -99354,6 +99484,97 @@ var init_timeback_qti_authoring_util = __esm(() => {
99354
99484
  });
99355
99485
 
99356
99486
  // ../api-core/src/utils/timeback-assessment-runtime.util.ts
99487
+ function assessmentPreparationContentionError(expectedResponseVersion, responseVersion, kind) {
99488
+ if (expectedResponseVersion === responseVersion) {
99489
+ return new ServiceUnavailableError(`The ${kind} response state changed repeatedly while this item was being prepared. Try again shortly.`, {
99490
+ retryable: true,
99491
+ reason: "PREPARATION_CONTENTION",
99492
+ expectedResponseVersion,
99493
+ responseVersion
99494
+ });
99495
+ }
99496
+ return AssessmentRuntimeError.from(assessmentResponseVersionConflict(expectedResponseVersion, responseVersion));
99497
+ }
99498
+ function assessmentTrackableCourse(integration) {
99499
+ if (!isTimebackGrade2(integration.grade) || !isTimebackSubject(integration.subject)) {
99500
+ return null;
99501
+ }
99502
+ return { grade: integration.grade, subject: integration.subject };
99503
+ }
99504
+ function buildAssessmentActivityData(input) {
99505
+ if (!input.course) {
99506
+ return;
99507
+ }
99508
+ return {
99509
+ activityId: input.activityId,
99510
+ activityName: input.activityName,
99511
+ ...input.course,
99512
+ courseId: input.courseId
99513
+ };
99514
+ }
99515
+ function buildDiagnosticAssessmentSnapshot(input) {
99516
+ return {
99517
+ attemptId: input.attemptId,
99518
+ responseVersion: input.metadata.responseVersion,
99519
+ status: "in_progress",
99520
+ flow: "platform-routed-item-submit",
99521
+ ...input.activityData ? { activityData: input.activityData } : {},
99522
+ assessment: assessmentPresentationForAttempt(input.assessment, input.attemptId),
99523
+ selection: {
99524
+ kind: "platform-routed-diagnostic",
99525
+ purpose: "diagnostic",
99526
+ definitionId: input.metadata.diagnostic.definitionId,
99527
+ diagnosticKey: input.metadata.diagnostic.diagnosticKey,
99528
+ routingRevision: input.metadata.diagnostic.routingRevision
99529
+ },
99530
+ routing: projectDiagnosticRoutingSnapshot(input.metadata.diagnostic.routingRevision, input.state),
99531
+ completion: null
99532
+ };
99533
+ }
99534
+ function buildConventionalAssessmentSnapshot(input) {
99535
+ const metadata2 = input.metadata;
99536
+ let selection;
99537
+ if (metadata2.purpose === "review") {
99538
+ selection = {
99539
+ kind: "standards-review",
99540
+ purpose: metadata2.purpose,
99541
+ standards: [...metadata2.review.standards],
99542
+ candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
99543
+ selections: [...metadata2.review.selections],
99544
+ fulfillment: metadata2.review.fulfillment
99545
+ };
99546
+ } else if (metadata2.purpose === "mastery") {
99547
+ selection = {
99548
+ kind: "standard-quiz",
99549
+ purpose: metadata2.purpose,
99550
+ standard: metadata2.mastery.standard
99551
+ };
99552
+ } else {
99553
+ selection = { kind: "fixed-test", purpose: metadata2.purpose };
99554
+ }
99555
+ return {
99556
+ attemptId: input.attemptId,
99557
+ responseVersion: metadata2.responseVersion,
99558
+ status: "in_progress",
99559
+ flow: assessmentFlowForPurpose(metadata2.purpose),
99560
+ ...input.activityData ? { activityData: input.activityData } : {},
99561
+ assessment: assessmentPresentationForAttempt(input.assessment, input.attemptId),
99562
+ responses: metadata2.responses,
99563
+ itemSubmissions: metadata2.itemSubmissions,
99564
+ score: null,
99565
+ selection
99566
+ };
99567
+ }
99568
+ function buildAssessmentAwardRecord(input, mastery) {
99569
+ return {
99570
+ xpAwarded: input.xpAwarded,
99571
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
99572
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {},
99573
+ masteredUnitsApplied: mastery.masteredUnitsApplied,
99574
+ ...mastery.pctCompleteApp !== undefined ? { pctCompleteApp: mastery.pctCompleteApp } : {},
99575
+ ...mastery.warnings ? { warnings: mastery.warnings } : {}
99576
+ };
99577
+ }
99357
99578
  function stageAssessmentAttemptSupersession(attempt) {
99358
99579
  Object.assign(attempt.result, ASSESSMENT_ATTEMPT_SUPERSEDED);
99359
99580
  Object.assign(attempt.selection, ASSESSMENT_ATTEMPT_SUPERSEDED);
@@ -99564,13 +99785,35 @@ function buildAssessmentSubmitResult(input) {
99564
99785
  }
99565
99786
  return { ...base, purpose: input.metadata.purpose };
99566
99787
  }
99788
+ function assessmentAwardFromResult(result, metadata2) {
99789
+ if (metadata2.award) {
99790
+ return metadata2.award;
99791
+ }
99792
+ const xp = result.metadata?.xp;
99793
+ return typeof xp === "number" && Number.isFinite(xp) && xp >= 0 ? { xpAwarded: xp, masteredUnitsApplied: 0 } : null;
99794
+ }
99567
99795
  function buildCompletedAssessmentSubmitResult(input) {
99568
- const submitted = buildAssessmentSubmitResult(input);
99569
- return submitted ? {
99796
+ const attemptId = input.result.sourcedId;
99797
+ const award = assessmentAwardFromResult(input.result, input.metadata);
99798
+ if (award === null) {
99799
+ throw new GoneError("This completed assessment is missing its recorded XP.", {
99800
+ attemptId
99801
+ });
99802
+ }
99803
+ const submitted = buildAssessmentSubmitResult({ attemptId, metadata: input.metadata });
99804
+ if (!submitted) {
99805
+ throw new GoneError("This completed assessment has incomplete persisted results.", {
99806
+ attemptId
99807
+ });
99808
+ }
99809
+ return {
99570
99810
  ...submitted,
99571
99811
  status: "completed",
99572
- xpAwarded: input.xpAwarded
99573
- } : null;
99812
+ xpAwarded: award.xpAwarded,
99813
+ masteredUnitsApplied: award.masteredUnitsApplied,
99814
+ ...award.pctCompleteApp !== undefined ? { pctCompleteApp: award.pctCompleteApp } : {},
99815
+ ...award.warnings ? { warnings: award.warnings } : {}
99816
+ };
99574
99817
  }
99575
99818
  function assessmentAttemptId(input) {
99576
99819
  return deterministicUUID([
@@ -99897,6 +100140,22 @@ function assessmentFixtureScoringKeys(assessment, questions) {
99897
100140
  ...Object.keys(responseAreas).length > 0 ? { responseAreas } : {}
99898
100141
  };
99899
100142
  }
100143
+ function prepareDiagnosticAssessmentResponses(assessment, current, input) {
100144
+ const update2 = { [input.itemIdentifier]: input.responses };
100145
+ validateAssessmentResponseUpdate(assessment, update2);
100146
+ const responses = applyAssessmentResponseUpdate(current, update2);
100147
+ const item = assessment.items.find((candidate) => candidate.identifier === input.itemIdentifier);
100148
+ const itemResponses = responses[input.itemIdentifier];
100149
+ const missingResponse = item?.interactions.find((interaction) => itemResponses?.[interaction.responseIdentifier] === undefined);
100150
+ if (!item || !itemResponses || missingResponse) {
100151
+ throw new AssessmentRuntimeError("INVALID_RESPONSE", `Diagnostic item ${input.itemIdentifier} requires a response for every interaction.`, {
100152
+ itemIdentifier: input.itemIdentifier,
100153
+ ...missingResponse ? { responseIdentifier: missingResponse.responseIdentifier } : {}
100154
+ });
100155
+ }
100156
+ validateAssessmentResponses(assessment, { [input.itemIdentifier]: itemResponses });
100157
+ return responses;
100158
+ }
99900
100159
  function validateAssessmentResponseUpdate(assessment, update2) {
99901
100160
  const message = assessmentResponseUpdateValidationMessage(assessment, update2);
99902
100161
  if (message) {
@@ -99983,7 +100242,7 @@ function buildAssessmentResultSubmission(input) {
99983
100242
  }
99984
100243
  };
99985
100244
  }
99986
- function buildAssessmentResultXpFinalization(input) {
100245
+ function buildAssessmentResultAwardFinalization(input) {
99987
100246
  const completion = input.metadata.completion;
99988
100247
  if (!completion || completion.totalQuestions === undefined) {
99989
100248
  throw new Error("A scored assessment must retain its completion facts before finalization");
@@ -99991,8 +100250,10 @@ function buildAssessmentResultXpFinalization(input) {
99991
100250
  const metadata2 = {
99992
100251
  ...input.metadata,
99993
100252
  finalizedAt: input.timestamp,
99994
- updatedAt: input.timestamp
100253
+ updatedAt: input.timestamp,
100254
+ award: input.award
99995
100255
  };
100256
+ const masteryRequested = input.award.masteredUnits !== undefined || input.award.masteredUnitsAbsolute !== undefined;
99996
100257
  return {
99997
100258
  metadata: metadata2,
99998
100259
  resultUpdate: {
@@ -100004,7 +100265,8 @@ function buildAssessmentResultXpFinalization(input) {
100004
100265
  ...ASSESSMENT_ATTEMPT_COMPLETED,
100005
100266
  metadata: {
100006
100267
  ...mergeAssessmentRuntimeMetadata(input.result.metadata, metadata2),
100007
- xp: input.xpAwarded,
100268
+ xp: input.award.xpAwarded,
100269
+ ...masteryRequested ? { masteredUnits: input.award.masteredUnitsApplied } : {},
100008
100270
  totalQuestions: completion.totalQuestions,
100009
100271
  correctQuestions: completion.correctQuestions,
100010
100272
  appName: input.appName
@@ -100017,6 +100279,7 @@ var init_timeback_assessment_runtime_util = __esm(() => {
100017
100279
  init_src();
100018
100280
  init_assessment_runtime2();
100019
100281
  init_qti();
100282
+ init_types2();
100020
100283
  init_utils6();
100021
100284
  init_timeback4();
100022
100285
  init_uuid();
@@ -100025,6 +100288,210 @@ var init_timeback_assessment_runtime_util = __esm(() => {
100025
100288
  });
100026
100289
 
100027
100290
  // ../api-core/src/utils/timeback-review-mapping.util.ts
100291
+ function reviewBankTestItemIdentifiers(test) {
100292
+ return qtiTestParts(test).flatMap((part) => part["qti-assessment-section"].flatMap((section) => (section["qti-assessment-item-ref"] ?? []).map((reference) => reference.identifier)));
100293
+ }
100294
+ function canonicalSyncContributors(contributors) {
100295
+ return contributors.map((contributor) => {
100296
+ const canonical = canonicalContributorIdentity(contributor);
100297
+ return {
100298
+ ...contributor,
100299
+ ...canonical
100300
+ };
100301
+ }).toSorted((left, right) => compareCodeUnits(left.standardKey, right.standardKey) || compareCodeUnits(left.qtiTestIdentifier, right.qtiTestIdentifier) || compareCodeUnits(left.assessmentKey, right.assessmentKey));
100302
+ }
100303
+ function canonicalContributorIdentity(contributor) {
100304
+ const standard = canonicalReviewStandardRef(contributor.standard);
100305
+ if (!standard) {
100306
+ throw new ValidationError(`Mastery assessment ${contributor.qtiTestIdentifier} has an invalid standard`);
100307
+ }
100308
+ return {
100309
+ qtiTestIdentifier: contributor.qtiTestIdentifier,
100310
+ standard,
100311
+ standardKey: assessmentStandardRefKey2(standard)
100312
+ };
100313
+ }
100314
+ async function reviewBankContributorFingerprint(contributors) {
100315
+ const uniqueTuples = new Map;
100316
+ for (const contributor of contributors) {
100317
+ const { qtiTestIdentifier, standardKey } = canonicalContributorIdentity(contributor);
100318
+ const tuple3 = { qtiTestIdentifier, standardKey };
100319
+ uniqueTuples.set(canonicalJson2(tuple3), tuple3);
100320
+ }
100321
+ const digest = await sha256Hex(canonicalJson2([...uniqueTuples.values()].toSorted((left, right) => compareCodeUnits(left.standardKey, right.standardKey) || compareCodeUnits(left.qtiTestIdentifier, right.qtiTestIdentifier))));
100322
+ return `review-bank-contributors-v1:${digest}`;
100323
+ }
100324
+ function conflictWarning(input) {
100325
+ const retained = `${input.retainedStandard.framework} · ${input.retainedStandard.identifier}`;
100326
+ const skipped = `${input.skippedStandard.framework} · ${input.skippedStandard.identifier}`;
100327
+ return {
100328
+ code: "QUESTION_STANDARD_CONFLICT",
100329
+ ...input,
100330
+ message: `Question ${input.itemIdentifier} is referenced by multiple mastery standards; retained ${retained} and skipped ${skipped} from ${input.skippedQtiTestIdentifier}.`
100331
+ };
100332
+ }
100333
+ async function prepareReviewBankSynchronization(input) {
100334
+ const contributors = canonicalSyncContributors(input.contributors);
100335
+ const selectedByItem = new Map;
100336
+ const warningKeys = new Set;
100337
+ const warnings = [];
100338
+ for (const contributor of contributors) {
100339
+ const itemIdentifiers2 = [
100340
+ ...new Set(reviewBankTestItemIdentifiers(contributor.test))
100341
+ ].toSorted();
100342
+ for (const itemIdentifier of itemIdentifiers2) {
100343
+ const selected = selectedByItem.get(itemIdentifier);
100344
+ if (!selected) {
100345
+ selectedByItem.set(itemIdentifier, {
100346
+ standard: contributor.standard,
100347
+ standardKey: contributor.standardKey
100348
+ });
100349
+ } else if (selected.standardKey !== contributor.standardKey) {
100350
+ const warningKey = `${itemIdentifier}\x00${contributor.standardKey}`;
100351
+ if (!warningKeys.has(warningKey)) {
100352
+ warningKeys.add(warningKey);
100353
+ warnings.push(conflictWarning({
100354
+ itemIdentifier,
100355
+ retainedStandard: selected.standard,
100356
+ skippedStandard: contributor.standard,
100357
+ skippedQtiTestIdentifier: contributor.qtiTestIdentifier
100358
+ }));
100359
+ }
100360
+ }
100361
+ }
100362
+ }
100363
+ const itemIdentifiers = [...selectedByItem.keys()].toSorted();
100364
+ const sourceContentRevision = input.contributorFingerprint ?? await reviewBankContributorFingerprint(contributors);
100365
+ const bank = await buildReviewBankIndexFromReferences({
100366
+ bankIdentifier: input.bankIdentifier,
100367
+ sourceContentRevision,
100368
+ items: itemIdentifiers.map((itemIdentifier) => ({
100369
+ itemIdentifier,
100370
+ standards: [selectedByItem.get(itemIdentifier).standard]
100371
+ }))
100372
+ });
100373
+ const manifest = await buildReviewBankManifest(bank);
100374
+ return {
100375
+ bankIdentifier: input.bankIdentifier,
100376
+ contributorFingerprint: sourceContentRevision,
100377
+ itemIdentifiers,
100378
+ manifest,
100379
+ warnings,
100380
+ summary: {
100381
+ itemCount: itemIdentifiers.length,
100382
+ standardCount: Object.keys(manifest.itemsByStandard).length,
100383
+ contributorCount: contributors.length,
100384
+ sourceFingerprint: manifest.sourceFingerprint
100385
+ }
100386
+ };
100387
+ }
100388
+ function reviewBankSinkMetadata(integrationId, plan) {
100389
+ return {
100390
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
100391
+ integrationId,
100392
+ [PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY]: {
100393
+ version: PLAYCADEMY_REVIEW_BANK_SINK_VERSION,
100394
+ integrationId,
100395
+ contributorFingerprint: plan.contributorFingerprint,
100396
+ warnings: plan.warnings
100397
+ },
100398
+ [PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: plan.manifest
100399
+ };
100400
+ }
100401
+ function reviewBankSinkMarker(test) {
100402
+ const parsed = ReviewBankSinkMarkerSchema.safeParse(test.metadata?.[PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY]);
100403
+ return parsed.success ? parsed.data : null;
100404
+ }
100405
+ function reviewBankSinkState(test, integrationId) {
100406
+ assertReviewBankSinkOwnership(test, integrationId);
100407
+ const marker = reviewBankSinkMarker(test);
100408
+ return {
100409
+ contributorFingerprint: marker?.contributorFingerprint ?? null,
100410
+ warnings: marker?.warnings ?? []
100411
+ };
100412
+ }
100413
+ async function unchangedReviewBankSynchronizationResult(input) {
100414
+ const sinkState = reviewBankSinkState(input.sink, input.integrationId);
100415
+ if (sinkState.contributorFingerprint !== input.contributorFingerprint) {
100416
+ return null;
100417
+ }
100418
+ const membershipItemIdentifiers = reviewBankTestItemIdentifiers(input.sink);
100419
+ const bank = await reviewBankIndexFromManifest(input.sink.metadata, {
100420
+ bankIdentifier: input.sink.identifier,
100421
+ membershipItemIdentifiers
100422
+ });
100423
+ const manifest = input.sink.metadata?.[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY];
100424
+ if (!bank || typeof manifest?.sourceFingerprint !== "string") {
100425
+ return null;
100426
+ }
100427
+ const standardCount = new Set(bank.items.flatMap((item) => item.standards.map(assessmentStandardRefKey2))).size;
100428
+ return {
100429
+ qtiTestIdentifier: input.sink.identifier,
100430
+ itemCount: bank.items.length,
100431
+ standardCount,
100432
+ contributorCount: input.contributorCount,
100433
+ sourceFingerprint: manifest.sourceFingerprint,
100434
+ addedItemCount: 0,
100435
+ removedItemCount: 0,
100436
+ unchanged: true,
100437
+ warnings: sinkState.warnings
100438
+ };
100439
+ }
100440
+ function reviewBankSynchronizationDiff(input) {
100441
+ let previousMembershipReadable = true;
100442
+ let previousItemIdentifiers = [];
100443
+ if (input.existingSink) {
100444
+ try {
100445
+ previousItemIdentifiers = reviewBankTestItemIdentifiers(input.existingSink);
100446
+ } catch {
100447
+ previousMembershipReadable = false;
100448
+ }
100449
+ }
100450
+ const previousItems = new Set(previousItemIdentifiers);
100451
+ const desiredItems = new Set(input.plan.itemIdentifiers);
100452
+ const addedItemCount = input.plan.itemIdentifiers.filter((identifier) => !previousItems.has(identifier)).length;
100453
+ const removedItemCount = [...previousItems].filter((identifier) => !desiredItems.has(identifier)).length;
100454
+ const currentManifest = input.existingSink?.metadata?.[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY];
100455
+ const currentContributorFingerprint = input.existingSink ? reviewBankSinkMarker(input.existingSink)?.contributorFingerprint ?? null : null;
100456
+ const canonicalMembership = previousItemIdentifiers.length === input.plan.itemIdentifiers.length && previousItemIdentifiers.every((identifier, index2) => identifier === input.plan.itemIdentifiers[index2]);
100457
+ const unchanged = input.existingSink !== null && previousMembershipReadable && canonicalMembership && canonicalJson2(currentManifest) === canonicalJson2(input.plan.manifest) && currentContributorFingerprint === input.plan.contributorFingerprint;
100458
+ return { addedItemCount, removedItemCount, unchanged };
100459
+ }
100460
+ function assertReviewBankSinkOwnership(test, integrationId) {
100461
+ const marker = reviewBankSinkMarker(test);
100462
+ if (marker?.integrationId !== integrationId || test.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM || test.identifier !== reviewBankSinkIdentifier(integrationId)) {
100463
+ throw new ValidationError(`QTI assessment ${test.identifier} is not the review-bank sink for this integration`);
100464
+ }
100465
+ }
100466
+ function buildReviewBankSinkInput(input) {
100467
+ return {
100468
+ ...input.includeIdentifier ? { identifier: input.identifier } : {},
100469
+ title: input.title,
100470
+ metadata: input.metadata,
100471
+ "qti-test-part": [
100472
+ {
100473
+ identifier: `${input.identifier}-part1`,
100474
+ navigationMode: "linear",
100475
+ submissionMode: "individual",
100476
+ "qti-assessment-section": [
100477
+ {
100478
+ identifier: `${input.identifier}-section1`,
100479
+ title: "Questions",
100480
+ visible: true,
100481
+ required: true,
100482
+ fixed: false,
100483
+ sequence: 1,
100484
+ "qti-assessment-item-ref": input.itemIdentifiers.map((itemIdentifier, index2) => ({
100485
+ identifier: itemIdentifier,
100486
+ href: input.itemHref(itemIdentifier),
100487
+ sequence: index2 + 1
100488
+ }))
100489
+ }
100490
+ ]
100491
+ }
100492
+ ]
100493
+ };
100494
+ }
100028
100495
  function reviewMappingIssueSummary(label, identifiers) {
100029
100496
  if (identifiers.length === 0) {
100030
100497
  return null;
@@ -100073,11 +100540,47 @@ async function prepareReviewMappingUpdate(test, questions) {
100073
100540
  }
100074
100541
  };
100075
100542
  }
100543
+ var PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY = "playcademyReviewBankSink", PLAYCADEMY_REVIEW_BANK_SINK_VERSION = 1, StoredSinkWarningSchema, ReviewBankSinkMarkerSchema;
100076
100544
  var init_timeback_review_mapping_util = __esm(() => {
100545
+ init_esm();
100077
100546
  init_assessment_runtime2();
100547
+ init_qti();
100548
+ init_timeback3();
100078
100549
  init_errors2();
100079
100550
  init_timeback_assessment_runtime_util();
100080
100551
  init_timeback_qti_authoring_util();
100552
+ StoredSinkWarningSchema = exports_external.object({
100553
+ code: exports_external.literal("QUESTION_STANDARD_CONFLICT"),
100554
+ itemIdentifier: exports_external.string(),
100555
+ retainedStandard: exports_external.object({ framework: exports_external.string(), identifier: exports_external.string() }),
100556
+ skippedStandard: exports_external.object({ framework: exports_external.string(), identifier: exports_external.string() }),
100557
+ skippedQtiTestIdentifier: exports_external.string()
100558
+ });
100559
+ ReviewBankSinkMarkerSchema = exports_external.object({
100560
+ version: exports_external.literal(PLAYCADEMY_REVIEW_BANK_SINK_VERSION),
100561
+ integrationId: exports_external.string(),
100562
+ contributorFingerprint: exports_external.string().nullable().catch(null),
100563
+ warnings: exports_external.array(StoredSinkWarningSchema).nullable().catch(null)
100564
+ }).transform((marker) => {
100565
+ if (!marker.contributorFingerprint || !marker.warnings) {
100566
+ return { ...marker, contributorFingerprint: null, warnings: [] };
100567
+ }
100568
+ const warnings = [];
100569
+ for (const warning of marker.warnings) {
100570
+ const retained = canonicalReviewStandardRef(warning.retainedStandard);
100571
+ const skipped = canonicalReviewStandardRef(warning.skippedStandard);
100572
+ if (!retained || !skipped) {
100573
+ return { ...marker, contributorFingerprint: null, warnings: [] };
100574
+ }
100575
+ warnings.push(conflictWarning({
100576
+ itemIdentifier: warning.itemIdentifier,
100577
+ retainedStandard: retained,
100578
+ skippedStandard: skipped,
100579
+ skippedQtiTestIdentifier: warning.skippedQtiTestIdentifier
100580
+ }));
100581
+ }
100582
+ return { ...marker, warnings };
100583
+ });
100081
100584
  });
100082
100585
 
100083
100586
  // ../api-core/src/utils/timeback-assessment-publication.util.ts
@@ -100381,11 +100884,6 @@ function assertAssessmentHasQuestions(questions) {
100381
100884
  throw new ValidationError("An assessment must contain at least one question to publish");
100382
100885
  }
100383
100886
  }
100384
- function assertReviewAssessmentHasStandards(standardCounts) {
100385
- if (standardCounts.some((count) => count !== 1)) {
100386
- throw new ValidationError("Every question in a review assessment must have exactly one standards alignment");
100387
- }
100388
- }
100389
100887
  function planAssessmentRemoval(status) {
100390
100888
  if (status === "draft") {
100391
100889
  return { kind: "delete", action: "discarded", operation: "discard_draft" };
@@ -100403,9 +100901,6 @@ function buildAssessmentAssociationUpdates(row, input) {
100403
100901
  updates.standardFramework = null;
100404
100902
  updates.standardIdentifier = null;
100405
100903
  }
100406
- if (row.status === "live" && input.purpose !== row.purpose) {
100407
- updates.sortOrder = null;
100408
- }
100409
100904
  }
100410
100905
  if (input.standard !== undefined) {
100411
100906
  updates.standardFramework = input.standard.framework;
@@ -100413,9 +100908,6 @@ function buildAssessmentAssociationUpdates(row, input) {
100413
100908
  }
100414
100909
  if (input.status !== undefined) {
100415
100910
  updates.status = input.status;
100416
- if (input.status === "archived") {
100417
- updates.sortOrder = null;
100418
- }
100419
100911
  }
100420
100912
  return updates;
100421
100913
  }
@@ -100424,30 +100916,17 @@ function assessmentStandardForRow(row) {
100424
100916
  }
100425
100917
  function validateUniqueAssessmentIdentifiers(testIdentifiers) {
100426
100918
  if (new Set(testIdentifiers).size !== testIdentifiers.length) {
100427
- throw new ValidationError("Assessment order must contain unique identifiers");
100428
- }
100429
- }
100430
- function assertAssessmentOrderUpdateSucceeded(updatedRow) {
100431
- if (!updatedRow) {
100432
- throw new ValidationError("Assessment order changed while it was being saved. Refresh and try again.");
100433
- }
100434
- return updatedRow;
100435
- }
100436
- function lockOrderAssessmentRows(rows) {
100437
- return rows.toSorted((left, right) => left.id.localeCompare(right.id));
100438
- }
100439
- function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
100440
- const rowsByIdentifier = new Map(liveRows.map((row) => [row.qtiTestIdentifier, row]));
100441
- if (liveRows.length !== testIdentifiers.length || testIdentifiers.some((identifier) => !rowsByIdentifier.has(identifier))) {
100442
- throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
100919
+ throw new ValidationError("Assessment manifests must contain unique QTI test identifiers");
100443
100920
  }
100444
- return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
100445
100921
  }
100446
100922
  var init_timeback_assessment_rules_util = __esm(() => {
100447
100923
  init_errors2();
100448
100924
  });
100449
100925
 
100450
100926
  // ../api-core/src/utils/timeback-qti-hydration.util.ts
100927
+ function qtiItemHref(client2, itemIdentifier) {
100928
+ return `${client2.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
100929
+ }
100451
100930
  async function hydrateQtiTestQuestions(client2, references) {
100452
100931
  const questions = await runWithConcurrency(references.questions, QTI_HYDRATION_CONCURRENCY, async (reference) => ({
100453
100932
  ...reference,
@@ -100470,6 +100949,28 @@ async function hydrateQtiTestQuestionSelection(client2, references, itemIdentifi
100470
100949
  questions: selectedReferences
100471
100950
  });
100472
100951
  }
100952
+ async function hydrateQtiItemSelection(client2, test, itemIdentifiers) {
100953
+ const testPart = qtiTestParts(test)[0];
100954
+ const section = testPart?.["qti-assessment-section"][0];
100955
+ if (!testPart || !section) {
100956
+ throw new Error(`QTI assessment ${test.identifier} has no section to reference items from`);
100957
+ }
100958
+ const questions = await runWithConcurrency(itemIdentifiers, QTI_HYDRATION_CONCURRENCY, async (itemIdentifier) => ({
100959
+ reference: {
100960
+ identifier: itemIdentifier,
100961
+ href: qtiItemHref(client2, itemIdentifier),
100962
+ testPart: testPart.identifier,
100963
+ section: section.identifier
100964
+ },
100965
+ question: await client2.qtiApi.assessmentItems.get(itemIdentifier)
100966
+ }));
100967
+ return {
100968
+ assessmentTest: test.identifier,
100969
+ title: test.title,
100970
+ totalQuestions: questions.length,
100971
+ questions
100972
+ };
100973
+ }
100473
100974
  async function loadQtiTestReferences(client2, identifier) {
100474
100975
  const [test, references] = await Promise.all([
100475
100976
  client2.qtiApi.assessmentTests.get(identifier),
@@ -100485,7 +100986,9 @@ async function loadHydratedQtiTest(client2, identifier) {
100485
100986
  };
100486
100987
  }
100487
100988
  var QTI_HYDRATION_CONCURRENCY = 8;
100488
- var init_timeback_qti_hydration_util = () => {};
100989
+ var init_timeback_qti_hydration_util = __esm(() => {
100990
+ init_timeback_qti_authoring_util();
100991
+ });
100489
100992
 
100490
100993
  // ../api-core/src/services/timeback-assessment-runtime.service.ts
100491
100994
  var TimebackAssessmentRuntimeService;
@@ -100515,8 +101018,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
100515
101018
  static EXPORT_CONCURRENCY = 4;
100516
101019
  static ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS = 3;
100517
101020
  static ASSESSMENT_VERSION_CONFIRMATION_DELAY_MS = 25;
100518
- static PROJECTION_BARRIER_MAX_ATTEMPTS = 5;
100519
- static PROJECTION_BARRIER_RETRY_DELAY_MS = 25;
100520
101021
  static SCORING_CONCURRENCY = 4;
100521
101022
  static STALE_AWARD_AGE_MS = 15 * 60 * 1000;
100522
101023
  deps;
@@ -101059,15 +101560,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101059
101560
  this.assertInProgress(attempt.result);
101060
101561
  if (!preview || preview.responseVersion !== attempt.metadata.responseVersion || preview.submissionId !== input.submissionId || preview.itemIdentifier !== input.itemIdentifier) {
101061
101562
  if (preparationAttempts >= TimebackAssessmentRuntimeService.ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS) {
101062
- if (input.expectedResponseVersion === attempt.metadata.responseVersion) {
101063
- throw new ServiceUnavailableError("The assessment response state changed repeatedly while this item was being prepared. Try again shortly.", {
101064
- retryable: true,
101065
- reason: "PREPARATION_CONTENTION",
101066
- expectedResponseVersion: input.expectedResponseVersion,
101067
- responseVersion: attempt.metadata.responseVersion
101068
- });
101069
- }
101070
- throw AssessmentRuntimeError.from(assessmentResponseVersionConflict(input.expectedResponseVersion, attempt.metadata.responseVersion));
101563
+ throw assessmentPreparationContentionError(input.expectedResponseVersion, attempt.metadata.responseVersion, "assessment");
101071
101564
  }
101072
101565
  return { action: "prepare" };
101073
101566
  }
@@ -101190,15 +101683,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101190
101683
  this.assertInProgress(attempt.result);
101191
101684
  if (!preview || preview.responseVersion !== metadata2.responseVersion || preview.submissionId !== input.submissionId || preview.routingNodeKey !== input.routingNodeKey || preview.itemIdentifier !== input.itemIdentifier) {
101192
101685
  if (preparationAttempts >= TimebackAssessmentRuntimeService.ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS) {
101193
- if (input.expectedResponseVersion === metadata2.responseVersion) {
101194
- throw new ServiceUnavailableError("The diagnostic response state changed repeatedly while this item was being prepared. Try again shortly.", {
101195
- retryable: true,
101196
- reason: "PREPARATION_CONTENTION",
101197
- expectedResponseVersion: input.expectedResponseVersion,
101198
- responseVersion: metadata2.responseVersion
101199
- });
101200
- }
101201
- throw AssessmentRuntimeError.from(assessmentResponseVersionConflict(input.expectedResponseVersion, metadata2.responseVersion));
101686
+ throw assessmentPreparationContentionError(input.expectedResponseVersion, metadata2.responseVersion, "diagnostic");
101202
101687
  }
101203
101688
  return { action: "prepare" };
101204
101689
  }
@@ -101213,7 +101698,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101213
101698
  itemIdentifier: input.itemIdentifier
101214
101699
  }));
101215
101700
  }
101216
- const responses = this.prepareDiagnosticResponses(preview.assessment, metadata2.responses, input);
101701
+ const responses = prepareDiagnosticAssessmentResponses(preview.assessment, metadata2.responses, input);
101217
101702
  const scoring = preview.scoring;
101218
101703
  if (!scoring || typeof scoring.isCorrect !== "boolean") {
101219
101704
  throw AssessmentRuntimeError.from(assessmentFlowViolation(`Diagnostic item ${input.itemIdentifier} did not produce determinate binary grading.`, { itemIdentifier: input.itemIdentifier }));
@@ -101286,22 +101771,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101286
101771
  answered: submission.answered
101287
101772
  };
101288
101773
  }
101289
- prepareDiagnosticResponses(assessment, current, input) {
101290
- const update2 = { [input.itemIdentifier]: input.responses };
101291
- validateAssessmentResponseUpdate(assessment, update2);
101292
- const responses = applyAssessmentResponseUpdate(current, update2);
101293
- const item = assessment.items.find((candidate) => candidate.identifier === input.itemIdentifier);
101294
- const itemResponses = responses[input.itemIdentifier];
101295
- const missingResponse = item?.interactions.find((interaction) => itemResponses?.[interaction.responseIdentifier] === undefined);
101296
- if (!item || !itemResponses || missingResponse) {
101297
- throw new AssessmentRuntimeError("INVALID_RESPONSE", `Diagnostic item ${input.itemIdentifier} requires a response for every interaction.`, {
101298
- itemIdentifier: input.itemIdentifier,
101299
- ...missingResponse ? { responseIdentifier: missingResponse.responseIdentifier } : {}
101300
- });
101301
- }
101302
- validateAssessmentResponses(assessment, { [input.itemIdentifier]: itemResponses });
101303
- return responses;
101304
- }
101305
101774
  async prepareDiagnosticItemSubmission(params, input) {
101306
101775
  try {
101307
101776
  return await this.scoreDiagnosticItemSubmission(params, input);
@@ -101335,7 +101804,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101335
101804
  const expected = loaded.state.next;
101336
101805
  let scoring = null;
101337
101806
  if (loaded.state.status === "in-progress" && expected?.nodeKey === input.routingNodeKey && expected.itemIdentifier === input.itemIdentifier && attempt.metadata.responseVersion === input.expectedResponseVersion) {
101338
- const responses = this.prepareDiagnosticResponses(assessment, attempt.metadata.responses, input);
101807
+ const responses = prepareDiagnosticAssessmentResponses(assessment, attempt.metadata.responses, input);
101339
101808
  scoring = await this.scoreItem(assessment, responses, input.itemIdentifier);
101340
101809
  }
101341
101810
  return {
@@ -101606,11 +102075,14 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101606
102075
  inProgress: attempt.result.inProgress ?? "",
101607
102076
  scoreStatus: attempt.result.scoreStatus,
101608
102077
  submissionId: attempt.metadata.submissionId,
101609
- xpAwarded: this.xpFromResult(attempt.result) ?? undefined
102078
+ award: assessmentAwardFromResult(attempt.result, attempt.metadata) ?? undefined
101610
102079
  }, input);
101611
102080
  if (disposition === "replay") {
101612
102081
  return {
101613
- response: this.completedResult(attempt.result, attempt.metadata),
102082
+ response: buildCompletedAssessmentSubmitResult({
102083
+ result: attempt.result,
102084
+ metadata: attempt.metadata
102085
+ }),
101614
102086
  completion: this.replayCompletion(attempt, input.submissionId, params, game2)
101615
102087
  };
101616
102088
  }
@@ -101618,20 +102090,27 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101618
102090
  throw AssessmentRuntimeError.from(assessmentAwardConflict({
101619
102091
  attemptId: attempt.result.sourcedId,
101620
102092
  submissionId: input.submissionId,
101621
- xpAwarded: input.xpAwarded
102093
+ xpAwarded: input.xpAwarded,
102094
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
102095
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {}
101622
102096
  }));
101623
102097
  }
101624
102098
  if (disposition === "reject") {
101625
102099
  throw AssessmentRuntimeError.from(assessmentAlreadySubmitted(attempt.result.sourcedId));
101626
102100
  }
101627
- const submitted = this.submittedResult(attempt.result, attempt.metadata);
101628
- const finalization = buildAssessmentResultXpFinalization({
102101
+ const mastery = await this.evaluateMasteryAward(attempt, params.studentId, input);
102102
+ const awardRecord = buildAssessmentAwardRecord(input, mastery);
102103
+ const finalization = buildAssessmentResultAwardFinalization({
101629
102104
  result: attempt.result,
101630
102105
  metadata: attempt.metadata,
101631
- xpAwarded: input.xpAwarded,
102106
+ award: awardRecord,
101632
102107
  appName: game2.appName,
101633
102108
  timestamp: new Date().toISOString()
101634
102109
  });
102110
+ const response = buildCompletedAssessmentSubmitResult({
102111
+ result: attempt.result,
102112
+ metadata: finalization.metadata
102113
+ });
101635
102114
  const finalized = await this.requireClient().api.oneroster.assessmentResults.upsert(attempt.result.sourcedId, finalization.resultUpdate);
101636
102115
  const finalizedForEmission = {
101637
102116
  ...finalized,
@@ -101650,25 +102129,78 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101650
102129
  "app.assessment.submission_id": input.submissionId,
101651
102130
  "app.assessment.purpose": finalization.metadata.purpose,
101652
102131
  "app.assessment.award_age_ms": this.awardAgeMs(attempt.result.scoreDate),
101653
- "app.assessment.xp_awarded": input.xpAwarded
102132
+ "app.assessment.xp_awarded": input.xpAwarded,
102133
+ "app.assessment.mastered_units_applied": mastery.masteredUnitsApplied,
102134
+ "app.assessment.mastery_achieved": mastery.masteryAchieved,
102135
+ "app.assessment.mastery_revoked": mastery.masteryRevoked
101654
102136
  });
102137
+ await this.settleMasteryAward(finalizedAttempt, mastery, input.submissionId, game2);
101655
102138
  return {
101656
- response: {
101657
- ...submitted,
101658
- status: "completed",
101659
- xpAwarded: input.xpAwarded
101660
- },
102139
+ response,
101661
102140
  completion: this.replayCompletion(finalizedAttempt, input.submissionId, params, game2)
101662
102141
  };
101663
102142
  });
101664
102143
  if (award.completion) {
101665
102144
  await this.emitCompletionBestEffort({
101666
102145
  ...award.completion,
101667
- xpAwarded: input.xpAwarded
102146
+ award: award.response
101668
102147
  });
101669
102148
  }
101670
102149
  return award.response;
101671
102150
  }
102151
+ async evaluateMasteryAward(attempt, studentIdentifier, input) {
102152
+ if (!hasMasteryAwardRequest(input)) {
102153
+ return NO_MASTERY_AWARD;
102154
+ }
102155
+ const student = await this.requireClient().roster.resolveStudent(studentIdentifier);
102156
+ return this.requireClient().activity.evaluateMasteryAward({
102157
+ studentId: student.id,
102158
+ courseId: attempt.integration.courseId,
102159
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
102160
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {}
102161
+ });
102162
+ }
102163
+ async settleMasteryAward(attempt, mastery, submissionId, game2) {
102164
+ if (!mastery.masteryAchieved && !mastery.masteryRevoked) {
102165
+ return;
102166
+ }
102167
+ const integration = attempt.integration;
102168
+ const course = this.trackableCourse(integration, "assessment.mastery_settlement_invalid_course_metadata", { "app.assessment.attempt_id": attempt.result.sourcedId });
102169
+ if (!course) {
102170
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
102171
+ return;
102172
+ }
102173
+ try {
102174
+ const student = await this.requireClient().roster.resolveStudent(attempt.result.student.sourcedId);
102175
+ const completionHistoryEventId = `urn:uuid:${await deterministicUUID([
102176
+ "playcademy-assessment-course-completed-event-v1",
102177
+ attempt.result.sourcedId,
102178
+ submissionId
102179
+ ].join("\x00"))}`;
102180
+ const settlement = await this.requireClient().activity.settleMasteryAward(mastery, {
102181
+ studentId: student.id,
102182
+ studentEmail: student.email,
102183
+ courseId: integration.courseId,
102184
+ gameId: integration.gameId,
102185
+ activityId: attempt.metadata.activityId,
102186
+ courseName: attempt.metadata.completion?.courseName ?? "Game Course",
102187
+ subject: course.subject,
102188
+ appName: game2.appName,
102189
+ sensorUrl: game2.sensorUrl,
102190
+ completionHistoryEventId
102191
+ });
102192
+ if (mastery.masteryAchieved && !settlement.completionEntryWritten) {
102193
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
102194
+ }
102195
+ } catch (error88) {
102196
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
102197
+ addEvent("assessment.mastery_settlement_failed", {
102198
+ "app.assessment.attempt_id": attempt.result.sourcedId,
102199
+ "exception.type": errorType(error88),
102200
+ "app.error.message": errorMessage(error88)
102201
+ });
102202
+ }
102203
+ }
101672
102204
  async finalizeDiagnosticSubmission(input) {
101673
102205
  const { attempt } = input;
101674
102206
  const routing = await this.loadAttemptDiagnosticManifest(attempt.metadata, input.db);
@@ -101894,8 +102426,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101894
102426
  id: row.id,
101895
102427
  assessmentKey: row.assessmentKey,
101896
102428
  qtiTestIdentifier: row.qtiTestIdentifier,
101897
- sortOrder: row.sortOrder,
101898
- updatedAt: row.updatedAt.toISOString(),
101899
102429
  standard: assessmentStandardForRow(row)
101900
102430
  })).filter((test) => !matchesMasteryStandard || matchesMasteryStandard(test.standard));
101901
102431
  }
@@ -102115,9 +102645,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102115
102645
  if (this.isSettled(resumed.result)) {
102116
102646
  return this.snapshotForResult(resumed.result, resumed.metadata, context2.integration);
102117
102647
  }
102118
- const catalog = await this.loadReviewBankCatalog(resumed.metadata.selectedTest.identifier, resumed.metadata.selectedTest.contentRevision);
102119
- const currentSource = await this.hydrateReviewBankSelection(catalog, resumed.metadata.review.selections.map((selection) => selection.itemIdentifier));
102120
- const source = this.pinnedReviewBankSource(currentSource, resumed.metadata);
102648
+ const source = await this.loadPinnedReviewBankSource(resumed.metadata);
102121
102649
  const assessment = projectReviewAssessment(source.assessment, source.bank, resumed.metadata.review.selections);
102122
102650
  await this.ensureReviewChildResults({
102123
102651
  result: resumed.result,
@@ -102161,7 +102689,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102161
102689
  membershipItemIdentifiers: loaded.references.questions.map((question) => question.reference.identifier)
102162
102690
  });
102163
102691
  } catch (error88) {
102164
- throw new ServiceUnavailableError(`Review bank ${identifier} has stale or invalid routing metadata. Use Update Mapping in assessment authoring.`, { reason: errorMessage(error88) });
102692
+ throw new ServiceUnavailableError(`Review bank ${identifier} has stale or invalid routing metadata. Use Sync Review Bank in assessment authoring.`, { reason: errorMessage(error88) });
102165
102693
  }
102166
102694
  if (!bank) {
102167
102695
  throw new ServiceUnavailableError(`Review bank ${identifier} needs its authoring mapping updated before it can serve review questions.`);
@@ -102177,28 +102705,60 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102177
102705
  this.reviewBankCache.set(`${identifier}\x00${bank.sourceContentRevision}`, catalog);
102178
102706
  return catalog;
102179
102707
  }
102180
- async hydrateReviewBankSelection(catalog, itemIdentifiers) {
102181
- const cacheKey2 = [
102182
- catalog.test.identifier,
102183
- catalog.bank.sourceContentRevision,
102184
- ...itemIdentifiers
102185
- ].join("\x00");
102708
+ async cachedReviewSelection(input) {
102709
+ const cacheKey2 = [input.identifier, input.contentRevision, ...input.itemIdentifiers].join("\x00");
102186
102710
  const cached3 = this.reviewSelectionCache.get(cacheKey2);
102187
102711
  if (cached3) {
102188
- return { assessment: cached3, bank: catalog.bank };
102712
+ return cached3;
102189
102713
  }
102190
- const questions = await hydrateQtiTestQuestionSelection(this.requireClient(), catalog.references, itemIdentifiers);
102191
- const assessment = await buildPlayableAssessment(catalog.test, questions);
102192
- assessment.contentRevision = catalog.bank.sourceContentRevision;
102714
+ const { test, questions } = await input.hydrate();
102715
+ const assessment = await buildPlayableAssessment(test, questions);
102716
+ assessment.contentRevision = input.contentRevision;
102193
102717
  this.reviewSelectionCache.set(cacheKey2, assessment);
102718
+ return assessment;
102719
+ }
102720
+ async hydrateReviewBankSelection(catalog, itemIdentifiers) {
102721
+ const assessment = await this.cachedReviewSelection({
102722
+ identifier: catalog.test.identifier,
102723
+ contentRevision: catalog.bank.sourceContentRevision,
102724
+ itemIdentifiers,
102725
+ hydrate: async () => ({
102726
+ test: catalog.test,
102727
+ questions: await hydrateQtiTestQuestionSelection(this.requireClient(), catalog.references, itemIdentifiers)
102728
+ })
102729
+ });
102194
102730
  return { assessment, bank: catalog.bank };
102195
102731
  }
102196
- pinnedReviewBankSource(source, metadata2) {
102732
+ async loadPinnedReviewBankSource(metadata2) {
102733
+ const itemIdentifiers = metadata2.review.selections.map((selection) => selection.itemIdentifier);
102734
+ let assessment;
102735
+ try {
102736
+ assessment = await this.cachedReviewSelection({
102737
+ identifier: metadata2.selectedTest.identifier,
102738
+ contentRevision: metadata2.selectedTest.contentRevision,
102739
+ itemIdentifiers,
102740
+ hydrate: async () => {
102741
+ const test = await this.requireClient().qtiApi.assessmentTests.get(metadata2.selectedTest.identifier);
102742
+ return {
102743
+ test,
102744
+ questions: await hydrateQtiItemSelection(this.requireClient(), test, itemIdentifiers)
102745
+ };
102746
+ }
102747
+ });
102748
+ } catch (error88) {
102749
+ if (isApiError(error88) && error88.statusCode === 404) {
102750
+ throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `A selected review item for ${metadata2.selectedTest.identifier} is no longer available.`, {
102751
+ identifier: metadata2.selectedTest.identifier,
102752
+ expectedRevision: metadata2.selectedTest.contentRevision
102753
+ });
102754
+ }
102755
+ throw error88;
102756
+ }
102197
102757
  return {
102198
- assessment: source.assessment,
102758
+ assessment,
102199
102759
  bank: {
102200
- bankIdentifier: source.assessment.identifier,
102201
- sourceContentRevision: source.assessment.contentRevision,
102760
+ bankIdentifier: assessment.identifier,
102761
+ sourceContentRevision: metadata2.selectedTest.contentRevision,
102202
102762
  bankRevision: metadata2.review.bankRevision,
102203
102763
  items: metadata2.review.selections.map((selection) => ({
102204
102764
  itemIdentifier: selection.itemIdentifier,
@@ -102222,9 +102782,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102222
102782
  if (metadata2.purpose !== "review") {
102223
102783
  return this.loadAssessment(metadata2.selectedTest.identifier, metadata2.selectedTest.contentRevision);
102224
102784
  }
102225
- const catalog = await this.loadReviewBankCatalog(metadata2.selectedTest.identifier, metadata2.selectedTest.contentRevision);
102226
- const currentSource = await this.hydrateReviewBankSelection(catalog, metadata2.review.selections.map((selection) => selection.itemIdentifier));
102227
- const source = this.pinnedReviewBankSource(currentSource, metadata2);
102785
+ const source = await this.loadPinnedReviewBankSource(metadata2);
102228
102786
  return projectReviewAssessment(source.assessment, source.bank, metadata2.review.selections);
102229
102787
  }
102230
102788
  isPlatformRoutedDiagnosticMetadata(metadata2) {
@@ -102710,7 +103268,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102710
103268
  async projectReviewItemResponse(params, projection) {
102711
103269
  await this.putReviewChildResponse(projection.result, projection.metadata, projection.itemIdentifier);
102712
103270
  try {
102713
- await this.crossAttemptLockBarrier(params.attemptId);
103271
+ await crossAssessmentAttemptLockBarrier(this.deps.assessmentRuntimeLock, this.deps.db, params.attemptId);
102714
103272
  const latest = await this.peekAttempt(params);
102715
103273
  if (this.isSettled(latest.result) && latest.metadata.purpose === "review") {
102716
103274
  await this.restoreCompletedReviewChildResponses(latest.result, latest.metadata, new Set([projection.itemIdentifier]));
@@ -102724,21 +103282,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102724
103282
  });
102725
103283
  }
102726
103284
  }
102727
- async crossAttemptLockBarrier(attemptId) {
102728
- for (let attempt = 1;attempt <= TimebackAssessmentRuntimeService.PROJECTION_BARRIER_MAX_ATTEMPTS; attempt += 1) {
102729
- try {
102730
- await this.deps.assessmentRuntimeLock(this.deps.db, [assessmentRuntimeAttemptLockKey(attemptId)], async () => {
102731
- return;
102732
- });
102733
- return;
102734
- } catch (error88) {
102735
- if (!(error88 instanceof ServiceUnavailableError) || attempt === TimebackAssessmentRuntimeService.PROJECTION_BARRIER_MAX_ATTEMPTS) {
102736
- throw error88;
102737
- }
102738
- await sleep(TimebackAssessmentRuntimeService.PROJECTION_BARRIER_RETRY_DELAY_MS);
102739
- }
102740
- }
102741
- }
102742
103285
  async restoreCompletedReviewChildResponses(result, metadata2, changedItemIdentifiers) {
102743
103286
  const outcomes = metadata2.completion?.itemOutcomes;
102744
103287
  const submissionId = metadata2.submissionId;
@@ -102884,7 +103427,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102884
103427
  attemptId: result.sourcedId,
102885
103428
  responseVersion: metadata2.responseVersion,
102886
103429
  status: "completed",
102887
- result: this.completedResult(result, metadata2)
103430
+ result: buildCompletedAssessmentSubmitResult({ result, metadata: metadata2 })
102888
103431
  };
102889
103432
  }
102890
103433
  const assessment = await this.loadAttemptAssessment(metadata2);
@@ -102901,58 +103444,37 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102901
103444
  return this.snapshot(result, metadata2, assessment, integration);
102902
103445
  }
102903
103446
  diagnosticSnapshot(result, metadata2, assessment, integration, state) {
102904
- const activityData = this.activityData(metadata2, assessment, integration);
102905
- return {
103447
+ return buildDiagnosticAssessmentSnapshot({
102906
103448
  attemptId: result.sourcedId,
102907
- responseVersion: metadata2.responseVersion,
102908
- status: "in_progress",
102909
- flow: "platform-routed-item-submit",
102910
- ...activityData ? { activityData } : {},
102911
- assessment: assessmentPresentationForAttempt(assessment, result.sourcedId),
102912
- selection: {
102913
- kind: "platform-routed-diagnostic",
102914
- purpose: "diagnostic",
102915
- definitionId: metadata2.diagnostic.definitionId,
102916
- diagnosticKey: metadata2.diagnostic.diagnosticKey,
102917
- routingRevision: metadata2.diagnostic.routingRevision
102918
- },
102919
- routing: this.diagnosticRoutingSnapshot(metadata2, state),
102920
- completion: null
102921
- };
103449
+ metadata: metadata2,
103450
+ assessment,
103451
+ state,
103452
+ activityData: this.activityData(metadata2, assessment, integration)
103453
+ });
102922
103454
  }
102923
103455
  diagnosticRoutingSnapshot(metadata2, state) {
102924
103456
  return projectDiagnosticRoutingSnapshot(metadata2.diagnostic.routingRevision, state);
102925
103457
  }
102926
103458
  snapshot(result, metadata2, assessment, integration) {
102927
- const selection = this.selectionContext(metadata2);
102928
- const activityData = this.activityData(metadata2, assessment, integration);
102929
- return {
103459
+ return buildConventionalAssessmentSnapshot({
102930
103460
  attemptId: result.sourcedId,
102931
- responseVersion: metadata2.responseVersion,
102932
- status: "in_progress",
102933
- flow: assessmentFlowForPurpose(metadata2.purpose),
102934
- ...activityData ? { activityData } : {},
102935
- assessment: assessmentPresentationForAttempt(assessment, result.sourcedId),
102936
- responses: metadata2.responses,
102937
- itemSubmissions: metadata2.itemSubmissions,
102938
- score: null,
102939
- selection
102940
- };
103461
+ metadata: metadata2,
103462
+ assessment,
103463
+ activityData: this.activityData(metadata2, assessment, integration)
103464
+ });
102941
103465
  }
102942
103466
  activityData(metadata2, assessment, integration) {
102943
103467
  const course = this.trackableCourse(integration, "assessment.activity_tracking_invalid_course_metadata");
102944
- if (!course) {
102945
- return;
102946
- }
102947
- return {
103468
+ return buildAssessmentActivityData({
102948
103469
  activityId: metadata2.activityId,
102949
103470
  activityName: assessment.title,
102950
- ...course,
102951
- courseId: integration.courseId
102952
- };
103471
+ courseId: integration.courseId,
103472
+ course
103473
+ });
102953
103474
  }
102954
103475
  trackableCourse(integration, event, attributes2 = {}) {
102955
- if (!isTimebackGrade2(integration.grade) || !isTimebackSubject(integration.subject)) {
103476
+ const course = assessmentTrackableCourse(integration);
103477
+ if (!course) {
102956
103478
  addEvent(event, {
102957
103479
  ...attributes2,
102958
103480
  "app.timeback.integration_id": integration.id,
@@ -102961,27 +103483,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102961
103483
  });
102962
103484
  return null;
102963
103485
  }
102964
- return { grade: integration.grade, subject: integration.subject };
102965
- }
102966
- selectionContext(metadata2) {
102967
- if (metadata2.purpose === "review") {
102968
- return {
102969
- kind: "standards-review",
102970
- purpose: metadata2.purpose,
102971
- standards: [...metadata2.review.standards],
102972
- candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
102973
- selections: [...metadata2.review.selections],
102974
- fulfillment: metadata2.review.fulfillment
102975
- };
102976
- }
102977
- if (metadata2.purpose === "mastery") {
102978
- return {
102979
- kind: "standard-quiz",
102980
- purpose: metadata2.purpose,
102981
- standard: metadata2.mastery.standard
102982
- };
102983
- }
102984
- return { kind: "fixed-test", purpose: metadata2.purpose };
103486
+ return course;
102985
103487
  }
102986
103488
  submittedResult(result, metadata2) {
102987
103489
  const response = buildAssessmentSubmitResult({
@@ -102995,29 +103497,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102995
103497
  }
102996
103498
  return response;
102997
103499
  }
102998
- completedResult(result, metadata2) {
102999
- const xpAwarded = this.xpFromResult(result);
103000
- if (xpAwarded === null) {
103001
- throw new GoneError("This completed assessment is missing its recorded XP.", {
103002
- attemptId: result.sourcedId
103003
- });
103004
- }
103005
- const response = buildCompletedAssessmentSubmitResult({
103006
- attemptId: result.sourcedId,
103007
- metadata: metadata2,
103008
- xpAwarded
103009
- });
103010
- if (!response) {
103011
- throw new GoneError("This completed assessment has incomplete persisted results.", {
103012
- attemptId: result.sourcedId
103013
- });
103014
- }
103015
- return response;
103016
- }
103017
- xpFromResult(result) {
103018
- const xp = result.metadata?.xp;
103019
- return typeof xp === "number" && Number.isFinite(xp) && xp >= 0 ? xp : null;
103020
- }
103021
103500
  awardAgeMs(scoreDate) {
103022
103501
  const submittedAt = Date.parse(scoreDate);
103023
103502
  return Number.isFinite(submittedAt) ? Math.max(0, Date.now() - submittedAt) : -1;
@@ -103144,7 +103623,9 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
103144
103623
  attemptNumber: attempt.metadata.attemptNumber,
103145
103624
  totalQuestions: input.totalQuestions,
103146
103625
  correctQuestions: input.correctQuestions,
103147
- xpAwarded: input.xpAwarded,
103626
+ xpAwarded: input.award.xpAwarded,
103627
+ ...input.award.masteredUnitsApplied !== 0 ? { masteredUnits: input.award.masteredUnitsApplied } : {},
103628
+ ...input.award.pctCompleteApp !== undefined ? { pctCompleteApp: input.award.pctCompleteApp } : {},
103148
103629
  submittedAt: attempt.result.scoreDate,
103149
103630
  eventId,
103150
103631
  ...session2 ? { resumeId: session2.resumeId } : {}
@@ -103269,6 +103750,9 @@ function assertAssessmentImportCourseMatches(manifest, integration) {
103269
103750
  throw new ValidationError(`This manifest is for ${manifest.subject} grade ${manifest.grade}, not ${integration.subject} grade ${integration.grade}.`);
103270
103751
  }
103271
103752
  }
103753
+ function assessmentImportsAffectReviewBank(targetStatus, entries) {
103754
+ return targetStatus === "live" && entries.some((entry) => entry.purpose === "mastery");
103755
+ }
103272
103756
  function associationMetadataMatches(row, entry) {
103273
103757
  return row.purpose === entry.purpose && (row.standardFramework ?? undefined) === entry.standard?.framework && (row.standardIdentifier ?? undefined) === entry.standard?.identifier;
103274
103758
  }
@@ -103318,18 +103802,6 @@ function attachedAssessmentImportMessage(targetStatus, editable) {
103318
103802
  }
103319
103803
  return editable ? "Attached as a draft." : "Attached as a read-only draft owned by another app.";
103320
103804
  }
103321
- function liveReviewAssessmentImportFailures(targetStatus, candidates, existingRows) {
103322
- if (targetStatus === "draft") {
103323
- return new Map;
103324
- }
103325
- const reviews = candidates.filter((candidate) => candidate.purpose === "review");
103326
- const hasExistingLiveReview = existingRows.some((row) => row.purpose === "review" && row.status === "live");
103327
- if (reviews.length <= 1 && !hasExistingLiveReview) {
103328
- return new Map;
103329
- }
103330
- const message = hasExistingLiveReview ? "Another review assessment is already live for this course." : "Only one review assessment can be imported live at a time.";
103331
- return new Map(reviews.map((review) => [review.qtiTestIdentifier, message]));
103332
- }
103333
103805
  var init_timeback_assessment_import_util = __esm(() => {
103334
103806
  init_errors2();
103335
103807
  init_timeback_qti_authoring_util();
@@ -103401,7 +103873,9 @@ class TimebackAssessmentsService {
103401
103873
  const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
103402
103874
  where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
103403
103875
  });
103404
- const assessments2 = await runWithConcurrency(rows, QTI_HYDRATION_CONCURRENCY, async (row) => {
103876
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
103877
+ const visibleRows = rows.filter((row) => row.purpose !== "review" || row.qtiTestIdentifier === sinkIdentifier);
103878
+ const assessments2 = await runWithConcurrency(visibleRows, QTI_HYDRATION_CONCURRENCY, async (row) => {
103405
103879
  try {
103406
103880
  const test = await client2.qtiApi.assessmentTests.get(row.qtiTestIdentifier);
103407
103881
  return {
@@ -103409,7 +103883,7 @@ class TimebackAssessmentsService {
103409
103883
  title: test.title,
103410
103884
  questionCount: countQtiTestItems(test),
103411
103885
  available: true,
103412
- editable: isQtiTestOwnedByGame(test, ownership.gameSlug)
103886
+ editable: row.purpose !== "review" && isQtiTestOwnedByGame(test, ownership.gameSlug)
103413
103887
  };
103414
103888
  } catch (error88) {
103415
103889
  addEvent("assessment.qti_fetch_failed", {
@@ -103429,8 +103903,11 @@ class TimebackAssessmentsService {
103429
103903
  return assessments2.toSorted((a, b) => a.title.localeCompare(b.title));
103430
103904
  }
103431
103905
  async createAssessment(integrationId, input) {
103432
- if (input.purpose === "diagnostic") {
103433
- throw new ValidationError("Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
103906
+ if (input.purpose === "diagnostic" || input.purpose === "review") {
103907
+ throw new ValidationError(input.purpose === "review" ? "The review bank is system-managed. Use Sync Review Bank instead." : "Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
103908
+ }
103909
+ if (input.assessmentKey === reviewBankSinkIdentifier(integrationId)) {
103910
+ throw new ValidationError("This assessment key is reserved for the review bank.");
103434
103911
  }
103435
103912
  const client2 = this.requireClient();
103436
103913
  const ownership = await this.requireQtiTestOwnershipContext(integrationId);
@@ -103506,24 +103983,41 @@ class TimebackAssessmentsService {
103506
103983
  index: index2,
103507
103984
  standard: this.requirePurposeStandard(assessment.purpose, assessment.standard)
103508
103985
  }));
103509
- const existingRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
103510
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
103511
- });
103512
- const existingByKey = new Map(existingRows.flatMap((row) => row.assessmentKey ? [[row.assessmentKey, row]] : []));
103513
- const existingByIdentifier = new Map(existingRows.map((row) => [row.qtiTestIdentifier, row]));
103986
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
103987
+ if (entries.some((entry) => entry.assessmentKey === sinkIdentifier || entry.qtiTestIdentifier === sinkIdentifier || entry.purpose === "review")) {
103988
+ throw new ValidationError("The review bank is system-managed and cannot be attached manually.");
103989
+ }
103514
103990
  const validated = await this.validateAssessmentImportEntries(client2, entries, manifest.targetStatus);
103515
103991
  const results = [];
103516
- const pendingInserts = this.planAssessmentImports({
103517
- validated,
103518
- existingByKey,
103519
- existingByIdentifier,
103520
- gameSlug,
103521
- results
103522
- });
103523
- const liveReviewFailures = liveReviewAssessmentImportFailures(manifest.targetStatus, pendingInserts.map(({ entry }) => entry), existingRows);
103524
- this.recordLiveReviewImportFailures(pendingInserts, liveReviewFailures, gameSlug, results);
103525
- const attachablePending = pendingInserts.filter(({ entry }) => !liveReviewFailures.has(entry.qtiTestIdentifier));
103526
- await this.insertImportedAssessments(integrationId, attachablePending, manifest.targetStatus, gameSlug, results);
103992
+ const affectsReviewBank = assessmentImportsAffectReviewBank(manifest.targetStatus, entries);
103993
+ const planAndInsert = async (db2, existingRows) => {
103994
+ const existingByKey = new Map(existingRows.flatMap((row) => row.assessmentKey ? [[row.assessmentKey, row]] : []));
103995
+ const existingByIdentifier = new Map(existingRows.map((row) => [row.qtiTestIdentifier, row]));
103996
+ const pendingInserts = this.planAssessmentImports({
103997
+ validated,
103998
+ existingByKey,
103999
+ existingByIdentifier,
104000
+ gameSlug,
104001
+ results
104002
+ });
104003
+ await this.insertImportedAssessments({
104004
+ db: db2,
104005
+ integrationId,
104006
+ pending: pendingInserts,
104007
+ targetStatus: manifest.targetStatus,
104008
+ gameSlug,
104009
+ results,
104010
+ abortOnError: affectsReviewBank
104011
+ });
104012
+ };
104013
+ if (affectsReviewBank) {
104014
+ await this.withLockedIntegrationAssessmentRows(integrationId, async (tx, lockedAssociations) => planAndInsert(tx, lockedAssociations));
104015
+ } else {
104016
+ const existingRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
104017
+ where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
104018
+ });
104019
+ await planAndInsert(this.deps.db, existingRows);
104020
+ }
103527
104021
  setAttribute("app.assessment.operation", "bulk_attach_existing");
103528
104022
  setAttribute("app.assessment.bulk_attach.target_status", manifest.targetStatus);
103529
104023
  setAttribute("app.assessment.bulk_attach.created", results.filter((result) => result.status === "created").length);
@@ -103536,11 +104030,6 @@ class TimebackAssessmentsService {
103536
104030
  assertManagedAssessmentIdentity(loaded.test, entry.assessmentKey);
103537
104031
  assertAssessmentHasQuestions(loaded.questions.questions);
103538
104032
  assertPlayableAssessmentImportQuestions(targetStatus, loaded.questions.questions.map(({ question }) => question));
103539
- if (entry.purpose === "review") {
103540
- assertReviewAssessmentHasStandards(loaded.questions.questions.map(({ question }) => reviewRoutingStandardRefsFromMetadata(question.metadata, {
103541
- customOnly: true
103542
- }).length));
103543
- }
103544
104033
  return { entry, test: loaded.test };
103545
104034
  } catch (error88) {
103546
104035
  return { entry, error: error88 };
@@ -103554,7 +104043,6 @@ class TimebackAssessmentsService {
103554
104043
  ...test ? { title: test.title } : {},
103555
104044
  purpose: entry.purpose,
103556
104045
  ...entry.standard ? { standard: entry.standard } : {},
103557
- sourceSortOrder: entry.sortOrder,
103558
104046
  ...test && gameSlug ? { editable: isQtiTestOwnedByGame(test, gameSlug) } : {}
103559
104047
  };
103560
104048
  }
@@ -103586,25 +104074,13 @@ class TimebackAssessmentsService {
103586
104074
  }
103587
104075
  return pendingInserts;
103588
104076
  }
103589
- recordLiveReviewImportFailures(pending, failures, gameSlug, results) {
103590
- for (const { entry, test } of pending) {
103591
- const message = failures.get(entry.qtiTestIdentifier);
103592
- if (message) {
103593
- results[entry.index] = {
103594
- ...this.associationImportResultBase(entry, test, gameSlug),
103595
- status: "failed",
103596
- message
103597
- };
103598
- }
103599
- }
103600
- }
103601
- async concurrentAssessmentImportDecision(integrationId, entry) {
104077
+ async concurrentAssessmentImportDecision(db2, integrationId, entry) {
103602
104078
  try {
103603
104079
  const [byKey, byIdentifier] = await Promise.all([
103604
- this.deps.db.query.gameTimebackAssessmentTests.findFirst({
104080
+ db2.query.gameTimebackAssessmentTests.findFirst({
103605
104081
  where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.assessmentKey, entry.assessmentKey))
103606
104082
  }),
103607
- this.deps.db.query.gameTimebackAssessmentTests.findFirst({
104083
+ db2.query.gameTimebackAssessmentTests.findFirst({
103608
104084
  where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, entry.qtiTestIdentifier))
103609
104085
  })
103610
104086
  ]);
@@ -103616,39 +104092,52 @@ class TimebackAssessmentsService {
103616
104092
  return null;
103617
104093
  }
103618
104094
  }
103619
- async insertImportedAssessments(integrationId, pending, targetStatus, gameSlug, results) {
103620
- for (const { entry, test } of pending) {
103621
- const base = this.associationImportResultBase(entry, test, gameSlug);
104095
+ async insertImportedAssessments(input) {
104096
+ for (const { entry, test } of input.pending) {
104097
+ const base = this.associationImportResultBase(entry, test, input.gameSlug);
103622
104098
  try {
103623
- const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
103624
- integrationId,
104099
+ const [row] = await input.db.insert(gameTimebackAssessmentTests).values({
104100
+ integrationId: input.integrationId,
103625
104101
  assessmentKey: entry.assessmentKey,
103626
104102
  qtiTestIdentifier: entry.qtiTestIdentifier,
103627
104103
  purpose: entry.purpose,
103628
- status: targetStatus,
103629
- sortOrder: entry.sortOrder,
104104
+ status: input.targetStatus,
103630
104105
  standardFramework: entry.standard?.framework,
103631
104106
  standardIdentifier: entry.standard?.identifier
103632
- }).returning();
104107
+ }).onConflictDoNothing().returning();
103633
104108
  if (!row) {
103634
- throw new Error("Assessment association create returned no row");
104109
+ const concurrent = await this.concurrentAssessmentImportDecision(input.db, input.integrationId, entry);
104110
+ if (concurrent?.decision.kind === "skip" || concurrent?.decision.kind === "fail") {
104111
+ input.results[entry.index] = {
104112
+ ...base,
104113
+ status: concurrent.decision.status,
104114
+ ...concurrent.byKey ? { association: this.associationImportSummary(concurrent.byKey) } : {},
104115
+ message: concurrent.decision.message
104116
+ };
104117
+ } else {
104118
+ throw new Error("Assessment association insert conflicted");
104119
+ }
104120
+ } else {
104121
+ input.results[entry.index] = {
104122
+ ...base,
104123
+ status: "created",
104124
+ association: this.associationImportSummary(row),
104125
+ message: attachedAssessmentImportMessage(input.targetStatus, base.editable ?? false)
104126
+ };
103635
104127
  }
103636
- results[entry.index] = {
103637
- ...base,
103638
- status: "created",
103639
- association: this.associationImportSummary(row),
103640
- message: attachedAssessmentImportMessage(targetStatus, base.editable ?? false)
103641
- };
103642
104128
  } catch (error88) {
103643
- results[entry.index] = {
104129
+ if (input.abortOnError) {
104130
+ throw error88;
104131
+ }
104132
+ input.results[entry.index] = {
103644
104133
  ...base,
103645
104134
  status: "failed",
103646
104135
  message: `Association attach failed: ${errorMessage(error88)}`
103647
104136
  };
103648
104137
  if (isUniqueViolation(error88)) {
103649
- const concurrent = await this.concurrentAssessmentImportDecision(integrationId, entry);
104138
+ const concurrent = await this.concurrentAssessmentImportDecision(input.db, input.integrationId, entry);
103650
104139
  if (concurrent?.decision.kind === "skip" || concurrent?.decision.kind === "fail") {
103651
- results[entry.index] = {
104140
+ input.results[entry.index] = {
103652
104141
  ...base,
103653
104142
  status: concurrent.decision.status,
103654
104143
  ...concurrent.byKey ? { association: this.associationImportSummary(concurrent.byKey) } : {},
@@ -103662,6 +104151,12 @@ class TimebackAssessmentsService {
103662
104151
  async updateAssessment(integrationId, qtiTestIdentifier, input) {
103663
104152
  try {
103664
104153
  return await this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
104154
+ if (row.purpose === "review" || qtiTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
104155
+ throw new ValidationError("The review bank is system-managed and cannot be edited.");
104156
+ }
104157
+ if (input.purpose === "review") {
104158
+ throw new ValidationError("Assessment purpose cannot be changed to the system-managed review bank.");
104159
+ }
103665
104160
  const nextPurpose = input.purpose ?? row.purpose;
103666
104161
  assertPurposeChangeDraft(row, nextPurpose);
103667
104162
  const requestedStandard = input.standard ? this.canonicalAssessmentStandard(input.standard) : undefined;
@@ -103680,19 +104175,10 @@ class TimebackAssessmentsService {
103680
104175
  ...associationUpdates,
103681
104176
  ...diagnosticChanges.updates
103682
104177
  };
103683
- const nextStatus = input.status ?? row.status;
103684
104178
  const publishing = input.status !== undefined && isAssessmentPublicationTransition(row.status, input.status);
103685
- const activatingReview = nextPurpose === "review" && nextStatus === "live" && (row.purpose !== "review" || row.status !== "live");
103686
104179
  if (input.status !== undefined) {
103687
104180
  validateAssessmentStatusTransition(row.status, input.status);
103688
104181
  }
103689
- if (!publishing && !activatingReview && nextPurpose === "review" && row.purpose !== "review") {
103690
- const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
103691
- await this.rebuildReviewMapping(this.requireClient(), qtiTestIdentifier, {
103692
- kind: "owned",
103693
- gameSlug: ownership.gameSlug
103694
- });
103695
- }
103696
104182
  if (input.title !== undefined) {
103697
104183
  assertDraftAssessment(row);
103698
104184
  assertAllAssessmentAssociationsDraft(associations);
@@ -103702,7 +104188,7 @@ class TimebackAssessmentsService {
103702
104188
  assertQtiTestOwnedByGame(test, ownership.gameSlug);
103703
104189
  await client2.qtiApi.assessmentTests.update(qtiTestIdentifier, buildQtiTestUpdateInput(test, input.title));
103704
104190
  }
103705
- if (publishing || activatingReview) {
104191
+ if (publishing) {
103706
104192
  if (nextPurpose === "diagnostic" && !nextDiagnostic) {
103707
104193
  throw new ValidationError("Platform-routed diagnostics require a diagnostic key and routing manifest before publication.");
103708
104194
  }
@@ -103712,8 +104198,7 @@ class TimebackAssessmentsService {
103712
104198
  updates.diagnosticKey = nextDiagnostic.diagnosticKey;
103713
104199
  updates.diagnosticRoutingManifest = nextDiagnostic.routingManifest;
103714
104200
  } else {
103715
- const reviewOwnership = nextPurpose === "review" && nextStatus === "live" ? await this.requireQtiTestOwnershipContext(integrationId, tx) : undefined;
103716
- await this.validateAssessmentHasQuestions(loaded, reviewOwnership?.gameSlug);
104201
+ await this.validateAssessmentHasQuestions(loaded);
103717
104202
  }
103718
104203
  if (publishing) {
103719
104204
  const publication = await this.publishManagedAssessment(row, nextPurpose, nextDiagnostic?.routingManifest ?? null, loaded);
@@ -103736,20 +104221,20 @@ class TimebackAssessmentsService {
103736
104221
  if (databaseConstraintName(error88) === "game_timeback_assessment_tests_one_live_diagnostic_key_idx") {
103737
104222
  throw new ValidationError("Only one live revision of a diagnostic key is allowed. Archive the current revision before publishing another.");
103738
104223
  }
103739
- if (databaseConstraintName(error88) === "game_timeback_assessment_tests_one_live_review_idx") {
103740
- throw new ValidationError("Only one live review assessment is allowed. Archive the current review bank before publishing another.");
103741
- }
103742
104224
  }
103743
104225
  throw error88;
103744
104226
  }
103745
104227
  }
103746
104228
  async removeAssessment(integrationId, qtiTestIdentifier) {
103747
104229
  return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx) => {
104230
+ if (qtiTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
104231
+ throw new ValidationError("The review bank is permanent and cannot be removed or archived.");
104232
+ }
103748
104233
  const plan = planAssessmentRemoval(row.status);
103749
104234
  if (plan.kind === "delete") {
103750
104235
  await tx.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
103751
104236
  } else if (plan.kind === "archive") {
103752
- await tx.update(gameTimebackAssessmentTests).set({ status: "archived", sortOrder: null, updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id));
104237
+ await tx.update(gameTimebackAssessmentTests).set({ status: "archived", updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id));
103753
104238
  }
103754
104239
  if (plan.kind !== "none") {
103755
104240
  setAttribute("app.assessment.operation", plan.operation);
@@ -103757,24 +104242,6 @@ class TimebackAssessmentsService {
103757
104242
  return { action: plan.action };
103758
104243
  });
103759
104244
  }
103760
- async reorderAssessments(integrationId, purpose, testIdentifiers) {
103761
- await this.requireIntegration(integrationId);
103762
- validateUniqueAssessmentIdentifiers(testIdentifiers);
103763
- const updatedAt = new Date;
103764
- await this.deps.db.transaction(async (tx) => {
103765
- const liveRows = await tx.query.gameTimebackAssessmentTests.findMany({
103766
- where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))
103767
- });
103768
- const orderedRows = orderLiveAssessmentRows(liveRows, purpose, testIdentifiers);
103769
- const positionByRowId = new Map(orderedRows.map((row, index2) => [row.id, index2 + 1]));
103770
- for (const row of lockOrderAssessmentRows(orderedRows)) {
103771
- const testIdentifier = row.qtiTestIdentifier;
103772
- const [updatedRow] = await tx.update(gameTimebackAssessmentTests).set({ sortOrder: positionByRowId.get(row.id), updatedAt }).where(and(eq(gameTimebackAssessmentTests.id, row.id), eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, testIdentifier), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))).returning({ id: gameTimebackAssessmentTests.id });
103773
- assertAssessmentOrderUpdateSucceeded(updatedRow);
103774
- }
103775
- });
103776
- setAttribute("app.assessment.operation", "reorder_live_assessments");
103777
- }
103778
104245
  async listQuestions(integrationId, qtiTestIdentifier) {
103779
104246
  const client2 = this.requireClient();
103780
104247
  await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
@@ -103815,19 +104282,96 @@ class TimebackAssessmentsService {
103815
104282
  });
103816
104283
  return { ...result, questions };
103817
104284
  }
103818
- async updateReviewMapping(integrationId, qtiTestIdentifier) {
103819
- return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (_row, _tx, associations) => {
103820
- if (!associations.some((association) => association.purpose === "review")) {
103821
- throw new ValidationError("Review mapping is available only for standards-review assessments.");
104285
+ async synchronizeReviewBank(integrationId) {
104286
+ const client2 = this.requireClient();
104287
+ await this.requireIntegration(integrationId);
104288
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
104289
+ for (let attempt = 0;attempt < REVIEW_BANK_SYNC_ATTEMPTS; attempt++) {
104290
+ const associations = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
104291
+ where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
104292
+ });
104293
+ const masterySources = this.reviewBankMasterySources(associations);
104294
+ const contributorFingerprint = await reviewBankContributorFingerprint(masterySources);
104295
+ const existingSink = await this.findReviewBankSink(client2, integrationId);
104296
+ if (existingSink) {
104297
+ assertReviewBankSinkOwnership(existingSink, integrationId);
104298
+ }
104299
+ let unchangedResult = null;
104300
+ if (existingSink) {
104301
+ try {
104302
+ unchangedResult = await unchangedReviewBankSynchronizationResult({
104303
+ sink: existingSink,
104304
+ integrationId,
104305
+ contributorFingerprint,
104306
+ contributorCount: masterySources.length
104307
+ });
104308
+ } catch (error88) {
104309
+ addEvent("assessment.review_mapping_noop_validation_failed", {
104310
+ "app.assessment.qti_test_identifier": existingSink.identifier,
104311
+ "exception.type": errorType(error88),
104312
+ "app.error.message": errorMessage(error88)
104313
+ });
104314
+ }
103822
104315
  }
103823
- const client2 = this.requireClient();
103824
- const result = await this.rebuildReviewMapping(client2, qtiTestIdentifier, {
103825
- kind: "live-review-repair"
104316
+ let plan = null;
104317
+ let addedItemCount = 0;
104318
+ let removedItemCount = 0;
104319
+ let catalogUnchanged = unchangedResult !== null;
104320
+ if (!unchangedResult) {
104321
+ const contributors = await runWithConcurrency(masterySources, QTI_HYDRATION_CONCURRENCY, async (source) => {
104322
+ const test = await client2.qtiApi.assessmentTests.get(source.qtiTestIdentifier);
104323
+ assertManagedAssessmentIdentity(test, source.assessmentKey);
104324
+ return { ...source, test };
104325
+ });
104326
+ plan = await prepareReviewBankSynchronization({
104327
+ bankIdentifier: sinkIdentifier,
104328
+ contributors,
104329
+ contributorFingerprint
104330
+ });
104331
+ const diff = reviewBankSynchronizationDiff({ existingSink, plan });
104332
+ addedItemCount = diff.addedItemCount;
104333
+ removedItemCount = diff.removedItemCount;
104334
+ catalogUnchanged = diff.unchanged;
104335
+ }
104336
+ const committed = await this.withLockedIntegrationAssessmentRows(integrationId, async (tx, lockedAssociations) => {
104337
+ const lockedSources = this.reviewBankMasterySources(lockedAssociations);
104338
+ const lockedFingerprint = await reviewBankContributorFingerprint(lockedSources);
104339
+ if (lockedFingerprint !== contributorFingerprint) {
104340
+ return null;
104341
+ }
104342
+ const associationChanged = await this.installReviewBankAssociation({
104343
+ tx,
104344
+ integrationId,
104345
+ associations: lockedAssociations,
104346
+ sinkIdentifier,
104347
+ beforeInstall: async () => {
104348
+ if (!catalogUnchanged) {
104349
+ await this.writeReviewBankSink({
104350
+ client: client2,
104351
+ integrationId,
104352
+ existingSink,
104353
+ plan,
104354
+ metadata: reviewBankSinkMetadata(integrationId, plan)
104355
+ });
104356
+ }
104357
+ }
104358
+ });
104359
+ const unchanged = catalogUnchanged && !associationChanged;
104360
+ const result = unchangedResult ? { ...unchangedResult, unchanged } : {
104361
+ qtiTestIdentifier: sinkIdentifier,
104362
+ ...plan.summary,
104363
+ addedItemCount,
104364
+ removedItemCount,
104365
+ unchanged,
104366
+ warnings: plan.warnings
104367
+ };
104368
+ return this.reviewMappingResult(result);
103826
104369
  });
103827
- setAttribute("app.assessment.operation", "update_review_mapping");
103828
- setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
103829
- return result;
103830
- });
104370
+ if (committed) {
104371
+ return committed;
104372
+ }
104373
+ }
104374
+ throw new ValidationError("Mastery assessments changed while the review bank was synchronizing. Try again.");
103831
104375
  }
103832
104376
  async listQuestionLibrary(integrationId, params) {
103833
104377
  const client2 = this.requireClient();
@@ -103847,15 +104391,18 @@ class TimebackAssessmentsService {
103847
104391
  purpose,
103848
104392
  standard: standardInput
103849
104393
  } = input;
103850
- if (purpose === "diagnostic") {
103851
- throw new ValidationError("Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
104394
+ if (purpose === "diagnostic" || purpose === "review") {
104395
+ throw new ValidationError(purpose === "review" ? "The review bank is system-managed. Use Sync Review Bank instead." : "Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
104396
+ }
104397
+ if (assessmentKey === reviewBankSinkIdentifier(integrationId) || targetTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
104398
+ throw new ValidationError("This assessment identity is reserved for the review bank.");
103852
104399
  }
103853
104400
  const client2 = this.requireClient();
103854
104401
  const ownership = await this.requireQtiTestOwnershipContext(integrationId);
103855
104402
  const { integration } = ownership;
103856
104403
  const standard = this.requirePurposeStandard(purpose, standardInput);
103857
104404
  const source = await client2.qtiApi.assessmentTests.get(sourceTestIdentifier);
103858
- const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => this.qtiItemHref(client2, identifier));
104405
+ const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => qtiItemHref(client2, identifier));
103859
104406
  const itemCopies = await runWithConcurrency(itemPlan, QTI_HYDRATION_CONCURRENCY, async (plan) => {
103860
104407
  const sourceItem = await client2.qtiApi.assessmentItems.get(plan.sourceIdentifier);
103861
104408
  return {
@@ -103980,7 +104527,7 @@ class TimebackAssessmentsService {
103980
104527
  };
103981
104528
  }
103982
104529
  const section = await this.qtiSectionItems(client2, qtiTestIdentifier, undefined, undefined, ownership.test);
103983
- const href = this.qtiItemHref(client2, itemIdentifier);
104530
+ const href = qtiItemHref(client2, itemIdentifier);
103984
104531
  let item;
103985
104532
  let itemCreationAttempted = false;
103986
104533
  let referenceCreationAttempted = false;
@@ -104090,7 +104637,7 @@ class TimebackAssessmentsService {
104090
104637
  const result = await section.items.reorder({
104091
104638
  items: items.map((item) => ({
104092
104639
  identifier: item.identifier,
104093
- href: item.href ?? this.qtiItemHref(client2, item.identifier),
104640
+ href: item.href ?? qtiItemHref(client2, item.identifier),
104094
104641
  sequence: item.sequence
104095
104642
  }))
104096
104643
  });
@@ -104104,6 +104651,118 @@ class TimebackAssessmentsService {
104104
104651
  }
104105
104652
  return this.deps.timeback;
104106
104653
  }
104654
+ reviewBankMasterySources(associations) {
104655
+ return associations.filter((association) => association.purpose === "mastery" && association.status === "live").map((association) => {
104656
+ const standard = assessmentStandardForRow(association);
104657
+ if (!association.assessmentKey || !standard) {
104658
+ throw new ValidationError(`Live mastery assessment ${association.qtiTestIdentifier} requires a managed identity and standard`);
104659
+ }
104660
+ if (assessmentKeyFromManagedQtiIdentifier(association.qtiTestIdentifier) !== association.assessmentKey) {
104661
+ throw new ValidationError(`Live mastery assessment ${association.qtiTestIdentifier} is not a managed publication for ${association.assessmentKey}`);
104662
+ }
104663
+ return {
104664
+ assessmentKey: association.assessmentKey,
104665
+ qtiTestIdentifier: association.qtiTestIdentifier,
104666
+ standard
104667
+ };
104668
+ });
104669
+ }
104670
+ async writeReviewBankSink(input) {
104671
+ function sinkInput(title, includeIdentifier = false) {
104672
+ return buildReviewBankSinkInput({
104673
+ identifier: input.plan.bankIdentifier,
104674
+ title,
104675
+ metadata: input.metadata,
104676
+ itemIdentifiers: input.plan.itemIdentifiers,
104677
+ itemHref: (identifier) => qtiItemHref(input.client, identifier),
104678
+ includeIdentifier
104679
+ });
104680
+ }
104681
+ if (input.existingSink) {
104682
+ await input.client.qtiApi.assessmentTests.update(input.plan.bankIdentifier, sinkInput(input.existingSink.title));
104683
+ return;
104684
+ }
104685
+ try {
104686
+ const createInput = sinkInput(REVIEW_BANK_TITLE, true);
104687
+ await input.client.qtiApi.assessmentTests.create(createInput);
104688
+ } catch (error88) {
104689
+ if (!isApiError(error88) || error88.statusCode !== 409) {
104690
+ throw error88;
104691
+ }
104692
+ const concurrentlyCreated = await input.client.qtiApi.assessmentTests.get(input.plan.bankIdentifier);
104693
+ assertReviewBankSinkOwnership(concurrentlyCreated, input.integrationId);
104694
+ await input.client.qtiApi.assessmentTests.update(input.plan.bankIdentifier, sinkInput(concurrentlyCreated.title));
104695
+ }
104696
+ }
104697
+ async installReviewBankAssociation(input) {
104698
+ const singleton = input.associations.find((association) => association.qtiTestIdentifier === input.sinkIdentifier);
104699
+ if (singleton && singleton.purpose !== "review") {
104700
+ throw new ValidationError("The reserved review-bank identifier is associated with another assessment purpose.");
104701
+ }
104702
+ const legacyReviews = input.associations.filter((association) => association.purpose === "review" && association.qtiTestIdentifier !== input.sinkIdentifier);
104703
+ const reservedKeyOwner = input.associations.find((association) => association.assessmentKey === input.sinkIdentifier && association.qtiTestIdentifier !== input.sinkIdentifier);
104704
+ if (reservedKeyOwner && reservedKeyOwner.purpose !== "review") {
104705
+ throw new ValidationError("The reserved review-bank assessment key is already in use.");
104706
+ }
104707
+ const singletonCurrent = Boolean(singleton && singleton.assessmentKey === input.sinkIdentifier && singleton.status === "live" && singleton.standardFramework === null && singleton.standardIdentifier === null && singleton.diagnosticKey === null && singleton.diagnosticRoutingManifest === null);
104708
+ const legacyCurrent = legacyReviews.every((association) => association.status === "archived" && association.assessmentKey !== input.sinkIdentifier);
104709
+ const changed = !singletonCurrent || !legacyCurrent;
104710
+ await input.beforeInstall();
104711
+ for (const legacy of legacyReviews) {
104712
+ if (legacy.status !== "archived" || legacy.assessmentKey === input.sinkIdentifier) {
104713
+ await input.tx.update(gameTimebackAssessmentTests).set({
104714
+ status: "archived",
104715
+ ...legacy.assessmentKey === input.sinkIdentifier ? { assessmentKey: null } : {},
104716
+ updatedAt: new Date
104717
+ }).where(eq(gameTimebackAssessmentTests.id, legacy.id));
104718
+ }
104719
+ }
104720
+ if (singletonCurrent) {
104721
+ return changed;
104722
+ }
104723
+ if (singleton) {
104724
+ await input.tx.update(gameTimebackAssessmentTests).set({
104725
+ assessmentKey: input.sinkIdentifier,
104726
+ purpose: "review",
104727
+ status: "live",
104728
+ standardFramework: null,
104729
+ standardIdentifier: null,
104730
+ diagnosticKey: null,
104731
+ diagnosticRoutingManifest: null,
104732
+ updatedAt: new Date
104733
+ }).where(eq(gameTimebackAssessmentTests.id, singleton.id));
104734
+ return changed;
104735
+ }
104736
+ await input.tx.insert(gameTimebackAssessmentTests).values({
104737
+ integrationId: input.integrationId,
104738
+ assessmentKey: input.sinkIdentifier,
104739
+ qtiTestIdentifier: input.sinkIdentifier,
104740
+ purpose: "review",
104741
+ status: "live",
104742
+ standardFramework: null,
104743
+ standardIdentifier: null,
104744
+ diagnosticKey: null,
104745
+ diagnosticRoutingManifest: null
104746
+ });
104747
+ return changed;
104748
+ }
104749
+ reviewMappingResult(result) {
104750
+ setAttribute("app.assessment.operation", "update_review_mapping");
104751
+ setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
104752
+ setAttribute("app.assessment.review_mapping_added_item_count", result.addedItemCount);
104753
+ setAttribute("app.assessment.review_mapping_removed_item_count", result.removedItemCount);
104754
+ return result;
104755
+ }
104756
+ async withLockedIntegrationAssessmentRows(integrationId, action) {
104757
+ return this.deps.db.transaction(async (tx) => {
104758
+ const [integration] = await tx.select({ id: gameTimebackIntegrations.id }).from(gameTimebackIntegrations).where(and(eq(gameTimebackIntegrations.id, integrationId), isActiveGameTimebackIntegrationStatus())).for("update");
104759
+ if (!integration) {
104760
+ throw new NotFoundError(`Integration not found: ${integrationId}`);
104761
+ }
104762
+ const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.integrationId, integrationId)).orderBy(gameTimebackAssessmentTests.id).for("update");
104763
+ return action(tx, rows);
104764
+ });
104765
+ }
104107
104766
  async withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, action) {
104108
104767
  return this.deps.db.transaction(async (tx) => {
104109
104768
  const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier)).orderBy(gameTimebackAssessmentTests.id).for("update");
@@ -104188,8 +104847,16 @@ class TimebackAssessmentsService {
104188
104847
  items: client2.qtiApi.assessmentTests.testParts(qtiTestIdentifier).sections(section.partIdentifier).items(section.sectionIdentifier)
104189
104848
  };
104190
104849
  }
104191
- qtiItemHref(client2, itemIdentifier) {
104192
- return `${client2.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
104850
+ async findReviewBankSink(client2, integrationId) {
104851
+ const identifier = reviewBankSinkIdentifier(integrationId);
104852
+ try {
104853
+ return await client2.qtiApi.assessmentTests.get(identifier);
104854
+ } catch (error88) {
104855
+ if (isApiError(error88) && error88.statusCode === 404) {
104856
+ return null;
104857
+ }
104858
+ throw error88;
104859
+ }
104193
104860
  }
104194
104861
  async ensureImmutableQtiResource(get, create) {
104195
104862
  try {
@@ -104230,7 +104897,7 @@ class TimebackAssessmentsService {
104230
104897
  test: loaded.test,
104231
104898
  questions: loaded.questions,
104232
104899
  diagnosticRoutingManifest,
104233
- itemHref: (identifier) => this.qtiItemHref(client2, identifier)
104900
+ itemHref: (identifier) => qtiItemHref(client2, identifier)
104234
104901
  });
104235
104902
  await this.ensureManagedQtiPublication(client2, plan);
104236
104903
  return plan;
@@ -104274,7 +104941,6 @@ class TimebackAssessmentsService {
104274
104941
  qtiTestIdentifier: row.qtiTestIdentifier,
104275
104942
  purpose: row.purpose,
104276
104943
  status: row.status,
104277
- sortOrder: row.sortOrder,
104278
104944
  standard: assessmentStandardForRow(row),
104279
104945
  diagnostic: diagnosticDefinitionForRow(row),
104280
104946
  createdAt: row.createdAt,
@@ -104312,15 +104978,11 @@ class TimebackAssessmentsService {
104312
104978
  const plan = buildQtiLibraryListPlan(params);
104313
104979
  return list(plan.params);
104314
104980
  }
104315
- async validateAssessmentHasQuestions(loaded, reviewGameSlug) {
104981
+ async validateAssessmentHasQuestions(loaded) {
104316
104982
  assertAssessmentHasQuestions(loaded.questions.questions);
104317
104983
  for (const { question } of loaded.questions.questions) {
104318
104984
  assertPlayableQtiQuestion(question);
104319
104985
  }
104320
- if (reviewGameSlug) {
104321
- assertQtiTestOwnedByGame(loaded.test, reviewGameSlug);
104322
- await this.writeReviewMapping(this.requireClient(), loaded.test, loaded.questions);
104323
- }
104324
104986
  }
104325
104987
  async prepareDiagnosticAssociationChanges(row, nextPurpose, requested) {
104326
104988
  if (row.purpose !== "diagnostic" && nextPurpose === "diagnostic" && !requested) {
@@ -104349,22 +105011,8 @@ class TimebackAssessmentsService {
104349
105011
  }
104350
105012
  };
104351
105013
  }
104352
- async rebuildReviewMapping(client2, qtiTestIdentifier, scope) {
104353
- const [test, references] = await Promise.all([
104354
- client2.qtiApi.assessmentTests.get(qtiTestIdentifier),
104355
- client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier)
104356
- ]);
104357
- if (scope.kind === "owned") {
104358
- assertQtiTestOwnedByGame(test, scope.gameSlug);
104359
- }
104360
- return this.writeReviewMapping(client2, test, await hydrateQtiTestQuestions(client2, references));
104361
- }
104362
- async writeReviewMapping(client2, test, questions) {
104363
- const prepared = await prepareReviewMappingUpdate(test, questions);
104364
- await client2.qtiApi.assessmentTests.update(test.identifier, prepared.update);
104365
- return prepared.summary;
104366
- }
104367
105014
  }
105015
+ var REVIEW_BANK_TITLE = "Review Bank", REVIEW_BANK_SYNC_ATTEMPTS = 2;
104368
105016
  var init_timeback_assessments_service = __esm(async () => {
104369
105017
  init_drizzle_orm();
104370
105018
  init_helpers_index();
@@ -164386,7 +165034,7 @@ function parseQtiLibraryParams(searchParams) {
164386
165034
  limit
164387
165035
  };
164388
165036
  }
164389
- var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, saveRuntimeAssessment, 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, updateReviewMapping, reorderAssessments, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
165037
+ var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, saveRuntimeAssessment, 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, synchronizeReviewBank, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
164390
165038
  var init_timeback_controller = __esm(() => {
164391
165039
  init_esm();
164392
165040
  init_src();
@@ -164748,7 +165396,9 @@ var init_timeback_controller = __esm(() => {
164748
165396
  attemptId,
164749
165397
  input: {
164750
165398
  submissionId: body2.submissionId,
164751
- xpAwarded: body2.xpAwarded
165399
+ xpAwarded: body2.xpAwarded,
165400
+ ...body2.masteredUnits !== undefined ? { masteredUnits: body2.masteredUnits } : {},
165401
+ ...body2.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: body2.masteredUnitsAbsolute } : {}
164752
165402
  },
164753
165403
  game: {
164754
165404
  appName: body2.appName,
@@ -165082,23 +165732,13 @@ var init_timeback_controller = __esm(() => {
165082
165732
  const body2 = await parseRequestBody(ctx.request, UpdateAssessmentRequestSchema);
165083
165733
  return ctx.services.timebackAssessments.updateAssessment(integrationId, testIdentifier, body2);
165084
165734
  });
165085
- updateReviewMapping = requireDeveloper(async (ctx) => {
165086
- const { gameId, courseId, testIdentifier } = ctx.params;
165087
- if (!gameId || !courseId || !testIdentifier) {
165088
- throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
165089
- }
165090
- const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
165091
- return ctx.services.timebackAssessments.updateReviewMapping(integrationId, testIdentifier);
165092
- });
165093
- reorderAssessments = requireDeveloper(async (ctx) => {
165735
+ synchronizeReviewBank = requireDeveloper(async (ctx) => {
165094
165736
  const { gameId, courseId } = ctx.params;
165095
165737
  if (!gameId || !courseId) {
165096
165738
  throw ApiError.badRequest("Missing gameId or courseId parameter");
165097
165739
  }
165098
- const body2 = await parseRequestBody(ctx.request, ReorderAssessmentsRequestSchema);
165099
165740
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
165100
- await ctx.services.timebackAssessments.reorderAssessments(integrationId, body2.purpose, body2.testIdentifiers);
165101
- return { success: true };
165741
+ return ctx.services.timebackAssessments.synchronizeReviewBank(integrationId);
165102
165742
  });
165103
165743
  reorderQuestions = requireDeveloper(async (ctx) => {
165104
165744
  const { gameId, courseId, testIdentifier } = ctx.params;
@@ -165239,8 +165879,7 @@ var init_timeback_controller = __esm(() => {
165239
165879
  createAssessment,
165240
165880
  attachExistingAssessments,
165241
165881
  updateAssessment,
165242
- updateReviewMapping,
165243
- reorderAssessments,
165882
+ synchronizeReviewBank,
165244
165883
  removeAssessment,
165245
165884
  reorderQuestions,
165246
165885
  listQuestions,