@playcademy/sandbox 0.6.1-beta.8 → 0.7.0

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