@playcademy/sandbox 0.8.1-beta.1 → 0.8.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 (3) hide show
  1. package/dist/cli.js +1129 -405
  2. package/dist/server.js +1129 -405
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1128,7 +1128,7 @@ var package_default;
1128
1128
  var init_package = __esm(() => {
1129
1129
  package_default = {
1130
1130
  name: "@playcademy/sandbox",
1131
- version: "0.8.1-beta.1",
1131
+ version: "0.8.1-beta.2",
1132
1132
  description: "Local development server for Playcademy game development",
1133
1133
  type: "module",
1134
1134
  exports: {
@@ -1498,6 +1498,9 @@ async function runWithConcurrency(items, concurrency, worker) {
1498
1498
  }
1499
1499
 
1500
1500
  // ../utils/src/timeback.ts
1501
+ function reviewBankSinkIdentifier(integrationId) {
1502
+ return `playcademy-review-bank.${integrationId}`;
1503
+ }
1501
1504
  function playcademySupportedQtiInteractionType(value) {
1502
1505
  if (typeof value !== "string") {
1503
1506
  return;
@@ -9481,9 +9484,12 @@ function classifyAssessmentSubmission(attempt, submissionId) {
9481
9484
  }
9482
9485
  return isAssessmentAttemptOpen(attempt) ? "submit" : "reject";
9483
9486
  }
9487
+ function isSameAssessmentAward(recorded, input) {
9488
+ return recorded.xpAwarded === input.xpAwarded && recorded.masteredUnits === input.masteredUnits && recorded.masteredUnitsAbsolute === input.masteredUnitsAbsolute;
9489
+ }
9484
9490
  function classifyAssessmentFinalization(attempt, input) {
9485
9491
  if (isAssessmentAttemptCompleted(attempt)) {
9486
- return attempt.submissionId === input.submissionId && attempt.xpAwarded === input.xpAwarded ? "replay" : "conflict";
9492
+ return attempt.submissionId === input.submissionId && attempt.award !== undefined && isSameAssessmentAward(attempt.award, input) ? "replay" : "conflict";
9487
9493
  }
9488
9494
  if (!isAssessmentAttemptAwaitingAward(attempt) || attempt.submissionId !== input.submissionId) {
9489
9495
  return "reject";
@@ -10964,7 +10970,7 @@ async function reviewBankRevision(input) {
10964
10970
  })}`);
10965
10971
  return `review-bank-v1:${bankRevisionUuid}`;
10966
10972
  }
10967
- async function reviewBankIndex(input) {
10973
+ async function buildReviewBankIndexFromReferences(input) {
10968
10974
  return {
10969
10975
  ...input,
10970
10976
  bankRevision: await reviewBankRevision(input)
@@ -10993,7 +10999,7 @@ async function buildReviewBankIndex(assessment, questions, options = {}) {
10993
10999
  standards
10994
11000
  };
10995
11001
  });
10996
- return reviewBankIndex({
11002
+ return buildReviewBankIndexFromReferences({
10997
11003
  bankIdentifier: assessment.identifier,
10998
11004
  sourceContentRevision: assessment.contentRevision,
10999
11005
  items
@@ -11098,7 +11104,7 @@ async function reviewBankIndexFromManifest(metadata2, input) {
11098
11104
  }
11099
11105
  }
11100
11106
  }
11101
- const index = await reviewBankIndex({
11107
+ const index = await buildReviewBankIndexFromReferences({
11102
11108
  bankIdentifier,
11103
11109
  sourceContentRevision,
11104
11110
  items: input.membershipItemIdentifiers.map((itemIdentifier) => ({
@@ -11453,6 +11459,18 @@ function isRoutedDiagnosticAttemptMetadata(value, itemSubmissions, responseVersi
11453
11459
  function isReviewAttemptMetadata(value) {
11454
11460
  return isRecord(value) && typeof value.requestFingerprint === "string" && typeof value.bankRevision === "string" && Array.isArray(value.standards) && value.standards.every(isAssessmentStandardRef) && Number.isInteger(value.candidateItemsPerStandard) && Array.isArray(value.selections) && value.selections.every((selection) => isRecord(selection) && isAssessmentStandardRef(selection.standard) && typeof selection.itemIdentifier === "string") && isReviewFulfillment(value.fulfillment);
11455
11461
  }
11462
+ function isOptionalInteger(value) {
11463
+ return value === undefined || Number.isInteger(value);
11464
+ }
11465
+ function isMasteryWriteWarning(value) {
11466
+ 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);
11467
+ }
11468
+ function isAssessmentAwardRecord(value) {
11469
+ if (value === undefined) {
11470
+ return true;
11471
+ }
11472
+ 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));
11473
+ }
11456
11474
  function isMasteryAttemptMetadata(value) {
11457
11475
  return isRecord(value) && typeof value.requestFingerprint === "string" && isAssessmentStandardRef(value.standard);
11458
11476
  }
@@ -11477,7 +11495,7 @@ function isPlaycademyAssessmentResultMetadataV1(value) {
11477
11495
  const review = metadata2.review;
11478
11496
  const diagnostic = metadata2.diagnostic;
11479
11497
  const routedDiagnostic = metadata2.purpose === "diagnostic" && diagnostic !== undefined && Array.isArray(metadata2.itemSubmissions) && Number.isInteger(metadata2.responseVersion) && isRoutedDiagnosticAttemptMetadata(diagnostic, metadata2.itemSubmissions, metadata2.responseVersion);
11480
- return metadata2.version === 1 && typeof metadata2.activityId === "string" && isAssessmentPurpose(metadata2.purpose) && typeof metadata2.courseId === "string" && typeof metadata2.integrationId === "string" && typeof metadata2.enrollmentId === "string" && Boolean(selectedTest) && (selectedTest?.assessmentKey === undefined || isNonEmptyString(selectedTest.assessmentKey)) && typeof selectedTest?.identifier === "string" && typeof selectedTest.contentRevision === "string" && Number.isInteger(metadata2.attemptNumber) && Number.isInteger(metadata2.responseVersion) && Boolean(metadata2.responses) && typeof metadata2.responses === "object" && Array.isArray(metadata2.itemSubmissions) && metadata2.itemSubmissions.every(isAssessmentItemSubmission) && typeof metadata2.startedAt === "string" && typeof metadata2.updatedAt === "string" && (metadata2.finalizedAt === undefined || typeof metadata2.finalizedAt === "string") && (metadata2.purpose === "mastery" ? isMasteryAttemptMetadata(mastery) : mastery === undefined) && (metadata2.purpose === "review" ? isReviewAttemptMetadata(review) : review === undefined) && (metadata2.purpose === "diagnostic" ? diagnostic === undefined || routedDiagnostic : diagnostic === undefined) && (metadata2.submissionId === undefined || typeof metadata2.submissionId === "string") && (metadata2.score === undefined || isAssessmentScore(metadata2.score)) && isCompletionMetadata(metadata2.completion, metadata2.purpose, routedDiagnostic);
11498
+ return metadata2.version === 1 && typeof metadata2.activityId === "string" && isAssessmentPurpose(metadata2.purpose) && typeof metadata2.courseId === "string" && typeof metadata2.integrationId === "string" && typeof metadata2.enrollmentId === "string" && Boolean(selectedTest) && (selectedTest?.assessmentKey === undefined || isNonEmptyString(selectedTest.assessmentKey)) && typeof selectedTest?.identifier === "string" && typeof selectedTest.contentRevision === "string" && Number.isInteger(metadata2.attemptNumber) && Number.isInteger(metadata2.responseVersion) && Boolean(metadata2.responses) && typeof metadata2.responses === "object" && Array.isArray(metadata2.itemSubmissions) && metadata2.itemSubmissions.every(isAssessmentItemSubmission) && typeof metadata2.startedAt === "string" && typeof metadata2.updatedAt === "string" && (metadata2.finalizedAt === undefined || typeof metadata2.finalizedAt === "string") && isAssessmentAwardRecord(metadata2.award) && (metadata2.purpose === "mastery" ? isMasteryAttemptMetadata(mastery) : mastery === undefined) && (metadata2.purpose === "review" ? isReviewAttemptMetadata(review) : review === undefined) && (metadata2.purpose === "diagnostic" ? diagnostic === undefined || routedDiagnostic : diagnostic === undefined) && (metadata2.submissionId === undefined || typeof metadata2.submissionId === "string") && (metadata2.score === undefined || isAssessmentScore(metadata2.score)) && isCompletionMetadata(metadata2.completion, metadata2.purpose, routedDiagnostic);
11481
11499
  }
11482
11500
  function isPlaycademyReviewAssessmentItemResultMetadataV1(value) {
11483
11501
  if (!isRecord(value) || !isRecord(value.responses)) {
@@ -11915,7 +11933,12 @@ var init_assessment_runtime2 = __esm(() => {
11915
11933
  });
11916
11934
  FinalizeAssessmentBodySchema = exports_external.object({
11917
11935
  submissionId: exports_external.string().trim().min(1),
11918
- xpAwarded: exports_external.number().finite().nonnegative()
11936
+ xpAwarded: exports_external.number().finite().nonnegative(),
11937
+ masteredUnits: exports_external.number().finite().optional(),
11938
+ masteredUnitsAbsolute: exports_external.number().finite().int().optional()
11939
+ }).refine((body2) => body2.masteredUnits === undefined || body2.masteredUnitsAbsolute === undefined, {
11940
+ message: "Provide either masteredUnits or masteredUnitsAbsolute, not both",
11941
+ path: ["masteredUnits"]
11919
11942
  });
11920
11943
  StartRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.and(StartAssessmentBodySchema);
11921
11944
  SaveRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend(SaveAssessmentBodySchema.shape);
@@ -11924,10 +11947,10 @@ var init_assessment_runtime2 = __esm(() => {
11924
11947
  appName: exports_external.string().trim().min(1),
11925
11948
  sensorUrl: exports_external.string().url()
11926
11949
  });
11927
- FinalizeRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend(FinalizeAssessmentBodySchema.shape).extend({
11950
+ FinalizeRuntimeAssessmentRequestSchema = AssessmentRuntimeIdentitySchema.extend({
11928
11951
  appName: exports_external.string().trim().min(1),
11929
11952
  sensorUrl: exports_external.string().url()
11930
- });
11953
+ }).and(FinalizeAssessmentBodySchema);
11931
11954
  });
11932
11955
 
11933
11956
  // ../api-core/src/errors/assessment-runtime.error.ts
@@ -47110,6 +47133,15 @@ function requireMasteryStandard(input, context2) {
47110
47133
  });
47111
47134
  }
47112
47135
  }
47136
+ function rejectSystemManagedReview(input, context2) {
47137
+ if (input.purpose === "review") {
47138
+ context2.addIssue({
47139
+ code: "custom",
47140
+ path: ["purpose"],
47141
+ message: "The review bank is system-managed and cannot be managed manually"
47142
+ });
47143
+ }
47144
+ }
47113
47145
  var TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, AssessmentPurposeSchema, AssessmentStatusSchema, AssessmentKeySchema, DiagnosticKeySchema, DiagnosticRoutingManifestSchema, DiagnosticAssessmentDefinitionInputSchema, AssessmentStandardRefSchema2, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, AssessmentAssociationImportEntrySchema, AttachExistingAssessmentsRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
47114
47146
  var init_schemas4 = __esm(() => {
47115
47147
  init_esm();
@@ -47371,7 +47403,10 @@ var init_schemas4 = __esm(() => {
47371
47403
  title: exports_external.string().min(1, "Assessment title is required"),
47372
47404
  purpose: AssessmentPurposeSchema,
47373
47405
  standard: AssessmentStandardRefSchema2.optional()
47374
- }).superRefine(requireMasteryStandard);
47406
+ }).superRefine((input, context2) => {
47407
+ requireMasteryStandard(input, context2);
47408
+ rejectSystemManagedReview(input, context2);
47409
+ });
47375
47410
  UpdateAssessmentRequestSchema = exports_external.object({
47376
47411
  title: exports_external.string().trim().min(1, "Assessment title is required").optional(),
47377
47412
  purpose: AssessmentPurposeSchema.optional(),
@@ -47380,13 +47415,16 @@ var init_schemas4 = __esm(() => {
47380
47415
  status: AssessmentStatusSchema.optional()
47381
47416
  }).refine((input) => input.title !== undefined || input.purpose !== undefined || input.standard !== undefined || input.diagnostic !== undefined || input.status !== undefined, {
47382
47417
  message: "Title, purpose, standard, diagnostic, or status is required"
47383
- });
47418
+ }).superRefine(rejectSystemManagedReview);
47384
47419
  CopyAssessmentRequestSchema = exports_external.object({
47385
47420
  assessmentKey: AssessmentKeySchema,
47386
47421
  testIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
47387
47422
  purpose: AssessmentPurposeSchema,
47388
47423
  standard: AssessmentStandardRefSchema2.optional()
47389
- }).superRefine(requireMasteryStandard);
47424
+ }).superRefine((input, context2) => {
47425
+ requireMasteryStandard(input, context2);
47426
+ rejectSystemManagedReview(input, context2);
47427
+ });
47390
47428
  AssessmentAssociationImportEntrySchema = exports_external.object({
47391
47429
  assessmentKey: AssessmentKeySchema,
47392
47430
  qtiTestIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
@@ -47395,6 +47433,7 @@ var init_schemas4 = __esm(() => {
47395
47433
  sortOrder: exports_external.number().int().nonnegative().nullable()
47396
47434
  }).superRefine((input, context2) => {
47397
47435
  requireMasteryStandard(input, context2);
47436
+ rejectSystemManagedReview(input, context2);
47398
47437
  if (input.purpose !== "mastery" && input.standard !== undefined) {
47399
47438
  context2.addIssue({
47400
47439
  code: "custom",
@@ -47444,7 +47483,7 @@ var init_schemas4 = __esm(() => {
47444
47483
  ReorderAssessmentsRequestSchema = exports_external.object({
47445
47484
  purpose: AssessmentPurposeSchema,
47446
47485
  testIdentifiers: exports_external.array(exports_external.string().trim().min(1, "Assessment identifier is required")).min(1, "At least one assessment is required")
47447
- });
47486
+ }).superRefine(rejectSystemManagedReview);
47448
47487
  ReorderQuestionsRequestSchema = exports_external.object({
47449
47488
  items: exports_external.array(exports_external.object({
47450
47489
  identifier: exports_external.string().min(1),
@@ -90873,6 +90912,7 @@ function buildAssessmentCompletionEvent(input) {
90873
90912
  totalQuestions: input.totalQuestions,
90874
90913
  correctQuestions: input.correctQuestions,
90875
90914
  xpEarned: input.xpAwarded,
90915
+ ...input.masteredUnits ? { masteredUnits: input.masteredUnits } : {},
90876
90916
  attemptNumber: input.attemptNumber,
90877
90917
  process: false,
90878
90918
  subject: input.subject,
@@ -90881,7 +90921,10 @@ function buildAssessmentCompletionEvent(input) {
90881
90921
  eventId: input.eventId,
90882
90922
  eventTime: input.submittedAt,
90883
90923
  runId: input.attemptId,
90884
- generatedExtensions: metadata2,
90924
+ generatedExtensions: {
90925
+ ...metadata2,
90926
+ ...input.pctCompleteApp !== undefined ? { pctCompleteApp: input.pctCompleteApp } : {}
90927
+ },
90885
90928
  eventExtensions: {
90886
90929
  playcademy: {
90887
90930
  assessmentPurpose: input.purpose,
@@ -90935,6 +90978,85 @@ function deriveSourcedIds(courseId) {
90935
90978
  componentResource: `${courseId}-cr`
90936
90979
  };
90937
90980
  }
90981
+ function hasMasteryAwardRequest(request) {
90982
+ return request.masteredUnits !== undefined || request.masteredUnitsAbsolute !== undefined;
90983
+ }
90984
+
90985
+ class ActivityMasteryAward {
90986
+ mastery;
90987
+ events;
90988
+ constructor(mastery, events) {
90989
+ this.mastery = mastery;
90990
+ this.events = events;
90991
+ }
90992
+ async evaluate(input) {
90993
+ const ids = deriveSourcedIds(input.courseId);
90994
+ const progress = await this.mastery.checkProgress({
90995
+ studentId: input.studentId,
90996
+ courseId: input.courseId,
90997
+ resourceId: ids.resource,
90998
+ masteredUnits: input.masteredUnits ?? 0,
90999
+ masteredUnitsAbsolute: input.masteredUnitsAbsolute
91000
+ });
91001
+ if (!progress) {
91002
+ return {
91003
+ masteredUnitsApplied: input.masteredUnits ?? 0,
91004
+ masteryAchieved: false,
91005
+ masteryRevoked: false
91006
+ };
91007
+ }
91008
+ return {
91009
+ masteredUnitsApplied: progress.effectiveDelta,
91010
+ pctCompleteApp: progress.pctCompleteApp,
91011
+ masteryAchieved: progress.masteryAchieved,
91012
+ masteryRevoked: progress.masteryRevoked,
91013
+ ...progress.writeWarning ? { warnings: [progress.writeWarning] } : {}
91014
+ };
91015
+ }
91016
+ async settle(evaluation, input) {
91017
+ let completionEntryWritten = false;
91018
+ if (evaluation.masteryAchieved) {
91019
+ setAttributes({ "app.timeback.mastery_achieved": true });
91020
+ completionEntryWritten = await this.mastery.createCompletionEntry(input.studentId, input.courseId, input.classId, input.appName);
91021
+ await this.emitCourseCompletionHistoryEvent(input);
91022
+ }
91023
+ if (evaluation.masteryRevoked) {
91024
+ await this.mastery.revokeCompletionEntry(input.studentId, input.courseId, input.classId, input.appName);
91025
+ }
91026
+ return { completionEntryWritten };
91027
+ }
91028
+ async emitCourseCompletionHistoryEvent(input) {
91029
+ const ids = deriveSourcedIds(input.courseId);
91030
+ await this.events.emitActivityEvent({
91031
+ eventId: input.completionHistoryEventId,
91032
+ studentId: input.studentId,
91033
+ studentEmail: input.studentEmail,
91034
+ gameId: input.gameId,
91035
+ activityId: input.activityId,
91036
+ activityName: "Course completed",
91037
+ courseId: ids.course,
91038
+ courseName: input.courseName,
91039
+ subject: input.subject,
91040
+ appName: input.appName,
91041
+ sensorUrl: input.sensorUrl,
91042
+ process: false,
91043
+ includeAttempt: false,
91044
+ eventExtensions: {
91045
+ playcademy: {
91046
+ eventKind: "course-completed",
91047
+ source: "gameplay"
91048
+ }
91049
+ },
91050
+ generatedExtensions: {
91051
+ playcademy: {
91052
+ eventKind: "course-completed",
91053
+ source: "gameplay",
91054
+ activityId: input.activityId
91055
+ }
91056
+ }
91057
+ }).catch(catchEvent("timeback.caliper_completion_event_failed"));
91058
+ }
91059
+ }
90938
91060
  function validateProgressData(progressData) {
90939
91061
  if (!progressData.subject) {
90940
91062
  throw new ConfigurationError("subject", "Subject is required for Caliper events. Provide it in progressData.subject");
@@ -90950,12 +91072,12 @@ function validateProgressData(progressData) {
90950
91072
  class ActivityRecord {
90951
91073
  core;
90952
91074
  students;
90953
- mastery;
91075
+ masteryAward;
90954
91076
  events;
90955
- constructor(core3, students, mastery, events) {
91077
+ constructor(core3, students, masteryAward, events) {
90956
91078
  this.core = core3;
90957
91079
  this.students = students;
90958
- this.mastery = mastery;
91080
+ this.masteryAward = masteryAward;
90959
91081
  this.events = events;
90960
91082
  }
90961
91083
  async record(courseId, studentIdentifier, progressData) {
@@ -90981,52 +91103,32 @@ class ActivityRecord {
90981
91103
  throw error88;
90982
91104
  }
90983
91105
  const legacyLineItemId = `${ids.course}-${activityId}-assessment`;
90984
- const [currentAttemptNumber, masteryProgress] = await Promise.all([
91106
+ const [currentAttemptNumber, mastery] = await Promise.all([
90985
91107
  this.resolveAttemptNumber(attemptNumber, studentId, caliperLineItemId, legacyLineItemId),
90986
- this.mastery.checkProgress({
91108
+ this.masteryAward.evaluate({
90987
91109
  studentId,
90988
91110
  courseId,
90989
- resourceId: ids.resource,
90990
- masteredUnits: progressData.masteredUnits ?? 0,
91111
+ masteredUnits: progressData.masteredUnits,
90991
91112
  masteredUnitsAbsolute: progressData.masteredUnitsAbsolute
90992
91113
  })
90993
91114
  ]);
90994
91115
  setAttributes({ "app.timeback.attempt_number": currentAttemptNumber });
90995
- let extensions = progressData.extensions;
90996
- const effectiveMasteredUnits = masteryProgress ? masteryProgress.effectiveDelta : progressData.masteredUnits ?? 0;
90997
- let pctCompleteApp;
90998
- let masteryAchieved = false;
90999
- let completionEntryWritten = false;
91000
- const warnings = masteryProgress?.writeWarning ? [masteryProgress.writeWarning] : undefined;
91001
- if (masteryProgress) {
91002
- masteryAchieved = masteryProgress.masteryAchieved;
91003
- pctCompleteApp = masteryProgress.pctCompleteApp;
91004
- extensions = {
91005
- ...extensions,
91006
- ...pctCompleteApp !== undefined ? { pctCompleteApp } : {}
91007
- };
91008
- }
91009
- if (masteryAchieved) {
91010
- setAttributes({ "app.timeback.mastery_achieved": true });
91011
- }
91012
- if (masteryAchieved) {
91013
- completionEntryWritten = await this.mastery.createCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
91014
- await this.emitCourseCompletionHistoryEvent({
91015
- studentId,
91016
- studentEmail,
91017
- gameId: progressData.gameId,
91018
- activityId,
91019
- courseId: ids.course,
91020
- courseName,
91021
- subject: progressData.subject,
91022
- appName: progressData.appName,
91023
- sensorUrl: progressData.sensorUrl,
91024
- eventId: progressData.completionHistoryEventId
91025
- });
91026
- }
91027
- if (masteryProgress?.masteryRevoked) {
91028
- await this.mastery.revokeCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
91029
- }
91116
+ const effectiveMasteredUnits = mastery.masteredUnitsApplied;
91117
+ const { pctCompleteApp, warnings } = mastery;
91118
+ const extensions = pctCompleteApp !== undefined ? { ...progressData.extensions, pctCompleteApp } : progressData.extensions;
91119
+ const { completionEntryWritten } = await this.masteryAward.settle(mastery, {
91120
+ studentId,
91121
+ studentEmail,
91122
+ courseId,
91123
+ classId: progressData.classId,
91124
+ gameId: progressData.gameId,
91125
+ activityId,
91126
+ courseName,
91127
+ subject: progressData.subject,
91128
+ appName: progressData.appName,
91129
+ sensorUrl: progressData.sensorUrl,
91130
+ completionHistoryEventId: progressData.completionHistoryEventId
91131
+ });
91030
91132
  try {
91031
91133
  await this.events.emitActivityEvent({
91032
91134
  studentId,
@@ -91047,7 +91149,7 @@ class ActivityRecord {
91047
91149
  appName: progressData.appName,
91048
91150
  sensorUrl: progressData.sensorUrl,
91049
91151
  eventId: progressData.eventId,
91050
- extensions: extensions || progressData.extensions,
91152
+ extensions,
91051
91153
  ...progressData.runId ? { runId: progressData.runId } : {}
91052
91154
  }).catch((error88) => {
91053
91155
  setAttributes({ "app.timeback.caliper_emit_failed": true });
@@ -91068,36 +91170,6 @@ class ActivityRecord {
91068
91170
  ...warnings ? { warnings } : {}
91069
91171
  };
91070
91172
  }
91071
- async emitCourseCompletionHistoryEvent(data) {
91072
- await this.events.emitActivityEvent({
91073
- eventId: data.eventId,
91074
- studentId: data.studentId,
91075
- studentEmail: data.studentEmail,
91076
- gameId: data.gameId,
91077
- activityId: data.activityId,
91078
- activityName: "Course completed",
91079
- courseId: data.courseId,
91080
- courseName: data.courseName,
91081
- subject: data.subject,
91082
- appName: data.appName,
91083
- sensorUrl: data.sensorUrl,
91084
- process: false,
91085
- includeAttempt: false,
91086
- eventExtensions: {
91087
- playcademy: {
91088
- eventKind: "course-completed",
91089
- source: "gameplay"
91090
- }
91091
- },
91092
- generatedExtensions: {
91093
- playcademy: {
91094
- eventKind: "course-completed",
91095
- source: "gameplay",
91096
- activityId: data.activityId
91097
- }
91098
- }
91099
- }).catch(catchEvent("timeback.caliper_completion_event_failed"));
91100
- }
91101
91173
  async resolveAttemptNumber(providedAttemptNumber, studentId, caliperLineItemId, legacyLineItemId) {
91102
91174
  if (providedAttemptNumber) {
91103
91175
  setAttributes({ "app.timeback.attempt_source": "provided" });
@@ -91495,9 +91567,12 @@ class ActivityEvents {
91495
91567
  }
91496
91568
  }
91497
91569
  function createActivityNamespace(core3, deps) {
91498
- const record3 = new ActivityRecord(core3, deps.students, deps.mastery, deps.events);
91570
+ const masteryAward = new ActivityMasteryAward(deps.mastery, deps.events);
91571
+ const record3 = new ActivityRecord(core3, deps.students, masteryAward, deps.events);
91499
91572
  const session2 = new ActivitySession(deps.students, deps.events);
91500
91573
  return {
91574
+ evaluateMasteryAward: (input) => masteryAward.evaluate(input),
91575
+ settleMasteryAward: (evaluation, input) => masteryAward.settle(evaluation, input),
91501
91576
  record: (courseId, studentIdentifier, progressData) => record3.record(courseId, studentIdentifier, progressData),
91502
91577
  session: (courseId, studentIdentifier, sessionData) => session2.record(courseId, studentIdentifier, sessionData),
91503
91578
  listEvents: (params) => deps.events.listEvents(params),
@@ -92781,8 +92856,9 @@ class TimebackClient2 {
92781
92856
  this.core.caches.clearAll();
92782
92857
  }
92783
92858
  }
92784
- var TimebackError, TimebackAuthenticationError, StudentNotFoundError, ConfigurationError, UUID_REGEX2, ONEROSTER_PATHS, TIMEBACK_API_URLS, QTI_API_URL = "https://qti.alpha-1edtech.ai/api", TIMEBACK_AUTH_URLS, CALIPER_API_URLS, ENV_VARS, ONEROSTER_STATUS, SCORE_STATUS, CACHE_DEFAULTS, PLAYCADEMY_DEFAULTS, RESOURCE_DEFAULTS, DEFAULT_TIMEOUT = 30000, SUBJECT_VALUES2, GRADE_VALUES3, MASTERY_WRITE_CAPPED_WARNING_CODE = "MASTERY_WRITE_CAPPED", EmailSchema;
92859
+ var TimebackError, TimebackAuthenticationError, StudentNotFoundError, ConfigurationError, UUID_REGEX2, ONEROSTER_PATHS, NO_MASTERY_AWARD, TIMEBACK_API_URLS, QTI_API_URL = "https://qti.alpha-1edtech.ai/api", TIMEBACK_AUTH_URLS, CALIPER_API_URLS, ENV_VARS, ONEROSTER_STATUS, SCORE_STATUS, CACHE_DEFAULTS, PLAYCADEMY_DEFAULTS, RESOURCE_DEFAULTS, DEFAULT_TIMEOUT = 30000, SUBJECT_VALUES2, GRADE_VALUES3, MASTERY_WRITE_CAPPED_WARNING_CODE = "MASTERY_WRITE_CAPPED", EmailSchema;
92785
92860
  var init_dist5 = __esm(async () => {
92861
+ init_spans();
92786
92862
  init_spans();
92787
92863
  init_src();
92788
92864
  init_spans();
@@ -92843,6 +92919,11 @@ var init_dist5 = __esm(async () => {
92843
92919
  courses: "/ims/oneroster/rostering/v1p2/courses",
92844
92920
  componentResources: "/ims/oneroster/rostering/v1p2/courses/component-resources"
92845
92921
  };
92922
+ NO_MASTERY_AWARD = {
92923
+ masteredUnitsApplied: 0,
92924
+ masteryAchieved: false,
92925
+ masteryRevoked: false
92926
+ };
92846
92927
  TIMEBACK_API_URLS = {
92847
92928
  production: "https://api.alpha-1edtech.ai",
92848
92929
  staging: "https://api.staging.alpha-1edtech.com"
@@ -95608,6 +95689,62 @@ function stringField2(value) {
95608
95689
  function firstStringField2(...values) {
95609
95690
  return values.map(stringField2).find(Boolean) ?? "";
95610
95691
  }
95692
+ function normalizedWhitespace2(value) {
95693
+ return value.normalize("NFKC").trim().replace(/\s+/g, " ");
95694
+ }
95695
+ function normalizedIdentityCase2(value) {
95696
+ return value.toLocaleUpperCase("en-US");
95697
+ }
95698
+ function frameworkAliasKey2(value) {
95699
+ return value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/g, "");
95700
+ }
95701
+ function isCommonCoreMathIdentifier2(identifier) {
95702
+ const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
95703
+ 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);
95704
+ }
95705
+ function isCommonCoreElaIdentifier2(identifier) {
95706
+ const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
95707
+ 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);
95708
+ }
95709
+ function canonicalFramework2(authoredFramework, identifier) {
95710
+ const aliasKey = frameworkAliasKey2(authoredFramework);
95711
+ if (COMMON_CORE_MATH_FRAMEWORK_ALIASES2.has(aliasKey) || COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey) && isCommonCoreMathIdentifier2(identifier)) {
95712
+ return "CCSS.Math";
95713
+ }
95714
+ if (COMMON_CORE_ELA_FRAMEWORK_ALIASES2.has(aliasKey) || COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey) && isCommonCoreElaIdentifier2(identifier)) {
95715
+ return "CCSS.ELA-Literacy";
95716
+ }
95717
+ if (COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey)) {
95718
+ return "CCSS";
95719
+ }
95720
+ return normalizedIdentityCase2(authoredFramework);
95721
+ }
95722
+ function canonicalIdentifier2(framework, authoredIdentifier) {
95723
+ const identifier = normalizedIdentityCase2(normalizedWhitespace2(authoredIdentifier));
95724
+ if (framework === "CCSS.Math") {
95725
+ return identifier.replace(/^CCSS\.MATH\.CONTENT\./, "").replace(/^CCSS\.MATH\.PRACTICE\./, "").replace(/^CCSS\.MATH\./, "");
95726
+ }
95727
+ if (framework === "CCSS.ELA-Literacy") {
95728
+ return identifier.replace(/^CCSS\.ELA-LITERACY\./, "");
95729
+ }
95730
+ return identifier;
95731
+ }
95732
+ function canonicalAssessmentStandardRef2(standard) {
95733
+ const authoredFramework = normalizedWhitespace2(standard.framework);
95734
+ const framework = canonicalFramework2(authoredFramework, standard.identifier);
95735
+ const identifier = canonicalIdentifier2(framework, standard.identifier);
95736
+ return {
95737
+ framework,
95738
+ identifier
95739
+ };
95740
+ }
95741
+ function assessmentStandardRefKey2(standard) {
95742
+ const canonical = canonicalAssessmentStandardRef2(standard);
95743
+ return JSON.stringify([
95744
+ normalizedIdentityCase2(canonical.framework),
95745
+ normalizedIdentityCase2(canonical.identifier)
95746
+ ]);
95747
+ }
95611
95748
  function dedupeStandards2(standards) {
95612
95749
  const deduped = new Map;
95613
95750
  for (const standard of standards) {
@@ -98341,6 +98478,22 @@ var init_locks = __esm(() => {
98341
98478
  });
98342
98479
 
98343
98480
  // ../api-core/src/utils/assessment-runtime-lock.util.ts
98481
+ async function crossAssessmentAttemptLockBarrier(lock, db2, attemptId) {
98482
+ const maxAttempts = 5;
98483
+ for (let attempt = 1;attempt <= maxAttempts; attempt += 1) {
98484
+ try {
98485
+ await lock(db2, [assessmentRuntimeAttemptLockKey(attemptId)], async () => {
98486
+ return;
98487
+ });
98488
+ return;
98489
+ } catch (error88) {
98490
+ if (!(error88 instanceof ServiceUnavailableError) || attempt === maxAttempts) {
98491
+ throw error88;
98492
+ }
98493
+ await sleep(25);
98494
+ }
98495
+ }
98496
+ }
98344
98497
  function normalizeKeys(keys) {
98345
98498
  return [...new Set(keys)].toSorted();
98346
98499
  }
@@ -99354,6 +99507,97 @@ var init_timeback_qti_authoring_util = __esm(() => {
99354
99507
  });
99355
99508
 
99356
99509
  // ../api-core/src/utils/timeback-assessment-runtime.util.ts
99510
+ function assessmentPreparationContentionError(expectedResponseVersion, responseVersion, kind) {
99511
+ if (expectedResponseVersion === responseVersion) {
99512
+ return new ServiceUnavailableError(`The ${kind} response state changed repeatedly while this item was being prepared. Try again shortly.`, {
99513
+ retryable: true,
99514
+ reason: "PREPARATION_CONTENTION",
99515
+ expectedResponseVersion,
99516
+ responseVersion
99517
+ });
99518
+ }
99519
+ return AssessmentRuntimeError.from(assessmentResponseVersionConflict(expectedResponseVersion, responseVersion));
99520
+ }
99521
+ function assessmentTrackableCourse(integration) {
99522
+ if (!isTimebackGrade2(integration.grade) || !isTimebackSubject(integration.subject)) {
99523
+ return null;
99524
+ }
99525
+ return { grade: integration.grade, subject: integration.subject };
99526
+ }
99527
+ function buildAssessmentActivityData(input) {
99528
+ if (!input.course) {
99529
+ return;
99530
+ }
99531
+ return {
99532
+ activityId: input.activityId,
99533
+ activityName: input.activityName,
99534
+ ...input.course,
99535
+ courseId: input.courseId
99536
+ };
99537
+ }
99538
+ function buildDiagnosticAssessmentSnapshot(input) {
99539
+ return {
99540
+ attemptId: input.attemptId,
99541
+ responseVersion: input.metadata.responseVersion,
99542
+ status: "in_progress",
99543
+ flow: "platform-routed-item-submit",
99544
+ ...input.activityData ? { activityData: input.activityData } : {},
99545
+ assessment: assessmentPresentationForAttempt(input.assessment, input.attemptId),
99546
+ selection: {
99547
+ kind: "platform-routed-diagnostic",
99548
+ purpose: "diagnostic",
99549
+ definitionId: input.metadata.diagnostic.definitionId,
99550
+ diagnosticKey: input.metadata.diagnostic.diagnosticKey,
99551
+ routingRevision: input.metadata.diagnostic.routingRevision
99552
+ },
99553
+ routing: projectDiagnosticRoutingSnapshot(input.metadata.diagnostic.routingRevision, input.state),
99554
+ completion: null
99555
+ };
99556
+ }
99557
+ function buildConventionalAssessmentSnapshot(input) {
99558
+ const metadata2 = input.metadata;
99559
+ let selection;
99560
+ if (metadata2.purpose === "review") {
99561
+ selection = {
99562
+ kind: "standards-review",
99563
+ purpose: metadata2.purpose,
99564
+ standards: [...metadata2.review.standards],
99565
+ candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
99566
+ selections: [...metadata2.review.selections],
99567
+ fulfillment: metadata2.review.fulfillment
99568
+ };
99569
+ } else if (metadata2.purpose === "mastery") {
99570
+ selection = {
99571
+ kind: "standard-quiz",
99572
+ purpose: metadata2.purpose,
99573
+ standard: metadata2.mastery.standard
99574
+ };
99575
+ } else {
99576
+ selection = { kind: "fixed-test", purpose: metadata2.purpose };
99577
+ }
99578
+ return {
99579
+ attemptId: input.attemptId,
99580
+ responseVersion: metadata2.responseVersion,
99581
+ status: "in_progress",
99582
+ flow: assessmentFlowForPurpose(metadata2.purpose),
99583
+ ...input.activityData ? { activityData: input.activityData } : {},
99584
+ assessment: assessmentPresentationForAttempt(input.assessment, input.attemptId),
99585
+ responses: metadata2.responses,
99586
+ itemSubmissions: metadata2.itemSubmissions,
99587
+ score: null,
99588
+ selection
99589
+ };
99590
+ }
99591
+ function buildAssessmentAwardRecord(input, mastery) {
99592
+ return {
99593
+ xpAwarded: input.xpAwarded,
99594
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
99595
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {},
99596
+ masteredUnitsApplied: mastery.masteredUnitsApplied,
99597
+ ...mastery.pctCompleteApp !== undefined ? { pctCompleteApp: mastery.pctCompleteApp } : {},
99598
+ ...mastery.warnings ? { warnings: mastery.warnings } : {}
99599
+ };
99600
+ }
99357
99601
  function stageAssessmentAttemptSupersession(attempt) {
99358
99602
  Object.assign(attempt.result, ASSESSMENT_ATTEMPT_SUPERSEDED);
99359
99603
  Object.assign(attempt.selection, ASSESSMENT_ATTEMPT_SUPERSEDED);
@@ -99564,13 +99808,35 @@ function buildAssessmentSubmitResult(input) {
99564
99808
  }
99565
99809
  return { ...base, purpose: input.metadata.purpose };
99566
99810
  }
99811
+ function assessmentAwardFromResult(result, metadata2) {
99812
+ if (metadata2.award) {
99813
+ return metadata2.award;
99814
+ }
99815
+ const xp = result.metadata?.xp;
99816
+ return typeof xp === "number" && Number.isFinite(xp) && xp >= 0 ? { xpAwarded: xp, masteredUnitsApplied: 0 } : null;
99817
+ }
99567
99818
  function buildCompletedAssessmentSubmitResult(input) {
99568
- const submitted = buildAssessmentSubmitResult(input);
99569
- return submitted ? {
99819
+ const attemptId = input.result.sourcedId;
99820
+ const award = assessmentAwardFromResult(input.result, input.metadata);
99821
+ if (award === null) {
99822
+ throw new GoneError("This completed assessment is missing its recorded XP.", {
99823
+ attemptId
99824
+ });
99825
+ }
99826
+ const submitted = buildAssessmentSubmitResult({ attemptId, metadata: input.metadata });
99827
+ if (!submitted) {
99828
+ throw new GoneError("This completed assessment has incomplete persisted results.", {
99829
+ attemptId
99830
+ });
99831
+ }
99832
+ return {
99570
99833
  ...submitted,
99571
99834
  status: "completed",
99572
- xpAwarded: input.xpAwarded
99573
- } : null;
99835
+ xpAwarded: award.xpAwarded,
99836
+ masteredUnitsApplied: award.masteredUnitsApplied,
99837
+ ...award.pctCompleteApp !== undefined ? { pctCompleteApp: award.pctCompleteApp } : {},
99838
+ ...award.warnings ? { warnings: award.warnings } : {}
99839
+ };
99574
99840
  }
99575
99841
  function assessmentAttemptId(input) {
99576
99842
  return deterministicUUID([
@@ -99897,6 +100163,22 @@ function assessmentFixtureScoringKeys(assessment, questions) {
99897
100163
  ...Object.keys(responseAreas).length > 0 ? { responseAreas } : {}
99898
100164
  };
99899
100165
  }
100166
+ function prepareDiagnosticAssessmentResponses(assessment, current, input) {
100167
+ const update2 = { [input.itemIdentifier]: input.responses };
100168
+ validateAssessmentResponseUpdate(assessment, update2);
100169
+ const responses = applyAssessmentResponseUpdate(current, update2);
100170
+ const item = assessment.items.find((candidate) => candidate.identifier === input.itemIdentifier);
100171
+ const itemResponses = responses[input.itemIdentifier];
100172
+ const missingResponse = item?.interactions.find((interaction) => itemResponses?.[interaction.responseIdentifier] === undefined);
100173
+ if (!item || !itemResponses || missingResponse) {
100174
+ throw new AssessmentRuntimeError("INVALID_RESPONSE", `Diagnostic item ${input.itemIdentifier} requires a response for every interaction.`, {
100175
+ itemIdentifier: input.itemIdentifier,
100176
+ ...missingResponse ? { responseIdentifier: missingResponse.responseIdentifier } : {}
100177
+ });
100178
+ }
100179
+ validateAssessmentResponses(assessment, { [input.itemIdentifier]: itemResponses });
100180
+ return responses;
100181
+ }
99900
100182
  function validateAssessmentResponseUpdate(assessment, update2) {
99901
100183
  const message = assessmentResponseUpdateValidationMessage(assessment, update2);
99902
100184
  if (message) {
@@ -99983,7 +100265,7 @@ function buildAssessmentResultSubmission(input) {
99983
100265
  }
99984
100266
  };
99985
100267
  }
99986
- function buildAssessmentResultXpFinalization(input) {
100268
+ function buildAssessmentResultAwardFinalization(input) {
99987
100269
  const completion = input.metadata.completion;
99988
100270
  if (!completion || completion.totalQuestions === undefined) {
99989
100271
  throw new Error("A scored assessment must retain its completion facts before finalization");
@@ -99991,8 +100273,10 @@ function buildAssessmentResultXpFinalization(input) {
99991
100273
  const metadata2 = {
99992
100274
  ...input.metadata,
99993
100275
  finalizedAt: input.timestamp,
99994
- updatedAt: input.timestamp
100276
+ updatedAt: input.timestamp,
100277
+ award: input.award
99995
100278
  };
100279
+ const masteryRequested = input.award.masteredUnits !== undefined || input.award.masteredUnitsAbsolute !== undefined;
99996
100280
  return {
99997
100281
  metadata: metadata2,
99998
100282
  resultUpdate: {
@@ -100004,7 +100288,8 @@ function buildAssessmentResultXpFinalization(input) {
100004
100288
  ...ASSESSMENT_ATTEMPT_COMPLETED,
100005
100289
  metadata: {
100006
100290
  ...mergeAssessmentRuntimeMetadata(input.result.metadata, metadata2),
100007
- xp: input.xpAwarded,
100291
+ xp: input.award.xpAwarded,
100292
+ ...masteryRequested ? { masteredUnits: input.award.masteredUnitsApplied } : {},
100008
100293
  totalQuestions: completion.totalQuestions,
100009
100294
  correctQuestions: completion.correctQuestions,
100010
100295
  appName: input.appName
@@ -100017,6 +100302,7 @@ var init_timeback_assessment_runtime_util = __esm(() => {
100017
100302
  init_src();
100018
100303
  init_assessment_runtime2();
100019
100304
  init_qti();
100305
+ init_types2();
100020
100306
  init_utils6();
100021
100307
  init_timeback4();
100022
100308
  init_uuid();
@@ -100025,6 +100311,210 @@ var init_timeback_assessment_runtime_util = __esm(() => {
100025
100311
  });
100026
100312
 
100027
100313
  // ../api-core/src/utils/timeback-review-mapping.util.ts
100314
+ function reviewBankTestItemIdentifiers(test) {
100315
+ return qtiTestParts(test).flatMap((part) => part["qti-assessment-section"].flatMap((section) => (section["qti-assessment-item-ref"] ?? []).map((reference) => reference.identifier)));
100316
+ }
100317
+ function canonicalSyncContributors(contributors) {
100318
+ return contributors.map((contributor) => {
100319
+ const canonical = canonicalContributorIdentity(contributor);
100320
+ return {
100321
+ ...contributor,
100322
+ ...canonical
100323
+ };
100324
+ }).toSorted((left, right) => compareCodeUnits(left.standardKey, right.standardKey) || compareCodeUnits(left.qtiTestIdentifier, right.qtiTestIdentifier) || compareCodeUnits(left.assessmentKey, right.assessmentKey));
100325
+ }
100326
+ function canonicalContributorIdentity(contributor) {
100327
+ const standard = canonicalReviewStandardRef(contributor.standard);
100328
+ if (!standard) {
100329
+ throw new ValidationError(`Mastery assessment ${contributor.qtiTestIdentifier} has an invalid standard`);
100330
+ }
100331
+ return {
100332
+ qtiTestIdentifier: contributor.qtiTestIdentifier,
100333
+ standard,
100334
+ standardKey: assessmentStandardRefKey2(standard)
100335
+ };
100336
+ }
100337
+ async function reviewBankContributorFingerprint(contributors) {
100338
+ const uniqueTuples = new Map;
100339
+ for (const contributor of contributors) {
100340
+ const { qtiTestIdentifier, standardKey } = canonicalContributorIdentity(contributor);
100341
+ const tuple3 = { qtiTestIdentifier, standardKey };
100342
+ uniqueTuples.set(canonicalJson2(tuple3), tuple3);
100343
+ }
100344
+ const digest = await sha256Hex(canonicalJson2([...uniqueTuples.values()].toSorted((left, right) => compareCodeUnits(left.standardKey, right.standardKey) || compareCodeUnits(left.qtiTestIdentifier, right.qtiTestIdentifier))));
100345
+ return `review-bank-contributors-v1:${digest}`;
100346
+ }
100347
+ function conflictWarning(input) {
100348
+ const retained = `${input.retainedStandard.framework} · ${input.retainedStandard.identifier}`;
100349
+ const skipped = `${input.skippedStandard.framework} · ${input.skippedStandard.identifier}`;
100350
+ return {
100351
+ code: "QUESTION_STANDARD_CONFLICT",
100352
+ ...input,
100353
+ message: `Question ${input.itemIdentifier} is referenced by multiple mastery standards; retained ${retained} and skipped ${skipped} from ${input.skippedQtiTestIdentifier}.`
100354
+ };
100355
+ }
100356
+ async function prepareReviewBankSynchronization(input) {
100357
+ const contributors = canonicalSyncContributors(input.contributors);
100358
+ const selectedByItem = new Map;
100359
+ const warningKeys = new Set;
100360
+ const warnings = [];
100361
+ for (const contributor of contributors) {
100362
+ const itemIdentifiers2 = [
100363
+ ...new Set(reviewBankTestItemIdentifiers(contributor.test))
100364
+ ].toSorted();
100365
+ for (const itemIdentifier of itemIdentifiers2) {
100366
+ const selected = selectedByItem.get(itemIdentifier);
100367
+ if (!selected) {
100368
+ selectedByItem.set(itemIdentifier, {
100369
+ standard: contributor.standard,
100370
+ standardKey: contributor.standardKey
100371
+ });
100372
+ } else if (selected.standardKey !== contributor.standardKey) {
100373
+ const warningKey = `${itemIdentifier}\x00${contributor.standardKey}`;
100374
+ if (!warningKeys.has(warningKey)) {
100375
+ warningKeys.add(warningKey);
100376
+ warnings.push(conflictWarning({
100377
+ itemIdentifier,
100378
+ retainedStandard: selected.standard,
100379
+ skippedStandard: contributor.standard,
100380
+ skippedQtiTestIdentifier: contributor.qtiTestIdentifier
100381
+ }));
100382
+ }
100383
+ }
100384
+ }
100385
+ }
100386
+ const itemIdentifiers = [...selectedByItem.keys()].toSorted();
100387
+ const sourceContentRevision = input.contributorFingerprint ?? await reviewBankContributorFingerprint(contributors);
100388
+ const bank = await buildReviewBankIndexFromReferences({
100389
+ bankIdentifier: input.bankIdentifier,
100390
+ sourceContentRevision,
100391
+ items: itemIdentifiers.map((itemIdentifier) => ({
100392
+ itemIdentifier,
100393
+ standards: [selectedByItem.get(itemIdentifier).standard]
100394
+ }))
100395
+ });
100396
+ const manifest = await buildReviewBankManifest(bank);
100397
+ return {
100398
+ bankIdentifier: input.bankIdentifier,
100399
+ contributorFingerprint: sourceContentRevision,
100400
+ itemIdentifiers,
100401
+ manifest,
100402
+ warnings,
100403
+ summary: {
100404
+ itemCount: itemIdentifiers.length,
100405
+ standardCount: Object.keys(manifest.itemsByStandard).length,
100406
+ contributorCount: contributors.length,
100407
+ sourceFingerprint: manifest.sourceFingerprint
100408
+ }
100409
+ };
100410
+ }
100411
+ function reviewBankSinkMetadata(integrationId, plan) {
100412
+ return {
100413
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
100414
+ integrationId,
100415
+ [PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY]: {
100416
+ version: PLAYCADEMY_REVIEW_BANK_SINK_VERSION,
100417
+ integrationId,
100418
+ contributorFingerprint: plan.contributorFingerprint,
100419
+ warnings: plan.warnings
100420
+ },
100421
+ [PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: plan.manifest
100422
+ };
100423
+ }
100424
+ function reviewBankSinkMarker(test) {
100425
+ const parsed = ReviewBankSinkMarkerSchema.safeParse(test.metadata?.[PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY]);
100426
+ return parsed.success ? parsed.data : null;
100427
+ }
100428
+ function reviewBankSinkState(test, integrationId) {
100429
+ assertReviewBankSinkOwnership(test, integrationId);
100430
+ const marker = reviewBankSinkMarker(test);
100431
+ return {
100432
+ contributorFingerprint: marker?.contributorFingerprint ?? null,
100433
+ warnings: marker?.warnings ?? []
100434
+ };
100435
+ }
100436
+ async function unchangedReviewBankSynchronizationResult(input) {
100437
+ const sinkState = reviewBankSinkState(input.sink, input.integrationId);
100438
+ if (sinkState.contributorFingerprint !== input.contributorFingerprint) {
100439
+ return null;
100440
+ }
100441
+ const membershipItemIdentifiers = reviewBankTestItemIdentifiers(input.sink);
100442
+ const bank = await reviewBankIndexFromManifest(input.sink.metadata, {
100443
+ bankIdentifier: input.sink.identifier,
100444
+ membershipItemIdentifiers
100445
+ });
100446
+ const manifest = input.sink.metadata?.[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY];
100447
+ if (!bank || typeof manifest?.sourceFingerprint !== "string") {
100448
+ return null;
100449
+ }
100450
+ const standardCount = new Set(bank.items.flatMap((item) => item.standards.map(assessmentStandardRefKey2))).size;
100451
+ return {
100452
+ qtiTestIdentifier: input.sink.identifier,
100453
+ itemCount: bank.items.length,
100454
+ standardCount,
100455
+ contributorCount: input.contributorCount,
100456
+ sourceFingerprint: manifest.sourceFingerprint,
100457
+ addedItemCount: 0,
100458
+ removedItemCount: 0,
100459
+ unchanged: true,
100460
+ warnings: sinkState.warnings
100461
+ };
100462
+ }
100463
+ function reviewBankSynchronizationDiff(input) {
100464
+ let previousMembershipReadable = true;
100465
+ let previousItemIdentifiers = [];
100466
+ if (input.existingSink) {
100467
+ try {
100468
+ previousItemIdentifiers = reviewBankTestItemIdentifiers(input.existingSink);
100469
+ } catch {
100470
+ previousMembershipReadable = false;
100471
+ }
100472
+ }
100473
+ const previousItems = new Set(previousItemIdentifiers);
100474
+ const desiredItems = new Set(input.plan.itemIdentifiers);
100475
+ const addedItemCount = input.plan.itemIdentifiers.filter((identifier) => !previousItems.has(identifier)).length;
100476
+ const removedItemCount = [...previousItems].filter((identifier) => !desiredItems.has(identifier)).length;
100477
+ const currentManifest = input.existingSink?.metadata?.[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY];
100478
+ const currentContributorFingerprint = input.existingSink ? reviewBankSinkMarker(input.existingSink)?.contributorFingerprint ?? null : null;
100479
+ const canonicalMembership = previousItemIdentifiers.length === input.plan.itemIdentifiers.length && previousItemIdentifiers.every((identifier, index2) => identifier === input.plan.itemIdentifiers[index2]);
100480
+ const unchanged = input.existingSink !== null && previousMembershipReadable && canonicalMembership && canonicalJson2(currentManifest) === canonicalJson2(input.plan.manifest) && currentContributorFingerprint === input.plan.contributorFingerprint;
100481
+ return { addedItemCount, removedItemCount, unchanged };
100482
+ }
100483
+ function assertReviewBankSinkOwnership(test, integrationId) {
100484
+ const marker = reviewBankSinkMarker(test);
100485
+ if (marker?.integrationId !== integrationId || test.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM || test.identifier !== reviewBankSinkIdentifier(integrationId)) {
100486
+ throw new ValidationError(`QTI assessment ${test.identifier} is not the review-bank sink for this integration`);
100487
+ }
100488
+ }
100489
+ function buildReviewBankSinkInput(input) {
100490
+ return {
100491
+ ...input.includeIdentifier ? { identifier: input.identifier } : {},
100492
+ title: input.title,
100493
+ metadata: input.metadata,
100494
+ "qti-test-part": [
100495
+ {
100496
+ identifier: `${input.identifier}-part1`,
100497
+ navigationMode: "linear",
100498
+ submissionMode: "individual",
100499
+ "qti-assessment-section": [
100500
+ {
100501
+ identifier: `${input.identifier}-section1`,
100502
+ title: "Questions",
100503
+ visible: true,
100504
+ required: true,
100505
+ fixed: false,
100506
+ sequence: 1,
100507
+ "qti-assessment-item-ref": input.itemIdentifiers.map((itemIdentifier, index2) => ({
100508
+ identifier: itemIdentifier,
100509
+ href: input.itemHref(itemIdentifier),
100510
+ sequence: index2 + 1
100511
+ }))
100512
+ }
100513
+ ]
100514
+ }
100515
+ ]
100516
+ };
100517
+ }
100028
100518
  function reviewMappingIssueSummary(label, identifiers) {
100029
100519
  if (identifiers.length === 0) {
100030
100520
  return null;
@@ -100073,11 +100563,47 @@ async function prepareReviewMappingUpdate(test, questions) {
100073
100563
  }
100074
100564
  };
100075
100565
  }
100566
+ var PLAYCADEMY_REVIEW_BANK_SINK_METADATA_KEY = "playcademyReviewBankSink", PLAYCADEMY_REVIEW_BANK_SINK_VERSION = 1, StoredSinkWarningSchema, ReviewBankSinkMarkerSchema;
100076
100567
  var init_timeback_review_mapping_util = __esm(() => {
100568
+ init_esm();
100077
100569
  init_assessment_runtime2();
100570
+ init_qti();
100571
+ init_timeback3();
100078
100572
  init_errors2();
100079
100573
  init_timeback_assessment_runtime_util();
100080
100574
  init_timeback_qti_authoring_util();
100575
+ StoredSinkWarningSchema = exports_external.object({
100576
+ code: exports_external.literal("QUESTION_STANDARD_CONFLICT"),
100577
+ itemIdentifier: exports_external.string(),
100578
+ retainedStandard: exports_external.object({ framework: exports_external.string(), identifier: exports_external.string() }),
100579
+ skippedStandard: exports_external.object({ framework: exports_external.string(), identifier: exports_external.string() }),
100580
+ skippedQtiTestIdentifier: exports_external.string()
100581
+ });
100582
+ ReviewBankSinkMarkerSchema = exports_external.object({
100583
+ version: exports_external.literal(PLAYCADEMY_REVIEW_BANK_SINK_VERSION),
100584
+ integrationId: exports_external.string(),
100585
+ contributorFingerprint: exports_external.string().nullable().catch(null),
100586
+ warnings: exports_external.array(StoredSinkWarningSchema).nullable().catch(null)
100587
+ }).transform((marker) => {
100588
+ if (!marker.contributorFingerprint || !marker.warnings) {
100589
+ return { ...marker, contributorFingerprint: null, warnings: [] };
100590
+ }
100591
+ const warnings = [];
100592
+ for (const warning of marker.warnings) {
100593
+ const retained = canonicalReviewStandardRef(warning.retainedStandard);
100594
+ const skipped = canonicalReviewStandardRef(warning.skippedStandard);
100595
+ if (!retained || !skipped) {
100596
+ return { ...marker, contributorFingerprint: null, warnings: [] };
100597
+ }
100598
+ warnings.push(conflictWarning({
100599
+ itemIdentifier: warning.itemIdentifier,
100600
+ retainedStandard: retained,
100601
+ skippedStandard: skipped,
100602
+ skippedQtiTestIdentifier: warning.skippedQtiTestIdentifier
100603
+ }));
100604
+ }
100605
+ return { ...marker, warnings };
100606
+ });
100081
100607
  });
100082
100608
 
100083
100609
  // ../api-core/src/utils/timeback-assessment-publication.util.ts
@@ -100381,11 +100907,6 @@ function assertAssessmentHasQuestions(questions) {
100381
100907
  throw new ValidationError("An assessment must contain at least one question to publish");
100382
100908
  }
100383
100909
  }
100384
- function assertReviewAssessmentHasStandards(standardCounts) {
100385
- if (standardCounts.some((count) => count !== 1)) {
100386
- throw new ValidationError("Every question in a review assessment must have exactly one standards alignment");
100387
- }
100388
- }
100389
100910
  function planAssessmentRemoval(status) {
100390
100911
  if (status === "draft") {
100391
100912
  return { kind: "delete", action: "discarded", operation: "discard_draft" };
@@ -100448,6 +100969,9 @@ var init_timeback_assessment_rules_util = __esm(() => {
100448
100969
  });
100449
100970
 
100450
100971
  // ../api-core/src/utils/timeback-qti-hydration.util.ts
100972
+ function qtiItemHref(client2, itemIdentifier) {
100973
+ return `${client2.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
100974
+ }
100451
100975
  async function hydrateQtiTestQuestions(client2, references) {
100452
100976
  const questions = await runWithConcurrency(references.questions, QTI_HYDRATION_CONCURRENCY, async (reference) => ({
100453
100977
  ...reference,
@@ -100470,6 +100994,28 @@ async function hydrateQtiTestQuestionSelection(client2, references, itemIdentifi
100470
100994
  questions: selectedReferences
100471
100995
  });
100472
100996
  }
100997
+ async function hydrateQtiItemSelection(client2, test, itemIdentifiers) {
100998
+ const testPart = qtiTestParts(test)[0];
100999
+ const section = testPart?.["qti-assessment-section"][0];
101000
+ if (!testPart || !section) {
101001
+ throw new Error(`QTI assessment ${test.identifier} has no section to reference items from`);
101002
+ }
101003
+ const questions = await runWithConcurrency(itemIdentifiers, QTI_HYDRATION_CONCURRENCY, async (itemIdentifier) => ({
101004
+ reference: {
101005
+ identifier: itemIdentifier,
101006
+ href: qtiItemHref(client2, itemIdentifier),
101007
+ testPart: testPart.identifier,
101008
+ section: section.identifier
101009
+ },
101010
+ question: await client2.qtiApi.assessmentItems.get(itemIdentifier)
101011
+ }));
101012
+ return {
101013
+ assessmentTest: test.identifier,
101014
+ title: test.title,
101015
+ totalQuestions: questions.length,
101016
+ questions
101017
+ };
101018
+ }
100473
101019
  async function loadQtiTestReferences(client2, identifier) {
100474
101020
  const [test, references] = await Promise.all([
100475
101021
  client2.qtiApi.assessmentTests.get(identifier),
@@ -100485,7 +101031,9 @@ async function loadHydratedQtiTest(client2, identifier) {
100485
101031
  };
100486
101032
  }
100487
101033
  var QTI_HYDRATION_CONCURRENCY = 8;
100488
- var init_timeback_qti_hydration_util = () => {};
101034
+ var init_timeback_qti_hydration_util = __esm(() => {
101035
+ init_timeback_qti_authoring_util();
101036
+ });
100489
101037
 
100490
101038
  // ../api-core/src/services/timeback-assessment-runtime.service.ts
100491
101039
  var TimebackAssessmentRuntimeService;
@@ -100515,8 +101063,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
100515
101063
  static EXPORT_CONCURRENCY = 4;
100516
101064
  static ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS = 3;
100517
101065
  static ASSESSMENT_VERSION_CONFIRMATION_DELAY_MS = 25;
100518
- static PROJECTION_BARRIER_MAX_ATTEMPTS = 5;
100519
- static PROJECTION_BARRIER_RETRY_DELAY_MS = 25;
100520
101066
  static SCORING_CONCURRENCY = 4;
100521
101067
  static STALE_AWARD_AGE_MS = 15 * 60 * 1000;
100522
101068
  deps;
@@ -101059,15 +101605,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101059
101605
  this.assertInProgress(attempt.result);
101060
101606
  if (!preview || preview.responseVersion !== attempt.metadata.responseVersion || preview.submissionId !== input.submissionId || preview.itemIdentifier !== input.itemIdentifier) {
101061
101607
  if (preparationAttempts >= TimebackAssessmentRuntimeService.ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS) {
101062
- if (input.expectedResponseVersion === attempt.metadata.responseVersion) {
101063
- throw new ServiceUnavailableError("The assessment response state changed repeatedly while this item was being prepared. Try again shortly.", {
101064
- retryable: true,
101065
- reason: "PREPARATION_CONTENTION",
101066
- expectedResponseVersion: input.expectedResponseVersion,
101067
- responseVersion: attempt.metadata.responseVersion
101068
- });
101069
- }
101070
- throw AssessmentRuntimeError.from(assessmentResponseVersionConflict(input.expectedResponseVersion, attempt.metadata.responseVersion));
101608
+ throw assessmentPreparationContentionError(input.expectedResponseVersion, attempt.metadata.responseVersion, "assessment");
101071
101609
  }
101072
101610
  return { action: "prepare" };
101073
101611
  }
@@ -101190,15 +101728,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101190
101728
  this.assertInProgress(attempt.result);
101191
101729
  if (!preview || preview.responseVersion !== metadata2.responseVersion || preview.submissionId !== input.submissionId || preview.routingNodeKey !== input.routingNodeKey || preview.itemIdentifier !== input.itemIdentifier) {
101192
101730
  if (preparationAttempts >= TimebackAssessmentRuntimeService.ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS) {
101193
- if (input.expectedResponseVersion === metadata2.responseVersion) {
101194
- throw new ServiceUnavailableError("The diagnostic response state changed repeatedly while this item was being prepared. Try again shortly.", {
101195
- retryable: true,
101196
- reason: "PREPARATION_CONTENTION",
101197
- expectedResponseVersion: input.expectedResponseVersion,
101198
- responseVersion: metadata2.responseVersion
101199
- });
101200
- }
101201
- throw AssessmentRuntimeError.from(assessmentResponseVersionConflict(input.expectedResponseVersion, metadata2.responseVersion));
101731
+ throw assessmentPreparationContentionError(input.expectedResponseVersion, metadata2.responseVersion, "diagnostic");
101202
101732
  }
101203
101733
  return { action: "prepare" };
101204
101734
  }
@@ -101213,7 +101743,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101213
101743
  itemIdentifier: input.itemIdentifier
101214
101744
  }));
101215
101745
  }
101216
- const responses = this.prepareDiagnosticResponses(preview.assessment, metadata2.responses, input);
101746
+ const responses = prepareDiagnosticAssessmentResponses(preview.assessment, metadata2.responses, input);
101217
101747
  const scoring = preview.scoring;
101218
101748
  if (!scoring || typeof scoring.isCorrect !== "boolean") {
101219
101749
  throw AssessmentRuntimeError.from(assessmentFlowViolation(`Diagnostic item ${input.itemIdentifier} did not produce determinate binary grading.`, { itemIdentifier: input.itemIdentifier }));
@@ -101286,22 +101816,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101286
101816
  answered: submission.answered
101287
101817
  };
101288
101818
  }
101289
- prepareDiagnosticResponses(assessment, current, input) {
101290
- const update2 = { [input.itemIdentifier]: input.responses };
101291
- validateAssessmentResponseUpdate(assessment, update2);
101292
- const responses = applyAssessmentResponseUpdate(current, update2);
101293
- const item = assessment.items.find((candidate) => candidate.identifier === input.itemIdentifier);
101294
- const itemResponses = responses[input.itemIdentifier];
101295
- const missingResponse = item?.interactions.find((interaction) => itemResponses?.[interaction.responseIdentifier] === undefined);
101296
- if (!item || !itemResponses || missingResponse) {
101297
- throw new AssessmentRuntimeError("INVALID_RESPONSE", `Diagnostic item ${input.itemIdentifier} requires a response for every interaction.`, {
101298
- itemIdentifier: input.itemIdentifier,
101299
- ...missingResponse ? { responseIdentifier: missingResponse.responseIdentifier } : {}
101300
- });
101301
- }
101302
- validateAssessmentResponses(assessment, { [input.itemIdentifier]: itemResponses });
101303
- return responses;
101304
- }
101305
101819
  async prepareDiagnosticItemSubmission(params, input) {
101306
101820
  try {
101307
101821
  return await this.scoreDiagnosticItemSubmission(params, input);
@@ -101335,7 +101849,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101335
101849
  const expected = loaded.state.next;
101336
101850
  let scoring = null;
101337
101851
  if (loaded.state.status === "in-progress" && expected?.nodeKey === input.routingNodeKey && expected.itemIdentifier === input.itemIdentifier && attempt.metadata.responseVersion === input.expectedResponseVersion) {
101338
- const responses = this.prepareDiagnosticResponses(assessment, attempt.metadata.responses, input);
101852
+ const responses = prepareDiagnosticAssessmentResponses(assessment, attempt.metadata.responses, input);
101339
101853
  scoring = await this.scoreItem(assessment, responses, input.itemIdentifier);
101340
101854
  }
101341
101855
  return {
@@ -101606,11 +102120,14 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101606
102120
  inProgress: attempt.result.inProgress ?? "",
101607
102121
  scoreStatus: attempt.result.scoreStatus,
101608
102122
  submissionId: attempt.metadata.submissionId,
101609
- xpAwarded: this.xpFromResult(attempt.result) ?? undefined
102123
+ award: assessmentAwardFromResult(attempt.result, attempt.metadata) ?? undefined
101610
102124
  }, input);
101611
102125
  if (disposition === "replay") {
101612
102126
  return {
101613
- response: this.completedResult(attempt.result, attempt.metadata),
102127
+ response: buildCompletedAssessmentSubmitResult({
102128
+ result: attempt.result,
102129
+ metadata: attempt.metadata
102130
+ }),
101614
102131
  completion: this.replayCompletion(attempt, input.submissionId, params, game2)
101615
102132
  };
101616
102133
  }
@@ -101618,20 +102135,27 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101618
102135
  throw AssessmentRuntimeError.from(assessmentAwardConflict({
101619
102136
  attemptId: attempt.result.sourcedId,
101620
102137
  submissionId: input.submissionId,
101621
- xpAwarded: input.xpAwarded
102138
+ xpAwarded: input.xpAwarded,
102139
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
102140
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {}
101622
102141
  }));
101623
102142
  }
101624
102143
  if (disposition === "reject") {
101625
102144
  throw AssessmentRuntimeError.from(assessmentAlreadySubmitted(attempt.result.sourcedId));
101626
102145
  }
101627
- const submitted = this.submittedResult(attempt.result, attempt.metadata);
101628
- const finalization = buildAssessmentResultXpFinalization({
102146
+ const mastery = await this.evaluateMasteryAward(attempt, params.studentId, input);
102147
+ const awardRecord = buildAssessmentAwardRecord(input, mastery);
102148
+ const finalization = buildAssessmentResultAwardFinalization({
101629
102149
  result: attempt.result,
101630
102150
  metadata: attempt.metadata,
101631
- xpAwarded: input.xpAwarded,
102151
+ award: awardRecord,
101632
102152
  appName: game2.appName,
101633
102153
  timestamp: new Date().toISOString()
101634
102154
  });
102155
+ const response = buildCompletedAssessmentSubmitResult({
102156
+ result: attempt.result,
102157
+ metadata: finalization.metadata
102158
+ });
101635
102159
  const finalized = await this.requireClient().api.oneroster.assessmentResults.upsert(attempt.result.sourcedId, finalization.resultUpdate);
101636
102160
  const finalizedForEmission = {
101637
102161
  ...finalized,
@@ -101650,25 +102174,78 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101650
102174
  "app.assessment.submission_id": input.submissionId,
101651
102175
  "app.assessment.purpose": finalization.metadata.purpose,
101652
102176
  "app.assessment.award_age_ms": this.awardAgeMs(attempt.result.scoreDate),
101653
- "app.assessment.xp_awarded": input.xpAwarded
102177
+ "app.assessment.xp_awarded": input.xpAwarded,
102178
+ "app.assessment.mastered_units_applied": mastery.masteredUnitsApplied,
102179
+ "app.assessment.mastery_achieved": mastery.masteryAchieved,
102180
+ "app.assessment.mastery_revoked": mastery.masteryRevoked
101654
102181
  });
102182
+ await this.settleMasteryAward(finalizedAttempt, mastery, input.submissionId, game2);
101655
102183
  return {
101656
- response: {
101657
- ...submitted,
101658
- status: "completed",
101659
- xpAwarded: input.xpAwarded
101660
- },
102184
+ response,
101661
102185
  completion: this.replayCompletion(finalizedAttempt, input.submissionId, params, game2)
101662
102186
  };
101663
102187
  });
101664
102188
  if (award.completion) {
101665
102189
  await this.emitCompletionBestEffort({
101666
102190
  ...award.completion,
101667
- xpAwarded: input.xpAwarded
102191
+ award: award.response
101668
102192
  });
101669
102193
  }
101670
102194
  return award.response;
101671
102195
  }
102196
+ async evaluateMasteryAward(attempt, studentIdentifier, input) {
102197
+ if (!hasMasteryAwardRequest(input)) {
102198
+ return NO_MASTERY_AWARD;
102199
+ }
102200
+ const student = await this.requireClient().roster.resolveStudent(studentIdentifier);
102201
+ return this.requireClient().activity.evaluateMasteryAward({
102202
+ studentId: student.id,
102203
+ courseId: attempt.integration.courseId,
102204
+ ...input.masteredUnits !== undefined ? { masteredUnits: input.masteredUnits } : {},
102205
+ ...input.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: input.masteredUnitsAbsolute } : {}
102206
+ });
102207
+ }
102208
+ async settleMasteryAward(attempt, mastery, submissionId, game2) {
102209
+ if (!mastery.masteryAchieved && !mastery.masteryRevoked) {
102210
+ return;
102211
+ }
102212
+ const integration = attempt.integration;
102213
+ const course = this.trackableCourse(integration, "assessment.mastery_settlement_invalid_course_metadata", { "app.assessment.attempt_id": attempt.result.sourcedId });
102214
+ if (!course) {
102215
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
102216
+ return;
102217
+ }
102218
+ try {
102219
+ const student = await this.requireClient().roster.resolveStudent(attempt.result.student.sourcedId);
102220
+ const completionHistoryEventId = `urn:uuid:${await deterministicUUID([
102221
+ "playcademy-assessment-course-completed-event-v1",
102222
+ attempt.result.sourcedId,
102223
+ submissionId
102224
+ ].join("\x00"))}`;
102225
+ const settlement = await this.requireClient().activity.settleMasteryAward(mastery, {
102226
+ studentId: student.id,
102227
+ studentEmail: student.email,
102228
+ courseId: integration.courseId,
102229
+ gameId: integration.gameId,
102230
+ activityId: attempt.metadata.activityId,
102231
+ courseName: attempt.metadata.completion?.courseName ?? "Game Course",
102232
+ subject: course.subject,
102233
+ appName: game2.appName,
102234
+ sensorUrl: game2.sensorUrl,
102235
+ completionHistoryEventId
102236
+ });
102237
+ if (mastery.masteryAchieved && !settlement.completionEntryWritten) {
102238
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
102239
+ }
102240
+ } catch (error88) {
102241
+ setAttributes({ "app.assessment.mastery_completion_entry_missing": true });
102242
+ addEvent("assessment.mastery_settlement_failed", {
102243
+ "app.assessment.attempt_id": attempt.result.sourcedId,
102244
+ "exception.type": errorType(error88),
102245
+ "app.error.message": errorMessage(error88)
102246
+ });
102247
+ }
102248
+ }
101672
102249
  async finalizeDiagnosticSubmission(input) {
101673
102250
  const { attempt } = input;
101674
102251
  const routing = await this.loadAttemptDiagnosticManifest(attempt.metadata, input.db);
@@ -102115,9 +102692,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102115
102692
  if (this.isSettled(resumed.result)) {
102116
102693
  return this.snapshotForResult(resumed.result, resumed.metadata, context2.integration);
102117
102694
  }
102118
- const catalog = await this.loadReviewBankCatalog(resumed.metadata.selectedTest.identifier, resumed.metadata.selectedTest.contentRevision);
102119
- const currentSource = await this.hydrateReviewBankSelection(catalog, resumed.metadata.review.selections.map((selection) => selection.itemIdentifier));
102120
- const source = this.pinnedReviewBankSource(currentSource, resumed.metadata);
102695
+ const source = await this.loadPinnedReviewBankSource(resumed.metadata);
102121
102696
  const assessment = projectReviewAssessment(source.assessment, source.bank, resumed.metadata.review.selections);
102122
102697
  await this.ensureReviewChildResults({
102123
102698
  result: resumed.result,
@@ -102161,7 +102736,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102161
102736
  membershipItemIdentifiers: loaded.references.questions.map((question) => question.reference.identifier)
102162
102737
  });
102163
102738
  } catch (error88) {
102164
- throw new ServiceUnavailableError(`Review bank ${identifier} has stale or invalid routing metadata. Use Update Mapping in assessment authoring.`, { reason: errorMessage(error88) });
102739
+ throw new ServiceUnavailableError(`Review bank ${identifier} has stale or invalid routing metadata. Use Sync Review Bank in assessment authoring.`, { reason: errorMessage(error88) });
102165
102740
  }
102166
102741
  if (!bank) {
102167
102742
  throw new ServiceUnavailableError(`Review bank ${identifier} needs its authoring mapping updated before it can serve review questions.`);
@@ -102177,28 +102752,60 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102177
102752
  this.reviewBankCache.set(`${identifier}\x00${bank.sourceContentRevision}`, catalog);
102178
102753
  return catalog;
102179
102754
  }
102180
- async hydrateReviewBankSelection(catalog, itemIdentifiers) {
102181
- const cacheKey2 = [
102182
- catalog.test.identifier,
102183
- catalog.bank.sourceContentRevision,
102184
- ...itemIdentifiers
102185
- ].join("\x00");
102755
+ async cachedReviewSelection(input) {
102756
+ const cacheKey2 = [input.identifier, input.contentRevision, ...input.itemIdentifiers].join("\x00");
102186
102757
  const cached3 = this.reviewSelectionCache.get(cacheKey2);
102187
102758
  if (cached3) {
102188
- return { assessment: cached3, bank: catalog.bank };
102759
+ return cached3;
102189
102760
  }
102190
- const questions = await hydrateQtiTestQuestionSelection(this.requireClient(), catalog.references, itemIdentifiers);
102191
- const assessment = await buildPlayableAssessment(catalog.test, questions);
102192
- assessment.contentRevision = catalog.bank.sourceContentRevision;
102761
+ const { test, questions } = await input.hydrate();
102762
+ const assessment = await buildPlayableAssessment(test, questions);
102763
+ assessment.contentRevision = input.contentRevision;
102193
102764
  this.reviewSelectionCache.set(cacheKey2, assessment);
102765
+ return assessment;
102766
+ }
102767
+ async hydrateReviewBankSelection(catalog, itemIdentifiers) {
102768
+ const assessment = await this.cachedReviewSelection({
102769
+ identifier: catalog.test.identifier,
102770
+ contentRevision: catalog.bank.sourceContentRevision,
102771
+ itemIdentifiers,
102772
+ hydrate: async () => ({
102773
+ test: catalog.test,
102774
+ questions: await hydrateQtiTestQuestionSelection(this.requireClient(), catalog.references, itemIdentifiers)
102775
+ })
102776
+ });
102194
102777
  return { assessment, bank: catalog.bank };
102195
102778
  }
102196
- pinnedReviewBankSource(source, metadata2) {
102779
+ async loadPinnedReviewBankSource(metadata2) {
102780
+ const itemIdentifiers = metadata2.review.selections.map((selection) => selection.itemIdentifier);
102781
+ let assessment;
102782
+ try {
102783
+ assessment = await this.cachedReviewSelection({
102784
+ identifier: metadata2.selectedTest.identifier,
102785
+ contentRevision: metadata2.selectedTest.contentRevision,
102786
+ itemIdentifiers,
102787
+ hydrate: async () => {
102788
+ const test = await this.requireClient().qtiApi.assessmentTests.get(metadata2.selectedTest.identifier);
102789
+ return {
102790
+ test,
102791
+ questions: await hydrateQtiItemSelection(this.requireClient(), test, itemIdentifiers)
102792
+ };
102793
+ }
102794
+ });
102795
+ } catch (error88) {
102796
+ if (isApiError(error88) && error88.statusCode === 404) {
102797
+ throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `A selected review item for ${metadata2.selectedTest.identifier} is no longer available.`, {
102798
+ identifier: metadata2.selectedTest.identifier,
102799
+ expectedRevision: metadata2.selectedTest.contentRevision
102800
+ });
102801
+ }
102802
+ throw error88;
102803
+ }
102197
102804
  return {
102198
- assessment: source.assessment,
102805
+ assessment,
102199
102806
  bank: {
102200
- bankIdentifier: source.assessment.identifier,
102201
- sourceContentRevision: source.assessment.contentRevision,
102807
+ bankIdentifier: assessment.identifier,
102808
+ sourceContentRevision: metadata2.selectedTest.contentRevision,
102202
102809
  bankRevision: metadata2.review.bankRevision,
102203
102810
  items: metadata2.review.selections.map((selection) => ({
102204
102811
  itemIdentifier: selection.itemIdentifier,
@@ -102222,9 +102829,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102222
102829
  if (metadata2.purpose !== "review") {
102223
102830
  return this.loadAssessment(metadata2.selectedTest.identifier, metadata2.selectedTest.contentRevision);
102224
102831
  }
102225
- const catalog = await this.loadReviewBankCatalog(metadata2.selectedTest.identifier, metadata2.selectedTest.contentRevision);
102226
- const currentSource = await this.hydrateReviewBankSelection(catalog, metadata2.review.selections.map((selection) => selection.itemIdentifier));
102227
- const source = this.pinnedReviewBankSource(currentSource, metadata2);
102832
+ const source = await this.loadPinnedReviewBankSource(metadata2);
102228
102833
  return projectReviewAssessment(source.assessment, source.bank, metadata2.review.selections);
102229
102834
  }
102230
102835
  isPlatformRoutedDiagnosticMetadata(metadata2) {
@@ -102710,7 +103315,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102710
103315
  async projectReviewItemResponse(params, projection) {
102711
103316
  await this.putReviewChildResponse(projection.result, projection.metadata, projection.itemIdentifier);
102712
103317
  try {
102713
- await this.crossAttemptLockBarrier(params.attemptId);
103318
+ await crossAssessmentAttemptLockBarrier(this.deps.assessmentRuntimeLock, this.deps.db, params.attemptId);
102714
103319
  const latest = await this.peekAttempt(params);
102715
103320
  if (this.isSettled(latest.result) && latest.metadata.purpose === "review") {
102716
103321
  await this.restoreCompletedReviewChildResponses(latest.result, latest.metadata, new Set([projection.itemIdentifier]));
@@ -102724,21 +103329,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102724
103329
  });
102725
103330
  }
102726
103331
  }
102727
- async crossAttemptLockBarrier(attemptId) {
102728
- for (let attempt = 1;attempt <= TimebackAssessmentRuntimeService.PROJECTION_BARRIER_MAX_ATTEMPTS; attempt += 1) {
102729
- try {
102730
- await this.deps.assessmentRuntimeLock(this.deps.db, [assessmentRuntimeAttemptLockKey(attemptId)], async () => {
102731
- return;
102732
- });
102733
- return;
102734
- } catch (error88) {
102735
- if (!(error88 instanceof ServiceUnavailableError) || attempt === TimebackAssessmentRuntimeService.PROJECTION_BARRIER_MAX_ATTEMPTS) {
102736
- throw error88;
102737
- }
102738
- await sleep(TimebackAssessmentRuntimeService.PROJECTION_BARRIER_RETRY_DELAY_MS);
102739
- }
102740
- }
102741
- }
102742
103332
  async restoreCompletedReviewChildResponses(result, metadata2, changedItemIdentifiers) {
102743
103333
  const outcomes = metadata2.completion?.itemOutcomes;
102744
103334
  const submissionId = metadata2.submissionId;
@@ -102884,7 +103474,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102884
103474
  attemptId: result.sourcedId,
102885
103475
  responseVersion: metadata2.responseVersion,
102886
103476
  status: "completed",
102887
- result: this.completedResult(result, metadata2)
103477
+ result: buildCompletedAssessmentSubmitResult({ result, metadata: metadata2 })
102888
103478
  };
102889
103479
  }
102890
103480
  const assessment = await this.loadAttemptAssessment(metadata2);
@@ -102901,58 +103491,37 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102901
103491
  return this.snapshot(result, metadata2, assessment, integration);
102902
103492
  }
102903
103493
  diagnosticSnapshot(result, metadata2, assessment, integration, state) {
102904
- const activityData = this.activityData(metadata2, assessment, integration);
102905
- return {
103494
+ return buildDiagnosticAssessmentSnapshot({
102906
103495
  attemptId: result.sourcedId,
102907
- responseVersion: metadata2.responseVersion,
102908
- status: "in_progress",
102909
- flow: "platform-routed-item-submit",
102910
- ...activityData ? { activityData } : {},
102911
- assessment: assessmentPresentationForAttempt(assessment, result.sourcedId),
102912
- selection: {
102913
- kind: "platform-routed-diagnostic",
102914
- purpose: "diagnostic",
102915
- definitionId: metadata2.diagnostic.definitionId,
102916
- diagnosticKey: metadata2.diagnostic.diagnosticKey,
102917
- routingRevision: metadata2.diagnostic.routingRevision
102918
- },
102919
- routing: this.diagnosticRoutingSnapshot(metadata2, state),
102920
- completion: null
102921
- };
103496
+ metadata: metadata2,
103497
+ assessment,
103498
+ state,
103499
+ activityData: this.activityData(metadata2, assessment, integration)
103500
+ });
102922
103501
  }
102923
103502
  diagnosticRoutingSnapshot(metadata2, state) {
102924
103503
  return projectDiagnosticRoutingSnapshot(metadata2.diagnostic.routingRevision, state);
102925
103504
  }
102926
103505
  snapshot(result, metadata2, assessment, integration) {
102927
- const selection = this.selectionContext(metadata2);
102928
- const activityData = this.activityData(metadata2, assessment, integration);
102929
- return {
103506
+ return buildConventionalAssessmentSnapshot({
102930
103507
  attemptId: result.sourcedId,
102931
- responseVersion: metadata2.responseVersion,
102932
- status: "in_progress",
102933
- flow: assessmentFlowForPurpose(metadata2.purpose),
102934
- ...activityData ? { activityData } : {},
102935
- assessment: assessmentPresentationForAttempt(assessment, result.sourcedId),
102936
- responses: metadata2.responses,
102937
- itemSubmissions: metadata2.itemSubmissions,
102938
- score: null,
102939
- selection
102940
- };
103508
+ metadata: metadata2,
103509
+ assessment,
103510
+ activityData: this.activityData(metadata2, assessment, integration)
103511
+ });
102941
103512
  }
102942
103513
  activityData(metadata2, assessment, integration) {
102943
103514
  const course = this.trackableCourse(integration, "assessment.activity_tracking_invalid_course_metadata");
102944
- if (!course) {
102945
- return;
102946
- }
102947
- return {
103515
+ return buildAssessmentActivityData({
102948
103516
  activityId: metadata2.activityId,
102949
103517
  activityName: assessment.title,
102950
- ...course,
102951
- courseId: integration.courseId
102952
- };
103518
+ courseId: integration.courseId,
103519
+ course
103520
+ });
102953
103521
  }
102954
103522
  trackableCourse(integration, event, attributes2 = {}) {
102955
- if (!isTimebackGrade2(integration.grade) || !isTimebackSubject(integration.subject)) {
103523
+ const course = assessmentTrackableCourse(integration);
103524
+ if (!course) {
102956
103525
  addEvent(event, {
102957
103526
  ...attributes2,
102958
103527
  "app.timeback.integration_id": integration.id,
@@ -102961,27 +103530,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102961
103530
  });
102962
103531
  return null;
102963
103532
  }
102964
- return { grade: integration.grade, subject: integration.subject };
102965
- }
102966
- selectionContext(metadata2) {
102967
- if (metadata2.purpose === "review") {
102968
- return {
102969
- kind: "standards-review",
102970
- purpose: metadata2.purpose,
102971
- standards: [...metadata2.review.standards],
102972
- candidateItemsPerStandard: metadata2.review.candidateItemsPerStandard,
102973
- selections: [...metadata2.review.selections],
102974
- fulfillment: metadata2.review.fulfillment
102975
- };
102976
- }
102977
- if (metadata2.purpose === "mastery") {
102978
- return {
102979
- kind: "standard-quiz",
102980
- purpose: metadata2.purpose,
102981
- standard: metadata2.mastery.standard
102982
- };
102983
- }
102984
- return { kind: "fixed-test", purpose: metadata2.purpose };
103533
+ return course;
102985
103534
  }
102986
103535
  submittedResult(result, metadata2) {
102987
103536
  const response = buildAssessmentSubmitResult({
@@ -102995,29 +103544,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
102995
103544
  }
102996
103545
  return response;
102997
103546
  }
102998
- completedResult(result, metadata2) {
102999
- const xpAwarded = this.xpFromResult(result);
103000
- if (xpAwarded === null) {
103001
- throw new GoneError("This completed assessment is missing its recorded XP.", {
103002
- attemptId: result.sourcedId
103003
- });
103004
- }
103005
- const response = buildCompletedAssessmentSubmitResult({
103006
- attemptId: result.sourcedId,
103007
- metadata: metadata2,
103008
- xpAwarded
103009
- });
103010
- if (!response) {
103011
- throw new GoneError("This completed assessment has incomplete persisted results.", {
103012
- attemptId: result.sourcedId
103013
- });
103014
- }
103015
- return response;
103016
- }
103017
- xpFromResult(result) {
103018
- const xp = result.metadata?.xp;
103019
- return typeof xp === "number" && Number.isFinite(xp) && xp >= 0 ? xp : null;
103020
- }
103021
103547
  awardAgeMs(scoreDate) {
103022
103548
  const submittedAt = Date.parse(scoreDate);
103023
103549
  return Number.isFinite(submittedAt) ? Math.max(0, Date.now() - submittedAt) : -1;
@@ -103144,7 +103670,9 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
103144
103670
  attemptNumber: attempt.metadata.attemptNumber,
103145
103671
  totalQuestions: input.totalQuestions,
103146
103672
  correctQuestions: input.correctQuestions,
103147
- xpAwarded: input.xpAwarded,
103673
+ xpAwarded: input.award.xpAwarded,
103674
+ ...input.award.masteredUnitsApplied !== 0 ? { masteredUnits: input.award.masteredUnitsApplied } : {},
103675
+ ...input.award.pctCompleteApp !== undefined ? { pctCompleteApp: input.award.pctCompleteApp } : {},
103148
103676
  submittedAt: attempt.result.scoreDate,
103149
103677
  eventId,
103150
103678
  ...session2 ? { resumeId: session2.resumeId } : {}
@@ -103269,6 +103797,9 @@ function assertAssessmentImportCourseMatches(manifest, integration) {
103269
103797
  throw new ValidationError(`This manifest is for ${manifest.subject} grade ${manifest.grade}, not ${integration.subject} grade ${integration.grade}.`);
103270
103798
  }
103271
103799
  }
103800
+ function assessmentImportsAffectReviewBank(targetStatus, entries) {
103801
+ return targetStatus === "live" && entries.some((entry) => entry.purpose === "mastery");
103802
+ }
103272
103803
  function associationMetadataMatches(row, entry) {
103273
103804
  return row.purpose === entry.purpose && (row.standardFramework ?? undefined) === entry.standard?.framework && (row.standardIdentifier ?? undefined) === entry.standard?.identifier;
103274
103805
  }
@@ -103318,18 +103849,6 @@ function attachedAssessmentImportMessage(targetStatus, editable) {
103318
103849
  }
103319
103850
  return editable ? "Attached as a draft." : "Attached as a read-only draft owned by another app.";
103320
103851
  }
103321
- function liveReviewAssessmentImportFailures(targetStatus, candidates, existingRows) {
103322
- if (targetStatus === "draft") {
103323
- return new Map;
103324
- }
103325
- const reviews = candidates.filter((candidate) => candidate.purpose === "review");
103326
- const hasExistingLiveReview = existingRows.some((row) => row.purpose === "review" && row.status === "live");
103327
- if (reviews.length <= 1 && !hasExistingLiveReview) {
103328
- return new Map;
103329
- }
103330
- const message = hasExistingLiveReview ? "Another review assessment is already live for this course." : "Only one review assessment can be imported live at a time.";
103331
- return new Map(reviews.map((review) => [review.qtiTestIdentifier, message]));
103332
- }
103333
103852
  var init_timeback_assessment_import_util = __esm(() => {
103334
103853
  init_errors2();
103335
103854
  init_timeback_qti_authoring_util();
@@ -103401,7 +103920,9 @@ class TimebackAssessmentsService {
103401
103920
  const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
103402
103921
  where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
103403
103922
  });
103404
- const assessments2 = await runWithConcurrency(rows, QTI_HYDRATION_CONCURRENCY, async (row) => {
103923
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
103924
+ const visibleRows = rows.filter((row) => row.purpose !== "review" || row.qtiTestIdentifier === sinkIdentifier);
103925
+ const assessments2 = await runWithConcurrency(visibleRows, QTI_HYDRATION_CONCURRENCY, async (row) => {
103405
103926
  try {
103406
103927
  const test = await client2.qtiApi.assessmentTests.get(row.qtiTestIdentifier);
103407
103928
  return {
@@ -103409,7 +103930,7 @@ class TimebackAssessmentsService {
103409
103930
  title: test.title,
103410
103931
  questionCount: countQtiTestItems(test),
103411
103932
  available: true,
103412
- editable: isQtiTestOwnedByGame(test, ownership.gameSlug)
103933
+ editable: row.purpose !== "review" && isQtiTestOwnedByGame(test, ownership.gameSlug)
103413
103934
  };
103414
103935
  } catch (error88) {
103415
103936
  addEvent("assessment.qti_fetch_failed", {
@@ -103429,8 +103950,11 @@ class TimebackAssessmentsService {
103429
103950
  return assessments2.toSorted((a, b) => a.title.localeCompare(b.title));
103430
103951
  }
103431
103952
  async createAssessment(integrationId, input) {
103432
- if (input.purpose === "diagnostic") {
103433
- throw new ValidationError("Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
103953
+ if (input.purpose === "diagnostic" || input.purpose === "review") {
103954
+ 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.");
103955
+ }
103956
+ if (input.assessmentKey === reviewBankSinkIdentifier(integrationId)) {
103957
+ throw new ValidationError("This assessment key is reserved for the review bank.");
103434
103958
  }
103435
103959
  const client2 = this.requireClient();
103436
103960
  const ownership = await this.requireQtiTestOwnershipContext(integrationId);
@@ -103506,24 +104030,41 @@ class TimebackAssessmentsService {
103506
104030
  index: index2,
103507
104031
  standard: this.requirePurposeStandard(assessment.purpose, assessment.standard)
103508
104032
  }));
103509
- const existingRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
103510
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
103511
- });
103512
- const existingByKey = new Map(existingRows.flatMap((row) => row.assessmentKey ? [[row.assessmentKey, row]] : []));
103513
- const existingByIdentifier = new Map(existingRows.map((row) => [row.qtiTestIdentifier, row]));
104033
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
104034
+ if (entries.some((entry) => entry.assessmentKey === sinkIdentifier || entry.qtiTestIdentifier === sinkIdentifier || entry.purpose === "review")) {
104035
+ throw new ValidationError("The review bank is system-managed and cannot be attached manually.");
104036
+ }
103514
104037
  const validated = await this.validateAssessmentImportEntries(client2, entries, manifest.targetStatus);
103515
104038
  const results = [];
103516
- const pendingInserts = this.planAssessmentImports({
103517
- validated,
103518
- existingByKey,
103519
- existingByIdentifier,
103520
- gameSlug,
103521
- results
103522
- });
103523
- const liveReviewFailures = liveReviewAssessmentImportFailures(manifest.targetStatus, pendingInserts.map(({ entry }) => entry), existingRows);
103524
- this.recordLiveReviewImportFailures(pendingInserts, liveReviewFailures, gameSlug, results);
103525
- const attachablePending = pendingInserts.filter(({ entry }) => !liveReviewFailures.has(entry.qtiTestIdentifier));
103526
- await this.insertImportedAssessments(integrationId, attachablePending, manifest.targetStatus, gameSlug, results);
104039
+ const affectsReviewBank = assessmentImportsAffectReviewBank(manifest.targetStatus, entries);
104040
+ const planAndInsert = async (db2, existingRows) => {
104041
+ const existingByKey = new Map(existingRows.flatMap((row) => row.assessmentKey ? [[row.assessmentKey, row]] : []));
104042
+ const existingByIdentifier = new Map(existingRows.map((row) => [row.qtiTestIdentifier, row]));
104043
+ const pendingInserts = this.planAssessmentImports({
104044
+ validated,
104045
+ existingByKey,
104046
+ existingByIdentifier,
104047
+ gameSlug,
104048
+ results
104049
+ });
104050
+ await this.insertImportedAssessments({
104051
+ db: db2,
104052
+ integrationId,
104053
+ pending: pendingInserts,
104054
+ targetStatus: manifest.targetStatus,
104055
+ gameSlug,
104056
+ results,
104057
+ abortOnError: affectsReviewBank
104058
+ });
104059
+ };
104060
+ if (affectsReviewBank) {
104061
+ await this.withLockedIntegrationAssessmentRows(integrationId, async (tx, lockedAssociations) => planAndInsert(tx, lockedAssociations));
104062
+ } else {
104063
+ const existingRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
104064
+ where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
104065
+ });
104066
+ await planAndInsert(this.deps.db, existingRows);
104067
+ }
103527
104068
  setAttribute("app.assessment.operation", "bulk_attach_existing");
103528
104069
  setAttribute("app.assessment.bulk_attach.target_status", manifest.targetStatus);
103529
104070
  setAttribute("app.assessment.bulk_attach.created", results.filter((result) => result.status === "created").length);
@@ -103536,11 +104077,6 @@ class TimebackAssessmentsService {
103536
104077
  assertManagedAssessmentIdentity(loaded.test, entry.assessmentKey);
103537
104078
  assertAssessmentHasQuestions(loaded.questions.questions);
103538
104079
  assertPlayableAssessmentImportQuestions(targetStatus, loaded.questions.questions.map(({ question }) => question));
103539
- if (entry.purpose === "review") {
103540
- assertReviewAssessmentHasStandards(loaded.questions.questions.map(({ question }) => reviewRoutingStandardRefsFromMetadata(question.metadata, {
103541
- customOnly: true
103542
- }).length));
103543
- }
103544
104080
  return { entry, test: loaded.test };
103545
104081
  } catch (error88) {
103546
104082
  return { entry, error: error88 };
@@ -103586,25 +104122,13 @@ class TimebackAssessmentsService {
103586
104122
  }
103587
104123
  return pendingInserts;
103588
104124
  }
103589
- recordLiveReviewImportFailures(pending, failures, gameSlug, results) {
103590
- for (const { entry, test } of pending) {
103591
- const message = failures.get(entry.qtiTestIdentifier);
103592
- if (message) {
103593
- results[entry.index] = {
103594
- ...this.associationImportResultBase(entry, test, gameSlug),
103595
- status: "failed",
103596
- message
103597
- };
103598
- }
103599
- }
103600
- }
103601
- async concurrentAssessmentImportDecision(integrationId, entry) {
104125
+ async concurrentAssessmentImportDecision(db2, integrationId, entry) {
103602
104126
  try {
103603
104127
  const [byKey, byIdentifier] = await Promise.all([
103604
- this.deps.db.query.gameTimebackAssessmentTests.findFirst({
104128
+ db2.query.gameTimebackAssessmentTests.findFirst({
103605
104129
  where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.assessmentKey, entry.assessmentKey))
103606
104130
  }),
103607
- this.deps.db.query.gameTimebackAssessmentTests.findFirst({
104131
+ db2.query.gameTimebackAssessmentTests.findFirst({
103608
104132
  where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, entry.qtiTestIdentifier))
103609
104133
  })
103610
104134
  ]);
@@ -103616,39 +104140,53 @@ class TimebackAssessmentsService {
103616
104140
  return null;
103617
104141
  }
103618
104142
  }
103619
- async insertImportedAssessments(integrationId, pending, targetStatus, gameSlug, results) {
103620
- for (const { entry, test } of pending) {
103621
- const base = this.associationImportResultBase(entry, test, gameSlug);
104143
+ async insertImportedAssessments(input) {
104144
+ for (const { entry, test } of input.pending) {
104145
+ const base = this.associationImportResultBase(entry, test, input.gameSlug);
103622
104146
  try {
103623
- const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
103624
- integrationId,
104147
+ const [row] = await input.db.insert(gameTimebackAssessmentTests).values({
104148
+ integrationId: input.integrationId,
103625
104149
  assessmentKey: entry.assessmentKey,
103626
104150
  qtiTestIdentifier: entry.qtiTestIdentifier,
103627
104151
  purpose: entry.purpose,
103628
- status: targetStatus,
104152
+ status: input.targetStatus,
103629
104153
  sortOrder: entry.sortOrder,
103630
104154
  standardFramework: entry.standard?.framework,
103631
104155
  standardIdentifier: entry.standard?.identifier
103632
- }).returning();
104156
+ }).onConflictDoNothing().returning();
103633
104157
  if (!row) {
103634
- throw new Error("Assessment association create returned no row");
104158
+ const concurrent = await this.concurrentAssessmentImportDecision(input.db, input.integrationId, entry);
104159
+ if (concurrent?.decision.kind === "skip" || concurrent?.decision.kind === "fail") {
104160
+ input.results[entry.index] = {
104161
+ ...base,
104162
+ status: concurrent.decision.status,
104163
+ ...concurrent.byKey ? { association: this.associationImportSummary(concurrent.byKey) } : {},
104164
+ message: concurrent.decision.message
104165
+ };
104166
+ } else {
104167
+ throw new Error("Assessment association insert conflicted");
104168
+ }
104169
+ } else {
104170
+ input.results[entry.index] = {
104171
+ ...base,
104172
+ status: "created",
104173
+ association: this.associationImportSummary(row),
104174
+ message: attachedAssessmentImportMessage(input.targetStatus, base.editable ?? false)
104175
+ };
103635
104176
  }
103636
- results[entry.index] = {
103637
- ...base,
103638
- status: "created",
103639
- association: this.associationImportSummary(row),
103640
- message: attachedAssessmentImportMessage(targetStatus, base.editable ?? false)
103641
- };
103642
104177
  } catch (error88) {
103643
- results[entry.index] = {
104178
+ if (input.abortOnError) {
104179
+ throw error88;
104180
+ }
104181
+ input.results[entry.index] = {
103644
104182
  ...base,
103645
104183
  status: "failed",
103646
104184
  message: `Association attach failed: ${errorMessage(error88)}`
103647
104185
  };
103648
104186
  if (isUniqueViolation(error88)) {
103649
- const concurrent = await this.concurrentAssessmentImportDecision(integrationId, entry);
104187
+ const concurrent = await this.concurrentAssessmentImportDecision(input.db, input.integrationId, entry);
103650
104188
  if (concurrent?.decision.kind === "skip" || concurrent?.decision.kind === "fail") {
103651
- results[entry.index] = {
104189
+ input.results[entry.index] = {
103652
104190
  ...base,
103653
104191
  status: concurrent.decision.status,
103654
104192
  ...concurrent.byKey ? { association: this.associationImportSummary(concurrent.byKey) } : {},
@@ -103662,6 +104200,12 @@ class TimebackAssessmentsService {
103662
104200
  async updateAssessment(integrationId, qtiTestIdentifier, input) {
103663
104201
  try {
103664
104202
  return await this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
104203
+ if (row.purpose === "review" || qtiTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
104204
+ throw new ValidationError("The review bank is system-managed and cannot be edited.");
104205
+ }
104206
+ if (input.purpose === "review") {
104207
+ throw new ValidationError("Assessment purpose cannot be changed to the system-managed review bank.");
104208
+ }
103665
104209
  const nextPurpose = input.purpose ?? row.purpose;
103666
104210
  assertPurposeChangeDraft(row, nextPurpose);
103667
104211
  const requestedStandard = input.standard ? this.canonicalAssessmentStandard(input.standard) : undefined;
@@ -103680,19 +104224,10 @@ class TimebackAssessmentsService {
103680
104224
  ...associationUpdates,
103681
104225
  ...diagnosticChanges.updates
103682
104226
  };
103683
- const nextStatus = input.status ?? row.status;
103684
104227
  const publishing = input.status !== undefined && isAssessmentPublicationTransition(row.status, input.status);
103685
- const activatingReview = nextPurpose === "review" && nextStatus === "live" && (row.purpose !== "review" || row.status !== "live");
103686
104228
  if (input.status !== undefined) {
103687
104229
  validateAssessmentStatusTransition(row.status, input.status);
103688
104230
  }
103689
- if (!publishing && !activatingReview && nextPurpose === "review" && row.purpose !== "review") {
103690
- const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
103691
- await this.rebuildReviewMapping(this.requireClient(), qtiTestIdentifier, {
103692
- kind: "owned",
103693
- gameSlug: ownership.gameSlug
103694
- });
103695
- }
103696
104231
  if (input.title !== undefined) {
103697
104232
  assertDraftAssessment(row);
103698
104233
  assertAllAssessmentAssociationsDraft(associations);
@@ -103702,7 +104237,7 @@ class TimebackAssessmentsService {
103702
104237
  assertQtiTestOwnedByGame(test, ownership.gameSlug);
103703
104238
  await client2.qtiApi.assessmentTests.update(qtiTestIdentifier, buildQtiTestUpdateInput(test, input.title));
103704
104239
  }
103705
- if (publishing || activatingReview) {
104240
+ if (publishing) {
103706
104241
  if (nextPurpose === "diagnostic" && !nextDiagnostic) {
103707
104242
  throw new ValidationError("Platform-routed diagnostics require a diagnostic key and routing manifest before publication.");
103708
104243
  }
@@ -103712,8 +104247,7 @@ class TimebackAssessmentsService {
103712
104247
  updates.diagnosticKey = nextDiagnostic.diagnosticKey;
103713
104248
  updates.diagnosticRoutingManifest = nextDiagnostic.routingManifest;
103714
104249
  } else {
103715
- const reviewOwnership = nextPurpose === "review" && nextStatus === "live" ? await this.requireQtiTestOwnershipContext(integrationId, tx) : undefined;
103716
- await this.validateAssessmentHasQuestions(loaded, reviewOwnership?.gameSlug);
104250
+ await this.validateAssessmentHasQuestions(loaded);
103717
104251
  }
103718
104252
  if (publishing) {
103719
104253
  const publication = await this.publishManagedAssessment(row, nextPurpose, nextDiagnostic?.routingManifest ?? null, loaded);
@@ -103736,15 +104270,15 @@ class TimebackAssessmentsService {
103736
104270
  if (databaseConstraintName(error88) === "game_timeback_assessment_tests_one_live_diagnostic_key_idx") {
103737
104271
  throw new ValidationError("Only one live revision of a diagnostic key is allowed. Archive the current revision before publishing another.");
103738
104272
  }
103739
- if (databaseConstraintName(error88) === "game_timeback_assessment_tests_one_live_review_idx") {
103740
- throw new ValidationError("Only one live review assessment is allowed. Archive the current review bank before publishing another.");
103741
- }
103742
104273
  }
103743
104274
  throw error88;
103744
104275
  }
103745
104276
  }
103746
104277
  async removeAssessment(integrationId, qtiTestIdentifier) {
103747
104278
  return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx) => {
104279
+ if (qtiTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
104280
+ throw new ValidationError("The review bank is permanent and cannot be removed or archived.");
104281
+ }
103748
104282
  const plan = planAssessmentRemoval(row.status);
103749
104283
  if (plan.kind === "delete") {
103750
104284
  await tx.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
@@ -103758,6 +104292,9 @@ class TimebackAssessmentsService {
103758
104292
  });
103759
104293
  }
103760
104294
  async reorderAssessments(integrationId, purpose, testIdentifiers) {
104295
+ if (purpose === "review") {
104296
+ throw new ValidationError("The review bank is system-managed and cannot be reordered.");
104297
+ }
103761
104298
  await this.requireIntegration(integrationId);
103762
104299
  validateUniqueAssessmentIdentifiers(testIdentifiers);
103763
104300
  const updatedAt = new Date;
@@ -103815,19 +104352,96 @@ class TimebackAssessmentsService {
103815
104352
  });
103816
104353
  return { ...result, questions };
103817
104354
  }
103818
- async updateReviewMapping(integrationId, qtiTestIdentifier) {
103819
- return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (_row, _tx, associations) => {
103820
- if (!associations.some((association) => association.purpose === "review")) {
103821
- throw new ValidationError("Review mapping is available only for standards-review assessments.");
104355
+ async synchronizeReviewBank(integrationId) {
104356
+ const client2 = this.requireClient();
104357
+ await this.requireIntegration(integrationId);
104358
+ const sinkIdentifier = reviewBankSinkIdentifier(integrationId);
104359
+ for (let attempt = 0;attempt < REVIEW_BANK_SYNC_ATTEMPTS; attempt++) {
104360
+ const associations = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
104361
+ where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
104362
+ });
104363
+ const masterySources = this.reviewBankMasterySources(associations);
104364
+ const contributorFingerprint = await reviewBankContributorFingerprint(masterySources);
104365
+ const existingSink = await this.findReviewBankSink(client2, integrationId);
104366
+ if (existingSink) {
104367
+ assertReviewBankSinkOwnership(existingSink, integrationId);
104368
+ }
104369
+ let unchangedResult = null;
104370
+ if (existingSink) {
104371
+ try {
104372
+ unchangedResult = await unchangedReviewBankSynchronizationResult({
104373
+ sink: existingSink,
104374
+ integrationId,
104375
+ contributorFingerprint,
104376
+ contributorCount: masterySources.length
104377
+ });
104378
+ } catch (error88) {
104379
+ addEvent("assessment.review_mapping_noop_validation_failed", {
104380
+ "app.assessment.qti_test_identifier": existingSink.identifier,
104381
+ "exception.type": errorType(error88),
104382
+ "app.error.message": errorMessage(error88)
104383
+ });
104384
+ }
103822
104385
  }
103823
- const client2 = this.requireClient();
103824
- const result = await this.rebuildReviewMapping(client2, qtiTestIdentifier, {
103825
- kind: "live-review-repair"
104386
+ let plan = null;
104387
+ let addedItemCount = 0;
104388
+ let removedItemCount = 0;
104389
+ let catalogUnchanged = unchangedResult !== null;
104390
+ if (!unchangedResult) {
104391
+ const contributors = await runWithConcurrency(masterySources, QTI_HYDRATION_CONCURRENCY, async (source) => {
104392
+ const test = await client2.qtiApi.assessmentTests.get(source.qtiTestIdentifier);
104393
+ assertManagedAssessmentIdentity(test, source.assessmentKey);
104394
+ return { ...source, test };
104395
+ });
104396
+ plan = await prepareReviewBankSynchronization({
104397
+ bankIdentifier: sinkIdentifier,
104398
+ contributors,
104399
+ contributorFingerprint
104400
+ });
104401
+ const diff = reviewBankSynchronizationDiff({ existingSink, plan });
104402
+ addedItemCount = diff.addedItemCount;
104403
+ removedItemCount = diff.removedItemCount;
104404
+ catalogUnchanged = diff.unchanged;
104405
+ }
104406
+ const committed = await this.withLockedIntegrationAssessmentRows(integrationId, async (tx, lockedAssociations) => {
104407
+ const lockedSources = this.reviewBankMasterySources(lockedAssociations);
104408
+ const lockedFingerprint = await reviewBankContributorFingerprint(lockedSources);
104409
+ if (lockedFingerprint !== contributorFingerprint) {
104410
+ return null;
104411
+ }
104412
+ const associationChanged = await this.installReviewBankAssociation({
104413
+ tx,
104414
+ integrationId,
104415
+ associations: lockedAssociations,
104416
+ sinkIdentifier,
104417
+ beforeInstall: async () => {
104418
+ if (!catalogUnchanged) {
104419
+ await this.writeReviewBankSink({
104420
+ client: client2,
104421
+ integrationId,
104422
+ existingSink,
104423
+ plan,
104424
+ metadata: reviewBankSinkMetadata(integrationId, plan)
104425
+ });
104426
+ }
104427
+ }
104428
+ });
104429
+ const unchanged = catalogUnchanged && !associationChanged;
104430
+ const result = unchangedResult ? { ...unchangedResult, unchanged } : {
104431
+ qtiTestIdentifier: sinkIdentifier,
104432
+ ...plan.summary,
104433
+ addedItemCount,
104434
+ removedItemCount,
104435
+ unchanged,
104436
+ warnings: plan.warnings
104437
+ };
104438
+ return this.reviewMappingResult(result);
103826
104439
  });
103827
- setAttribute("app.assessment.operation", "update_review_mapping");
103828
- setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
103829
- return result;
103830
- });
104440
+ if (committed) {
104441
+ return committed;
104442
+ }
104443
+ }
104444
+ throw new ValidationError("Mastery assessments changed while the review bank was synchronizing. Try again.");
103831
104445
  }
103832
104446
  async listQuestionLibrary(integrationId, params) {
103833
104447
  const client2 = this.requireClient();
@@ -103847,15 +104461,18 @@ class TimebackAssessmentsService {
103847
104461
  purpose,
103848
104462
  standard: standardInput
103849
104463
  } = input;
103850
- if (purpose === "diagnostic") {
103851
- throw new ValidationError("Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
104464
+ if (purpose === "diagnostic" || purpose === "review") {
104465
+ 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.");
104466
+ }
104467
+ if (assessmentKey === reviewBankSinkIdentifier(integrationId) || targetTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
104468
+ throw new ValidationError("This assessment identity is reserved for the review bank.");
103852
104469
  }
103853
104470
  const client2 = this.requireClient();
103854
104471
  const ownership = await this.requireQtiTestOwnershipContext(integrationId);
103855
104472
  const { integration } = ownership;
103856
104473
  const standard = this.requirePurposeStandard(purpose, standardInput);
103857
104474
  const source = await client2.qtiApi.assessmentTests.get(sourceTestIdentifier);
103858
- const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => this.qtiItemHref(client2, identifier));
104475
+ const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => qtiItemHref(client2, identifier));
103859
104476
  const itemCopies = await runWithConcurrency(itemPlan, QTI_HYDRATION_CONCURRENCY, async (plan) => {
103860
104477
  const sourceItem = await client2.qtiApi.assessmentItems.get(plan.sourceIdentifier);
103861
104478
  return {
@@ -103980,7 +104597,7 @@ class TimebackAssessmentsService {
103980
104597
  };
103981
104598
  }
103982
104599
  const section = await this.qtiSectionItems(client2, qtiTestIdentifier, undefined, undefined, ownership.test);
103983
- const href = this.qtiItemHref(client2, itemIdentifier);
104600
+ const href = qtiItemHref(client2, itemIdentifier);
103984
104601
  let item;
103985
104602
  let itemCreationAttempted = false;
103986
104603
  let referenceCreationAttempted = false;
@@ -104090,7 +104707,7 @@ class TimebackAssessmentsService {
104090
104707
  const result = await section.items.reorder({
104091
104708
  items: items.map((item) => ({
104092
104709
  identifier: item.identifier,
104093
- href: item.href ?? this.qtiItemHref(client2, item.identifier),
104710
+ href: item.href ?? qtiItemHref(client2, item.identifier),
104094
104711
  sequence: item.sequence
104095
104712
  }))
104096
104713
  });
@@ -104104,6 +104721,121 @@ class TimebackAssessmentsService {
104104
104721
  }
104105
104722
  return this.deps.timeback;
104106
104723
  }
104724
+ reviewBankMasterySources(associations) {
104725
+ return associations.filter((association) => association.purpose === "mastery" && association.status === "live").map((association) => {
104726
+ const standard = assessmentStandardForRow(association);
104727
+ if (!association.assessmentKey || !standard) {
104728
+ throw new ValidationError(`Live mastery assessment ${association.qtiTestIdentifier} requires a managed identity and standard`);
104729
+ }
104730
+ if (assessmentKeyFromManagedQtiIdentifier(association.qtiTestIdentifier) !== association.assessmentKey) {
104731
+ throw new ValidationError(`Live mastery assessment ${association.qtiTestIdentifier} is not a managed publication for ${association.assessmentKey}`);
104732
+ }
104733
+ return {
104734
+ assessmentKey: association.assessmentKey,
104735
+ qtiTestIdentifier: association.qtiTestIdentifier,
104736
+ standard
104737
+ };
104738
+ });
104739
+ }
104740
+ async writeReviewBankSink(input) {
104741
+ function sinkInput(title, includeIdentifier = false) {
104742
+ return buildReviewBankSinkInput({
104743
+ identifier: input.plan.bankIdentifier,
104744
+ title,
104745
+ metadata: input.metadata,
104746
+ itemIdentifiers: input.plan.itemIdentifiers,
104747
+ itemHref: (identifier) => qtiItemHref(input.client, identifier),
104748
+ includeIdentifier
104749
+ });
104750
+ }
104751
+ if (input.existingSink) {
104752
+ await input.client.qtiApi.assessmentTests.update(input.plan.bankIdentifier, sinkInput(input.existingSink.title));
104753
+ return;
104754
+ }
104755
+ try {
104756
+ const createInput = sinkInput(REVIEW_BANK_TITLE, true);
104757
+ await input.client.qtiApi.assessmentTests.create(createInput);
104758
+ } catch (error88) {
104759
+ if (!isApiError(error88) || error88.statusCode !== 409) {
104760
+ throw error88;
104761
+ }
104762
+ const concurrentlyCreated = await input.client.qtiApi.assessmentTests.get(input.plan.bankIdentifier);
104763
+ assertReviewBankSinkOwnership(concurrentlyCreated, input.integrationId);
104764
+ await input.client.qtiApi.assessmentTests.update(input.plan.bankIdentifier, sinkInput(concurrentlyCreated.title));
104765
+ }
104766
+ }
104767
+ async installReviewBankAssociation(input) {
104768
+ const singleton = input.associations.find((association) => association.qtiTestIdentifier === input.sinkIdentifier);
104769
+ if (singleton && singleton.purpose !== "review") {
104770
+ throw new ValidationError("The reserved review-bank identifier is associated with another assessment purpose.");
104771
+ }
104772
+ const legacyReviews = input.associations.filter((association) => association.purpose === "review" && association.qtiTestIdentifier !== input.sinkIdentifier);
104773
+ const reservedKeyOwner = input.associations.find((association) => association.assessmentKey === input.sinkIdentifier && association.qtiTestIdentifier !== input.sinkIdentifier);
104774
+ if (reservedKeyOwner && reservedKeyOwner.purpose !== "review") {
104775
+ throw new ValidationError("The reserved review-bank assessment key is already in use.");
104776
+ }
104777
+ 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);
104778
+ const legacyCurrent = legacyReviews.every((association) => association.status === "archived" && association.sortOrder === null && association.assessmentKey !== input.sinkIdentifier);
104779
+ const changed = !singletonCurrent || !legacyCurrent;
104780
+ await input.beforeInstall();
104781
+ for (const legacy of legacyReviews) {
104782
+ if (legacy.status !== "archived" || legacy.sortOrder !== null || legacy.assessmentKey === input.sinkIdentifier) {
104783
+ await input.tx.update(gameTimebackAssessmentTests).set({
104784
+ status: "archived",
104785
+ sortOrder: null,
104786
+ ...legacy.assessmentKey === input.sinkIdentifier ? { assessmentKey: null } : {},
104787
+ updatedAt: new Date
104788
+ }).where(eq(gameTimebackAssessmentTests.id, legacy.id));
104789
+ }
104790
+ }
104791
+ if (singletonCurrent) {
104792
+ return changed;
104793
+ }
104794
+ if (singleton) {
104795
+ await input.tx.update(gameTimebackAssessmentTests).set({
104796
+ assessmentKey: input.sinkIdentifier,
104797
+ purpose: "review",
104798
+ status: "live",
104799
+ sortOrder: null,
104800
+ standardFramework: null,
104801
+ standardIdentifier: null,
104802
+ diagnosticKey: null,
104803
+ diagnosticRoutingManifest: null,
104804
+ updatedAt: new Date
104805
+ }).where(eq(gameTimebackAssessmentTests.id, singleton.id));
104806
+ return changed;
104807
+ }
104808
+ await input.tx.insert(gameTimebackAssessmentTests).values({
104809
+ integrationId: input.integrationId,
104810
+ assessmentKey: input.sinkIdentifier,
104811
+ qtiTestIdentifier: input.sinkIdentifier,
104812
+ purpose: "review",
104813
+ status: "live",
104814
+ sortOrder: null,
104815
+ standardFramework: null,
104816
+ standardIdentifier: null,
104817
+ diagnosticKey: null,
104818
+ diagnosticRoutingManifest: null
104819
+ });
104820
+ return changed;
104821
+ }
104822
+ reviewMappingResult(result) {
104823
+ setAttribute("app.assessment.operation", "update_review_mapping");
104824
+ setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
104825
+ setAttribute("app.assessment.review_mapping_added_item_count", result.addedItemCount);
104826
+ setAttribute("app.assessment.review_mapping_removed_item_count", result.removedItemCount);
104827
+ return result;
104828
+ }
104829
+ async withLockedIntegrationAssessmentRows(integrationId, action) {
104830
+ return this.deps.db.transaction(async (tx) => {
104831
+ const [integration] = await tx.select({ id: gameTimebackIntegrations.id }).from(gameTimebackIntegrations).where(and(eq(gameTimebackIntegrations.id, integrationId), isActiveGameTimebackIntegrationStatus())).for("update");
104832
+ if (!integration) {
104833
+ throw new NotFoundError(`Integration not found: ${integrationId}`);
104834
+ }
104835
+ const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.integrationId, integrationId)).orderBy(gameTimebackAssessmentTests.id).for("update");
104836
+ return action(tx, rows);
104837
+ });
104838
+ }
104107
104839
  async withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, action) {
104108
104840
  return this.deps.db.transaction(async (tx) => {
104109
104841
  const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier)).orderBy(gameTimebackAssessmentTests.id).for("update");
@@ -104188,8 +104920,16 @@ class TimebackAssessmentsService {
104188
104920
  items: client2.qtiApi.assessmentTests.testParts(qtiTestIdentifier).sections(section.partIdentifier).items(section.sectionIdentifier)
104189
104921
  };
104190
104922
  }
104191
- qtiItemHref(client2, itemIdentifier) {
104192
- return `${client2.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
104923
+ async findReviewBankSink(client2, integrationId) {
104924
+ const identifier = reviewBankSinkIdentifier(integrationId);
104925
+ try {
104926
+ return await client2.qtiApi.assessmentTests.get(identifier);
104927
+ } catch (error88) {
104928
+ if (isApiError(error88) && error88.statusCode === 404) {
104929
+ return null;
104930
+ }
104931
+ throw error88;
104932
+ }
104193
104933
  }
104194
104934
  async ensureImmutableQtiResource(get, create) {
104195
104935
  try {
@@ -104230,7 +104970,7 @@ class TimebackAssessmentsService {
104230
104970
  test: loaded.test,
104231
104971
  questions: loaded.questions,
104232
104972
  diagnosticRoutingManifest,
104233
- itemHref: (identifier) => this.qtiItemHref(client2, identifier)
104973
+ itemHref: (identifier) => qtiItemHref(client2, identifier)
104234
104974
  });
104235
104975
  await this.ensureManagedQtiPublication(client2, plan);
104236
104976
  return plan;
@@ -104312,15 +105052,11 @@ class TimebackAssessmentsService {
104312
105052
  const plan = buildQtiLibraryListPlan(params);
104313
105053
  return list(plan.params);
104314
105054
  }
104315
- async validateAssessmentHasQuestions(loaded, reviewGameSlug) {
105055
+ async validateAssessmentHasQuestions(loaded) {
104316
105056
  assertAssessmentHasQuestions(loaded.questions.questions);
104317
105057
  for (const { question } of loaded.questions.questions) {
104318
105058
  assertPlayableQtiQuestion(question);
104319
105059
  }
104320
- if (reviewGameSlug) {
104321
- assertQtiTestOwnedByGame(loaded.test, reviewGameSlug);
104322
- await this.writeReviewMapping(this.requireClient(), loaded.test, loaded.questions);
104323
- }
104324
105060
  }
104325
105061
  async prepareDiagnosticAssociationChanges(row, nextPurpose, requested) {
104326
105062
  if (row.purpose !== "diagnostic" && nextPurpose === "diagnostic" && !requested) {
@@ -104349,22 +105085,8 @@ class TimebackAssessmentsService {
104349
105085
  }
104350
105086
  };
104351
105087
  }
104352
- async rebuildReviewMapping(client2, qtiTestIdentifier, scope) {
104353
- const [test, references] = await Promise.all([
104354
- client2.qtiApi.assessmentTests.get(qtiTestIdentifier),
104355
- client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier)
104356
- ]);
104357
- if (scope.kind === "owned") {
104358
- assertQtiTestOwnedByGame(test, scope.gameSlug);
104359
- }
104360
- return this.writeReviewMapping(client2, test, await hydrateQtiTestQuestions(client2, references));
104361
- }
104362
- async writeReviewMapping(client2, test, questions) {
104363
- const prepared = await prepareReviewMappingUpdate(test, questions);
104364
- await client2.qtiApi.assessmentTests.update(test.identifier, prepared.update);
104365
- return prepared.summary;
104366
- }
104367
105088
  }
105089
+ var REVIEW_BANK_TITLE = "Review Bank", REVIEW_BANK_SYNC_ATTEMPTS = 2;
104368
105090
  var init_timeback_assessments_service = __esm(async () => {
104369
105091
  init_drizzle_orm();
104370
105092
  init_helpers_index();
@@ -164386,7 +165108,7 @@ function parseQtiLibraryParams(searchParams) {
164386
165108
  limit
164387
165109
  };
164388
165110
  }
164389
- var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, saveRuntimeAssessment, submitRuntimeAssessmentItem, submitRuntimeAssessment, finalizeRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, attachExistingAssessments, updateAssessment, updateReviewMapping, reorderAssessments, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
165111
+ var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, saveRuntimeAssessment, submitRuntimeAssessmentItem, submitRuntimeAssessment, finalizeRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, attachExistingAssessments, updateAssessment, synchronizeReviewBank, reorderAssessments, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
164390
165112
  var init_timeback_controller = __esm(() => {
164391
165113
  init_esm();
164392
165114
  init_src();
@@ -164748,7 +165470,9 @@ var init_timeback_controller = __esm(() => {
164748
165470
  attemptId,
164749
165471
  input: {
164750
165472
  submissionId: body2.submissionId,
164751
- xpAwarded: body2.xpAwarded
165473
+ xpAwarded: body2.xpAwarded,
165474
+ ...body2.masteredUnits !== undefined ? { masteredUnits: body2.masteredUnits } : {},
165475
+ ...body2.masteredUnitsAbsolute !== undefined ? { masteredUnitsAbsolute: body2.masteredUnitsAbsolute } : {}
164752
165476
  },
164753
165477
  game: {
164754
165478
  appName: body2.appName,
@@ -165082,13 +165806,13 @@ var init_timeback_controller = __esm(() => {
165082
165806
  const body2 = await parseRequestBody(ctx.request, UpdateAssessmentRequestSchema);
165083
165807
  return ctx.services.timebackAssessments.updateAssessment(integrationId, testIdentifier, body2);
165084
165808
  });
165085
- updateReviewMapping = requireDeveloper(async (ctx) => {
165086
- const { gameId, courseId, testIdentifier } = ctx.params;
165087
- if (!gameId || !courseId || !testIdentifier) {
165088
- throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
165809
+ synchronizeReviewBank = requireDeveloper(async (ctx) => {
165810
+ const { gameId, courseId } = ctx.params;
165811
+ if (!gameId || !courseId) {
165812
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
165089
165813
  }
165090
165814
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
165091
- return ctx.services.timebackAssessments.updateReviewMapping(integrationId, testIdentifier);
165815
+ return ctx.services.timebackAssessments.synchronizeReviewBank(integrationId);
165092
165816
  });
165093
165817
  reorderAssessments = requireDeveloper(async (ctx) => {
165094
165818
  const { gameId, courseId } = ctx.params;
@@ -165239,7 +165963,7 @@ var init_timeback_controller = __esm(() => {
165239
165963
  createAssessment,
165240
165964
  attachExistingAssessments,
165241
165965
  updateAssessment,
165242
- updateReviewMapping,
165966
+ synchronizeReviewBank,
165243
165967
  reorderAssessments,
165244
165968
  removeAssessment,
165245
165969
  reorderQuestions,