@playcademy/vite-plugin 1.3.1-beta.1 → 1.3.1-beta.2

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 +1134 -405
  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.2",
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.2",
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";
@@ -35894,7 +35900,7 @@ async function reviewBankRevision(input) {
35894
35900
  })}`);
35895
35901
  return `review-bank-v1:${bankRevisionUuid}`;
35896
35902
  }
35897
- async function reviewBankIndex(input) {
35903
+ async function buildReviewBankIndexFromReferences(input) {
35898
35904
  return {
35899
35905
  ...input,
35900
35906
  bankRevision: await reviewBankRevision(input)
@@ -35923,7 +35929,7 @@ async function buildReviewBankIndex(assessment, questions, options = {}) {
35923
35929
  standards
35924
35930
  };
35925
35931
  });
35926
- return reviewBankIndex({
35932
+ return buildReviewBankIndexFromReferences({
35927
35933
  bankIdentifier: assessment.identifier,
35928
35934
  sourceContentRevision: assessment.contentRevision,
35929
35935
  items
@@ -36028,7 +36034,7 @@ async function reviewBankIndexFromManifest(metadata2, input) {
36028
36034
  }
36029
36035
  }
36030
36036
  }
36031
- const index = await reviewBankIndex({
36037
+ const index = await buildReviewBankIndexFromReferences({
36032
36038
  bankIdentifier,
36033
36039
  sourceContentRevision,
36034
36040
  items: input.membershipItemIdentifiers.map((itemIdentifier) => ({
@@ -36383,6 +36389,18 @@ function isRoutedDiagnosticAttemptMetadata(value, itemSubmissions, responseVersi
36383
36389
  function isReviewAttemptMetadata(value) {
36384
36390
  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
36391
  }
36392
+ function isOptionalInteger(value) {
36393
+ return value === undefined || Number.isInteger(value);
36394
+ }
36395
+ function isMasteryWriteWarning(value) {
36396
+ 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);
36397
+ }
36398
+ function isAssessmentAwardRecord(value) {
36399
+ if (value === undefined) {
36400
+ return true;
36401
+ }
36402
+ 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));
36403
+ }
36386
36404
  function isMasteryAttemptMetadata(value) {
36387
36405
  return isRecord(value) && typeof value.requestFingerprint === "string" && isAssessmentStandardRef(value.standard);
36388
36406
  }
@@ -36407,7 +36425,7 @@ function isPlaycademyAssessmentResultMetadataV1(value) {
36407
36425
  const review = metadata2.review;
36408
36426
  const diagnostic = metadata2.diagnostic;
36409
36427
  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);
36428
+ 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
36429
  }
36412
36430
  function isPlaycademyReviewAssessmentItemResultMetadataV1(value) {
36413
36431
  if (!isRecord(value) || !isRecord(value.responses)) {
@@ -36927,7 +36945,12 @@ var init_assessment_runtime2 = __esm(() => {
36927
36945
  });
36928
36946
  FinalizeAssessmentBodySchema = exports_external.object({
36929
36947
  submissionId: exports_external.string().trim().min(1),
36930
- xpAwarded: exports_external.number().finite().nonnegative()
36948
+ xpAwarded: exports_external.number().finite().nonnegative(),
36949
+ masteredUnits: exports_external.number().finite().optional(),
36950
+ masteredUnitsAbsolute: exports_external.number().finite().int().optional()
36951
+ }).refine((body2) => body2.masteredUnits === undefined || body2.masteredUnitsAbsolute === undefined, {
36952
+ message: "Provide either masteredUnits or masteredUnitsAbsolute, not both",
36953
+ path: ["masteredUnits"]
36931
36954
  });
36932
36955
  StartRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.and(StartAssessmentBodySchema);
36933
36956
  SaveRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend(SaveAssessmentBodySchema.shape);
@@ -36936,10 +36959,10 @@ var init_assessment_runtime2 = __esm(() => {
36936
36959
  appName: exports_external.string().trim().min(1),
36937
36960
  sensorUrl: exports_external.string().url()
36938
36961
  });
36939
- FinalizeRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend(FinalizeAssessmentBodySchema.shape).extend({
36962
+ FinalizeRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend({
36940
36963
  appName: exports_external.string().trim().min(1),
36941
36964
  sensorUrl: exports_external.string().url()
36942
- });
36965
+ }).and(FinalizeAssessmentBodySchema);
36943
36966
  });
36944
36967
  var DOMAIN_CODE_BY_STATUS;
36945
36968
  var AssessmentRuntimeError;
@@ -71642,6 +71665,15 @@ function requireMasteryStandard(input, context2) {
71642
71665
  });
71643
71666
  }
71644
71667
  }
71668
+ function rejectSystemManagedReview(input, context2) {
71669
+ if (input.purpose === "review") {
71670
+ context2.addIssue({
71671
+ code: "custom",
71672
+ path: ["purpose"],
71673
+ message: "The review bank is system-managed and cannot be managed manually"
71674
+ });
71675
+ }
71676
+ }
71645
71677
  var TimebackGradeSchema;
71646
71678
  var TimebackSubjectSchema;
71647
71679
  var CourseGoalsSchema;
@@ -71946,7 +71978,10 @@ var init_schemas4 = __esm(() => {
71946
71978
  title: exports_external.string().min(1, "Assessment title is required"),
71947
71979
  purpose: AssessmentPurposeSchema,
71948
71980
  standard: AssessmentStandardRefSchema2.optional()
71949
- }).superRefine(requireMasteryStandard);
71981
+ }).superRefine((input, context2) => {
71982
+ requireMasteryStandard(input, context2);
71983
+ rejectSystemManagedReview(input, context2);
71984
+ });
71950
71985
  UpdateAssessmentRequestSchema = exports_external.object({
71951
71986
  title: exports_external.string().trim().min(1, "Assessment title is required").optional(),
71952
71987
  purpose: AssessmentPurposeSchema.optional(),
@@ -71955,13 +71990,16 @@ var init_schemas4 = __esm(() => {
71955
71990
  status: AssessmentStatusSchema.optional()
71956
71991
  }).refine((input) => input.title !== undefined || input.purpose !== undefined || input.standard !== undefined || input.diagnostic !== undefined || input.status !== undefined, {
71957
71992
  message: "Title, purpose, standard, diagnostic, or status is required"
71958
- });
71993
+ }).superRefine(rejectSystemManagedReview);
71959
71994
  CopyAssessmentRequestSchema = exports_external.object({
71960
71995
  assessmentKey: AssessmentKeySchema,
71961
71996
  testIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
71962
71997
  purpose: AssessmentPurposeSchema,
71963
71998
  standard: AssessmentStandardRefSchema2.optional()
71964
- }).superRefine(requireMasteryStandard);
71999
+ }).superRefine((input, context2) => {
72000
+ requireMasteryStandard(input, context2);
72001
+ rejectSystemManagedReview(input, context2);
72002
+ });
71965
72003
  AssessmentAssociationImportEntrySchema = exports_external.object({
71966
72004
  assessmentKey: AssessmentKeySchema,
71967
72005
  qtiTestIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
@@ -71970,6 +72008,7 @@ var init_schemas4 = __esm(() => {
71970
72008
  sortOrder: exports_external.number().int().nonnegative().nullable()
71971
72009
  }).superRefine((input, context2) => {
71972
72010
  requireMasteryStandard(input, context2);
72011
+ rejectSystemManagedReview(input, context2);
71973
72012
  if (input.purpose !== "mastery" && input.standard !== undefined) {
71974
72013
  context2.addIssue({
71975
72014
  code: "custom",
@@ -72019,7 +72058,7 @@ var init_schemas4 = __esm(() => {
72019
72058
  ReorderAssessmentsRequestSchema = exports_external.object({
72020
72059
  purpose: AssessmentPurposeSchema,
72021
72060
  testIdentifiers: exports_external.array(exports_external.string().trim().min(1, "Assessment identifier is required")).min(1, "At least one assessment is required")
72022
- });
72061
+ }).superRefine(rejectSystemManagedReview);
72023
72062
  ReorderQuestionsRequestSchema = exports_external.object({
72024
72063
  items: exports_external.array(exports_external.object({
72025
72064
  identifier: exports_external.string().min(1),
@@ -116646,6 +116685,7 @@ function buildAssessmentCompletionEvent(input) {
116646
116685
  totalQuestions: input.totalQuestions,
116647
116686
  correctQuestions: input.correctQuestions,
116648
116687
  xpEarned: input.xpAwarded,
116688
+ ...input.masteredUnits ? { masteredUnits: input.masteredUnits } : {},
116649
116689
  attemptNumber: input.attemptNumber,
116650
116690
  process: false,
116651
116691
  subject: input.subject,
@@ -116654,7 +116694,10 @@ function buildAssessmentCompletionEvent(input) {
116654
116694
  eventId: input.eventId,
116655
116695
  eventTime: input.submittedAt,
116656
116696
  runId: input.attemptId,
116657
- generatedExtensions: metadata2,
116697
+ generatedExtensions: {
116698
+ ...metadata2,
116699
+ ...input.pctCompleteApp !== undefined ? { pctCompleteApp: input.pctCompleteApp } : {}
116700
+ },
116658
116701
  eventExtensions: {
116659
116702
  playcademy: {
116660
116703
  assessmentPurpose: input.purpose,
@@ -116708,6 +116751,85 @@ function deriveSourcedIds(courseId) {
116708
116751
  componentResource: `${courseId}-cr`
116709
116752
  };
116710
116753
  }
116754
+ function hasMasteryAwardRequest(request) {
116755
+ return request.masteredUnits !== undefined || request.masteredUnitsAbsolute !== undefined;
116756
+ }
116757
+
116758
+ class ActivityMasteryAward {
116759
+ mastery;
116760
+ events;
116761
+ constructor(mastery, events) {
116762
+ this.mastery = mastery;
116763
+ this.events = events;
116764
+ }
116765
+ async evaluate(input) {
116766
+ const ids = deriveSourcedIds(input.courseId);
116767
+ const progress = await this.mastery.checkProgress({
116768
+ studentId: input.studentId,
116769
+ courseId: input.courseId,
116770
+ resourceId: ids.resource,
116771
+ masteredUnits: input.masteredUnits ?? 0,
116772
+ masteredUnitsAbsolute: input.masteredUnitsAbsolute
116773
+ });
116774
+ if (!progress) {
116775
+ return {
116776
+ masteredUnitsApplied: input.masteredUnits ?? 0,
116777
+ masteryAchieved: false,
116778
+ masteryRevoked: false
116779
+ };
116780
+ }
116781
+ return {
116782
+ masteredUnitsApplied: progress.effectiveDelta,
116783
+ pctCompleteApp: progress.pctCompleteApp,
116784
+ masteryAchieved: progress.masteryAchieved,
116785
+ masteryRevoked: progress.masteryRevoked,
116786
+ ...progress.writeWarning ? { warnings: [progress.writeWarning] } : {}
116787
+ };
116788
+ }
116789
+ async settle(evaluation, input) {
116790
+ let completionEntryWritten = false;
116791
+ if (evaluation.masteryAchieved) {
116792
+ setAttributes({ "app.timeback.mastery_achieved": true });
116793
+ completionEntryWritten = await this.mastery.createCompletionEntry(input.studentId, input.courseId, input.classId, input.appName);
116794
+ await this.emitCourseCompletionHistoryEvent(input);
116795
+ }
116796
+ if (evaluation.masteryRevoked) {
116797
+ await this.mastery.revokeCompletionEntry(input.studentId, input.courseId, input.classId, input.appName);
116798
+ }
116799
+ return { completionEntryWritten };
116800
+ }
116801
+ async emitCourseCompletionHistoryEvent(input) {
116802
+ const ids = deriveSourcedIds(input.courseId);
116803
+ await this.events.emitActivityEvent({
116804
+ eventId: input.completionHistoryEventId,
116805
+ studentId: input.studentId,
116806
+ studentEmail: input.studentEmail,
116807
+ gameId: input.gameId,
116808
+ activityId: input.activityId,
116809
+ activityName: "Course completed",
116810
+ courseId: ids.course,
116811
+ courseName: input.courseName,
116812
+ subject: input.subject,
116813
+ appName: input.appName,
116814
+ sensorUrl: input.sensorUrl,
116815
+ process: false,
116816
+ includeAttempt: false,
116817
+ eventExtensions: {
116818
+ playcademy: {
116819
+ eventKind: "course-completed",
116820
+ source: "gameplay"
116821
+ }
116822
+ },
116823
+ generatedExtensions: {
116824
+ playcademy: {
116825
+ eventKind: "course-completed",
116826
+ source: "gameplay",
116827
+ activityId: input.activityId
116828
+ }
116829
+ }
116830
+ }).catch(catchEvent("timeback.caliper_completion_event_failed"));
116831
+ }
116832
+ }
116711
116833
  function validateProgressData(progressData) {
116712
116834
  if (!progressData.subject) {
116713
116835
  throw new ConfigurationError("subject", "Subject is required for Caliper events. Provide it in progressData.subject");
@@ -116723,12 +116845,12 @@ function validateProgressData(progressData) {
116723
116845
  class ActivityRecord {
116724
116846
  core;
116725
116847
  students;
116726
- mastery;
116848
+ masteryAward;
116727
116849
  events;
116728
- constructor(core3, students, mastery, events) {
116850
+ constructor(core3, students, masteryAward, events) {
116729
116851
  this.core = core3;
116730
116852
  this.students = students;
116731
- this.mastery = mastery;
116853
+ this.masteryAward = masteryAward;
116732
116854
  this.events = events;
116733
116855
  }
116734
116856
  async record(courseId, studentIdentifier, progressData) {
@@ -116754,52 +116876,32 @@ class ActivityRecord {
116754
116876
  throw error88;
116755
116877
  }
116756
116878
  const legacyLineItemId = `${ids.course}-${activityId}-assessment`;
116757
- const [currentAttemptNumber, masteryProgress] = await Promise.all([
116879
+ const [currentAttemptNumber, mastery] = await Promise.all([
116758
116880
  this.resolveAttemptNumber(attemptNumber, studentId, caliperLineItemId, legacyLineItemId),
116759
- this.mastery.checkProgress({
116881
+ this.masteryAward.evaluate({
116760
116882
  studentId,
116761
116883
  courseId,
116762
- resourceId: ids.resource,
116763
- masteredUnits: progressData.masteredUnits ?? 0,
116884
+ masteredUnits: progressData.masteredUnits,
116764
116885
  masteredUnitsAbsolute: progressData.masteredUnitsAbsolute
116765
116886
  })
116766
116887
  ]);
116767
116888
  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
- }
116889
+ const effectiveMasteredUnits = mastery.masteredUnitsApplied;
116890
+ const { pctCompleteApp, warnings } = mastery;
116891
+ const extensions = pctCompleteApp !== undefined ? { ...progressData.extensions, pctCompleteApp } : progressData.extensions;
116892
+ const { completionEntryWritten } = await this.masteryAward.settle(mastery, {
116893
+ studentId,
116894
+ studentEmail,
116895
+ courseId,
116896
+ classId: progressData.classId,
116897
+ gameId: progressData.gameId,
116898
+ activityId,
116899
+ courseName,
116900
+ subject: progressData.subject,
116901
+ appName: progressData.appName,
116902
+ sensorUrl: progressData.sensorUrl,
116903
+ completionHistoryEventId: progressData.completionHistoryEventId
116904
+ });
116803
116905
  try {
116804
116906
  await this.events.emitActivityEvent({
116805
116907
  studentId,
@@ -116820,7 +116922,7 @@ class ActivityRecord {
116820
116922
  appName: progressData.appName,
116821
116923
  sensorUrl: progressData.sensorUrl,
116822
116924
  eventId: progressData.eventId,
116823
- extensions: extensions || progressData.extensions,
116925
+ extensions,
116824
116926
  ...progressData.runId ? { runId: progressData.runId } : {}
116825
116927
  }).catch((error88) => {
116826
116928
  setAttributes({ "app.timeback.caliper_emit_failed": true });
@@ -116841,36 +116943,6 @@ class ActivityRecord {
116841
116943
  ...warnings ? { warnings } : {}
116842
116944
  };
116843
116945
  }
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
116946
  async resolveAttemptNumber(providedAttemptNumber, studentId, caliperLineItemId, legacyLineItemId) {
116875
116947
  if (providedAttemptNumber) {
116876
116948
  setAttributes({ "app.timeback.attempt_source": "provided" });
@@ -117268,9 +117340,12 @@ class ActivityEvents {
117268
117340
  }
117269
117341
  }
117270
117342
  function createActivityNamespace(core3, deps) {
117271
- const record3 = new ActivityRecord(core3, deps.students, deps.mastery, deps.events);
117343
+ const masteryAward = new ActivityMasteryAward(deps.mastery, deps.events);
117344
+ const record3 = new ActivityRecord(core3, deps.students, masteryAward, deps.events);
117272
117345
  const session2 = new ActivitySession(deps.students, deps.events);
117273
117346
  return {
117347
+ evaluateMasteryAward: (input) => masteryAward.evaluate(input),
117348
+ settleMasteryAward: (evaluation, input) => masteryAward.settle(evaluation, input),
117274
117349
  record: (courseId, studentIdentifier, progressData) => record3.record(courseId, studentIdentifier, progressData),
117275
117350
  session: (courseId, studentIdentifier, sessionData) => session2.record(courseId, studentIdentifier, sessionData),
117276
117351
  listEvents: (params) => deps.events.listEvents(params),
@@ -118560,6 +118635,7 @@ var StudentNotFoundError;
118560
118635
  var ConfigurationError;
118561
118636
  var UUID_REGEX2;
118562
118637
  var ONEROSTER_PATHS;
118638
+ var NO_MASTERY_AWARD;
118563
118639
  var TIMEBACK_API_URLS;
118564
118640
  var QTI_API_URL = "https://qti.alpha-1edtech.ai/api";
118565
118641
  var TIMEBACK_AUTH_URLS;
@@ -118576,6 +118652,7 @@ var GRADE_VALUES3;
118576
118652
  var MASTERY_WRITE_CAPPED_WARNING_CODE = "MASTERY_WRITE_CAPPED";
118577
118653
  var EmailSchema;
118578
118654
  var init_dist5 = __esm(async () => {
118655
+ init_spans();
118579
118656
  init_spans();
118580
118657
  init_src();
118581
118658
  init_spans();
@@ -118636,6 +118713,11 @@ var init_dist5 = __esm(async () => {
118636
118713
  courses: "/ims/oneroster/rostering/v1p2/courses",
118637
118714
  componentResources: "/ims/oneroster/rostering/v1p2/courses/component-resources"
118638
118715
  };
118716
+ NO_MASTERY_AWARD = {
118717
+ masteredUnitsApplied: 0,
118718
+ masteryAchieved: false,
118719
+ masteryRevoked: false
118720
+ };
118639
118721
  TIMEBACK_API_URLS = {
118640
118722
  production: "https://api.alpha-1edtech.ai",
118641
118723
  staging: "https://api.staging.alpha-1edtech.com"
@@ -121390,6 +121472,62 @@ function stringField2(value) {
121390
121472
  function firstStringField2(...values) {
121391
121473
  return values.map(stringField2).find(Boolean) ?? "";
121392
121474
  }
121475
+ function normalizedWhitespace2(value) {
121476
+ return value.normalize("NFKC").trim().replace(/\s+/g, " ");
121477
+ }
121478
+ function normalizedIdentityCase2(value) {
121479
+ return value.toLocaleUpperCase("en-US");
121480
+ }
121481
+ function frameworkAliasKey2(value) {
121482
+ return value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/g, "");
121483
+ }
121484
+ function isCommonCoreMathIdentifier2(identifier) {
121485
+ const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
121486
+ 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);
121487
+ }
121488
+ function isCommonCoreElaIdentifier2(identifier) {
121489
+ const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
121490
+ 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);
121491
+ }
121492
+ function canonicalFramework2(authoredFramework, identifier) {
121493
+ const aliasKey = frameworkAliasKey2(authoredFramework);
121494
+ if (COMMON_CORE_MATH_FRAMEWORK_ALIASES2.has(aliasKey) || COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey) && isCommonCoreMathIdentifier2(identifier)) {
121495
+ return "CCSS.Math";
121496
+ }
121497
+ if (COMMON_CORE_ELA_FRAMEWORK_ALIASES2.has(aliasKey) || COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey) && isCommonCoreElaIdentifier2(identifier)) {
121498
+ return "CCSS.ELA-Literacy";
121499
+ }
121500
+ if (COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey)) {
121501
+ return "CCSS";
121502
+ }
121503
+ return normalizedIdentityCase2(authoredFramework);
121504
+ }
121505
+ function canonicalIdentifier2(framework, authoredIdentifier) {
121506
+ const identifier = normalizedIdentityCase2(normalizedWhitespace2(authoredIdentifier));
121507
+ if (framework === "CCSS.Math") {
121508
+ return identifier.replace(/^CCSS\.MATH\.CONTENT\./, "").replace(/^CCSS\.MATH\.PRACTICE\./, "").replace(/^CCSS\.MATH\./, "");
121509
+ }
121510
+ if (framework === "CCSS.ELA-Literacy") {
121511
+ return identifier.replace(/^CCSS\.ELA-LITERACY\./, "");
121512
+ }
121513
+ return identifier;
121514
+ }
121515
+ function canonicalAssessmentStandardRef2(standard) {
121516
+ const authoredFramework = normalizedWhitespace2(standard.framework);
121517
+ const framework = canonicalFramework2(authoredFramework, standard.identifier);
121518
+ const identifier = canonicalIdentifier2(framework, standard.identifier);
121519
+ return {
121520
+ framework,
121521
+ identifier
121522
+ };
121523
+ }
121524
+ function assessmentStandardRefKey2(standard) {
121525
+ const canonical = canonicalAssessmentStandardRef2(standard);
121526
+ return JSON.stringify([
121527
+ normalizedIdentityCase2(canonical.framework),
121528
+ normalizedIdentityCase2(canonical.identifier)
121529
+ ]);
121530
+ }
121393
121531
  function dedupeStandards2(standards) {
121394
121532
  const deduped = new Map;
121395
121533
  for (const standard of standards) {
@@ -124161,6 +124299,22 @@ var init_locks = __esm(() => {
124161
124299
  }
124162
124300
  };
124163
124301
  });
124302
+ async function crossAssessmentAttemptLockBarrier(lock, db2, attemptId) {
124303
+ const maxAttempts = 5;
124304
+ for (let attempt = 1;attempt <= maxAttempts; attempt += 1) {
124305
+ try {
124306
+ await lock(db2, [assessmentRuntimeAttemptLockKey(attemptId)], async () => {
124307
+ return;
124308
+ });
124309
+ return;
124310
+ } catch (error88) {
124311
+ if (!(error88 instanceof ServiceUnavailableError) || attempt === maxAttempts) {
124312
+ throw error88;
124313
+ }
124314
+ await sleep(25);
124315
+ }
124316
+ }
124317
+ }
124164
124318
  function normalizeKeys(keys) {
124165
124319
  return [...new Set(keys)].toSorted();
124166
124320
  }
@@ -125176,6 +125330,97 @@ var init_timeback_qti_authoring_util = __esm(() => {
125176
125330
  buildMatchQuestionXml
125177
125331
  ];
125178
125332
  });
125333
+ function assessmentPreparationContentionError(expectedResponseVersion, responseVersion, kind) {
125334
+ if (expectedResponseVersion === responseVersion) {
125335
+ return new ServiceUnavailableError(`The ${kind} response state changed repeatedly while this item was being prepared. Try again shortly.`, {
125336
+ retryable: true,
125337
+ reason: "PREPARATION_CONTENTION",
125338
+ expectedResponseVersion,
125339
+ responseVersion
125340
+ });
125341
+ }
125342
+ return AssessmentRuntimeError.from(assessmentResponseVersionConflict(expectedResponseVersion, responseVersion));
125343
+ }
125344
+ function assessmentTrackableCourse(integration) {
125345
+ if (!isTimebackGrade2(integration.grade) || !isTimebackSubject(integration.subject)) {
125346
+ return null;
125347
+ }
125348
+ return { grade: integration.grade, subject: integration.subject };
125349
+ }
125350
+ function buildAssessmentActivityData(input) {
125351
+ if (!input.course) {
125352
+ return;
125353
+ }
125354
+ return {
125355
+ activityId: input.activityId,
125356
+ activityName: input.activityName,
125357
+ ...input.course,
125358
+ courseId: input.courseId
125359
+ };
125360
+ }
125361
+ function buildDiagnosticAssessmentSnapshot(input) {
125362
+ return {
125363
+ attemptId: input.attemptId,
125364
+ responseVersion: input.metadata.responseVersion,
125365
+ status: "in_progress",
125366
+ flow: "platform-routed-item-submit",
125367
+ ...input.activityData ? { activityData: input.activityData } : {},
125368
+ assessment: assessmentPresentationForAttempt(input.assessment, input.attemptId),
125369
+ selection: {
125370
+ kind: "platform-routed-diagnostic",
125371
+ purpose: "diagnostic",
125372
+ definitionId: input.metadata.diagnostic.definitionId,
125373
+ diagnosticKey: input.metadata.diagnostic.diagnosticKey,
125374
+ routingRevision: input.metadata.diagnostic.routingRevision
125375
+ },
125376
+ routing: projectDiagnosticRoutingSnapshot(input.metadata.diagnostic.routingRevision, input.state),
125377
+ completion: null
125378
+ };
125379
+ }
125380
+ function buildConventionalAssessmentSnapshot(input) {
125381
+ const metadata2 = input.metadata;
125382
+ let selection;
125383
+ if (metadata2.purpose === "review") {
125384
+ selection = {
125385
+ kind: "standards-review",
125386
+ purpose: metadata2.purpose,
125387
+ standards: [...metadata2.review.standards],
125388
+ candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
125389
+ selections: [...metadata2.review.selections],
125390
+ fulfillment: metadata2.review.fulfillment
125391
+ };
125392
+ } else if (metadata2.purpose === "mastery") {
125393
+ selection = {
125394
+ kind: "standard-quiz",
125395
+ purpose: metadata2.purpose,
125396
+ standard: metadata2.mastery.standard
125397
+ };
125398
+ } else {
125399
+ selection = { kind: "fixed-test", purpose: metadata2.purpose };
125400
+ }
125401
+ return {
125402
+ attemptId: input.attemptId,
125403
+ responseVersion: metadata2.responseVersion,
125404
+ status: "in_progress",
125405
+ flow: assessmentFlowForPurpose(metadata2.purpose),
125406
+ ...input.activityData ? { activityData: input.activityData } : {},
125407
+ assessment: assessmentPresentationForAttempt(input.assessment, input.attemptId),
125408
+ responses: metadata2.responses,
125409
+ itemSubmissions: metadata2.itemSubmissions,
125410
+ score: null,
125411
+ selection
125412
+ };
125413
+ }
125414
+ function buildAssessmentAwardRecord(input, mastery) {
125415
+ return {
125416
+ xpAwarded: input.xpAwarded,
125417
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
125418
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {},
125419
+ masteredUnitsApplied: mastery.masteredUnitsApplied,
125420
+ ...mastery.pctCompleteApp !== undefined ? { pctCompleteApp: mastery.pctCompleteApp } : {},
125421
+ ...mastery.warnings ? { warnings: mastery.warnings } : {}
125422
+ };
125423
+ }
125179
125424
  function stageAssessmentAttemptSupersession(attempt) {
125180
125425
  Object.assign(attempt.result, ASSESSMENT_ATTEMPT_SUPERSEDED);
125181
125426
  Object.assign(attempt.selection, ASSESSMENT_ATTEMPT_SUPERSEDED);
@@ -125386,13 +125631,35 @@ function buildAssessmentSubmitResult(input) {
125386
125631
  }
125387
125632
  return { ...base, purpose: input.metadata.purpose };
125388
125633
  }
125634
+ function assessmentAwardFromResult(result, metadata2) {
125635
+ if (metadata2.award) {
125636
+ return metadata2.award;
125637
+ }
125638
+ const xp = result.metadata?.xp;
125639
+ return typeof xp === "number" && Number.isFinite(xp) && xp >= 0 ? { xpAwarded: xp, masteredUnitsApplied: 0 } : null;
125640
+ }
125389
125641
  function buildCompletedAssessmentSubmitResult(input) {
125390
- const submitted = buildAssessmentSubmitResult(input);
125391
- return submitted ? {
125642
+ const attemptId = input.result.sourcedId;
125643
+ const award = assessmentAwardFromResult(input.result, input.metadata);
125644
+ if (award === null) {
125645
+ throw new GoneError("This completed assessment is missing its recorded XP.", {
125646
+ attemptId
125647
+ });
125648
+ }
125649
+ const submitted = buildAssessmentSubmitResult({ attemptId, metadata: input.metadata });
125650
+ if (!submitted) {
125651
+ throw new GoneError("This completed assessment has incomplete persisted results.", {
125652
+ attemptId
125653
+ });
125654
+ }
125655
+ return {
125392
125656
  ...submitted,
125393
125657
  status: "completed",
125394
- xpAwarded: input.xpAwarded
125395
- } : null;
125658
+ xpAwarded: award.xpAwarded,
125659
+ masteredUnitsApplied: award.masteredUnitsApplied,
125660
+ ...award.pctCompleteApp !== undefined ? { pctCompleteApp: award.pctCompleteApp } : {},
125661
+ ...award.warnings ? { warnings: award.warnings } : {}
125662
+ };
125396
125663
  }
125397
125664
  function assessmentAttemptId(input) {
125398
125665
  return deterministicUUID([
@@ -125719,6 +125986,22 @@ function assessmentFixtureScoringKeys(assessment, questions) {
125719
125986
  ...Object.keys(responseAreas).length > 0 ? { responseAreas } : {}
125720
125987
  };
125721
125988
  }
125989
+ function prepareDiagnosticAssessmentResponses(assessment, current, input) {
125990
+ const update2 = { [input.itemIdentifier]: input.responses };
125991
+ validateAssessmentResponseUpdate(assessment, update2);
125992
+ const responses = applyAssessmentResponseUpdate(current, update2);
125993
+ const item = assessment.items.find((candidate) => candidate.identifier === input.itemIdentifier);
125994
+ const itemResponses = responses[input.itemIdentifier];
125995
+ const missingResponse = item?.interactions.find((interaction) => itemResponses?.[interaction.responseIdentifier] === undefined);
125996
+ if (!item || !itemResponses || missingResponse) {
125997
+ throw new AssessmentRuntimeError("INVALID_RESPONSE", `Diagnostic item ${input.itemIdentifier} requires a response for every interaction.`, {
125998
+ itemIdentifier: input.itemIdentifier,
125999
+ ...missingResponse ? { responseIdentifier: missingResponse.responseIdentifier } : {}
126000
+ });
126001
+ }
126002
+ validateAssessmentResponses(assessment, { [input.itemIdentifier]: itemResponses });
126003
+ return responses;
126004
+ }
125722
126005
  function validateAssessmentResponseUpdate(assessment, update2) {
125723
126006
  const message = assessmentResponseUpdateValidationMessage(assessment, update2);
125724
126007
  if (message) {
@@ -125805,7 +126088,7 @@ function buildAssessmentResultSubmission(input) {
125805
126088
  }
125806
126089
  };
125807
126090
  }
125808
- function buildAssessmentResultXpFinalization(input) {
126091
+ function buildAssessmentResultAwardFinalization(input) {
125809
126092
  const completion = input.metadata.completion;
125810
126093
  if (!completion || completion.totalQuestions === undefined) {
125811
126094
  throw new Error("A scored assessment must retain its completion facts before finalization");
@@ -125813,8 +126096,10 @@ function buildAssessmentResultXpFinalization(input) {
125813
126096
  const metadata2 = {
125814
126097
  ...input.metadata,
125815
126098
  finalizedAt: input.timestamp,
125816
- updatedAt: input.timestamp
126099
+ updatedAt: input.timestamp,
126100
+ award: input.award
125817
126101
  };
126102
+ const masteryRequested = input.award.masteredUnits !== undefined || input.award.masteredUnitsAbsolute !== undefined;
125818
126103
  return {
125819
126104
  metadata: metadata2,
125820
126105
  resultUpdate: {
@@ -125826,7 +126111,8 @@ function buildAssessmentResultXpFinalization(input) {
125826
126111
  ...ASSESSMENT_ATTEMPT_COMPLETED,
125827
126112
  metadata: {
125828
126113
  ...mergeAssessmentRuntimeMetadata(input.result.metadata, metadata2),
125829
- xp: input.xpAwarded,
126114
+ xp: input.award.xpAwarded,
126115
+ ...masteryRequested ? { masteredUnits: input.award.masteredUnitsApplied } : {},
125830
126116
  totalQuestions: completion.totalQuestions,
125831
126117
  correctQuestions: completion.correctQuestions,
125832
126118
  appName: input.appName
@@ -125839,12 +126125,217 @@ var init_timeback_assessment_runtime_util = __esm(() => {
125839
126125
  init_src();
125840
126126
  init_assessment_runtime2();
125841
126127
  init_qti();
126128
+ init_types2();
125842
126129
  init_utils6();
125843
126130
  init_timeback4();
125844
126131
  init_uuid();
125845
126132
  init_errors2();
125846
126133
  PLAYABLE_SHAPE_SET = new Set(PLAYABLE_ASSESSMENT_SHAPES);
125847
126134
  });
126135
+ function reviewBankTestItemIdentifiers(test) {
126136
+ return qtiTestParts(test).flatMap((part) => part["qti-assessment-section"].flatMap((section) => (section["qti-assessment-item-ref"] ?? []).map((reference) => reference.identifier)));
126137
+ }
126138
+ function canonicalSyncContributors(contributors) {
126139
+ return contributors.map((contributor) => {
126140
+ const canonical = canonicalContributorIdentity(contributor);
126141
+ return {
126142
+ ...contributor,
126143
+ ...canonical
126144
+ };
126145
+ }).toSorted((left, right) => compareCodeUnits(left.standardKey, right.standardKey) || compareCodeUnits(left.qtiTestIdentifier, right.qtiTestIdentifier) || compareCodeUnits(left.assessmentKey, right.assessmentKey));
126146
+ }
126147
+ function canonicalContributorIdentity(contributor) {
126148
+ const standard = canonicalReviewStandardRef(contributor.standard);
126149
+ if (!standard) {
126150
+ throw new ValidationError(`Mastery assessment ${contributor.qtiTestIdentifier} has an invalid standard`);
126151
+ }
126152
+ return {
126153
+ qtiTestIdentifier: contributor.qtiTestIdentifier,
126154
+ standard,
126155
+ standardKey: assessmentStandardRefKey2(standard)
126156
+ };
126157
+ }
126158
+ async function reviewBankContributorFingerprint(contributors) {
126159
+ const uniqueTuples = new Map;
126160
+ for (const contributor of contributors) {
126161
+ const { qtiTestIdentifier, standardKey } = canonicalContributorIdentity(contributor);
126162
+ const tuple3 = { qtiTestIdentifier, standardKey };
126163
+ uniqueTuples.set(canonicalJson2(tuple3), tuple3);
126164
+ }
126165
+ const digest = await sha256Hex(canonicalJson2([...uniqueTuples.values()].toSorted((left, right) => compareCodeUnits(left.standardKey, right.standardKey) || compareCodeUnits(left.qtiTestIdentifier, right.qtiTestIdentifier))));
126166
+ return `review-bank-contributors-v1:${digest}`;
126167
+ }
126168
+ function conflictWarning(input) {
126169
+ const retained = `${input.retainedStandard.framework} · ${input.retainedStandard.identifier}`;
126170
+ const skipped = `${input.skippedStandard.framework} · ${input.skippedStandard.identifier}`;
126171
+ return {
126172
+ code: "QUESTION_STANDARD_CONFLICT",
126173
+ ...input,
126174
+ message: `Question ${input.itemIdentifier} is referenced by multiple mastery standards; retained ${retained} and skipped ${skipped} from ${input.skippedQtiTestIdentifier}.`
126175
+ };
126176
+ }
126177
+ async function prepareReviewBankSynchronization(input) {
126178
+ const contributors = canonicalSyncContributors(input.contributors);
126179
+ const selectedByItem = new Map;
126180
+ const warningKeys = new Set;
126181
+ const warnings = [];
126182
+ for (const contributor of contributors) {
126183
+ const itemIdentifiers2 = [
126184
+ ...new Set(reviewBankTestItemIdentifiers(contributor.test))
126185
+ ].toSorted();
126186
+ for (const itemIdentifier of itemIdentifiers2) {
126187
+ const selected = selectedByItem.get(itemIdentifier);
126188
+ if (!selected) {
126189
+ selectedByItem.set(itemIdentifier, {
126190
+ standard: contributor.standard,
126191
+ standardKey: contributor.standardKey
126192
+ });
126193
+ } else if (selected.standardKey !== contributor.standardKey) {
126194
+ const warningKey = `${itemIdentifier}\x00${contributor.standardKey}`;
126195
+ if (!warningKeys.has(warningKey)) {
126196
+ warningKeys.add(warningKey);
126197
+ warnings.push(conflictWarning({
126198
+ itemIdentifier,
126199
+ retainedStandard: selected.standard,
126200
+ skippedStandard: contributor.standard,
126201
+ skippedQtiTestIdentifier: contributor.qtiTestIdentifier
126202
+ }));
126203
+ }
126204
+ }
126205
+ }
126206
+ }
126207
+ const itemIdentifiers = [...selectedByItem.keys()].toSorted();
126208
+ const sourceContentRevision = input.contributorFingerprint ?? await reviewBankContributorFingerprint(contributors);
126209
+ const bank = await buildReviewBankIndexFromReferences({
126210
+ bankIdentifier: input.bankIdentifier,
126211
+ sourceContentRevision,
126212
+ items: itemIdentifiers.map((itemIdentifier) => ({
126213
+ itemIdentifier,
126214
+ standards: [selectedByItem.get(itemIdentifier).standard]
126215
+ }))
126216
+ });
126217
+ const manifest = await buildReviewBankManifest(bank);
126218
+ return {
126219
+ bankIdentifier: input.bankIdentifier,
126220
+ contributorFingerprint: sourceContentRevision,
126221
+ itemIdentifiers,
126222
+ manifest,
126223
+ warnings,
126224
+ summary: {
126225
+ itemCount: itemIdentifiers.length,
126226
+ standardCount: Object.keys(manifest.itemsByStandard).length,
126227
+ contributorCount: contributors.length,
126228
+ sourceFingerprint: manifest.sourceFingerprint
126229
+ }
126230
+ };
126231
+ }
126232
+ function reviewBankSinkMetadata(integrationId, plan) {
126233
+ return {
126234
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
126235
+ integrationId,
126236
+ [PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY]: {
126237
+ version: PLAYCADEMY_REVIEW_BANK_SINK_VERSION,
126238
+ integrationId,
126239
+ contributorFingerprint: plan.contributorFingerprint,
126240
+ warnings: plan.warnings
126241
+ },
126242
+ [PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: plan.manifest
126243
+ };
126244
+ }
126245
+ function reviewBankSinkMarker(test) {
126246
+ const parsed = ReviewBankSinkMarkerSchema.safeParse(test.metadata?.[PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY]);
126247
+ return parsed.success ? parsed.data : null;
126248
+ }
126249
+ function reviewBankSinkState(test, integrationId) {
126250
+ assertReviewBankSinkOwnership(test, integrationId);
126251
+ const marker = reviewBankSinkMarker(test);
126252
+ return {
126253
+ contributorFingerprint: marker?.contributorFingerprint ?? null,
126254
+ warnings: marker?.warnings ?? []
126255
+ };
126256
+ }
126257
+ async function unchangedReviewBankSynchronizationResult(input) {
126258
+ const sinkState = reviewBankSinkState(input.sink, input.integrationId);
126259
+ if (sinkState.contributorFingerprint !== input.contributorFingerprint) {
126260
+ return null;
126261
+ }
126262
+ const membershipItemIdentifiers = reviewBankTestItemIdentifiers(input.sink);
126263
+ const bank = await reviewBankIndexFromManifest(input.sink.metadata, {
126264
+ bankIdentifier: input.sink.identifier,
126265
+ membershipItemIdentifiers
126266
+ });
126267
+ const manifest = input.sink.metadata?.[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY];
126268
+ if (!bank || typeof manifest?.sourceFingerprint !== "string") {
126269
+ return null;
126270
+ }
126271
+ const standardCount = new Set(bank.items.flatMap((item) => item.standards.map(assessmentStandardRefKey2))).size;
126272
+ return {
126273
+ qtiTestIdentifier: input.sink.identifier,
126274
+ itemCount: bank.items.length,
126275
+ standardCount,
126276
+ contributorCount: input.contributorCount,
126277
+ sourceFingerprint: manifest.sourceFingerprint,
126278
+ addedItemCount: 0,
126279
+ removedItemCount: 0,
126280
+ unchanged: true,
126281
+ warnings: sinkState.warnings
126282
+ };
126283
+ }
126284
+ function reviewBankSynchronizationDiff(input) {
126285
+ let previousMembershipReadable = true;
126286
+ let previousItemIdentifiers = [];
126287
+ if (input.existingSink) {
126288
+ try {
126289
+ previousItemIdentifiers = reviewBankTestItemIdentifiers(input.existingSink);
126290
+ } catch {
126291
+ previousMembershipReadable = false;
126292
+ }
126293
+ }
126294
+ const previousItems = new Set(previousItemIdentifiers);
126295
+ const desiredItems = new Set(input.plan.itemIdentifiers);
126296
+ const addedItemCount = input.plan.itemIdentifiers.filter((identifier) => !previousItems.has(identifier)).length;
126297
+ const removedItemCount = [...previousItems].filter((identifier) => !desiredItems.has(identifier)).length;
126298
+ const currentManifest = input.existingSink?.metadata?.[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY];
126299
+ const currentContributorFingerprint = input.existingSink ? reviewBankSinkMarker(input.existingSink)?.contributorFingerprint ?? null : null;
126300
+ const canonicalMembership = previousItemIdentifiers.length === input.plan.itemIdentifiers.length && previousItemIdentifiers.every((identifier, index2) => identifier === input.plan.itemIdentifiers[index2]);
126301
+ const unchanged = input.existingSink !== null && previousMembershipReadable && canonicalMembership && canonicalJson2(currentManifest) === canonicalJson2(input.plan.manifest) && currentContributorFingerprint === input.plan.contributorFingerprint;
126302
+ return { addedItemCount, removedItemCount, unchanged };
126303
+ }
126304
+ function assertReviewBankSinkOwnership(test, integrationId) {
126305
+ const marker = reviewBankSinkMarker(test);
126306
+ if (marker?.integrationId !== integrationId || test.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM || test.identifier !== reviewBankSinkIdentifier(integrationId)) {
126307
+ throw new ValidationError(`QTI assessment ${test.identifier} is not the review-bank sink for this integration`);
126308
+ }
126309
+ }
126310
+ function buildReviewBankSinkInput(input) {
126311
+ return {
126312
+ ...input.includeIdentifier ? { identifier: input.identifier } : {},
126313
+ title: input.title,
126314
+ metadata: input.metadata,
126315
+ "qti-test-part": [
126316
+ {
126317
+ identifier: `${input.identifier}-part1`,
126318
+ navigationMode: "linear",
126319
+ submissionMode: "individual",
126320
+ "qti-assessment-section": [
126321
+ {
126322
+ identifier: `${input.identifier}-section1`,
126323
+ title: "Questions",
126324
+ visible: true,
126325
+ required: true,
126326
+ fixed: false,
126327
+ sequence: 1,
126328
+ "qti-assessment-item-ref": input.itemIdentifiers.map((itemIdentifier, index2) => ({
126329
+ identifier: itemIdentifier,
126330
+ href: input.itemHref(itemIdentifier),
126331
+ sequence: index2 + 1
126332
+ }))
126333
+ }
126334
+ ]
126335
+ }
126336
+ ]
126337
+ };
126338
+ }
125848
126339
  function reviewMappingIssueSummary(label, identifiers) {
125849
126340
  if (identifiers.length === 0) {
125850
126341
  return null;
@@ -125893,11 +126384,50 @@ async function prepareReviewMappingUpdate(test, questions) {
125893
126384
  }
125894
126385
  };
125895
126386
  }
126387
+ var PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY = "playcademyReviewBankSink";
126388
+ var PLAYCADEMY_REVIEW_BANK_SINK_VERSION = 1;
126389
+ var StoredSinkWarningSchema;
126390
+ var ReviewBankSinkMarkerSchema;
125896
126391
  var init_timeback_review_mapping_util = __esm(() => {
126392
+ init_esm();
125897
126393
  init_assessment_runtime2();
126394
+ init_qti();
126395
+ init_timeback3();
125898
126396
  init_errors2();
125899
126397
  init_timeback_assessment_runtime_util();
125900
126398
  init_timeback_qti_authoring_util();
126399
+ StoredSinkWarningSchema = exports_external.object({
126400
+ code: exports_external.literal("QUESTION_STANDARD_CONFLICT"),
126401
+ itemIdentifier: exports_external.string(),
126402
+ retainedStandard: exports_external.object({ framework: exports_external.string(), identifier: exports_external.string() }),
126403
+ skippedStandard: exports_external.object({ framework: exports_external.string(), identifier: exports_external.string() }),
126404
+ skippedQtiTestIdentifier: exports_external.string()
126405
+ });
126406
+ ReviewBankSinkMarkerSchema = exports_external.object({
126407
+ version: exports_external.literal(PLAYCADEMY_REVIEW_BANK_SINK_VERSION),
126408
+ integrationId: exports_external.string(),
126409
+ contributorFingerprint: exports_external.string().nullable().catch(null),
126410
+ warnings: exports_external.array(StoredSinkWarningSchema).nullable().catch(null)
126411
+ }).transform((marker) => {
126412
+ if (!marker.contributorFingerprint || !marker.warnings) {
126413
+ return { ...marker, contributorFingerprint: null, warnings: [] };
126414
+ }
126415
+ const warnings = [];
126416
+ for (const warning of marker.warnings) {
126417
+ const retained = canonicalReviewStandardRef(warning.retainedStandard);
126418
+ const skipped = canonicalReviewStandardRef(warning.skippedStandard);
126419
+ if (!retained || !skipped) {
126420
+ return { ...marker, contributorFingerprint: null, warnings: [] };
126421
+ }
126422
+ warnings.push(conflictWarning({
126423
+ itemIdentifier: warning.itemIdentifier,
126424
+ retainedStandard: retained,
126425
+ skippedStandard: skipped,
126426
+ skippedQtiTestIdentifier: warning.skippedQtiTestIdentifier
126427
+ }));
126428
+ }
126429
+ return { ...marker, warnings };
126430
+ });
125901
126431
  });
125902
126432
  function assessmentKeyFromManagedQtiIdentifier(identifier) {
125903
126433
  const assessmentKey = /^playcademy-test\.(.+)\.[a-f0-9]{64}$/.exec(identifier)?.[1];
@@ -126201,11 +126731,6 @@ function assertAssessmentHasQuestions(questions) {
126201
126731
  throw new ValidationError("An assessment must contain at least one question to publish");
126202
126732
  }
126203
126733
  }
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
126734
  function planAssessmentRemoval(status) {
126210
126735
  if (status === "draft") {
126211
126736
  return { kind: "delete", action: "discarded", operation: "discard_draft" };
@@ -126266,6 +126791,9 @@ function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
126266
126791
  var init_timeback_assessment_rules_util = __esm(() => {
126267
126792
  init_errors2();
126268
126793
  });
126794
+ function qtiItemHref(client2, itemIdentifier) {
126795
+ return `${client2.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
126796
+ }
126269
126797
  async function hydrateQtiTestQuestions(client2, references) {
126270
126798
  const questions = await runWithConcurrency(references.questions, QTI_HYDRATION_CONCURRENCY, async (reference) => ({
126271
126799
  ...reference,
@@ -126288,6 +126816,28 @@ async function hydrateQtiTestQuestionSelection(client2, references, itemIdentifi
126288
126816
  questions: selectedReferences
126289
126817
  });
126290
126818
  }
126819
+ async function hydrateQtiItemSelection(client2, test, itemIdentifiers) {
126820
+ const testPart = qtiTestParts(test)[0];
126821
+ const section = testPart?.["qti-assessment-section"][0];
126822
+ if (!testPart || !section) {
126823
+ throw new Error(`QTI assessment ${test.identifier} has no section to reference items from`);
126824
+ }
126825
+ const questions = await runWithConcurrency(itemIdentifiers, QTI_HYDRATION_CONCURRENCY, async (itemIdentifier) => ({
126826
+ reference: {
126827
+ identifier: itemIdentifier,
126828
+ href: qtiItemHref(client2, itemIdentifier),
126829
+ testPart: testPart.identifier,
126830
+ section: section.identifier
126831
+ },
126832
+ question: await client2.qtiApi.assessmentItems.get(itemIdentifier)
126833
+ }));
126834
+ return {
126835
+ assessmentTest: test.identifier,
126836
+ title: test.title,
126837
+ totalQuestions: questions.length,
126838
+ questions
126839
+ };
126840
+ }
126291
126841
  async function loadQtiTestReferences(client2, identifier) {
126292
126842
  const [test, references] = await Promise.all([
126293
126843
  client2.qtiApi.assessmentTests.get(identifier),
@@ -126303,7 +126853,9 @@ async function loadHydratedQtiTest(client2, identifier) {
126303
126853
  };
126304
126854
  }
126305
126855
  var QTI_HYDRATION_CONCURRENCY = 8;
126306
- var init_timeback_qti_hydration_util = () => {};
126856
+ var init_timeback_qti_hydration_util = __esm(() => {
126857
+ init_timeback_qti_authoring_util();
126858
+ });
126307
126859
  var TimebackAssessmentRuntimeService;
126308
126860
  var init_timeback_assessment_runtime_service = __esm(async () => {
126309
126861
  init_drizzle_orm();
@@ -126331,8 +126883,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
126331
126883
  static EXPORT_CONCURRENCY = 4;
126332
126884
  static ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS = 3;
126333
126885
  static ASSESSMENT_VERSION_CONFIRMATION_DELAY_MS = 25;
126334
- static PROJECTION_BARRIER_MAX_ATTEMPTS = 5;
126335
- static PROJECTION_BARRIER_RETRY_DELAY_MS = 25;
126336
126886
  static SCORING_CONCURRENCY = 4;
126337
126887
  static STALE_AWARD_AGE_MS = 900000;
126338
126888
  deps;
@@ -126875,15 +127425,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
126875
127425
  this.assertInProgress(attempt.result);
126876
127426
  if (!preview || preview.responseVersion !== attempt.metadata.responseVersion || preview.submissionId !== input.submissionId || preview.itemIdentifier !== input.itemIdentifier) {
126877
127427
  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));
127428
+ throw assessmentPreparationContentionError(input.expectedResponseVersion, attempt.metadata.responseVersion, "assessment");
126887
127429
  }
126888
127430
  return { action: "prepare" };
126889
127431
  }
@@ -127006,15 +127548,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127006
127548
  this.assertInProgress(attempt.result);
127007
127549
  if (!preview || preview.responseVersion !== metadata2.responseVersion || preview.submissionId !== input.submissionId || preview.routingNodeKey !== input.routingNodeKey || preview.itemIdentifier !== input.itemIdentifier) {
127008
127550
  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));
127551
+ throw assessmentPreparationContentionError(input.expectedResponseVersion, metadata2.responseVersion, "diagnostic");
127018
127552
  }
127019
127553
  return { action: "prepare" };
127020
127554
  }
