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