@playcademy/sandbox 0.7.1-beta.25 → 0.7.1-beta.26

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 +1638 -1240
  2. package/dist/server.js +1638 -1240
  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.7.1-beta.25",
1130
+ version: "0.7.1-beta.26",
1131
1131
  description: "Local development server for Playcademy game development",
1132
1132
  type: "module",
1133
1133
  exports: {
@@ -10762,6 +10762,14 @@ function compareAssessmentCatalogOrder(left, right) {
10762
10762
  } else if (leftHasValidDate !== rightHasValidDate) {
10763
10763
  return leftHasValidDate ? -1 : 1;
10764
10764
  }
10765
+ if (left.assessmentKey !== null && right.assessmentKey !== null) {
10766
+ const keyDifference = left.assessmentKey.localeCompare(right.assessmentKey);
10767
+ if (keyDifference) {
10768
+ return keyDifference;
10769
+ }
10770
+ } else if (left.assessmentKey !== null || right.assessmentKey !== null) {
10771
+ return left.assessmentKey === null ? 1 : -1;
10772
+ }
10765
10773
  return left.qtiTestIdentifier.localeCompare(right.qtiTestIdentifier);
10766
10774
  }
10767
10775
  function orderRuntimeAssessmentTests(tests) {
@@ -10781,13 +10789,13 @@ function selectRuntimeAssessment(liveTests, attempts) {
10781
10789
  return null;
10782
10790
  }
10783
10791
  const completed = attempts.filter(isAssessmentAttemptCompleted);
10784
- const exposedTestIds = new Set(attempts.filter((attempt) => isAssessmentAttemptOpen(attempt) || isAssessmentAttemptCompleted(attempt)).map((attempt) => attempt.selectedTestIdentifier));
10785
- const unattempted = orderedTests.find((test) => !exposedTestIds.has(test.qtiTestIdentifier));
10792
+ const exposedAssessmentKeys = new Set(attempts.filter((attempt) => isAssessmentAttemptOpen(attempt) || isAssessmentAttemptCompleted(attempt)).map((attempt) => attempt.selectedAssessmentKey));
10793
+ const unattempted = orderedTests.find((test) => !exposedAssessmentKeys.has(test.assessmentKey));
10786
10794
  if (unattempted) {
10787
10795
  return { kind: "start", test: unattempted, reason: "unattempted" };
10788
10796
  }
10789
10797
  const latestCompleted = completed.toSorted(compareNewestCompletedAssessmentAttempt)[0];
10790
- const latestTestIndex = latestCompleted ? orderedTests.findIndex((test) => test.qtiTestIdentifier === latestCompleted.selectedTestIdentifier) : -1;
10798
+ const latestTestIndex = latestCompleted ? orderedTests.findIndex((test) => test.assessmentKey === latestCompleted.selectedAssessmentKey) : -1;
10791
10799
  if (latestTestIndex === -1) {
10792
10800
  return { kind: "start", test: orderedTests[0], reason: "first_live" };
10793
10801
  }
@@ -11449,7 +11457,7 @@ function isPlaycademyAssessmentResultMetadataV1(value) {
11449
11457
  const review = metadata2.review;
11450
11458
  const diagnostic = metadata2.diagnostic;
11451
11459
  const routedDiagnostic = metadata2.purpose === "diagnostic" && diagnostic !== undefined && Array.isArray(metadata2.itemSubmissions) && Number.isInteger(metadata2.responseVersion) && isRoutedDiagnosticAttemptMetadata(diagnostic, metadata2.itemSubmissions, metadata2.responseVersion);
11452
- 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) && 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.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);
11460
+ 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.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);
11453
11461
  }