@@ -127029,7 +127563,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127029
127563
  itemIdentifier: input.itemIdentifier
127030
127564
  }));
127031
127565
  }
127032
- const responses = this.prepareDiagnosticResponses(preview.assessment, metadata2.responses, input);
127566
+ const responses = prepareDiagnosticAssessmentResponses(preview.assessment, metadata2.responses, input);
127033
127567
  const scoring = preview.scoring;
127034
127568
  if (!scoring || typeof scoring.isCorrect !== "boolean") {
127035
127569
  throw AssessmentRuntimeError.from(assessmentFlowViolation(`Diagnostic item ${input.itemIdentifier} did not produce determinate binary grading.`, { itemIdentifier: input.itemIdentifier }));
@@ -127102,22 +127636,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127102
127636
  answered: submission.answered
127103
127637
  };
127104
127638
  }
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
127639
  async prepareDiagnosticItemSubmission(params, input) {
127122
127640
  try {
127123
127641
  return await this.scoreDiagnosticItemSubmission(params, input);
@@ -127151,7 +127669,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127151
127669
  const expected = loaded.state.next;
127152
127670
  let scoring = null;
127153
127671
  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);
127672
+ const responses = prepareDiagnosticAssessmentResponses(assessment, attempt.metadata.responses, input);
127155
127673
  scoring = await this.scoreItem(assessment, responses, input.itemIdentifier);
127156
127674
  }
