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