11454
11462
  function isPlaycademyReviewAssessmentItemResultMetadataV1(value) {
11455
11463
  if (!isRecord(value) || !isRecord(value.responses)) {
@@ -11537,7 +11545,7 @@ function playcademyDiagnosticAssessmentItemResultMetadata(value) {
11537
11545
  const normalized = metadata2.responses === undefined ? { ...metadata2, responses: {} } : metadata2;
11538
11546
  return isPlaycademyDiagnosticAssessmentItemResultMetadataV1(normalized) ? normalized : null;
11539
11547
  }
11540
- var SCRIPT_SCHEMES, DATA_RASTER_IMAGE_PATTERN, QTI_MATHML_ALLOWED_TAGS, metadataSymbol, parser, SUPPORTED_QTI_INTERACTION_TYPES, URL_ATTRIBUTES, IMAGE_URL_ATTRIBUTES, INTERACTION_TYPES, EMPHASIS_TAGS, STRONG_TAGS, CONTENT_SKIPPED_TAGS, BLOCK_CONTENT_KINDS, GAP_MATCH_TOKEN_TAGS, STAGE_GRAPHIC_EXCLUDED_CONTAINERS, BLANK_SENTINEL = "￿", MARKED_BLANK_PATTERN, MARKED_BLANK_GLOBAL_PATTERN, MATCH_CORRECT_TEMPLATE = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES, RESPONSE_PROCESSING_TEMPLATES, VOID_BODY_ELEMENTS, URL_ATTRIBUTES2, IMAGE_URL_ATTRIBUTES2, VOID_CHOICE_ELEMENTS, NUMERIC_COMPARISON_ATTRIBUTES, RESPONSE_CARDINALITIES, RESPONSE_BASE_TYPES, POINT_INTERACTION_TYPES, INDEPENDENT_PROCESSING_TAGS, UUID_PATTERN, COMMON_CORE_MATH_FRAMEWORK_ALIASES, COMMON_CORE_ELA_FRAMEWORK_ALIASES, COMMON_CORE_FRAMEWORK_ALIASES, ASSESSMENT_ATTEMPT_OPEN, ASSESSMENT_ATTEMPT_COMPLETED, ASSESSMENT_ATTEMPT_SUPERSEDED, ASSESSMENT_RUNTIME_ERROR_STATUS, ROUTING_KEY_MAX_LENGTH = 128, ROUTING_RESULT_KEY_MAX_LENGTH = 256, ROUTING_STAGE_LIMIT = 32, ROUTING_TRACK_LIMIT = 128, ROUTING_NODE_LIMIT = 1000, ROUTING_TERMINAL_LIMIT = 1000, ROUTING_GROUP_LIMIT = 128, ROUTING_PREDICATE_DEPTH_LIMIT = 8, ROUTING_EXPLORATION_STATE_LIMIT = 200000, RoutingKeySchema, RoutingResultKeySchema, DiagnosticTrackOutcomeSchema, DiagnosticActivationPredicateSchema, DiagnosticRoutingTransitionSchema, DiagnosticRoutingManifestV1Schema, GRADE_VALUES, POINT_RESPONSE_PATTERN, REVIEW_SELECTION_POLICY_VERSION = "unseen-first-least-recently-delivered-v1", PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY = "playcademyReviewBank", PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION = 1, DEFAULT_REVIEW_SELECTION_POLICY, RuntimeSubjectSchema, RuntimeGradeSchema, OptionalQueryGradeSchema, AssessmentResponseValueSchema, AssessmentRuntimeIdentitySchema, ResponseKeySchema, StartAssessmentBaseSchema, AssessmentStandardRefSchema, LatestAssessmentFilterBaseSchema, LatestAssessmentFiltersSchema, LatestRuntimeAssessmentQuerySchema, StartAssessmentBodySchema, SaveAssessmentBodySchema, SubmitAssessmentItemBodySchema, SubmitAssessmentBodySchema, StartRuntimeAssessmentRequestSchema, SaveRuntimeAssessmentRequestSchema, SubmitAssessmentItemRuntimeRequestSchema, SubmitRuntimeAssessmentRequestSchema;
11548
+ var SCRIPT_SCHEMES, DATA_RASTER_IMAGE_PATTERN, QTI_MATHML_ALLOWED_TAGS, metadataSymbol, parser, SUPPORTED_QTI_INTERACTION_TYPES, URL_ATTRIBUTES, IMAGE_URL_ATTRIBUTES, INTERACTION_TYPES, EMPHASIS_TAGS, STRONG_TAGS, CONTENT_SKIPPED_TAGS, BLOCK_CONTENT_KINDS, GAP_MATCH_TOKEN_TAGS, STAGE_GRAPHIC_EXCLUDED_CONTAINERS, BLANK_SENTINEL = "￿", MARKED_BLANK_PATTERN, MARKED_BLANK_GLOBAL_PATTERN, MATCH_CORRECT_TEMPLATE = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES, RESPONSE_PROCESSING_TEMPLATES, VOID_BODY_ELEMENTS, URL_ATTRIBUTES2, IMAGE_URL_ATTRIBUTES2, VOID_CHOICE_ELEMENTS, NUMERIC_COMPARISON_ATTRIBUTES, RESPONSE_CARDINALITIES, RESPONSE_BASE_TYPES, POINT_INTERACTION_TYPES, INDEPENDENT_PROCESSING_TAGS, UUID_PATTERN, COMMON_CORE_MATH_FRAMEWORK_ALIASES, COMMON_CORE_ELA_FRAMEWORK_ALIASES, COMMON_CORE_FRAMEWORK_ALIASES, ASSESSMENT_ATTEMPT_OPEN, ASSESSMENT_ATTEMPT_COMPLETED, ASSESSMENT_ATTEMPT_SUPERSEDED, ASSESSMENT_RUNTIME_ERROR_STATUS, ROUTING_KEY_MAX_LENGTH = 128, ROUTING_RESULT_KEY_MAX_LENGTH = 256, ROUTING_STAGE_LIMIT = 32, ROUTING_TRACK_LIMIT = 128, ROUTING_NODE_LIMIT = 1000, ROUTING_TERMINAL_LIMIT = 1000, ROUTING_GROUP_LIMIT = 128, ROUTING_PREDICATE_DEPTH_LIMIT = 8, ROUTING_EXPLORATION_STATE_LIMIT = 200000, RoutingKeySchema, RoutingResultKeySchema, DiagnosticTrackOutcomeSchema, DiagnosticActivationPredicateSchema, DiagnosticRoutingTransitionSchema, DiagnosticRoutingManifestV1Schema, GRADE_VALUES, POINT_RESPONSE_PATTERN, REVIEW_SELECTION_POLICY_VERSION = "unseen-first-least-recently-delivered-v1", PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY = "playcademyReviewBank", PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION = 1, DEFAULT_REVIEW_SELECTION_POLICY, ASSESSMENT_RUNTIME_FIXTURE_BUNDLE_VERSION = 7, RuntimeSubjectSchema, RuntimeGradeSchema, OptionalQueryGradeSchema, AssessmentResponseValueSchema, AssessmentRuntimeIdentitySchema, ResponseKeySchema, StartAssessmentBaseSchema, AssessmentStandardRefSchema, LatestAssessmentFilterBaseSchema, LatestAssessmentFiltersSchema, LatestRuntimeAssessmentQuerySchema, StartAssessmentBodySchema, SaveAssessmentBodySchema, SubmitAssessmentItemBodySchema, SubmitAssessmentBodySchema, StartRuntimeAssessmentRequestSchema, SaveRuntimeAssessmentRequestSchema, SubmitAssessmentItemRuntimeRequestSchema, SubmitRuntimeAssessmentRequestSchema;
11541
11549
  var init_assessment_runtime2 = __esm(() => {
11542
11550
  init_timeback3();
11543
11551
  init_timeback3();
@@ -18195,6 +18203,7 @@ var init_table7 = __esm(() => {
18195
18203
  gameTimebackAssessmentTests = pgTable("game_timeback_assessment_tests", {
18196
18204
  id: uuid("id").primaryKey().defaultRandom(),
18197
18205
  integrationId: uuid("integration_id").notNull().references(() => gameTimebackIntegrations.id, { onDelete: "cascade" }),
18206
+ assessmentKey: text("assessment_key"),
18198
18207
  qtiTestIdentifier: text("qti_test_identifier").notNull(),
18199
18208
  purpose: gameTimebackAssessmentPurposeEnum("purpose").notNull().default("end_of_course"),
18200
18209
  status: gameTimebackAssessmentStatusEnum("status").notNull().default("draft"),
@@ -18207,6 +18216,7 @@ var init_table7 = __esm(() => {
18207
18216
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
18208
18217
  }, (table3) => [
18209
18218
  uniqueIndex("game_timeback_assessment_tests_integration_qti_idx").on(table3.integrationId, table3.qtiTestIdentifier),
18219
+ uniqueIndex("game_timeback_assessment_tests_integration_key_idx").on(table3.integrationId, table3.assessmentKey).where(sql`${table3.assessmentKey} IS NOT NULL`),
18210
18220
  uniqueIndex("game_timeback_assessment_tests_one_live_review_idx").on(table3.integrationId).where(sql`${table3.purpose} = 'review' AND ${table3.status} = 'live'`),
18211
18221
  uniqueIndex("game_timeback_assessment_tests_one_live_diagnostic_key_idx").on(table3.integrationId, table3.diagnosticKey).where(sql`${table3.purpose} = 'diagnostic' AND ${table3.status} = 'live' AND ${table3.diagnosticKey} IS NOT NULL`),
18212
18222
  check("game_timeback_assessment_tests_mastery_standard_check", sql`(
@@ -47068,7 +47078,7 @@ function requireMasteryStandard(input, context2) {
47068
47078
  });
47069
47079
  }
47070
47080
  }
47071
- 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, DiagnosticKeySchema, DiagnosticRoutingManifestSchema, DiagnosticAssessmentDefinitionInputSchema, AssessmentStandardRefSchema2, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, AssessmentAssociationImportEntrySchema, AttachExistingAssessmentsRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
47081
+ 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;
47072
47082
  var init_schemas4 = __esm(() => {
47073
47083
  init_esm();
47074
47084
  init_src();
@@ -47313,6 +47323,7 @@ var init_schemas4 = __esm(() => {
47313
47323
  });
47314
47324
  AssessmentPurposeSchema = exports_external.enum(gameTimebackAssessmentPurposeEnum.enumValues);
47315
47325
  AssessmentStatusSchema = exports_external.enum(gameTimebackAssessmentStatusEnum.enumValues);
47326
+ AssessmentKeySchema = exports_external.string().min(1, "Assessment key is required").max(100, "Assessment key cannot exceed 100 characters").regex(/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/, "Assessment key must use lowercase letters, numbers, dots, underscores, or hyphens");
47316
47327
  DiagnosticKeySchema = exports_external.string().trim().min(1).max(200);
47317
47328
  DiagnosticRoutingManifestSchema = exports_external.custom((value) => typeof value === "object" && value !== null && !Array.isArray(value), "Diagnostic routing manifest must be an object");
47318
47329
  DiagnosticAssessmentDefinitionInputSchema = exports_external.object({
@@ -47324,6 +47335,7 @@ var init_schemas4 = __esm(() => {
47324
47335
  identifier: exports_external.string().trim().min(1).max(TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength)
47325
47336
  });
47326
47337
  CreateAssessmentRequestSchema = exports_external.object({
47338
+ assessmentKey: AssessmentKeySchema,
47327
47339
  title: exports_external.string().min(1, "Assessment title is required"),
47328
47340
  purpose: AssessmentPurposeSchema,
47329
47341
  standard: AssessmentStandardRefSchema2.optional()
@@ -47338,11 +47350,13 @@ var init_schemas4 = __esm(() => {
47338
47350
  message: "Title, purpose, standard, diagnostic, or status is required"
47339
47351
  });
47340
47352
  CopyAssessmentRequestSchema = exports_external.object({
47353
+ assessmentKey: AssessmentKeySchema,
47341
47354
  testIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
47342
47355
  purpose: AssessmentPurposeSchema,
47343
47356
  standard: AssessmentStandardRefSchema2.optional()
47344
47357
  }).superRefine(requireMasteryStandard);
47345
47358
  AssessmentAssociationImportEntrySchema = exports_external.object({
47359
+ assessmentKey: AssessmentKeySchema,
47346
47360
  qtiTestIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
47347
47361
  purpose: AssessmentPurposeSchema,
47348
47362
  standard: AssessmentStandardRefSchema2.optional(),
@@ -47374,16 +47388,25 @@ var init_schemas4 = __esm(() => {
47374
47388
  targetStatus: exports_external.enum(["draft", "live"]),
47375
47389
  assessments: exports_external.array(AssessmentAssociationImportEntrySchema).min(1, "At least one assessment is required").max(500, "A bulk import can contain at most 500 assessments")
47376
47390
  }).superRefine((input, context2) => {
47377
- const seen = new Set;
47391
+ const seenIdentifiers = new Set;
47392
+ const seenKeys = new Set;
47378
47393
  input.assessments.forEach((assessment, index2) => {
47379
- if (seen.has(assessment.qtiTestIdentifier)) {
47394
+ if (seenIdentifiers.has(assessment.qtiTestIdentifier)) {
47380
47395
  context2.addIssue({
47381
47396
  code: "custom",
47382
47397
  path: ["assessments", index2, "qtiTestIdentifier"],
47383
47398
  message: "Assessment identifiers must be unique"
47384
47399
  });
47385
47400
  }
47386
- seen.add(assessment.qtiTestIdentifier);
47401
+ if (seenKeys.has(assessment.assessmentKey)) {
47402
+ context2.addIssue({
47403
+ code: "custom",
47404
+ path: ["assessments", index2, "assessmentKey"],
47405
+ message: "Assessment keys must be unique"
47406
+ });
47407
+ }
47408
+ seenIdentifiers.add(assessment.qtiTestIdentifier);
47409
+ seenKeys.add(assessment.assessmentKey);
47387
47410
  });
47388
47411
  });
47389
47412
  ReorderAssessmentsRequestSchema = exports_external.object({
@@ -98355,114 +98378,945 @@ var init_assessment_runtime_lock_util = __esm(() => {
98355
98378
  };
98356
98379
  });
98357
98380
 
98358
- // ../api-core/src/utils/timeback-assessment-rules.util.ts
98359
- function validateAssessmentStatusTransition(current, next) {
98360
- if (current === next) {
98381
+ // ../api-core/src/utils/timeback-qti-authoring.util.ts
98382
+ function recordValue(value) {
98383
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
98384
+ }
98385
+ function persistableAuthoringDocument(value) {
98386
+ const document2 = recordValue(value);
98387
+ const item = recordValue(document2?.item);
98388
+ if (!document2 || !item) {
98361
98389
  return;
98362
98390
  }
98363
- const allowed = current === "draft" && next === "live" || current === "live" && next === "archived" || current === "archived" && next === "live";
98364
- if (!allowed) {
98365
- throw new ValidationError(`Assessment status cannot change from ${current} to ${next}`);
98391
+ const persistableItem = { ...item };
98392
+ Reflect.deleteProperty(persistableItem, "metadata");
98393
+ return { ...document2, item: persistableItem };
98394
+ }
98395
+ function qtiAuthoringSnapshot(input) {
98396
+ const document2 = persistableAuthoringDocument(input.document);
98397
+ if (!recordValue(input.interaction) && !document2) {
98398
+ return;
98366
98399
  }
98400
+ return Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => {
98401
+ if (field === "document") {
98402
+ return document2 ? [[field, document2]] : [];
98403
+ }
98404
+ return input[field] === undefined ? [] : [[field, input[field]]];
98405
+ }));
98367
98406
  }
98368
- function isAssessmentPublicationTransition(current, next) {
98369
- return current !== "live" && next === "live";
98407
+ function buildOwnedQtiQuestionMetadata(input, ownership, existingMetadata = {}) {
98408
+ const inputMetadata = recordValue(input.metadata) ?? {};
98409
+ const metadata2 = {
98410
+ ...existingMetadata,
98411
+ ...inputMetadata
98412
+ };
98413
+ const existingAuthoring = recordValue(existingMetadata[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
98414
+ Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
98415
+ Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_EDITOR_MODE_KEY);
98416
+ const authoring = qtiAuthoringSnapshot(input);
98417
+ const shouldReplaceAuthoring = "interaction" in input || "numericTextEntry" in input || "document" in input;
98418
+ const persistedAuthoring = authoring ?? (shouldReplaceAuthoring ? undefined : existingAuthoring);
98419
+ return {
98420
+ ...metadata2,
98421
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
98422
+ ownerGameSlug: ownership.gameSlug,
98423
+ [PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]: ownership.testIdentifier,
98424
+ ...persistedAuthoring ? { [PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]: persistedAuthoring } : {}
98425
+ };
98370
98426
  }
98371
- function assertDraftAssessment(row) {
98372
- if (row.status !== "draft") {
98373
- throw new ValidationError("Only draft assessments can change QTI content or question membership");
98427
+ function restoreQtiQuestionAuthoringData(item) {
98428
+ const authoring = recordValue(item.metadata?.[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
98429
+ if (!authoring) {
98430
+ return item;
98374
98431
  }
98432
+ const restored = Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => authoring[field] === undefined ? [] : [[field, authoring[field]]]));
98433
+ return { ...item, ...restored };
98375
98434
  }
98376
- function assertMasteryPurposeChangeDraft(row, nextPurpose) {
98377
- if (row.purpose !== nextPurpose && (row.purpose === "mastery" || nextPurpose === "mastery") && row.status !== "draft") {
98378
- throw new ValidationError("Only draft assessments can change to or from mastery");
98435
+ function mergeQtiQuestionItem(item, fallbackItem, itemIdentifier, metadata2) {
98436
+ return restoreQtiQuestionAuthoringData({
98437
+ ...fallbackItem,
98438
+ ...item,
98439
+ identifier: itemIdentifier,
98440
+ title: item.title || fallbackItem?.title || itemIdentifier,
98441
+ type: item.type ?? fallbackItem?.type,
98442
+ rawXml: item.rawXml ?? fallbackItem?.rawXml,
98443
+ interaction: item.interaction ?? fallbackItem?.interaction,
98444
+ responseDeclarations: item.responseDeclarations ?? fallbackItem?.responseDeclarations,
98445
+ metadata: {
98446
+ ...fallbackItem?.metadata,
98447
+ ...item.metadata,
98448
+ ...metadata2
98449
+ }
98450
+ });
98451
+ }
98452
+ function newPlaycademyQuestionIdentifier(testIdentifier) {
98453
+ return `${testIdentifier}-q${crypto.randomUUID().slice(0, 8)}`;
98454
+ }
98455
+ function parseQtiQuestionCreationInput(input) {
98456
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
98457
+ throw new ValidationError("Question creation input must be an object");
98458
+ }
98459
+ const rawInput = input;
98460
+ if (rawInput.mode === undefined) {
98461
+ return { kind: "authoring", input: rawInput };
98379
98462
  }
98463
+ if (rawInput.mode !== "copy") {
98464
+ throw new ValidationError("Question creation mode is invalid");
98465
+ }
98466
+ const sourceItemIdentifier = typeof rawInput.sourceItemIdentifier === "string" ? rawInput.sourceItemIdentifier.trim() : "";
98467
+ if (!sourceItemIdentifier) {
98468
+ throw new ValidationError("A source question identifier is required to create a copy");
98469
+ }
98470
+ const unexpectedField = Object.keys(rawInput).find((field) => field !== "mode" && field !== "sourceItemIdentifier");
98471
+ if (unexpectedField) {
98472
+ throw new ValidationError(`Question copy input contains an unexpected “${unexpectedField}” field`);
98473
+ }
98474
+ return { kind: "copy", sourceItemIdentifier };
98475
+ }
98476
+ function nonEmptyMetadataString(value) {
98477
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
98380
98478
  }
98381
- function assertDiagnosticPurposeChangeDraft(row, nextPurpose) {
98382
- if (row.purpose !== nextPurpose && (row.purpose === "diagnostic" || nextPurpose === "diagnostic") && row.status !== "draft") {
98383
- throw new ValidationError("Only draft assessments can change to or from diagnostic");
98479
+ function qtiQuestionOwnerTestIdentifier(item) {
98480
+ if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
98481
+ return;
98384
98482
  }
98483
+ return nonEmptyMetadataString(item.metadata[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]);
98385
98484
  }
98386
- function diagnosticDefinitionForRow(row) {
98387
- return row.diagnosticKey && row.diagnosticRoutingManifest ? {
98388
- diagnosticKey: row.diagnosticKey,
98389
- routingManifest: row.diagnosticRoutingManifest
98390
- } : null;
98485
+ function qtiTestReferencesItem(test, itemIdentifier) {
98486
+ return (test["qti-test-part"] ?? []).some((part) => (part["qti-assessment-section"] ?? []).some((section) => (section["qti-assessment-item-ref"] ?? []).some((reference) => reference.identifier === itemIdentifier)));
98391
98487
  }
98392
- function assertAllAssessmentAssociationsDraft(rows) {
98393
- if (rows.some((row) => row.status !== "draft")) {
98394
- throw new ValidationError("QTI content cannot change while any associated assessment is live or archived");
98488
+ function resolveQtiQuestionOwnerGameSlug(item, ownerTest) {
98489
+ if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
98490
+ return;
98491
+ }
98492
+ if ("ownerGameSlug" in item.metadata) {
98493
+ return nonEmptyMetadataString(item.metadata.ownerGameSlug);
98494
+ }
98495
+ const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
98496
+ if (!ownerTestIdentifier || !ownerTest || ownerTest.identifier !== ownerTestIdentifier || !qtiTestReferencesItem(ownerTest, item.identifier) || ownerTest.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
98497
+ return;
98395
98498
  }
98499
+ return nonEmptyMetadataString(ownerTest.metadata.ownerGameSlug);
98396
98500
  }
98397
- function assertAssessmentHasQuestions(questions) {
98398
- if (questions.length === 0) {
98399
- throw new ValidationError("An assessment must contain at least one question to publish");
98501
+ function isQtiQuestionOwnedByGame(item, gameSlug, ownerTest) {
98502
+ return resolveQtiQuestionOwnerGameSlug(item, ownerTest) === gameSlug;
98503
+ }
98504
+ function isQtiQuestionOwnedByContext(item, ownership, ownerTest) {
98505
+ const declaredOwnerTest = qtiQuestionOwnerTestIdentifier(item);
98506
+ if (declaredOwnerTest) {
98507
+ return declaredOwnerTest === ownership.testIdentifier && isQtiQuestionOwnedByGame(item, ownership.gameSlug, ownerTest);
98400
98508
  }
98509
+ return isQtiItemOwnedByTest(item, ownership.testIdentifier);
98401
98510
  }
98402
- function assertReviewAssessmentHasStandards(standardCounts) {
98403
- if (standardCounts.some((count) => count !== 1)) {
98404
- throw new ValidationError("Every question in a review assessment must have exactly one standards alignment");
98511
+ function normalizedQtiInteractionType(value) {
98512
+ if (typeof value !== "string") {
98513
+ return;
98405
98514
  }
98515
+ const normalized = value.trim().toLowerCase().replaceAll("_", "-");
98516
+ return normalized || undefined;
98406
98517
  }
98407
- function planAssessmentRemoval(status) {
98408
- if (status === "draft") {
98409
- return { kind: "delete", action: "discarded", operation: "discard_draft" };
98518
+ function authoringDocumentItem(value) {
98519
+ const document2 = recordValue(value);
98520
+ if (!document2) {
98521
+ throw new ValidationError("Authored question data is invalid");
98410
98522
  }
98411
- if (status === "live") {
98412
- return { kind: "archive", action: "archived", operation: "archive" };
98523
+ if (document2.format !== "playcademy-question" || document2.version !== 1) {
98524
+ throw new ValidationError("Authored questions require a version 1 playcademy-question document");
98413
98525
  }
98414
- return { kind: "none", action: "archived" };
98526
+ const item = recordValue(document2.item);
98527
+ if (!item || typeof item.title !== "string" || !Array.isArray(item.body) || !Array.isArray(item.responseDeclarations)) {
98528
+ throw new ValidationError("An authored question needs an item with a title, a body, and response declarations");
98529
+ }
98530
+ const declarations = item.responseDeclarations.map(recordValue);
98531
+ const declaredIdentifiers = declarations.map((declaration) => typeof declaration?.identifier === "string" ? declaration.identifier : "");
98532
+ if (declarations.length === 0) {
98533
+ throw new ValidationError("An authored question requires at least one response declaration");
98534
+ }
98535
+ const declaredIdentifierSet = new Set(declaredIdentifiers);
98536
+ if (declaredIdentifiers.some((identifier) => !identifier) || declaredIdentifierSet.size !== declaredIdentifiers.length) {
98537
+ throw new ValidationError("Response declaration identifiers must be unique");
98538
+ }
98539
+ const projection = projectQtiAuthoringBody(item.body);
98540
+ if (projection.nestedInteractions.length > 0) {
98541
+ throw new ValidationError("Interactions nested inside another interaction cannot be compiled");
98542
+ }
98543
+ if (projection.interactions.length !== declarations.length) {
98544
+ throw new ValidationError("Every response declaration must belong to exactly one interaction");
98545
+ }
98546
+ const interactionIdentifiers = projection.interactions.map((interaction) => interaction.responseIdentifier);
98547
+ const interactionIdentifierSet = new Set(interactionIdentifiers);
98548
+ if (interactionIdentifierSet.size !== interactionIdentifiers.length) {
98549
+ throw new ValidationError("Every interaction needs its own response identifier");
98550
+ }
98551
+ if (interactionIdentifiers.some((identifier) => !declaredIdentifierSet.has(identifier)) || declaredIdentifiers.some((identifier) => !interactionIdentifierSet.has(identifier))) {
98552
+ throw new ValidationError("Every interaction must declare exactly one response");
98553
+ }
98554
+ const unauthorable = projection.interactions.find((interaction) => !qtiAuthoringInteractionType(interaction));
98555
+ if (unauthorable) {
98556
+ throw new ValidationError(`Interaction type “${String(unauthorable.type)}” cannot be authored by Playcademy yet`);
98557
+ }
98558
+ const inline = projection.interactions.filter(isInlineQtiAuthoringInteraction);
98559
+ if (projection.interactions.length - inline.length > 1) {
98560
+ throw new ValidationError("A question supports any number of inline interactions but at most one block interaction");
98561
+ }
98562
+ if (item.body.some((node) => node.kind === "interaction" && isInlineQtiAuthoringInteraction(node))) {
98563
+ throw new ValidationError("Every inline interaction must sit at its blank inside the question’s prose");
98564
+ }
98565
+ const blanks = inline.map((interaction) => projection.blankIndexes.get(interaction));
98566
+ if (blanks.some((blank) => blank === undefined) || new Set(blanks).size !== blanks.length) {
98567
+ throw new ValidationError("Every inline interaction needs its own blank position");
98568
+ }
98569
+ return item;
98415
98570
  }
98416
- function buildAssessmentAssociationUpdates(row, input) {
98417
- const updates = {};
98418
- if (input.purpose !== undefined) {
98419
- updates.purpose = input.purpose;
98420
- if (input.purpose !== "mastery" && (row.purpose === "mastery" || row.standardFramework || row.standardIdentifier)) {
98421
- updates.standardFramework = null;
98422
- updates.standardIdentifier = null;
98571
+ function parsedQtiItemOrNull(rawXml) {
98572
+ if (typeof rawXml !== "string" || !rawXml) {
98573
+ return null;
98574
+ }
98575
+ try {
98576
+ return parseQtiItemXml(rawXml);
98577
+ } catch {
98578
+ return null;
98579
+ }
98580
+ }
98581
+ function isAuthorableMultiInteractionQtiItem(question) {
98582
+ const item = parsedQtiItemOrNull(question.rawXml);
98583
+ return item !== null && (authorableQtiInteractions(item)?.length ?? 0) > 1;
98584
+ }
98585
+ function assertPlayableQtiQuestion(question) {
98586
+ if (!question.rawXml) {
98587
+ assertSupportedQtiQuestionInteraction(question);
98588
+ return;
98589
+ }
98590
+ const item = parsedQtiItemOrNull(question.rawXml);
98591
+ const playable = item && playableQtiInteractions(item);
98592
+ if (!playable || qtiItemScoringValidationMessage(item)) {
98593
+ throw new ValidationError(`Question ${question.identifier} uses an interaction Playcademy cannot play or score.`);
98594
+ }
98595
+ }
98596
+ function assertSupportedQtiQuestionInteraction(question) {
98597
+ const candidate = recordValue(question);
98598
+ if (candidate?.type === AUTHORING_DOCUMENT_QUESTION_TYPE) {
98599
+ authoringDocumentItem(candidate.document);
98600
+ return;
98601
+ }
98602
+ if (!supportedQtiQuestionInteractionType(question) && !isAuthorableMultiInteractionQtiItem(question)) {
98603
+ throw new ValidationError("This QTI interaction type is not supported by the Playcademy question editor.");
98604
+ }
98605
+ }
98606
+ function invalidQtiCopyXml() {
98607
+ throw new ValidationError("The source question does not contain valid QTI item XML");
98608
+ }
98609
+ function markupEnd2(xml, start2, allowInternalSubset) {
98610
+ let quote = null;
98611
+ let subsetDepth = 0;
98612
+ for (let index2 = start2;index2 < xml.length; index2 += 1) {
98613
+ const character = xml[index2];
98614
+ if (quote) {
98615
+ if (character === quote) {
98616
+ quote = null;
98617
+ }
98618
+ } else if (character === '"' || character === "'") {
98619
+ quote = character;
98620
+ } else if (allowInternalSubset && character === "[") {
98621
+ subsetDepth += 1;
98622
+ } else if (allowInternalSubset && character === "]") {
98623
+ subsetDepth = Math.max(0, subsetDepth - 1);
98624
+ } else if (character === ">" && subsetDepth === 0) {
98625
+ return index2;
98423
98626
  }
98424
- if (row.status === "live" && input.purpose !== row.purpose) {
98425
- updates.sortOrder = null;
98627
+ }
98628
+ return invalidQtiCopyXml();
98629
+ }
98630
+ function skipXmlWhitespace(xml, start2, end = xml.length) {
98631
+ let cursor2 = start2;
98632
+ while (cursor2 < end && /\s/.test(xml[cursor2] ?? "")) {
98633
+ cursor2 += 1;
98634
+ }
98635
+ return cursor2;
98636
+ }
98637
+ function xmlPreambleEnd(xml, cursor2) {
98638
+ if (xml.startsWith("<?", cursor2)) {
98639
+ const end = xml.indexOf("?>", cursor2 + 2);
98640
+ if (end === -1) {
98641
+ return invalidQtiCopyXml();
98426
98642
  }
98643
+ return end + 2;
98427
98644
  }
98428
- if (input.standard !== undefined) {
98429
- updates.standardFramework = input.standard.framework;
98430
- updates.standardIdentifier = input.standard.identifier;
98645
+ if (xml.startsWith("<!--", cursor2)) {
98646
+ const end = xml.indexOf("-->", cursor2 + 4);
98647
+ if (end === -1) {
98648
+ return invalidQtiCopyXml();
98649
+ }
98650
+ return end + 3;
98431
98651
  }
98432
- if (input.status !== undefined) {
98433
- updates.status = input.status;
98434
- if (input.status === "archived") {
98435
- updates.sortOrder = null;
98652
+ if (/^<!DOCTYPE\b/i.test(xml.slice(cursor2))) {
98653
+ return markupEnd2(xml, cursor2 + 2, true) + 1;
98654
+ }
98655
+ return null;
98656
+ }
98657
+ function qtiRootAttributes(xml, start2, end) {
98658
+ const attributes2 = [];
98659
+ let cursor2 = start2;
98660
+ while (cursor2 < end) {
98661
+ cursor2 = skipXmlWhitespace(xml, cursor2, end);
98662
+ if (xml[cursor2] === "/") {
98663
+ cursor2 += 1;
98664
+ } else if (cursor2 < end) {
98665
+ const nameMatch = /^[A-Za-z_][\w.:-]*/.exec(xml.slice(cursor2, end));
98666
+ if (!nameMatch) {
98667
+ return invalidQtiCopyXml();
98668
+ }
98669
+ const name3 = nameMatch[0];
98670
+ cursor2 = skipXmlWhitespace(xml, cursor2 + name3.length, end);
98671
+ if (xml[cursor2] !== "=") {
98672
+ return invalidQtiCopyXml();
98673
+ }
98674
+ cursor2 = skipXmlWhitespace(xml, cursor2 + 1, end);
98675
+ const quote = xml[cursor2];
98676
+ if (quote !== '"' && quote !== "'") {
98677
+ return invalidQtiCopyXml();
98678
+ }
98679
+ const valueStart = cursor2 + 1;
98680
+ const valueEnd = xml.indexOf(quote, valueStart);
98681
+ if (valueEnd === -1 || valueEnd > end) {
98682
+ return invalidQtiCopyXml();
98683
+ }
98684
+ attributes2.push({ name: name3, quote, valueStart, valueEnd });
98685
+ cursor2 = valueEnd + 1;
98436
98686
  }
98437
98687
  }
98438
- return updates;
98688
+ return attributes2;
98439
98689
  }
98440
- function assessmentStandardForRow(row) {
98441
- return row.standardFramework && row.standardIdentifier ? { framework: row.standardFramework, identifier: row.standardIdentifier } : null;
98690
+ function qtiItemStartTag(xml) {
98691
+ let cursor2 = xml.charCodeAt(0) === 65279 ? 1 : 0;
98692
+ while (cursor2 < xml.length) {
98693
+ cursor2 = skipXmlWhitespace(xml, cursor2);
98694
+ const preambleEnd = xmlPreambleEnd(xml, cursor2);
98695
+ if (preambleEnd !== null) {
98696
+ cursor2 = preambleEnd;
98697
+ } else {
98698
+ const root = /^<([A-Za-z_][\w.:-]*)/.exec(xml.slice(cursor2));
98699
+ if (!root || root[1].split(":").at(-1) !== "qti-assessment-item") {
98700
+ return invalidQtiCopyXml();
98701
+ }
98702
+ const attributesStart = cursor2 + root[0].length;
98703
+ const end = markupEnd2(xml, attributesStart, false);
98704
+ const attributes2 = qtiRootAttributes(xml, attributesStart, end);
98705
+ let insertionPoint = skipXmlWhitespaceBackward(xml, end);
98706
+ if (xml[insertionPoint - 1] === "/") {
98707
+ insertionPoint -= 1;
98708
+ }
98709
+ return { attributes: attributes2, insertionPoint };
98710
+ }
98711
+ }
98712
+ return invalidQtiCopyXml();
98442
98713
  }
98443
- function validateUniqueAssessmentIdentifiers(testIdentifiers) {
98444
- if (new Set(testIdentifiers).size !== testIdentifiers.length) {
98445
- throw new ValidationError("Assessment order must contain unique identifiers");
98714
+ function skipXmlWhitespaceBackward(xml, start2) {
98715
+ let cursor2 = start2;
98716
+ while (/\s/.test(xml[cursor2 - 1] ?? "")) {
98717
+ cursor2 -= 1;
98446
98718
  }
98719
+ return cursor2;
98447
98720
  }
98448
- function assertAssessmentOrderUpdateSucceeded(updatedRow) {
98449
- if (!updatedRow) {
98450
- throw new ValidationError("Assessment order changed while it was being saved. Refresh and try again.");
98721
+ function escapeXmlAttribute(value, quote) {
98722
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(quote, quote === '"' ? "&quot;" : "&apos;");
98723
+ }
98724
+ function rewriteQtiItemIdentity(xml, identifier, title) {
98725
+ if (!xml.trim() || !identifier.trim() || !title.trim()) {
98726
+ return invalidQtiCopyXml();
98451
98727
  }
98452
- return updatedRow;
98728
+ const root = qtiItemStartTag(xml);
98729
+ const replacements = [];
98730
+ const targetAttributes = new Map([
98731
+ ["identifier", identifier],
98732
+ ["title", title]
98733
+ ]);
98734
+ for (const [name3, value] of targetAttributes) {
98735
+ const matches = root.attributes.filter((attribute2) => attribute2.name === name3);
98736
+ if (matches.length > 1) {
98737
+ return invalidQtiCopyXml();
98738
+ }
98739
+ const attribute = matches[0];
98740
+ if (attribute) {
98741
+ replacements.push({
98742
+ start: attribute.valueStart,
98743
+ end: attribute.valueEnd,
98744
+ value: escapeXmlAttribute(value, attribute.quote)
98745
+ });
98746
+ } else {
98747
+ replacements.push({
98748
+ start: root.insertionPoint,
98749
+ end: root.insertionPoint,
98750
+ value: ` ${name3}="${escapeXmlAttribute(value, '"')}"`
98751
+ });
98752
+ }
98753
+ }
98754
+ return replacements.toSorted((left, right) => right.start - left.start).reduce((rewritten, replacement) => `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`, xml);
98453
98755
  }
98454
- function lockOrderAssessmentRows(rows) {
98455
- return rows.toSorted((left, right) => left.id.localeCompare(right.id));
98756
+ function buildQtiQuestionCopyInput(input) {
98757
+ assertPlayableQtiQuestion(input.source);
98758
+ const title = input.source.title?.trim() || input.source.identifier;
98759
+ if (!input.source.rawXml) {
98760
+ return invalidQtiCopyXml();
98761
+ }
98762
+ const sourceMetadata = { ...input.source.metadata };
98763
+ if (sourceMetadata.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
98764
+ Reflect.deleteProperty(sourceMetadata, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
98765
+ }
98766
+ const metadata2 = buildOwnedQtiQuestionMetadata({
98767
+ metadata: {
98768
+ copiedFromItemIdentifier: input.source.identifier,
98769
+ integrationId: input.integrationId
98770
+ }
98771
+ }, {
98772
+ gameSlug: input.gameSlug,
98773
+ testIdentifier: input.targetTestIdentifier
98774
+ }, sourceMetadata);
98775
+ return {
98776
+ identifier: input.targetIdentifier,
98777
+ title,
98778
+ xml: rewriteQtiItemIdentity(input.source.rawXml, input.targetIdentifier, title),
98779
+ metadata: metadata2
98780
+ };
98456
98781
  }
98457
- function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
98458
- const rowsByIdentifier = new Map(liveRows.map((row) => [row.qtiTestIdentifier, row]));
98459
- if (liveRows.length !== testIdentifiers.length || testIdentifiers.some((identifier) => !rowsByIdentifier.has(identifier))) {
98460
- throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
98782
+ function assertQtiQuestionEditable(item, ownership, ownerTest) {
98783
+ if (!isQtiQuestionOwnedByContext(item, ownership, ownerTest)) {
98784
+ throw new ValidationError("Shared question references are read-only. Copy the question to create an editable independent item.");
98461
98785
  }
98462
- return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
98463
98786
  }
98464
- var init_timeback_assessment_rules_util = __esm(() => {
98787
+ function buildNumericQuestionXml(input, identifier, title) {
98788
+ if (input.format === "xml") {
98789
+ throw new ValidationError("Raw question XML is not accepted at this API boundary");
98790
+ }
98791
+ const candidate = input.numericTextEntry;
98792
+ if (candidate === undefined) {
98793
+ return null;
98794
+ }
98795
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
98796
+ throw new ValidationError("Numeric text-entry data is invalid");
98797
+ }
98798
+ const prompt = "prompt" in candidate ? candidate.prompt : undefined;
98799
+ const numeric3 = parseNumericTextEntry({
98800
+ baseType: "baseType" in candidate ? candidate.baseType : undefined,
98801
+ answer: "answer" in candidate ? candidate.answer : undefined,
98802
+ comparison: "comparison" in candidate ? candidate.comparison : undefined
98803
+ });
98804
+ if (!numeric3.success) {
98805
+ throw new ValidationError(numeric3.message);
98806
+ }
98807
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
98808
+ throw new ValidationError("Numeric questions require a title and prompt");
98809
+ }
98810
+ return numericTextEntryXml({ identifier, title, prompt: prompt.trim(), numeric: numeric3.value });
98811
+ }
98812
+ function buildExactMatchQuestionXml(input) {
98813
+ const responseIdentifier = escapeXml(input.responseIdentifier);
98814
+ const correctValues = input.correctIdentifiers.map((identifier) => ` <qti-value>${escapeXml(identifier)}</qti-value>`).join(`
98815
+ `);
98816
+ return `<?xml version="1.0" encoding="UTF-8"?>
98817
+ <qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
98818
+ <qti-response-declaration identifier="${responseIdentifier}" cardinality="${input.cardinality}" base-type="identifier">
98819
+ <qti-correct-response>
98820
+ ${correctValues}
98821
+ </qti-correct-response>
98822
+ </qti-response-declaration>
98823
+ <qti-outcome-declaration identifier="FEEDBACK" cardinality="single" base-type="identifier" />
98824
+ <qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
98825
+ <qti-default-value><qti-value>0</qti-value></qti-default-value>
98826
+ </qti-outcome-declaration>
98827
+ <qti-item-body>
98828
+ ${input.itemBody}
98829
+ </qti-item-body>
98830
+ <qti-response-processing>
98831
+ <qti-response-condition>
98832
+ <qti-response-if>
98833
+ <qti-match><qti-variable identifier="${responseIdentifier}" /><qti-correct identifier="${responseIdentifier}" /></qti-match>
98834
+ <qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">CORRECT</qti-base-value></qti-set-outcome-value>
98835
+ <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
98836
+ </qti-response-if>
98837
+ <qti-response-else>
98838
+ <qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">INCORRECT</qti-base-value></qti-set-outcome-value>
98839
+ <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">0</qti-base-value></qti-set-outcome-value>
98840
+ </qti-response-else>
98841
+ </qti-response-condition>
98842
+ </qti-response-processing>
98843
+ </qti-assessment-item>`;
98844
+ }
98845
+ function parseStructuredChoices(value, options) {
98846
+ const { label, minimum, validate: validate2 } = options;
98847
+ if (!Array.isArray(value) || value.length < minimum) {
98848
+ throw new ValidationError(`${label} questions require at least ${minimum === 1 ? "one choice" : `${minimum} choices`}`);
98849
+ }
98850
+ const choices = value.map((choice) => {
98851
+ const record3 = recordValue(choice);
98852
+ const identifier = record3?.identifier;
98853
+ const content = record3?.content;
98854
+ if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim() || validate2 !== undefined && record3 !== undefined && !validate2(record3)) {
98855
+ throw new ValidationError(`${label} options are invalid`);
98856
+ }
98857
+ return { identifier: identifier.trim(), content: content.trim() };
98858
+ });
98859
+ if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
98860
+ throw new ValidationError(`${label} option identifiers must be unique`);
98861
+ }
98862
+ return choices;
98863
+ }
98864
+ function declaredCorrectResponse(input, responseIdentifier) {
98865
+ const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
98866
+ const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
98867
+ const rawValues = recordValue(declaration?.correctResponse)?.value;
98868
+ return {
98869
+ cardinality: declaration?.cardinality,
98870
+ baseType: declaration?.baseType,
98871
+ values: Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : []
98872
+ };
98873
+ }
98874
+ function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
98875
+ const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
98876
+ if (cardinality !== "single" || baseType !== "identifier") {
98877
+ throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
98878
+ }
98879
+ if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
98880
+ throw new ValidationError("Inline-choice questions require one valid correct option");
98881
+ }
98882
+ return values[0];
98883
+ }
98884
+ function structuredQuestionEnvelope(input, type, label, title) {
98885
+ const interaction = recordValue(input.interaction);
98886
+ if (normalizedQtiInteractionType(interaction?.type ?? input.type) !== type) {
98887
+ return null;
98888
+ }
98889
+ const structure = recordValue(interaction?.questionStructure);
98890
+ const responseIdentifier = interaction?.responseIdentifier;
98891
+ const prompt = structure?.prompt;
98892
+ if (!interaction || !structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
98893
+ throw new ValidationError(`${label} question data is invalid`);
98894
+ }
98895
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
98896
+ throw new ValidationError(`${label} questions require a title and prompt`);
98897
+ }
98898
+ return {
98899
+ interaction,
98900
+ structure,
98901
+ responseIdentifier: responseIdentifier.trim(),
98902
+ prompt: prompt.trim()
98903
+ };
98904
+ }
98905
+ function buildInlineChoiceQuestionXml(input, identifier, title) {
98906
+ const envelope = structuredQuestionEnvelope(input, "inline-choice", "Inline-choice", title);
98907
+ if (!envelope) {
98908
+ return null;
98909
+ }
98910
+ const { structure, responseIdentifier, prompt } = envelope;
98911
+ const promptParts = prompt.split(INLINE_CHOICE_BLANK);
98912
+ if (promptParts.length !== 2) {
98913
+ throw new ValidationError("Inline-choice questions require exactly one blank");
98914
+ }
98915
+ const choices = parseStructuredChoices(structure.inlineChoices, {
98916
+ label: "Inline-choice",
98917
+ minimum: 2
98918
+ });
98919
+ const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
98920
+ const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
98921
+ const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
98922
+ `);
98923
+ const [before = "", after = ""] = promptParts;
98924
+ const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
98925
+ ${choicesXml}
98926
+ </qti-inline-choice-interaction>${escapeXml(after)}</p>`;
98927
+ return buildExactMatchQuestionXml({
98928
+ identifier,
98929
+ title,
98930
+ responseIdentifier,
98931
+ cardinality: "single",
98932
+ correctIdentifiers: [correctIdentifier],
98933
+ itemBody
98934
+ });
98935
+ }
98936
+ function parseStructuredMatchChoices(value, label) {
98937
+ return parseStructuredChoices(value, {
98938
+ label: `Match ${label}`,
98939
+ minimum: 1,
98940
+ validate: (choice) => choice.matchMax === 1
98941
+ });
98942
+ }
98943
+ function structuredMatchCorrectPairs(input, responseIdentifier, sources, targets) {
98944
+ const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
98945
+ if (cardinality !== "multiple" || baseType !== "directedPair") {
98946
+ throw new ValidationError("Match questions require a multiple directed-pair response declaration");
98947
+ }
98948
+ const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
98949
+ const targetIdentifiers = new Set(targets.map((choice) => choice.identifier));
98950
+ const valid = values.length > 0 && new Set(values).size === values.length && values.every((value) => {
98951
+ const [source, target, extra] = value.split(" ");
98952
+ return extra === undefined && Boolean(source && target) && sourceIdentifiers.has(source) && targetIdentifiers.has(target);
98953
+ });
98954
+ if (!valid) {
98955
+ throw new ValidationError("Every Match correct response must name one declared source and target choice");
98956
+ }
98957
+ return values;
98958
+ }
98959
+ function buildMatchQuestionXml(input, identifier, title) {
98960
+ const envelope = structuredQuestionEnvelope(input, "match", "Match", title);
98961
+ if (!envelope) {
98962
+ return null;
98963
+ }
98964
+ const { interaction, structure, responseIdentifier, prompt } = envelope;
98965
+ const sources = parseStructuredMatchChoices(structure.sourceChoices, "source");
98966
+ const targets = parseStructuredMatchChoices(structure.targetChoices, "target");
98967
+ const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
98968
+ if (targets.some((choice) => sourceIdentifiers.has(choice.identifier))) {
98969
+ throw new ValidationError("Match source and target choice identifiers must be unique");
98970
+ }
98971
+ const correctValues = structuredMatchCorrectPairs(input, responseIdentifier, sources, targets);
98972
+ const maxAssociations = interaction.maxAssociations;
98973
+ if (typeof maxAssociations !== "number" || !Number.isInteger(maxAssociations) || maxAssociations < 0 || maxAssociations !== 0 && maxAssociations < correctValues.length) {
98974
+ throw new ValidationError("Match max associations must fit every correct pair");
98975
+ }
98976
+ const item = {
98977
+ key: identifier,
98978
+ title,
98979
+ body: [
98980
+ {
98981
+ kind: "interaction",
98982
+ type: "match",
98983
+ responseIdentifier,
98984
+ attributes: {
98985
+ shuffle: interaction.shuffle === true,
98986
+ "max-associations": maxAssociations
98987
+ },
98988
+ content: [
98989
+ {
98990
+ kind: "element",
98991
+ name: "qti-prompt",
98992
+ children: [{ kind: "text", text: prompt }]
98993
+ }
98994
+ ],
98995
+ choiceSets: [sources, targets].map((choices) => choices.map((choice) => ({
98996
+ identifier: choice.identifier,
98997
+ content: [{ kind: "text", text: choice.content }]
98998
+ })))
98999
+ }
99000
+ ],
99001
+ responseDeclarations: [
99002
+ {
99003
+ identifier: responseIdentifier,
99004
+ cardinality: "multiple",
99005
+ baseType: "directedPair",
99006
+ correctValues
99007
+ }
99008
+ ]
99009
+ };
99010
+ try {
99011
+ return compileQtiAuthoringItemXml(item, { identifier, title });
99012
+ } catch (error88) {
99013
+ throw new ValidationError(`This Match question cannot be compiled to QTI: ${errorMessage(error88)}`);
99014
+ }
99015
+ }
99016
+ function parseHottextSegments2(value) {
99017
+ if (!Array.isArray(value) || value.length === 0) {
99018
+ throw new ValidationError("Hottext questions require passage segments");
99019
+ }
99020
+ const segments = value.map((segment) => {
99021
+ const record3 = recordValue(segment);
99022
+ const identifier = record3?.identifier;
99023
+ const content = record3?.content;
99024
+ const mode = record3?.mode;
99025
+ if (typeof identifier !== "string" || typeof content !== "string" || !content.trim() || !["plain", "option", "correct"].includes(String(mode))) {
99026
+ throw new ValidationError("Hottext passage segments are invalid");
99027
+ }
99028
+ return {
99029
+ identifier: identifier.trim(),
99030
+ content: content.trim(),
99031
+ mode
99032
+ };
99033
+ });
99034
+ const selectable = segments.filter((segment) => segment.mode !== "plain");
99035
+ const correct = selectable.filter((segment) => segment.mode === "correct");
99036
+ const identifiers = selectable.map((segment) => segment.identifier);
99037
+ if (selectable.length < 2) {
99038
+ throw new ValidationError("Hottext questions require at least two selectable phrases");
99039
+ }
99040
+ if (identifiers.some((identifier) => !identifier)) {
99041
+ throw new ValidationError("Hottext selectable phrases require identifiers");
99042
+ }
99043
+ if (new Set(identifiers).size !== identifiers.length) {
99044
+ throw new ValidationError("Hottext selectable phrase identifiers must be unique");
99045
+ }
99046
+ if (correct.length === 0) {
99047
+ throw new ValidationError("Hottext questions require a correct phrase");
99048
+ }
99049
+ return { segments, selectable, correct };
99050
+ }
99051
+ function buildHottextQuestionXml(input, identifier, title) {
99052
+ const candidate = input.hottext;
99053
+ if (candidate === undefined) {
99054
+ return null;
99055
+ }
99056
+ const hottext = recordValue(candidate);
99057
+ if (!hottext) {
99058
+ throw new ValidationError("Hottext question data is invalid");
99059
+ }
99060
+ const prompt = hottext.prompt;
99061
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
99062
+ throw new ValidationError("Hottext questions require a title and prompt");
99063
+ }
99064
+ const { segments, selectable, correct } = parseHottextSegments2(hottext.segments);
99065
+ const multiple = correct.length > 1;
99066
+ const maxChoices = hottextMaxChoices(selectable.length, multiple);
99067
+ const passage = segments.map((segment) => segment.mode === "plain" ? escapeXml(segment.content) : `<qti-hottext identifier="${escapeXml(segment.identifier)}">${escapeXml(segment.content)}</qti-hottext>`).join(" ");
99068
+ const itemBody = ` <qti-hottext-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
99069
+ <qti-prompt>${escapeXml(prompt.trim())}</qti-prompt>
99070
+ <p>${passage}</p>
99071
+ </qti-hottext-interaction>`;
99072
+ return buildExactMatchQuestionXml({
99073
+ identifier,
99074
+ title,
99075
+ responseIdentifier: "RESPONSE",
99076
+ cardinality: multiple ? "multiple" : "single",
99077
+ correctIdentifiers: correct.map((segment) => segment.identifier),
99078
+ itemBody
99079
+ });
99080
+ }
99081
+ function buildAuthoringDocumentQuestionXml(input, identifier, title) {
99082
+ if (input.type !== AUTHORING_DOCUMENT_QUESTION_TYPE) {
99083
+ return null;
99084
+ }
99085
+ const item = authoringDocumentItem(input.document);
99086
+ if (!title) {
99087
+ throw new ValidationError("Authored questions require a title");
99088
+ }
99089
+ try {
99090
+ return compileQtiAuthoringItemXml(item, { identifier, title });
99091
+ } catch (error88) {
99092
+ throw new ValidationError(`This question cannot be compiled to QTI: ${errorMessage(error88)}`);
99093
+ }
99094
+ }
99095
+ function buildQuestionXml(input, identifier, title) {
99096
+ for (const build2 of QUESTION_XML_BUILDERS) {
99097
+ const xml = build2(input, identifier, title);
99098
+ if (xml !== null) {
99099
+ return xml;
99100
+ }
99101
+ }
99102
+ return null;
99103
+ }
99104
+ function buildCreatedQtiQuestionReference(input) {
99105
+ return {
99106
+ ownership: "owned",
99107
+ reference: {
99108
+ identifier: input.itemIdentifier,
99109
+ href: input.href,
99110
+ testPart: input.partIdentifier,
99111
+ section: input.sectionIdentifier
99112
+ },
99113
+ question: mergeQtiQuestionItem(input.item, input.fallbackItem, input.itemIdentifier, input.metadata)
99114
+ };
99115
+ }
99116
+ function buildHydratedQtiQuestionReference(input) {
99117
+ const identifier = input.reference.reference.identifier;
99118
+ const ownerGameSlug = resolveQtiQuestionOwnerGameSlug(input.item, input.ownerTest);
99119
+ const authoritativeItem = ownerGameSlug ? {
99120
+ ...input.item,
99121
+ metadata: { ...input.item.metadata, ownerGameSlug }
99122
+ } : input.item;
99123
+ const question = mergeQtiQuestionItem(authoritativeItem, input.reference.question, identifier);
99124
+ return {
99125
+ ...input.reference,
99126
+ ownership: isQtiQuestionOwnedByContext(question, { gameSlug: input.gameSlug, testIdentifier: input.testIdentifier }, input.ownerTest) ? "owned" : "shared",
99127
+ question
99128
+ };
99129
+ }
99130
+ function isQtiTestOwnedByGame(test, gameSlug) {
99131
+ return test.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && test.metadata.ownerGameSlug === gameSlug;
99132
+ }
99133
+ function assertQtiTestOwnedByGame(test, gameSlug) {
99134
+ if (!isQtiTestOwnedByGame(test, gameSlug)) {
99135
+ throw new ValidationError("Shared assessment references are read-only. Copy the assessment to create an editable independent test.");
99136
+ }
99137
+ }
99138
+ function qtiTestParts(test) {
99139
+ const parts2 = test["qti-test-part"];
99140
+ if (!Array.isArray(parts2) || parts2.length === 0) {
99141
+ throw new ValidationError(`Assessment ${test.identifier} has no test parts`);
99142
+ }
99143
+ for (const [partIndex, part] of parts2.entries()) {
99144
+ if (!part || typeof part !== "object" || Array.isArray(part)) {
99145
+ throw new ValidationError(`Assessment ${test.identifier} contains an invalid test part at position ${partIndex + 1}`);
99146
+ }
99147
+ const sections = part["qti-assessment-section"];
99148
+ if (!Array.isArray(sections)) {
99149
+ throw new ValidationError(`Assessment ${test.identifier} test part ${partIndex + 1} has no section list`);
99150
+ }
99151
+ }
99152
+ return parts2;
99153
+ }
99154
+ function qtiTestOptionalAttributes(test) {
99155
+ return {
99156
+ ...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
99157
+ ...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
99158
+ ...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
99159
+ ...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
99160
+ };
99161
+ }
99162
+ function buildQtiAssessmentItemCopyPlan(test, targetTestIdentifier, hrefForIdentifier, createIdentifier = newPlaycademyQuestionIdentifier) {
99163
+ const sourceIdentifiers = [];
99164
+ const seen = new Set;
99165
+ for (const part of qtiTestParts(test)) {
99166
+ for (const section of part["qti-assessment-section"]) {
99167
+ for (const reference of section["qti-assessment-item-ref"] ?? []) {
99168
+ if (!seen.has(reference.identifier)) {
99169
+ seen.add(reference.identifier);
99170
+ sourceIdentifiers.push(reference.identifier);
99171
+ }
99172
+ }
99173
+ }
99174
+ }
99175
+ return sourceIdentifiers.map((sourceIdentifier) => {
99176
+ const targetIdentifier = createIdentifier(targetTestIdentifier);
99177
+ return {
99178
+ sourceIdentifier,
99179
+ targetIdentifier,
99180
+ href: hrefForIdentifier(targetIdentifier)
99181
+ };
99182
+ });
99183
+ }
99184
+ function buildQtiTestStructureInput(test, targetTestIdentifier, itemCopies) {
99185
+ const outcomeDeclarations = test["qti-outcome-declaration"];
99186
+ if (outcomeDeclarations !== undefined && !Array.isArray(outcomeDeclarations)) {
99187
+ throw new ValidationError(`Assessment ${test.identifier} has an invalid outcome declaration list`);
99188
+ }
99189
+ return {
99190
+ "qti-test-part": qtiTestParts(test).map((part, partIndex) => ({
99191
+ identifier: targetTestIdentifier ? `${targetTestIdentifier}-part${partIndex + 1}` : part.identifier,
99192
+ navigationMode: part.navigationMode,
99193
+ submissionMode: part.submissionMode,
99194
+ "qti-assessment-section": part["qti-assessment-section"].map((section, sectionIndex) => {
99195
+ let sectionIdentifier = section.identifier;
99196
+ if (targetTestIdentifier) {
99197
+ sectionIdentifier = partIndex === 0 && sectionIndex === 0 ? `${targetTestIdentifier}-section1` : `${targetTestIdentifier}-part${partIndex + 1}-section${sectionIndex + 1}`;
99198
+ }
99199
+ return {
99200
+ identifier: sectionIdentifier,
99201
+ title: section.title,
99202
+ visible: section.visible ?? true,
99203
+ ...section.required !== undefined ? { required: section.required } : {},
99204
+ ...section.fixed !== undefined ? { fixed: section.fixed } : {},
99205
+ sequence: section.sequence ?? sectionIndex + 1,
99206
+ ...section["qti-assessment-item-ref"] ? {
99207
+ "qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item, itemIndex) => {
99208
+ const copy = itemCopies?.get(item.identifier);
99209
+ if (itemCopies && !copy) {
99210
+ throw new ValidationError(`Assessment copy is missing question ${item.identifier}`);
99211
+ }
99212
+ return {
99213
+ identifier: copy?.targetIdentifier ?? item.identifier,
99214
+ href: copy?.href ?? item.href,
99215
+ sequence: item.sequence ?? itemIndex + 1
99216
+ };
99217
+ })
99218
+ } : {}
99219
+ };
99220
+ })
99221
+ })),
99222
+ ...outcomeDeclarations ? {
99223
+ "qti-outcome-declaration": outcomeDeclarations.map((declaration) => ({
99224
+ identifier: declaration.identifier,
99225
+ ...declaration.cardinality !== undefined ? { cardinality: declaration.cardinality } : {},
99226
+ baseType: declaration.baseType,
99227
+ ...declaration.normalMaximum !== undefined ? { normalMaximum: declaration.normalMaximum } : {},
99228
+ ...declaration.normalMinimum !== undefined ? { normalMinimum: declaration.normalMinimum } : {},
99229
+ ...declaration.defaultValue ? {
99230
+ defaultValue: declaration.defaultValue.value !== undefined ? { value: declaration.defaultValue.value } : {}
99231
+ } : {}
99232
+ }))
99233
+ } : {}
99234
+ };
99235
+ }
99236
+ function buildQtiTestUpdateInput(test, title) {
99237
+ return {
99238
+ title,
99239
+ ...qtiTestOptionalAttributes(test),
99240
+ ...test.metadata ? { metadata: test.metadata } : {},
99241
+ ...buildQtiTestStructureInput(test)
99242
+ };
99243
+ }
99244
+ function buildQtiTestCopyInput(source, targetTestIdentifier, metadata2, itemCopies) {
99245
+ const copiesBySourceIdentifier = new Map(itemCopies.map((copy) => [copy.sourceIdentifier, copy]));
99246
+ return {
99247
+ identifier: targetTestIdentifier,
99248
+ title: `${source.title} (copy)`,
99249
+ ...qtiTestOptionalAttributes(source),
99250
+ metadata: metadata2,
99251
+ ...buildQtiTestStructureInput(source, targetTestIdentifier, copiesBySourceIdentifier)
99252
+ };
99253
+ }
99254
+ function validateUniqueQuestionIdentifiers(itemIdentifiers) {
99255
+ if (itemIdentifiers.length === 0 || new Set(itemIdentifiers).size !== itemIdentifiers.length) {
99256
+ throw new ValidationError("Question order must contain unique question identifiers");
99257
+ }
99258
+ }
99259
+ function resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers) {
99260
+ let selectedPart;
99261
+ let selectedSection;
99262
+ for (const part of qtiTestParts(test)) {
99263
+ for (const section of part["qti-assessment-section"]) {
99264
+ if (!itemIdentifier || section["qti-assessment-item-ref"]?.some((item) => item.identifier === itemIdentifier)) {
99265
+ selectedPart = part;
99266
+ selectedSection = section;
99267
+ break;
99268
+ }
99269
+ }
99270
+ if (selectedSection) {
99271
+ break;
99272
+ }
99273
+ }
99274
+ if (!selectedPart || !selectedSection) {
99275
+ throw new ValidationError(itemIdentifier ? `Question ${itemIdentifier} is not referenced by assessment ${qtiTestIdentifier}` : `Assessment ${qtiTestIdentifier} has no section for questions`);
99276
+ }
99277
+ if (expectedItemIdentifiers) {
99278
+ const sectionIdentifiers = selectedSection["qti-assessment-item-ref"]?.map((item) => item.identifier) ?? [];
99279
+ const expectedIdentifiers = new Set(expectedItemIdentifiers);
99280
+ if (expectedIdentifiers.size !== sectionIdentifiers.length || sectionIdentifiers.some((identifier) => !expectedIdentifiers.has(identifier))) {
99281
+ throw new ValidationError("Questions can only be reordered within one complete assessment section");
99282
+ }
99283
+ }
99284
+ return {
99285
+ partIdentifier: selectedPart.identifier,
99286
+ sectionIdentifier: selectedSection.identifier
99287
+ };
99288
+ }
99289
+ var PLAYCADEMY_QTI_AUTHORING_METADATA_KEY = "playcademyAuthoring", AUTHORING_DOCUMENT_QUESTION_TYPE = "authoring-document", QTI_AUTHORING_FIELDS, QUESTION_XML_BUILDERS;
99290
+ var init_timeback_qti_authoring_util = __esm(() => {
99291
+ init_qti();
99292
+ init_timeback3();
98465
99293
  init_errors2();
99294
+ QTI_AUTHORING_FIELDS = [
99295
+ "type",
99296
+ "document",
99297
+ "qtiVersion",
99298
+ "timeDependent",
99299
+ "adaptive",
99300
+ "preInteraction",
99301
+ "interaction",
99302
+ "postInteraction",
99303
+ "responseDeclarations",
99304
+ "outcomeDeclarations",
99305
+ "responseProcessing",
99306
+ "modalFeedback",
99307
+ "feedbackInline",
99308
+ "feedbackBlock",
99309
+ "rubrics",
99310
+ "stimulus",
99311
+ "content"
99312
+ ];
99313
+ QUESTION_XML_BUILDERS = [
99314
+ buildNumericQuestionXml,
99315
+ buildAuthoringDocumentQuestionXml,
99316
+ buildHottextQuestionXml,
99317
+ buildInlineChoiceQuestionXml,
99318
+ buildMatchQuestionXml
99319
+ ];
98466
99320
  });