127157
127675
  return {
@@ -127422,11 +127940,14 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127422
127940
  inProgress: attempt.result.inProgress ?? "",
127423
127941
  scoreStatus: attempt.result.scoreStatus,
127424
127942
  submissionId: attempt.metadata.submissionId,
127425
- xpAwarded: this.xpFromResult(attempt.result) ?? undefined
127943
+ award: assessmentAwardFromResult(attempt.result, attempt.metadata) ?? undefined
127426
127944
  }, input);
127427
127945
  if (disposition === "replay") {
127428
127946
  return {
127429
- response: this.completedResult(attempt.result, attempt.metadata),
127947
+ response: buildCompletedAssessmentSubmitResult({
127948
+ result: attempt.result,
127949
+ metadata: attempt.metadata
127950
+ }),
127430
127951
  completion: this.replayCompletion(attempt, input.submissionId, params, game2)
127431
127952
  };
127432
127953
  }
@@ -127434,20 +127955,27 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127434
127955
  throw AssessmentRuntimeError.from(assessmentAwardConflict({
127435
127956
  attemptId: attempt.result.sourcedId,
127436
127957
  submissionId: input.submissionId,
127437
- xpAwarded: input.xpAwarded
127958
+ xpAwarded: input.xpAwarded,
127959
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
127960
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {}
127438
127961
  }));
