@playcademy/vite-plugin 1.1.3-beta.7 → 1.1.3-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1791 -626
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -24314,7 +24314,7 @@ import path2 from "node:path";
|
|
|
24314
24314
|
// package.json
|
|
24315
24315
|
var package_default = {
|
|
24316
24316
|
name: "@playcademy/vite-plugin",
|
|
24317
|
-
version: "1.1.3-beta.
|
|
24317
|
+
version: "1.1.3-beta.9",
|
|
24318
24318
|
type: "module",
|
|
24319
24319
|
exports: {
|
|
24320
24320
|
".": {
|
|
@@ -25846,7 +25846,7 @@ var package_default2;
|
|
|
25846
25846
|
var init_package = __esm(() => {
|
|
25847
25847
|
package_default2 = {
|
|
25848
25848
|
name: "@playcademy/sandbox",
|
|
25849
|
-
version: "0.6.1-beta.
|
|
25849
|
+
version: "0.6.1-beta.9",
|
|
25850
25850
|
description: "Local development server for Playcademy game development",
|
|
25851
25851
|
type: "module",
|
|
25852
25852
|
exports: {
|
|
@@ -36392,6 +36392,8 @@ var init_table6 = __esm(() => {
|
|
|
36392
36392
|
}));
|
|
36393
36393
|
});
|
|
36394
36394
|
var gameTimebackIntegrationStatusEnum;
|
|
36395
|
+
var gameTimebackAssessmentPurposeEnum;
|
|
36396
|
+
var gameTimebackAssessmentStatusEnum;
|
|
36395
36397
|
var gameTimebackIntegrations;
|
|
36396
36398
|
var gameTimebackAssessmentTests;
|
|
36397
36399
|
var gameTimebackMetricDiscrepancyVerifications;
|
|
@@ -36405,6 +36407,15 @@ var init_table7 = __esm(() => {
|
|
|
36405
36407
|
"active",
|
|
36406
36408
|
"deactivated"
|
|
36407
36409
|
]);
|
|
36410
|
+
gameTimebackAssessmentPurposeEnum = pgEnum("game_timeback_assessment_purpose", [
|
|
36411
|
+
"end_of_course",
|
|
36412
|
+
"diagnostic"
|
|
36413
|
+
]);
|
|
36414
|
+
gameTimebackAssessmentStatusEnum = pgEnum("game_timeback_assessment_status", [
|
|
36415
|
+
"draft",
|
|
36416
|
+
"live",
|
|
36417
|
+
"archived"
|
|
36418
|
+
]);
|
|
36408
36419
|
gameTimebackIntegrations = pgTable("game_timeback_integrations", {
|
|
36409
36420
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
36410
36421
|
gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
|
|
@@ -36425,10 +36436,11 @@ var init_table7 = __esm(() => {
|
|
|
36425
36436
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
36426
36437
|
integrationId: uuid("integration_id").notNull().references(() => gameTimebackIntegrations.id, { onDelete: "cascade" }),
|
|
36427
36438
|
qtiTestIdentifier: text("qti_test_identifier").notNull(),
|
|
36428
|
-
|
|
36429
|
-
|
|
36430
|
-
sortOrder: integer("sort_order")
|
|
36431
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
36439
|
+
purpose: gameTimebackAssessmentPurposeEnum("purpose").notNull().default("end_of_course"),
|
|
36440
|
+
status: gameTimebackAssessmentStatusEnum("status").notNull().default("draft"),
|
|
36441
|
+
sortOrder: integer("sort_order"),
|
|
36442
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
36443
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
36432
36444
|
}, (table3) => [
|
|
36433
36445
|
uniqueIndex("game_timeback_assessment_tests_integration_qti_idx").on(table3.integrationId, table3.qtiTestIdentifier)
|
|
36434
36446
|
]);
|
|
@@ -36478,6 +36490,8 @@ __export(exports_tables_index, {
|
|
|
36478
36490
|
gameTimebackIntegrations: () => gameTimebackIntegrations,
|
|
36479
36491
|
gameTimebackIntegrationStatusEnum: () => gameTimebackIntegrationStatusEnum,
|
|
36480
36492
|
gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
|
|
36493
|
+
gameTimebackAssessmentStatusEnum: () => gameTimebackAssessmentStatusEnum,
|
|
36494
|
+
gameTimebackAssessmentPurposeEnum: () => gameTimebackAssessmentPurposeEnum,
|
|
36481
36495
|
gameTimebackActivityCompletions: () => gameTimebackActivityCompletions,
|
|
36482
36496
|
gameScoresRelations: () => gameScoresRelations,
|
|
36483
36497
|
gameScores: () => gameScores,
|
|
@@ -52783,14 +52797,16 @@ function assertBaselineClaimValid(args2) {
|
|
|
52783
52797
|
function validateBaselineClaim(args2) {
|
|
52784
52798
|
const { claimedTag, evidence, tables, indexes: indexes2, views, lastDeployAt } = args2;
|
|
52785
52799
|
const claimedIndex = evidence.findIndex((entry2) => entry2.tag === claimedTag);
|
|
52800
|
+
const expected = replayEvidence(evidence, claimedIndex);
|
|
52801
|
+
const live = { tables, indexes: indexes2, views };
|
|
52786
52802
|
const verdicts = [];
|
|
52787
52803
|
const unverified = [];
|
|
52788
52804
|
evidence.forEach((entry2, index2) => {
|
|
52789
52805
|
if (claimedIndex === -1 || index2 > claimedIndex) {
|
|
52790
|
-
verdicts.push(judgeBeyond(entry2,
|
|
52806
|
+
verdicts.push(judgeBeyond(entry2, expected, live));
|
|
52791
52807
|
return;
|
|
52792
52808
|
}
|
|
52793
|
-
const judged = judgeClaimed(entry2,
|
|
52809
|
+
const judged = judgeClaimed(entry2, expected, live, lastDeployAt);
|
|
52794
52810
|
verdicts.push(judged.verdict);
|
|
52795
52811
|
if (judged.unverifiable) {
|
|
52796
52812
|
unverified.push(judged.verdict);
|
|
@@ -52803,8 +52819,99 @@ function validateBaselineClaim(args2) {
|
|
|
52803
52819
|
suggestedTag: suggestTag(evidence, tables)
|
|
52804
52820
|
};
|
|
52805
52821
|
}
|
|
52806
|
-
function
|
|
52807
|
-
|
|
52822
|
+
function replayEvidence(evidence, claimedIndex) {
|
|
52823
|
+
const state = { tables: new Map, indexes: new Map, views: new Map };
|
|
52824
|
+
for (let index2 = 0;index2 <= claimedIndex; index2++) {
|
|
52825
|
+
applyEvidenceEntry(state, evidence[index2]);
|
|
52826
|
+
}
|
|
52827
|
+
return state;
|
|
52828
|
+
}
|
|
52829
|
+
function applyEvidenceEntry(state, entry2) {
|
|
52830
|
+
for (const table8 of entry2.dropsTables) {
|
|
52831
|
+
state.tables.delete(table8);
|
|
52832
|
+
}
|
|
52833
|
+
for (const dropped of entry2.dropsColumns) {
|
|
52834
|
+
state.tables.get(dropped.table)?.columns.delete(dropped.column);
|
|
52835
|
+
}
|
|
52836
|
+
for (const droppedIndex of entry2.dropsIndexes) {
|
|
52837
|
+
state.indexes.delete(droppedIndex);
|
|
52838
|
+
}
|
|
52839
|
+
for (const view2 of entry2.dropsViews) {
|
|
52840
|
+
state.views.delete(view2);
|
|
52841
|
+
}
|
|
52842
|
+
for (const table8 of entry2.createsTables) {
|
|
52843
|
+
state.tables.set(table8.name, {
|
|
52844
|
+
creator: entry2.tag,
|
|
52845
|
+
columns: new Map(table8.columns.map((column2) => [column2, entry2.tag]))
|
|
52846
|
+
});
|
|
52847
|
+
}
|
|
52848
|
+
for (const added of entry2.addsColumns) {
|
|
52849
|
+
state.tables.get(added.table)?.columns.set(added.column, entry2.tag);
|
|
52850
|
+
}
|
|
52851
|
+
for (const createdIndex of entry2.createsIndexes) {
|
|
52852
|
+
state.indexes.set(createdIndex, entry2.tag);
|
|
52853
|
+
}
|
|
52854
|
+
for (const view2 of entry2.createsViews) {
|
|
52855
|
+
state.views.set(view2, entry2.tag);
|
|
52856
|
+
}
|
|
52857
|
+
}
|
|
52858
|
+
function judgeClaimed(entry2, expected, live, lastDeployAt) {
|
|
52859
|
+
let surviving = 0;
|
|
52860
|
+
for (const table8 of entry2.createsTables) {
|
|
52861
|
+
const expectation = expected.tables.get(table8.name);
|
|
52862
|
+
if (expectation?.creator === entry2.tag) {
|
|
52863
|
+
surviving++;
|
|
52864
|
+
const columns2 = live.tables.get(table8.name);
|
|
52865
|
+
if (!columns2) {
|
|
52866
|
+
return {
|
|
52867
|
+
verdict: {
|
|
52868
|
+
tag: entry2.tag,
|
|
52869
|
+
verdict: "contradicted",
|
|
52870
|
+
detail: `creates table \`${table8.name}\`, which is not in the live database`
|
|
52871
|
+
},
|
|
52872
|
+
unverifiable: false
|
|
52873
|
+
};
|
|
52874
|
+
}
|
|
52875
|
+
const missing = [...expectation.columns.entries()].filter(([, creator]) => creator === entry2.tag).map(([column2]) => column2).filter((column2) => !columns2.includes(column2));
|
|
52876
|
+
if (missing.length > 0) {
|
|
52877
|
+
return {
|
|
52878
|
+
verdict: {
|
|
52879
|
+
tag: entry2.tag,
|
|
52880
|
+
verdict: "contradicted",
|
|
52881
|
+
detail: `table \`${table8.name}\` exists but is missing column(s) ${missing.map((column2) => `\`${column2}\``).join(", ")} this migration defines`
|
|
52882
|
+
},
|
|
52883
|
+
unverifiable: false
|
|
52884
|
+
};
|
|
52885
|
+
}
|
|
52886
|
+
}
|
|
52887
|
+
}
|
|
52888
|
+
for (const added of entry2.addsColumns) {
|
|
52889
|
+
if (expected.tables.get(added.table)?.columns.get(added.column) === entry2.tag) {
|
|
52890
|
+
surviving++;
|
|
52891
|
+
const columns2 = live.tables.get(added.table);
|
|
52892
|
+
if (columns2 && !columns2.includes(added.column)) {
|
|
52893
|
+
return {
|
|
52894
|
+
verdict: {
|
|
52895
|
+
tag: entry2.tag,
|
|
52896
|
+
verdict: "contradicted",
|
|
52897
|
+
detail: `adds column \`${added.column}\` to \`${added.table}\`, which the live table does not have`
|
|
52898
|
+
},
|
|
52899
|
+
unverifiable: false
|
|
52900
|
+
};
|
|
52901
|
+
}
|
|
52902
|
+
}
|
|
52903
|
+
}
|
|
52904
|
+
for (const name2 of entry2.createsIndexes) {
|
|
52905
|
+
if (expected.indexes.get(name2) === entry2.tag && live.indexes.has(name2)) {
|
|
52906
|
+
surviving++;
|
|
52907
|
+
}
|
|
52908
|
+
}
|
|
52909
|
+
for (const name2 of entry2.createsViews) {
|
|
52910
|
+
if (expected.views.get(name2) === entry2.tag && live.views.has(name2)) {
|
|
52911
|
+
surviving++;
|
|
52912
|
+
}
|
|
52913
|
+
}
|
|
52914
|
+
if (surviving === 0) {
|
|
52808
52915
|
const generated = new Date(entry2.generatedAt);
|
|
52809
52916
|
if (lastDeployAt && generated > lastDeployAt) {
|
|
52810
52917
|
return {
|
|
@@ -52818,48 +52925,11 @@ function judgeClaimed(entry2, tables, lastDeployAt) {
|
|
|
52818
52925
|
}
|
|
52819
52926
|
return { verdict: { tag: entry2.tag, verdict: "no-signal" }, unverifiable: false };
|
|
52820
52927
|
}
|
|
52821
|
-
for (const table8 of entry2.createsTables) {
|
|
52822
|
-
const columns2 = tables.get(table8.name);
|
|
52823
|
-
if (!columns2) {
|
|
52824
|
-
return {
|
|
52825
|
-
verdict: {
|
|
52826
|
-
tag: entry2.tag,
|
|
52827
|
-
verdict: "contradicted",
|
|
52828
|
-
detail: `creates table \`${table8.name}\`, which is not in the live database`
|
|
52829
|
-
},
|
|
52830
|
-
unverifiable: false
|
|
52831
|
-
};
|
|
52832
|
-
}
|
|
52833
|
-
const missing = table8.columns.filter((column2) => !columns2.includes(column2));
|
|
52834
|
-
if (missing.length > 0) {
|
|
52835
|
-
return {
|
|
52836
|
-
verdict: {
|
|
52837
|
-
tag: entry2.tag,
|
|
52838
|
-
verdict: "contradicted",
|
|
52839
|
-
detail: `table \`${table8.name}\` exists but is missing column(s) ${missing.map((column2) => `\`${column2}\``).join(", ")} this migration defines`
|
|
52840
|
-
},
|
|
52841
|
-
unverifiable: false
|
|
52842
|
-
};
|
|
52843
|
-
}
|
|
52844
|
-
}
|
|
52845
|
-
for (const added of entry2.addsColumns) {
|
|
52846
|
-
const columns2 = tables.get(added.table);
|
|
52847
|
-
if (columns2 && !columns2.includes(added.column)) {
|
|
52848
|
-
return {
|
|
52849
|
-
verdict: {
|
|
52850
|
-
tag: entry2.tag,
|
|
52851
|
-
verdict: "contradicted",
|
|
52852
|
-
detail: `adds column \`${added.column}\` to \`${added.table}\`, which the live table does not have`
|
|
52853
|
-
},
|
|
52854
|
-
unverifiable: false
|
|
52855
|
-
};
|
|
52856
|
-
}
|
|
52857
|
-
}
|
|
52858
52928
|
return { verdict: { tag: entry2.tag, verdict: "verified" }, unverifiable: false };
|
|
52859
52929
|
}
|
|
52860
|
-
function judgeBeyond(entry2,
|
|
52930
|
+
function judgeBeyond(entry2, expected, live) {
|
|
52861
52931
|
for (const table8 of entry2.createsTables) {
|
|
52862
|
-
if (tables.has(table8.name)) {
|
|
52932
|
+
if (live.tables.has(table8.name) && !expected.tables.has(table8.name)) {
|
|
52863
52933
|
return {
|
|
52864
52934
|
tag: entry2.tag,
|
|
52865
52935
|
verdict: "contradicted",
|
|
@@ -52868,8 +52938,9 @@ function judgeBeyond(entry2, tables, indexes2, views) {
|
|
|
52868
52938
|
}
|
|
52869
52939
|
}
|
|
52870
52940
|
for (const added of entry2.addsColumns) {
|
|
52871
|
-
const columns2 = tables.get(added.table);
|
|
52872
|
-
|
|
52941
|
+
const columns2 = live.tables.get(added.table);
|
|
52942
|
+
const explained = expected.tables.get(added.table)?.columns.has(added.column);
|
|
52943
|
+
if (columns2?.includes(added.column) && !explained) {
|
|
52873
52944
|
return {
|
|
52874
52945
|
tag: entry2.tag,
|
|
52875
52946
|
verdict: "contradicted",
|
|
@@ -52878,7 +52949,7 @@ function judgeBeyond(entry2, tables, indexes2, views) {
|
|
|
52878
52949
|
}
|
|
52879
52950
|
}
|
|
52880
52951
|
for (const index2 of entry2.createsIndexes) {
|
|
52881
|
-
if (
|
|
52952
|
+
if (live.indexes.has(index2) && !expected.indexes.has(index2)) {
|
|
52882
52953
|
return {
|
|
52883
52954
|
tag: entry2.tag,
|
|
52884
52955
|
verdict: "contradicted",
|
|
@@ -52887,7 +52958,7 @@ function judgeBeyond(entry2, tables, indexes2, views) {
|
|
|
52887
52958
|
}
|
|
52888
52959
|
}
|
|
52889
52960
|
for (const view2 of entry2.createsViews) {
|
|
52890
|
-
if (views.has(view2)) {
|
|
52961
|
+
if (live.views.has(view2) && !expected.views.has(view2)) {
|
|
52891
52962
|
return {
|
|
52892
52963
|
tag: entry2.tag,
|
|
52893
52964
|
verdict: "contradicted",
|
|
@@ -52898,19 +52969,20 @@ function judgeBeyond(entry2, tables, indexes2, views) {
|
|
|
52898
52969
|
return { tag: entry2.tag, verdict: "verified" };
|
|
52899
52970
|
}
|
|
52900
52971
|
function suggestTag(evidence, tables) {
|
|
52901
|
-
|
|
52902
|
-
|
|
52903
|
-
|
|
52904
|
-
}
|
|
52905
|
-
let
|
|
52906
|
-
|
|
52907
|
-
|
|
52972
|
+
const liveCreated = [
|
|
52973
|
+
...new Set(evidence.flatMap((entry2) => entry2.createsTables.map((table8) => table8.name)))
|
|
52974
|
+
].filter((name2) => tables.has(name2));
|
|
52975
|
+
const state = { tables: new Map, indexes: new Map, views: new Map };
|
|
52976
|
+
let best = null;
|
|
52977
|
+
for (const entry2 of evidence) {
|
|
52978
|
+
applyEvidenceEntry(state, entry2);
|
|
52979
|
+
const allPresent = [...state.tables.keys()].every((name2) => tables.has(name2));
|
|
52980
|
+
const noStray = liveCreated.every((name2) => state.tables.has(name2));
|
|
52981
|
+
if (allPresent && noStray) {
|
|
52982
|
+
best = entry2.tag;
|
|
52983
|
+
}
|
|
52908
52984
|
}
|
|
52909
|
-
|
|
52910
|
-
return candidate >= 0 && candidate >= absentFrom - 1 ? evidence[candidate].tag : null;
|
|
52911
|
-
}
|
|
52912
|
-
function hasSignal(entry2) {
|
|
52913
|
-
return entry2.createsTables.length > 0 || entry2.addsColumns.length > 0 || entry2.createsIndexes.length > 0 || entry2.createsViews.length > 0;
|
|
52985
|
+
return best;
|
|
52914
52986
|
}
|
|
52915
52987
|
var init_baseline_validation_util = __esm(() => {
|
|
52916
52988
|
init_spans();
|
|
@@ -54520,6 +54592,25 @@ function sleep(ms) {
|
|
|
54520
54592
|
}
|
|
54521
54593
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
54522
54594
|
}
|
|
54595
|
+
async function runWithConcurrency(items, concurrency, worker) {
|
|
54596
|
+
if (items.length === 0) {
|
|
54597
|
+
return [];
|
|
54598
|
+
}
|
|
54599
|
+
const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
|
|
54600
|
+
const results = Array.from({ length: items.length });
|
|
54601
|
+
let nextIndex = 0;
|
|
54602
|
+
await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
|
|
54603
|
+
while (true) {
|
|
54604
|
+
const currentIndex = nextIndex;
|
|
54605
|
+
nextIndex++;
|
|
54606
|
+
if (currentIndex >= items.length) {
|
|
54607
|
+
return;
|
|
54608
|
+
}
|
|
54609
|
+
results[currentIndex] = await worker(items[currentIndex]);
|
|
54610
|
+
}
|
|
54611
|
+
}));
|
|
54612
|
+
return results;
|
|
54613
|
+
}
|
|
54523
54614
|
function isObject(value) {
|
|
54524
54615
|
return typeof value === "object" && value !== null;
|
|
54525
54616
|
}
|
|
@@ -56036,7 +56127,7 @@ class KVBackupService {
|
|
|
56036
56127
|
"app.kv_backup.dry_run": dryRun,
|
|
56037
56128
|
"app.kv_backup.game_slug": options.gameSlug
|
|
56038
56129
|
});
|
|
56039
|
-
const results = await
|
|
56130
|
+
const results = await runWithConcurrency(targets, namespaceConcurrency, (target) => this.backupNamespace(target, {
|
|
56040
56131
|
bucketName,
|
|
56041
56132
|
stage,
|
|
56042
56133
|
runId,
|
|
@@ -56102,7 +56193,7 @@ class KVBackupService {
|
|
|
56102
56193
|
}
|
|
56103
56194
|
static async fetchNamespaceEntries(cloudflare2, namespaceId, keyConcurrency = DEFAULT_KEY_CONCURRENCY) {
|
|
56104
56195
|
const keys = await KVBackupService.withRetries(`List KV keys for namespace ${namespaceId}`, () => cloudflare2.kv.listKeys(namespaceId));
|
|
56105
|
-
return
|
|
56196
|
+
return runWithConcurrency(keys, keyConcurrency, async (key) => {
|
|
56106
56197
|
const safeLabel = KVBackupService.redactKeyForLog(key.name);
|
|
56107
56198
|
const value = await KVBackupService.withRetries(`Fetch KV value ${safeLabel}`, () => cloudflare2.kv.getValue(namespaceId, key.name));
|
|
56108
56199
|
const metadata2 = KVBackupService.parseMetadata(key.metadata);
|
|
@@ -56279,25 +56370,6 @@ class KVBackupService {
|
|
|
56279
56370
|
}
|
|
56280
56371
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
56281
56372
|
}
|
|
56282
|
-
static async runWithConcurrency(items, concurrency, worker) {
|
|
56283
|
-
if (items.length === 0) {
|
|
56284
|
-
return [];
|
|
56285
|
-
}
|
|
56286
|
-
const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
|
|
56287
|
-
const results = Array.from({ length: items.length });
|
|
56288
|
-
let nextIndex = 0;
|
|
56289
|
-
await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
|
|
56290
|
-
while (true) {
|
|
56291
|
-
const currentIndex = nextIndex;
|
|
56292
|
-
nextIndex++;
|
|
56293
|
-
if (currentIndex >= items.length) {
|
|
56294
|
-
return;
|
|
56295
|
-
}
|
|
56296
|
-
results[currentIndex] = await worker(items[currentIndex]);
|
|
56297
|
-
}
|
|
56298
|
-
}));
|
|
56299
|
-
return results;
|
|
56300
|
-
}
|
|
56301
56373
|
}
|
|
56302
56374
|
var BACKUP_SCHEMA_VERSION = 1;
|
|
56303
56375
|
var DEFAULT_NAMESPACE_CONCURRENCY = 2;
|
|
@@ -58466,7 +58538,14 @@ var init_schemas2 = __esm(() => {
|
|
|
58466
58538
|
column: exports_external.string().min(1)
|
|
58467
58539
|
})),
|
|
58468
58540
|
createsIndexes: exports_external.array(exports_external.string()),
|
|
58469
|
-
createsViews: exports_external.array(exports_external.string())
|
|
58541
|
+
createsViews: exports_external.array(exports_external.string()),
|
|
58542
|
+
dropsTables: exports_external.array(exports_external.string()),
|
|
58543
|
+
dropsColumns: exports_external.array(exports_external.object({
|
|
58544
|
+
table: exports_external.string().min(1),
|
|
58545
|
+
column: exports_external.string().min(1)
|
|
58546
|
+
})),
|
|
58547
|
+
dropsIndexes: exports_external.array(exports_external.string()),
|
|
58548
|
+
dropsViews: exports_external.array(exports_external.string())
|
|
58470
58549
|
})).optional();
|
|
58471
58550
|
DeployBaselineSchema = exports_external.object({
|
|
58472
58551
|
lastAppliedMigrationTag: exports_external.string().min(1).optional(),
|
|
@@ -58653,12 +58732,14 @@ var EnrollStudentRequestSchema;
|
|
|
58653
58732
|
var UnenrollStudentRequestSchema;
|
|
58654
58733
|
var ReactivateEnrollmentRequestSchema;
|
|
58655
58734
|
var VerifyTimebackMetricDiscrepancyRequestSchema;
|
|
58656
|
-
var
|
|
58735
|
+
var AssessmentPurposeSchema;
|
|
58736
|
+
var AssessmentStatusSchema;
|
|
58657
58737
|
var CreateAssessmentRequestSchema;
|
|
58738
|
+
var UpdateAssessmentRequestSchema;
|
|
58739
|
+
var CopyAssessmentRequestSchema;
|
|
58658
58740
|
var ReorderAssessmentsRequestSchema;
|
|
58659
58741
|
var ReorderQuestionsRequestSchema;
|
|
58660
58742
|
var init_schemas4 = __esm(() => {
|
|
58661
|
-
init_drizzle_zod();
|
|
58662
58743
|
init_esm();
|
|
58663
58744
|
init_table7();
|
|
58664
58745
|
TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
|
@@ -58911,15 +58992,26 @@ var init_schemas4 = __esm(() => {
|
|
|
58911
58992
|
runId: exports_external.string().uuid(),
|
|
58912
58993
|
activityId: exports_external.string().min(1).optional()
|
|
58913
58994
|
});
|
|
58914
|
-
|
|
58915
|
-
|
|
58916
|
-
createdAt: true
|
|
58917
|
-
});
|
|
58995
|
+
AssessmentPurposeSchema = exports_external.enum(gameTimebackAssessmentPurposeEnum.enumValues);
|
|
58996
|
+
AssessmentStatusSchema = exports_external.enum(gameTimebackAssessmentStatusEnum.enumValues);
|
|
58918
58997
|
CreateAssessmentRequestSchema = exports_external.object({
|
|
58919
|
-
title: exports_external.string().min(1, "Assessment title is required")
|
|
58998
|
+
title: exports_external.string().min(1, "Assessment title is required"),
|
|
58999
|
+
purpose: AssessmentPurposeSchema
|
|
59000
|
+
});
|
|
59001
|
+
UpdateAssessmentRequestSchema = exports_external.object({
|
|
59002
|
+
title: exports_external.string().trim().min(1, "Assessment title is required").optional(),
|
|
59003
|
+
purpose: AssessmentPurposeSchema.optional(),
|
|
59004
|
+
status: AssessmentStatusSchema.optional()
|
|
59005
|
+
}).refine((input) => input.title !== undefined || input.purpose !== undefined || input.status !== undefined, {
|
|
59006
|
+
message: "Title, purpose, or status is required"
|
|
59007
|
+
});
|
|
59008
|
+
CopyAssessmentRequestSchema = exports_external.object({
|
|
59009
|
+
testIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
|
|
59010
|
+
purpose: AssessmentPurposeSchema
|
|
58920
59011
|
});
|
|
58921
59012
|
ReorderAssessmentsRequestSchema = exports_external.object({
|
|
58922
|
-
|
|
59013
|
+
purpose: AssessmentPurposeSchema,
|
|
59014
|
+
testIdentifiers: exports_external.array(exports_external.string().trim().min(1, "Assessment identifier is required")).min(1, "At least one assessment is required")
|
|
58923
59015
|
});
|
|
58924
59016
|
ReorderQuestionsRequestSchema = exports_external.object({
|
|
58925
59017
|
items: exports_external.array(exports_external.object({
|
|
@@ -80384,7 +80476,7 @@ var INITIAL_RETRY_DELAY_MS2 = 1000;
|
|
|
80384
80476
|
var DEFAULT_LIMIT2 = 100;
|
|
80385
80477
|
var DEFAULT_MAX_ITEMS2 = 1e4;
|
|
80386
80478
|
var Paginator6;
|
|
80387
|
-
var
|
|
80479
|
+
var init_chunk_pt7w63g2 = __esm(async () => {
|
|
80388
80480
|
init_chunk_6jf1natv();
|
|
80389
80481
|
ApiError3 = class ApiError32 extends Error {
|
|
80390
80482
|
statusCode;
|
|
@@ -95705,8 +95797,7 @@ function resolveToProvider23(config4, registry3 = DEFAULT_PROVIDER_REGISTRY2) {
|
|
|
95705
95797
|
function translateParams2(params) {
|
|
95706
95798
|
const {
|
|
95707
95799
|
orderBy,
|
|
95708
|
-
|
|
95709
|
-
fields: __,
|
|
95800
|
+
fields: _2,
|
|
95710
95801
|
...rest
|
|
95711
95802
|
} = params;
|
|
95712
95803
|
return {
|
|
@@ -95714,6 +95805,23 @@ function translateParams2(params) {
|
|
|
95714
95805
|
order: orderBy
|
|
95715
95806
|
};
|
|
95716
95807
|
}
|
|
95808
|
+
function buildListQueryParams(params) {
|
|
95809
|
+
const queryParams = {};
|
|
95810
|
+
const filter = params.where ? whereToFilter2(params.where) : undefined;
|
|
95811
|
+
if (filter !== undefined)
|
|
95812
|
+
queryParams.filter = filter;
|
|
95813
|
+
if (params.query !== undefined)
|
|
95814
|
+
queryParams.query = params.query;
|
|
95815
|
+
if (params.page !== undefined)
|
|
95816
|
+
queryParams.page = params.page;
|
|
95817
|
+
if (params.limit !== undefined)
|
|
95818
|
+
queryParams.limit = params.limit;
|
|
95819
|
+
if (params.sort)
|
|
95820
|
+
queryParams.sort = params.sort;
|
|
95821
|
+
if (params.order)
|
|
95822
|
+
queryParams.order = params.order;
|
|
95823
|
+
return queryParams;
|
|
95824
|
+
}
|
|
95717
95825
|
|
|
95718
95826
|
class AssessmentItemsResource2 {
|
|
95719
95827
|
transport;
|
|
@@ -95722,19 +95830,8 @@ class AssessmentItemsResource2 {
|
|
|
95722
95830
|
}
|
|
95723
95831
|
list(params = {}) {
|
|
95724
95832
|
validatePageListParams2(params);
|
|
95725
|
-
const queryParams = {};
|
|
95726
|
-
if (params.query !== undefined)
|
|
95727
|
-
queryParams.query = params.query;
|
|
95728
|
-
if (params.page !== undefined)
|
|
95729
|
-
queryParams.page = params.page;
|
|
95730
|
-
if (params.limit !== undefined)
|
|
95731
|
-
queryParams.limit = params.limit;
|
|
95732
|
-
if (params.sort)
|
|
95733
|
-
queryParams.sort = params.sort;
|
|
95734
|
-
if (params.order)
|
|
95735
|
-
queryParams.order = params.order;
|
|
95736
95833
|
return this.transport.request("/assessment-items", {
|
|
95737
|
-
params:
|
|
95834
|
+
params: buildListQueryParams(params)
|
|
95738
95835
|
});
|
|
95739
95836
|
}
|
|
95740
95837
|
stream(params = {}) {
|
|
@@ -95857,20 +95954,9 @@ class TestPartSectionsHelper2 {
|
|
|
95857
95954
|
}
|
|
95858
95955
|
list(params = {}) {
|
|
95859
95956
|
validatePageListParams2(params);
|
|
95860
|
-
const queryParams = {};
|
|
95861
|
-
if (params.query !== undefined)
|
|
95862
|
-
queryParams.query = params.query;
|
|
95863
|
-
if (params.page !== undefined)
|
|
95864
|
-
queryParams.page = params.page;
|
|
95865
|
-
if (params.limit !== undefined)
|
|
95866
|
-
queryParams.limit = params.limit;
|
|
95867
|
-
if (params.sort)
|
|
95868
|
-
queryParams.sort = params.sort;
|
|
95869
|
-
if (params.order)
|
|
95870
|
-
queryParams.order = params.order;
|
|
95871
95957
|
const path3 = `/assessment-tests/${encodeURIComponent(this.testId)}/test-parts/${encodeURIComponent(this.testPartId)}/sections`;
|
|
95872
95958
|
return this.transport.request(path3, {
|
|
95873
|
-
params:
|
|
95959
|
+
params: buildListQueryParams(params)
|
|
95874
95960
|
});
|
|
95875
95961
|
}
|
|
95876
95962
|
get(identifier) {
|
|
@@ -95917,19 +96003,10 @@ class AssessmentTestPartsHelper2 {
|
|
|
95917
96003
|
}
|
|
95918
96004
|
list(params = {}) {
|
|
95919
96005
|
validatePageListParams2(params);
|
|
95920
|
-
const queryParams = {};
|
|
95921
|
-
if (params.query !== undefined)
|
|
95922
|
-
queryParams.query = params.query;
|
|
95923
|
-
if (params.page !== undefined)
|
|
95924
|
-
queryParams.page = params.page;
|
|
95925
|
-
if (params.limit !== undefined)
|
|
95926
|
-
queryParams.limit = params.limit;
|
|
95927
|
-
if (params.sort)
|
|
95928
|
-
queryParams.sort = params.sort;
|
|
95929
|
-
if (params.order)
|
|
95930
|
-
queryParams.order = params.order;
|
|
95931
96006
|
const path3 = `/assessment-tests/${encodeURIComponent(this.testId)}/test-parts`;
|
|
95932
|
-
return this.transport.request(path3, {
|
|
96007
|
+
return this.transport.request(path3, {
|
|
96008
|
+
params: buildListQueryParams(params)
|
|
96009
|
+
});
|
|
95933
96010
|
}
|
|
95934
96011
|
get(identifier) {
|
|
95935
96012
|
validateNonEmptyString2(identifier, "testPartId");
|
|
@@ -95972,19 +96049,8 @@ class AssessmentTestsResource2 {
|
|
|
95972
96049
|
}
|
|
95973
96050
|
list(params = {}) {
|
|
95974
96051
|
validatePageListParams2(params);
|
|
95975
|
-
const queryParams = {};
|
|
95976
|
-
if (params.query !== undefined)
|
|
95977
|
-
queryParams.query = params.query;
|
|
95978
|
-
if (params.page !== undefined)
|
|
95979
|
-
queryParams.page = params.page;
|
|
95980
|
-
if (params.limit !== undefined)
|
|
95981
|
-
queryParams.limit = params.limit;
|
|
95982
|
-
if (params.sort)
|
|
95983
|
-
queryParams.sort = params.sort;
|
|
95984
|
-
if (params.order)
|
|
95985
|
-
queryParams.order = params.order;
|
|
95986
96052
|
return this.transport.request("/assessment-tests", {
|
|
95987
|
-
params:
|
|
96053
|
+
params: buildListQueryParams(params)
|
|
95988
96054
|
});
|
|
95989
96055
|
}
|
|
95990
96056
|
stream(params = {}) {
|
|
@@ -96090,19 +96156,8 @@ class StimuliResource2 {
|
|
|
96090
96156
|
}
|
|
96091
96157
|
list(params = {}) {
|
|
96092
96158
|
validatePageListParams2(params);
|
|
96093
|
-
const queryParams = {};
|
|
96094
|
-
if (params.query !== undefined)
|
|
96095
|
-
queryParams.query = params.query;
|
|
96096
|
-
if (params.page !== undefined)
|
|
96097
|
-
queryParams.page = params.page;
|
|
96098
|
-
if (params.limit !== undefined)
|
|
96099
|
-
queryParams.limit = params.limit;
|
|
96100
|
-
if (params.sort)
|
|
96101
|
-
queryParams.sort = params.sort;
|
|
96102
|
-
if (params.order)
|
|
96103
|
-
queryParams.order = params.order;
|
|
96104
96159
|
return this.transport.request("/stimuli", {
|
|
96105
|
-
params:
|
|
96160
|
+
params: buildListQueryParams(params)
|
|
96106
96161
|
});
|
|
96107
96162
|
}
|
|
96108
96163
|
stream(params = {}) {
|
|
@@ -96475,7 +96530,7 @@ var init_dist3 = __esm(async () => {
|
|
|
96475
96530
|
init_v42();
|
|
96476
96531
|
init_v42();
|
|
96477
96532
|
init_v42();
|
|
96478
|
-
await
|
|
96533
|
+
await init_chunk_pt7w63g2();
|
|
96479
96534
|
QTI_ENV_VARS2 = {
|
|
96480
96535
|
baseUrl: ["TIMEBACK_API_BASE_URL", "TIMEBACK_BASE_URL", "QTI_BASE_URL"],
|
|
96481
96536
|
clientId: ["TIMEBACK_API_CLIENT_ID", "TIMEBACK_CLIENT_ID", "QTI_CLIENT_ID"],
|
|
@@ -103602,13 +103657,6 @@ function deriveSourcedIds(courseId) {
|
|
|
103602
103657
|
componentResource: `${courseId}-cr`
|
|
103603
103658
|
};
|
|
103604
103659
|
}
|
|
103605
|
-
function deriveAssessmentBankIds(courseId) {
|
|
103606
|
-
return {
|
|
103607
|
-
component: `${courseId}-assessment-bank-component`,
|
|
103608
|
-
resource: `${courseId}-assessment-bank-resource`,
|
|
103609
|
-
componentResource: `${courseId}-assessment-bank-cr`
|
|
103610
|
-
};
|
|
103611
|
-
}
|
|
103612
103660
|
function validateProgressData(progressData) {
|
|
103613
103661
|
if (!progressData.subject) {
|
|
103614
103662
|
throw new ConfigurationError("subject", "Subject is required for Caliper events. Provide it in progressData.subject");
|
|
@@ -104897,59 +104945,22 @@ class CourseAssessments {
|
|
|
104897
104945
|
constructor(core3) {
|
|
104898
104946
|
this.core = core3;
|
|
104899
104947
|
}
|
|
104900
|
-
|
|
104901
|
-
|
|
104902
|
-
|
|
104903
|
-
|
|
104904
|
-
|
|
104905
|
-
|
|
104906
|
-
|
|
104907
|
-
|
|
104908
|
-
|
|
104909
|
-
|
|
104910
|
-
|
|
104911
|
-
|
|
104912
|
-
|
|
104913
|
-
|
|
104914
|
-
throw error88;
|
|
104915
|
-
}
|
|
104916
|
-
}
|
|
104917
|
-
try {
|
|
104918
|
-
await oneroster.resources.create({
|
|
104919
|
-
sourcedId: bankIds.resource,
|
|
104920
|
-
status: "active",
|
|
104921
|
-
title: "Assessment Bank",
|
|
104922
|
-
vendorResourceId: "",
|
|
104923
|
-
vendorId: undefined,
|
|
104924
|
-
metadata: {
|
|
104925
|
-
type: "assessment-bank",
|
|
104926
|
-
resources: [],
|
|
104927
|
-
lessonType: "test-out",
|
|
104928
|
-
subject: input.subject,
|
|
104929
|
-
grade: String(input.grade)
|
|
104930
|
-
}
|
|
104931
|
-
});
|
|
104932
|
-
} catch (error88) {
|
|
104933
|
-
if (!isAlreadyExists(error88)) {
|
|
104934
|
-
throw error88;
|
|
104935
|
-
}
|
|
104936
|
-
}
|
|
104937
|
-
try {
|
|
104938
|
-
await oneroster.courses.createComponentResource({
|
|
104939
|
-
sourcedId: bankIds.componentResource,
|
|
104940
|
-
status: "active",
|
|
104941
|
-
title: "Test Out",
|
|
104942
|
-
resource: { sourcedId: bankIds.resource },
|
|
104943
|
-
courseComponent: { sourcedId: bankIds.component },
|
|
104944
|
-
sortOrder: 9999,
|
|
104945
|
-
metadata: { lessonType: "test-out" },
|
|
104946
|
-
lessonType: "test-out"
|
|
104947
|
-
});
|
|
104948
|
-
} catch (error88) {
|
|
104949
|
-
if (!isAlreadyExists(error88)) {
|
|
104950
|
-
throw error88;
|
|
104948
|
+
createItemFromXml(input) {
|
|
104949
|
+
return this.core.qti.assessmentItems.createFromXml({
|
|
104950
|
+
format: "xml",
|
|
104951
|
+
xml: input.xml,
|
|
104952
|
+
...input.metadata ? { metadata: input.metadata } : {}
|
|
104953
|
+
});
|
|
104954
|
+
}
|
|
104955
|
+
updateItemFromXml(identifier, input) {
|
|
104956
|
+
return this.core.qti.getTransport().request(`/assessment-items/${encodeURIComponent(identifier)}`, {
|
|
104957
|
+
method: "PUT",
|
|
104958
|
+
body: {
|
|
104959
|
+
format: "xml",
|
|
104960
|
+
xml: input.xml,
|
|
104961
|
+
...input.metadata ? { metadata: input.metadata } : {}
|
|
104951
104962
|
}
|
|
104952
|
-
}
|
|
104963
|
+
});
|
|
104953
104964
|
}
|
|
104954
104965
|
async createTestScaffold(input) {
|
|
104955
104966
|
const partId = `${input.identifier}-part1`;
|
|
@@ -104984,18 +104995,6 @@ class CourseAssessments {
|
|
|
104984
104995
|
}
|
|
104985
104996
|
}
|
|
104986
104997
|
}
|
|
104987
|
-
async teardownTest(qtiTestIdentifier) {
|
|
104988
|
-
const qti = this.core.qti;
|
|
104989
|
-
const partId = `${qtiTestIdentifier}-part1`;
|
|
104990
|
-
const sectionId = `${qtiTestIdentifier}-section1`;
|
|
104991
|
-
const questions = await qti.assessmentTests.getQuestions(qtiTestIdentifier);
|
|
104992
|
-
const sectionItems = qti.assessmentTests.testParts(qtiTestIdentifier).sections(partId).items(sectionId);
|
|
104993
|
-
for (const q of questions.questions ?? []) {
|
|
104994
|
-
await sectionItems.remove(q.reference.identifier);
|
|
104995
|
-
await qti.assessmentItems.delete(q.reference.identifier);
|
|
104996
|
-
}
|
|
104997
|
-
await qti.assessmentTests.delete(qtiTestIdentifier);
|
|
104998
|
-
}
|
|
104999
104998
|
}
|
|
105000
104999
|
function buildCoursePayload(config4) {
|
|
105001
105000
|
return {
|
|
@@ -105225,9 +105224,9 @@ function createCourseNamespace(core3) {
|
|
|
105225
105224
|
cleanup: (courseId) => integration.cleanup(courseId),
|
|
105226
105225
|
deactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "tobedeleted"),
|
|
105227
105226
|
reactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "active"),
|
|
105228
|
-
ensureAssessmentBank: (input) => assessments.ensureBank(input),
|
|
105229
105227
|
createAssessmentTest: (input) => assessments.createTestScaffold(input),
|
|
105230
|
-
|
|
105228
|
+
createAssessmentItemXml: (input) => assessments.createItemFromXml(input),
|
|
105229
|
+
updateAssessmentItemXml: (identifier, input) => assessments.updateItemFromXml(identifier, input)
|
|
105231
105230
|
};
|
|
105232
105231
|
}
|
|
105233
105232
|
function recordTimebackClientSummary(outcome) {
|
|
@@ -105731,13 +105730,6 @@ function deriveSourcedIds2(courseId) {
|
|
|
105731
105730
|
componentResource: `${courseId}-cr`
|
|
105732
105731
|
};
|
|
105733
105732
|
}
|
|
105734
|
-
function deriveAssessmentBankIds2(courseId) {
|
|
105735
|
-
return {
|
|
105736
|
-
component: `${courseId}-assessment-bank-component`,
|
|
105737
|
-
resource: `${courseId}-assessment-bank-resource`,
|
|
105738
|
-
componentResource: `${courseId}-assessment-bank-cr`
|
|
105739
|
-
};
|
|
105740
|
-
}
|
|
105741
105733
|
var CACHE_DEFAULTS4;
|
|
105742
105734
|
var RESOURCE_DEFAULTS4;
|
|
105743
105735
|
var init_utils6 = __esm(() => {
|
|
@@ -105771,6 +105763,96 @@ var init_utils6 = __esm(() => {
|
|
|
105771
105763
|
componentResource: TIMEBACK_COMPONENT_RESOURCE_DEFAULTS
|
|
105772
105764
|
};
|
|
105773
105765
|
});
|
|
105766
|
+
function playcademySupportedQtiInteractionType(value) {
|
|
105767
|
+
if (typeof value !== "string") {
|
|
105768
|
+
return;
|
|
105769
|
+
}
|
|
105770
|
+
const normalized = value.trim().toLowerCase().replaceAll("_", "-");
|
|
105771
|
+
return PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET.has(normalized) ? normalized : undefined;
|
|
105772
|
+
}
|
|
105773
|
+
function parseNumericTextEntry(input) {
|
|
105774
|
+
const answer = typeof input.answer === "string" ? input.answer.trim() : "";
|
|
105775
|
+
const comparison = input.comparison;
|
|
105776
|
+
if (input.baseType !== "integer" && input.baseType !== "float") {
|
|
105777
|
+
return { success: false, message: "Numeric answers must be integer or float responses" };
|
|
105778
|
+
}
|
|
105779
|
+
if (!answer || (input.baseType === "integer" ? !INTEGER_ANSWER_PATTERN.test(answer) : !FLOAT_ANSWER_PATTERN.test(answer) || !Number.isFinite(Number(answer)))) {
|
|
105780
|
+
return { success: false, message: `The correct answer is not a valid ${input.baseType}` };
|
|
105781
|
+
}
|
|
105782
|
+
if (!comparison || typeof comparison !== "object" || !("kind" in comparison)) {
|
|
105783
|
+
return { success: false, message: "Numeric comparison is required" };
|
|
105784
|
+
}
|
|
105785
|
+
if (comparison.kind === "equal") {
|
|
105786
|
+
return {
|
|
105787
|
+
success: true,
|
|
105788
|
+
value: { baseType: input.baseType, answer, comparison: { kind: "equal" } }
|
|
105789
|
+
};
|
|
105790
|
+
}
|
|
105791
|
+
if (input.baseType === "integer") {
|
|
105792
|
+
return { success: false, message: "Integer answers cannot use decimal-place rounding" };
|
|
105793
|
+
}
|
|
105794
|
+
const figures = "figures" in comparison ? comparison.figures : undefined;
|
|
105795
|
+
const roundingMode = "roundingMode" in comparison ? comparison.roundingMode : undefined;
|
|
105796
|
+
if (comparison.kind !== "equal-rounded" || roundingMode !== "decimalPlaces" || !Number.isInteger(figures) || figures < 0 || figures > 15) {
|
|
105797
|
+
return { success: false, message: "Decimal places must be a whole number from 0 to 15" };
|
|
105798
|
+
}
|
|
105799
|
+
return {
|
|
105800
|
+
success: true,
|
|
105801
|
+
value: {
|
|
105802
|
+
baseType: "float",
|
|
105803
|
+
answer,
|
|
105804
|
+
comparison: { kind: "equal-rounded", roundingMode, figures }
|
|
105805
|
+
}
|
|
105806
|
+
};
|
|
105807
|
+
}
|
|
105808
|
+
function escapeXml(value) {
|
|
105809
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
105810
|
+
}
|
|
105811
|
+
function hottextMaxChoices(selectableCount, multiple) {
|
|
105812
|
+
return multiple ? selectableCount : 1;
|
|
105813
|
+
}
|
|
105814
|
+
function numericTextEntryXml(input) {
|
|
105815
|
+
const comparison = input.numeric.comparison.kind === "equal-rounded" ? `<qti-equal-rounded rounding-mode="decimalPlaces" figures="${input.numeric.comparison.figures}">` : "<qti-equal>";
|
|
105816
|
+
const closeComparison = input.numeric.comparison.kind === "equal-rounded" ? "</qti-equal-rounded>" : "</qti-equal>";
|
|
105817
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
105818
|
+
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
|
|
105819
|
+
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="${input.numeric.baseType}">
|
|
105820
|
+
<qti-correct-response><qti-value>${escapeXml(input.numeric.answer)}</qti-value></qti-correct-response>
|
|
105821
|
+
</qti-response-declaration>
|
|
105822
|
+
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
|
|
105823
|
+
<qti-default-value><qti-value>0</qti-value></qti-default-value>
|
|
105824
|
+
</qti-outcome-declaration>
|
|
105825
|
+
<qti-item-body>
|
|
105826
|
+
<p>${escapeXml(input.prompt)} <qti-text-entry-interaction response-identifier="RESPONSE" expected-length="15" /></p>
|
|
105827
|
+
</qti-item-body>
|
|
105828
|
+
<qti-response-processing>
|
|
105829
|
+
<qti-response-condition>
|
|
105830
|
+
<qti-response-if>
|
|
105831
|
+
${comparison}<qti-variable identifier="RESPONSE" /><qti-correct identifier="RESPONSE" />${closeComparison}
|
|
105832
|
+
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
|
|
105833
|
+
</qti-response-if>
|
|
105834
|
+
</qti-response-condition>
|
|
105835
|
+
</qti-response-processing>
|
|
105836
|
+
</qti-assessment-item>`;
|
|
105837
|
+
}
|
|
105838
|
+
function countQtiTestItems(test) {
|
|
105839
|
+
let itemCount = 0;
|
|
105840
|
+
for (const part of test["qti-test-part"] ?? []) {
|
|
105841
|
+
for (const section of part["qti-assessment-section"] ?? []) {
|
|
105842
|
+
itemCount += section["qti-assessment-item-ref"]?.length ?? 0;
|
|
105843
|
+
}
|
|
105844
|
+
}
|
|
105845
|
+
return itemCount;
|
|
105846
|
+
}
|
|
105847
|
+
function isQtiItemOwnedByTest(item, testIdentifier) {
|
|
105848
|
+
const hasOwnershipMetadata = Boolean(item.metadata && (("ownerSystem" in item.metadata) || (PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY in item.metadata)));
|
|
105849
|
+
if (hasOwnershipMetadata) {
|
|
105850
|
+
return item.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && item.metadata?.[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY] === testIdentifier;
|
|
105851
|
+
}
|
|
105852
|
+
const identifierPrefix = `${testIdentifier}-q`;
|
|
105853
|
+
const identifierSuffix = item.identifier?.startsWith(identifierPrefix) ? item.identifier.slice(identifierPrefix.length) : undefined;
|
|
105854
|
+
return identifierSuffix !== undefined && /^[0-9a-f]{8}$/i.test(identifierSuffix);
|
|
105855
|
+
}
|
|
105774
105856
|
function formatGradeLabel(grade) {
|
|
105775
105857
|
if (grade === null || grade === undefined) {
|
|
105776
105858
|
return "N/A";
|
|
@@ -105814,6 +105896,14 @@ function parseTimebackDiscrepancyQueueMetrics(values) {
|
|
|
105814
105896
|
}
|
|
105815
105897
|
var TIMEBACK_DISCREPANCY_QUEUE_WINDOWS;
|
|
105816
105898
|
var TIMEBACK_DISCREPANCY_QUEUE_METRICS;
|
|
105899
|
+
var PLAYCADEMY_QTI_OWNER_SYSTEM = "playcademy";
|
|
105900
|
+
var PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY = "ownerTestIdentifier";
|
|
105901
|
+
var PLAYCADEMY_QTI_EDITOR_MODE_KEY = "playcademyEditorMode";
|
|
105902
|
+
var PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES;
|
|
105903
|
+
var PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET;
|
|
105904
|
+
var INTEGER_ANSWER_PATTERN;
|
|
105905
|
+
var FLOAT_ANSWER_PATTERN;
|
|
105906
|
+
var INLINE_CHOICE_BLANK = "_____";
|
|
105817
105907
|
var DEFAULT_TIMEBACK_DISCREPANCY_QUEUE_WINDOW = "this-week";
|
|
105818
105908
|
var TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES;
|
|
105819
105909
|
var TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES;
|
|
@@ -105827,6 +105917,17 @@ var init_timeback3 = __esm(() => {
|
|
|
105827
105917
|
"custom"
|
|
105828
105918
|
];
|
|
105829
105919
|
TIMEBACK_DISCREPANCY_QUEUE_METRICS = ["xp", "mastery", "time", "score"];
|
|
105920
|
+
PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES = [
|
|
105921
|
+
"choice",
|
|
105922
|
+
"inline-choice",
|
|
105923
|
+
"text-entry",
|
|
105924
|
+
"order",
|
|
105925
|
+
"match",
|
|
105926
|
+
"hottext"
|
|
105927
|
+
];
|
|
105928
|
+
PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPE_SET = new Set(PLAYCADEMY_SUPPORTED_QTI_INTERACTION_TYPES);
|
|
105929
|
+
INTEGER_ANSWER_PATTERN = /^[+-]?\d+$/;
|
|
105930
|
+
FLOAT_ANSWER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
105830
105931
|
TIMEBACK_DISCREPANCY_QUEUE_WINDOW_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_WINDOWS);
|
|
105831
105932
|
TIMEBACK_DISCREPANCY_QUEUE_METRIC_VALUES = new Set(TIMEBACK_DISCREPANCY_QUEUE_METRICS);
|
|
105832
105933
|
});
|
|
@@ -107057,7 +107158,7 @@ class TimebackAdminService {
|
|
|
107057
107158
|
}
|
|
107058
107159
|
async getMasterableUnitsByCourse(courseIds) {
|
|
107059
107160
|
const uniqueCourseIds = [...new Set(courseIds)];
|
|
107060
|
-
const results = await
|
|
107161
|
+
const results = await runWithConcurrency(uniqueCourseIds, TimebackAdminService.MASTERABLE_UNITS_CONCURRENCY, async (courseId) => [courseId, await this.getMasterableUnits(courseId)]);
|
|
107061
107162
|
return new Map(results);
|
|
107062
107163
|
}
|
|
107063
107164
|
deriveGameSensorUrl(game2) {
|
|
@@ -107238,7 +107339,7 @@ class TimebackAdminService {
|
|
|
107238
107339
|
async loadEnrollmentAnalyticsSummaries(enrollmentIds) {
|
|
107239
107340
|
const client = this.requireClient();
|
|
107240
107341
|
const uniqueEnrollmentIds = [...new Set(enrollmentIds)];
|
|
107241
|
-
const results = await
|
|
107342
|
+
const results = await runWithConcurrency(uniqueEnrollmentIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (enrollmentId) => {
|
|
107242
107343
|
try {
|
|
107243
107344
|
const analytics = await client.api.edubridge.analytics.getEnrollmentFacts({
|
|
107244
107345
|
enrollmentId,
|
|
@@ -107332,7 +107433,7 @@ class TimebackAdminService {
|
|
|
107332
107433
|
if (options.runOwnersById.size === 0) {
|
|
107333
107434
|
return { events: [], runIds: new Set };
|
|
107334
107435
|
}
|
|
107335
|
-
const hydratedRuns = await
|
|
107436
|
+
const hydratedRuns = await runWithConcurrency([...options.runOwnersById.entries()], TimebackAdminService.DISCREPANCY_QUEUE_RUN_HYDRATION_CONCURRENCY, async ([runId, studentId]) => {
|
|
107336
107437
|
try {
|
|
107337
107438
|
return {
|
|
107338
107439
|
runId,
|
|
@@ -107365,7 +107466,7 @@ class TimebackAdminService {
|
|
|
107365
107466
|
};
|
|
107366
107467
|
}
|
|
107367
107468
|
async buildMetricDiscrepancyQueueCandidates(user, options) {
|
|
107368
|
-
const comparisonResults = await
|
|
107469
|
+
const comparisonResults = await runWithConcurrency(options.activityGroups, TimebackAdminService.DISCREPANCY_QUEUE_COMPARISON_CONCURRENCY, async (group) => {
|
|
107369
107470
|
try {
|
|
107370
107471
|
return {
|
|
107371
107472
|
group,
|
|
@@ -108365,7 +108466,7 @@ class TimebackAdminService {
|
|
|
108365
108466
|
const action = context2.newMasterableUnits < context2.oldMasterableUnits ? "complete" : "revoke";
|
|
108366
108467
|
const failed = [];
|
|
108367
108468
|
let processed = 0;
|
|
108368
|
-
await
|
|
108469
|
+
await runWithConcurrency(context2.affectedStudentIds, 8, async (studentId) => {
|
|
108369
108470
|
try {
|
|
108370
108471
|
await upsertMasteryCompletionEntry({
|
|
108371
108472
|
client,
|
|
@@ -108586,28 +108687,9 @@ class TimebackAdminService {
|
|
|
108586
108687
|
}
|
|
108587
108688
|
async getCompletionStatusByCourse(client, courseIds, studentId) {
|
|
108588
108689
|
const uniqueCourseIds = [...new Set(courseIds)];
|
|
108589
|
-
const results = await
|
|
108690
|
+
const results = await runWithConcurrency(uniqueCourseIds, TimebackAdminService.ANALYTICS_CONCURRENCY, async (courseId) => [courseId, await this.getCompletionStatus(client, courseId, studentId)]);
|
|
108590
108691
|
return new Map(results);
|
|
108591
108692
|
}
|
|
108592
|
-
static async runWithConcurrency(items, concurrency, worker) {
|
|
108593
|
-
if (items.length === 0) {
|
|
108594
|
-
return [];
|
|
108595
|
-
}
|
|
108596
|
-
const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
|
|
108597
|
-
const results = Array.from({ length: items.length });
|
|
108598
|
-
let nextIndex = 0;
|
|
108599
|
-
await Promise.all(Array.from({ length: effectiveConcurrency }, async () => {
|
|
108600
|
-
while (true) {
|
|
108601
|
-
const currentIndex = nextIndex;
|
|
108602
|
-
nextIndex++;
|
|
108603
|
-
if (currentIndex >= items.length) {
|
|
108604
|
-
return;
|
|
108605
|
-
}
|
|
108606
|
-
results[currentIndex] = await worker(items[currentIndex]);
|
|
108607
|
-
}
|
|
108608
|
-
}));
|
|
108609
|
-
return results;
|
|
108610
|
-
}
|
|
108611
108693
|
}
|
|
108612
108694
|
var init_timeback_admin_service = __esm(async () => {
|
|
108613
108695
|
init_drizzle_orm();
|
|
@@ -108633,8 +108715,874 @@ var init_timeback_admin_service = __esm(async () => {
|
|
|
108633
108715
|
init_timeback_mastery_completion_util()
|
|
108634
108716
|
]);
|
|
108635
108717
|
});
|
|
108718
|
+
function validateAssessmentStatusTransition(current, next) {
|
|
108719
|
+
if (current === next) {
|
|
108720
|
+
return;
|
|
108721
|
+
}
|
|
108722
|
+
const allowed = current === "draft" && next === "live" || current === "live" && next === "archived" || current === "archived" && next === "live";
|
|
108723
|
+
if (!allowed) {
|
|
108724
|
+
throw new ValidationError(`Assessment status cannot change from ${current} to ${next}`);
|
|
108725
|
+
}
|
|
108726
|
+
}
|
|
108727
|
+
function isAssessmentPublicationTransition(current, next) {
|
|
108728
|
+
return current !== "live" && next === "live";
|
|
108729
|
+
}
|
|
108730
|
+
function assertDraftAssessment(row) {
|
|
108731
|
+
if (row.status !== "draft") {
|
|
108732
|
+
throw new ValidationError("Only draft assessments can change QTI content or question membership");
|
|
108733
|
+
}
|
|
108734
|
+
}
|
|
108735
|
+
function assertAllAssessmentAssociationsDraft(rows) {
|
|
108736
|
+
if (rows.some((row) => row.status !== "draft")) {
|
|
108737
|
+
throw new ValidationError("QTI content cannot change while any associated assessment is live or archived");
|
|
108738
|
+
}
|
|
108739
|
+
}
|
|
108740
|
+
function assertAssessmentHasQuestions(questions) {
|
|
108741
|
+
if (questions.length === 0) {
|
|
108742
|
+
throw new ValidationError("An assessment must contain at least one question to publish");
|
|
108743
|
+
}
|
|
108744
|
+
}
|
|
108745
|
+
function planAssessmentRemoval(status) {
|
|
108746
|
+
if (status === "draft") {
|
|
108747
|
+
return { kind: "delete", action: "discarded", operation: "discard_draft" };
|
|
108748
|
+
}
|
|
108749
|
+
if (status === "live") {
|
|
108750
|
+
return { kind: "archive", action: "archived", operation: "archive" };
|
|
108751
|
+
}
|
|
108752
|
+
return { kind: "none", action: "archived" };
|
|
108753
|
+
}
|
|
108754
|
+
function buildAssessmentAssociationUpdates(row, input) {
|
|
108755
|
+
const updates = {};
|
|
108756
|
+
if (input.purpose !== undefined) {
|
|
108757
|
+
updates.purpose = input.purpose;
|
|
108758
|
+
if (row.status === "live" && input.purpose !== row.purpose) {
|
|
108759
|
+
updates.sortOrder = null;
|
|
108760
|
+
}
|
|
108761
|
+
}
|
|
108762
|
+
if (input.status !== undefined) {
|
|
108763
|
+
updates.status = input.status;
|
|
108764
|
+
if (input.status === "archived") {
|
|
108765
|
+
updates.sortOrder = null;
|
|
108766
|
+
}
|
|
108767
|
+
}
|
|
108768
|
+
return updates;
|
|
108769
|
+
}
|
|
108770
|
+
function validateUniqueAssessmentIdentifiers(testIdentifiers) {
|
|
108771
|
+
if (new Set(testIdentifiers).size !== testIdentifiers.length) {
|
|
108772
|
+
throw new ValidationError("Assessment order must contain unique identifiers");
|
|
108773
|
+
}
|
|
108774
|
+
}
|
|
108775
|
+
function assertAssessmentOrderUpdateSucceeded(updatedRow) {
|
|
108776
|
+
if (!updatedRow) {
|
|
108777
|
+
throw new ValidationError("Assessment order changed while it was being saved. Refresh and try again.");
|
|
108778
|
+
}
|
|
108779
|
+
return updatedRow;
|
|
108780
|
+
}
|
|
108781
|
+
function lockOrderAssessmentRows(rows) {
|
|
108782
|
+
return rows.toSorted((left, right) => left.id.localeCompare(right.id));
|
|
108783
|
+
}
|
|
108784
|
+
function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
|
|
108785
|
+
const rowsByIdentifier = new Map(liveRows.map((row) => [row.qtiTestIdentifier, row]));
|
|
108786
|
+
if (liveRows.length !== testIdentifiers.length || testIdentifiers.some((identifier) => !rowsByIdentifier.has(identifier))) {
|
|
108787
|
+
throw new ValidationError(`Assessment order must include every live ${purpose} assessment exactly once`);
|
|
108788
|
+
}
|
|
108789
|
+
return testIdentifiers.map((identifier) => rowsByIdentifier.get(identifier));
|
|
108790
|
+
}
|
|
108791
|
+
var init_timeback_assessment_rules_util = __esm(() => {
|
|
108792
|
+
init_errors();
|
|
108793
|
+
});
|
|
108794
|
+
function recordValue(value) {
|
|
108795
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
108796
|
+
}
|
|
108797
|
+
function qtiAuthoringSnapshot(input) {
|
|
108798
|
+
if (!recordValue(input.interaction)) {
|
|
108799
|
+
return;
|
|
108800
|
+
}
|
|
108801
|
+
return Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => input[field] === undefined ? [] : [[field, input[field]]]));
|
|
108802
|
+
}
|
|
108803
|
+
function buildOwnedQtiQuestionMetadata(input, ownership, existingMetadata = {}) {
|
|
108804
|
+
const inputMetadata = recordValue(input.metadata) ?? {};
|
|
108805
|
+
const metadata2 = {
|
|
108806
|
+
...existingMetadata,
|
|
108807
|
+
...inputMetadata
|
|
108808
|
+
};
|
|
108809
|
+
const existingAuthoring = recordValue(existingMetadata[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
|
|
108810
|
+
Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
|
|
108811
|
+
Reflect.deleteProperty(metadata2, PLAYCADEMY_QTI_EDITOR_MODE_KEY);
|
|
108812
|
+
const authoring = qtiAuthoringSnapshot(input);
|
|
108813
|
+
const shouldReplaceAuthoring = "interaction" in input || "numericTextEntry" in input;
|
|
108814
|
+
const persistedAuthoring = authoring ?? (shouldReplaceAuthoring ? undefined : existingAuthoring);
|
|
108815
|
+
return {
|
|
108816
|
+
...metadata2,
|
|
108817
|
+
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
108818
|
+
ownerGameSlug: ownership.gameSlug,
|
|
108819
|
+
[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]: ownership.testIdentifier,
|
|
108820
|
+
...persistedAuthoring ? { [PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]: persistedAuthoring } : {}
|
|
108821
|
+
};
|
|
108822
|
+
}
|
|
108823
|
+
function restoreQtiQuestionAuthoringData(item) {
|
|
108824
|
+
const authoring = recordValue(item.metadata?.[PLAYCADEMY_QTI_AUTHORING_METADATA_KEY]);
|
|
108825
|
+
if (!authoring) {
|
|
108826
|
+
return item;
|
|
108827
|
+
}
|
|
108828
|
+
const restored = Object.fromEntries(QTI_AUTHORING_FIELDS.flatMap((field) => authoring[field] === undefined ? [] : [[field, authoring[field]]]));
|
|
108829
|
+
return { ...item, ...restored };
|
|
108830
|
+
}
|
|
108831
|
+
function mergeQtiQuestionItem(item, fallbackItem, itemIdentifier, metadata2) {
|
|
108832
|
+
return restoreQtiQuestionAuthoringData({
|
|
108833
|
+
...fallbackItem,
|
|
108834
|
+
...item,
|
|
108835
|
+
identifier: itemIdentifier,
|
|
108836
|
+
title: item.title || fallbackItem?.title || itemIdentifier,
|
|
108837
|
+
type: item.type ?? fallbackItem?.type,
|
|
108838
|
+
rawXml: item.rawXml ?? fallbackItem?.rawXml,
|
|
108839
|
+
interaction: item.interaction ?? fallbackItem?.interaction,
|
|
108840
|
+
responseDeclarations: item.responseDeclarations ?? fallbackItem?.responseDeclarations,
|
|
108841
|
+
metadata: {
|
|
108842
|
+
...fallbackItem?.metadata,
|
|
108843
|
+
...item.metadata,
|
|
108844
|
+
...metadata2
|
|
108845
|
+
}
|
|
108846
|
+
});
|
|
108847
|
+
}
|
|
108848
|
+
function newPlaycademyQuestionIdentifier(testIdentifier) {
|
|
108849
|
+
return `${testIdentifier}-q${crypto.randomUUID().slice(0, 8)}`;
|
|
108850
|
+
}
|
|
108851
|
+
function parseQtiQuestionCreationInput(input) {
|
|
108852
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
108853
|
+
throw new ValidationError("Question creation input must be an object");
|
|
108854
|
+
}
|
|
108855
|
+
const rawInput = input;
|
|
108856
|
+
if (rawInput.mode === undefined) {
|
|
108857
|
+
return { kind: "authoring", input: rawInput };
|
|
108858
|
+
}
|
|
108859
|
+
if (rawInput.mode !== "copy") {
|
|
108860
|
+
throw new ValidationError("Question creation mode is invalid");
|
|
108861
|
+
}
|
|
108862
|
+
const sourceItemIdentifier = typeof rawInput.sourceItemIdentifier === "string" ? rawInput.sourceItemIdentifier.trim() : "";
|
|
108863
|
+
if (!sourceItemIdentifier) {
|
|
108864
|
+
throw new ValidationError("A source question identifier is required to create a copy");
|
|
108865
|
+
}
|
|
108866
|
+
const unexpectedField = Object.keys(rawInput).find((field) => field !== "mode" && field !== "sourceItemIdentifier");
|
|
108867
|
+
if (unexpectedField) {
|
|
108868
|
+
throw new ValidationError(`Question copy input contains an unexpected “${unexpectedField}” field`);
|
|
108869
|
+
}
|
|
108870
|
+
return { kind: "copy", sourceItemIdentifier };
|
|
108871
|
+
}
|
|
108872
|
+
function nonEmptyMetadataString(value) {
|
|
108873
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
108874
|
+
}
|
|
108875
|
+
function qtiQuestionOwnerTestIdentifier(item) {
|
|
108876
|
+
if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
108877
|
+
return;
|
|
108878
|
+
}
|
|
108879
|
+
return nonEmptyMetadataString(item.metadata[PLAYCADEMY_QTI_OWNER_TEST_IDENTIFIER_KEY]);
|
|
108880
|
+
}
|
|
108881
|
+
function qtiTestReferencesItem(test, itemIdentifier) {
|
|
108882
|
+
return (test["qti-test-part"] ?? []).some((part) => (part["qti-assessment-section"] ?? []).some((section) => (section["qti-assessment-item-ref"] ?? []).some((reference) => reference.identifier === itemIdentifier)));
|
|
108883
|
+
}
|
|
108884
|
+
function resolveQtiQuestionOwnerGameSlug(item, ownerTest) {
|
|
108885
|
+
if (item.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
108886
|
+
return;
|
|
108887
|
+
}
|
|
108888
|
+
if ("ownerGameSlug" in item.metadata) {
|
|
108889
|
+
return nonEmptyMetadataString(item.metadata.ownerGameSlug);
|
|
108890
|
+
}
|
|
108891
|
+
const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
|
|
108892
|
+
if (!ownerTestIdentifier || !ownerTest || ownerTest.identifier !== ownerTestIdentifier || !qtiTestReferencesItem(ownerTest, item.identifier) || ownerTest.metadata?.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
108893
|
+
return;
|
|
108894
|
+
}
|
|
108895
|
+
return nonEmptyMetadataString(ownerTest.metadata.ownerGameSlug);
|
|
108896
|
+
}
|
|
108897
|
+
function isQtiQuestionOwnedByGame(item, gameSlug, ownerTest) {
|
|
108898
|
+
return resolveQtiQuestionOwnerGameSlug(item, ownerTest) === gameSlug;
|
|
108899
|
+
}
|
|
108900
|
+
function isQtiQuestionOwnedByContext(item, ownership, ownerTest) {
|
|
108901
|
+
const declaredOwnerTest = qtiQuestionOwnerTestIdentifier(item);
|
|
108902
|
+
if (declaredOwnerTest) {
|
|
108903
|
+
return declaredOwnerTest === ownership.testIdentifier && isQtiQuestionOwnedByGame(item, ownership.gameSlug, ownerTest);
|
|
108904
|
+
}
|
|
108905
|
+
return isQtiItemOwnedByTest(item, ownership.testIdentifier);
|
|
108906
|
+
}
|
|
108907
|
+
function normalizedQtiInteractionType(value) {
|
|
108908
|
+
if (typeof value !== "string") {
|
|
108909
|
+
return;
|
|
108910
|
+
}
|
|
108911
|
+
const normalized = value.trim().toLowerCase().replaceAll("_", "-");
|
|
108912
|
+
return normalized || undefined;
|
|
108913
|
+
}
|
|
108914
|
+
function qtiXmlInteractionTypes(xml) {
|
|
108915
|
+
const markup = xml.replaceAll(/<!--[\s\S]*?-->/g, "").replaceAll(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
|
|
108916
|
+
const interactionPattern = /<(?!\/)(?:[A-Za-z_][\w.-]*:)?qti-([a-z][\w-]*)-interaction\b[^>]*>/gi;
|
|
108917
|
+
return [...markup.matchAll(interactionPattern)].map((match) => match[1].toLowerCase());
|
|
108918
|
+
}
|
|
108919
|
+
function supportedQtiQuestionInteractionType(question) {
|
|
108920
|
+
const interaction = recordValue(question.interaction);
|
|
108921
|
+
const structuredTypes = [
|
|
108922
|
+
normalizedQtiInteractionType(question.type),
|
|
108923
|
+
normalizedQtiInteractionType(interaction?.type)
|
|
108924
|
+
].filter((type) => Boolean(type));
|
|
108925
|
+
const distinctStructuredTypes = [...new Set(structuredTypes)];
|
|
108926
|
+
if (distinctStructuredTypes.length > 1) {
|
|
108927
|
+
return;
|
|
108928
|
+
}
|
|
108929
|
+
const rawXml = typeof question.rawXml === "string" ? question.rawXml : undefined;
|
|
108930
|
+
if (rawXml) {
|
|
108931
|
+
const xmlTypes = qtiXmlInteractionTypes(rawXml);
|
|
108932
|
+
if (xmlTypes.length !== 1) {
|
|
108933
|
+
return;
|
|
108934
|
+
}
|
|
108935
|
+
const [xmlType] = xmlTypes;
|
|
108936
|
+
const [structuredType2] = distinctStructuredTypes;
|
|
108937
|
+
if (structuredType2 && structuredType2 !== xmlType) {
|
|
108938
|
+
return;
|
|
108939
|
+
}
|
|
108940
|
+
return playcademySupportedQtiInteractionType(xmlType);
|
|
108941
|
+
}
|
|
108942
|
+
const [structuredType] = distinctStructuredTypes;
|
|
108943
|
+
return playcademySupportedQtiInteractionType(structuredType);
|
|
108944
|
+
}
|
|
108945
|
+
function assertSupportedQtiQuestionInteraction(question) {
|
|
108946
|
+
if (!supportedQtiQuestionInteractionType(question)) {
|
|
108947
|
+
throw new ValidationError("This QTI interaction type is not supported by the Playcademy question editor.");
|
|
108948
|
+
}
|
|
108949
|
+
}
|
|
108950
|
+
function invalidQtiCopyXml() {
|
|
108951
|
+
throw new ValidationError("The source question does not contain valid QTI item XML");
|
|
108952
|
+
}
|
|
108953
|
+
function markupEnd(xml, start2, allowInternalSubset) {
|
|
108954
|
+
let quote = null;
|
|
108955
|
+
let subsetDepth = 0;
|
|
108956
|
+
for (let index2 = start2;index2 < xml.length; index2 += 1) {
|
|
108957
|
+
const character = xml[index2];
|
|
108958
|
+
if (quote) {
|
|
108959
|
+
if (character === quote) {
|
|
108960
|
+
quote = null;
|
|
108961
|
+
}
|
|
108962
|
+
} else if (character === '"' || character === "'") {
|
|
108963
|
+
quote = character;
|
|
108964
|
+
} else if (allowInternalSubset && character === "[") {
|
|
108965
|
+
subsetDepth += 1;
|
|
108966
|
+
} else if (allowInternalSubset && character === "]") {
|
|
108967
|
+
subsetDepth = Math.max(0, subsetDepth - 1);
|
|
108968
|
+
} else if (character === ">" && subsetDepth === 0) {
|
|
108969
|
+
return index2;
|
|
108970
|
+
}
|
|
108971
|
+
}
|
|
108972
|
+
return invalidQtiCopyXml();
|
|
108973
|
+
}
|
|
108974
|
+
function skipXmlWhitespace(xml, start2, end = xml.length) {
|
|
108975
|
+
let cursor2 = start2;
|
|
108976
|
+
while (cursor2 < end && /\s/.test(xml[cursor2] ?? "")) {
|
|
108977
|
+
cursor2 += 1;
|
|
108978
|
+
}
|
|
108979
|
+
return cursor2;
|
|
108980
|
+
}
|
|
108981
|
+
function xmlPreambleEnd(xml, cursor2) {
|
|
108982
|
+
if (xml.startsWith("<?", cursor2)) {
|
|
108983
|
+
const end = xml.indexOf("?>", cursor2 + 2);
|
|
108984
|
+
if (end === -1) {
|
|
108985
|
+
return invalidQtiCopyXml();
|
|
108986
|
+
}
|
|
108987
|
+
return end + 2;
|
|
108988
|
+
}
|
|
108989
|
+
if (xml.startsWith("<!--", cursor2)) {
|
|
108990
|
+
const end = xml.indexOf("-->", cursor2 + 4);
|
|
108991
|
+
if (end === -1) {
|
|
108992
|
+
return invalidQtiCopyXml();
|
|
108993
|
+
}
|
|
108994
|
+
return end + 3;
|
|
108995
|
+
}
|
|
108996
|
+
if (/^<!DOCTYPE\b/i.test(xml.slice(cursor2))) {
|
|
108997
|
+
return markupEnd(xml, cursor2 + 2, true) + 1;
|
|
108998
|
+
}
|
|
108999
|
+
return null;
|
|
109000
|
+
}
|
|
109001
|
+
function qtiRootAttributes(xml, start2, end) {
|
|
109002
|
+
const attributes = [];
|
|
109003
|
+
let cursor2 = start2;
|
|
109004
|
+
while (cursor2 < end) {
|
|
109005
|
+
cursor2 = skipXmlWhitespace(xml, cursor2, end);
|
|
109006
|
+
if (xml[cursor2] === "/") {
|
|
109007
|
+
cursor2 += 1;
|
|
109008
|
+
} else if (cursor2 < end) {
|
|
109009
|
+
const nameMatch = /^[A-Za-z_][\w.:-]*/.exec(xml.slice(cursor2, end));
|
|
109010
|
+
if (!nameMatch) {
|
|
109011
|
+
return invalidQtiCopyXml();
|
|
109012
|
+
}
|
|
109013
|
+
const name3 = nameMatch[0];
|
|
109014
|
+
cursor2 = skipXmlWhitespace(xml, cursor2 + name3.length, end);
|
|
109015
|
+
if (xml[cursor2] !== "=") {
|
|
109016
|
+
return invalidQtiCopyXml();
|
|
109017
|
+
}
|
|
109018
|
+
cursor2 = skipXmlWhitespace(xml, cursor2 + 1, end);
|
|
109019
|
+
const quote = xml[cursor2];
|
|
109020
|
+
if (quote !== '"' && quote !== "'") {
|
|
109021
|
+
return invalidQtiCopyXml();
|
|
109022
|
+
}
|
|
109023
|
+
const valueStart = cursor2 + 1;
|
|
109024
|
+
const valueEnd = xml.indexOf(quote, valueStart);
|
|
109025
|
+
if (valueEnd === -1 || valueEnd > end) {
|
|
109026
|
+
return invalidQtiCopyXml();
|
|
109027
|
+
}
|
|
109028
|
+
attributes.push({ name: name3, quote, valueStart, valueEnd });
|
|
109029
|
+
cursor2 = valueEnd + 1;
|
|
109030
|
+
}
|
|
109031
|
+
}
|
|
109032
|
+
return attributes;
|
|
109033
|
+
}
|
|
109034
|
+
function qtiItemStartTag(xml) {
|
|
109035
|
+
let cursor2 = xml.charCodeAt(0) === 65279 ? 1 : 0;
|
|
109036
|
+
while (cursor2 < xml.length) {
|
|
109037
|
+
cursor2 = skipXmlWhitespace(xml, cursor2);
|
|
109038
|
+
const preambleEnd = xmlPreambleEnd(xml, cursor2);
|
|
109039
|
+
if (preambleEnd !== null) {
|
|
109040
|
+
cursor2 = preambleEnd;
|
|
109041
|
+
} else {
|
|
109042
|
+
const root = /^<([A-Za-z_][\w.:-]*)/.exec(xml.slice(cursor2));
|
|
109043
|
+
if (!root || root[1].split(":").at(-1) !== "qti-assessment-item") {
|
|
109044
|
+
return invalidQtiCopyXml();
|
|
109045
|
+
}
|
|
109046
|
+
const attributesStart = cursor2 + root[0].length;
|
|
109047
|
+
const end = markupEnd(xml, attributesStart, false);
|
|
109048
|
+
const attributes = qtiRootAttributes(xml, attributesStart, end);
|
|
109049
|
+
let insertionPoint = skipXmlWhitespaceBackward(xml, end);
|
|
109050
|
+
if (xml[insertionPoint - 1] === "/") {
|
|
109051
|
+
insertionPoint -= 1;
|
|
109052
|
+
}
|
|
109053
|
+
return { attributes, insertionPoint };
|
|
109054
|
+
}
|
|
109055
|
+
}
|
|
109056
|
+
return invalidQtiCopyXml();
|
|
109057
|
+
}
|
|
109058
|
+
function skipXmlWhitespaceBackward(xml, start2) {
|
|
109059
|
+
let cursor2 = start2;
|
|
109060
|
+
while (/\s/.test(xml[cursor2 - 1] ?? "")) {
|
|
109061
|
+
cursor2 -= 1;
|
|
109062
|
+
}
|
|
109063
|
+
return cursor2;
|
|
109064
|
+
}
|
|
109065
|
+
function escapeXmlAttribute(value, quote) {
|
|
109066
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(quote, quote === '"' ? """ : "'");
|
|
109067
|
+
}
|
|
109068
|
+
function rewriteQtiItemIdentity(xml, identifier, title) {
|
|
109069
|
+
if (!xml.trim() || !identifier.trim() || !title.trim()) {
|
|
109070
|
+
return invalidQtiCopyXml();
|
|
109071
|
+
}
|
|
109072
|
+
const root = qtiItemStartTag(xml);
|
|
109073
|
+
const replacements = [];
|
|
109074
|
+
const targetAttributes = new Map([
|
|
109075
|
+
["identifier", identifier],
|
|
109076
|
+
["title", title]
|
|
109077
|
+
]);
|
|
109078
|
+
for (const [name3, value] of targetAttributes) {
|
|
109079
|
+
const matches = root.attributes.filter((attribute2) => attribute2.name === name3);
|
|
109080
|
+
if (matches.length > 1) {
|
|
109081
|
+
return invalidQtiCopyXml();
|
|
109082
|
+
}
|
|
109083
|
+
const attribute = matches[0];
|
|
109084
|
+
if (attribute) {
|
|
109085
|
+
replacements.push({
|
|
109086
|
+
start: attribute.valueStart,
|
|
109087
|
+
end: attribute.valueEnd,
|
|
109088
|
+
value: escapeXmlAttribute(value, attribute.quote)
|
|
109089
|
+
});
|
|
109090
|
+
} else {
|
|
109091
|
+
replacements.push({
|
|
109092
|
+
start: root.insertionPoint,
|
|
109093
|
+
end: root.insertionPoint,
|
|
109094
|
+
value: ` ${name3}="${escapeXmlAttribute(value, '"')}"`
|
|
109095
|
+
});
|
|
109096
|
+
}
|
|
109097
|
+
}
|
|
109098
|
+
return replacements.toSorted((left, right) => right.start - left.start).reduce((rewritten, replacement) => `${rewritten.slice(0, replacement.start)}${replacement.value}${rewritten.slice(replacement.end)}`, xml);
|
|
109099
|
+
}
|
|
109100
|
+
function buildQtiQuestionCopyInput(input) {
|
|
109101
|
+
assertSupportedQtiQuestionInteraction(input.source);
|
|
109102
|
+
const title = input.source.title?.trim() || input.source.identifier;
|
|
109103
|
+
if (!input.source.rawXml) {
|
|
109104
|
+
return invalidQtiCopyXml();
|
|
109105
|
+
}
|
|
109106
|
+
const sourceMetadata = { ...input.source.metadata };
|
|
109107
|
+
if (sourceMetadata.ownerSystem !== PLAYCADEMY_QTI_OWNER_SYSTEM) {
|
|
109108
|
+
Reflect.deleteProperty(sourceMetadata, PLAYCADEMY_QTI_AUTHORING_METADATA_KEY);
|
|
109109
|
+
}
|
|
109110
|
+
const metadata2 = buildOwnedQtiQuestionMetadata({
|
|
109111
|
+
metadata: {
|
|
109112
|
+
copiedFromItemIdentifier: input.source.identifier,
|
|
109113
|
+
integrationId: input.integrationId
|
|
109114
|
+
}
|
|
109115
|
+
}, {
|
|
109116
|
+
gameSlug: input.gameSlug,
|
|
109117
|
+
testIdentifier: input.targetTestIdentifier
|
|
109118
|
+
}, sourceMetadata);
|
|
109119
|
+
return {
|
|
109120
|
+
identifier: input.targetIdentifier,
|
|
109121
|
+
title,
|
|
109122
|
+
xml: rewriteQtiItemIdentity(input.source.rawXml, input.targetIdentifier, title),
|
|
109123
|
+
metadata: metadata2
|
|
109124
|
+
};
|
|
109125
|
+
}
|
|
109126
|
+
function assertQtiQuestionEditable(item, ownership, ownerTest) {
|
|
109127
|
+
if (!isQtiQuestionOwnedByContext(item, ownership, ownerTest)) {
|
|
109128
|
+
throw new ValidationError("Shared question references are read-only. Copy the question to create an editable independent item.");
|
|
109129
|
+
}
|
|
109130
|
+
}
|
|
109131
|
+
function buildNumericQuestionXml(input, identifier, title) {
|
|
109132
|
+
if (input.format === "xml") {
|
|
109133
|
+
throw new ValidationError("Raw question XML is not accepted at this API boundary");
|
|
109134
|
+
}
|
|
109135
|
+
const candidate = input.numericTextEntry;
|
|
109136
|
+
if (candidate === undefined) {
|
|
109137
|
+
return null;
|
|
109138
|
+
}
|
|
109139
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
109140
|
+
throw new ValidationError("Numeric text-entry data is invalid");
|
|
109141
|
+
}
|
|
109142
|
+
const prompt = "prompt" in candidate ? candidate.prompt : undefined;
|
|
109143
|
+
const numeric3 = parseNumericTextEntry({
|
|
109144
|
+
baseType: "baseType" in candidate ? candidate.baseType : undefined,
|
|
109145
|
+
answer: "answer" in candidate ? candidate.answer : undefined,
|
|
109146
|
+
comparison: "comparison" in candidate ? candidate.comparison : undefined
|
|
109147
|
+
});
|
|
109148
|
+
if (!numeric3.success) {
|
|
109149
|
+
throw new ValidationError(numeric3.message);
|
|
109150
|
+
}
|
|
109151
|
+
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
109152
|
+
throw new ValidationError("Numeric questions require a title and prompt");
|
|
109153
|
+
}
|
|
109154
|
+
return numericTextEntryXml({ identifier, title, prompt: prompt.trim(), numeric: numeric3.value });
|
|
109155
|
+
}
|
|
109156
|
+
function buildExactMatchQuestionXml(input) {
|
|
109157
|
+
const responseIdentifier = escapeXml(input.responseIdentifier);
|
|
109158
|
+
const correctValues = input.correctIdentifiers.map((identifier) => ` <qti-value>${escapeXml(identifier)}</qti-value>`).join(`
|
|
109159
|
+
`);
|
|
109160
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
109161
|
+
<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" identifier="${escapeXml(input.identifier)}" title="${escapeXml(input.title)}" adaptive="false" time-dependent="false">
|
|
109162
|
+
<qti-response-declaration identifier="${responseIdentifier}" cardinality="${input.cardinality}" base-type="identifier">
|
|
109163
|
+
<qti-correct-response>
|
|
109164
|
+
${correctValues}
|
|
109165
|
+
</qti-correct-response>
|
|
109166
|
+
</qti-response-declaration>
|
|
109167
|
+
<qti-outcome-declaration identifier="FEEDBACK" cardinality="single" base-type="identifier" />
|
|
109168
|
+
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
|
|
109169
|
+
<qti-default-value><qti-value>0</qti-value></qti-default-value>
|
|
109170
|
+
</qti-outcome-declaration>
|
|
109171
|
+
<qti-item-body>
|
|
109172
|
+
${input.itemBody}
|
|
109173
|
+
</qti-item-body>
|
|
109174
|
+
<qti-response-processing>
|
|
109175
|
+
<qti-response-condition>
|
|
109176
|
+
<qti-response-if>
|
|
109177
|
+
<qti-match><qti-variable identifier="${responseIdentifier}" /><qti-correct identifier="${responseIdentifier}" /></qti-match>
|
|
109178
|
+
<qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">CORRECT</qti-base-value></qti-set-outcome-value>
|
|
109179
|
+
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">1</qti-base-value></qti-set-outcome-value>
|
|
109180
|
+
</qti-response-if>
|
|
109181
|
+
<qti-response-else>
|
|
109182
|
+
<qti-set-outcome-value identifier="FEEDBACK"><qti-base-value base-type="identifier">INCORRECT</qti-base-value></qti-set-outcome-value>
|
|
109183
|
+
<qti-set-outcome-value identifier="SCORE"><qti-base-value base-type="float">0</qti-base-value></qti-set-outcome-value>
|
|
109184
|
+
</qti-response-else>
|
|
109185
|
+
</qti-response-condition>
|
|
109186
|
+
</qti-response-processing>
|
|
109187
|
+
</qti-assessment-item>`;
|
|
109188
|
+
}
|
|
109189
|
+
function parseInlineChoices(value) {
|
|
109190
|
+
if (!Array.isArray(value) || value.length < 2) {
|
|
109191
|
+
throw new ValidationError("Inline-choice questions require at least two choices");
|
|
109192
|
+
}
|
|
109193
|
+
const choices = value.map((choice) => {
|
|
109194
|
+
const record3 = recordValue(choice);
|
|
109195
|
+
const identifier = record3?.identifier;
|
|
109196
|
+
const content = record3?.content;
|
|
109197
|
+
if (typeof identifier !== "string" || !identifier.trim() || typeof content !== "string" || !content.trim()) {
|
|
109198
|
+
throw new ValidationError("Inline-choice options are invalid");
|
|
109199
|
+
}
|
|
109200
|
+
return { identifier: identifier.trim(), content: content.trim() };
|
|
109201
|
+
});
|
|
109202
|
+
if (new Set(choices.map((choice) => choice.identifier)).size !== choices.length) {
|
|
109203
|
+
throw new ValidationError("Inline-choice option identifiers must be unique");
|
|
109204
|
+
}
|
|
109205
|
+
return choices;
|
|
109206
|
+
}
|
|
109207
|
+
function inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers) {
|
|
109208
|
+
const declarations = Array.isArray(input.responseDeclarations) ? input.responseDeclarations : [];
|
|
109209
|
+
const declaration = declarations.map(recordValue).find((candidate) => candidate?.identifier === responseIdentifier);
|
|
109210
|
+
const correctResponse = recordValue(declaration?.correctResponse);
|
|
109211
|
+
const rawValues = correctResponse?.value;
|
|
109212
|
+
const values = Array.isArray(rawValues) ? rawValues.filter((value) => typeof value === "string") : [];
|
|
109213
|
+
if (declaration?.cardinality !== "single" || declaration.baseType !== "identifier") {
|
|
109214
|
+
throw new ValidationError("Inline-choice questions require a single-identifier response declaration");
|
|
109215
|
+
}
|
|
109216
|
+
if (values.length !== 1 || !choiceIdentifiers.has(values[0])) {
|
|
109217
|
+
throw new ValidationError("Inline-choice questions require one valid correct option");
|
|
109218
|
+
}
|
|
109219
|
+
return values[0];
|
|
109220
|
+
}
|
|
109221
|
+
function buildInlineChoiceQuestionXml(input, identifier, title) {
|
|
109222
|
+
const interaction = recordValue(input.interaction);
|
|
109223
|
+
const interactionType = normalizedQtiInteractionType(interaction?.type ?? input.type);
|
|
109224
|
+
if (interactionType !== "inline-choice") {
|
|
109225
|
+
return null;
|
|
109226
|
+
}
|
|
109227
|
+
const responseIdentifier = interaction?.responseIdentifier;
|
|
109228
|
+
const structure = recordValue(interaction?.questionStructure);
|
|
109229
|
+
const prompt = structure?.prompt;
|
|
109230
|
+
if (!structure || typeof responseIdentifier !== "string" || !responseIdentifier.trim()) {
|
|
109231
|
+
throw new ValidationError("Inline-choice question data is invalid");
|
|
109232
|
+
}
|
|
109233
|
+
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
109234
|
+
throw new ValidationError("Inline-choice questions require a title and prompt");
|
|
109235
|
+
}
|
|
109236
|
+
const normalizedPrompt = prompt.trim();
|
|
109237
|
+
const promptParts = normalizedPrompt.split(INLINE_CHOICE_BLANK);
|
|
109238
|
+
if (promptParts.length !== 2) {
|
|
109239
|
+
throw new ValidationError("Inline-choice questions require exactly one blank");
|
|
109240
|
+
}
|
|
109241
|
+
const choices = parseInlineChoices(structure.inlineChoices);
|
|
109242
|
+
const choiceIdentifiers = new Set(choices.map((choice) => choice.identifier));
|
|
109243
|
+
const correctIdentifier = inlineChoiceCorrectIdentifier(input, responseIdentifier, choiceIdentifiers);
|
|
109244
|
+
const choicesXml = choices.map((choice) => ` <qti-inline-choice identifier="${escapeXml(choice.identifier)}">${escapeXml(choice.content)}</qti-inline-choice>`).join(`
|
|
109245
|
+
`);
|
|
109246
|
+
const [before = "", after = ""] = promptParts;
|
|
109247
|
+
const itemBody = ` <p>${escapeXml(before)}<qti-inline-choice-interaction response-identifier="${escapeXml(responseIdentifier)}">
|
|
109248
|
+
${choicesXml}
|
|
109249
|
+
</qti-inline-choice-interaction>${escapeXml(after)}</p>`;
|
|
109250
|
+
return buildExactMatchQuestionXml({
|
|
109251
|
+
identifier,
|
|
109252
|
+
title,
|
|
109253
|
+
responseIdentifier,
|
|
109254
|
+
cardinality: "single",
|
|
109255
|
+
correctIdentifiers: [correctIdentifier],
|
|
109256
|
+
itemBody
|
|
109257
|
+
});
|
|
109258
|
+
}
|
|
109259
|
+
function parseHottextSegments(value) {
|
|
109260
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
109261
|
+
throw new ValidationError("Hottext questions require passage segments");
|
|
109262
|
+
}
|
|
109263
|
+
const segments = value.map((segment) => {
|
|
109264
|
+
const record3 = recordValue(segment);
|
|
109265
|
+
const identifier = record3?.identifier;
|
|
109266
|
+
const content = record3?.content;
|
|
109267
|
+
const mode = record3?.mode;
|
|
109268
|
+
if (typeof identifier !== "string" || typeof content !== "string" || !content.trim() || !["plain", "option", "correct"].includes(String(mode))) {
|
|
109269
|
+
throw new ValidationError("Hottext passage segments are invalid");
|
|
109270
|
+
}
|
|
109271
|
+
return {
|
|
109272
|
+
identifier: identifier.trim(),
|
|
109273
|
+
content: content.trim(),
|
|
109274
|
+
mode
|
|
109275
|
+
};
|
|
109276
|
+
});
|
|
109277
|
+
const selectable = segments.filter((segment) => segment.mode !== "plain");
|
|
109278
|
+
const correct = selectable.filter((segment) => segment.mode === "correct");
|
|
109279
|
+
const identifiers = selectable.map((segment) => segment.identifier);
|
|
109280
|
+
if (selectable.length < 2) {
|
|
109281
|
+
throw new ValidationError("Hottext questions require at least two selectable phrases");
|
|
109282
|
+
}
|
|
109283
|
+
if (identifiers.some((identifier) => !identifier)) {
|
|
109284
|
+
throw new ValidationError("Hottext selectable phrases require identifiers");
|
|
109285
|
+
}
|
|
109286
|
+
if (new Set(identifiers).size !== identifiers.length) {
|
|
109287
|
+
throw new ValidationError("Hottext selectable phrase identifiers must be unique");
|
|
109288
|
+
}
|
|
109289
|
+
if (correct.length === 0) {
|
|
109290
|
+
throw new ValidationError("Hottext questions require a correct phrase");
|
|
109291
|
+
}
|
|
109292
|
+
return { segments, selectable, correct };
|
|
109293
|
+
}
|
|
109294
|
+
function buildHottextQuestionXml(input, identifier, title) {
|
|
109295
|
+
const candidate = input.hottext;
|
|
109296
|
+
if (candidate === undefined) {
|
|
109297
|
+
return null;
|
|
109298
|
+
}
|
|
109299
|
+
const hottext = recordValue(candidate);
|
|
109300
|
+
if (!hottext) {
|
|
109301
|
+
throw new ValidationError("Hottext question data is invalid");
|
|
109302
|
+
}
|
|
109303
|
+
const prompt = hottext.prompt;
|
|
109304
|
+
if (!title || typeof prompt !== "string" || !prompt.trim()) {
|
|
109305
|
+
throw new ValidationError("Hottext questions require a title and prompt");
|
|
109306
|
+
}
|
|
109307
|
+
const { segments, selectable, correct } = parseHottextSegments(hottext.segments);
|
|
109308
|
+
const multiple = correct.length > 1;
|
|
109309
|
+
const maxChoices = hottextMaxChoices(selectable.length, multiple);
|
|
109310
|
+
const passage = segments.map((segment) => segment.mode === "plain" ? escapeXml(segment.content) : `<qti-hottext identifier="${escapeXml(segment.identifier)}">${escapeXml(segment.content)}</qti-hottext>`).join(" ");
|
|
109311
|
+
const itemBody = ` <qti-hottext-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
|
|
109312
|
+
<qti-prompt>${escapeXml(prompt.trim())}</qti-prompt>
|
|
109313
|
+
<p>${passage}</p>
|
|
109314
|
+
</qti-hottext-interaction>`;
|
|
109315
|
+
return buildExactMatchQuestionXml({
|
|
109316
|
+
identifier,
|
|
109317
|
+
title,
|
|
109318
|
+
responseIdentifier: "RESPONSE",
|
|
109319
|
+
cardinality: multiple ? "multiple" : "single",
|
|
109320
|
+
correctIdentifiers: correct.map((segment) => segment.identifier),
|
|
109321
|
+
itemBody
|
|
109322
|
+
});
|
|
109323
|
+
}
|
|
109324
|
+
function buildQuestionXml(input, identifier, title) {
|
|
109325
|
+
for (const build2 of QUESTION_XML_BUILDERS) {
|
|
109326
|
+
const xml = build2(input, identifier, title);
|
|
109327
|
+
if (xml !== null) {
|
|
109328
|
+
return xml;
|
|
109329
|
+
}
|
|
109330
|
+
}
|
|
109331
|
+
return null;
|
|
109332
|
+
}
|
|
109333
|
+
function buildCreatedQtiQuestionReference(input) {
|
|
109334
|
+
return {
|
|
109335
|
+
ownership: "owned",
|
|
109336
|
+
reference: {
|
|
109337
|
+
identifier: input.itemIdentifier,
|
|
109338
|
+
href: input.href,
|
|
109339
|
+
testPart: input.partIdentifier,
|
|
109340
|
+
section: input.sectionIdentifier
|
|
109341
|
+
},
|
|
109342
|
+
question: mergeQtiQuestionItem(input.item, input.fallbackItem, input.itemIdentifier, input.metadata)
|
|
109343
|
+
};
|
|
109344
|
+
}
|
|
109345
|
+
function buildHydratedQtiQuestionReference(input) {
|
|
109346
|
+
const identifier = input.reference.reference.identifier;
|
|
109347
|
+
const ownerGameSlug = resolveQtiQuestionOwnerGameSlug(input.item, input.ownerTest);
|
|
109348
|
+
const authoritativeItem = ownerGameSlug ? {
|
|
109349
|
+
...input.item,
|
|
109350
|
+
metadata: { ...input.item.metadata, ownerGameSlug }
|
|
109351
|
+
} : input.item;
|
|
109352
|
+
const question = mergeQtiQuestionItem(authoritativeItem, input.reference.question, identifier);
|
|
109353
|
+
return {
|
|
109354
|
+
...input.reference,
|
|
109355
|
+
ownership: isQtiQuestionOwnedByContext(question, { gameSlug: input.gameSlug, testIdentifier: input.testIdentifier }, input.ownerTest) ? "owned" : "shared",
|
|
109356
|
+
question
|
|
109357
|
+
};
|
|
109358
|
+
}
|
|
109359
|
+
function isQtiTestOwnedByGame(test, gameSlug) {
|
|
109360
|
+
return test.metadata?.ownerSystem === PLAYCADEMY_QTI_OWNER_SYSTEM && test.metadata.ownerGameSlug === gameSlug;
|
|
109361
|
+
}
|
|
109362
|
+
function assertQtiTestOwnedByGame(test, gameSlug) {
|
|
109363
|
+
if (!isQtiTestOwnedByGame(test, gameSlug)) {
|
|
109364
|
+
throw new ValidationError("Shared assessment references are read-only. Copy the assessment to create an editable independent test.");
|
|
109365
|
+
}
|
|
109366
|
+
}
|
|
109367
|
+
function qtiTestParts(test) {
|
|
109368
|
+
const parts2 = test["qti-test-part"];
|
|
109369
|
+
if (!Array.isArray(parts2) || parts2.length === 0) {
|
|
109370
|
+
throw new ValidationError(`Assessment ${test.identifier} has no test parts`);
|
|
109371
|
+
}
|
|
109372
|
+
for (const [partIndex, part] of parts2.entries()) {
|
|
109373
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) {
|
|
109374
|
+
throw new ValidationError(`Assessment ${test.identifier} contains an invalid test part at position ${partIndex + 1}`);
|
|
109375
|
+
}
|
|
109376
|
+
const sections = part["qti-assessment-section"];
|
|
109377
|
+
if (!Array.isArray(sections)) {
|
|
109378
|
+
throw new ValidationError(`Assessment ${test.identifier} test part ${partIndex + 1} has no section list`);
|
|
109379
|
+
}
|
|
109380
|
+
}
|
|
109381
|
+
return parts2;
|
|
109382
|
+
}
|
|
109383
|
+
function qtiTestOptionalAttributes(test) {
|
|
109384
|
+
return {
|
|
109385
|
+
...test.qtiVersion ? { qtiVersion: test.qtiVersion } : {},
|
|
109386
|
+
...test.timeLimit !== undefined ? { timeLimit: test.timeLimit } : {},
|
|
109387
|
+
...test.maxAttempts !== undefined ? { maxAttempts: test.maxAttempts } : {},
|
|
109388
|
+
...test.toolsEnabled !== undefined ? { toolsEnabled: test.toolsEnabled } : {}
|
|
109389
|
+
};
|
|
109390
|
+
}
|
|
109391
|
+
function buildQtiAssessmentItemCopyPlan(test, targetTestIdentifier, hrefForIdentifier, createIdentifier = newPlaycademyQuestionIdentifier) {
|
|
109392
|
+
const sourceIdentifiers = [];
|
|
109393
|
+
const seen = new Set;
|
|
109394
|
+
for (const part of qtiTestParts(test)) {
|
|
109395
|
+
for (const section of part["qti-assessment-section"]) {
|
|
109396
|
+
for (const reference of section["qti-assessment-item-ref"] ?? []) {
|
|
109397
|
+
if (!seen.has(reference.identifier)) {
|
|
109398
|
+
seen.add(reference.identifier);
|
|
109399
|
+
sourceIdentifiers.push(reference.identifier);
|
|
109400
|
+
}
|
|
109401
|
+
}
|
|
109402
|
+
}
|
|
109403
|
+
}
|
|
109404
|
+
return sourceIdentifiers.map((sourceIdentifier) => {
|
|
109405
|
+
const targetIdentifier = createIdentifier(targetTestIdentifier);
|
|
109406
|
+
return {
|
|
109407
|
+
sourceIdentifier,
|
|
109408
|
+
targetIdentifier,
|
|
109409
|
+
href: hrefForIdentifier(targetIdentifier)
|
|
109410
|
+
};
|
|
109411
|
+
});
|
|
109412
|
+
}
|
|
109413
|
+
function buildQtiTestStructureInput(test, targetTestIdentifier, itemCopies) {
|
|
109414
|
+
const outcomeDeclarations = test["qti-outcome-declaration"];
|
|
109415
|
+
if (outcomeDeclarations !== undefined && !Array.isArray(outcomeDeclarations)) {
|
|
109416
|
+
throw new ValidationError(`Assessment ${test.identifier} has an invalid outcome declaration list`);
|
|
109417
|
+
}
|
|
109418
|
+
return {
|
|
109419
|
+
"qti-test-part": qtiTestParts(test).map((part, partIndex) => ({
|
|
109420
|
+
identifier: targetTestIdentifier ? `${targetTestIdentifier}-part${partIndex + 1}` : part.identifier,
|
|
109421
|
+
navigationMode: part.navigationMode,
|
|
109422
|
+
submissionMode: part.submissionMode,
|
|
109423
|
+
"qti-assessment-section": part["qti-assessment-section"].map((section, sectionIndex) => {
|
|
109424
|
+
let sectionIdentifier = section.identifier;
|
|
109425
|
+
if (targetTestIdentifier) {
|
|
109426
|
+
sectionIdentifier = partIndex === 0 && sectionIndex === 0 ? `${targetTestIdentifier}-section1` : `${targetTestIdentifier}-part${partIndex + 1}-section${sectionIndex + 1}`;
|
|
109427
|
+
}
|
|
109428
|
+
return {
|
|
109429
|
+
identifier: sectionIdentifier,
|
|
109430
|
+
title: section.title,
|
|
109431
|
+
visible: section.visible ?? true,
|
|
109432
|
+
...section.required !== undefined ? { required: section.required } : {},
|
|
109433
|
+
...section.fixed !== undefined ? { fixed: section.fixed } : {},
|
|
109434
|
+
sequence: section.sequence ?? sectionIndex + 1,
|
|
109435
|
+
...section["qti-assessment-item-ref"] ? {
|
|
109436
|
+
"qti-assessment-item-ref": section["qti-assessment-item-ref"].map((item, itemIndex) => {
|
|
109437
|
+
const copy = itemCopies?.get(item.identifier);
|
|
109438
|
+
if (itemCopies && !copy) {
|
|
109439
|
+
throw new ValidationError(`Assessment copy is missing question ${item.identifier}`);
|
|
109440
|
+
}
|
|
109441
|
+
return {
|
|
109442
|
+
identifier: copy?.targetIdentifier ?? item.identifier,
|
|
109443
|
+
href: copy?.href ?? item.href,
|
|
109444
|
+
sequence: item.sequence ?? itemIndex + 1
|
|
109445
|
+
};
|
|
109446
|
+
})
|
|
109447
|
+
} : {}
|
|
109448
|
+
};
|
|
109449
|
+
})
|
|
109450
|
+
})),
|
|
109451
|
+
...outcomeDeclarations ? {
|
|
109452
|
+
"qti-outcome-declaration": outcomeDeclarations.map((declaration) => ({
|
|
109453
|
+
identifier: declaration.identifier,
|
|
109454
|
+
...declaration.cardinality !== undefined ? { cardinality: declaration.cardinality } : {},
|
|
109455
|
+
baseType: declaration.baseType,
|
|
109456
|
+
...declaration.normalMaximum !== undefined ? { normalMaximum: declaration.normalMaximum } : {},
|
|
109457
|
+
...declaration.normalMinimum !== undefined ? { normalMinimum: declaration.normalMinimum } : {},
|
|
109458
|
+
...declaration.defaultValue ? {
|
|
109459
|
+
defaultValue: declaration.defaultValue.value !== undefined ? { value: declaration.defaultValue.value } : {}
|
|
109460
|
+
} : {}
|
|
109461
|
+
}))
|
|
109462
|
+
} : {}
|
|
109463
|
+
};
|
|
109464
|
+
}
|
|
109465
|
+
function buildQtiTestUpdateInput(test, title) {
|
|
109466
|
+
return {
|
|
109467
|
+
title,
|
|
109468
|
+
...qtiTestOptionalAttributes(test),
|
|
109469
|
+
...test.metadata ? { metadata: test.metadata } : {},
|
|
109470
|
+
...buildQtiTestStructureInput(test)
|
|
109471
|
+
};
|
|
109472
|
+
}
|
|
109473
|
+
function buildQtiTestCopyInput(source, targetTestIdentifier, metadata2, itemCopies) {
|
|
109474
|
+
const copiesBySourceIdentifier = new Map(itemCopies.map((copy) => [copy.sourceIdentifier, copy]));
|
|
109475
|
+
return {
|
|
109476
|
+
identifier: targetTestIdentifier,
|
|
109477
|
+
title: `${source.title} (copy)`,
|
|
109478
|
+
...qtiTestOptionalAttributes(source),
|
|
109479
|
+
metadata: metadata2,
|
|
109480
|
+
...buildQtiTestStructureInput(source, targetTestIdentifier, copiesBySourceIdentifier)
|
|
109481
|
+
};
|
|
109482
|
+
}
|
|
109483
|
+
function validateUniqueQuestionIdentifiers(itemIdentifiers) {
|
|
109484
|
+
if (itemIdentifiers.length === 0 || new Set(itemIdentifiers).size !== itemIdentifiers.length) {
|
|
109485
|
+
throw new ValidationError("Question order must contain unique question identifiers");
|
|
109486
|
+
}
|
|
109487
|
+
}
|
|
109488
|
+
function resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers) {
|
|
109489
|
+
let selectedPart;
|
|
109490
|
+
let selectedSection;
|
|
109491
|
+
for (const part of qtiTestParts(test)) {
|
|
109492
|
+
for (const section of part["qti-assessment-section"]) {
|
|
109493
|
+
if (!itemIdentifier || section["qti-assessment-item-ref"]?.some((item) => item.identifier === itemIdentifier)) {
|
|
109494
|
+
selectedPart = part;
|
|
109495
|
+
selectedSection = section;
|
|
109496
|
+
break;
|
|
109497
|
+
}
|
|
109498
|
+
}
|
|
109499
|
+
if (selectedSection) {
|
|
109500
|
+
break;
|
|
109501
|
+
}
|
|
109502
|
+
}
|
|
109503
|
+
if (!selectedPart || !selectedSection) {
|
|
109504
|
+
throw new ValidationError(itemIdentifier ? `Question ${itemIdentifier} is not referenced by assessment ${qtiTestIdentifier}` : `Assessment ${qtiTestIdentifier} has no section for questions`);
|
|
109505
|
+
}
|
|
109506
|
+
if (expectedItemIdentifiers) {
|
|
109507
|
+
const sectionIdentifiers = selectedSection["qti-assessment-item-ref"]?.map((item) => item.identifier) ?? [];
|
|
109508
|
+
const expectedIdentifiers = new Set(expectedItemIdentifiers);
|
|
109509
|
+
if (expectedIdentifiers.size !== sectionIdentifiers.length || sectionIdentifiers.some((identifier) => !expectedIdentifiers.has(identifier))) {
|
|
109510
|
+
throw new ValidationError("Questions can only be reordered within one complete assessment section");
|
|
109511
|
+
}
|
|
109512
|
+
}
|
|
109513
|
+
return {
|
|
109514
|
+
partIdentifier: selectedPart.identifier,
|
|
109515
|
+
sectionIdentifier: selectedSection.identifier
|
|
109516
|
+
};
|
|
109517
|
+
}
|
|
109518
|
+
var PLAYCADEMY_QTI_AUTHORING_METADATA_KEY = "playcademyAuthoring";
|
|
109519
|
+
var QTI_AUTHORING_FIELDS;
|
|
109520
|
+
var QUESTION_XML_BUILDERS;
|
|
109521
|
+
var init_timeback_qti_authoring_util = __esm(() => {
|
|
109522
|
+
init_timeback3();
|
|
109523
|
+
init_errors();
|
|
109524
|
+
QTI_AUTHORING_FIELDS = [
|
|
109525
|
+
"type",
|
|
109526
|
+
"qtiVersion",
|
|
109527
|
+
"timeDependent",
|
|
109528
|
+
"adaptive",
|
|
109529
|
+
"preInteraction",
|
|
109530
|
+
"interaction",
|
|
109531
|
+
"postInteraction",
|
|
109532
|
+
"responseDeclarations",
|
|
109533
|
+
"outcomeDeclarations",
|
|
109534
|
+
"responseProcessing",
|
|
109535
|
+
"modalFeedback",
|
|
109536
|
+
"feedbackInline",
|
|
109537
|
+
"feedbackBlock",
|
|
109538
|
+
"rubrics",
|
|
109539
|
+
"stimulus",
|
|
109540
|
+
"content"
|
|
109541
|
+
];
|
|
109542
|
+
QUESTION_XML_BUILDERS = [
|
|
109543
|
+
buildNumericQuestionXml,
|
|
109544
|
+
buildHottextQuestionXml,
|
|
109545
|
+
buildInlineChoiceQuestionXml
|
|
109546
|
+
];
|
|
109547
|
+
});
|
|
109548
|
+
function newPlaycademyTestIdentifier() {
|
|
109549
|
+
return `${PLAYCADEMY_QTI_SOURCE_PREFIX}${crypto.randomUUID()}`;
|
|
109550
|
+
}
|
|
109551
|
+
function playcademySourceWhere() {
|
|
109552
|
+
return {
|
|
109553
|
+
identifier: {
|
|
109554
|
+
gte: PLAYCADEMY_QTI_SOURCE_PREFIX,
|
|
109555
|
+
lt: PLAYCADEMY_QTI_SOURCE_UPPER_BOUND
|
|
109556
|
+
}
|
|
109557
|
+
};
|
|
109558
|
+
}
|
|
109559
|
+
function buildQtiLibraryListPlan(params) {
|
|
109560
|
+
const query = params.query?.trim() || undefined;
|
|
109561
|
+
if (!params.source && !query) {
|
|
109562
|
+
throw new Error("An exact QTI source identifier is required for a global lookup");
|
|
109563
|
+
}
|
|
109564
|
+
let where;
|
|
109565
|
+
if (params.source) {
|
|
109566
|
+
where = playcademySourceWhere();
|
|
109567
|
+
} else if (query) {
|
|
109568
|
+
where = { identifier: query };
|
|
109569
|
+
}
|
|
109570
|
+
return {
|
|
109571
|
+
kind: "direct",
|
|
109572
|
+
params: {
|
|
109573
|
+
...where ? { where } : {},
|
|
109574
|
+
...params.source && query ? { query } : {},
|
|
109575
|
+
page: params.page,
|
|
109576
|
+
limit: params.limit,
|
|
109577
|
+
...params.source ? { sort: "identifier", order: "asc" } : {}
|
|
109578
|
+
}
|
|
109579
|
+
};
|
|
109580
|
+
}
|
|
109581
|
+
var PLAYCADEMY_QTI_SOURCE_PREFIX = "playcademy-test-";
|
|
109582
|
+
var PLAYCADEMY_QTI_SOURCE_UPPER_BOUND = "playcademy-test.";
|
|
108636
109583
|
|
|
108637
109584
|
class TimebackAssessmentsService {
|
|
109585
|
+
static QTI_HYDRATION_CONCURRENCY = 8;
|
|
108638
109586
|
deps;
|
|
108639
109587
|
constructor(deps) {
|
|
108640
109588
|
this.deps = deps;
|
|
@@ -108651,33 +109599,19 @@ class TimebackAssessmentsService {
|
|
|
108651
109599
|
}
|
|
108652
109600
|
async listAssessments(integrationId) {
|
|
108653
109601
|
const client = this.requireClient();
|
|
108654
|
-
await this.
|
|
109602
|
+
const ownership = await this.requireQtiTestOwnershipContext(integrationId);
|
|
108655
109603
|
const rows = await this.deps.db.query.gameTimebackAssessmentTests.findMany({
|
|
108656
|
-
where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
|
|
108657
|
-
orderBy: asc(gameTimebackAssessmentTests.sortOrder)
|
|
109604
|
+
where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
|
|
108658
109605
|
});
|
|
108659
|
-
|
|
108660
|
-
return [];
|
|
108661
|
-
}
|
|
108662
|
-
const assessments = await Promise.all(rows.map(async (row) => {
|
|
109606
|
+
const assessments = await runWithConcurrency(rows, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (row) => {
|
|
108663
109607
|
try {
|
|
108664
109608
|
const test = await client.qtiApi.assessmentTests.get(row.qtiTestIdentifier);
|
|
108665
|
-
let itemCount = 0;
|
|
108666
|
-
for (const part of test["qti-test-part"] ?? []) {
|
|
108667
|
-
for (const section of part["qti-assessment-section"] ?? []) {
|
|
108668
|
-
itemCount += section["qti-assessment-item-ref"]?.length ?? 0;
|
|
108669
|
-
}
|
|
108670
|
-
}
|
|
108671
109609
|
return {
|
|
108672
|
-
|
|
108673
|
-
integrationId: row.integrationId,
|
|
108674
|
-
qtiTestIdentifier: row.qtiTestIdentifier,
|
|
108675
|
-
bankResourceId: row.bankResourceId,
|
|
108676
|
-
bankActive: row.bankActive,
|
|
108677
|
-
sortOrder: row.sortOrder,
|
|
109610
|
+
...this.associationSummary(row),
|
|
108678
109611
|
title: test.title,
|
|
108679
|
-
questionCount:
|
|
108680
|
-
|
|
109612
|
+
questionCount: countQtiTestItems(test),
|
|
109613
|
+
available: true,
|
|
109614
|
+
editable: isQtiTestOwnedByGame(test, ownership.gameSlug)
|
|
108681
109615
|
};
|
|
108682
109616
|
} catch (error88) {
|
|
108683
109617
|
addEvent("assessment.qti_fetch_failed", {
|
|
@@ -108686,319 +109620,420 @@ class TimebackAssessmentsService {
|
|
|
108686
109620
|
"app.error.message": errorMessage2(error88)
|
|
108687
109621
|
});
|
|
108688
109622
|
return {
|
|
108689
|
-
|
|
108690
|
-
integrationId: row.integrationId,
|
|
108691
|
-
qtiTestIdentifier: row.qtiTestIdentifier,
|
|
108692
|
-
bankResourceId: row.bankResourceId,
|
|
108693
|
-
bankActive: row.bankActive,
|
|
108694
|
-
sortOrder: row.sortOrder,
|
|
109623
|
+
...this.associationSummary(row),
|
|
108695
109624
|
title: row.qtiTestIdentifier,
|
|
108696
109625
|
questionCount: 0,
|
|
108697
|
-
|
|
109626
|
+
available: false,
|
|
109627
|
+
editable: false
|
|
108698
109628
|
};
|
|
108699
109629
|
}
|
|
108700
|
-
})
|
|
108701
|
-
return assessments;
|
|
109630
|
+
});
|
|
109631
|
+
return assessments.toSorted((a, b) => a.title.localeCompare(b.title));
|
|
108702
109632
|
}
|
|
108703
109633
|
async createAssessment(integrationId, input) {
|
|
108704
109634
|
const client = this.requireClient();
|
|
108705
|
-
const
|
|
108706
|
-
|
|
108707
|
-
courseId: integration.courseId,
|
|
108708
|
-
subject: integration.subject,
|
|
108709
|
-
grade: integration.grade
|
|
108710
|
-
});
|
|
109635
|
+
const ownership = await this.requireQtiTestOwnershipContext(integrationId);
|
|
109636
|
+
const { integration } = ownership;
|
|
108711
109637
|
await client.course.createAssessmentTest({
|
|
108712
109638
|
identifier: input.qtiTestIdentifier,
|
|
108713
109639
|
title: input.title,
|
|
108714
109640
|
metadata: {
|
|
109641
|
+
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
109642
|
+
ownerGameSlug: ownership.gameSlug,
|
|
108715
109643
|
integrationId,
|
|
108716
109644
|
subject: integration.subject,
|
|
108717
109645
|
grade: String(integration.grade)
|
|
108718
109646
|
}
|
|
108719
109647
|
});
|
|
108720
|
-
const maxSortOrder = await this.getMaxSortOrder(integrationId);
|
|
108721
|
-
const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
|
|
108722
|
-
integrationId,
|
|
108723
|
-
qtiTestIdentifier: input.qtiTestIdentifier,
|
|
108724
|
-
sortOrder: maxSortOrder + 1
|
|
108725
|
-
}).returning();
|
|
108726
|
-
setAttribute("app.assessment.operation", "create");
|
|
108727
|
-
return row;
|
|
108728
|
-
}
|
|
108729
|
-
async deleteAssessment(integrationId, qtiTestIdentifier) {
|
|
108730
|
-
const client = this.requireClient();
|
|
108731
|
-
const row = await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
|
|
108732
|
-
if (row.bankActive) {
|
|
108733
|
-
await this.deactivateAssessment(integrationId, qtiTestIdentifier);
|
|
108734
|
-
}
|
|
108735
109648
|
try {
|
|
108736
|
-
await
|
|
109649
|
+
const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
|
|
109650
|
+
integrationId,
|
|
109651
|
+
qtiTestIdentifier: input.qtiTestIdentifier,
|
|
109652
|
+
purpose: input.purpose,
|
|
109653
|
+
status: "draft"
|
|
109654
|
+
}).returning();
|
|
109655
|
+
setAttribute("app.assessment.operation", "create");
|
|
109656
|
+
return row;
|
|
108737
109657
|
} catch (error88) {
|
|
108738
|
-
|
|
108739
|
-
|
|
108740
|
-
|
|
108741
|
-
|
|
108742
|
-
|
|
109658
|
+
let committedRow;
|
|
109659
|
+
try {
|
|
109660
|
+
committedRow = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
|
|
109661
|
+
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, input.qtiTestIdentifier))
|
|
109662
|
+
});
|
|
109663
|
+
} catch (verificationError) {
|
|
109664
|
+
addEvent("assessment.qti_test_create_commit_verification_failed", {
|
|
109665
|
+
"app.assessment.qti_test_identifier": input.qtiTestIdentifier,
|
|
109666
|
+
"exception.type": errorType(verificationError),
|
|
109667
|
+
"app.error.message": errorMessage2(verificationError)
|
|
109668
|
+
});
|
|
109669
|
+
throw error88;
|
|
109670
|
+
}
|
|
109671
|
+
if (committedRow) {
|
|
109672
|
+
setAttribute("app.assessment.operation", "create");
|
|
109673
|
+
return committedRow;
|
|
109674
|
+
}
|
|
109675
|
+
try {
|
|
109676
|
+
await client.qtiApi.assessmentTests.delete(input.qtiTestIdentifier);
|
|
109677
|
+
} catch (cleanupError) {
|
|
109678
|
+
if (!isApiError(cleanupError) || cleanupError.statusCode !== 404) {
|
|
109679
|
+
addEvent("assessment.qti_test_create_cleanup_failed", {
|
|
109680
|
+
"app.assessment.qti_test_identifier": input.qtiTestIdentifier,
|
|
109681
|
+
"exception.type": errorType(cleanupError),
|
|
109682
|
+
"app.error.message": errorMessage2(cleanupError)
|
|
109683
|
+
});
|
|
109684
|
+
}
|
|
109685
|
+
}
|
|
109686
|
+
throw error88;
|
|
108743
109687
|
}
|
|
108744
|
-
await this.deps.db.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
|
|
108745
|
-
setAttribute("app.assessment.operation", "delete");
|
|
108746
109688
|
}
|
|
108747
|
-
async
|
|
108748
|
-
|
|
108749
|
-
|
|
108750
|
-
|
|
108751
|
-
|
|
108752
|
-
|
|
108753
|
-
|
|
108754
|
-
|
|
108755
|
-
|
|
108756
|
-
|
|
108757
|
-
|
|
108758
|
-
|
|
108759
|
-
|
|
108760
|
-
|
|
108761
|
-
|
|
108762
|
-
|
|
108763
|
-
|
|
108764
|
-
|
|
108765
|
-
|
|
108766
|
-
|
|
109689
|
+
async updateAssessment(integrationId, qtiTestIdentifier, input) {
|
|
109690
|
+
return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
|
|
109691
|
+
const updates = buildAssessmentAssociationUpdates(row, input);
|
|
109692
|
+
if (input.status !== undefined) {
|
|
109693
|
+
validateAssessmentStatusTransition(row.status, input.status);
|
|
109694
|
+
if (isAssessmentPublicationTransition(row.status, input.status)) {
|
|
109695
|
+
await this.validateAssessmentHasQuestions(qtiTestIdentifier);
|
|
109696
|
+
}
|
|
109697
|
+
}
|
|
109698
|
+
if (input.title !== undefined) {
|
|
109699
|
+
assertDraftAssessment(row);
|
|
109700
|
+
assertAllAssessmentAssociationsDraft(associations);
|
|
109701
|
+
const client = this.requireClient();
|
|
109702
|
+
const test = await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
|
|
109703
|
+
const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
|
|
109704
|
+
assertQtiTestOwnedByGame(test, ownership.gameSlug);
|
|
109705
|
+
await client.qtiApi.assessmentTests.update(qtiTestIdentifier, buildQtiTestUpdateInput(test, input.title));
|
|
109706
|
+
}
|
|
109707
|
+
let updated = row;
|
|
109708
|
+
if (Object.keys(updates).length > 0) {
|
|
109709
|
+
const [updatedRow] = await tx.update(gameTimebackAssessmentTests).set({ ...updates, updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id)).returning();
|
|
109710
|
+
updated = updatedRow ?? row;
|
|
109711
|
+
}
|
|
109712
|
+
setAttribute("app.assessment.operation", input.title !== undefined ? "update_test" : "update_association");
|
|
109713
|
+
return updated;
|
|
108767
109714
|
});
|
|
108768
|
-
return item;
|
|
108769
109715
|
}
|
|
108770
|
-
async
|
|
108771
|
-
|
|
108772
|
-
|
|
108773
|
-
|
|
108774
|
-
|
|
108775
|
-
|
|
108776
|
-
|
|
108777
|
-
|
|
108778
|
-
|
|
108779
|
-
|
|
109716
|
+
async removeAssessment(integrationId, qtiTestIdentifier) {
|
|
109717
|
+
return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx) => {
|
|
109718
|
+
const plan = planAssessmentRemoval(row.status);
|
|
109719
|
+
if (plan.kind === "delete") {
|
|
109720
|
+
await tx.delete(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.id, row.id));
|
|
109721
|
+
} else if (plan.kind === "archive") {
|
|
109722
|
+
await tx.update(gameTimebackAssessmentTests).set({ status: "archived", sortOrder: null, updatedAt: new Date }).where(eq(gameTimebackAssessmentTests.id, row.id));
|
|
109723
|
+
}
|
|
109724
|
+
if (plan.kind !== "none") {
|
|
109725
|
+
setAttribute("app.assessment.operation", plan.operation);
|
|
109726
|
+
}
|
|
109727
|
+
return { action: plan.action };
|
|
109728
|
+
});
|
|
108780
109729
|
}
|
|
108781
|
-
async
|
|
108782
|
-
|
|
108783
|
-
|
|
108784
|
-
const
|
|
108785
|
-
|
|
108786
|
-
|
|
108787
|
-
|
|
108788
|
-
|
|
108789
|
-
|
|
108790
|
-
|
|
109730
|
+
async reorderAssessments(integrationId, purpose, testIdentifiers) {
|
|
109731
|
+
await this.requireIntegration(integrationId);
|
|
109732
|
+
validateUniqueAssessmentIdentifiers(testIdentifiers);
|
|
109733
|
+
const updatedAt = new Date;
|
|
109734
|
+
await this.deps.db.transaction(async (tx) => {
|
|
109735
|
+
const liveRows = await tx.query.gameTimebackAssessmentTests.findMany({
|
|
109736
|
+
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))
|
|
109737
|
+
});
|
|
109738
|
+
const orderedRows = orderLiveAssessmentRows(liveRows, purpose, testIdentifiers);
|
|
109739
|
+
const positionByRowId = new Map(orderedRows.map((row, index2) => [row.id, index2 + 1]));
|
|
109740
|
+
for (const row of lockOrderAssessmentRows(orderedRows)) {
|
|
109741
|
+
const testIdentifier = row.qtiTestIdentifier;
|
|
109742
|
+
const [updatedRow] = await tx.update(gameTimebackAssessmentTests).set({ sortOrder: positionByRowId.get(row.id), updatedAt }).where(and(eq(gameTimebackAssessmentTests.id, row.id), eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, testIdentifier), eq(gameTimebackAssessmentTests.purpose, purpose), eq(gameTimebackAssessmentTests.status, "live"))).returning({ id: gameTimebackAssessmentTests.id });
|
|
109743
|
+
assertAssessmentOrderUpdateSucceeded(updatedRow);
|
|
109744
|
+
}
|
|
108791
109745
|
});
|
|
109746
|
+
setAttribute("app.assessment.operation", "reorder_live_assessments");
|
|
108792
109747
|
}
|
|
108793
|
-
async
|
|
109748
|
+
async listQuestions(integrationId, qtiTestIdentifier) {
|
|
108794
109749
|
const client = this.requireClient();
|
|
108795
|
-
|
|
108796
|
-
const
|
|
108797
|
-
|
|
108798
|
-
|
|
108799
|
-
|
|
108800
|
-
|
|
108801
|
-
const qtiTestUrl = `${client.getQtiBaseUrl()}/assessment-tests/${qtiTestIdentifier}`;
|
|
108802
|
-
await client.course.ensureAssessmentBank({
|
|
108803
|
-
courseId: integration.courseId,
|
|
108804
|
-
subject: integration.subject,
|
|
108805
|
-
grade: integration.grade
|
|
108806
|
-
});
|
|
108807
|
-
const parentResource = await client.api.oneroster.resources.get(bankIds.resource);
|
|
108808
|
-
const currentResources = parentResource.metadata?.resources ?? [];
|
|
108809
|
-
let childResourceId = row.bankResourceId;
|
|
108810
|
-
if (childResourceId) {
|
|
109750
|
+
await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
|
|
109751
|
+
const { gameSlug } = await this.requireQtiTestOwnershipContext(integrationId);
|
|
109752
|
+
const result = await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
|
|
109753
|
+
const ownerTests = new Map;
|
|
109754
|
+
const questions = await runWithConcurrency(result.questions, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (reference) => {
|
|
109755
|
+
let item;
|
|
108811
109756
|
try {
|
|
108812
|
-
await client.
|
|
108813
|
-
status: "active",
|
|
108814
|
-
title: `Assessment Bank Test: ${qtiTestIdentifier}`,
|
|
108815
|
-
vendorResourceId: "",
|
|
108816
|
-
metadata: {
|
|
108817
|
-
type: "qti",
|
|
108818
|
-
subType: "qti-test",
|
|
108819
|
-
url: qtiTestUrl
|
|
108820
|
-
}
|
|
108821
|
-
});
|
|
108822
|
-
if (!currentResources.includes(childResourceId)) {
|
|
108823
|
-
await client.api.oneroster.resources.update(bankIds.resource, {
|
|
108824
|
-
status: "active",
|
|
108825
|
-
title: parentResource.title,
|
|
108826
|
-
vendorResourceId: parentResource.vendorResourceId,
|
|
108827
|
-
vendorId: parentResource.vendorId,
|
|
108828
|
-
metadata: {
|
|
108829
|
-
...parentResource.metadata,
|
|
108830
|
-
resources: [...currentResources, childResourceId]
|
|
108831
|
-
}
|
|
108832
|
-
});
|
|
108833
|
-
}
|
|
109757
|
+
item = await client.qtiApi.assessmentItems.get(reference.reference.identifier);
|
|
108834
109758
|
} catch (error88) {
|
|
108835
|
-
addEvent("assessment.
|
|
108836
|
-
"app.assessment.
|
|
109759
|
+
addEvent("assessment.qti_question_hydration_failed", {
|
|
109760
|
+
"app.assessment.qti_test_identifier": qtiTestIdentifier,
|
|
109761
|
+
"app.assessment.qti_item_identifier": reference.reference.identifier,
|
|
108837
109762
|
"exception.type": errorType(error88),
|
|
108838
109763
|
"app.error.message": errorMessage2(error88)
|
|
108839
109764
|
});
|
|
108840
|
-
|
|
108841
|
-
}
|
|
108842
|
-
}
|
|
108843
|
-
if (!childResourceId) {
|
|
108844
|
-
for (const resourceId of currentResources) {
|
|
108845
|
-
try {
|
|
108846
|
-
const resource = await client.api.oneroster.resources.get(resourceId);
|
|
108847
|
-
const resourceUrl = resource.metadata?.url;
|
|
108848
|
-
if (resourceUrl === qtiTestUrl) {
|
|
108849
|
-
childResourceId = resourceId;
|
|
108850
|
-
addEvent("assessment.child_resource_reused", {
|
|
108851
|
-
"app.assessment.qti_test_identifier": qtiTestIdentifier,
|
|
108852
|
-
"app.assessment.child_resource_id": resourceId
|
|
108853
|
-
});
|
|
108854
|
-
break;
|
|
108855
|
-
}
|
|
108856
|
-
} catch {}
|
|
109765
|
+
return reference;
|
|
108857
109766
|
}
|
|
108858
|
-
|
|
108859
|
-
|
|
108860
|
-
|
|
108861
|
-
|
|
108862
|
-
|
|
108863
|
-
|
|
108864
|
-
|
|
108865
|
-
|
|
108866
|
-
|
|
108867
|
-
url: qtiTestUrl
|
|
108868
|
-
}
|
|
108869
|
-
});
|
|
108870
|
-
childResourceId = childResult.sourcedIdPairs.allocatedSourcedId;
|
|
108871
|
-
await client.api.oneroster.resources.update(bankIds.resource, {
|
|
108872
|
-
status: "active",
|
|
108873
|
-
title: parentResource.title,
|
|
108874
|
-
vendorResourceId: parentResource.vendorResourceId,
|
|
108875
|
-
vendorId: parentResource.vendorId,
|
|
108876
|
-
metadata: {
|
|
108877
|
-
...parentResource.metadata,
|
|
108878
|
-
resources: [...currentResources, childResourceId]
|
|
108879
|
-
}
|
|
109767
|
+
let ownerTest;
|
|
109768
|
+
try {
|
|
109769
|
+
ownerTest = await this.qtiQuestionOwnerTest(client, item, ownerTests);
|
|
109770
|
+
} catch (error88) {
|
|
109771
|
+
addEvent("assessment.qti_question_owner_test_fetch_failed", {
|
|
109772
|
+
"app.assessment.qti_test_identifier": qtiTestIdentifier,
|
|
109773
|
+
"app.assessment.qti_item_identifier": reference.reference.identifier,
|
|
109774
|
+
"exception.type": errorType(error88),
|
|
109775
|
+
"app.error.message": errorMessage2(error88)
|
|
108880
109776
|
});
|
|
108881
109777
|
}
|
|
108882
|
-
|
|
108883
|
-
|
|
108884
|
-
|
|
108885
|
-
|
|
108886
|
-
|
|
109778
|
+
return buildHydratedQtiQuestionReference({
|
|
109779
|
+
reference,
|
|
109780
|
+
item,
|
|
109781
|
+
gameSlug,
|
|
109782
|
+
ownerTest,
|
|
109783
|
+
testIdentifier: qtiTestIdentifier
|
|
109784
|
+
});
|
|
108887
109785
|
});
|
|
109786
|
+
return { ...result, questions };
|
|
108888
109787
|
}
|
|
108889
|
-
async
|
|
109788
|
+
async listQuestionLibrary(integrationId, params) {
|
|
108890
109789
|
const client = this.requireClient();
|
|
108891
|
-
|
|
108892
|
-
|
|
108893
|
-
if (!row.bankActive) {
|
|
108894
|
-
throw new ValidationError("Assessment is already in draft");
|
|
108895
|
-
}
|
|
108896
|
-
if (!row.bankResourceId) {
|
|
108897
|
-
throw new ValidationError("Assessment has no bank resource — activate it first");
|
|
108898
|
-
}
|
|
108899
|
-
const childResourceId = row.bankResourceId;
|
|
108900
|
-
const bankIds = deriveAssessmentBankIds2(integration.courseId);
|
|
108901
|
-
try {
|
|
108902
|
-
const parentResource = await client.api.oneroster.resources.get(bankIds.resource);
|
|
108903
|
-
const currentResources = parentResource.metadata?.resources ?? [];
|
|
108904
|
-
const filtered = currentResources.filter((id) => id !== childResourceId);
|
|
108905
|
-
if (filtered.length !== currentResources.length) {
|
|
108906
|
-
await client.api.oneroster.resources.update(bankIds.resource, {
|
|
108907
|
-
status: "active",
|
|
108908
|
-
title: parentResource.title,
|
|
108909
|
-
vendorResourceId: parentResource.vendorResourceId,
|
|
108910
|
-
vendorId: parentResource.vendorId,
|
|
108911
|
-
metadata: {
|
|
108912
|
-
...parentResource.metadata,
|
|
108913
|
-
resources: filtered
|
|
108914
|
-
}
|
|
108915
|
-
});
|
|
108916
|
-
}
|
|
108917
|
-
} catch (error88) {
|
|
108918
|
-
addEvent("assessment.parent_resource_update_failed", {
|
|
108919
|
-
"app.assessment.bank_resource_id": bankIds.resource,
|
|
108920
|
-
"exception.type": errorType(error88),
|
|
108921
|
-
"app.error.message": errorMessage2(error88)
|
|
108922
|
-
});
|
|
108923
|
-
}
|
|
108924
|
-
try {
|
|
108925
|
-
await client.api.oneroster.resources.delete(childResourceId);
|
|
108926
|
-
} catch (error88) {
|
|
108927
|
-
addEvent("assessment.child_resource_delete_failed", {
|
|
108928
|
-
"app.assessment.child_resource_id": childResourceId,
|
|
108929
|
-
"exception.type": errorType(error88),
|
|
108930
|
-
"app.error.message": errorMessage2(error88)
|
|
108931
|
-
});
|
|
108932
|
-
}
|
|
108933
|
-
await this.deps.db.update(gameTimebackAssessmentTests).set({ bankActive: false }).where(eq(gameTimebackAssessmentTests.id, row.id));
|
|
108934
|
-
setAttribute("app.assessment.operation", "deactivate");
|
|
108935
|
-
}
|
|
108936
|
-
isAssessmentActive(row) {
|
|
108937
|
-
return row.bankActive;
|
|
109790
|
+
await this.requireIntegration(integrationId);
|
|
109791
|
+
return this.listQtiLibrary(params, async (listParams) => await client.qtiApi.assessmentItems.list(listParams));
|
|
108938
109792
|
}
|
|
108939
|
-
async
|
|
108940
|
-
const
|
|
108941
|
-
|
|
108942
|
-
|
|
108943
|
-
where: eq(gameTimebackAssessmentTests.integrationId, integrationId)
|
|
108944
|
-
});
|
|
108945
|
-
const activeCount = rows.filter((r) => r.bankActive).length;
|
|
108946
|
-
return {
|
|
108947
|
-
bankIds,
|
|
108948
|
-
totalAssessments: rows.length,
|
|
108949
|
-
activeAssessments: activeCount,
|
|
108950
|
-
draftAssessments: rows.length - activeCount
|
|
108951
|
-
};
|
|
109793
|
+
async listTestLibrary(integrationId, params) {
|
|
109794
|
+
const client = this.requireClient();
|
|
109795
|
+
await this.requireIntegration(integrationId);
|
|
109796
|
+
return this.listQtiLibrary(params, async (listParams) => await client.qtiApi.assessmentTests.list(listParams));
|
|
108952
109797
|
}
|
|
108953
|
-
async
|
|
109798
|
+
async copyAssessment(integrationId, sourceTestIdentifier, targetTestIdentifier, purpose) {
|
|
108954
109799
|
const client = this.requireClient();
|
|
108955
|
-
const
|
|
108956
|
-
const
|
|
108957
|
-
const
|
|
108958
|
-
|
|
109800
|
+
const ownership = await this.requireQtiTestOwnershipContext(integrationId);
|
|
109801
|
+
const { integration } = ownership;
|
|
109802
|
+
const source = await client.qtiApi.assessmentTests.get(sourceTestIdentifier);
|
|
109803
|
+
const itemPlan = buildQtiAssessmentItemCopyPlan(source, targetTestIdentifier, (identifier) => this.qtiItemHref(client, identifier));
|
|
109804
|
+
const itemCopies = await runWithConcurrency(itemPlan, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (plan) => {
|
|
109805
|
+
const sourceItem = await client.qtiApi.assessmentItems.get(plan.sourceIdentifier);
|
|
109806
|
+
return {
|
|
109807
|
+
plan,
|
|
109808
|
+
input: buildQtiQuestionCopyInput({
|
|
109809
|
+
source: sourceItem,
|
|
109810
|
+
targetIdentifier: plan.targetIdentifier,
|
|
109811
|
+
targetTestIdentifier,
|
|
109812
|
+
gameSlug: ownership.gameSlug,
|
|
109813
|
+
integrationId
|
|
109814
|
+
})
|
|
109815
|
+
};
|
|
108959
109816
|
});
|
|
109817
|
+
const testInput = buildQtiTestCopyInput(source, targetTestIdentifier, {
|
|
109818
|
+
...source.metadata,
|
|
109819
|
+
ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
|
|
109820
|
+
ownerGameSlug: ownership.gameSlug,
|
|
109821
|
+
integrationId,
|
|
109822
|
+
subject: integration.subject,
|
|
109823
|
+
grade: String(integration.grade),
|
|
109824
|
+
copiedFromTestIdentifier: sourceTestIdentifier
|
|
109825
|
+
}, itemPlan);
|
|
109826
|
+
const attemptedItemIdentifiers = [];
|
|
109827
|
+
let testCreationAttempted = false;
|
|
109828
|
+
let associationCreationAttempted = false;
|
|
108960
109829
|
try {
|
|
108961
|
-
|
|
109830
|
+
for (const copy of itemCopies) {
|
|
109831
|
+
attemptedItemIdentifiers.push(copy.plan.targetIdentifier);
|
|
109832
|
+
await client.course.createAssessmentItemXml({
|
|
109833
|
+
xml: copy.input.xml,
|
|
109834
|
+
metadata: copy.input.metadata
|
|
109835
|
+
});
|
|
109836
|
+
}
|
|
109837
|
+
testCreationAttempted = true;
|
|
109838
|
+
await client.qtiApi.assessmentTests.create(testInput);
|
|
109839
|
+
associationCreationAttempted = true;
|
|
109840
|
+
const [row] = await this.deps.db.insert(gameTimebackAssessmentTests).values({
|
|
109841
|
+
integrationId,
|
|
109842
|
+
qtiTestIdentifier: targetTestIdentifier,
|
|
109843
|
+
purpose,
|
|
109844
|
+
status: "draft"
|
|
109845
|
+
}).returning();
|
|
109846
|
+
setAttribute("app.assessment.operation", "copy_test");
|
|
109847
|
+
return row;
|
|
108962
109848
|
} catch (error88) {
|
|
108963
|
-
|
|
108964
|
-
|
|
108965
|
-
"exception.type": errorType(error88),
|
|
108966
|
-
"app.error.message": errorMessage2(error88)
|
|
108967
|
-
});
|
|
108968
|
-
}
|
|
108969
|
-
for (const row of activeRows) {
|
|
108970
|
-
if (row.bankResourceId) {
|
|
109849
|
+
if (associationCreationAttempted) {
|
|
109850
|
+
let committedRow;
|
|
108971
109851
|
try {
|
|
108972
|
-
await
|
|
108973
|
-
|
|
108974
|
-
|
|
108975
|
-
|
|
108976
|
-
|
|
108977
|
-
"app.
|
|
109852
|
+
committedRow = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
|
|
109853
|
+
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, targetTestIdentifier))
|
|
109854
|
+
});
|
|
109855
|
+
} catch (verificationError) {
|
|
109856
|
+
addEvent("assessment.qti_test_copy_commit_verification_failed", {
|
|
109857
|
+
"app.assessment.qti_test_identifier": targetTestIdentifier,
|
|
109858
|
+
"exception.type": errorType(verificationError),
|
|
109859
|
+
"app.error.message": errorMessage2(verificationError)
|
|
108978
109860
|
});
|
|
109861
|
+
throw error88;
|
|
109862
|
+
}
|
|
109863
|
+
if (committedRow) {
|
|
109864
|
+
setAttribute("app.assessment.operation", "copy_test");
|
|
109865
|
+
return committedRow;
|
|
108979
109866
|
}
|
|
108980
109867
|
}
|
|
109868
|
+
await this.cleanupQtiAssessmentCopy(client, testCreationAttempted ? targetTestIdentifier : undefined, attemptedItemIdentifiers);
|
|
109869
|
+
throw error88;
|
|
108981
109870
|
}
|
|
108982
|
-
|
|
108983
|
-
|
|
108984
|
-
|
|
108985
|
-
|
|
108986
|
-
|
|
108987
|
-
|
|
108988
|
-
|
|
109871
|
+
}
|
|
109872
|
+
async createQuestion(integrationId, qtiTestIdentifier, input) {
|
|
109873
|
+
return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
|
|
109874
|
+
const client = this.requireClient();
|
|
109875
|
+
assertAllAssessmentAssociationsDraft(associations);
|
|
109876
|
+
const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
|
|
109877
|
+
const creation = parseQtiQuestionCreationInput(input);
|
|
109878
|
+
const itemIdentifier = newPlaycademyQuestionIdentifier(qtiTestIdentifier);
|
|
109879
|
+
let title;
|
|
109880
|
+
let metadata2;
|
|
109881
|
+
let xml;
|
|
109882
|
+
let structuredInput;
|
|
109883
|
+
let fallbackItem;
|
|
109884
|
+
if (creation.kind === "copy") {
|
|
109885
|
+
const source = await client.qtiApi.assessmentItems.get(creation.sourceItemIdentifier);
|
|
109886
|
+
const copy = buildQtiQuestionCopyInput({
|
|
109887
|
+
source,
|
|
109888
|
+
targetIdentifier: itemIdentifier,
|
|
109889
|
+
targetTestIdentifier: qtiTestIdentifier,
|
|
109890
|
+
gameSlug: ownership.gameSlug,
|
|
109891
|
+
integrationId
|
|
109892
|
+
});
|
|
109893
|
+
title = copy.title;
|
|
109894
|
+
metadata2 = copy.metadata;
|
|
109895
|
+
xml = copy.xml;
|
|
109896
|
+
fallbackItem = {
|
|
109897
|
+
...source,
|
|
109898
|
+
identifier: itemIdentifier,
|
|
109899
|
+
title,
|
|
109900
|
+
rawXml: xml,
|
|
109901
|
+
metadata: metadata2
|
|
109902
|
+
};
|
|
109903
|
+
} else {
|
|
109904
|
+
structuredInput = creation.input;
|
|
109905
|
+
assertSupportedQtiQuestionInteraction(structuredInput);
|
|
109906
|
+
title = typeof structuredInput.title === "string" ? structuredInput.title.trim() : "";
|
|
109907
|
+
metadata2 = buildOwnedQtiQuestionMetadata(structuredInput, {
|
|
109908
|
+
gameSlug: ownership.gameSlug,
|
|
109909
|
+
testIdentifier: qtiTestIdentifier
|
|
109910
|
+
});
|
|
109911
|
+
xml = buildQuestionXml(structuredInput, itemIdentifier, title);
|
|
109912
|
+
fallbackItem = {
|
|
109913
|
+
...structuredInput,
|
|
109914
|
+
identifier: itemIdentifier,
|
|
109915
|
+
title,
|
|
109916
|
+
...xml ? { rawXml: xml } : {}
|
|
109917
|
+
};
|
|
109918
|
+
}
|
|
109919
|
+
const section = await this.qtiSectionItems(client, qtiTestIdentifier, undefined, undefined, ownership.test);
|
|
109920
|
+
const href = this.qtiItemHref(client, itemIdentifier);
|
|
109921
|
+
let item;
|
|
109922
|
+
let itemCreationAttempted = false;
|
|
109923
|
+
let referenceCreationAttempted = false;
|
|
109924
|
+
try {
|
|
109925
|
+
itemCreationAttempted = true;
|
|
109926
|
+
item = xml ? await client.course.createAssessmentItemXml({ xml, metadata: metadata2 }) : await client.qtiApi.assessmentItems.create({
|
|
109927
|
+
...structuredInput,
|
|
109928
|
+
identifier: itemIdentifier,
|
|
109929
|
+
metadata: metadata2
|
|
109930
|
+
});
|
|
109931
|
+
referenceCreationAttempted = true;
|
|
109932
|
+
await section.items.add({
|
|
109933
|
+
identifier: itemIdentifier,
|
|
109934
|
+
href
|
|
109935
|
+
});
|
|
109936
|
+
} catch (error88) {
|
|
109937
|
+
let referenceRemoved = !referenceCreationAttempted;
|
|
109938
|
+
const creationStatus = isApiError(error88) ? error88.statusCode : undefined;
|
|
109939
|
+
const itemCreationDefinitelyRejected = !referenceCreationAttempted && typeof creationStatus === "number" && creationStatus >= 400 && creationStatus < 500 && creationStatus !== 408;
|
|
109940
|
+
if (referenceCreationAttempted) {
|
|
109941
|
+
try {
|
|
109942
|
+
await section.items.remove(itemIdentifier);
|
|
109943
|
+
referenceRemoved = true;
|
|
109944
|
+
} catch (cleanupError) {
|
|
109945
|
+
referenceRemoved = isApiError(cleanupError) && cleanupError.statusCode === 404;
|
|
109946
|
+
addEvent("assessment.qti_question_reference_cleanup_failed", {
|
|
109947
|
+
"app.assessment.qti_test_identifier": qtiTestIdentifier,
|
|
109948
|
+
"app.assessment.qti_item_identifier": itemIdentifier,
|
|
109949
|
+
"exception.type": errorType(cleanupError),
|
|
109950
|
+
"app.error.message": errorMessage2(cleanupError)
|
|
109951
|
+
});
|
|
109952
|
+
}
|
|
109953
|
+
}
|
|
109954
|
+
if (itemCreationAttempted && !itemCreationDefinitelyRejected && referenceRemoved) {
|
|
109955
|
+
try {
|
|
109956
|
+
await client.qtiApi.assessmentItems.delete(itemIdentifier);
|
|
109957
|
+
} catch (cleanupError) {
|
|
109958
|
+
addEvent("assessment.qti_question_cleanup_failed", {
|
|
109959
|
+
"app.assessment.qti_test_identifier": qtiTestIdentifier,
|
|
109960
|
+
"app.assessment.qti_item_identifier": itemIdentifier,
|
|
109961
|
+
"exception.type": errorType(cleanupError),
|
|
109962
|
+
"app.error.message": errorMessage2(cleanupError)
|
|
109963
|
+
});
|
|
109964
|
+
}
|
|
109965
|
+
}
|
|
109966
|
+
throw error88;
|
|
109967
|
+
}
|
|
109968
|
+
setAttribute("app.assessment.operation", creation.kind === "copy" ? "copy_question" : "create_question");
|
|
109969
|
+
return buildCreatedQtiQuestionReference({
|
|
109970
|
+
item,
|
|
109971
|
+
fallbackItem,
|
|
109972
|
+
itemIdentifier,
|
|
109973
|
+
href,
|
|
109974
|
+
partIdentifier: section.partIdentifier,
|
|
109975
|
+
sectionIdentifier: section.sectionIdentifier,
|
|
109976
|
+
metadata: metadata2
|
|
108989
109977
|
});
|
|
108990
|
-
}
|
|
108991
|
-
|
|
108992
|
-
|
|
108993
|
-
|
|
108994
|
-
|
|
108995
|
-
|
|
108996
|
-
|
|
108997
|
-
|
|
109978
|
+
});
|
|
109979
|
+
}
|
|
109980
|
+
async updateQuestion(integrationId, qtiTestIdentifier, itemIdentifier, input) {
|
|
109981
|
+
return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
|
|
109982
|
+
const client = this.requireClient();
|
|
109983
|
+
assertAllAssessmentAssociationsDraft(associations);
|
|
109984
|
+
const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
|
|
109985
|
+
const [, item] = await Promise.all([
|
|
109986
|
+
this.qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, undefined, ownership.test),
|
|
109987
|
+
client.qtiApi.assessmentItems.get(itemIdentifier)
|
|
109988
|
+
]);
|
|
109989
|
+
const ownerTest = await this.qtiQuestionOwnerTest(client, item, undefined, ownership.test);
|
|
109990
|
+
const replacesInteraction = "type" in input || "interaction" in input || "numericTextEntry" in input;
|
|
109991
|
+
assertQtiQuestionEditable(item, { gameSlug: ownership.gameSlug, testIdentifier: qtiTestIdentifier }, ownerTest);
|
|
109992
|
+
assertSupportedQtiQuestionInteraction(replacesInteraction ? input : item);
|
|
109993
|
+
let title = typeof input.title === "string" ? input.title.trim() : "";
|
|
109994
|
+
if (!title) {
|
|
109995
|
+
title = item.title;
|
|
109996
|
+
}
|
|
109997
|
+
const metadata2 = buildOwnedQtiQuestionMetadata(input, { gameSlug: ownership.gameSlug, testIdentifier: qtiTestIdentifier }, item.metadata);
|
|
109998
|
+
const xml = buildQuestionXml(input, itemIdentifier, title);
|
|
109999
|
+
const updated = xml ? await client.course.updateAssessmentItemXml(itemIdentifier, { xml, metadata: metadata2 }) : await client.qtiApi.assessmentItems.update(itemIdentifier, {
|
|
110000
|
+
...input,
|
|
110001
|
+
title,
|
|
110002
|
+
metadata: metadata2
|
|
110003
|
+
});
|
|
110004
|
+
setAttribute("app.assessment.operation", "update_question");
|
|
110005
|
+
return updated;
|
|
110006
|
+
});
|
|
110007
|
+
}
|
|
110008
|
+
async removeQuestion(integrationId, qtiTestIdentifier, itemIdentifier) {
|
|
110009
|
+
return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
|
|
110010
|
+
const client = this.requireClient();
|
|
110011
|
+
assertAllAssessmentAssociationsDraft(associations);
|
|
110012
|
+
const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
|
|
110013
|
+
const section = await this.qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, undefined, ownership.test);
|
|
110014
|
+
await section.items.remove(itemIdentifier);
|
|
110015
|
+
setAttribute("app.assessment.operation", "remove_question");
|
|
110016
|
+
});
|
|
110017
|
+
}
|
|
110018
|
+
async reorderQuestions(integrationId, qtiTestIdentifier, items) {
|
|
110019
|
+
return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
|
|
110020
|
+
const client = this.requireClient();
|
|
110021
|
+
assertAllAssessmentAssociationsDraft(associations);
|
|
110022
|
+
const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
|
|
110023
|
+
const identifiers = items.map((item) => item.identifier);
|
|
110024
|
+
validateUniqueQuestionIdentifiers(identifiers);
|
|
110025
|
+
const firstIdentifier = identifiers[0];
|
|
110026
|
+
const section = await this.qtiSectionItems(client, qtiTestIdentifier, firstIdentifier, identifiers, ownership.test);
|
|
110027
|
+
const result = await section.items.reorder({
|
|
110028
|
+
items: items.map((item) => ({
|
|
110029
|
+
identifier: item.identifier,
|
|
110030
|
+
href: item.href ?? this.qtiItemHref(client, item.identifier),
|
|
110031
|
+
sequence: item.sequence
|
|
110032
|
+
}))
|
|
108998
110033
|
});
|
|
108999
|
-
|
|
109000
|
-
|
|
109001
|
-
|
|
110034
|
+
setAttribute("app.assessment.operation", "reorder_questions");
|
|
110035
|
+
return result;
|
|
110036
|
+
});
|
|
109002
110037
|
}
|
|
109003
110038
|
requireClient() {
|
|
109004
110039
|
if (!this.deps.timeback) {
|
|
@@ -109006,8 +110041,18 @@ class TimebackAssessmentsService {
|
|
|
109006
110041
|
}
|
|
109007
110042
|
return this.deps.timeback;
|
|
109008
110043
|
}
|
|
109009
|
-
async
|
|
109010
|
-
|
|
110044
|
+
async withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, action) {
|
|
110045
|
+
return this.deps.db.transaction(async (tx) => {
|
|
110046
|
+
const rows = await tx.select().from(gameTimebackAssessmentTests).where(eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier)).orderBy(gameTimebackAssessmentTests.id).for("update");
|
|
110047
|
+
const row = rows.find((candidate) => candidate.integrationId === integrationId);
|
|
110048
|
+
if (!row) {
|
|
110049
|
+
throw new NotFoundError(`Assessment not found: ${qtiTestIdentifier}`);
|
|
110050
|
+
}
|
|
110051
|
+
return action(row, tx, rows);
|
|
110052
|
+
});
|
|
110053
|
+
}
|
|
110054
|
+
async requireIntegration(integrationId, db2 = this.deps.db) {
|
|
110055
|
+
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
109011
110056
|
where: and(eq(gameTimebackIntegrations.id, integrationId), isActiveGameTimebackIntegrationStatus())
|
|
109012
110057
|
});
|
|
109013
110058
|
if (!integration) {
|
|
@@ -109015,6 +110060,17 @@ class TimebackAssessmentsService {
|
|
|
109015
110060
|
}
|
|
109016
110061
|
return integration;
|
|
109017
110062
|
}
|
|
110063
|
+
async requireQtiTestOwnershipContext(integrationId, db2 = this.deps.db) {
|
|
110064
|
+
const integration = await this.requireIntegration(integrationId, db2);
|
|
110065
|
+
const game2 = await db2.query.games.findFirst({
|
|
110066
|
+
where: eq(games.id, integration.gameId),
|
|
110067
|
+
columns: { slug: true }
|
|
110068
|
+
});
|
|
110069
|
+
if (!game2) {
|
|
110070
|
+
throw new NotFoundError(`Game not found for integration: ${integrationId}`);
|
|
110071
|
+
}
|
|
110072
|
+
return { integration, gameSlug: game2.slug };
|
|
110073
|
+
}
|
|
109018
110074
|
async requireAssessmentRow(integrationId, qtiTestIdentifier) {
|
|
109019
110075
|
const row = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
|
|
109020
110076
|
where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier))
|
|
@@ -109024,27 +110080,125 @@ class TimebackAssessmentsService {
|
|
|
109024
110080
|
}
|
|
109025
110081
|
return row;
|
|
109026
110082
|
}
|
|
109027
|
-
|
|
109028
|
-
|
|
110083
|
+
async requireDraftAssessmentRow(integrationId, qtiTestIdentifier, lockedRow) {
|
|
110084
|
+
const row = lockedRow ?? await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
|
|
110085
|
+
assertDraftAssessment(row);
|
|
110086
|
+
return row;
|
|
109029
110087
|
}
|
|
109030
|
-
async
|
|
109031
|
-
const
|
|
109032
|
-
|
|
109033
|
-
|
|
110088
|
+
async requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, lockedRow, db2 = this.deps.db) {
|
|
110089
|
+
const client = this.requireClient();
|
|
110090
|
+
await this.requireDraftAssessmentRow(integrationId, qtiTestIdentifier, lockedRow);
|
|
110091
|
+
const test = await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
|
|
110092
|
+
const ownership = await this.requireQtiTestOwnershipContext(integrationId, db2);
|
|
110093
|
+
assertQtiTestOwnedByGame(test, ownership.gameSlug);
|
|
110094
|
+
return { ...ownership, test };
|
|
110095
|
+
}
|
|
110096
|
+
async qtiQuestionOwnerTest(client, item, cache, knownTest) {
|
|
110097
|
+
if (item.metadata && "ownerGameSlug" in item.metadata) {
|
|
110098
|
+
return;
|
|
110099
|
+
}
|
|
110100
|
+
const ownerTestIdentifier = qtiQuestionOwnerTestIdentifier(item);
|
|
110101
|
+
if (!ownerTestIdentifier) {
|
|
110102
|
+
return;
|
|
110103
|
+
}
|
|
110104
|
+
if (knownTest?.identifier === ownerTestIdentifier) {
|
|
110105
|
+
return knownTest;
|
|
110106
|
+
}
|
|
110107
|
+
function loadOwnerTest(identifier) {
|
|
110108
|
+
return client.qtiApi.assessmentTests.get(identifier);
|
|
110109
|
+
}
|
|
110110
|
+
if (!cache) {
|
|
110111
|
+
return loadOwnerTest(ownerTestIdentifier);
|
|
110112
|
+
}
|
|
110113
|
+
let ownerTest = cache.get(ownerTestIdentifier);
|
|
110114
|
+
if (!ownerTest) {
|
|
110115
|
+
ownerTest = loadOwnerTest(ownerTestIdentifier);
|
|
110116
|
+
cache.set(ownerTestIdentifier, ownerTest);
|
|
110117
|
+
}
|
|
110118
|
+
return ownerTest;
|
|
110119
|
+
}
|
|
110120
|
+
async assertQtiTestQuestionsSupported(client, qtiTestIdentifier, questions) {
|
|
110121
|
+
const result = questions ?? await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
|
|
110122
|
+
await runWithConcurrency(result.questions, TimebackAssessmentsService.QTI_HYDRATION_CONCURRENCY, async (reference) => {
|
|
110123
|
+
const item = await client.qtiApi.assessmentItems.get(reference.reference.identifier);
|
|
110124
|
+
assertSupportedQtiQuestionInteraction(item);
|
|
109034
110125
|
});
|
|
109035
|
-
|
|
109036
|
-
|
|
110126
|
+
}
|
|
110127
|
+
async qtiSectionItems(client, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers, knownTest) {
|
|
110128
|
+
const test = knownTest ?? await client.qtiApi.assessmentTests.get(qtiTestIdentifier);
|
|
110129
|
+
const section = resolveQtiQuestionSection(test, qtiTestIdentifier, itemIdentifier, expectedItemIdentifiers);
|
|
110130
|
+
return {
|
|
110131
|
+
...section,
|
|
110132
|
+
items: client.qtiApi.assessmentTests.testParts(qtiTestIdentifier).sections(section.partIdentifier).items(section.sectionIdentifier)
|
|
110133
|
+
};
|
|
110134
|
+
}
|
|
110135
|
+
qtiItemHref(client, itemIdentifier) {
|
|
110136
|
+
return `${client.getQtiBaseUrl()}/assessment-items/${itemIdentifier}`;
|
|
110137
|
+
}
|
|
110138
|
+
async cleanupQtiAssessmentCopy(client, testIdentifier, itemIdentifiers) {
|
|
110139
|
+
let testDeleted = !testIdentifier;
|
|
110140
|
+
if (testIdentifier) {
|
|
110141
|
+
try {
|
|
110142
|
+
await client.qtiApi.assessmentTests.delete(testIdentifier);
|
|
110143
|
+
testDeleted = true;
|
|
110144
|
+
} catch (error88) {
|
|
110145
|
+
testDeleted = isApiError(error88) && error88.statusCode === 404;
|
|
110146
|
+
addEvent("assessment.qti_test_copy_cleanup_failed", {
|
|
110147
|
+
"app.assessment.qti_test_identifier": testIdentifier,
|
|
110148
|
+
"exception.type": errorType(error88),
|
|
110149
|
+
"app.error.message": errorMessage2(error88)
|
|
110150
|
+
});
|
|
110151
|
+
}
|
|
110152
|
+
}
|
|
110153
|
+
if (!testDeleted) {
|
|
110154
|
+
return;
|
|
110155
|
+
}
|
|
110156
|
+
for (const itemIdentifier of itemIdentifiers.toReversed()) {
|
|
110157
|
+
try {
|
|
110158
|
+
await client.qtiApi.assessmentItems.delete(itemIdentifier);
|
|
110159
|
+
} catch (error88) {
|
|
110160
|
+
addEvent("assessment.qti_question_cleanup_failed", {
|
|
110161
|
+
...testIdentifier ? { "app.assessment.qti_test_identifier": testIdentifier } : {},
|
|
110162
|
+
"app.assessment.qti_item_identifier": itemIdentifier,
|
|
110163
|
+
"exception.type": errorType(error88),
|
|
110164
|
+
"app.error.message": errorMessage2(error88)
|
|
110165
|
+
});
|
|
110166
|
+
}
|
|
109037
110167
|
}
|
|
109038
|
-
|
|
110168
|
+
}
|
|
110169
|
+
associationSummary(row) {
|
|
110170
|
+
return {
|
|
110171
|
+
id: row.id,
|
|
110172
|
+
integrationId: row.integrationId,
|
|
110173
|
+
qtiTestIdentifier: row.qtiTestIdentifier,
|
|
110174
|
+
purpose: row.purpose,
|
|
110175
|
+
status: row.status,
|
|
110176
|
+
sortOrder: row.sortOrder,
|
|
110177
|
+
createdAt: row.createdAt,
|
|
110178
|
+
updatedAt: row.updatedAt
|
|
110179
|
+
};
|
|
110180
|
+
}
|
|
110181
|
+
async listQtiLibrary(params, list) {
|
|
110182
|
+
const plan = buildQtiLibraryListPlan(params);
|
|
110183
|
+
return list(plan.params);
|
|
110184
|
+
}
|
|
110185
|
+
async validateAssessmentHasQuestions(qtiTestIdentifier) {
|
|
110186
|
+
const client = this.requireClient();
|
|
110187
|
+
const result = await client.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
|
|
110188
|
+
assertAssessmentHasQuestions(result.questions);
|
|
110189
|
+
await this.assertQtiTestQuestionsSupported(client, qtiTestIdentifier, result);
|
|
109039
110190
|
}
|
|
109040
110191
|
}
|
|
109041
|
-
var init_timeback_assessments_service = __esm(() => {
|
|
110192
|
+
var init_timeback_assessments_service = __esm(async () => {
|
|
109042
110193
|
init_drizzle_orm();
|
|
109043
110194
|
init_helpers_index();
|
|
109044
110195
|
init_tables_index();
|
|
109045
110196
|
init_spans();
|
|
109046
|
-
|
|
110197
|
+
init_timeback3();
|
|
109047
110198
|
init_errors();
|
|
110199
|
+
init_timeback_assessment_rules_util();
|
|
110200
|
+
init_timeback_qti_authoring_util();
|
|
110201
|
+
await init_errors8();
|
|
109048
110202
|
});
|
|
109049
110203
|
function buildTimebackBaseConfigFromExistingConfig(config4) {
|
|
109050
110204
|
return {
|
|
@@ -111116,10 +112270,10 @@ var init_platform2 = __esm(async () => {
|
|
|
111116
112270
|
init_kv_service();
|
|
111117
112271
|
init_secrets_service();
|
|
111118
112272
|
init_seed_service();
|
|
111119
|
-
init_timeback_assessments_service();
|
|
111120
112273
|
init_upload_service();
|
|
111121
112274
|
await __promiseAll([
|
|
111122
112275
|
init_timeback_admin_service(),
|
|
112276
|
+
init_timeback_assessments_service(),
|
|
111123
112277
|
init_timeback_service()
|
|
111124
112278
|
]);
|
|
111125
112279
|
});
|
|
@@ -170328,16 +171482,6 @@ function requireAnonymous(handler) {
|
|
|
170328
171482
|
return handler(ctx);
|
|
170329
171483
|
};
|
|
170330
171484
|
}
|
|
170331
|
-
function requireAdmin(handler) {
|
|
170332
|
-
return async (ctx) => {
|
|
170333
|
-
assertAuthenticatedRequest(ctx);
|
|
170334
|
-
rejectDashboardWorkerKey(ctx);
|
|
170335
|
-
if (ctx.user.role !== "admin") {
|
|
170336
|
-
throw ApiError.forbidden("Admin access required");
|
|
170337
|
-
}
|
|
170338
|
-
return handler(ctx);
|
|
170339
|
-
};
|
|
170340
|
-
}
|
|
170341
171485
|
function requireDeveloper(handler) {
|
|
170342
171486
|
return async (ctx) => {
|
|
170343
171487
|
assertAuthenticatedRequest(ctx);
|
|
@@ -171522,6 +172666,26 @@ var init_session_controller = __esm(() => {
|
|
|
171522
172666
|
mintToken
|
|
171523
172667
|
});
|
|
171524
172668
|
});
|
|
172669
|
+
function parseQtiLibraryParams(searchParams) {
|
|
172670
|
+
const requestedPage = Number(searchParams.get("page"));
|
|
172671
|
+
const requestedLimit = Number(searchParams.get("limit"));
|
|
172672
|
+
const page = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
|
172673
|
+
const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 50) : 20;
|
|
172674
|
+
const query = searchParams.get("q")?.trim() || undefined;
|
|
172675
|
+
const requestedSource = searchParams.get("source");
|
|
172676
|
+
if (requestedSource && requestedSource !== "playcademy") {
|
|
172677
|
+
throw ApiError.badRequest("Unsupported QTI library source");
|
|
172678
|
+
}
|
|
172679
|
+
if (requestedSource !== "playcademy" && !query) {
|
|
172680
|
+
throw ApiError.badRequest("An exact QTI source identifier is required for a global lookup");
|
|
172681
|
+
}
|
|
172682
|
+
return {
|
|
172683
|
+
query,
|
|
172684
|
+
source: requestedSource === "playcademy" ? "playcademy" : undefined,
|
|
172685
|
+
page,
|
|
172686
|
+
limit
|
|
172687
|
+
};
|
|
172688
|
+
}
|
|
171525
172689
|
var populateStudent;
|
|
171526
172690
|
var getUser;
|
|
171527
172691
|
var getUserEnrollments;
|
|
@@ -171563,17 +172727,17 @@ var unenrollStudent;
|
|
|
171563
172727
|
var reactivateEnrollment;
|
|
171564
172728
|
var listAssessments;
|
|
171565
172729
|
var createAssessment;
|
|
171566
|
-
var
|
|
172730
|
+
var updateAssessment;
|
|
171567
172731
|
var reorderAssessments;
|
|
171568
172732
|
var reorderQuestions;
|
|
171569
|
-
var
|
|
171570
|
-
var deactivateAssessment;
|
|
172733
|
+
var removeAssessment;
|
|
171571
172734
|
var listQuestions;
|
|
172735
|
+
var listQuestionLibrary;
|
|
172736
|
+
var listTestLibrary;
|
|
172737
|
+
var copyAssessment;
|
|
171572
172738
|
var createQuestion;
|
|
171573
172739
|
var updateQuestion;
|
|
171574
|
-
var
|
|
171575
|
-
var getAssessmentBankStatus;
|
|
171576
|
-
var destroyAssessmentBank;
|
|
172740
|
+
var removeQuestion;
|
|
171577
172741
|
var timeback2;
|
|
171578
172742
|
var init_timeback_controller = __esm(() => {
|
|
171579
172743
|
init_esm();
|
|
@@ -172089,7 +173253,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
172089
173253
|
const body2 = await parseRequestBody(ctx.request, ReactivateEnrollmentRequestSchema);
|
|
172090
173254
|
return ctx.services.timebackAdmin.reactivateEnrollment(body2, ctx.user);
|
|
172091
173255
|
});
|
|
172092
|
-
listAssessments =
|
|
173256
|
+
listAssessments = requireDeveloper(async (ctx) => {
|
|
172093
173257
|
const { gameId, courseId } = ctx.params;
|
|
172094
173258
|
if (!gameId || !courseId) {
|
|
172095
173259
|
throw ApiError.badRequest("Missing gameId or courseId parameter");
|
|
@@ -172097,40 +173261,40 @@ var init_timeback_controller = __esm(() => {
|
|
|
172097
173261
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
172098
173262
|
return ctx.services.timebackAssessments.listAssessments(integrationId);
|
|
172099
173263
|
});
|
|
172100
|
-
createAssessment =
|
|
173264
|
+
createAssessment = requireDeveloper(async (ctx) => {
|
|
172101
173265
|
const { gameId, courseId } = ctx.params;
|
|
172102
173266
|
if (!gameId || !courseId) {
|
|
172103
173267
|
throw ApiError.badRequest("Missing gameId or courseId parameter");
|
|
172104
173268
|
}
|
|
172105
173269
|
const body2 = await parseRequestBody(ctx.request, CreateAssessmentRequestSchema);
|
|
172106
173270
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
172107
|
-
const
|
|
172108
|
-
const qtiTestIdentifier = `assessment-${shortId}`;
|
|
173271
|
+
const qtiTestIdentifier = newPlaycademyTestIdentifier();
|
|
172109
173272
|
return ctx.services.timebackAssessments.createAssessment(integrationId, {
|
|
172110
173273
|
title: body2.title,
|
|
173274
|
+
purpose: body2.purpose,
|
|
172111
173275
|
qtiTestIdentifier
|
|
172112
173276
|
});
|
|
172113
173277
|
});
|
|
172114
|
-
|
|
173278
|
+
updateAssessment = requireDeveloper(async (ctx) => {
|
|
172115
173279
|
const { gameId, courseId, testIdentifier } = ctx.params;
|
|
172116
173280
|
if (!gameId || !courseId || !testIdentifier) {
|
|
172117
173281
|
throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
|
|
172118
173282
|
}
|
|
172119
173283
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
172120
|
-
await ctx.
|
|
172121
|
-
return
|
|
173284
|
+
const body2 = await parseRequestBody(ctx.request, UpdateAssessmentRequestSchema);
|
|
173285
|
+
return ctx.services.timebackAssessments.updateAssessment(integrationId, testIdentifier, body2);
|
|
172122
173286
|
});
|
|
172123
|
-
reorderAssessments =
|
|
173287
|
+
reorderAssessments = requireDeveloper(async (ctx) => {
|
|
172124
173288
|
const { gameId, courseId } = ctx.params;
|
|
172125
173289
|
if (!gameId || !courseId) {
|
|
172126
173290
|
throw ApiError.badRequest("Missing gameId or courseId parameter");
|
|
172127
173291
|
}
|
|
172128
173292
|
const body2 = await parseRequestBody(ctx.request, ReorderAssessmentsRequestSchema);
|
|
172129
173293
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
172130
|
-
await ctx.services.timebackAssessments.reorderAssessments(integrationId, body2.
|
|
173294
|
+
await ctx.services.timebackAssessments.reorderAssessments(integrationId, body2.purpose, body2.testIdentifiers);
|
|
172131
173295
|
return { success: true };
|
|
172132
173296
|
});
|
|
172133
|
-
reorderQuestions =
|
|
173297
|
+
reorderQuestions = requireDeveloper(async (ctx) => {
|
|
172134
173298
|
const { gameId, courseId, testIdentifier } = ctx.params;
|
|
172135
173299
|
if (!gameId || !courseId || !testIdentifier) {
|
|
172136
173300
|
throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
|
|
@@ -172140,33 +173304,51 @@ var init_timeback_controller = __esm(() => {
|
|
|
172140
173304
|
await ctx.services.timebackAssessments.reorderQuestions(integrationId, testIdentifier, body2.items);
|
|
172141
173305
|
return { success: true };
|
|
172142
173306
|
});
|
|
172143
|
-
|
|
173307
|
+
removeAssessment = requireDeveloper(async (ctx) => {
|
|
172144
173308
|
const { gameId, courseId, testIdentifier } = ctx.params;
|
|
172145
173309
|
if (!gameId || !courseId || !testIdentifier) {
|
|
172146
173310
|
throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
|
|
172147
173311
|
}
|
|
172148
173312
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
172149
|
-
|
|
172150
|
-
return { success: true };
|
|
173313
|
+
return ctx.services.timebackAssessments.removeAssessment(integrationId, testIdentifier);
|
|
172151
173314
|
});
|
|
172152
|
-
|
|
173315
|
+
listQuestions = requireDeveloper(async (ctx) => {
|
|
172153
173316
|
const { gameId, courseId, testIdentifier } = ctx.params;
|
|
172154
173317
|
if (!gameId || !courseId || !testIdentifier) {
|
|
172155
173318
|
throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
|
|
172156
173319
|
}
|
|
172157
173320
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
172158
|
-
|
|
172159
|
-
return { success: true };
|
|
173321
|
+
return ctx.services.timebackAssessments.listQuestions(integrationId, testIdentifier);
|
|
172160
173322
|
});
|
|
172161
|
-
|
|
172162
|
-
const { gameId, courseId
|
|
172163
|
-
if (!gameId || !courseId
|
|
172164
|
-
throw ApiError.badRequest("Missing gameId
|
|
173323
|
+
listQuestionLibrary = requireDeveloper(async (ctx) => {
|
|
173324
|
+
const { gameId, courseId } = ctx.params;
|
|
173325
|
+
if (!gameId || !courseId) {
|
|
173326
|
+
throw ApiError.badRequest("Missing gameId or courseId parameter");
|
|
172165
173327
|
}
|
|
173328
|
+
const params = parseQtiLibraryParams(ctx.url.searchParams);
|
|
172166
173329
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
172167
|
-
return ctx.services.timebackAssessments.
|
|
173330
|
+
return ctx.services.timebackAssessments.listQuestionLibrary(integrationId, params);
|
|
173331
|
+
});
|
|
173332
|
+
listTestLibrary = requireDeveloper(async (ctx) => {
|
|
173333
|
+
const { gameId, courseId } = ctx.params;
|
|
173334
|
+
if (!gameId || !courseId) {
|
|
173335
|
+
throw ApiError.badRequest("Missing gameId or courseId parameter");
|
|
173336
|
+
}
|
|
173337
|
+
const params = parseQtiLibraryParams(ctx.url.searchParams);
|
|
173338
|
+
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
173339
|
+
return ctx.services.timebackAssessments.listTestLibrary(integrationId, params);
|
|
173340
|
+
});
|
|
173341
|
+
copyAssessment = requireDeveloper(async (ctx) => {
|
|
173342
|
+
const { gameId, courseId } = ctx.params;
|
|
173343
|
+
if (!gameId || !courseId) {
|
|
173344
|
+
throw ApiError.badRequest("Missing gameId or courseId parameter");
|
|
173345
|
+
}
|
|
173346
|
+
const body2 = await parseRequestBody(ctx.request, CopyAssessmentRequestSchema);
|
|
173347
|
+
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
173348
|
+
const targetTestIdentifier = newPlaycademyTestIdentifier();
|
|
173349
|
+
return ctx.services.timebackAssessments.copyAssessment(integrationId, body2.testIdentifier, targetTestIdentifier, body2.purpose);
|
|
172168
173350
|
});
|
|
172169
|
-
createQuestion =
|
|
173351
|
+
createQuestion = requireDeveloper(async (ctx) => {
|
|
172170
173352
|
const { gameId, courseId, testIdentifier } = ctx.params;
|
|
172171
173353
|
if (!gameId || !courseId || !testIdentifier) {
|
|
172172
173354
|
throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
|
|
@@ -172175,7 +173357,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
172175
173357
|
const body2 = await ctx.request.json();
|
|
172176
173358
|
return ctx.services.timebackAssessments.createQuestion(integrationId, testIdentifier, body2);
|
|
172177
173359
|
});
|
|
172178
|
-
updateQuestion =
|
|
173360
|
+
updateQuestion = requireDeveloper(async (ctx) => {
|
|
172179
173361
|
const { gameId, courseId, testIdentifier, itemIdentifier } = ctx.params;
|
|
172180
173362
|
if (!gameId || !courseId || !testIdentifier || !itemIdentifier) {
|
|
172181
173363
|
throw ApiError.badRequest("Missing gameId, courseId, testIdentifier, or itemIdentifier parameter");
|
|
@@ -172184,30 +173366,13 @@ var init_timeback_controller = __esm(() => {
|
|
|
172184
173366
|
const body2 = await ctx.request.json();
|
|
172185
173367
|
return ctx.services.timebackAssessments.updateQuestion(integrationId, testIdentifier, itemIdentifier, body2);
|
|
172186
173368
|
});
|
|
172187
|
-
|
|
173369
|
+
removeQuestion = requireDeveloper(async (ctx) => {
|
|
172188
173370
|
const { gameId, courseId, testIdentifier, itemIdentifier } = ctx.params;
|
|
172189
173371
|
if (!gameId || !courseId || !testIdentifier || !itemIdentifier) {
|
|
172190
173372
|
throw ApiError.badRequest("Missing gameId, courseId, testIdentifier, or itemIdentifier parameter");
|
|
172191
173373
|
}
|
|
172192
173374
|
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
172193
|
-
await ctx.services.timebackAssessments.
|
|
172194
|
-
return { success: true };
|
|
172195
|
-
});
|
|
172196
|
-
getAssessmentBankStatus = requireGameManagementAccess(async (ctx) => {
|
|
172197
|
-
const { gameId, courseId } = ctx.params;
|
|
172198
|
-
if (!gameId || !courseId) {
|
|
172199
|
-
throw ApiError.badRequest("Missing gameId or courseId parameter");
|
|
172200
|
-
}
|
|
172201
|
-
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
172202
|
-
return ctx.services.timebackAssessments.getBankStatus(integrationId);
|
|
172203
|
-
});
|
|
172204
|
-
destroyAssessmentBank = requireAdmin(async (ctx) => {
|
|
172205
|
-
const { gameId, courseId } = ctx.params;
|
|
172206
|
-
if (!gameId || !courseId) {
|
|
172207
|
-
throw ApiError.badRequest("Missing gameId or courseId parameter");
|
|
172208
|
-
}
|
|
172209
|
-
const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
|
|
172210
|
-
await ctx.services.timebackAssessments.destroyBank(integrationId);
|
|
173375
|
+
await ctx.services.timebackAssessments.removeQuestion(integrationId, testIdentifier, itemIdentifier);
|
|
172211
173376
|
return { success: true };
|
|
172212
173377
|
});
|
|
172213
173378
|
timeback2 = defineControllerNames("timeback", {
|
|
@@ -172252,17 +173417,17 @@ var init_timeback_controller = __esm(() => {
|
|
|
172252
173417
|
reactivateEnrollment,
|
|
172253
173418
|
listAssessments,
|
|
172254
173419
|
createAssessment,
|
|
172255
|
-
|
|
173420
|
+
updateAssessment,
|
|
172256
173421
|
reorderAssessments,
|
|
173422
|
+
removeAssessment,
|
|
172257
173423
|
reorderQuestions,
|
|
172258
|
-
activateAssessment,
|
|
172259
|
-
deactivateAssessment,
|
|
172260
173424
|
listQuestions,
|
|
173425
|
+
listTestLibrary,
|
|
173426
|
+
copyAssessment,
|
|
173427
|
+
listQuestionLibrary,
|
|
172261
173428
|
createQuestion,
|
|
172262
173429
|
updateQuestion,
|
|
172263
|
-
|
|
172264
|
-
getAssessmentBankStatus,
|
|
172265
|
-
destroyAssessmentBank
|
|
173430
|
+
removeQuestion
|
|
172266
173431
|
});
|
|
172267
173432
|
});
|
|
172268
173433
|
var initiate;
|