98467
99321
 
98468
99322
  // ../api-core/src/utils/timeback-assessment-runtime.util.ts
@@ -99096,6 +99950,429 @@ var init_timeback_assessment_runtime_util = __esm(() => {
99096
99950
  PLAYABLE_SHAPE_SET = new Set(PLAYABLE_ASSESSMENT_SHAPES);
99097
99951
  });
99098
99952
 
99953
+ // ../api-core/src/utils/timeback-review-mapping.util.ts
99954
+ function reviewMappingIssueSummary(label, identifiers) {
99955
+ if (identifiers.length === 0) {
99956
+ return null;
99957
+ }
99958
+ const displayed = identifiers.slice(0, 10).join(", ");
99959
+ const remainder = identifiers.length > 10 ? `, and ${identifiers.length - 10} more` : "";
99960
+ return `${label} (${identifiers.length}): ${displayed}${remainder}`;
99961
+ }
99962
+ async function prepareReviewMappingUpdate(test, questions) {
99963
+ const contentRevision = await assessmentContentRevision(test, questions);
99964
+ const assessment = {
99965
+ identifier: test.identifier,
99966
+ contractVersion: 1,
99967
+ contentRevision,
99968
+ title: test.title,
99969
+ items: questions.questions.map(({ question }) => ({
99970
+ identifier: question.identifier,
99971
+ title: question.title,
99972
+ prompt: "",
99973
+ maxScore: 1,
99974
+ interactions: []
99975
+ }))
99976
+ };
99977
+ const bank = await buildReviewBankIndex(assessment, questions, { customOnly: true });
99978
+ const issues = reviewBankMappingIssues(bank);
99979
+ if (issues.missingOrInvalidItemIdentifiers.length > 0 || issues.multipleStandardItemIdentifiers.length > 0) {
99980
+ const details = [
99981
+ reviewMappingIssueSummary("missing or invalid associations", issues.missingOrInvalidItemIdentifiers),
99982
+ reviewMappingIssueSummary("multiple associations", issues.multipleStandardItemIdentifiers)
99983
+ ].filter((detail) => detail !== null);
99984
+ throw new ValidationError(`Every review question must have exactly one valid standard/node association. ${details.join("; ")}.`);
99985
+ }
99986
+ const manifest = await buildReviewBankManifest(bank);
99987
+ return {
99988
+ update: {
99989
+ ...buildQtiTestUpdateInput(test, test.title),
99990
+ metadata: {
99991
+ ...test.metadata,
99992
+ [PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: manifest
99993
+ }
99994
+ },
99995
+ summary: {
99996
+ itemCount: bank.items.length,
99997
+ standardCount: Object.keys(manifest.itemsByStandard).length,
99998
+ sourceFingerprint: manifest.sourceFingerprint
99999
+ }
100000
+ };
100001
+ }
100002
+ var init_timeback_review_mapping_util = __esm(() => {
100003
+ init_assessment_runtime2();
100004
+ init_errors2();
100005
+ init_timeback_assessment_runtime_util();
100006
+ init_timeback_qti_authoring_util();
100007
+ });
100008
+
100009
+ // ../api-core/src/utils/timeback-assessment-publication.util.ts
100010
+ function assessmentKeyFromManagedQtiIdentifier(identifier) {
100011
+ const assessmentKey = /^playcademy-test\.(.+)\.[a-f0-9]{64}$/.exec(identifier)?.[1];
100012
+ return assessmentKey && AssessmentKeySchema.safeParse(assessmentKey).success ? assessmentKey : null;
100013
+ }
100014
+ function assertManagedAssessmentIdentity(test, assessmentKey) {
100015
+ const managed = managedMetadataRecord(test.metadata, PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY);
100016
+ const sourceHash = managed?.sourceHash;
100017
+ const publicationHash = managed?.publicationHash;
100018
+ const hashPattern = /^[a-f0-9]{64}$/;
100019
+ if (managed?.version !== 1 || managed.assessmentKey !== assessmentKey || typeof sourceHash !== "string" || !hashPattern.test(sourceHash) || typeof publicationHash !== "string" || !hashPattern.test(publicationHash) || test.identifier !== `playcademy-test.${assessmentKey}.${publicationHash}`) {
100020
+ throw new ValidationError(`QTI assessment ${test.identifier} is not a managed publication for ${assessmentKey}`);
100021
+ }
100022
+ }
100023
+ function canonicalManagedAssetUrls(source) {
100024
+ return source.replace(MANAGED_ASSET_URL, `${CANONICAL_ASSESSMENT_ASSET_ORIGIN}/$1`);
100025
+ }
100026
+ function declarationNeutralQtiXml(source) {
100027
+ return source.replaceAll(`\r
100028
+ `, `
100029
+ `).replaceAll("\r", `
100030
+ `).replace(/^\s*<\?xml\b[^?]*\?>\s*/i, "");
100031
+ }
100032
+ function managedMetadataRecord(metadata2, key) {
100033
+ const managed = metadata2?.[key];
100034
+ return isRecord5(managed) ? managed : null;
100035
+ }
100036
+ function sourceMetadata(metadata2) {
100037
+ return Object.fromEntries(Object.entries(metadata2 ?? {}).filter(([key]) => !GENERATED_METADATA_KEYS.has(key)));
100038
+ }
100039
+ function normalizedQtiItemSource(item) {
100040
+ if (!item.rawXml?.trim()) {
100041
+ throw new ValidationError(`Question ${item.identifier} has no authoritative QTI XML`);
100042
+ }
100043
+ const title = item.title?.trim() || item.identifier;
100044
+ const declarationNeutralXml = declarationNeutralQtiXml(item.rawXml);
100045
+ const identityNeutralXml = rewriteQtiItemIdentity(declarationNeutralXml, "playcademy-item.content", title);
100046
+ return identityNeutralXml.replace(MANAGED_ASSET_URL, "playcademy-asset://$1").trim();
100047
+ }
100048
+ function testOptionalAttributes(test) {
100049
+ return {
100050
+ ...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
100051
+ ...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
100052
+ ...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
100053
+ ...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
100054
+ };
100055
+ }
100056
+ function normalizedTestStructure(test, itemIdentity) {
100057
+ const structure = buildQtiTestStructureInput(test);
100058
+ return {
100059
+ "qti-test-part": structure["qti-test-part"].map((part) => ({
100060
+ navigationMode: part.navigationMode,
100061
+ submissionMode: part.submissionMode,
100062
+ "qti-assessment-section": part["qti-assessment-section"].map((section) => ({
100063
+ title: section.title,
100064
+ visible: section.visible,
100065
+ ...section.required !== undefined ? { required: section.required } : {},
100066
+ ...section.fixed !== undefined ? { fixed: section.fixed } : {},
100067
+ sequence: section.sequence,
100068
+ ..."qti-assessment-item-ref" in section && section["qti-assessment-item-ref"] ? {
100069
+ "qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item) => {
100070
+ const identity = itemIdentity.get(item.identifier);
100071
+ if (!identity) {
100072
+ throw new ValidationError(`Assessment ${test.identifier} references unavailable question ${item.identifier}`);
100073
+ }
100074
+ return { identity, sequence: item.sequence };
100075
+ })
100076
+ } : {}
100077
+ }))
100078
+ })),
100079
+ ..."qti-outcome-declaration" in structure ? { "qti-outcome-declaration": structure["qti-outcome-declaration"] } : {}
100080
+ };
100081
+ }
100082
+ function remapDiagnosticRouting(manifest, itemIdentity) {
100083
+ if (!manifest) {
100084
+ return null;
100085
+ }
100086
+ return {
100087
+ ...manifest,
100088
+ nodes: manifest.nodes.map((node) => {
100089
+ const itemIdentifier = itemIdentity.get(node.itemIdentifier);
100090
+ if (!itemIdentifier) {
100091
+ throw new ValidationError(`Diagnostic routing node ${node.key} references unavailable question ${node.itemIdentifier}`);
100092
+ }
100093
+ return { ...node, itemIdentifier };
100094
+ })
100095
+ };
100096
+ }
100097
+ function canonicalManagedAssessmentArtifact(test) {
100098
+ const structure = buildQtiTestStructureInput(test);
100099
+ const physicalItemIdentity = new Map;
100100
+ for (const part of structure["qti-test-part"]) {
100101
+ for (const section of part["qti-assessment-section"]) {
100102
+ for (const reference of section["qti-assessment-item-ref"] ?? []) {
100103
+ physicalItemIdentity.set(reference.identifier, reference.identifier);
100104
+ }
100105
+ }
100106
+ }
100107
+ return canonicalJson2({
100108
+ title: test.title,
100109
+ ...testOptionalAttributes(test),
100110
+ metadata: sourceMetadata(test.metadata),
100111
+ structure: normalizedTestStructure(test, physicalItemIdentity)
100112
+ });
100113
+ }
100114
+ async function prepareManagedAssessmentPublication(input) {
100115
+ const parsedKey = AssessmentKeySchema.safeParse(input.assessmentKey);
100116
+ if (!parsedKey.success) {
100117
+ throw new ValidationError(parsedKey.error.issues[0]?.message ?? "Invalid assessment key");
100118
+ }
100119
+ const assessmentKey = parsedKey.data;
100120
+ const items = await Promise.all(input.questions.questions.map(async ({ question }) => {
100121
+ const metadata2 = sourceMetadata(question.metadata);
100122
+ const normalizedSource = normalizedQtiItemSource(question);
100123
+ const contentHash = await sha256Hex(canonicalJson2({ version: 1, xml: normalizedSource, metadata: metadata2 }));
100124
+ const qtiItemIdentifier = `playcademy-item.${contentHash}`;
100125
+ const title = question.title?.trim() || question.identifier;
100126
+ return {
100127
+ sourceItemIdentifier: question.identifier,
100128
+ qtiItemIdentifier,
100129
+ contentHash,
100130
+ xml: canonicalManagedAssetUrls(rewriteQtiItemIdentity(declarationNeutralQtiXml(question.rawXml), qtiItemIdentifier, title)),
100131
+ metadata: {
100132
+ ...metadata2,
100133
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
100134
+ [PLAYCADEMY_MANAGED_ITEM_METADATA_KEY]: { version: 1, contentHash }
100135
+ }
100136
+ };
100137
+ }));
100138
+ const itemBySource = new Map(items.map((item) => [item.sourceItemIdentifier, item]));
100139
+ const contentHashBySource = new Map([...itemBySource].map(([source, item]) => [source, item.contentHash]));
100140
+ const qtiIdentifierBySource = new Map([...itemBySource].map(([source, item]) => [source, item.qtiItemIdentifier]));
100141
+ const normalizedRouting = remapDiagnosticRouting(input.diagnosticRoutingManifest, contentHashBySource);
100142
+ const sourceHash = await sha256Hex(canonicalJson2({
100143
+ version: 1,
100144
+ title: input.test.title,
100145
+ ...testOptionalAttributes(input.test),
100146
+ metadata: sourceMetadata(input.test.metadata),
100147
+ structure: normalizedTestStructure(input.test, contentHashBySource),
100148
+ diagnosticRoutingManifest: normalizedRouting
100149
+ }));
100150
+ const compiledRouting = remapDiagnosticRouting(input.diagnosticRoutingManifest, qtiIdentifierBySource);
100151
+ const publicationHash = await sha256Hex(canonicalJson2({
100152
+ version: 1,
100153
+ sourceHash,
100154
+ itemIdentifiers: items.map((item) => item.qtiItemIdentifier),
100155
+ structure: normalizedTestStructure(input.test, qtiIdentifierBySource),
100156
+ diagnosticRoutingManifest: compiledRouting
100157
+ }));
100158
+ const qtiTestIdentifier = `playcademy-test.${assessmentKey}.${publicationHash}`;
100159
+ const copiesBySource = new Map(items.map((item) => [
100160
+ item.sourceItemIdentifier,
100161
+ {
100162
+ sourceIdentifier: item.sourceItemIdentifier,
100163
+ targetIdentifier: item.qtiItemIdentifier,
100164
+ href: input.itemHref(item.qtiItemIdentifier)
100165
+ }
100166
+ ]));
100167
+ let testInput = {
100168
+ identifier: qtiTestIdentifier,
100169
+ title: input.test.title,
100170
+ ...testOptionalAttributes(input.test),
100171
+ metadata: {
100172
+ ...sourceMetadata(input.test.metadata),
100173
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
100174
+ [PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY]: {
100175
+ version: 1,
100176
+ assessmentKey,
100177
+ sourceHash,
100178
+ publicationHash,
100179
+ ...compiledRouting ? { diagnosticRoutingManifest: compiledRouting } : {}
100180
+ }
100181
+ },
100182
+ ...buildQtiTestStructureInput(input.test, qtiTestIdentifier, copiesBySource)
100183
+ };
100184
+ const physicalQuestions = {
100185
+ ...input.questions,
100186
+ assessmentTest: qtiTestIdentifier,
100187
+ questions: input.questions.questions.map((entry) => {
100188
+ const item = itemBySource.get(entry.question.identifier);
100189
+ return {
100190
+ reference: {
100191
+ ...entry.reference,
100192
+ identifier: item.qtiItemIdentifier,
100193
+ href: input.itemHref(item.qtiItemIdentifier)
100194
+ },
100195
+ question: {
100196
+ ...entry.question,
100197
+ identifier: item.qtiItemIdentifier,
100198
+ rawXml: item.xml,
100199
+ metadata: item.metadata
100200
+ }
100201
+ };
100202
+ })
100203
+ };
100204
+ try {
100205
+ const prepared = await prepareReviewMappingUpdate(testInput, physicalQuestions);
100206
+ testInput = { identifier: qtiTestIdentifier, ...prepared.update };
100207
+ } catch (error88) {
100208
+ if (input.purpose === "review" || !(error88 instanceof ValidationError)) {
100209
+ throw error88;
100210
+ }
100211
+ }
100212
+ return {
100213
+ assessmentKey,
100214
+ sourceHash,
100215
+ publicationHash,
100216
+ qtiTestIdentifier,
100217
+ testInput,
100218
+ items,
100219
+ diagnosticRoutingManifest: compiledRouting
100220
+ };
100221
+ }
100222
+ function assertManagedAssessmentPublication(test, expected) {
100223
+ const managed = managedMetadataRecord(test.metadata, PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY);
100224
+ if (test.identifier !== expected.qtiTestIdentifier || !managed || managed.assessmentKey !== expected.assessmentKey || managed.sourceHash !== expected.sourceHash || managed.publicationHash !== expected.publicationHash || canonicalJson2(managed.diagnosticRoutingManifest ?? null) !== canonicalJson2(expected.diagnosticRoutingManifest) || canonicalManagedAssessmentArtifact(test) !== canonicalManagedAssessmentArtifact(expected.testInput)) {
100225
+ throw new ValidationError(`QTI identifier collision for managed assessment ${expected.assessmentKey}`);
100226
+ }
100227
+ }
100228
+ function managedAssessmentDiagnosticRoutingManifest(test, assessmentKey) {
100229
+ const managed = managedMetadataRecord(test.metadata, PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY);
100230
+ const manifest = managed?.diagnosticRoutingManifest;
100231
+ return managed?.assessmentKey === assessmentKey && isRecord5(manifest) ? manifest : null;
100232
+ }
100233
+ async function assertManagedAssessmentItem(item, expected) {
100234
+ const managed = managedMetadataRecord(item.metadata, PLAYCADEMY_MANAGED_ITEM_METADATA_KEY);
100235
+ const actualHash = await sha256Hex(canonicalJson2({
100236
+ version: 1,
100237
+ xml: normalizedQtiItemSource(item),
100238
+ metadata: sourceMetadata(item.metadata)
100239
+ }));
100240
+ if (item.identifier !== expected.qtiItemIdentifier || actualHash !== expected.contentHash || managed?.contentHash !== expected.contentHash) {
100241
+ throw new ValidationError(`QTI identifier collision for item ${expected.qtiItemIdentifier}`);
100242
+ }
100243
+ }
100244
+ var PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY = "playcademyManagedAssessment", PLAYCADEMY_MANAGED_ITEM_METADATA_KEY = "playcademyManagedItem", GENERATED_METADATA_KEYS, MANAGED_ASSET_URL, CANONICAL_ASSESSMENT_ASSET_ORIGIN = "https://cdn.playcademy.net";
100245
+ var init_timeback_assessment_publication_util = __esm(() => {
100246
+ init_src();
100247
+ init_schemas_index();
100248
+ init_assessment_runtime2();
100249
+ init_timeback3();
100250
+ init_errors2();
100251
+ init_timeback_qti_authoring_util();
100252
+ init_timeback_review_mapping_util();
100253
+ init_timeback_util();
100254
+ GENERATED_METADATA_KEYS = new Set([
100255
+ PLAYCADEMY_QTI_AUTHORING_METADATA_KEY,
100256
+ PLAYCADEMY_QTI_EDITOR_MODE_KEY,
100257
+ PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY,
100258
+ PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY,
100259
+ PLAYCADEMY_MANAGED_ASSESSMENT_METADATA_KEY,
100260
+ PLAYCADEMY_MANAGED_ITEM_METADATA_KEY,
100261
+ "copiedFromItemIdentifier",
100262
+ "copiedFromTestIdentifier",
100263
+ "integrationId",
100264
+ "ownerGameSlug",
100265
+ "ownerSystem",
100266
+ "sourceIdentifier"
100267
+ ]);
100268
+ MANAGED_ASSET_URL = new RegExp(`https?://[^\\s"')]+/(${ASSESSMENT_ASSET_KEY_PREFIX}v1/sha256/[a-f0-9]{64}\\.[a-z0-9]+)`, "gi");
100269
+ });
100270
+
100271
+ // ../api-core/src/utils/timeback-assessment-rules.util.ts
100272
+ function validateAssessmentStatusTransition(current, next) {
100273
+ if (current === next) {
100274
+ return;
100275
+ }
100276
+ const allowed = current === "draft" && next === "live" || current === "live" && next === "archived" || current === "archived" && next === "live";
100277
+ if (!allowed) {
100278
+ throw new ValidationError(`Assessment status cannot change from ${current} to ${next}`);
100279
+ }
100280
+ }
100281
+ function isAssessmentPublicationTransition(current, next) {
100282
+ return current !== "live" && next === "live";
100283
+ }
100284
+ function assertDraftAssessment(row) {
100285
+ if (row.status !== "draft") {
100286
+ throw new ValidationError("Only draft assessments can change QTI content or question membership");
100287
+ }
100288
+ }
100289
+ function assertPurposeChangeDraft(row, nextPurpose) {
100290
+ if (row.purpose !== nextPurpose && row.status !== "draft") {
100291
+ throw new ValidationError("Only draft assessments can change purpose");
100292
+ }
100293
+ }
100294
+ function diagnosticDefinitionForRow(row) {
100295
+ return row.diagnosticKey && row.diagnosticRoutingManifest ? {
100296
+ diagnosticKey: row.diagnosticKey,
100297
+ routingManifest: row.diagnosticRoutingManifest
100298
+ } : null;
100299
+ }
100300
+ function assertAllAssessmentAssociationsDraft(rows) {
100301
+ if (rows.some((row) => row.status !== "draft")) {
100302
+ throw new ValidationError("QTI content cannot change while any associated assessment is live or archived");
100303
+ }
100304
+ }
100305
+ function assertAssessmentHasQuestions(questions) {
100306
+ if (questions.length === 0) {
100307
+ throw new ValidationError("An assessment must contain at least one question to publish");
100308
+ }
100309
+ }
100310
+ function assertReviewAssessmentHasStandards(standardCounts) {
100311
+ if (standardCounts.some((count) => count !== 1)) {
100312
+ throw new ValidationError("Every question in a review assessment must have exactly one standards alignment");
100313
+ }
100314
+ }
100315
+ function planAssessmentRemoval(status) {
100316
+ if (status === "draft") {
100317
+ return { kind: "delete", action: "discarded", operation: "discard_draft" };
100318
+ }
100319
+ if (status === "live") {
100320
+ return { kind: "archive", action: "archived", operation: "archive" };
100321
+ }
100322
+ return { kind: "none", action: "archived" };
100323
+ }
100324
+ function buildAssessmentAssociationUpdates(row, input) {
100325
+ const updates = {};
100326
+ if (input.purpose !== undefined) {
100327
+ updates.purpose = input.purpose;
100328
+ if (input.purpose !== "mastery" && (row.purpose === "mastery" || row.standardFramework || row.standardIdentifier)) {
100329
+ updates.standardFramework = null;
100330
+ updates.standardIdentifier = null;
100331
+ }
100332
+ if (row.status === "live" && input.purpose !== row.purpose) {
100333
+ updates.sortOrder = null;
100334
+ }
100335
+ }
100336
+ if (input.standard !== undefined) {
100337
+ updates.standardFramework = input.standard.framework;
100338
+ updates.standardIdentifier = input.standard.identifier;
100339
+ }
100340
+ if (input.status !== undefined) {
100341
+ updates.status = input.status;
100342
+ if (input.status === "archived") {
100343
+ updates.sortOrder = null;
100344
+ }
100345
+ }
100346
+ return updates;
100347
+ }
100348
+ function assessmentStandardForRow(row) {
100349
+ return row.standardFramework && row.standardIdentifier ? { framework: row.standardFramework, identifier: row.standardIdentifier } : null;
100350
+ }
100351
+ function validateUniqueAssessmentIdentifiers(testIdentifiers) {
100352
+ if (new Set(testIdentifiers).size !== testIdentifiers.length) {
100353
+ throw new ValidationError("Assessment order must contain unique identifiers");
100354
+ }
100355
+ }
100356
+ function assertAssessmentOrderUpdateSucceeded(updatedRow) {
100357
+ if (!updatedRow) {
100358
+ throw new ValidationError("Assessment order changed while it was being saved. Refresh and try again.");
100359
+ }
100360
+ return updatedRow;
100361
+ }
100362
+ function lockOrderAssessmentRows(rows) {
100363
+ return rows.toSorted((left, right) => left.id.localeCompare(right.id));
100364
+ }
100365
+ function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
100366
+ const rowsByIdentifier = new Map(liveRows.map((row) => [row.qtiTestIdentifier, row]));
100367
+ if (liveRows.length !== testIdentifiers.length || testIdentifiers.some((identifier) => !rowsByIdentifier.has(identifier))) {
100368
+ throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
100369
+ }
100370
+ return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
100371
+ }
100372
+ var init_timeback_assessment_rules_util = __esm(() => {
100373
+ init_errors2();
100374
+ });
100375
+
99099
100376
  // ../api-core/src/utils/timeback-qti-hydration.util.ts