127439
127962
  }
127440
127963
  if (disposition === "reject") {
127441
127964
  throw AssessmentRuntimeError.from(assessmentAlreadySubmitted(attempt.result.sourcedId));
127442
127965
  }
127443
- const submitted = this.submittedResult(attempt.result, attempt.metadata);
127444
- const finalization = buildAssessmentResultXpFinalization({
127966
+ const mastery = await this.evaluateMasteryAward(attempt, params.studentId, input);
127967
+ const awardRecord = buildAssessmentAwardRecord(input, mastery);
127968
+ const finalization = buildAssessmentResultAwardFinalization({
127445
127969
  result: attempt.result,
127446
127970
  metadata: attempt.metadata,
127447
- xpAwarded: input.xpAwarded,
127971
+ award: awardRecord,
127448
127972
  appName: game2.appName,
127449
127973
  timestamp: new Date().toISOString()
127450
127974
  });
127975
+ const response = buildCompletedAssessmentSubmitResult({
127976
+ result: attempt.result,
127977
+ metadata: finalization.metadata
127978
+ });
127451
127979
  const finalized = await this.requireClient().api.oneroster.assessmentResults.upsert(attempt.result.sourcedId, finalization.resultUpdate);
127452
127980
  const finalizedForEmission = {
127453
127981
  ...finalized,
@@ -127466,25 +127994,78 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127466
127994
  "app.assessment.submission_id": input.submissionId,
127467
127995
  "app.assessment.purpose": finalization.metadata.purpose,
127468
127996
  "app.assessment.award_age_ms": this.awardAgeMs(attempt.result.scoreDate),
127469
- "app.assessment.xp_awarded": input.xpAwarded
127997
+ "app.assessment.xp_awarded": input.xpAwarded,
127998
+ "app.assessment.mastered_units_applied": mastery.masteredUnitsApplied,
127999
+ "app.assessment.mastery_achieved": mastery.masteryAchieved,
128000
+ "app.assessment.mastery_revoked": mastery.masteryRevoked
127470
128001
  });
128002
+ await this.settleMasteryAward(finalizedAttempt, mastery, input.submissionId, game2);
127471
128003
  return {
127472
- response: {
127473
- ...submitted,
127474
- status: "completed",
127475
- xpAwarded: input.xpAwarded
127476
- },
128004
+ response,
127477
128005
  completion: this.replayCompletion(finalizedAttempt, input.submissionId, params, game2)
127478
128006
  };
127479
128007
  });
