@playcademy/vite-plugin 1.3.1-beta.1 → 1.3.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 (2) hide show
  1. package/dist/index.js +1136 -494
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -24317,7 +24317,7 @@ import path2 from "node:path";
24317
24317
  // package.json
24318
24318
  var package_default = {
24319
24319
  name: "@playcademy/vite-plugin",
24320
- version: "1.3.1-beta.1",
24320
+ version: "1.3.1-beta.3",
24321
24321
  type: "module",
24322
24322
  exports: {
24323
24323
  ".": {
@@ -25934,7 +25934,7 @@ var package_default2;
25934
25934
  var init_package = __esm(() => {
25935
25935
  package_default2 = {
25936
25936
  name: "@playcademy/sandbox",
25937
- version: "0.8.1-beta.1",
25937
+ version: "0.8.1-beta.3",
25938
25938
  description: "Local development server for Playcademy game development",
25939
25939
  type: "module",
25940
25940
  exports: {
@@ -26324,6 +26324,9 @@ async function runWithConcurrency(items, concurrency, worker) {
26324
26324
  }
26325
26325
  return results;
26326
26326
  }
26327
+ function reviewBankSinkIdentifier(integrationId) {
26328
+ return `playcademy-review-bank.${integrationId}`;
26329
+ }
26327
26330
  function playcademySupportedQtiInteractionType(value) {
26328
26331
  if (typeof value !== "string") {
26329
26332
  return;
@@ -34411,9 +34414,12 @@ function classifyAssessmentSubmission(attempt, submissionId) {
34411
34414
  }
34412
34415
  return isAssessmentAttemptOpen(attempt) ? "submit" : "reject";
34413
34416
  }
34417
+ function isSameAssessmentAward(recorded, input) {
34418
+ return recorded.xpAwarded === input.xpAwarded && recorded.masteredUnits === input.masteredUnits && recorded.masteredUnitsAbsolute === input.masteredUnitsAbsolute;
34419
+ }
34414
34420
  function classifyAssessmentFinalization(attempt, input) {
34415
34421
  if (isAssessmentAttemptCompleted(attempt)) {
34416
- return attempt.submissionId === input.submissionId && attempt.xpAwarded === input.xpAwarded ? "replay" : "conflict";
34422
+ return attempt.submissionId === input.submissionId && attempt.award !== undefined && isSameAssessmentAward(attempt.award, input) ? "replay" : "conflict";
34417
34423
  }
34418
34424
  if (!isAssessmentAttemptAwaitingAward(attempt) || attempt.submissionId !== input.submissionId) {
34419
34425
  return "reject";
@@ -35691,39 +35697,21 @@ function compareNewestCompletedAssessmentAttempt(left, right) {
35691
35697
  return newestAttempt(left, right, "scoreDate");
35692
35698
  }
35693
35699
  function compareAssessmentCatalogOrder(left, right) {
35694
- if (left.sortOrder !== null || right.sortOrder !== null) {
35695
- if (left.sortOrder === null) {
35696
- return 1;
35697
- }
35698
- if (right.sortOrder === null) {
35699
- return -1;
35700
- }
35701
- const orderDifference = left.sortOrder - right.sortOrder;
35702
- if (orderDifference) {
35703
- return orderDifference;
35704
- }
35705
- }
35706
- const leftUpdatedAt = Date.parse(left.updatedAt);
35707
- const rightUpdatedAt = Date.parse(right.updatedAt);
35708
- const leftHasValidDate = Number.isFinite(leftUpdatedAt);
35709
- const rightHasValidDate = Number.isFinite(rightUpdatedAt);
35710
- if (leftHasValidDate && rightHasValidDate) {
35711
- const updatedDifference = leftUpdatedAt - rightUpdatedAt;
35712
- if (updatedDifference) {
35713
- return updatedDifference;
35714
- }
35715
- } else if (leftHasValidDate !== rightHasValidDate) {
35716
- return leftHasValidDate ? -1 : 1;
35717
- }
35718
35700
  if (left.assessmentKey !== null && right.assessmentKey !== null) {
35719
- const keyDifference = left.assessmentKey.localeCompare(right.assessmentKey);
35701
+ const keyDifference = compareCodePoints(left.assessmentKey, right.assessmentKey);
35720
35702
  if (keyDifference) {
35721
35703
  return keyDifference;
35722
35704
  }
35723
35705
  } else if (left.assessmentKey !== null || right.assessmentKey !== null) {
35724
35706
  return left.assessmentKey === null ? 1 : -1;
35725
35707
  }
35726
- return left.qtiTestIdentifier.localeCompare(right.qtiTestIdentifier);
35708
+ return compareCodePoints(left.qtiTestIdentifier, right.qtiTestIdentifier);
35709
+ }
35710
+ function compareCodePoints(left, right) {
35711
+ if (left === right) {
35712
+ return 0;
35713
+ }
35714
+ return left < right ? -1 : 1;
35727
35715
  }
35728
35716
  function orderRuntimeAssessmentTests(tests) {
35729
35717
  return [...tests].toSorted(compareAssessmentCatalogOrder);
@@ -35894,7 +35882,7 @@ async function reviewBankRevision(input) {
35894
35882
  })}`);
35895
35883
  return `review-bank-v1:${bankRevisionUuid}`;
35896
35884
  }
35897
- async function reviewBankIndex(input) {
35885
+ async function buildReviewBankIndexFromReferences(input) {
35898
35886
  return {
35899
35887
  ...input,
35900
35888
  bankRevision: await reviewBankRevision(input)
@@ -35923,7 +35911,7 @@ async function buildReviewBankIndex(assessment, questions, options = {}) {
35923
35911
  standards
35924
35912
  };
35925
35913
  });
35926
- return reviewBankIndex({
35914
+ return buildReviewBankIndexFromReferences({
35927
35915
  bankIdentifier: assessment.identifier,
35928
35916
  sourceContentRevision: assessment.contentRevision,
35929
35917
  items
@@ -36028,7 +36016,7 @@ async function reviewBankIndexFromManifest(metadata2, input) {
36028
36016
  }
36029
36017
  }
36030
36018
  }
36031
- const index = await reviewBankIndex({
36019
+ const index = await buildReviewBankIndexFromReferences({
36032
36020
  bankIdentifier,
36033
36021
  sourceContentRevision,
36034
36022
  items: input.membershipItemIdentifiers.map((itemIdentifier) => ({
@@ -36383,6 +36371,18 @@ function isRoutedDiagnosticAttemptMetadata(value, itemSubmissions, responseVersi
36383
36371
  function isReviewAttemptMetadata(value) {
36384
36372
  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);
36385
36373
  }
36374
+ function isOptionalInteger(value) {
36375
+ return value === undefined || Number.isInteger(value);
36376
+ }
36377
+ function isMasteryWriteWarning(value) {
36378
+ 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);
36379
+ }
36380
+ function isAssessmentAwardRecord(value) {
36381
+ if (value === undefined) {
36382
+ return true;
36383
+ }
36384
+ 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));
36385
+ }
36386
36386
  function isMasteryAttemptMetadata(value) {
36387
36387
  return isRecord(value) && typeof value.requestFingerprint === "string" && isAssessmentStandardRef(value.standard);
36388
36388
  }
@@ -36407,7 +36407,7 @@ function isPlaycademyAssessmentResultMetadataV1(value) {
36407
36407
  const review = metadata2.review;
36408
36408
  const diagnostic = metadata2.diagnostic;
36409
36409
  const routedDiagnostic = metadata2.purpose === "diagnostic" && diagnostic !== undefined && Array.isArray(metadata2.itemSubmissions) && Number.isInteger(metadata2.responseVersion) && isRoutedDiagnosticAttemptMetadata(diagnostic, metadata2.itemSubmissions, metadata2.responseVersion);
36410
- 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);
36410
+ 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);
36411
36411
  }
36412
36412
  function isPlaycademyReviewAssessmentItemResultMetadataV1(value) {
36413
36413
  if (!isRecord(value) || !isRecord(value.responses)) {
@@ -36556,7 +36556,7 @@ var REVIEW_SELECTION_POLICY_VERSION = "unseen-first-least-recently-delivered-v1"
36556
36556
  var PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY = "playcademyReviewBank";
36557
36557
  var PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION = 1;
36558
36558
  var DEFAULT_REVIEW_SELECTION_POLICY;
36559
- var ASSESSMENT_RUNTIME_FIXTURE_BUNDLE_VERSION = 7;
36559
+ var ASSESSMENT_RUNTIME_FIXTURE_BUNDLE_VERSION = 8;
36560
36560
  var RuntimeSubjectSchema;
36561
36561
  var RuntimeGradeSchema;
36562
36562
  var OptionalQueryGradeSchema;
@@ -36927,7 +36927,12 @@ var init_assessment_runtime2 = __esm(() => {
36927
36927
  });
36928
36928
  FinalizeAssessmentBodySchema = exports_external.object({
36929
36929
  submissionId: exports_external.string().trim().min(1),
36930
- xpAwarded: exports_external.number().finite().nonnegative()
36930
+ xpAwarded: exports_external.number().finite().nonnegative(),
36931
+ masteredUnits: exports_external.number().finite().optional(),
36932
+ masteredUnitsAbsolute: exports_external.number().finite().int().optional()
36933
+ }).refine((body2) => body2.masteredUnits === undefined || body2.masteredUnitsAbsolute === undefined, {
36934
+ message: "Provide either masteredUnits or masteredUnitsAbsolute, not both",
36935
+ path: ["masteredUnits"]
36931
36936
  });
36932
36937
  StartRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.and(StartAssessmentBodySchema);
36933
36938
  SaveRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend(SaveAssessmentBodySchema.shape);
@@ -36936,10 +36941,10 @@ var init_assessment_runtime2 = __esm(() => {
36936
36941
  appName: exports_external.string().trim().min(1),
36937
36942
  sensorUrl: exports_external.string().url()
36938
36943
  });
36939
- FinalizeRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend(FinalizeAssessmentBodySchema.shape).extend({
36944
+ FinalizeRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend({
36940
36945
  appName: exports_external.string().trim().min(1),
36941
36946
  sensorUrl: exports_external.string().url()
36942
- });
36947
+ }).and(FinalizeAssessmentBodySchema);
36943
36948
  });
36944
36949
  var DOMAIN_CODE_BY_STATUS;
36945
36950
  var AssessmentRuntimeError;
@@ -71642,6 +71647,15 @@ function requireMasteryStandard(input, context2) {
71642
71647
  });
71643
71648
  }
71644
71649
  }
71650
+ function rejectSystemManagedReview(input, context2) {
71651
+ if (input.purpose === "review") {
71652
+ context2.addIssue({
71653
+ code: "custom",
71654
+ path: ["purpose"],
71655
+ message: "The review bank is system-managed and cannot be managed manually"
71656
+ });
71657
+ }
71658
+ }
71645
71659
  var TimebackGradeSchema;
71646
71660
  var TimebackSubjectSchema;
71647
71661
  var CourseGoalsSchema;
@@ -71684,7 +71698,6 @@ var UpdateAssessmentRequestSchema;
71684
71698
  var CopyAssessmentRequestSchema;
71685
71699
  var AssessmentAssociationImportEntrySchema;
71686
71700
  var AttachExistingAssessmentsRequestSchema;
71687
- var ReorderAssessmentsRequestSchema;
71688
71701
  var ReorderQuestionsRequestSchema;
71689
71702
  var init_schemas4 = __esm(() => {
71690
71703
  init_esm();
@@ -71946,7 +71959,10 @@ var init_schemas4 = __esm(() => {
71946
71959
  title: exports_external.string().min(1, "Assessment title is required"),
71947
71960
  purpose: AssessmentPurposeSchema,
71948
71961
  standard: AssessmentStandardRefSchema2.optional()
71949
- }).superRefine(requireMasteryStandard);
71962
+ }).superRefine((input, context2) => {
71963
+ requireMasteryStandard(input, context2);
71964
+ rejectSystemManagedReview(input, context2);
71965
+ });
71950
71966
  UpdateAssessmentRequestSchema = exports_external.object({
71951
71967
  title: exports_external.string().trim().min(1, "Assessment title is required").optional(),
71952
71968
  purpose: AssessmentPurposeSchema.optional(),
@@ -71955,21 +71971,24 @@ var init_schemas4 = __esm(() => {
71955
71971
  status: AssessmentStatusSchema.optional()
71956
71972
  }).refine((input) => input.title !== undefined || input.purpose !== undefined || input.standard !== undefined || input.diagnostic !== undefined || input.status !== undefined, {
71957
71973
  message: "Title, purpose, standard, diagnostic, or status is required"
71958
- });
71974
+ }).superRefine(rejectSystemManagedReview);
71959
71975
  CopyAssessmentRequestSchema = exports_external.object({
71960
71976
  assessmentKey: AssessmentKeySchema,
71961
71977
  testIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
71962
71978
  purpose: AssessmentPurposeSchema,
71963
71979
  standard: AssessmentStandardRefSchema2.optional()
71964
- }).superRefine(requireMasteryStandard);
71980
+ }).superRefine((input, context2) => {
71981
+ requireMasteryStandard(input, context2);
71982
+ rejectSystemManagedReview(input, context2);
71983
+ });
71965
71984
  AssessmentAssociationImportEntrySchema = exports_external.object({
71966
71985
  assessmentKey: AssessmentKeySchema,
71967
71986
  qtiTestIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
71968
71987
  purpose: AssessmentPurposeSchema,
71969
- standard: AssessmentStandardRefSchema2.optional(),
71970
- sortOrder: exports_external.number().int().nonnegative().nullable()
71988
+ standard: AssessmentStandardRefSchema2.optional()
71971
71989
  }).superRefine((input, context2) => {
71972
71990
  requireMasteryStandard(input, context2);
71991
+ rejectSystemManagedReview(input, context2);
71973
71992
  if (input.purpose !== "mastery" && input.standard !== undefined) {
71974
71993
  context2.addIssue({
71975
71994
  code: "custom",
@@ -72016,10 +72035,6 @@ var init_schemas4 = __esm(() => {
72016
72035
  seenKeys.add(assessment.assessmentKey);
72017
72036
  });
72018
72037
  });
72019
- ReorderAssessmentsRequestSchema = exports_external.object({
72020
- purpose: AssessmentPurposeSchema,
72021
- testIdentifiers: exports_external.array(exports_external.string().trim().min(1, "Assessment identifier is required")).min(1, "At least one assessment is required")
72022
- });
72023
72038
  ReorderQuestionsRequestSchema = exports_external.object({
72024
72039
  items: exports_external.array(exports_external.object({
72025
72040
  identifier: exports_external.string().min(1),
@@ -116646,6 +116661,7 @@ function buildAssessmentCompletionEvent(input) {
116646
116661
  totalQuestions: input.totalQuestions,
116647
116662
  correctQuestions: input.correctQuestions,
116648
116663
  xpEarned: input.xpAwarded,
116664
+ ...input.masteredUnits ? { masteredUnits: input.masteredUnits } : {},
116649
116665
  attemptNumber: input.attemptNumber,
116650
116666
  process: false,
116651
116667
  subject: input.subject,
@@ -116654,7 +116670,10 @@ function buildAssessmentCompletionEvent(input) {
116654
116670
  eventId: input.eventId,
116655
116671
  eventTime: input.submittedAt,
116656
116672
  runId: input.attemptId,
116657
- generatedExtensions: metadata2,
116673
+ generatedExtensions: {
116674
+ ...metadata2,
116675
+ ...input.pctCompleteApp !== undefined ? { pctCompleteApp: input.pctCompleteApp } : {}
116676
+ },
116658
116677
  eventExtensions: {
116659
116678
  playcademy: {
116660
116679
  assessmentPurpose: input.purpose,
@@ -116708,6 +116727,85 @@ function deriveSourcedIds(courseId) {
116708
116727
  componentResource: `${courseId}-cr`
116709
116728
  };
116710
116729
  }
116730
+ function hasMasteryAwardRequest(request) {
116731
+ return request.masteredUnits !== undefined || request.masteredUnitsAbsolute !== undefined;
116732
+ }
116733
+
116734
+ class ActivityMasteryAward {
116735
+ mastery;
116736
+ events;
116737
+ constructor(mastery, events) {
116738
+ this.mastery = mastery;
116739
+ this.events = events;
116740
+ }
116741
+ async evaluate(input) {
116742
+ const ids = deriveSourcedIds(input.courseId);
116743
+ const progress = await this.mastery.checkProgress({
116744
+ studentId: input.studentId,
116745
+ courseId: input.courseId,
116746
+ resourceId: ids.resource,
116747
+ masteredUnits: input.masteredUnits ?? 0,
116748
+ masteredUnitsAbsolute: input.masteredUnitsAbsolute
116749
+ });
116750
+ if (!progress) {
116751
+ return {
116752
+ masteredUnitsApplied: input.masteredUnits ?? 0,
116753
+ masteryAchieved: false,
116754
+ masteryRevoked: false
116755
+ };
116756
+ }
116757
+ return {
116758
+ masteredUnitsApplied: progress.effectiveDelta,
116759
+ pctCompleteApp: progress.pctCompleteApp,
116760
+ masteryAchieved: progress.masteryAchieved,
116761
+ masteryRevoked: progress.masteryRevoked,
116762
+ ...progress.writeWarning ? { warnings: [progress.writeWarning] } : {}
116763
+ };
116764
+ }
116765
+ async settle(evaluation, input) {
116766
+ let completionEntryWritten = false;
116767
+ if (evaluation.masteryAchieved) {
116768
+ setAttributes({ "app.timeback.mastery_achieved": true });
116769
+ completionEntryWritten = await this.mastery.createCompletionEntry(input.studentId, input.courseId, input.classId, input.appName);
116770
+ await this.emitCourseCompletionHistoryEvent(input);
116771
+ }
116772
+ if (evaluation.masteryRevoked) {
116773
+ await this.mastery.revokeCompletionEntry(input.studentId, input.courseId, input.classId, input.appName);
116774
+ }
116775
+ return { completionEntryWritten };
116776
+ }
116777
+ async emitCourseCompletionHistoryEvent(input) {
116778
+ const ids = deriveSourcedIds(input.courseId);
116779
+ await this.events.emitActivityEvent({
116780
+ eventId: input.completionHistoryEventId,
116781
+ studentId: input.studentId,
116782
+ studentEmail: input.studentEmail,
116783
+ gameId: input.gameId,
116784
+ activityId: input.activityId,
116785
+ activityName: "Course completed",
116786
+ courseId: ids.course,
116787
+ courseName: input.courseName,
116788
+ subject: input.subject,
116789
+ appName: input.appName,
116790
+ sensorUrl: input.sensorUrl,
116791
+ process: false,
116792
+ includeAttempt: false,
116793
+ eventExtensions: {
116794
+ playcademy: {
116795
+ eventKind: "course-completed",
116796
+ source: "gameplay"
116797
+ }
116798
+ },
116799
+ generatedExtensions: {
116800
+ playcademy: {
116801
+ eventKind: "course-completed",
116802
+ source: "gameplay",
116803
+ activityId: input.activityId
116804
+ }
116805
+ }
116806
+ }).catch(catchEvent("timeback.caliper_completion_event_failed"));
116807
+ }
116808
+ }
116711
116809
  function validateProgressData(progressData) {
116712
116810
  if (!progressData.subject) {
116713
116811
  throw new ConfigurationError("subject", "Subject is required for Caliper events. Provide it in progressData.subject");
@@ -116723,12 +116821,12 @@ function validateProgressData(progressData) {
116723
116821
  class ActivityRecord {
116724
116822
  core;
116725
116823
  students;
116726
- mastery;
116824
+ masteryAward;
116727
116825
  events;
116728
- constructor(core3, students, mastery, events) {
116826
+ constructor(core3, students, masteryAward, events) {
116729
116827
  this.core = core3;
116730
116828
  this.students = students;
116731
- this.mastery = mastery;
116829
+ this.masteryAward = masteryAward;
116732
116830
  this.events = events;
116733
116831
  }
116734
116832
  async record(courseId, studentIdentifier, progressData) {
@@ -116754,52 +116852,32 @@ class ActivityRecord {
116754
116852
  throw error88;
116755
116853
  }
116756
116854
  const legacyLineItemId = `${ids.course}-${activityId}-assessment`;
116757
- const [currentAttemptNumber, masteryProgress] = await Promise.all([
116855
+ const [currentAttemptNumber, mastery] = await Promise.all([
116758
116856
  this.resolveAttemptNumber(attemptNumber, studentId, caliperLineItemId, legacyLineItemId),
116759
- this.mastery.checkProgress({
116857
+ this.masteryAward.evaluate({
116760
116858
  studentId,
116761
116859
  courseId,
116762
- resourceId: ids.resource,
116763
- masteredUnits: progressData.masteredUnits ?? 0,
116860
+ masteredUnits: progressData.masteredUnits,
116764
116861
  masteredUnitsAbsolute: progressData.masteredUnitsAbsolute
116765
116862
  })
116766
116863
  ]);
116767
116864
  setAttributes({ "app.timeback.attempt_number": currentAttemptNumber });
116768
- let extensions = progressData.extensions;
116769
- const effectiveMasteredUnits = masteryProgress ? masteryProgress.effectiveDelta : progressData.masteredUnits ?? 0;
116770
- let pctCompleteApp;
116771
- let masteryAchieved = false;
116772
- let completionEntryWritten = false;
116773
- const warnings = masteryProgress?.writeWarning ? [masteryProgress.writeWarning] : undefined;
116774
- if (masteryProgress) {
116775
- masteryAchieved = masteryProgress.masteryAchieved;
116776
- pctCompleteApp = masteryProgress.pctCompleteApp;
116777
- extensions = {
116778
- ...extensions,
116779
- ...pctCompleteApp !== undefined ? { pctCompleteApp } : {}
116780
- };
116781
- }
116782
- if (masteryAchieved) {
116783
- setAttributes({ "app.timeback.mastery_achieved": true });
116784
- }
116785
- if (masteryAchieved) {
116786
- completionEntryWritten = await this.mastery.createCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
116787
- await this.emitCourseCompletionHistoryEvent({
116788
- studentId,
116789
- studentEmail,
116790
- gameId: progressData.gameId,
116791
- activityId,
116792
- courseId: ids.course,
116793
- courseName,
116794
- subject: progressData.subject,
116795
- appName: progressData.appName,
116796
- sensorUrl: progressData.sensorUrl,
116797
- eventId: progressData.completionHistoryEventId
116798
- });
116799
- }
116800
- if (masteryProgress?.masteryRevoked) {
116801
- await this.mastery.revokeCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
116802
- }
116865
+ const effectiveMasteredUnits = mastery.masteredUnitsApplied;
116866
+ const { pctCompleteApp, warnings } = mastery;
116867
+ const extensions = pctCompleteApp !== undefined ? { ...progressData.extensions, pctCompleteApp } : progressData.extensions;
116868
+ const { completionEntryWritten } = await this.masteryAward.settle(mastery, {
116869
+ studentId,
116870
+ studentEmail,
116871
+ courseId,
116872
+ classId: progressData.classId,
116873
+ gameId: progressData.gameId,
116874
+ activityId,
116875
+ courseName,
116876
+ subject: progressData.subject,
116877
+ appName: progressData.appName,
116878
+ sensorUrl: progressData.sensorUrl,
116879
+ completionHistoryEventId: progressData.completionHistoryEventId
116880
+ });
116803
116881
  try {
116804
116882
  await this.events.emitActivityEvent({
116805
116883
  studentId,
@@ -116820,7 +116898,7 @@ class ActivityRecord {
116820
116898
  appName: progressData.appName,
116821
116899
  sensorUrl: progressData.sensorUrl,
116822
116900
  eventId: progressData.eventId,
116823
- extensions: extensions || progressData.extensions,
116901
+ extensions,
116824
116902
  ...progressData.runId ? { runId: progressData.runId } : {}
116825
116903
  }).catch((error88) => {
116826
116904
  setAttributes({ "app.timeback.caliper_emit_failed": true });
@@ -116841,36 +116919,6 @@ class ActivityRecord {
116841
116919
  ...warnings ? { warnings } : {}
116842
116920
  };
116843
116921
  }
116844
- async emitCourseCompletionHistoryEvent(data) {
116845
- await this.events.emitActivityEvent({
116846
- eventId: data.eventId,
116847
- studentId: data.studentId,
116848
- studentEmail: data.studentEmail,
116849
- gameId: data.gameId,
116850
- activityId: data.activityId,
116851
- activityName: "Course completed",
116852
- courseId: data.courseId,
116853
- courseName: data.courseName,
116854
- subject: data.subject,
116855
- appName: data.appName,
116856
- sensorUrl: data.sensorUrl,
116857
- process: false,
116858
- includeAttempt: false,
116859
- eventExtensions: {
116860
- playcademy: {
116861
- eventKind: "course-completed",
116862
- source: "gameplay"
116863
- }
116864
- },
116865
- generatedExtensions: {
116866
- playcademy: {
116867
- eventKind: "course-completed",
116868
- source: "gameplay",
116869
- activityId: data.activityId
116870
- }
116871
- }
116872
- }).catch(catchEvent("timeback.caliper_completion_event_failed"));
116873
- }
116874
116922
  async resolveAttemptNumber(providedAttemptNumber, studentId, caliperLineItemId, legacyLineItemId) {
116875
116923
  if (providedAttemptNumber) {
116876
116924
  setAttributes({ "app.timeback.attempt_source": "provided" });
@@ -117268,9 +117316,12 @@ class ActivityEvents {
117268
117316
  }
117269
117317
  }
117270
117318
  function createActivityNamespace(core3, deps) {
117271
- const record3 = new ActivityRecord(core3, deps.students, deps.mastery, deps.events);
117319
+ const masteryAward = new ActivityMasteryAward(deps.mastery, deps.events);
117320
+ const record3 = new ActivityRecord(core3, deps.students, masteryAward, deps.events);
117272
117321
  const session2 = new ActivitySession(deps.students, deps.events);
117273
117322
  return {
117323
+ evaluateMasteryAward: (input) => masteryAward.evaluate(input),
117324
+ settleMasteryAward: (evaluation, input) => masteryAward.settle(evaluation, input),
117274
117325
  record: (courseId, studentIdentifier, progressData) => record3.record(courseId, studentIdentifier, progressData),
117275
117326
  session: (courseId, studentIdentifier, sessionData) => session2.record(courseId, studentIdentifier, sessionData),
117276
117327
  listEvents: (params) => deps.events.listEvents(params),
@@ -118560,6 +118611,7 @@ var StudentNotFoundError;
118560
118611
  var ConfigurationError;
118561
118612
  var UUID_REGEX2;
118562
118613
  var ONEROSTER_PATHS;
118614
+ var NO_MASTERY_AWARD;
118563
118615
  var TIMEBACK_API_URLS;
118564
118616
  var QTI_API_URL = "https://qti.alpha-1edtech.ai/api";
118565
118617
  var TIMEBACK_AUTH_URLS;
@@ -118576,6 +118628,7 @@ var GRADE_VALUES3;
118576
118628
  var MASTERY_WRITE_CAPPED_WARNING_CODE = "MASTERY_WRITE_CAPPED";
118577
118629
  var EmailSchema;
118578
118630
  var init_dist5 = __esm(async () => {
118631
+ init_spans();
118579
118632
  init_spans();
118580
118633
  init_src();
118581
118634
  init_spans();
@@ -118636,6 +118689,11 @@ var init_dist5 = __esm(async () => {
118636
118689
  courses: "/ims/oneroster/rostering/v1p2/courses",
118637
118690
  componentResources: "/ims/oneroster/rostering/v1p2/courses/component-resources"
118638
118691
  };
118692
+ NO_MASTERY_AWARD = {
118693
+ masteredUnitsApplied: 0,
118694
+ masteryAchieved: false,
118695
+ masteryRevoked: false
118696
+ };
118639
118697
  TIMEBACK_API_URLS = {
118640
118698
  production: "https://api.alpha-1edtech.ai",
118641
118699
  staging: "https://api.staging.alpha-1edtech.com"
@@ -121390,6 +121448,62 @@ function stringField2(value) {
121390
121448
  function firstStringField2(...values) {
121391
121449
  return values.map(stringField2).find(Boolean) ?? "";
121392
121450
  }
121451
+ function normalizedWhitespace2(value) {
121452
+ return value.normalize("NFKC").trim().replace(/\s+/g, " ");
121453
+ }
121454
+ function normalizedIdentityCase2(value) {
121455
+ return value.toLocaleUpperCase("en-US");
121456
+ }
121457
+ function frameworkAliasKey2(value) {
121458
+ return value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/g, "");
121459
+ }
121460
+ function isCommonCoreMathIdentifier2(identifier) {
121461
+ const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
121462
+ 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);
121463
+ }
121464
+ function isCommonCoreElaIdentifier2(identifier) {
121465
+ const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
121466
+ 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);
121467
+ }
121468
+ function canonicalFramework2(authoredFramework, identifier) {
121469
+ const aliasKey = frameworkAliasKey2(authoredFramework);
121470
+ if (COMMON_CORE_MATH_FRAMEWORK_ALIASES2.has(aliasKey) || COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey) && isCommonCoreMathIdentifier2(identifier)) {
121471
+ return "CCSS.Math";
121472
+ }
121473
+ if (COMMON_CORE_ELA_FRAMEWORK_ALIASES2.has(aliasKey) || COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey) && isCommonCoreElaIdentifier2(identifier)) {
121474
+ return "CCSS.ELA-Literacy";
121475
+ }
121476
+ if (COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey)) {
121477
+ return "CCSS";
121478
+ }
121479
+ return normalizedIdentityCase2(authoredFramework);
121480
+ }
121481
+ function canonicalIdentifier2(framework, authoredIdentifier) {
121482
+ const identifier = normalizedIdentityCase2(normalizedWhitespace2(authoredIdentifier));
121483
+ if (framework === "CCSS.Math") {
121484
+ return identifier.replace(/^CCSS\.MATH\.CONTENT\./, "").replace(/^CCSS\.MATH\.PRACTICE\./, "").replace(/^CCSS\.MATH\./, "");
121485
+ }
121486
+ if (framework === "CCSS.ELA-Literacy") {
121487
+ return identifier.replace(/^CCSS\.ELA-LITERACY\./, "");
121488
+ }
121489
+ return identifier;
121490
+ }
121491
+ function canonicalAssessmentStandardRef2(standard) {
121492
+ const authoredFramework = normalizedWhitespace2(standard.framework);
121493
+ const framework = canonicalFramework2(authoredFramework, standard.identifier);
121494
+ const identifier = canonicalIdentifier2(framework, standard.identifier);
121495
+ return {
121496
+ framework,
121497
+ identifier
121498
+ };
121499
+ }
121500
+ function assessmentStandardRefKey2(standard) {
121501
+ const canonical = canonicalAssessmentStandardRef2(standard);
121502
+ return JSON.stringify([
121503
+ normalizedIdentityCase2(canonical.framework),
121504
+ normalizedIdentityCase2(canonical.identifier)
121505
+ ]);
121506
+ }
121393
121507
  function dedupeStandards2(standards) {
121394
121508
  const deduped = new Map;
121395
121509
  for (const standard of standards) {
@@ -124161,6 +124275,22 @@ var init_locks = __esm(() => {
124161
124275
  }
124162
124276
  };
124163
124277
  });
124278
+ async function crossAssessmentAttemptLockBarrier(lock, db2, attemptId) {
124279
+ const maxAttempts = 5;
124280
+ for (let attempt = 1;attempt <= maxAttempts; attempt += 1) {
124281
+ try {
124282
+ await lock(db2, [assessmentRuntimeAttemptLockKey(attemptId)], async () => {
124283
+ return;
124284
+ });
124285
+ return;
124286
+ } catch (error88) {
124287
+ if (!(error88 instanceof ServiceUnavailableError) || attempt === maxAttempts) {
124288
+ throw error88;
124289
+ }
124290
+ await sleep(25);
124291
+ }
124292
+ }
124293
+ }
124164
124294
  function normalizeKeys(keys) {
124165
124295
  return [...new Set(keys)].toSorted();
124166
124296
  }
@@ -125176,6 +125306,97 @@ var init_timeback_qti_authoring_util = __esm(() => {
125176
125306
  buildMatchQuestionXml
125177
125307
  ];
125178
125308
  });
125309
+ function assessmentPreparationContentionError(expectedResponseVersion, responseVersion, kind) {
125310
+ if (expectedResponseVersion === responseVersion) {
125311
+ return new ServiceUnavailableError(`The ${kind} response state changed repeatedly while this item was being prepared. Try again shortly.`, {
125312
+ retryable: true,
125313
+ reason: "PREPARATION_CONTENTION",
125314
+ expectedResponseVersion,
125315
+ responseVersion
125316
+ });
125317
+ }
125318
+ return AssessmentRuntimeError.from(assessmentResponseVersionConflict(expectedResponseVersion, responseVersion));
125319
+ }
125320
+ function assessmentTrackableCourse(integration) {
125321
+ if (!isTimebackGrade2(integration.grade) || !isTimebackSubject(integration.subject)) {
125322
+ return null;
125323
+ }
125324
+ return { grade: integration.grade, subject: integration.subject };
125325
+ }
125326
+ function buildAssessmentActivityData(input) {
125327
+ if (!input.course) {
125328
+ return;
125329
+ }
125330
+ return {
125331
+ activityId: input.activityId,
125332
+ activityName: input.activityName,
125333
+ ...input.course,
125334
+ courseId: input.courseId
125335
+ };
125336
+ }
125337
+ function buildDiagnosticAssessmentSnapshot(input) {
125338
+ return {
125339
+ attemptId: input.attemptId,
125340
+ responseVersion: input.metadata.responseVersion,
125341
+ status: "in_progress",
125342
+ flow: "platform-routed-item-submit",
125343
+ ...input.activityData ? { activityData: input.activityData } : {},
125344
+ assessment: assessmentPresentationForAttempt(input.assessment, input.attemptId),
125345
+ selection: {
125346
+ kind: "platform-routed-diagnostic",
125347
+ purpose: "diagnostic",
125348
+ definitionId: input.metadata.diagnostic.definitionId,
125349
+ diagnosticKey: input.metadata.diagnostic.diagnosticKey,
125350
+ routingRevision: input.metadata.diagnostic.routingRevision
125351
+ },
125352
+ routing: projectDiagnosticRoutingSnapshot(input.metadata.diagnostic.routingRevision, input.state),
125353
+ completion: null
125354
+ };
125355
+ }
125356
+ function buildConventionalAssessmentSnapshot(input) {
125357
+ const metadata2 = input.metadata;
125358
+ let selection;
125359
+ if (metadata2.purpose === "review") {
125360
+ selection = {
125361
+ kind: "standards-review",
125362
+ purpose: metadata2.purpose,
125363
+ standards: [...metadata2.review.standards],
125364
+ candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
125365
+ selections: [...metadata2.review.selections],
125366
+ fulfillment: metadata2.review.fulfillment
125367
+ };
125368
+ } else if (metadata2.purpose === "mastery") {
125369
+ selection = {
125370
+ kind: "standard-quiz",
125371
+ purpose: metadata2.purpose,
125372
+ standard: metadata2.mastery.standard
125373
+ };
125374
+ } else {
125375
+ selection = { kind: "fixed-test", purpose: metadata2.purpose };
125376
+ }
125377
+ return {
125378
+ attemptId: input.attemptId,
125379
+ responseVersion: metadata2.responseVersion,
125380
+ status: "in_progress",
125381
+ flow: assessmentFlowForPurpose(metadata2.purpose),
125382
+ ...input.activityData ? { activityData: input.activityData } : {},
125383
+ assessment: assessmentPresentationForAttempt(input.assessment, input.attemptId),
125384
+ responses: metadata2.responses,
125385
+ itemSubmissions: metadata2.itemSubmissions,
125386
+ score: null,
125387
+ selection
125388
+ };
125389
+ }
125390
+ function buildAssessmentAwardRecord(input, mastery) {
125391
+ return {
125392
+ xpAwarded: input.xpAwarded,
125393
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
125394
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {},
125395
+ masteredUnitsApplied: mastery.masteredUnitsApplied,
125396
+ ...mastery.pctCompleteApp !== undefined ? { pctCompleteApp: mastery.pctCompleteApp } : {},
125397
+ ...mastery.warnings ? { warnings: mastery.warnings } : {}
125398
+ };
125399
+ }
125179
125400
  function stageAssessmentAttemptSupersession(attempt) {
125180
125401
  Object.assign(attempt.result, ASSESSMENT_ATTEMPT_SUPERSEDED);
125181
125402
  Object.assign(attempt.selection, ASSESSMENT_ATTEMPT_SUPERSEDED);
@@ -125386,13 +125607,35 @@ function buildAssessmentSubmitResult(input) {
125386
125607
  }
125387
125608
  return { ...base, purpose: input.metadata.purpose };
125388
125609
  }
125610
+ function assessmentAwardFromResult(result, metadata2) {
125611
+ if (metadata2.award) {
125612
+ return metadata2.award;
125613
+ }
125614
+ const xp = result.metadata?.xp;
125615
+ return typeof xp === "number" && Number.isFinite(xp) && xp >= 0 ? { xpAwarded: xp, masteredUnitsApplied: 0 } : null;
125616
+ }
125389
125617
  function buildCompletedAssessmentSubmitResult(input) {
125390
- const submitted = buildAssessmentSubmitResult(input);
125391
- return submitted ? {
125618
+ const attemptId = input.result.sourcedId;
125619
+ const award = assessmentAwardFromResult(input.result, input.metadata);
125620
+ if (award === null) {
125621
+ throw new GoneError("This completed assessment is missing its recorded XP.", {
125622
+ attemptId
125623
+ });
125624
+ }
125625
+ const submitted = buildAssessmentSubmitResult({ attemptId, metadata: input.metadata });
125626
+ if (!submitted) {
125627
+ throw new GoneError("This completed assessment has incomplete persisted results.", {
125628
+ attemptId
125629
+ });
125630
+ }
125631
+ return {
125392
125632
  ...submitted,
125393
125633
  status: "completed",
125394
- xpAwarded: input.xpAwarded
125395
- } : null;
125634
+ xpAwarded: award.xpAwarded,
125635
+ masteredUnitsApplied: award.masteredUnitsApplied,
125636
+ ...award.pctCompleteApp !== undefined ? { pctCompleteApp: award.pctCompleteApp } : {},
125637
+ ...award.warnings ? { warnings: award.warnings } : {}
125638
+ };
125396
125639
  }
125397
125640
  function assessmentAttemptId(input) {
125398
125641
  return deterministicUUID([
@@ -125719,6 +125962,22 @@ function assessmentFixtureScoringKeys(assessment, questions) {
125719
125962
  ...Object.keys(responseAreas).length > 0 ? { responseAreas } : {}
125720
125963
  };
125721
125964
  }
125965
+ function prepareDiagnosticAssessmentResponses(assessment, current, input) {
125966
+ const update2 = { [input.itemIdentifier]: input.responses };
125967
+ validateAssessmentResponseUpdate(assessment, update2);
125968
+ const responses = applyAssessmentResponseUpdate(current, update2);
125969
+ const item = assessment.items.find((candidate) => candidate.identifier === input.itemIdentifier);
125970
+ const itemResponses = responses[input.itemIdentifier];
125971
+ const missingResponse = item?.interactions.find((interaction) => itemResponses?.[interaction.responseIdentifier] === undefined);
125972
+ if (!item || !itemResponses || missingResponse) {
125973
+ throw new AssessmentRuntimeError("INVALID_RESPONSE", `Diagnostic item ${input.itemIdentifier} requires a response for every interaction.`, {
125974
+ itemIdentifier: input.itemIdentifier,
125975
+ ...missingResponse ? { responseIdentifier: missingResponse.responseIdentifier } : {}
125976
+ });
125977
+ }
125978
+ validateAssessmentResponses(assessment, { [input.itemIdentifier]: itemResponses });
125979
+ return responses;
125980
+ }
125722
125981
  function validateAssessmentResponseUpdate(assessment, update2) {
125723
125982
  const message = assessmentResponseUpdateValidationMessage(assessment, update2);
125724
125983
  if (message) {
@@ -125805,7 +126064,7 @@ function buildAssessmentResultSubmission(input) {
125805
126064
  }
125806
126065
  };
125807
126066
  }
125808
- function buildAssessmentResultXpFinalization(input) {
126067
+ function buildAssessmentResultAwardFinalization(input) {
125809
126068
  const completion = input.metadata.completion;
125810
126069
  if (!completion || completion.totalQuestions === undefined) {
125811
126070
  throw new Error("A scored assessment must retain its completion facts before finalization");
@@ -125813,8 +126072,10 @@ function buildAssessmentResultXpFinalization(input) {
125813
126072
  const metadata2 = {
125814
126073
  ...input.metadata,
125815
126074
  finalizedAt: input.timestamp,
125816
- updatedAt: input.timestamp
126075
+ updatedAt: input.timestamp,
126076
+ award: input.award
125817
126077
  };
126078
+ const masteryRequested = input.award.masteredUnits !== undefined || input.award.masteredUnitsAbsolute !== undefined;
125818
126079
  return {
125819
126080
  metadata: metadata2,
125820
126081
  resultUpdate: {
@@ -125826,7 +126087,8 @@ function buildAssessmentResultXpFinalization(input) {
125826
126087
  ...ASSESSMENT_ATTEMPT_COMPLETED,
125827
126088
  metadata: {
125828
126089
  ...mergeAssessmentRuntimeMetadata(input.result.metadata, metadata2),
125829
- xp: input.xpAwarded,
126090
+ xp: input.award.xpAwarded,
126091
+ ...masteryRequested ? { masteredUnits: input.award.masteredUnitsApplied } : {},
125830
126092
  totalQuestions: completion.totalQuestions,
125831
126093
  correctQuestions: completion.correctQuestions,
125832
126094
  appName: input.appName
@@ -125839,12 +126101,217 @@ var init_timeback_assessment_runtime_util = __esm(() => {
125839
126101
  init_src();
125840
126102
  init_assessment_runtime2();
125841
126103
  init_qti();
126104
+ init_types2();
125842
126105
  init_utils6();
125843
126106
  init_timeback4();
125844
126107
  init_uuid();
125845
126108
  init_errors2();
125846
126109
  PLAYABLE_SHAPE_SET = new Set(PLAYABLE_ASSESSMENT_SHAPES);
125847
126110
  });
126111
+ function reviewBankTestItemIdentifiers(test) {
126112
+ return qtiTestParts(test).flatMap((part) => part["qti-assessment-section"].flatMap((section) => (section["qti-assessment-item-ref"] ?? []).map((reference) => reference.identifier)));
126113
+ }
126114
+ function canonicalSyncContributors(contributors) {
126115
+ return contributors.map((contributor) => {
126116
+ const canonical = canonicalContributorIdentity(contributor);
126117
+ return {
126118
+ ...contributor,
126119
+ ...canonical
126120
+ };
126121
+ }).toSorted((left, right) => compareCodeUnits(left.standardKey, right.standardKey) || compareCodeUnits(left.qtiTestIdentifier, right.qtiTestIdentifier) || compareCodeUnits(left.assessmentKey, right.assessmentKey));
126122
+ }
126123
+ function canonicalContributorIdentity(contributor) {
126124
+ const standard = canonicalReviewStandardRef(contributor.standard);
126125
+ if (!standard) {
126126
+ throw new ValidationError(`Mastery assessment ${contributor.qtiTestIdentifier} has an invalid standard`);
126127
+ }
126128
+ return {
126129
+ qtiTestIdentifier: contributor.qtiTestIdentifier,
126130
+ standard,
126131
+ standardKey: assessmentStandardRefKey2(standard)
126132
+ };
126133
+ }
126134
+ async function reviewBankContributorFingerprint(contributors) {
126135
+ const uniqueTuples = new Map;
126136
+ for (const contributor of contributors) {
126137
+ const { qtiTestIdentifier, standardKey } = canonicalContributorIdentity(contributor);
126138
+ const tuple3 = { qtiTestIdentifier, standardKey };
126139
+ uniqueTuples.set(canonicalJson2(tuple3), tuple3);
126140
+ }
126141
+ const digest = await sha256Hex(canonicalJson2([...uniqueTuples.values()].toSorted((left, right) => compareCodeUnits(left.standardKey, right.standardKey) || compareCodeUnits(left.qtiTestIdentifier, right.qtiTestIdentifier))));
126142
+ return `review-bank-contributors-v1:${digest}`;
126143
+ }
126144
+ function conflictWarning(input) {
126145
+ const retained = `${input.retainedStandard.framework} · ${input.retainedStandard.identifier}`;
126146
+ const skipped = `${input.skippedStandard.framework} · ${input.skippedStandard.identifier}`;
126147
+ return {
126148
+ code: "QUESTION_STANDARD_CONFLICT",
126149
+ ...input,
126150
+ message: `Question ${input.itemIdentifier} is referenced by multiple mastery standards; retained ${retained} and skipped ${skipped} from ${input.skippedQtiTestIdentifier}.`
126151
+ };
126152
+ }
126153
+ async function prepareReviewBankSynchronization(input) {
126154
+ const contributors = canonicalSyncContributors(input.contributors);
126155
+ const selectedByItem = new Map;
126156
+ const warningKeys = new Set;
126157
+ const warnings = [];
126158
+ for (const contributor of contributors) {
126159
+ const itemIdentifiers2 = [
126160
+ ...new Set(reviewBankTestItemIdentifiers(contributor.test))
126161
+ ].toSorted();
126162
+ for (const itemIdentifier of itemIdentifiers2) {
126163
+ const selected = selectedByItem.get(itemIdentifier);
126164
+ if (!selected) {
126165
+ selectedByItem.set(itemIdentifier, {
126166
+ standard: contributor.standard,
126167
+ standardKey: contributor.standardKey
126168
+ });
126169
+ } else if (selected.standardKey !== contributor.standardKey) {
126170
+ const warningKey = `${itemIdentifier}\x00${contributor.standardKey}`;
126171
+ if (!warningKeys.has(warningKey)) {
126172
+ warningKeys.add(warningKey);
126173
+ warnings.push(conflictWarning({
126174
+ itemIdentifier,
126175
+ retainedStandard: selected.standard,
126176
+ skippedStandard: contributor.standard,
126177
+ skippedQtiTestIdentifier: contributor.qtiTestIdentifier
126178
+ }));
126179
+ }
126180
+ }
126181
+ }
126182
+ }
126183
+ const itemIdentifiers = [...selectedByItem.keys()].toSorted();
126184
+ const sourceContentRevision = input.contributorFingerprint ?? await reviewBankContributorFingerprint(contributors);
126185
+ const bank = await buildReviewBankIndexFromReferences({
126186
+ bankIdentifier: input.bankIdentifier,
126187
+ sourceContentRevision,
126188
+ items: itemIdentifiers.map((itemIdentifier) => ({
126189
+ itemIdentifier,
126190
+ standards: [selectedByItem.get(itemIdentifier).standard]
126191
+ }))
126192
+ });
126193
+ const manifest = await buildReviewBankManifest(bank);
126194
+ return {
126195
+ bankIdentifier: input.bankIdentifier,
126196
+ contributorFingerprint: sourceContentRevision,
126197
+ itemIdentifiers,
126198
+ manifest,
126199
+ warnings,
126200
+ summary: {
126201
+ itemCount: itemIdentifiers.length,
126202
+ standardCount: Object.keys(manifest.itemsByStandard).length,
126203
+ contributorCount: contributors.length,
126204
+ sourceFingerprint: manifest.sourceFingerprint
126205
+ }
126206
+ };
126207
+ }
126208
+ function reviewBankSinkMetadata(integrationId, plan) {
126209
+ return {
126210
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
126211
+ integrationId,
126212
+ [PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY]: {
126213
+ version: PLAYCADEMY_REVIEW_BANK_SINK_VERSION,
126214
+ integrationId,
126215
+ contributorFingerprint: plan.contributorFingerprint,
126216
+ warnings: plan.warnings
126217
+ },
126218
+ [PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: plan.manifest
126219
+ };
126220
+ }
126221
+ function reviewBankSinkMarker(test) {
126222
+ const parsed = ReviewBankSinkMarkerSchema.safeParse(test.metadata?.[PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY]);
126223
+ return parsed.success ? parsed.data : null;
126224
+ }
126225
+ function reviewBankSinkState(test, integrationId) {
126226
+ assertReviewBankSinkOwnership(test, integrationId);
126227
+ const marker = reviewBankSinkMarker(test);
126228
+ return {
126229
+ contributorFingerprint: marker?.contributorFingerprint ?? null,
126230
+ warnings: marker?.warnings ?? []
126231
+ };
126232
+ }
126233
+ async function unchangedReviewBankSynchronizationResult(input) {
126234
+ const sinkState = reviewBankSinkState(input.sink, input.integrationId);
126235
+ if (sinkState.contributorFingerprint !== input.contributorFingerprint) {
126236
+ return null;
126237
+ }
126238
+ const membershipItemIdentifiers = reviewBankTestItemIdentifiers(input.sink);
126239
+ const bank = await reviewBankIndexFromManifest(input.sink.metadata, {
126240
+ bankIdentifier: input.sink.identifier,
126241
+ membershipItemIdentifiers
126242
+ });
126243
+ const manifest = input.sink.metadata?.[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY];
126244
+ if (!bank || typeof manifest?.sourceFingerprint !== "string") {
126245
+ return null;
126246
+ }
126247
+ const standardCount = new Set(bank.items.flatMap((item) => item.standards.map(assessmentStandardRefKey2))).size;
126248
+ return {
126249
+ qtiTestIdentifier: input.sink.identifier,
126250
+ itemCount: bank.items.length,
126251
+ standardCount,
126252
+ contributorCount: input.contributorCount,
126253
+ sourceFingerprint: manifest.sourceFingerprint,
126254
+ addedItemCount: 0,
126255
+ removedItemCount: 0,
126256
+ unchanged: true,
126257
+ warnings: sinkState.warnings
126258
+ };
126259
+ }
126260
+ function reviewBankSynchronizationDiff(input) {
126261
+ let previousMembershipReadable = true;
126262
+ let previousItemIdentifiers = [];
126263
+ if (input.existingSink) {
126264
+ try {
126265
+ previousItemIdentifiers = reviewBankTestItemIdentifiers(input.existingSink);
126266
+ } catch {
126267
+ previousMembershipReadable = false;
126268
+ }
126269
+ }
126270
+ const previousItems = new Set(previousItemIdentifiers);
126271
+ const desiredItems = new Set(input.plan.itemIdentifiers);
126272
+ const addedItemCount = input.plan.itemIdentifiers.filter((identifier) => !previousItems.has(identifier)).length;
126273
+ const removedItemCount = [...previousItems].filter((identifier) => !desiredItems.has(identifier)).length;
126274
+ const currentManifest = input.existingSink?.metadata?.[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY];
126275
+ const currentContributorFingerprint = input.existingSink ? reviewBankSinkMarker(input.existingSink)?.contributorFingerprint ?? null : null;
126276
+ const canonicalMembership = previousItemIdentifiers.length === input.plan.itemIdentifiers.length && previousItemIdentifiers.every((identifier, index2) => identifier === input.plan.itemIdentifiers[index2]);
126277
+ const unchanged = input.existingSink !== null && previousMembershipReadable && canonicalMembership && canonicalJson2(currentManifest) === canonicalJson2(input.plan.manifest) && currentContributorFingerprint === input.plan.contributorFingerprint;
126278
+ return { addedItemCount, removedItemCount, unchanged };
126279
+ }
126280
+ function assertReviewBankSinkOwnership(test, integrationId) {
126281
+ const marker = reviewBankSinkMarker(test);
126282
+ if (marker?.integrationId !== integrationId || test.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM || test.identifier !== reviewBankSinkIdentifier(integrationId)) {
126283
+ throw new ValidationError(`QTI assessment ${test.identifier} is not the review-bank sink for this integration`);
126284
+ }
126285
+ }
126286
+ function buildReviewBankSinkInput(input) {
126287
+ return {
126288
+ ...input.includeIdentifier ? { identifier: input.identifier } : {},
126289
+ title: input.title,
126290
+ metadata: input.metadata,
126291
+ "qti-test-part": [
126292
+ {
126293
+ identifier: `${input.identifier}-part1`,
126294
+ navigationMode: "linear",
126295
+ submissionMode: "individual",
126296
+ "qti-assessment-section": [
126297
+ {
126298
+ identifier: `${input.identifier}-section1`,
126299
+ title: "Questions",
126300
+ visible: true,
126301
+ required: true,
126302
+ fixed: false,
126303
+ sequence: 1,
126304
+ "qti-assessment-item-ref": input.itemIdentifiers.map((itemIdentifier, index2) => ({
126305
+ identifier: itemIdentifier,
126306
+ href: input.itemHref(itemIdentifier),
126307
+ sequence: index2 + 1
126308
+ }))
126309
+ }
126310
+ ]
126311
+ }
126312
+ ]
126313
+ };
126314
+ }
125848
126315
  function reviewMappingIssueSummary(label, identifiers) {
125849
126316
  if (identifiers.length === 0) {
125850
126317
  return null;
@@ -125893,11 +126360,50 @@ async function prepareReviewMappingUpdate(test, questions) {
125893
126360
  }
125894
126361
  };
125895
126362
  }
126363
+ var PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY = "playcademyReviewBankSink";
126364
+ var PLAYCADEMY_REVIEW_BANK_SINK_VERSION = 1;
126365
+ var StoredSinkWarningSchema;
126366
+ var ReviewBankSinkMarkerSchema;
125896
126367
  var init_timeback_review_mapping_util = __esm(() => {
126368
+ init_esm();
125897
126369
  init_assessment_runtime2();
126370
+ init_qti();
126371
+ init_timeback3();
125898
126372
  init_errors2();
125899
126373
  init_timeback_assessment_runtime_util();
125900
126374
  init_timeback_qti_authoring_util();
126375
+ StoredSinkWarningSchema = exports_external.object({
126376
+ code: exports_external.literal("QUESTION_STANDARD_CONFLICT"),
126377
+ itemIdentifier: exports_external.string(),
126378
+ retainedStandard: exports_external.object({ framework: exports_external.string(), identifier: exports_external.string() }),
126379
+ skippedStandard: exports_external.object({ framework: exports_external.string(), identifier: exports_external.string() }),
126380
+ skippedQtiTestIdentifier: exports_external.string()
126381
+ });
126382
+ ReviewBankSinkMarkerSchema = exports_external.object({
126383
+ version: exports_external.literal(PLAYCADEMY_REVIEW_BANK_SINK_VERSION),
126384
+ integrationId: exports_external.string(),
126385
+ contributorFingerprint: exports_external.string().nullable().catch(null),
126386
+ warnings: exports_external.array(StoredSinkWarningSchema).nullable().catch(null)
126387
+ }).transform((marker) => {
126388
+ if (!marker.contributorFingerprint || !marker.warnings) {
126389
+ return { ...marker, contributorFingerprint: null, warnings: [] };
126390
+ }
126391
+ const warnings = [];
126392
+ for (const warning of marker.warnings) {
126393
+ const retained = canonicalReviewStandardRef(warning.retainedStandard);
126394
+ const skipped = canonicalReviewStandardRef(warning.skippedStandard);
126395
+ if (!retained || !skipped) {
126396
+ return { ...marker, contributorFingerprint: null, warnings: [] };
126397
+ }
126398
+ warnings.push(conflictWarning({
126399
+ itemIdentifier: warning.itemIdentifier,
126400
+ retainedStandard: retained,
126401
+ skippedStandard: skipped,
126402
+ skippedQtiTestIdentifier: warning.skippedQtiTestIdentifier
126403
+ }));
126404
+ }
126405
+ return { ...marker, warnings };
126406
+ });
125901
126407
  });
125902
126408
  function assessmentKeyFromManagedQtiIdentifier(identifier) {
125903
126409
  const assessmentKey = /^playcademy-test\.(.+)\.[a-f0-9]{64}$/.exec(identifier)?.[1];
@@ -126201,11 +126707,6 @@ function assertAssessmentHasQuestions(questions) {
126201
126707
  throw new ValidationError("An assessment must contain at least one question to publish");
126202
126708
  }
126203
126709
  }
126204
- function assertReviewAssessmentHasStandards(standardCounts) {
126205
- if (standardCounts.some((count) => count !== 1)) {
126206
- throw new ValidationError("Every question in a review assessment must have exactly one standards alignment");
126207
- }
126208
- }
126209
126710
  function planAssessmentRemoval(status) {
126210
126711
  if (status === "draft") {
126211
126712
  return { kind: "delete", action: "discarded", operation: "discard_draft" };
@@ -126223,9 +126724,6 @@ function buildAssessmentAssociationUpdates(row, input) {
126223
126724
  updates.standardFramework = null;
126224
126725
  updates.standardIdentifier = null;
126225
126726
  }
126226
- if (row.status === "live" && input.purpose !== row.purpose) {
126227
- updates.sortOrder = null;
126228
- }
126229
126727
  }
126230
126728
  if (input.standard !== undefined) {
126231
126729
  updates.standardFramework = input.standard.framework;
@@ -126233,9 +126731,6 @@ function buildAssessmentAssociationUpdates(row, input) {
126233
126731
  }
126234
126732
  if (input.status !== undefined) {
126235
126733
  updates.status = input.status;
126236
- if (input.status === "archived") {
126237
- updates.sortOrder = null;
126238
- }
126239
126734
  }
126240
126735
  return updates;
126241
126736
  }
@@ -126244,28 +126739,15 @@ function assessmentStandardForRow(row) {
126244
126739
  }
126245
126740
  function validateUniqueAssessmentIdentifiers(testIdentifiers) {
126246
126741
  if (new Set(testIdentifiers).size !== testIdentifiers.length) {
126247
- throw new ValidationError("Assessment order must contain unique identifiers");
126248
- }
126249
- }
126250
- function assertAssessmentOrderUpdateSucceeded(updatedRow) {
126251
- if (!updatedRow) {
126252
- throw new ValidationError("Assessment order changed while it was being saved. Refresh and try again.");
126253
- }
126254
- return updatedRow;
126255
- }
126256
- function lockOrderAssessmentRows(rows) {
126257
- return rows.toSorted((left, right) => left.id.localeCompare(right.id));
126258
- }
126259
- function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
126260
- const rowsByIdentifier = new Map(liveRows.map((row) => [row.qtiTestIdentifier, row]));
126261
- if (liveRows.length !== testIdentifiers.length || testIdentifiers.some((identifier) => !rowsByIdentifier.has(identifier))) {
126262
- throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
126742
+ throw new ValidationError("Assessment manifests must contain unique QTI test identifiers");
126263
126743
  }
126264
- return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
126265
126744
  }
126266
126745
  var init_timeback_assessment_rules_util = __esm(() => {
126267
126746
  init_errors2();
126268
126747
  });
126748
+ function qtiItemHref(client2, itemIdentifier) {
126749
+ return `${client2.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
126750
+ }
126269
126751
  async function hydrateQtiTestQuestions(client2, references) {
126270
126752
  const questions = await runWithConcurrency(references.questions, QTI_HYDRATION_CONCURRENCY, async (reference) => ({
126271
126753
  ...reference,
@@ -126288,6 +126770,28 @@ async function hydrateQtiTestQuestionSelection(client2, references, itemIdentifi
126288
126770
  questions: selectedReferences
126289
126771
  });
126290
126772
  }
126773
+ async function hydrateQtiItemSelection(client2, test, itemIdentifiers) {
126774
+ const testPart = qtiTestParts(test)[0];
126775
+ const section = testPart?.["qti-assessment-section"][0];
126776
+ if (!testPart || !section) {
126777
+ throw new Error(`QTI assessment ${test.identifier} has no section to reference items from`);
126778
+ }
126779
+ const questions = await runWithConcurrency(itemIdentifiers, QTI_HYDRATION_CONCURRENCY, async (itemIdentifier) => ({
126780
+ reference: {
126781
+ identifier: itemIdentifier,
126782
+ href: qtiItemHref(client2, itemIdentifier),
126783
+ testPart: testPart.identifier,
126784
+ section: section.identifier
126785
+ },
126786
+ question: await client2.qtiApi.assessmentItems.get(itemIdentifier)
126787
+ }));
126788
+ return {
126789
+ assessmentTest: test.identifier,
126790
+ title: test.title,
126791
+ totalQuestions: questions.length,
126792
+ questions
126793
+ };
126794
+ }
126291
126795
  async function loadQtiTestReferences(client2, identifier) {
126292
126796
  const [test, references] = await Promise.all([
126293
126797
  client2.qtiApi.assessmentTests.get(identifier),
@@ -126303,7 +126807,9 @@ async function loadHydratedQtiTest(client2, identifier) {
126303
126807
  };
126304
126808
  }
126305
126809
  var QTI_HYDRATION_CONCURRENCY = 8;
126306
- var init_timeback_qti_hydration_util = () => {};
126810
+ var init_timeback_qti_hydration_util = __esm(() => {
126811
+ init_timeback_qti_authoring_util();
126812
+ });
126307
126813
  var TimebackAssessmentRuntimeService;
126308
126814
  var init_timeback_assessment_runtime_service = __esm(async () => {
126309
126815
  init_drizzle_orm();
@@ -126331,8 +126837,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
126331
126837
  static EXPORT_CONCURRENCY = 4;
126332
126838
  static ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS = 3;
126333
126839
  static ASSESSMENT_VERSION_CONFIRMATION_DELAY_MS = 25;
126334
- static PROJECTION_BARRIER_MAX_ATTEMPTS = 5;
126335
- static PROJECTION_BARRIER_RETRY_DELAY_MS = 25;
126336
126840
  static SCORING_CONCURRENCY = 4;
126337
126841
  static STALE_AWARD_AGE_MS = 900000;
126338
126842
  deps;
@@ -126875,15 +127379,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
126875
127379
  this.assertInProgress(attempt.result);
126876
127380
  if (!preview || preview.responseVersion !== attempt.metadata.responseVersion || preview.submissionId !== input.submissionId || preview.itemIdentifier !== input.itemIdentifier) {
126877
127381
  if (preparationAttempts >= TimebackAssessmentRuntimeService2.ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS) {
126878
- if (input.expectedResponseVersion === attempt.metadata.responseVersion) {
126879
- throw new ServiceUnavailableError("The assessment response state changed repeatedly while this item was being prepared. Try again shortly.", {
126880
- retryable: true,
126881
- reason: "PREPARATION_CONTENTION",
126882
- expectedResponseVersion: input.expectedResponseVersion,
126883
- responseVersion: attempt.metadata.responseVersion
126884
- });
126885
- }
126886
- throw AssessmentRuntimeError.from(assessmentResponseVersionConflict(input.expectedResponseVersion, attempt.metadata.responseVersion));
127382
+ throw assessmentPreparationContentionError(input.expectedResponseVersion, attempt.metadata.responseVersion, "assessment");
126887
127383
  }
126888
127384
  return { action: "prepare" };
126889
127385
  }
@@ -127006,15 +127502,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127006
127502
  this.assertInProgress(attempt.result);
127007
127503
  if (!preview || preview.responseVersion !== metadata2.responseVersion || preview.submissionId !== input.submissionId || preview.routingNodeKey !== input.routingNodeKey || preview.itemIdentifier !== input.itemIdentifier) {
127008
127504
  if (preparationAttempts >= TimebackAssessmentRuntimeService2.ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS) {
127009
- if (input.expectedResponseVersion === metadata2.responseVersion) {
127010
- throw new ServiceUnavailableError("The diagnostic response state changed repeatedly while this item was being prepared. Try again shortly.", {
127011
- retryable: true,
127012
- reason: "PREPARATION_CONTENTION",
127013
- expectedResponseVersion: input.expectedResponseVersion,
127014
- responseVersion: metadata2.responseVersion
127015
- });
127016
- }
127017
- throw AssessmentRuntimeError.from(assessmentResponseVersionConflict(input.expectedResponseVersion, metadata2.responseVersion));
127505
+ throw assessmentPreparationContentionError(input.expectedResponseVersion, metadata2.responseVersion, "diagnostic");
127018
127506
  }
127019
127507
  return { action: "prepare" };
127020
127508
  }
@@ -127029,7 +127517,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127029
127517
  itemIdentifier: input.itemIdentifier
127030
127518
  }));
127031
127519
  }
127032
- const responses = this.prepareDiagnosticResponses(preview.assessment, metadata2.responses, input);
127520
+ const responses = prepareDiagnosticAssessmentResponses(preview.assessment, metadata2.responses, input);
127033
127521
  const scoring = preview.scoring;
127034
127522
  if (!scoring || typeof scoring.isCorrect !== "boolean") {
127035
127523
  throw AssessmentRuntimeError.from(assessmentFlowViolation(`Diagnostic item ${input.itemIdentifier} did not produce determinate binary grading.`, { itemIdentifier: input.itemIdentifier }));
@@ -127102,22 +127590,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127102
127590
  answered: submission.answered
127103
127591
  };
127104
127592
  }
127105
- prepareDiagnosticResponses(assessment, current, input) {
127106
- const update2 = { [input.itemIdentifier]: input.responses };
127107
- validateAssessmentResponseUpdate(assessment, update2);
127108
- const responses = applyAssessmentResponseUpdate(current, update2);
127109
- const item = assessment.items.find((candidate) => candidate.identifier === input.itemIdentifier);
127110
- const itemResponses = responses[input.itemIdentifier];
127111
- const missingResponse = item?.interactions.find((interaction) => itemResponses?.[interaction.responseIdentifier] === undefined);
127112
- if (!item || !itemResponses || missingResponse) {
127113
- throw new AssessmentRuntimeError("INVALID_RESPONSE", `Diagnostic item ${input.itemIdentifier} requires a response for every interaction.`, {
127114
- itemIdentifier: input.itemIdentifier,
127115
- ...missingResponse ? { responseIdentifier: missingResponse.responseIdentifier } : {}
127116
- });
127117
- }
127118
- validateAssessmentResponses(assessment, { [input.itemIdentifier]: itemResponses });
127119
- return responses;
127120
- }
127121
127593
  async prepareDiagnosticItemSubmission(params, input) {
127122
127594
  try {
127123
127595
  return await this.scoreDiagnosticItemSubmission(params, input);
@@ -127151,7 +127623,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127151
127623
  const expected = loaded.state.next;
127152
127624
  let scoring = null;
127153
127625
  if (loaded.state.status === "in-progress" && expected?.nodeKey === input.routingNodeKey && expected.itemIdentifier === input.itemIdentifier && attempt.metadata.responseVersion === input.expectedResponseVersion) {
127154
- const responses = this.prepareDiagnosticResponses(assessment, attempt.metadata.responses, input);
127626
+ const responses = prepareDiagnosticAssessmentResponses(assessment, attempt.metadata.responses, input);
127155
127627
  scoring = await this.scoreItem(assessment, responses, input.itemIdentifier);
127156
127628
  }
127157
127629
  return {
@@ -127422,11 +127894,14 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127422
127894
  inProgress: attempt.result.inProgress ?? "",
127423
127895
  scoreStatus: attempt.result.scoreStatus,
127424
127896
  submissionId: attempt.metadata.submissionId,
127425
- xpAwarded: this.xpFromResult(attempt.result) ?? undefined
127897
+ award: assessmentAwardFromResult(attempt.result, attempt.metadata) ?? undefined
127426
127898
  }, input);
127427
127899
  if (disposition === "replay") {
127428
127900
  return {
127429
- response: this.completedResult(attempt.result, attempt.metadata),
127901
+ response: buildCompletedAssessmentSubmitResult({
127902
+ result: attempt.result,
127903
+ metadata: attempt.metadata
127904
+ }),
127430
127905
  completion: this.replayCompletion(attempt, input.submissionId, params, game2)
127431
127906
  };
127432
127907
  }
@@ -127434,20 +127909,27 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127434
127909
  throw AssessmentRuntimeError.from(assessmentAwardConflict({
127435
127910
  attemptId: attempt.result.sourcedId,
127436
127911
  submissionId: input.submissionId,
127437
- xpAwarded: input.xpAwarded
127912
+ xpAwarded: input.xpAwarded,
127913
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
127914
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {}
127438
127915
  }));
127439
127916
  }
127440
127917
  if (disposition === "reject") {
127441
127918
  throw AssessmentRuntimeError.from(assessmentAlreadySubmitted(attempt.result.sourcedId));
127442
127919
  }
127443
- const submitted = this.submittedResult(attempt.result, attempt.metadata);
127444
- const finalization = buildAssessmentResultXpFinalization({
127920
+ const mastery = await this.evaluateMasteryAward(attempt, params.studentId, input);
127921
+ const awardRecord = buildAssessmentAwardRecord(input, mastery);
127922
+ const finalization = buildAssessmentResultAwardFinalization({
127445
127923
  result: attempt.result,
127446
127924
  metadata: attempt.metadata,
127447
- xpAwarded: input.xpAwarded,
127925
+ award: awardRecord,
127448
127926
  appName: game2.appName,
127449
127927
  timestamp: new Date().toISOString()
127450
127928
  });
127929
+ const response = buildCompletedAssessmentSubmitResult({
127930
+ result: attempt.result,
127931
+ metadata: finalization.metadata
127932
+ });
127451
127933
  const finalized = await this.requireClient().api.oneroster.assessmentResults.upsert(attempt.result.sourcedId, finalization.resultUpdate);
127452
127934
  const finalizedForEmission = {
127453
127935
  ...finalized,
@@ -127466,25 +127948,78 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127466
127948
  "app.assessment.submission_id": input.submissionId,
127467
127949
  "app.assessment.purpose": finalization.metadata.purpose,
127468
127950
  "app.assessment.award_age_ms": this.awardAgeMs(attempt.result.scoreDate),
127469
- "app.assessment.xp_awarded": input.xpAwarded
127951
+ "app.assessment.xp_awarded": input.xpAwarded,
127952
+ "app.assessment.mastered_units_applied": mastery.masteredUnitsApplied,
127953
+ "app.assessment.mastery_achieved": mastery.masteryAchieved,
127954
+ "app.assessment.mastery_revoked": mastery.masteryRevoked
127470
127955
  });
127956
+ await this.settleMasteryAward(finalizedAttempt, mastery, input.submissionId, game2);
127471
127957
  return {
127472
- response: {
127473
- ...submitted,
127474
- status: "completed",
127475
- xpAwarded: input.xpAwarded
127476
- },
127958
+ response,
127477
127959
  completion: this.replayCompletion(finalizedAttempt, input.submissionId, params, game2)
127478
127960
  };
127479
127961
  });
127480
127962
  if (award.completion) {
127481
127963
  await this.emitCompletionBestEffort({
127482
127964
  ...award.completion,
127483
- xpAwarded: input.xpAwarded
127965
+ award: award.response
127484
127966
  });
127485
127967
  }
127486
127968
  return award.response;
127487
127969
  }
127970
+ async evaluateMasteryAward(attempt, studentIdentifier, input) {
127971
+ if (!hasMasteryAwardRequest(input)) {
127972
+ return NO_MASTERY_AWARD;
127973
+ }
127974
+ const student = await this.requireClient().roster.resolveStudent(studentIdentifier);
127975
+ return this.requireClient().activity.evaluateMasteryAward({
127976
+ studentId: student.id,
127977
+ courseId: attempt.integration.courseId,
127978
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
127979
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {}
127980
+ });
127981
+ }
127982
+ async settleMasteryAward(attempt, mastery, submissionId, game2) {
127983
+ if (!mastery.masteryAchieved && !mastery.masteryRevoked) {
127984
+ return;
127985
+ }
127986
+ const integration = attempt.integration;
127987
+ const course = this.trackableCourse(integration, "assessment.mastery_settlement_invalid_course_metadata", { "app.assessment.attempt_id": attempt.result.sourcedId });
127988
+ if (!course) {
127989
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
127990
+ return;
127991
+ }
127992
+ try {
127993
+ const student = await this.requireClient().roster.resolveStudent(attempt.result.student.sourcedId);
127994
+ const completionHistoryEventId = `urn:uuid:${await deterministicUUID([
127995
+ "playcademy-assessment-course-completed-event-v1",
127996
+ attempt.result.sourcedId,
127997
+ submissionId
127998
+ ].join("\x00"))}`;
127999
+ const settlement = await this.requireClient().activity.settleMasteryAward(mastery, {
128000
+ studentId: student.id,
128001
+ studentEmail: student.email,
128002
+ courseId: integration.courseId,
128003
+ gameId: integration.gameId,
128004
+ activityId: attempt.metadata.activityId,
128005
+ courseName: attempt.metadata.completion?.courseName ?? "Game Course",
128006
+ subject: course.subject,
128007
+ appName: game2.appName,
128008
+ sensorUrl: game2.sensorUrl,
128009
+ completionHistoryEventId
128010
+ });
128011
+ if (mastery.masteryAchieved && !settlement.completionEntryWritten) {
128012
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
128013
+ }
128014
+ } catch (error88) {
128015
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
128016
+ addEvent("assessment.mastery_settlement_failed", {
128017
+ "app.assessment.attempt_id": attempt.result.sourcedId,
128018
+ "exception.type": errorType(error88),
128019
+ "app.error.message": errorMessage2(error88)
128020
+ });
128021
+ }
128022
+ }
127488
128023
  async finalizeDiagnosticSubmission(input) {
127489
128024
  const { attempt } = input;
127490
128025
  const routing = await this.loadAttemptDiagnosticManifest(attempt.metadata, input.db);
@@ -127710,8 +128245,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127710
128245
  id: row.id,
127711
128246
  assessmentKey: row.assessmentKey,
127712
128247
  qtiTestIdentifier: row.qtiTestIdentifier,
127713
- sortOrder: row.sortOrder,
127714
- updatedAt: row.updatedAt.toISOString(),
127715
128248
  standard: assessmentStandardForRow(row)
127716
128249
  })).filter((test) => !matchesMasteryStandard || matchesMasteryStandard(test.standard));
127717
128250
  }
@@ -127931,9 +128464,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127931
128464
  if (this.isSettled(resumed.result)) {
127932
128465
  return this.snapshotForResult(resumed.result, resumed.metadata, context2.integration);
127933
128466
  }
127934
- const catalog = await this.loadReviewBankCatalog(resumed.metadata.selectedTest.identifier, resumed.metadata.selectedTest.contentRevision);
127935
- const currentSource = await this.hydrateReviewBankSelection(catalog, resumed.metadata.review.selections.map((selection) => selection.itemIdentifier));
127936
- const source = this.pinnedReviewBankSource(currentSource, resumed.metadata);
128467
+ const source = await this.loadPinnedReviewBankSource(resumed.metadata);
127937
128468
  const assessment = projectReviewAssessment(source.assessment, source.bank, resumed.metadata.review.selections);
127938
128469
  await this.ensureReviewChildResults({
127939
128470
  result: resumed.result,
@@ -127977,7 +128508,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127977
128508
  membershipItemIdentifiers: loaded.references.questions.map((question) => question.reference.identifier)
127978
128509
  });
127979
128510
  } catch (error88) {
127980
- throw new ServiceUnavailableError(`Review bank ${identifier} has stale or invalid routing metadata. Use Update Mapping in assessment authoring.`, { reason: errorMessage2(error88) });
128511
+ throw new ServiceUnavailableError(`Review bank ${identifier} has stale or invalid routing metadata. Use Sync Review Bank in assessment authoring.`, { reason: errorMessage2(error88) });
127981
128512
  }
127982
128513
  if (!bank) {
127983
128514
  throw new ServiceUnavailableError(`Review bank ${identifier} needs its authoring mapping updated before it can serve review questions.`);
@@ -127993,28 +128524,60 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127993
128524
  this.reviewBankCache.set(`${identifier}\x00${bank.sourceContentRevision}`, catalog);
127994
128525
  return catalog;
127995
128526
  }
127996
- async hydrateReviewBankSelection(catalog, itemIdentifiers) {
127997
- const cacheKey2 = [
127998
- catalog.test.identifier,
127999
- catalog.bank.sourceContentRevision,
128000
- ...itemIdentifiers
128001
- ].join("\x00");
128527
+ async cachedReviewSelection(input) {
128528
+ const cacheKey2 = [input.identifier, input.contentRevision, ...input.itemIdentifiers].join("\x00");
128002
128529
  const cached3 = this.reviewSelectionCache.get(cacheKey2);
128003
128530
  if (cached3) {
128004
- return { assessment: cached3, bank: catalog.bank };
128531
+ return cached3;
128005
128532
  }
128006
- const questions = await hydrateQtiTestQuestionSelection(this.requireClient(), catalog.references, itemIdentifiers);
128007
- const assessment = await buildPlayableAssessment(catalog.test, questions);
128008
- assessment.contentRevision = catalog.bank.sourceContentRevision;
128533
+ const { test, questions } = await input.hydrate();
128534
+ const assessment = await buildPlayableAssessment(test, questions);
128535
+ assessment.contentRevision = input.contentRevision;
128009
128536
  this.reviewSelectionCache.set(cacheKey2, assessment);
128537
+ return assessment;
128538
+ }
128539
+ async hydrateReviewBankSelection(catalog, itemIdentifiers) {
128540
+ const assessment = await this.cachedReviewSelection({
128541
+ identifier: catalog.test.identifier,
128542
+ contentRevision: catalog.bank.sourceContentRevision,
128543
+ itemIdentifiers,
128544
+ hydrate: async () => ({
128545
+ test: catalog.test,
128546
+ questions: await hydrateQtiTestQuestionSelection(this.requireClient(), catalog.references, itemIdentifiers)
128547
+ })
128548
+ });
128010
128549
  return { assessment, bank: catalog.bank };
128011
128550
  }
128012
- pinnedReviewBankSource(source, metadata2) {
128551
+ async loadPinnedReviewBankSource(metadata2) {
128552
+ const itemIdentifiers = metadata2.review.selections.map((selection) => selection.itemIdentifier);
128553
+ let assessment;
128554
+ try {
128555
+ assessment = await this.cachedReviewSelection({
128556
+ identifier: metadata2.selectedTest.identifier,
128557
+ contentRevision: metadata2.selectedTest.contentRevision,
128558
+ itemIdentifiers,
128559
+ hydrate: async () => {
128560
+ const test = await this.requireClient().qtiApi.assessmentTests.get(metadata2.selectedTest.identifier);
128561
+ return {
128562
+ test,
128563
+ questions: await hydrateQtiItemSelection(this.requireClient(), test, itemIdentifiers)
128564
+ };
128565
+ }
128566
+ });
128567
+ } catch (error88) {
128568
+ if (isApiError(error88) && error88.statusCode === 404) {
128569
+ throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `A selected review item for ${metadata2.selectedTest.identifier} is no longer available.`, {
128570
+ identifier: metadata2.selectedTest.identifier,
128571
+ expectedRevision: metadata2.selectedTest.contentRevision
128572
+ });
128573
+ }
128574
+ throw error88;
128575
+ }
128013
128576
  return {
128014
- assessment: source.assessment,
128577
+ assessment,
128015
128578
  bank: {
128016
- bankIdentifier: source.assessment.identifier,
128017
- sourceContentRevision: source.assessment.contentRevision,
128579
+ bankIdentifier: assessment.identifier,
128580
+ sourceContentRevision: metadata2.selectedTest.contentRevision,
128018
128581
  bankRevision: metadata2.review.bankRevision,
128019
128582
  items: metadata2.review.selections.map((selection) => ({
128020
128583
  itemIdentifier: selection.itemIdentifier,
@@ -128038,9 +128601,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128038
128601
  if (metadata2.purpose !== "review") {
128039
128602
  return this.loadAssessment(metadata2.selectedTest.identifier, metadata2.selectedTest.contentRevision);
128040
128603
  }
128041
- const catalog = await this.loadReviewBankCatalog(metadata2.selectedTest.identifier, metadata2.selectedTest.contentRevision);
128042
- const currentSource = await this.hydrateReviewBankSelection(catalog, metadata2.review.selections.map((selection) => selection.itemIdentifier));
128043
- const source = this.pinnedReviewBankSource(currentSource, metadata2);
128604
+ const source = await this.loadPinnedReviewBankSource(metadata2);
128044
128605
  return projectReviewAssessment(source.assessment, source.bank, metadata2.review.selections);
128045
128606
  }
128046
128607
  isPlatformRoutedDiagnosticMetadata(metadata2) {
@@ -128526,7 +129087,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128526
129087
  async projectReviewItemResponse(params, projection) {
128527
129088
  await this.putReviewChildResponse(projection.result, projection.metadata, projection.itemIdentifier);
128528
129089
  try {
128529
- await this.crossAttemptLockBarrier(params.attemptId);
129090
+ await crossAssessmentAttemptLockBarrier(this.deps.assessmentRuntimeLock, this.deps.db, params.attemptId);
128530
129091
  const latest = await this.peekAttempt(params);
128531
129092
  if (this.isSettled(latest.result) && latest.metadata.purpose === "review") {
128532
129093
  await this.restoreCompletedReviewChildResponses(latest.result, latest.metadata, new Set([projection.itemIdentifier]));
@@ -128540,21 +129101,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128540
129101
  });
128541
129102
  }
128542
129103
  }
128543
- async crossAttemptLockBarrier(attemptId) {
128544
- for (let attempt = 1;attempt <= TimebackAssessmentRuntimeService2.PROJECTION_BARRIER_MAX_ATTEMPTS; attempt += 1) {
128545
- try {
128546
- await this.deps.assessmentRuntimeLock(this.deps.db, [assessmentRuntimeAttemptLockKey(attemptId)], async () => {
128547
- return;
128548
- });
128549
- return;
128550
- } catch (error88) {
128551
- if (!(error88 instanceof ServiceUnavailableError) || attempt === TimebackAssessmentRuntimeService2.PROJECTION_BARRIER_MAX_ATTEMPTS) {
128552
- throw error88;
128553
- }
128554
- await sleep(TimebackAssessmentRuntimeService2.PROJECTION_BARRIER_RETRY_DELAY_MS);
128555
- }
128556
- }
128557
- }
128558
129104
  async restoreCompletedReviewChildResponses(result, metadata2, changedItemIdentifiers) {
128559
129105
  const outcomes = metadata2.completion?.itemOutcomes;
128560
129106
  const submissionId = metadata2.submissionId;
@@ -128700,7 +129246,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128700
129246
  attemptId: result.sourcedId,
128701
129247
  responseVersion: metadata2.responseVersion,
128702
129248
  status: "completed",
128703
- result: this.completedResult(result, metadata2)
129249
+ result: buildCompletedAssessmentSubmitResult({ result, metadata: metadata2 })
128704
129250
  };
128705
129251
  }
128706
129252
  const assessment = await this.loadAttemptAssessment(metadata2);
@@ -128717,58 +129263,37 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128717
129263
  return this.snapshot(result, metadata2, assessment, integration);
128718
129264
  }
128719
129265
  diagnosticSnapshot(result, metadata2, assessment, integration, state) {
128720
- const activityData = this.activityData(metadata2, assessment, integration);
128721
- return {
129266
+ return buildDiagnosticAssessmentSnapshot({
128722
129267
  attemptId: result.sourcedId,
128723
- responseVersion: metadata2.responseVersion,
128724
- status: "in_progress",
128725
- flow: "platform-routed-item-submit",
128726
- ...activityData ? { activityData } : {},
128727
- assessment: assessmentPresentationForAttempt(assessment, result.sourcedId),
128728
- selection: {
128729
- kind: "platform-routed-diagnostic",
128730
- purpose: "diagnostic",
128731
- definitionId: metadata2.diagnostic.definitionId,
128732
- diagnosticKey: metadata2.diagnostic.diagnosticKey,
128733
- routingRevision: metadata2.diagnostic.routingRevision
128734
- },
128735
- routing: this.diagnosticRoutingSnapshot(metadata2, state),
128736
- completion: null
128737
- };
129268
+ metadata: metadata2,
129269
+ assessment,
129270
+ state,
129271
+ activityData: this.activityData(metadata2, assessment, integration)
129272
+ });
128738
129273
  }
128739
129274
  diagnosticRoutingSnapshot(metadata2, state) {
128740
129275
  return projectDiagnosticRoutingSnapshot(metadata2.diagnostic.routingRevision, state);
128741
129276
  }
128742
129277
  snapshot(result, metadata2, assessment, integration) {
128743
- const selection = this.selectionContext(metadata2);
128744
- const activityData = this.activityData(metadata2, assessment, integration);
128745
- return {
129278
+ return buildConventionalAssessmentSnapshot({
128746
129279
  attemptId: result.sourcedId,
128747
- responseVersion: metadata2.responseVersion,
128748
- status: "in_progress",
128749
- flow: assessmentFlowForPurpose(metadata2.purpose),
128750
- ...activityData ? { activityData } : {},
128751
- assessment: assessmentPresentationForAttempt(assessment, result.sourcedId),
128752
- responses: metadata2.responses,
128753
- itemSubmissions: metadata2.itemSubmissions,
128754
- score: null,
128755
- selection
128756
- };
129280
+ metadata: metadata2,
129281
+ assessment,
129282
+ activityData: this.activityData(metadata2, assessment, integration)
129283
+ });
128757
129284
  }
128758
129285
  activityData(metadata2, assessment, integration) {
128759
129286
  const course = this.trackableCourse(integration, "assessment.activity_tracking_invalid_course_metadata");
128760
- if (!course) {
128761
- return;
128762
- }
128763
- return {
129287
+ return buildAssessmentActivityData({
128764
129288
  activityId: metadata2.activityId,
128765
129289
  activityName: assessment.title,
128766
- ...course,
128767
- courseId: integration.courseId
128768
- };
129290
+ courseId: integration.courseId,
129291
+ course
129292
+ });
128769
129293
  }
128770
129294
  trackableCourse(integration, event, attributes2 = {}) {
128771
- if (!isTimebackGrade2(integration.grade) || !isTimebackSubject(integration.subject)) {
129295
+ const course = assessmentTrackableCourse(integration);
129296
+ if (!course) {
128772
129297
  addEvent(event, {
128773
129298
  ...attributes2,
128774
129299
  "app.timeback.integration_id": integration.id,
@@ -128777,27 +129302,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128777
129302
  });
128778
129303
  return null;
128779
129304
  }
128780
- return { grade: integration.grade, subject: integration.subject };
128781
- }
128782
- selectionContext(metadata2) {
128783
- if (metadata2.purpose === "review") {
128784
- return {
128785
- kind: "standards-review",
128786
- purpose: metadata2.purpose,
128787
- standards: [...metadata2.review.standards],
128788
- candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
128789
- selections: [...metadata2.review.selections],
128790
- fulfillment: metadata2.review.fulfillment
128791
- };
128792
- }
128793
- if (metadata2.purpose === "mastery") {
128794
- return {
128795
- kind: "standard-quiz",
128796
- purpose: metadata2.purpose,
128797
- standard: metadata2.mastery.standard
128798
- };
128799
- }
128800
- return { kind: "fixed-test", purpose: metadata2.purpose };
129305
+ return course;
128801
129306
  }
128802
129307
  submittedResult(result, metadata2) {
128803
129308
  const response = buildAssessmentSubmitResult({
@@ -128811,29 +129316,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128811
129316
  }
128812
129317
  return response;
128813
129318
  }
128814
- completedResult(result, metadata2) {
128815
- const xpAwarded = this.xpFromResult(result);
128816
- if (xpAwarded === null) {
128817
- throw new GoneError("This completed assessment is missing its recorded XP.", {
128818
- attemptId: result.sourcedId
128819
- });
128820
- }
128821
- const response = buildCompletedAssessmentSubmitResult({
128822
- attemptId: result.sourcedId,
128823
- metadata: metadata2,
128824
- xpAwarded
128825
- });
128826
- if (!response) {
128827
- throw new GoneError("This completed assessment has incomplete persisted results.", {
128828
- attemptId: result.sourcedId
128829
- });
128830
- }
128831
- return response;
128832
- }
128833
- xpFromResult(result) {
128834
- const xp = result.metadata?.xp;
128835
- return typeof xp === "number" && Number.isFinite(xp) && xp >= 0 ? xp : null;
128836
- }
128837
129319
  awardAgeMs(scoreDate) {
128838
129320
  const submittedAt = Date.parse(scoreDate);
128839
129321
  return Number.isFinite(submittedAt) ? Math.max(0, Date.now() - submittedAt) : -1;
@@ -128960,7 +129442,9 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128960
129442
  attemptNumber: attempt.metadata.attemptNumber,
128961
129443
  totalQuestions: input.totalQuestions,
128962
129444
  correctQuestions: input.correctQuestions,
128963
- xpAwarded: input.xpAwarded,
129445
+ xpAwarded: input.award.xpAwarded,
129446
+ ...input.award.masteredUnitsApplied !== 0 ? { masteredUnits: input.award.masteredUnitsApplied } : {},
129447
+ ...input.award.pctCompleteApp !== undefined ? { pctCompleteApp: input.award.pctCompleteApp } : {},
128964
129448
  submittedAt: attempt.result.scoreDate,
128965
129449
  eventId,
128966
129450
  ...session2 ? { resumeId: session2.resumeId } : {}
@@ -129081,6 +129565,9 @@ function assertAssessmentImportCourseMatches(manifest, integration) {
129081
129565
  throw new ValidationError(`This manifest is for ${manifest.subject} grade ${manifest.grade}, not ${integration.subject} grade ${integration.grade}.`);
129082
129566
  }
129083
129567
  }
129568
+ function assessmentImportsAffectReviewBank(targetStatus, entries) {
129569
+ return targetStatus === "live" && entries.some((entry2) => entry2.purpose === "mastery");
129570
+ }
129084
129571
  function associationMetadataMatches(row, entry2) {
129085
129572
  return row.purpose === entry2.purpose && (row.standardFramework ?? undefined) === entry2.standard?.framework && (row.standardIdentifier ?? undefined) === entry2.standard?.identifier;
129086
129573
  }
@@ -129130,18 +129617,6 @@ function attachedAssessmentImportMessage(targetStatus, editable) {
129130
129617
  }
129131
129618
  return editable ? "Attached as a draft." : "Attached as a read-only draft owned by another app.";
129132
129619
  }
129133
- function liveReviewAssessmentImportFailures(targetStatus, candidates, existingRows) {
129134
- if (targetStatus === "draft") {
129135
- return new Map;
129136
- }
129137
- const reviews = candidates.filter((candidate) => candidate.purpose === "review");
129138
- const hasExistingLiveReview = existingRows.some((row) => row.purpose === "review" && row.status === "live");
129139
- if (reviews.length <= 1 && !hasExistingLiveReview) {
129140
- return new Map;
129141
- }
129142
- const message = hasExistingLiveReview ? "Another review assessment is already live for this course." : "Only one review assessment can be imported live at a time.";
129143
- return new Map(reviews.map((review) => [review.qtiTestIdentifier, message]));
129144
- }
129145
129620
  var init_timeback_assessment_import_util = __esm(() => {
129146
129621
  init_errors2();
129147
129622
  init_timeback_qti_authoring_util();
@@ -129210,7 +129685,9 @@ class TimebackAssessmentsService {
129210
129685
  const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
129211
129686
  where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
129212
129687
  });
129213
- const assessments2 = await runWithConcurrency(rows, QTI_HYDRATION_CONCURRENCY, async (row) => {
129688
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
129689
+ const visibleRows = rows.filter((row) => row.purpose !== "review" || row.qtiTestIdentifier === sinkIdentifier);
129690
+ const assessments2 = await runWithConcurrency(visibleRows, QTI_HYDRATION_CONCURRENCY, async (row) => {
129214
129691
  try {
129215
129692
  const test = await client2.qtiApi.assessmentTests.get(row.qtiTestIdentifier);
129216
129693
  return {
@@ -129218,7 +129695,7 @@ class TimebackAssessmentsService {
129218
129695
  title: test.title,
129219
129696
  questionCount: countQtiTestItems(test),
129220
129697
  available: true,
129221
- editable: isQtiTestOwnedByGame(test, ownership.gameSlug)
129698
+ editable: row.purpose !== "review" && isQtiTestOwnedByGame(test, ownership.gameSlug)
129222
129699
  };
129223
129700
  } catch (error88) {
129224
129701
  addEvent("assessment.qti_fetch_failed", {
@@ -129238,8 +129715,11 @@ class TimebackAssessmentsService {
129238
129715
  return assessments2.toSorted((a, b) => a.title.localeCompare(b.title));
129239
129716
  }
129240
129717
  async createAssessment(integrationId, input) {
129241
- if (input.purpose === "diagnostic") {
129242
- throw new ValidationError("Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
129718
+ if (input.purpose === "diagnostic" || input.purpose === "review") {
129719
+ 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.");
129720
+ }
129721
+ if (input.assessmentKey === reviewBankSinkIdentifier(integrationId)) {
129722
+ throw new ValidationError("This assessment key is reserved for the review bank.");
129243
129723
  }
129244
129724
  const client2 = this.requireClient();
129245
129725
  const ownership = await this.requireQtiTestOwnershipContext(integrationId);
@@ -129315,24 +129795,41 @@ class TimebackAssessmentsService {
129315
129795
  index: index2,
129316
129796
  standard: this.requirePurposeStandard(assessment.purpose, assessment.standard)
129317
129797
  }));
129318
- const existingRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
129319
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
129320
- });
129321
- const existingByKey = new Map(existingRows.flatMap((row) => row.assessmentKey ? [[row.assessmentKey, row]] : []));
129322
- const existingByIdentifier = new Map(existingRows.map((row) => [row.qtiTestIdentifier, row]));
129798
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
129799
+ if (entries.some((entry2) => entry2.assessmentKey === sinkIdentifier || entry2.qtiTestIdentifier === sinkIdentifier || entry2.purpose === "review")) {
129800
+ throw new ValidationError("The review bank is system-managed and cannot be attached manually.");
129801
+ }
129323
129802
  const validated = await this.validateAssessmentImportEntries(client2, entries, manifest.targetStatus);
129324
129803
  const results = [];
129325
- const pendingInserts = this.planAssessmentImports({
129326
- validated,
129327
- existingByKey,
129328
- existingByIdentifier,
129329
- gameSlug,
129330
- results
129331
- });
129332
- const liveReviewFailures = liveReviewAssessmentImportFailures(manifest.targetStatus, pendingInserts.map(({ entry: entry2 }) => entry2), existingRows);
129333
- this.recordLiveReviewImportFailures(pendingInserts, liveReviewFailures, gameSlug, results);
129334
- const attachablePending = pendingInserts.filter(({ entry: entry2 }) => !liveReviewFailures.has(entry2.qtiTestIdentifier));
129335
- await this.insertImportedAssessments(integrationId, attachablePending, manifest.targetStatus, gameSlug, results);
129804
+ const affectsReviewBank = assessmentImportsAffectReviewBank(manifest.targetStatus, entries);
129805
+ const planAndInsert = async (db2, existingRows) => {
129806
+ const existingByKey = new Map(existingRows.flatMap((row) => row.assessmentKey ? [[row.assessmentKey, row]] : []));
129807
+ const existingByIdentifier = new Map(existingRows.map((row) => [row.qtiTestIdentifier, row]));
129808
+ const pendingInserts = this.planAssessmentImports({
129809
+ validated,
129810
+ existingByKey,
129811
+ existingByIdentifier,
129812
+ gameSlug,
129813
+ results
129814
+ });
129815
+ await this.insertImportedAssessments({
129816
+ db: db2,
129817
+ integrationId,
129818
+ pending: pendingInserts,
129819
+ targetStatus: manifest.targetStatus,
129820
+ gameSlug,
129821
+ results,
129822
+ abortOnError: affectsReviewBank
129823
+ });
129824
+ };
129825
+ if (affectsReviewBank) {
129826
+ await this.withLockedIntegrationAssessmentRows(integrationId, async (tx, lockedAssociations) => planAndInsert(tx, lockedAssociations));
129827
+ } else {
129828
+ const existingRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
129829
+ where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
129830
+ });
129831
+ await planAndInsert(this.deps.db, existingRows);
129832
+ }
129336
129833
  setAttribute("app.assessment.operation", "bulk_attach_existing");
129337
129834
  setAttribute("app.assessment.bulk_attach.target_status", manifest.targetStatus);
129338
129835
  setAttribute("app.assessment.bulk_attach.created", results.filter((result) => result.status === "created").length);
@@ -129345,11 +129842,6 @@ class TimebackAssessmentsService {
129345
129842
  assertManagedAssessmentIdentity(loaded.test, entry2.assessmentKey);
129346
129843
  assertAssessmentHasQuestions(loaded.questions.questions);
129347
129844
  assertPlayableAssessmentImportQuestions(targetStatus, loaded.questions.questions.map(({ question }) => question));
129348
- if (entry2.purpose === "review") {
129349
- assertReviewAssessmentHasStandards(loaded.questions.questions.map(({ question }) => reviewRoutingStandardRefsFromMetadata(question.metadata, {
129350
- customOnly: true
129351
- }).length));
129352
- }
129353
129845
  return { entry: entry2, test: loaded.test };
129354
129846
  } catch (error88) {
129355
129847
  return { entry: entry2, error: error88 };
@@ -129363,7 +129855,6 @@ class TimebackAssessmentsService {
129363
129855
  ...test ? { title: test.title } : {},
129364
129856
  purpose: entry2.purpose,
129365
129857
  ...entry2.standard ? { standard: entry2.standard } : {},
129366
- sourceSortOrder: entry2.sortOrder,
129367
129858
  ...test && gameSlug ? { editable: isQtiTestOwnedByGame(test, gameSlug) } : {}
129368
129859
  };
129369
129860
  }
@@ -129395,25 +129886,13 @@ class TimebackAssessmentsService {
129395
129886
  }
129396
129887
  return pendingInserts;
129397
129888
  }
129398
- recordLiveReviewImportFailures(pending, failures, gameSlug, results) {
129399
- for (const { entry: entry2, test } of pending) {
129400
- const message = failures.get(entry2.qtiTestIdentifier);
129401
- if (message) {
129402
- results[entry2.index] = {
129403
- ...this.associationImportResultBase(entry2, test, gameSlug),
129404
- status: "failed",
129405
- message
129406
- };
129407
- }
129408
- }
129409
- }
129410
- async concurrentAssessmentImportDecision(integrationId, entry2) {
129889
+ async concurrentAssessmentImportDecision(db2, integrationId, entry2) {
129411
129890
  try {
129412
129891
  const [byKey, byIdentifier] = await Promise.all([
129413
- this.deps.db.query.gameTimebackAssessmentTests.findFirst({
129892
+ db2.query.gameTimebackAssessmentTests.findFirst({
129414
129893
  where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.assessmentKey, entry2.assessmentKey))
129415
129894
  }),
129416
- this.deps.db.query.gameTimebackAssessmentTests.findFirst({
129895
+ db2.query.gameTimebackAssessmentTests.findFirst({
129417
129896
  where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, entry2.qtiTestIdentifier))
129418
129897
  })
129419
129898
  ]);
@@ -129425,39 +129904,52 @@ class TimebackAssessmentsService {
129425
129904
  return null;
129426
129905
  }
129427
129906
  }
129428
- async insertImportedAssessments(integrationId, pending, targetStatus, gameSlug, results) {
129429
- for (const { entry: entry2, test } of pending) {
129430
- const base = this.associationImportResultBase(entry2, test, gameSlug);
129907
+ async insertImportedAssessments(input) {
129908
+ for (const { entry: entry2, test } of input.pending) {
129909
+ const base = this.associationImportResultBase(entry2, test, input.gameSlug);
129431
129910
  try {
129432
- const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
129433
- integrationId,
129911
+ const [row] = await input.db.insert(gameTimebackAssessmentTests).values({
129912
+ integrationId: input.integrationId,
129434
129913
  assessmentKey: entry2.assessmentKey,
129435
129914
  qtiTestIdentifier: entry2.qtiTestIdentifier,
129436
129915
  purpose: entry2.purpose,
129437
- status: targetStatus,
129438
- sortOrder: entry2.sortOrder,
129916
+ status: input.targetStatus,
129439
129917
  standardFramework: entry2.standard?.framework,
129440
129918
  standardIdentifier: entry2.standard?.identifier
129441
- }).returning();
129919
+ }).onConflictDoNothing().returning();
129442
129920
  if (!row) {
129443
- throw new Error("Assessment association create returned no row");
129921
+ const concurrent = await this.concurrentAssessmentImportDecision(input.db, input.integrationId, entry2);
129922
+ if (concurrent?.decision.kind === "skip" || concurrent?.decision.kind === "fail") {
129923
+ input.results[entry2.index] = {
129924
+ ...base,
129925
+ status: concurrent.decision.status,
129926
+ ...concurrent.byKey ? { association: this.associationImportSummary(concurrent.byKey) } : {},
129927
+ message: concurrent.decision.message
129928
+ };
129929
+ } else {
129930
+ throw new Error("Assessment association insert conflicted");
129931
+ }
129932
+ } else {
129933
+ input.results[entry2.index] = {
129934
+ ...base,
129935
+ status: "created",
129936
+ association: this.associationImportSummary(row),
129937
+ message: attachedAssessmentImportMessage(input.targetStatus, base.editable ?? false)
129938
+ };
129444
129939
  }
129445
- results[entry2.index] = {
129446
- ...base,
129447
- status: "created",
129448
- association: this.associationImportSummary(row),
129449
- message: attachedAssessmentImportMessage(targetStatus, base.editable ?? false)
129450
- };
129451
129940
  } catch (error88) {
129452
- results[entry2.index] = {
129941
+ if (input.abortOnError) {
129942
+ throw error88;
129943
+ }
129944
+ input.results[entry2.index] = {
129453
129945
  ...base,
129454
129946
  status: "failed",
129455
129947
  message: `Association attach failed: ${errorMessage2(error88)}`
129456
129948
  };
129457
129949
  if (isUniqueViolation(error88)) {
129458
- const concurrent = await this.concurrentAssessmentImportDecision(integrationId, entry2);
129950
+ const concurrent = await this.concurrentAssessmentImportDecision(input.db, input.integrationId, entry2);
129459
129951
  if (concurrent?.decision.kind === "skip" || concurrent?.decision.kind === "fail") {
129460
- results[entry2.index] = {
129952
+ input.results[entry2.index] = {
129461
129953
  ...base,
129462
129954
  status: concurrent.decision.status,
129463
129955
  ...concurrent.byKey ? { association: this.associationImportSummary(concurrent.byKey) } : {},
@@ -129471,6 +129963,12 @@ class TimebackAssessmentsService {
129471
129963
  async updateAssessment(integrationId, qtiTestIdentifier, input) {
129472
129964
  try {
129473
129965
  return await this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
129966
+ if (row.purpose === "review" || qtiTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
129967
+ throw new ValidationError("The review bank is system-managed and cannot be edited.");
129968
+ }
129969
+ if (input.purpose === "review") {
129970
+ throw new ValidationError("Assessment purpose cannot be changed to the system-managed review bank.");
129971
+ }
129474
129972
  const nextPurpose = input.purpose ?? row.purpose;
129475
129973
  assertPurposeChangeDraft(row, nextPurpose);
129476
129974
  const requestedStandard = input.standard ? this.canonicalAssessmentStandard(input.standard) : undefined;
@@ -129489,19 +129987,10 @@ class TimebackAssessmentsService {
129489
129987
  ...associationUpdates,
129490
129988
  ...diagnosticChanges.updates
129491
129989
  };
129492
- const nextStatus = input.status ?? row.status;
129493
129990
  const publishing = input.status !== undefined && isAssessmentPublicationTransition(row.status, input.status);
129494
- const activatingReview = nextPurpose === "review" && nextStatus === "live" && (row.purpose !== "review" || row.status !== "live");
129495
129991
  if (input.status !== undefined) {
129496
129992
  validateAssessmentStatusTransition(row.status, input.status);
129497
129993
  }
129498
- if (!publishing && !activatingReview && nextPurpose === "review" && row.purpose !== "review") {
129499
- const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
129500
- await this.rebuildReviewMapping(this.requireClient(), qtiTestIdentifier, {
129501
- kind: "owned",
129502
- gameSlug: ownership.gameSlug
129503
- });
129504
- }
129505
129994
  if (input.title !== undefined) {
129506
129995
  assertDraftAssessment(row);
129507
129996
  assertAllAssessmentAssociationsDraft(associations);
@@ -129511,7 +130000,7 @@ class TimebackAssessmentsService {
129511
130000
  assertQtiTestOwnedByGame(test, ownership.gameSlug);
129512
130001
  await client2.qtiApi.assessmentTests.update(qtiTestIdentifier, buildQtiTestUpdateInput(test, input.title));
129513
130002
  }
129514
- if (publishing || activatingReview) {
130003
+ if (publishing) {
129515
130004
  if (nextPurpose === "diagnostic" && !nextDiagnostic) {
129516
130005
  throw new ValidationError("Platform-routed diagnostics require a diagnostic key and routing manifest before publication.");
129517
130006
  }
@@ -129521,8 +130010,7 @@ class TimebackAssessmentsService {
129521
130010
  updates.diagnosticKey = nextDiagnostic.diagnosticKey;
129522
130011
  updates.diagnosticRoutingManifest = nextDiagnostic.routingManifest;
129523
130012
  } else {
129524
- const reviewOwnership = nextPurpose === "review" && nextStatus === "live" ? await this.requireQtiTestOwnershipContext(integrationId, tx) : undefined;
129525
- await this.validateAssessmentHasQuestions(loaded, reviewOwnership?.gameSlug);
130013
+ await this.validateAssessmentHasQuestions(loaded);
129526
130014
  }
129527
130015
  if (publishing) {
129528
130016
  const publication = await this.publishManagedAssessment(row, nextPurpose, nextDiagnostic?.routingManifest ?? null, loaded);
@@ -129545,20 +130033,20 @@ class TimebackAssessmentsService {
129545
130033
  if (databaseConstraintName(error88) === "game_timeback_assessment_tests_one_live_diagnostic_key_idx") {
129546
130034
  throw new ValidationError("Only one live revision of a diagnostic key is allowed. Archive the current revision before publishing another.");
129547
130035
  }
129548
- if (databaseConstraintName(error88) === "game_timeback_assessment_tests_one_live_review_idx") {
129549
- throw new ValidationError("Only one live review assessment is allowed. Archive the current review bank before publishing another.");
129550
- }
129551
130036
  }
129552
130037
  throw error88;
129553
130038
  }
129554
130039
  }
129555
130040
  async removeAssessment(integrationId, qtiTestIdentifier) {
129556
130041
  return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx) => {
130042
+ if (qtiTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
130043
+ throw new ValidationError("The review bank is permanent and cannot be removed or archived.");
130044
+ }
129557
130045
  const plan = planAssessmentRemoval(row.status);
129558
130046
  if (plan.kind === "delete") {
129559
130047
  await tx.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
129560
130048
  } else if (plan.kind === "archive") {
129561
- await tx.update(gameTimebackAssessmentTests).set({ status: "archived", sortOrder: null, updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id));
130049
+ await tx.update(gameTimebackAssessmentTests).set({ status: "archived", updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id));
129562
130050
  }
129563
130051
  if (plan.kind !== "none") {
129564
130052
  setAttribute("app.assessment.operation", plan.operation);
@@ -129566,24 +130054,6 @@ class TimebackAssessmentsService {
129566
130054
  return { action: plan.action };
129567
130055
  });
129568
130056
  }
129569
- async reorderAssessments(integrationId, purpose, testIdentifiers) {
129570
- await this.requireIntegration(integrationId);
129571
- validateUniqueAssessmentIdentifiers(testIdentifiers);
129572
- const updatedAt = new Date;
129573
- await this.deps.db.transaction(async (tx) => {
129574
- const liveRows = await tx.query.gameTimebackAssessmentTests.findMany({
129575
- where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))
129576
- });
129577
- const orderedRows = orderLiveAssessmentRows(liveRows, purpose, testIdentifiers);
129578
- const positionByRowId = new Map(orderedRows.map((row, index2) => [row.id, index2 + 1]));
129579
- for (const row of lockOrderAssessmentRows(orderedRows)) {
129580
- const testIdentifier = row.qtiTestIdentifier;
129581
- 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 });
129582
- assertAssessmentOrderUpdateSucceeded(updatedRow);
129583
- }
129584
- });
129585
- setAttribute("app.assessment.operation", "reorder_live_assessments");
129586
- }
129587
130057
  async listQuestions(integrationId, qtiTestIdentifier) {
129588
130058
  const client2 = this.requireClient();
129589
130059
  await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
@@ -129624,19 +130094,96 @@ class TimebackAssessmentsService {
129624
130094
  });
129625
130095
  return { ...result, questions };
129626
130096
  }
129627
- async updateReviewMapping(integrationId, qtiTestIdentifier) {
129628
- return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (_row, _tx, associations) => {
129629
- if (!associations.some((association) => association.purpose === "review")) {
129630
- throw new ValidationError("Review mapping is available only for standards-review assessments.");
130097
+ async synchronizeReviewBank(integrationId) {
130098
+ const client2 = this.requireClient();
130099
+ await this.requireIntegration(integrationId);
130100
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
130101
+ for (let attempt = 0;attempt < REVIEW_BANK_SYNC_ATTEMPTS; attempt++) {
130102
+ const associations = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
130103
+ where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
130104
+ });
130105
+ const masterySources = this.reviewBankMasterySources(associations);
130106
+ const contributorFingerprint = await reviewBankContributorFingerprint(masterySources);
130107
+ const existingSink = await this.findReviewBankSink(client2, integrationId);
130108
+ if (existingSink) {
130109
+ assertReviewBankSinkOwnership(existingSink, integrationId);
130110
+ }
130111
+ let unchangedResult = null;
130112
+ if (existingSink) {
130113
+ try {
130114
+ unchangedResult = await unchangedReviewBankSynchronizationResult({
130115
+ sink: existingSink,
130116
+ integrationId,
130117
+ contributorFingerprint,
130118
+ contributorCount: masterySources.length
130119
+ });
130120
+ } catch (error88) {
130121
+ addEvent("assessment.review_mapping_noop_validation_failed", {
130122
+ "app.assessment.qti_test_identifier": existingSink.identifier,
130123
+ "exception.type": errorType(error88),
130124
+ "app.error.message": errorMessage2(error88)
130125
+ });
130126
+ }
129631
130127
  }
129632
- const client2 = this.requireClient();
129633
- const result = await this.rebuildReviewMapping(client2, qtiTestIdentifier, {
129634
- kind: "live-review-repair"
130128
+ let plan = null;
130129
+ let addedItemCount = 0;
130130
+ let removedItemCount = 0;
130131
+ let catalogUnchanged = unchangedResult !== null;
130132
+ if (!unchangedResult) {
130133
+ const contributors = await runWithConcurrency(masterySources, QTI_HYDRATION_CONCURRENCY, async (source) => {
130134
+ const test = await client2.qtiApi.assessmentTests.get(source.qtiTestIdentifier);
130135
+ assertManagedAssessmentIdentity(test, source.assessmentKey);
130136
+ return { ...source, test };
130137
+ });
130138
+ plan = await prepareReviewBankSynchronization({
130139
+ bankIdentifier: sinkIdentifier,
130140
+ contributors,
130141
+ contributorFingerprint
130142
+ });
130143
+ const diff = reviewBankSynchronizationDiff({ existingSink, plan });
130144
+ addedItemCount = diff.addedItemCount;
130145
+ removedItemCount = diff.removedItemCount;
130146
+ catalogUnchanged = diff.unchanged;
130147
+ }
130148
+ const committed = await this.withLockedIntegrationAssessmentRows(integrationId, async (tx, lockedAssociations) => {
130149
+ const lockedSources = this.reviewBankMasterySources(lockedAssociations);
130150
+ const lockedFingerprint = await reviewBankContributorFingerprint(lockedSources);
130151
+ if (lockedFingerprint !== contributorFingerprint) {
130152
+ return null;
130153
+ }
130154
+ const associationChanged = await this.installReviewBankAssociation({
130155
+ tx,
130156
+ integrationId,
130157
+ associations: lockedAssociations,
130158
+ sinkIdentifier,
130159
+ beforeInstall: async () => {
130160
+ if (!catalogUnchanged) {
130161
+ await this.writeReviewBankSink({
130162
+ client: client2,
130163
+ integrationId,
130164
+ existingSink,
130165
+ plan,
130166
+ metadata: reviewBankSinkMetadata(integrationId, plan)
130167
+ });
130168
+ }
130169
+ }
130170
+ });
130171
+ const unchanged = catalogUnchanged && !associationChanged;
130172
+ const result = unchangedResult ? { ...unchangedResult, unchanged } : {
130173
+ qtiTestIdentifier: sinkIdentifier,
130174
+ ...plan.summary,
130175
+ addedItemCount,
130176
+ removedItemCount,
130177
+ unchanged,
130178
+ warnings: plan.warnings
130179
+ };
130180
+ return this.reviewMappingResult(result);
129635
130181
  });
129636
- setAttribute("app.assessment.operation", "update_review_mapping");
129637
- setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
129638
- return result;
129639
- });
130182
+ if (committed) {
130183
+ return committed;
130184
+ }
130185
+ }
130186
+ throw new ValidationError("Mastery assessments changed while the review bank was synchronizing. Try again.");
129640
130187
  }
129641
130188
  async listQuestionLibrary(integrationId, params) {
129642
130189
  const client2 = this.requireClient();
@@ -129656,15 +130203,18 @@ class TimebackAssessmentsService {
129656
130203
  purpose,
129657
130204
  standard: standardInput
129658
130205
  } = input;
129659
- if (purpose === "diagnostic") {
129660
- throw new ValidationError("Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
130206
+ if (purpose === "diagnostic" || purpose === "review") {
130207
+ 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.");
130208
+ }
130209
+ if (assessmentKey === reviewBankSinkIdentifier(integrationId) || targetTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
130210
+ throw new ValidationError("This assessment identity is reserved for the review bank.");
129661
130211
  }
129662
130212
  const client2 = this.requireClient();
129663
130213
  const ownership = await this.requireQtiTestOwnershipContext(integrationId);
129664
130214
  const { integration } = ownership;
129665
130215
  const standard = this.requirePurposeStandard(purpose, standardInput);
129666
130216
  const source = await client2.qtiApi.assessmentTests.get(sourceTestIdentifier);
129667
- const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => this.qtiItemHref(client2, identifier));
130217
+ const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => qtiItemHref(client2, identifier));
129668
130218
  const itemCopies = await runWithConcurrency(itemPlan, QTI_HYDRATION_CONCURRENCY, async (plan) => {
129669
130219
  const sourceItem = await client2.qtiApi.assessmentItems.get(plan.sourceIdentifier);
129670
130220
  return {
@@ -129789,7 +130339,7 @@ class TimebackAssessmentsService {
129789
130339
  };
129790
130340
  }
129791
130341
  const section = await this.qtiSectionItems(client2, qtiTestIdentifier, undefined, undefined, ownership.test);
129792
- const href = this.qtiItemHref(client2, itemIdentifier);
130342
+ const href = qtiItemHref(client2, itemIdentifier);
129793
130343
  let item;
129794
130344
  let itemCreationAttempted = false;
129795
130345
  let referenceCreationAttempted = false;
@@ -129899,7 +130449,7 @@ class TimebackAssessmentsService {
129899
130449
  const result = await section.items.reorder({
129900
130450
  items: items.map((item) => ({
129901
130451
  identifier: item.identifier,
129902
- href: item.href ?? this.qtiItemHref(client2, item.identifier),
130452
+ href: item.href ?? qtiItemHref(client2, item.identifier),
129903
130453
  sequence: item.sequence
129904
130454
  }))
129905
130455
  });
@@ -129913,6 +130463,118 @@ class TimebackAssessmentsService {
129913
130463
  }
129914
130464
  return this.deps.timeback;
129915
130465
  }
130466
+ reviewBankMasterySources(associations) {
130467
+ return associations.filter((association) => association.purpose === "mastery" && association.status === "live").map((association) => {
130468
+ const standard = assessmentStandardForRow(association);
130469
+ if (!association.assessmentKey || !standard) {
130470
+ throw new ValidationError(`Live mastery assessment ${association.qtiTestIdentifier} requires a managed identity and standard`);
130471
+ }
130472
+ if (assessmentKeyFromManagedQtiIdentifier(association.qtiTestIdentifier) !== association.assessmentKey) {
130473
+ throw new ValidationError(`Live mastery assessment ${association.qtiTestIdentifier} is not a managed publication for ${association.assessmentKey}`);
130474
+ }
130475
+ return {
130476
+ assessmentKey: association.assessmentKey,
130477
+ qtiTestIdentifier: association.qtiTestIdentifier,
130478
+ standard
130479
+ };
130480
+ });
130481
+ }
130482
+ async writeReviewBankSink(input) {
130483
+ function sinkInput(title, includeIdentifier = false) {
130484
+ return buildReviewBankSinkInput({
130485
+ identifier: input.plan.bankIdentifier,
130486
+ title,
130487
+ metadata: input.metadata,
130488
+ itemIdentifiers: input.plan.itemIdentifiers,
130489
+ itemHref: (identifier) => qtiItemHref(input.client, identifier),
130490
+ includeIdentifier
130491
+ });
130492
+ }
130493
+ if (input.existingSink) {
130494
+ await input.client.qtiApi.assessmentTests.update(input.plan.bankIdentifier, sinkInput(input.existingSink.title));
130495
+ return;
130496
+ }
130497
+ try {
130498
+ const createInput = sinkInput(REVIEW_BANK_TITLE, true);
130499
+ await input.client.qtiApi.assessmentTests.create(createInput);
130500
+ } catch (error88) {
130501
+ if (!isApiError(error88) || error88.statusCode !== 409) {
130502
+ throw error88;
130503
+ }
130504
+ const concurrentlyCreated = await input.client.qtiApi.assessmentTests.get(input.plan.bankIdentifier);
130505
+ assertReviewBankSinkOwnership(concurrentlyCreated, input.integrationId);
130506
+ await input.client.qtiApi.assessmentTests.update(input.plan.bankIdentifier, sinkInput(concurrentlyCreated.title));
130507
+ }
130508
+ }
130509
+ async installReviewBankAssociation(input) {
130510
+ const singleton = input.associations.find((association) => association.qtiTestIdentifier === input.sinkIdentifier);
130511
+ if (singleton && singleton.purpose !== "review") {
130512
+ throw new ValidationError("The reserved review-bank identifier is associated with another assessment purpose.");
130513
+ }
130514
+ const legacyReviews = input.associations.filter((association) => association.purpose === "review" && association.qtiTestIdentifier !== input.sinkIdentifier);
130515
+ const reservedKeyOwner = input.associations.find((association) => association.assessmentKey === input.sinkIdentifier && association.qtiTestIdentifier !== input.sinkIdentifier);
130516
+ if (reservedKeyOwner && reservedKeyOwner.purpose !== "review") {
130517
+ throw new ValidationError("The reserved review-bank assessment key is already in use.");
130518
+ }
130519
+ const singletonCurrent = Boolean(singleton && singleton.assessmentKey === input.sinkIdentifier && singleton.status === "live" && singleton.standardFramework === null && singleton.standardIdentifier === null && singleton.diagnosticKey === null && singleton.diagnosticRoutingManifest === null);
130520
+ const legacyCurrent = legacyReviews.every((association) => association.status === "archived" && association.assessmentKey !== input.sinkIdentifier);
130521
+ const changed = !singletonCurrent || !legacyCurrent;
130522
+ await input.beforeInstall();
130523
+ for (const legacy of legacyReviews) {
130524
+ if (legacy.status !== "archived" || legacy.assessmentKey === input.sinkIdentifier) {
130525
+ await input.tx.update(gameTimebackAssessmentTests).set({
130526
+ status: "archived",
130527
+ ...legacy.assessmentKey === input.sinkIdentifier ? { assessmentKey: null } : {},
130528
+ updatedAt: new Date
130529
+ }).where(eq(gameTimebackAssessmentTests.id, legacy.id));
130530
+ }
130531
+ }
130532
+ if (singletonCurrent) {
130533
+ return changed;
130534
+ }
130535
+ if (singleton) {
130536
+ await input.tx.update(gameTimebackAssessmentTests).set({
130537
+ assessmentKey: input.sinkIdentifier,
130538
+ purpose: "review",
130539
+ status: "live",
130540
+ standardFramework: null,
130541
+ standardIdentifier: null,
130542
+ diagnosticKey: null,
130543
+ diagnosticRoutingManifest: null,
130544
+ updatedAt: new Date
130545
+ }).where(eq(gameTimebackAssessmentTests.id, singleton.id));
130546
+ return changed;
130547
+ }
130548
+ await input.tx.insert(gameTimebackAssessmentTests).values({
130549
+ integrationId: input.integrationId,
130550
+ assessmentKey: input.sinkIdentifier,
130551
+ qtiTestIdentifier: input.sinkIdentifier,
130552
+ purpose: "review",
130553
+ status: "live",
130554
+ standardFramework: null,
130555
+ standardIdentifier: null,
130556
+ diagnosticKey: null,
130557
+ diagnosticRoutingManifest: null
130558
+ });
130559
+ return changed;
130560
+ }
130561
+ reviewMappingResult(result) {
130562
+ setAttribute("app.assessment.operation", "update_review_mapping");
130563
+ setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
130564
+ setAttribute("app.assessment.review_mapping_added_item_count", result.addedItemCount);
130565
+ setAttribute("app.assessment.review_mapping_removed_item_count", result.removedItemCount);
130566
+ return result;
130567
+ }
130568
+ async withLockedIntegrationAssessmentRows(integrationId, action) {
130569
+ return this.deps.db.transaction(async (tx) => {
130570
+ const [integration] = await tx.select({ id: gameTimebackIntegrations.id }).from(gameTimebackIntegrations).where(and(eq(gameTimebackIntegrations.id, integrationId), isActiveGameTimebackIntegrationStatus())).for("update");
130571
+ if (!integration) {
130572
+ throw new NotFoundError(`Integration not found: ${integrationId}`);
130573
+ }
130574
+ const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.integrationId, integrationId)).orderBy(gameTimebackAssessmentTests.id).for("update");
130575
+ return action(tx, rows);
130576
+ });
130577
+ }
129916
130578
  async withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, action) {
129917
130579
  return this.deps.db.transaction(async (tx) => {
129918
130580
  const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier)).orderBy(gameTimebackAssessmentTests.id).for("update");
@@ -129997,8 +130659,16 @@ class TimebackAssessmentsService {
129997
130659
  items: client2.qtiApi.assessmentTests.testParts(qtiTestIdentifier).sections(section.partIdentifier).items(section.sectionIdentifier)
129998
130660
  };
129999
130661
  }
130000
- qtiItemHref(client2, itemIdentifier) {
130001
- return `${client2.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
130662
+ async findReviewBankSink(client2, integrationId) {
130663
+ const identifier = reviewBankSinkIdentifier(integrationId);
130664
+ try {
130665
+ return await client2.qtiApi.assessmentTests.get(identifier);
130666
+ } catch (error88) {
130667
+ if (isApiError(error88) && error88.statusCode === 404) {
130668
+ return null;
130669
+ }
130670
+ throw error88;
130671
+ }
130002
130672
  }
130003
130673
  async ensureImmutableQtiResource(get, create) {
130004
130674
  try {
@@ -130039,7 +130709,7 @@ class TimebackAssessmentsService {
130039
130709
  test: loaded.test,
130040
130710
  questions: loaded.questions,
130041
130711
  diagnosticRoutingManifest,
130042
- itemHref: (identifier) => this.qtiItemHref(client2, identifier)
130712
+ itemHref: (identifier) => qtiItemHref(client2, identifier)
130043
130713
  });
130044
130714
  await this.ensureManagedQtiPublication(client2, plan);
130045
130715
  return plan;
@@ -130083,7 +130753,6 @@ class TimebackAssessmentsService {
130083
130753
  qtiTestIdentifier: row.qtiTestIdentifier,
130084
130754
  purpose: row.purpose,
130085
130755
  status: row.status,
130086
- sortOrder: row.sortOrder,
130087
130756
  standard: assessmentStandardForRow(row),
130088
130757
  diagnostic: diagnosticDefinitionForRow(row),
130089
130758
  createdAt: row.createdAt,
@@ -130121,15 +130790,11 @@ class TimebackAssessmentsService {
130121
130790
  const plan = buildQtiLibraryListPlan(params);
130122
130791
  return list(plan.params);
130123
130792
  }
130124
- async validateAssessmentHasQuestions(loaded, reviewGameSlug) {
130793
+ async validateAssessmentHasQuestions(loaded) {
130125
130794
  assertAssessmentHasQuestions(loaded.questions.questions);
130126
130795
  for (const { question } of loaded.questions.questions) {
130127
130796
  assertPlayableQtiQuestion(question);
130128
130797
  }
130129
- if (reviewGameSlug) {
130130
- assertQtiTestOwnedByGame(loaded.test, reviewGameSlug);
130131
- await this.writeReviewMapping(this.requireClient(), loaded.test, loaded.questions);
130132
- }
130133
130798
  }
130134
130799
  async prepareDiagnosticAssociationChanges(row, nextPurpose, requested) {
130135
130800
  if (row.purpose !== "diagnostic" && nextPurpose === "diagnostic" && !requested) {
@@ -130158,22 +130823,9 @@ class TimebackAssessmentsService {
130158
130823
  }
130159
130824
  };
130160
130825
  }
130161
- async rebuildReviewMapping(client2, qtiTestIdentifier, scope) {
130162
- const [test, references] = await Promise.all([
130163
- client2.qtiApi.assessmentTests.get(qtiTestIdentifier),
130164
- client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier)
130165
- ]);
130166
- if (scope.kind === "owned") {
130167
- assertQtiTestOwnedByGame(test, scope.gameSlug);
130168
- }
130169
- return this.writeReviewMapping(client2, test, await hydrateQtiTestQuestions(client2, references));
130170
- }
130171
- async writeReviewMapping(client2, test, questions) {
130172
- const prepared = await prepareReviewMappingUpdate(test, questions);
130173
- await client2.qtiApi.assessmentTests.update(test.identifier, prepared.update);
130174
- return prepared.summary;
130175
- }
130176
130826
  }
130827
+ var REVIEW_BANK_TITLE = "Review Bank";
130828
+ var REVIEW_BANK_SYNC_ATTEMPTS = 2;
130177
130829
  var init_timeback_assessments_service = __esm(async () => {
130178
130830
  init_drizzle_orm();
130179
130831
  init_helpers_index();
@@ -192833,8 +193485,7 @@ var listAssessments;
192833
193485
  var createAssessment;
192834
193486
  var attachExistingAssessments;
192835
193487
  var updateAssessment;
192836
- var updateReviewMapping;
192837
- var reorderAssessments;
193488
+ var synchronizeReviewBank;
192838
193489
  var reorderQuestions;
192839
193490
  var removeAssessment;
192840
193491
  var listQuestions;
@@ -193206,7 +193857,9 @@ var init_timeback_controller = __esm(() => {
193206
193857
  attemptId,
193207
193858
  input: {
193208
193859
  submissionId: body2.submissionId,
193209
- xpAwarded: body2.xpAwarded
193860
+ xpAwarded: body2.xpAwarded,
193861
+ ...body2.masteredUnits !== undefined ? { masteredUnits: body2.masteredUnits } : {},
193862
+ ...body2.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: body2.masteredUnitsAbsolute } : {}
193210
193863
  },
193211
193864
  game: {
193212
193865
  appName: body2.appName,
@@ -193540,23 +194193,13 @@ var init_timeback_controller = __esm(() => {
193540
194193
  const body2 = await parseRequestBody(ctx.request, UpdateAssessmentRequestSchema);
193541
194194
  return ctx.services.timebackAssessments.updateAssessment(integrationId, testIdentifier, body2);
193542
194195
  });
193543
- updateReviewMapping = requireDeveloper(async (ctx) => {
193544
- const { gameId, courseId, testIdentifier } = ctx.params;
193545
- if (!gameId || !courseId || !testIdentifier) {
193546
- throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
193547
- }
193548
- const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
193549
- return ctx.services.timebackAssessments.updateReviewMapping(integrationId, testIdentifier);
193550
- });
193551
- reorderAssessments = requireDeveloper(async (ctx) => {
194196
+ synchronizeReviewBank = requireDeveloper(async (ctx) => {
193552
194197
  const { gameId, courseId } = ctx.params;
193553
194198
  if (!gameId || !courseId) {
193554
194199
  throw ApiError.badRequest("Missing gameId or courseId parameter");
193555
194200
  }
193556
- const body2 = await parseRequestBody(ctx.request, ReorderAssessmentsRequestSchema);
193557
194201
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
193558
- await ctx.services.timebackAssessments.reorderAssessments(integrationId, body2.purpose, body2.testIdentifiers);
193559
- return { success: true };
194202
+ return ctx.services.timebackAssessments.synchronizeReviewBank(integrationId);
193560
194203
  });
193561
194204
  reorderQuestions = requireDeveloper(async (ctx) => {
193562
194205
  const { gameId, courseId, testIdentifier } = ctx.params;
@@ -193697,8 +194340,7 @@ var init_timeback_controller = __esm(() => {
193697
194340
  createAssessment,
193698
194341
  attachExistingAssessments,
193699
194342
  updateAssessment,
193700
- updateReviewMapping,
193701
- reorderAssessments,
194343
+ synchronizeReviewBank,
193702
194344
  removeAssessment,
193703
194345
  reorderQuestions,
193704
194346
  listQuestions,