@playcademy/vite-plugin 1.2.1-beta.24 → 1.2.1-beta.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1053 -635
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -24315,7 +24315,7 @@ import path2 from "node:path";
24315
24315
  // package.json
24316
24316
  var package_default = {
24317
24317
  name: "@playcademy/vite-plugin",
24318
- version: "1.2.1-beta.24",
24318
+ version: "1.2.1-beta.26",
24319
24319
  type: "module",
24320
24320
  exports: {
24321
24321
  ".": {
@@ -24767,6 +24767,7 @@ import { stdout } from "process";
24767
24767
  import { createHash } from "node:crypto";
24768
24768
  import { createHash as createHash2 } from "node:crypto";
24769
24769
  import crypto3 from "node:crypto";
24770
+ import * as crypto4 from "node:crypto";
24770
24771
  import * as s3 from "fs";
24771
24772
  import * as o3 from "path";
24772
24773
  import fs22 from "node:fs";
@@ -24774,10 +24775,9 @@ import { dirname, isAbsolute, join as join4 } from "node:path";
24774
24775
  import process22 from "process";
24775
24776
  import os2 from "os";
24776
24777
  import tty from "tty";
24777
- import { randomUUID } from "crypto";
24778
+ import { randomUUID as randomUUID2 } from "crypto";
24778
24779
  import fs3 from "node:fs";
24779
24780
  import fs4 from "node:fs";
24780
- import * as crypto4 from "node:crypto";
24781
24781
  import { randomUUID as randomUUID3 } from "node:crypto";
24782
24782
  var __create2 = Object.create;
24783
24783
  var __getProtoOf2 = Object.getPrototypeOf;
@@ -25921,7 +25921,7 @@ var package_default2;
25921
25921
  var init_package = __esm(() => {
25922
25922
  package_default2 = {
25923
25923
  name: "@playcademy/sandbox",
25924
- version: "0.7.1-beta.22",
25924
+ version: "0.7.1-beta.24",
25925
25925
  description: "Local development server for Playcademy game development",
25926
25926
  type: "module",
25927
25927
  exports: {
@@ -34376,6 +34376,9 @@ function isAssessmentAttemptOpen(attempt) {
34376
34376
  function isAssessmentAttemptCompleted(attempt) {
34377
34377
  return attempt.inProgress === ASSESSMENT_ATTEMPT_COMPLETED.inProgress && attempt.scoreStatus === ASSESSMENT_ATTEMPT_COMPLETED.scoreStatus;
34378
34378
  }
34379
+ function isAssessmentAttemptSuperseded(attempt) {
34380
+ return attempt.inProgress === ASSESSMENT_ATTEMPT_SUPERSEDED.inProgress && attempt.scoreStatus === ASSESSMENT_ATTEMPT_SUPERSEDED.scoreStatus;
34381
+ }
34379
34382
  function classifyAssessmentSubmission(attempt, submissionId) {
34380
34383
  if (isAssessmentAttemptCompleted(attempt)) {
34381
34384
  return attempt.submissionId === submissionId ? "replay" : "reject";
@@ -34433,6 +34436,15 @@ function assessmentReviewBankShortage(fulfillment) {
34433
34436
  function assessmentFlowForPurpose(purpose) {
34434
34437
  return purpose === "review" ? "item-submit" : "attempt-submit";
34435
34438
  }
34439
+ function compareCodeUnits(left, right) {
34440
+ if (left < right) {
34441
+ return -1;
34442
+ }
34443
+ if (left > right) {
34444
+ return 1;
34445
+ }
34446
+ return 0;
34447
+ }
34436
34448
  function canonicalJson(value) {
34437
34449
  if (value === null || typeof value !== "object") {
34438
34450
  return JSON.stringify(value) ?? "null";
@@ -34440,18 +34452,9 @@ function canonicalJson(value) {
34440
34452
  if (Array.isArray(value)) {
34441
34453
  return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
34442
34454
  }
34443
- const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined).toSorted(([left], [right]) => compareCanonicalKeys(left, right)).map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`);
34455
+ const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined).toSorted(([left], [right]) => compareCodeUnits(left, right)).map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`);
34444
34456
  return `{${entries.join(",")}}`;
34445
34457
  }
34446
- function compareCanonicalKeys(left, right) {
34447
- if (left < right) {
34448
- return -1;
34449
- }
34450
- if (left > right) {
34451
- return 1;
34452
- }
34453
- return 0;
34454
- }
34455
34458
  async function diagnosticRoutingRevision(manifest) {
34456
34459
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalJson(manifest)));
34457
34460
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -35751,15 +35754,6 @@ function assessmentPresentationForAttempt(assessment, attemptId) {
35751
35754
  });
35752
35755
  return { ...assessment, items };
35753
35756
  }
35754
- function compareCodeUnits(left, right) {
35755
- if (left < right) {
35756
- return -1;
35757
- }
35758
- if (left > right) {
35759
- return 1;
35760
- }
35761
- return 0;
35762
- }
35763
35757
  function reviewStandardFieldsWithinLimits(input) {
35764
35758
  return input.framework.trim().length <= TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength && input.identifier.trim().length <= TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standardFieldLength;
35765
35759
  }
@@ -35770,6 +35764,19 @@ function canonicalReviewStandardRef(input) {
35770
35764
  }
35771
35765
  return standard.framework && standard.identifier ? standard : null;
35772
35766
  }
35767
+ function isKnownDescriptiveCurriculumFramework(framework) {
35768
+ return framework === "CCSS" || framework.startsWith("CCSS.");
35769
+ }
35770
+ function reviewRoutingStandardRefsFromMetadata(metadata2, options = {}) {
35771
+ const refs = new Map;
35772
+ for (const authored of qtiAssessmentStandardRefsFromMetadata(metadata2)) {
35773
+ const standard = canonicalReviewStandardRef(authored);
35774
+ if (standard && (!options.customOnly || !isKnownDescriptiveCurriculumFramework(standard.framework))) {
35775
+ refs.set(assessmentStandardRefKey(standard), standard);
35776
+ }
35777
+ }
35778
+ return [...refs.values()];
35779
+ }
35773
35780
  function canonicalRequestStandards(standards) {
35774
35781
  if (standards.length > TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards) {
35775
35782
  throw new RangeError(`A review request may contain at most ${TIMEBACK_ASSESSMENT_REVIEW_REQUEST_LIMITS.standards} standards`);
@@ -35825,42 +35832,172 @@ function qtiQuestionByIdentifier(questions) {
35825
35832
  }
35826
35833
  return byIdentifier;
35827
35834
  }
35828
- async function buildReviewBankIndex(assessment, questions) {
35835
+ async function reviewBankRevision(input) {
35836
+ const alignmentSignature = input.items.map((item) => ({
35837
+ itemIdentifier: item.itemIdentifier,
35838
+ standardKeys: item.standards.map(assessmentStandardRefKey)
35839
+ }));
35840
+ const bankRevisionUuid = await deterministicUUID(`playcademy:review-bank:v1:${JSON.stringify({
35841
+ bankIdentifier: input.bankIdentifier,
35842
+ contentRevision: input.sourceContentRevision,
35843
+ alignments: alignmentSignature
35844
+ })}`);
35845
+ return `review-bank-v1:${bankRevisionUuid}`;
35846
+ }
35847
+ async function reviewBankIndex(input) {
35848
+ return {
35849
+ ...input,
35850
+ bankRevision: await reviewBankRevision(input)
35851
+ };
35852
+ }
35853
+ async function reviewBankSourceFingerprint(input) {
35854
+ const fingerprintUuid = await deterministicUUID(`playcademy:review-bank-source:v1:${JSON.stringify({
35855
+ bankIdentifier: input.bankIdentifier,
35856
+ items: input.items.map((item) => ({
35857
+ itemIdentifier: item.itemIdentifier,
35858
+ standardKeys: item.standards.map(assessmentStandardRefKey)
35859
+ }))
35860
+ })}`);
35861
+ return `review-bank-source-v1:${fingerprintUuid}`;
35862
+ }
35863
+ async function buildReviewBankIndex(assessment, questions, options = {}) {
35829
35864
  if (assessment.identifier !== questions.assessmentTest) {
35830
35865
  throw new Error(`QTI questions for ${questions.assessmentTest} do not describe assessment ${assessment.identifier}`);
35831
35866
  }
35832
35867
  const questionByIdentifier = qtiQuestionByIdentifier(questions);
35833
35868
  const items = assessment.items.map((item) => {
35834
35869
  const question = questionByIdentifier.get(item.identifier);
35835
- const canonical = new Map;
35836
- for (const authored of qtiAssessmentStandardRefsFromMetadata(question?.metadata)) {
35837
- const standard = canonicalReviewStandardRef(authored);
35838
- if (standard) {
35839
- canonical.set(assessmentStandardRefKey(standard), standard);
35840
- }
35841
- }
35842
- const standards = [...canonical.values()].toSorted((left, right) => compareCodeUnits(assessmentStandardRefKey(left), assessmentStandardRefKey(right)));
35870
+ const standards = reviewRoutingStandardRefsFromMetadata(question?.metadata, options).toSorted((left, right) => compareCodeUnits(assessmentStandardRefKey(left), assessmentStandardRefKey(right)));
35843
35871
  return {
35844
35872
  itemIdentifier: item.identifier,
35845
35873
  standards
35846
35874
  };
35847
35875
  });
35848
- const alignmentSignature = items.map((item) => ({
35849
- itemIdentifier: item.itemIdentifier,
35850
- standardKeys: item.standards.map(assessmentStandardRefKey)
35851
- }));
35852
- const bankRevisionUuid = await deterministicUUID(`playcademy:review-bank:v1:${JSON.stringify({
35853
- bankIdentifier: assessment.identifier,
35854
- contentRevision: assessment.contentRevision,
35855
- alignments: alignmentSignature
35856
- })}`);
35857
- return {
35876
+ return reviewBankIndex({
35858
35877
  bankIdentifier: assessment.identifier,
35859
35878
  sourceContentRevision: assessment.contentRevision,
35860
- bankRevision: `review-bank-v1:${bankRevisionUuid}`,
35861
35879
  items
35880
+ });
35881
+ }
35882
+ function emptyReviewBankManifest(bankIdentifier) {
35883
+ return {
35884
+ version: PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION,
35885
+ bankIdentifier,
35886
+ sourceFingerprint: null,
35887
+ sourceContentRevision: null,
35888
+ bankRevision: null,
35889
+ itemsByStandard: {}
35890
+ };
35891
+ }
35892
+ function reviewBankMappingIssues(bank) {
35893
+ return {
35894
+ missingOrInvalidItemIdentifiers: bank.items.filter((item) => item.standards.length === 0).map((item) => item.itemIdentifier),
35895
+ multipleStandardItemIdentifiers: bank.items.filter((item) => item.standards.length > 1).map((item) => item.itemIdentifier)
35896
+ };
35897
+ }
35898
+ async function buildReviewBankManifest(bank) {
35899
+ const itemsByStandard = new Map;
35900
+ for (const item of bank.items) {
35901
+ for (const standard of item.standards) {
35902
+ const key = assessmentStandardRefKey(standard);
35903
+ const identifiers = itemsByStandard.get(key) ?? [];
35904
+ identifiers.push(item.itemIdentifier);
35905
+ itemsByStandard.set(key, identifiers);
35906
+ }
35907
+ }
35908
+ return {
35909
+ version: PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION,
35910
+ bankIdentifier: bank.bankIdentifier,
35911
+ sourceFingerprint: await reviewBankSourceFingerprint(bank),
35912
+ sourceContentRevision: bank.sourceContentRevision,
35913
+ bankRevision: bank.bankRevision,
35914
+ itemsByStandard: Object.fromEntries([...itemsByStandard.entries()].toSorted(([left], [right]) => compareCodeUnits(left, right)))
35862
35915
  };
35863
35916
  }
35917
+ function requiredManifestString(record, field) {
35918
+ const value = record[field];
35919
+ if (typeof value !== "string" || !value.trim()) {
35920
+ throw new Error(`Review-bank manifest requires ${field}`);
35921
+ }
35922
+ return value.trim();
35923
+ }
35924
+ function standardFromManifestKey(key) {
35925
+ try {
35926
+ const parsed = JSON.parse(key);
35927
+ if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== "string" || typeof parsed[1] !== "string") {
35928
+ return null;
35929
+ }
35930
+ const standard = canonicalReviewStandardRef({
35931
+ framework: parsed[0],
35932
+ identifier: parsed[1]
35933
+ });
35934
+ return standard && assessmentStandardRefKey(standard) === key ? standard : null;
35935
+ } catch {
35936
+ return null;
35937
+ }
35938
+ }
35939
+ async function reviewBankIndexFromManifest(metadata2, input) {
35940
+ const raw = isRecord(metadata2) ? metadata2[PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY] : undefined;
35941
+ if (raw === undefined) {
35942
+ return null;
35943
+ }
35944
+ if (!isRecord(raw) || raw.version !== PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION) {
35945
+ throw new Error("Review-bank manifest has an unsupported version or shape");
35946
+ }
35947
+ const manifest = raw;
35948
+ const bankIdentifier = requiredManifestString(manifest, "bankIdentifier");
35949
+ if (bankIdentifier !== input.bankIdentifier) {
35950
+ throw new Error(`Review-bank manifest for ${bankIdentifier} does not describe ${input.bankIdentifier}`);
35951
+ }
35952
+ if (manifest.sourceFingerprint === null && manifest.sourceContentRevision === null && manifest.bankRevision === null) {
35953
+ return null;
35954
+ }
35955
+ const sourceFingerprint = requiredManifestString(manifest, "sourceFingerprint");
35956
+ const sourceContentRevision = requiredManifestString(manifest, "sourceContentRevision");
35957
+ const declaredBankRevision = requiredManifestString(manifest, "bankRevision");
35958
+ const itemsByStandard = manifest.itemsByStandard;
35959
+ if (!isRecord(itemsByStandard)) {
35960
+ throw new Error("Review-bank manifest requires an itemsByStandard mapping");
35961
+ }
35962
+ const membership = new Set(input.membershipItemIdentifiers);
35963
+ const standardsByItem = new Map;
35964
+ for (const [standardKey, rawItemIdentifiers] of Object.entries(itemsByStandard)) {
35965
+ const standard = standardFromManifestKey(standardKey);
35966
+ if (!standard || !Array.isArray(rawItemIdentifiers)) {
35967
+ throw new Error("Review-bank manifest contains an invalid standard mapping");
35968
+ }
35969
+ for (const rawItemIdentifier of rawItemIdentifiers) {
35970
+ if (typeof rawItemIdentifier !== "string" || !rawItemIdentifier.trim()) {
35971
+ throw new Error("Review-bank manifest contains an invalid item identifier");
35972
+ }
35973
+ const itemIdentifier = rawItemIdentifier.trim();
35974
+ if (membership.has(itemIdentifier)) {
35975
+ const standards = standardsByItem.get(itemIdentifier) ?? new Map;
35976
+ standards.set(assessmentStandardRefKey(standard), standard);
35977
+ standardsByItem.set(itemIdentifier, standards);
35978
+ }
35979
+ }
35980
+ }
35981
+ const index = await reviewBankIndex({
35982
+ bankIdentifier,
35983
+ sourceContentRevision,
35984
+ items: input.membershipItemIdentifiers.map((itemIdentifier) => ({
35985
+ itemIdentifier,
35986
+ standards: [...standardsByItem.get(itemIdentifier)?.values() ?? []].toSorted((left, right) => compareCodeUnits(assessmentStandardRefKey(left), assessmentStandardRefKey(right)))
35987
+ }))
35988
+ });
35989
+ const issues = reviewBankMappingIssues(index);
35990
+ if (issues.missingOrInvalidItemIdentifiers.length > 0 || issues.multipleStandardItemIdentifiers.length > 0) {
35991
+ throw new Error("Review-bank manifest must map every member to exactly one standard/node key");
35992
+ }
35993
+ if (await reviewBankSourceFingerprint(index) !== sourceFingerprint) {
35994
+ throw new Error("Review-bank manifest source fingerprint is stale");
35995
+ }
35996
+ if (index.bankRevision !== declaredBankRevision) {
35997
+ throw new Error("Review-bank manifest revision does not match its candidate index");
35998
+ }
35999
+ return index;
36000
+ }
35864
36001
  function exposureTimestamp(value) {
35865
36002
  const timestamp = Date.parse(value);
35866
36003
  return Number.isFinite(timestamp) ? timestamp : 0;
@@ -36348,6 +36485,7 @@ var COMMON_CORE_ELA_FRAMEWORK_ALIASES;
36348
36485
  var COMMON_CORE_FRAMEWORK_ALIASES;
36349
36486
  var ASSESSMENT_ATTEMPT_OPEN;
36350
36487
  var ASSESSMENT_ATTEMPT_COMPLETED;
36488
+ var ASSESSMENT_ATTEMPT_SUPERSEDED;
36351
36489
  var ASSESSMENT_RUNTIME_ERROR_STATUS;
36352
36490
  var ROUTING_KEY_MAX_LENGTH = 128;
36353
36491
  var ROUTING_RESULT_KEY_MAX_LENGTH = 256;
@@ -36367,6 +36505,8 @@ var DiagnosticRoutingManifestV1Schema;
36367
36505
  var GRADE_VALUES;
36368
36506
  var POINT_RESPONSE_PATTERN;
36369
36507
  var REVIEW_SELECTION_POLICY_VERSION = "unseen-first-least-recently-delivered-v1";
36508
+ var PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY = "playcademyReviewBank";
36509
+ var PLAYCADEMY_REVIEW_BANK_MANIFEST_VERSION = 1;
36370
36510
  var DEFAULT_REVIEW_SELECTION_POLICY;
36371
36511
  var RuntimeSubjectSchema;
36372
36512
  var RuntimeGradeSchema;
@@ -36571,6 +36711,10 @@ var init_assessment_runtime2 = __esm(() => {
36571
36711
  inProgress: "false",
36572
36712
  scoreStatus: "fully graded"
36573
36713
  };
36714
+ ASSESSMENT_ATTEMPT_SUPERSEDED = {
36715
+ inProgress: "false",
36716
+ scoreStatus: "not submitted"
36717
+ };
36574
36718
  ASSESSMENT_RUNTIME_ERROR_STATUS = {
36575
36719
  NO_ELIGIBLE_TESTS: 404,
36576
36720
  REVIEW_BANK_SHORTAGE: 422,
@@ -62910,67 +63054,6 @@ var init_infra2 = __esm(() => {
62910
63054
  init_alerts_service();
62911
63055
  init_kv_backup_service();
62912
63056
  });
62913
- function createInMemoryDatabaseLock() {
62914
- const tails = new Map;
62915
- return async (db2, key, operation) => {
62916
- const previous = tails.get(key) ?? Promise.resolve();
62917
- let release;
62918
- const gate = new Promise((resolve2) => {
62919
- release = resolve2;
62920
- });
62921
- const tail = previous.then(() => gate);
62922
- tails.set(key, tail);
62923
- await previous;
62924
- try {
62925
- return await operation(db2);
62926
- } finally {
62927
- release();
62928
- if (tails.get(key) === tail) {
62929
- tails.delete(key);
62930
- }
62931
- }
62932
- };
62933
- }
62934
- var LockContentionError;
62935
- var init_locks = __esm(() => {
62936
- init_src6();
62937
- LockContentionError = class LockContentionError2 extends Error {
62938
- constructor(key) {
62939
- super(`Lock is already held: ${key}`);
62940
- this.name = "LockContentionError";
62941
- }
62942
- };
62943
- });
62944
- function assessmentRuntimeStartLockKey(input) {
62945
- return JSON.stringify([
62946
- ASSESSMENT_RUNTIME_LOCK_NAMESPACE,
62947
- "start",
62948
- input.studentId,
62949
- input.activityId,
62950
- input.purpose,
62951
- input.courseId
62952
- ]);
62953
- }
62954
- function assessmentRuntimeAttemptLockKey(attemptId) {
62955
- return JSON.stringify([ASSESSMENT_RUNTIME_LOCK_NAMESPACE, "attempt", attemptId]);
62956
- }
62957
- function createAssessmentRuntimeLock(lock) {
62958
- return async (db2, key, operation) => {
62959
- try {
62960
- return await lock(db2, key, operation);
62961
- } catch (error) {
62962
- if (error instanceof LockContentionError) {
62963
- throw new ServiceUnavailableError("Another assessment operation is in progress. Try again shortly.", { retryable: true });
62964
- }
62965
- throw error;
62966
- }
62967
- };
62968
- }
62969
- var ASSESSMENT_RUNTIME_LOCK_NAMESPACE = "playcademy-assessment-runtime-v1";
62970
- var init_assessment_runtime_lock_util = __esm(() => {
62971
- init_locks();
62972
- init_errors2();
62973
- });
62974
63057
  function hasPagingOptions(options) {
62975
63058
  return options.cursor !== undefined || options.limit !== undefined || options.delimiter !== undefined;
62976
63059
  }
@@ -115025,73 +115108,6 @@ function stringField2(value) {
115025
115108
  function firstStringField2(...values) {
115026
115109
  return values.map(stringField2).find(Boolean) ?? "";
115027
115110
  }
115028
- function normalizedWhitespace2(value) {
115029
- return value.normalize("NFKC").trim().replace(/\s+/g, " ");
115030
- }
115031
- function normalizedIdentityCase2(value) {
115032
- return value.toLocaleUpperCase("en-US");
115033
- }
115034
- function frameworkAliasKey2(value) {
115035
- return value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/g, "");
115036
- }
115037
- function isUuidShaped2(value) {
115038
- return UUID_PATTERN2.test(value.trim());
115039
- }
115040
- function isCommonCoreMathIdentifier2(identifier) {
115041
- const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
115042
- return canonical.startsWith("CCSS.MATH.") || /^(?:K|[1-8])\.(?:CC|OA|NBT|NF|MD|RP|NS|EE|F|G|SP)\./.test(canonical) || /^(?:HS)?(?:N|A|F|G|S)-[A-Z]+\./.test(canonical) || /^MP\.?\d/.test(canonical);
115043
- }
115044
- function isCommonCoreElaIdentifier2(identifier) {
115045
- const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
115046
- return canonical.startsWith("CCSS.ELA-LITERACY.") || /^(?:RL|RI|RF|W|SL|L)\.(?:K|[1-9]|1[0-2])\./.test(canonical) || /^(?:RH|RST|WHST)\.(?:6-8|9-10|11-12)\./.test(canonical) || /^CCRA\.(?:R|W|SL|L)\./.test(canonical);
115047
- }
115048
- function canonicalFramework2(authoredFramework, identifier) {
115049
- const aliasKey = frameworkAliasKey2(authoredFramework);
115050
- if (COMMON_CORE_MATH_FRAMEWORK_ALIASES2.has(aliasKey) || COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey) && isCommonCoreMathIdentifier2(identifier)) {
115051
- return "CCSS.Math";
115052
- }
115053
- if (COMMON_CORE_ELA_FRAMEWORK_ALIASES2.has(aliasKey) || COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey) && isCommonCoreElaIdentifier2(identifier)) {
115054
- return "CCSS.ELA-Literacy";
115055
- }
115056
- if (COMMON_CORE_FRAMEWORK_ALIASES2.has(aliasKey)) {
115057
- return "CCSS";
115058
- }
115059
- return normalizedIdentityCase2(authoredFramework);
115060
- }
115061
- function canonicalIdentifier2(framework, authoredIdentifier) {
115062
- const identifier = normalizedIdentityCase2(normalizedWhitespace2(authoredIdentifier));
115063
- if (framework === "CCSS.Math") {
115064
- return identifier.replace(/^CCSS\.MATH\.CONTENT\./, "").replace(/^CCSS\.MATH\.PRACTICE\./, "").replace(/^CCSS\.MATH\./, "");
115065
- }
115066
- if (framework === "CCSS.ELA-Literacy") {
115067
- return identifier.replace(/^CCSS\.ELA-LITERACY\./, "");
115068
- }
115069
- return identifier;
115070
- }
115071
- function readableAlignmentIdentifier2(alignment) {
115072
- const identifier = alignment.identifier?.trim() ?? "";
115073
- if (identifier && !isUuidShaped2(identifier)) {
115074
- return identifier;
115075
- }
115076
- const id = alignment.id.trim();
115077
- return id && !isUuidShaped2(id) ? id : "";
115078
- }
115079
- function canonicalAssessmentStandardRef2(standard) {
115080
- const authoredFramework = normalizedWhitespace2(standard.framework);
115081
- const framework = canonicalFramework2(authoredFramework, standard.identifier);
115082
- const identifier = canonicalIdentifier2(framework, standard.identifier);
115083
- return {
115084
- framework,
115085
- identifier
115086
- };
115087
- }
115088
- function assessmentStandardRefKey2(standard) {
115089
- const canonical = canonicalAssessmentStandardRef2(standard);
115090
- return JSON.stringify([
115091
- normalizedIdentityCase2(canonical.framework),
115092
- normalizedIdentityCase2(canonical.identifier)
115093
- ]);
115094
- }
115095
115111
  function dedupeStandards2(standards) {
115096
115112
  const deduped = new Map;
115097
115113
  for (const standard of standards) {
@@ -115182,22 +115198,6 @@ function qtiStandardsFromMetadata(metadata2) {
115182
115198
  standards.push(...qtiAlignmentStandardsFromMetadata2(metadata2));
115183
115199
  return dedupeStandards2(standards);
115184
115200
  }
115185
- function qtiAssessmentStandardRefsFromMetadata2(metadata2) {
115186
- const refs = new Map;
115187
- for (const alignment of qtiAlignmentStandardsFromMetadata2(metadata2)) {
115188
- const identifier = readableAlignmentIdentifier2(alignment);
115189
- if (identifier) {
115190
- const canonical = canonicalAssessmentStandardRef2({
115191
- framework: alignment.source,
115192
- identifier
115193
- });
115194
- if (canonical.framework && canonical.identifier) {
115195
- refs.set(assessmentStandardRefKey2(canonical), canonical);
115196
- }
115197
- }
115198
- }
115199
- return [...refs.values()];
115200
- }
115201
115201
  var EVENT_HANDLER_ATTRIBUTE;
115202
115202
  var SCRIPT_SCHEMES2;
115203
115203
  var DATA_RASTER_IMAGE_PATTERN2;
@@ -115242,7 +115242,6 @@ var BLANK_SENTINEL22 = "
115242
115242
  var PLAYABLE_POINT_VALUE;
115243
115243
  var POINT_INTERACTION_TYPES2;
115244
115244
  var INDEPENDENT_PROCESSING_TAGS2;
115245
- var UUID_PATTERN2;
115246
115245
  var COMMON_CORE_MATH_FRAMEWORK_ALIASES2;
115247
115246
  var COMMON_CORE_ELA_FRAMEWORK_ALIASES2;
115248
115247
  var COMMON_CORE_FRAMEWORK_ALIASES2;
@@ -115495,7 +115494,6 @@ var init_qti = __esm(() => {
115495
115494
  "qti-sum",
115496
115495
  "qti-base-value"
115497
115496
  ]);
115498
- UUID_PATTERN2 = /^(?:urn:uuid:)?\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?$/i;
115499
115497
  COMMON_CORE_MATH_FRAMEWORK_ALIASES2 = new Set([
115500
115498
  "ccssm",
115501
115499
  "ccssmath",
@@ -117851,6 +117849,109 @@ var init_timeback_admin_service = __esm(async () => {
117851
117849
  init_timeback_mastery_completion_util()
117852
117850
  ]);
117853
117851
  });
117852
+ function createInMemoryDatabaseLock() {
117853
+ const tails = new Map;
117854
+ return async (db2, key, operation) => {
117855
+ const previous = tails.get(key) ?? Promise.resolve();
117856
+ let release;
117857
+ const gate = new Promise((resolve2) => {
117858
+ release = resolve2;
117859
+ });
117860
+ const tail = previous.then(() => gate);
117861
+ tails.set(key, tail);
117862
+ await previous;
117863
+ try {
117864
+ return await operation(db2);
117865
+ } finally {
117866
+ release();
117867
+ if (tails.get(key) === tail) {
117868
+ tails.delete(key);
117869
+ }
117870
+ }
117871
+ };
117872
+ }
117873
+ var LockContentionError;
117874
+ var init_locks = __esm(() => {
117875
+ LockContentionError = class LockContentionError2 extends Error {
117876
+ constructor(key) {
117877
+ super(`Lock is already held: ${key}`);
117878
+ this.name = "LockContentionError";
117879
+ }
117880
+ };
117881
+ });
117882
+ function normalizeKeys(keys) {
117883
+ return [...new Set(keys)].toSorted();
117884
+ }
117885
+ function errorCode(error88) {
117886
+ if (typeof error88 !== "object" || error88 === null || !("code" in error88)) {
117887
+ return;
117888
+ }
117889
+ return typeof error88.code === "string" ? error88.code : undefined;
117890
+ }
117891
+ function translateAssessmentRuntimeLockError(error88) {
117892
+ if (error88 instanceof LockContentionError) {
117893
+ throw new ServiceUnavailableError("Another assessment operation is in progress. Try again shortly.", { retryable: true });
117894
+ }
117895
+ if (error88 instanceof AssessmentRuntimeLockTimeoutError) {
117896
+ throw new ServiceUnavailableError("The assessment operation took too long. Try again shortly.", { retryable: true, reason: "LOCK_TIMEOUT" });
117897
+ }
117898
+ const code = errorCode(error88);
117899
+ if (code && POSTGRES_TIMEOUT_ERROR_CODES.has(code)) {
117900
+ throw new ServiceUnavailableError("The assessment operation took too long. Try again shortly.", { retryable: true, reason: "LOCK_TIMEOUT" });
117901
+ }
117902
+ if (code && POSTGRES_CONNECTION_ERROR_CODES.has(code)) {
117903
+ throw new ServiceUnavailableError("The assessment lock connection was interrupted. Try again shortly.", { retryable: true, reason: "LOCK_CONNECTION_LOST" });
117904
+ }
117905
+ throw error88;
117906
+ }
117907
+ function createInMemoryAssessmentRuntimeLock() {
117908
+ const lock = createInMemoryDatabaseLock();
117909
+ return async (db2, keys, operation) => {
117910
+ const ordered = normalizeKeys(keys);
117911
+ function acquire(index2, operationDb) {
117912
+ const key = ordered[index2];
117913
+ return key ? lock(operationDb, key, (nested) => acquire(index2 + 1, nested)) : operation(operationDb);
117914
+ }
117915
+ try {
117916
+ return await acquire(0, db2);
117917
+ } catch (error88) {
117918
+ return translateAssessmentRuntimeLockError(error88);
117919
+ }
117920
+ };
117921
+ }
117922
+ function assessmentRuntimeStartLockKey(input) {
117923
+ return JSON.stringify([
117924
+ ASSESSMENT_RUNTIME_LOCK_NAMESPACE,
117925
+ "start",
117926
+ input.studentId,
117927
+ input.activityId,
117928
+ input.purpose,
117929
+ input.courseId
117930
+ ]);
117931
+ }
117932
+ function assessmentRuntimeAttemptLockKey(attemptId) {
117933
+ return JSON.stringify([ASSESSMENT_RUNTIME_LOCK_NAMESPACE, "attempt", attemptId]);
117934
+ }
117935
+ var ASSESSMENT_RUNTIME_LOCK_NAMESPACE = "playcademy-assessment-runtime-v1";
117936
+ var POSTGRES_TIMEOUT_ERROR_CODES;
117937
+ var POSTGRES_CONNECTION_ERROR_CODES;
117938
+ var AssessmentRuntimeLockTimeoutError;
117939
+ var init_assessment_runtime_lock_util = __esm(() => {
117940
+ init_locks();
117941
+ init_errors2();
117942
+ POSTGRES_TIMEOUT_ERROR_CODES = new Set(["25P03", "57014"]);
117943
+ POSTGRES_CONNECTION_ERROR_CODES = new Set([
117944
+ "CONNECTION_CLOSED",
117945
+ "CONNECTION_DESTROYED",
117946
+ "CONNECTION_ENDED"
117947
+ ]);
117948
+ AssessmentRuntimeLockTimeoutError = class AssessmentRuntimeLockTimeoutError2 extends Error {
117949
+ constructor(timeoutMs) {
117950
+ super(`Assessment operation exceeded its ${timeoutMs}ms lock deadline`);
117951
+ this.name = "AssessmentRuntimeLockTimeoutError";
117952
+ }
117953
+ };
117954
+ });
117854
117955
  function validateAssessmentStatusTransition(current, next) {
117855
117956
  if (current === next) {
117856
117957
  return;
@@ -117895,8 +117996,8 @@ function assertAssessmentHasQuestions(questions) {
117895
117996
  }
117896
117997
  }
117897
117998
  function assertReviewAssessmentHasStandards(standardCounts) {
117898
- if (standardCounts.some((count) => count <= 0)) {
117899
- throw new ValidationError("Every question in a review assessment must have a standards alignment");
117999
+ if (standardCounts.some((count) => count !== 1)) {
118000
+ throw new ValidationError("Every question in a review assessment must have exactly one standards alignment");
117900
118001
  }
117901
118002
  }
117902
118003
  function planAssessmentRemoval(status) {
@@ -117959,6 +118060,41 @@ function orderLiveAssessmentRows(liveRows, purpose, testIdentifiers) {
117959
118060
  var init_timeback_assessment_rules_util = __esm(() => {
117960
118061
  init_errors2();
117961
118062
  });
118063
+ function stageAssessmentAttemptSupersession(attempt) {
118064
+ Object.assign(attempt.result, ASSESSMENT_ATTEMPT_SUPERSEDED);
118065
+ Object.assign(attempt.selection, ASSESSMENT_ATTEMPT_SUPERSEDED);
118066
+ }
118067
+ function shouldConfirmAssessmentVersionUnavailable(details) {
118068
+ return typeof details === "object" && details !== null && "expectedRevision" in details && typeof details.expectedRevision === "string" && !("currentRevision" in details);
118069
+ }
118070
+ function buildAssessmentAttemptSupersessionUpdate(result) {
118071
+ if (result.status !== "active" || !isAssessmentAttemptOpen({
118072
+ inProgress: result.inProgress ?? "",
118073
+ scoreStatus: result.scoreStatus
118074
+ })) {
118075
+ return null;
118076
+ }
118077
+ return {
118078
+ status: "active",
118079
+ assessmentLineItem: result.assessmentLineItem,
118080
+ student: result.student,
118081
+ score: result.score ?? 0,
118082
+ scoreDate: result.scoreDate,
118083
+ ...ASSESSMENT_ATTEMPT_SUPERSEDED,
118084
+ metadata: result.metadata ?? {}
118085
+ };
118086
+ }
118087
+ async function resolveAvailableAssessmentAttempt(initialDecision, select3, resume) {
118088
+ let decision = initialDecision;
118089
+ while (decision?.kind === "resume") {
118090
+ const snapshot = await resume(decision);
118091
+ if (snapshot !== null) {
118092
+ return { kind: "resumed", snapshot };
118093
+ }
118094
+ decision = select3();
118095
+ }
118096
+ return { kind: "continue", decision };
118097
+ }
117962
118098
  function buildReviewItemResultMetadata(input) {
117963
118099
  const administration = itemAdministration(input.metadata.itemSubmissions, input.selection.itemIdentifier);
117964
118100
  if (!administration) {
@@ -118403,8 +118539,8 @@ function parsePlayableItem(question) {
118403
118539
  function assessmentContentRevision(test, questions) {
118404
118540
  return sha256Hex(canonicalJson2({
118405
118541
  identifier: test.identifier,
118542
+ title: test.title,
118406
118543
  qtiVersion: test.qtiVersion,
118407
- rawXml: test.rawXml,
118408
118544
  parts: test["qti-test-part"],
118409
118545
  questions: questions.questions.map(({ reference, question }) => ({
118410
118546
  identifier: reference.identifier,
@@ -118560,11 +118696,30 @@ async function hydrateQtiTestQuestions(client2, references) {
118560
118696
  }));
118561
118697
  return { ...references, questions };
118562
118698
  }
118563
- async function loadHydratedQtiTest(client2, identifier) {
118699
+ async function hydrateQtiTestQuestionSelection(client2, references, itemIdentifiers) {
118700
+ const referenceByIdentifier = new Map(references.questions.map((reference) => [reference.reference.identifier, reference]));
118701
+ const selectedReferences = itemIdentifiers.map((itemIdentifier) => {
118702
+ const reference = referenceByIdentifier.get(itemIdentifier);
118703
+ if (!reference) {
118704
+ throw new Error(`QTI assessment ${references.assessmentTest} does not contain item ${itemIdentifier}`);
118705
+ }
118706
+ return reference;
118707
+ });
118708
+ return hydrateQtiTestQuestions(client2, {
118709
+ ...references,
118710
+ totalQuestions: selectedReferences.length,
118711
+ questions: selectedReferences
118712
+ });
118713
+ }
118714
+ async function loadQtiTestReferences(client2, identifier) {
118564
118715
  const [test, references] = await Promise.all([
118565
118716
  client2.qtiApi.assessmentTests.get(identifier),
118566
118717
  client2.qtiApi.assessmentTests.getQuestions(identifier)
118567
118718
  ]);
118719
+ return { test, references };
118720
+ }
118721
+ async function loadHydratedQtiTest(client2, identifier) {
118722
+ const { test, references } = await loadQtiTestReferences(client2, identifier);
118568
118723
  return {
118569
118724
  test,
118570
118725
  questions: await hydrateQtiTestQuestions(client2, references)
@@ -118578,6 +118733,7 @@ class TimebackAssessmentRuntimeService {
118578
118733
  static ASSESSMENT_CACHE_TTL_MS = 60000;
118579
118734
  static EXPORT_CONCURRENCY = 4;
118580
118735
  static ITEM_SUBMISSION_MAX_PREPARATION_ATTEMPTS = 3;
118736
+ static ASSESSMENT_VERSION_CONFIRMATION_DELAY_MS = 25;
118581
118737
  static PROJECTION_BARRIER_MAX_ATTEMPTS = 5;
118582
118738
  static PROJECTION_BARRIER_RETRY_DELAY_MS = 25;
118583
118739
  static SCORING_CONCURRENCY = 4;
@@ -118590,6 +118746,10 @@ class TimebackAssessmentRuntimeService {
118590
118746
  defaultTTL: TimebackAssessmentRuntimeService.ASSESSMENT_CACHE_TTL_MS,
118591
118747
  maxSize: TimebackAssessmentRuntimeService.ASSESSMENT_CACHE_LIMIT
118592
118748
  });
118749
+ reviewSelectionCache = new TimebackCache({
118750
+ defaultTTL: TimebackAssessmentRuntimeService.ASSESSMENT_CACHE_TTL_MS,
118751
+ maxSize: TimebackAssessmentRuntimeService.ASSESSMENT_CACHE_LIMIT
118752
+ });
118593
118753
  constructor(deps) {
118594
118754
  this.deps = deps;
118595
118755
  }
@@ -118608,14 +118768,15 @@ class TimebackAssessmentRuntimeService {
118608
118768
  }
118609
118769
  const masteryStandard = input.purpose === "mastery" ? normalizeMasteryStandard(input.standard) : undefined;
118610
118770
  const requestFingerprint = this.startRequestFingerprint(input);
118771
+ const pendingSupersessions = new Set;
118611
118772
  const candidates = await this.candidateIntegrations(gameId, studentId, input);
118612
118773
  const lockKeys = candidates.rows.map((row) => assessmentRuntimeStartLockKey({
118613
118774
  studentId,
118614
118775
  activityId: input.activityId,
118615
118776
  purpose: input.purpose,
118616
118777
  courseId: row.courseId
118617
- })).toSorted();
118618
- return this.withStartLocks(lockKeys, async (db2) => {
118778
+ }));
118779
+ return this.deps.assessmentRuntimeLock(this.deps.db, lockKeys, async (db2) => {
118619
118780
  const context2 = await this.selectIntegration(candidates, studentId, input);
118620
118781
  const attemptScope = {
118621
118782
  studentId,
@@ -118630,20 +118791,21 @@ class TimebackAssessmentRuntimeService {
118630
118791
  this.listAttempts(attemptScope)
118631
118792
  ]);
118632
118793
  const attempts = listing.attempts;
118633
- const compatibleAttempts = this.attemptsMatchingRequest(attempts, requestFingerprint);
118634
- let decision = this.requireDecision(selectRuntimeAssessment(tests, [...compatibleAttempts.values()].map((entry2) => entry2.selection)), input.purpose);
118635
- if (decision.kind === "resume") {
118636
- return this.resumeSnapshot(decision, attempts, context2.integration);
118794
+ const initialResolution = await this.resumeAvailableAttempt(tests, attempts, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeSnapshot(resumeDecision, attempts, context2.integration));
118795
+ if (initialResolution.kind === "resumed") {
118796
+ await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
118797
+ return initialResolution.snapshot;
118637
118798
  }
118799
+ let decision = this.requireDecision(initialResolution.decision, input.purpose);
118638
118800
  let assessment = await this.loadAssessment(decision.test.qtiTestIdentifier);
118639
118801
  const refreshedListing = await this.listAttempts(attemptScope);
118640
118802
  const refreshed = refreshedListing.attempts;
118641
- const refreshedCompatible = this.attemptsMatchingRequest(refreshed, requestFingerprint);
118642
- const refreshedDecision = this.requireDecision(selectRuntimeAssessment(tests, [...refreshedCompatible.values()].map((entry2) => entry2.selection)), input.purpose);
118643
- if (refreshedDecision.kind === "resume") {
118644
- return this.resumeSnapshot(refreshedDecision, refreshed, context2.integration);
118803
+ const refreshedResolution = await this.resumeAvailableAttempt(tests, refreshed, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeSnapshot(resumeDecision, refreshed, context2.integration));
118804
+ if (refreshedResolution.kind === "resumed") {
118805
+ await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
118806
+ return refreshedResolution.snapshot;
118645
118807
  }
118646
- decision = refreshedDecision;
118808
+ decision = this.requireDecision(refreshedResolution.decision, input.purpose);
118647
118809
  if (assessment.identifier !== decision.test.qtiTestIdentifier) {
118648
118810
  assessment = await this.loadAssessment(decision.test.qtiTestIdentifier);
118649
118811
  }
@@ -118701,6 +118863,7 @@ class TimebackAssessmentRuntimeService {
118701
118863
  grade: context2.integration.grade
118702
118864
  })
118703
118865
  });
118866
+ await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
118704
118867
  return this.snapshot(result, metadata2, assessment, context2.integration);
118705
118868
  });
118706
118869
  }
@@ -118710,14 +118873,15 @@ class TimebackAssessmentRuntimeService {
118710
118873
  input
118711
118874
  }) {
118712
118875
  const requestFingerprint = this.startRequestFingerprint(input);
118876
+ const pendingSupersessions = new Set;
118713
118877
  const candidates = await this.candidateIntegrations(gameId, studentId, input);
118714
118878
  const lockKeys = candidates.rows.map((row) => assessmentRuntimeStartLockKey({
118715
118879
  studentId,
118716
118880
  activityId: input.activityId,
118717
118881
  purpose: input.purpose,
118718
118882
  courseId: row.courseId
118719
- })).toSorted();
118720
- return this.withStartLocks(lockKeys, async (db2) => {
118883
+ }));
118884
+ return this.deps.assessmentRuntimeLock(this.deps.db, lockKeys, async (db2) => {
118721
118885
  const context2 = await this.selectIntegration(candidates, studentId, input);
118722
118886
  const attemptScope = {
118723
118887
  studentId,
@@ -118731,22 +118895,23 @@ class TimebackAssessmentRuntimeService {
118731
118895
  this.liveTests(context2.integration.id, input.purpose, db2, undefined, input.diagnosticKey),
118732
118896
  this.listAttempts(attemptScope)
118733
118897
  ]);
118734
- const compatible = this.attemptsMatchingRequest(listing.attempts, requestFingerprint);
118735
- let decision = this.requireDecision(selectRuntimeAssessment(tests, [...compatible.values()].map((entry2) => entry2.selection)), input.purpose);
118736
- if (decision.kind === "resume") {
118737
- return this.resumeDiagnosticSnapshot(decision, listing.attempts, context2.integration, db2);
118898
+ const initialResolution = await this.resumeAvailableAttempt(tests, listing.attempts, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeDiagnosticSnapshot(resumeDecision, listing.attempts, context2.integration, db2));
118899
+ if (initialResolution.kind === "resumed") {
118900
+ await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
118901
+ return initialResolution.snapshot;
118738
118902
  }
118903
+ let decision = this.requireDecision(initialResolution.decision, input.purpose);
118739
118904
  let definition = await this.requireDiagnosticDefinition(decision.test.id, context2.integration.id, db2, true);
118740
118905
  let assessment = await this.loadAssessment(definition.qtiTestIdentifier);
118741
118906
  let routing = await this.initializeHostedDiagnostic(definition, assessment);
118742
118907
  const refreshedListing = await this.listAttempts(attemptScope);
118743
118908
  const refreshed = refreshedListing.attempts;
118744
- const refreshedCompatible = this.attemptsMatchingRequest(refreshed, requestFingerprint);
118745
- const refreshedDecision = this.requireDecision(selectRuntimeAssessment(tests, [...refreshedCompatible.values()].map((entry2) => entry2.selection)), input.purpose);
118746
- if (refreshedDecision.kind === "resume") {
118747
- return this.resumeDiagnosticSnapshot(refreshedDecision, refreshed, context2.integration, db2);
118909
+ const refreshedResolution = await this.resumeAvailableAttempt(tests, refreshed, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeDiagnosticSnapshot(resumeDecision, refreshed, context2.integration, db2));
118910
+ if (refreshedResolution.kind === "resumed") {
118911
+ await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
118912
+ return refreshedResolution.snapshot;
118748
118913
  }
118749
- decision = refreshedDecision;
118914
+ decision = this.requireDecision(refreshedResolution.decision, input.purpose);
118750
118915
  if (definition.id !== decision.test.id) {
118751
118916
  definition = await this.requireDiagnosticDefinition(decision.test.id, context2.integration.id, db2, true);
118752
118917
  assessment = await this.loadAssessment(definition.qtiTestIdentifier);
@@ -118815,6 +118980,7 @@ class TimebackAssessmentRuntimeService {
118815
118980
  grade: context2.integration.grade
118816
118981
  })
118817
118982
  });
118983
+ await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
118818
118984
  return this.diagnosticSnapshot(result, metadata2, assessment, context2.integration, routing.state);
118819
118985
  });
118820
118986
  }
@@ -118823,7 +118989,9 @@ class TimebackAssessmentRuntimeService {
118823
118989
  studentId,
118824
118990
  input
118825
118991
  }) {
118992
+ const preparationStartedAt = Date.now();
118826
118993
  const requestFingerprint = reviewRequestFingerprint(input, DEFAULT_REVIEW_SELECTION_POLICY);
118994
+ const pendingSupersessions = new Set;
118827
118995
  const normalizedRequest = normalizeReviewRequest(input);
118828
118996
  const candidates = await this.candidateIntegrations(gameId, studentId, input);
118829
118997
  const lockKeys = candidates.rows.map((row) => assessmentRuntimeStartLockKey({
@@ -118831,8 +118999,8 @@ class TimebackAssessmentRuntimeService {
118831
118999
  activityId: input.activityId,
118832
119000
  purpose: input.purpose,
118833
119001
  courseId: row.courseId
118834
- })).toSorted();
118835
- return this.withStartLocks(lockKeys, async (db2) => {
119002
+ }));
119003
+ return this.deps.assessmentRuntimeLock(this.deps.db, lockKeys, async (db2) => {
118836
119004
  const context2 = await this.selectIntegration(candidates, studentId, input);
118837
119005
  const attemptScope = {
118838
119006
  studentId,
@@ -118847,38 +119015,44 @@ class TimebackAssessmentRuntimeService {
118847
119015
  this.listAttempts(attemptScope)
118848
119016
  ]);
118849
119017
  const attempts = listing.attempts;
118850
- const compatible = this.attemptsMatchingRequest(attempts, requestFingerprint);
118851
- let decision = selectRuntimeAssessment(tests, [...compatible.values()].map((entry2) => entry2.selection));
118852
- if (decision?.kind === "resume") {
118853
- return this.resumeReviewSnapshot(decision, compatible, listing.reviewChildren, context2);
119018
+ const initialResolution = await this.resumeAvailableAttempt(tests, attempts, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeReviewSnapshot(resumeDecision, attempts, listing.reviewChildren, context2));
119019
+ if (initialResolution.kind === "resumed") {
119020
+ await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
119021
+ return initialResolution.snapshot;
118854
119022
  }
119023
+ let decision = initialResolution.decision;
118855
119024
  if (tests.length === 0 || !decision) {
118856
119025
  throw AssessmentRuntimeError.from(assessmentNoEligibleTests(input.purpose));
118857
119026
  }
118858
119027
  if (tests.length > 1) {
118859
119028
  throw new ValidationError("Standards review requires exactly one live review-bank assessment.");
118860
119029
  }
118861
- let source = await this.loadReviewBank(decision.test.qtiTestIdentifier);
119030
+ const manifestStartedAt = Date.now();
119031
+ let catalog = await this.loadReviewBankCatalog(decision.test.qtiTestIdentifier);
119032
+ this.recordReviewPreparationPhase("manifest_load", manifestStartedAt, {
119033
+ candidateCount: catalog.bank.items.length
119034
+ });
118862
119035
  const refreshedListing = await this.listAttempts(attemptScope);
118863
119036
  const refreshed = refreshedListing.attempts;
118864
- const refreshedCompatible = this.attemptsMatchingRequest(refreshed, requestFingerprint);
118865
- const refreshedDecision = selectRuntimeAssessment(tests, [...refreshedCompatible.values()].map((entry2) => entry2.selection));
118866
- if (refreshedDecision?.kind === "resume") {
118867
- return this.resumeReviewSnapshot(refreshedDecision, refreshedCompatible, refreshedListing.reviewChildren, context2);
119037
+ const refreshedResolution = await this.resumeAvailableAttempt(tests, refreshed, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeReviewSnapshot(resumeDecision, refreshed, refreshedListing.reviewChildren, context2));
119038
+ if (refreshedResolution.kind === "resumed") {
119039
+ await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
119040
+ return refreshedResolution.snapshot;
118868
119041
  }
119042
+ const refreshedDecision = refreshedResolution.decision;
118869
119043
  if (!refreshedDecision || refreshedDecision.kind !== "start") {
118870
119044
  throw AssessmentRuntimeError.from(assessmentNoEligibleTests(input.purpose));
118871
119045
  }
118872
119046
  decision = refreshedDecision;
118873
- if (source.assessment.identifier !== decision.test.qtiTestIdentifier) {
118874
- source = await this.loadReviewBank(decision.test.qtiTestIdentifier);
119047
+ if (catalog.test.identifier !== decision.test.qtiTestIdentifier) {
119048
+ catalog = await this.loadReviewBankCatalog(decision.test.qtiTestIdentifier);
118875
119049
  }
118876
119050
  const exposures = this.reviewExposures(refreshedListing.reviewChildren.values(), {
118877
- bankRevision: source.bank.bankRevision
119051
+ bankRevision: catalog.bank.bankRevision
118878
119052
  });
118879
119053
  const selected = selectReviewItems({
118880
119054
  request: input,
118881
- bank: source.bank,
119055
+ bank: catalog.bank,
118882
119056
  exposures
118883
119057
  });
118884
119058
  if (selected.selections.length === 0) {
@@ -118887,7 +119061,13 @@ class TimebackAssessmentRuntimeService {
118887
119061
  }
118888
119062
  throw AssessmentRuntimeError.from(assessmentReviewBankShortage(selected.fulfillment));
118889
119063
  }
119064
+ const hydrationStartedAt = Date.now();
119065
+ const source = await this.hydrateReviewBankSelection(catalog, selected.selections.map((selection) => selection.itemIdentifier));
119066
+ this.recordReviewPreparationPhase("selected_hydration", hydrationStartedAt, {
119067
+ selectedCount: selected.selections.length
119068
+ });
118890
119069
  const assessment = projectReviewAssessment(source.assessment, source.bank, selected.selections);
119070
+ const provisioningStartedAt = Date.now();
118891
119071
  const parentLineItemId = await this.ensureReviewBankLineItem({
118892
119072
  integration: context2.integration,
118893
119073
  activityId: input.activityId,
@@ -118950,6 +119130,12 @@ class TimebackAssessmentRuntimeService {
118950
119130
  existingChildResults: refreshedListing.reviewChildren,
118951
119131
  lookupMissingResults: false
118952
119132
  });
119133
+ await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
119134
+ this.recordReviewPreparationPhase("provisioning", provisioningStartedAt);
119135
+ this.recordReviewPreparationPhase("total", preparationStartedAt, {
119136
+ candidateCount: catalog.bank.items.length,
119137
+ selectedCount: selected.selections.length
119138
+ });
118953
119139
  return this.snapshot(result, metadata2, assessment, context2.integration);
118954
119140
  });
118955
119141
  }
@@ -119032,7 +119218,7 @@ class TimebackAssessmentRuntimeService {
119032
119218
  "app.error.message": errorMessage2(error88)
119033
119219
  });
119034
119220
  }
119035
- return this.deps.assessmentRuntimeLock(this.deps.db, assessmentRuntimeAttemptLockKey(params.attemptId), async (db2) => {
119221
+ return this.deps.assessmentRuntimeLock(this.deps.db, [assessmentRuntimeAttemptLockKey(params.attemptId)], async (db2) => {
119036
119222
  const attempt = await this.authorizeAttempt(params, {
119037
119223
  db: db2,
119038
119224
  developerAccessValidated: true
@@ -119080,7 +119266,7 @@ class TimebackAssessmentRuntimeService {
119080
119266
  let preview = await this.prepareItemSubmission(params, input);
119081
119267
  let preparationAttempts = 1;
119082
119268
  while (true) {
119083
- const committed = await this.deps.assessmentRuntimeLock(this.deps.db, assessmentRuntimeAttemptLockKey(params.attemptId), async (db2) => {
119269
+ const committed = await this.deps.assessmentRuntimeLock(this.deps.db, [assessmentRuntimeAttemptLockKey(params.attemptId)], async (db2) => {
119084
119270
  const attempt = await this.authorizeAttempt(params, {
119085
119271
  db: db2,
119086
119272
  developerAccessValidated: true
@@ -119176,7 +119362,7 @@ class TimebackAssessmentRuntimeService {
119176
119362
  let preview = await this.prepareDiagnosticItemSubmission(params, input);
119177
119363
  let preparationAttempts = 1;
119178
119364
  while (true) {
119179
- const committed = await this.deps.assessmentRuntimeLock(this.deps.db, assessmentRuntimeAttemptLockKey(params.attemptId), async (db2) => {
119365
+ const committed = await this.deps.assessmentRuntimeLock(this.deps.db, [assessmentRuntimeAttemptLockKey(params.attemptId)], async (db2) => {
119180
119366
  const attempt = await this.authorizeAttempt(params, {
119181
119367
  db: db2,
119182
119368
  developerAccessValidated: true
@@ -119303,7 +119489,7 @@ class TimebackAssessmentRuntimeService {
119303
119489
  preparationAttempts += 1;
119304
119490
  preview = await this.scoreDiagnosticItemSubmission(params, input);
119305
119491
  } else {
119306
- await this.projectDiagnosticItemResponse(committed.projection);
119492
+ await this.projectDiagnosticItemResponse(committed.projection, preview?.assessment);
119307
119493
  return committed.response;
119308
119494
  }
119309
119495
  }
@@ -119510,7 +119696,7 @@ class TimebackAssessmentRuntimeService {
119510
119696
  }) {
119511
119697
  await this.deps.validateDeveloperAccess(params.user, params.gameId);
119512
119698
  const preview = await this.prepareSubmission(params, input);
119513
- const submission = await this.deps.assessmentRuntimeLock(this.deps.db, assessmentRuntimeAttemptLockKey(params.attemptId), async (db2) => {
119699
+ const submission = await this.deps.assessmentRuntimeLock(this.deps.db, [assessmentRuntimeAttemptLockKey(params.attemptId)], async (db2) => {
119514
119700
  const attempt = await this.authorizeAttempt(params, {
119515
119701
  db: db2,
119516
119702
  developerAccessValidated: true
@@ -119717,7 +119903,7 @@ class TimebackAssessmentRuntimeService {
119717
119903
  where: and(eq(gameTimebackAssessmentTests.id, test.id), eq(gameTimebackAssessmentTests.integrationId, integration.id))
119718
119904
  }) : undefined;
119719
119905
  const diagnostic = definition?.diagnosticKey ? await this.initializeHostedDiagnostic(definition, loaded.assessment) : null;
119720
- const reviewBank = purpose === "review" ? await buildReviewBankIndex(loaded.assessment, loaded.questions) : null;
119906
+ const reviewBank = purpose === "review" ? await buildReviewBankIndex(loaded.assessment, loaded.questions, { customOnly: true }) : null;
119721
119907
  return {
119722
119908
  ...test,
119723
119909
  live: true,
@@ -119780,7 +119966,13 @@ class TimebackAssessmentRuntimeService {
119780
119966
  const enrollmentByCourse = new Map(enrollments.map((enrollment) => [enrollment.course.id, enrollment]));
119781
119967
  const candidateRows = integrations.filter((integration) => enrollmentByCourse.has(integration.courseId));
119782
119968
  const liveTestRows = candidateRows.length === 0 ? [] : await db2.query.gameTimebackAssessmentTests.findMany({
119783
- where: and(inArray(gameTimebackAssessmentTests.integrationId, candidateRows.map((row) => row.id)), eq(gameTimebackAssessmentTests.purpose, input.purpose), eq(gameTimebackAssessmentTests.status, "live"))
119969
+ where: and(inArray(gameTimebackAssessmentTests.integrationId, candidateRows.map((row) => row.id)), eq(gameTimebackAssessmentTests.purpose, input.purpose), eq(gameTimebackAssessmentTests.status, "live")),
119970
+ columns: {
119971
+ integrationId: true,
119972
+ standardFramework: true,
119973
+ standardIdentifier: true,
119974
+ diagnosticKey: true
119975
+ }
119784
119976
  });
119785
119977
  const matchesMasteryStandard = input.purpose === "mastery" ? masteryStandardMatcher(input.standard) : undefined;
119786
119978
  const integrationIdsWithLiveTests = new Set(liveTestRows.filter((row) => (!matchesMasteryStandard || matchesMasteryStandard(assessmentStandardForRow(row))) && (input.purpose !== "diagnostic" || row.diagnosticKey === input.diagnosticKey)).map((row) => row.integrationId));
@@ -119827,10 +120019,6 @@ class TimebackAssessmentRuntimeService {
119827
120019
  enrollment: enrollmentByCourse.get(selection.selected.courseId)
119828
120020
  };
119829
120021
  }
119830
- async withStartLocks(keys, operation) {
119831
- const acquire = (index2, db2) => index2 === keys.length ? operation(db2) : this.deps.assessmentRuntimeLock(db2, keys[index2], (nested) => acquire(index2 + 1, nested));
119832
- return acquire(0, this.deps.db);
119833
- }
119834
120022
  async liveTests(integrationId, purpose, db2 = this.deps.db, requestedStandard, requestedDiagnosticKey) {
119835
120023
  const matchesMasteryStandard = requestedStandard ? masteryStandardMatcher(requestedStandard) : undefined;
119836
120024
  const rows = await db2.query.gameTimebackAssessmentTests.findMany({
@@ -119973,6 +120161,75 @@ class TimebackAssessmentRuntimeService {
119973
120161
  });
119974
120162
  return this.diagnosticSnapshot(resumed.result, resumed.metadata, assessment, integration, routing.state);
119975
120163
  }
120164
+ async resumeAvailableAttempt(tests, attempts, requestFingerprint, pendingSupersessions, resume) {
120165
+ const select3 = () => selectRuntimeAssessment(tests, [...this.attemptsMatchingRequest(attempts, requestFingerprint).values()].map((entry2) => entry2.selection));
120166
+ return resolveAvailableAssessmentAttempt(select3(), select3, (decision) => this.resumeSnapshotOrStageSupersession(decision, attempts, pendingSupersessions, () => resume(decision)));
120167
+ }
120168
+ async resumeSnapshotOrStageSupersession(decision, attempts, pendingSupersessions, resume) {
120169
+ try {
120170
+ return await resume();
120171
+ } catch (error88) {
120172
+ if (!(error88 instanceof AssessmentRuntimeError) || error88.runtimeCode !== "SELECTED_TEST_VERSION_UNAVAILABLE") {
120173
+ throw error88;
120174
+ }
120175
+ if (shouldConfirmAssessmentVersionUnavailable(error88.details)) {
120176
+ await sleep(TimebackAssessmentRuntimeService.ASSESSMENT_VERSION_CONFIRMATION_DELAY_MS);
120177
+ try {
120178
+ return await resume();
120179
+ } catch (confirmationError) {
120180
+ if (!(confirmationError instanceof AssessmentRuntimeError) || confirmationError.runtimeCode !== "SELECTED_TEST_VERSION_UNAVAILABLE") {
120181
+ throw confirmationError;
120182
+ }
120183
+ }
120184
+ }
120185
+ const staged = attempts.get(decision.attempt.attemptId);
120186
+ pendingSupersessions.add(staged.result.sourcedId);
120187
+ stageAssessmentAttemptSupersession(staged);
120188
+ addEvent("assessment.attempt_supersession_staged", {
120189
+ "app.assessment.attempt_id": decision.attempt.attemptId,
120190
+ "app.assessment.reason": "selected_test_version_unavailable"
120191
+ });
120192
+ return null;
120193
+ }
120194
+ }
120195
+ async persistPendingSupersessions(db2, attemptIds) {
120196
+ for (const attemptId of [...attemptIds].toSorted()) {
120197
+ await this.deps.assessmentRuntimeLock(db2, [assessmentRuntimeAttemptLockKey(attemptId)], async () => {
120198
+ let result;
120199
+ try {
120200
+ result = await this.requireClient().api.oneroster.assessmentResults.get(attemptId);
120201
+ } catch (error88) {
120202
+ if (isApiError(error88) && error88.statusCode === 404) {
120203
+ return;
120204
+ }
120205
+ throw error88;
120206
+ }
120207
+ const update2 = buildAssessmentAttemptSupersessionUpdate(result);
120208
+ if (!update2) {
120209
+ return;
120210
+ }
120211
+ await this.requireClient().api.oneroster.assessmentResults.upsert(attemptId, update2);
120212
+ addEvent("assessment.attempt_superseded", {
120213
+ "app.assessment.attempt_id": attemptId,
120214
+ "app.assessment.reason": "selected_test_version_unavailable"
120215
+ });
120216
+ });
120217
+ }
120218
+ }
120219
+ async persistPendingSupersessionsBestEffort(db2, attemptIds) {
120220
+ if (attemptIds.size === 0) {
120221
+ return;
120222
+ }
120223
+ try {
120224
+ await this.persistPendingSupersessions(db2, attemptIds);
120225
+ } catch (error88) {
120226
+ addEvent("assessment.attempt_supersession_persist_failed", {
120227
+ "app.assessment.attempt_ids": [...attemptIds].toSorted().join(","),
120228
+ "exception.type": errorType(error88),
120229
+ "app.error.message": errorMessage2(error88)
120230
+ });
120231
+ }
120232
+ }
119976
120233
  async resumeReviewSnapshot(decision, attempts, existingChildResults, context2) {
119977
120234
  if (decision.additionalResumableAttemptIds.length > 0) {
119978
120235
  addEvent("assessment.multiple_resumable_attempts", {
@@ -119984,7 +120241,8 @@ class TimebackAssessmentRuntimeService {
119984
120241
  if (resumed.metadata.purpose !== "review") {
119985
120242
  throw new Error("A standards-review request selected a non-review attempt");
119986
120243
  }
119987
- const currentSource = await this.loadReviewBank(resumed.metadata.selectedTest.identifier, resumed.metadata.selectedTest.contentRevision);
120244
+ const catalog = await this.loadReviewBankCatalog(resumed.metadata.selectedTest.identifier, resumed.metadata.selectedTest.contentRevision);
120245
+ const currentSource = await this.hydrateReviewBankSelection(catalog, resumed.metadata.review.selections.map((selection) => selection.itemIdentifier));
119988
120246
  const source = this.pinnedReviewBankSource(currentSource, resumed.metadata);
119989
120247
  const assessment = projectReviewAssessment(source.assessment, source.bank, resumed.metadata.review.selections);
119990
120248
  await this.ensureReviewChildResults({
@@ -119998,20 +120256,68 @@ class TimebackAssessmentRuntimeService {
119998
120256
  });
119999
120257
  return this.snapshot(resumed.result, resumed.metadata, assessment, context2.integration);
120000
120258
  }
120001
- async loadReviewBank(identifier, expectedSourceRevision) {
120259
+ recordReviewPreparationPhase(phase, startedAt, counts = {}) {
120260
+ addEvent("assessment.review_preparation_phase", {
120261
+ "app.assessment.review_phase": phase,
120262
+ "app.assessment.duration_ms": Date.now() - startedAt,
120263
+ ...counts.candidateCount === undefined ? {} : { "app.assessment.candidate_count": counts.candidateCount },
120264
+ ...counts.selectedCount === undefined ? {} : { "app.assessment.selected_count": counts.selectedCount }
120265
+ });
120266
+ }
120267
+ async loadReviewBankCatalog(identifier, expectedSourceRevision) {
120002
120268
  if (expectedSourceRevision) {
120003
120269
  const cached3 = this.reviewBankCache.get(`${identifier}\x00${expectedSourceRevision}`);
120004
120270
  if (cached3) {
120005
120271
  return cached3;
120006
120272
  }
120007
120273
  }
120008
- const loaded = await this.loadAssessmentSource(identifier, expectedSourceRevision);
120009
- const source = {
120010
- assessment: loaded.assessment,
120011
- bank: await buildReviewBankIndex(loaded.assessment, loaded.questions)
120012
- };
120013
- this.reviewBankCache.set(`${identifier}\x00${source.assessment.contentRevision}`, source);
120014
- return source;
120274
+ let loaded;
120275
+ try {
120276
+ loaded = await loadQtiTestReferences(this.requireClient(), identifier);
120277
+ } catch (error88) {
120278
+ if (expectedSourceRevision && isApiError(error88) && error88.statusCode === 404) {
120279
+ throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `The selected assessment version for ${identifier} is no longer available.`, { identifier, expectedRevision: expectedSourceRevision });
120280
+ }
120281
+ throw error88;
120282
+ }
120283
+ let bank;
120284
+ try {
120285
+ bank = await reviewBankIndexFromManifest(loaded.test.metadata, {
120286
+ bankIdentifier: identifier,
120287
+ membershipItemIdentifiers: loaded.references.questions.map((question) => question.reference.identifier)
120288
+ });
120289
+ } catch (error88) {
120290
+ throw new ServiceUnavailableError(`Review bank ${identifier} has stale or invalid routing metadata. Use Update Mapping in assessment authoring.`, { reason: errorMessage2(error88) });
120291
+ }
120292
+ if (!bank) {
120293
+ throw new ServiceUnavailableError(`Review bank ${identifier} needs its authoring mapping updated before it can serve review questions.`);
120294
+ }
120295
+ if (expectedSourceRevision && bank.sourceContentRevision !== expectedSourceRevision) {
120296
+ throw new AssessmentRuntimeError("SELECTED_TEST_VERSION_UNAVAILABLE", `The selected assessment version for ${identifier} is no longer available.`, {
120297
+ identifier,
120298
+ expectedRevision: expectedSourceRevision,
120299
+ currentRevision: bank.sourceContentRevision
120300
+ });
120301
+ }
120302
+ const catalog = { ...loaded, bank };
120303
+ this.reviewBankCache.set(`${identifier}\x00${bank.sourceContentRevision}`, catalog);
120304
+ return catalog;
120305
+ }
120306
+ async hydrateReviewBankSelection(catalog, itemIdentifiers) {
120307
+ const cacheKey2 = [
120308
+ catalog.test.identifier,
120309
+ catalog.bank.sourceContentRevision,
120310
+ ...itemIdentifiers
120311
+ ].join("\x00");
120312
+ const cached3 = this.reviewSelectionCache.get(cacheKey2);
120313
+ if (cached3) {
120314
+ return { assessment: cached3, bank: catalog.bank };
120315
+ }
120316
+ const questions = await hydrateQtiTestQuestionSelection(this.requireClient(), catalog.references, itemIdentifiers);
120317
+ const assessment = await buildPlayableAssessment(catalog.test, questions);
120318
+ assessment.contentRevision = catalog.bank.sourceContentRevision;
120319
+ this.reviewSelectionCache.set(cacheKey2, assessment);
120320
+ return { assessment, bank: catalog.bank };
120015
120321
  }
120016
120322
  pinnedReviewBankSource(source, metadata2) {
120017
120323
  return {
@@ -120042,7 +120348,8 @@ class TimebackAssessmentRuntimeService {
120042
120348
  if (metadata2.purpose !== "review") {
120043
120349
  return this.loadAssessment(metadata2.selectedTest.identifier, metadata2.selectedTest.contentRevision);
120044
120350
  }
120045
- const currentSource = await this.loadReviewBank(metadata2.selectedTest.identifier, metadata2.selectedTest.contentRevision);
120351
+ const catalog = await this.loadReviewBankCatalog(metadata2.selectedTest.identifier, metadata2.selectedTest.contentRevision);
120352
+ const currentSource = await this.hydrateReviewBankSelection(catalog, metadata2.review.selections.map((selection) => selection.itemIdentifier));
120046
120353
  const source = this.pinnedReviewBankSource(currentSource, metadata2);
120047
120354
  return projectReviewAssessment(source.assessment, source.bank, metadata2.review.selections);
120048
120355
  }
@@ -120540,7 +120847,7 @@ class TimebackAssessmentRuntimeService {
120540
120847
  async crossAttemptLockBarrier(attemptId) {
120541
120848
  for (let attempt = 1;attempt <= TimebackAssessmentRuntimeService.PROJECTION_BARRIER_MAX_ATTEMPTS; attempt += 1) {
120542
120849
  try {
120543
- await this.deps.assessmentRuntimeLock(this.deps.db, assessmentRuntimeAttemptLockKey(attemptId), async () => {
120850
+ await this.deps.assessmentRuntimeLock(this.deps.db, [assessmentRuntimeAttemptLockKey(attemptId)], async () => {
120544
120851
  return;
120545
120852
  });
120546
120853
  return;
@@ -120665,6 +120972,14 @@ class TimebackAssessmentRuntimeService {
120665
120972
  return AssessmentRuntimeError.from(assessmentAttemptUnauthorized(attemptId));
120666
120973
  }
120667
120974
  async snapshotForResult(result, metadata2, integration) {
120975
+ if (isAssessmentAttemptSuperseded({
120976
+ inProgress: result.inProgress ?? "",
120977
+ scoreStatus: result.scoreStatus
120978
+ })) {
120979
+ throw new GoneError("This assessment attempt has been superseded.", {
120980
+ attemptId: result.sourcedId
120981
+ });
120982
+ }
120668
120983
  const assessment = await this.loadAttemptAssessment(metadata2);
120669
120984
  if (this.isPlatformRoutedDiagnosticMetadata(metadata2)) {
120670
120985
  const routing = await this.loadAttemptDiagnosticRouting(metadata2, assessment);
@@ -122010,6 +122325,60 @@ function buildQtiLibraryListPlan(params) {
122010
122325
  }
122011
122326
  var PLAYCADEMY_QTI_SOURCE_PREFIX = "playcademy-test-";
122012
122327
  var PLAYCADEMY_QTI_SOURCE_UPPER_BOUND = "playcademy-test.";
122328
+ function reviewMappingIssueSummary(label, identifiers) {
122329
+ if (identifiers.length === 0) {
122330
+ return null;
122331
+ }
122332
+ const displayed = identifiers.slice(0, 10).join(", ");
122333
+ const remainder = identifiers.length > 10 ? `, and ${identifiers.length - 10} more` : "";
122334
+ return `${label} (${identifiers.length}): ${displayed}${remainder}`;
122335
+ }
122336
+ async function prepareReviewMappingUpdate(test, questions) {
122337
+ const contentRevision = await assessmentContentRevision(test, questions);
122338
+ const assessment = {
122339
+ identifier: test.identifier,
122340
+ contractVersion: 1,
122341
+ contentRevision,
122342
+ title: test.title,
122343
+ items: questions.questions.map(({ question }) => ({
122344
+ identifier: question.identifier,
122345
+ title: question.title,
122346
+ prompt: "",
122347
+ maxScore: 1,
122348
+ interactions: []
122349
+ }))
122350
+ };
122351
+ const bank = await buildReviewBankIndex(assessment, questions, { customOnly: true });
122352
+ const issues = reviewBankMappingIssues(bank);
122353
+ if (issues.missingOrInvalidItemIdentifiers.length > 0 || issues.multipleStandardItemIdentifiers.length > 0) {
122354
+ const details = [
122355
+ reviewMappingIssueSummary("missing or invalid associations", issues.missingOrInvalidItemIdentifiers),
122356
+ reviewMappingIssueSummary("multiple associations", issues.multipleStandardItemIdentifiers)
122357
+ ].filter((detail) => detail !== null);
122358
+ throw new ValidationError(`Every review question must have exactly one valid standard/node association. ${details.join("; ")}.`);
122359
+ }
122360
+ const manifest = await buildReviewBankManifest(bank);
122361
+ return {
122362
+ update: {
122363
+ ...buildQtiTestUpdateInput(test, test.title),
122364
+ metadata: {
122365
+ ...test.metadata,
122366
+ [PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: manifest
122367
+ }
122368
+ },
122369
+ summary: {
122370
+ itemCount: bank.items.length,
122371
+ standardCount: Object.keys(manifest.itemsByStandard).length,
122372
+ sourceFingerprint: manifest.sourceFingerprint
122373
+ }
122374
+ };
122375
+ }
122376
+ var init_timeback_review_mapping_util = __esm(() => {
122377
+ init_assessment_runtime2();
122378
+ init_errors2();
122379
+ init_timeback_assessment_runtime_util();
122380
+ init_timeback_qti_authoring_util();
122381
+ });
122013
122382
  function databaseConstraintName(error88) {
122014
122383
  if (typeof error88 !== "object" || error88 === null) {
122015
122384
  return;
@@ -122091,7 +122460,8 @@ class TimebackAssessmentsService {
122091
122460
  ownerGameSlug: ownership.gameSlug,
122092
122461
  integrationId,
122093
122462
  subject: integration.subject,
122094
- grade: String(integration.grade)
122463
+ grade: String(integration.grade),
122464
+ [PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: emptyReviewBankManifest(input.qtiTestIdentifier)
122095
122465
  }
122096
122466
  });
122097
122467
  try {
@@ -122160,7 +122530,9 @@ class TimebackAssessmentsService {
122160
122530
  assertAssessmentHasQuestions(loaded.questions.questions);
122161
122531
  assertPlayableAssessmentImportQuestions(manifest.targetStatus, loaded.questions.questions.map(({ question }) => question));
122162
122532
  if (entry2.purpose === "review") {
122163
- assertReviewAssessmentHasStandards(loaded.questions.questions.map(({ question }) => qtiAssessmentStandardRefsFromMetadata2(question.metadata).filter((standard) => canonicalReviewStandardRef(standard) !== null).length));
122533
+ assertReviewAssessmentHasStandards(loaded.questions.questions.map(({ question }) => reviewRoutingStandardRefsFromMetadata(question.metadata, {
122534
+ customOnly: true
122535
+ }).length));
122164
122536
  }
122165
122537
  return { entry: entry2, test: loaded.test };
122166
122538
  } catch (error88) {
@@ -122312,6 +122684,10 @@ class TimebackAssessmentsService {
122312
122684
  if (input.status !== undefined) {
122313
122685
  validateAssessmentStatusTransition(row.status, input.status);
122314
122686
  }
122687
+ if (!publishing && !activatingReview && nextPurpose === "review" && row.purpose !== "review") {
122688
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
122689
+ await this.rebuildReviewMapping(this.requireClient(), qtiTestIdentifier, ownership.gameSlug);
122690
+ }
122315
122691
  if (input.title !== undefined) {
122316
122692
  assertDraftAssessment(row);
122317
122693
  assertAllAssessmentAssociationsDraft(associations);
@@ -122330,7 +122706,8 @@ class TimebackAssessmentsService {
122330
122706
  updates.diagnosticKey = nextDiagnostic.diagnosticKey;
122331
122707
  updates.diagnosticRoutingManifest = nextDiagnostic.routingManifest;
122332
122708
  } else {
122333
- await this.validateAssessmentHasQuestions(qtiTestIdentifier, nextPurpose === "review" && nextStatus === "live");
122709
+ const reviewOwnership = nextPurpose === "review" && nextStatus === "live" ? await this.requireQtiTestOwnershipContext(integrationId, tx) : undefined;
122710
+ await this.validateAssessmentHasQuestions(qtiTestIdentifier, reviewOwnership?.gameSlug);
122334
122711
  }
122335
122712
  }
122336
122713
  let updated = row;
@@ -122425,6 +122802,19 @@ class TimebackAssessmentsService {
122425
122802
  });
122426
122803
  return { ...result, questions };
122427
122804
  }
122805
+ async updateReviewMapping(integrationId, qtiTestIdentifier) {
122806
+ return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx, associations) => {
122807
+ if (!associations.some((association) => association.purpose === "review")) {
122808
+ throw new ValidationError("Review mapping is available only for standards-review assessments.");
122809
+ }
122810
+ const client2 = this.requireClient();
122811
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId, tx);
122812
+ const result = await this.rebuildReviewMapping(client2, qtiTestIdentifier, ownership.gameSlug);
122813
+ setAttribute("app.assessment.operation", "update_review_mapping");
122814
+ setAttribute("app.assessment.review_mapping_item_count", result.itemCount);
122815
+ return result;
122816
+ });
122817
+ }
122428
122818
  async listQuestionLibrary(integrationId, params) {
122429
122819
  const client2 = this.requireClient();
122430
122820
  await this.requireIntegration(integrationId);
@@ -122465,7 +122855,8 @@ class TimebackAssessmentsService {
122465
122855
  integrationId,
122466
122856
  subject: integration.subject,
122467
122857
  grade: String(integration.grade),
122468
- copiedFromTestIdentifier: sourceTestIdentifier
122858
+ copiedFromTestIdentifier: sourceTestIdentifier,
122859
+ [PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: emptyReviewBankManifest(targetTestIdentifier)
122469
122860
  }, itemPlan);
122470
122861
  const attemptedItemIdentifiers = [];
122471
122862
  let testCreationAttempted = false;
@@ -122853,16 +123244,20 @@ class TimebackAssessmentsService {
122853
123244
  const plan = buildQtiLibraryListPlan(params);
122854
123245
  return list(plan.params);
122855
123246
  }
122856
- async validateAssessmentHasQuestions(qtiTestIdentifier, requireReviewStandards = false) {
123247
+ async validateAssessmentHasQuestions(qtiTestIdentifier, reviewGameSlug) {
122857
123248
  const client2 = this.requireClient();
122858
- const result = await client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier);
123249
+ const [test, result] = await Promise.all([
123250
+ client2.qtiApi.assessmentTests.get(qtiTestIdentifier),
123251
+ client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier)
123252
+ ]);
122859
123253
  assertAssessmentHasQuestions(result.questions);
122860
123254
  const hydrated = await hydrateQtiTestQuestions(client2, result);
122861
123255
  for (const { question } of hydrated.questions) {
122862
123256
  assertPlayableQtiQuestion(question);
122863
123257
  }
122864
- if (requireReviewStandards) {
122865
- assertReviewAssessmentHasStandards(hydrated.questions.map(({ question }) => qtiAssessmentStandardRefsFromMetadata2(question.metadata).filter((standard) => canonicalReviewStandardRef(standard) !== null).length));
123258
+ if (reviewGameSlug) {
123259
+ assertQtiTestOwnedByGame(test, reviewGameSlug);
123260
+ await this.writeReviewMapping(client2, test, hydrated);
122866
123261
  }
122867
123262
  }
122868
123263
  async prepareDiagnosticAssociationChanges(row, nextPurpose, requested) {
@@ -122927,6 +123322,19 @@ class TimebackAssessmentsService {
122927
123322
  routingManifest: validation.manifest
122928
123323
  };
122929
123324
  }
123325
+ async rebuildReviewMapping(client2, qtiTestIdentifier, gameSlug) {
123326
+ const [test, references] = await Promise.all([
123327
+ client2.qtiApi.assessmentTests.get(qtiTestIdentifier),
123328
+ client2.qtiApi.assessmentTests.getQuestions(qtiTestIdentifier)
123329
+ ]);
123330
+ assertQtiTestOwnedByGame(test, gameSlug);
123331
+ return this.writeReviewMapping(client2, test, await hydrateQtiTestQuestions(client2, references));
123332
+ }
123333
+ async writeReviewMapping(client2, test, questions) {
123334
+ const prepared = await prepareReviewMappingUpdate(test, questions);
123335
+ await client2.qtiApi.assessmentTests.update(test.identifier, prepared.update);
123336
+ return prepared.summary;
123337
+ }
122930
123338
  }
122931
123339
  var init_timeback_assessments_service = __esm(async () => {
122932
123340
  init_drizzle_orm();
@@ -122934,7 +123342,6 @@ var init_timeback_assessments_service = __esm(async () => {
122934
123342
  init_tables_index();
122935
123343
  init_spans();
122936
123344
  init_assessment_runtime2();
122937
- init_qti();
122938
123345
  init_timeback3();
122939
123346
  init_errors2();
122940
123347
  init_timeback_assessment_import_util();
@@ -122942,6 +123349,7 @@ var init_timeback_assessments_service = __esm(async () => {
122942
123349
  init_timeback_assessment_runtime_util();
122943
123350
  init_timeback_qti_authoring_util();
122944
123351
  init_timeback_qti_hydration_util();
123352
+ init_timeback_review_mapping_util();
122945
123353
  await init_errors8();
122946
123354
  });
122947
123355
  function buildTimebackBaseConfigFromExistingConfig(config5) {
@@ -124928,7 +125336,7 @@ var init_upload_service = __esm(() => {
124928
125336
  function createPlatformServices(deps) {
124929
125337
  const {
124930
125338
  db: db2,
124931
- databaseLock,
125339
+ assessmentRuntimeLock,
124932
125340
  config: config5,
124933
125341
  cloudflare: cloudflare2,
124934
125342
  corsKvs,
@@ -124997,7 +125405,7 @@ function createPlatformServices(deps) {
124997
125405
  db: db2,
124998
125406
  timeback: timebackClient,
124999
125407
  validateDeveloperAccess,
125000
- assessmentRuntimeLock: createAssessmentRuntimeLock(databaseLock)
125408
+ assessmentRuntimeLock
125001
125409
  });
125002
125410
  return {
125003
125411
  bucket,
@@ -125015,7 +125423,6 @@ function createPlatformServices(deps) {
125015
125423
  };
125016
125424
  }
125017
125425
  var init_platform2 = __esm(async () => {
125018
- init_assessment_runtime_lock_util();
125019
125426
  init_bucket_service();
125020
125427
  init_database_service();
125021
125428
  init_deployment_state_service();
@@ -125695,7 +126102,7 @@ var init_standalone = __esm(() => {
125695
126102
  init_verify_service();
125696
126103
  });
125697
126104
  function createServices(ctx) {
125698
- const { db: db2, databaseLock, config: config5, providers, cloudflare: cloudflare2, timeback: timeback2, discord, corsKvs } = ctx;
126105
+ const { db: db2, assessmentRuntimeLock, config: config5, providers, cloudflare: cloudflare2, timeback: timeback2, discord, corsKvs } = ctx;
125699
126106
  const { auth: auth2, storage, r2Storage, cache } = providers;
125700
126107
  const infra2 = createInfraServices({
125701
126108
  db: db2,
@@ -125717,7 +126124,7 @@ function createServices(ctx) {
125717
126124
  });
125718
126125
  const platform2 = createPlatformServices({
125719
126126
  db: db2,
125720
- databaseLock,
126127
+ assessmentRuntimeLock,
125721
126128
  config: config5,
125722
126129
  cloudflare: cloudflare2,
125723
126130
  corsKvs,
@@ -125744,6 +126151,324 @@ var init_factory = __esm(async () => {
125744
126151
  init_standalone();
125745
126152
  await init_platform2();
125746
126153
  });
126154
+ function isAuthenticated(ctx) {
126155
+ return ctx.user != null;
126156
+ }
126157
+ var init_types5 = () => {};
126158
+ function hasGameManagementAccess(user) {
126159
+ return user.role === "admin" || user.role === "teacher" || user.role === "developer" && user.developerStatus === "approved";
126160
+ }
126161
+ function isDashboardWorkerKey(key) {
126162
+ return Boolean(key?.permissions?.[DASHBOARD_PERMISSION_NAMESPACE]);
126163
+ }
126164
+ function workerKeyGrantsGameRead(key, slug2) {
126165
+ if (!isDashboardWorkerKey(key) && !isGameWorkerKey(key)) {
126166
+ return false;
126167
+ }
126168
+ const games2 = key?.permissions?.games;
126169
+ return Boolean(games2?.includes(`read:${slug2}`) || games2?.includes(`write:${slug2}`));
126170
+ }
126171
+ function isGameWorkerKey(key) {
126172
+ return Boolean(key?.name?.startsWith(GAME_WORKER_KEY_PREFIX));
126173
+ }
126174
+ function deniedWorkerKeyRequest(key, method, pathname) {
126175
+ if (isDashboardWorkerKey(key) && !isAllowedDashboardWorkerRequest(method, pathname, key.permissions)) {
126176
+ return DASHBOARD_WORKER_KEY_RESTRICTED;
126177
+ }
126178
+ return null;
126179
+ }
126180
+ function rejectDashboardWorkerKey(ctx) {
126181
+ if (isDashboardWorkerKey(ctx.apiKey)) {
126182
+ throw ApiError.forbidden(DASHBOARD_WORKER_KEY_RESTRICTED);
126183
+ }
126184
+ }
126185
+ function assertAuthenticatedRequest(ctx) {
126186
+ if (!isAuthenticated(ctx)) {
126187
+ throw ApiError.unauthorized("Valid session or bearer token required");
126188
+ }
126189
+ if (ctx.apiKey) {
126190
+ const denied = deniedWorkerKeyRequest(ctx.apiKey, ctx.request.method, ctx.url.pathname);
126191
+ if (denied) {
126192
+ throw ApiError.forbidden(denied);
126193
+ }
126194
+ }
126195
+ }
126196
+ function requireAuth(handler) {
126197
+ return async (ctx) => {
126198
+ assertAuthenticatedRequest(ctx);
126199
+ return handler(ctx);
126200
+ };
126201
+ }
126202
+ function requireNonAnonymous(handler) {
126203
+ return async (ctx) => {
126204
+ assertAuthenticatedRequest(ctx);
126205
+ if (ctx.user.isAnonymous) {
126206
+ throw ApiError.forbidden("This operation is not available for demo/anonymous users");
126207
+ }
126208
+ return handler(ctx);
126209
+ };
126210
+ }
126211
+ function requireAnonymous(handler) {
126212
+ return async (ctx) => {
126213
+ assertAuthenticatedRequest(ctx);
126214
+ if (!ctx.user.isAnonymous) {
126215
+ throw ApiError.forbidden("This operation is only available for demo/anonymous users");
126216
+ }
126217
+ return handler(ctx);
126218
+ };
126219
+ }
126220
+ function requireDeveloper(handler) {
126221
+ return async (ctx) => {
126222
+ assertAuthenticatedRequest(ctx);
126223
+ rejectDashboardWorkerKey(ctx);
126224
+ const isAdmin = ctx.user.role === "admin";
126225
+ const isApprovedDev = ctx.user.role === "developer" && ctx.user.developerStatus === "approved";
126226
+ if (!isAdmin && !isApprovedDev) {
126227
+ throw ApiError.forbidden("Must be an approved developer");
126228
+ }
126229
+ return handler(ctx);
126230
+ };
126231
+ }
126232
+ function requireHumanDeveloper(handler) {
126233
+ return requireDeveloper(async (ctx) => {
126234
+ if (isDashboardWorkerKey(ctx.apiKey) || isGameWorkerKey(ctx.apiKey)) {
126235
+ throw ApiError.forbidden(WORKER_KEY_HUMAN_ONLY);
126236
+ }
126237
+ return handler(ctx);
126238
+ });
126239
+ }
126240
+ function requireGameManagementAccess(handler) {
126241
+ return async (ctx) => {
126242
+ assertAuthenticatedRequest(ctx);
126243
+ rejectDashboardWorkerKey(ctx);
126244
+ if (!hasGameManagementAccess(ctx.user)) {
126245
+ throw ApiError.forbidden("Game management access required");
126246
+ }
126247
+ return handler(ctx);
126248
+ };
126249
+ }
126250
+ var WORKER_KEY_HUMAN_ONLY = "This operation requires your own credential — platform worker keys are refused";
126251
+ var init_auth_util = __esm(() => {
126252
+ init_errors2();
126253
+ init_types5();
126254
+ init_dashboard_util();
126255
+ init_deployment_util();
126256
+ });
126257
+ function defineControllerNames(namespace, handlers) {
126258
+ for (const [key, handler] of Object.entries(handlers)) {
126259
+ Object.defineProperty(handler, "name", {
126260
+ value: `${namespace}.${key}`,
126261
+ configurable: true
126262
+ });
126263
+ }
126264
+ return handlers;
126265
+ }
126266
+ function generateUsername(email5) {
126267
+ const baseUsername = (email5.split("@")[0] || "user").toLowerCase();
126268
+ const cleanUsername = baseUsername.replace(/[^a-z0-9]/g, "");
126269
+ const randomSuffix = Math.random().toString(36).substring(2, 7);
126270
+ return `${cleanUsername}_${randomSuffix}`;
126271
+ }
126272
+ function extractRedirectPath(targetUri, currentHost) {
126273
+ try {
126274
+ const targetUrl = new URL(targetUri);
126275
+ if (targetUrl.hostname === currentHost) {
126276
+ return targetUrl.pathname + targetUrl.search;
126277
+ }
126278
+ } catch {}
126279
+ return "/";
126280
+ }
126281
+ function validateLtiClaims(claims) {
126282
+ const messageType = claims["https://purl.imsglobal.org/spec/lti/claim/message_type"];
126283
+ const version4 = claims["https://purl.imsglobal.org/spec/lti/claim/version"];
126284
+ if (messageType !== "LtiResourceLinkRequest") {
126285
+ return `Invalid LTI message type: ${messageType}`;
126286
+ }
126287
+ if (version4 !== "1.3.0") {
126288
+ return `Unsupported LTI version: ${version4}`;
126289
+ }
126290
+ return null;
126291
+ }
126292
+ var init_lti_util = () => {};
126293
+ async function provisionLtiUser(db2, claims) {
126294
+ const database = db2;
126295
+ const email5 = claims.email;
126296
+ const ltiTimebackId = claims.sub;
126297
+ const providerId = AUTH_PROVIDER_IDS.TIMEBACK_LTI;
126298
+ if (!email5) {
126299
+ throw new ValidationError("Email is required in LTI claims");
126300
+ }
126301
+ const existingAccount = await database.query.accounts.findFirst({
126302
+ where: and(eq(accounts.accountId, ltiTimebackId), eq(accounts.providerId, providerId))
126303
+ });
126304
+ if (existingAccount) {
126305
+ const user = await database.query.users.findFirst({
126306
+ where: eq(users.id, existingAccount.userId)
126307
+ });
126308
+ if (user) {
126309
+ setAttribute("app.lti.provision", "existing_account");
126310
+ return user;
126311
+ }
126312
+ }
126313
+ const existingUser = await database.query.users.findFirst({
126314
+ where: eq(users.email, email5)
126315
+ });
126316
+ if (existingUser) {
126317
+ await database.transaction(async (tx) => {
126318
+ const existingLtiAccount = await tx.query.accounts.findFirst({
126319
+ where: and(eq(accounts.userId, existingUser.id), eq(accounts.providerId, providerId))
126320
+ });
126321
+ if (!existingLtiAccount) {
126322
+ const [account] = await tx.insert(accounts).values({
126323
+ id: crypto4.randomUUID(),
126324
+ userId: existingUser.id,
126325
+ accountId: ltiTimebackId,
126326
+ providerId,
126327
+ accessToken: null,
126328
+ refreshToken: null,
126329
+ accessTokenExpiresAt: null,
126330
+ refreshTokenExpiresAt: null,
126331
+ createdAt: new Date,
126332
+ updatedAt: new Date
126333
+ }).returning({ id: accounts.id });
126334
+ if (!account) {
126335
+ throw new InternalError("Failed to link LTI account");
126336
+ }
126337
+ }
126338
+ });
126339
+ setAttribute("app.lti.provision", "linked_account");
126340
+ return existingUser;
126341
+ }
126342
+ const newUserId = crypto4.randomUUID();
126343
+ const createdUser = await database.transaction(async (tx) => {
126344
+ const [insertedUser] = await tx.insert(users).values({
126345
+ id: newUserId,
126346
+ email: email5,
126347
+ emailVerified: true,
126348
+ username: generateUsername(email5),
126349
+ name: claims.name || claims.given_name || email5.split("@")[0] || "Timeback User",
126350
+ createdAt: new Date,
126351
+ updatedAt: new Date
126352
+ }).returning();
126353
+ if (!insertedUser) {
126354
+ throw new InternalError("Failed to create user");
126355
+ }
126356
+ await tx.insert(accounts).values({
126357
+ id: crypto4.randomUUID(),
126358
+ userId: newUserId,
126359
+ accountId: ltiTimebackId,
126360
+ providerId,
126361
+ accessToken: null,
126362
+ refreshToken: null,
126363
+ accessTokenExpiresAt: null,
126364
+ refreshTokenExpiresAt: null,
126365
+ createdAt: new Date,
126366
+ updatedAt: new Date
126367
+ });
126368
+ setAttribute("app.lti.provision", "created_user");
126369
+ return insertedUser;
126370
+ });
126371
+ return createdUser;
126372
+ }
126373
+ var init_lti_provisioning = __esm(() => {
126374
+ init_drizzle_orm();
126375
+ init_src();
126376
+ init_tables_index();
126377
+ init_spans();
126378
+ init_errors2();
126379
+ init_lti_util();
126380
+ });
126381
+ function requireGameId(gameId) {
126382
+ if (!gameId) {
126383
+ throw ApiError.badRequest("Missing game ID");
126384
+ }
126385
+ if (!isValidUUID(gameId)) {
126386
+ throw ApiError.unprocessableEntity("gameId must be a valid UUID format");
126387
+ }
126388
+ return gameId;
126389
+ }
126390
+ function requireSlug(slug2) {
126391
+ if (!slug2) {
126392
+ throw ApiError.badRequest("Missing game slug");
126393
+ }
126394
+ return slug2;
126395
+ }
126396
+ function requireUserId(userId) {
126397
+ if (!userId) {
126398
+ throw ApiError.badRequest("Missing user ID");
126399
+ }
126400
+ if (!isValidUUID(userId)) {
126401
+ throw ApiError.unprocessableEntity("userId must be a valid UUID format");
126402
+ }
126403
+ return userId;
126404
+ }
126405
+ function parseLimitParam(url4, max) {
126406
+ const raw = url4.searchParams.get("limit");
126407
+ if (raw === null) {
126408
+ return;
126409
+ }
126410
+ const limit = Number(raw);
126411
+ if (!Number.isInteger(limit) || limit < 1 || limit > max) {
126412
+ throw ApiError.badRequest(`limit must be an integer between 1 and ${max}`);
126413
+ }
126414
+ return limit;
126415
+ }
126416
+ var init_params_util = __esm(() => {
126417
+ init_src9();
126418
+ init_errors2();
126419
+ });
126420
+ function formatZodError(error88) {
126421
+ const flat = error88.flatten();
126422
+ const result = {};
126423
+ if (Object.keys(flat.fieldErrors).length > 0) {
126424
+ result.fields = {};
126425
+ for (const [field, messages] of Object.entries(flat.fieldErrors)) {
126426
+ if (messages && messages.length > 0) {
126427
+ result.fields[field] = messages[0];
126428
+ }
126429
+ }
126430
+ }
126431
+ if (flat.formErrors.length > 0) {
126432
+ result.errors = flat.formErrors;
126433
+ }
126434
+ return result;
126435
+ }
126436
+ function recordValidationFailure(details) {
126437
+ setAttribute("app.request.outcome", "validation_failed");
126438
+ addEvent("request.validation_failed", {
126439
+ "app.validation.error": JSON.stringify(details)
126440
+ });
126441
+ }
126442
+ async function parseRequestBody(request, schema2) {
126443
+ try {
126444
+ return schema2.parse(await request.json());
126445
+ } catch (error88) {
126446
+ if (error88 instanceof exports_external.ZodError) {
126447
+ const details = formatZodError(error88);
126448
+ recordValidationFailure(details);
126449
+ throw ApiError.unprocessableEntity("Validation failed", details);
126450
+ }
126451
+ throw ApiError.invalidJsonBody();
126452
+ }
126453
+ }
126454
+ var init_validation_util = __esm(() => {
126455
+ init_esm();
126456
+ init_spans();
126457
+ init_errors2();
126458
+ });
126459
+ var init_utils7 = __esm(() => {
126460
+ init_auth_util();
126461
+ init_assessment_runtime_lock_util();
126462
+ init_dashboard_util();
126463
+ init_deployment_util();
126464
+ init_leaderboard_util();
126465
+ init_lti_util();
126466
+ init_lti_provisioning();
126467
+ init_params_util();
126468
+ init_secrets_util();
126469
+ init_timeback_util();
126470
+ init_validation_util();
126471
+ });
125747
126472
  function buildTimebackClient() {
125748
126473
  if (!hasTimebackCredentials()) {
125749
126474
  return;
@@ -126116,7 +126841,7 @@ function createSandboxContext(options) {
126116
126841
  const services = {};
126117
126842
  const ctx = {
126118
126843
  db: options.db,
126119
- databaseLock: createInMemoryDatabaseLock(),
126844
+ assessmentRuntimeLock: createInMemoryAssessmentRuntimeLock(),
126120
126845
  config: config5,
126121
126846
  providers,
126122
126847
  services,
@@ -126145,7 +126870,7 @@ var cachedServiceContext = null;
126145
126870
  var cachedDb = null;
126146
126871
  var init_context = __esm(async () => {
126147
126872
  init_config2();
126148
- init_locks();
126873
+ init_utils7();
126149
126874
  init_src6();
126150
126875
  init_providers();
126151
126876
  await __promiseAll([
@@ -143454,7 +144179,7 @@ var nullableType2;
143454
144179
  var preprocessType2;
143455
144180
  var pipelineType2;
143456
144181
  var coerce2;
143457
- var init_types5;
144182
+ var init_types6;
143458
144183
  var init_external4;
143459
144184
  var init_v32;
143460
144185
  var init_esm2;
@@ -143640,7 +144365,7 @@ var prepareMigrationMeta;
143640
144365
  var schemaRenameKey;
143641
144366
  var tableRenameKey;
143642
144367
  var columnRenameKey;
143643
- var init_utils7;
144368
+ var init_utils8;
143644
144369
  var import_hanji;
143645
144370
  var warning;
143646
144371
  var error88;
@@ -145179,7 +145904,7 @@ var PrimaryKey3;
145179
145904
  var init_primary_keys3;
145180
145905
  var MySqlViewConfig;
145181
145906
  var init_view_common32;
145182
- var init_utils8;
145907
+ var init_utils82;
145183
145908
  var _a3222;
145184
145909
  var _b239;
145185
145910
  var MySqlDeleteBase;
@@ -145927,7 +146652,7 @@ var sqlitePushIntrospect = async (db2, filters) => {
145927
146652
  };
145928
146653
  var generateDrizzleJson = (imports, prevId, schemaFilters, casing2) => {
145929
146654
  const prepared = prepareFromExports(imports);
145930
- const id = randomUUID();
146655
+ const id = randomUUID2();
145931
146656
  const snapshot = generatePgSnapshot(prepared.tables, prepared.enums, prepared.schemas, prepared.sequences, prepared.roles, prepared.policies, prepared.views, prepared.matViews, casing2, schemaFilters);
145932
146657
  return fillPgSnapshot({
145933
146658
  serialized: snapshot,
@@ -145976,7 +146701,7 @@ var pushSchema = async (imports, drizzleInstance, schemaFilters, tablesFilter, e
145976
146701
  var generateSQLiteDrizzleJson = async (imports, prevId, casing2) => {
145977
146702
  const { prepareFromExports: prepareFromExports5 } = await Promise.resolve().then(() => (init_sqliteImports(), sqliteImports_exports));
145978
146703
  const prepared = prepareFromExports5(imports);
145979
- const id = randomUUID();
146704
+ const id = randomUUID2();
145980
146705
  const snapshot = generateSqliteSnapshot(prepared.tables, prepared.views, casing2);
145981
146706
  return {
145982
146707
  ...snapshot,
@@ -146027,7 +146752,7 @@ var pushSQLiteSchema = async (imports, drizzleInstance) => {
146027
146752
  var generateMySQLDrizzleJson = async (imports, prevId, casing2) => {
146028
146753
  const { prepareFromExports: prepareFromExports5 } = await Promise.resolve().then(() => (init_mysqlImports(), mysqlImports_exports));
146029
146754
  const prepared = prepareFromExports5(imports);
146030
- const id = randomUUID();
146755
+ const id = randomUUID2();
146031
146756
  const snapshot = generateMySqlSnapshot(prepared.tables, prepared.views, casing2);
146032
146757
  return {
146033
146758
  ...snapshot,
@@ -146077,7 +146802,7 @@ var pushMySQLSchema = async (imports, drizzleInstance, databaseName) => {
146077
146802
  var generateSingleStoreDrizzleJson = async (imports, prevId, casing2) => {
146078
146803
  const { prepareFromExports: prepareFromExports5 } = await Promise.resolve().then(() => (init_singlestoreImports(), singlestoreImports_exports));
146079
146804
  const prepared = prepareFromExports5(imports);
146080
- const id = randomUUID();
146805
+ const id = randomUUID2();
146081
146806
  const snapshot = generateSingleStoreSnapshot(prepared.tables, casing2);
146082
146807
  return {
146083
146808
  ...snapshot,
@@ -149692,7 +150417,7 @@ See: https://github.com/isaacs/node-glob/issues/167`);
149692
150417
  })(errorUtil2 || (errorUtil2 = {}));
149693
150418
  }
149694
150419
  });
149695
- init_types5 = __esm2({
150420
+ init_types6 = __esm2({
149696
150421
  "../node_modules/.pnpm/zod@3.25.42/node_modules/zod/dist/esm/v3/types.js"() {
149697
150422
  init_ZodError2();
149698
150423
  init_errors9();
@@ -152857,7 +153582,7 @@ See: https://github.com/isaacs/node-glob/issues/167`);
152857
153582
  init_parseUtil2();
152858
153583
  init_typeAliases2();
152859
153584
  init_util5();
152860
- init_types5();
153585
+ init_types6();
152861
153586
  init_ZodError2();
152862
153587
  }
152863
153588
  });
@@ -154590,7 +155315,7 @@ See: https://github.com/isaacs/node-glob/issues/167`);
154590
155315
  backwardCompatibleSqliteSchema = unionType2([sqliteSchemaV5, schema3]);
154591
155316
  }
154592
155317
  });
154593
- init_utils7 = __esm2({
155318
+ init_utils8 = __esm2({
154594
155319
  "src/utils.ts"() {
154595
155320
  init_views();
154596
155321
  init_global2();
@@ -154642,7 +155367,7 @@ See: https://github.com/isaacs/node-glob/issues/167`);
154642
155367
  "src/cli/views.ts"() {
154643
155368
  init_source();
154644
155369
  import_hanji = __toESM5(require_hanji());
154645
- init_utils7();
155370
+ init_utils8();
154646
155371
  warning = (msg) => {
154647
155372
  (0, import_hanji.render)(`[${source_default.yellow("Warning")}] ${msg}`);
154648
155373
  };
@@ -157350,7 +158075,7 @@ Is ${source_default.bold.blue(this.base.name)} schema created or renamed from an
157350
158075
  init_pgSchema();
157351
158076
  init_singlestoreSchema();
157352
158077
  init_sqliteSchema();
157353
- init_utils7();
158078
+ init_utils8();
157354
158079
  parseType = (schemaPrefix, type) => {
157355
158080
  const pgNativeTypes = [
157356
158081
  "uuid",
@@ -159846,7 +160571,7 @@ ${BREAKPOINT}ALTER TABLE ${tableNameWithSchema} ADD CONSTRAINT "${statement.newC
159846
160571
  init_source();
159847
160572
  init_sqliteSchema();
159848
160573
  init_sqlgenerator();
159849
- init_utils7();
160574
+ init_utils8();
159850
160575
  _moveDataStatements = (tableName, json42, dataLoss = false) => {
159851
160576
  const statements = [];
159852
160577
  const newTableName = `__new_${tableName}`;
@@ -162004,7 +162729,7 @@ ${BREAKPOINT}ALTER TABLE ${tableNameWithSchema} ADD CONSTRAINT "${statement.newC
162004
162729
  init_singlestoreSchema();
162005
162730
  init_sqliteSchema();
162006
162731
  init_statementCombiner();
162007
- init_utils7();
162732
+ init_utils8();
162008
162733
  makeChanged = (schema5) => {
162009
162734
  return objectType2({
162010
162735
  type: enumType2(["changed"]),
@@ -164229,7 +164954,7 @@ ${BREAKPOINT}ALTER TABLE ${tableNameWithSchema} ADD CONSTRAINT "${statement.newC
164229
164954
  init_pgSchema();
164230
164955
  init_sqliteSchema();
164231
164956
  init_snapshotsDiffer();
164232
- init_utils7();
164957
+ init_utils8();
164233
164958
  init_words();
164234
164959
  init_outputs();
164235
164960
  init_views();
@@ -170789,7 +171514,7 @@ params: ${params}`);
170789
171514
  init_pg_core2();
170790
171515
  init_vector32();
170791
171516
  init_outputs();
170792
- init_utils7();
171517
+ init_utils8();
170793
171518
  init_utils62();
170794
171519
  indexName = (tableName, columns2) => {
170795
171520
  return `${tableName}_${columns2.join("_")}_index`;
@@ -174798,7 +175523,7 @@ ORDER BY
174798
175523
  init_dist9();
174799
175524
  init_sqlite_core();
174800
175525
  init_outputs();
174801
- init_utils7();
175526
+ init_utils8();
174802
175527
  init_utils62();
174803
175528
  generateSqliteSnapshot = (tables, views, casing2) => {
174804
175529
  const dialect6 = new SQLiteSyncDialect({ casing: casing2 });
@@ -176751,7 +177476,7 @@ ${withStyle.errorWarning(`We've found duplicated view name across ${source_defau
176751
177476
  MySqlViewConfig = Symbol.for("drizzle:MySqlViewConfig");
176752
177477
  }
176753
177478
  });
176754
- init_utils8 = __esm2({
177479
+ init_utils82 = __esm2({
176755
177480
  "../drizzle-orm/dist/mysql-core/utils.js"() {
176756
177481
  init_entity2();
176757
177482
  init_dist9();
@@ -176773,7 +177498,7 @@ ${withStyle.errorWarning(`We've found duplicated view name across ${source_defau
176773
177498
  init_query_promise2();
176774
177499
  init_selection_proxy2();
176775
177500
  init_table8();
176776
- init_utils8();
177501
+ init_utils82();
176777
177502
  MySqlDeleteBase = class extends (_b239 = QueryPromise2, _a3222 = entityKind2, _b239) {
176778
177503
  constructor(table62, session3, dialect6, withList) {
176779
177504
  super();
@@ -177621,7 +178346,7 @@ ${withStyle.errorWarning(`We've found duplicated view name across ${source_defau
177621
178346
  init_table8();
177622
178347
  init_utils22();
177623
178348
  init_view_common3();
177624
- init_utils8();
178349
+ init_utils82();
177625
178350
  init_view_base3();
177626
178351
  _a325 = entityKind2;
177627
178352
  MySqlSelectBuilder = class {
@@ -178020,7 +178745,7 @@ ${withStyle.errorWarning(`We've found duplicated view name across ${source_defau
178020
178745
  init_sql5();
178021
178746
  init_table8();
178022
178747
  init_utils22();
178023
- init_utils8();
178748
+ init_utils82();
178024
178749
  init_query_builder4();
178025
178750
  _a329 = entityKind2;
178026
178751
  MySqlInsertBuilder = class {
@@ -178124,7 +178849,7 @@ ${withStyle.errorWarning(`We've found duplicated view name across ${source_defau
178124
178849
  init_selection_proxy2();
178125
178850
  init_table8();
178126
178851
  init_utils22();
178127
- init_utils8();
178852
+ init_utils82();
178128
178853
  _a331 = entityKind2;
178129
178854
  MySqlUpdateBuilder = class {
178130
178855
  constructor(table62, session3, dialect6, withList) {
@@ -178660,7 +179385,7 @@ ${withStyle.errorWarning(`We've found duplicated view name across ${source_defau
178660
179385
  init_subquery4();
178661
179386
  init_table42();
178662
179387
  init_unique_constraint3();
178663
- init_utils8();
179388
+ init_utils82();
178664
179389
  init_view_common32();
178665
179390
  init_view3();
178666
179391
  }
@@ -178671,7 +179396,7 @@ ${withStyle.errorWarning(`We've found duplicated view name across ${source_defau
178671
179396
  init_dist9();
178672
179397
  init_mysql_core();
178673
179398
  init_outputs();
178674
- init_utils7();
179399
+ init_utils8();
178675
179400
  init_utils62();
178676
179401
  handleEnumType = (type) => {
178677
179402
  let str = type.split("(")[1];
@@ -183105,7 +183830,7 @@ The unique index ${source_default.underline.blue(name22)} on the ${source_defaul
183105
183830
  import_hanji10 = __toESM5(require_hanji());
183106
183831
  init_sqlgenerator();
183107
183832
  init_singlestoreSchema();
183108
- init_utils7();
183833
+ init_utils8();
183109
183834
  init_selector_ui();
183110
183835
  init_outputs();
183111
183836
  filterStatements2 = (statements, currentSchema, prevSchema) => {
@@ -183368,7 +184093,7 @@ The unique index ${source_default.underline.blue(name22)} on the ${source_defaul
183368
184093
  init_sqlgenerator();
183369
184094
  init_selector_ui();
183370
184095
  init_pgSchema();
183371
- init_utils7();
184096
+ init_utils8();
183372
184097
  import_hanji6 = __toESM5(require_hanji());
183373
184098
  init_mjs();
183374
184099
  init_global2();
@@ -184231,323 +184956,6 @@ var init_manifest = __esm(async () => {
184231
184956
  });
184232
184957
  });
184233
184958
  });
184234
- function isAuthenticated(ctx) {
184235
- return ctx.user != null;
184236
- }
184237
- var init_types6 = () => {};
184238
- function hasGameManagementAccess(user) {
184239
- return user.role === "admin" || user.role === "teacher" || user.role === "developer" && user.developerStatus === "approved";
184240
- }
184241
- function isDashboardWorkerKey(key) {
184242
- return Boolean(key?.permissions?.[DASHBOARD_PERMISSION_NAMESPACE]);
184243
- }
184244
- function workerKeyGrantsGameRead(key, slug2) {
184245
- if (!isDashboardWorkerKey(key) && !isGameWorkerKey(key)) {
184246
- return false;
184247
- }
184248
- const games2 = key?.permissions?.games;
184249
- return Boolean(games2?.includes(`read:${slug2}`) || games2?.includes(`write:${slug2}`));
184250
- }
184251
- function isGameWorkerKey(key) {
184252
- return Boolean(key?.name?.startsWith(GAME_WORKER_KEY_PREFIX));
184253
- }
184254
- function deniedWorkerKeyRequest(key, method, pathname) {
184255
- if (isDashboardWorkerKey(key) && !isAllowedDashboardWorkerRequest(method, pathname, key.permissions)) {
184256
- return DASHBOARD_WORKER_KEY_RESTRICTED;
184257
- }
184258
- return null;
184259
- }
184260
- function rejectDashboardWorkerKey(ctx) {
184261
- if (isDashboardWorkerKey(ctx.apiKey)) {
184262
- throw ApiError.forbidden(DASHBOARD_WORKER_KEY_RESTRICTED);
184263
- }
184264
- }
184265
- function assertAuthenticatedRequest(ctx) {
184266
- if (!isAuthenticated(ctx)) {
184267
- throw ApiError.unauthorized("Valid session or bearer token required");
184268
- }
184269
- if (ctx.apiKey) {
184270
- const denied = deniedWorkerKeyRequest(ctx.apiKey, ctx.request.method, ctx.url.pathname);
184271
- if (denied) {
184272
- throw ApiError.forbidden(denied);
184273
- }
184274
- }
184275
- }
184276
- function requireAuth(handler) {
184277
- return async (ctx) => {
184278
- assertAuthenticatedRequest(ctx);
184279
- return handler(ctx);
184280
- };
184281
- }
184282
- function requireNonAnonymous(handler) {
184283
- return async (ctx) => {
184284
- assertAuthenticatedRequest(ctx);
184285
- if (ctx.user.isAnonymous) {
184286
- throw ApiError.forbidden("This operation is not available for demo/anonymous users");
184287
- }
184288
- return handler(ctx);
184289
- };
184290
- }
184291
- function requireAnonymous(handler) {
184292
- return async (ctx) => {
184293
- assertAuthenticatedRequest(ctx);
184294
- if (!ctx.user.isAnonymous) {
184295
- throw ApiError.forbidden("This operation is only available for demo/anonymous users");
184296
- }
184297
- return handler(ctx);
184298
- };
184299
- }
184300
- function requireDeveloper(handler) {
184301
- return async (ctx) => {
184302
- assertAuthenticatedRequest(ctx);
184303
- rejectDashboardWorkerKey(ctx);
184304
- const isAdmin = ctx.user.role === "admin";
184305
- const isApprovedDev = ctx.user.role === "developer" && ctx.user.developerStatus === "approved";
184306
- if (!isAdmin && !isApprovedDev) {
184307
- throw ApiError.forbidden("Must be an approved developer");
184308
- }
184309
- return handler(ctx);
184310
- };
184311
- }
184312
- function requireHumanDeveloper(handler) {
184313
- return requireDeveloper(async (ctx) => {
184314
- if (isDashboardWorkerKey(ctx.apiKey) || isGameWorkerKey(ctx.apiKey)) {
184315
- throw ApiError.forbidden(WORKER_KEY_HUMAN_ONLY);
184316
- }
184317
- return handler(ctx);
184318
- });
184319
- }
184320
- function requireGameManagementAccess(handler) {
184321
- return async (ctx) => {
184322
- assertAuthenticatedRequest(ctx);
184323
- rejectDashboardWorkerKey(ctx);
184324
- if (!hasGameManagementAccess(ctx.user)) {
184325
- throw ApiError.forbidden("Game management access required");
184326
- }
184327
- return handler(ctx);
184328
- };
184329
- }
184330
- var WORKER_KEY_HUMAN_ONLY = "This operation requires your own credential — platform worker keys are refused";
184331
- var init_auth_util = __esm(() => {
184332
- init_errors2();
184333
- init_types6();
184334
- init_dashboard_util();
184335
- init_deployment_util();
184336
- });
184337
- function defineControllerNames(namespace, handlers) {
184338
- for (const [key, handler] of Object.entries(handlers)) {
184339
- Object.defineProperty(handler, "name", {
184340
- value: `${namespace}.${key}`,
184341
- configurable: true
184342
- });
184343
- }
184344
- return handlers;
184345
- }
184346
- function generateUsername(email5) {
184347
- const baseUsername = (email5.split("@")[0] || "user").toLowerCase();
184348
- const cleanUsername = baseUsername.replace(/[^a-z0-9]/g, "");
184349
- const randomSuffix = Math.random().toString(36).substring(2, 7);
184350
- return `${cleanUsername}_${randomSuffix}`;
184351
- }
184352
- function extractRedirectPath(targetUri, currentHost) {
184353
- try {
184354
- const targetUrl = new URL(targetUri);
184355
- if (targetUrl.hostname === currentHost) {
184356
- return targetUrl.pathname + targetUrl.search;
184357
- }
184358
- } catch {}
184359
- return "/";
184360
- }
184361
- function validateLtiClaims(claims) {
184362
- const messageType = claims["https://purl.imsglobal.org/spec/lti/claim/message_type"];
184363
- const version6 = claims["https://purl.imsglobal.org/spec/lti/claim/version"];
184364
- if (messageType !== "LtiResourceLinkRequest") {
184365
- return `Invalid LTI message type: ${messageType}`;
184366
- }
184367
- if (version6 !== "1.3.0") {
184368
- return `Unsupported LTI version: ${version6}`;
184369
- }
184370
- return null;
184371
- }
184372
- var init_lti_util = () => {};
184373
- async function provisionLtiUser(db2, claims) {
184374
- const database = db2;
184375
- const email5 = claims.email;
184376
- const ltiTimebackId = claims.sub;
184377
- const providerId = AUTH_PROVIDER_IDS.TIMEBACK_LTI;
184378
- if (!email5) {
184379
- throw new ValidationError("Email is required in LTI claims");
184380
- }
184381
- const existingAccount = await database.query.accounts.findFirst({
184382
- where: and(eq(accounts.accountId, ltiTimebackId), eq(accounts.providerId, providerId))
184383
- });
184384
- if (existingAccount) {
184385
- const user = await database.query.users.findFirst({
184386
- where: eq(users.id, existingAccount.userId)
184387
- });
184388
- if (user) {
184389
- setAttribute("app.lti.provision", "existing_account");
184390
- return user;
184391
- }
184392
- }
184393
- const existingUser = await database.query.users.findFirst({
184394
- where: eq(users.email, email5)
184395
- });
184396
- if (existingUser) {
184397
- await database.transaction(async (tx) => {
184398
- const existingLtiAccount = await tx.query.accounts.findFirst({
184399
- where: and(eq(accounts.userId, existingUser.id), eq(accounts.providerId, providerId))
184400
- });
184401
- if (!existingLtiAccount) {
184402
- const [account] = await tx.insert(accounts).values({
184403
- id: crypto4.randomUUID(),
184404
- userId: existingUser.id,
184405
- accountId: ltiTimebackId,
184406
- providerId,
184407
- accessToken: null,
184408
- refreshToken: null,
184409
- accessTokenExpiresAt: null,
184410
- refreshTokenExpiresAt: null,
184411
- createdAt: new Date,
184412
- updatedAt: new Date
184413
- }).returning({ id: accounts.id });
184414
- if (!account) {
184415
- throw new InternalError("Failed to link LTI account");
184416
- }
184417
- }
184418
- });
184419
- setAttribute("app.lti.provision", "linked_account");
184420
- return existingUser;
184421
- }
184422
- const newUserId = crypto4.randomUUID();
184423
- const createdUser = await database.transaction(async (tx) => {
184424
- const [insertedUser] = await tx.insert(users).values({
184425
- id: newUserId,
184426
- email: email5,
184427
- emailVerified: true,
184428
- username: generateUsername(email5),
184429
- name: claims.name || claims.given_name || email5.split("@")[0] || "Timeback User",
184430
- createdAt: new Date,
184431
- updatedAt: new Date
184432
- }).returning();
184433
- if (!insertedUser) {
184434
- throw new InternalError("Failed to create user");
184435
- }
184436
- await tx.insert(accounts).values({
184437
- id: crypto4.randomUUID(),
184438
- userId: newUserId,
184439
- accountId: ltiTimebackId,
184440
- providerId,
184441
- accessToken: null,
184442
- refreshToken: null,
184443
- accessTokenExpiresAt: null,
184444
- refreshTokenExpiresAt: null,
184445
- createdAt: new Date,
184446
- updatedAt: new Date
184447
- });
184448
- setAttribute("app.lti.provision", "created_user");
184449
- return insertedUser;
184450
- });
184451
- return createdUser;
184452
- }
184453
- var init_lti_provisioning = __esm(() => {
184454
- init_drizzle_orm();
184455
- init_src();
184456
- init_tables_index();
184457
- init_spans();
184458
- init_errors2();
184459
- init_lti_util();
184460
- });
184461
- function requireGameId(gameId) {
184462
- if (!gameId) {
184463
- throw ApiError.badRequest("Missing game ID");
184464
- }
184465
- if (!isValidUUID(gameId)) {
184466
- throw ApiError.unprocessableEntity("gameId must be a valid UUID format");
184467
- }
184468
- return gameId;
184469
- }
184470
- function requireSlug(slug2) {
184471
- if (!slug2) {
184472
- throw ApiError.badRequest("Missing game slug");
184473
- }
184474
- return slug2;
184475
- }
184476
- function requireUserId(userId) {
184477
- if (!userId) {
184478
- throw ApiError.badRequest("Missing user ID");
184479
- }
184480
- if (!isValidUUID(userId)) {
184481
- throw ApiError.unprocessableEntity("userId must be a valid UUID format");
184482
- }
184483
- return userId;
184484
- }
184485
- function parseLimitParam(url4, max2) {
184486
- const raw2 = url4.searchParams.get("limit");
184487
- if (raw2 === null) {
184488
- return;
184489
- }
184490
- const limit = Number(raw2);
184491
- if (!Number.isInteger(limit) || limit < 1 || limit > max2) {
184492
- throw ApiError.badRequest(`limit must be an integer between 1 and ${max2}`);
184493
- }
184494
- return limit;
184495
- }
184496
- var init_params_util = __esm(() => {
184497
- init_src9();
184498
- init_errors2();
184499
- });
184500
- function formatZodError(error89) {
184501
- const flat = error89.flatten();
184502
- const result = {};
184503
- if (Object.keys(flat.fieldErrors).length > 0) {
184504
- result.fields = {};
184505
- for (const [field, messages] of Object.entries(flat.fieldErrors)) {
184506
- if (messages && messages.length > 0) {
184507
- result.fields[field] = messages[0];
184508
- }
184509
- }
184510
- }
184511
- if (flat.formErrors.length > 0) {
184512
- result.errors = flat.formErrors;
184513
- }
184514
- return result;
184515
- }
184516
- function recordValidationFailure(details) {
184517
- setAttribute("app.request.outcome", "validation_failed");
184518
- addEvent("request.validation_failed", {
184519
- "app.validation.error": JSON.stringify(details)
184520
- });
184521
- }
184522
- async function parseRequestBody(request, schema4) {
184523
- try {
184524
- return schema4.parse(await request.json());
184525
- } catch (error89) {
184526
- if (error89 instanceof exports_external.ZodError) {
184527
- const details = formatZodError(error89);
184528
- recordValidationFailure(details);
184529
- throw ApiError.unprocessableEntity("Validation failed", details);
184530
- }
184531
- throw ApiError.invalidJsonBody();
184532
- }
184533
- }
184534
- var init_validation_util = __esm(() => {
184535
- init_esm();
184536
- init_spans();
184537
- init_errors2();
184538
- });
184539
- var init_utils11 = __esm(() => {
184540
- init_auth_util();
184541
- init_dashboard_util();
184542
- init_deployment_util();
184543
- init_leaderboard_util();
184544
- init_lti_util();
184545
- init_lti_provisioning();
184546
- init_params_util();
184547
- init_secrets_util();
184548
- init_timeback_util();
184549
- init_validation_util();
184550
- });
184551
184959
  var listFiles;
184552
184960
  var getFile;
184553
184961
  var putFile;
@@ -184556,7 +184964,7 @@ var initiateUpload;
184556
184964
  var bucket;
184557
184965
  var init_bucket_controller = __esm(() => {
184558
184966
  init_errors2();
184559
- init_utils11();
184967
+ init_utils7();
184560
184968
  listFiles = requireDeveloper(async (ctx) => {
184561
184969
  const slug2 = ctx.params.slug;
184562
184970
  if (!slug2) {
@@ -184655,7 +185063,7 @@ var dashboard2;
184655
185063
  var init_dashboard_controller = __esm(() => {
184656
185064
  init_schemas_index();
184657
185065
  init_errors2();
184658
- init_utils11();
185066
+ init_utils7();
184659
185067
  listUsers = requireHumanDeveloper(async (ctx) => {
184660
185068
  const slug2 = requireSlug(ctx.params.slug);
184661
185069
  const users2 = await ctx.services.dashboard.listUsers(slug2, ctx.user);
@@ -184726,7 +185134,7 @@ var init_database_controller = __esm(() => {
184726
185134
  init_esm();
184727
185135
  init_schemas_index();
184728
185136
  init_errors2();
184729
- init_utils11();
185137
+ init_utils7();
184730
185138
  reset = requireDeveloper(async (ctx) => {
184731
185139
  const slug2 = ctx.params.slug;
184732
185140
  if (!slug2) {
@@ -184787,7 +185195,7 @@ var init_deploy_controller = __esm(() => {
184787
185195
  init_esm();
184788
185196
  init_schemas_index();
184789
185197
  init_errors2();
184790
- init_utils11();
185198
+ init_utils7();
184791
185199
  deploy = defineControllerNames("deploy", {
184792
185200
  createJob: requireDeveloper(createJob),
184793
185201
  getJob: requireDeveloper(getJob)
@@ -184805,7 +185213,7 @@ var deploymentState;
184805
185213
  var init_deployment_state_controller = __esm(() => {
184806
185214
  init_schemas_index();
184807
185215
  init_errors2();
184808
- init_utils11();
185216
+ init_utils7();
184809
185217
  get = requireDeveloper(async (ctx) => {
184810
185218
  const slug2 = requireSlug(ctx.params.slug);
184811
185219
  const include = ctx.url.searchParams.getAll("include").flatMap((value) => value.split(",")).filter(Boolean);
@@ -184875,7 +185283,7 @@ var apply;
184875
185283
  var getStatus;
184876
185284
  var developer;
184877
185285
  var init_developer_controller = __esm(() => {
184878
- init_utils11();
185286
+ init_utils7();
184879
185287
  apply = requireNonAnonymous(async (ctx) => {
184880
185288
  await ctx.services.developer.apply(ctx.user);
184881
185289
  });
@@ -184898,7 +185306,7 @@ var init_domain_controller = __esm(() => {
184898
185306
  init_schemas_index();
184899
185307
  init_config2();
184900
185308
  init_errors2();
184901
- init_utils11();
185309
+ init_utils7();
184902
185310
  add = requireDeveloper(async (ctx) => {
184903
185311
  const slug2 = ctx.params.slug;
184904
185312
  if (!slug2) {
@@ -184975,7 +185383,7 @@ var gameMembers2;
184975
185383
  var init_game_member_controller = __esm(() => {
184976
185384
  init_schemas_index();
184977
185385
  init_errors2();
184978
- init_utils11();
185386
+ init_utils7();
184979
185387
  listMembers = requireNonAnonymous(async (ctx) => {
184980
185388
  const gameId = requireGameId(ctx.params.gameId);
184981
185389
  return ctx.services.gameMember.list(gameId, ctx.user);
@@ -185024,7 +185432,7 @@ var init_game_controller = __esm(() => {
185024
185432
  init_esm();
185025
185433
  init_schemas_index();
185026
185434
  init_errors2();
185027
- init_utils11();
185435
+ init_utils7();
185028
185436
  init_auth_util();
185029
185437
  list2 = requireNonAnonymous(async (ctx) => ctx.services.game.list(ctx.user));
185030
185438
  listAccessible = requireNonAnonymous(async (ctx) => ctx.services.game.listAccessible(ctx.user));
@@ -185101,7 +185509,7 @@ var init_kv_controller = __esm(() => {
185101
185509
  init_esm();
185102
185510
  init_schemas_index();
185103
185511
  init_errors2();
185104
- init_utils11();
185512
+ init_utils7();
185105
185513
  listKeys = requireDeveloper(async (ctx) => {
185106
185514
  const slug2 = ctx.params.slug;
185107
185515
  if (!slug2) {
@@ -185209,7 +185617,7 @@ var init_leaderboard_controller = __esm(() => {
185209
185617
  init_esm();
185210
185618
  init_schemas_index();
185211
185619
  init_errors2();
185212
- init_utils11();
185620
+ init_utils7();
185213
185621
  submitScore = requireAuth(async (ctx) => {
185214
185622
  const gameId = ctx.params.gameId;
185215
185623
  if (!gameId) {
@@ -185317,7 +185725,7 @@ var generateToken;
185317
185725
  var logs;
185318
185726
  var init_logs_controller = __esm(() => {
185319
185727
  init_errors2();
185320
- init_utils11();
185728
+ init_utils7();
185321
185729
  generateToken = requireDeveloper(async (ctx) => {
185322
185730
  const slug2 = ctx.params.slug;
185323
185731
  if (!slug2) {
@@ -185351,7 +185759,7 @@ var init_logs_controller = __esm(() => {
185351
185759
  var getStatus3;
185352
185760
  var lti;
185353
185761
  var init_lti_controller = __esm(() => {
185354
- init_utils11();
185762
+ init_utils7();
185355
185763
  getStatus3 = requireNonAnonymous(async (ctx) => ctx.services.lti.getStatus(ctx.user));
185356
185764
  lti = defineControllerNames("lti", {
185357
185765
  getStatus: getStatus3
@@ -185366,7 +185774,7 @@ var init_secrets_controller = __esm(() => {
185366
185774
  init_esm();
185367
185775
  init_schemas_index();
185368
185776
  init_errors2();
185369
- init_utils11();
185777
+ init_utils7();
185370
185778
  listKeys2 = requireDeveloper(async (ctx) => {
185371
185779
  const slug2 = ctx.params.slug;
185372
185780
  if (!slug2) {
@@ -185428,7 +185836,7 @@ var init_seed_controller = __esm(() => {
185428
185836
  init_esm();
185429
185837
  init_schemas_index();
185430
185838
  init_errors2();
185431
- init_utils11();
185839
+ init_utils7();
185432
185840
  seed2 = requireDeveloper(async (ctx) => {
185433
185841
  const slug2 = ctx.params.slug;
185434
185842
  if (!slug2) {
@@ -185460,7 +185868,7 @@ var init_session_controller = __esm(() => {
185460
185868
  init_spans();
185461
185869
  init_tunnel();
185462
185870
  init_errors2();
185463
- init_utils11();
185871
+ init_utils7();
185464
185872
  MintTokenBody = exports_external.object({
185465
185873
  reason: exports_external.enum(["initial", "refresh_scheduled", "refresh_stale"]).optional()
185466
185874
  });
@@ -185557,6 +185965,7 @@ var listAssessments;
185557
185965
  var createAssessment;
185558
185966
  var attachExistingAssessments;
185559
185967
  var updateAssessment;
185968
+ var updateReviewMapping;
185560
185969
  var reorderAssessments;
185561
185970
  var reorderQuestions;
185562
185971
  var removeAssessment;
@@ -185576,7 +185985,7 @@ var init_timeback_controller = __esm(() => {
185576
185985
  init_src9();
185577
185986
  init_timeback3();
185578
185987
  init_errors2();
185579
- init_utils11();
185988
+ init_utils7();
185580
185989
  populateStudent = requireNonAnonymous(async (ctx) => {
185581
185990
  let providedNames;
185582
185991
  try {
@@ -186241,6 +186650,14 @@ var init_timeback_controller = __esm(() => {
186241
186650
  const body2 = await parseRequestBody(ctx.request, UpdateAssessmentRequestSchema);
186242
186651
  return ctx.services.timebackAssessments.updateAssessment(integrationId, testIdentifier, body2);
186243
186652
  });
186653
+ updateReviewMapping = requireDeveloper(async (ctx) => {
186654
+ const { gameId, courseId, testIdentifier } = ctx.params;
186655
+ if (!gameId || !courseId || !testIdentifier) {
186656
+ throw ApiError.badRequest("Missing gameId, courseId, or testIdentifier parameter");
186657
+ }
186658
+ const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
186659
+ return ctx.services.timebackAssessments.updateReviewMapping(integrationId, testIdentifier);
186660
+ });
186244
186661
  reorderAssessments = requireDeveloper(async (ctx) => {
186245
186662
  const { gameId, courseId } = ctx.params;
186246
186663
  if (!gameId || !courseId) {
@@ -186383,6 +186800,7 @@ var init_timeback_controller = __esm(() => {
186383
186800
  createAssessment,
186384
186801
  attachExistingAssessments,
186385
186802
  updateAssessment,
186803
+ updateReviewMapping,
186386
186804
  reorderAssessments,
186387
186805
  removeAssessment,
186388
186806
  reorderQuestions,
@@ -186401,7 +186819,7 @@ var init_upload_controller = __esm(() => {
186401
186819
  init_esm();
186402
186820
  init_schemas_index();
186403
186821
  init_errors2();
186404
- init_utils11();
186822
+ init_utils7();
186405
186823
  initiate = requireDeveloper(async (ctx) => {
186406
186824
  let body2;
186407
186825
  try {
@@ -186428,7 +186846,7 @@ var users2;
186428
186846
  var init_user_controller = __esm(() => {
186429
186847
  init_src();
186430
186848
  init_schemas_index();
186431
- init_utils11();
186849
+ init_utils7();
186432
186850
  getMe = requireNonAnonymous(async (ctx) => ctx.services.user.getMe(ctx.user, ctx.gameId, ctx.request.headers.get(PLAYCADEMY_BROWSER_TIME_ZONE_HEADER)));
186433
186851
  getDemoProfile = requireAnonymous(async (ctx) => ctx.services.user.getDemoProfile(ctx.user.id));
186434
186852
  updateDemoProfile = requireAnonymous(async (ctx) => {
@@ -186461,7 +186879,7 @@ var init_verify_controller = __esm(() => {
186461
186879
  init_esm();
186462
186880
  init_schemas_index();
186463
186881
  init_errors2();
186464
- init_utils11();
186882
+ init_utils7();
186465
186883
  verify = defineControllerNames("verify", {
186466
186884
  verifyToken
186467
186885
  });
@@ -186642,7 +187060,7 @@ async function buildMockUserResponse(db2, user, gameId, observedTimeZone) {
186642
187060
  var LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS2;
186643
187061
  var init_timeback7 = __esm(() => {
186644
187062
  init_drizzle_orm();
186645
- init_utils11();
187063
+ init_utils7();
186646
187064
  init_src();
186647
187065
  init_tables_index();
186648
187066
  init_src6();
@@ -187053,7 +187471,7 @@ var init_seed2 = __esm(async () => {
187053
187471
  init_dist7();
187054
187472
  init_esm();
187055
187473
  init_errors2();
187056
- init_utils11();
187474
+ init_utils7();
187057
187475
  init_schemas_index();
187058
187476
  await init_api3();
187059
187477
  gameSeedRouter = new Hono2;
@@ -187283,7 +187701,7 @@ var init_timeback8 = __esm(async () => {
187283
187701
  init_dist7();
187284
187702
  init_controllers();
187285
187703
  init_errors2();
187286
- init_utils11();
187704
+ init_utils7();
187287
187705
  init_src();
187288
187706
  init_schemas_index();
187289
187707
  init_timeback3();
@@ -187522,7 +187940,7 @@ var init_lti = __esm(async () => {
187522
187940
  init_drizzle_orm();
187523
187941
  init_dist7();
187524
187942
  init_controllers();
187525
- init_utils11();
187943
+ init_utils7();
187526
187944
  init_tables_index();
187527
187945
  init_src6();
187528
187946
  init_constants();