127480
128008
  if (award.completion) {
127481
128009
  await this.emitCompletionBestEffort({
127482
128010
  ...award.completion,
127483
- xpAwarded: input.xpAwarded
128011
+ award: award.response
127484
128012
  });
127485
128013
  }
127486
128014
  return award.response;
127487
128015
  }
128016
+ async evaluateMasteryAward(attempt, studentIdentifier, input) {
128017
+ if (!hasMasteryAwardRequest(input)) {
128018
+ return NO_MASTERY_AWARD;
128019
+ }
128020
+ const student = await this.requireClient().roster.resolveStudent(studentIdentifier);
128021
+ return this.requireClient().activity.evaluateMasteryAward({
128022
+ studentId: student.id,
128023
+ courseId: attempt.integration.courseId,
128024
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
128025
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {}
128026
+ });
128027
+ }
128028
+ async settleMasteryAward(attempt, mastery, submissionId, game2) {
128029
+ if (!mastery.masteryAchieved && !mastery.masteryRevoked) {
128030
+ return;
128031
+ }
128032
+ const integration = attempt.integration;
128033
+ const course = this.trackableCourse(integration, "assessment.mastery_settlement_invalid_course_metadata", { "app.assessment.attempt_id": attempt.result.sourcedId });
128034
+ if (!course) {
128035
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
128036
+ return;
128037
+ }
128038
+ try {
128039
+ const student = await this.requireClient().roster.resolveStudent(attempt.result.student.sourcedId);
128040
+ const completionHistoryEventId = `urn:uuid:${await deterministicUUID([
128041
+ "playcademy-assessment-course-completed-event-v1",
128042
+ attempt.result.sourcedId,
128043
+ submissionId
128044
+ ].join("\x00"))}`;
128045
+ const settlement = await this.requireClient().activity.settleMasteryAward(mastery, {
128046
+ studentId: student.id,
128047
+ studentEmail: student.email,
128048
+ courseId: integration.courseId,
128049
+ gameId: integration.gameId,
128050
+ activityId: attempt.metadata.activityId,
128051
+ courseName: attempt.metadata.completion?.courseName ?? "Game Course",
128052
+ subject: course.subject,
128053
+ appName: game2.appName,
128054
+ sensorUrl: game2.sensorUrl,
128055
+ completionHistoryEventId
128056
+ });
128057
+ if (mastery.masteryAchieved && !settlement.completionEntryWritten) {
128058
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
128059
+ }
128060
+ } catch (error88) {
128061
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
128062
+ addEvent("assessment.mastery_settlement_failed", {
128063
+ "app.assessment.attempt_id": attempt.result.sourcedId,
128064
+ "exception.type": errorType(error88),
128065
+ "app.error.message": errorMessage2(error88)
128066
+ });
128067
+ }
128068
+ }
127488
128069
  async finalizeDiagnosticSubmission(input) {
127489
128070
  const { attempt } = input;
127490
128071
  const routing = await this.loadAttemptDiagnosticManifest(attempt.metadata, input.db);
@@ -127931,9 +128512,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127931
128512
  if (this.isSettled(resumed.result)) {
127932
128513
  return this.snapshotForResult(resumed.result, resumed.metadata, context2.integration);
127933
128514
  }
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);
128515
+ const source = await this.loadPinnedReviewBankSource(resumed.metadata);
127937
128516
  const assessment = projectReviewAssessment(source.assessment, source.bank, resumed.metadata.review.selections);
127938
128517
  await this.ensureReviewChildResults({
127939
128518
  result: resumed.result,
@@ -127977,7 +128556,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127977
128556
  membershipItemIdentifiers: loaded.references.questions.map((question) => question.reference.identifier)
127978
128557
  });
127979
128558
  } catch (error88) {
127980
- throw new ServiceUnavailableError(`Review bank ${identifier} has stale or invalid routing metadata. Use Update Mapping in assessment authoring.`, { reason: errorMessage2(error88) });
128559
+ throw new ServiceUnavailableError(`Review bank ${identifier} has stale or invalid routing metadata. Use Sync Review Bank in assessment authoring.`, { reason: errorMessage2(error88) });
127981
128560
  }
127982
128561
  if (!bank) {
127983
128562
  throw new ServiceUnavailableError(`Review bank ${identifier} needs its authoring mapping updated before it can serve review questions.`);
@@ -127993,28 +128572,60 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
127993
128572
  this.reviewBankCache.set(`${identifier}\x00${bank.sourceContentRevision}`, catalog);
127994
128573
  return catalog;
127995
128574
  }
