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