@playcademy/sandbox 0.6.1-beta.8 → 0.6.1-beta.9

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 +1656 -566
  2. package/dist/server.js +1656 -566
  3. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -1083,7 +1083,7 @@ var package_default;
1083
1083
  var init_package = __esm(() => {
1084
1084
  package_default = {
1085
1085
  name: "@playcademy/sandbox",
1086
- version: "0.6.1-beta.8",
1086
+ version: "0.6.1-beta.9",
1087
1087
  description: "Local development server for Playcademy game development",
1088
1088
  type: "module",
1089
1089
  exports: {
@@ -11586,7 +11586,7 @@ var init_table6 = __esm(() => {
11586
11586
  });
11587
11587
 
11588
11588
  // ../data/src/domains/timeback/table.ts
11589
- var gameTimebackIntegrationStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications, gameTimebackActivityCompletions;
11589
+ var gameTimebackIntegrationStatusEnum, gameTimebackAssessmentPurposeEnum, gameTimebackAssessmentStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications, gameTimebackActivityCompletions;
11590
11590
  var init_table7 = __esm(() => {
11591
11591
  init_drizzle_orm();
11592
11592
  init_pg_core();
@@ -11596,6 +11596,15 @@ var init_table7 = __esm(() => {
11596
11596
  "active",
11597
11597
  "deactivated"
11598
11598
  ]);
11599
+ gameTimebackAssessmentPurposeEnum = pgEnum("game_timeback_assessment_purpose", [
11600
+ "end_of_course",
11601
+ "diagnostic"
11602
+ ]);
11603
+ gameTimebackAssessmentStatusEnum = pgEnum("game_timeback_assessment_status", [
11604
+ "draft",
11605
+ "live",
11606
+ "archived"
11607
+ ]);
11599
11608
  gameTimebackIntegrations = pgTable("game_timeback_integrations", {
11600
11609
  id: uuid("id").primaryKey().defaultRandom(),
11601
11610
  gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
@@ -11616,10 +11625,11 @@ var init_table7 = __esm(() => {
11616
11625
  id: uuid("id").primaryKey().defaultRandom(),
11617
11626
  integrationId: uuid("integration_id").notNull().references(() => gameTimebackIntegrations.id, { onDelete: "cascade" }),
11618
11627
  qtiTestIdentifier: text("qti_test_identifier").notNull(),
11619
- bankResourceId: text("bank_resource_id"),
11620
- bankActive: boolean("bank_active").notNull().default(false),
11621
- sortOrder: integer("sort_order").notNull().default(0),
11622
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
11628
+ purpose: gameTimebackAssessmentPurposeEnum("purpose").notNull().default("end_of_course"),
11629
+ status: gameTimebackAssessmentStatusEnum("status").notNull().default("draft"),
11630
+ sortOrder: integer("sort_order"),
11631
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
11632
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
11623
11633
  }, (table3) => [
11624
11634
  uniqueIndex("game_timeback_assessment_tests_integration_qti_idx").on(table3.integrationId, table3.qtiTestIdentifier)
11625
11635
  ]);
@@ -11671,6 +11681,8 @@ __export(exports_tables_index, {
11671
11681
  gameTimebackIntegrations: () => gameTimebackIntegrations,
11672
11682
  gameTimebackIntegrationStatusEnum: () => gameTimebackIntegrationStatusEnum,
11673
11683
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
11684
+ gameTimebackAssessmentStatusEnum: () => gameTimebackAssessmentStatusEnum,
11685
+ gameTimebackAssessmentPurposeEnum: () => gameTimebackAssessmentPurposeEnum,
11674
11686
  gameTimebackActivityCompletions: () => gameTimebackActivityCompletions,
11675
11687
  gameScoresRelations: () => gameScoresRelations,
11676
11688
  gameScores: () => gameScores,
@@ -30100,6 +30112,25 @@ function sleep(ms) {
30100
30112
  }
30101
30113
  return new Promise((resolve) => setTimeout(resolve, ms));
30102
30114
  }
30115
+ async function runWithConcurrency(items, concurrency, worker) {
30116
+ if (items.length === 0) {
30117
+ return [];
30118
+ }
30119
+ const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
30120
+ const results = Array.from({ length: items.length });
30121
+ let nextIndex = 0;
30122
+ await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
30123
+ while (true) {
30124
+ const currentIndex = nextIndex;
30125
+ nextIndex++;
30126
+ if (currentIndex >= items.length) {
30127
+ return;
30128
+ }
30129
+ results[currentIndex] = await worker(items[currentIndex]);
30130
+ }
30131
+ }));
30132
+ return results;
30133
+ }
30103
30134
 
30104
30135
  // ../timeback/dist/types.js
30105
30136
  function isObject(value) {
@@ -31634,7 +31665,7 @@ class KVBackupService {
31634
31665
  "app.kv_backup.dry_run": dryRun,
31635
31666
  "app.kv_backup.game_slug": options.gameSlug
31636
31667
  });
31637
- const results = await KVBackupService.runWithConcurrency(targets, namespaceConcurrency, (target) => this.backupNamespace(target, {
31668
+ const results = await runWithConcurrency(targets, namespaceConcurrency, (target) => this.backupNamespace(target, {
31638
31669
  bucketName,
31639
31670
  stage,
31640
31671
  runId,
@@ -31700,7 +31731,7 @@ class KVBackupService {
31700
31731
  }
31701
31732
  static async fetchNamespaceEntries(cloudflare2, namespaceId, keyConcurrency = DEFAULT_KEY_CONCURRENCY) {
31702
31733
  const keys = await KVBackupService.withRetries(`List KV keys for namespace ${namespaceId}`, () => cloudflare2.kv.listKeys(namespaceId));
31703
- return KVBackupService.runWithConcurrency(keys, keyConcurrency, async (key) => {
31734
+ return runWithConcurrency(keys, keyConcurrency, async (key) => {
31704
31735
  const safeLabel = KVBackupService.redactKeyForLog(key.name);
31705
31736
  const value = await KVBackupService.withRetries(`Fetch KV value ${safeLabel}`, () => cloudflare2.kv.getValue(namespaceId, key.name));
31706
31737
  const metadata2 = KVBackupService.parseMetadata(key.metadata);
@@ -31877,25 +31908,6 @@ class KVBackupService {
31877
31908
  }
31878
31909
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
31879
31910
  }
31880
- static async runWithConcurrency(items, concurrency, worker) {
31881
- if (items.length === 0) {
31882
- return [];
31883
- }
31884
- const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
31885
- const results = Array.from({ length: items.length });
31886
- let nextIndex = 0;
31887
- await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
31888
- while (true) {
31889
- const currentIndex = nextIndex;
31890
- nextIndex++;
31891
- if (currentIndex >= items.length) {
31892
- return;
31893
- }
31894
- results[currentIndex] = await worker(items[currentIndex]);
31895
- }
31896
- }));
31897
- return results;
31898
- }
31899
31911
  }
31900
31912
  var BACKUP_SCHEMA_VERSION = 1, DEFAULT_NAMESPACE_CONCURRENCY = 2, DEFAULT_KEY_CONCURRENCY = 10, DEFAULT_RETRY_ATTEMPTS = 3, RETRY_BASE_DELAY_MS = 500;
31901
31913
  var init_kv_backup_service = __esm(() => {
@@ -34202,9 +34214,8 @@ function isValidAdminAttributionDate(value) {
34202
34214
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
34203
34215
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
34204
34216
  }
34205
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS2, 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, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
34217
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS2, 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, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
34206
34218
  var init_schemas4 = __esm(() => {
34207
- init_drizzle_zod();
34208
34219
  init_esm();
34209
34220
  init_table7();
34210
34221
  TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
@@ -34457,15 +34468,26 @@ var init_schemas4 = __esm(() => {
34457
34468
  runId: exports_external.string().uuid(),
34458
34469
  activityId: exports_external.string().min(1).optional()
34459
34470
  });
34460
- InsertAssessmentTestSchema = createInsertSchema(gameTimebackAssessmentTests).omit({
34461
- id: true,
34462
- createdAt: true
34463
- });
34471
+ AssessmentPurposeSchema = exports_external.enum(gameTimebackAssessmentPurposeEnum.enumValues);
34472
+ AssessmentStatusSchema = exports_external.enum(gameTimebackAssessmentStatusEnum.enumValues);
34464
34473
  CreateAssessmentRequestSchema = exports_external.object({
34465
- title: exports_external.string().min(1, "Assessment title is required")
34474
+ title: exports_external.string().min(1, "Assessment title is required"),
34475
+ purpose: AssessmentPurposeSchema
34476
+ });
34477
+ UpdateAssessmentRequestSchema = exports_external.object({
34478
+ title: exports_external.string().trim().min(1, "Assessment title is required").optional(),
34479
+ purpose: AssessmentPurposeSchema.optional(),
34480
+ status: AssessmentStatusSchema.optional()
34481
+ }).refine((input) => input.title !== undefined || input.purpose !== undefined || input.status !== undefined, {
34482
+ message: "Title, purpose, or status is required"
34483
+ });
34484
+ CopyAssessmentRequestSchema = exports_external.object({
34485
+ testIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
34486
+ purpose: AssessmentPurposeSchema
34466
34487
  });
34467
34488
  ReorderAssessmentsRequestSchema = exports_external.object({
34468
- identifiers: exports_external.array(exports_external.string().min(1)).min(1, "At least one identifier is required")
34489
+ purpose: AssessmentPurposeSchema,
34490
+ testIdentifiers: exports_external.array(exports_external.string().trim().min(1, "Assessment identifier is required")).min(1, "At least one assessment is required")
34469
34491
  });
34470
34492
  ReorderQuestionsRequestSchema = exports_external.object({
34471
34493
  items: exports_external.array(exports_external.object({
@@ -53964,10 +53986,10 @@ var init_dist2 = __esm(async () => {
53964
53986
  };
53965
53987
  });
53966
53988
 
53967
- // ../../node_modules/.bun/@timeback+qti@0.3.1+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-6jf1natv.js
53989
+ // ../../node_modules/.bun/@timeback+qti@0.4.1-beta.20260717234944+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-6jf1natv.js
53968
53990
  var init_chunk_6jf1natv = () => {};
53969
53991
 
53970
- // ../../node_modules/.bun/@timeback+qti@0.3.1+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-dd0pbbq3.js
53992
+ // ../../node_modules/.bun/@timeback+qti@0.4.1-beta.20260717234944+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-dd0pbbq3.js
53971
53993
  var exports_chunk_dd0pbbq3 = {};
53972
53994
  __export(exports_chunk_dd0pbbq3, {
53973
53995
  types: () => types6,
@@ -54378,7 +54400,7 @@ var init_chunk_dd0pbbq3 = __esm(() => {
54378
54400
  util_default2 = { TextEncoder: TextEncoder3, TextDecoder: TextDecoder3, promisify: promisify2, log: log9, inherits: inherits2, _extend: _extend2, callbackifyOnRejected: callbackifyOnRejected2, callbackify: callbackify2 };
54379
54401
  });
54380
54402
 
54381
- // ../../node_modules/.bun/@timeback+qti@0.3.1+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-m54fefpq.js
54403
+ // ../../node_modules/.bun/@timeback+qti@0.4.1-beta.20260717234944+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-pt7w63g2.js
54382
54404
  function createInputValidationError2(message, issues) {
54383
54405
  return new InputValidationError2(message, issues);
54384
54406
  }
@@ -55429,7 +55451,7 @@ var ApiError3, UnauthorizedError3, ForbiddenError2, NotFoundError3, ValidationEr
55429
55451
  console[method](label, style);
55430
55452
  }
55431
55453
  }, LOG_LEVELS2, GLOBAL_LOGGING_CONFIG_KEY2, BEYONDAI_PATHS2, LEARNWITHAI_PATHS2, PLATFORM_PATHS2, DEFAULT_PROVIDER_REGISTRY2, MODE_TRANSPORT2, MODE_PROVIDER2, MODE_ENV_CONFIG2, MODE_EXPLICIT_CONFIG2, MODE_ENV_FALLBACK_PLATFORM2, MODE_ENV_FALLBACK_EXPLICIT2, MODE_TOKEN_PROVIDER2, MODES2, MAX_RETRIES2 = 3, RETRY_STATUS_CODES2, INITIAL_RETRY_DELAY_MS2 = 1000, DEFAULT_LIMIT2 = 100, DEFAULT_MAX_ITEMS2 = 1e4, Paginator6;
55432
- var init_chunk_m54fefpq = __esm(async () => {
55454
+ var init_chunk_pt7w63g2 = __esm(async () => {
55433
55455
  init_chunk_6jf1natv();
55434
55456
  ApiError3 = class ApiError3 extends Error {
55435
55457
  statusCode;
@@ -70575,15 +70597,14 @@ var init_v42 = __esm(() => {
70575
70597
  init_classic2();
70576
70598
  });
70577
70599
 
70578
- // ../../node_modules/.bun/@timeback+qti@0.3.1+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/index.js
70600
+ // ../../node_modules/.bun/@timeback+qti@0.4.1-beta.20260717234944+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/index.js
70579
70601
  function resolveToProvider23(config4, registry3 = DEFAULT_PROVIDER_REGISTRY2) {
70580
70602
  return resolveToProvider10(config4, QTI_ENV_VARS2, registry3);
70581
70603
  }
70582
70604
  function translateParams2(params) {
70583
70605
  const {
70584
70606
  orderBy,
70585
- filter: _,
70586
- fields: __,
70607
+ fields: _,
70587
70608
  ...rest
70588
70609
  } = params;
70589
70610
  return {
@@ -70591,6 +70612,23 @@ function translateParams2(params) {
70591
70612
  order: orderBy
70592
70613
  };
70593
70614
  }
70615
+ function buildListQueryParams(params) {
70616
+ const queryParams = {};
70617
+ const filter = params.where ? whereToFilter2(params.where) : undefined;
70618
+ if (filter !== undefined)
70619
+ queryParams.filter = filter;
70620
+ if (params.query !== undefined)
70621
+ queryParams.query = params.query;
70622
+ if (params.page !== undefined)
70623
+ queryParams.page = params.page;
70624
+ if (params.limit !== undefined)
70625
+ queryParams.limit = params.limit;
70626
+ if (params.sort)
70627
+ queryParams.sort = params.sort;
70628
+ if (params.order)
70629
+ queryParams.order = params.order;
70630
+ return queryParams;
70631
+ }
70594
70632
 
70595
70633
  class AssessmentItemsResource2 {
70596
70634
  transport;
@@ -70599,19 +70637,8 @@ class AssessmentItemsResource2 {
70599
70637
  }
70600
70638
  list(params = {}) {
70601
70639
  validatePageListParams2(params);
70602
- const queryParams = {};
70603
- if (params.query !== undefined)
70604
- queryParams.query = params.query;
70605
- if (params.page !== undefined)
70606
- queryParams.page = params.page;
70607
- if (params.limit !== undefined)
70608
- queryParams.limit = params.limit;
70609
- if (params.sort)
70610
- queryParams.sort = params.sort;
70611
- if (params.order)
70612
- queryParams.order = params.order;
70613
70640
  return this.transport.request("/assessment-items", {
70614
- params: queryParams
70641
+ params: buildListQueryParams(params)
70615
70642
  });
70616
70643
  }
70617
70644
  stream(params = {}) {
@@ -70734,20 +70761,9 @@ class TestPartSectionsHelper2 {
70734
70761
  }
70735
70762
  list(params = {}) {
70736
70763
  validatePageListParams2(params);
70737
- const queryParams = {};
70738
- if (params.query !== undefined)
70739
- queryParams.query = params.query;
70740
- if (params.page !== undefined)
70741
- queryParams.page = params.page;
70742
- if (params.limit !== undefined)
70743
- queryParams.limit = params.limit;
70744
- if (params.sort)
70745
- queryParams.sort = params.sort;
70746
- if (params.order)
70747
- queryParams.order = params.order;
70748
70764
  const path = `/assessment-tests/${encodeURIComponent(this.testId)}/test-parts/${encodeURIComponent(this.testPartId)}/sections`;
70749
70765
  return this.transport.request(path, {
70750
- params: queryParams
70766
+ params: buildListQueryParams(params)
70751
70767
  });
70752
70768
  }
70753
70769
  get(identifier) {
@@ -70794,19 +70810,10 @@ class AssessmentTestPartsHelper2 {
70794
70810
  }
70795
70811
  list(params = {}) {
70796
70812
  validatePageListParams2(params);
70797
- const queryParams = {};
70798
- if (params.query !== undefined)
70799
- queryParams.query = params.query;
70800
- if (params.page !== undefined)
70801
- queryParams.page = params.page;
70802
- if (params.limit !== undefined)
70803
- queryParams.limit = params.limit;
70804
- if (params.sort)
70805
- queryParams.sort = params.sort;
70806
- if (params.order)
70807
- queryParams.order = params.order;
70808
70813
  const path = `/assessment-tests/${encodeURIComponent(this.testId)}/test-parts`;
70809
- return this.transport.request(path, { params: queryParams });
70814
+ return this.transport.request(path, {
70815
+ params: buildListQueryParams(params)
70816
+ });
70810
70817
  }
70811
70818
  get(identifier) {
70812
70819
  validateNonEmptyString2(identifier, "testPartId");
@@ -70849,19 +70856,8 @@ class AssessmentTestsResource2 {
70849
70856
  }
70850
70857
  list(params = {}) {
70851
70858
  validatePageListParams2(params);
70852
- const queryParams = {};
70853
- if (params.query !== undefined)
70854
- queryParams.query = params.query;
70855
- if (params.page !== undefined)
70856
- queryParams.page = params.page;
70857
- if (params.limit !== undefined)
70858
- queryParams.limit = params.limit;
70859
- if (params.sort)
70860
- queryParams.sort = params.sort;
70861
- if (params.order)
70862
- queryParams.order = params.order;
70863
70859
  return this.transport.request("/assessment-tests", {
70864
- params: queryParams
70860
+ params: buildListQueryParams(params)
70865
70861
  });
70866
70862
  }
70867
70863
  stream(params = {}) {
@@ -70967,19 +70963,8 @@ class StimuliResource2 {
70967
70963
  }
70968
70964
  list(params = {}) {
70969
70965
  validatePageListParams2(params);
70970
- const queryParams = {};
70971
- if (params.query !== undefined)
70972
- queryParams.query = params.query;
70973
- if (params.page !== undefined)
70974
- queryParams.page = params.page;
70975
- if (params.limit !== undefined)
70976
- queryParams.limit = params.limit;
70977
- if (params.sort)
70978
- queryParams.sort = params.sort;
70979
- if (params.order)
70980
- queryParams.order = params.order;
70981
70966
  return this.transport.request("/stimuli", {
70982
- params: queryParams
70967
+ params: buildListQueryParams(params)
70983
70968
  });
70984
70969
  }
70985
70970
  stream(params = {}) {
@@ -71111,7 +71096,7 @@ var init_dist3 = __esm(async () => {
71111
71096
  init_v42();
71112
71097
  init_v42();
71113
71098
  init_v42();
71114
- await init_chunk_m54fefpq();
71099
+ await init_chunk_pt7w63g2();
71115
71100
  QTI_ENV_VARS2 = {
71116
71101
  baseUrl: ["TIMEBACK_API_BASE_URL", "TIMEBACK_BASE_URL", "QTI_BASE_URL"],
71117
71102
  clientId: ["TIMEBACK_API_CLIENT_ID", "TIMEBACK_CLIENT_ID", "QTI_CLIENT_ID"],
@@ -77952,13 +77937,6 @@ function deriveSourcedIds(courseId) {
77952
77937
  componentResource: `${courseId}-cr`
77953
77938
  };
77954
77939
  }
77955
- function deriveAssessmentBankIds(courseId) {
77956
- return {
77957
- component: `${courseId}-assessment-bank-component`,
77958
- resource: `${courseId}-assessment-bank-resource`,
77959
- componentResource: `${courseId}-assessment-bank-cr`
77960
- };
77961
- }
77962
77940
  function validateProgressData(progressData) {
77963
77941
  if (!progressData.subject) {
77964
77942
  throw new ConfigurationError("subject", "Subject is required for Caliper events. Provide it in progressData.subject");
@@ -79247,59 +79225,22 @@ class CourseAssessments {
79247
79225
  constructor(core3) {
79248
79226
  this.core = core3;
79249
79227
  }
79250
- async ensureBank(input) {
79251
- const oneroster = this.core.api.oneroster;
79252
- const bankIds = deriveAssessmentBankIds(input.courseId);
79253
- try {
79254
- await oneroster.courses.createComponent({
79255
- sourcedId: bankIds.component,
79256
- status: "active",
79257
- title: "Test Out",
79258
- course: { sourcedId: input.courseId },
79259
- sortOrder: 9999,
79260
- metadata: { lessonType: "test-out" }
79261
- });
79262
- } catch (error88) {
79263
- if (!isAlreadyExists(error88)) {
79264
- throw error88;
79265
- }
79266
- }
79267
- try {
79268
- await oneroster.resources.create({
79269
- sourcedId: bankIds.resource,
79270
- status: "active",
79271
- title: "Assessment Bank",
79272
- vendorResourceId: "",
79273
- vendorId: undefined,
79274
- metadata: {
79275
- type: "assessment-bank",
79276
- resources: [],
79277
- lessonType: "test-out",
79278
- subject: input.subject,
79279
- grade: String(input.grade)
79280
- }
79281
- });
79282
- } catch (error88) {
79283
- if (!isAlreadyExists(error88)) {
79284
- throw error88;
79285
- }
79286
- }
79287
- try {
79288
- await oneroster.courses.createComponentResource({
79289
- sourcedId: bankIds.componentResource,
79290
- status: "active",
79291
- title: "Test Out",
79292
- resource: { sourcedId: bankIds.resource },
79293
- courseComponent: { sourcedId: bankIds.component },
79294
- sortOrder: 9999,
79295
- metadata: { lessonType: "test-out" },
79296
- lessonType: "test-out"
79297
- });
79298
- } catch (error88) {
79299
- if (!isAlreadyExists(error88)) {
79300
- throw error88;
79228
+ createItemFromXml(input) {
79229
+ return this.core.qti.assessmentItems.createFromXml({
79230
+ format: "xml",
79231
+ xml: input.xml,
79232
+ ...input.metadata ? { metadata: input.metadata } : {}
79233
+ });
79234
+ }
79235
+ updateItemFromXml(identifier, input) {
79236
+ return this.core.qti.getTransport().request(`/assessment-items/${encodeURIComponent(identifier)}`, {
79237
+ method: "PUT",
79238
+ body: {
79239
+ format: "xml",
79240
+ xml: input.xml,
79241
+ ...input.metadata ? { metadata: input.metadata } : {}
79301
79242
  }
79302
- }
79243
+ });
79303
79244
  }
79304
79245
  async createTestScaffold(input) {
79305
79246
  const partId = `${input.identifier}-part1`;
@@ -79334,18 +79275,6 @@ class CourseAssessments {
79334
79275
  }
79335
79276
  }
79336
79277
  }
79337
- async teardownTest(qtiTestIdentifier) {
79338
- const qti = this.core.qti;
79339
- const partId = `${qtiTestIdentifier}-part1`;
79340
- const sectionId = `${qtiTestIdentifier}-section1`;
79341
- const questions = await qti.assessmentTests.getQuestions(qtiTestIdentifier);
79342
- const sectionItems = qti.assessmentTests.testParts(qtiTestIdentifier).sections(partId).items(sectionId);
79343
- for (const q of questions.questions ?? []) {
79344
- await sectionItems.remove(q.reference.identifier);
79345
- await qti.assessmentItems.delete(q.reference.identifier);
79346
- }
79347
- await qti.assessmentTests.delete(qtiTestIdentifier);
79348
- }
79349
79278
  }
79350
79279
  function buildCoursePayload(config4) {
79351
79280
  return {
@@ -79575,9 +79504,9 @@ function createCourseNamespace(core3) {
79575
79504
  cleanup: (courseId) => integration.cleanup(courseId),
79576
79505
  deactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "tobedeleted"),
79577
79506
  reactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "active"),
79578
- ensureAssessmentBank: (input) => assessments.ensureBank(input),
79579
79507
  createAssessmentTest: (input) => assessments.createTestScaffold(input),
79580
- teardownAssessmentTest: (qtiTestIdentifier) => assessments.teardownTest(qtiTestIdentifier)
79508
+ createAssessmentItemXml: (input) => assessments.createItemFromXml(input),
79509
+ updateAssessmentItemXml: (identifier, input) => assessments.updateItemFromXml(identifier, input)
79581
79510
  };
79582
79511
  }
79583
79512
  function recordTimebackClientSummary(outcome) {
@@ -80060,13 +79989,6 @@ function deriveSourcedIds2(courseId) {
80060
79989
  componentResource: `${courseId}-cr`
80061
79990
  };
80062
79991
  }
80063
- function deriveAssessmentBankIds2(courseId) {
80064
- return {
80065
- component: `${courseId}-assessment-bank-component`,
80066
- resource: `${courseId}-assessment-bank-resource`,
80067
- componentResource: `${courseId}-assessment-bank-cr`
80068
- };
80069
- }
80070
79992
  var CACHE_DEFAULTS4, RESOURCE_DEFAULTS4;
80071
79993
  var init_utils6 = __esm(() => {
80072
79994
  init_src();
@@ -80101,6 +80023,96 @@ var init_utils6 = __esm(() => {
80101
80023
  });
80102
80024
 
80103
80025
  // ../utils/src/timeback.ts
80026
+ function playcademySupportedQtiInteractionType(value) {
80027
+ if (typeof value !== "string") {
80028
+ return;
80029
+ }
80030
+ const normalized = value.trim().toLowerCase().replaceAll("_", "-");
80031
+ return PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET.has(normalized) ? normalized : undefined;
80032
+ }
80033
+ function parseNumericTextEntry(input) {
80034
+ const answer = typeof input.answer === "string" ? input.answer.trim() : "";
80035
+ const comparison = input.comparison;
80036
+ if (input.baseType !== "integer" && input.baseType !== "float") {
80037
+ return { success: false, message: "Numeric answers must be integer or float responses" };
80038
+ }
80039
+ if (!answer || (input.baseType === "integer" ? !INTEGER_ANSWER_PATTERN.test(answer) : !FLOAT_ANSWER_PATTERN.test(answer) || !Number.isFinite(Number(answer)))) {
80040
+ return { success: false, message: `The correct answer is not a valid ${input.baseType}` };
80041
+ }
80042
+ if (!comparison || typeof comparison !== "object" || !("kind" in comparison)) {
80043
+ return { success: false, message: "Numeric comparison is required" };
80044
+ }
80045
+ if (comparison.kind === "equal") {
80046
+ return {
80047
+ success: true,
80048
+ value: { baseType: input.baseType, answer, comparison: { kind: "equal" } }
80049
+ };
80050
+ }
80051
+ if (input.baseType === "integer") {
80052
+ return { success: false, message: "Integer answers cannot use decimal-place rounding" };
80053
+ }
80054
+ const figures = "figures" in comparison ? comparison.figures : undefined;
80055
+ const roundingMode = "roundingMode" in comparison ? comparison.roundingMode : undefined;
80056
+ if (comparison.kind !== "equal-rounded" || roundingMode !== "decimalPlaces" || !Number.isInteger(figures) || figures < 0 || figures > 15) {
80057
+ return { success: false, message: "Decimal places must be a whole number from 0 to 15" };
80058
+ }
80059
+ return {
80060
+ success: true,
80061
+ value: {
80062
+ baseType: "float",
80063
+ answer,
80064
+ comparison: { kind: "equal-rounded", roundingMode, figures }
80065
+ }
80066
+ };
80067
+ }
80068
+ function escapeXml(value) {
80069
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
80070
+ }
80071
+ function hottextMaxChoices(selectableCount, multiple) {
80072
+ return multiple ? selectableCount : 1;
80073
+ }
80074
+ function numericTextEntryXml(input) {
80075
+ const comparison = input.numeric.comparison.kind === "equal-rounded" ? `<qti-equal-rounded rounding-mode="decimalPlaces" figures="${input.numeric.comparison.figures}">` : "<qti-equal>";
80076
+ const closeComparison = input.numeric.comparison.kind === "equal-rounded" ? "</qti-equal-rounded>" : "</qti-equal>";
80077
+ return `<?xml version="1.0" encoding="UTF-8"?>
80078
+ <qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
80079
+ <qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="${input.numeric.baseType}">
80080
+ <qti-correct-response><qti-value>${escapeXml(input.numeric.answer)}</qti-value></qti-correct-response>
80081
+ </qti-response-declaration>
80082
+ <qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
80083
+ <qti-default-value><qti-value>0</qti-value></qti-default-value>
80084
+ </qti-outcome-declaration>
80085
+ <qti-item-body>
80086
+ <p>${escapeXml(input.prompt)} <qti-text-entry-interaction response-identifier="RESPONSE" expected-length="15" /></p>
80087
+ </qti-item-body>
80088
+ <qti-response-processing>
80089
+ <qti-response-condition>
80090
+ <qti-response-if>
80091
+ ${comparison}<qti-variable identifier="RESPONSE" /><qti-correct identifier="RESPONSE" />${closeComparison}
80092
+ <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
80093
+ </qti-response-if>
80094
+ </qti-response-condition>
80095
+ </qti-response-processing>
80096
+ </qti-assessment-item>`;
80097
+ }
80098
+ function countQtiTestItems(test) {
80099
+ let itemCount = 0;
80100
+ for (const part of test["qti-test-part"] ?? []) {
80101
+ for (const section of part["qti-assessment-section"] ?? []) {
80102
+ itemCount += section["qti-assessment-item-ref"]?.length ?? 0;
80103
+ }
80104
+ }
80105
+ return itemCount;
80106
+ }
80107
+ function isQtiItemOwnedByTest(item, testIdentifier) {
80108
+ const hasOwnershipMetadata = Boolean(item.metadata && (("ownerSystem" in item.metadata) || (PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY in item.metadata)));
80109
+ if (hasOwnershipMetadata) {
80110
+ return item.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && item.metadata?.[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY] === testIdentifier;
80111
+ }
80112
+ const identifierPrefix = `${testIdentifier}-q`;
80113
+ const identifierSuffix = item.identifier?.startsWith(identifierPrefix) ? item.identifier.slice(identifierPrefix.length) : undefined;
80114
+ return identifierSuffix !== undefined && /^[0-9a-f]{8}$/i.test(identifierSuffix);
80115
+ }
80104
80116
  function formatGradeLabel(grade) {
80105
80117
  if (grade === null || grade === undefined) {
80106
80118
  return "N/A";
@@ -80142,7 +80154,7 @@ function parseTimebackDiscrepancyQueueMetrics(values) {
80142
80154
  }
80143
80155
  return metrics;
80144
80156
  }
80145
- var TIMEBACK_DISCREPANCY_QUEUE_WINDOWS, TIMEBACK_DISCREPANCY_QUEUE_METRICS, DEFAULT_TIMEBACK_DISCREPANCY_QUEUE_WINDOW = "this-week", TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES, TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES;
80157
+ var TIMEBACK_DISCREPANCY_QUEUE_WINDOWS, TIMEBACK_DISCREPANCY_QUEUE_METRICS, PLAYCADEMY_QTI_OWNER_SYSTEM = "playcademy", PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY = "ownerTestIdentifier", PLAYCADEMY_QTI_EDITOR_MODE_KEY = "playcademyEditorMode", PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES, PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET, INTEGER_ANSWER_PATTERN, FLOAT_ANSWER_PATTERN, INLINE_CHOICE_BLANK = "_____", DEFAULT_TIMEBACK_DISCREPANCY_QUEUE_WINDOW = "this-week", TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES, TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES;
80146
80158
  var init_timeback3 = __esm(() => {
80147
80159
  TIMEBACK_DISCREPANCY_QUEUE_WINDOWS = [
80148
80160
  "today",
@@ -80153,6 +80165,17 @@ var init_timeback3 = __esm(() => {
80153
80165
  "custom"
80154
80166
  ];
80155
80167
  TIMEBACK_DISCREPANCY_QUEUE_METRICS = ["xp", "mastery", "time", "score"];
80168
+ PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES = [
80169
+ "choice",
80170
+ "inline-choice",
80171
+ "text-entry",
80172
+ "order",
80173
+ "match",
80174
+ "hottext"
80175
+ ];
80176
+ PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET = new Set(PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES);
80177
+ INTEGER_ANSWER_PATTERN = /^[+-]?\d+$/;
80178
+ FLOAT_ANSWER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
80156
80179
  TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_WINDOWS);
80157
80180
  TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_METRICS);
80158
80181
  });
@@ -81395,7 +81418,7 @@ class TimebackAdminService {
81395
81418
  }
81396
81419
  async getMasterableUnitsByCourse(courseIds) {
81397
81420
  const uniqueCourseIds = [...new Set(courseIds)];
81398
- const results = await TimebackAdminService.runWithConcurrency(uniqueCourseIds, TimebackAdminService.MASTERABLE_UNITS_CONCURRENCY, async (courseId) => [courseId, await this.getMasterableUnits(courseId)]);
81421
+ const results = await runWithConcurrency(uniqueCourseIds, TimebackAdminService.MASTERABLE_UNITS_CONCURRENCY, async (courseId) => [courseId, await this.getMasterableUnits(courseId)]);
81399
81422
  return new Map(results);
81400
81423
  }
81401
81424
  deriveGameSensorUrl(game2) {
@@ -81576,7 +81599,7 @@ class TimebackAdminService {
81576
81599
  async loadEnrollmentAnalyticsSummaries(enrollmentIds) {
81577
81600
  const client = this.requireClient();
81578
81601
  const uniqueEnrollmentIds = [...new Set(enrollmentIds)];
81579
- const results = await TimebackAdminService.runWithConcurrency(uniqueEnrollmentIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (enrollmentId) => {
81602
+ const results = await runWithConcurrency(uniqueEnrollmentIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (enrollmentId) => {
81580
81603
  try {
81581
81604
  const analytics = await client.api.edubridge.analytics.getEnrollmentFacts({
81582
81605
  enrollmentId,
@@ -81670,7 +81693,7 @@ class TimebackAdminService {
81670
81693
  if (options.runOwnersById.size === 0) {
81671
81694
  return { events: [], runIds: new Set };
81672
81695
  }
81673
- const hydratedRuns = await TimebackAdminService.runWithConcurrency([...options.runOwnersById.entries()], TimebackAdminService.DISCREPANCY_QUEUE_RUN_HYDRATION_CONCURRENCY, async ([runId, studentId]) => {
81696
+ const hydratedRuns = await runWithConcurrency([...options.runOwnersById.entries()], TimebackAdminService.DISCREPANCY_QUEUE_RUN_HYDRATION_CONCURRENCY, async ([runId, studentId]) => {
81674
81697
  try {
81675
81698
  return {
81676
81699
  runId,
@@ -81703,7 +81726,7 @@ class TimebackAdminService {
81703
81726
  };
81704
81727
  }
81705
81728
  async buildMetricDiscrepancyQueueCandidates(user, options) {
81706
- const comparisonResults = await TimebackAdminService.runWithConcurrency(options.activityGroups, TimebackAdminService.DISCREPANCY_QUEUE_COMPARISON_CONCURRENCY, async (group) => {
81729
+ const comparisonResults = await runWithConcurrency(options.activityGroups, TimebackAdminService.DISCREPANCY_QUEUE_COMPARISON_CONCURRENCY, async (group) => {
81707
81730
  try {
81708
81731
  return {
81709
81732
  group,
@@ -82703,7 +82726,7 @@ class TimebackAdminService {
82703
82726
  const action = context2.newMasterableUnits < context2.oldMasterableUnits ? "complete" : "revoke";
82704
82727
  const failed = [];
82705
82728
  let processed = 0;
82706
- await TimebackAdminService.runWithConcurrency(context2.affectedStudentIds, 8, async (studentId) => {
82729
+ await runWithConcurrency(context2.affectedStudentIds, 8, async (studentId) => {
82707
82730
  try {
82708
82731
  await upsertMasteryCompletionEntry({
82709
82732
  client,
@@ -82924,28 +82947,9 @@ class TimebackAdminService {
82924
82947
  }
82925
82948
  async getCompletionStatusByCourse(client, courseIds, studentId) {
82926
82949
  const uniqueCourseIds = [...new Set(courseIds)];
82927
- const results = await TimebackAdminService.runWithConcurrency(uniqueCourseIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (courseId) => [courseId, await this.getCompletionStatus(client, courseId, studentId)]);
82950
+ const results = await runWithConcurrency(uniqueCourseIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (courseId) => [courseId, await this.getCompletionStatus(client, courseId, studentId)]);
82928
82951
  return new Map(results);
82929
82952
  }
82930
- static async runWithConcurrency(items, concurrency, worker) {
82931
- if (items.length === 0) {
82932
- return [];
82933
- }
82934
- const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
82935
- const results = Array.from({ length: items.length });
82936
- let nextIndex = 0;
82937
- await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
82938
- while (true) {
82939
- const currentIndex = nextIndex;
82940
- nextIndex++;
82941
- if (currentIndex >= items.length) {
82942
- return;
82943
- }
82944
- results[currentIndex] = await worker(items[currentIndex]);
82945
- }
82946
- }));
82947
- return results;
82948
- }
82949
82953
  }
82950
82954
  var init_timeback_admin_service = __esm(async () => {
82951
82955
  init_drizzle_orm();
@@ -82972,8 +82976,877 @@ var init_timeback_admin_service = __esm(async () => {
82972
82976
  ]);
82973
82977
  });
82974
82978
 
82979
+ // ../api-core/src/utils/timeback-assessment-rules.util.ts
82980
+ function validateAssessmentStatusTransition(current, next) {
82981
+ if (current === next) {
82982
+ return;
82983
+ }
82984
+ const allowed = current === "draft" && next === "live" || current === "live" && next === "archived" || current === "archived" && next === "live";
82985
+ if (!allowed) {
82986
+ throw new ValidationError(`Assessment status cannot change from ${current} to ${next}`);
82987
+ }
82988
+ }
82989
+ function isAssessmentPublicationTransition(current, next) {
82990
+ return current !== "live" && next === "live";
82991
+ }
82992
+ function assertDraftAssessment(row) {
82993
+ if (row.status !== "draft") {
82994
+ throw new ValidationError("Only draft assessments can change QTI content or question membership");
82995
+ }
82996
+ }
82997
+ function assertAllAssessmentAssociationsDraft(rows) {
82998
+ if (rows.some((row) => row.status !== "draft")) {
82999
+ throw new ValidationError("QTI content cannot change while any associated assessment is live or archived");
83000
+ }
83001
+ }
83002
+ function assertAssessmentHasQuestions(questions) {
83003
+ if (questions.length === 0) {
83004
+ throw new ValidationError("An assessment must contain at least one question to publish");
83005
+ }
83006
+ }
83007
+ function planAssessmentRemoval(status) {
83008
+ if (status === "draft") {
83009
+ return { kind: "delete", action: "discarded", operation: "discard_draft" };
83010
+ }
83011
+ if (status === "live") {
83012
+ return { kind: "archive", action: "archived", operation: "archive" };
83013
+ }
83014
+ return { kind: "none", action: "archived" };
83015
+ }
83016
+ function buildAssessmentAssociationUpdates(row, input) {
83017
+ const updates = {};
83018
+ if (input.purpose !== undefined) {
83019
+ updates.purpose = input.purpose;
83020
+ if (row.status === "live" && input.purpose !== row.purpose) {
83021
+ updates.sortOrder = null;
83022
+ }
83023
+ }
83024
+ if (input.status !== undefined) {
83025
+ updates.status = input.status;
83026
+ if (input.status === "archived") {
83027
+ updates.sortOrder = null;
83028
+ }
83029
+ }
83030
+ return updates;
83031
+ }
83032
+ function validateUniqueAssessmentIdentifiers(testIdentifiers) {
83033
+ if (new Set(testIdentifiers).size !== testIdentifiers.length) {
83034
+ throw new ValidationError("Assessment order must contain unique identifiers");
83035
+ }
83036
+ }
83037
+ function assertAssessmentOrderUpdateSucceeded(updatedRow) {
83038
+ if (!updatedRow) {
83039
+ throw new ValidationError("Assessment order changed while it was being saved. Refresh and try again.");
83040
+ }
83041
+ return updatedRow;
83042
+ }
83043
+ function lockOrderAssessmentRows(rows) {
83044
+ return rows.toSorted((left, right) => left.id.localeCompare(right.id));
83045
+ }
83046
+ function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
83047
+ const rowsByIdentifier = new Map(liveRows.map((row) => [row.qtiTestIdentifier, row]));
83048
+ if (liveRows.length !== testIdentifiers.length || testIdentifiers.some((identifier) => !rowsByIdentifier.has(identifier))) {
83049
+ throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
83050
+ }
83051
+ return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
83052
+ }
83053
+ var init_timeback_assessment_rules_util = __esm(() => {
83054
+ init_errors();
83055
+ });
83056
+
83057
+ // ../api-core/src/utils/timeback-qti-authoring.util.ts
83058
+ function recordValue(value) {
83059
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
83060
+ }
83061
+ function qtiAuthoringSnapshot(input) {
83062
+ if (!recordValue(input.interaction)) {
83063
+ return;
83064
+ }
83065
+ return Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => input[field] === undefined ? [] : [[field, input[field]]]));
83066
+ }
83067
+ function buildOwnedQtiQuestionMetadata(input, ownership, existingMetadata = {}) {
83068
+ const inputMetadata = recordValue(input.metadata) ?? {};
83069
+ const metadata2 = {
83070
+ ...existingMetadata,
83071
+ ...inputMetadata
83072
+ };
83073
+ const existingAuthoring = recordValue(existingMetadata[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
83074
+ Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
83075
+ Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_EDITOR_MODE_KEY);
83076
+ const authoring = qtiAuthoringSnapshot(input);
83077
+ const shouldReplaceAuthoring = "interaction" in input || "numericTextEntry" in input;
83078
+ const persistedAuthoring = authoring ?? (shouldReplaceAuthoring ? undefined : existingAuthoring);
83079
+ return {
83080
+ ...metadata2,
83081
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
83082
+ ownerGameSlug: ownership.gameSlug,
83083
+ [PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]: ownership.testIdentifier,
83084
+ ...persistedAuthoring ? { [PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]: persistedAuthoring } : {}
83085
+ };
83086
+ }
83087
+ function restoreQtiQuestionAuthoringData(item) {
83088
+ const authoring = recordValue(item.metadata?.[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
83089
+ if (!authoring) {
83090
+ return item;
83091
+ }
83092
+ const restored = Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => authoring[field] === undefined ? [] : [[field, authoring[field]]]));
83093
+ return { ...item, ...restored };
83094
+ }
83095
+ function mergeQtiQuestionItem(item, fallbackItem, itemIdentifier, metadata2) {
83096
+ return restoreQtiQuestionAuthoringData({
83097
+ ...fallbackItem,
83098
+ ...item,
83099
+ identifier: itemIdentifier,
83100
+ title: item.title || fallbackItem?.title || itemIdentifier,
83101
+ type: item.type ?? fallbackItem?.type,
83102
+ rawXml: item.rawXml ?? fallbackItem?.rawXml,
83103
+ interaction: item.interaction ?? fallbackItem?.interaction,
83104
+ responseDeclarations: item.responseDeclarations ?? fallbackItem?.responseDeclarations,
83105
+ metadata: {
83106
+ ...fallbackItem?.metadata,
83107
+ ...item.metadata,
83108
+ ...metadata2
83109
+ }
83110
+ });
83111
+ }
83112
+ function newPlaycademyQuestionIdentifier(testIdentifier) {
83113
+ return `${testIdentifier}-q${crypto.randomUUID().slice(0, 8)}`;
83114
+ }
83115
+ function parseQtiQuestionCreationInput(input) {
83116
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
83117
+ throw new ValidationError("Question creation input must be an object");
83118
+ }
83119
+ const rawInput = input;
83120
+ if (rawInput.mode === undefined) {
83121
+ return { kind: "authoring", input: rawInput };
83122
+ }
83123
+ if (rawInput.mode !== "copy") {
83124
+ throw new ValidationError("Question creation mode is invalid");
83125
+ }
83126
+ const sourceItemIdentifier = typeof rawInput.sourceItemIdentifier === "string" ? rawInput.sourceItemIdentifier.trim() : "";
83127
+ if (!sourceItemIdentifier) {
83128
+ throw new ValidationError("A source question identifier is required to create a copy");
83129
+ }
83130
+ const unexpectedField = Object.keys(rawInput).find((field) => field !== "mode" && field !== "sourceItemIdentifier");
83131
+ if (unexpectedField) {
83132
+ throw new ValidationError(`Question copy input contains an unexpected “${unexpectedField}” field`);
83133
+ }
83134
+ return { kind: "copy", sourceItemIdentifier };
83135
+ }
83136
+ function nonEmptyMetadataString(value) {
83137
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
83138
+ }
83139
+ function qtiQuestionOwnerTestIdentifier(item) {
83140
+ if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
83141
+ return;
83142
+ }
83143
+ return nonEmptyMetadataString(item.metadata[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]);
83144
+ }
83145
+ function qtiTestReferencesItem(test, itemIdentifier) {
83146
+ return (test["qti-test-part"] ?? []).some((part) => (part["qti-assessment-section"] ?? []).some((section) => (section["qti-assessment-item-ref"] ?? []).some((reference) => reference.identifier === itemIdentifier)));
83147
+ }
83148
+ function resolveQtiQuestionOwnerGameSlug(item, ownerTest) {
83149
+ if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
83150
+ return;
83151
+ }
83152
+ if ("ownerGameSlug" in item.metadata) {
83153
+ return nonEmptyMetadataString(item.metadata.ownerGameSlug);
83154
+ }
83155
+ const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
83156
+ if (!ownerTestIdentifier || !ownerTest || ownerTest.identifier !== ownerTestIdentifier || !qtiTestReferencesItem(ownerTest, item.identifier) || ownerTest.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
83157
+ return;
83158
+ }
83159
+ return nonEmptyMetadataString(ownerTest.metadata.ownerGameSlug);
83160
+ }
83161
+ function isQtiQuestionOwnedByGame(item, gameSlug, ownerTest) {
83162
+ return resolveQtiQuestionOwnerGameSlug(item, ownerTest) === gameSlug;
83163
+ }
83164
+ function isQtiQuestionOwnedByContext(item, ownership, ownerTest) {
83165
+ const declaredOwnerTest = qtiQuestionOwnerTestIdentifier(item);
83166
+ if (declaredOwnerTest) {
83167
+ return declaredOwnerTest === ownership.testIdentifier && isQtiQuestionOwnedByGame(item, ownership.gameSlug, ownerTest);
83168
+ }
83169
+ return isQtiItemOwnedByTest(item, ownership.testIdentifier);
83170
+ }
83171
+ function normalizedQtiInteractionType(value) {
83172
+ if (typeof value !== "string") {
83173
+ return;
83174
+ }
83175
+ const normalized = value.trim().toLowerCase().replaceAll("_", "-");
83176
+ return normalized || undefined;
83177
+ }
83178
+ function qtiXmlInteractionTypes(xml) {
83179
+ const markup = xml.replaceAll(/<!--[\s\S]*?-->/g, "").replaceAll(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
83180
+ const interactionPattern = /<(?!\/)(?:[A-Za-z_][\w.-]*:)?qti-([a-z][\w-]*)-interaction\b[^>]*>/gi;
83181
+ return [...markup.matchAll(interactionPattern)].map((match) => match[1].toLowerCase());
83182
+ }
83183
+ function supportedQtiQuestionInteractionType(question) {
83184
+ const interaction = recordValue(question.interaction);
83185
+ const structuredTypes = [
83186
+ normalizedQtiInteractionType(question.type),
83187
+ normalizedQtiInteractionType(interaction?.type)
83188
+ ].filter((type) => Boolean(type));
83189
+ const distinctStructuredTypes = [...new Set(structuredTypes)];
83190
+ if (distinctStructuredTypes.length > 1) {
83191
+ return;
83192
+ }
83193
+ const rawXml = typeof question.rawXml === "string" ? question.rawXml : undefined;
83194
+ if (rawXml) {
83195
+ const xmlTypes = qtiXmlInteractionTypes(rawXml);
83196
+ if (xmlTypes.length !== 1) {
83197
+ return;
83198
+ }
83199
+ const [xmlType] = xmlTypes;
83200
+ const [structuredType2] = distinctStructuredTypes;
83201
+ if (structuredType2 && structuredType2 !== xmlType) {
83202
+ return;
83203
+ }
83204
+ return playcademySupportedQtiInteractionType(xmlType);
83205
+ }
83206
+ const [structuredType] = distinctStructuredTypes;
83207
+ return playcademySupportedQtiInteractionType(structuredType);
83208
+ }
83209
+ function assertSupportedQtiQuestionInteraction(question) {
83210
+ if (!supportedQtiQuestionInteractionType(question)) {
83211
+ throw new ValidationError("This QTI interaction type is not supported by the Playcademy question editor.");
83212
+ }
83213
+ }
83214
+ function invalidQtiCopyXml() {
83215
+ throw new ValidationError("The source question does not contain valid QTI item XML");
83216
+ }
83217
+ function markupEnd(xml, start2, allowInternalSubset) {
83218
+ let quote = null;
83219
+ let subsetDepth = 0;
83220
+ for (let index2 = start2;index2 < xml.length; index2 += 1) {
83221
+ const character = xml[index2];
83222
+ if (quote) {
83223
+ if (character === quote) {
83224
+ quote = null;
83225
+ }
83226
+ } else if (character === '"' || character === "'") {
83227
+ quote = character;
83228
+ } else if (allowInternalSubset && character === "[") {
83229
+ subsetDepth += 1;
83230
+ } else if (allowInternalSubset && character === "]") {
83231
+ subsetDepth = Math.max(0, subsetDepth - 1);
83232
+ } else if (character === ">" && subsetDepth === 0) {
83233
+ return index2;
83234
+ }
83235
+ }
83236
+ return invalidQtiCopyXml();
83237
+ }
83238
+ function skipXmlWhitespace(xml, start2, end = xml.length) {
83239
+ let cursor2 = start2;
83240
+ while (cursor2 < end && /\s/.test(xml[cursor2] ?? "")) {
83241
+ cursor2 += 1;
83242
+ }
83243
+ return cursor2;
83244
+ }
83245
+ function xmlPreambleEnd(xml, cursor2) {
83246
+ if (xml.startsWith("<?", cursor2)) {
83247
+ const end = xml.indexOf("?>", cursor2 + 2);
83248
+ if (end === -1) {
83249
+ return invalidQtiCopyXml();
83250
+ }
83251
+ return end + 2;
83252
+ }
83253
+ if (xml.startsWith("<!--", cursor2)) {
83254
+ const end = xml.indexOf("-->", cursor2 + 4);
83255
+ if (end === -1) {
83256
+ return invalidQtiCopyXml();
83257
+ }
83258
+ return end + 3;
83259
+ }
83260
+ if (/^<!DOCTYPE\b/i.test(xml.slice(cursor2))) {
83261
+ return markupEnd(xml, cursor2 + 2, true) + 1;
83262
+ }
83263
+ return null;
83264
+ }
83265
+ function qtiRootAttributes(xml, start2, end) {
83266
+ const attributes = [];
83267
+ let cursor2 = start2;
83268
+ while (cursor2 < end) {
83269
+ cursor2 = skipXmlWhitespace(xml, cursor2, end);
83270
+ if (xml[cursor2] === "/") {
83271
+ cursor2 += 1;
83272
+ } else if (cursor2 < end) {
83273
+ const nameMatch = /^[A-Za-z_][\w.:-]*/.exec(xml.slice(cursor2, end));
83274
+ if (!nameMatch) {
83275
+ return invalidQtiCopyXml();
83276
+ }
83277
+ const name3 = nameMatch[0];
83278
+ cursor2 = skipXmlWhitespace(xml, cursor2 + name3.length, end);
83279
+ if (xml[cursor2] !== "=") {
83280
+ return invalidQtiCopyXml();
83281
+ }
83282
+ cursor2 = skipXmlWhitespace(xml, cursor2 + 1, end);
83283
+ const quote = xml[cursor2];
83284
+ if (quote !== '"' && quote !== "'") {
83285
+ return invalidQtiCopyXml();
83286
+ }
83287
+ const valueStart = cursor2 + 1;
83288
+ const valueEnd = xml.indexOf(quote, valueStart);
83289
+ if (valueEnd === -1 || valueEnd > end) {
83290
+ return invalidQtiCopyXml();
83291
+ }
83292
+ attributes.push({ name: name3, quote, valueStart, valueEnd });
83293
+ cursor2 = valueEnd + 1;
83294
+ }
83295
+ }
83296
+ return attributes;
83297
+ }
83298
+ function qtiItemStartTag(xml) {
83299
+ let cursor2 = xml.charCodeAt(0) === 65279 ? 1 : 0;
83300
+ while (cursor2 < xml.length) {
83301
+ cursor2 = skipXmlWhitespace(xml, cursor2);
83302
+ const preambleEnd = xmlPreambleEnd(xml, cursor2);
83303
+ if (preambleEnd !== null) {
83304
+ cursor2 = preambleEnd;
83305
+ } else {
83306
+ const root = /^<([A-Za-z_][\w.:-]*)/.exec(xml.slice(cursor2));
83307
+ if (!root || root[1].split(":").at(-1) !== "qti-assessment-item") {
83308
+ return invalidQtiCopyXml();
83309
+ }
83310
+ const attributesStart = cursor2 + root[0].length;
83311
+ const end = markupEnd(xml, attributesStart, false);
83312
+ const attributes = qtiRootAttributes(xml, attributesStart, end);
83313
+ let insertionPoint = skipXmlWhitespaceBackward(xml, end);
83314
+ if (xml[insertionPoint - 1] === "/") {
83315
+ insertionPoint -= 1;
83316
+ }
83317
+ return { attributes, insertionPoint };
83318
+ }
83319
+ }
83320
+ return invalidQtiCopyXml();
83321
+ }
83322
+ function skipXmlWhitespaceBackward(xml, start2) {
83323
+ let cursor2 = start2;
83324
+ while (/\s/.test(xml[cursor2 - 1] ?? "")) {
83325
+ cursor2 -= 1;
83326
+ }
83327
+ return cursor2;
83328
+ }
83329
+ function escapeXmlAttribute(value, quote) {
83330
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(quote, quote === '"' ? "&quot;" : "&apos;");
83331
+ }
83332
+ function rewriteQtiItemIdentity(xml, identifier, title) {
83333
+ if (!xml.trim() || !identifier.trim() || !title.trim()) {
83334
+ return invalidQtiCopyXml();
83335
+ }
83336
+ const root = qtiItemStartTag(xml);
83337
+ const replacements = [];
83338
+ const targetAttributes = new Map([
83339
+ ["identifier", identifier],
83340
+ ["title", title]
83341
+ ]);
83342
+ for (const [name3, value] of targetAttributes) {
83343
+ const matches = root.attributes.filter((attribute2) => attribute2.name === name3);
83344
+ if (matches.length > 1) {
83345
+ return invalidQtiCopyXml();
83346
+ }
83347
+ const attribute = matches[0];
83348
+ if (attribute) {
83349
+ replacements.push({
83350
+ start: attribute.valueStart,
83351
+ end: attribute.valueEnd,
83352
+ value: escapeXmlAttribute(value, attribute.quote)
83353
+ });
83354
+ } else {
83355
+ replacements.push({
83356
+ start: root.insertionPoint,
83357
+ end: root.insertionPoint,
83358
+ value: ` ${name3}="${escapeXmlAttribute(value, '"')}"`
83359
+ });
83360
+ }
83361
+ }
83362
+ return replacements.toSorted((left, right) => right.start - left.start).reduce((rewritten, replacement) => `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`, xml);
83363
+ }
83364
+ function buildQtiQuestionCopyInput(input) {
83365
+ assertSupportedQtiQuestionInteraction(input.source);
83366
+ const title = input.source.title?.trim() || input.source.identifier;
83367
+ if (!input.source.rawXml) {
83368
+ return invalidQtiCopyXml();
83369
+ }
83370
+ const sourceMetadata = { ...input.source.metadata };
83371
+ if (sourceMetadata.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
83372
+ Reflect.deleteProperty(sourceMetadata, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
83373
+ }
83374
+ const metadata2 = buildOwnedQtiQuestionMetadata({
83375
+ metadata: {
83376
+ copiedFromItemIdentifier: input.source.identifier,
83377
+ integrationId: input.integrationId
83378
+ }
83379
+ }, {
83380
+ gameSlug: input.gameSlug,
83381
+ testIdentifier: input.targetTestIdentifier
83382
+ }, sourceMetadata);
83383
+ return {
83384
+ identifier: input.targetIdentifier,
83385
+ title,
83386
+ xml: rewriteQtiItemIdentity(input.source.rawXml, input.targetIdentifier, title),
83387
+ metadata: metadata2
83388
+ };
83389
+ }
83390
+ function assertQtiQuestionEditable(item, ownership, ownerTest) {
83391
+ if (!isQtiQuestionOwnedByContext(item, ownership, ownerTest)) {
83392
+ throw new ValidationError("Shared question references are read-only. Copy the question to create an editable independent item.");
83393
+ }
83394
+ }
83395
+ function buildNumericQuestionXml(input, identifier, title) {
83396
+ if (input.format === "xml") {
83397
+ throw new ValidationError("Raw question XML is not accepted at this API boundary");
83398
+ }
83399
+ const candidate = input.numericTextEntry;
83400
+ if (candidate === undefined) {
83401
+ return null;
83402
+ }
83403
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
83404
+ throw new ValidationError("Numeric text-entry data is invalid");
83405
+ }
83406
+ const prompt = "prompt" in candidate ? candidate.prompt : undefined;
83407
+ const numeric3 = parseNumericTextEntry({
83408
+ baseType: "baseType" in candidate ? candidate.baseType : undefined,
83409
+ answer: "answer" in candidate ? candidate.answer : undefined,
83410
+ comparison: "comparison" in candidate ? candidate.comparison : undefined
83411
+ });
83412
+ if (!numeric3.success) {
83413
+ throw new ValidationError(numeric3.message);
83414
+ }
83415
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
83416
+ throw new ValidationError("Numeric questions require a title and prompt");
83417
+ }
83418
+ return numericTextEntryXml({ identifier, title, prompt: prompt.trim(), numeric: numeric3.value });
83419
+ }
83420
+ function buildExactMatchQuestionXml(input) {
83421
+ const responseIdentifier = escapeXml(input.responseIdentifier);
83422
+ const correctValues = input.correctIdentifiers.map((identifier) => ` <qti-value>${escapeXml(identifier)}</qti-value>`).join(`
83423
+ `);
83424
+ return `<?xml version="1.0" encoding="UTF-8"?>
83425
+ <qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
83426
+ <qti-response-declaration identifier="${responseIdentifier}" cardinality="${input.cardinality}" base-type="identifier">
83427
+ <qti-correct-response>
83428
+ ${correctValues}
83429
+ </qti-correct-response>
83430
+ </qti-response-declaration>
83431
+ <qti-outcome-declaration identifier="FEEDBACK" cardinality="single" base-type="identifier" />
83432
+ <qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
83433
+ <qti-default-value><qti-value>0</qti-value></qti-default-value>
83434
+ </qti-outcome-declaration>
83435
+ <qti-item-body>
83436
+ ${input.itemBody}
83437
+ </qti-item-body>
83438
+ <qti-response-processing>
83439
+ <qti-response-condition>
83440
+ <qti-response-if>
83441
+ <qti-match><qti-variable identifier="${responseIdentifier}" /><qti-correct identifier="${responseIdentifier}" /></qti-match>
83442
+ <qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">CORRECT</qti-base-value></qti-set-outcome-value>
83443
+ <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
83444
+ </qti-response-if>
83445
+ <qti-response-else>
83446
+ <qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">INCORRECT</qti-base-value></qti-set-outcome-value>
83447
+ <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">0</qti-base-value></qti-set-outcome-value>
83448
+ </qti-response-else>
83449
+ </qti-response-condition>
83450
+ </qti-response-processing>
83451
+ </qti-assessment-item>`;
83452
+ }
83453
+ function parseInlineChoices(value) {
83454
+ if (!Array.isArray(value) || value.length < 2) {
83455
+ throw new ValidationError("Inline-choice questions require at least two choices");
83456
+ }
83457
+ const choices = value.map((choice) => {
83458
+ const record3 = recordValue(choice);
83459
+ const identifier = record3?.identifier;
83460
+ const content = record3?.content;
83461
+ if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim()) {
83462
+ throw new ValidationError("Inline-choice options are invalid");
83463
+ }
83464
+ return { identifier: identifier.trim(), content: content.trim() };
83465
+ });
83466
+ if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
83467
+ throw new ValidationError("Inline-choice option identifiers must be unique");
83468
+ }
83469
+ return choices;
83470
+ }
83471
+ function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
83472
+ const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
83473
+ const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
83474
+ const correctResponse = recordValue(declaration?.correctResponse);
83475
+ const rawValues = correctResponse?.value;
83476
+ const values = Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : [];
83477
+ if (declaration?.cardinality !== "single" || declaration.baseType !== "identifier") {
83478
+ throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
83479
+ }
83480
+ if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
83481
+ throw new ValidationError("Inline-choice questions require one valid correct option");
83482
+ }
83483
+ return values[0];
83484
+ }
83485
+ function buildInlineChoiceQuestionXml(input, identifier, title) {
83486
+ const interaction = recordValue(input.interaction);
83487
+ const interactionType = normalizedQtiInteractionType(interaction?.type ?? input.type);
83488
+ if (interactionType !== "inline-choice") {
83489
+ return null;
83490
+ }
83491
+ const responseIdentifier = interaction?.responseIdentifier;
83492
+ const structure = recordValue(interaction?.questionStructure);
83493
+ const prompt = structure?.prompt;
83494
+ if (!structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
83495
+ throw new ValidationError("Inline-choice question data is invalid");
83496
+ }
83497
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
83498
+ throw new ValidationError("Inline-choice questions require a title and prompt");
83499
+ }
83500
+ const normalizedPrompt = prompt.trim();
83501
+ const promptParts = normalizedPrompt.split(INLINE_CHOICE_BLANK);
83502
+ if (promptParts.length !== 2) {
83503
+ throw new ValidationError("Inline-choice questions require exactly one blank");
83504
+ }
83505
+ const choices = parseInlineChoices(structure.inlineChoices);
83506
+ const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
83507
+ const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
83508
+ const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
83509
+ `);
83510
+ const [before = "", after = ""] = promptParts;
83511
+ const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
83512
+ ${choicesXml}
83513
+ </qti-inline-choice-interaction>${escapeXml(after)}</p>`;
83514
+ return buildExactMatchQuestionXml({
83515
+ identifier,
83516
+ title,
83517
+ responseIdentifier,
83518
+ cardinality: "single",
83519
+ correctIdentifiers: [correctIdentifier],
83520
+ itemBody
83521
+ });
83522
+ }
83523
+ function parseHottextSegments(value) {
83524
+ if (!Array.isArray(value) || value.length === 0) {
83525
+ throw new ValidationError("Hottext questions require passage segments");
83526
+ }
83527
+ const segments = value.map((segment) => {
83528
+ const record3 = recordValue(segment);
83529
+ const identifier = record3?.identifier;
83530
+ const content = record3?.content;
83531
+ const mode = record3?.mode;
83532
+ if (typeof identifier !== "string" || typeof content !== "string" || !content.trim() || !["plain", "option", "correct"].includes(String(mode))) {
83533
+ throw new ValidationError("Hottext passage segments are invalid");
83534
+ }
83535
+ return {
83536
+ identifier: identifier.trim(),
83537
+ content: content.trim(),
83538
+ mode
83539
+ };
83540
+ });
83541
+ const selectable = segments.filter((segment) => segment.mode !== "plain");
83542
+ const correct = selectable.filter((segment) => segment.mode === "correct");
83543
+ const identifiers = selectable.map((segment) => segment.identifier);
83544
+ if (selectable.length < 2) {
83545
+ throw new ValidationError("Hottext questions require at least two selectable phrases");
83546
+ }
83547
+ if (identifiers.some((identifier) => !identifier)) {
83548
+ throw new ValidationError("Hottext selectable phrases require identifiers");
83549
+ }
83550
+ if (new Set(identifiers).size !== identifiers.length) {
83551
+ throw new ValidationError("Hottext selectable phrase identifiers must be unique");
83552
+ }
83553
+ if (correct.length === 0) {
83554
+ throw new ValidationError("Hottext questions require a correct phrase");
83555
+ }
83556
+ return { segments, selectable, correct };
83557
+ }
83558
+ function buildHottextQuestionXml(input, identifier, title) {
83559
+ const candidate = input.hottext;
83560
+ if (candidate === undefined) {
83561
+ return null;
83562
+ }
83563
+ const hottext = recordValue(candidate);
83564
+ if (!hottext) {
83565
+ throw new ValidationError("Hottext question data is invalid");
83566
+ }
83567
+ const prompt = hottext.prompt;
83568
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
83569
+ throw new ValidationError("Hottext questions require a title and prompt");
83570
+ }
83571
+ const { segments, selectable, correct } = parseHottextSegments(hottext.segments);
83572
+ const multiple = correct.length > 1;
83573
+ const maxChoices = hottextMaxChoices(selectable.length, multiple);
83574
+ const passage = segments.map((segment) => segment.mode === "plain" ? escapeXml(segment.content) : `<qti-hottext identifier="${escapeXml(segment.identifier)}">${escapeXml(segment.content)}</qti-hottext>`).join(" ");
83575
+ const itemBody = ` <qti-hottext-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
83576
+ <qti-prompt>${escapeXml(prompt.trim())}</qti-prompt>
83577
+ <p>${passage}</p>
83578
+ </qti-hottext-interaction>`;
83579
+ return buildExactMatchQuestionXml({
83580
+ identifier,
83581
+ title,
83582
+ responseIdentifier: "RESPONSE",
83583
+ cardinality: multiple ? "multiple" : "single",
83584
+ correctIdentifiers: correct.map((segment) => segment.identifier),
83585
+ itemBody
83586
+ });
83587
+ }
83588
+ function buildQuestionXml(input, identifier, title) {
83589
+ for (const build2 of QUESTION_XML_BUILDERS) {
83590
+ const xml = build2(input, identifier, title);
83591
+ if (xml !== null) {
83592
+ return xml;
83593
+ }
83594
+ }
83595
+ return null;
83596
+ }
83597
+ function buildCreatedQtiQuestionReference(input) {
83598
+ return {
83599
+ ownership: "owned",
83600
+ reference: {
83601
+ identifier: input.itemIdentifier,
83602
+ href: input.href,
83603
+ testPart: input.partIdentifier,
83604
+ section: input.sectionIdentifier
83605
+ },
83606
+ question: mergeQtiQuestionItem(input.item, input.fallbackItem, input.itemIdentifier, input.metadata)
83607
+ };
83608
+ }
83609
+ function buildHydratedQtiQuestionReference(input) {
83610
+ const identifier = input.reference.reference.identifier;
83611
+ const ownerGameSlug = resolveQtiQuestionOwnerGameSlug(input.item, input.ownerTest);
83612
+ const authoritativeItem = ownerGameSlug ? {
83613
+ ...input.item,
83614
+ metadata: { ...input.item.metadata, ownerGameSlug }
83615
+ } : input.item;
83616
+ const question = mergeQtiQuestionItem(authoritativeItem, input.reference.question, identifier);
83617
+ return {
83618
+ ...input.reference,
83619
+ ownership: isQtiQuestionOwnedByContext(question, { gameSlug: input.gameSlug, testIdentifier: input.testIdentifier }, input.ownerTest) ? "owned" : "shared",
83620
+ question
83621
+ };
83622
+ }
83623
+ function isQtiTestOwnedByGame(test, gameSlug) {
83624
+ return test.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && test.metadata.ownerGameSlug === gameSlug;
83625
+ }
83626
+ function assertQtiTestOwnedByGame(test, gameSlug) {
83627
+ if (!isQtiTestOwnedByGame(test, gameSlug)) {
83628
+ throw new ValidationError("Shared assessment references are read-only. Copy the assessment to create an editable independent test.");
83629
+ }
83630
+ }
83631
+ function qtiTestParts(test) {
83632
+ const parts2 = test["qti-test-part"];
83633
+ if (!Array.isArray(parts2) || parts2.length === 0) {
83634
+ throw new ValidationError(`Assessment ${test.identifier} has no test parts`);
83635
+ }
83636
+ for (const [partIndex, part] of parts2.entries()) {
83637
+ if (!part || typeof part !== "object" || Array.isArray(part)) {
83638
+ throw new ValidationError(`Assessment ${test.identifier} contains an invalid test part at position ${partIndex + 1}`);
83639
+ }
83640
+ const sections = part["qti-assessment-section"];
83641
+ if (!Array.isArray(sections)) {
83642
+ throw new ValidationError(`Assessment ${test.identifier} test part ${partIndex + 1} has no section list`);
83643
+ }
83644
+ }
83645
+ return parts2;
83646
+ }
83647
+ function qtiTestOptionalAttributes(test) {
83648
+ return {
83649
+ ...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
83650
+ ...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
83651
+ ...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
83652
+ ...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
83653
+ };
83654
+ }
83655
+ function buildQtiAssessmentItemCopyPlan(test, targetTestIdentifier, hrefForIdentifier, createIdentifier = newPlaycademyQuestionIdentifier) {
83656
+ const sourceIdentifiers = [];
83657
+ const seen = new Set;
83658
+ for (const part of qtiTestParts(test)) {
83659
+ for (const section of part["qti-assessment-section"]) {
83660
+ for (const reference of section["qti-assessment-item-ref"] ?? []) {
83661
+ if (!seen.has(reference.identifier)) {
83662
+ seen.add(reference.identifier);
83663
+ sourceIdentifiers.push(reference.identifier);
83664
+ }
83665
+ }
83666
+ }
83667
+ }
83668
+ return sourceIdentifiers.map((sourceIdentifier) => {
83669
+ const targetIdentifier = createIdentifier(targetTestIdentifier);
83670
+ return {
83671
+ sourceIdentifier,
83672
+ targetIdentifier,
83673
+ href: hrefForIdentifier(targetIdentifier)
83674
+ };
83675
+ });
83676
+ }
83677
+ function buildQtiTestStructureInput(test, targetTestIdentifier, itemCopies) {
83678
+ const outcomeDeclarations = test["qti-outcome-declaration"];
83679
+ if (outcomeDeclarations !== undefined && !Array.isArray(outcomeDeclarations)) {
83680
+ throw new ValidationError(`Assessment ${test.identifier} has an invalid outcome declaration list`);
83681
+ }
83682
+ return {
83683
+ "qti-test-part": qtiTestParts(test).map((part, partIndex) => ({
83684
+ identifier: targetTestIdentifier ? `${targetTestIdentifier}-part${partIndex + 1}` : part.identifier,
83685
+ navigationMode: part.navigationMode,
83686
+ submissionMode: part.submissionMode,
83687
+ "qti-assessment-section": part["qti-assessment-section"].map((section, sectionIndex) => {
83688
+ let sectionIdentifier = section.identifier;
83689
+ if (targetTestIdentifier) {
83690
+ sectionIdentifier = partIndex === 0 && sectionIndex === 0 ? `${targetTestIdentifier}-section1` : `${targetTestIdentifier}-part${partIndex + 1}-section${sectionIndex + 1}`;
83691
+ }
83692
+ return {
83693
+ identifier: sectionIdentifier,
83694
+ title: section.title,
83695
+ visible: section.visible ?? true,
83696
+ ...section.required !== undefined ? { required: section.required } : {},
83697
+ ...section.fixed !== undefined ? { fixed: section.fixed } : {},
83698
+ sequence: section.sequence ?? sectionIndex + 1,
83699
+ ...section["qti-assessment-item-ref"] ? {
83700
+ "qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item, itemIndex) => {
83701
+ const copy = itemCopies?.get(item.identifier);
83702
+ if (itemCopies && !copy) {
83703
+ throw new ValidationError(`Assessment copy is missing question ${item.identifier}`);
83704
+ }
83705
+ return {
83706
+ identifier: copy?.targetIdentifier ?? item.identifier,
83707
+ href: copy?.href ?? item.href,
83708
+ sequence: item.sequence ?? itemIndex + 1
83709
+ };
83710
+ })
83711
+ } : {}
83712
+ };
83713
+ })
83714
+ })),
83715
+ ...outcomeDeclarations ? {
83716
+ "qti-outcome-declaration": outcomeDeclarations.map((declaration) => ({
83717
+ identifier: declaration.identifier,
83718
+ ...declaration.cardinality !== undefined ? { cardinality: declaration.cardinality } : {},
83719
+ baseType: declaration.baseType,
83720
+ ...declaration.normalMaximum !== undefined ? { normalMaximum: declaration.normalMaximum } : {},
83721
+ ...declaration.normalMinimum !== undefined ? { normalMinimum: declaration.normalMinimum } : {},
83722
+ ...declaration.defaultValue ? {
83723
+ defaultValue: declaration.defaultValue.value !== undefined ? { value: declaration.defaultValue.value } : {}
83724
+ } : {}
83725
+ }))
83726
+ } : {}
83727
+ };
83728
+ }
83729
+ function buildQtiTestUpdateInput(test, title) {
83730
+ return {
83731
+ title,
83732
+ ...qtiTestOptionalAttributes(test),
83733
+ ...test.metadata ? { metadata: test.metadata } : {},
83734
+ ...buildQtiTestStructureInput(test)
83735
+ };
83736
+ }
83737
+ function buildQtiTestCopyInput(source, targetTestIdentifier, metadata2, itemCopies) {
83738
+ const copiesBySourceIdentifier = new Map(itemCopies.map((copy) => [copy.sourceIdentifier, copy]));
83739
+ return {
83740
+ identifier: targetTestIdentifier,
83741
+ title: `${source.title} (copy)`,
83742
+ ...qtiTestOptionalAttributes(source),
83743
+ metadata: metadata2,
83744
+ ...buildQtiTestStructureInput(source, targetTestIdentifier, copiesBySourceIdentifier)
83745
+ };
83746
+ }
83747
+ function validateUniqueQuestionIdentifiers(itemIdentifiers) {
83748
+ if (itemIdentifiers.length === 0 || new Set(itemIdentifiers).size !== itemIdentifiers.length) {
83749
+ throw new ValidationError("Question order must contain unique question identifiers");
83750
+ }
83751
+ }
83752
+ function resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers) {
83753
+ let selectedPart;
83754
+ let selectedSection;
83755
+ for (const part of qtiTestParts(test)) {
83756
+ for (const section of part["qti-assessment-section"]) {
83757
+ if (!itemIdentifier || section["qti-assessment-item-ref"]?.some((item) => item.identifier === itemIdentifier)) {
83758
+ selectedPart = part;
83759
+ selectedSection = section;
83760
+ break;
83761
+ }
83762
+ }
83763
+ if (selectedSection) {
83764
+ break;
83765
+ }
83766
+ }
83767
+ if (!selectedPart || !selectedSection) {
83768
+ throw new ValidationError(itemIdentifier ? `Question ${itemIdentifier} is not referenced by assessment ${qtiTestIdentifier}` : `Assessment ${qtiTestIdentifier} has no section for questions`);
83769
+ }
83770
+ if (expectedItemIdentifiers) {
83771
+ const sectionIdentifiers = selectedSection["qti-assessment-item-ref"]?.map((item) => item.identifier) ?? [];
83772
+ const expectedIdentifiers = new Set(expectedItemIdentifiers);
83773
+ if (expectedIdentifiers.size !== sectionIdentifiers.length || sectionIdentifiers.some((identifier) => !expectedIdentifiers.has(identifier))) {
83774
+ throw new ValidationError("Questions can only be reordered within one complete assessment section");
83775
+ }
83776
+ }
83777
+ return {
83778
+ partIdentifier: selectedPart.identifier,
83779
+ sectionIdentifier: selectedSection.identifier
83780
+ };
83781
+ }
83782
+ var PLAYCADEMY_QTI_AUTHORING_METADATA_KEY = "playcademyAuthoring", QTI_AUTHORING_FIELDS, QUESTION_XML_BUILDERS;
83783
+ var init_timeback_qti_authoring_util = __esm(() => {
83784
+ init_timeback3();
83785
+ init_errors();
83786
+ QTI_AUTHORING_FIELDS = [
83787
+ "type",
83788
+ "qtiVersion",
83789
+ "timeDependent",
83790
+ "adaptive",
83791
+ "preInteraction",
83792
+ "interaction",
83793
+ "postInteraction",
83794
+ "responseDeclarations",
83795
+ "outcomeDeclarations",
83796
+ "responseProcessing",
83797
+ "modalFeedback",
83798
+ "feedbackInline",
83799
+ "feedbackBlock",
83800
+ "rubrics",
83801
+ "stimulus",
83802
+ "content"
83803
+ ];
83804
+ QUESTION_XML_BUILDERS = [
83805
+ buildNumericQuestionXml,
83806
+ buildHottextQuestionXml,
83807
+ buildInlineChoiceQuestionXml
83808
+ ];
83809
+ });
83810
+
83811
+ // ../api-core/src/utils/timeback-qti-library.util.ts
83812
+ function newPlaycademyTestIdentifier() {
83813
+ return `${PLAYCADEMY_QTI_SOURCE_PREFIX}${crypto.randomUUID()}`;
83814
+ }
83815
+ function playcademySourceWhere() {
83816
+ return {
83817
+ identifier: {
83818
+ gte: PLAYCADEMY_QTI_SOURCE_PREFIX,
83819
+ lt: PLAYCADEMY_QTI_SOURCE_UPPER_BOUND
83820
+ }
83821
+ };
83822
+ }
83823
+ function buildQtiLibraryListPlan(params) {
83824
+ const query = params.query?.trim() || undefined;
83825
+ if (!params.source && !query) {
83826
+ throw new Error("An exact QTI source identifier is required for a global lookup");
83827
+ }
83828
+ let where;
83829
+ if (params.source) {
83830
+ where = playcademySourceWhere();
83831
+ } else if (query) {
83832
+ where = { identifier: query };
83833
+ }
83834
+ return {
83835
+ kind: "direct",
83836
+ params: {
83837
+ ...where ? { where } : {},
83838
+ ...params.source && query ? { query } : {},
83839
+ page: params.page,
83840
+ limit: params.limit,
83841
+ ...params.source ? { sort: "identifier", order: "asc" } : {}
83842
+ }
83843
+ };
83844
+ }
83845
+ var PLAYCADEMY_QTI_SOURCE_PREFIX = "playcademy-test-", PLAYCADEMY_QTI_SOURCE_UPPER_BOUND = "playcademy-test.";
83846
+
82975
83847
  // ../api-core/src/services/timeback-assessments.service.ts
82976
83848
  class TimebackAssessmentsService {
83849
+ static QTI_HYDRATION_CONCURRENCY = 8;
82977
83850
  deps;
82978
83851
  constructor(deps) {
82979
83852
  this.deps = deps;
@@ -82990,33 +83863,19 @@ class TimebackAssessmentsService {
82990
83863
  }
82991
83864
  async listAssessments(integrationId) {
82992
83865
  const client = this.requireClient();
82993
- await this.requireIntegration(integrationId);
83866
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId);
82994
83867
  const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
82995
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId),
82996
- orderBy: asc(gameTimebackAssessmentTests.sortOrder)
83868
+ where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
82997
83869
  });
82998
- if (rows.length === 0) {
82999
- return [];
83000
- }
83001
- const assessments = await Promise.all(rows.map(async (row) => {
83870
+ const assessments = await runWithConcurrency(rows, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (row) => {
83002
83871
  try {
83003
83872
  const test = await client.qtiApi.assessmentTests.get(row.qtiTestIdentifier);
83004
- let itemCount = 0;
83005
- for (const part of test["qti-test-part"] ?? []) {
83006
- for (const section of part["qti-assessment-section"] ?? []) {
83007
- itemCount += section["qti-assessment-item-ref"]?.length ?? 0;
83008
- }
83009
- }
83010
83873
  return {
83011
- id: row.id,
83012
- integrationId: row.integrationId,
83013
- qtiTestIdentifier: row.qtiTestIdentifier,
83014
- bankResourceId: row.bankResourceId,
83015
- bankActive: row.bankActive,
83016
- sortOrder: row.sortOrder,
83874
+ ...this.associationSummary(row),
83017
83875
  title: test.title,
83018
- questionCount: itemCount,
83019
- isActive: row.bankActive
83876
+ questionCount: countQtiTestItems(test),
83877
+ available: true,
83878
+ editable: isQtiTestOwnedByGame(test, ownership.gameSlug)
83020
83879
  };
83021
83880
  } catch (error88) {
83022
83881
  addEvent("assessment.qti_fetch_failed", {
@@ -83025,319 +83884,420 @@ class TimebackAssessmentsService {
83025
83884
  "app.error.message": errorMessage(error88)
83026
83885
  });
83027
83886
  return {
83028
- id: row.id,
83029
- integrationId: row.integrationId,
83030
- qtiTestIdentifier: row.qtiTestIdentifier,
83031
- bankResourceId: row.bankResourceId,
83032
- bankActive: row.bankActive,
83033
- sortOrder: row.sortOrder,
83887
+ ...this.associationSummary(row),
83034
83888
  title: row.qtiTestIdentifier,
83035
83889
  questionCount: 0,
83036
- isActive: row.bankActive
83890
+ available: false,
83891
+ editable: false
83037
83892
  };
83038
83893
  }
83039
- }));
83040
- return assessments;
83894
+ });
83895
+ return assessments.toSorted((a, b) => a.title.localeCompare(b.title));
83041
83896
  }
83042
83897
  async createAssessment(integrationId, input) {
83043
83898
  const client = this.requireClient();
83044
- const integration = await this.requireIntegration(integrationId);
83045
- await client.course.ensureAssessmentBank({
83046
- courseId: integration.courseId,
83047
- subject: integration.subject,
83048
- grade: integration.grade
83049
- });
83899
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId);
83900
+ const { integration } = ownership;
83050
83901
  await client.course.createAssessmentTest({
83051
83902
  identifier: input.qtiTestIdentifier,
83052
83903
  title: input.title,
83053
83904
  metadata: {
83905
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
83906
+ ownerGameSlug: ownership.gameSlug,
83054
83907
  integrationId,
83055
83908
  subject: integration.subject,
83056
83909
  grade: String(integration.grade)
83057
83910
  }
83058
83911
  });
83059
- const maxSortOrder = await this.getMaxSortOrder(integrationId);
83060
- const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
83061
- integrationId,
83062
- qtiTestIdentifier: input.qtiTestIdentifier,
83063
- sortOrder: maxSortOrder + 1
83064
- }).returning();
83065
- setAttribute("app.assessment.operation", "create");
83066
- return row;
83067
- }
83068
- async deleteAssessment(integrationId, qtiTestIdentifier) {
83069
- const client = this.requireClient();
83070
- const row = await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83071
- if (row.bankActive) {
83072
- await this.deactivateAssessment(integrationId, qtiTestIdentifier);
83073
- }
83074
83912
  try {
83075
- await client.course.teardownAssessmentTest(qtiTestIdentifier);
83913
+ const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
83914
+ integrationId,
83915
+ qtiTestIdentifier: input.qtiTestIdentifier,
83916
+ purpose: input.purpose,
83917
+ status: "draft"
83918
+ }).returning();
83919
+ setAttribute("app.assessment.operation", "create");
83920
+ return row;
83076
83921
  } catch (error88) {
83077
- addEvent("assessment.qti_cleanup_partial", {
83078
- "app.assessment.qti_test_identifier": qtiTestIdentifier,
83079
- "exception.type": errorType(error88),
83080
- "app.error.message": errorMessage(error88)
83081
- });
83922
+ let committedRow;
83923
+ try {
83924
+ committedRow = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
83925
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, input.qtiTestIdentifier))
83926
+ });
83927
+ } catch (verificationError) {
83928
+ addEvent("assessment.qti_test_create_commit_verification_failed", {
83929
+ "app.assessment.qti_test_identifier": input.qtiTestIdentifier,
83930
+ "exception.type": errorType(verificationError),
83931
+ "app.error.message": errorMessage(verificationError)
83932
+ });
83933
+ throw error88;
83934
+ }
83935
+ if (committedRow) {
83936
+ setAttribute("app.assessment.operation", "create");
83937
+ return committedRow;
83938
+ }
83939
+ try {
83940
+ await client.qtiApi.assessmentTests.delete(input.qtiTestIdentifier);
83941
+ } catch (cleanupError) {
83942
+ if (!isApiError(cleanupError) || cleanupError.statusCode !== 404) {
83943
+ addEvent("assessment.qti_test_create_cleanup_failed", {
83944
+ "app.assessment.qti_test_identifier": input.qtiTestIdentifier,
83945
+ "exception.type": errorType(cleanupError),
83946
+ "app.error.message": errorMessage(cleanupError)
83947
+ });
83948
+ }
83949
+ }
83950
+ throw error88;
83082
83951
  }
83083
- await this.deps.db.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
83084
- setAttribute("app.assessment.operation", "delete");
83085
83952
  }
83086
- async reorderAssessments(integrationId, identifiers) {
83087
- await this.requireIntegration(integrationId);
83088
- await Promise.all(identifiers.map((identifier, i2) => this.deps.db.update(gameTimebackAssessmentTests).set({ sortOrder: i2 }).where(and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, identifier)))));
83089
- }
83090
- async listQuestions(integrationId, qtiTestIdentifier) {
83091
- const client = this.requireClient();
83092
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83093
- return await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
83094
- }
83095
- async createQuestion(integrationId, qtiTestIdentifier, input) {
83096
- const client = this.requireClient();
83097
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83098
- const itemIdentifier = input.identifier;
83099
- if (typeof itemIdentifier !== "string" || !itemIdentifier) {
83100
- throw new ValidationError("Question identifier is required");
83101
- }
83102
- const item = await client.qtiApi.assessmentItems.create(input);
83103
- await this.qtiSectionItems(client, qtiTestIdentifier).add({
83104
- identifier: itemIdentifier,
83105
- href: `${client.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`
83953
+ async updateAssessment(integrationId, qtiTestIdentifier, input) {
83954
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
83955
+ const updates = buildAssessmentAssociationUpdates(row, input);
83956
+ if (input.status !== undefined) {
83957
+ validateAssessmentStatusTransition(row.status, input.status);
83958
+ if (isAssessmentPublicationTransition(row.status, input.status)) {
83959
+ await this.validateAssessmentHasQuestions(qtiTestIdentifier);
83960
+ }
83961
+ }
83962
+ if (input.title !== undefined) {
83963
+ assertDraftAssessment(row);
83964
+ assertAllAssessmentAssociationsDraft(associations);
83965
+ const client = this.requireClient();
83966
+ const test = await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
83967
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
83968
+ assertQtiTestOwnedByGame(test, ownership.gameSlug);
83969
+ await client.qtiApi.assessmentTests.update(qtiTestIdentifier, buildQtiTestUpdateInput(test, input.title));
83970
+ }
83971
+ let updated = row;
83972
+ if (Object.keys(updates).length > 0) {
83973
+ const [updatedRow] = await tx.update(gameTimebackAssessmentTests).set({ ...updates, updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id)).returning();
83974
+ updated = updatedRow ?? row;
83975
+ }
83976
+ setAttribute("app.assessment.operation", input.title !== undefined ? "update_test" : "update_association");
83977
+ return updated;
83106
83978
  });
83107
- return item;
83108
83979
  }
83109
- async updateQuestion(integrationId, qtiTestIdentifier, itemIdentifier, input) {
83110
- const client = this.requireClient();
83111
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83112
- return client.qtiApi.assessmentItems.update(itemIdentifier, input);
83113
- }
83114
- async deleteQuestion(integrationId, qtiTestIdentifier, itemIdentifier) {
83115
- const client = this.requireClient();
83116
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83117
- await this.qtiSectionItems(client, qtiTestIdentifier).remove(itemIdentifier);
83118
- await client.qtiApi.assessmentItems.delete(itemIdentifier);
83980
+ async removeAssessment(integrationId, qtiTestIdentifier) {
83981
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx) => {
83982
+ const plan = planAssessmentRemoval(row.status);
83983
+ if (plan.kind === "delete") {
83984
+ await tx.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
83985
+ } else if (plan.kind === "archive") {
83986
+ await tx.update(gameTimebackAssessmentTests).set({ status: "archived", sortOrder: null, updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id));
83987
+ }
83988
+ if (plan.kind !== "none") {
83989
+ setAttribute("app.assessment.operation", plan.operation);
83990
+ }
83991
+ return { action: plan.action };
83992
+ });
83119
83993
  }
83120
- async reorderQuestions(integrationId, qtiTestIdentifier, items) {
83121
- const client = this.requireClient();
83122
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83123
- const qtiBaseUrl = client.getQtiBaseUrl();
83124
- return this.qtiSectionItems(client, qtiTestIdentifier).reorder({
83125
- items: items.map((item) => ({
83126
- identifier: item.identifier,
83127
- href: item.href ?? `${qtiBaseUrl}/assessment-items/${item.identifier}`,
83128
- sequence: item.sequence
83129
- }))
83994
+ async reorderAssessments(integrationId, purpose, testIdentifiers) {
83995
+ await this.requireIntegration(integrationId);
83996
+ validateUniqueAssessmentIdentifiers(testIdentifiers);
83997
+ const updatedAt = new Date;
83998
+ await this.deps.db.transaction(async (tx) => {
83999
+ const liveRows = await tx.query.gameTimebackAssessmentTests.findMany({
84000
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))
84001
+ });
84002
+ const orderedRows = orderLiveAssessmentRows(liveRows, purpose, testIdentifiers);
84003
+ const positionByRowId = new Map(orderedRows.map((row, index2) => [row.id, index2 + 1]));
84004
+ for (const row of lockOrderAssessmentRows(orderedRows)) {
84005
+ const testIdentifier = row.qtiTestIdentifier;
84006
+ const [updatedRow] = await tx.update(gameTimebackAssessmentTests).set({ sortOrder: positionByRowId.get(row.id), updatedAt }).where(and(eq(gameTimebackAssessmentTests.id, row.id), eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, testIdentifier), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))).returning({ id: gameTimebackAssessmentTests.id });
84007
+ assertAssessmentOrderUpdateSucceeded(updatedRow);
84008
+ }
83130
84009
  });
84010
+ setAttribute("app.assessment.operation", "reorder_live_assessments");
83131
84011
  }
83132
- async activateAssessment(integrationId, qtiTestIdentifier) {
84012
+ async listQuestions(integrationId, qtiTestIdentifier) {
83133
84013
  const client = this.requireClient();
83134
- const integration = await this.requireIntegration(integrationId);
83135
- const row = await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83136
- if (row.bankActive) {
83137
- throw new ValidationError("Assessment is already active");
83138
- }
83139
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
83140
- const qtiTestUrl = `${client.getQtiBaseUrl()}/assessment-tests/${qtiTestIdentifier}`;
83141
- await client.course.ensureAssessmentBank({
83142
- courseId: integration.courseId,
83143
- subject: integration.subject,
83144
- grade: integration.grade
83145
- });
83146
- const parentResource = await client.api.oneroster.resources.get(bankIds.resource);
83147
- const currentResources = parentResource.metadata?.resources ?? [];
83148
- let childResourceId = row.bankResourceId;
83149
- if (childResourceId) {
84014
+ await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
84015
+ const { gameSlug } = await this.requireQtiTestOwnershipContext(integrationId);
84016
+ const result = await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
84017
+ const ownerTests = new Map;
84018
+ const questions = await runWithConcurrency(result.questions, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (reference) => {
84019
+ let item;
83150
84020
  try {
83151
- await client.api.oneroster.resources.update(childResourceId, {
83152
- status: "active",
83153
- title: `Assessment Bank Test: ${qtiTestIdentifier}`,
83154
- vendorResourceId: "",
83155
- metadata: {
83156
- type: "qti",
83157
- subType: "qti-test",
83158
- url: qtiTestUrl
83159
- }
83160
- });
83161
- if (!currentResources.includes(childResourceId)) {
83162
- await client.api.oneroster.resources.update(bankIds.resource, {
83163
- status: "active",
83164
- title: parentResource.title,
83165
- vendorResourceId: parentResource.vendorResourceId,
83166
- vendorId: parentResource.vendorId,
83167
- metadata: {
83168
- ...parentResource.metadata,
83169
- resources: [...currentResources, childResourceId]
83170
- }
83171
- });
83172
- }
84021
+ item = await client.qtiApi.assessmentItems.get(reference.reference.identifier);
83173
84022
  } catch (error88) {
83174
- addEvent("assessment.child_resource_reactivation_failed", {
83175
- "app.assessment.child_resource_id": childResourceId ?? "",
84023
+ addEvent("assessment.qti_question_hydration_failed", {
84024
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
84025
+ "app.assessment.qti_item_identifier": reference.reference.identifier,
83176
84026
  "exception.type": errorType(error88),
83177
84027
  "app.error.message": errorMessage(error88)
83178
84028
  });
83179
- childResourceId = null;
84029
+ return reference;
83180
84030
  }
83181
- }
83182
- if (!childResourceId) {
83183
- for (const resourceId of currentResources) {
83184
- try {
83185
- const resource = await client.api.oneroster.resources.get(resourceId);
83186
- const resourceUrl = resource.metadata?.url;
83187
- if (resourceUrl === qtiTestUrl) {
83188
- childResourceId = resourceId;
83189
- addEvent("assessment.child_resource_reused", {
83190
- "app.assessment.qti_test_identifier": qtiTestIdentifier,
83191
- "app.assessment.child_resource_id": resourceId
83192
- });
83193
- break;
83194
- }
83195
- } catch {}
83196
- }
83197
- if (!childResourceId) {
83198
- const childResult = await client.api.oneroster.resources.create({
83199
- status: "active",
83200
- title: `Assessment Bank Test: ${qtiTestIdentifier}`,
83201
- vendorResourceId: "",
83202
- vendorId: undefined,
83203
- metadata: {
83204
- type: "qti",
83205
- subType: "qti-test",
83206
- url: qtiTestUrl
83207
- }
83208
- });
83209
- childResourceId = childResult.sourcedIdPairs.allocatedSourcedId;
83210
- await client.api.oneroster.resources.update(bankIds.resource, {
83211
- status: "active",
83212
- title: parentResource.title,
83213
- vendorResourceId: parentResource.vendorResourceId,
83214
- vendorId: parentResource.vendorId,
83215
- metadata: {
83216
- ...parentResource.metadata,
83217
- resources: [...currentResources, childResourceId]
83218
- }
84031
+ let ownerTest;
84032
+ try {
84033
+ ownerTest = await this.qtiQuestionOwnerTest(client, item, ownerTests);
84034
+ } catch (error88) {
84035
+ addEvent("assessment.qti_question_owner_test_fetch_failed", {
84036
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
84037
+ "app.assessment.qti_item_identifier": reference.reference.identifier,
84038
+ "exception.type": errorType(error88),
84039
+ "app.error.message": errorMessage(error88)
83219
84040
  });
83220
84041
  }
83221
- }
83222
- await this.deps.db.update(gameTimebackAssessmentTests).set({ bankResourceId: childResourceId, bankActive: true }).where(eq(gameTimebackAssessmentTests.id, row.id));
83223
- setAttributes({
83224
- "app.assessment.operation": "activate",
83225
- "app.assessment.child_resource_id": childResourceId ?? ""
84042
+ return buildHydratedQtiQuestionReference({
84043
+ reference,
84044
+ item,
84045
+ gameSlug,
84046
+ ownerTest,
84047
+ testIdentifier: qtiTestIdentifier
84048
+ });
83226
84049
  });
84050
+ return { ...result, questions };
83227
84051
  }
83228
- async deactivateAssessment(integrationId, qtiTestIdentifier) {
84052
+ async listQuestionLibrary(integrationId, params) {
83229
84053
  const client = this.requireClient();
83230
- const integration = await this.requireIntegration(integrationId);
83231
- const row = await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83232
- if (!row.bankActive) {
83233
- throw new ValidationError("Assessment is already in draft");
83234
- }
83235
- if (!row.bankResourceId) {
83236
- throw new ValidationError("Assessment has no bank resource — activate it first");
83237
- }
83238
- const childResourceId = row.bankResourceId;
83239
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
83240
- try {
83241
- const parentResource = await client.api.oneroster.resources.get(bankIds.resource);
83242
- const currentResources = parentResource.metadata?.resources ?? [];
83243
- const filtered = currentResources.filter((id) => id !== childResourceId);
83244
- if (filtered.length !== currentResources.length) {
83245
- await client.api.oneroster.resources.update(bankIds.resource, {
83246
- status: "active",
83247
- title: parentResource.title,
83248
- vendorResourceId: parentResource.vendorResourceId,
83249
- vendorId: parentResource.vendorId,
83250
- metadata: {
83251
- ...parentResource.metadata,
83252
- resources: filtered
83253
- }
83254
- });
83255
- }
83256
- } catch (error88) {
83257
- addEvent("assessment.parent_resource_update_failed", {
83258
- "app.assessment.bank_resource_id": bankIds.resource,
83259
- "exception.type": errorType(error88),
83260
- "app.error.message": errorMessage(error88)
83261
- });
83262
- }
83263
- try {
83264
- await client.api.oneroster.resources.delete(childResourceId);
83265
- } catch (error88) {
83266
- addEvent("assessment.child_resource_delete_failed", {
83267
- "app.assessment.child_resource_id": childResourceId,
83268
- "exception.type": errorType(error88),
83269
- "app.error.message": errorMessage(error88)
83270
- });
83271
- }
83272
- await this.deps.db.update(gameTimebackAssessmentTests).set({ bankActive: false }).where(eq(gameTimebackAssessmentTests.id, row.id));
83273
- setAttribute("app.assessment.operation", "deactivate");
83274
- }
83275
- isAssessmentActive(row) {
83276
- return row.bankActive;
84054
+ await this.requireIntegration(integrationId);
84055
+ return this.listQtiLibrary(params, async (listParams) => await client.qtiApi.assessmentItems.list(listParams));
83277
84056
  }
83278
- async getBankStatus(integrationId) {
83279
- const integration = await this.requireIntegration(integrationId);
83280
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
83281
- const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
83282
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
83283
- });
83284
- const activeCount = rows.filter((r) => r.bankActive).length;
83285
- return {
83286
- bankIds,
83287
- totalAssessments: rows.length,
83288
- activeAssessments: activeCount,
83289
- draftAssessments: rows.length - activeCount
83290
- };
84057
+ async listTestLibrary(integrationId, params) {
84058
+ const client = this.requireClient();
84059
+ await this.requireIntegration(integrationId);
84060
+ return this.listQtiLibrary(params, async (listParams) => await client.qtiApi.assessmentTests.list(listParams));
83291
84061
  }
83292
- async destroyBank(integrationId) {
84062
+ async copyAssessment(integrationId, sourceTestIdentifier, targetTestIdentifier, purpose) {
83293
84063
  const client = this.requireClient();
83294
- const integration = await this.requireIntegration(integrationId);
83295
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
83296
- const activeRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
83297
- where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId))
84064
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId);
84065
+ const { integration } = ownership;
84066
+ const source = await client.qtiApi.assessmentTests.get(sourceTestIdentifier);
84067
+ const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => this.qtiItemHref(client, identifier));
84068
+ const itemCopies = await runWithConcurrency(itemPlan, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (plan) => {
84069
+ const sourceItem = await client.qtiApi.assessmentItems.get(plan.sourceIdentifier);
84070
+ return {
84071
+ plan,
84072
+ input: buildQtiQuestionCopyInput({
84073
+ source: sourceItem,
84074
+ targetIdentifier: plan.targetIdentifier,
84075
+ targetTestIdentifier,
84076
+ gameSlug: ownership.gameSlug,
84077
+ integrationId
84078
+ })
84079
+ };
83298
84080
  });
84081
+ const testInput = buildQtiTestCopyInput(source, targetTestIdentifier, {
84082
+ ...source.metadata,
84083
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
84084
+ ownerGameSlug: ownership.gameSlug,
84085
+ integrationId,
84086
+ subject: integration.subject,
84087
+ grade: String(integration.grade),
84088
+ copiedFromTestIdentifier: sourceTestIdentifier
84089
+ }, itemPlan);
84090
+ const attemptedItemIdentifiers = [];
84091
+ let testCreationAttempted = false;
84092
+ let associationCreationAttempted = false;
83299
84093
  try {
83300
- await client.api.oneroster.courses.deleteComponentResource(bankIds.componentResource);
84094
+ for (const copy of itemCopies) {
84095
+ attemptedItemIdentifiers.push(copy.plan.targetIdentifier);
84096
+ await client.course.createAssessmentItemXml({
84097
+ xml: copy.input.xml,
84098
+ metadata: copy.input.metadata
84099
+ });
84100
+ }
84101
+ testCreationAttempted = true;
84102
+ await client.qtiApi.assessmentTests.create(testInput);
84103
+ associationCreationAttempted = true;
84104
+ const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
84105
+ integrationId,
84106
+ qtiTestIdentifier: targetTestIdentifier,
84107
+ purpose,
84108
+ status: "draft"
84109
+ }).returning();
84110
+ setAttribute("app.assessment.operation", "copy_test");
84111
+ return row;
83301
84112
  } catch (error88) {
83302
- addEvent("assessment.component_resource_delete_failed", {
83303
- "app.assessment.component_resource_id": bankIds.componentResource,
83304
- "exception.type": errorType(error88),
83305
- "app.error.message": errorMessage(error88)
83306
- });
83307
- }
83308
- for (const row of activeRows) {
83309
- if (row.bankResourceId) {
84113
+ if (associationCreationAttempted) {
84114
+ let committedRow;
83310
84115
  try {
83311
- await client.api.oneroster.resources.delete(row.bankResourceId);
83312
- } catch (error88) {
83313
- addEvent("assessment.child_resource_delete_failed", {
83314
- "app.assessment.child_resource_id": row.bankResourceId,
83315
- "exception.type": errorType(error88),
83316
- "app.error.message": errorMessage(error88)
84116
+ committedRow = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
84117
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, targetTestIdentifier))
83317
84118
  });
84119
+ } catch (verificationError) {
84120
+ addEvent("assessment.qti_test_copy_commit_verification_failed", {
84121
+ "app.assessment.qti_test_identifier": targetTestIdentifier,
84122
+ "exception.type": errorType(verificationError),
84123
+ "app.error.message": errorMessage(verificationError)
84124
+ });
84125
+ throw error88;
84126
+ }
84127
+ if (committedRow) {
84128
+ setAttribute("app.assessment.operation", "copy_test");
84129
+ return committedRow;
83318
84130
  }
83319
84131
  }
84132
+ await this.cleanupQtiAssessmentCopy(client, testCreationAttempted ? targetTestIdentifier : undefined, attemptedItemIdentifiers);
84133
+ throw error88;
83320
84134
  }
83321
- try {
83322
- await client.api.oneroster.resources.delete(bankIds.resource);
83323
- } catch (error88) {
83324
- addEvent("assessment.parent_resource_delete_failed", {
83325
- "app.assessment.bank_resource_id": bankIds.resource,
83326
- "exception.type": errorType(error88),
83327
- "app.error.message": errorMessage(error88)
84135
+ }
84136
+ async createQuestion(integrationId, qtiTestIdentifier, input) {
84137
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
84138
+ const client = this.requireClient();
84139
+ assertAllAssessmentAssociationsDraft(associations);
84140
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
84141
+ const creation = parseQtiQuestionCreationInput(input);
84142
+ const itemIdentifier = newPlaycademyQuestionIdentifier(qtiTestIdentifier);
84143
+ let title;
84144
+ let metadata2;
84145
+ let xml;
84146
+ let structuredInput;
84147
+ let fallbackItem;
84148
+ if (creation.kind === "copy") {
84149
+ const source = await client.qtiApi.assessmentItems.get(creation.sourceItemIdentifier);
84150
+ const copy = buildQtiQuestionCopyInput({
84151
+ source,
84152
+ targetIdentifier: itemIdentifier,
84153
+ targetTestIdentifier: qtiTestIdentifier,
84154
+ gameSlug: ownership.gameSlug,
84155
+ integrationId
84156
+ });
84157
+ title = copy.title;
84158
+ metadata2 = copy.metadata;
84159
+ xml = copy.xml;
84160
+ fallbackItem = {
84161
+ ...source,
84162
+ identifier: itemIdentifier,
84163
+ title,
84164
+ rawXml: xml,
84165
+ metadata: metadata2
84166
+ };
84167
+ } else {
84168
+ structuredInput = creation.input;
84169
+ assertSupportedQtiQuestionInteraction(structuredInput);
84170
+ title = typeof structuredInput.title === "string" ? structuredInput.title.trim() : "";
84171
+ metadata2 = buildOwnedQtiQuestionMetadata(structuredInput, {
84172
+ gameSlug: ownership.gameSlug,
84173
+ testIdentifier: qtiTestIdentifier
84174
+ });
84175
+ xml = buildQuestionXml(structuredInput, itemIdentifier, title);
84176
+ fallbackItem = {
84177
+ ...structuredInput,
84178
+ identifier: itemIdentifier,
84179
+ title,
84180
+ ...xml ? { rawXml: xml } : {}
84181
+ };
84182
+ }
84183
+ const section = await this.qtiSectionItems(client, qtiTestIdentifier, undefined, undefined, ownership.test);
84184
+ const href = this.qtiItemHref(client, itemIdentifier);
84185
+ let item;
84186
+ let itemCreationAttempted = false;
84187
+ let referenceCreationAttempted = false;
84188
+ try {
84189
+ itemCreationAttempted = true;
84190
+ item = xml ? await client.course.createAssessmentItemXml({ xml, metadata: metadata2 }) : await client.qtiApi.assessmentItems.create({
84191
+ ...structuredInput,
84192
+ identifier: itemIdentifier,
84193
+ metadata: metadata2
84194
+ });
84195
+ referenceCreationAttempted = true;
84196
+ await section.items.add({
84197
+ identifier: itemIdentifier,
84198
+ href
84199
+ });
84200
+ } catch (error88) {
84201
+ let referenceRemoved = !referenceCreationAttempted;
84202
+ const creationStatus = isApiError(error88) ? error88.statusCode : undefined;
84203
+ const itemCreationDefinitelyRejected = !referenceCreationAttempted && typeof creationStatus === "number" && creationStatus >= 400 && creationStatus < 500 && creationStatus !== 408;
84204
+ if (referenceCreationAttempted) {
84205
+ try {
84206
+ await section.items.remove(itemIdentifier);
84207
+ referenceRemoved = true;
84208
+ } catch (cleanupError) {
84209
+ referenceRemoved = isApiError(cleanupError) && cleanupError.statusCode === 404;
84210
+ addEvent("assessment.qti_question_reference_cleanup_failed", {
84211
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
84212
+ "app.assessment.qti_item_identifier": itemIdentifier,
84213
+ "exception.type": errorType(cleanupError),
84214
+ "app.error.message": errorMessage(cleanupError)
84215
+ });
84216
+ }
84217
+ }
84218
+ if (itemCreationAttempted && !itemCreationDefinitelyRejected && referenceRemoved) {
84219
+ try {
84220
+ await client.qtiApi.assessmentItems.delete(itemIdentifier);
84221
+ } catch (cleanupError) {
84222
+ addEvent("assessment.qti_question_cleanup_failed", {
84223
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
84224
+ "app.assessment.qti_item_identifier": itemIdentifier,
84225
+ "exception.type": errorType(cleanupError),
84226
+ "app.error.message": errorMessage(cleanupError)
84227
+ });
84228
+ }
84229
+ }
84230
+ throw error88;
84231
+ }
84232
+ setAttribute("app.assessment.operation", creation.kind === "copy" ? "copy_question" : "create_question");
84233
+ return buildCreatedQtiQuestionReference({
84234
+ item,
84235
+ fallbackItem,
84236
+ itemIdentifier,
84237
+ href,
84238
+ partIdentifier: section.partIdentifier,
84239
+ sectionIdentifier: section.sectionIdentifier,
84240
+ metadata: metadata2
83328
84241
  });
83329
- }
83330
- try {
83331
- await client.api.oneroster.courses.deleteComponent(bankIds.component);
83332
- } catch (error88) {
83333
- addEvent("assessment.course_component_delete_failed", {
83334
- "app.assessment.component_id": bankIds.component,
83335
- "exception.type": errorType(error88),
83336
- "app.error.message": errorMessage(error88)
84242
+ });
84243
+ }
84244
+ async updateQuestion(integrationId, qtiTestIdentifier, itemIdentifier, input) {
84245
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
84246
+ const client = this.requireClient();
84247
+ assertAllAssessmentAssociationsDraft(associations);
84248
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
84249
+ const [, item] = await Promise.all([
84250
+ this.qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, undefined, ownership.test),
84251
+ client.qtiApi.assessmentItems.get(itemIdentifier)
84252
+ ]);
84253
+ const ownerTest = await this.qtiQuestionOwnerTest(client, item, undefined, ownership.test);
84254
+ const replacesInteraction = "type" in input || "interaction" in input || "numericTextEntry" in input;
84255
+ assertQtiQuestionEditable(item, { gameSlug: ownership.gameSlug, testIdentifier: qtiTestIdentifier }, ownerTest);
84256
+ assertSupportedQtiQuestionInteraction(replacesInteraction ? input : item);
84257
+ let title = typeof input.title === "string" ? input.title.trim() : "";
84258
+ if (!title) {
84259
+ title = item.title;
84260
+ }
84261
+ const metadata2 = buildOwnedQtiQuestionMetadata(input, { gameSlug: ownership.gameSlug, testIdentifier: qtiTestIdentifier }, item.metadata);
84262
+ const xml = buildQuestionXml(input, itemIdentifier, title);
84263
+ const updated = xml ? await client.course.updateAssessmentItemXml(itemIdentifier, { xml, metadata: metadata2 }) : await client.qtiApi.assessmentItems.update(itemIdentifier, {
84264
+ ...input,
84265
+ title,
84266
+ metadata: metadata2
84267
+ });
84268
+ setAttribute("app.assessment.operation", "update_question");
84269
+ return updated;
84270
+ });
84271
+ }
84272
+ async removeQuestion(integrationId, qtiTestIdentifier, itemIdentifier) {
84273
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
84274
+ const client = this.requireClient();
84275
+ assertAllAssessmentAssociationsDraft(associations);
84276
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
84277
+ const section = await this.qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, undefined, ownership.test);
84278
+ await section.items.remove(itemIdentifier);
84279
+ setAttribute("app.assessment.operation", "remove_question");
84280
+ });
84281
+ }
84282
+ async reorderQuestions(integrationId, qtiTestIdentifier, items) {
84283
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
84284
+ const client = this.requireClient();
84285
+ assertAllAssessmentAssociationsDraft(associations);
84286
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
84287
+ const identifiers = items.map((item) => item.identifier);
84288
+ validateUniqueQuestionIdentifiers(identifiers);
84289
+ const firstIdentifier = identifiers[0];
84290
+ const section = await this.qtiSectionItems(client, qtiTestIdentifier, firstIdentifier, identifiers, ownership.test);
84291
+ const result = await section.items.reorder({
84292
+ items: items.map((item) => ({
84293
+ identifier: item.identifier,
84294
+ href: item.href ?? this.qtiItemHref(client, item.identifier),
84295
+ sequence: item.sequence
84296
+ }))
83337
84297
  });
83338
- }
83339
- await this.deps.db.update(gameTimebackAssessmentTests).set({ bankResourceId: null, bankActive: false }).where(eq(gameTimebackAssessmentTests.integrationId, integrationId));
83340
- setAttribute("app.assessment.operation", "destroy_bank");
84298
+ setAttribute("app.assessment.operation", "reorder_questions");
84299
+ return result;
84300
+ });
83341
84301
  }
83342
84302
  requireClient() {
83343
84303
  if (!this.deps.timeback) {
@@ -83345,8 +84305,18 @@ class TimebackAssessmentsService {
83345
84305
  }
83346
84306
  return this.deps.timeback;
83347
84307
  }
83348
- async requireIntegration(integrationId) {
83349
- const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
84308
+ async withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, action) {
84309
+ return this.deps.db.transaction(async (tx) => {
84310
+ const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier)).orderBy(gameTimebackAssessmentTests.id).for("update");
84311
+ const row = rows.find((candidate) => candidate.integrationId === integrationId);
84312
+ if (!row) {
84313
+ throw new NotFoundError(`Assessment not found: ${qtiTestIdentifier}`);
84314
+ }
84315
+ return action(row, tx, rows);
84316
+ });
84317
+ }
84318
+ async requireIntegration(integrationId, db2 = this.deps.db) {
84319
+ const integration = await db2.query.gameTimebackIntegrations.findFirst({
83350
84320
  where: and(eq(gameTimebackIntegrations.id, integrationId), isActiveGameTimebackIntegrationStatus())
83351
84321
  });
83352
84322
  if (!integration) {
@@ -83354,6 +84324,17 @@ class TimebackAssessmentsService {
83354
84324
  }
83355
84325
  return integration;
83356
84326
  }
84327
+ async requireQtiTestOwnershipContext(integrationId, db2 = this.deps.db) {
84328
+ const integration = await this.requireIntegration(integrationId, db2);
84329
+ const game2 = await db2.query.games.findFirst({
84330
+ where: eq(games.id, integration.gameId),
84331
+ columns: { slug: true }
84332
+ });
84333
+ if (!game2) {
84334
+ throw new NotFoundError(`Game not found for integration: ${integrationId}`);
84335
+ }
84336
+ return { integration, gameSlug: game2.slug };
84337
+ }
83357
84338
  async requireAssessmentRow(integrationId, qtiTestIdentifier) {
83358
84339
  const row = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
83359
84340
  where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier))
@@ -83363,27 +84344,125 @@ class TimebackAssessmentsService {
83363
84344
  }
83364
84345
  return row;
83365
84346
  }
83366
- qtiSectionItems(client, qtiTestIdentifier) {
83367
- return client.qtiApi.assessmentTests.testParts(qtiTestIdentifier).sections(`${qtiTestIdentifier}-part1`).items(`${qtiTestIdentifier}-section1`);
84347
+ async requireDraftAssessmentRow(integrationId, qtiTestIdentifier, lockedRow) {
84348
+ const row = lockedRow ?? await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
84349
+ assertDraftAssessment(row);
84350
+ return row;
83368
84351
  }
83369
- async getMaxSortOrder(integrationId) {
83370
- const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
83371
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId),
83372
- columns: { sortOrder: true }
84352
+ async requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, lockedRow, db2 = this.deps.db) {
84353
+ const client = this.requireClient();
84354
+ await this.requireDraftAssessmentRow(integrationId, qtiTestIdentifier, lockedRow);
84355
+ const test = await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
84356
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId, db2);
84357
+ assertQtiTestOwnedByGame(test, ownership.gameSlug);
84358
+ return { ...ownership, test };
84359
+ }
84360
+ async qtiQuestionOwnerTest(client, item, cache, knownTest) {
84361
+ if (item.metadata && "ownerGameSlug" in item.metadata) {
84362
+ return;
84363
+ }
84364
+ const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
84365
+ if (!ownerTestIdentifier) {
84366
+ return;
84367
+ }
84368
+ if (knownTest?.identifier === ownerTestIdentifier) {
84369
+ return knownTest;
84370
+ }
84371
+ function loadOwnerTest(identifier) {
84372
+ return client.qtiApi.assessmentTests.get(identifier);
84373
+ }
84374
+ if (!cache) {
84375
+ return loadOwnerTest(ownerTestIdentifier);
84376
+ }
84377
+ let ownerTest = cache.get(ownerTestIdentifier);
84378
+ if (!ownerTest) {
84379
+ ownerTest = loadOwnerTest(ownerTestIdentifier);
84380
+ cache.set(ownerTestIdentifier, ownerTest);
84381
+ }
84382
+ return ownerTest;
84383
+ }
84384
+ async assertQtiTestQuestionsSupported(client, qtiTestIdentifier, questions) {
84385
+ const result = questions ?? await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
84386
+ await runWithConcurrency(result.questions, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (reference) => {
84387
+ const item = await client.qtiApi.assessmentItems.get(reference.reference.identifier);
84388
+ assertSupportedQtiQuestionInteraction(item);
83373
84389
  });
83374
- if (rows.length === 0) {
83375
- return -1;
84390
+ }
84391
+ async qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers, knownTest) {
84392
+ const test = knownTest ?? await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
84393
+ const section = resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers);
84394
+ return {
84395
+ ...section,
84396
+ items: client.qtiApi.assessmentTests.testParts(qtiTestIdentifier).sections(section.partIdentifier).items(section.sectionIdentifier)
84397
+ };
84398
+ }
84399
+ qtiItemHref(client, itemIdentifier) {
84400
+ return `${client.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
84401
+ }
84402
+ async cleanupQtiAssessmentCopy(client, testIdentifier, itemIdentifiers) {
84403
+ let testDeleted = !testIdentifier;
84404
+ if (testIdentifier) {
84405
+ try {
84406
+ await client.qtiApi.assessmentTests.delete(testIdentifier);
84407
+ testDeleted = true;
84408
+ } catch (error88) {
84409
+ testDeleted = isApiError(error88) && error88.statusCode === 404;
84410
+ addEvent("assessment.qti_test_copy_cleanup_failed", {
84411
+ "app.assessment.qti_test_identifier": testIdentifier,
84412
+ "exception.type": errorType(error88),
84413
+ "app.error.message": errorMessage(error88)
84414
+ });
84415
+ }
84416
+ }
84417
+ if (!testDeleted) {
84418
+ return;
83376
84419
  }
83377
- return Math.max(...rows.map((r) => r.sortOrder));
84420
+ for (const itemIdentifier of itemIdentifiers.toReversed()) {
84421
+ try {
84422
+ await client.qtiApi.assessmentItems.delete(itemIdentifier);
84423
+ } catch (error88) {
84424
+ addEvent("assessment.qti_question_cleanup_failed", {
84425
+ ...testIdentifier ? { "app.assessment.qti_test_identifier": testIdentifier } : {},
84426
+ "app.assessment.qti_item_identifier": itemIdentifier,
84427
+ "exception.type": errorType(error88),
84428
+ "app.error.message": errorMessage(error88)
84429
+ });
84430
+ }
84431
+ }
84432
+ }
84433
+ associationSummary(row) {
84434
+ return {
84435
+ id: row.id,
84436
+ integrationId: row.integrationId,
84437
+ qtiTestIdentifier: row.qtiTestIdentifier,
84438
+ purpose: row.purpose,
84439
+ status: row.status,
84440
+ sortOrder: row.sortOrder,
84441
+ createdAt: row.createdAt,
84442
+ updatedAt: row.updatedAt
84443
+ };
84444
+ }
84445
+ async listQtiLibrary(params, list) {
84446
+ const plan = buildQtiLibraryListPlan(params);
84447
+ return list(plan.params);
84448
+ }
84449
+ async validateAssessmentHasQuestions(qtiTestIdentifier) {
84450
+ const client = this.requireClient();
84451
+ const result = await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
84452
+ assertAssessmentHasQuestions(result.questions);
84453
+ await this.assertQtiTestQuestionsSupported(client, qtiTestIdentifier, result);
83378
84454
  }
83379
84455
  }
83380
- var init_timeback_assessments_service = __esm(() => {
84456
+ var init_timeback_assessments_service = __esm(async () => {
83381
84457
  init_drizzle_orm();
83382
84458
  init_helpers_index();
83383
84459
  init_tables_index();
83384
84460
  init_spans();
83385
- init_utils6();
84461
+ init_timeback3();
83386
84462
  init_errors();
84463
+ init_timeback_assessment_rules_util();
84464
+ init_timeback_qti_authoring_util();
84465
+ await init_errors8();
83387
84466
  });
83388
84467
 
83389
84468
  // ../api-core/src/utils/timeback-create-integration.util.ts
@@ -85476,10 +86555,10 @@ var init_platform2 = __esm(async () => {
85476
86555
  init_kv_service();
85477
86556
  init_secrets_service();
85478
86557
  init_seed_service();
85479
- init_timeback_assessments_service();
85480
86558
  init_upload_service();
85481
86559
  await __promiseAll([
85482
86560
  init_timeback_admin_service(),
86561
+ init_timeback_assessments_service(),
85483
86562
  init_timeback_service()
85484
86563
  ]);
85485
86564
  });
@@ -142098,16 +143177,6 @@ function requireAnonymous(handler) {
142098
143177
  return handler(ctx);
142099
143178
  };
142100
143179
  }
142101
- function requireAdmin(handler) {
142102
- return async (ctx) => {
142103
- assertAuthenticatedRequest(ctx);
142104
- rejectDashboardWorkerKey(ctx);
142105
- if (ctx.user.role !== "admin") {
142106
- throw ApiError.forbidden("Admin access required");
142107
- }
142108
- return handler(ctx);
142109
- };
142110
- }
142111
143180
  function requireDeveloper(handler) {
142112
143181
  return async (ctx) => {
142113
143182
  assertAuthenticatedRequest(ctx);
@@ -143271,7 +144340,27 @@ var init_session_controller = __esm(() => {
143271
144340
  });
143272
144341
 
143273
144342
  // ../api-core/src/controllers/timeback.controller.ts
143274
- var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
144343
+ function parseQtiLibraryParams(searchParams) {
144344
+ const requestedPage = Number(searchParams.get("page"));
144345
+ const requestedLimit = Number(searchParams.get("limit"));
144346
+ const page = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
144347
+ const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 50) : 20;
144348
+ const query = searchParams.get("q")?.trim() || undefined;
144349
+ const requestedSource = searchParams.get("source");
144350
+ if (requestedSource && requestedSource !== "playcademy") {
144351
+ throw ApiError.badRequest("Unsupported QTI library source");
144352
+ }
144353
+ if (requestedSource !== "playcademy" && !query) {
144354
+ throw ApiError.badRequest("An exact QTI source identifier is required for a global lookup");
144355
+ }
144356
+ return {
144357
+ query,
144358
+ source: requestedSource === "playcademy" ? "playcademy" : undefined,
144359
+ page,
144360
+ limit
144361
+ };
144362
+ }
144363
+ var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, updateAssessment, reorderAssessments, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
143275
144364
  var init_timeback_controller = __esm(() => {
143276
144365
  init_esm();
143277
144366
  init_schemas_index();
@@ -143786,7 +144875,7 @@ var init_timeback_controller = __esm(() => {
143786
144875
  const body2 = await parseRequestBody(ctx.request, ReactivateEnrollmentRequestSchema);
143787
144876
  return ctx.services.timebackAdmin.reactivateEnrollment(body2, ctx.user);
143788
144877
  });
143789
- listAssessments = requireGameManagementAccess(async (ctx) => {
144878
+ listAssessments = requireDeveloper(async (ctx) => {
143790
144879
  const { gameId, courseId } = ctx.params;
143791
144880
  if (!gameId || !courseId) {
143792
144881
  throw ApiError.badRequest("Missing gameId or courseId parameter");
@@ -143794,40 +144883,40 @@ var init_timeback_controller = __esm(() => {
143794
144883
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143795
144884
  return ctx.services.timebackAssessments.listAssessments(integrationId);
143796
144885
  });
143797
- createAssessment = requireGameManagementAccess(async (ctx) => {
144886
+ createAssessment = requireDeveloper(async (ctx) => {
143798
144887
  const { gameId, courseId } = ctx.params;
143799
144888
  if (!gameId || !courseId) {
143800
144889
  throw ApiError.badRequest("Missing gameId or courseId parameter");
143801
144890
  }
143802
144891
  const body2 = await parseRequestBody(ctx.request, CreateAssessmentRequestSchema);
143803
144892
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143804
- const shortId = crypto.randomUUID().slice(0, 8);
143805
- const qtiTestIdentifier = `assessment-${shortId}`;
144893
+ const qtiTestIdentifier = newPlaycademyTestIdentifier();
143806
144894
  return ctx.services.timebackAssessments.createAssessment(integrationId, {
143807
144895
  title: body2.title,
144896
+ purpose: body2.purpose,
143808
144897
  qtiTestIdentifier
143809
144898
  });
143810
144899
  });
143811
- deleteAssessment = requireAdmin(async (ctx) => {
144900
+ updateAssessment = requireDeveloper(async (ctx) => {
143812
144901
  const { gameId, courseId, testIdentifier } = ctx.params;
143813
144902
  if (!gameId || !courseId || !testIdentifier) {
143814
144903
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
143815
144904
  }
143816
144905
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143817
- await ctx.services.timebackAssessments.deleteAssessment(integrationId, testIdentifier);
143818
- return { success: true };
144906
+ const body2 = await parseRequestBody(ctx.request, UpdateAssessmentRequestSchema);
144907
+ return ctx.services.timebackAssessments.updateAssessment(integrationId, testIdentifier, body2);
143819
144908
  });
143820
- reorderAssessments = requireGameManagementAccess(async (ctx) => {
144909
+ reorderAssessments = requireDeveloper(async (ctx) => {
143821
144910
  const { gameId, courseId } = ctx.params;
143822
144911
  if (!gameId || !courseId) {
143823
144912
  throw ApiError.badRequest("Missing gameId or courseId parameter");
143824
144913
  }
143825
144914
  const body2 = await parseRequestBody(ctx.request, ReorderAssessmentsRequestSchema);
143826
144915
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143827
- await ctx.services.timebackAssessments.reorderAssessments(integrationId, body2.identifiers);
144916
+ await ctx.services.timebackAssessments.reorderAssessments(integrationId, body2.purpose, body2.testIdentifiers);
143828
144917
  return { success: true };
143829
144918
  });
143830
- reorderQuestions = requireGameManagementAccess(async (ctx) => {
144919
+ reorderQuestions = requireDeveloper(async (ctx) => {
143831
144920
  const { gameId, courseId, testIdentifier } = ctx.params;
143832
144921
  if (!gameId || !courseId || !testIdentifier) {
143833
144922
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
@@ -143837,33 +144926,51 @@ var init_timeback_controller = __esm(() => {
143837
144926
  await ctx.services.timebackAssessments.reorderQuestions(integrationId, testIdentifier, body2.items);
143838
144927
  return { success: true };
143839
144928
  });
143840
- activateAssessment = requireGameManagementAccess(async (ctx) => {
144929
+ removeAssessment = requireDeveloper(async (ctx) => {
143841
144930
  const { gameId, courseId, testIdentifier } = ctx.params;
143842
144931
  if (!gameId || !courseId || !testIdentifier) {
143843
144932
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
143844
144933
  }
143845
144934
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143846
- await ctx.services.timebackAssessments.activateAssessment(integrationId, testIdentifier);
143847
- return { success: true };
144935
+ return ctx.services.timebackAssessments.removeAssessment(integrationId, testIdentifier);
143848
144936
  });
143849
- deactivateAssessment = requireGameManagementAccess(async (ctx) => {
144937
+ listQuestions = requireDeveloper(async (ctx) => {
143850
144938
  const { gameId, courseId, testIdentifier } = ctx.params;
143851
144939
  if (!gameId || !courseId || !testIdentifier) {
143852
144940
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
143853
144941
  }
143854
144942
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143855
- await ctx.services.timebackAssessments.deactivateAssessment(integrationId, testIdentifier);
143856
- return { success: true };
144943
+ return ctx.services.timebackAssessments.listQuestions(integrationId, testIdentifier);
143857
144944
  });
143858
- listQuestions = requireGameManagementAccess(async (ctx) => {
143859
- const { gameId, courseId, testIdentifier } = ctx.params;
143860
- if (!gameId || !courseId || !testIdentifier) {
143861
- throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
144945
+ listQuestionLibrary = requireDeveloper(async (ctx) => {
144946
+ const { gameId, courseId } = ctx.params;
144947
+ if (!gameId || !courseId) {
144948
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
143862
144949
  }
144950
+ const params = parseQtiLibraryParams(ctx.url.searchParams);
143863
144951
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143864
- return ctx.services.timebackAssessments.listQuestions(integrationId, testIdentifier);
144952
+ return ctx.services.timebackAssessments.listQuestionLibrary(integrationId, params);
144953
+ });
144954
+ listTestLibrary = requireDeveloper(async (ctx) => {
144955
+ const { gameId, courseId } = ctx.params;
144956
+ if (!gameId || !courseId) {
144957
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
144958
+ }
144959
+ const params = parseQtiLibraryParams(ctx.url.searchParams);
144960
+ const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
144961
+ return ctx.services.timebackAssessments.listTestLibrary(integrationId, params);
143865
144962
  });
143866
- createQuestion = requireGameManagementAccess(async (ctx) => {
144963
+ copyAssessment = requireDeveloper(async (ctx) => {
144964
+ const { gameId, courseId } = ctx.params;
144965
+ if (!gameId || !courseId) {
144966
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
144967
+ }
144968
+ const body2 = await parseRequestBody(ctx.request, CopyAssessmentRequestSchema);
144969
+ const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
144970
+ const targetTestIdentifier = newPlaycademyTestIdentifier();
144971
+ return ctx.services.timebackAssessments.copyAssessment(integrationId, body2.testIdentifier, targetTestIdentifier, body2.purpose);
144972
+ });
144973
+ createQuestion = requireDeveloper(async (ctx) => {
143867
144974
  const { gameId, courseId, testIdentifier } = ctx.params;
143868
144975
  if (!gameId || !courseId || !testIdentifier) {
143869
144976
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
@@ -143872,7 +144979,7 @@ var init_timeback_controller = __esm(() => {
143872
144979
  const body2 = await ctx.request.json();
143873
144980
  return ctx.services.timebackAssessments.createQuestion(integrationId, testIdentifier, body2);
143874
144981
  });
143875
- updateQuestion = requireGameManagementAccess(async (ctx) => {
144982
+ updateQuestion = requireDeveloper(async (ctx) => {
143876
144983
  const { gameId, courseId, testIdentifier, itemIdentifier } = ctx.params;
143877
144984
  if (!gameId || !courseId || !testIdentifier || !itemIdentifier) {
143878
144985
  throw ApiError.badRequest("Missing gameId, courseId, testIdentifier, or itemIdentifier parameter");
@@ -143881,30 +144988,13 @@ var init_timeback_controller = __esm(() => {
143881
144988
  const body2 = await ctx.request.json();
143882
144989
  return ctx.services.timebackAssessments.updateQuestion(integrationId, testIdentifier, itemIdentifier, body2);
143883
144990
  });
143884
- deleteQuestion = requireGameManagementAccess(async (ctx) => {
144991
+ removeQuestion = requireDeveloper(async (ctx) => {
143885
144992
  const { gameId, courseId, testIdentifier, itemIdentifier } = ctx.params;
143886
144993
  if (!gameId || !courseId || !testIdentifier || !itemIdentifier) {
143887
144994
  throw ApiError.badRequest("Missing gameId, courseId, testIdentifier, or itemIdentifier parameter");
143888
144995
  }
143889
144996
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143890
- await ctx.services.timebackAssessments.deleteQuestion(integrationId, testIdentifier, itemIdentifier);
143891
- return { success: true };
143892
- });
143893
- getAssessmentBankStatus = requireGameManagementAccess(async (ctx) => {
143894
- const { gameId, courseId } = ctx.params;
143895
- if (!gameId || !courseId) {
143896
- throw ApiError.badRequest("Missing gameId or courseId parameter");
143897
- }
143898
- const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143899
- return ctx.services.timebackAssessments.getBankStatus(integrationId);
143900
- });
143901
- destroyAssessmentBank = requireAdmin(async (ctx) => {
143902
- const { gameId, courseId } = ctx.params;
143903
- if (!gameId || !courseId) {
143904
- throw ApiError.badRequest("Missing gameId or courseId parameter");
143905
- }
143906
- const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143907
- await ctx.services.timebackAssessments.destroyBank(integrationId);
144997
+ await ctx.services.timebackAssessments.removeQuestion(integrationId, testIdentifier, itemIdentifier);
143908
144998
  return { success: true };
143909
144999
  });
143910
145000
  timeback2 = defineControllerNames("timeback", {
@@ -143949,17 +145039,17 @@ var init_timeback_controller = __esm(() => {
143949
145039
  reactivateEnrollment,
143950
145040
  listAssessments,
143951
145041
  createAssessment,
143952
- deleteAssessment,
145042
+ updateAssessment,
143953
145043
  reorderAssessments,
145044
+ removeAssessment,
143954
145045
  reorderQuestions,
143955
- activateAssessment,
143956
- deactivateAssessment,
143957
145046
  listQuestions,
145047
+ listTestLibrary,
145048
+ copyAssessment,
145049
+ listQuestionLibrary,
143958
145050
  createQuestion,
143959
145051
  updateQuestion,
143960
- deleteQuestion,
143961
- getAssessmentBankStatus,
143962
- destroyAssessmentBank
145052
+ removeQuestion
143963
145053
  });
143964
145054
  });
143965
145055