127996
- async hydrateReviewBankSelection(catalog, itemIdentifiers) {
127997
- const cacheKey2 = [
127998
- catalog.test.identifier,
127999
- catalog.bank.sourceContentRevision,
128000
- ...itemIdentifiers
128001
- ].join("\x00");
128575
+ async cachedReviewSelection(input) {
128576
+ const cacheKey2 = [input.identifier, input.contentRevision, ...input.itemIdentifiers].join("\x00");
128002
128577
  const cached3 = this.reviewSelectionCache.get(cacheKey2);
128003
128578
  if (cached3) {
128004
- return { assessment: cached3, bank: catalog.bank };
128579
+ return cached3;
128005
128580
  }
128006
- const questions = await hydrateQtiTestQuestionSelection(this.requireClient(), catalog.references, itemIdentifiers);
128007
- const assessment = await buildPlayableAssessment(catalog.test, questions);
128008
- assessment.contentRevision = catalog.bank.sourceContentRevision;
128581
+ const { test, questions } = await input.hydrate();
128582
+ const assessment = await buildPlayableAssessment(test, questions);
128583
+ assessment.contentRevision = input.contentRevision;
128009
128584
  this.reviewSelectionCache.set(cacheKey2, assessment);
128585
+ return assessment;
128586
+ }
128587
+ async hydrateReviewBankSelection(catalog, itemIdentifiers) {
128588
+ const assessment = await this.cachedReviewSelection({
128589
+ identifier: catalog.test.identifier,
128590
+ contentRevision: catalog.bank.sourceContentRevision,
128591
+ itemIdentifiers,
128592
+ hydrate: async () => ({
128593
+ test: catalog.test,
128594
+ questions: await hydrateQtiTestQuestionSelection(this.requireClient(), catalog.references, itemIdentifiers)
128595
+ })
128596
+ });
128010
128597
  return { assessment, bank: catalog.bank };
128011
128598
  }
128012
- pinnedReviewBankSource(source, metadata2) {
128599
+ async loadPinnedReviewBankSource(metadata2) {
128600
+ const itemIdentifiers = metadata2.review.selections.map((selection) => selection.itemIdentifier);
128601
+ let assessment;
128602
+ try {
128603
+ assessment = await this.cachedReviewSelection({
128604
+ identifier: metadata2.selectedTest.identifier,
128605
+ contentRevision: metadata2.selectedTest.contentRevision,
128606
+ itemIdentifiers,
128607
+ hydrate: async () => {
128608
+ const test = await this.requireClient().qtiApi.assessmentTests.get(metadata2.selectedTest.identifier);
128609
+ return {
128610
+ test,
128611
+ questions: await hydrateQtiItemSelection(this.requireClient(), test, itemIdentifiers)
128612
+ };
128613
+ }
128614
+ });
128615
+ } catch (error88) {
128616
+ if (isApiError(error88) && error88.statusCode === 404) {
128617
+ throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `A selected review item for ${metadata2.selectedTest.identifier} is no longer available.`, {
128618
+ identifier: metadata2.selectedTest.identifier,
128619
+ expectedRevision: metadata2.selectedTest.contentRevision
128620
+ });
128621
+ }
128622
+ throw error88;
128623
+ }
128013
128624
  return {
128014
- assessment: source.assessment,
128625
+ assessment,
128015
128626
  bank: {
128016
- bankIdentifier: source.assessment.identifier,
128017
- sourceContentRevision: source.assessment.contentRevision,
128627
+ bankIdentifier: assessment.identifier,
128628
+ sourceContentRevision: metadata2.selectedTest.contentRevision,
128018
128629
  bankRevision: metadata2.review.bankRevision,
128019
128630
  items: metadata2.review.selections.map((selection) => ({
128020
128631
  itemIdentifier: selection.itemIdentifier,
@@ -128038,9 +128649,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128038
128649
  if (metadata2.purpose !== "review") {
128039
128650
  return this.loadAssessment(metadata2.selectedTest.identifier, metadata2.selectedTest.contentRevision);
128040
128651
  }
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);
128652
+ const source = await this.loadPinnedReviewBankSource(metadata2);
128044
128653
  return projectReviewAssessment(source.assessment, source.bank, metadata2.review.selections);
128045
128654
  }
128046
128655
  isPlatformRoutedDiagnosticMetadata(metadata2) {
@@ -128526,7 +129135,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128526
129135
  async projectReviewItemResponse(params, projection) {
128527
129136
  await this.putReviewChildResponse(projection.result, projection.metadata, projection.itemIdentifier);
128528
129137
  try {
128529
- await this.crossAttemptLockBarrier(params.attemptId);
129138
+ await crossAssessmentAttemptLockBarrier(this.deps.assessmentRuntimeLock, this.deps.db, params.attemptId);
128530
129139
  const latest = await this.peekAttempt(params);
128531
129140
  if (this.isSettled(latest.result) && latest.metadata.purpose === "review") {
128532
129141
  await this.restoreCompletedReviewChildResponses(latest.result, latest.metadata, new Set([projection.itemIdentifier]));
@@ -128540,21 +129149,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128540
129149
  });
128541
129150
  }
128542
129151
  }
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
129152
  async restoreCompletedReviewChildResponses(result, metadata2, changedItemIdentifiers) {
128559
129153
  const outcomes = metadata2.completion?.itemOutcomes;
128560
129154
  const submissionId = metadata2.submissionId;
@@ -128700,7 +129294,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128700
129294
  attemptId: result.sourcedId,
128701
129295
  responseVersion: metadata2.responseVersion,
128702
129296
  status: "completed",
128703
- result: this.completedResult(result, metadata2)
129297
+ result: buildCompletedAssessmentSubmitResult({ result, metadata: metadata2 })
128704
129298
  };
128705
129299
  }
128706
129300
  const assessment = await this.loadAttemptAssessment(metadata2);
@@ -128717,58 +129311,37 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128717
129311
  return this.snapshot(result, metadata2, assessment, integration);
128718
129312
  }
128719
129313
  diagnosticSnapshot(result, metadata2, assessment, integration, state) {
128720
- const activityData = this.activityData(metadata2, assessment, integration);
128721
- return {
129314
+ return buildDiagnosticAssessmentSnapshot({
128722
129315
  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
- };
129316
+ metadata: metadata2,
129317
+ assessment,
129318
+ state,
129319
+ activityData: this.activityData(metadata2, assessment, integration)
129320
+ });
128738
129321
  }
128739
129322
  diagnosticRoutingSnapshot(metadata2, state) {
128740
129323
  return projectDiagnosticRoutingSnapshot(metadata2.diagnostic.routingRevision, state);
128741
129324
  }
128742
129325
  snapshot(result, metadata2, assessment, integration) {
128743
- const selection = this.selectionContext(metadata2);
128744
- const activityData = this.activityData(metadata2, assessment, integration);
128745
- return {
129326
+ return buildConventionalAssessmentSnapshot({
128746
129327
  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
- };
129328
+ metadata: metadata2,
129329
+ assessment,
129330
+ activityData: this.activityData(metadata2, assessment, integration)
129331
+ });
128757
129332
  }
128758
129333
  activityData(metadata2, assessment, integration) {
128759
129334
  const course = this.trackableCourse(integration, "assessment.activity_tracking_invalid_course_metadata");
128760
- if (!course) {
128761
- return;
128762
- }
128763
- return {
129335
+ return buildAssessmentActivityData({
128764
129336
  activityId: metadata2.activityId,
128765
129337
  activityName: assessment.title,
128766
- ...course,
128767
- courseId: integration.courseId
128768
- };
129338
+ courseId: integration.courseId,
129339
+ course
129340
+ });
128769
129341
  }
128770
129342
  trackableCourse(integration, event, attributes2 = {}) {
128771
- if (!isTimebackGrade2(integration.grade) || !isTimebackSubject(integration.subject)) {
129343
+ const course = assessmentTrackableCourse(integration);
129344
+ if (!course) {
128772
129345
  addEvent(event, {
128773
129346
  ...attributes2,
128774
129347
  "app.timeback.integration_id": integration.id,
@@ -128777,27 +129350,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128777
129350
  });
128778
129351
  return null;
128779
129352
  }
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 };
129353
+ return course;
128801
129354
  }
128802
129355
  submittedResult(result, metadata2) {
128803
129356
  const response = buildAssessmentSubmitResult({
@@ -128811,29 +129364,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128811
129364
  }
128812
129365
  return response;
128813
129366
  }
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
129367
  awardAgeMs(scoreDate) {
128838
129368
  const submittedAt = Date.parse(scoreDate);
128839
129369
  return Number.isFinite(submittedAt) ? Math.max(0, Date.now() - submittedAt) : -1;
@@ -128960,7 +129490,9 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
128960
129490
  attemptNumber: attempt.metadata.attemptNumber,
128961
129491
  totalQuestions: input.totalQuestions,
128962
129492
  correctQuestions: input.correctQuestions,
128963
- xpAwarded: input.xpAwarded,
129493
+ xpAwarded: input.award.xpAwarded,
129494
+ ...input.award.masteredUnitsApplied !== 0 ? { masteredUnits: input.award.masteredUnitsApplied } : {},
129495
+ ...input.award.pctCompleteApp !== undefined ? { pctCompleteApp: input.award.pctCompleteApp } : {},
128964
129496
  submittedAt: attempt.result.scoreDate,
128965
129497
  eventId,
128966
129498
  ...session2 ? { resumeId: session2.resumeId } : {}
@@ -129081,6 +129613,9 @@ function assertAssessmentImportCourseMatches(manifest, integration) {
129081
129613
  throw new ValidationError(`This manifest is for ${manifest.subject} grade ${manifest.grade}, not ${integration.subject} grade ${integration.grade}.`);
129082
129614
  }
129083
129615
  }
129616
+ function assessmentImportsAffectReviewBank(targetStatus, entries) {
129617
+ return targetStatus === "live" && entries.some((entry2) => entry2.purpose === "mastery");
129618
+ }
129084
129619
  function associationMetadataMatches(row, entry2) {
129085
129620
  return row.purpose === entry2.purpose && (row.standardFramework ?? undefined) === entry2.standard?.framework && (row.standardIdentifier ?? undefined) === entry2.standard?.identifier;
129086
129621
  }
@@ -129130,18 +129665,6 @@ function attachedAssessmentImportMessage(targetStatus, editable) {
129130
129665
  }
129131
129666
  return editable ? "Attached as a draft." : "Attached as a read-only draft owned by another app.";
129132
129667
  }
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
129668
  var init_timeback_assessment_import_util = __esm(() => {
129146
129669
  init_errors2();
129147
129670
  init_timeback_qti_authoring_util();
@@ -129210,7 +129733,9 @@ class TimebackAssessmentsService {
129210
129733
  const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
129211
129734
  where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
129212
129735
  });
129213
- const assessments2 = await runWithConcurrency(rows, QTI_HYDRATION_CONCURRENCY, async (row) => {
129736
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
129737
+ const visibleRows = rows.filter((row) => row.purpose !== "review" || row.qtiTestIdentifier === sinkIdentifier);
129738
+ const assessments2 = await runWithConcurrency(visibleRows, QTI_HYDRATION_CONCURRENCY, async (row) => {
129214
129739
  try {
129215
129740
  const test = await client2.qtiApi.assessmentTests.get(row.qtiTestIdentifier);
129216
129741
  return {
@@ -129218,7 +129743,7 @@ class TimebackAssessmentsService {
129218
129743
  title: test.title,
129219
129744
  questionCount: countQtiTestItems(test),
129220
129745
  available: true,
129221
- editable: isQtiTestOwnedByGame(test, ownership.gameSlug)
129746
+ editable: row.purpose !== "review" && isQtiTestOwnedByGame(test, ownership.gameSlug)
129222
129747
  };
129223
129748
  } catch (error88) {
129224
129749
  addEvent("assessment.qti_fetch_failed", {
@@ -129238,8 +129763,11 @@ class TimebackAssessmentsService {
129238
129763
  return assessments2.toSorted((a, b) => a.title.localeCompare(b.title));
129239
129764
  }
129240
129765
  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.");
129766
+ if (input.purpose === "diagnostic" || input.purpose === "review") {
129767
+ 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.");
129768
+ }
129769
+ if (input.assessmentKey === reviewBankSinkIdentifier(integrationId)) {
129770
+ throw new ValidationError("This assessment key is reserved for the review bank.");
129243
129771
  }
129244
129772
  const client2 = this.requireClient();
129245
129773
  const ownership = await this.requireQtiTestOwnershipContext(integrationId);
@@ -129315,24 +129843,41 @@ class TimebackAssessmentsService {
129315
129843
  index: index2,
129316
129844
  standard: this.requirePurposeStandard(assessment.purpose, assessment.standard)
129317
129845
  }));
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]));
129846
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
129847
+ if (entries.some((entry2) => entry2.assessmentKey === sinkIdentifier || entry2.qtiTestIdentifier === sinkIdentifier || entry2.purpose === "review")) {
129848
+ throw new ValidationError("The review bank is system-managed and cannot be attached manually.");
129849
+ }
129323
129850
  const validated = await this.validateAssessmentImportEntries(client2, entries, manifest.targetStatus);
129324
129851
  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);
129852
+ const affectsReviewBank = assessmentImportsAffectReviewBank(manifest.targetStatus, entries);
129853
+ const planAndInsert = async (db2, existingRows) => {
129854
+ const existingByKey = new Map(existingRows.flatMap((row) => row.assessmentKey ? [[row.assessmentKey, row]] : []));
129855
+ const existingByIdentifier = new Map(existingRows.map((row) => [row.qtiTestIdentifier, row]));
129856
+ const pendingInserts = this.planAssessmentImports({
129857
+ validated,
129858
+ existingByKey,
129859
+ existingByIdentifier,
129860
+ gameSlug,
129861
+ results
129862
+ });
129863
+ await this.insertImportedAssessments({
129864
+ db: db2,
129865
+ integrationId,
129866
+ pending: pendingInserts,
129867
+ targetStatus: manifest.targetStatus,
129868
+ gameSlug,
129869
+ results,
129870
+ abortOnError: affectsReviewBank
129871
+ });
129872
+ };
129873
+ if (affectsReviewBank) {
129874
+ await this.withLockedIntegrationAssessmentRows(integrationId, async (tx, lockedAssociations) => planAndInsert(tx, lockedAssociations));
129875
+ } else {
129876
+ const existingRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
129877
+ where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
129878
+ });
129879
+ await planAndInsert(this.deps.db, existingRows);
129880
+ }
129336
129881
  setAttribute("app.assessment.operation", "bulk_attach_existing");
129337
129882
  setAttribute("app.assessment.bulk_attach.target_status", manifest.targetStatus);
129338
129883
  setAttribute("app.assessment.bulk_attach.created", results.filter((result) => result.status === "created").length);
@@ -129345,11 +129890,6 @@ class TimebackAssessmentsService {
129345
129890
  assertManagedAssessmentIdentity(loaded.test, entry2.assessmentKey);
129346
129891
  assertAssessmentHasQuestions(loaded.questions.questions);
129347
129892
  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
129893
  return { entry: entry2, test: loaded.test };
129354
129894
  } catch (error88) {
129355
129895
  return { entry: entry2, error: error88 };
@@ -129395,25 +129935,13 @@ class TimebackAssessmentsService {
129395
129935
  }
129396
129936
  return pendingInserts;
129397
129937
  }
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) {
129938
+ async concurrentAssessmentImportDecision(db2, integrationId, entry2) {
129411
129939
  try {
129412
129940
  const [byKey, byIdentifier] = await Promise.all([
129413
- this.deps.db.query.gameTimebackAssessmentTests.findFirst({
129941
+ db2.query.gameTimebackAssessmentTests.findFirst({
129414
129942
  where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.assessmentKey, entry2.assessmentKey))
129415
129943
  }),
129416
- this.deps.db.query.gameTimebackAssessmentTests.findFirst({
129944
+ db2.query.gameTimebackAssessmentTests.findFirst({
129417
129945
  where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, entry2.qtiTestIdentifier))
129418
129946
  })
129419
129947
  ]);
@@ -129425,39 +129953,53 @@ class TimebackAssessmentsService {
129425
129953
  return null;
129426
129954
  }
129427
129955
  }
129428
- async insertImportedAssessments(integrationId, pending, targetStatus, gameSlug, results) {
129429
- for (const { entry: entry2, test } of pending) {
129430
- const base = this.associationImportResultBase(entry2, test, gameSlug);
129956
+ async insertImportedAssessments(input) {
129957
+ for (const { entry: entry2, test } of input.pending) {
129958
+ const base = this.associationImportResultBase(entry2, test, input.gameSlug);
129431
129959
  try {
129432
- const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
129433
- integrationId,
129960
+ const [row] = await input.db.insert(gameTimebackAssessmentTests).values({
129961
+ integrationId: input.integrationId,
129434
129962
  assessmentKey: entry2.assessmentKey,
129435
129963
  qtiTestIdentifier: entry2.qtiTestIdentifier,
129436
129964
  purpose: entry2.purpose,
129437
- status: targetStatus,
129965
+ status: input.targetStatus,
129438
129966
  sortOrder: entry2.sortOrder,
129439
129967
  standardFramework: entry2.standard?.framework,
129440
129968
  standardIdentifier: entry2.standard?.identifier
129441
- }).returning();
129969
+ }).onConflictDoNothing().returning();
129442
129970
  if (!row) {
129443
- throw new Error("Assessment association create returned no row");
129971
+ const concurrent = await this.concurrentAssessmentImportDecision(input.db, input.integrationId, entry2);
129972
+ if (concurrent?.decision.kind === "skip" || concurrent?.decision.kind === "fail") {
129973
+ input.results[entry2.index] = {
129974
+ ...base,
129975
+ status: concurrent.decision.status,
129976
+ ...concurrent.byKey ? { association: this.associationImportSummary(concurrent.byKey) } : {},
129977
+ message: concurrent.decision.message
129978
+ };
129979
+ } else {
129980
+ throw new Error("Assessment association insert conflicted");
129981
+ }
129982
+ } else {
129983
+ input.results[entry2.index] = {
129984
+ ...base,
129985
+ status: "created",
129986
+ association: this.associationImportSummary(row),
129987
+ message: attachedAssessmentImportMessage(input.targetStatus, base.editable ?? false)
129988
+ };
129444
129989
  }
129445
- results[entry2.index] = {
129446
- ...base,
129447
- status: "created",
129448
- association: this.associationImportSummary(row),
129449
- message: attachedAssessmentImportMessage(targetStatus, base.editable ?? false)
129450
- };
129451
129990
  } catch (error88) {
129452
- results[entry2.index] = {
129991
+ if (input.abortOnError) {
129992
+ throw error88;
129993
+ }
129994
+ input.results[entry2.index] = {
129453
129995
  ...base,
129454
129996
  status: "failed",
129455
129997
  message: `Association attach failed: ${errorMessage2(error88)}`
129456
129998
  };
129457
129999
  if (isUniqueViolation(error88)) {
129458
- const concurrent = await this.concurrentAssessmentImportDecision(integrationId, entry2);
130000
+ const concurrent = await this.concurrentAssessmentImportDecision(input.db, input.integrationId, entry2);
129459
130001
  if (concurrent?.decision.kind === "skip" || concurrent?.decision.kind === "fail") {
129460
- results[entry2.index] = {
130002
+ input.results[entry2.index] = {
129461
130003
  ...base,
129462
130004
  status: concurrent.decision.status,
129463
130005
  ...concurrent.byKey ? { association: this.associationImportSummary(concurrent.byKey) } : {},
@@ -129471,6 +130013,12 @@ class TimebackAssessmentsService {
129471
130013
  async updateAssessment(integrationId, qtiTestIdentifier, input) {
129472
130014
  try {
129473
130015
  return await this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
130016
+ if (row.purpose === "review" || qtiTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
130017
+ throw new ValidationError("The review bank is system-managed and cannot be edited.");
130018
+ }
130019
+ if (input.purpose === "review") {
130020
+ throw new ValidationError("Assessment purpose cannot be changed to the system-managed review bank.");
130021
+ }
129474
130022
  const nextPurpose = input.purpose ?? row.purpose;
129475
130023
  assertPurposeChangeDraft(row, nextPurpose);
129476
130024
  const requestedStandard = input.standard ? this.canonicalAssessmentStandard(input.standard) : undefined;
@@ -129489,19 +130037,10 @@ class TimebackAssessmentsService {
129489
130037
  ...associationUpdates,
129490
130038
  ...diagnosticChanges.updates
129491
130039
  };
129492
- const nextStatus = input.status ?? row.status;
129493
130040
  const publishing = input.status !== undefined && isAssessmentPublicationTransition(row.status, input.status);
129494
- const activatingReview = nextPurpose === "review" && nextStatus === "live" && (row.purpose !== "review" || row.status !== "live");
129495
130041
  if (input.status !== undefined) {
129496
130042
  validateAssessmentStatusTransition(row.status, input.status);
129497
130043
  }
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
130044
  if (input.title !== undefined) {
129506
130045
  assertDraftAssessment(row);
129507
130046
  assertAllAssessmentAssociationsDraft(associations);
@@ -129511,7 +130050,7 @@ class TimebackAssessmentsService {
129511
130050
  assertQtiTestOwnedByGame(test, ownership.gameSlug);
129512
130051
  await client2.qtiApi.assessmentTests.update(qtiTestIdentifier, buildQtiTestUpdateInput(test, input.title));
129513
130052
  }
129514
- if (publishing || activatingReview) {
130053
+ if (publishing) {
129515
130054
  if (nextPurpose === "diagnostic" && !nextDiagnostic) {
129516
130055
  throw new ValidationError("Platform-routed diagnostics require a diagnostic key and routing manifest before publication.");
129517
130056
  }
@@ -129521,8 +130060,7 @@ class TimebackAssessmentsService {
129521
130060
  updates.diagnosticKey = nextDiagnostic.diagnosticKey;
129522
130061
  updates.diagnosticRoutingManifest = nextDiagnostic.routingManifest;
129523
130062
  } else {
129524
- const reviewOwnership = nextPurpose === "review" && nextStatus === "live" ? await this.requireQtiTestOwnershipContext(integrationId, tx) : undefined;
129525
- await this.validateAssessmentHasQuestions(loaded, reviewOwnership?.gameSlug);
130063
+ await this.validateAssessmentHasQuestions(loaded);
129526
130064
  }
129527
130065
  if (publishing) {
129528
130066
  const publication = await this.publishManagedAssessment(row, nextPurpose, nextDiagnostic?.routingManifest ?? null, loaded);
@@ -129545,15 +130083,15 @@ class TimebackAssessmentsService {
129545
130083
  if (databaseConstraintName(error88) === "game_timeback_assessment_tests_one_live_diagnostic_key_idx") {
129546
130084
  throw new ValidationError("Only one live revision of a diagnostic key is allowed. Archive the current revision before publishing another.");
129547
130085
  }
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
130086
  }
129552
130087
  throw error88;
129553
130088
  }
129554
130089
  }
129555
130090
  async removeAssessment(integrationId, qtiTestIdentifier) {
129556
130091
  return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx) => {
130092
+ if (qtiTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
130093
+ throw new ValidationError("The review bank is permanent and cannot be removed or archived.");
130094
+ }
129557
130095
  const plan = planAssessmentRemoval(row.status);
129558
130096
  if (plan.kind === "delete") {
129559
130097
  await tx.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
@@ -129567,6 +130105,9 @@ class TimebackAssessmentsService {
129567
130105
  });
129568
130106
  }
129569
130107
  async reorderAssessments(integrationId, purpose, testIdentifiers) {
130108
+ if (purpose === "review") {
130109
+ throw new ValidationError("The review bank is system-managed and cannot be reordered.");
130110
+ }
129570
130111
  await this.requireIntegration(integrationId);
129571
130112
  validateUniqueAssessmentIdentifiers(testIdentifiers);
129572
130113
  const updatedAt = new Date;
@@ -129624,19 +130165,96 @@ class TimebackAssessmentsService {
129624
130165
  });
129625
130166
  return { ...result, questions };
129626
130167
  }
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.");
130168
+ async synchronizeReviewBank(integrationId) {
130169
+ const client2 = this.requireClient();
130170
+ await this.requireIntegration(integrationId);
130171
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
130172
+ for (let attempt = 0;attempt < REVIEW_BANK_SYNC_ATTEMPTS; attempt++) {
130173
+ const associations = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
130174
+ where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
130175
+ });
130176
+ const masterySources = this.reviewBankMasterySources(associations);
130177
+ const contributorFingerprint = await reviewBankContributorFingerprint(masterySources);
130178
+ const existingSink = await this.findReviewBankSink(client2, integrationId);
130179
+ if (existingSink) {
130180
+ assertReviewBankSinkOwnership(existingSink, integrationId);
130181
+ }
130182
+ let unchangedResult = null;
130183
+ if (existingSink) {
130184
+ try {
130185
+ unchangedResult = await unchangedReviewBankSynchronizationResult({
130186
+ sink: existingSink,
130187
+ integrationId,
130188
+ contributorFingerprint,
130189
+ contributorCount: masterySources.length
130190
+ });
130191
+ } catch (error88) {
130192
+ addEvent("assessment.review_mapping_noop_validation_failed", {
130193
+ "app.assessment.qti_test_identifier": existingSink.identifier,
130194
+ "exception.type": errorType(error88),
130195
+ "app.error.message": errorMessage2(error88)
130196
+ });
130197
+ }
129631
130198
  }
129632
- const client2 = this.requireClient();
129633
- const result = await this.rebuildReviewMapping(client2, qtiTestIdentifier, {
129634
- kind: "live-review-repair"
130199
+ let plan = null;
130200
+ let addedItemCount = 0;
130201
+ let removedItemCount = 0;
130202
+ let catalogUnchanged = unchangedResult !== null;
130203
+ if (!unchangedResult) {
130204
+ const contributors = await runWithConcurrency(masterySources, QTI_HYDRATION_CONCURRENCY, async (source) => {
130205
+ const test = await client2.qtiApi.assessmentTests.get(source.qtiTestIdentifier);
130206
+ assertManagedAssessmentIdentity(test, source.assessmentKey);
130207
+ return { ...source, test };
130208
+ });
130209
+ plan = await prepareReviewBankSynchronization({
130210
+ bankIdentifier: sinkIdentifier,
130211
+ contributors,
130212
+ contributorFingerprint
130213
+ });
130214
+ const diff = reviewBankSynchronizationDiff({ existingSink, plan });
130215
+ addedItemCount = diff.addedItemCount;
130216
+ removedItemCount = diff.removedItemCount;
130217
+ catalogUnchanged = diff.unchanged;
130218
+ }
130219
+ const committed = await this.withLockedIntegrationAssessmentRows(integrationId, async (tx, lockedAssociations) => {
130220
+ const lockedSources = this.reviewBankMasterySources(lockedAssociations);
130221
+ const lockedFingerprint = await reviewBankContributorFingerprint(lockedSources);
130222
+ if (lockedFingerprint !== contributorFingerprint) {
130223
+ return null;
130224
+ }
130225
+ const associationChanged = await this.installReviewBankAssociation({
130226
+ tx,
130227
+ integrationId,
130228
+ associations: lockedAssociations,
130229
+ sinkIdentifier,
130230
+ beforeInstall: async () => {
130231
+ if (!catalogUnchanged) {
130232
+ await this.writeReviewBankSink({
130233
+ client: client2,
130234
+ integrationId,
130235
+ existingSink,
130236
+ plan,
130237
+ metadata: reviewBankSinkMetadata(integrationId, plan)
130238
+ });
130239
+ }
130240
+ }
130241
+ });
130242
+ const unchanged = catalogUnchanged && !associationChanged;
130243
+ const result = unchangedResult ? { ...unchangedResult, unchanged } : {
130244
+ qtiTestIdentifier: sinkIdentifier,
130245
+ ...plan.summary,
130246
+ addedItemCount,
130247
+ removedItemCount,
130248
+ unchanged,
130249
+ warnings: plan.warnings
130250
+ };
130251
+ return this.reviewMappingResult(result);
129635
130252
  });
129636
- setAttribute("app.assessment.operation", "update_review_mapping");
129637
- setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
129638
- return result;
129639
- });
130253
+ if (committed) {
130254
+ return committed;
130255
+ }
130256
+ }
130257
+ throw new ValidationError("Mastery assessments changed while the review bank was synchronizing. Try again.");
129640
130258
  }
129641
130259
  async listQuestionLibrary(integrationId, params) {
129642
130260
  const client2 = this.requireClient();
@@ -129656,15 +130274,18 @@ class TimebackAssessmentsService {
129656
130274
  purpose,
129657
130275
  standard: standardInput
129658
130276
  } = input;
129659
- if (purpose === "diagnostic") {
129660
- throw new ValidationError("Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
130277
+ if (purpose === "diagnostic" || purpose === "review") {
130278
+ 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.");
130279
+ }
130280
+ if (assessmentKey === reviewBankSinkIdentifier(integrationId) || targetTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
130281
+ throw new ValidationError("This assessment identity is reserved for the review bank.");
129661
130282
  }
129662
130283
  const client2 = this.requireClient();
129663
130284
  const ownership = await this.requireQtiTestOwnershipContext(integrationId);
129664
130285
  const { integration } = ownership;
129665
130286
  const standard = this.requirePurposeStandard(purpose, standardInput);
129666
130287
  const source = await client2.qtiApi.assessmentTests.get(sourceTestIdentifier);
129667
- const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => this.qtiItemHref(client2, identifier));
130288
+ const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => qtiItemHref(client2, identifier));
129668
130289
  const itemCopies = await runWithConcurrency(itemPlan, QTI_HYDRATION_CONCURRENCY, async (plan) => {
129669
130290
  const sourceItem = await client2.qtiApi.assessmentItems.get(plan.sourceIdentifier);
129670
130291
  return {
@@ -129789,7 +130410,7 @@ class TimebackAssessmentsService {
129789
130410
  };
129790
130411
  }
129791
130412
  const section = await this.qtiSectionItems(client2, qtiTestIdentifier, undefined, undefined, ownership.test);
129792
- const href = this.qtiItemHref(client2, itemIdentifier);
130413
+ const href = qtiItemHref(client2, itemIdentifier);
129793
130414
  let item;
129794
130415
  let itemCreationAttempted = false;
129795
130416
  let referenceCreationAttempted = false;
@@ -129899,7 +130520,7 @@ class TimebackAssessmentsService {
129899
130520
  const result = await section.items.reorder({
129900
130521
  items: items.map((item) => ({
129901
130522
  identifier: item.identifier,
129902
- href: item.href ?? this.qtiItemHref(client2, item.identifier),
130523
+ href: item.href ?? qtiItemHref(client2, item.identifier),
129903
130524
  sequence: item.sequence
129904
130525
  }))
129905
130526
  });
@@ -129913,6 +130534,121 @@ class TimebackAssessmentsService {
129913
130534
  }
129914
130535
  return this.deps.timeback;
129915
130536
  }
130537
+ reviewBankMasterySources(associations) {
130538
+ return associations.filter((association) => association.purpose === "mastery" && association.status === "live").map((association) => {
130539
+ const standard = assessmentStandardForRow(association);
130540
+ if (!association.assessmentKey || !standard) {
130541
+ throw new ValidationError(`Live mastery assessment ${association.qtiTestIdentifier} requires a managed identity and standard`);
130542
+ }
130543
+ if (assessmentKeyFromManagedQtiIdentifier(association.qtiTestIdentifier) !== association.assessmentKey) {
130544
+ throw new ValidationError(`Live mastery assessment ${association.qtiTestIdentifier} is not a managed publication for ${association.assessmentKey}`);
130545
+ }
130546
+ return {
130547
+ assessmentKey: association.assessmentKey,
130548
+ qtiTestIdentifier: association.qtiTestIdentifier,
130549
+ standard
130550
+ };
130551
+ });
130552
+ }
130553
+ async writeReviewBankSink(input) {
130554
+ function sinkInput(title, includeIdentifier = false) {
130555
+ return buildReviewBankSinkInput({
130556
+ identifier: input.plan.bankIdentifier,
130557
+ title,
130558
+ metadata: input.metadata,
130559
+ itemIdentifiers: input.plan.itemIdentifiers,
130560
+ itemHref: (identifier) => qtiItemHref(input.client, identifier),
130561
+ includeIdentifier
130562
+ });
130563
+ }
130564
+ if (input.existingSink) {
130565
+ await input.client.qtiApi.assessmentTests.update(input.plan.bankIdentifier, sinkInput(input.existingSink.title));
130566
+ return;
130567
+ }
130568
+ try {
130569
+ const createInput = sinkInput(REVIEW_BANK_TITLE, true);
130570
+ await input.client.qtiApi.assessmentTests.create(createInput);
130571
+ } catch (error88) {
130572
+ if (!isApiError(error88) || error88.statusCode !== 409) {
130573
+ throw error88;
130574
+ }
130575
+ const concurrentlyCreated = await input.client.qtiApi.assessmentTests.get(input.plan.bankIdentifier);
130576
+ assertReviewBankSinkOwnership(concurrentlyCreated, input.integrationId);
130577
+ await input.client.qtiApi.assessmentTests.update(input.plan.bankIdentifier, sinkInput(concurrentlyCreated.title));
130578
+ }
130579
+ }
130580
+ async installReviewBankAssociation(input) {
130581
+ const singleton = input.associations.find((association) => association.qtiTestIdentifier === input.sinkIdentifier);
130582
+ if (singleton && singleton.purpose !== "review") {
130583
+ throw new ValidationError("The reserved review-bank identifier is associated with another assessment purpose.");
130584
+ }
130585
+ const legacyReviews = input.associations.filter((association) => association.purpose === "review" && association.qtiTestIdentifier !== input.sinkIdentifier);
130586
+ const reservedKeyOwner = input.associations.find((association) => association.assessmentKey === input.sinkIdentifier && association.qtiTestIdentifier !== input.sinkIdentifier);
130587
+ if (reservedKeyOwner && reservedKeyOwner.purpose !== "review") {
130588
+ throw new ValidationError("The reserved review-bank assessment key is already in use.");
130589
+ }
130590
+ const singletonCurrent = Boolean(singleton && singleton.assessmentKey === input.sinkIdentifier && singleton.status === "live" && singleton.sortOrder === null && singleton.standardFramework === null && singleton.standardIdentifier === null && singleton.diagnosticKey === null && singleton.diagnosticRoutingManifest === null);
130591
+ const legacyCurrent = legacyReviews.every((association) => association.status === "archived" && association.sortOrder === null && association.assessmentKey !== input.sinkIdentifier);
130592
+ const changed = !singletonCurrent || !legacyCurrent;
130593
+ await input.beforeInstall();
130594
+ for (const legacy of legacyReviews) {
130595
+ if (legacy.status !== "archived" || legacy.sortOrder !== null || legacy.assessmentKey === input.sinkIdentifier) {
130596
+ await input.tx.update(gameTimebackAssessmentTests).set({
130597
+ status: "archived",
130598
+ sortOrder: null,
130599
+ ...legacy.assessmentKey === input.sinkIdentifier ? { assessmentKey: null } : {},
130600
+ updatedAt: new Date
130601
+ }).where(eq(gameTimebackAssessmentTests.id, legacy.id));
130602
+ }
130603
+ }
130604
+ if (singletonCurrent) {
130605
+ return changed;
130606
+ }
130607
+ if (singleton) {
130608
+ await input.tx.update(gameTimebackAssessmentTests).set({
130609
+ assessmentKey: input.sinkIdentifier,
130610
+ purpose: "review",
130611
+ status: "live",
130612
+ sortOrder: null,
130613
+ standardFramework: null,
130614
+ standardIdentifier: null,
130615
+ diagnosticKey: null,
130616
+ diagnosticRoutingManifest: null,
130617
+ updatedAt: new Date
130618
+ }).where(eq(gameTimebackAssessmentTests.id, singleton.id));
130619
+ return changed;
130620
+ }
130621
+ await input.tx.insert(gameTimebackAssessmentTests).values({
130622
+ integrationId: input.integrationId,
130623
+ assessmentKey: input.sinkIdentifier,
130624
+ qtiTestIdentifier: input.sinkIdentifier,
130625
+ purpose: "review",
130626
+ status: "live",
130627
+ sortOrder: null,
130628
+ standardFramework: null,
130629
+ standardIdentifier: null,
130630
+ diagnosticKey: null,
130631
+ diagnosticRoutingManifest: null
130632
+ });
130633
+ return changed;
130634
+ }
130635
+ reviewMappingResult(result) {
130636
+ setAttribute("app.assessment.operation", "update_review_mapping");
130637
+ setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
130638
+ setAttribute("app.assessment.review_mapping_added_item_count", result.addedItemCount);
130639
+ setAttribute("app.assessment.review_mapping_removed_item_count", result.removedItemCount);
130640
+ return result;
130641
+ }
130642
+ async withLockedIntegrationAssessmentRows(integrationId, action) {
130643
+ return this.deps.db.transaction(async (tx) => {
130644
+ const [integration] = await tx.select({ id: gameTimebackIntegrations.id }).from(gameTimebackIntegrations).where(and(eq(gameTimebackIntegrations.id, integrationId), isActiveGameTimebackIntegrationStatus())).for("update");
130645
+ if (!integration) {
130646
+ throw new NotFoundError(`Integration not found: ${integrationId}`);
130647
+ }
130648
+ const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.integrationId, integrationId)).orderBy(gameTimebackAssessmentTests.id).for("update");
130649
+ return action(tx, rows);
130650
+ });
130651
+ }
129916
130652
  async withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, action) {
129917
130653
  return this.deps.db.transaction(async (tx) => {
129918
130654
  const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier)).orderBy(gameTimebackAssessmentTests.id).for("update");
@@ -129997,8 +130733,16 @@ class TimebackAssessmentsService {
129997
130733
  items: client2.qtiApi.assessmentTests.testParts(qtiTestIdentifier).sections(section.partIdentifier).items(section.sectionIdentifier)
129998
130734
  };
129999
130735
  }
130000
- qtiItemHref(client2, itemIdentifier) {
130001
- return `${client2.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
130736
+ async findReviewBankSink(client2, integrationId) {
130737
+ const identifier = reviewBankSinkIdentifier(integrationId);
130738
+ try {
130739
+ return await client2.qtiApi.assessmentTests.get(identifier);
130740
+ } catch (error88) {
130741
+ if (isApiError(error88) && error88.statusCode === 404) {
130742
+ return null;
130743
+ }
130744
+ throw error88;
130745
+ }
130002
130746
  }
130003
130747
  async ensureImmutableQtiResource(get, create) {
130004
130748
  try {
@@ -130039,7 +130783,7 @@ class TimebackAssessmentsService {
130039
130783
  test: loaded.test,
130040
130784
  questions: loaded.questions,
130041
130785
  diagnosticRoutingManifest,
130042
- itemHref: (identifier) => this.qtiItemHref(client2, identifier)
130786
+ itemHref: (identifier) => qtiItemHref(client2, identifier)
130043
130787
  });
130044
130788
  await this.ensureManagedQtiPublication(client2, plan);
130045
130789
  return plan;
@@ -130121,15 +130865,11 @@ class TimebackAssessmentsService {
130121
130865
  const plan = buildQtiLibraryListPlan(params);
130122
130866
  return list(plan.params);
130123
130867
  }
130124
- async validateAssessmentHasQuestions(loaded, reviewGameSlug) {
130868
+ async validateAssessmentHasQuestions(loaded) {
130125
130869
  assertAssessmentHasQuestions(loaded.questions.questions);
130126
130870
  for (const { question } of loaded.questions.questions) {
130127
130871
  assertPlayableQtiQuestion(question);
130128
130872
  }
130129
- if (reviewGameSlug) {
130130
- assertQtiTestOwnedByGame(loaded.test, reviewGameSlug);
130131
- await this.writeReviewMapping(this.requireClient(), loaded.test, loaded.questions);
130132
- }
130133
130873
  }
130134
130874
  async prepareDiagnosticAssociationChanges(row, nextPurpose, requested) {
130135
130875
  if (row.purpose !== "diagnostic" && nextPurpose === "diagnostic" && !requested) {
@@ -130158,22 +130898,9 @@ class TimebackAssessmentsService {
130158
130898
  }
130159
130899
  };
130160
130900
  }
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
130901
  }
130902
+ var REVIEW_BANK_TITLE = "Review Bank";
130903
+ var REVIEW_BANK_SYNC_ATTEMPTS = 2;
130177
130904
  var init_timeback_assessments_service = __esm(async () => {
130178
130905
  init_drizzle_orm();
130179
130906
  init_helpers_index();
@@ -192833,7 +193560,7 @@ var listAssessments;
192833
193560
  var createAssessment;
192834
193561
  var attachExistingAssessments;
192835
193562
  var updateAssessment;
192836
- var updateReviewMapping;
193563
+ var synchronizeReviewBank;
192837
193564
  var reorderAssessments;
192838
193565
  var reorderQuestions;
192839
193566
  var removeAssessment;
@@ -193206,7 +193933,9 @@ var init_timeback_controller = __esm(() => {
193206
193933
  attemptId,
193207
193934
  input: {
193208
193935
  submissionId: body2.submissionId,
193209
- xpAwarded: body2.xpAwarded
193936
+ xpAwarded: body2.xpAwarded,
193937
+ ...body2.masteredUnits !== undefined ? { masteredUnits: body2.masteredUnits } : {},
193938
+ ...body2.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: body2.masteredUnitsAbsolute } : {}
193210
193939
  },
193211
193940
  game: {
193212
193941
  appName: body2.appName,
@@ -193540,13 +194269,13 @@ var init_timeback_controller = __esm(() => {
193540
194269
  const body2 = await parseRequestBody(ctx.request, UpdateAssessmentRequestSchema);
193541
194270
  return ctx.services.timebackAssessments.updateAssessment(integrationId, testIdentifier, body2);
193542
194271
  });
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");
194272
+ synchronizeReviewBank = requireDeveloper(async (ctx) => {
194273
+ const { gameId, courseId } = ctx.params;
194274
+ if (!gameId || !courseId) {
194275
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
193547
194276
  }
193548
194277
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
193549
- return ctx.services.timebackAssessments.updateReviewMapping(integrationId, testIdentifier);
194278
+ return ctx.services.timebackAssessments.synchronizeReviewBank(integrationId);
193550
194279
  });
193551
194280
  reorderAssessments = requireDeveloper(async (ctx) => {
193552
194281
  const { gameId, courseId } = ctx.params;
@@ -193697,7 +194426,7 @@ var init_timeback_controller = __esm(() => {
193697
194426
  createAssessment,
193698
194427
  attachExistingAssessments,
193699
194428
  updateAssessment,
193700
- updateReviewMapping,
194429
+ synchronizeReviewBank,
193701
194430
  reorderAssessments,
193702
194431
  removeAssessment,
193703
194432
  reorderQuestions,