99100
100377
  async function hydrateQtiTestQuestions(client2, references) {
99101
100378
  const questions = await runWithConcurrency(references.questions, QTI_HYDRATION_CONCURRENCY, async (reference) => ({
@@ -99240,6 +100517,7 @@ class TimebackAssessmentRuntimeService {
99240
100517
  integrationId: context2.integration.id,
99241
100518
  enrollmentId: context2.enrollment.id,
99242
100519
  selectedTest: {
100520
+ assessmentKey: decision.test.assessmentKey,
99243
100521
  identifier: assessment.identifier,
99244
100522
  contentRevision: assessment.contentRevision
99245
100523
  },
@@ -99357,6 +100635,7 @@ class TimebackAssessmentRuntimeService {
99357
100635
  integrationId: context2.integration.id,
99358
100636
  enrollmentId: context2.enrollment.id,
99359
100637
  selectedTest: {
100638
+ assessmentKey: definition.assessmentKey,
99360
100639
  identifier: assessment.identifier,
99361
100640
  contentRevision: assessment.contentRevision
99362
100641
  },
@@ -99498,6 +100777,7 @@ class TimebackAssessmentRuntimeService {
99498
100777
  integrationId: context2.integration.id,
99499
100778
  enrollmentId: context2.enrollment.id,
99500
100779
  selectedTest: {
100780
+ assessmentKey: decision.test.assessmentKey,
99501
100781
  identifier: source.assessment.identifier,
99502
100782
  contentRevision: source.assessment.contentRevision
99503
100783
  },
@@ -100341,7 +101621,7 @@ class TimebackAssessmentRuntimeService {
100341
101621
  });
100342
101622
  const catalogs = loadedCatalogs.filter((catalog) => catalog !== null);
100343
101623
  return {
100344
- version: 6,
101624
+ version: ASSESSMENT_RUNTIME_FIXTURE_BUNDLE_VERSION,
100345
101625
  gameId,
100346
101626
  exportedAt: new Date().toISOString(),
100347
101627
  catalogs
@@ -100375,7 +101655,7 @@ class TimebackAssessmentRuntimeService {
100375
101655
  const enrollmentByCourse = new Map(enrollments.map((enrollment) => [enrollment.course.id, enrollment]));
100376
101656
  const candidateRows = integrations.filter((integration) => enrollmentByCourse.has(integration.courseId));
100377
101657
  const liveTestRows = candidateRows.length === 0 ? [] : await db2.query.gameTimebackAssessmentTests.findMany({
100378
- where: and(inArray(gameTimebackAssessmentTests.integrationId, candidateRows.map((row) => row.id)), eq(gameTimebackAssessmentTests.purpose, input.purpose), eq(gameTimebackAssessmentTests.status, "live")),
101658
+ where: and(inArray(gameTimebackAssessmentTests.integrationId, candidateRows.map((row) => row.id)), eq(gameTimebackAssessmentTests.purpose, input.purpose), eq(gameTimebackAssessmentTests.status, "live"), isNotNull(gameTimebackAssessmentTests.assessmentKey)),
100379
101659
  columns: {
100380
101660
  integrationId: true,
100381
101661
  standardFramework: true,
@@ -100431,10 +101711,11 @@ class TimebackAssessmentRuntimeService {
100431
101711
  async liveTests(integrationId, purpose, db2 = this.deps.db, requestedStandard, requestedDiagnosticKey) {
100432
101712
  const matchesMasteryStandard = requestedStandard ? masteryStandardMatcher(requestedStandard) : undefined;
100433
101713
  const rows = await db2.query.gameTimebackAssessmentTests.findMany({
100434
- where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))
101714
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"), isNotNull(gameTimebackAssessmentTests.assessmentKey))
100435
101715
  });
100436
101716
  return rows.filter((row) => purpose !== "diagnostic" || requestedDiagnosticKey === undefined || row.diagnosticKey === requestedDiagnosticKey).map((row) => ({
100437
101717
  id: row.id,
101718
+ assessmentKey: row.assessmentKey,
100438
101719
  qtiTestIdentifier: row.qtiTestIdentifier,
100439
101720
  sortOrder: row.sortOrder,
100440
101721
  updatedAt: row.updatedAt.toISOString(),
@@ -100476,19 +101757,23 @@ class TimebackAssessmentRuntimeService {
100476
101757
  reviewChildren.set(result.sourcedId, result);
100477
101758
  }
100478
101759
  if (metadata2 && metadata2.activityId === scope.activityId && metadata2.purpose === scope.purpose && metadata2.courseId === scope.courseId && metadata2.integrationId === scope.integrationId && result.student.sourcedId === scope.studentId) {
100479
- attempts.set(result.sourcedId, {
100480
- result,
100481
- metadata: metadata2,
100482
- selection: {
100483
- attemptId: result.sourcedId,
100484
- selectedTestIdentifier: metadata2.selectedTest.identifier,
100485
- inProgress: result.inProgress ?? "",
100486
- scoreStatus: result.scoreStatus,
100487
- scoreDate: result.scoreDate,
100488
- updatedAt: result.dateLastModified ?? metadata2.updatedAt,
100489
- resumable: metadata2.enrollmentId === scope.enrollmentId
100490
- }
100491
- });
101760
+ const selectedAssessmentKey = metadata2.selectedTest.assessmentKey ?? assessmentKeyFromManagedQtiIdentifier(metadata2.selectedTest.identifier);
101761
+ if (selectedAssessmentKey) {
101762
+ attempts.set(result.sourcedId, {
101763
+ result,
101764
+ metadata: metadata2,
101765
+ selection: {
101766
+ attemptId: result.sourcedId,
101767
+ selectedAssessmentKey,
101768
+ selectedTestIdentifier: metadata2.selectedTest.identifier,
101769
+ inProgress: result.inProgress ?? "",
101770
+ scoreStatus: result.scoreStatus,
101771
+ scoreDate: result.scoreDate,
101772
+ updatedAt: result.dateLastModified ?? metadata2.updatedAt,
101773
+ resumable: metadata2.enrollmentId === scope.enrollmentId
101774
+ }
101775
+ });
101776
+ }
100492
101777
  }
100493
101778
  }
100494
101779
  return { attempts, reviewChildren };
@@ -100765,14 +102050,23 @@ class TimebackAssessmentRuntimeService {
100765
102050
  isPlatformRoutedDiagnosticMetadata(metadata2) {
100766
102051
  return metadata2.purpose === "diagnostic" && metadata2.diagnostic !== undefined;
100767
102052
  }
100768
- async requireDiagnosticDefinition(definitionId, integrationId, db2 = this.deps.db, requireLive = false) {
102053
+ async requireDiagnosticDefinition(definitionId, integrationId, db2 = this.deps.db, requireLive = false, pinnedQtiTestIdentifier) {
100769
102054
  const row = await db2.query.gameTimebackAssessmentTests.findFirst({
100770
102055
  where: and(eq(gameTimebackAssessmentTests.id, definitionId), eq(gameTimebackAssessmentTests.integrationId, integrationId))
100771
102056
  });
100772
- if (!row || row.purpose !== "diagnostic" || requireLive && row.status !== "live" || !row.diagnosticKey || !row.diagnosticRoutingManifest) {
102057
+ if (!row || row.purpose !== "diagnostic" || requireLive && row.status !== "live" || !row.assessmentKey || !row.diagnosticKey) {
100773
102058
  throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `The selected diagnostic definition ${definitionId} is unavailable.`, { definitionId });
100774
102059
  }
100775
- return row;
102060
+ const requestedQtiTestIdentifier = pinnedQtiTestIdentifier ?? row.qtiTestIdentifier;
102061
+ const pinnedManifest = requestedQtiTestIdentifier === row.qtiTestIdentifier ? row.diagnosticRoutingManifest : managedAssessmentDiagnosticRoutingManifest(await this.requireClient().qtiApi.assessmentTests.get(requestedQtiTestIdentifier), row.assessmentKey);
102062
+ if (!pinnedManifest) {
102063
+ throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `The pinned diagnostic publication for ${definitionId} is unavailable.`, { definitionId, pinnedQtiTestIdentifier });
102064
+ }
102065
+ return {
102066
+ ...row,
102067
+ qtiTestIdentifier: requestedQtiTestIdentifier,
102068
+ diagnosticRoutingManifest: pinnedManifest
102069
+ };
100776
102070
  }
100777
102071
  async initializeHostedDiagnostic(definition, assessment) {
100778
102072
  const initialized = await this.initializeHostedDiagnosticManifest(definition);
@@ -100806,7 +102100,7 @@ class TimebackAssessmentRuntimeService {
100806
102100
  };
100807
102101
  }
100808
102102
  async loadAttemptDiagnosticManifest(metadata2, db2 = this.deps.db) {
100809
- const definition = await this.requireDiagnosticDefinition(metadata2.diagnostic.definitionId, metadata2.integrationId, db2);
102103
+ const definition = await this.requireDiagnosticDefinition(metadata2.diagnostic.definitionId, metadata2.integrationId, db2, false, metadata2.selectedTest.identifier);
100810
102104
  const initialized = await this.initializeHostedDiagnosticManifest(definition);
100811
102105
  if (definition.qtiTestIdentifier !== metadata2.selectedTest.identifier || definition.diagnosticKey !== metadata2.diagnostic.diagnosticKey || initialized.routingRevision !== metadata2.diagnostic.routingRevision) {
100812
102106
  throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `The pinned definition for diagnostic ${metadata2.diagnostic.diagnosticKey} no longer matches the attempt.`, { definitionId: metadata2.diagnostic.definitionId });
@@ -101681,6 +102975,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101681
102975
  init_uuid();
101682
102976
  init_errors2();
101683
102977
  init_assessment_runtime_lock_util();
102978
+ init_timeback_assessment_publication_util();
101684
102979
  init_timeback_assessment_rules_util();
101685
102980
  init_timeback_assessment_runtime_util();
101686
102981
  init_timeback_qti_hydration_util();
@@ -101690,945 +102985,50 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
101690
102985
  ]);
101691
102986
  });
101692
102987
 
101693
- // ../api-core/src/utils/timeback-qti-authoring.util.ts
101694
- function recordValue(value) {
101695
- return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
101696
- }
101697
- function persistableAuthoringDocument(value) {
101698
- const document2 = recordValue(value);
101699
- const item = recordValue(document2?.item);
101700
- if (!document2 || !item) {
101701
- return;
101702
- }
101703
- const persistableItem = { ...item };
101704
- Reflect.deleteProperty(persistableItem, "metadata");
101705
- return { ...document2, item: persistableItem };
101706
- }
101707
- function qtiAuthoringSnapshot(input) {
101708
- const document2 = persistableAuthoringDocument(input.document);
101709
- if (!recordValue(input.interaction) && !document2) {
101710
- return;
101711
- }
101712
- return Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => {
101713
- if (field === "document") {
101714
- return document2 ? [[field, document2]] : [];
101715
- }
101716
- return input[field] === undefined ? [] : [[field, input[field]]];
102988
+ // ../api-core/src/utils/timeback-assessment-diagnostic.util.ts
102989
+ function diagnosticRoutingCapabilities(assessment, scoring) {
102990
+ return assessment.items.map((item) => ({
102991
+ itemIdentifier: item.identifier,
102992
+ supportsDeterminateBinaryGrading: item.interactions.length > 0 && item.interactions.every((interaction) => {
102993
+ const responseIdentifier = interaction.responseIdentifier;
102994
+ return Object.hasOwn(scoring.correctResponses[item.identifier] ?? {}, responseIdentifier) || (scoring.responseAreas?.[item.identifier]?.[responseIdentifier]?.length ?? 0) > 0;
102995
+ })
101717
102996
  }));
101718
102997
  }
101719
- function buildOwnedQtiQuestionMetadata(input, ownership, existingMetadata = {}) {
101720
- const inputMetadata = recordValue(input.metadata) ?? {};
101721
- const metadata2 = {
101722
- ...existingMetadata,
101723
- ...inputMetadata
101724
- };
101725
- const existingAuthoring = recordValue(existingMetadata[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
101726
- Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
101727
- Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_EDITOR_MODE_KEY);
101728
- const authoring = qtiAuthoringSnapshot(input);
101729
- const shouldReplaceAuthoring = "interaction" in input || "numericTextEntry" in input || "document" in input;
101730
- const persistedAuthoring = authoring ?? (shouldReplaceAuthoring ? undefined : existingAuthoring);
101731
- return {
101732
- ...metadata2,
101733
- ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
101734
- ownerGameSlug: ownership.gameSlug,
101735
- [PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]: ownership.testIdentifier,
101736
- ...persistedAuthoring ? { [PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]: persistedAuthoring } : {}
101737
- };
101738
- }
101739
- function restoreQtiQuestionAuthoringData(item) {
101740
- const authoring = recordValue(item.metadata?.[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
101741
- if (!authoring) {
101742
- return item;
101743
- }
101744
- const restored = Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => authoring[field] === undefined ? [] : [[field, authoring[field]]]));
101745
- return { ...item, ...restored };
101746
- }
101747
- function mergeQtiQuestionItem(item, fallbackItem, itemIdentifier, metadata2) {
101748
- return restoreQtiQuestionAuthoringData({
101749
- ...fallbackItem,
101750
- ...item,
101751
- identifier: itemIdentifier,
101752
- title: item.title || fallbackItem?.title || itemIdentifier,
101753
- type: item.type ?? fallbackItem?.type,
101754
- rawXml: item.rawXml ?? fallbackItem?.rawXml,
101755
- interaction: item.interaction ?? fallbackItem?.interaction,
101756
- responseDeclarations: item.responseDeclarations ?? fallbackItem?.responseDeclarations,
101757
- metadata: {
101758
- ...fallbackItem?.metadata,
101759
- ...item.metadata,
101760
- ...metadata2
101761
- }
101762
- });
101763
- }
101764
- function newPlaycademyQuestionIdentifier(testIdentifier) {
101765
- return `${testIdentifier}-q${crypto.randomUUID().slice(0, 8)}`;
101766
- }
101767
- function parseQtiQuestionCreationInput(input) {
101768
- if (!input || typeof input !== "object" || Array.isArray(input)) {
101769
- throw new ValidationError("Question creation input must be an object");
102998
+ function prepareDiagnosticDefinition(input) {
102999
+ const diagnosticKey = input.diagnosticKey.trim();
103000
+ if (!diagnosticKey || diagnosticKey.length > 200) {
103001
+ throw new ValidationError("Diagnostic key must contain between 1 and 200 characters");
101770
103002
  }
101771
- const rawInput = input;
101772
- if (rawInput.mode === undefined) {
101773
- return { kind: "authoring", input: rawInput };
101774
- }
101775
- if (rawInput.mode !== "copy") {
101776
- throw new ValidationError("Question creation mode is invalid");
101777
- }
101778
- const sourceItemIdentifier = typeof rawInput.sourceItemIdentifier === "string" ? rawInput.sourceItemIdentifier.trim() : "";
101779
- if (!sourceItemIdentifier) {
101780
- throw new ValidationError("A source question identifier is required to create a copy");
101781
- }
101782
- const unexpectedField = Object.keys(rawInput).find((field) => field !== "mode" && field !== "sourceItemIdentifier");
101783
- if (unexpectedField) {
101784
- throw new ValidationError(`Question copy input contains an unexpected “${unexpectedField}” field`);
101785
- }
101786
- return { kind: "copy", sourceItemIdentifier };
101787
- }
101788
- function nonEmptyMetadataString(value) {
101789
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
101790
- }
101791
- function qtiQuestionOwnerTestIdentifier(item) {
101792
- if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
101793
- return;
101794
- }
101795
- return nonEmptyMetadataString(item.metadata[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]);
101796
- }
101797
- function qtiTestReferencesItem(test, itemIdentifier) {
101798
- return (test["qti-test-part"] ?? []).some((part) => (part["qti-assessment-section"] ?? []).some((section) => (section["qti-assessment-item-ref"] ?? []).some((reference) => reference.identifier === itemIdentifier)));
101799
- }
101800
- function resolveQtiQuestionOwnerGameSlug(item, ownerTest) {
101801
- if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
101802
- return;
101803
- }
101804
- if ("ownerGameSlug" in item.metadata) {
101805
- return nonEmptyMetadataString(item.metadata.ownerGameSlug);
101806
- }
101807
- const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
101808
- if (!ownerTestIdentifier || !ownerTest || ownerTest.identifier !== ownerTestIdentifier || !qtiTestReferencesItem(ownerTest, item.identifier) || ownerTest.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
101809
- return;
101810
- }
101811
- return nonEmptyMetadataString(ownerTest.metadata.ownerGameSlug);
101812
- }
101813
- function isQtiQuestionOwnedByGame(item, gameSlug, ownerTest) {
101814
- return resolveQtiQuestionOwnerGameSlug(item, ownerTest) === gameSlug;
101815
- }
101816
- function isQtiQuestionOwnedByContext(item, ownership, ownerTest) {
101817
- const declaredOwnerTest = qtiQuestionOwnerTestIdentifier(item);
101818
- if (declaredOwnerTest) {
101819
- return declaredOwnerTest === ownership.testIdentifier && isQtiQuestionOwnedByGame(item, ownership.gameSlug, ownerTest);
101820
- }
101821
- return isQtiItemOwnedByTest(item, ownership.testIdentifier);
101822
- }
101823
- function normalizedQtiInteractionType(value) {
101824
- if (typeof value !== "string") {
101825
- return;
101826
- }
101827
- const normalized = value.trim().toLowerCase().replaceAll("_", "-");
101828
- return normalized || undefined;
101829
- }
101830
- function authoringDocumentItem(value) {
101831
- const document2 = recordValue(value);
101832
- if (!document2) {
101833
- throw new ValidationError("Authored question data is invalid");
101834
- }
101835
- if (document2.format !== "playcademy-question" || document2.version !== 1) {
101836
- throw new ValidationError("Authored questions require a version 1 playcademy-question document");
101837
- }
101838
- const item = recordValue(document2.item);
101839
- if (!item || typeof item.title !== "string" || !Array.isArray(item.body) || !Array.isArray(item.responseDeclarations)) {
101840
- throw new ValidationError("An authored question needs an item with a title, a body, and response declarations");
101841
- }
101842
- const declarations = item.responseDeclarations.map(recordValue);
101843
- const declaredIdentifiers = declarations.map((declaration) => typeof declaration?.identifier === "string" ? declaration.identifier : "");
101844
- if (declarations.length === 0) {
101845
- throw new ValidationError("An authored question requires at least one response declaration");
101846
- }
101847
- const declaredIdentifierSet = new Set(declaredIdentifiers);
101848
- if (declaredIdentifiers.some((identifier) => !identifier) || declaredIdentifierSet.size !== declaredIdentifiers.length) {
101849
- throw new ValidationError("Response declaration identifiers must be unique");
101850
- }
101851
- const projection = projectQtiAuthoringBody(item.body);
101852
- if (projection.nestedInteractions.length > 0) {
101853
- throw new ValidationError("Interactions nested inside another interaction cannot be compiled");
101854
- }
101855
- if (projection.interactions.length !== declarations.length) {
101856
- throw new ValidationError("Every response declaration must belong to exactly one interaction");
101857
- }
101858
- const interactionIdentifiers = projection.interactions.map((interaction) => interaction.responseIdentifier);
101859
- const interactionIdentifierSet = new Set(interactionIdentifiers);
101860
- if (interactionIdentifierSet.size !== interactionIdentifiers.length) {
101861
- throw new ValidationError("Every interaction needs its own response identifier");
101862
- }
101863
- if (interactionIdentifiers.some((identifier) => !declaredIdentifierSet.has(identifier)) || declaredIdentifiers.some((identifier) => !interactionIdentifierSet.has(identifier))) {
101864
- throw new ValidationError("Every interaction must declare exactly one response");
101865
- }
101866
- const unauthorable = projection.interactions.find((interaction) => !qtiAuthoringInteractionType(interaction));
101867
- if (unauthorable) {
101868
- throw new ValidationError(`Interaction type “${String(unauthorable.type)}” cannot be authored by Playcademy yet`);
101869
- }
101870
- const inline = projection.interactions.filter(isInlineQtiAuthoringInteraction);
101871
- if (projection.interactions.length - inline.length > 1) {
101872
- throw new ValidationError("A question supports any number of inline interactions but at most one block interaction");
101873
- }
101874
- if (item.body.some((node) => node.kind === "interaction" && isInlineQtiAuthoringInteraction(node))) {
101875
- throw new ValidationError("Every inline interaction must sit at its blank inside the question’s prose");
101876
- }
101877
- const blanks = inline.map((interaction) => projection.blankIndexes.get(interaction));
101878
- if (blanks.some((blank) => blank === undefined) || new Set(blanks).size !== blanks.length) {
101879
- throw new ValidationError("Every inline interaction needs its own blank position");
101880
- }
101881
- return item;
101882
- }
101883
- function parsedQtiItemOrNull(rawXml) {
101884
- if (typeof rawXml !== "string" || !rawXml) {
101885
- return null;
101886
- }
101887
- try {
101888
- return parseQtiItemXml(rawXml);
101889
- } catch {
101890
- return null;
101891
- }
101892
- }
101893
- function isAuthorableMultiInteractionQtiItem(question) {
101894
- const item = parsedQtiItemOrNull(question.rawXml);
101895
- return item !== null && (authorableQtiInteractions(item)?.length ?? 0) > 1;
101896
- }
101897
- function assertPlayableQtiQuestion(question) {
101898
- if (!question.rawXml) {
101899
- assertSupportedQtiQuestionInteraction(question);
101900
- return;
101901
- }
101902
- const item = parsedQtiItemOrNull(question.rawXml);
101903
- const playable = item && playableQtiInteractions(item);
101904
- if (!playable || qtiItemScoringValidationMessage(item)) {
101905
- throw new ValidationError(`Question ${question.identifier} uses an interaction Playcademy cannot play or score.`);
101906
- }
101907
- }
101908
- function assertSupportedQtiQuestionInteraction(question) {
101909
- const candidate = recordValue(question);
101910
- if (candidate?.type === AUTHORING_DOCUMENT_QUESTION_TYPE) {
101911
- authoringDocumentItem(candidate.document);
101912
- return;
101913
- }
101914
- if (!supportedQtiQuestionInteractionType(question) && !isAuthorableMultiInteractionQtiItem(question)) {
101915
- throw new ValidationError("This QTI interaction type is not supported by the Playcademy question editor.");
101916
- }
101917
- }
101918
- function invalidQtiCopyXml() {
101919
- throw new ValidationError("The source question does not contain valid QTI item XML");
101920
- }
101921
- function markupEnd2(xml, start2, allowInternalSubset) {
101922
- let quote = null;
101923
- let subsetDepth = 0;
101924
- for (let index2 = start2;index2 < xml.length; index2 += 1) {
101925
- const character = xml[index2];
101926
- if (quote) {
101927
- if (character === quote) {
101928
- quote = null;
101929
- }
101930
- } else if (character === '"' || character === "'") {
101931
- quote = character;
101932
- } else if (allowInternalSubset && character === "[") {
101933
- subsetDepth += 1;
101934
- } else if (allowInternalSubset && character === "]") {
101935
- subsetDepth = Math.max(0, subsetDepth - 1);
101936
- } else if (character === ">" && subsetDepth === 0) {
101937
- return index2;
101938
- }
101939
- }
101940
- return invalidQtiCopyXml();
101941
- }
101942
- function skipXmlWhitespace(xml, start2, end = xml.length) {
101943
- let cursor2 = start2;
101944
- while (cursor2 < end && /\s/.test(xml[cursor2] ?? "")) {
101945
- cursor2 += 1;
101946
- }
101947
- return cursor2;
101948
- }
101949
- function xmlPreambleEnd(xml, cursor2) {
101950
- if (xml.startsWith("<?", cursor2)) {
101951
- const end = xml.indexOf("?>", cursor2 + 2);
101952
- if (end === -1) {
101953
- return invalidQtiCopyXml();
101954
- }
101955
- return end + 2;
101956
- }
101957
- if (xml.startsWith("<!--", cursor2)) {
101958
- const end = xml.indexOf("-->", cursor2 + 4);
101959
- if (end === -1) {
101960
- return invalidQtiCopyXml();
101961
- }
101962
- return end + 3;
101963
- }
101964
- if (/^<!DOCTYPE\b/i.test(xml.slice(cursor2))) {
101965
- return markupEnd2(xml, cursor2 + 2, true) + 1;
101966
- }
101967
- return null;
101968
- }
101969
- function qtiRootAttributes(xml, start2, end) {
101970
- const attributes2 = [];
101971
- let cursor2 = start2;
101972
- while (cursor2 < end) {
101973
- cursor2 = skipXmlWhitespace(xml, cursor2, end);
101974
- if (xml[cursor2] === "/") {
101975
- cursor2 += 1;
101976
- } else if (cursor2 < end) {
101977
- const nameMatch = /^[A-Za-z_][\w.:-]*/.exec(xml.slice(cursor2, end));
101978
- if (!nameMatch) {
101979
- return invalidQtiCopyXml();
101980
- }
101981
- const name3 = nameMatch[0];
101982
- cursor2 = skipXmlWhitespace(xml, cursor2 + name3.length, end);
101983
- if (xml[cursor2] !== "=") {
101984
- return invalidQtiCopyXml();
101985
- }
101986
- cursor2 = skipXmlWhitespace(xml, cursor2 + 1, end);
101987
- const quote = xml[cursor2];
101988
- if (quote !== '"' && quote !== "'") {
101989
- return invalidQtiCopyXml();
101990
- }
101991
- const valueStart = cursor2 + 1;
101992
- const valueEnd = xml.indexOf(quote, valueStart);
101993
- if (valueEnd === -1 || valueEnd > end) {
101994
- return invalidQtiCopyXml();
101995
- }
101996
- attributes2.push({ name: name3, quote, valueStart, valueEnd });
101997
- cursor2 = valueEnd + 1;
101998
- }
101999
- }
102000
- return attributes2;
102001
- }
102002
- function qtiItemStartTag(xml) {
102003
- let cursor2 = xml.charCodeAt(0) === 65279 ? 1 : 0;
102004
- while (cursor2 < xml.length) {
102005
- cursor2 = skipXmlWhitespace(xml, cursor2);
102006
- const preambleEnd = xmlPreambleEnd(xml, cursor2);
102007
- if (preambleEnd !== null) {
102008
- cursor2 = preambleEnd;
102009
- } else {
102010
- const root = /^<([A-Za-z_][\w.:-]*)/.exec(xml.slice(cursor2));
102011
- if (!root || root[1].split(":").at(-1) !== "qti-assessment-item") {
102012
- return invalidQtiCopyXml();
102013
- }
102014
- const attributesStart = cursor2 + root[0].length;
102015
- const end = markupEnd2(xml, attributesStart, false);
102016
- const attributes2 = qtiRootAttributes(xml, attributesStart, end);
102017
- let insertionPoint = skipXmlWhitespaceBackward(xml, end);
102018
- if (xml[insertionPoint - 1] === "/") {
102019
- insertionPoint -= 1;
102020
- }
102021
- return { attributes: attributes2, insertionPoint };
102022
- }
102023
- }
102024
- return invalidQtiCopyXml();
102025
- }
102026
- function skipXmlWhitespaceBackward(xml, start2) {
102027
- let cursor2 = start2;
102028
- while (/\s/.test(xml[cursor2 - 1] ?? "")) {
102029
- cursor2 -= 1;
102030
- }
102031
- return cursor2;
102032
- }
102033
- function escapeXmlAttribute(value, quote) {
102034
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(quote, quote === '"' ? "&quot;" : "&apos;");
102035
- }
102036
- function rewriteQtiItemIdentity(xml, identifier, title) {
102037
- if (!xml.trim() || !identifier.trim() || !title.trim()) {
102038
- return invalidQtiCopyXml();
102039
- }
102040
- const root = qtiItemStartTag(xml);
102041
- const replacements = [];
102042
- const targetAttributes = new Map([
102043
- ["identifier", identifier],
102044
- ["title", title]
102045
- ]);
102046
- for (const [name3, value] of targetAttributes) {
102047
- const matches = root.attributes.filter((attribute2) => attribute2.name === name3);
102048
- if (matches.length > 1) {
102049
- return invalidQtiCopyXml();
102050
- }
102051
- const attribute = matches[0];
102052
- if (attribute) {
102053
- replacements.push({
102054
- start: attribute.valueStart,
102055
- end: attribute.valueEnd,
102056
- value: escapeXmlAttribute(value, attribute.quote)
102057
- });
102058
- } else {
102059
- replacements.push({
102060
- start: root.insertionPoint,
102061
- end: root.insertionPoint,
102062
- value: ` ${name3}="${escapeXmlAttribute(value, '"')}"`
102063
- });
102064
- }
102065
- }
102066
- return replacements.toSorted((left, right) => right.start - left.start).reduce((rewritten, replacement) => `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`, xml);
102067
- }
102068
- function buildQtiQuestionCopyInput(input) {
102069
- assertPlayableQtiQuestion(input.source);
102070
- const title = input.source.title?.trim() || input.source.identifier;
102071
- if (!input.source.rawXml) {
102072
- return invalidQtiCopyXml();
102073
- }
102074
- const sourceMetadata = { ...input.source.metadata };
102075
- if (sourceMetadata.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
102076
- Reflect.deleteProperty(sourceMetadata, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
102077
- }
102078
- const metadata2 = buildOwnedQtiQuestionMetadata({
102079
- metadata: {
102080
- copiedFromItemIdentifier: input.source.identifier,
102081
- integrationId: input.integrationId
102082
- }
102083
- }, {
102084
- gameSlug: input.gameSlug,
102085
- testIdentifier: input.targetTestIdentifier
102086
- }, sourceMetadata);
102087
- return {
102088
- identifier: input.targetIdentifier,
102089
- title,
102090
- xml: rewriteQtiItemIdentity(input.source.rawXml, input.targetIdentifier, title),
102091
- metadata: metadata2
102092
- };
102093
- }
102094
- function assertQtiQuestionEditable(item, ownership, ownerTest) {
102095
- if (!isQtiQuestionOwnedByContext(item, ownership, ownerTest)) {
102096
- throw new ValidationError("Shared question references are read-only. Copy the question to create an editable independent item.");
102097
- }
102098
- }
102099
- function buildNumericQuestionXml(input, identifier, title) {
102100
- if (input.format === "xml") {
102101
- throw new ValidationError("Raw question XML is not accepted at this API boundary");
102102
- }
102103
- const candidate = input.numericTextEntry;
102104
- if (candidate === undefined) {
102105
- return null;
102106
- }
102107
- if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
102108
- throw new ValidationError("Numeric text-entry data is invalid");
102109
- }
102110
- const prompt = "prompt" in candidate ? candidate.prompt : undefined;
102111
- const numeric3 = parseNumericTextEntry({
102112
- baseType: "baseType" in candidate ? candidate.baseType : undefined,
102113
- answer: "answer" in candidate ? candidate.answer : undefined,
102114
- comparison: "comparison" in candidate ? candidate.comparison : undefined
102115
- });
102116
- if (!numeric3.success) {
102117
- throw new ValidationError(numeric3.message);
102118
- }
102119
- if (!title || typeof prompt !== "string" || !prompt.trim()) {
102120
- throw new ValidationError("Numeric questions require a title and prompt");
102121
- }
102122
- return numericTextEntryXml({ identifier, title, prompt: prompt.trim(), numeric: numeric3.value });
102123
- }
102124
- function buildExactMatchQuestionXml(input) {
102125
- const responseIdentifier = escapeXml(input.responseIdentifier);
102126
- const correctValues = input.correctIdentifiers.map((identifier) => ` <qti-value>${escapeXml(identifier)}</qti-value>`).join(`
102127
- `);
102128
- return `<?xml version="1.0" encoding="UTF-8"?>
102129
- <qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
102130
- <qti-response-declaration identifier="${responseIdentifier}" cardinality="${input.cardinality}" base-type="identifier">
102131
- <qti-correct-response>
102132
- ${correctValues}
102133
- </qti-correct-response>
102134
- </qti-response-declaration>
102135
- <qti-outcome-declaration identifier="FEEDBACK" cardinality="single" base-type="identifier" />
102136
- <qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
102137
- <qti-default-value><qti-value>0</qti-value></qti-default-value>
102138
- </qti-outcome-declaration>
102139
- <qti-item-body>
102140
- ${input.itemBody}
102141
- </qti-item-body>
102142
- <qti-response-processing>
102143
- <qti-response-condition>
102144
- <qti-response-if>
102145
- <qti-match><qti-variable identifier="${responseIdentifier}" /><qti-correct identifier="${responseIdentifier}" /></qti-match>
102146
- <qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">CORRECT</qti-base-value></qti-set-outcome-value>
102147
- <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
102148
- </qti-response-if>
102149
- <qti-response-else>
102150
- <qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">INCORRECT</qti-base-value></qti-set-outcome-value>
102151
- <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">0</qti-base-value></qti-set-outcome-value>
102152
- </qti-response-else>
102153
- </qti-response-condition>
102154
- </qti-response-processing>
102155
- </qti-assessment-item>`;
102156
- }
102157
- function parseStructuredChoices(value, options) {
102158
- const { label, minimum, validate: validate2 } = options;
102159
- if (!Array.isArray(value) || value.length < minimum) {
102160
- throw new ValidationError(`${label} questions require at least ${minimum === 1 ? "one choice" : `${minimum} choices`}`);
102161
- }
102162
- const choices = value.map((choice) => {
102163
- const record3 = recordValue(choice);
102164
- const identifier = record3?.identifier;
102165
- const content = record3?.content;
102166
- if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim() || validate2 !== undefined && record3 !== undefined && !validate2(record3)) {
102167
- throw new ValidationError(`${label} options are invalid`);
102168
- }
102169
- return { identifier: identifier.trim(), content: content.trim() };
102170
- });
102171
- if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
102172
- throw new ValidationError(`${label} option identifiers must be unique`);
102173
- }
102174
- return choices;
102175
- }
102176
- function declaredCorrectResponse(input, responseIdentifier) {
102177
- const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
102178
- const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
102179
- const rawValues = recordValue(declaration?.correctResponse)?.value;
102180
- return {
102181
- cardinality: declaration?.cardinality,
102182
- baseType: declaration?.baseType,
102183
- values: Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : []
102184
- };
102185
- }
102186
- function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
102187
- const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
102188
- if (cardinality !== "single" || baseType !== "identifier") {
102189
- throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
102190
- }
102191
- if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
102192
- throw new ValidationError("Inline-choice questions require one valid correct option");
102193
- }
102194
- return values[0];
102195
- }
102196
- function structuredQuestionEnvelope(input, type, label, title) {
102197
- const interaction = recordValue(input.interaction);
102198
- if (normalizedQtiInteractionType(interaction?.type ?? input.type) !== type) {
102199
- return null;
102200
- }
102201
- const structure = recordValue(interaction?.questionStructure);
102202
- const responseIdentifier = interaction?.responseIdentifier;
102203
- const prompt = structure?.prompt;
102204
- if (!interaction || !structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
102205
- throw new ValidationError(`${label} question data is invalid`);
102206
- }
102207
- if (!title || typeof prompt !== "string" || !prompt.trim()) {
102208
- throw new ValidationError(`${label} questions require a title and prompt`);
102209
- }
102210
- return {
102211
- interaction,
102212
- structure,
102213
- responseIdentifier: responseIdentifier.trim(),
102214
- prompt: prompt.trim()
102215
- };
102216
- }
102217
- function buildInlineChoiceQuestionXml(input, identifier, title) {
102218
- const envelope = structuredQuestionEnvelope(input, "inline-choice", "Inline-choice", title);
102219
- if (!envelope) {
102220
- return null;
102221
- }
102222
- const { structure, responseIdentifier, prompt } = envelope;
102223
- const promptParts = prompt.split(INLINE_CHOICE_BLANK);
102224
- if (promptParts.length !== 2) {
102225
- throw new ValidationError("Inline-choice questions require exactly one blank");
102226
- }
102227
- const choices = parseStructuredChoices(structure.inlineChoices, {
102228
- label: "Inline-choice",
102229
- minimum: 2
102230
- });
102231
- const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
102232
- const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
102233
- const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
102234
- `);
102235
- const [before = "", after = ""] = promptParts;
102236
- const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
102237
- ${choicesXml}
102238
- </qti-inline-choice-interaction>${escapeXml(after)}</p>`;
102239
- return buildExactMatchQuestionXml({
102240
- identifier,
102241
- title,
102242
- responseIdentifier,
102243
- cardinality: "single",
102244
- correctIdentifiers: [correctIdentifier],
102245
- itemBody
102246
- });
102247
- }
102248
- function parseStructuredMatchChoices(value, label) {
102249
- return parseStructuredChoices(value, {
102250
- label: `Match ${label}`,
102251
- minimum: 1,
102252
- validate: (choice) => choice.matchMax === 1
102253
- });
102254
- }
102255
- function structuredMatchCorrectPairs(input, responseIdentifier, sources, targets) {
102256
- const { cardinality, baseType, values } = declaredCorrectResponse(input, responseIdentifier);
102257
- if (cardinality !== "multiple" || baseType !== "directedPair") {
102258
- throw new ValidationError("Match questions require a multiple directed-pair response declaration");
102259
- }
102260
- const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
102261
- const targetIdentifiers = new Set(targets.map((choice) => choice.identifier));
102262
- const valid = values.length > 0 && new Set(values).size === values.length && values.every((value) => {
102263
- const [source, target, extra] = value.split(" ");
102264
- return extra === undefined && Boolean(source && target) && sourceIdentifiers.has(source) && targetIdentifiers.has(target);
102265
- });
102266
- if (!valid) {
102267
- throw new ValidationError("Every Match correct response must name one declared source and target choice");
102268
- }
102269
- return values;
102270
- }
102271
- function buildMatchQuestionXml(input, identifier, title) {
102272
- const envelope = structuredQuestionEnvelope(input, "match", "Match", title);
102273
- if (!envelope) {
102274
- return null;
102275
- }
102276
- const { interaction, structure, responseIdentifier, prompt } = envelope;
102277
- const sources = parseStructuredMatchChoices(structure.sourceChoices, "source");
102278
- const targets = parseStructuredMatchChoices(structure.targetChoices, "target");
102279
- const sourceIdentifiers = new Set(sources.map((choice) => choice.identifier));
102280
- if (targets.some((choice) => sourceIdentifiers.has(choice.identifier))) {
102281
- throw new ValidationError("Match source and target choice identifiers must be unique");
102282
- }
102283
- const correctValues = structuredMatchCorrectPairs(input, responseIdentifier, sources, targets);
102284
- const maxAssociations = interaction.maxAssociations;
102285
- if (typeof maxAssociations !== "number" || !Number.isInteger(maxAssociations) || maxAssociations < 0 || maxAssociations !== 0 && maxAssociations < correctValues.length) {
102286
- throw new ValidationError("Match max associations must fit every correct pair");
102287
- }
102288
- const item = {
102289
- key: identifier,
102290
- title,
102291
- body: [
102292
- {
102293
- kind: "interaction",
102294
- type: "match",
102295
- responseIdentifier,
102296
- attributes: {
102297
- shuffle: interaction.shuffle === true,
102298
- "max-associations": maxAssociations
102299
- },
102300
- content: [
102301
- {
102302
- kind: "element",
102303
- name: "qti-prompt",
102304
- children: [{ kind: "text", text: prompt }]
102305
- }
102306
- ],
102307
- choiceSets: [sources, targets].map((choices) => choices.map((choice) => ({
102308
- identifier: choice.identifier,
102309
- content: [{ kind: "text", text: choice.content }]
102310
- })))
102311
- }
102312
- ],
102313
- responseDeclarations: [
102314
- {
102315
- identifier: responseIdentifier,
102316
- cardinality: "multiple",
102317
- baseType: "directedPair",
102318
- correctValues
102319
- }
102320
- ]
102321
- };
102322
- try {
102323
- return compileQtiAuthoringItemXml(item, { identifier, title });
102324
- } catch (error88) {
102325
- throw new ValidationError(`This Match question cannot be compiled to QTI: ${errorMessage(error88)}`);
102326
- }
102327
- }
102328
- function parseHottextSegments2(value) {
102329
- if (!Array.isArray(value) || value.length === 0) {
102330
- throw new ValidationError("Hottext questions require passage segments");
102331
- }
102332
- const segments = value.map((segment) => {
102333
- const record3 = recordValue(segment);
102334
- const identifier = record3?.identifier;
102335
- const content = record3?.content;
102336
- const mode = record3?.mode;
102337
- if (typeof identifier !== "string" || typeof content !== "string" || !content.trim() || !["plain", "option", "correct"].includes(String(mode))) {
102338
- throw new ValidationError("Hottext passage segments are invalid");
102339
- }
102340
- return {
102341
- identifier: identifier.trim(),
102342
- content: content.trim(),
102343
- mode
102344
- };
102345
- });
102346
- const selectable = segments.filter((segment) => segment.mode !== "plain");
102347
- const correct = selectable.filter((segment) => segment.mode === "correct");
102348
- const identifiers = selectable.map((segment) => segment.identifier);
102349
- if (selectable.length < 2) {
102350
- throw new ValidationError("Hottext questions require at least two selectable phrases");
102351
- }
102352
- if (identifiers.some((identifier) => !identifier)) {
102353
- throw new ValidationError("Hottext selectable phrases require identifiers");
102354
- }
102355
- if (new Set(identifiers).size !== identifiers.length) {
102356
- throw new ValidationError("Hottext selectable phrase identifiers must be unique");
102357
- }
102358
- if (correct.length === 0) {
102359
- throw new ValidationError("Hottext questions require a correct phrase");
102360
- }
102361
- return { segments, selectable, correct };
102362
- }
102363
- function buildHottextQuestionXml(input, identifier, title) {
102364
- const candidate = input.hottext;
102365
- if (candidate === undefined) {
102366
- return null;
102367
- }
102368
- const hottext = recordValue(candidate);
102369
- if (!hottext) {
102370
- throw new ValidationError("Hottext question data is invalid");
102371
- }
102372
- const prompt = hottext.prompt;
102373
- if (!title || typeof prompt !== "string" || !prompt.trim()) {
102374
- throw new ValidationError("Hottext questions require a title and prompt");
102375
- }
102376
- const { segments, selectable, correct } = parseHottextSegments2(hottext.segments);
102377
- const multiple = correct.length > 1;
102378
- const maxChoices = hottextMaxChoices(selectable.length, multiple);
102379
- const passage = segments.map((segment) => segment.mode === "plain" ? escapeXml(segment.content) : `<qti-hottext identifier="${escapeXml(segment.identifier)}">${escapeXml(segment.content)}</qti-hottext>`).join(" ");
102380
- const itemBody = ` <qti-hottext-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
102381
- <qti-prompt>${escapeXml(prompt.trim())}</qti-prompt>
102382
- <p>${passage}</p>
102383
- </qti-hottext-interaction>`;
102384
- return buildExactMatchQuestionXml({
102385
- identifier,
102386
- title,
102387
- responseIdentifier: "RESPONSE",
102388
- cardinality: multiple ? "multiple" : "single",
102389
- correctIdentifiers: correct.map((segment) => segment.identifier),
102390
- itemBody
102391
- });
102392
- }
102393
- function buildAuthoringDocumentQuestionXml(input, identifier, title) {
102394
- if (input.type !== AUTHORING_DOCUMENT_QUESTION_TYPE) {
102395
- return null;
102396
- }
102397
- const item = authoringDocumentItem(input.document);
102398
- if (!title) {
102399
- throw new ValidationError("Authored questions require a title");
102400
- }
102401
- try {
102402
- return compileQtiAuthoringItemXml(item, { identifier, title });
102403
- } catch (error88) {
102404
- throw new ValidationError(`This question cannot be compiled to QTI: ${errorMessage(error88)}`);
102405
- }
102406
- }
102407
- function buildQuestionXml(input, identifier, title) {
102408
- for (const build2 of QUESTION_XML_BUILDERS) {
102409
- const xml = build2(input, identifier, title);
102410
- if (xml !== null) {
102411
- return xml;
102412
- }
102413
- }
102414
- return null;
102415
- }
102416
- function buildCreatedQtiQuestionReference(input) {
102417
- return {
102418
- ownership: "owned",
102419
- reference: {
102420
- identifier: input.itemIdentifier,
102421
- href: input.href,
102422
- testPart: input.partIdentifier,
102423
- section: input.sectionIdentifier
102424
- },
102425
- question: mergeQtiQuestionItem(input.item, input.fallbackItem, input.itemIdentifier, input.metadata)
102426
- };
102427
- }
102428
- function buildHydratedQtiQuestionReference(input) {
102429
- const identifier = input.reference.reference.identifier;
102430
- const ownerGameSlug = resolveQtiQuestionOwnerGameSlug(input.item, input.ownerTest);
102431
- const authoritativeItem = ownerGameSlug ? {
102432
- ...input.item,
102433
- metadata: { ...input.item.metadata, ownerGameSlug }
102434
- } : input.item;
102435
- const question = mergeQtiQuestionItem(authoritativeItem, input.reference.question, identifier);
102436
- return {
102437
- ...input.reference,
102438
- ownership: isQtiQuestionOwnedByContext(question, { gameSlug: input.gameSlug, testIdentifier: input.testIdentifier }, input.ownerTest) ? "owned" : "shared",
102439
- question
102440
- };
102441
- }
102442
- function isQtiTestOwnedByGame(test, gameSlug) {
102443
- return test.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && test.metadata.ownerGameSlug === gameSlug;
102444
- }
102445
- function assertQtiTestOwnedByGame(test, gameSlug) {
102446
- if (!isQtiTestOwnedByGame(test, gameSlug)) {
102447
- throw new ValidationError("Shared assessment references are read-only. Copy the assessment to create an editable independent test.");
102448
- }
102449
- }
102450
- function qtiTestParts(test) {
102451
- const parts2 = test["qti-test-part"];
102452
- if (!Array.isArray(parts2) || parts2.length === 0) {
102453
- throw new ValidationError(`Assessment ${test.identifier} has no test parts`);
102454
- }
102455
- for (const [partIndex, part] of parts2.entries()) {
102456
- if (!part || typeof part !== "object" || Array.isArray(part)) {
102457
- throw new ValidationError(`Assessment ${test.identifier} contains an invalid test part at position ${partIndex + 1}`);
102458
- }
102459
- const sections = part["qti-assessment-section"];
102460
- if (!Array.isArray(sections)) {
102461
- throw new ValidationError(`Assessment ${test.identifier} test part ${partIndex + 1} has no section list`);
102462
- }
102463
- }
102464
- return parts2;
102465
- }
102466
- function qtiTestOptionalAttributes(test) {
102467
- return {
102468
- ...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
102469
- ...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
102470
- ...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
102471
- ...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
102472
- };
102473
- }
102474
- function buildQtiAssessmentItemCopyPlan(test, targetTestIdentifier, hrefForIdentifier, createIdentifier = newPlaycademyQuestionIdentifier) {
102475
- const sourceIdentifiers = [];
102476
- const seen = new Set;
102477
- for (const part of qtiTestParts(test)) {
102478
- for (const section of part["qti-assessment-section"]) {
102479
- for (const reference of section["qti-assessment-item-ref"] ?? []) {
102480
- if (!seen.has(reference.identifier)) {
102481
- seen.add(reference.identifier);
102482
- sourceIdentifiers.push(reference.identifier);
102483
- }
102484
- }
102485
- }
102486
- }
102487
- return sourceIdentifiers.map((sourceIdentifier) => {
102488
- const targetIdentifier = createIdentifier(targetTestIdentifier);
102489
- return {
102490
- sourceIdentifier,
102491
- targetIdentifier,
102492
- href: hrefForIdentifier(targetIdentifier)
102493
- };
103003
+ const validation = validateDiagnosticRoutingManifest(input.routingManifest, undefined, {
103004
+ analyzeBounds: false
102494
103005
  });
102495
- }
102496
- function buildQtiTestStructureInput(test, targetTestIdentifier, itemCopies) {
102497
- const outcomeDeclarations = test["qti-outcome-declaration"];
102498
- if (outcomeDeclarations !== undefined && !Array.isArray(outcomeDeclarations)) {
102499
- throw new ValidationError(`Assessment ${test.identifier} has an invalid outcome declaration list`);
102500
- }
102501
- return {
102502
- "qti-test-part": qtiTestParts(test).map((part, partIndex) => ({
102503
- identifier: targetTestIdentifier ? `${targetTestIdentifier}-part${partIndex + 1}` : part.identifier,
102504
- navigationMode: part.navigationMode,
102505
- submissionMode: part.submissionMode,
102506
- "qti-assessment-section": part["qti-assessment-section"].map((section, sectionIndex) => {
102507
- let sectionIdentifier = section.identifier;
102508
- if (targetTestIdentifier) {
102509
- sectionIdentifier = partIndex === 0 && sectionIndex === 0 ? `${targetTestIdentifier}-section1` : `${targetTestIdentifier}-part${partIndex + 1}-section${sectionIndex + 1}`;
102510
- }
102511
- return {
102512
- identifier: sectionIdentifier,
102513
- title: section.title,
102514
- visible: section.visible ?? true,
102515
- ...section.required !== undefined ? { required: section.required } : {},
102516
- ...section.fixed !== undefined ? { fixed: section.fixed } : {},
102517
- sequence: section.sequence ?? sectionIndex + 1,
102518
- ...section["qti-assessment-item-ref"] ? {
102519
- "qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item, itemIndex) => {
102520
- const copy = itemCopies?.get(item.identifier);
102521
- if (itemCopies && !copy) {
102522
- throw new ValidationError(`Assessment copy is missing question ${item.identifier}`);
102523
- }
102524
- return {
102525
- identifier: copy?.targetIdentifier ?? item.identifier,
102526
- href: copy?.href ?? item.href,
102527
- sequence: item.sequence ?? itemIndex + 1
102528
- };
102529
- })
102530
- } : {}
102531
- };
102532
- })
102533
- })),
102534
- ...outcomeDeclarations ? {
102535
- "qti-outcome-declaration": outcomeDeclarations.map((declaration) => ({
102536
- identifier: declaration.identifier,
102537
- ...declaration.cardinality !== undefined ? { cardinality: declaration.cardinality } : {},
102538
- baseType: declaration.baseType,
102539
- ...declaration.normalMaximum !== undefined ? { normalMaximum: declaration.normalMaximum } : {},
102540
- ...declaration.normalMinimum !== undefined ? { normalMinimum: declaration.normalMinimum } : {},
102541
- ...declaration.defaultValue ? {
102542
- defaultValue: declaration.defaultValue.value !== undefined ? { value: declaration.defaultValue.value } : {}
102543
- } : {}
102544
- }))
102545
- } : {}
102546
- };
102547
- }
102548
- function buildQtiTestUpdateInput(test, title) {
102549
- return {
102550
- title,
102551
- ...qtiTestOptionalAttributes(test),
102552
- ...test.metadata ? { metadata: test.metadata } : {},
102553
- ...buildQtiTestStructureInput(test)
102554
- };
102555
- }
102556
- function buildQtiTestCopyInput(source, targetTestIdentifier, metadata2, itemCopies) {
102557
- const copiesBySourceIdentifier = new Map(itemCopies.map((copy) => [copy.sourceIdentifier, copy]));
102558
- return {
102559
- identifier: targetTestIdentifier,
102560
- title: `${source.title} (copy)`,
102561
- ...qtiTestOptionalAttributes(source),
102562
- metadata: metadata2,
102563
- ...buildQtiTestStructureInput(source, targetTestIdentifier, copiesBySourceIdentifier)
102564
- };
102565
- }
102566
- function validateUniqueQuestionIdentifiers(itemIdentifiers) {
102567
- if (itemIdentifiers.length === 0 || new Set(itemIdentifiers).size !== itemIdentifiers.length) {
102568
- throw new ValidationError("Question order must contain unique question identifiers");
103006
+ if (!validation.valid || !validation.manifest) {
103007
+ const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
103008
+ throw new ValidationError(`Diagnostic routing manifest is invalid.${details ? ` ${details}` : ""}`);
102569
103009
  }
103010
+ return { diagnosticKey, routingManifest: validation.manifest };
102570
103011
  }
102571
- function resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers) {
102572
- let selectedPart;
102573
- let selectedSection;
102574
- for (const part of qtiTestParts(test)) {
102575
- for (const section of part["qti-assessment-section"]) {
102576
- if (!itemIdentifier || section["qti-assessment-item-ref"]?.some((item) => item.identifier === itemIdentifier)) {
102577
- selectedPart = part;
102578
- selectedSection = section;
102579
- break;
102580
- }
102581
- }
102582
- if (selectedSection) {
102583
- break;
102584
- }
102585
- }
102586
- if (!selectedPart || !selectedSection) {
102587
- throw new ValidationError(itemIdentifier ? `Question ${itemIdentifier} is not referenced by assessment ${qtiTestIdentifier}` : `Assessment ${qtiTestIdentifier} has no section for questions`);
103012
+ async function preparePublishedDiagnosticDefinition(definition, loaded) {
103013
+ assertAssessmentHasQuestions(loaded.questions.questions);
103014
+ for (const { question } of loaded.questions.questions) {
103015
+ assertPlayableQtiQuestion(question);
102588
103016
  }
102589
- if (expectedItemIdentifiers) {
102590
- const sectionIdentifiers = selectedSection["qti-assessment-item-ref"]?.map((item) => item.identifier) ?? [];
102591
- const expectedIdentifiers = new Set(expectedItemIdentifiers);
102592
- if (expectedIdentifiers.size !== sectionIdentifiers.length || sectionIdentifiers.some((identifier) => !expectedIdentifiers.has(identifier))) {
102593
- throw new ValidationError("Questions can only be reordered within one complete assessment section");
102594
- }
103017
+ const assessment = await buildPlayableAssessment(loaded.test, loaded.questions);
103018
+ const scoring = assessmentFixtureScoringKeys(assessment, loaded.questions);
103019
+ const validation = validateDiagnosticRoutingManifest(definition.routingManifest, diagnosticRoutingCapabilities(assessment, scoring));
103020
+ if (!validation.valid || !validation.manifest) {
103021
+ const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
103022
+ throw new ValidationError(`Diagnostic routing manifest cannot be published.${details ? ` ${details}` : ""}`);
102595
103023
  }
102596
- return {
102597
- partIdentifier: selectedPart.identifier,
102598
- sectionIdentifier: selectedSection.identifier
102599
- };
103024
+ return { diagnosticKey: definition.diagnosticKey, routingManifest: validation.manifest };
102600
103025
  }
102601
- var PLAYCADEMY_QTI_AUTHORING_METADATA_KEY = "playcademyAuthoring", AUTHORING_DOCUMENT_QUESTION_TYPE = "authoring-document", QTI_AUTHORING_FIELDS, QUESTION_XML_BUILDERS;
102602
- var init_timeback_qti_authoring_util = __esm(() => {
102603
- init_qti();
102604
- init_timeback3();
103026
+ var init_timeback_assessment_diagnostic_util = __esm(() => {
103027
+ init_assessment_runtime2();
102605
103028
  init_errors2();
102606
- QTI_AUTHORING_FIELDS = [
102607
- "type",
102608
- "document",
102609
- "qtiVersion",
102610
- "timeDependent",
102611
- "adaptive",
102612
- "preInteraction",
102613
- "interaction",
102614
- "postInteraction",
102615
- "responseDeclarations",
102616
- "outcomeDeclarations",
102617
- "responseProcessing",
102618
- "modalFeedback",
102619
- "feedbackInline",
102620
- "feedbackBlock",
102621
- "rubrics",
102622
- "stimulus",
102623
- "content"
102624
- ];
102625
- QUESTION_XML_BUILDERS = [
102626
- buildNumericQuestionXml,
102627
- buildAuthoringDocumentQuestionXml,
102628
- buildHottextQuestionXml,
102629
- buildInlineChoiceQuestionXml,
102630
- buildMatchQuestionXml
102631
- ];
103029
+ init_timeback_assessment_rules_util();
103030
+ init_timeback_assessment_runtime_util();
103031
+ init_timeback_qti_authoring_util();
102632
103032
  });
102633
103033
 
102634
103034
  // ../api-core/src/utils/timeback-assessment-import.util.ts
@@ -102648,34 +103048,44 @@ function assertAssessmentImportCourseMatches(manifest, integration) {
102648
103048
  function associationMetadataMatches(row, entry) {
102649
103049
  return row.purpose === entry.purpose && (row.standardFramework ?? undefined) === entry.standard?.framework && (row.standardIdentifier ?? undefined) === entry.standard?.identifier;
102650
103050
  }
102651
- function targetStatusSatisfied(row, targetStatus) {
102652
- return row.status === targetStatus || targetStatus === "draft" && row.status === "live";
102653
- }
102654
- function classifyExistingAssessmentImport(row, entry, targetStatus) {
102655
- if (!associationMetadataMatches(row, entry)) {
103051
+ function planAssessmentAssociationImport(entry, existingByKey, existingByIdentifier) {
103052
+ if (!existingByKey) {
103053
+ if (!existingByIdentifier) {
103054
+ return { kind: "insert" };
103055
+ }
103056
+ const existingKey = existingByIdentifier.assessmentKey;
102656
103057
  return {
103058
+ kind: "fail",
102657
103059
  status: "failed",
102658
- message: "Already attached with different purpose or standard metadata."
103060
+ message: existingKey ? `QTI publication is already attached under assessment key ${existingKey}.` : "QTI publication is already attached without a managed assessment key."
102659
103061
  };
102660
103062
  }
102661
- if (targetStatusSatisfied(row, targetStatus)) {
102662
- return { status: "skipped", message: `Already attached as ${row.status}.` };
102663
- }
102664
- if (row.status === "archived") {
103063
+ if (existingByIdentifier && existingByIdentifier.id !== existingByKey.id) {
103064
+ const existingKey = existingByIdentifier.assessmentKey;
102665
103065
  return {
103066
+ kind: "fail",
102666
103067
  status: "failed",
102667
- message: "Already attached as archived and cannot be imported again."
103068
+ message: existingKey ? `QTI publication is already attached under assessment key ${existingKey}.` : "QTI publication is already attached without a managed assessment key."
102668
103069
  };
102669
103070
  }
102670
- if (targetStatus === "live" && row.status === "draft") {
103071
+ if (!associationMetadataMatches(existingByKey, entry)) {
102671
103072
  return {
103073
+ kind: "fail",
102672
103074
  status: "failed",
102673
- message: "Already attached as a draft. Publish it from the assessment editor before retrying."
103075
+ message: "Assessment key is already attached with different purpose or standard metadata."
103076
+ };
103077
+ }
103078
+ if (existingByKey.qtiTestIdentifier === entry.qtiTestIdentifier) {
103079
+ return {
103080
+ kind: "skip",
103081
+ status: "skipped",
103082
+ message: "Assessment key already points to this publication."
102674
103083
  };
102675
103084
  }
102676
103085
  return {
103086
+ kind: "fail",
102677
103087
  status: "failed",
102678
- message: `Already attached as ${row.status}, which does not match the requested ${targetStatus} status.`
103088
+ message: "Assessment key is already attached to a different QTI publication."
102679
103089
  };
102680
103090
  }
102681
103091
  function attachedAssessmentImportMessage(targetStatus, editable) {
@@ -102737,62 +103147,6 @@ function buildQtiLibraryListPlan(params) {
102737
103147
  }
102738
103148
  var PLAYCADEMY_QTI_SOURCE_PREFIX = "playcademy-test-", PLAYCADEMY_QTI_SOURCE_UPPER_BOUND = "playcademy-test.";
102739
103149
 
102740
- // ../api-core/src/utils/timeback-review-mapping.util.ts
102741
- function reviewMappingIssueSummary(label, identifiers) {
102742
- if (identifiers.length === 0) {
102743
- return null;
102744
- }
102745
- const displayed = identifiers.slice(0, 10).join(", ");
102746
- const remainder = identifiers.length > 10 ? `, and ${identifiers.length - 10} more` : "";
102747
- return `${label} (${identifiers.length}): ${displayed}${remainder}`;
102748
- }
102749
- async function prepareReviewMappingUpdate(test, questions) {
102750
- const contentRevision = await assessmentContentRevision(test, questions);
102751
- const assessment = {
102752
- identifier: test.identifier,
102753
- contractVersion: 1,
102754
- contentRevision,
102755
- title: test.title,
102756
- items: questions.questions.map(({ question }) => ({
102757
- identifier: question.identifier,
102758
- title: question.title,
102759
- prompt: "",
102760
- maxScore: 1,
102761
- interactions: []
102762
- }))
102763
- };
102764
- const bank = await buildReviewBankIndex(assessment, questions, { customOnly: true });
102765
- const issues = reviewBankMappingIssues(bank);
102766
- if (issues.missingOrInvalidItemIdentifiers.length > 0 || issues.multipleStandardItemIdentifiers.length > 0) {
102767
- const details = [
102768
- reviewMappingIssueSummary("missing or invalid associations", issues.missingOrInvalidItemIdentifiers),
102769
- reviewMappingIssueSummary("multiple associations", issues.multipleStandardItemIdentifiers)
102770
- ].filter((detail) => detail !== null);
102771
- throw new ValidationError(`Every review question must have exactly one valid standard/node association. ${details.join("; ")}.`);
102772
- }
102773
- const manifest = await buildReviewBankManifest(bank);
102774
- return {
102775
- update: {
102776
- ...buildQtiTestUpdateInput(test, test.title),
102777
- metadata: {
102778
- ...test.metadata,
102779
- [PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: manifest
102780
- }
102781
- },
102782
- summary: {
102783
- itemCount: bank.items.length,
102784
- standardCount: Object.keys(manifest.itemsByStandard).length,
102785
- sourceFingerprint: manifest.sourceFingerprint
102786
- }
102787
- };
102788
- }
102789
- var init_timeback_review_mapping_util = __esm(() => {
102790
- init_assessment_runtime2();
102791
- init_errors2();
102792
- init_timeback_assessment_runtime_util();
102793
- init_timeback_qti_authoring_util();
102794
- });
102795
-
102796
103150
  // ../api-core/src/services/timeback-assessments.service.ts
102797
103151
  function databaseConstraintName(error88) {
102798
103152
  if (typeof error88 !== "object" || error88 === null) {
@@ -102801,15 +103155,6 @@ function databaseConstraintName(error88) {
102801
103155
  const name3 = error88.constraint_name;
102802
103156
  return typeof name3 === "string" ? name3 : undefined;
102803
103157
  }
102804
- function diagnosticRoutingCapabilities(assessment, scoring) {
102805
- return assessment.items.map((item) => ({
102806
- itemIdentifier: item.identifier,
102807
- supportsDeterminateBinaryGrading: item.interactions.length > 0 && item.interactions.every((interaction) => {
102808
- const responseIdentifier = interaction.responseIdentifier;
102809
- return Object.hasOwn(scoring.correctResponses[item.identifier] ?? {}, responseIdentifier) || (scoring.responseAreas?.[item.identifier]?.[responseIdentifier]?.length ?? 0) > 0;
102810
- })
102811
- }));
102812
- }
102813
103158
 
102814
103159
  class TimebackAssessmentsService {
102815
103160
  deps;
@@ -102873,6 +103218,7 @@ class TimebackAssessmentsService {
102873
103218
  metadata: {
102874
103219
  ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
102875
103220
  ownerGameSlug: ownership.gameSlug,
103221
+ assessmentKey: input.assessmentKey,
102876
103222
  integrationId,
102877
103223
  subject: integration.subject,
102878
103224
  grade: String(integration.grade),
@@ -102882,6 +103228,7 @@ class TimebackAssessmentsService {
102882
103228
  try {
102883
103229
  const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
102884
103230
  integrationId,
103231
+ assessmentKey: input.assessmentKey,
102885
103232
  qtiTestIdentifier: input.qtiTestIdentifier,
102886
103233
  purpose: input.purpose,
102887
103234
  status: "draft",
@@ -102938,12 +103285,33 @@ class TimebackAssessmentsService {
102938
103285
  const existingRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
102939
103286
  where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
102940
103287
  });
103288
+ const existingByKey = new Map(existingRows.flatMap((row) => row.assessmentKey ? [[row.assessmentKey, row]] : []));
102941
103289
  const existingByIdentifier = new Map(existingRows.map((row) => [row.qtiTestIdentifier, row]));
102942
- const validated = await runWithConcurrency(entries, QTI_HYDRATION_CONCURRENCY, async (entry) => {
103290
+ const validated = await this.validateAssessmentImportEntries(client2, entries, manifest.targetStatus);
103291
+ const results = [];
103292
+ const pendingInserts = this.planAssessmentImports({
103293
+ validated,
103294
+ existingByKey,
103295
+ existingByIdentifier,
103296
+ gameSlug,
103297
+ results
103298
+ });
103299
+ const liveReviewFailures = liveReviewAssessmentImportFailures(manifest.targetStatus, pendingInserts.map(({ entry }) => entry), existingRows);
103300
+ this.recordLiveReviewImportFailures(pendingInserts, liveReviewFailures, gameSlug, results);
103301
+ const attachablePending = pendingInserts.filter(({ entry }) => !liveReviewFailures.has(entry.qtiTestIdentifier));
103302
+ await this.insertImportedAssessments(integrationId, attachablePending, manifest.targetStatus, gameSlug, results);
103303
+ setAttribute("app.assessment.operation", "bulk_attach_existing");
103304
+ setAttribute("app.assessment.bulk_attach.target_status", manifest.targetStatus);
103305
+ setAttribute("app.assessment.bulk_attach.created", results.filter((result) => result.status === "created").length);
103306
+ return { results };
103307
+ }
103308
+ async validateAssessmentImportEntries(client2, entries, targetStatus) {
103309
+ return runWithConcurrency(entries, QTI_HYDRATION_CONCURRENCY, async (entry) => {
102943
103310
  try {
102944
103311
  const loaded = await loadHydratedQtiTest(client2, entry.qtiTestIdentifier);
103312
+ assertManagedAssessmentIdentity(loaded.test, entry.assessmentKey);
102945
103313
  assertAssessmentHasQuestions(loaded.questions.questions);
102946
- assertPlayableAssessmentImportQuestions(manifest.targetStatus, loaded.questions.questions.map(({ question }) => question));
103314
+ assertPlayableAssessmentImportQuestions(targetStatus, loaded.questions.questions.map(({ question }) => question));
102947
103315
  if (entry.purpose === "review") {
102948
103316
  assertReviewAssessmentHasStandards(loaded.questions.questions.map(({ question }) => reviewRoutingStandardRefsFromMetadata(question.metadata, {
102949
103317
  customOnly: true
@@ -102954,77 +103322,86 @@ class TimebackAssessmentsService {
102954
103322
  return { entry, error: error88 };
102955
103323
  }
102956
103324
  });
102957
- const results = [];
102958
- const pending = [];
102959
- for (const validation of validated) {
103325
+ }
103326
+ associationImportResultBase(entry, test, gameSlug) {
103327
+ return {
103328
+ assessmentKey: entry.assessmentKey,
103329
+ qtiTestIdentifier: entry.qtiTestIdentifier,
103330
+ ...test ? { title: test.title } : {},
103331
+ purpose: entry.purpose,
103332
+ ...entry.standard ? { standard: entry.standard } : {},
103333
+ sourceSortOrder: entry.sortOrder,
103334
+ ...test && gameSlug ? { editable: isQtiTestOwnedByGame(test, gameSlug) } : {}
103335
+ };
103336
+ }
103337
+ planAssessmentImports(input) {
103338
+ const pendingInserts = [];
103339
+ for (const validation of input.validated) {
102960
103340
  const { entry } = validation;
102961
- const { index: index2 } = entry;
102962
- const base = {
102963
- qtiTestIdentifier: entry.qtiTestIdentifier,
102964
- purpose: entry.purpose,
102965
- ...entry.standard ? { standard: entry.standard } : {},
102966
- sourceSortOrder: entry.sortOrder
102967
- };
103341
+ const base = this.associationImportResultBase(entry, "test" in validation ? validation.test : undefined, input.gameSlug);
102968
103342
  if ("error" in validation) {
102969
- results[index2] = {
103343
+ input.results[entry.index] = {
102970
103344
  ...base,
102971
103345
  status: "failed",
102972
103346
  message: `QTI validation failed: ${errorMessage(validation.error)}`
102973
103347
  };
102974
103348
  } else {
102975
- const existing = existingByIdentifier.get(entry.qtiTestIdentifier);
102976
- const editable = isQtiTestOwnedByGame(validation.test, gameSlug);
102977
- const hydratedBase = { ...base, title: validation.test.title, editable };
102978
- if (!existing) {
102979
- pending.push({ entry, test: validation.test });
103349
+ const existingForKey = input.existingByKey.get(entry.assessmentKey);
103350
+ const decision = planAssessmentAssociationImport(entry, existingForKey, input.existingByIdentifier.get(entry.qtiTestIdentifier));
103351
+ if (decision.kind === "insert") {
103352
+ pendingInserts.push({ entry, test: validation.test });
102980
103353
  } else {
102981
- const decision = classifyExistingAssessmentImport(existing, {
102982
- purpose: entry.purpose,
102983
- ...entry.standard ? { standard: entry.standard } : {}
102984
- }, manifest.targetStatus);
102985
- results[index2] = {
102986
- ...hydratedBase,
103354
+ input.results[entry.index] = {
103355
+ ...base,
102987
103356
  status: decision.status,
102988
- association: this.associationImportSummary(existing),
103357
+ ...existingForKey ? { association: this.associationImportSummary(existingForKey) } : {},
102989
103358
  message: decision.message
102990
103359
  };
102991
103360
  }
102992
103361
  }
102993
103362
  }
102994
- const liveReviewFailures = liveReviewAssessmentImportFailures(manifest.targetStatus, pending.map(({ entry }) => entry), existingRows);
103363
+ return pendingInserts;
103364
+ }
103365
+ recordLiveReviewImportFailures(pending, failures, gameSlug, results) {
102995
103366
  for (const { entry, test } of pending) {
102996
- const message = liveReviewFailures.get(entry.qtiTestIdentifier);
103367
+ const message = failures.get(entry.qtiTestIdentifier);
102997
103368
  if (message) {
102998
103369
  results[entry.index] = {
102999
- qtiTestIdentifier: entry.qtiTestIdentifier,
103000
- title: test.title,
103001
- purpose: entry.purpose,
103002
- ...entry.standard ? { standard: entry.standard } : {},
103003
- sourceSortOrder: entry.sortOrder,
103004
- editable: isQtiTestOwnedByGame(test, gameSlug),
103370
+ ...this.associationImportResultBase(entry, test, gameSlug),
103005
103371
  status: "failed",
103006
103372
  message
103007
103373
  };
103008
103374
  }
103009
103375
  }
103010
- const attachablePending = pending.filter(({ entry }) => !liveReviewFailures.has(entry.qtiTestIdentifier));
103011
- for (const validation of attachablePending) {
103012
- const { entry, test } = validation;
103013
- const { index: index2 } = entry;
103014
- const base = {
103015
- qtiTestIdentifier: entry.qtiTestIdentifier,
103016
- title: test.title,
103017
- purpose: entry.purpose,
103018
- ...entry.standard ? { standard: entry.standard } : {},
103019
- sourceSortOrder: entry.sortOrder,
103020
- editable: isQtiTestOwnedByGame(test, gameSlug)
103376
+ }
103377
+ async concurrentAssessmentImportDecision(integrationId, entry) {
103378
+ try {
103379
+ const [byKey, byIdentifier] = await Promise.all([
103380
+ this.deps.db.query.gameTimebackAssessmentTests.findFirst({
103381
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.assessmentKey, entry.assessmentKey))
103382
+ }),
103383
+ this.deps.db.query.gameTimebackAssessmentTests.findFirst({
103384
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, entry.qtiTestIdentifier))
103385
+ })
103386
+ ]);
103387
+ return {
103388
+ byKey,
103389
+ decision: planAssessmentAssociationImport(entry, byKey, byIdentifier)
103021
103390
  };
103391
+ } catch {
103392
+ return null;
103393
+ }
103394
+ }
103395
+ async insertImportedAssessments(integrationId, pending, targetStatus, gameSlug, results) {
103396
+ for (const { entry, test } of pending) {
103397
+ const base = this.associationImportResultBase(entry, test, gameSlug);
103022
103398
  try {
103023
103399
  const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
103024
103400
  integrationId,
103401
+ assessmentKey: entry.assessmentKey,
103025
103402
  qtiTestIdentifier: entry.qtiTestIdentifier,
103026
103403
  purpose: entry.purpose,
103027
- status: manifest.targetStatus,
103404
+ status: targetStatus,
103028
103405
  sortOrder: entry.sortOrder,
103029
103406
  standardFramework: entry.standard?.framework,
103030
103407
  standardIdentifier: entry.standard?.identifier
@@ -103032,51 +103409,37 @@ class TimebackAssessmentsService {
103032
103409
  if (!row) {
103033
103410
  throw new Error("Assessment association create returned no row");
103034
103411
  }
103035
- results[index2] = {
103412
+ results[entry.index] = {
103036
103413
  ...base,
103037
103414
  status: "created",
103038
103415
  association: this.associationImportSummary(row),
103039
- message: attachedAssessmentImportMessage(manifest.targetStatus, base.editable)
103416
+ message: attachedAssessmentImportMessage(targetStatus, base.editable ?? false)
103040
103417
  };
103041
103418
  } catch (error88) {
103042
- results[index2] = {
103419
+ results[entry.index] = {
103043
103420
  ...base,
103044
103421
  status: "failed",
103045
103422
  message: `Association attach failed: ${errorMessage(error88)}`
103046
103423
  };
103047
103424
  if (isUniqueViolation(error88)) {
103048
- let concurrentAssociation;
103049
- try {
103050
- concurrentAssociation = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
103051
- where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, entry.qtiTestIdentifier))
103052
- });
103053
- } catch {}
103054
- if (concurrentAssociation) {
103055
- const decision = classifyExistingAssessmentImport(concurrentAssociation, {
103056
- purpose: entry.purpose,
103057
- ...entry.standard ? { standard: entry.standard } : {}
103058
- }, manifest.targetStatus);
103059
- results[index2] = {
103425
+ const concurrent = await this.concurrentAssessmentImportDecision(integrationId, entry);
103426
+ if (concurrent?.decision.kind === "skip" || concurrent?.decision.kind === "fail") {
103427
+ results[entry.index] = {
103060
103428
  ...base,
103061
- status: decision.status,
103062
- association: this.associationImportSummary(concurrentAssociation),
103063
- message: decision.message
103429
+ status: concurrent.decision.status,
103430
+ ...concurrent.byKey ? { association: this.associationImportSummary(concurrent.byKey) } : {},
103431
+ message: concurrent.decision.message
103064
103432
  };
103065
103433
  }
103066
103434
  }
103067
103435
  }
103068
103436
  }
103069
- setAttribute("app.assessment.operation", "bulk_attach_existing");
103070
- setAttribute("app.assessment.bulk_attach.target_status", manifest.targetStatus);
103071
- setAttribute("app.assessment.bulk_attach.created", results.filter((result) => result.status === "created").length);
103072
- return { results };
103073
103437
  }
103074
103438
  async updateAssessment(integrationId, qtiTestIdentifier, input) {
103075
103439
  try {
103076
103440
  return await this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
103077
103441
  const nextPurpose = input.purpose ?? row.purpose;
103078
- assertMasteryPurposeChangeDraft(row, nextPurpose);
103079
- assertDiagnosticPurposeChangeDraft(row, nextPurpose);
103442
+ assertPurposeChangeDraft(row, nextPurpose);
103080
103443
  const requestedStandard = input.standard ? this.canonicalAssessmentStandard(input.standard) : undefined;
103081
103444
  const nextStandard = requestedStandard ?? (nextPurpose === "mastery" ? assessmentStandardForRow(row) : null);
103082
103445
  this.assertPurposeStandard(nextPurpose, nextStandard);
@@ -103101,7 +103464,10 @@ class TimebackAssessmentsService {
103101
103464
  }
103102
103465
  if (!publishing && !activatingReview && nextPurpose === "review" && row.purpose !== "review") {
103103
103466
  const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
103104
- await this.rebuildReviewMapping(this.requireClient(), qtiTestIdentifier, ownership.gameSlug);
103467
+ await this.rebuildReviewMapping(this.requireClient(), qtiTestIdentifier, {
103468
+ kind: "owned",
103469
+ gameSlug: ownership.gameSlug
103470
+ });
103105
103471
  }
103106
103472
  if (input.title !== undefined) {
103107
103473
  assertDraftAssessment(row);
@@ -103113,16 +103479,24 @@ class TimebackAssessmentsService {
103113
103479
  await client2.qtiApi.assessmentTests.update(qtiTestIdentifier, buildQtiTestUpdateInput(test, input.title));
103114
103480
  }
103115
103481
  if (publishing || activatingReview) {
103116
- if (nextPurpose === "diagnostic") {
103117
- if (!nextDiagnostic) {
103118
- throw new ValidationError("Platform-routed diagnostics require a diagnostic key and routing manifest before publication.");
103119
- }
103120
- nextDiagnostic = await this.preparePublishedDiagnosticDefinition(qtiTestIdentifier, nextDiagnostic);
103482
+ if (nextPurpose === "diagnostic" && !nextDiagnostic) {
103483
+ throw new ValidationError("Platform-routed diagnostics require a diagnostic key and routing manifest before publication.");
103484
+ }
103485
+ const loaded = await loadHydratedQtiTest(this.requireClient(), qtiTestIdentifier);
103486
+ if (nextPurpose === "diagnostic" && nextDiagnostic) {
103487
+ nextDiagnostic = await preparePublishedDiagnosticDefinition(nextDiagnostic, loaded);
103121
103488
  updates.diagnosticKey = nextDiagnostic.diagnosticKey;
103122
103489
  updates.diagnosticRoutingManifest = nextDiagnostic.routingManifest;
103123
103490
  } else {
103124
103491
  const reviewOwnership = nextPurpose === "review" && nextStatus === "live" ? await this.requireQtiTestOwnershipContext(integrationId, tx) : undefined;
103125
- await this.validateAssessmentHasQuestions(qtiTestIdentifier, reviewOwnership?.gameSlug);
103492
+ await this.validateAssessmentHasQuestions(loaded, reviewOwnership?.gameSlug);
103493
+ }
103494
+ if (publishing) {
103495
+ const publication = await this.publishManagedAssessment(row, nextPurpose, nextDiagnostic?.routingManifest ?? null, loaded);
103496
+ updates.qtiTestIdentifier = publication.qtiTestIdentifier;
103497
+ if (nextPurpose === "diagnostic") {
103498
+ updates.diagnosticRoutingManifest = publication.diagnosticRoutingManifest;
103499
+ }
103126
103500
  }
103127
103501
  }
103128
103502
  let updated = row;
@@ -103218,13 +103592,14 @@ class TimebackAssessmentsService {
103218
103592
  return { ...result, questions };
103219
103593
  }
103220
103594
  async updateReviewMapping(integrationId, qtiTestIdentifier) {
103221
- return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
103595
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (_row, _tx, associations) => {
103222
103596
  if (!associations.some((association) => association.purpose === "review")) {
103223
103597
  throw new ValidationError("Review mapping is available only for standards-review assessments.");
103224
103598
  }
103225
103599
  const client2 = this.requireClient();
103226
- const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
103227
- const result = await this.rebuildReviewMapping(client2, qtiTestIdentifier, ownership.gameSlug);
103600
+ const result = await this.rebuildReviewMapping(client2, qtiTestIdentifier, {
103601
+ kind: "live-review-repair"
103602
+ });
103228
103603
  setAttribute("app.assessment.operation", "update_review_mapping");
103229
103604
  setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
103230
103605
  return result;
@@ -103240,7 +103615,14 @@ class TimebackAssessmentsService {
103240
103615
  await this.requireIntegration(integrationId);
103241
103616
  return this.listQtiLibrary(params, async (listParams) => await client2.qtiApi.assessmentTests.list(listParams));
103242
103617
  }
103243
- async copyAssessment(integrationId, sourceTestIdentifier, targetTestIdentifier, purpose, standardInput) {
103618
+ async copyAssessment(integrationId, input) {
103619
+ const {
103620
+ sourceTestIdentifier,
103621
+ targetTestIdentifier,
103622
+ assessmentKey,
103623
+ purpose,
103624
+ standard: standardInput
103625
+ } = input;
103244
103626
  if (purpose === "diagnostic") {
103245
103627
  throw new ValidationError("Adaptive diagnostics must be created by importing assessment JSON with a routing sidecar.");
103246
103628
  }
@@ -103267,6 +103649,7 @@ class TimebackAssessmentsService {
103267
103649
  ...source.metadata,
103268
103650
  ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
103269
103651
  ownerGameSlug: ownership.gameSlug,
103652
+ assessmentKey,
103270
103653
  integrationId,
103271
103654
  subject: integration.subject,
103272
103655
  grade: String(integration.grade),
@@ -103289,6 +103672,7 @@ class TimebackAssessmentsService {
103289
103672
  associationCreationAttempted = true;
103290
103673
  const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
103291
103674
  integrationId,
103675
+ assessmentKey,
103292
103676
  qtiTestIdentifier: targetTestIdentifier,
103293
103677
  purpose,
103294
103678
  status: "draft",
@@ -103583,6 +103967,50 @@ class TimebackAssessmentsService {
103583
103967
  qtiItemHref(client2, itemIdentifier) {
103584
103968
  return `${client2.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
103585
103969
  }
103970
+ async ensureImmutableQtiResource(get, create) {
103971
+ try {
103972
+ return await get();
103973
+ } catch (error88) {
103974
+ if (!isApiError(error88) || error88.statusCode !== 404) {
103975
+ throw error88;
103976
+ }
103977
+ }
103978
+ try {
103979
+ await create();
103980
+ } catch (error88) {
103981
+ if (!isApiError(error88) || error88.statusCode !== 409) {
103982
+ throw error88;
103983
+ }
103984
+ }
103985
+ return get();
103986
+ }
103987
+ async ensureManagedQtiPublication(client2, plan) {
103988
+ await runWithConcurrency(plan.items, QTI_HYDRATION_CONCURRENCY, async (plannedItem) => {
103989
+ const existing = await this.ensureImmutableQtiResource(async () => await client2.qtiApi.assessmentItems.get(plannedItem.qtiItemIdentifier), () => client2.course.createAssessmentItemXml({
103990
+ xml: plannedItem.xml,
103991
+ metadata: plannedItem.metadata
103992
+ }));
103993
+ await assertManagedAssessmentItem(existing, plannedItem);
103994
+ });
103995
+ const existingTest = await this.ensureImmutableQtiResource(async () => await client2.qtiApi.assessmentTests.get(plan.qtiTestIdentifier), () => client2.qtiApi.assessmentTests.create(plan.testInput));
103996
+ assertManagedAssessmentPublication(existingTest, plan);
103997
+ }
103998
+ async publishManagedAssessment(row, purpose, diagnosticRoutingManifest, loaded) {
103999
+ if (!row.assessmentKey) {
104000
+ throw new ValidationError("This unmanaged assessment cannot be published. Republish it with an explicit assessment key.");
104001
+ }
104002
+ const client2 = this.requireClient();
104003
+ const plan = await prepareManagedAssessmentPublication({
104004
+ assessmentKey: row.assessmentKey,
104005
+ purpose,
104006
+ test: loaded.test,
104007
+ questions: loaded.questions,
104008
+ diagnosticRoutingManifest,
104009
+ itemHref: (identifier) => this.qtiItemHref(client2, identifier)
104010
+ });
104011
+ await this.ensureManagedQtiPublication(client2, plan);
104012
+ return plan;
104013
+ }
103586
104014
  async cleanupQtiAssessmentCopy(client2, testIdentifier, itemIdentifiers) {
103587
104015
  let testDeleted = !testIdentifier;
103588
104016
  if (testIdentifier) {
@@ -103618,6 +104046,7 @@ class TimebackAssessmentsService {
103618
104046
  return {
103619
104047
  id: row.id,
103620
104048
  integrationId: row.integrationId,
104049
+ assessmentKey: row.assessmentKey,
103621
104050
  qtiTestIdentifier: row.qtiTestIdentifier,
103622
104051
  purpose: row.purpose,
103623
104052
  status: row.status,
@@ -103659,20 +104088,14 @@ class TimebackAssessmentsService {
103659
104088
  const plan = buildQtiLibraryListPlan(params);
103660
104089
  return list(plan.params);
103661
104090
  }
103662
- async validateAssessmentHasQuestions(qtiTestIdentifier, reviewGameSlug) {
103663
- const client2 = this.requireClient();
103664
- const [test, result] = await Promise.all([
103665
- client2.qtiApi.assessmentTests.get(qtiTestIdentifier),
103666
- client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier)
103667
- ]);
103668
- assertAssessmentHasQuestions(result.questions);
103669
- const hydrated = await hydrateQtiTestQuestions(client2, result);
103670
- for (const { question } of hydrated.questions) {
104091
+ async validateAssessmentHasQuestions(loaded, reviewGameSlug) {
104092
+ assertAssessmentHasQuestions(loaded.questions.questions);
104093
+ for (const { question } of loaded.questions.questions) {
103671
104094
  assertPlayableQtiQuestion(question);
103672
104095
  }
103673
104096
  if (reviewGameSlug) {
103674
- assertQtiTestOwnedByGame(test, reviewGameSlug);
103675
- await this.writeReviewMapping(client2, test, hydrated);
104097
+ assertQtiTestOwnedByGame(loaded.test, reviewGameSlug);
104098
+ await this.writeReviewMapping(this.requireClient(), loaded.test, loaded.questions);
103676
104099
  }
103677
104100
  }
103678
104101
  async prepareDiagnosticAssociationChanges(row, nextPurpose, requested) {
@@ -103689,7 +104112,7 @@ class TimebackAssessmentsService {
103689
104112
  if (requested === null || nextPurpose !== "diagnostic") {
103690
104113
  nextDiagnostic = null;
103691
104114
  } else if (requested) {
103692
- nextDiagnostic = await this.prepareDiagnosticDefinition(requested);
104115
+ nextDiagnostic = prepareDiagnosticDefinition(requested);
103693
104116
  }
103694
104117
  if (requested === undefined && nextPurpose === row.purpose) {
103695
104118
  return { nextDiagnostic, updates: {} };
@@ -103702,47 +104125,14 @@ class TimebackAssessmentsService {
103702
104125
  }
103703
104126
  };
103704
104127
  }
103705
- prepareDiagnosticDefinition(input) {
103706
- const diagnosticKey = input.diagnosticKey.trim();
103707
- if (!diagnosticKey || diagnosticKey.length > 200) {
103708
- throw new ValidationError("Diagnostic key must contain between 1 and 200 characters");
103709
- }
103710
- const validation = validateDiagnosticRoutingManifest(input.routingManifest, undefined, {
103711
- analyzeBounds: false
103712
- });
103713
- if (!validation.valid || !validation.manifest) {
103714
- const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
103715
- throw new ValidationError(`Diagnostic routing manifest is invalid.${details ? ` ${details}` : ""}`);
103716
- }
103717
- return {
103718
- diagnosticKey,
103719
- routingManifest: validation.manifest
103720
- };
103721
- }
103722
- async preparePublishedDiagnosticDefinition(qtiTestIdentifier, definition) {
103723
- const loaded = await loadHydratedQtiTest(this.requireClient(), qtiTestIdentifier);
103724
- assertAssessmentHasQuestions(loaded.questions.questions);
103725
- for (const { question } of loaded.questions.questions) {
103726
- assertPlayableQtiQuestion(question);
103727
- }
103728
- const assessment = await buildPlayableAssessment(loaded.test, loaded.questions);
103729
- const scoring = assessmentFixtureScoringKeys(assessment, loaded.questions);
103730
- const validation = validateDiagnosticRoutingManifest(definition.routingManifest, diagnosticRoutingCapabilities(assessment, scoring));
103731
- if (!validation.valid || !validation.manifest) {
103732
- const details = validation.errors.slice(0, 3).map((issue7) => issue7.message).join(" ");
103733
- throw new ValidationError(`Diagnostic routing manifest cannot be published.${details ? ` ${details}` : ""}`);
103734
- }
103735
- return {
103736
- diagnosticKey: definition.diagnosticKey,
103737
- routingManifest: validation.manifest
103738
- };
103739
- }
103740
- async rebuildReviewMapping(client2, qtiTestIdentifier, gameSlug) {
104128
+ async rebuildReviewMapping(client2, qtiTestIdentifier, scope) {
103741
104129
  const [test, references] = await Promise.all([
103742
104130
  client2.qtiApi.assessmentTests.get(qtiTestIdentifier),
103743
104131
  client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier)
103744
104132
  ]);
103745
- assertQtiTestOwnedByGame(test, gameSlug);
104133
+ if (scope.kind === "owned") {
104134
+ assertQtiTestOwnedByGame(test, scope.gameSlug);
104135
+ }
103746
104136
  return this.writeReviewMapping(client2, test, await hydrateQtiTestQuestions(client2, references));
103747
104137
  }
103748
104138
  async writeReviewMapping(client2, test, questions) {
@@ -103759,9 +104149,10 @@ var init_timeback_assessments_service = __esm(async () => {
103759
104149
  init_assessment_runtime2();
103760
104150
  init_timeback3();
103761
104151
  init_errors2();
104152
+ init_timeback_assessment_diagnostic_util();
103762
104153
  init_timeback_assessment_import_util();
104154
+ init_timeback_assessment_publication_util();
103763
104155
  init_timeback_assessment_rules_util();
103764
- init_timeback_assessment_runtime_util();
103765
104156
  init_timeback_qti_authoring_util();
103766
104157
  init_timeback_qti_hydration_util();
103767
104158
  init_timeback_review_mapping_util();
@@ -164421,6 +164812,7 @@ var init_timeback_controller = __esm(() => {
164421
164812
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
164422
164813
  const qtiTestIdentifier = newPlaycademyTestIdentifier();
164423
164814
  return ctx.services.timebackAssessments.createAssessment(integrationId, {
164815
+ assessmentKey: body2.assessmentKey,
164424
164816
  title: body2.title,
164425
164817
  purpose: body2.purpose,
164426
164818
  standard: body2.standard,
@@ -164515,7 +164907,13 @@ var init_timeback_controller = __esm(() => {
164515
164907
  const body2 = await parseRequestBody(ctx.request, CopyAssessmentRequestSchema);
164516
164908
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
164517
164909
  const targetTestIdentifier = newPlaycademyTestIdentifier();
164518
- return ctx.services.timebackAssessments.copyAssessment(integrationId, body2.testIdentifier, targetTestIdentifier, body2.purpose, body2.standard);
164910
+ return ctx.services.timebackAssessments.copyAssessment(integrationId, {
164911
+ sourceTestIdentifier: body2.testIdentifier,
164912
+ targetTestIdentifier,
164913
+ assessmentKey: body2.assessmentKey,
164914
+ purpose: body2.purpose,
164915
+ standard: body2.standard
164916
+ });
164519
164917
  });
164520
164918
  createQuestion = requireDeveloper(async (ctx) => {
164521
164919
  const { gameId, courseId, testIdentifier } = ctx.params;