@playcademy/vite-plugin 1.1.3-beta.8 → 1.1.3-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1666 -566
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -24314,7 +24314,7 @@ import path2 from "node:path";
24314
24314
  // package.json
24315
24315
  var package_default = {
24316
24316
  name: "@playcademy/vite-plugin",
24317
- version: "1.1.3-beta.8",
24317
+ version: "1.1.3-beta.9",
24318
24318
  type: "module",
24319
24319
  exports: {
24320
24320
  ".": {
@@ -25846,7 +25846,7 @@ var package_default2;
25846
25846
  var init_package = __esm(() => {
25847
25847
  package_default2 = {
25848
25848
  name: "@playcademy/sandbox",
25849
- version: "0.6.1-beta.8",
25849
+ version: "0.6.1-beta.9",
25850
25850
  description: "Local development server for Playcademy game development",
25851
25851
  type: "module",
25852
25852
  exports: {
@@ -36392,6 +36392,8 @@ var init_table6 = __esm(() => {
36392
36392
  }));
36393
36393
  });
36394
36394
  var gameTimebackIntegrationStatusEnum;
36395
+ var gameTimebackAssessmentPurposeEnum;
36396
+ var gameTimebackAssessmentStatusEnum;
36395
36397
  var gameTimebackIntegrations;
36396
36398
  var gameTimebackAssessmentTests;
36397
36399
  var gameTimebackMetricDiscrepancyVerifications;
@@ -36405,6 +36407,15 @@ var init_table7 = __esm(() => {
36405
36407
  "active",
36406
36408
  "deactivated"
36407
36409
  ]);
36410
+ gameTimebackAssessmentPurposeEnum = pgEnum("game_timeback_assessment_purpose", [
36411
+ "end_of_course",
36412
+ "diagnostic"
36413
+ ]);
36414
+ gameTimebackAssessmentStatusEnum = pgEnum("game_timeback_assessment_status", [
36415
+ "draft",
36416
+ "live",
36417
+ "archived"
36418
+ ]);
36408
36419
  gameTimebackIntegrations = pgTable("game_timeback_integrations", {
36409
36420
  id: uuid("id").primaryKey().defaultRandom(),
36410
36421
  gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
@@ -36425,10 +36436,11 @@ var init_table7 = __esm(() => {
36425
36436
  id: uuid("id").primaryKey().defaultRandom(),
36426
36437
  integrationId: uuid("integration_id").notNull().references(() => gameTimebackIntegrations.id, { onDelete: "cascade" }),
36427
36438
  qtiTestIdentifier: text("qti_test_identifier").notNull(),
36428
- bankResourceId: text("bank_resource_id"),
36429
- bankActive: boolean("bank_active").notNull().default(false),
36430
- sortOrder: integer("sort_order").notNull().default(0),
36431
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
36439
+ purpose: gameTimebackAssessmentPurposeEnum("purpose").notNull().default("end_of_course"),
36440
+ status: gameTimebackAssessmentStatusEnum("status").notNull().default("draft"),
36441
+ sortOrder: integer("sort_order"),
36442
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
36443
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
36432
36444
  }, (table3) => [
36433
36445
  uniqueIndex("game_timeback_assessment_tests_integration_qti_idx").on(table3.integrationId, table3.qtiTestIdentifier)
36434
36446
  ]);
@@ -36478,6 +36490,8 @@ __export(exports_tables_index, {
36478
36490
  gameTimebackIntegrations: () => gameTimebackIntegrations,
36479
36491
  gameTimebackIntegrationStatusEnum: () => gameTimebackIntegrationStatusEnum,
36480
36492
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
36493
+ gameTimebackAssessmentStatusEnum: () => gameTimebackAssessmentStatusEnum,
36494
+ gameTimebackAssessmentPurposeEnum: () => gameTimebackAssessmentPurposeEnum,
36481
36495
  gameTimebackActivityCompletions: () => gameTimebackActivityCompletions,
36482
36496
  gameScoresRelations: () => gameScoresRelations,
36483
36497
  gameScores: () => gameScores,
@@ -54578,6 +54592,25 @@ function sleep(ms) {
54578
54592
  }
54579
54593
  return new Promise((resolve2) => setTimeout(resolve2, ms));
54580
54594
  }
54595
+ async function runWithConcurrency(items, concurrency, worker) {
54596
+ if (items.length === 0) {
54597
+ return [];
54598
+ }
54599
+ const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
54600
+ const results = Array.from({ length: items.length });
54601
+ let nextIndex = 0;
54602
+ await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
54603
+ while (true) {
54604
+ const currentIndex = nextIndex;
54605
+ nextIndex++;
54606
+ if (currentIndex >= items.length) {
54607
+ return;
54608
+ }
54609
+ results[currentIndex] = await worker(items[currentIndex]);
54610
+ }
54611
+ }));
54612
+ return results;
54613
+ }
54581
54614
  function isObject(value) {
54582
54615
  return typeof value === "object" && value !== null;
54583
54616
  }
@@ -56094,7 +56127,7 @@ class KVBackupService {
56094
56127
  "app.kv_backup.dry_run": dryRun,
56095
56128
  "app.kv_backup.game_slug": options.gameSlug
56096
56129
  });
56097
- const results = await KVBackupService.runWithConcurrency(targets, namespaceConcurrency, (target) => this.backupNamespace(target, {
56130
+ const results = await runWithConcurrency(targets, namespaceConcurrency, (target) => this.backupNamespace(target, {
56098
56131
  bucketName,
56099
56132
  stage,
56100
56133
  runId,
@@ -56160,7 +56193,7 @@ class KVBackupService {
56160
56193
  }
56161
56194
  static async fetchNamespaceEntries(cloudflare2, namespaceId, keyConcurrency = DEFAULT_KEY_CONCURRENCY) {
56162
56195
  const keys = await KVBackupService.withRetries(`List KV keys for namespace ${namespaceId}`, () => cloudflare2.kv.listKeys(namespaceId));
56163
- return KVBackupService.runWithConcurrency(keys, keyConcurrency, async (key) => {
56196
+ return runWithConcurrency(keys, keyConcurrency, async (key) => {
56164
56197
  const safeLabel = KVBackupService.redactKeyForLog(key.name);
56165
56198
  const value = await KVBackupService.withRetries(`Fetch KV value ${safeLabel}`, () => cloudflare2.kv.getValue(namespaceId, key.name));
56166
56199
  const metadata2 = KVBackupService.parseMetadata(key.metadata);
@@ -56337,25 +56370,6 @@ class KVBackupService {
56337
56370
  }
56338
56371
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
56339
56372
  }
56340
- static async runWithConcurrency(items, concurrency, worker) {
56341
- if (items.length === 0) {
56342
- return [];
56343
- }
56344
- const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
56345
- const results = Array.from({ length: items.length });
56346
- let nextIndex = 0;
56347
- await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
56348
- while (true) {
56349
- const currentIndex = nextIndex;
56350
- nextIndex++;
56351
- if (currentIndex >= items.length) {
56352
- return;
56353
- }
56354
- results[currentIndex] = await worker(items[currentIndex]);
56355
- }
56356
- }));
56357
- return results;
56358
- }
56359
56373
  }
56360
56374
  var BACKUP_SCHEMA_VERSION = 1;
56361
56375
  var DEFAULT_NAMESPACE_CONCURRENCY = 2;
@@ -58718,12 +58732,14 @@ var EnrollStudentRequestSchema;
58718
58732
  var UnenrollStudentRequestSchema;
58719
58733
  var ReactivateEnrollmentRequestSchema;
58720
58734
  var VerifyTimebackMetricDiscrepancyRequestSchema;
58721
- var InsertAssessmentTestSchema;
58735
+ var AssessmentPurposeSchema;
58736
+ var AssessmentStatusSchema;
58722
58737
  var CreateAssessmentRequestSchema;
58738
+ var UpdateAssessmentRequestSchema;
58739
+ var CopyAssessmentRequestSchema;
58723
58740
  var ReorderAssessmentsRequestSchema;
58724
58741
  var ReorderQuestionsRequestSchema;
58725
58742
  var init_schemas4 = __esm(() => {
58726
- init_drizzle_zod();
58727
58743
  init_esm();
58728
58744
  init_table7();
58729
58745
  TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
@@ -58976,15 +58992,26 @@ var init_schemas4 = __esm(() => {
58976
58992
  runId: exports_external.string().uuid(),
58977
58993
  activityId: exports_external.string().min(1).optional()
58978
58994
  });
58979
- InsertAssessmentTestSchema = createInsertSchema(gameTimebackAssessmentTests).omit({
58980
- id: true,
58981
- createdAt: true
58982
- });
58995
+ AssessmentPurposeSchema = exports_external.enum(gameTimebackAssessmentPurposeEnum.enumValues);
58996
+ AssessmentStatusSchema = exports_external.enum(gameTimebackAssessmentStatusEnum.enumValues);
58983
58997
  CreateAssessmentRequestSchema = exports_external.object({
58984
- title: exports_external.string().min(1, "Assessment title is required")
58998
+ title: exports_external.string().min(1, "Assessment title is required"),
58999
+ purpose: AssessmentPurposeSchema
59000
+ });
59001
+ UpdateAssessmentRequestSchema = exports_external.object({
59002
+ title: exports_external.string().trim().min(1, "Assessment title is required").optional(),
59003
+ purpose: AssessmentPurposeSchema.optional(),
59004
+ status: AssessmentStatusSchema.optional()
59005
+ }).refine((input) => input.title !== undefined || input.purpose !== undefined || input.status !== undefined, {
59006
+ message: "Title, purpose, or status is required"
59007
+ });
59008
+ CopyAssessmentRequestSchema = exports_external.object({
59009
+ testIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
59010
+ purpose: AssessmentPurposeSchema
58985
59011
  });
58986
59012
  ReorderAssessmentsRequestSchema = exports_external.object({
58987
- identifiers: exports_external.array(exports_external.string().min(1)).min(1, "At least one identifier is required")
59013
+ purpose: AssessmentPurposeSchema,
59014
+ testIdentifiers: exports_external.array(exports_external.string().trim().min(1, "Assessment identifier is required")).min(1, "At least one assessment is required")
58988
59015
  });
58989
59016
  ReorderQuestionsRequestSchema = exports_external.object({
58990
59017
  items: exports_external.array(exports_external.object({
@@ -80449,7 +80476,7 @@ var INITIAL_RETRY_DELAY_MS2 = 1000;
80449
80476
  var DEFAULT_LIMIT2 = 100;
80450
80477
  var DEFAULT_MAX_ITEMS2 = 1e4;
80451
80478
  var Paginator6;
80452
- var init_chunk_m54fefpq = __esm(async () => {
80479
+ var init_chunk_pt7w63g2 = __esm(async () => {
80453
80480
  init_chunk_6jf1natv();
80454
80481
  ApiError3 = class ApiError32 extends Error {
80455
80482
  statusCode;
@@ -95770,8 +95797,7 @@ function resolveToProvider23(config4, registry3 = DEFAULT_PROVIDER_REGISTRY2) {
95770
95797
  function translateParams2(params) {
95771
95798
  const {
95772
95799
  orderBy,
95773
- filter: _2,
95774
- fields: __,
95800
+ fields: _2,
95775
95801
  ...rest
95776
95802
  } = params;
95777
95803
  return {
@@ -95779,6 +95805,23 @@ function translateParams2(params) {
95779
95805
  order: orderBy
95780
95806
  };
95781
95807
  }
95808
+ function buildListQueryParams(params) {
95809
+ const queryParams = {};
95810
+ const filter = params.where ? whereToFilter2(params.where) : undefined;
95811
+ if (filter !== undefined)
95812
+ queryParams.filter = filter;
95813
+ if (params.query !== undefined)
95814
+ queryParams.query = params.query;
95815
+ if (params.page !== undefined)
95816
+ queryParams.page = params.page;
95817
+ if (params.limit !== undefined)
95818
+ queryParams.limit = params.limit;
95819
+ if (params.sort)
95820
+ queryParams.sort = params.sort;
95821
+ if (params.order)
95822
+ queryParams.order = params.order;
95823
+ return queryParams;
95824
+ }
95782
95825
 
95783
95826
  class AssessmentItemsResource2 {
95784
95827
  transport;
@@ -95787,19 +95830,8 @@ class AssessmentItemsResource2 {
95787
95830
  }
95788
95831
  list(params = {}) {
95789
95832
  validatePageListParams2(params);
95790
- const queryParams = {};
95791
- if (params.query !== undefined)
95792
- queryParams.query = params.query;
95793
- if (params.page !== undefined)
95794
- queryParams.page = params.page;
95795
- if (params.limit !== undefined)
95796
- queryParams.limit = params.limit;
95797
- if (params.sort)
95798
- queryParams.sort = params.sort;
95799
- if (params.order)
95800
- queryParams.order = params.order;
95801
95833
  return this.transport.request("/assessment-items", {
95802
- params: queryParams
95834
+ params: buildListQueryParams(params)
95803
95835
  });
95804
95836
  }
95805
95837
  stream(params = {}) {
@@ -95922,20 +95954,9 @@ class TestPartSectionsHelper2 {
95922
95954
  }
95923
95955
  list(params = {}) {
95924
95956
  validatePageListParams2(params);
95925
- const queryParams = {};
95926
- if (params.query !== undefined)
95927
- queryParams.query = params.query;
95928
- if (params.page !== undefined)
95929
- queryParams.page = params.page;
95930
- if (params.limit !== undefined)
95931
- queryParams.limit = params.limit;
95932
- if (params.sort)
95933
- queryParams.sort = params.sort;
95934
- if (params.order)
95935
- queryParams.order = params.order;
95936
95957
  const path3 = `/assessment-tests/${encodeURIComponent(this.testId)}/test-parts/${encodeURIComponent(this.testPartId)}/sections`;
95937
95958
  return this.transport.request(path3, {
95938
- params: queryParams
95959
+ params: buildListQueryParams(params)
95939
95960
  });
95940
95961
  }
95941
95962
  get(identifier) {
@@ -95982,19 +96003,10 @@ class AssessmentTestPartsHelper2 {
95982
96003
  }
95983
96004
  list(params = {}) {
95984
96005
  validatePageListParams2(params);
95985
- const queryParams = {};
95986
- if (params.query !== undefined)
95987
- queryParams.query = params.query;
95988
- if (params.page !== undefined)
95989
- queryParams.page = params.page;
95990
- if (params.limit !== undefined)
95991
- queryParams.limit = params.limit;
95992
- if (params.sort)
95993
- queryParams.sort = params.sort;
95994
- if (params.order)
95995
- queryParams.order = params.order;
95996
96006
  const path3 = `/assessment-tests/${encodeURIComponent(this.testId)}/test-parts`;
95997
- return this.transport.request(path3, { params: queryParams });
96007
+ return this.transport.request(path3, {
96008
+ params: buildListQueryParams(params)
96009
+ });
95998
96010
  }
95999
96011
  get(identifier) {
96000
96012
  validateNonEmptyString2(identifier, "testPartId");
@@ -96037,19 +96049,8 @@ class AssessmentTestsResource2 {
96037
96049
  }
96038
96050
  list(params = {}) {
96039
96051
  validatePageListParams2(params);
96040
- const queryParams = {};
96041
- if (params.query !== undefined)
96042
- queryParams.query = params.query;
96043
- if (params.page !== undefined)
96044
- queryParams.page = params.page;
96045
- if (params.limit !== undefined)
96046
- queryParams.limit = params.limit;
96047
- if (params.sort)
96048
- queryParams.sort = params.sort;
96049
- if (params.order)
96050
- queryParams.order = params.order;
96051
96052
  return this.transport.request("/assessment-tests", {
96052
- params: queryParams
96053
+ params: buildListQueryParams(params)
96053
96054
  });
96054
96055
  }
96055
96056
  stream(params = {}) {
@@ -96155,19 +96156,8 @@ class StimuliResource2 {
96155
96156
  }
96156
96157
  list(params = {}) {
96157
96158
  validatePageListParams2(params);
96158
- const queryParams = {};
96159
- if (params.query !== undefined)
96160
- queryParams.query = params.query;
96161
- if (params.page !== undefined)
96162
- queryParams.page = params.page;
96163
- if (params.limit !== undefined)
96164
- queryParams.limit = params.limit;
96165
- if (params.sort)
96166
- queryParams.sort = params.sort;
96167
- if (params.order)
96168
- queryParams.order = params.order;
96169
96159
  return this.transport.request("/stimuli", {
96170
- params: queryParams
96160
+ params: buildListQueryParams(params)
96171
96161
  });
96172
96162
  }
96173
96163
  stream(params = {}) {
@@ -96540,7 +96530,7 @@ var init_dist3 = __esm(async () => {
96540
96530
  init_v42();
96541
96531
  init_v42();
96542
96532
  init_v42();
96543
- await init_chunk_m54fefpq();
96533
+ await init_chunk_pt7w63g2();
96544
96534
  QTI_ENV_VARS2 = {
96545
96535
  baseUrl: ["TIMEBACK_API_BASE_URL", "TIMEBACK_BASE_URL", "QTI_BASE_URL"],
96546
96536
  clientId: ["TIMEBACK_API_CLIENT_ID", "TIMEBACK_CLIENT_ID", "QTI_CLIENT_ID"],
@@ -103667,13 +103657,6 @@ function deriveSourcedIds(courseId) {
103667
103657
  componentResource: `${courseId}-cr`
103668
103658
  };
103669
103659
  }
103670
- function deriveAssessmentBankIds(courseId) {
103671
- return {
103672
- component: `${courseId}-assessment-bank-component`,
103673
- resource: `${courseId}-assessment-bank-resource`,
103674
- componentResource: `${courseId}-assessment-bank-cr`
103675
- };
103676
- }
103677
103660
  function validateProgressData(progressData) {
103678
103661
  if (!progressData.subject) {
103679
103662
  throw new ConfigurationError("subject", "Subject is required for Caliper events. Provide it in progressData.subject");
@@ -104962,59 +104945,22 @@ class CourseAssessments {
104962
104945
  constructor(core3) {
104963
104946
  this.core = core3;
104964
104947
  }
104965
- async ensureBank(input) {
104966
- const oneroster = this.core.api.oneroster;
104967
- const bankIds = deriveAssessmentBankIds(input.courseId);
104968
- try {
104969
- await oneroster.courses.createComponent({
104970
- sourcedId: bankIds.component,
104971
- status: "active",
104972
- title: "Test Out",
104973
- course: { sourcedId: input.courseId },
104974
- sortOrder: 9999,
104975
- metadata: { lessonType: "test-out" }
104976
- });
104977
- } catch (error88) {
104978
- if (!isAlreadyExists(error88)) {
104979
- throw error88;
104980
- }
104981
- }
104982
- try {
104983
- await oneroster.resources.create({
104984
- sourcedId: bankIds.resource,
104985
- status: "active",
104986
- title: "Assessment Bank",
104987
- vendorResourceId: "",
104988
- vendorId: undefined,
104989
- metadata: {
104990
- type: "assessment-bank",
104991
- resources: [],
104992
- lessonType: "test-out",
104993
- subject: input.subject,
104994
- grade: String(input.grade)
104995
- }
104996
- });
104997
- } catch (error88) {
104998
- if (!isAlreadyExists(error88)) {
104999
- throw error88;
105000
- }
105001
- }
105002
- try {
105003
- await oneroster.courses.createComponentResource({
105004
- sourcedId: bankIds.componentResource,
105005
- status: "active",
105006
- title: "Test Out",
105007
- resource: { sourcedId: bankIds.resource },
105008
- courseComponent: { sourcedId: bankIds.component },
105009
- sortOrder: 9999,
105010
- metadata: { lessonType: "test-out" },
105011
- lessonType: "test-out"
105012
- });
105013
- } catch (error88) {
105014
- if (!isAlreadyExists(error88)) {
105015
- throw error88;
104948
+ createItemFromXml(input) {
104949
+ return this.core.qti.assessmentItems.createFromXml({
104950
+ format: "xml",
104951
+ xml: input.xml,
104952
+ ...input.metadata ? { metadata: input.metadata } : {}
104953
+ });
104954
+ }
104955
+ updateItemFromXml(identifier, input) {
104956
+ return this.core.qti.getTransport().request(`/assessment-items/${encodeURIComponent(identifier)}`, {
104957
+ method: "PUT",
104958
+ body: {
104959
+ format: "xml",
104960
+ xml: input.xml,
104961
+ ...input.metadata ? { metadata: input.metadata } : {}
105016
104962
  }
105017
- }
104963
+ });
105018
104964
  }
105019
104965
  async createTestScaffold(input) {
105020
104966
  const partId = `${input.identifier}-part1`;
@@ -105049,18 +104995,6 @@ class CourseAssessments {
105049
104995
  }
105050
104996
  }
105051
104997
  }
105052
- async teardownTest(qtiTestIdentifier) {
105053
- const qti = this.core.qti;
105054
- const partId = `${qtiTestIdentifier}-part1`;
105055
- const sectionId = `${qtiTestIdentifier}-section1`;
105056
- const questions = await qti.assessmentTests.getQuestions(qtiTestIdentifier);
105057
- const sectionItems = qti.assessmentTests.testParts(qtiTestIdentifier).sections(partId).items(sectionId);
105058
- for (const q of questions.questions ?? []) {
105059
- await sectionItems.remove(q.reference.identifier);
105060
- await qti.assessmentItems.delete(q.reference.identifier);
105061
- }
105062
- await qti.assessmentTests.delete(qtiTestIdentifier);
105063
- }
105064
104998
  }
105065
104999
  function buildCoursePayload(config4) {
105066
105000
  return {
@@ -105290,9 +105224,9 @@ function createCourseNamespace(core3) {
105290
105224
  cleanup: (courseId) => integration.cleanup(courseId),
105291
105225
  deactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "tobedeleted"),
105292
105226
  reactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "active"),
105293
- ensureAssessmentBank: (input) => assessments.ensureBank(input),
105294
105227
  createAssessmentTest: (input) => assessments.createTestScaffold(input),
105295
- teardownAssessmentTest: (qtiTestIdentifier) => assessments.teardownTest(qtiTestIdentifier)
105228
+ createAssessmentItemXml: (input) => assessments.createItemFromXml(input),
105229
+ updateAssessmentItemXml: (identifier, input) => assessments.updateItemFromXml(identifier, input)
105296
105230
  };
105297
105231
  }
105298
105232
  function recordTimebackClientSummary(outcome) {
@@ -105796,13 +105730,6 @@ function deriveSourcedIds2(courseId) {
105796
105730
  componentResource: `${courseId}-cr`
105797
105731
  };
105798
105732
  }
105799
- function deriveAssessmentBankIds2(courseId) {
105800
- return {
105801
- component: `${courseId}-assessment-bank-component`,
105802
- resource: `${courseId}-assessment-bank-resource`,
105803
- componentResource: `${courseId}-assessment-bank-cr`
105804
- };
105805
- }
105806
105733
  var CACHE_DEFAULTS4;
105807
105734
  var RESOURCE_DEFAULTS4;
105808
105735
  var init_utils6 = __esm(() => {
@@ -105836,6 +105763,96 @@ var init_utils6 = __esm(() => {
105836
105763
  componentResource: TIMEBACK_COMPONENT_RESOURCE_DEFAULTS
105837
105764
  };
105838
105765
  });
105766
+ function playcademySupportedQtiInteractionType(value) {
105767
+ if (typeof value !== "string") {
105768
+ return;
105769
+ }
105770
+ const normalized = value.trim().toLowerCase().replaceAll("_", "-");
105771
+ return PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET.has(normalized) ? normalized : undefined;
105772
+ }
105773
+ function parseNumericTextEntry(input) {
105774
+ const answer = typeof input.answer === "string" ? input.answer.trim() : "";
105775
+ const comparison = input.comparison;
105776
+ if (input.baseType !== "integer" && input.baseType !== "float") {
105777
+ return { success: false, message: "Numeric answers must be integer or float responses" };
105778
+ }
105779
+ if (!answer || (input.baseType === "integer" ? !INTEGER_ANSWER_PATTERN.test(answer) : !FLOAT_ANSWER_PATTERN.test(answer) || !Number.isFinite(Number(answer)))) {
105780
+ return { success: false, message: `The correct answer is not a valid ${input.baseType}` };
105781
+ }
105782
+ if (!comparison || typeof comparison !== "object" || !("kind" in comparison)) {
105783
+ return { success: false, message: "Numeric comparison is required" };
105784
+ }
105785
+ if (comparison.kind === "equal") {
105786
+ return {
105787
+ success: true,
105788
+ value: { baseType: input.baseType, answer, comparison: { kind: "equal" } }
105789
+ };
105790
+ }
105791
+ if (input.baseType === "integer") {
105792
+ return { success: false, message: "Integer answers cannot use decimal-place rounding" };
105793
+ }
105794
+ const figures = "figures" in comparison ? comparison.figures : undefined;
105795
+ const roundingMode = "roundingMode" in comparison ? comparison.roundingMode : undefined;
105796
+ if (comparison.kind !== "equal-rounded" || roundingMode !== "decimalPlaces" || !Number.isInteger(figures) || figures < 0 || figures > 15) {
105797
+ return { success: false, message: "Decimal places must be a whole number from 0 to 15" };
105798
+ }
105799
+ return {
105800
+ success: true,
105801
+ value: {
105802
+ baseType: "float",
105803
+ answer,
105804
+ comparison: { kind: "equal-rounded", roundingMode, figures }
105805
+ }
105806
+ };
105807
+ }
105808
+ function escapeXml(value) {
105809
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
105810
+ }
105811
+ function hottextMaxChoices(selectableCount, multiple) {
105812
+ return multiple ? selectableCount : 1;
105813
+ }
105814
+ function numericTextEntryXml(input) {
105815
+ const comparison = input.numeric.comparison.kind === "equal-rounded" ? `<qti-equal-rounded rounding-mode="decimalPlaces" figures="${input.numeric.comparison.figures}">` : "<qti-equal>";
105816
+ const closeComparison = input.numeric.comparison.kind === "equal-rounded" ? "</qti-equal-rounded>" : "</qti-equal>";
105817
+ return `<?xml version="1.0" encoding="UTF-8"?>
105818
+ <qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
105819
+ <qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="${input.numeric.baseType}">
105820
+ <qti-correct-response><qti-value>${escapeXml(input.numeric.answer)}</qti-value></qti-correct-response>
105821
+ </qti-response-declaration>
105822
+ <qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
105823
+ <qti-default-value><qti-value>0</qti-value></qti-default-value>
105824
+ </qti-outcome-declaration>
105825
+ <qti-item-body>
105826
+ <p>${escapeXml(input.prompt)} <qti-text-entry-interaction response-identifier="RESPONSE" expected-length="15" /></p>
105827
+ </qti-item-body>
105828
+ <qti-response-processing>
105829
+ <qti-response-condition>
105830
+ <qti-response-if>
105831
+ ${comparison}<qti-variable identifier="RESPONSE" /><qti-correct identifier="RESPONSE" />${closeComparison}
105832
+ <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
105833
+ </qti-response-if>
105834
+ </qti-response-condition>
105835
+ </qti-response-processing>
105836
+ </qti-assessment-item>`;
105837
+ }
105838
+ function countQtiTestItems(test) {
105839
+ let itemCount = 0;
105840
+ for (const part of test["qti-test-part"] ?? []) {
105841
+ for (const section of part["qti-assessment-section"] ?? []) {
105842
+ itemCount += section["qti-assessment-item-ref"]?.length ?? 0;
105843
+ }
105844
+ }
105845
+ return itemCount;
105846
+ }
105847
+ function isQtiItemOwnedByTest(item, testIdentifier) {
105848
+ const hasOwnershipMetadata = Boolean(item.metadata && (("ownerSystem" in item.metadata) || (PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY in item.metadata)));
105849
+ if (hasOwnershipMetadata) {
105850
+ return item.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && item.metadata?.[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY] === testIdentifier;
105851
+ }
105852
+ const identifierPrefix = `${testIdentifier}-q`;
105853
+ const identifierSuffix = item.identifier?.startsWith(identifierPrefix) ? item.identifier.slice(identifierPrefix.length) : undefined;
105854
+ return identifierSuffix !== undefined && /^[0-9a-f]{8}$/i.test(identifierSuffix);
105855
+ }
105839
105856
  function formatGradeLabel(grade) {
105840
105857
  if (grade === null || grade === undefined) {
105841
105858
  return "N/A";
@@ -105879,6 +105896,14 @@ function parseTimebackDiscrepancyQueueMetrics(values) {
105879
105896
  }
105880
105897
  var TIMEBACK_DISCREPANCY_QUEUE_WINDOWS;
105881
105898
  var TIMEBACK_DISCREPANCY_QUEUE_METRICS;
105899
+ var PLAYCADEMY_QTI_OWNER_SYSTEM = "playcademy";
105900
+ var PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY = "ownerTestIdentifier";
105901
+ var PLAYCADEMY_QTI_EDITOR_MODE_KEY = "playcademyEditorMode";
105902
+ var PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES;
105903
+ var PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET;
105904
+ var INTEGER_ANSWER_PATTERN;
105905
+ var FLOAT_ANSWER_PATTERN;
105906
+ var INLINE_CHOICE_BLANK = "_____";
105882
105907
  var DEFAULT_TIMEBACK_DISCREPANCY_QUEUE_WINDOW = "this-week";
105883
105908
  var TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES;
105884
105909
  var TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES;
@@ -105892,6 +105917,17 @@ var init_timeback3 = __esm(() => {
105892
105917
  "custom"
105893
105918
  ];
105894
105919
  TIMEBACK_DISCREPANCY_QUEUE_METRICS = ["xp", "mastery", "time", "score"];
105920
+ PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES = [
105921
+ "choice",
105922
+ "inline-choice",
105923
+ "text-entry",
105924
+ "order",
105925
+ "match",
105926
+ "hottext"
105927
+ ];
105928
+ PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET = new Set(PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES);
105929
+ INTEGER_ANSWER_PATTERN = /^[+-]?\d+$/;
105930
+ FLOAT_ANSWER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
105895
105931
  TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_WINDOWS);
105896
105932
  TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_METRICS);
105897
105933
  });
@@ -107122,7 +107158,7 @@ class TimebackAdminService {
107122
107158
  }
107123
107159
  async getMasterableUnitsByCourse(courseIds) {
107124
107160
  const uniqueCourseIds = [...new Set(courseIds)];
107125
- const results = await TimebackAdminService.runWithConcurrency(uniqueCourseIds, TimebackAdminService.MASTERABLE_UNITS_CONCURRENCY, async (courseId) => [courseId, await this.getMasterableUnits(courseId)]);
107161
+ const results = await runWithConcurrency(uniqueCourseIds, TimebackAdminService.MASTERABLE_UNITS_CONCURRENCY, async (courseId) => [courseId, await this.getMasterableUnits(courseId)]);
107126
107162
  return new Map(results);
107127
107163
  }
107128
107164
  deriveGameSensorUrl(game2) {
@@ -107303,7 +107339,7 @@ class TimebackAdminService {
107303
107339
  async loadEnrollmentAnalyticsSummaries(enrollmentIds) {
107304
107340
  const client = this.requireClient();
107305
107341
  const uniqueEnrollmentIds = [...new Set(enrollmentIds)];
107306
- const results = await TimebackAdminService.runWithConcurrency(uniqueEnrollmentIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (enrollmentId) => {
107342
+ const results = await runWithConcurrency(uniqueEnrollmentIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (enrollmentId) => {
107307
107343
  try {
107308
107344
  const analytics = await client.api.edubridge.analytics.getEnrollmentFacts({
107309
107345
  enrollmentId,
@@ -107397,7 +107433,7 @@ class TimebackAdminService {
107397
107433
  if (options.runOwnersById.size === 0) {
107398
107434
  return { events: [], runIds: new Set };
107399
107435
  }
107400
- const hydratedRuns = await TimebackAdminService.runWithConcurrency([...options.runOwnersById.entries()], TimebackAdminService.DISCREPANCY_QUEUE_RUN_HYDRATION_CONCURRENCY, async ([runId, studentId]) => {
107436
+ const hydratedRuns = await runWithConcurrency([...options.runOwnersById.entries()], TimebackAdminService.DISCREPANCY_QUEUE_RUN_HYDRATION_CONCURRENCY, async ([runId, studentId]) => {
107401
107437
  try {
107402
107438
  return {
107403
107439
  runId,
@@ -107430,7 +107466,7 @@ class TimebackAdminService {
107430
107466
  };
107431
107467
  }
107432
107468
  async buildMetricDiscrepancyQueueCandidates(user, options) {
107433
- const comparisonResults = await TimebackAdminService.runWithConcurrency(options.activityGroups, TimebackAdminService.DISCREPANCY_QUEUE_COMPARISON_CONCURRENCY, async (group) => {
107469
+ const comparisonResults = await runWithConcurrency(options.activityGroups, TimebackAdminService.DISCREPANCY_QUEUE_COMPARISON_CONCURRENCY, async (group) => {
107434
107470
  try {
107435
107471
  return {
107436
107472
  group,
@@ -108430,7 +108466,7 @@ class TimebackAdminService {
108430
108466
  const action = context2.newMasterableUnits < context2.oldMasterableUnits ? "complete" : "revoke";
108431
108467
  const failed = [];
108432
108468
  let processed = 0;
108433
- await TimebackAdminService.runWithConcurrency(context2.affectedStudentIds, 8, async (studentId) => {
108469
+ await runWithConcurrency(context2.affectedStudentIds, 8, async (studentId) => {
108434
108470
  try {
108435
108471
  await upsertMasteryCompletionEntry({
108436
108472
  client,
@@ -108651,28 +108687,9 @@ class TimebackAdminService {
108651
108687
  }
108652
108688
  async getCompletionStatusByCourse(client, courseIds, studentId) {
108653
108689
  const uniqueCourseIds = [...new Set(courseIds)];
108654
- const results = await TimebackAdminService.runWithConcurrency(uniqueCourseIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (courseId) => [courseId, await this.getCompletionStatus(client, courseId, studentId)]);
108690
+ const results = await runWithConcurrency(uniqueCourseIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (courseId) => [courseId, await this.getCompletionStatus(client, courseId, studentId)]);
108655
108691
  return new Map(results);
108656
108692
  }
108657
- static async runWithConcurrency(items, concurrency, worker) {
108658
- if (items.length === 0) {
108659
- return [];
108660
- }
108661
- const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
108662
- const results = Array.from({ length: items.length });
108663
- let nextIndex = 0;
108664
- await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
108665
- while (true) {
108666
- const currentIndex = nextIndex;
108667
- nextIndex++;
108668
- if (currentIndex >= items.length) {
108669
- return;
108670
- }
108671
- results[currentIndex] = await worker(items[currentIndex]);
108672
- }
108673
- }));
108674
- return results;
108675
- }
108676
108693
  }
108677
108694
  var init_timeback_admin_service = __esm(async () => {
108678
108695
  init_drizzle_orm();
@@ -108698,8 +108715,874 @@ var init_timeback_admin_service = __esm(async () => {
108698
108715
  init_timeback_mastery_completion_util()
108699
108716
  ]);
108700
108717
  });
108718
+ function validateAssessmentStatusTransition(current, next) {
108719
+ if (current === next) {
108720
+ return;
108721
+ }
108722
+ const allowed = current === "draft" && next === "live" || current === "live" && next === "archived" || current === "archived" && next === "live";
108723
+ if (!allowed) {
108724
+ throw new ValidationError(`Assessment status cannot change from ${current} to ${next}`);
108725
+ }
108726
+ }
108727
+ function isAssessmentPublicationTransition(current, next) {
108728
+ return current !== "live" && next === "live";
108729
+ }
108730
+ function assertDraftAssessment(row) {
108731
+ if (row.status !== "draft") {
108732
+ throw new ValidationError("Only draft assessments can change QTI content or question membership");
108733
+ }
108734
+ }
108735
+ function assertAllAssessmentAssociationsDraft(rows) {
108736
+ if (rows.some((row) => row.status !== "draft")) {
108737
+ throw new ValidationError("QTI content cannot change while any associated assessment is live or archived");
108738
+ }
108739
+ }
108740
+ function assertAssessmentHasQuestions(questions) {
108741
+ if (questions.length === 0) {
108742
+ throw new ValidationError("An assessment must contain at least one question to publish");
108743
+ }
108744
+ }
108745
+ function planAssessmentRemoval(status) {
108746
+ if (status === "draft") {
108747
+ return { kind: "delete", action: "discarded", operation: "discard_draft" };
108748
+ }
108749
+ if (status === "live") {
108750
+ return { kind: "archive", action: "archived", operation: "archive" };
108751
+ }
108752
+ return { kind: "none", action: "archived" };
108753
+ }
108754
+ function buildAssessmentAssociationUpdates(row, input) {
108755
+ const updates = {};
108756
+ if (input.purpose !== undefined) {
108757
+ updates.purpose = input.purpose;
108758
+ if (row.status === "live" && input.purpose !== row.purpose) {
108759
+ updates.sortOrder = null;
108760
+ }
108761
+ }
108762
+ if (input.status !== undefined) {
108763
+ updates.status = input.status;
108764
+ if (input.status === "archived") {
108765
+ updates.sortOrder = null;
108766
+ }
108767
+ }
108768
+ return updates;
108769
+ }
108770
+ function validateUniqueAssessmentIdentifiers(testIdentifiers) {
108771
+ if (new Set(testIdentifiers).size !== testIdentifiers.length) {
108772
+ throw new ValidationError("Assessment order must contain unique identifiers");
108773
+ }
108774
+ }
108775
+ function assertAssessmentOrderUpdateSucceeded(updatedRow) {
108776
+ if (!updatedRow) {
108777
+ throw new ValidationError("Assessment order changed while it was being saved. Refresh and try again.");
108778
+ }
108779
+ return updatedRow;
108780
+ }
108781
+ function lockOrderAssessmentRows(rows) {
108782
+ return rows.toSorted((left, right) => left.id.localeCompare(right.id));
108783
+ }
108784
+ function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
108785
+ const rowsByIdentifier = new Map(liveRows.map((row) => [row.qtiTestIdentifier, row]));
108786
+ if (liveRows.length !== testIdentifiers.length || testIdentifiers.some((identifier) => !rowsByIdentifier.has(identifier))) {
108787
+ throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
108788
+ }
108789
+ return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
108790
+ }
108791
+ var init_timeback_assessment_rules_util = __esm(() => {
108792
+ init_errors();
108793
+ });
108794
+ function recordValue(value) {
108795
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
108796
+ }
108797
+ function qtiAuthoringSnapshot(input) {
108798
+ if (!recordValue(input.interaction)) {
108799
+ return;
108800
+ }
108801
+ return Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => input[field] === undefined ? [] : [[field, input[field]]]));
108802
+ }
108803
+ function buildOwnedQtiQuestionMetadata(input, ownership, existingMetadata = {}) {
108804
+ const inputMetadata = recordValue(input.metadata) ?? {};
108805
+ const metadata2 = {
108806
+ ...existingMetadata,
108807
+ ...inputMetadata
108808
+ };
108809
+ const existingAuthoring = recordValue(existingMetadata[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
108810
+ Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
108811
+ Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_EDITOR_MODE_KEY);
108812
+ const authoring = qtiAuthoringSnapshot(input);
108813
+ const shouldReplaceAuthoring = "interaction" in input || "numericTextEntry" in input;
108814
+ const persistedAuthoring = authoring ?? (shouldReplaceAuthoring ? undefined : existingAuthoring);
108815
+ return {
108816
+ ...metadata2,
108817
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
108818
+ ownerGameSlug: ownership.gameSlug,
108819
+ [PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]: ownership.testIdentifier,
108820
+ ...persistedAuthoring ? { [PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]: persistedAuthoring } : {}
108821
+ };
108822
+ }
108823
+ function restoreQtiQuestionAuthoringData(item) {
108824
+ const authoring = recordValue(item.metadata?.[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
108825
+ if (!authoring) {
108826
+ return item;
108827
+ }
108828
+ const restored = Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => authoring[field] === undefined ? [] : [[field, authoring[field]]]));
108829
+ return { ...item, ...restored };
108830
+ }
108831
+ function mergeQtiQuestionItem(item, fallbackItem, itemIdentifier, metadata2) {
108832
+ return restoreQtiQuestionAuthoringData({
108833
+ ...fallbackItem,
108834
+ ...item,
108835
+ identifier: itemIdentifier,
108836
+ title: item.title || fallbackItem?.title || itemIdentifier,
108837
+ type: item.type ?? fallbackItem?.type,
108838
+ rawXml: item.rawXml ?? fallbackItem?.rawXml,
108839
+ interaction: item.interaction ?? fallbackItem?.interaction,
108840
+ responseDeclarations: item.responseDeclarations ?? fallbackItem?.responseDeclarations,
108841
+ metadata: {
108842
+ ...fallbackItem?.metadata,
108843
+ ...item.metadata,
108844
+ ...metadata2
108845
+ }
108846
+ });
108847
+ }
108848
+ function newPlaycademyQuestionIdentifier(testIdentifier) {
108849
+ return `${testIdentifier}-q${crypto.randomUUID().slice(0, 8)}`;
108850
+ }
108851
+ function parseQtiQuestionCreationInput(input) {
108852
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
108853
+ throw new ValidationError("Question creation input must be an object");
108854
+ }
108855
+ const rawInput = input;
108856
+ if (rawInput.mode === undefined) {
108857
+ return { kind: "authoring", input: rawInput };
108858
+ }
108859
+ if (rawInput.mode !== "copy") {
108860
+ throw new ValidationError("Question creation mode is invalid");
108861
+ }
108862
+ const sourceItemIdentifier = typeof rawInput.sourceItemIdentifier === "string" ? rawInput.sourceItemIdentifier.trim() : "";
108863
+ if (!sourceItemIdentifier) {
108864
+ throw new ValidationError("A source question identifier is required to create a copy");
108865
+ }
108866
+ const unexpectedField = Object.keys(rawInput).find((field) => field !== "mode" && field !== "sourceItemIdentifier");
108867
+ if (unexpectedField) {
108868
+ throw new ValidationError(`Question copy input contains an unexpected “${unexpectedField}” field`);
108869
+ }
108870
+ return { kind: "copy", sourceItemIdentifier };
108871
+ }
108872
+ function nonEmptyMetadataString(value) {
108873
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
108874
+ }
108875
+ function qtiQuestionOwnerTestIdentifier(item) {
108876
+ if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
108877
+ return;
108878
+ }
108879
+ return nonEmptyMetadataString(item.metadata[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]);
108880
+ }
108881
+ function qtiTestReferencesItem(test, itemIdentifier) {
108882
+ return (test["qti-test-part"] ?? []).some((part) => (part["qti-assessment-section"] ?? []).some((section) => (section["qti-assessment-item-ref"] ?? []).some((reference) => reference.identifier === itemIdentifier)));
108883
+ }
108884
+ function resolveQtiQuestionOwnerGameSlug(item, ownerTest) {
108885
+ if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
108886
+ return;
108887
+ }
108888
+ if ("ownerGameSlug" in item.metadata) {
108889
+ return nonEmptyMetadataString(item.metadata.ownerGameSlug);
108890
+ }
108891
+ const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
108892
+ if (!ownerTestIdentifier || !ownerTest || ownerTest.identifier !== ownerTestIdentifier || !qtiTestReferencesItem(ownerTest, item.identifier) || ownerTest.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
108893
+ return;
108894
+ }
108895
+ return nonEmptyMetadataString(ownerTest.metadata.ownerGameSlug);
108896
+ }
108897
+ function isQtiQuestionOwnedByGame(item, gameSlug, ownerTest) {
108898
+ return resolveQtiQuestionOwnerGameSlug(item, ownerTest) === gameSlug;
108899
+ }
108900
+ function isQtiQuestionOwnedByContext(item, ownership, ownerTest) {
108901
+ const declaredOwnerTest = qtiQuestionOwnerTestIdentifier(item);
108902
+ if (declaredOwnerTest) {
108903
+ return declaredOwnerTest === ownership.testIdentifier && isQtiQuestionOwnedByGame(item, ownership.gameSlug, ownerTest);
108904
+ }
108905
+ return isQtiItemOwnedByTest(item, ownership.testIdentifier);
108906
+ }
108907
+ function normalizedQtiInteractionType(value) {
108908
+ if (typeof value !== "string") {
108909
+ return;
108910
+ }
108911
+ const normalized = value.trim().toLowerCase().replaceAll("_", "-");
108912
+ return normalized || undefined;
108913
+ }
108914
+ function qtiXmlInteractionTypes(xml) {
108915
+ const markup = xml.replaceAll(/<!--[\s\S]*?-->/g, "").replaceAll(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
108916
+ const interactionPattern = /<(?!\/)(?:[A-Za-z_][\w.-]*:)?qti-([a-z][\w-]*)-interaction\b[^>]*>/gi;
108917
+ return [...markup.matchAll(interactionPattern)].map((match) => match[1].toLowerCase());
108918
+ }
108919
+ function supportedQtiQuestionInteractionType(question) {
108920
+ const interaction = recordValue(question.interaction);
108921
+ const structuredTypes = [
108922
+ normalizedQtiInteractionType(question.type),
108923
+ normalizedQtiInteractionType(interaction?.type)
108924
+ ].filter((type) => Boolean(type));
108925
+ const distinctStructuredTypes = [...new Set(structuredTypes)];
108926
+ if (distinctStructuredTypes.length > 1) {
108927
+ return;
108928
+ }
108929
+ const rawXml = typeof question.rawXml === "string" ? question.rawXml : undefined;
108930
+ if (rawXml) {
108931
+ const xmlTypes = qtiXmlInteractionTypes(rawXml);
108932
+ if (xmlTypes.length !== 1) {
108933
+ return;
108934
+ }
108935
+ const [xmlType] = xmlTypes;
108936
+ const [structuredType2] = distinctStructuredTypes;
108937
+ if (structuredType2 && structuredType2 !== xmlType) {
108938
+ return;
108939
+ }
108940
+ return playcademySupportedQtiInteractionType(xmlType);
108941
+ }
108942
+ const [structuredType] = distinctStructuredTypes;
108943
+ return playcademySupportedQtiInteractionType(structuredType);
108944
+ }
108945
+ function assertSupportedQtiQuestionInteraction(question) {
108946
+ if (!supportedQtiQuestionInteractionType(question)) {
108947
+ throw new ValidationError("This QTI interaction type is not supported by the Playcademy question editor.");
108948
+ }
108949
+ }
108950
+ function invalidQtiCopyXml() {
108951
+ throw new ValidationError("The source question does not contain valid QTI item XML");
108952
+ }
108953
+ function markupEnd(xml, start2, allowInternalSubset) {
108954
+ let quote = null;
108955
+ let subsetDepth = 0;
108956
+ for (let index2 = start2;index2 < xml.length; index2 += 1) {
108957
+ const character = xml[index2];
108958
+ if (quote) {
108959
+ if (character === quote) {
108960
+ quote = null;
108961
+ }
108962
+ } else if (character === '"' || character === "'") {
108963
+ quote = character;
108964
+ } else if (allowInternalSubset && character === "[") {
108965
+ subsetDepth += 1;
108966
+ } else if (allowInternalSubset && character === "]") {
108967
+ subsetDepth = Math.max(0, subsetDepth - 1);
108968
+ } else if (character === ">" && subsetDepth === 0) {
108969
+ return index2;
108970
+ }
108971
+ }
108972
+ return invalidQtiCopyXml();
108973
+ }
108974
+ function skipXmlWhitespace(xml, start2, end = xml.length) {
108975
+ let cursor2 = start2;
108976
+ while (cursor2 < end && /\s/.test(xml[cursor2] ?? "")) {
108977
+ cursor2 += 1;
108978
+ }
108979
+ return cursor2;
108980
+ }
108981
+ function xmlPreambleEnd(xml, cursor2) {
108982
+ if (xml.startsWith("<?", cursor2)) {
108983
+ const end = xml.indexOf("?>", cursor2 + 2);
108984
+ if (end === -1) {
108985
+ return invalidQtiCopyXml();
108986
+ }
108987
+ return end + 2;
108988
+ }
108989
+ if (xml.startsWith("<!--", cursor2)) {
108990
+ const end = xml.indexOf("-->", cursor2 + 4);
108991
+ if (end === -1) {
108992
+ return invalidQtiCopyXml();
108993
+ }
108994
+ return end + 3;
108995
+ }
108996
+ if (/^<!DOCTYPE\b/i.test(xml.slice(cursor2))) {
108997
+ return markupEnd(xml, cursor2 + 2, true) + 1;
108998
+ }
108999
+ return null;
109000
+ }
109001
+ function qtiRootAttributes(xml, start2, end) {
109002
+ const attributes = [];
109003
+ let cursor2 = start2;
109004
+ while (cursor2 < end) {
109005
+ cursor2 = skipXmlWhitespace(xml, cursor2, end);
109006
+ if (xml[cursor2] === "/") {
109007
+ cursor2 += 1;
109008
+ } else if (cursor2 < end) {
109009
+ const nameMatch = /^[A-Za-z_][\w.:-]*/.exec(xml.slice(cursor2, end));
109010
+ if (!nameMatch) {
109011
+ return invalidQtiCopyXml();
109012
+ }
109013
+ const name3 = nameMatch[0];
109014
+ cursor2 = skipXmlWhitespace(xml, cursor2 + name3.length, end);
109015
+ if (xml[cursor2] !== "=") {
109016
+ return invalidQtiCopyXml();
109017
+ }
109018
+ cursor2 = skipXmlWhitespace(xml, cursor2 + 1, end);
109019
+ const quote = xml[cursor2];
109020
+ if (quote !== '"' && quote !== "'") {
109021
+ return invalidQtiCopyXml();
109022
+ }
109023
+ const valueStart = cursor2 + 1;
109024
+ const valueEnd = xml.indexOf(quote, valueStart);
109025
+ if (valueEnd === -1 || valueEnd > end) {
109026
+ return invalidQtiCopyXml();
109027
+ }
109028
+ attributes.push({ name: name3, quote, valueStart, valueEnd });
109029
+ cursor2 = valueEnd + 1;
109030
+ }
109031
+ }
109032
+ return attributes;
109033
+ }
109034
+ function qtiItemStartTag(xml) {
109035
+ let cursor2 = xml.charCodeAt(0) === 65279 ? 1 : 0;
109036
+ while (cursor2 < xml.length) {
109037
+ cursor2 = skipXmlWhitespace(xml, cursor2);
109038
+ const preambleEnd = xmlPreambleEnd(xml, cursor2);
109039
+ if (preambleEnd !== null) {
109040
+ cursor2 = preambleEnd;
109041
+ } else {
109042
+ const root = /^<([A-Za-z_][\w.:-]*)/.exec(xml.slice(cursor2));
109043
+ if (!root || root[1].split(":").at(-1) !== "qti-assessment-item") {
109044
+ return invalidQtiCopyXml();
109045
+ }
109046
+ const attributesStart = cursor2 + root[0].length;
109047
+ const end = markupEnd(xml, attributesStart, false);
109048
+ const attributes = qtiRootAttributes(xml, attributesStart, end);
109049
+ let insertionPoint = skipXmlWhitespaceBackward(xml, end);
109050
+ if (xml[insertionPoint - 1] === "/") {
109051
+ insertionPoint -= 1;
109052
+ }
109053
+ return { attributes, insertionPoint };
109054
+ }
109055
+ }
109056
+ return invalidQtiCopyXml();
109057
+ }
109058
+ function skipXmlWhitespaceBackward(xml, start2) {
109059
+ let cursor2 = start2;
109060
+ while (/\s/.test(xml[cursor2 - 1] ?? "")) {
109061
+ cursor2 -= 1;
109062
+ }
109063
+ return cursor2;
109064
+ }
109065
+ function escapeXmlAttribute(value, quote) {
109066
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(quote, quote === '"' ? "&quot;" : "&apos;");
109067
+ }
109068
+ function rewriteQtiItemIdentity(xml, identifier, title) {
109069
+ if (!xml.trim() || !identifier.trim() || !title.trim()) {
109070
+ return invalidQtiCopyXml();
109071
+ }
109072
+ const root = qtiItemStartTag(xml);
109073
+ const replacements = [];
109074
+ const targetAttributes = new Map([
109075
+ ["identifier", identifier],
109076
+ ["title", title]
109077
+ ]);
109078
+ for (const [name3, value] of targetAttributes) {
109079
+ const matches = root.attributes.filter((attribute2) => attribute2.name === name3);
109080
+ if (matches.length > 1) {
109081
+ return invalidQtiCopyXml();
109082
+ }
109083
+ const attribute = matches[0];
109084
+ if (attribute) {
109085
+ replacements.push({
109086
+ start: attribute.valueStart,
109087
+ end: attribute.valueEnd,
109088
+ value: escapeXmlAttribute(value, attribute.quote)
109089
+ });
109090
+ } else {
109091
+ replacements.push({
109092
+ start: root.insertionPoint,
109093
+ end: root.insertionPoint,
109094
+ value: ` ${name3}="${escapeXmlAttribute(value, '"')}"`
109095
+ });
109096
+ }
109097
+ }
109098
+ return replacements.toSorted((left, right) => right.start - left.start).reduce((rewritten, replacement) => `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`, xml);
109099
+ }
109100
+ function buildQtiQuestionCopyInput(input) {
109101
+ assertSupportedQtiQuestionInteraction(input.source);
109102
+ const title = input.source.title?.trim() || input.source.identifier;
109103
+ if (!input.source.rawXml) {
109104
+ return invalidQtiCopyXml();
109105
+ }
109106
+ const sourceMetadata = { ...input.source.metadata };
109107
+ if (sourceMetadata.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
109108
+ Reflect.deleteProperty(sourceMetadata, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
109109
+ }
109110
+ const metadata2 = buildOwnedQtiQuestionMetadata({
109111
+ metadata: {
109112
+ copiedFromItemIdentifier: input.source.identifier,
109113
+ integrationId: input.integrationId
109114
+ }
109115
+ }, {
109116
+ gameSlug: input.gameSlug,
109117
+ testIdentifier: input.targetTestIdentifier
109118
+ }, sourceMetadata);
109119
+ return {
109120
+ identifier: input.targetIdentifier,
109121
+ title,
109122
+ xml: rewriteQtiItemIdentity(input.source.rawXml, input.targetIdentifier, title),
109123
+ metadata: metadata2
109124
+ };
109125
+ }
109126
+ function assertQtiQuestionEditable(item, ownership, ownerTest) {
109127
+ if (!isQtiQuestionOwnedByContext(item, ownership, ownerTest)) {
109128
+ throw new ValidationError("Shared question references are read-only. Copy the question to create an editable independent item.");
109129
+ }
109130
+ }
109131
+ function buildNumericQuestionXml(input, identifier, title) {
109132
+ if (input.format === "xml") {
109133
+ throw new ValidationError("Raw question XML is not accepted at this API boundary");
109134
+ }
109135
+ const candidate = input.numericTextEntry;
109136
+ if (candidate === undefined) {
109137
+ return null;
109138
+ }
109139
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
109140
+ throw new ValidationError("Numeric text-entry data is invalid");
109141
+ }
109142
+ const prompt = "prompt" in candidate ? candidate.prompt : undefined;
109143
+ const numeric3 = parseNumericTextEntry({
109144
+ baseType: "baseType" in candidate ? candidate.baseType : undefined,
109145
+ answer: "answer" in candidate ? candidate.answer : undefined,
109146
+ comparison: "comparison" in candidate ? candidate.comparison : undefined
109147
+ });
109148
+ if (!numeric3.success) {
109149
+ throw new ValidationError(numeric3.message);
109150
+ }
109151
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
109152
+ throw new ValidationError("Numeric questions require a title and prompt");
109153
+ }
109154
+ return numericTextEntryXml({ identifier, title, prompt: prompt.trim(), numeric: numeric3.value });
109155
+ }
109156
+ function buildExactMatchQuestionXml(input) {
109157
+ const responseIdentifier = escapeXml(input.responseIdentifier);
109158
+ const correctValues = input.correctIdentifiers.map((identifier) => ` <qti-value>${escapeXml(identifier)}</qti-value>`).join(`
109159
+ `);
109160
+ return `<?xml version="1.0" encoding="UTF-8"?>
109161
+ <qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
109162
+ <qti-response-declaration identifier="${responseIdentifier}" cardinality="${input.cardinality}" base-type="identifier">
109163
+ <qti-correct-response>
109164
+ ${correctValues}
109165
+ </qti-correct-response>
109166
+ </qti-response-declaration>
109167
+ <qti-outcome-declaration identifier="FEEDBACK" cardinality="single" base-type="identifier" />
109168
+ <qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
109169
+ <qti-default-value><qti-value>0</qti-value></qti-default-value>
109170
+ </qti-outcome-declaration>
109171
+ <qti-item-body>
109172
+ ${input.itemBody}
109173
+ </qti-item-body>
109174
+ <qti-response-processing>
109175
+ <qti-response-condition>
109176
+ <qti-response-if>
109177
+ <qti-match><qti-variable identifier="${responseIdentifier}" /><qti-correct identifier="${responseIdentifier}" /></qti-match>
109178
+ <qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">CORRECT</qti-base-value></qti-set-outcome-value>
109179
+ <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
109180
+ </qti-response-if>
109181
+ <qti-response-else>
109182
+ <qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">INCORRECT</qti-base-value></qti-set-outcome-value>
109183
+ <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">0</qti-base-value></qti-set-outcome-value>
109184
+ </qti-response-else>
109185
+ </qti-response-condition>
109186
+ </qti-response-processing>
109187
+ </qti-assessment-item>`;
109188
+ }
109189
+ function parseInlineChoices(value) {
109190
+ if (!Array.isArray(value) || value.length < 2) {
109191
+ throw new ValidationError("Inline-choice questions require at least two choices");
109192
+ }
109193
+ const choices = value.map((choice) => {
109194
+ const record3 = recordValue(choice);
109195
+ const identifier = record3?.identifier;
109196
+ const content = record3?.content;
109197
+ if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim()) {
109198
+ throw new ValidationError("Inline-choice options are invalid");
109199
+ }
109200
+ return { identifier: identifier.trim(), content: content.trim() };
109201
+ });
109202
+ if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
109203
+ throw new ValidationError("Inline-choice option identifiers must be unique");
109204
+ }
109205
+ return choices;
109206
+ }
109207
+ function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
109208
+ const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
109209
+ const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
109210
+ const correctResponse = recordValue(declaration?.correctResponse);
109211
+ const rawValues = correctResponse?.value;
109212
+ const values = Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : [];
109213
+ if (declaration?.cardinality !== "single" || declaration.baseType !== "identifier") {
109214
+ throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
109215
+ }
109216
+ if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
109217
+ throw new ValidationError("Inline-choice questions require one valid correct option");
109218
+ }
109219
+ return values[0];
109220
+ }
109221
+ function buildInlineChoiceQuestionXml(input, identifier, title) {
109222
+ const interaction = recordValue(input.interaction);
109223
+ const interactionType = normalizedQtiInteractionType(interaction?.type ?? input.type);
109224
+ if (interactionType !== "inline-choice") {
109225
+ return null;
109226
+ }
109227
+ const responseIdentifier = interaction?.responseIdentifier;
109228
+ const structure = recordValue(interaction?.questionStructure);
109229
+ const prompt = structure?.prompt;
109230
+ if (!structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
109231
+ throw new ValidationError("Inline-choice question data is invalid");
109232
+ }
109233
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
109234
+ throw new ValidationError("Inline-choice questions require a title and prompt");
109235
+ }
109236
+ const normalizedPrompt = prompt.trim();
109237
+ const promptParts = normalizedPrompt.split(INLINE_CHOICE_BLANK);
109238
+ if (promptParts.length !== 2) {
109239
+ throw new ValidationError("Inline-choice questions require exactly one blank");
109240
+ }
109241
+ const choices = parseInlineChoices(structure.inlineChoices);
109242
+ const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
109243
+ const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
109244
+ const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
109245
+ `);
109246
+ const [before = "", after = ""] = promptParts;
109247
+ const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
109248
+ ${choicesXml}
109249
+ </qti-inline-choice-interaction>${escapeXml(after)}</p>`;
109250
+ return buildExactMatchQuestionXml({
109251
+ identifier,
109252
+ title,
109253
+ responseIdentifier,
109254
+ cardinality: "single",
109255
+ correctIdentifiers: [correctIdentifier],
109256
+ itemBody
109257
+ });
109258
+ }
109259
+ function parseHottextSegments(value) {
109260
+ if (!Array.isArray(value) || value.length === 0) {
109261
+ throw new ValidationError("Hottext questions require passage segments");
109262
+ }
109263
+ const segments = value.map((segment) => {
109264
+ const record3 = recordValue(segment);
109265
+ const identifier = record3?.identifier;
109266
+ const content = record3?.content;
109267
+ const mode = record3?.mode;
109268
+ if (typeof identifier !== "string" || typeof content !== "string" || !content.trim() || !["plain", "option", "correct"].includes(String(mode))) {
109269
+ throw new ValidationError("Hottext passage segments are invalid");
109270
+ }
109271
+ return {
109272
+ identifier: identifier.trim(),
109273
+ content: content.trim(),
109274
+ mode
109275
+ };
109276
+ });
109277
+ const selectable = segments.filter((segment) => segment.mode !== "plain");
109278
+ const correct = selectable.filter((segment) => segment.mode === "correct");
109279
+ const identifiers = selectable.map((segment) => segment.identifier);
109280
+ if (selectable.length < 2) {
109281
+ throw new ValidationError("Hottext questions require at least two selectable phrases");
109282
+ }
109283
+ if (identifiers.some((identifier) => !identifier)) {
109284
+ throw new ValidationError("Hottext selectable phrases require identifiers");
109285
+ }
109286
+ if (new Set(identifiers).size !== identifiers.length) {
109287
+ throw new ValidationError("Hottext selectable phrase identifiers must be unique");
109288
+ }
109289
+ if (correct.length === 0) {
109290
+ throw new ValidationError("Hottext questions require a correct phrase");
109291
+ }
109292
+ return { segments, selectable, correct };
109293
+ }
109294
+ function buildHottextQuestionXml(input, identifier, title) {
109295
+ const candidate = input.hottext;
109296
+ if (candidate === undefined) {
109297
+ return null;
109298
+ }
109299
+ const hottext = recordValue(candidate);
109300
+ if (!hottext) {
109301
+ throw new ValidationError("Hottext question data is invalid");
109302
+ }
109303
+ const prompt = hottext.prompt;
109304
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
109305
+ throw new ValidationError("Hottext questions require a title and prompt");
109306
+ }
109307
+ const { segments, selectable, correct } = parseHottextSegments(hottext.segments);
109308
+ const multiple = correct.length > 1;
109309
+ const maxChoices = hottextMaxChoices(selectable.length, multiple);
109310
+ const passage = segments.map((segment) => segment.mode === "plain" ? escapeXml(segment.content) : `<qti-hottext identifier="${escapeXml(segment.identifier)}">${escapeXml(segment.content)}</qti-hottext>`).join(" ");
109311
+ const itemBody = ` <qti-hottext-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
109312
+ <qti-prompt>${escapeXml(prompt.trim())}</qti-prompt>
109313
+ <p>${passage}</p>
109314
+ </qti-hottext-interaction>`;
109315
+ return buildExactMatchQuestionXml({
109316
+ identifier,
109317
+ title,
109318
+ responseIdentifier: "RESPONSE",
109319
+ cardinality: multiple ? "multiple" : "single",
109320
+ correctIdentifiers: correct.map((segment) => segment.identifier),
109321
+ itemBody
109322
+ });
109323
+ }
109324
+ function buildQuestionXml(input, identifier, title) {
109325
+ for (const build2 of QUESTION_XML_BUILDERS) {
109326
+ const xml = build2(input, identifier, title);
109327
+ if (xml !== null) {
109328
+ return xml;
109329
+ }
109330
+ }
109331
+ return null;
109332
+ }
109333
+ function buildCreatedQtiQuestionReference(input) {
109334
+ return {
109335
+ ownership: "owned",
109336
+ reference: {
109337
+ identifier: input.itemIdentifier,
109338
+ href: input.href,
109339
+ testPart: input.partIdentifier,
109340
+ section: input.sectionIdentifier
109341
+ },
109342
+ question: mergeQtiQuestionItem(input.item, input.fallbackItem, input.itemIdentifier, input.metadata)
109343
+ };
109344
+ }
109345
+ function buildHydratedQtiQuestionReference(input) {
109346
+ const identifier = input.reference.reference.identifier;
109347
+ const ownerGameSlug = resolveQtiQuestionOwnerGameSlug(input.item, input.ownerTest);
109348
+ const authoritativeItem = ownerGameSlug ? {
109349
+ ...input.item,
109350
+ metadata: { ...input.item.metadata, ownerGameSlug }
109351
+ } : input.item;
109352
+ const question = mergeQtiQuestionItem(authoritativeItem, input.reference.question, identifier);
109353
+ return {
109354
+ ...input.reference,
109355
+ ownership: isQtiQuestionOwnedByContext(question, { gameSlug: input.gameSlug, testIdentifier: input.testIdentifier }, input.ownerTest) ? "owned" : "shared",
109356
+ question
109357
+ };
109358
+ }
109359
+ function isQtiTestOwnedByGame(test, gameSlug) {
109360
+ return test.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && test.metadata.ownerGameSlug === gameSlug;
109361
+ }
109362
+ function assertQtiTestOwnedByGame(test, gameSlug) {
109363
+ if (!isQtiTestOwnedByGame(test, gameSlug)) {
109364
+ throw new ValidationError("Shared assessment references are read-only. Copy the assessment to create an editable independent test.");
109365
+ }
109366
+ }
109367
+ function qtiTestParts(test) {
109368
+ const parts2 = test["qti-test-part"];
109369
+ if (!Array.isArray(parts2) || parts2.length === 0) {
109370
+ throw new ValidationError(`Assessment ${test.identifier} has no test parts`);
109371
+ }
109372
+ for (const [partIndex, part] of parts2.entries()) {
109373
+ if (!part || typeof part !== "object" || Array.isArray(part)) {
109374
+ throw new ValidationError(`Assessment ${test.identifier} contains an invalid test part at position ${partIndex + 1}`);
109375
+ }
109376
+ const sections = part["qti-assessment-section"];
109377
+ if (!Array.isArray(sections)) {
109378
+ throw new ValidationError(`Assessment ${test.identifier} test part ${partIndex + 1} has no section list`);
109379
+ }
109380
+ }
109381
+ return parts2;
109382
+ }
109383
+ function qtiTestOptionalAttributes(test) {
109384
+ return {
109385
+ ...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
109386
+ ...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
109387
+ ...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
109388
+ ...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
109389
+ };
109390
+ }
109391
+ function buildQtiAssessmentItemCopyPlan(test, targetTestIdentifier, hrefForIdentifier, createIdentifier = newPlaycademyQuestionIdentifier) {
109392
+ const sourceIdentifiers = [];
109393
+ const seen = new Set;
109394
+ for (const part of qtiTestParts(test)) {
109395
+ for (const section of part["qti-assessment-section"]) {
109396
+ for (const reference of section["qti-assessment-item-ref"] ?? []) {
109397
+ if (!seen.has(reference.identifier)) {
109398
+ seen.add(reference.identifier);
109399
+ sourceIdentifiers.push(reference.identifier);
109400
+ }
109401
+ }
109402
+ }
109403
+ }
109404
+ return sourceIdentifiers.map((sourceIdentifier) => {
109405
+ const targetIdentifier = createIdentifier(targetTestIdentifier);
109406
+ return {
109407
+ sourceIdentifier,
109408
+ targetIdentifier,
109409
+ href: hrefForIdentifier(targetIdentifier)
109410
+ };
109411
+ });
109412
+ }
109413
+ function buildQtiTestStructureInput(test, targetTestIdentifier, itemCopies) {
109414
+ const outcomeDeclarations = test["qti-outcome-declaration"];
109415
+ if (outcomeDeclarations !== undefined && !Array.isArray(outcomeDeclarations)) {
109416
+ throw new ValidationError(`Assessment ${test.identifier} has an invalid outcome declaration list`);
109417
+ }
109418
+ return {
109419
+ "qti-test-part": qtiTestParts(test).map((part, partIndex) => ({
109420
+ identifier: targetTestIdentifier ? `${targetTestIdentifier}-part${partIndex + 1}` : part.identifier,
109421
+ navigationMode: part.navigationMode,
109422
+ submissionMode: part.submissionMode,
109423
+ "qti-assessment-section": part["qti-assessment-section"].map((section, sectionIndex) => {
109424
+ let sectionIdentifier = section.identifier;
109425
+ if (targetTestIdentifier) {
109426
+ sectionIdentifier = partIndex === 0 && sectionIndex === 0 ? `${targetTestIdentifier}-section1` : `${targetTestIdentifier}-part${partIndex + 1}-section${sectionIndex + 1}`;
109427
+ }
109428
+ return {
109429
+ identifier: sectionIdentifier,
109430
+ title: section.title,
109431
+ visible: section.visible ?? true,
109432
+ ...section.required !== undefined ? { required: section.required } : {},
109433
+ ...section.fixed !== undefined ? { fixed: section.fixed } : {},
109434
+ sequence: section.sequence ?? sectionIndex + 1,
109435
+ ...section["qti-assessment-item-ref"] ? {
109436
+ "qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item, itemIndex) => {
109437
+ const copy = itemCopies?.get(item.identifier);
109438
+ if (itemCopies && !copy) {
109439
+ throw new ValidationError(`Assessment copy is missing question ${item.identifier}`);
109440
+ }
109441
+ return {
109442
+ identifier: copy?.targetIdentifier ?? item.identifier,
109443
+ href: copy?.href ?? item.href,
109444
+ sequence: item.sequence ?? itemIndex + 1
109445
+ };
109446
+ })
109447
+ } : {}
109448
+ };
109449
+ })
109450
+ })),
109451
+ ...outcomeDeclarations ? {
109452
+ "qti-outcome-declaration": outcomeDeclarations.map((declaration) => ({
109453
+ identifier: declaration.identifier,
109454
+ ...declaration.cardinality !== undefined ? { cardinality: declaration.cardinality } : {},
109455
+ baseType: declaration.baseType,
109456
+ ...declaration.normalMaximum !== undefined ? { normalMaximum: declaration.normalMaximum } : {},
109457
+ ...declaration.normalMinimum !== undefined ? { normalMinimum: declaration.normalMinimum } : {},
109458
+ ...declaration.defaultValue ? {
109459
+ defaultValue: declaration.defaultValue.value !== undefined ? { value: declaration.defaultValue.value } : {}
109460
+ } : {}
109461
+ }))
109462
+ } : {}
109463
+ };
109464
+ }
109465
+ function buildQtiTestUpdateInput(test, title) {
109466
+ return {
109467
+ title,
109468
+ ...qtiTestOptionalAttributes(test),
109469
+ ...test.metadata ? { metadata: test.metadata } : {},
109470
+ ...buildQtiTestStructureInput(test)
109471
+ };
109472
+ }
109473
+ function buildQtiTestCopyInput(source, targetTestIdentifier, metadata2, itemCopies) {
109474
+ const copiesBySourceIdentifier = new Map(itemCopies.map((copy) => [copy.sourceIdentifier, copy]));
109475
+ return {
109476
+ identifier: targetTestIdentifier,
109477
+ title: `${source.title} (copy)`,
109478
+ ...qtiTestOptionalAttributes(source),
109479
+ metadata: metadata2,
109480
+ ...buildQtiTestStructureInput(source, targetTestIdentifier, copiesBySourceIdentifier)
109481
+ };
109482
+ }
109483
+ function validateUniqueQuestionIdentifiers(itemIdentifiers) {
109484
+ if (itemIdentifiers.length === 0 || new Set(itemIdentifiers).size !== itemIdentifiers.length) {
109485
+ throw new ValidationError("Question order must contain unique question identifiers");
109486
+ }
109487
+ }
109488
+ function resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers) {
109489
+ let selectedPart;
109490
+ let selectedSection;
109491
+ for (const part of qtiTestParts(test)) {
109492
+ for (const section of part["qti-assessment-section"]) {
109493
+ if (!itemIdentifier || section["qti-assessment-item-ref"]?.some((item) => item.identifier === itemIdentifier)) {
109494
+ selectedPart = part;
109495
+ selectedSection = section;
109496
+ break;
109497
+ }
109498
+ }
109499
+ if (selectedSection) {
109500
+ break;
109501
+ }
109502
+ }
109503
+ if (!selectedPart || !selectedSection) {
109504
+ throw new ValidationError(itemIdentifier ? `Question ${itemIdentifier} is not referenced by assessment ${qtiTestIdentifier}` : `Assessment ${qtiTestIdentifier} has no section for questions`);
109505
+ }
109506
+ if (expectedItemIdentifiers) {
109507
+ const sectionIdentifiers = selectedSection["qti-assessment-item-ref"]?.map((item) => item.identifier) ?? [];
109508
+ const expectedIdentifiers = new Set(expectedItemIdentifiers);
109509
+ if (expectedIdentifiers.size !== sectionIdentifiers.length || sectionIdentifiers.some((identifier) => !expectedIdentifiers.has(identifier))) {
109510
+ throw new ValidationError("Questions can only be reordered within one complete assessment section");
109511
+ }
109512
+ }
109513
+ return {
109514
+ partIdentifier: selectedPart.identifier,
109515
+ sectionIdentifier: selectedSection.identifier
109516
+ };
109517
+ }
109518
+ var PLAYCADEMY_QTI_AUTHORING_METADATA_KEY = "playcademyAuthoring";
109519
+ var QTI_AUTHORING_FIELDS;
109520
+ var QUESTION_XML_BUILDERS;
109521
+ var init_timeback_qti_authoring_util = __esm(() => {
109522
+ init_timeback3();
109523
+ init_errors();
109524
+ QTI_AUTHORING_FIELDS = [
109525
+ "type",
109526
+ "qtiVersion",
109527
+ "timeDependent",
109528
+ "adaptive",
109529
+ "preInteraction",
109530
+ "interaction",
109531
+ "postInteraction",
109532
+ "responseDeclarations",
109533
+ "outcomeDeclarations",
109534
+ "responseProcessing",
109535
+ "modalFeedback",
109536
+ "feedbackInline",
109537
+ "feedbackBlock",
109538
+ "rubrics",
109539
+ "stimulus",
109540
+ "content"
109541
+ ];
109542
+ QUESTION_XML_BUILDERS = [
109543
+ buildNumericQuestionXml,
109544
+ buildHottextQuestionXml,
109545
+ buildInlineChoiceQuestionXml
109546
+ ];
109547
+ });
109548
+ function newPlaycademyTestIdentifier() {
109549
+ return `${PLAYCADEMY_QTI_SOURCE_PREFIX}${crypto.randomUUID()}`;
109550
+ }
109551
+ function playcademySourceWhere() {
109552
+ return {
109553
+ identifier: {
109554
+ gte: PLAYCADEMY_QTI_SOURCE_PREFIX,
109555
+ lt: PLAYCADEMY_QTI_SOURCE_UPPER_BOUND
109556
+ }
109557
+ };
109558
+ }
109559
+ function buildQtiLibraryListPlan(params) {
109560
+ const query = params.query?.trim() || undefined;
109561
+ if (!params.source && !query) {
109562
+ throw new Error("An exact QTI source identifier is required for a global lookup");
109563
+ }
109564
+ let where;
109565
+ if (params.source) {
109566
+ where = playcademySourceWhere();
109567
+ } else if (query) {
109568
+ where = { identifier: query };
109569
+ }
109570
+ return {
109571
+ kind: "direct",
109572
+ params: {
109573
+ ...where ? { where } : {},
109574
+ ...params.source && query ? { query } : {},
109575
+ page: params.page,
109576
+ limit: params.limit,
109577
+ ...params.source ? { sort: "identifier", order: "asc" } : {}
109578
+ }
109579
+ };
109580
+ }
109581
+ var PLAYCADEMY_QTI_SOURCE_PREFIX = "playcademy-test-";
109582
+ var PLAYCADEMY_QTI_SOURCE_UPPER_BOUND = "playcademy-test.";
108701
109583
 
108702
109584
  class TimebackAssessmentsService {
109585
+ static QTI_HYDRATION_CONCURRENCY = 8;
108703
109586
  deps;
108704
109587
  constructor(deps) {
108705
109588
  this.deps = deps;
@@ -108716,33 +109599,19 @@ class TimebackAssessmentsService {
108716
109599
  }
108717
109600
  async listAssessments(integrationId) {
108718
109601
  const client = this.requireClient();
108719
- await this.requireIntegration(integrationId);
109602
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId);
108720
109603
  const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
108721
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId),
108722
- orderBy: asc(gameTimebackAssessmentTests.sortOrder)
109604
+ where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
108723
109605
  });
108724
- if (rows.length === 0) {
108725
- return [];
108726
- }
108727
- const assessments = await Promise.all(rows.map(async (row) => {
109606
+ const assessments = await runWithConcurrency(rows, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (row) => {
108728
109607
  try {
108729
109608
  const test = await client.qtiApi.assessmentTests.get(row.qtiTestIdentifier);
108730
- let itemCount = 0;
108731
- for (const part of test["qti-test-part"] ?? []) {
108732
- for (const section of part["qti-assessment-section"] ?? []) {
108733
- itemCount += section["qti-assessment-item-ref"]?.length ?? 0;
108734
- }
108735
- }
108736
109609
  return {
108737
- id: row.id,
108738
- integrationId: row.integrationId,
108739
- qtiTestIdentifier: row.qtiTestIdentifier,
108740
- bankResourceId: row.bankResourceId,
108741
- bankActive: row.bankActive,
108742
- sortOrder: row.sortOrder,
109610
+ ...this.associationSummary(row),
108743
109611
  title: test.title,
108744
- questionCount: itemCount,
108745
- isActive: row.bankActive
109612
+ questionCount: countQtiTestItems(test),
109613
+ available: true,
109614
+ editable: isQtiTestOwnedByGame(test, ownership.gameSlug)
108746
109615
  };
108747
109616
  } catch (error88) {
108748
109617
  addEvent("assessment.qti_fetch_failed", {
@@ -108751,319 +109620,420 @@ class TimebackAssessmentsService {
108751
109620
  "app.error.message": errorMessage2(error88)
108752
109621
  });
108753
109622
  return {
108754
- id: row.id,
108755
- integrationId: row.integrationId,
108756
- qtiTestIdentifier: row.qtiTestIdentifier,
108757
- bankResourceId: row.bankResourceId,
108758
- bankActive: row.bankActive,
108759
- sortOrder: row.sortOrder,
109623
+ ...this.associationSummary(row),
108760
109624
  title: row.qtiTestIdentifier,
108761
109625
  questionCount: 0,
108762
- isActive: row.bankActive
109626
+ available: false,
109627
+ editable: false
108763
109628
  };
108764
109629
  }
108765
- }));
108766
- return assessments;
109630
+ });
109631
+ return assessments.toSorted((a, b) => a.title.localeCompare(b.title));
108767
109632
  }
108768
109633
  async createAssessment(integrationId, input) {
108769
109634
  const client = this.requireClient();
108770
- const integration = await this.requireIntegration(integrationId);
108771
- await client.course.ensureAssessmentBank({
108772
- courseId: integration.courseId,
108773
- subject: integration.subject,
108774
- grade: integration.grade
108775
- });
109635
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId);
109636
+ const { integration } = ownership;
108776
109637
  await client.course.createAssessmentTest({
108777
109638
  identifier: input.qtiTestIdentifier,
108778
109639
  title: input.title,
108779
109640
  metadata: {
109641
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
109642
+ ownerGameSlug: ownership.gameSlug,
108780
109643
  integrationId,
108781
109644
  subject: integration.subject,
108782
109645
  grade: String(integration.grade)
108783
109646
  }
108784
109647
  });
108785
- const maxSortOrder = await this.getMaxSortOrder(integrationId);
108786
- const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
108787
- integrationId,
108788
- qtiTestIdentifier: input.qtiTestIdentifier,
108789
- sortOrder: maxSortOrder + 1
108790
- }).returning();
108791
- setAttribute("app.assessment.operation", "create");
108792
- return row;
108793
- }
108794
- async deleteAssessment(integrationId, qtiTestIdentifier) {
108795
- const client = this.requireClient();
108796
- const row = await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
108797
- if (row.bankActive) {
108798
- await this.deactivateAssessment(integrationId, qtiTestIdentifier);
108799
- }
108800
109648
  try {
108801
- await client.course.teardownAssessmentTest(qtiTestIdentifier);
109649
+ const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
109650
+ integrationId,
109651
+ qtiTestIdentifier: input.qtiTestIdentifier,
109652
+ purpose: input.purpose,
109653
+ status: "draft"
109654
+ }).returning();
109655
+ setAttribute("app.assessment.operation", "create");
109656
+ return row;
108802
109657
  } catch (error88) {
108803
- addEvent("assessment.qti_cleanup_partial", {
108804
- "app.assessment.qti_test_identifier": qtiTestIdentifier,
108805
- "exception.type": errorType(error88),
108806
- "app.error.message": errorMessage2(error88)
108807
- });
109658
+ let committedRow;
109659
+ try {
109660
+ committedRow = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
109661
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, input.qtiTestIdentifier))
109662
+ });
109663
+ } catch (verificationError) {
109664
+ addEvent("assessment.qti_test_create_commit_verification_failed", {
109665
+ "app.assessment.qti_test_identifier": input.qtiTestIdentifier,
109666
+ "exception.type": errorType(verificationError),
109667
+ "app.error.message": errorMessage2(verificationError)
109668
+ });
109669
+ throw error88;
109670
+ }
109671
+ if (committedRow) {
109672
+ setAttribute("app.assessment.operation", "create");
109673
+ return committedRow;
109674
+ }
109675
+ try {
109676
+ await client.qtiApi.assessmentTests.delete(input.qtiTestIdentifier);
109677
+ } catch (cleanupError) {
109678
+ if (!isApiError(cleanupError) || cleanupError.statusCode !== 404) {
109679
+ addEvent("assessment.qti_test_create_cleanup_failed", {
109680
+ "app.assessment.qti_test_identifier": input.qtiTestIdentifier,
109681
+ "exception.type": errorType(cleanupError),
109682
+ "app.error.message": errorMessage2(cleanupError)
109683
+ });
109684
+ }
109685
+ }
109686
+ throw error88;
108808
109687
  }
108809
- await this.deps.db.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
108810
- setAttribute("app.assessment.operation", "delete");
108811
109688
  }
108812
- async reorderAssessments(integrationId, identifiers) {
108813
- await this.requireIntegration(integrationId);
108814
- 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)))));
108815
- }
108816
- async listQuestions(integrationId, qtiTestIdentifier) {
108817
- const client = this.requireClient();
108818
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
108819
- return await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
108820
- }
108821
- async createQuestion(integrationId, qtiTestIdentifier, input) {
108822
- const client = this.requireClient();
108823
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
108824
- const itemIdentifier = input.identifier;
108825
- if (typeof itemIdentifier !== "string" || !itemIdentifier) {
108826
- throw new ValidationError("Question identifier is required");
108827
- }
108828
- const item = await client.qtiApi.assessmentItems.create(input);
108829
- await this.qtiSectionItems(client, qtiTestIdentifier).add({
108830
- identifier: itemIdentifier,
108831
- href: `${client.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`
109689
+ async updateAssessment(integrationId, qtiTestIdentifier, input) {
109690
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
109691
+ const updates = buildAssessmentAssociationUpdates(row, input);
109692
+ if (input.status !== undefined) {
109693
+ validateAssessmentStatusTransition(row.status, input.status);
109694
+ if (isAssessmentPublicationTransition(row.status, input.status)) {
109695
+ await this.validateAssessmentHasQuestions(qtiTestIdentifier);
109696
+ }
109697
+ }
109698
+ if (input.title !== undefined) {
109699
+ assertDraftAssessment(row);
109700
+ assertAllAssessmentAssociationsDraft(associations);
109701
+ const client = this.requireClient();
109702
+ const test = await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
109703
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
109704
+ assertQtiTestOwnedByGame(test, ownership.gameSlug);
109705
+ await client.qtiApi.assessmentTests.update(qtiTestIdentifier, buildQtiTestUpdateInput(test, input.title));
109706
+ }
109707
+ let updated = row;
109708
+ if (Object.keys(updates).length > 0) {
109709
+ const [updatedRow] = await tx.update(gameTimebackAssessmentTests).set({ ...updates, updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id)).returning();
109710
+ updated = updatedRow ?? row;
109711
+ }
109712
+ setAttribute("app.assessment.operation", input.title !== undefined ? "update_test" : "update_association");
109713
+ return updated;
108832
109714
  });
108833
- return item;
108834
- }
108835
- async updateQuestion(integrationId, qtiTestIdentifier, itemIdentifier, input) {
108836
- const client = this.requireClient();
108837
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
108838
- return client.qtiApi.assessmentItems.update(itemIdentifier, input);
108839
109715
  }
108840
- async deleteQuestion(integrationId, qtiTestIdentifier, itemIdentifier) {
108841
- const client = this.requireClient();
108842
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
108843
- await this.qtiSectionItems(client, qtiTestIdentifier).remove(itemIdentifier);
108844
- await client.qtiApi.assessmentItems.delete(itemIdentifier);
109716
+ async removeAssessment(integrationId, qtiTestIdentifier) {
109717
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx) => {
109718
+ const plan = planAssessmentRemoval(row.status);
109719
+ if (plan.kind === "delete") {
109720
+ await tx.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
109721
+ } else if (plan.kind === "archive") {
109722
+ await tx.update(gameTimebackAssessmentTests).set({ status: "archived", sortOrder: null, updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id));
109723
+ }
109724
+ if (plan.kind !== "none") {
109725
+ setAttribute("app.assessment.operation", plan.operation);
109726
+ }
109727
+ return { action: plan.action };
109728
+ });
108845
109729
  }
108846
- async reorderQuestions(integrationId, qtiTestIdentifier, items) {
108847
- const client = this.requireClient();
108848
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
108849
- const qtiBaseUrl = client.getQtiBaseUrl();
108850
- return this.qtiSectionItems(client, qtiTestIdentifier).reorder({
108851
- items: items.map((item) => ({
108852
- identifier: item.identifier,
108853
- href: item.href ?? `${qtiBaseUrl}/assessment-items/${item.identifier}`,
108854
- sequence: item.sequence
108855
- }))
109730
+ async reorderAssessments(integrationId, purpose, testIdentifiers) {
109731
+ await this.requireIntegration(integrationId);
109732
+ validateUniqueAssessmentIdentifiers(testIdentifiers);
109733
+ const updatedAt = new Date;
109734
+ await this.deps.db.transaction(async (tx) => {
109735
+ const liveRows = await tx.query.gameTimebackAssessmentTests.findMany({
109736
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))
109737
+ });
109738
+ const orderedRows = orderLiveAssessmentRows(liveRows, purpose, testIdentifiers);
109739
+ const positionByRowId = new Map(orderedRows.map((row, index2) => [row.id, index2 + 1]));
109740
+ for (const row of lockOrderAssessmentRows(orderedRows)) {
109741
+ const testIdentifier = row.qtiTestIdentifier;
109742
+ 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 });
109743
+ assertAssessmentOrderUpdateSucceeded(updatedRow);
109744
+ }
108856
109745
  });
109746
+ setAttribute("app.assessment.operation", "reorder_live_assessments");
108857
109747
  }
108858
- async activateAssessment(integrationId, qtiTestIdentifier) {
109748
+ async listQuestions(integrationId, qtiTestIdentifier) {
108859
109749
  const client = this.requireClient();
108860
- const integration = await this.requireIntegration(integrationId);
108861
- const row = await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
108862
- if (row.bankActive) {
108863
- throw new ValidationError("Assessment is already active");
108864
- }
108865
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
108866
- const qtiTestUrl = `${client.getQtiBaseUrl()}/assessment-tests/${qtiTestIdentifier}`;
108867
- await client.course.ensureAssessmentBank({
108868
- courseId: integration.courseId,
108869
- subject: integration.subject,
108870
- grade: integration.grade
108871
- });
108872
- const parentResource = await client.api.oneroster.resources.get(bankIds.resource);
108873
- const currentResources = parentResource.metadata?.resources ?? [];
108874
- let childResourceId = row.bankResourceId;
108875
- if (childResourceId) {
109750
+ await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
109751
+ const { gameSlug } = await this.requireQtiTestOwnershipContext(integrationId);
109752
+ const result = await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
109753
+ const ownerTests = new Map;
109754
+ const questions = await runWithConcurrency(result.questions, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (reference) => {
109755
+ let item;
108876
109756
  try {
108877
- await client.api.oneroster.resources.update(childResourceId, {
108878
- status: "active",
108879
- title: `Assessment Bank Test: ${qtiTestIdentifier}`,
108880
- vendorResourceId: "",
108881
- metadata: {
108882
- type: "qti",
108883
- subType: "qti-test",
108884
- url: qtiTestUrl
108885
- }
108886
- });
108887
- if (!currentResources.includes(childResourceId)) {
108888
- await client.api.oneroster.resources.update(bankIds.resource, {
108889
- status: "active",
108890
- title: parentResource.title,
108891
- vendorResourceId: parentResource.vendorResourceId,
108892
- vendorId: parentResource.vendorId,
108893
- metadata: {
108894
- ...parentResource.metadata,
108895
- resources: [...currentResources, childResourceId]
108896
- }
108897
- });
108898
- }
109757
+ item = await client.qtiApi.assessmentItems.get(reference.reference.identifier);
108899
109758
  } catch (error88) {
108900
- addEvent("assessment.child_resource_reactivation_failed", {
108901
- "app.assessment.child_resource_id": childResourceId ?? "",
109759
+ addEvent("assessment.qti_question_hydration_failed", {
109760
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
109761
+ "app.assessment.qti_item_identifier": reference.reference.identifier,
108902
109762
  "exception.type": errorType(error88),
108903
109763
  "app.error.message": errorMessage2(error88)
108904
109764
  });
108905
- childResourceId = null;
109765
+ return reference;
108906
109766
  }
108907
- }
108908
- if (!childResourceId) {
108909
- for (const resourceId of currentResources) {
108910
- try {
108911
- const resource = await client.api.oneroster.resources.get(resourceId);
108912
- const resourceUrl = resource.metadata?.url;
108913
- if (resourceUrl === qtiTestUrl) {
108914
- childResourceId = resourceId;
108915
- addEvent("assessment.child_resource_reused", {
108916
- "app.assessment.qti_test_identifier": qtiTestIdentifier,
108917
- "app.assessment.child_resource_id": resourceId
108918
- });
108919
- break;
108920
- }
108921
- } catch {}
108922
- }
108923
- if (!childResourceId) {
108924
- const childResult = await client.api.oneroster.resources.create({
108925
- status: "active",
108926
- title: `Assessment Bank Test: ${qtiTestIdentifier}`,
108927
- vendorResourceId: "",
108928
- vendorId: undefined,
108929
- metadata: {
108930
- type: "qti",
108931
- subType: "qti-test",
108932
- url: qtiTestUrl
108933
- }
108934
- });
108935
- childResourceId = childResult.sourcedIdPairs.allocatedSourcedId;
108936
- await client.api.oneroster.resources.update(bankIds.resource, {
108937
- status: "active",
108938
- title: parentResource.title,
108939
- vendorResourceId: parentResource.vendorResourceId,
108940
- vendorId: parentResource.vendorId,
108941
- metadata: {
108942
- ...parentResource.metadata,
108943
- resources: [...currentResources, childResourceId]
108944
- }
109767
+ let ownerTest;
109768
+ try {
109769
+ ownerTest = await this.qtiQuestionOwnerTest(client, item, ownerTests);
109770
+ } catch (error88) {
109771
+ addEvent("assessment.qti_question_owner_test_fetch_failed", {
109772
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
109773
+ "app.assessment.qti_item_identifier": reference.reference.identifier,
109774
+ "exception.type": errorType(error88),
109775
+ "app.error.message": errorMessage2(error88)
108945
109776
  });
108946
109777
  }
108947
- }
108948
- await this.deps.db.update(gameTimebackAssessmentTests).set({ bankResourceId: childResourceId, bankActive: true }).where(eq(gameTimebackAssessmentTests.id, row.id));
108949
- setAttributes({
108950
- "app.assessment.operation": "activate",
108951
- "app.assessment.child_resource_id": childResourceId ?? ""
109778
+ return buildHydratedQtiQuestionReference({
109779
+ reference,
109780
+ item,
109781
+ gameSlug,
109782
+ ownerTest,
109783
+ testIdentifier: qtiTestIdentifier
109784
+ });
108952
109785
  });
109786
+ return { ...result, questions };
108953
109787
  }
108954
- async deactivateAssessment(integrationId, qtiTestIdentifier) {
109788
+ async listQuestionLibrary(integrationId, params) {
108955
109789
  const client = this.requireClient();
108956
- const integration = await this.requireIntegration(integrationId);
108957
- const row = await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
108958
- if (!row.bankActive) {
108959
- throw new ValidationError("Assessment is already in draft");
108960
- }
108961
- if (!row.bankResourceId) {
108962
- throw new ValidationError("Assessment has no bank resource — activate it first");
108963
- }
108964
- const childResourceId = row.bankResourceId;
108965
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
108966
- try {
108967
- const parentResource = await client.api.oneroster.resources.get(bankIds.resource);
108968
- const currentResources = parentResource.metadata?.resources ?? [];
108969
- const filtered = currentResources.filter((id) => id !== childResourceId);
108970
- if (filtered.length !== currentResources.length) {
108971
- await client.api.oneroster.resources.update(bankIds.resource, {
108972
- status: "active",
108973
- title: parentResource.title,
108974
- vendorResourceId: parentResource.vendorResourceId,
108975
- vendorId: parentResource.vendorId,
108976
- metadata: {
108977
- ...parentResource.metadata,
108978
- resources: filtered
108979
- }
108980
- });
108981
- }
108982
- } catch (error88) {
108983
- addEvent("assessment.parent_resource_update_failed", {
108984
- "app.assessment.bank_resource_id": bankIds.resource,
108985
- "exception.type": errorType(error88),
108986
- "app.error.message": errorMessage2(error88)
108987
- });
108988
- }
108989
- try {
108990
- await client.api.oneroster.resources.delete(childResourceId);
108991
- } catch (error88) {
108992
- addEvent("assessment.child_resource_delete_failed", {
108993
- "app.assessment.child_resource_id": childResourceId,
108994
- "exception.type": errorType(error88),
108995
- "app.error.message": errorMessage2(error88)
108996
- });
108997
- }
108998
- await this.deps.db.update(gameTimebackAssessmentTests).set({ bankActive: false }).where(eq(gameTimebackAssessmentTests.id, row.id));
108999
- setAttribute("app.assessment.operation", "deactivate");
109000
- }
109001
- isAssessmentActive(row) {
109002
- return row.bankActive;
109790
+ await this.requireIntegration(integrationId);
109791
+ return this.listQtiLibrary(params, async (listParams) => await client.qtiApi.assessmentItems.list(listParams));
109003
109792
  }
109004
- async getBankStatus(integrationId) {
109005
- const integration = await this.requireIntegration(integrationId);
109006
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
109007
- const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
109008
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
109009
- });
109010
- const activeCount = rows.filter((r) => r.bankActive).length;
109011
- return {
109012
- bankIds,
109013
- totalAssessments: rows.length,
109014
- activeAssessments: activeCount,
109015
- draftAssessments: rows.length - activeCount
109016
- };
109793
+ async listTestLibrary(integrationId, params) {
109794
+ const client = this.requireClient();
109795
+ await this.requireIntegration(integrationId);
109796
+ return this.listQtiLibrary(params, async (listParams) => await client.qtiApi.assessmentTests.list(listParams));
109017
109797
  }
109018
- async destroyBank(integrationId) {
109798
+ async copyAssessment(integrationId, sourceTestIdentifier, targetTestIdentifier, purpose) {
109019
109799
  const client = this.requireClient();
109020
- const integration = await this.requireIntegration(integrationId);
109021
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
109022
- const activeRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
109023
- where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId))
109800
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId);
109801
+ const { integration } = ownership;
109802
+ const source = await client.qtiApi.assessmentTests.get(sourceTestIdentifier);
109803
+ const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => this.qtiItemHref(client, identifier));
109804
+ const itemCopies = await runWithConcurrency(itemPlan, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (plan) => {
109805
+ const sourceItem = await client.qtiApi.assessmentItems.get(plan.sourceIdentifier);
109806
+ return {
109807
+ plan,
109808
+ input: buildQtiQuestionCopyInput({
109809
+ source: sourceItem,
109810
+ targetIdentifier: plan.targetIdentifier,
109811
+ targetTestIdentifier,
109812
+ gameSlug: ownership.gameSlug,
109813
+ integrationId
109814
+ })
109815
+ };
109024
109816
  });
109817
+ const testInput = buildQtiTestCopyInput(source, targetTestIdentifier, {
109818
+ ...source.metadata,
109819
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
109820
+ ownerGameSlug: ownership.gameSlug,
109821
+ integrationId,
109822
+ subject: integration.subject,
109823
+ grade: String(integration.grade),
109824
+ copiedFromTestIdentifier: sourceTestIdentifier
109825
+ }, itemPlan);
109826
+ const attemptedItemIdentifiers = [];
109827
+ let testCreationAttempted = false;
109828
+ let associationCreationAttempted = false;
109025
109829
  try {
109026
- await client.api.oneroster.courses.deleteComponentResource(bankIds.componentResource);
109830
+ for (const copy of itemCopies) {
109831
+ attemptedItemIdentifiers.push(copy.plan.targetIdentifier);
109832
+ await client.course.createAssessmentItemXml({
109833
+ xml: copy.input.xml,
109834
+ metadata: copy.input.metadata
109835
+ });
109836
+ }
109837
+ testCreationAttempted = true;
109838
+ await client.qtiApi.assessmentTests.create(testInput);
109839
+ associationCreationAttempted = true;
109840
+ const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
109841
+ integrationId,
109842
+ qtiTestIdentifier: targetTestIdentifier,
109843
+ purpose,
109844
+ status: "draft"
109845
+ }).returning();
109846
+ setAttribute("app.assessment.operation", "copy_test");
109847
+ return row;
109027
109848
  } catch (error88) {
109028
- addEvent("assessment.component_resource_delete_failed", {
109029
- "app.assessment.component_resource_id": bankIds.componentResource,
109030
- "exception.type": errorType(error88),
109031
- "app.error.message": errorMessage2(error88)
109032
- });
109033
- }
109034
- for (const row of activeRows) {
109035
- if (row.bankResourceId) {
109849
+ if (associationCreationAttempted) {
109850
+ let committedRow;
109036
109851
  try {
109037
- await client.api.oneroster.resources.delete(row.bankResourceId);
109038
- } catch (error88) {
109039
- addEvent("assessment.child_resource_delete_failed", {
109040
- "app.assessment.child_resource_id": row.bankResourceId,
109041
- "exception.type": errorType(error88),
109042
- "app.error.message": errorMessage2(error88)
109852
+ committedRow = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
109853
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, targetTestIdentifier))
109854
+ });
109855
+ } catch (verificationError) {
109856
+ addEvent("assessment.qti_test_copy_commit_verification_failed", {
109857
+ "app.assessment.qti_test_identifier": targetTestIdentifier,
109858
+ "exception.type": errorType(verificationError),
109859
+ "app.error.message": errorMessage2(verificationError)
109043
109860
  });
109861
+ throw error88;
109862
+ }
109863
+ if (committedRow) {
109864
+ setAttribute("app.assessment.operation", "copy_test");
109865
+ return committedRow;
109044
109866
  }
109045
109867
  }
109868
+ await this.cleanupQtiAssessmentCopy(client, testCreationAttempted ? targetTestIdentifier : undefined, attemptedItemIdentifiers);
109869
+ throw error88;
109046
109870
  }
109047
- try {
109048
- await client.api.oneroster.resources.delete(bankIds.resource);
109049
- } catch (error88) {
109050
- addEvent("assessment.parent_resource_delete_failed", {
109051
- "app.assessment.bank_resource_id": bankIds.resource,
109052
- "exception.type": errorType(error88),
109053
- "app.error.message": errorMessage2(error88)
109871
+ }
109872
+ async createQuestion(integrationId, qtiTestIdentifier, input) {
109873
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
109874
+ const client = this.requireClient();
109875
+ assertAllAssessmentAssociationsDraft(associations);
109876
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
109877
+ const creation = parseQtiQuestionCreationInput(input);
109878
+ const itemIdentifier = newPlaycademyQuestionIdentifier(qtiTestIdentifier);
109879
+ let title;
109880
+ let metadata2;
109881
+ let xml;
109882
+ let structuredInput;
109883
+ let fallbackItem;
109884
+ if (creation.kind === "copy") {
109885
+ const source = await client.qtiApi.assessmentItems.get(creation.sourceItemIdentifier);
109886
+ const copy = buildQtiQuestionCopyInput({
109887
+ source,
109888
+ targetIdentifier: itemIdentifier,
109889
+ targetTestIdentifier: qtiTestIdentifier,
109890
+ gameSlug: ownership.gameSlug,
109891
+ integrationId
109892
+ });
109893
+ title = copy.title;
109894
+ metadata2 = copy.metadata;
109895
+ xml = copy.xml;
109896
+ fallbackItem = {
109897
+ ...source,
109898
+ identifier: itemIdentifier,
109899
+ title,
109900
+ rawXml: xml,
109901
+ metadata: metadata2
109902
+ };
109903
+ } else {
109904
+ structuredInput = creation.input;
109905
+ assertSupportedQtiQuestionInteraction(structuredInput);
109906
+ title = typeof structuredInput.title === "string" ? structuredInput.title.trim() : "";
109907
+ metadata2 = buildOwnedQtiQuestionMetadata(structuredInput, {
109908
+ gameSlug: ownership.gameSlug,
109909
+ testIdentifier: qtiTestIdentifier
109910
+ });
109911
+ xml = buildQuestionXml(structuredInput, itemIdentifier, title);
109912
+ fallbackItem = {
109913
+ ...structuredInput,
109914
+ identifier: itemIdentifier,
109915
+ title,
109916
+ ...xml ? { rawXml: xml } : {}
109917
+ };
109918
+ }
109919
+ const section = await this.qtiSectionItems(client, qtiTestIdentifier, undefined, undefined, ownership.test);
109920
+ const href = this.qtiItemHref(client, itemIdentifier);
109921
+ let item;
109922
+ let itemCreationAttempted = false;
109923
+ let referenceCreationAttempted = false;
109924
+ try {
109925
+ itemCreationAttempted = true;
109926
+ item = xml ? await client.course.createAssessmentItemXml({ xml, metadata: metadata2 }) : await client.qtiApi.assessmentItems.create({
109927
+ ...structuredInput,
109928
+ identifier: itemIdentifier,
109929
+ metadata: metadata2
109930
+ });
109931
+ referenceCreationAttempted = true;
109932
+ await section.items.add({
109933
+ identifier: itemIdentifier,
109934
+ href
109935
+ });
109936
+ } catch (error88) {
109937
+ let referenceRemoved = !referenceCreationAttempted;
109938
+ const creationStatus = isApiError(error88) ? error88.statusCode : undefined;
109939
+ const itemCreationDefinitelyRejected = !referenceCreationAttempted && typeof creationStatus === "number" && creationStatus >= 400 && creationStatus < 500 && creationStatus !== 408;
109940
+ if (referenceCreationAttempted) {
109941
+ try {
109942
+ await section.items.remove(itemIdentifier);
109943
+ referenceRemoved = true;
109944
+ } catch (cleanupError) {
109945
+ referenceRemoved = isApiError(cleanupError) && cleanupError.statusCode === 404;
109946
+ addEvent("assessment.qti_question_reference_cleanup_failed", {
109947
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
109948
+ "app.assessment.qti_item_identifier": itemIdentifier,
109949
+ "exception.type": errorType(cleanupError),
109950
+ "app.error.message": errorMessage2(cleanupError)
109951
+ });
109952
+ }
109953
+ }
109954
+ if (itemCreationAttempted && !itemCreationDefinitelyRejected && referenceRemoved) {
109955
+ try {
109956
+ await client.qtiApi.assessmentItems.delete(itemIdentifier);
109957
+ } catch (cleanupError) {
109958
+ addEvent("assessment.qti_question_cleanup_failed", {
109959
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
109960
+ "app.assessment.qti_item_identifier": itemIdentifier,
109961
+ "exception.type": errorType(cleanupError),
109962
+ "app.error.message": errorMessage2(cleanupError)
109963
+ });
109964
+ }
109965
+ }
109966
+ throw error88;
109967
+ }
109968
+ setAttribute("app.assessment.operation", creation.kind === "copy" ? "copy_question" : "create_question");
109969
+ return buildCreatedQtiQuestionReference({
109970
+ item,
109971
+ fallbackItem,
109972
+ itemIdentifier,
109973
+ href,
109974
+ partIdentifier: section.partIdentifier,
109975
+ sectionIdentifier: section.sectionIdentifier,
109976
+ metadata: metadata2
109054
109977
  });
109055
- }
109056
- try {
109057
- await client.api.oneroster.courses.deleteComponent(bankIds.component);
109058
- } catch (error88) {
109059
- addEvent("assessment.course_component_delete_failed", {
109060
- "app.assessment.component_id": bankIds.component,
109061
- "exception.type": errorType(error88),
109062
- "app.error.message": errorMessage2(error88)
109978
+ });
109979
+ }
109980
+ async updateQuestion(integrationId, qtiTestIdentifier, itemIdentifier, input) {
109981
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
109982
+ const client = this.requireClient();
109983
+ assertAllAssessmentAssociationsDraft(associations);
109984
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
109985
+ const [, item] = await Promise.all([
109986
+ this.qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, undefined, ownership.test),
109987
+ client.qtiApi.assessmentItems.get(itemIdentifier)
109988
+ ]);
109989
+ const ownerTest = await this.qtiQuestionOwnerTest(client, item, undefined, ownership.test);
109990
+ const replacesInteraction = "type" in input || "interaction" in input || "numericTextEntry" in input;
109991
+ assertQtiQuestionEditable(item, { gameSlug: ownership.gameSlug, testIdentifier: qtiTestIdentifier }, ownerTest);
109992
+ assertSupportedQtiQuestionInteraction(replacesInteraction ? input : item);
109993
+ let title = typeof input.title === "string" ? input.title.trim() : "";
109994
+ if (!title) {
109995
+ title = item.title;
109996
+ }
109997
+ const metadata2 = buildOwnedQtiQuestionMetadata(input, { gameSlug: ownership.gameSlug, testIdentifier: qtiTestIdentifier }, item.metadata);
109998
+ const xml = buildQuestionXml(input, itemIdentifier, title);
109999
+ const updated = xml ? await client.course.updateAssessmentItemXml(itemIdentifier, { xml, metadata: metadata2 }) : await client.qtiApi.assessmentItems.update(itemIdentifier, {
110000
+ ...input,
110001
+ title,
110002
+ metadata: metadata2
110003
+ });
110004
+ setAttribute("app.assessment.operation", "update_question");
110005
+ return updated;
110006
+ });
110007
+ }
110008
+ async removeQuestion(integrationId, qtiTestIdentifier, itemIdentifier) {
110009
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
110010
+ const client = this.requireClient();
110011
+ assertAllAssessmentAssociationsDraft(associations);
110012
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
110013
+ const section = await this.qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, undefined, ownership.test);
110014
+ await section.items.remove(itemIdentifier);
110015
+ setAttribute("app.assessment.operation", "remove_question");
110016
+ });
110017
+ }
110018
+ async reorderQuestions(integrationId, qtiTestIdentifier, items) {
110019
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
110020
+ const client = this.requireClient();
110021
+ assertAllAssessmentAssociationsDraft(associations);
110022
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
110023
+ const identifiers = items.map((item) => item.identifier);
110024
+ validateUniqueQuestionIdentifiers(identifiers);
110025
+ const firstIdentifier = identifiers[0];
110026
+ const section = await this.qtiSectionItems(client, qtiTestIdentifier, firstIdentifier, identifiers, ownership.test);
110027
+ const result = await section.items.reorder({
110028
+ items: items.map((item) => ({
110029
+ identifier: item.identifier,
110030
+ href: item.href ?? this.qtiItemHref(client, item.identifier),
110031
+ sequence: item.sequence
110032
+ }))
109063
110033
  });
109064
- }
109065
- await this.deps.db.update(gameTimebackAssessmentTests).set({ bankResourceId: null, bankActive: false }).where(eq(gameTimebackAssessmentTests.integrationId, integrationId));
109066
- setAttribute("app.assessment.operation", "destroy_bank");
110034
+ setAttribute("app.assessment.operation", "reorder_questions");
110035
+ return result;
110036
+ });
109067
110037
  }
109068
110038
  requireClient() {
109069
110039
  if (!this.deps.timeback) {
@@ -109071,8 +110041,18 @@ class TimebackAssessmentsService {
109071
110041
  }
109072
110042
  return this.deps.timeback;
109073
110043
  }
109074
- async requireIntegration(integrationId) {
109075
- const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
110044
+ async withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, action) {
110045
+ return this.deps.db.transaction(async (tx) => {
110046
+ const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier)).orderBy(gameTimebackAssessmentTests.id).for("update");
110047
+ const row = rows.find((candidate) => candidate.integrationId === integrationId);
110048
+ if (!row) {
110049
+ throw new NotFoundError(`Assessment not found: ${qtiTestIdentifier}`);
110050
+ }
110051
+ return action(row, tx, rows);
110052
+ });
110053
+ }
110054
+ async requireIntegration(integrationId, db2 = this.deps.db) {
110055
+ const integration = await db2.query.gameTimebackIntegrations.findFirst({
109076
110056
  where: and(eq(gameTimebackIntegrations.id, integrationId), isActiveGameTimebackIntegrationStatus())
109077
110057
  });
109078
110058
  if (!integration) {
@@ -109080,6 +110060,17 @@ class TimebackAssessmentsService {
109080
110060
  }
109081
110061
  return integration;
109082
110062
  }
110063
+ async requireQtiTestOwnershipContext(integrationId, db2 = this.deps.db) {
110064
+ const integration = await this.requireIntegration(integrationId, db2);
110065
+ const game2 = await db2.query.games.findFirst({
110066
+ where: eq(games.id, integration.gameId),
110067
+ columns: { slug: true }
110068
+ });
110069
+ if (!game2) {
110070
+ throw new NotFoundError(`Game not found for integration: ${integrationId}`);
110071
+ }
110072
+ return { integration, gameSlug: game2.slug };
110073
+ }
109083
110074
  async requireAssessmentRow(integrationId, qtiTestIdentifier) {
109084
110075
  const row = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
109085
110076
  where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier))
@@ -109089,27 +110080,125 @@ class TimebackAssessmentsService {
109089
110080
  }
109090
110081
  return row;
109091
110082
  }
109092
- qtiSectionItems(client, qtiTestIdentifier) {
109093
- return client.qtiApi.assessmentTests.testParts(qtiTestIdentifier).sections(`${qtiTestIdentifier}-part1`).items(`${qtiTestIdentifier}-section1`);
110083
+ async requireDraftAssessmentRow(integrationId, qtiTestIdentifier, lockedRow) {
110084
+ const row = lockedRow ?? await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
110085
+ assertDraftAssessment(row);
110086
+ return row;
109094
110087
  }
109095
- async getMaxSortOrder(integrationId) {
109096
- const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
109097
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId),
109098
- columns: { sortOrder: true }
110088
+ async requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, lockedRow, db2 = this.deps.db) {
110089
+ const client = this.requireClient();
110090
+ await this.requireDraftAssessmentRow(integrationId, qtiTestIdentifier, lockedRow);
110091
+ const test = await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
110092
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId, db2);
110093
+ assertQtiTestOwnedByGame(test, ownership.gameSlug);
110094
+ return { ...ownership, test };
110095
+ }
110096
+ async qtiQuestionOwnerTest(client, item, cache, knownTest) {
110097
+ if (item.metadata && "ownerGameSlug" in item.metadata) {
110098
+ return;
110099
+ }
110100
+ const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
110101
+ if (!ownerTestIdentifier) {
110102
+ return;
110103
+ }
110104
+ if (knownTest?.identifier === ownerTestIdentifier) {
110105
+ return knownTest;
110106
+ }
110107
+ function loadOwnerTest(identifier) {
110108
+ return client.qtiApi.assessmentTests.get(identifier);
110109
+ }
110110
+ if (!cache) {
110111
+ return loadOwnerTest(ownerTestIdentifier);
110112
+ }
110113
+ let ownerTest = cache.get(ownerTestIdentifier);
110114
+ if (!ownerTest) {
110115
+ ownerTest = loadOwnerTest(ownerTestIdentifier);
110116
+ cache.set(ownerTestIdentifier, ownerTest);
110117
+ }
110118
+ return ownerTest;
110119
+ }
110120
+ async assertQtiTestQuestionsSupported(client, qtiTestIdentifier, questions) {
110121
+ const result = questions ?? await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
110122
+ await runWithConcurrency(result.questions, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (reference) => {
110123
+ const item = await client.qtiApi.assessmentItems.get(reference.reference.identifier);
110124
+ assertSupportedQtiQuestionInteraction(item);
109099
110125
  });
109100
- if (rows.length === 0) {
109101
- return -1;
110126
+ }
110127
+ async qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers, knownTest) {
110128
+ const test = knownTest ?? await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
110129
+ const section = resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers);
110130
+ return {
110131
+ ...section,
110132
+ items: client.qtiApi.assessmentTests.testParts(qtiTestIdentifier).sections(section.partIdentifier).items(section.sectionIdentifier)
110133
+ };
110134
+ }
110135
+ qtiItemHref(client, itemIdentifier) {
110136
+ return `${client.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
110137
+ }
110138
+ async cleanupQtiAssessmentCopy(client, testIdentifier, itemIdentifiers) {
110139
+ let testDeleted = !testIdentifier;
110140
+ if (testIdentifier) {
110141
+ try {
110142
+ await client.qtiApi.assessmentTests.delete(testIdentifier);
110143
+ testDeleted = true;
110144
+ } catch (error88) {
110145
+ testDeleted = isApiError(error88) && error88.statusCode === 404;
110146
+ addEvent("assessment.qti_test_copy_cleanup_failed", {
110147
+ "app.assessment.qti_test_identifier": testIdentifier,
110148
+ "exception.type": errorType(error88),
110149
+ "app.error.message": errorMessage2(error88)
110150
+ });
110151
+ }
110152
+ }
110153
+ if (!testDeleted) {
110154
+ return;
110155
+ }
110156
+ for (const itemIdentifier of itemIdentifiers.toReversed()) {
110157
+ try {
110158
+ await client.qtiApi.assessmentItems.delete(itemIdentifier);
110159
+ } catch (error88) {
110160
+ addEvent("assessment.qti_question_cleanup_failed", {
110161
+ ...testIdentifier ? { "app.assessment.qti_test_identifier": testIdentifier } : {},
110162
+ "app.assessment.qti_item_identifier": itemIdentifier,
110163
+ "exception.type": errorType(error88),
110164
+ "app.error.message": errorMessage2(error88)
110165
+ });
110166
+ }
109102
110167
  }
109103
- return Math.max(...rows.map((r) => r.sortOrder));
110168
+ }
110169
+ associationSummary(row) {
110170
+ return {
110171
+ id: row.id,
110172
+ integrationId: row.integrationId,
110173
+ qtiTestIdentifier: row.qtiTestIdentifier,
110174
+ purpose: row.purpose,
110175
+ status: row.status,
110176
+ sortOrder: row.sortOrder,
110177
+ createdAt: row.createdAt,
110178
+ updatedAt: row.updatedAt
110179
+ };
110180
+ }
110181
+ async listQtiLibrary(params, list) {
110182
+ const plan = buildQtiLibraryListPlan(params);
110183
+ return list(plan.params);
110184
+ }
110185
+ async validateAssessmentHasQuestions(qtiTestIdentifier) {
110186
+ const client = this.requireClient();
110187
+ const result = await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
110188
+ assertAssessmentHasQuestions(result.questions);
110189
+ await this.assertQtiTestQuestionsSupported(client, qtiTestIdentifier, result);
109104
110190
  }
109105
110191
  }
109106
- var init_timeback_assessments_service = __esm(() => {
110192
+ var init_timeback_assessments_service = __esm(async () => {
109107
110193
  init_drizzle_orm();
109108
110194
  init_helpers_index();
109109
110195
  init_tables_index();
109110
110196
  init_spans();
109111
- init_utils6();
110197
+ init_timeback3();
109112
110198
  init_errors();
110199
+ init_timeback_assessment_rules_util();
110200
+ init_timeback_qti_authoring_util();
110201
+ await init_errors8();
109113
110202
  });
109114
110203
  function buildTimebackBaseConfigFromExistingConfig(config4) {
109115
110204
  return {
@@ -111181,10 +112270,10 @@ var init_platform2 = __esm(async () => {
111181
112270
  init_kv_service();
111182
112271
  init_secrets_service();
111183
112272
  init_seed_service();
111184
- init_timeback_assessments_service();
111185
112273
  init_upload_service();
111186
112274
  await __promiseAll([
111187
112275
  init_timeback_admin_service(),
112276
+ init_timeback_assessments_service(),
111188
112277
  init_timeback_service()
111189
112278
  ]);
111190
112279
  });
@@ -170393,16 +171482,6 @@ function requireAnonymous(handler) {
170393
171482
  return handler(ctx);
170394
171483
  };
170395
171484
  }
170396
- function requireAdmin(handler) {
170397
- return async (ctx) => {
170398
- assertAuthenticatedRequest(ctx);
170399
- rejectDashboardWorkerKey(ctx);
170400
- if (ctx.user.role !== "admin") {
170401
- throw ApiError.forbidden("Admin access required");
170402
- }
170403
- return handler(ctx);
170404
- };
170405
- }
170406
171485
  function requireDeveloper(handler) {
170407
171486
  return async (ctx) => {
170408
171487
  assertAuthenticatedRequest(ctx);
@@ -171587,6 +172666,26 @@ var init_session_controller = __esm(() => {
171587
172666
  mintToken
171588
172667
  });
171589
172668
  });
172669
+ function parseQtiLibraryParams(searchParams) {
172670
+ const requestedPage = Number(searchParams.get("page"));
172671
+ const requestedLimit = Number(searchParams.get("limit"));
172672
+ const page = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
172673
+ const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 50) : 20;
172674
+ const query = searchParams.get("q")?.trim() || undefined;
172675
+ const requestedSource = searchParams.get("source");
172676
+ if (requestedSource && requestedSource !== "playcademy") {
172677
+ throw ApiError.badRequest("Unsupported QTI library source");
172678
+ }
172679
+ if (requestedSource !== "playcademy" && !query) {
172680
+ throw ApiError.badRequest("An exact QTI source identifier is required for a global lookup");
172681
+ }
172682
+ return {
172683
+ query,
172684
+ source: requestedSource === "playcademy" ? "playcademy" : undefined,
172685
+ page,
172686
+ limit
172687
+ };
172688
+ }
171590
172689
  var populateStudent;
171591
172690
  var getUser;
171592
172691
  var getUserEnrollments;
@@ -171628,17 +172727,17 @@ var unenrollStudent;
171628
172727
  var reactivateEnrollment;
171629
172728
  var listAssessments;
171630
172729
  var createAssessment;
171631
- var deleteAssessment;
172730
+ var updateAssessment;
171632
172731
  var reorderAssessments;
171633
172732
  var reorderQuestions;
171634
- var activateAssessment;
171635
- var deactivateAssessment;
172733
+ var removeAssessment;
171636
172734
  var listQuestions;
172735
+ var listQuestionLibrary;
172736
+ var listTestLibrary;
172737
+ var copyAssessment;
171637
172738
  var createQuestion;
171638
172739
  var updateQuestion;
171639
- var deleteQuestion;
171640
- var getAssessmentBankStatus;
171641
- var destroyAssessmentBank;
172740
+ var removeQuestion;
171642
172741
  var timeback2;
171643
172742
  var init_timeback_controller = __esm(() => {
171644
172743
  init_esm();
@@ -172154,7 +173253,7 @@ var init_timeback_controller = __esm(() => {
172154
173253
  const body2 = await parseRequestBody(ctx.request, ReactivateEnrollmentRequestSchema);
172155
173254
  return ctx.services.timebackAdmin.reactivateEnrollment(body2, ctx.user);
172156
173255
  });
172157
- listAssessments = requireGameManagementAccess(async (ctx) => {
173256
+ listAssessments = requireDeveloper(async (ctx) => {
172158
173257
  const { gameId, courseId } = ctx.params;
172159
173258
  if (!gameId || !courseId) {
172160
173259
  throw ApiError.badRequest("Missing gameId or courseId parameter");
@@ -172162,40 +173261,40 @@ var init_timeback_controller = __esm(() => {
172162
173261
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
172163
173262
  return ctx.services.timebackAssessments.listAssessments(integrationId);
172164
173263
  });
172165
- createAssessment = requireGameManagementAccess(async (ctx) => {
173264
+ createAssessment = requireDeveloper(async (ctx) => {
172166
173265
  const { gameId, courseId } = ctx.params;
172167
173266
  if (!gameId || !courseId) {
172168
173267
  throw ApiError.badRequest("Missing gameId or courseId parameter");
172169
173268
  }
172170
173269
  const body2 = await parseRequestBody(ctx.request, CreateAssessmentRequestSchema);
172171
173270
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
172172
- const shortId = crypto.randomUUID().slice(0, 8);
172173
- const qtiTestIdentifier = `assessment-${shortId}`;
173271
+ const qtiTestIdentifier = newPlaycademyTestIdentifier();
172174
173272
  return ctx.services.timebackAssessments.createAssessment(integrationId, {
172175
173273
  title: body2.title,
173274
+ purpose: body2.purpose,
172176
173275
  qtiTestIdentifier
172177
173276
  });
172178
173277
  });
172179
- deleteAssessment = requireAdmin(async (ctx) => {
173278
+ updateAssessment = requireDeveloper(async (ctx) => {
172180
173279
  const { gameId, courseId, testIdentifier } = ctx.params;
172181
173280
  if (!gameId || !courseId || !testIdentifier) {
172182
173281
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
172183
173282
  }
172184
173283
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
172185
- await ctx.services.timebackAssessments.deleteAssessment(integrationId, testIdentifier);
172186
- return { success: true };
173284
+ const body2 = await parseRequestBody(ctx.request, UpdateAssessmentRequestSchema);
173285
+ return ctx.services.timebackAssessments.updateAssessment(integrationId, testIdentifier, body2);
172187
173286
  });
172188
- reorderAssessments = requireGameManagementAccess(async (ctx) => {
173287
+ reorderAssessments = requireDeveloper(async (ctx) => {
172189
173288
  const { gameId, courseId } = ctx.params;
172190
173289
  if (!gameId || !courseId) {
172191
173290
  throw ApiError.badRequest("Missing gameId or courseId parameter");
172192
173291
  }
172193
173292
  const body2 = await parseRequestBody(ctx.request, ReorderAssessmentsRequestSchema);
172194
173293
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
172195
- await ctx.services.timebackAssessments.reorderAssessments(integrationId, body2.identifiers);
173294
+ await ctx.services.timebackAssessments.reorderAssessments(integrationId, body2.purpose, body2.testIdentifiers);
172196
173295
  return { success: true };
172197
173296
  });
172198
- reorderQuestions = requireGameManagementAccess(async (ctx) => {
173297
+ reorderQuestions = requireDeveloper(async (ctx) => {
172199
173298
  const { gameId, courseId, testIdentifier } = ctx.params;
172200
173299
  if (!gameId || !courseId || !testIdentifier) {
172201
173300
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
@@ -172205,33 +173304,51 @@ var init_timeback_controller = __esm(() => {
172205
173304
  await ctx.services.timebackAssessments.reorderQuestions(integrationId, testIdentifier, body2.items);
172206
173305
  return { success: true };
172207
173306
  });
172208
- activateAssessment = requireGameManagementAccess(async (ctx) => {
173307
+ removeAssessment = requireDeveloper(async (ctx) => {
172209
173308
  const { gameId, courseId, testIdentifier } = ctx.params;
172210
173309
  if (!gameId || !courseId || !testIdentifier) {
172211
173310
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
172212
173311
  }
172213
173312
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
172214
- await ctx.services.timebackAssessments.activateAssessment(integrationId, testIdentifier);
172215
- return { success: true };
173313
+ return ctx.services.timebackAssessments.removeAssessment(integrationId, testIdentifier);
172216
173314
  });
172217
- deactivateAssessment = requireGameManagementAccess(async (ctx) => {
173315
+ listQuestions = requireDeveloper(async (ctx) => {
172218
173316
  const { gameId, courseId, testIdentifier } = ctx.params;
172219
173317
  if (!gameId || !courseId || !testIdentifier) {
172220
173318
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
172221
173319
  }
172222
173320
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
172223
- await ctx.services.timebackAssessments.deactivateAssessment(integrationId, testIdentifier);
172224
- return { success: true };
173321
+ return ctx.services.timebackAssessments.listQuestions(integrationId, testIdentifier);
172225
173322
  });
172226
- listQuestions = requireGameManagementAccess(async (ctx) => {
172227
- const { gameId, courseId, testIdentifier } = ctx.params;
172228
- if (!gameId || !courseId || !testIdentifier) {
172229
- throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
173323
+ listQuestionLibrary = requireDeveloper(async (ctx) => {
173324
+ const { gameId, courseId } = ctx.params;
173325
+ if (!gameId || !courseId) {
173326
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
172230
173327
  }
173328
+ const params = parseQtiLibraryParams(ctx.url.searchParams);
172231
173329
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
172232
- return ctx.services.timebackAssessments.listQuestions(integrationId, testIdentifier);
173330
+ return ctx.services.timebackAssessments.listQuestionLibrary(integrationId, params);
173331
+ });
173332
+ listTestLibrary = requireDeveloper(async (ctx) => {
173333
+ const { gameId, courseId } = ctx.params;
173334
+ if (!gameId || !courseId) {
173335
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
173336
+ }
173337
+ const params = parseQtiLibraryParams(ctx.url.searchParams);
173338
+ const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
173339
+ return ctx.services.timebackAssessments.listTestLibrary(integrationId, params);
172233
173340
  });
172234
- createQuestion = requireGameManagementAccess(async (ctx) => {
173341
+ copyAssessment = requireDeveloper(async (ctx) => {
173342
+ const { gameId, courseId } = ctx.params;
173343
+ if (!gameId || !courseId) {
173344
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
173345
+ }
173346
+ const body2 = await parseRequestBody(ctx.request, CopyAssessmentRequestSchema);
173347
+ const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
173348
+ const targetTestIdentifier = newPlaycademyTestIdentifier();
173349
+ return ctx.services.timebackAssessments.copyAssessment(integrationId, body2.testIdentifier, targetTestIdentifier, body2.purpose);
173350
+ });
173351
+ createQuestion = requireDeveloper(async (ctx) => {
172235
173352
  const { gameId, courseId, testIdentifier } = ctx.params;
172236
173353
  if (!gameId || !courseId || !testIdentifier) {
172237
173354
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
@@ -172240,7 +173357,7 @@ var init_timeback_controller = __esm(() => {
172240
173357
  const body2 = await ctx.request.json();
172241
173358
  return ctx.services.timebackAssessments.createQuestion(integrationId, testIdentifier, body2);
172242
173359
  });
172243
- updateQuestion = requireGameManagementAccess(async (ctx) => {
173360
+ updateQuestion = requireDeveloper(async (ctx) => {
172244
173361
  const { gameId, courseId, testIdentifier, itemIdentifier } = ctx.params;
172245
173362
  if (!gameId || !courseId || !testIdentifier || !itemIdentifier) {
172246
173363
  throw ApiError.badRequest("Missing gameId, courseId, testIdentifier, or itemIdentifier parameter");
@@ -172249,30 +173366,13 @@ var init_timeback_controller = __esm(() => {
172249
173366
  const body2 = await ctx.request.json();
172250
173367
  return ctx.services.timebackAssessments.updateQuestion(integrationId, testIdentifier, itemIdentifier, body2);
172251
173368
  });
172252
- deleteQuestion = requireGameManagementAccess(async (ctx) => {
173369
+ removeQuestion = requireDeveloper(async (ctx) => {
172253
173370
  const { gameId, courseId, testIdentifier, itemIdentifier } = ctx.params;
172254
173371
  if (!gameId || !courseId || !testIdentifier || !itemIdentifier) {
172255
173372
  throw ApiError.badRequest("Missing gameId, courseId, testIdentifier, or itemIdentifier parameter");
172256
173373
  }
172257
173374
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
172258
- await ctx.services.timebackAssessments.deleteQuestion(integrationId, testIdentifier, itemIdentifier);
172259
- return { success: true };
172260
- });
172261
- getAssessmentBankStatus = requireGameManagementAccess(async (ctx) => {
172262
- const { gameId, courseId } = ctx.params;
172263
- if (!gameId || !courseId) {
172264
- throw ApiError.badRequest("Missing gameId or courseId parameter");
172265
- }
172266
- const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
172267
- return ctx.services.timebackAssessments.getBankStatus(integrationId);
172268
- });
172269
- destroyAssessmentBank = requireAdmin(async (ctx) => {
172270
- const { gameId, courseId } = ctx.params;
172271
- if (!gameId || !courseId) {
172272
- throw ApiError.badRequest("Missing gameId or courseId parameter");
172273
- }
172274
- const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
172275
- await ctx.services.timebackAssessments.destroyBank(integrationId);
173375
+ await ctx.services.timebackAssessments.removeQuestion(integrationId, testIdentifier, itemIdentifier);
172276
173376
  return { success: true };
172277
173377
  });
172278
173378
  timeback2 = defineControllerNames("timeback", {
@@ -172317,17 +173417,17 @@ var init_timeback_controller = __esm(() => {
172317
173417
  reactivateEnrollment,
172318
173418
  listAssessments,
172319
173419
  createAssessment,
172320
- deleteAssessment,
173420
+ updateAssessment,
172321
173421
  reorderAssessments,
173422
+ removeAssessment,
172322
173423
  reorderQuestions,
172323
- activateAssessment,
172324
- deactivateAssessment,
172325
173424
  listQuestions,
173425
+ listTestLibrary,
173426
+ copyAssessment,
173427
+ listQuestionLibrary,
172326
173428
  createQuestion,
172327
173429
  updateQuestion,
172328
- deleteQuestion,
172329
- getAssessmentBankStatus,
172330
- destroyAssessmentBank
173430
+ removeQuestion
172331
173431
  });
172332
173432
  });
172333
173433
  var initiate;