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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +1781 -626
  2. package/dist/server.js +1781 -626
  3. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -1083,7 +1083,7 @@ var package_default;
1083
1083
  var init_package = __esm(() => {
1084
1084
  package_default = {
1085
1085
  name: "@playcademy/sandbox",
1086
- version: "0.6.1-beta.7",
1086
+ version: "0.6.1-beta.9",
1087
1087
  description: "Local development server for Playcademy game development",
1088
1088
  type: "module",
1089
1089
  exports: {
@@ -11586,7 +11586,7 @@ var init_table6 = __esm(() => {
11586
11586
  });
11587
11587
 
11588
11588
  // ../data/src/domains/timeback/table.ts
11589
- var gameTimebackIntegrationStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications, gameTimebackActivityCompletions;
11589
+ var gameTimebackIntegrationStatusEnum, gameTimebackAssessmentPurposeEnum, gameTimebackAssessmentStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications, gameTimebackActivityCompletions;
11590
11590
  var init_table7 = __esm(() => {
11591
11591
  init_drizzle_orm();
11592
11592
  init_pg_core();
@@ -11596,6 +11596,15 @@ var init_table7 = __esm(() => {
11596
11596
  "active",
11597
11597
  "deactivated"
11598
11598
  ]);
11599
+ gameTimebackAssessmentPurposeEnum = pgEnum("game_timeback_assessment_purpose", [
11600
+ "end_of_course",
11601
+ "diagnostic"
11602
+ ]);
11603
+ gameTimebackAssessmentStatusEnum = pgEnum("game_timeback_assessment_status", [
11604
+ "draft",
11605
+ "live",
11606
+ "archived"
11607
+ ]);
11599
11608
  gameTimebackIntegrations = pgTable("game_timeback_integrations", {
11600
11609
  id: uuid("id").primaryKey().defaultRandom(),
11601
11610
  gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
@@ -11616,10 +11625,11 @@ var init_table7 = __esm(() => {
11616
11625
  id: uuid("id").primaryKey().defaultRandom(),
11617
11626
  integrationId: uuid("integration_id").notNull().references(() => gameTimebackIntegrations.id, { onDelete: "cascade" }),
11618
11627
  qtiTestIdentifier: text("qti_test_identifier").notNull(),
11619
- bankResourceId: text("bank_resource_id"),
11620
- bankActive: boolean("bank_active").notNull().default(false),
11621
- sortOrder: integer("sort_order").notNull().default(0),
11622
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
11628
+ purpose: gameTimebackAssessmentPurposeEnum("purpose").notNull().default("end_of_course"),
11629
+ status: gameTimebackAssessmentStatusEnum("status").notNull().default("draft"),
11630
+ sortOrder: integer("sort_order"),
11631
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
11632
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
11623
11633
  }, (table3) => [
11624
11634
  uniqueIndex("game_timeback_assessment_tests_integration_qti_idx").on(table3.integrationId, table3.qtiTestIdentifier)
11625
11635
  ]);
@@ -11671,6 +11681,8 @@ __export(exports_tables_index, {
11671
11681
  gameTimebackIntegrations: () => gameTimebackIntegrations,
11672
11682
  gameTimebackIntegrationStatusEnum: () => gameTimebackIntegrationStatusEnum,
11673
11683
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
11684
+ gameTimebackAssessmentStatusEnum: () => gameTimebackAssessmentStatusEnum,
11685
+ gameTimebackAssessmentPurposeEnum: () => gameTimebackAssessmentPurposeEnum,
11674
11686
  gameTimebackActivityCompletions: () => gameTimebackActivityCompletions,
11675
11687
  gameScoresRelations: () => gameScoresRelations,
11676
11688
  gameScores: () => gameScores,
@@ -28292,14 +28304,16 @@ function assertBaselineClaimValid(args2) {
28292
28304
  function validateBaselineClaim(args2) {
28293
28305
  const { claimedTag, evidence, tables, indexes: indexes2, views, lastDeployAt } = args2;
28294
28306
  const claimedIndex = evidence.findIndex((entry) => entry.tag === claimedTag);
28307
+ const expected = replayEvidence(evidence, claimedIndex);
28308
+ const live = { tables, indexes: indexes2, views };
28295
28309
  const verdicts = [];
28296
28310
  const unverified = [];
28297
28311
  evidence.forEach((entry, index2) => {
28298
28312
  if (claimedIndex === -1 || index2 > claimedIndex) {
28299
- verdicts.push(judgeBeyond(entry, tables, indexes2, views));
28313
+ verdicts.push(judgeBeyond(entry, expected, live));
28300
28314
  return;
28301
28315
  }
28302
- const judged = judgeClaimed(entry, tables, lastDeployAt);
28316
+ const judged = judgeClaimed(entry, expected, live, lastDeployAt);
28303
28317
  verdicts.push(judged.verdict);
28304
28318
  if (judged.unverifiable) {
28305
28319
  unverified.push(judged.verdict);
@@ -28312,8 +28326,99 @@ function validateBaselineClaim(args2) {
28312
28326
  suggestedTag: suggestTag(evidence, tables)
28313
28327
  };
28314
28328
  }
28315
- function judgeClaimed(entry, tables, lastDeployAt) {
28316
- if (!hasSignal(entry)) {
28329
+ function replayEvidence(evidence, claimedIndex) {
28330
+ const state = { tables: new Map, indexes: new Map, views: new Map };
28331
+ for (let index2 = 0;index2 <= claimedIndex; index2++) {
28332
+ applyEvidenceEntry(state, evidence[index2]);
28333
+ }
28334
+ return state;
28335
+ }
28336
+ function applyEvidenceEntry(state, entry) {
28337
+ for (const table8 of entry.dropsTables) {
28338
+ state.tables.delete(table8);
28339
+ }
28340
+ for (const dropped of entry.dropsColumns) {
28341
+ state.tables.get(dropped.table)?.columns.delete(dropped.column);
28342
+ }
28343
+ for (const droppedIndex of entry.dropsIndexes) {
28344
+ state.indexes.delete(droppedIndex);
28345
+ }
28346
+ for (const view2 of entry.dropsViews) {
28347
+ state.views.delete(view2);
28348
+ }
28349
+ for (const table8 of entry.createsTables) {
28350
+ state.tables.set(table8.name, {
28351
+ creator: entry.tag,
28352
+ columns: new Map(table8.columns.map((column2) => [column2, entry.tag]))
28353
+ });
28354
+ }
28355
+ for (const added of entry.addsColumns) {
28356
+ state.tables.get(added.table)?.columns.set(added.column, entry.tag);
28357
+ }
28358
+ for (const createdIndex of entry.createsIndexes) {
28359
+ state.indexes.set(createdIndex, entry.tag);
28360
+ }
28361
+ for (const view2 of entry.createsViews) {
28362
+ state.views.set(view2, entry.tag);
28363
+ }
28364
+ }
28365
+ function judgeClaimed(entry, expected, live, lastDeployAt) {
28366
+ let surviving = 0;
28367
+ for (const table8 of entry.createsTables) {
28368
+ const expectation = expected.tables.get(table8.name);
28369
+ if (expectation?.creator === entry.tag) {
28370
+ surviving++;
28371
+ const columns2 = live.tables.get(table8.name);
28372
+ if (!columns2) {
28373
+ return {
28374
+ verdict: {
28375
+ tag: entry.tag,
28376
+ verdict: "contradicted",
28377
+ detail: `creates table \`${table8.name}\`, which is not in the live database`
28378
+ },
28379
+ unverifiable: false
28380
+ };
28381
+ }
28382
+ const missing = [...expectation.columns.entries()].filter(([, creator]) => creator === entry.tag).map(([column2]) => column2).filter((column2) => !columns2.includes(column2));
28383
+ if (missing.length > 0) {
28384
+ return {
28385
+ verdict: {
28386
+ tag: entry.tag,
28387
+ verdict: "contradicted",
28388
+ detail: `table \`${table8.name}\` exists but is missing column(s) ${missing.map((column2) => `\`${column2}\``).join(", ")} this migration defines`
28389
+ },
28390
+ unverifiable: false
28391
+ };
28392
+ }
28393
+ }
28394
+ }
28395
+ for (const added of entry.addsColumns) {
28396
+ if (expected.tables.get(added.table)?.columns.get(added.column) === entry.tag) {
28397
+ surviving++;
28398
+ const columns2 = live.tables.get(added.table);
28399
+ if (columns2 && !columns2.includes(added.column)) {
28400
+ return {
28401
+ verdict: {
28402
+ tag: entry.tag,
28403
+ verdict: "contradicted",
28404
+ detail: `adds column \`${added.column}\` to \`${added.table}\`, which the live table does not have`
28405
+ },
28406
+ unverifiable: false
28407
+ };
28408
+ }
28409
+ }
28410
+ }
28411
+ for (const name2 of entry.createsIndexes) {
28412
+ if (expected.indexes.get(name2) === entry.tag && live.indexes.has(name2)) {
28413
+ surviving++;
28414
+ }
28415
+ }
28416
+ for (const name2 of entry.createsViews) {
28417
+ if (expected.views.get(name2) === entry.tag && live.views.has(name2)) {
28418
+ surviving++;
28419
+ }
28420
+ }
28421
+ if (surviving === 0) {
28317
28422
  const generated = new Date(entry.generatedAt);
28318
28423
  if (lastDeployAt && generated > lastDeployAt) {
28319
28424
  return {
@@ -28327,48 +28432,11 @@ function judgeClaimed(entry, tables, lastDeployAt) {
28327
28432
  }
28328
28433
  return { verdict: { tag: entry.tag, verdict: "no-signal" }, unverifiable: false };
28329
28434
  }
28330
- for (const table8 of entry.createsTables) {
28331
- const columns2 = tables.get(table8.name);
28332
- if (!columns2) {
28333
- return {
28334
- verdict: {
28335
- tag: entry.tag,
28336
- verdict: "contradicted",
28337
- detail: `creates table \`${table8.name}\`, which is not in the live database`
28338
- },
28339
- unverifiable: false
28340
- };
28341
- }
28342
- const missing = table8.columns.filter((column2) => !columns2.includes(column2));
28343
- if (missing.length > 0) {
28344
- return {
28345
- verdict: {
28346
- tag: entry.tag,
28347
- verdict: "contradicted",
28348
- detail: `table \`${table8.name}\` exists but is missing column(s) ${missing.map((column2) => `\`${column2}\``).join(", ")} this migration defines`
28349
- },
28350
- unverifiable: false
28351
- };
28352
- }
28353
- }
28354
- for (const added of entry.addsColumns) {
28355
- const columns2 = tables.get(added.table);
28356
- if (columns2 && !columns2.includes(added.column)) {
28357
- return {
28358
- verdict: {
28359
- tag: entry.tag,
28360
- verdict: "contradicted",
28361
- detail: `adds column \`${added.column}\` to \`${added.table}\`, which the live table does not have`
28362
- },
28363
- unverifiable: false
28364
- };
28365
- }
28366
- }
28367
28435
  return { verdict: { tag: entry.tag, verdict: "verified" }, unverifiable: false };
28368
28436
  }
28369
- function judgeBeyond(entry, tables, indexes2, views) {
28437
+ function judgeBeyond(entry, expected, live) {
28370
28438
  for (const table8 of entry.createsTables) {
28371
- if (tables.has(table8.name)) {
28439
+ if (live.tables.has(table8.name) && !expected.tables.has(table8.name)) {
28372
28440
  return {
28373
28441
  tag: entry.tag,
28374
28442
  verdict: "contradicted",
@@ -28377,8 +28445,9 @@ function judgeBeyond(entry, tables, indexes2, views) {
28377
28445
  }
28378
28446
  }
28379
28447
  for (const added of entry.addsColumns) {
28380
- const columns2 = tables.get(added.table);
28381
- if (columns2?.includes(added.column)) {
28448
+ const columns2 = live.tables.get(added.table);
28449
+ const explained = expected.tables.get(added.table)?.columns.has(added.column);
28450
+ if (columns2?.includes(added.column) && !explained) {
28382
28451
  return {
28383
28452
  tag: entry.tag,
28384
28453
  verdict: "contradicted",
@@ -28387,7 +28456,7 @@ function judgeBeyond(entry, tables, indexes2, views) {
28387
28456
  }
28388
28457
  }
28389
28458
  for (const index2 of entry.createsIndexes) {
28390
- if (indexes2.has(index2)) {
28459
+ if (live.indexes.has(index2) && !expected.indexes.has(index2)) {
28391
28460
  return {
28392
28461
  tag: entry.tag,
28393
28462
  verdict: "contradicted",
@@ -28396,7 +28465,7 @@ function judgeBeyond(entry, tables, indexes2, views) {
28396
28465
  }
28397
28466
  }
28398
28467
  for (const view2 of entry.createsViews) {
28399
- if (views.has(view2)) {
28468
+ if (live.views.has(view2) && !expected.views.has(view2)) {
28400
28469
  return {
28401
28470
  tag: entry.tag,
28402
28471
  verdict: "contradicted",
@@ -28407,19 +28476,20 @@ function judgeBeyond(entry, tables, indexes2, views) {
28407
28476
  return { tag: entry.tag, verdict: "verified" };
28408
28477
  }
28409
28478
  function suggestTag(evidence, tables) {
28410
- let presentPrefix = 0;
28411
- while (presentPrefix < evidence.length && evidence[presentPrefix].createsTables.every((table8) => tables.has(table8.name))) {
28412
- presentPrefix++;
28413
- }
28414
- let absentFrom = evidence.length;
28415
- while (absentFrom > 0 && evidence[absentFrom - 1].createsTables.every((table8) => !tables.has(table8.name))) {
28416
- absentFrom--;
28479
+ const liveCreated = [
28480
+ ...new Set(evidence.flatMap((entry) => entry.createsTables.map((table8) => table8.name)))
28481
+ ].filter((name2) => tables.has(name2));
28482
+ const state = { tables: new Map, indexes: new Map, views: new Map };
28483
+ let best = null;
28484
+ for (const entry of evidence) {
28485
+ applyEvidenceEntry(state, entry);
28486
+ const allPresent = [...state.tables.keys()].every((name2) => tables.has(name2));
28487
+ const noStray = liveCreated.every((name2) => state.tables.has(name2));
28488
+ if (allPresent && noStray) {
28489
+ best = entry.tag;
28490
+ }
28417
28491
  }
28418
- const candidate = presentPrefix - 1;
28419
- return candidate >= 0 && candidate >= absentFrom - 1 ? evidence[candidate].tag : null;
28420
- }
28421
- function hasSignal(entry) {
28422
- return entry.createsTables.length > 0 || entry.addsColumns.length > 0 || entry.createsIndexes.length > 0 || entry.createsViews.length > 0;
28492
+ return best;
28423
28493
  }
28424
28494
  var init_baseline_validation_util = __esm(() => {
28425
28495
  init_spans();
@@ -30042,6 +30112,25 @@ function sleep(ms) {
30042
30112
  }
30043
30113
  return new Promise((resolve) => setTimeout(resolve, ms));
30044
30114
  }
30115
+ async function runWithConcurrency(items, concurrency, worker) {
30116
+ if (items.length === 0) {
30117
+ return [];
30118
+ }
30119
+ const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
30120
+ const results = Array.from({ length: items.length });
30121
+ let nextIndex = 0;
30122
+ await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
30123
+ while (true) {
30124
+ const currentIndex = nextIndex;
30125
+ nextIndex++;
30126
+ if (currentIndex >= items.length) {
30127
+ return;
30128
+ }
30129
+ results[currentIndex] = await worker(items[currentIndex]);
30130
+ }
30131
+ }));
30132
+ return results;
30133
+ }
30045
30134
 
30046
30135
  // ../timeback/dist/types.js
30047
30136
  function isObject(value) {
@@ -31576,7 +31665,7 @@ class KVBackupService {
31576
31665
  "app.kv_backup.dry_run": dryRun,
31577
31666
  "app.kv_backup.game_slug": options.gameSlug
31578
31667
  });
31579
- const results = await KVBackupService.runWithConcurrency(targets, namespaceConcurrency, (target) => this.backupNamespace(target, {
31668
+ const results = await runWithConcurrency(targets, namespaceConcurrency, (target) => this.backupNamespace(target, {
31580
31669
  bucketName,
31581
31670
  stage,
31582
31671
  runId,
@@ -31642,7 +31731,7 @@ class KVBackupService {
31642
31731
  }
31643
31732
  static async fetchNamespaceEntries(cloudflare2, namespaceId, keyConcurrency = DEFAULT_KEY_CONCURRENCY) {
31644
31733
  const keys = await KVBackupService.withRetries(`List KV keys for namespace ${namespaceId}`, () => cloudflare2.kv.listKeys(namespaceId));
31645
- return KVBackupService.runWithConcurrency(keys, keyConcurrency, async (key) => {
31734
+ return runWithConcurrency(keys, keyConcurrency, async (key) => {
31646
31735
  const safeLabel = KVBackupService.redactKeyForLog(key.name);
31647
31736
  const value = await KVBackupService.withRetries(`Fetch KV value ${safeLabel}`, () => cloudflare2.kv.getValue(namespaceId, key.name));
31648
31737
  const metadata2 = KVBackupService.parseMetadata(key.metadata);
@@ -31819,25 +31908,6 @@ class KVBackupService {
31819
31908
  }
31820
31909
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
31821
31910
  }
31822
- static async runWithConcurrency(items, concurrency, worker) {
31823
- if (items.length === 0) {
31824
- return [];
31825
- }
31826
- const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
31827
- const results = Array.from({ length: items.length });
31828
- let nextIndex = 0;
31829
- await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
31830
- while (true) {
31831
- const currentIndex = nextIndex;
31832
- nextIndex++;
31833
- if (currentIndex >= items.length) {
31834
- return;
31835
- }
31836
- results[currentIndex] = await worker(items[currentIndex]);
31837
- }
31838
- }));
31839
- return results;
31840
- }
31841
31911
  }
31842
31912
  var BACKUP_SCHEMA_VERSION = 1, DEFAULT_NAMESPACE_CONCURRENCY = 2, DEFAULT_KEY_CONCURRENCY = 10, DEFAULT_RETRY_ATTEMPTS = 3, RETRY_BASE_DELAY_MS = 500;
31843
31913
  var init_kv_backup_service = __esm(() => {
@@ -33979,7 +34049,14 @@ var init_schemas2 = __esm(() => {
33979
34049
  column: exports_external.string().min(1)
33980
34050
  })),
33981
34051
  createsIndexes: exports_external.array(exports_external.string()),
33982
- createsViews: exports_external.array(exports_external.string())
34052
+ createsViews: exports_external.array(exports_external.string()),
34053
+ dropsTables: exports_external.array(exports_external.string()),
34054
+ dropsColumns: exports_external.array(exports_external.object({
34055
+ table: exports_external.string().min(1),
34056
+ column: exports_external.string().min(1)
34057
+ })),
34058
+ dropsIndexes: exports_external.array(exports_external.string()),
34059
+ dropsViews: exports_external.array(exports_external.string())
33983
34060
  })).optional();
33984
34061
  DeployBaselineSchema = exports_external.object({
33985
34062
  lastAppliedMigrationTag: exports_external.string().min(1).optional(),
@@ -34137,9 +34214,8 @@ function isValidAdminAttributionDate(value) {
34137
34214
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
34138
34215
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
34139
34216
  }
34140
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS2, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
34217
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS2, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, AssessmentPurposeSchema, AssessmentStatusSchema, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
34141
34218
  var init_schemas4 = __esm(() => {
34142
- init_drizzle_zod();
34143
34219
  init_esm();
34144
34220
  init_table7();
34145
34221
  TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
@@ -34392,15 +34468,26 @@ var init_schemas4 = __esm(() => {
34392
34468
  runId: exports_external.string().uuid(),
34393
34469
  activityId: exports_external.string().min(1).optional()
34394
34470
  });
34395
- InsertAssessmentTestSchema = createInsertSchema(gameTimebackAssessmentTests).omit({
34396
- id: true,
34397
- createdAt: true
34398
- });
34471
+ AssessmentPurposeSchema = exports_external.enum(gameTimebackAssessmentPurposeEnum.enumValues);
34472
+ AssessmentStatusSchema = exports_external.enum(gameTimebackAssessmentStatusEnum.enumValues);
34399
34473
  CreateAssessmentRequestSchema = exports_external.object({
34400
- title: exports_external.string().min(1, "Assessment title is required")
34474
+ title: exports_external.string().min(1, "Assessment title is required"),
34475
+ purpose: AssessmentPurposeSchema
34476
+ });
34477
+ UpdateAssessmentRequestSchema = exports_external.object({
34478
+ title: exports_external.string().trim().min(1, "Assessment title is required").optional(),
34479
+ purpose: AssessmentPurposeSchema.optional(),
34480
+ status: AssessmentStatusSchema.optional()
34481
+ }).refine((input) => input.title !== undefined || input.purpose !== undefined || input.status !== undefined, {
34482
+ message: "Title, purpose, or status is required"
34483
+ });
34484
+ CopyAssessmentRequestSchema = exports_external.object({
34485
+ testIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
34486
+ purpose: AssessmentPurposeSchema
34401
34487
  });
34402
34488
  ReorderAssessmentsRequestSchema = exports_external.object({
34403
- identifiers: exports_external.array(exports_external.string().min(1)).min(1, "At least one identifier is required")
34489
+ purpose: AssessmentPurposeSchema,
34490
+ testIdentifiers: exports_external.array(exports_external.string().trim().min(1, "Assessment identifier is required")).min(1, "At least one assessment is required")
34404
34491
  });
34405
34492
  ReorderQuestionsRequestSchema = exports_external.object({
34406
34493
  items: exports_external.array(exports_external.object({
@@ -53899,10 +53986,10 @@ var init_dist2 = __esm(async () => {
53899
53986
  };
53900
53987
  });
53901
53988
 
53902
- // ../../node_modules/.bun/@timeback+qti@0.3.1+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-6jf1natv.js
53989
+ // ../../node_modules/.bun/@timeback+qti@0.4.1-beta.20260717234944+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-6jf1natv.js
53903
53990
  var init_chunk_6jf1natv = () => {};
53904
53991
 
53905
- // ../../node_modules/.bun/@timeback+qti@0.3.1+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-dd0pbbq3.js
53992
+ // ../../node_modules/.bun/@timeback+qti@0.4.1-beta.20260717234944+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-dd0pbbq3.js
53906
53993
  var exports_chunk_dd0pbbq3 = {};
53907
53994
  __export(exports_chunk_dd0pbbq3, {
53908
53995
  types: () => types6,
@@ -54313,7 +54400,7 @@ var init_chunk_dd0pbbq3 = __esm(() => {
54313
54400
  util_default2 = { TextEncoder: TextEncoder3, TextDecoder: TextDecoder3, promisify: promisify2, log: log9, inherits: inherits2, _extend: _extend2, callbackifyOnRejected: callbackifyOnRejected2, callbackify: callbackify2 };
54314
54401
  });
54315
54402
 
54316
- // ../../node_modules/.bun/@timeback+qti@0.3.1+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-m54fefpq.js
54403
+ // ../../node_modules/.bun/@timeback+qti@0.4.1-beta.20260717234944+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/chunk-pt7w63g2.js
54317
54404
  function createInputValidationError2(message, issues) {
54318
54405
  return new InputValidationError2(message, issues);
54319
54406
  }
@@ -55364,7 +55451,7 @@ var ApiError3, UnauthorizedError3, ForbiddenError2, NotFoundError3, ValidationEr
55364
55451
  console[method](label, style);
55365
55452
  }
55366
55453
  }, LOG_LEVELS2, GLOBAL_LOGGING_CONFIG_KEY2, BEYONDAI_PATHS2, LEARNWITHAI_PATHS2, PLATFORM_PATHS2, DEFAULT_PROVIDER_REGISTRY2, MODE_TRANSPORT2, MODE_PROVIDER2, MODE_ENV_CONFIG2, MODE_EXPLICIT_CONFIG2, MODE_ENV_FALLBACK_PLATFORM2, MODE_ENV_FALLBACK_EXPLICIT2, MODE_TOKEN_PROVIDER2, MODES2, MAX_RETRIES2 = 3, RETRY_STATUS_CODES2, INITIAL_RETRY_DELAY_MS2 = 1000, DEFAULT_LIMIT2 = 100, DEFAULT_MAX_ITEMS2 = 1e4, Paginator6;
55367
- var init_chunk_m54fefpq = __esm(async () => {
55454
+ var init_chunk_pt7w63g2 = __esm(async () => {
55368
55455
  init_chunk_6jf1natv();
55369
55456
  ApiError3 = class ApiError3 extends Error {
55370
55457
  statusCode;
@@ -70510,15 +70597,14 @@ var init_v42 = __esm(() => {
70510
70597
  init_classic2();
70511
70598
  });
70512
70599
 
70513
- // ../../node_modules/.bun/@timeback+qti@0.3.1+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/index.js
70600
+ // ../../node_modules/.bun/@timeback+qti@0.4.1-beta.20260717234944+1fb4c65d43e298b9/node_modules/@timeback/qti/dist/index.js
70514
70601
  function resolveToProvider23(config4, registry3 = DEFAULT_PROVIDER_REGISTRY2) {
70515
70602
  return resolveToProvider10(config4, QTI_ENV_VARS2, registry3);
70516
70603
  }
70517
70604
  function translateParams2(params) {
70518
70605
  const {
70519
70606
  orderBy,
70520
- filter: _,
70521
- fields: __,
70607
+ fields: _,
70522
70608
  ...rest
70523
70609
  } = params;
70524
70610
  return {
@@ -70526,6 +70612,23 @@ function translateParams2(params) {
70526
70612
  order: orderBy
70527
70613
  };
70528
70614
  }
70615
+ function buildListQueryParams(params) {
70616
+ const queryParams = {};
70617
+ const filter = params.where ? whereToFilter2(params.where) : undefined;
70618
+ if (filter !== undefined)
70619
+ queryParams.filter = filter;
70620
+ if (params.query !== undefined)
70621
+ queryParams.query = params.query;
70622
+ if (params.page !== undefined)
70623
+ queryParams.page = params.page;
70624
+ if (params.limit !== undefined)
70625
+ queryParams.limit = params.limit;
70626
+ if (params.sort)
70627
+ queryParams.sort = params.sort;
70628
+ if (params.order)
70629
+ queryParams.order = params.order;
70630
+ return queryParams;
70631
+ }
70529
70632
 
70530
70633
  class AssessmentItemsResource2 {
70531
70634
  transport;
@@ -70534,19 +70637,8 @@ class AssessmentItemsResource2 {
70534
70637
  }
70535
70638
  list(params = {}) {
70536
70639
  validatePageListParams2(params);
70537
- const queryParams = {};
70538
- if (params.query !== undefined)
70539
- queryParams.query = params.query;
70540
- if (params.page !== undefined)
70541
- queryParams.page = params.page;
70542
- if (params.limit !== undefined)
70543
- queryParams.limit = params.limit;
70544
- if (params.sort)
70545
- queryParams.sort = params.sort;
70546
- if (params.order)
70547
- queryParams.order = params.order;
70548
70640
  return this.transport.request("/assessment-items", {
70549
- params: queryParams
70641
+ params: buildListQueryParams(params)
70550
70642
  });
70551
70643
  }
70552
70644
  stream(params = {}) {
@@ -70669,20 +70761,9 @@ class TestPartSectionsHelper2 {
70669
70761
  }
70670
70762
  list(params = {}) {
70671
70763
  validatePageListParams2(params);
70672
- const queryParams = {};
70673
- if (params.query !== undefined)
70674
- queryParams.query = params.query;
70675
- if (params.page !== undefined)
70676
- queryParams.page = params.page;
70677
- if (params.limit !== undefined)
70678
- queryParams.limit = params.limit;
70679
- if (params.sort)
70680
- queryParams.sort = params.sort;
70681
- if (params.order)
70682
- queryParams.order = params.order;
70683
70764
  const path = `/assessment-tests/${encodeURIComponent(this.testId)}/test-parts/${encodeURIComponent(this.testPartId)}/sections`;
70684
70765
  return this.transport.request(path, {
70685
- params: queryParams
70766
+ params: buildListQueryParams(params)
70686
70767
  });
70687
70768
  }
70688
70769
  get(identifier) {
@@ -70729,19 +70810,10 @@ class AssessmentTestPartsHelper2 {
70729
70810
  }
70730
70811
  list(params = {}) {
70731
70812
  validatePageListParams2(params);
70732
- const queryParams = {};
70733
- if (params.query !== undefined)
70734
- queryParams.query = params.query;
70735
- if (params.page !== undefined)
70736
- queryParams.page = params.page;
70737
- if (params.limit !== undefined)
70738
- queryParams.limit = params.limit;
70739
- if (params.sort)
70740
- queryParams.sort = params.sort;
70741
- if (params.order)
70742
- queryParams.order = params.order;
70743
70813
  const path = `/assessment-tests/${encodeURIComponent(this.testId)}/test-parts`;
70744
- return this.transport.request(path, { params: queryParams });
70814
+ return this.transport.request(path, {
70815
+ params: buildListQueryParams(params)
70816
+ });
70745
70817
  }
70746
70818
  get(identifier) {
70747
70819
  validateNonEmptyString2(identifier, "testPartId");
@@ -70784,19 +70856,8 @@ class AssessmentTestsResource2 {
70784
70856
  }
70785
70857
  list(params = {}) {
70786
70858
  validatePageListParams2(params);
70787
- const queryParams = {};
70788
- if (params.query !== undefined)
70789
- queryParams.query = params.query;
70790
- if (params.page !== undefined)
70791
- queryParams.page = params.page;
70792
- if (params.limit !== undefined)
70793
- queryParams.limit = params.limit;
70794
- if (params.sort)
70795
- queryParams.sort = params.sort;
70796
- if (params.order)
70797
- queryParams.order = params.order;
70798
70859
  return this.transport.request("/assessment-tests", {
70799
- params: queryParams
70860
+ params: buildListQueryParams(params)
70800
70861
  });
70801
70862
  }
70802
70863
  stream(params = {}) {
@@ -70902,19 +70963,8 @@ class StimuliResource2 {
70902
70963
  }
70903
70964
  list(params = {}) {
70904
70965
  validatePageListParams2(params);
70905
- const queryParams = {};
70906
- if (params.query !== undefined)
70907
- queryParams.query = params.query;
70908
- if (params.page !== undefined)
70909
- queryParams.page = params.page;
70910
- if (params.limit !== undefined)
70911
- queryParams.limit = params.limit;
70912
- if (params.sort)
70913
- queryParams.sort = params.sort;
70914
- if (params.order)
70915
- queryParams.order = params.order;
70916
70966
  return this.transport.request("/stimuli", {
70917
- params: queryParams
70967
+ params: buildListQueryParams(params)
70918
70968
  });
70919
70969
  }
70920
70970
  stream(params = {}) {
@@ -71046,7 +71096,7 @@ var init_dist3 = __esm(async () => {
71046
71096
  init_v42();
71047
71097
  init_v42();
71048
71098
  init_v42();
71049
- await init_chunk_m54fefpq();
71099
+ await init_chunk_pt7w63g2();
71050
71100
  QTI_ENV_VARS2 = {
71051
71101
  baseUrl: ["TIMEBACK_API_BASE_URL", "TIMEBACK_BASE_URL", "QTI_BASE_URL"],
71052
71102
  clientId: ["TIMEBACK_API_CLIENT_ID", "TIMEBACK_CLIENT_ID", "QTI_CLIENT_ID"],
@@ -77887,13 +77937,6 @@ function deriveSourcedIds(courseId) {
77887
77937
  componentResource: `${courseId}-cr`
77888
77938
  };
77889
77939
  }
77890
- function deriveAssessmentBankIds(courseId) {
77891
- return {
77892
- component: `${courseId}-assessment-bank-component`,
77893
- resource: `${courseId}-assessment-bank-resource`,
77894
- componentResource: `${courseId}-assessment-bank-cr`
77895
- };
77896
- }
77897
77940
  function validateProgressData(progressData) {
77898
77941
  if (!progressData.subject) {
77899
77942
  throw new ConfigurationError("subject", "Subject is required for Caliper events. Provide it in progressData.subject");
@@ -79182,59 +79225,22 @@ class CourseAssessments {
79182
79225
  constructor(core3) {
79183
79226
  this.core = core3;
79184
79227
  }
79185
- async ensureBank(input) {
79186
- const oneroster = this.core.api.oneroster;
79187
- const bankIds = deriveAssessmentBankIds(input.courseId);
79188
- try {
79189
- await oneroster.courses.createComponent({
79190
- sourcedId: bankIds.component,
79191
- status: "active",
79192
- title: "Test Out",
79193
- course: { sourcedId: input.courseId },
79194
- sortOrder: 9999,
79195
- metadata: { lessonType: "test-out" }
79196
- });
79197
- } catch (error88) {
79198
- if (!isAlreadyExists(error88)) {
79199
- throw error88;
79200
- }
79201
- }
79202
- try {
79203
- await oneroster.resources.create({
79204
- sourcedId: bankIds.resource,
79205
- status: "active",
79206
- title: "Assessment Bank",
79207
- vendorResourceId: "",
79208
- vendorId: undefined,
79209
- metadata: {
79210
- type: "assessment-bank",
79211
- resources: [],
79212
- lessonType: "test-out",
79213
- subject: input.subject,
79214
- grade: String(input.grade)
79215
- }
79216
- });
79217
- } catch (error88) {
79218
- if (!isAlreadyExists(error88)) {
79219
- throw error88;
79220
- }
79221
- }
79222
- try {
79223
- await oneroster.courses.createComponentResource({
79224
- sourcedId: bankIds.componentResource,
79225
- status: "active",
79226
- title: "Test Out",
79227
- resource: { sourcedId: bankIds.resource },
79228
- courseComponent: { sourcedId: bankIds.component },
79229
- sortOrder: 9999,
79230
- metadata: { lessonType: "test-out" },
79231
- lessonType: "test-out"
79232
- });
79233
- } catch (error88) {
79234
- if (!isAlreadyExists(error88)) {
79235
- throw error88;
79228
+ createItemFromXml(input) {
79229
+ return this.core.qti.assessmentItems.createFromXml({
79230
+ format: "xml",
79231
+ xml: input.xml,
79232
+ ...input.metadata ? { metadata: input.metadata } : {}
79233
+ });
79234
+ }
79235
+ updateItemFromXml(identifier, input) {
79236
+ return this.core.qti.getTransport().request(`/assessment-items/${encodeURIComponent(identifier)}`, {
79237
+ method: "PUT",
79238
+ body: {
79239
+ format: "xml",
79240
+ xml: input.xml,
79241
+ ...input.metadata ? { metadata: input.metadata } : {}
79236
79242
  }
79237
- }
79243
+ });
79238
79244
  }
79239
79245
  async createTestScaffold(input) {
79240
79246
  const partId = `${input.identifier}-part1`;
@@ -79269,18 +79275,6 @@ class CourseAssessments {
79269
79275
  }
79270
79276
  }
79271
79277
  }
79272
- async teardownTest(qtiTestIdentifier) {
79273
- const qti = this.core.qti;
79274
- const partId = `${qtiTestIdentifier}-part1`;
79275
- const sectionId = `${qtiTestIdentifier}-section1`;
79276
- const questions = await qti.assessmentTests.getQuestions(qtiTestIdentifier);
79277
- const sectionItems = qti.assessmentTests.testParts(qtiTestIdentifier).sections(partId).items(sectionId);
79278
- for (const q of questions.questions ?? []) {
79279
- await sectionItems.remove(q.reference.identifier);
79280
- await qti.assessmentItems.delete(q.reference.identifier);
79281
- }
79282
- await qti.assessmentTests.delete(qtiTestIdentifier);
79283
- }
79284
79278
  }
79285
79279
  function buildCoursePayload(config4) {
79286
79280
  return {
@@ -79510,9 +79504,9 @@ function createCourseNamespace(core3) {
79510
79504
  cleanup: (courseId) => integration.cleanup(courseId),
79511
79505
  deactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "tobedeleted"),
79512
79506
  reactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "active"),
79513
- ensureAssessmentBank: (input) => assessments.ensureBank(input),
79514
79507
  createAssessmentTest: (input) => assessments.createTestScaffold(input),
79515
- teardownAssessmentTest: (qtiTestIdentifier) => assessments.teardownTest(qtiTestIdentifier)
79508
+ createAssessmentItemXml: (input) => assessments.createItemFromXml(input),
79509
+ updateAssessmentItemXml: (identifier, input) => assessments.updateItemFromXml(identifier, input)
79516
79510
  };
79517
79511
  }
79518
79512
  function recordTimebackClientSummary(outcome) {
@@ -79995,13 +79989,6 @@ function deriveSourcedIds2(courseId) {
79995
79989
  componentResource: `${courseId}-cr`
79996
79990
  };
79997
79991
  }
79998
- function deriveAssessmentBankIds2(courseId) {
79999
- return {
80000
- component: `${courseId}-assessment-bank-component`,
80001
- resource: `${courseId}-assessment-bank-resource`,
80002
- componentResource: `${courseId}-assessment-bank-cr`
80003
- };
80004
- }
80005
79992
  var CACHE_DEFAULTS4, RESOURCE_DEFAULTS4;
80006
79993
  var init_utils6 = __esm(() => {
80007
79994
  init_src();
@@ -80036,6 +80023,96 @@ var init_utils6 = __esm(() => {
80036
80023
  });
80037
80024
 
80038
80025
  // ../utils/src/timeback.ts
80026
+ function playcademySupportedQtiInteractionType(value) {
80027
+ if (typeof value !== "string") {
80028
+ return;
80029
+ }
80030
+ const normalized = value.trim().toLowerCase().replaceAll("_", "-");
80031
+ return PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET.has(normalized) ? normalized : undefined;
80032
+ }
80033
+ function parseNumericTextEntry(input) {
80034
+ const answer = typeof input.answer === "string" ? input.answer.trim() : "";
80035
+ const comparison = input.comparison;
80036
+ if (input.baseType !== "integer" && input.baseType !== "float") {
80037
+ return { success: false, message: "Numeric answers must be integer or float responses" };
80038
+ }
80039
+ if (!answer || (input.baseType === "integer" ? !INTEGER_ANSWER_PATTERN.test(answer) : !FLOAT_ANSWER_PATTERN.test(answer) || !Number.isFinite(Number(answer)))) {
80040
+ return { success: false, message: `The correct answer is not a valid ${input.baseType}` };
80041
+ }
80042
+ if (!comparison || typeof comparison !== "object" || !("kind" in comparison)) {
80043
+ return { success: false, message: "Numeric comparison is required" };
80044
+ }
80045
+ if (comparison.kind === "equal") {
80046
+ return {
80047
+ success: true,
80048
+ value: { baseType: input.baseType, answer, comparison: { kind: "equal" } }
80049
+ };
80050
+ }
80051
+ if (input.baseType === "integer") {
80052
+ return { success: false, message: "Integer answers cannot use decimal-place rounding" };
80053
+ }
80054
+ const figures = "figures" in comparison ? comparison.figures : undefined;
80055
+ const roundingMode = "roundingMode" in comparison ? comparison.roundingMode : undefined;
80056
+ if (comparison.kind !== "equal-rounded" || roundingMode !== "decimalPlaces" || !Number.isInteger(figures) || figures < 0 || figures > 15) {
80057
+ return { success: false, message: "Decimal places must be a whole number from 0 to 15" };
80058
+ }
80059
+ return {
80060
+ success: true,
80061
+ value: {
80062
+ baseType: "float",
80063
+ answer,
80064
+ comparison: { kind: "equal-rounded", roundingMode, figures }
80065
+ }
80066
+ };
80067
+ }
80068
+ function escapeXml(value) {
80069
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
80070
+ }
80071
+ function hottextMaxChoices(selectableCount, multiple) {
80072
+ return multiple ? selectableCount : 1;
80073
+ }
80074
+ function numericTextEntryXml(input) {
80075
+ const comparison = input.numeric.comparison.kind === "equal-rounded" ? `<qti-equal-rounded rounding-mode="decimalPlaces" figures="${input.numeric.comparison.figures}">` : "<qti-equal>";
80076
+ const closeComparison = input.numeric.comparison.kind === "equal-rounded" ? "</qti-equal-rounded>" : "</qti-equal>";
80077
+ return `<?xml version="1.0" encoding="UTF-8"?>
80078
+ <qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
80079
+ <qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="${input.numeric.baseType}">
80080
+ <qti-correct-response><qti-value>${escapeXml(input.numeric.answer)}</qti-value></qti-correct-response>
80081
+ </qti-response-declaration>
80082
+ <qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
80083
+ <qti-default-value><qti-value>0</qti-value></qti-default-value>
80084
+ </qti-outcome-declaration>
80085
+ <qti-item-body>
80086
+ <p>${escapeXml(input.prompt)} <qti-text-entry-interaction response-identifier="RESPONSE" expected-length="15" /></p>
80087
+ </qti-item-body>
80088
+ <qti-response-processing>
80089
+ <qti-response-condition>
80090
+ <qti-response-if>
80091
+ ${comparison}<qti-variable identifier="RESPONSE" /><qti-correct identifier="RESPONSE" />${closeComparison}
80092
+ <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
80093
+ </qti-response-if>
80094
+ </qti-response-condition>
80095
+ </qti-response-processing>
80096
+ </qti-assessment-item>`;
80097
+ }
80098
+ function countQtiTestItems(test) {
80099
+ let itemCount = 0;
80100
+ for (const part of test["qti-test-part"] ?? []) {
80101
+ for (const section of part["qti-assessment-section"] ?? []) {
80102
+ itemCount += section["qti-assessment-item-ref"]?.length ?? 0;
80103
+ }
80104
+ }
80105
+ return itemCount;
80106
+ }
80107
+ function isQtiItemOwnedByTest(item, testIdentifier) {
80108
+ const hasOwnershipMetadata = Boolean(item.metadata && (("ownerSystem" in item.metadata) || (PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY in item.metadata)));
80109
+ if (hasOwnershipMetadata) {
80110
+ return item.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && item.metadata?.[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY] === testIdentifier;
80111
+ }
80112
+ const identifierPrefix = `${testIdentifier}-q`;
80113
+ const identifierSuffix = item.identifier?.startsWith(identifierPrefix) ? item.identifier.slice(identifierPrefix.length) : undefined;
80114
+ return identifierSuffix !== undefined && /^[0-9a-f]{8}$/i.test(identifierSuffix);
80115
+ }
80039
80116
  function formatGradeLabel(grade) {
80040
80117
  if (grade === null || grade === undefined) {
80041
80118
  return "N/A";
@@ -80077,7 +80154,7 @@ function parseTimebackDiscrepancyQueueMetrics(values) {
80077
80154
  }
80078
80155
  return metrics;
80079
80156
  }
80080
- var TIMEBACK_DISCREPANCY_QUEUE_WINDOWS, TIMEBACK_DISCREPANCY_QUEUE_METRICS, DEFAULT_TIMEBACK_DISCREPANCY_QUEUE_WINDOW = "this-week", TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES, TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES;
80157
+ var TIMEBACK_DISCREPANCY_QUEUE_WINDOWS, TIMEBACK_DISCREPANCY_QUEUE_METRICS, PLAYCADEMY_QTI_OWNER_SYSTEM = "playcademy", PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY = "ownerTestIdentifier", PLAYCADEMY_QTI_EDITOR_MODE_KEY = "playcademyEditorMode", PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES, PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET, INTEGER_ANSWER_PATTERN, FLOAT_ANSWER_PATTERN, INLINE_CHOICE_BLANK = "_____", DEFAULT_TIMEBACK_DISCREPANCY_QUEUE_WINDOW = "this-week", TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES, TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES;
80081
80158
  var init_timeback3 = __esm(() => {
80082
80159
  TIMEBACK_DISCREPANCY_QUEUE_WINDOWS = [
80083
80160
  "today",
@@ -80088,6 +80165,17 @@ var init_timeback3 = __esm(() => {
80088
80165
  "custom"
80089
80166
  ];
80090
80167
  TIMEBACK_DISCREPANCY_QUEUE_METRICS = ["xp", "mastery", "time", "score"];
80168
+ PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES = [
80169
+ "choice",
80170
+ "inline-choice",
80171
+ "text-entry",
80172
+ "order",
80173
+ "match",
80174
+ "hottext"
80175
+ ];
80176
+ PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET = new Set(PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES);
80177
+ INTEGER_ANSWER_PATTERN = /^[+-]?\d+$/;
80178
+ FLOAT_ANSWER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
80091
80179
  TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_WINDOWS);
80092
80180
  TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_METRICS);
80093
80181
  });
@@ -81330,7 +81418,7 @@ class TimebackAdminService {
81330
81418
  }
81331
81419
  async getMasterableUnitsByCourse(courseIds) {
81332
81420
  const uniqueCourseIds = [...new Set(courseIds)];
81333
- const results = await TimebackAdminService.runWithConcurrency(uniqueCourseIds, TimebackAdminService.MASTERABLE_UNITS_CONCURRENCY, async (courseId) => [courseId, await this.getMasterableUnits(courseId)]);
81421
+ const results = await runWithConcurrency(uniqueCourseIds, TimebackAdminService.MASTERABLE_UNITS_CONCURRENCY, async (courseId) => [courseId, await this.getMasterableUnits(courseId)]);
81334
81422
  return new Map(results);
81335
81423
  }
81336
81424
  deriveGameSensorUrl(game2) {
@@ -81511,7 +81599,7 @@ class TimebackAdminService {
81511
81599
  async loadEnrollmentAnalyticsSummaries(enrollmentIds) {
81512
81600
  const client = this.requireClient();
81513
81601
  const uniqueEnrollmentIds = [...new Set(enrollmentIds)];
81514
- const results = await TimebackAdminService.runWithConcurrency(uniqueEnrollmentIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (enrollmentId) => {
81602
+ const results = await runWithConcurrency(uniqueEnrollmentIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (enrollmentId) => {
81515
81603
  try {
81516
81604
  const analytics = await client.api.edubridge.analytics.getEnrollmentFacts({
81517
81605
  enrollmentId,
@@ -81605,7 +81693,7 @@ class TimebackAdminService {
81605
81693
  if (options.runOwnersById.size === 0) {
81606
81694
  return { events: [], runIds: new Set };
81607
81695
  }
81608
- const hydratedRuns = await TimebackAdminService.runWithConcurrency([...options.runOwnersById.entries()], TimebackAdminService.DISCREPANCY_QUEUE_RUN_HYDRATION_CONCURRENCY, async ([runId, studentId]) => {
81696
+ const hydratedRuns = await runWithConcurrency([...options.runOwnersById.entries()], TimebackAdminService.DISCREPANCY_QUEUE_RUN_HYDRATION_CONCURRENCY, async ([runId, studentId]) => {
81609
81697
  try {
81610
81698
  return {
81611
81699
  runId,
@@ -81638,7 +81726,7 @@ class TimebackAdminService {
81638
81726
  };
81639
81727
  }
81640
81728
  async buildMetricDiscrepancyQueueCandidates(user, options) {
81641
- const comparisonResults = await TimebackAdminService.runWithConcurrency(options.activityGroups, TimebackAdminService.DISCREPANCY_QUEUE_COMPARISON_CONCURRENCY, async (group) => {
81729
+ const comparisonResults = await runWithConcurrency(options.activityGroups, TimebackAdminService.DISCREPANCY_QUEUE_COMPARISON_CONCURRENCY, async (group) => {
81642
81730
  try {
81643
81731
  return {
81644
81732
  group,
@@ -82638,7 +82726,7 @@ class TimebackAdminService {
82638
82726
  const action = context2.newMasterableUnits < context2.oldMasterableUnits ? "complete" : "revoke";
82639
82727
  const failed = [];
82640
82728
  let processed = 0;
82641
- await TimebackAdminService.runWithConcurrency(context2.affectedStudentIds, 8, async (studentId) => {
82729
+ await runWithConcurrency(context2.affectedStudentIds, 8, async (studentId) => {
82642
82730
  try {
82643
82731
  await upsertMasteryCompletionEntry({
82644
82732
  client,
@@ -82859,28 +82947,9 @@ class TimebackAdminService {
82859
82947
  }
82860
82948
  async getCompletionStatusByCourse(client, courseIds, studentId) {
82861
82949
  const uniqueCourseIds = [...new Set(courseIds)];
82862
- const results = await TimebackAdminService.runWithConcurrency(uniqueCourseIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (courseId) => [courseId, await this.getCompletionStatus(client, courseId, studentId)]);
82950
+ const results = await runWithConcurrency(uniqueCourseIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (courseId) => [courseId, await this.getCompletionStatus(client, courseId, studentId)]);
82863
82951
  return new Map(results);
82864
82952
  }
82865
- static async runWithConcurrency(items, concurrency, worker) {
82866
- if (items.length === 0) {
82867
- return [];
82868
- }
82869
- const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
82870
- const results = Array.from({ length: items.length });
82871
- let nextIndex = 0;
82872
- await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
82873
- while (true) {
82874
- const currentIndex = nextIndex;
82875
- nextIndex++;
82876
- if (currentIndex >= items.length) {
82877
- return;
82878
- }
82879
- results[currentIndex] = await worker(items[currentIndex]);
82880
- }
82881
- }));
82882
- return results;
82883
- }
82884
82953
  }
82885
82954
  var init_timeback_admin_service = __esm(async () => {
82886
82955
  init_drizzle_orm();
@@ -82907,8 +82976,877 @@ var init_timeback_admin_service = __esm(async () => {
82907
82976
  ]);
82908
82977
  });
82909
82978
 
82979
+ // ../api-core/src/utils/timeback-assessment-rules.util.ts
82980
+ function validateAssessmentStatusTransition(current, next) {
82981
+ if (current === next) {
82982
+ return;
82983
+ }
82984
+ const allowed = current === "draft" && next === "live" || current === "live" && next === "archived" || current === "archived" && next === "live";
82985
+ if (!allowed) {
82986
+ throw new ValidationError(`Assessment status cannot change from ${current} to ${next}`);
82987
+ }
82988
+ }
82989
+ function isAssessmentPublicationTransition(current, next) {
82990
+ return current !== "live" && next === "live";
82991
+ }
82992
+ function assertDraftAssessment(row) {
82993
+ if (row.status !== "draft") {
82994
+ throw new ValidationError("Only draft assessments can change QTI content or question membership");
82995
+ }
82996
+ }
82997
+ function assertAllAssessmentAssociationsDraft(rows) {
82998
+ if (rows.some((row) => row.status !== "draft")) {
82999
+ throw new ValidationError("QTI content cannot change while any associated assessment is live or archived");
83000
+ }
83001
+ }
83002
+ function assertAssessmentHasQuestions(questions) {
83003
+ if (questions.length === 0) {
83004
+ throw new ValidationError("An assessment must contain at least one question to publish");
83005
+ }
83006
+ }
83007
+ function planAssessmentRemoval(status) {
83008
+ if (status === "draft") {
83009
+ return { kind: "delete", action: "discarded", operation: "discard_draft" };
83010
+ }
83011
+ if (status === "live") {
83012
+ return { kind: "archive", action: "archived", operation: "archive" };
83013
+ }
83014
+ return { kind: "none", action: "archived" };
83015
+ }
83016
+ function buildAssessmentAssociationUpdates(row, input) {
83017
+ const updates = {};
83018
+ if (input.purpose !== undefined) {
83019
+ updates.purpose = input.purpose;
83020
+ if (row.status === "live" && input.purpose !== row.purpose) {
83021
+ updates.sortOrder = null;
83022
+ }
83023
+ }
83024
+ if (input.status !== undefined) {
83025
+ updates.status = input.status;
83026
+ if (input.status === "archived") {
83027
+ updates.sortOrder = null;
83028
+ }
83029
+ }
83030
+ return updates;
83031
+ }
83032
+ function validateUniqueAssessmentIdentifiers(testIdentifiers) {
83033
+ if (new Set(testIdentifiers).size !== testIdentifiers.length) {
83034
+ throw new ValidationError("Assessment order must contain unique identifiers");
83035
+ }
83036
+ }
83037
+ function assertAssessmentOrderUpdateSucceeded(updatedRow) {
83038
+ if (!updatedRow) {
83039
+ throw new ValidationError("Assessment order changed while it was being saved. Refresh and try again.");
83040
+ }
83041
+ return updatedRow;
83042
+ }
83043
+ function lockOrderAssessmentRows(rows) {
83044
+ return rows.toSorted((left, right) => left.id.localeCompare(right.id));
83045
+ }
83046
+ function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
83047
+ const rowsByIdentifier = new Map(liveRows.map((row) => [row.qtiTestIdentifier, row]));
83048
+ if (liveRows.length !== testIdentifiers.length || testIdentifiers.some((identifier) => !rowsByIdentifier.has(identifier))) {
83049
+ throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
83050
+ }
83051
+ return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
83052
+ }
83053
+ var init_timeback_assessment_rules_util = __esm(() => {
83054
+ init_errors();
83055
+ });
83056
+
83057
+ // ../api-core/src/utils/timeback-qti-authoring.util.ts
83058
+ function recordValue(value) {
83059
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
83060
+ }
83061
+ function qtiAuthoringSnapshot(input) {
83062
+ if (!recordValue(input.interaction)) {
83063
+ return;
83064
+ }
83065
+ return Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => input[field] === undefined ? [] : [[field, input[field]]]));
83066
+ }
83067
+ function buildOwnedQtiQuestionMetadata(input, ownership, existingMetadata = {}) {
83068
+ const inputMetadata = recordValue(input.metadata) ?? {};
83069
+ const metadata2 = {
83070
+ ...existingMetadata,
83071
+ ...inputMetadata
83072
+ };
83073
+ const existingAuthoring = recordValue(existingMetadata[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
83074
+ Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
83075
+ Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_EDITOR_MODE_KEY);
83076
+ const authoring = qtiAuthoringSnapshot(input);
83077
+ const shouldReplaceAuthoring = "interaction" in input || "numericTextEntry" in input;
83078
+ const persistedAuthoring = authoring ?? (shouldReplaceAuthoring ? undefined : existingAuthoring);
83079
+ return {
83080
+ ...metadata2,
83081
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
83082
+ ownerGameSlug: ownership.gameSlug,
83083
+ [PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]: ownership.testIdentifier,
83084
+ ...persistedAuthoring ? { [PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]: persistedAuthoring } : {}
83085
+ };
83086
+ }
83087
+ function restoreQtiQuestionAuthoringData(item) {
83088
+ const authoring = recordValue(item.metadata?.[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
83089
+ if (!authoring) {
83090
+ return item;
83091
+ }
83092
+ const restored = Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => authoring[field] === undefined ? [] : [[field, authoring[field]]]));
83093
+ return { ...item, ...restored };
83094
+ }
83095
+ function mergeQtiQuestionItem(item, fallbackItem, itemIdentifier, metadata2) {
83096
+ return restoreQtiQuestionAuthoringData({
83097
+ ...fallbackItem,
83098
+ ...item,
83099
+ identifier: itemIdentifier,
83100
+ title: item.title || fallbackItem?.title || itemIdentifier,
83101
+ type: item.type ?? fallbackItem?.type,
83102
+ rawXml: item.rawXml ?? fallbackItem?.rawXml,
83103
+ interaction: item.interaction ?? fallbackItem?.interaction,
83104
+ responseDeclarations: item.responseDeclarations ?? fallbackItem?.responseDeclarations,
83105
+ metadata: {
83106
+ ...fallbackItem?.metadata,
83107
+ ...item.metadata,
83108
+ ...metadata2
83109
+ }
83110
+ });
83111
+ }
83112
+ function newPlaycademyQuestionIdentifier(testIdentifier) {
83113
+ return `${testIdentifier}-q${crypto.randomUUID().slice(0, 8)}`;
83114
+ }
83115
+ function parseQtiQuestionCreationInput(input) {
83116
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
83117
+ throw new ValidationError("Question creation input must be an object");
83118
+ }
83119
+ const rawInput = input;
83120
+ if (rawInput.mode === undefined) {
83121
+ return { kind: "authoring", input: rawInput };
83122
+ }
83123
+ if (rawInput.mode !== "copy") {
83124
+ throw new ValidationError("Question creation mode is invalid");
83125
+ }
83126
+ const sourceItemIdentifier = typeof rawInput.sourceItemIdentifier === "string" ? rawInput.sourceItemIdentifier.trim() : "";
83127
+ if (!sourceItemIdentifier) {
83128
+ throw new ValidationError("A source question identifier is required to create a copy");
83129
+ }
83130
+ const unexpectedField = Object.keys(rawInput).find((field) => field !== "mode" && field !== "sourceItemIdentifier");
83131
+ if (unexpectedField) {
83132
+ throw new ValidationError(`Question copy input contains an unexpected “${unexpectedField}” field`);
83133
+ }
83134
+ return { kind: "copy", sourceItemIdentifier };
83135
+ }
83136
+ function nonEmptyMetadataString(value) {
83137
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
83138
+ }
83139
+ function qtiQuestionOwnerTestIdentifier(item) {
83140
+ if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
83141
+ return;
83142
+ }
83143
+ return nonEmptyMetadataString(item.metadata[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]);
83144
+ }
83145
+ function qtiTestReferencesItem(test, itemIdentifier) {
83146
+ return (test["qti-test-part"] ?? []).some((part) => (part["qti-assessment-section"] ?? []).some((section) => (section["qti-assessment-item-ref"] ?? []).some((reference) => reference.identifier === itemIdentifier)));
83147
+ }
83148
+ function resolveQtiQuestionOwnerGameSlug(item, ownerTest) {
83149
+ if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
83150
+ return;
83151
+ }
83152
+ if ("ownerGameSlug" in item.metadata) {
83153
+ return nonEmptyMetadataString(item.metadata.ownerGameSlug);
83154
+ }
83155
+ const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
83156
+ if (!ownerTestIdentifier || !ownerTest || ownerTest.identifier !== ownerTestIdentifier || !qtiTestReferencesItem(ownerTest, item.identifier) || ownerTest.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
83157
+ return;
83158
+ }
83159
+ return nonEmptyMetadataString(ownerTest.metadata.ownerGameSlug);
83160
+ }
83161
+ function isQtiQuestionOwnedByGame(item, gameSlug, ownerTest) {
83162
+ return resolveQtiQuestionOwnerGameSlug(item, ownerTest) === gameSlug;
83163
+ }
83164
+ function isQtiQuestionOwnedByContext(item, ownership, ownerTest) {
83165
+ const declaredOwnerTest = qtiQuestionOwnerTestIdentifier(item);
83166
+ if (declaredOwnerTest) {
83167
+ return declaredOwnerTest === ownership.testIdentifier && isQtiQuestionOwnedByGame(item, ownership.gameSlug, ownerTest);
83168
+ }
83169
+ return isQtiItemOwnedByTest(item, ownership.testIdentifier);
83170
+ }
83171
+ function normalizedQtiInteractionType(value) {
83172
+ if (typeof value !== "string") {
83173
+ return;
83174
+ }
83175
+ const normalized = value.trim().toLowerCase().replaceAll("_", "-");
83176
+ return normalized || undefined;
83177
+ }
83178
+ function qtiXmlInteractionTypes(xml) {
83179
+ const markup = xml.replaceAll(/<!--[\s\S]*?-->/g, "").replaceAll(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
83180
+ const interactionPattern = /<(?!\/)(?:[A-Za-z_][\w.-]*:)?qti-([a-z][\w-]*)-interaction\b[^>]*>/gi;
83181
+ return [...markup.matchAll(interactionPattern)].map((match) => match[1].toLowerCase());
83182
+ }
83183
+ function supportedQtiQuestionInteractionType(question) {
83184
+ const interaction = recordValue(question.interaction);
83185
+ const structuredTypes = [
83186
+ normalizedQtiInteractionType(question.type),
83187
+ normalizedQtiInteractionType(interaction?.type)
83188
+ ].filter((type) => Boolean(type));
83189
+ const distinctStructuredTypes = [...new Set(structuredTypes)];
83190
+ if (distinctStructuredTypes.length > 1) {
83191
+ return;
83192
+ }
83193
+ const rawXml = typeof question.rawXml === "string" ? question.rawXml : undefined;
83194
+ if (rawXml) {
83195
+ const xmlTypes = qtiXmlInteractionTypes(rawXml);
83196
+ if (xmlTypes.length !== 1) {
83197
+ return;
83198
+ }
83199
+ const [xmlType] = xmlTypes;
83200
+ const [structuredType2] = distinctStructuredTypes;
83201
+ if (structuredType2 && structuredType2 !== xmlType) {
83202
+ return;
83203
+ }
83204
+ return playcademySupportedQtiInteractionType(xmlType);
83205
+ }
83206
+ const [structuredType] = distinctStructuredTypes;
83207
+ return playcademySupportedQtiInteractionType(structuredType);
83208
+ }
83209
+ function assertSupportedQtiQuestionInteraction(question) {
83210
+ if (!supportedQtiQuestionInteractionType(question)) {
83211
+ throw new ValidationError("This QTI interaction type is not supported by the Playcademy question editor.");
83212
+ }
83213
+ }
83214
+ function invalidQtiCopyXml() {
83215
+ throw new ValidationError("The source question does not contain valid QTI item XML");
83216
+ }
83217
+ function markupEnd(xml, start2, allowInternalSubset) {
83218
+ let quote = null;
83219
+ let subsetDepth = 0;
83220
+ for (let index2 = start2;index2 < xml.length; index2 += 1) {
83221
+ const character = xml[index2];
83222
+ if (quote) {
83223
+ if (character === quote) {
83224
+ quote = null;
83225
+ }
83226
+ } else if (character === '"' || character === "'") {
83227
+ quote = character;
83228
+ } else if (allowInternalSubset && character === "[") {
83229
+ subsetDepth += 1;
83230
+ } else if (allowInternalSubset && character === "]") {
83231
+ subsetDepth = Math.max(0, subsetDepth - 1);
83232
+ } else if (character === ">" && subsetDepth === 0) {
83233
+ return index2;
83234
+ }
83235
+ }
83236
+ return invalidQtiCopyXml();
83237
+ }
83238
+ function skipXmlWhitespace(xml, start2, end = xml.length) {
83239
+ let cursor2 = start2;
83240
+ while (cursor2 < end && /\s/.test(xml[cursor2] ?? "")) {
83241
+ cursor2 += 1;
83242
+ }
83243
+ return cursor2;
83244
+ }
83245
+ function xmlPreambleEnd(xml, cursor2) {
83246
+ if (xml.startsWith("<?", cursor2)) {
83247
+ const end = xml.indexOf("?>", cursor2 + 2);
83248
+ if (end === -1) {
83249
+ return invalidQtiCopyXml();
83250
+ }
83251
+ return end + 2;
83252
+ }
83253
+ if (xml.startsWith("<!--", cursor2)) {
83254
+ const end = xml.indexOf("-->", cursor2 + 4);
83255
+ if (end === -1) {
83256
+ return invalidQtiCopyXml();
83257
+ }
83258
+ return end + 3;
83259
+ }
83260
+ if (/^<!DOCTYPE\b/i.test(xml.slice(cursor2))) {
83261
+ return markupEnd(xml, cursor2 + 2, true) + 1;
83262
+ }
83263
+ return null;
83264
+ }
83265
+ function qtiRootAttributes(xml, start2, end) {
83266
+ const attributes = [];
83267
+ let cursor2 = start2;
83268
+ while (cursor2 < end) {
83269
+ cursor2 = skipXmlWhitespace(xml, cursor2, end);
83270
+ if (xml[cursor2] === "/") {
83271
+ cursor2 += 1;
83272
+ } else if (cursor2 < end) {
83273
+ const nameMatch = /^[A-Za-z_][\w.:-]*/.exec(xml.slice(cursor2, end));
83274
+ if (!nameMatch) {
83275
+ return invalidQtiCopyXml();
83276
+ }
83277
+ const name3 = nameMatch[0];
83278
+ cursor2 = skipXmlWhitespace(xml, cursor2 + name3.length, end);
83279
+ if (xml[cursor2] !== "=") {
83280
+ return invalidQtiCopyXml();
83281
+ }
83282
+ cursor2 = skipXmlWhitespace(xml, cursor2 + 1, end);
83283
+ const quote = xml[cursor2];
83284
+ if (quote !== '"' && quote !== "'") {
83285
+ return invalidQtiCopyXml();
83286
+ }
83287
+ const valueStart = cursor2 + 1;
83288
+ const valueEnd = xml.indexOf(quote, valueStart);
83289
+ if (valueEnd === -1 || valueEnd > end) {
83290
+ return invalidQtiCopyXml();
83291
+ }
83292
+ attributes.push({ name: name3, quote, valueStart, valueEnd });
83293
+ cursor2 = valueEnd + 1;
83294
+ }
83295
+ }
83296
+ return attributes;
83297
+ }
83298
+ function qtiItemStartTag(xml) {
83299
+ let cursor2 = xml.charCodeAt(0) === 65279 ? 1 : 0;
83300
+ while (cursor2 < xml.length) {
83301
+ cursor2 = skipXmlWhitespace(xml, cursor2);
83302
+ const preambleEnd = xmlPreambleEnd(xml, cursor2);
83303
+ if (preambleEnd !== null) {
83304
+ cursor2 = preambleEnd;
83305
+ } else {
83306
+ const root = /^<([A-Za-z_][\w.:-]*)/.exec(xml.slice(cursor2));
83307
+ if (!root || root[1].split(":").at(-1) !== "qti-assessment-item") {
83308
+ return invalidQtiCopyXml();
83309
+ }
83310
+ const attributesStart = cursor2 + root[0].length;
83311
+ const end = markupEnd(xml, attributesStart, false);
83312
+ const attributes = qtiRootAttributes(xml, attributesStart, end);
83313
+ let insertionPoint = skipXmlWhitespaceBackward(xml, end);
83314
+ if (xml[insertionPoint - 1] === "/") {
83315
+ insertionPoint -= 1;
83316
+ }
83317
+ return { attributes, insertionPoint };
83318
+ }
83319
+ }
83320
+ return invalidQtiCopyXml();
83321
+ }
83322
+ function skipXmlWhitespaceBackward(xml, start2) {
83323
+ let cursor2 = start2;
83324
+ while (/\s/.test(xml[cursor2 - 1] ?? "")) {
83325
+ cursor2 -= 1;
83326
+ }
83327
+ return cursor2;
83328
+ }
83329
+ function escapeXmlAttribute(value, quote) {
83330
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(quote, quote === '"' ? "&quot;" : "&apos;");
83331
+ }
83332
+ function rewriteQtiItemIdentity(xml, identifier, title) {
83333
+ if (!xml.trim() || !identifier.trim() || !title.trim()) {
83334
+ return invalidQtiCopyXml();
83335
+ }
83336
+ const root = qtiItemStartTag(xml);
83337
+ const replacements = [];
83338
+ const targetAttributes = new Map([
83339
+ ["identifier", identifier],
83340
+ ["title", title]
83341
+ ]);
83342
+ for (const [name3, value] of targetAttributes) {
83343
+ const matches = root.attributes.filter((attribute2) => attribute2.name === name3);
83344
+ if (matches.length > 1) {
83345
+ return invalidQtiCopyXml();
83346
+ }
83347
+ const attribute = matches[0];
83348
+ if (attribute) {
83349
+ replacements.push({
83350
+ start: attribute.valueStart,
83351
+ end: attribute.valueEnd,
83352
+ value: escapeXmlAttribute(value, attribute.quote)
83353
+ });
83354
+ } else {
83355
+ replacements.push({
83356
+ start: root.insertionPoint,
83357
+ end: root.insertionPoint,
83358
+ value: ` ${name3}="${escapeXmlAttribute(value, '"')}"`
83359
+ });
83360
+ }
83361
+ }
83362
+ return replacements.toSorted((left, right) => right.start - left.start).reduce((rewritten, replacement) => `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`, xml);
83363
+ }
83364
+ function buildQtiQuestionCopyInput(input) {
83365
+ assertSupportedQtiQuestionInteraction(input.source);
83366
+ const title = input.source.title?.trim() || input.source.identifier;
83367
+ if (!input.source.rawXml) {
83368
+ return invalidQtiCopyXml();
83369
+ }
83370
+ const sourceMetadata = { ...input.source.metadata };
83371
+ if (sourceMetadata.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
83372
+ Reflect.deleteProperty(sourceMetadata, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
83373
+ }
83374
+ const metadata2 = buildOwnedQtiQuestionMetadata({
83375
+ metadata: {
83376
+ copiedFromItemIdentifier: input.source.identifier,
83377
+ integrationId: input.integrationId
83378
+ }
83379
+ }, {
83380
+ gameSlug: input.gameSlug,
83381
+ testIdentifier: input.targetTestIdentifier
83382
+ }, sourceMetadata);
83383
+ return {
83384
+ identifier: input.targetIdentifier,
83385
+ title,
83386
+ xml: rewriteQtiItemIdentity(input.source.rawXml, input.targetIdentifier, title),
83387
+ metadata: metadata2
83388
+ };
83389
+ }
83390
+ function assertQtiQuestionEditable(item, ownership, ownerTest) {
83391
+ if (!isQtiQuestionOwnedByContext(item, ownership, ownerTest)) {
83392
+ throw new ValidationError("Shared question references are read-only. Copy the question to create an editable independent item.");
83393
+ }
83394
+ }
83395
+ function buildNumericQuestionXml(input, identifier, title) {
83396
+ if (input.format === "xml") {
83397
+ throw new ValidationError("Raw question XML is not accepted at this API boundary");
83398
+ }
83399
+ const candidate = input.numericTextEntry;
83400
+ if (candidate === undefined) {
83401
+ return null;
83402
+ }
83403
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
83404
+ throw new ValidationError("Numeric text-entry data is invalid");
83405
+ }
83406
+ const prompt = "prompt" in candidate ? candidate.prompt : undefined;
83407
+ const numeric3 = parseNumericTextEntry({
83408
+ baseType: "baseType" in candidate ? candidate.baseType : undefined,
83409
+ answer: "answer" in candidate ? candidate.answer : undefined,
83410
+ comparison: "comparison" in candidate ? candidate.comparison : undefined
83411
+ });
83412
+ if (!numeric3.success) {
83413
+ throw new ValidationError(numeric3.message);
83414
+ }
83415
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
83416
+ throw new ValidationError("Numeric questions require a title and prompt");
83417
+ }
83418
+ return numericTextEntryXml({ identifier, title, prompt: prompt.trim(), numeric: numeric3.value });
83419
+ }
83420
+ function buildExactMatchQuestionXml(input) {
83421
+ const responseIdentifier = escapeXml(input.responseIdentifier);
83422
+ const correctValues = input.correctIdentifiers.map((identifier) => ` <qti-value>${escapeXml(identifier)}</qti-value>`).join(`
83423
+ `);
83424
+ return `<?xml version="1.0" encoding="UTF-8"?>
83425
+ <qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
83426
+ <qti-response-declaration identifier="${responseIdentifier}" cardinality="${input.cardinality}" base-type="identifier">
83427
+ <qti-correct-response>
83428
+ ${correctValues}
83429
+ </qti-correct-response>
83430
+ </qti-response-declaration>
83431
+ <qti-outcome-declaration identifier="FEEDBACK" cardinality="single" base-type="identifier" />
83432
+ <qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
83433
+ <qti-default-value><qti-value>0</qti-value></qti-default-value>
83434
+ </qti-outcome-declaration>
83435
+ <qti-item-body>
83436
+ ${input.itemBody}
83437
+ </qti-item-body>
83438
+ <qti-response-processing>
83439
+ <qti-response-condition>
83440
+ <qti-response-if>
83441
+ <qti-match><qti-variable identifier="${responseIdentifier}" /><qti-correct identifier="${responseIdentifier}" /></qti-match>
83442
+ <qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">CORRECT</qti-base-value></qti-set-outcome-value>
83443
+ <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
83444
+ </qti-response-if>
83445
+ <qti-response-else>
83446
+ <qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">INCORRECT</qti-base-value></qti-set-outcome-value>
83447
+ <qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">0</qti-base-value></qti-set-outcome-value>
83448
+ </qti-response-else>
83449
+ </qti-response-condition>
83450
+ </qti-response-processing>
83451
+ </qti-assessment-item>`;
83452
+ }
83453
+ function parseInlineChoices(value) {
83454
+ if (!Array.isArray(value) || value.length < 2) {
83455
+ throw new ValidationError("Inline-choice questions require at least two choices");
83456
+ }
83457
+ const choices = value.map((choice) => {
83458
+ const record3 = recordValue(choice);
83459
+ const identifier = record3?.identifier;
83460
+ const content = record3?.content;
83461
+ if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim()) {
83462
+ throw new ValidationError("Inline-choice options are invalid");
83463
+ }
83464
+ return { identifier: identifier.trim(), content: content.trim() };
83465
+ });
83466
+ if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
83467
+ throw new ValidationError("Inline-choice option identifiers must be unique");
83468
+ }
83469
+ return choices;
83470
+ }
83471
+ function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
83472
+ const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
83473
+ const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
83474
+ const correctResponse = recordValue(declaration?.correctResponse);
83475
+ const rawValues = correctResponse?.value;
83476
+ const values = Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : [];
83477
+ if (declaration?.cardinality !== "single" || declaration.baseType !== "identifier") {
83478
+ throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
83479
+ }
83480
+ if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
83481
+ throw new ValidationError("Inline-choice questions require one valid correct option");
83482
+ }
83483
+ return values[0];
83484
+ }
83485
+ function buildInlineChoiceQuestionXml(input, identifier, title) {
83486
+ const interaction = recordValue(input.interaction);
83487
+ const interactionType = normalizedQtiInteractionType(interaction?.type ?? input.type);
83488
+ if (interactionType !== "inline-choice") {
83489
+ return null;
83490
+ }
83491
+ const responseIdentifier = interaction?.responseIdentifier;
83492
+ const structure = recordValue(interaction?.questionStructure);
83493
+ const prompt = structure?.prompt;
83494
+ if (!structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
83495
+ throw new ValidationError("Inline-choice question data is invalid");
83496
+ }
83497
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
83498
+ throw new ValidationError("Inline-choice questions require a title and prompt");
83499
+ }
83500
+ const normalizedPrompt = prompt.trim();
83501
+ const promptParts = normalizedPrompt.split(INLINE_CHOICE_BLANK);
83502
+ if (promptParts.length !== 2) {
83503
+ throw new ValidationError("Inline-choice questions require exactly one blank");
83504
+ }
83505
+ const choices = parseInlineChoices(structure.inlineChoices);
83506
+ const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
83507
+ const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
83508
+ const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
83509
+ `);
83510
+ const [before = "", after = ""] = promptParts;
83511
+ const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
83512
+ ${choicesXml}
83513
+ </qti-inline-choice-interaction>${escapeXml(after)}</p>`;
83514
+ return buildExactMatchQuestionXml({
83515
+ identifier,
83516
+ title,
83517
+ responseIdentifier,
83518
+ cardinality: "single",
83519
+ correctIdentifiers: [correctIdentifier],
83520
+ itemBody
83521
+ });
83522
+ }
83523
+ function parseHottextSegments(value) {
83524
+ if (!Array.isArray(value) || value.length === 0) {
83525
+ throw new ValidationError("Hottext questions require passage segments");
83526
+ }
83527
+ const segments = value.map((segment) => {
83528
+ const record3 = recordValue(segment);
83529
+ const identifier = record3?.identifier;
83530
+ const content = record3?.content;
83531
+ const mode = record3?.mode;
83532
+ if (typeof identifier !== "string" || typeof content !== "string" || !content.trim() || !["plain", "option", "correct"].includes(String(mode))) {
83533
+ throw new ValidationError("Hottext passage segments are invalid");
83534
+ }
83535
+ return {
83536
+ identifier: identifier.trim(),
83537
+ content: content.trim(),
83538
+ mode
83539
+ };
83540
+ });
83541
+ const selectable = segments.filter((segment) => segment.mode !== "plain");
83542
+ const correct = selectable.filter((segment) => segment.mode === "correct");
83543
+ const identifiers = selectable.map((segment) => segment.identifier);
83544
+ if (selectable.length < 2) {
83545
+ throw new ValidationError("Hottext questions require at least two selectable phrases");
83546
+ }
83547
+ if (identifiers.some((identifier) => !identifier)) {
83548
+ throw new ValidationError("Hottext selectable phrases require identifiers");
83549
+ }
83550
+ if (new Set(identifiers).size !== identifiers.length) {
83551
+ throw new ValidationError("Hottext selectable phrase identifiers must be unique");
83552
+ }
83553
+ if (correct.length === 0) {
83554
+ throw new ValidationError("Hottext questions require a correct phrase");
83555
+ }
83556
+ return { segments, selectable, correct };
83557
+ }
83558
+ function buildHottextQuestionXml(input, identifier, title) {
83559
+ const candidate = input.hottext;
83560
+ if (candidate === undefined) {
83561
+ return null;
83562
+ }
83563
+ const hottext = recordValue(candidate);
83564
+ if (!hottext) {
83565
+ throw new ValidationError("Hottext question data is invalid");
83566
+ }
83567
+ const prompt = hottext.prompt;
83568
+ if (!title || typeof prompt !== "string" || !prompt.trim()) {
83569
+ throw new ValidationError("Hottext questions require a title and prompt");
83570
+ }
83571
+ const { segments, selectable, correct } = parseHottextSegments(hottext.segments);
83572
+ const multiple = correct.length > 1;
83573
+ const maxChoices = hottextMaxChoices(selectable.length, multiple);
83574
+ const passage = segments.map((segment) => segment.mode === "plain" ? escapeXml(segment.content) : `<qti-hottext identifier="${escapeXml(segment.identifier)}">${escapeXml(segment.content)}</qti-hottext>`).join(" ");
83575
+ const itemBody = ` <qti-hottext-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
83576
+ <qti-prompt>${escapeXml(prompt.trim())}</qti-prompt>
83577
+ <p>${passage}</p>
83578
+ </qti-hottext-interaction>`;
83579
+ return buildExactMatchQuestionXml({
83580
+ identifier,
83581
+ title,
83582
+ responseIdentifier: "RESPONSE",
83583
+ cardinality: multiple ? "multiple" : "single",
83584
+ correctIdentifiers: correct.map((segment) => segment.identifier),
83585
+ itemBody
83586
+ });
83587
+ }
83588
+ function buildQuestionXml(input, identifier, title) {
83589
+ for (const build2 of QUESTION_XML_BUILDERS) {
83590
+ const xml = build2(input, identifier, title);
83591
+ if (xml !== null) {
83592
+ return xml;
83593
+ }
83594
+ }
83595
+ return null;
83596
+ }
83597
+ function buildCreatedQtiQuestionReference(input) {
83598
+ return {
83599
+ ownership: "owned",
83600
+ reference: {
83601
+ identifier: input.itemIdentifier,
83602
+ href: input.href,
83603
+ testPart: input.partIdentifier,
83604
+ section: input.sectionIdentifier
83605
+ },
83606
+ question: mergeQtiQuestionItem(input.item, input.fallbackItem, input.itemIdentifier, input.metadata)
83607
+ };
83608
+ }
83609
+ function buildHydratedQtiQuestionReference(input) {
83610
+ const identifier = input.reference.reference.identifier;
83611
+ const ownerGameSlug = resolveQtiQuestionOwnerGameSlug(input.item, input.ownerTest);
83612
+ const authoritativeItem = ownerGameSlug ? {
83613
+ ...input.item,
83614
+ metadata: { ...input.item.metadata, ownerGameSlug }
83615
+ } : input.item;
83616
+ const question = mergeQtiQuestionItem(authoritativeItem, input.reference.question, identifier);
83617
+ return {
83618
+ ...input.reference,
83619
+ ownership: isQtiQuestionOwnedByContext(question, { gameSlug: input.gameSlug, testIdentifier: input.testIdentifier }, input.ownerTest) ? "owned" : "shared",
83620
+ question
83621
+ };
83622
+ }
83623
+ function isQtiTestOwnedByGame(test, gameSlug) {
83624
+ return test.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && test.metadata.ownerGameSlug === gameSlug;
83625
+ }
83626
+ function assertQtiTestOwnedByGame(test, gameSlug) {
83627
+ if (!isQtiTestOwnedByGame(test, gameSlug)) {
83628
+ throw new ValidationError("Shared assessment references are read-only. Copy the assessment to create an editable independent test.");
83629
+ }
83630
+ }
83631
+ function qtiTestParts(test) {
83632
+ const parts2 = test["qti-test-part"];
83633
+ if (!Array.isArray(parts2) || parts2.length === 0) {
83634
+ throw new ValidationError(`Assessment ${test.identifier} has no test parts`);
83635
+ }
83636
+ for (const [partIndex, part] of parts2.entries()) {
83637
+ if (!part || typeof part !== "object" || Array.isArray(part)) {
83638
+ throw new ValidationError(`Assessment ${test.identifier} contains an invalid test part at position ${partIndex + 1}`);
83639
+ }
83640
+ const sections = part["qti-assessment-section"];
83641
+ if (!Array.isArray(sections)) {
83642
+ throw new ValidationError(`Assessment ${test.identifier} test part ${partIndex + 1} has no section list`);
83643
+ }
83644
+ }
83645
+ return parts2;
83646
+ }
83647
+ function qtiTestOptionalAttributes(test) {
83648
+ return {
83649
+ ...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
83650
+ ...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
83651
+ ...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
83652
+ ...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
83653
+ };
83654
+ }
83655
+ function buildQtiAssessmentItemCopyPlan(test, targetTestIdentifier, hrefForIdentifier, createIdentifier = newPlaycademyQuestionIdentifier) {
83656
+ const sourceIdentifiers = [];
83657
+ const seen = new Set;
83658
+ for (const part of qtiTestParts(test)) {
83659
+ for (const section of part["qti-assessment-section"]) {
83660
+ for (const reference of section["qti-assessment-item-ref"] ?? []) {
83661
+ if (!seen.has(reference.identifier)) {
83662
+ seen.add(reference.identifier);
83663
+ sourceIdentifiers.push(reference.identifier);
83664
+ }
83665
+ }
83666
+ }
83667
+ }
83668
+ return sourceIdentifiers.map((sourceIdentifier) => {
83669
+ const targetIdentifier = createIdentifier(targetTestIdentifier);
83670
+ return {
83671
+ sourceIdentifier,
83672
+ targetIdentifier,
83673
+ href: hrefForIdentifier(targetIdentifier)
83674
+ };
83675
+ });
83676
+ }
83677
+ function buildQtiTestStructureInput(test, targetTestIdentifier, itemCopies) {
83678
+ const outcomeDeclarations = test["qti-outcome-declaration"];
83679
+ if (outcomeDeclarations !== undefined && !Array.isArray(outcomeDeclarations)) {
83680
+ throw new ValidationError(`Assessment ${test.identifier} has an invalid outcome declaration list`);
83681
+ }
83682
+ return {
83683
+ "qti-test-part": qtiTestParts(test).map((part, partIndex) => ({
83684
+ identifier: targetTestIdentifier ? `${targetTestIdentifier}-part${partIndex + 1}` : part.identifier,
83685
+ navigationMode: part.navigationMode,
83686
+ submissionMode: part.submissionMode,
83687
+ "qti-assessment-section": part["qti-assessment-section"].map((section, sectionIndex) => {
83688
+ let sectionIdentifier = section.identifier;
83689
+ if (targetTestIdentifier) {
83690
+ sectionIdentifier = partIndex === 0 && sectionIndex === 0 ? `${targetTestIdentifier}-section1` : `${targetTestIdentifier}-part${partIndex + 1}-section${sectionIndex + 1}`;
83691
+ }
83692
+ return {
83693
+ identifier: sectionIdentifier,
83694
+ title: section.title,
83695
+ visible: section.visible ?? true,
83696
+ ...section.required !== undefined ? { required: section.required } : {},
83697
+ ...section.fixed !== undefined ? { fixed: section.fixed } : {},
83698
+ sequence: section.sequence ?? sectionIndex + 1,
83699
+ ...section["qti-assessment-item-ref"] ? {
83700
+ "qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item, itemIndex) => {
83701
+ const copy = itemCopies?.get(item.identifier);
83702
+ if (itemCopies && !copy) {
83703
+ throw new ValidationError(`Assessment copy is missing question ${item.identifier}`);
83704
+ }
83705
+ return {
83706
+ identifier: copy?.targetIdentifier ?? item.identifier,
83707
+ href: copy?.href ?? item.href,
83708
+ sequence: item.sequence ?? itemIndex + 1
83709
+ };
83710
+ })
83711
+ } : {}
83712
+ };
83713
+ })
83714
+ })),
83715
+ ...outcomeDeclarations ? {
83716
+ "qti-outcome-declaration": outcomeDeclarations.map((declaration) => ({
83717
+ identifier: declaration.identifier,
83718
+ ...declaration.cardinality !== undefined ? { cardinality: declaration.cardinality } : {},
83719
+ baseType: declaration.baseType,
83720
+ ...declaration.normalMaximum !== undefined ? { normalMaximum: declaration.normalMaximum } : {},
83721
+ ...declaration.normalMinimum !== undefined ? { normalMinimum: declaration.normalMinimum } : {},
83722
+ ...declaration.defaultValue ? {
83723
+ defaultValue: declaration.defaultValue.value !== undefined ? { value: declaration.defaultValue.value } : {}
83724
+ } : {}
83725
+ }))
83726
+ } : {}
83727
+ };
83728
+ }
83729
+ function buildQtiTestUpdateInput(test, title) {
83730
+ return {
83731
+ title,
83732
+ ...qtiTestOptionalAttributes(test),
83733
+ ...test.metadata ? { metadata: test.metadata } : {},
83734
+ ...buildQtiTestStructureInput(test)
83735
+ };
83736
+ }
83737
+ function buildQtiTestCopyInput(source, targetTestIdentifier, metadata2, itemCopies) {
83738
+ const copiesBySourceIdentifier = new Map(itemCopies.map((copy) => [copy.sourceIdentifier, copy]));
83739
+ return {
83740
+ identifier: targetTestIdentifier,
83741
+ title: `${source.title} (copy)`,
83742
+ ...qtiTestOptionalAttributes(source),
83743
+ metadata: metadata2,
83744
+ ...buildQtiTestStructureInput(source, targetTestIdentifier, copiesBySourceIdentifier)
83745
+ };
83746
+ }
83747
+ function validateUniqueQuestionIdentifiers(itemIdentifiers) {
83748
+ if (itemIdentifiers.length === 0 || new Set(itemIdentifiers).size !== itemIdentifiers.length) {
83749
+ throw new ValidationError("Question order must contain unique question identifiers");
83750
+ }
83751
+ }
83752
+ function resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers) {
83753
+ let selectedPart;
83754
+ let selectedSection;
83755
+ for (const part of qtiTestParts(test)) {
83756
+ for (const section of part["qti-assessment-section"]) {
83757
+ if (!itemIdentifier || section["qti-assessment-item-ref"]?.some((item) => item.identifier === itemIdentifier)) {
83758
+ selectedPart = part;
83759
+ selectedSection = section;
83760
+ break;
83761
+ }
83762
+ }
83763
+ if (selectedSection) {
83764
+ break;
83765
+ }
83766
+ }
83767
+ if (!selectedPart || !selectedSection) {
83768
+ throw new ValidationError(itemIdentifier ? `Question ${itemIdentifier} is not referenced by assessment ${qtiTestIdentifier}` : `Assessment ${qtiTestIdentifier} has no section for questions`);
83769
+ }
83770
+ if (expectedItemIdentifiers) {
83771
+ const sectionIdentifiers = selectedSection["qti-assessment-item-ref"]?.map((item) => item.identifier) ?? [];
83772
+ const expectedIdentifiers = new Set(expectedItemIdentifiers);
83773
+ if (expectedIdentifiers.size !== sectionIdentifiers.length || sectionIdentifiers.some((identifier) => !expectedIdentifiers.has(identifier))) {
83774
+ throw new ValidationError("Questions can only be reordered within one complete assessment section");
83775
+ }
83776
+ }
83777
+ return {
83778
+ partIdentifier: selectedPart.identifier,
83779
+ sectionIdentifier: selectedSection.identifier
83780
+ };
83781
+ }
83782
+ var PLAYCADEMY_QTI_AUTHORING_METADATA_KEY = "playcademyAuthoring", QTI_AUTHORING_FIELDS, QUESTION_XML_BUILDERS;
83783
+ var init_timeback_qti_authoring_util = __esm(() => {
83784
+ init_timeback3();
83785
+ init_errors();
83786
+ QTI_AUTHORING_FIELDS = [
83787
+ "type",
83788
+ "qtiVersion",
83789
+ "timeDependent",
83790
+ "adaptive",
83791
+ "preInteraction",
83792
+ "interaction",
83793
+ "postInteraction",
83794
+ "responseDeclarations",
83795
+ "outcomeDeclarations",
83796
+ "responseProcessing",
83797
+ "modalFeedback",
83798
+ "feedbackInline",
83799
+ "feedbackBlock",
83800
+ "rubrics",
83801
+ "stimulus",
83802
+ "content"
83803
+ ];
83804
+ QUESTION_XML_BUILDERS = [
83805
+ buildNumericQuestionXml,
83806
+ buildHottextQuestionXml,
83807
+ buildInlineChoiceQuestionXml
83808
+ ];
83809
+ });
83810
+
83811
+ // ../api-core/src/utils/timeback-qti-library.util.ts
83812
+ function newPlaycademyTestIdentifier() {
83813
+ return `${PLAYCADEMY_QTI_SOURCE_PREFIX}${crypto.randomUUID()}`;
83814
+ }
83815
+ function playcademySourceWhere() {
83816
+ return {
83817
+ identifier: {
83818
+ gte: PLAYCADEMY_QTI_SOURCE_PREFIX,
83819
+ lt: PLAYCADEMY_QTI_SOURCE_UPPER_BOUND
83820
+ }
83821
+ };
83822
+ }
83823
+ function buildQtiLibraryListPlan(params) {
83824
+ const query = params.query?.trim() || undefined;
83825
+ if (!params.source && !query) {
83826
+ throw new Error("An exact QTI source identifier is required for a global lookup");
83827
+ }
83828
+ let where;
83829
+ if (params.source) {
83830
+ where = playcademySourceWhere();
83831
+ } else if (query) {
83832
+ where = { identifier: query };
83833
+ }
83834
+ return {
83835
+ kind: "direct",
83836
+ params: {
83837
+ ...where ? { where } : {},
83838
+ ...params.source && query ? { query } : {},
83839
+ page: params.page,
83840
+ limit: params.limit,
83841
+ ...params.source ? { sort: "identifier", order: "asc" } : {}
83842
+ }
83843
+ };
83844
+ }
83845
+ var PLAYCADEMY_QTI_SOURCE_PREFIX = "playcademy-test-", PLAYCADEMY_QTI_SOURCE_UPPER_BOUND = "playcademy-test.";
83846
+
82910
83847
  // ../api-core/src/services/timeback-assessments.service.ts
82911
83848
  class TimebackAssessmentsService {
83849
+ static QTI_HYDRATION_CONCURRENCY = 8;
82912
83850
  deps;
82913
83851
  constructor(deps) {
82914
83852
  this.deps = deps;
@@ -82925,33 +83863,19 @@ class TimebackAssessmentsService {
82925
83863
  }
82926
83864
  async listAssessments(integrationId) {
82927
83865
  const client = this.requireClient();
82928
- await this.requireIntegration(integrationId);
83866
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId);
82929
83867
  const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
82930
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId),
82931
- orderBy: asc(gameTimebackAssessmentTests.sortOrder)
83868
+ where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
82932
83869
  });
82933
- if (rows.length === 0) {
82934
- return [];
82935
- }
82936
- const assessments = await Promise.all(rows.map(async (row) => {
83870
+ const assessments = await runWithConcurrency(rows, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (row) => {
82937
83871
  try {
82938
83872
  const test = await client.qtiApi.assessmentTests.get(row.qtiTestIdentifier);
82939
- let itemCount = 0;
82940
- for (const part of test["qti-test-part"] ?? []) {
82941
- for (const section of part["qti-assessment-section"] ?? []) {
82942
- itemCount += section["qti-assessment-item-ref"]?.length ?? 0;
82943
- }
82944
- }
82945
83873
  return {
82946
- id: row.id,
82947
- integrationId: row.integrationId,
82948
- qtiTestIdentifier: row.qtiTestIdentifier,
82949
- bankResourceId: row.bankResourceId,
82950
- bankActive: row.bankActive,
82951
- sortOrder: row.sortOrder,
83874
+ ...this.associationSummary(row),
82952
83875
  title: test.title,
82953
- questionCount: itemCount,
82954
- isActive: row.bankActive
83876
+ questionCount: countQtiTestItems(test),
83877
+ available: true,
83878
+ editable: isQtiTestOwnedByGame(test, ownership.gameSlug)
82955
83879
  };
82956
83880
  } catch (error88) {
82957
83881
  addEvent("assessment.qti_fetch_failed", {
@@ -82960,319 +83884,420 @@ class TimebackAssessmentsService {
82960
83884
  "app.error.message": errorMessage(error88)
82961
83885
  });
82962
83886
  return {
82963
- id: row.id,
82964
- integrationId: row.integrationId,
82965
- qtiTestIdentifier: row.qtiTestIdentifier,
82966
- bankResourceId: row.bankResourceId,
82967
- bankActive: row.bankActive,
82968
- sortOrder: row.sortOrder,
83887
+ ...this.associationSummary(row),
82969
83888
  title: row.qtiTestIdentifier,
82970
83889
  questionCount: 0,
82971
- isActive: row.bankActive
83890
+ available: false,
83891
+ editable: false
82972
83892
  };
82973
83893
  }
82974
- }));
82975
- return assessments;
83894
+ });
83895
+ return assessments.toSorted((a, b) => a.title.localeCompare(b.title));
82976
83896
  }
82977
83897
  async createAssessment(integrationId, input) {
82978
83898
  const client = this.requireClient();
82979
- const integration = await this.requireIntegration(integrationId);
82980
- await client.course.ensureAssessmentBank({
82981
- courseId: integration.courseId,
82982
- subject: integration.subject,
82983
- grade: integration.grade
82984
- });
83899
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId);
83900
+ const { integration } = ownership;
82985
83901
  await client.course.createAssessmentTest({
82986
83902
  identifier: input.qtiTestIdentifier,
82987
83903
  title: input.title,
82988
83904
  metadata: {
83905
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
83906
+ ownerGameSlug: ownership.gameSlug,
82989
83907
  integrationId,
82990
83908
  subject: integration.subject,
82991
83909
  grade: String(integration.grade)
82992
83910
  }
82993
83911
  });
82994
- const maxSortOrder = await this.getMaxSortOrder(integrationId);
82995
- const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
82996
- integrationId,
82997
- qtiTestIdentifier: input.qtiTestIdentifier,
82998
- sortOrder: maxSortOrder + 1
82999
- }).returning();
83000
- setAttribute("app.assessment.operation", "create");
83001
- return row;
83002
- }
83003
- async deleteAssessment(integrationId, qtiTestIdentifier) {
83004
- const client = this.requireClient();
83005
- const row = await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83006
- if (row.bankActive) {
83007
- await this.deactivateAssessment(integrationId, qtiTestIdentifier);
83008
- }
83009
83912
  try {
83010
- await client.course.teardownAssessmentTest(qtiTestIdentifier);
83913
+ const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
83914
+ integrationId,
83915
+ qtiTestIdentifier: input.qtiTestIdentifier,
83916
+ purpose: input.purpose,
83917
+ status: "draft"
83918
+ }).returning();
83919
+ setAttribute("app.assessment.operation", "create");
83920
+ return row;
83011
83921
  } catch (error88) {
83012
- addEvent("assessment.qti_cleanup_partial", {
83013
- "app.assessment.qti_test_identifier": qtiTestIdentifier,
83014
- "exception.type": errorType(error88),
83015
- "app.error.message": errorMessage(error88)
83016
- });
83922
+ let committedRow;
83923
+ try {
83924
+ committedRow = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
83925
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, input.qtiTestIdentifier))
83926
+ });
83927
+ } catch (verificationError) {
83928
+ addEvent("assessment.qti_test_create_commit_verification_failed", {
83929
+ "app.assessment.qti_test_identifier": input.qtiTestIdentifier,
83930
+ "exception.type": errorType(verificationError),
83931
+ "app.error.message": errorMessage(verificationError)
83932
+ });
83933
+ throw error88;
83934
+ }
83935
+ if (committedRow) {
83936
+ setAttribute("app.assessment.operation", "create");
83937
+ return committedRow;
83938
+ }
83939
+ try {
83940
+ await client.qtiApi.assessmentTests.delete(input.qtiTestIdentifier);
83941
+ } catch (cleanupError) {
83942
+ if (!isApiError(cleanupError) || cleanupError.statusCode !== 404) {
83943
+ addEvent("assessment.qti_test_create_cleanup_failed", {
83944
+ "app.assessment.qti_test_identifier": input.qtiTestIdentifier,
83945
+ "exception.type": errorType(cleanupError),
83946
+ "app.error.message": errorMessage(cleanupError)
83947
+ });
83948
+ }
83949
+ }
83950
+ throw error88;
83017
83951
  }
83018
- await this.deps.db.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
83019
- setAttribute("app.assessment.operation", "delete");
83020
- }
83021
- async reorderAssessments(integrationId, identifiers) {
83022
- await this.requireIntegration(integrationId);
83023
- 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)))));
83024
- }
83025
- async listQuestions(integrationId, qtiTestIdentifier) {
83026
- const client = this.requireClient();
83027
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83028
- return await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
83029
83952
  }
83030
- async createQuestion(integrationId, qtiTestIdentifier, input) {
83031
- const client = this.requireClient();
83032
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83033
- const itemIdentifier = input.identifier;
83034
- if (typeof itemIdentifier !== "string" || !itemIdentifier) {
83035
- throw new ValidationError("Question identifier is required");
83036
- }
83037
- const item = await client.qtiApi.assessmentItems.create(input);
83038
- await this.qtiSectionItems(client, qtiTestIdentifier).add({
83039
- identifier: itemIdentifier,
83040
- href: `${client.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`
83953
+ async updateAssessment(integrationId, qtiTestIdentifier, input) {
83954
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
83955
+ const updates = buildAssessmentAssociationUpdates(row, input);
83956
+ if (input.status !== undefined) {
83957
+ validateAssessmentStatusTransition(row.status, input.status);
83958
+ if (isAssessmentPublicationTransition(row.status, input.status)) {
83959
+ await this.validateAssessmentHasQuestions(qtiTestIdentifier);
83960
+ }
83961
+ }
83962
+ if (input.title !== undefined) {
83963
+ assertDraftAssessment(row);
83964
+ assertAllAssessmentAssociationsDraft(associations);
83965
+ const client = this.requireClient();
83966
+ const test = await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
83967
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
83968
+ assertQtiTestOwnedByGame(test, ownership.gameSlug);
83969
+ await client.qtiApi.assessmentTests.update(qtiTestIdentifier, buildQtiTestUpdateInput(test, input.title));
83970
+ }
83971
+ let updated = row;
83972
+ if (Object.keys(updates).length > 0) {
83973
+ const [updatedRow] = await tx.update(gameTimebackAssessmentTests).set({ ...updates, updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id)).returning();
83974
+ updated = updatedRow ?? row;
83975
+ }
83976
+ setAttribute("app.assessment.operation", input.title !== undefined ? "update_test" : "update_association");
83977
+ return updated;
83041
83978
  });
83042
- return item;
83043
83979
  }
83044
- async updateQuestion(integrationId, qtiTestIdentifier, itemIdentifier, input) {
83045
- const client = this.requireClient();
83046
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83047
- return client.qtiApi.assessmentItems.update(itemIdentifier, input);
83048
- }
83049
- async deleteQuestion(integrationId, qtiTestIdentifier, itemIdentifier) {
83050
- const client = this.requireClient();
83051
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83052
- await this.qtiSectionItems(client, qtiTestIdentifier).remove(itemIdentifier);
83053
- await client.qtiApi.assessmentItems.delete(itemIdentifier);
83980
+ async removeAssessment(integrationId, qtiTestIdentifier) {
83981
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx) => {
83982
+ const plan = planAssessmentRemoval(row.status);
83983
+ if (plan.kind === "delete") {
83984
+ await tx.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
83985
+ } else if (plan.kind === "archive") {
83986
+ await tx.update(gameTimebackAssessmentTests).set({ status: "archived", sortOrder: null, updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id));
83987
+ }
83988
+ if (plan.kind !== "none") {
83989
+ setAttribute("app.assessment.operation", plan.operation);
83990
+ }
83991
+ return { action: plan.action };
83992
+ });
83054
83993
  }
83055
- async reorderQuestions(integrationId, qtiTestIdentifier, items) {
83056
- const client = this.requireClient();
83057
- await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83058
- const qtiBaseUrl = client.getQtiBaseUrl();
83059
- return this.qtiSectionItems(client, qtiTestIdentifier).reorder({
83060
- items: items.map((item) => ({
83061
- identifier: item.identifier,
83062
- href: item.href ?? `${qtiBaseUrl}/assessment-items/${item.identifier}`,
83063
- sequence: item.sequence
83064
- }))
83994
+ async reorderAssessments(integrationId, purpose, testIdentifiers) {
83995
+ await this.requireIntegration(integrationId);
83996
+ validateUniqueAssessmentIdentifiers(testIdentifiers);
83997
+ const updatedAt = new Date;
83998
+ await this.deps.db.transaction(async (tx) => {
83999
+ const liveRows = await tx.query.gameTimebackAssessmentTests.findMany({
84000
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))
84001
+ });
84002
+ const orderedRows = orderLiveAssessmentRows(liveRows, purpose, testIdentifiers);
84003
+ const positionByRowId = new Map(orderedRows.map((row, index2) => [row.id, index2 + 1]));
84004
+ for (const row of lockOrderAssessmentRows(orderedRows)) {
84005
+ const testIdentifier = row.qtiTestIdentifier;
84006
+ const [updatedRow] = await tx.update(gameTimebackAssessmentTests).set({ sortOrder: positionByRowId.get(row.id), updatedAt }).where(and(eq(gameTimebackAssessmentTests.id, row.id), eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, testIdentifier), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))).returning({ id: gameTimebackAssessmentTests.id });
84007
+ assertAssessmentOrderUpdateSucceeded(updatedRow);
84008
+ }
83065
84009
  });
84010
+ setAttribute("app.assessment.operation", "reorder_live_assessments");
83066
84011
  }
83067
- async activateAssessment(integrationId, qtiTestIdentifier) {
84012
+ async listQuestions(integrationId, qtiTestIdentifier) {
83068
84013
  const client = this.requireClient();
83069
- const integration = await this.requireIntegration(integrationId);
83070
- const row = await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83071
- if (row.bankActive) {
83072
- throw new ValidationError("Assessment is already active");
83073
- }
83074
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
83075
- const qtiTestUrl = `${client.getQtiBaseUrl()}/assessment-tests/${qtiTestIdentifier}`;
83076
- await client.course.ensureAssessmentBank({
83077
- courseId: integration.courseId,
83078
- subject: integration.subject,
83079
- grade: integration.grade
83080
- });
83081
- const parentResource = await client.api.oneroster.resources.get(bankIds.resource);
83082
- const currentResources = parentResource.metadata?.resources ?? [];
83083
- let childResourceId = row.bankResourceId;
83084
- if (childResourceId) {
84014
+ await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
84015
+ const { gameSlug } = await this.requireQtiTestOwnershipContext(integrationId);
84016
+ const result = await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
84017
+ const ownerTests = new Map;
84018
+ const questions = await runWithConcurrency(result.questions, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (reference) => {
84019
+ let item;
83085
84020
  try {
83086
- await client.api.oneroster.resources.update(childResourceId, {
83087
- status: "active",
83088
- title: `Assessment Bank Test: ${qtiTestIdentifier}`,
83089
- vendorResourceId: "",
83090
- metadata: {
83091
- type: "qti",
83092
- subType: "qti-test",
83093
- url: qtiTestUrl
83094
- }
83095
- });
83096
- if (!currentResources.includes(childResourceId)) {
83097
- await client.api.oneroster.resources.update(bankIds.resource, {
83098
- status: "active",
83099
- title: parentResource.title,
83100
- vendorResourceId: parentResource.vendorResourceId,
83101
- vendorId: parentResource.vendorId,
83102
- metadata: {
83103
- ...parentResource.metadata,
83104
- resources: [...currentResources, childResourceId]
83105
- }
83106
- });
83107
- }
84021
+ item = await client.qtiApi.assessmentItems.get(reference.reference.identifier);
83108
84022
  } catch (error88) {
83109
- addEvent("assessment.child_resource_reactivation_failed", {
83110
- "app.assessment.child_resource_id": childResourceId ?? "",
84023
+ addEvent("assessment.qti_question_hydration_failed", {
84024
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
84025
+ "app.assessment.qti_item_identifier": reference.reference.identifier,
83111
84026
  "exception.type": errorType(error88),
83112
84027
  "app.error.message": errorMessage(error88)
83113
84028
  });
83114
- childResourceId = null;
83115
- }
83116
- }
83117
- if (!childResourceId) {
83118
- for (const resourceId of currentResources) {
83119
- try {
83120
- const resource = await client.api.oneroster.resources.get(resourceId);
83121
- const resourceUrl = resource.metadata?.url;
83122
- if (resourceUrl === qtiTestUrl) {
83123
- childResourceId = resourceId;
83124
- addEvent("assessment.child_resource_reused", {
83125
- "app.assessment.qti_test_identifier": qtiTestIdentifier,
83126
- "app.assessment.child_resource_id": resourceId
83127
- });
83128
- break;
83129
- }
83130
- } catch {}
84029
+ return reference;
83131
84030
  }
83132
- if (!childResourceId) {
83133
- const childResult = await client.api.oneroster.resources.create({
83134
- status: "active",
83135
- title: `Assessment Bank Test: ${qtiTestIdentifier}`,
83136
- vendorResourceId: "",
83137
- vendorId: undefined,
83138
- metadata: {
83139
- type: "qti",
83140
- subType: "qti-test",
83141
- url: qtiTestUrl
83142
- }
83143
- });
83144
- childResourceId = childResult.sourcedIdPairs.allocatedSourcedId;
83145
- await client.api.oneroster.resources.update(bankIds.resource, {
83146
- status: "active",
83147
- title: parentResource.title,
83148
- vendorResourceId: parentResource.vendorResourceId,
83149
- vendorId: parentResource.vendorId,
83150
- metadata: {
83151
- ...parentResource.metadata,
83152
- resources: [...currentResources, childResourceId]
83153
- }
84031
+ let ownerTest;
84032
+ try {
84033
+ ownerTest = await this.qtiQuestionOwnerTest(client, item, ownerTests);
84034
+ } catch (error88) {
84035
+ addEvent("assessment.qti_question_owner_test_fetch_failed", {
84036
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
84037
+ "app.assessment.qti_item_identifier": reference.reference.identifier,
84038
+ "exception.type": errorType(error88),
84039
+ "app.error.message": errorMessage(error88)
83154
84040
  });
83155
84041
  }
83156
- }
83157
- await this.deps.db.update(gameTimebackAssessmentTests).set({ bankResourceId: childResourceId, bankActive: true }).where(eq(gameTimebackAssessmentTests.id, row.id));
83158
- setAttributes({
83159
- "app.assessment.operation": "activate",
83160
- "app.assessment.child_resource_id": childResourceId ?? ""
84042
+ return buildHydratedQtiQuestionReference({
84043
+ reference,
84044
+ item,
84045
+ gameSlug,
84046
+ ownerTest,
84047
+ testIdentifier: qtiTestIdentifier
84048
+ });
83161
84049
  });
84050
+ return { ...result, questions };
83162
84051
  }
83163
- async deactivateAssessment(integrationId, qtiTestIdentifier) {
84052
+ async listQuestionLibrary(integrationId, params) {
83164
84053
  const client = this.requireClient();
83165
- const integration = await this.requireIntegration(integrationId);
83166
- const row = await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
83167
- if (!row.bankActive) {
83168
- throw new ValidationError("Assessment is already in draft");
83169
- }
83170
- if (!row.bankResourceId) {
83171
- throw new ValidationError("Assessment has no bank resource — activate it first");
83172
- }
83173
- const childResourceId = row.bankResourceId;
83174
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
83175
- try {
83176
- const parentResource = await client.api.oneroster.resources.get(bankIds.resource);
83177
- const currentResources = parentResource.metadata?.resources ?? [];
83178
- const filtered = currentResources.filter((id) => id !== childResourceId);
83179
- if (filtered.length !== currentResources.length) {
83180
- await client.api.oneroster.resources.update(bankIds.resource, {
83181
- status: "active",
83182
- title: parentResource.title,
83183
- vendorResourceId: parentResource.vendorResourceId,
83184
- vendorId: parentResource.vendorId,
83185
- metadata: {
83186
- ...parentResource.metadata,
83187
- resources: filtered
83188
- }
83189
- });
83190
- }
83191
- } catch (error88) {
83192
- addEvent("assessment.parent_resource_update_failed", {
83193
- "app.assessment.bank_resource_id": bankIds.resource,
83194
- "exception.type": errorType(error88),
83195
- "app.error.message": errorMessage(error88)
83196
- });
83197
- }
83198
- try {
83199
- await client.api.oneroster.resources.delete(childResourceId);
83200
- } catch (error88) {
83201
- addEvent("assessment.child_resource_delete_failed", {
83202
- "app.assessment.child_resource_id": childResourceId,
83203
- "exception.type": errorType(error88),
83204
- "app.error.message": errorMessage(error88)
83205
- });
83206
- }
83207
- await this.deps.db.update(gameTimebackAssessmentTests).set({ bankActive: false }).where(eq(gameTimebackAssessmentTests.id, row.id));
83208
- setAttribute("app.assessment.operation", "deactivate");
83209
- }
83210
- isAssessmentActive(row) {
83211
- return row.bankActive;
84054
+ await this.requireIntegration(integrationId);
84055
+ return this.listQtiLibrary(params, async (listParams) => await client.qtiApi.assessmentItems.list(listParams));
83212
84056
  }
83213
- async getBankStatus(integrationId) {
83214
- const integration = await this.requireIntegration(integrationId);
83215
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
83216
- const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
83217
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
83218
- });
83219
- const activeCount = rows.filter((r) => r.bankActive).length;
83220
- return {
83221
- bankIds,
83222
- totalAssessments: rows.length,
83223
- activeAssessments: activeCount,
83224
- draftAssessments: rows.length - activeCount
83225
- };
84057
+ async listTestLibrary(integrationId, params) {
84058
+ const client = this.requireClient();
84059
+ await this.requireIntegration(integrationId);
84060
+ return this.listQtiLibrary(params, async (listParams) => await client.qtiApi.assessmentTests.list(listParams));
83226
84061
  }
83227
- async destroyBank(integrationId) {
84062
+ async copyAssessment(integrationId, sourceTestIdentifier, targetTestIdentifier, purpose) {
83228
84063
  const client = this.requireClient();
83229
- const integration = await this.requireIntegration(integrationId);
83230
- const bankIds = deriveAssessmentBankIds2(integration.courseId);
83231
- const activeRows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
83232
- where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId))
84064
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId);
84065
+ const { integration } = ownership;
84066
+ const source = await client.qtiApi.assessmentTests.get(sourceTestIdentifier);
84067
+ const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => this.qtiItemHref(client, identifier));
84068
+ const itemCopies = await runWithConcurrency(itemPlan, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (plan) => {
84069
+ const sourceItem = await client.qtiApi.assessmentItems.get(plan.sourceIdentifier);
84070
+ return {
84071
+ plan,
84072
+ input: buildQtiQuestionCopyInput({
84073
+ source: sourceItem,
84074
+ targetIdentifier: plan.targetIdentifier,
84075
+ targetTestIdentifier,
84076
+ gameSlug: ownership.gameSlug,
84077
+ integrationId
84078
+ })
84079
+ };
83233
84080
  });
84081
+ const testInput = buildQtiTestCopyInput(source, targetTestIdentifier, {
84082
+ ...source.metadata,
84083
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
84084
+ ownerGameSlug: ownership.gameSlug,
84085
+ integrationId,
84086
+ subject: integration.subject,
84087
+ grade: String(integration.grade),
84088
+ copiedFromTestIdentifier: sourceTestIdentifier
84089
+ }, itemPlan);
84090
+ const attemptedItemIdentifiers = [];
84091
+ let testCreationAttempted = false;
84092
+ let associationCreationAttempted = false;
83234
84093
  try {
83235
- await client.api.oneroster.courses.deleteComponentResource(bankIds.componentResource);
84094
+ for (const copy of itemCopies) {
84095
+ attemptedItemIdentifiers.push(copy.plan.targetIdentifier);
84096
+ await client.course.createAssessmentItemXml({
84097
+ xml: copy.input.xml,
84098
+ metadata: copy.input.metadata
84099
+ });
84100
+ }
84101
+ testCreationAttempted = true;
84102
+ await client.qtiApi.assessmentTests.create(testInput);
84103
+ associationCreationAttempted = true;
84104
+ const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
84105
+ integrationId,
84106
+ qtiTestIdentifier: targetTestIdentifier,
84107
+ purpose,
84108
+ status: "draft"
84109
+ }).returning();
84110
+ setAttribute("app.assessment.operation", "copy_test");
84111
+ return row;
83236
84112
  } catch (error88) {
83237
- addEvent("assessment.component_resource_delete_failed", {
83238
- "app.assessment.component_resource_id": bankIds.componentResource,
83239
- "exception.type": errorType(error88),
83240
- "app.error.message": errorMessage(error88)
83241
- });
83242
- }
83243
- for (const row of activeRows) {
83244
- if (row.bankResourceId) {
84113
+ if (associationCreationAttempted) {
84114
+ let committedRow;
83245
84115
  try {
83246
- await client.api.oneroster.resources.delete(row.bankResourceId);
83247
- } catch (error88) {
83248
- addEvent("assessment.child_resource_delete_failed", {
83249
- "app.assessment.child_resource_id": row.bankResourceId,
83250
- "exception.type": errorType(error88),
83251
- "app.error.message": errorMessage(error88)
84116
+ committedRow = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
84117
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, targetTestIdentifier))
83252
84118
  });
84119
+ } catch (verificationError) {
84120
+ addEvent("assessment.qti_test_copy_commit_verification_failed", {
84121
+ "app.assessment.qti_test_identifier": targetTestIdentifier,
84122
+ "exception.type": errorType(verificationError),
84123
+ "app.error.message": errorMessage(verificationError)
84124
+ });
84125
+ throw error88;
84126
+ }
84127
+ if (committedRow) {
84128
+ setAttribute("app.assessment.operation", "copy_test");
84129
+ return committedRow;
83253
84130
  }
83254
84131
  }
84132
+ await this.cleanupQtiAssessmentCopy(client, testCreationAttempted ? targetTestIdentifier : undefined, attemptedItemIdentifiers);
84133
+ throw error88;
83255
84134
  }
83256
- try {
83257
- await client.api.oneroster.resources.delete(bankIds.resource);
83258
- } catch (error88) {
83259
- addEvent("assessment.parent_resource_delete_failed", {
83260
- "app.assessment.bank_resource_id": bankIds.resource,
83261
- "exception.type": errorType(error88),
83262
- "app.error.message": errorMessage(error88)
84135
+ }
84136
+ async createQuestion(integrationId, qtiTestIdentifier, input) {
84137
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
84138
+ const client = this.requireClient();
84139
+ assertAllAssessmentAssociationsDraft(associations);
84140
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
84141
+ const creation = parseQtiQuestionCreationInput(input);
84142
+ const itemIdentifier = newPlaycademyQuestionIdentifier(qtiTestIdentifier);
84143
+ let title;
84144
+ let metadata2;
84145
+ let xml;
84146
+ let structuredInput;
84147
+ let fallbackItem;
84148
+ if (creation.kind === "copy") {
84149
+ const source = await client.qtiApi.assessmentItems.get(creation.sourceItemIdentifier);
84150
+ const copy = buildQtiQuestionCopyInput({
84151
+ source,
84152
+ targetIdentifier: itemIdentifier,
84153
+ targetTestIdentifier: qtiTestIdentifier,
84154
+ gameSlug: ownership.gameSlug,
84155
+ integrationId
84156
+ });
84157
+ title = copy.title;
84158
+ metadata2 = copy.metadata;
84159
+ xml = copy.xml;
84160
+ fallbackItem = {
84161
+ ...source,
84162
+ identifier: itemIdentifier,
84163
+ title,
84164
+ rawXml: xml,
84165
+ metadata: metadata2
84166
+ };
84167
+ } else {
84168
+ structuredInput = creation.input;
84169
+ assertSupportedQtiQuestionInteraction(structuredInput);
84170
+ title = typeof structuredInput.title === "string" ? structuredInput.title.trim() : "";
84171
+ metadata2 = buildOwnedQtiQuestionMetadata(structuredInput, {
84172
+ gameSlug: ownership.gameSlug,
84173
+ testIdentifier: qtiTestIdentifier
84174
+ });
84175
+ xml = buildQuestionXml(structuredInput, itemIdentifier, title);
84176
+ fallbackItem = {
84177
+ ...structuredInput,
84178
+ identifier: itemIdentifier,
84179
+ title,
84180
+ ...xml ? { rawXml: xml } : {}
84181
+ };
84182
+ }
84183
+ const section = await this.qtiSectionItems(client, qtiTestIdentifier, undefined, undefined, ownership.test);
84184
+ const href = this.qtiItemHref(client, itemIdentifier);
84185
+ let item;
84186
+ let itemCreationAttempted = false;
84187
+ let referenceCreationAttempted = false;
84188
+ try {
84189
+ itemCreationAttempted = true;
84190
+ item = xml ? await client.course.createAssessmentItemXml({ xml, metadata: metadata2 }) : await client.qtiApi.assessmentItems.create({
84191
+ ...structuredInput,
84192
+ identifier: itemIdentifier,
84193
+ metadata: metadata2
84194
+ });
84195
+ referenceCreationAttempted = true;
84196
+ await section.items.add({
84197
+ identifier: itemIdentifier,
84198
+ href
84199
+ });
84200
+ } catch (error88) {
84201
+ let referenceRemoved = !referenceCreationAttempted;
84202
+ const creationStatus = isApiError(error88) ? error88.statusCode : undefined;
84203
+ const itemCreationDefinitelyRejected = !referenceCreationAttempted && typeof creationStatus === "number" && creationStatus >= 400 && creationStatus < 500 && creationStatus !== 408;
84204
+ if (referenceCreationAttempted) {
84205
+ try {
84206
+ await section.items.remove(itemIdentifier);
84207
+ referenceRemoved = true;
84208
+ } catch (cleanupError) {
84209
+ referenceRemoved = isApiError(cleanupError) && cleanupError.statusCode === 404;
84210
+ addEvent("assessment.qti_question_reference_cleanup_failed", {
84211
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
84212
+ "app.assessment.qti_item_identifier": itemIdentifier,
84213
+ "exception.type": errorType(cleanupError),
84214
+ "app.error.message": errorMessage(cleanupError)
84215
+ });
84216
+ }
84217
+ }
84218
+ if (itemCreationAttempted && !itemCreationDefinitelyRejected && referenceRemoved) {
84219
+ try {
84220
+ await client.qtiApi.assessmentItems.delete(itemIdentifier);
84221
+ } catch (cleanupError) {
84222
+ addEvent("assessment.qti_question_cleanup_failed", {
84223
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
84224
+ "app.assessment.qti_item_identifier": itemIdentifier,
84225
+ "exception.type": errorType(cleanupError),
84226
+ "app.error.message": errorMessage(cleanupError)
84227
+ });
84228
+ }
84229
+ }
84230
+ throw error88;
84231
+ }
84232
+ setAttribute("app.assessment.operation", creation.kind === "copy" ? "copy_question" : "create_question");
84233
+ return buildCreatedQtiQuestionReference({
84234
+ item,
84235
+ fallbackItem,
84236
+ itemIdentifier,
84237
+ href,
84238
+ partIdentifier: section.partIdentifier,
84239
+ sectionIdentifier: section.sectionIdentifier,
84240
+ metadata: metadata2
83263
84241
  });
83264
- }
83265
- try {
83266
- await client.api.oneroster.courses.deleteComponent(bankIds.component);
83267
- } catch (error88) {
83268
- addEvent("assessment.course_component_delete_failed", {
83269
- "app.assessment.component_id": bankIds.component,
83270
- "exception.type": errorType(error88),
83271
- "app.error.message": errorMessage(error88)
84242
+ });
84243
+ }
84244
+ async updateQuestion(integrationId, qtiTestIdentifier, itemIdentifier, input) {
84245
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
84246
+ const client = this.requireClient();
84247
+ assertAllAssessmentAssociationsDraft(associations);
84248
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
84249
+ const [, item] = await Promise.all([
84250
+ this.qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, undefined, ownership.test),
84251
+ client.qtiApi.assessmentItems.get(itemIdentifier)
84252
+ ]);
84253
+ const ownerTest = await this.qtiQuestionOwnerTest(client, item, undefined, ownership.test);
84254
+ const replacesInteraction = "type" in input || "interaction" in input || "numericTextEntry" in input;
84255
+ assertQtiQuestionEditable(item, { gameSlug: ownership.gameSlug, testIdentifier: qtiTestIdentifier }, ownerTest);
84256
+ assertSupportedQtiQuestionInteraction(replacesInteraction ? input : item);
84257
+ let title = typeof input.title === "string" ? input.title.trim() : "";
84258
+ if (!title) {
84259
+ title = item.title;
84260
+ }
84261
+ const metadata2 = buildOwnedQtiQuestionMetadata(input, { gameSlug: ownership.gameSlug, testIdentifier: qtiTestIdentifier }, item.metadata);
84262
+ const xml = buildQuestionXml(input, itemIdentifier, title);
84263
+ const updated = xml ? await client.course.updateAssessmentItemXml(itemIdentifier, { xml, metadata: metadata2 }) : await client.qtiApi.assessmentItems.update(itemIdentifier, {
84264
+ ...input,
84265
+ title,
84266
+ metadata: metadata2
84267
+ });
84268
+ setAttribute("app.assessment.operation", "update_question");
84269
+ return updated;
84270
+ });
84271
+ }
84272
+ async removeQuestion(integrationId, qtiTestIdentifier, itemIdentifier) {
84273
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
84274
+ const client = this.requireClient();
84275
+ assertAllAssessmentAssociationsDraft(associations);
84276
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
84277
+ const section = await this.qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, undefined, ownership.test);
84278
+ await section.items.remove(itemIdentifier);
84279
+ setAttribute("app.assessment.operation", "remove_question");
84280
+ });
84281
+ }
84282
+ async reorderQuestions(integrationId, qtiTestIdentifier, items) {
84283
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
84284
+ const client = this.requireClient();
84285
+ assertAllAssessmentAssociationsDraft(associations);
84286
+ const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
84287
+ const identifiers = items.map((item) => item.identifier);
84288
+ validateUniqueQuestionIdentifiers(identifiers);
84289
+ const firstIdentifier = identifiers[0];
84290
+ const section = await this.qtiSectionItems(client, qtiTestIdentifier, firstIdentifier, identifiers, ownership.test);
84291
+ const result = await section.items.reorder({
84292
+ items: items.map((item) => ({
84293
+ identifier: item.identifier,
84294
+ href: item.href ?? this.qtiItemHref(client, item.identifier),
84295
+ sequence: item.sequence
84296
+ }))
83272
84297
  });
83273
- }
83274
- await this.deps.db.update(gameTimebackAssessmentTests).set({ bankResourceId: null, bankActive: false }).where(eq(gameTimebackAssessmentTests.integrationId, integrationId));
83275
- setAttribute("app.assessment.operation", "destroy_bank");
84298
+ setAttribute("app.assessment.operation", "reorder_questions");
84299
+ return result;
84300
+ });
83276
84301
  }
83277
84302
  requireClient() {
83278
84303
  if (!this.deps.timeback) {
@@ -83280,8 +84305,18 @@ class TimebackAssessmentsService {
83280
84305
  }
83281
84306
  return this.deps.timeback;
83282
84307
  }
83283
- async requireIntegration(integrationId) {
83284
- const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
84308
+ async withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, action) {
84309
+ return this.deps.db.transaction(async (tx) => {
84310
+ const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier)).orderBy(gameTimebackAssessmentTests.id).for("update");
84311
+ const row = rows.find((candidate) => candidate.integrationId === integrationId);
84312
+ if (!row) {
84313
+ throw new NotFoundError(`Assessment not found: ${qtiTestIdentifier}`);
84314
+ }
84315
+ return action(row, tx, rows);
84316
+ });
84317
+ }
84318
+ async requireIntegration(integrationId, db2 = this.deps.db) {
84319
+ const integration = await db2.query.gameTimebackIntegrations.findFirst({
83285
84320
  where: and(eq(gameTimebackIntegrations.id, integrationId), isActiveGameTimebackIntegrationStatus())
83286
84321
  });
83287
84322
  if (!integration) {
@@ -83289,6 +84324,17 @@ class TimebackAssessmentsService {
83289
84324
  }
83290
84325
  return integration;
83291
84326
  }
84327
+ async requireQtiTestOwnershipContext(integrationId, db2 = this.deps.db) {
84328
+ const integration = await this.requireIntegration(integrationId, db2);
84329
+ const game2 = await db2.query.games.findFirst({
84330
+ where: eq(games.id, integration.gameId),
84331
+ columns: { slug: true }
84332
+ });
84333
+ if (!game2) {
84334
+ throw new NotFoundError(`Game not found for integration: ${integrationId}`);
84335
+ }
84336
+ return { integration, gameSlug: game2.slug };
84337
+ }
83292
84338
  async requireAssessmentRow(integrationId, qtiTestIdentifier) {
83293
84339
  const row = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
83294
84340
  where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier))
@@ -83298,27 +84344,125 @@ class TimebackAssessmentsService {
83298
84344
  }
83299
84345
  return row;
83300
84346
  }
83301
- qtiSectionItems(client, qtiTestIdentifier) {
83302
- return client.qtiApi.assessmentTests.testParts(qtiTestIdentifier).sections(`${qtiTestIdentifier}-part1`).items(`${qtiTestIdentifier}-section1`);
84347
+ async requireDraftAssessmentRow(integrationId, qtiTestIdentifier, lockedRow) {
84348
+ const row = lockedRow ?? await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
84349
+ assertDraftAssessment(row);
84350
+ return row;
83303
84351
  }
83304
- async getMaxSortOrder(integrationId) {
83305
- const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
83306
- where: eq(gameTimebackAssessmentTests.integrationId, integrationId),
83307
- columns: { sortOrder: true }
84352
+ async requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, lockedRow, db2 = this.deps.db) {
84353
+ const client = this.requireClient();
84354
+ await this.requireDraftAssessmentRow(integrationId, qtiTestIdentifier, lockedRow);
84355
+ const test = await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
84356
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId, db2);
84357
+ assertQtiTestOwnedByGame(test, ownership.gameSlug);
84358
+ return { ...ownership, test };
84359
+ }
84360
+ async qtiQuestionOwnerTest(client, item, cache, knownTest) {
84361
+ if (item.metadata && "ownerGameSlug" in item.metadata) {
84362
+ return;
84363
+ }
84364
+ const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
84365
+ if (!ownerTestIdentifier) {
84366
+ return;
84367
+ }
84368
+ if (knownTest?.identifier === ownerTestIdentifier) {
84369
+ return knownTest;
84370
+ }
84371
+ function loadOwnerTest(identifier) {
84372
+ return client.qtiApi.assessmentTests.get(identifier);
84373
+ }
84374
+ if (!cache) {
84375
+ return loadOwnerTest(ownerTestIdentifier);
84376
+ }
84377
+ let ownerTest = cache.get(ownerTestIdentifier);
84378
+ if (!ownerTest) {
84379
+ ownerTest = loadOwnerTest(ownerTestIdentifier);
84380
+ cache.set(ownerTestIdentifier, ownerTest);
84381
+ }
84382
+ return ownerTest;
84383
+ }
84384
+ async assertQtiTestQuestionsSupported(client, qtiTestIdentifier, questions) {
84385
+ const result = questions ?? await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
84386
+ await runWithConcurrency(result.questions, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (reference) => {
84387
+ const item = await client.qtiApi.assessmentItems.get(reference.reference.identifier);
84388
+ assertSupportedQtiQuestionInteraction(item);
83308
84389
  });
83309
- if (rows.length === 0) {
83310
- return -1;
84390
+ }
84391
+ async qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers, knownTest) {
84392
+ const test = knownTest ?? await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
84393
+ const section = resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers);
84394
+ return {
84395
+ ...section,
84396
+ items: client.qtiApi.assessmentTests.testParts(qtiTestIdentifier).sections(section.partIdentifier).items(section.sectionIdentifier)
84397
+ };
84398
+ }
84399
+ qtiItemHref(client, itemIdentifier) {
84400
+ return `${client.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
84401
+ }
84402
+ async cleanupQtiAssessmentCopy(client, testIdentifier, itemIdentifiers) {
84403
+ let testDeleted = !testIdentifier;
84404
+ if (testIdentifier) {
84405
+ try {
84406
+ await client.qtiApi.assessmentTests.delete(testIdentifier);
84407
+ testDeleted = true;
84408
+ } catch (error88) {
84409
+ testDeleted = isApiError(error88) && error88.statusCode === 404;
84410
+ addEvent("assessment.qti_test_copy_cleanup_failed", {
84411
+ "app.assessment.qti_test_identifier": testIdentifier,
84412
+ "exception.type": errorType(error88),
84413
+ "app.error.message": errorMessage(error88)
84414
+ });
84415
+ }
84416
+ }
84417
+ if (!testDeleted) {
84418
+ return;
83311
84419
  }
83312
- return Math.max(...rows.map((r) => r.sortOrder));
84420
+ for (const itemIdentifier of itemIdentifiers.toReversed()) {
84421
+ try {
84422
+ await client.qtiApi.assessmentItems.delete(itemIdentifier);
84423
+ } catch (error88) {
84424
+ addEvent("assessment.qti_question_cleanup_failed", {
84425
+ ...testIdentifier ? { "app.assessment.qti_test_identifier": testIdentifier } : {},
84426
+ "app.assessment.qti_item_identifier": itemIdentifier,
84427
+ "exception.type": errorType(error88),
84428
+ "app.error.message": errorMessage(error88)
84429
+ });
84430
+ }
84431
+ }
84432
+ }
84433
+ associationSummary(row) {
84434
+ return {
84435
+ id: row.id,
84436
+ integrationId: row.integrationId,
84437
+ qtiTestIdentifier: row.qtiTestIdentifier,
84438
+ purpose: row.purpose,
84439
+ status: row.status,
84440
+ sortOrder: row.sortOrder,
84441
+ createdAt: row.createdAt,
84442
+ updatedAt: row.updatedAt
84443
+ };
84444
+ }
84445
+ async listQtiLibrary(params, list) {
84446
+ const plan = buildQtiLibraryListPlan(params);
84447
+ return list(plan.params);
84448
+ }
84449
+ async validateAssessmentHasQuestions(qtiTestIdentifier) {
84450
+ const client = this.requireClient();
84451
+ const result = await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
84452
+ assertAssessmentHasQuestions(result.questions);
84453
+ await this.assertQtiTestQuestionsSupported(client, qtiTestIdentifier, result);
83313
84454
  }
83314
84455
  }
83315
- var init_timeback_assessments_service = __esm(() => {
84456
+ var init_timeback_assessments_service = __esm(async () => {
83316
84457
  init_drizzle_orm();
83317
84458
  init_helpers_index();
83318
84459
  init_tables_index();
83319
84460
  init_spans();
83320
- init_utils6();
84461
+ init_timeback3();
83321
84462
  init_errors();
84463
+ init_timeback_assessment_rules_util();
84464
+ init_timeback_qti_authoring_util();
84465
+ await init_errors8();
83322
84466
  });
83323
84467
 
83324
84468
  // ../api-core/src/utils/timeback-create-integration.util.ts
@@ -85411,10 +86555,10 @@ var init_platform2 = __esm(async () => {
85411
86555
  init_kv_service();
85412
86556
  init_secrets_service();
85413
86557
  init_seed_service();
85414
- init_timeback_assessments_service();
85415
86558
  init_upload_service();
85416
86559
  await __promiseAll([
85417
86560
  init_timeback_admin_service(),
86561
+ init_timeback_assessments_service(),
85418
86562
  init_timeback_service()
85419
86563
  ]);
85420
86564
  });
@@ -142033,16 +143177,6 @@ function requireAnonymous(handler) {
142033
143177
  return handler(ctx);
142034
143178
  };
142035
143179
  }
142036
- function requireAdmin(handler) {
142037
- return async (ctx) => {
142038
- assertAuthenticatedRequest(ctx);
142039
- rejectDashboardWorkerKey(ctx);
142040
- if (ctx.user.role !== "admin") {
142041
- throw ApiError.forbidden("Admin access required");
142042
- }
142043
- return handler(ctx);
142044
- };
142045
- }
142046
143180
  function requireDeveloper(handler) {
142047
143181
  return async (ctx) => {
142048
143182
  assertAuthenticatedRequest(ctx);
@@ -143206,7 +144340,27 @@ var init_session_controller = __esm(() => {
143206
144340
  });
143207
144341
 
143208
144342
  // ../api-core/src/controllers/timeback.controller.ts
143209
- var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
144343
+ function parseQtiLibraryParams(searchParams) {
144344
+ const requestedPage = Number(searchParams.get("page"));
144345
+ const requestedLimit = Number(searchParams.get("limit"));
144346
+ const page = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
144347
+ const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 50) : 20;
144348
+ const query = searchParams.get("q")?.trim() || undefined;
144349
+ const requestedSource = searchParams.get("source");
144350
+ if (requestedSource && requestedSource !== "playcademy") {
144351
+ throw ApiError.badRequest("Unsupported QTI library source");
144352
+ }
144353
+ if (requestedSource !== "playcademy" && !query) {
144354
+ throw ApiError.badRequest("An exact QTI source identifier is required for a global lookup");
144355
+ }
144356
+ return {
144357
+ query,
144358
+ source: requestedSource === "playcademy" ? "playcademy" : undefined,
144359
+ page,
144360
+ limit
144361
+ };
144362
+ }
144363
+ var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, updateAssessment, reorderAssessments, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
143210
144364
  var init_timeback_controller = __esm(() => {
143211
144365
  init_esm();
143212
144366
  init_schemas_index();
@@ -143721,7 +144875,7 @@ var init_timeback_controller = __esm(() => {
143721
144875
  const body2 = await parseRequestBody(ctx.request, ReactivateEnrollmentRequestSchema);
143722
144876
  return ctx.services.timebackAdmin.reactivateEnrollment(body2, ctx.user);
143723
144877
  });
143724
- listAssessments = requireGameManagementAccess(async (ctx) => {
144878
+ listAssessments = requireDeveloper(async (ctx) => {
143725
144879
  const { gameId, courseId } = ctx.params;
143726
144880
  if (!gameId || !courseId) {
143727
144881
  throw ApiError.badRequest("Missing gameId or courseId parameter");
@@ -143729,40 +144883,40 @@ var init_timeback_controller = __esm(() => {
143729
144883
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143730
144884
  return ctx.services.timebackAssessments.listAssessments(integrationId);
143731
144885
  });
143732
- createAssessment = requireGameManagementAccess(async (ctx) => {
144886
+ createAssessment = requireDeveloper(async (ctx) => {
143733
144887
  const { gameId, courseId } = ctx.params;
143734
144888
  if (!gameId || !courseId) {
143735
144889
  throw ApiError.badRequest("Missing gameId or courseId parameter");
143736
144890
  }
143737
144891
  const body2 = await parseRequestBody(ctx.request, CreateAssessmentRequestSchema);
143738
144892
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143739
- const shortId = crypto.randomUUID().slice(0, 8);
143740
- const qtiTestIdentifier = `assessment-${shortId}`;
144893
+ const qtiTestIdentifier = newPlaycademyTestIdentifier();
143741
144894
  return ctx.services.timebackAssessments.createAssessment(integrationId, {
143742
144895
  title: body2.title,
144896
+ purpose: body2.purpose,
143743
144897
  qtiTestIdentifier
143744
144898
  });
143745
144899
  });
143746
- deleteAssessment = requireAdmin(async (ctx) => {
144900
+ updateAssessment = requireDeveloper(async (ctx) => {
143747
144901
  const { gameId, courseId, testIdentifier } = ctx.params;
143748
144902
  if (!gameId || !courseId || !testIdentifier) {
143749
144903
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
143750
144904
  }
143751
144905
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143752
- await ctx.services.timebackAssessments.deleteAssessment(integrationId, testIdentifier);
143753
- return { success: true };
144906
+ const body2 = await parseRequestBody(ctx.request, UpdateAssessmentRequestSchema);
144907
+ return ctx.services.timebackAssessments.updateAssessment(integrationId, testIdentifier, body2);
143754
144908
  });
143755
- reorderAssessments = requireGameManagementAccess(async (ctx) => {
144909
+ reorderAssessments = requireDeveloper(async (ctx) => {
143756
144910
  const { gameId, courseId } = ctx.params;
143757
144911
  if (!gameId || !courseId) {
143758
144912
  throw ApiError.badRequest("Missing gameId or courseId parameter");
143759
144913
  }
143760
144914
  const body2 = await parseRequestBody(ctx.request, ReorderAssessmentsRequestSchema);
143761
144915
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143762
- await ctx.services.timebackAssessments.reorderAssessments(integrationId, body2.identifiers);
144916
+ await ctx.services.timebackAssessments.reorderAssessments(integrationId, body2.purpose, body2.testIdentifiers);
143763
144917
  return { success: true };
143764
144918
  });
143765
- reorderQuestions = requireGameManagementAccess(async (ctx) => {
144919
+ reorderQuestions = requireDeveloper(async (ctx) => {
143766
144920
  const { gameId, courseId, testIdentifier } = ctx.params;
143767
144921
  if (!gameId || !courseId || !testIdentifier) {
143768
144922
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
@@ -143772,33 +144926,51 @@ var init_timeback_controller = __esm(() => {
143772
144926
  await ctx.services.timebackAssessments.reorderQuestions(integrationId, testIdentifier, body2.items);
143773
144927
  return { success: true };
143774
144928
  });
143775
- activateAssessment = requireGameManagementAccess(async (ctx) => {
144929
+ removeAssessment = requireDeveloper(async (ctx) => {
143776
144930
  const { gameId, courseId, testIdentifier } = ctx.params;
143777
144931
  if (!gameId || !courseId || !testIdentifier) {
143778
144932
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
143779
144933
  }
143780
144934
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143781
- await ctx.services.timebackAssessments.activateAssessment(integrationId, testIdentifier);
143782
- return { success: true };
144935
+ return ctx.services.timebackAssessments.removeAssessment(integrationId, testIdentifier);
143783
144936
  });
143784
- deactivateAssessment = requireGameManagementAccess(async (ctx) => {
144937
+ listQuestions = requireDeveloper(async (ctx) => {
143785
144938
  const { gameId, courseId, testIdentifier } = ctx.params;
143786
144939
  if (!gameId || !courseId || !testIdentifier) {
143787
144940
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
143788
144941
  }
143789
144942
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143790
- await ctx.services.timebackAssessments.deactivateAssessment(integrationId, testIdentifier);
143791
- return { success: true };
144943
+ return ctx.services.timebackAssessments.listQuestions(integrationId, testIdentifier);
143792
144944
  });
143793
- listQuestions = requireGameManagementAccess(async (ctx) => {
143794
- const { gameId, courseId, testIdentifier } = ctx.params;
143795
- if (!gameId || !courseId || !testIdentifier) {
143796
- throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
144945
+ listQuestionLibrary = requireDeveloper(async (ctx) => {
144946
+ const { gameId, courseId } = ctx.params;
144947
+ if (!gameId || !courseId) {
144948
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
143797
144949
  }
144950
+ const params = parseQtiLibraryParams(ctx.url.searchParams);
143798
144951
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143799
- return ctx.services.timebackAssessments.listQuestions(integrationId, testIdentifier);
144952
+ return ctx.services.timebackAssessments.listQuestionLibrary(integrationId, params);
144953
+ });
144954
+ listTestLibrary = requireDeveloper(async (ctx) => {
144955
+ const { gameId, courseId } = ctx.params;
144956
+ if (!gameId || !courseId) {
144957
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
144958
+ }
144959
+ const params = parseQtiLibraryParams(ctx.url.searchParams);
144960
+ const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
144961
+ return ctx.services.timebackAssessments.listTestLibrary(integrationId, params);
144962
+ });
144963
+ copyAssessment = requireDeveloper(async (ctx) => {
144964
+ const { gameId, courseId } = ctx.params;
144965
+ if (!gameId || !courseId) {
144966
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
144967
+ }
144968
+ const body2 = await parseRequestBody(ctx.request, CopyAssessmentRequestSchema);
144969
+ const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
144970
+ const targetTestIdentifier = newPlaycademyTestIdentifier();
144971
+ return ctx.services.timebackAssessments.copyAssessment(integrationId, body2.testIdentifier, targetTestIdentifier, body2.purpose);
143800
144972
  });
143801
- createQuestion = requireGameManagementAccess(async (ctx) => {
144973
+ createQuestion = requireDeveloper(async (ctx) => {
143802
144974
  const { gameId, courseId, testIdentifier } = ctx.params;
143803
144975
  if (!gameId || !courseId || !testIdentifier) {
143804
144976
  throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
@@ -143807,7 +144979,7 @@ var init_timeback_controller = __esm(() => {
143807
144979
  const body2 = await ctx.request.json();
143808
144980
  return ctx.services.timebackAssessments.createQuestion(integrationId, testIdentifier, body2);
143809
144981
  });
143810
- updateQuestion = requireGameManagementAccess(async (ctx) => {
144982
+ updateQuestion = requireDeveloper(async (ctx) => {
143811
144983
  const { gameId, courseId, testIdentifier, itemIdentifier } = ctx.params;
143812
144984
  if (!gameId || !courseId || !testIdentifier || !itemIdentifier) {
143813
144985
  throw ApiError.badRequest("Missing gameId, courseId, testIdentifier, or itemIdentifier parameter");
@@ -143816,30 +144988,13 @@ var init_timeback_controller = __esm(() => {
143816
144988
  const body2 = await ctx.request.json();
143817
144989
  return ctx.services.timebackAssessments.updateQuestion(integrationId, testIdentifier, itemIdentifier, body2);
143818
144990
  });
143819
- deleteQuestion = requireGameManagementAccess(async (ctx) => {
144991
+ removeQuestion = requireDeveloper(async (ctx) => {
143820
144992
  const { gameId, courseId, testIdentifier, itemIdentifier } = ctx.params;
143821
144993
  if (!gameId || !courseId || !testIdentifier || !itemIdentifier) {
143822
144994
  throw ApiError.badRequest("Missing gameId, courseId, testIdentifier, or itemIdentifier parameter");
143823
144995
  }
143824
144996
  const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143825
- await ctx.services.timebackAssessments.deleteQuestion(integrationId, testIdentifier, itemIdentifier);
143826
- return { success: true };
143827
- });
143828
- getAssessmentBankStatus = requireGameManagementAccess(async (ctx) => {
143829
- const { gameId, courseId } = ctx.params;
143830
- if (!gameId || !courseId) {
143831
- throw ApiError.badRequest("Missing gameId or courseId parameter");
143832
- }
143833
- const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143834
- return ctx.services.timebackAssessments.getBankStatus(integrationId);
143835
- });
143836
- destroyAssessmentBank = requireAdmin(async (ctx) => {
143837
- const { gameId, courseId } = ctx.params;
143838
- if (!gameId || !courseId) {
143839
- throw ApiError.badRequest("Missing gameId or courseId parameter");
143840
- }
143841
- const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
143842
- await ctx.services.timebackAssessments.destroyBank(integrationId);
144997
+ await ctx.services.timebackAssessments.removeQuestion(integrationId, testIdentifier, itemIdentifier);
143843
144998
  return { success: true };
143844
144999
  });
143845
145000
  timeback2 = defineControllerNames("timeback", {
@@ -143884,17 +145039,17 @@ var init_timeback_controller = __esm(() => {
143884
145039
  reactivateEnrollment,
143885
145040
  listAssessments,
143886
145041
  createAssessment,
143887
- deleteAssessment,
145042
+ updateAssessment,
143888
145043
  reorderAssessments,
145044
+ removeAssessment,
143889
145045
  reorderQuestions,
143890
- activateAssessment,
143891
- deactivateAssessment,
143892
145046
  listQuestions,
145047
+ listTestLibrary,
145048
+ copyAssessment,
145049
+ listQuestionLibrary,
143893
145050
  createQuestion,
143894
145051
  updateQuestion,
143895
- deleteQuestion,
143896
- getAssessmentBankStatus,
143897
- destroyAssessmentBank
145052
+ removeQuestion
143898
145053
  });
143899
145054
  });
143900
145055