@playcademy/sandbox 0.10.1-beta.4 → 0.10.1-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +329 -102
  2. package/dist/server.js +329 -102
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1128,7 +1128,7 @@ var package_default;
1128
1128
  var init_package = __esm(() => {
1129
1129
  package_default = {
1130
1130
  name: "@playcademy/sandbox",
1131
- version: "0.10.1-beta.4",
1131
+ version: "0.10.1-beta.5",
1132
1132
  description: "Local development server for Playcademy game development",
1133
1133
  type: "module",
1134
1134
  exports: {
@@ -47989,7 +47989,7 @@ function rejectSystemManagedReview(input, context2) {
47989
47989
  });
47990
47990
  }
47991
47991
  }
47992
- var TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, AssessmentPurposeSchema, AssessmentStatusSchema, AssessmentKeySchema, DiagnosticKeySchema, DiagnosticRoutingManifestSchema, DiagnosticAssessmentDefinitionInputSchema, AssessmentStandardRefSchema3, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, AssessmentAssociationImportEntrySchema, AttachExistingAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
47992
+ var TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, AssessmentPurposeSchema, AssessmentStatusSchema, AssessmentKeySchema, DiagnosticKeySchema, DiagnosticRoutingManifestSchema, DiagnosticAssessmentDefinitionInputSchema, AssessmentStandardRefSchema3, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, ReviseAssessmentRequestSchema, AssessmentAssociationImportEntrySchema, AttachExistingAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
47993
47993
  var init_schemas4 = __esm(() => {
47994
47994
  init_esm();
47995
47995
  init_src();
@@ -48272,6 +48272,11 @@ var init_schemas4 = __esm(() => {
48272
48272
  requireMasteryStandard(input, context2);
48273
48273
  rejectSystemManagedReview(input, context2);
48274
48274
  });
48275
+ ReviseAssessmentRequestSchema = exports_external.object({
48276
+ currentQtiTestIdentifier: exports_external.string().trim().min(1, "The current assessment identifier is required"),
48277
+ title: exports_external.string().trim().min(1, "Assessment title is required"),
48278
+ questions: exports_external.array(exports_external.record(exports_external.string(), exports_external.unknown())).min(1, "A revision must contain at least one question").max(500, "A revision cannot contain more than 500 questions")
48279
+ });
48275
48280
  AssessmentAssociationImportEntrySchema = exports_external.object({
48276
48281
  assessmentKey: AssessmentKeySchema,
48277
48282
  qtiTestIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
@@ -106222,6 +106227,69 @@ var init_timeback_assessment_import_util = __esm(() => {
106222
106227
  init_timeback_qti_authoring_util();
106223
106228
  });
106224
106229
 
106230
+ // ../api-core/src/utils/timeback-assessment-revision.util.ts
106231
+ function assertRevisableAssessment(row, reviewBankIdentifier) {
106232
+ if (row.purpose === "review" || row.qtiTestIdentifier === reviewBankIdentifier) {
106233
+ throw new ValidationError("The review bank is system-managed. Revise its mastery sources instead.");
106234
+ }
106235
+ if (row.purpose === "diagnostic") {
106236
+ throw new ValidationError("Diagnostics are revised by importing a new revision and archiving the current one.");
106237
+ }
106238
+ if (row.status !== "live") {
106239
+ throw new ValidationError(row.status === "draft" ? "Draft assessments are edited directly. Only live assessments can be revised in place." : "Archived assessments cannot be revised. Restore it to live first.");
106240
+ }
106241
+ if (!row.assessmentKey) {
106242
+ throw new ValidationError("This unmanaged assessment cannot be revised. Republish it with an explicit assessment key.");
106243
+ }
106244
+ }
106245
+ function assertRevisableTestStructure(current) {
106246
+ const parts2 = qtiTestParts(current);
106247
+ const sectionCount = parts2.reduce((count, part) => count + part["qti-assessment-section"].length, 0);
106248
+ if (parts2.length !== 1 || sectionCount !== 1) {
106249
+ throw new ValidationError(`Assessment ${current.identifier} has ${parts2.length} test part(s) and ${sectionCount} section(s). Revisions can only replace content in single-section assessments.`);
106250
+ }
106251
+ }
106252
+ function buildRevisionSourceTestInput(input) {
106253
+ assertRevisableTestStructure(input.current);
106254
+ const { "qti-outcome-declaration": outcomeDeclarations, ...structure } = buildQtiTestStructureInput(input.current, input.sourceIdentifier);
106255
+ return {
106256
+ identifier: input.sourceIdentifier,
106257
+ title: input.title,
106258
+ ...qtiTestOptionalAttributes(input.current),
106259
+ metadata: {
106260
+ ...sourceMetadata(input.current.metadata),
106261
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
106262
+ ownerGameSlug: input.gameSlug,
106263
+ assessmentKey: input.assessmentKey,
106264
+ integrationId: input.integrationId,
106265
+ subject: input.subject,
106266
+ grade: String(input.grade),
106267
+ [PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: emptyReviewBankManifest(input.sourceIdentifier)
106268
+ },
106269
+ ...outcomeDeclarations && outcomeDeclarations.length > 0 ? { "qti-outcome-declaration": outcomeDeclarations } : {},
106270
+ "qti-test-part": structure["qti-test-part"].map((part) => ({
106271
+ ...part,
106272
+ "qti-assessment-section": part["qti-assessment-section"].map((section) => ({
106273
+ ...section,
106274
+ "qti-assessment-item-ref": []
106275
+ }))
106276
+ }))
106277
+ };
106278
+ }
106279
+ function revisionConflict(requestedIdentifier, currentIdentifier) {
106280
+ return new ConflictError("This assessment was revised by someone else. Reload to see the current version before revising again.", {
106281
+ requestedQtiTestIdentifier: requestedIdentifier,
106282
+ currentQtiTestIdentifier: currentIdentifier
106283
+ });
106284
+ }
106285
+ var init_timeback_assessment_revision_util = __esm(() => {
106286
+ init_assessment_runtime2();
106287
+ init_timeback3();
106288
+ init_errors2();
106289
+ init_timeback_assessment_publication_util();
106290
+ init_timeback_qti_authoring_util();
106291
+ });
106292
+
106225
106293
  // ../api-core/src/utils/timeback-qti-library.util.ts
106226
106294
  function newPlaycademyTestIdentifier() {
106227
106295
  return `${PLAYCADEMY_QTI_SOURCE_PREFIX}${crypto.randomUUID()}`;
@@ -106647,6 +106715,109 @@ class TimebackAssessmentsService {
106647
106715
  throw error88;
106648
106716
  }
106649
106717
  }
106718
+ async reviseAssessment(integrationId, input) {
106719
+ const client2 = this.requireClient();
106720
+ const reviewBankIdentifier = reviewBankSinkIdentifier(integrationId);
106721
+ const current = await this.requireCurrentRevisionRow(integrationId, input.currentQtiTestIdentifier);
106722
+ assertRevisableAssessment(current, reviewBankIdentifier);
106723
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId);
106724
+ const { integration } = ownership;
106725
+ const creations = input.questions.map((question) => {
106726
+ const creation = parseQtiQuestionCreationInput(question);
106727
+ if (creation.kind === "authoring") {
106728
+ assertSupportedQtiQuestionInteraction(creation.input);
106729
+ }
106730
+ return creation;
106731
+ });
106732
+ assertAssessmentHasQuestions(creations);
106733
+ const currentTest = await client2.qtiApi.assessmentTests.get(current.qtiTestIdentifier);
106734
+ const sourceIdentifier = input.sourceTestIdentifier;
106735
+ const sourceInput = buildRevisionSourceTestInput({
106736
+ sourceIdentifier,
106737
+ title: input.title,
106738
+ current: currentTest,
106739
+ assessmentKey: current.assessmentKey,
106740
+ integrationId,
106741
+ gameSlug: ownership.gameSlug,
106742
+ subject: integration.subject,
106743
+ grade: integration.grade
106744
+ });
106745
+ const createdItemIdentifiers = [];
106746
+ let sourceCreateAttempted = false;
106747
+ try {
106748
+ sourceCreateAttempted = true;
106749
+ await client2.qtiApi.assessmentTests.create(sourceInput);
106750
+ const sourceTest = await client2.qtiApi.assessmentTests.get(sourceIdentifier);
106751
+ for (const creation of creations) {
106752
+ const created = await this.createQuestionInOwnedTest({
106753
+ client: client2,
106754
+ integrationId,
106755
+ qtiTestIdentifier: sourceIdentifier,
106756
+ test: sourceTest,
106757
+ gameSlug: ownership.gameSlug,
106758
+ creation
106759
+ });
106760
+ createdItemIdentifiers.push(created.reference.identifier);
106761
+ }
106762
+ const loaded = await loadHydratedQtiTest(client2, sourceIdentifier);
106763
+ await this.validateAssessmentHasQuestions(loaded);
106764
+ const publication = await this.publishManagedAssessment(current, current.purpose, null, loaded);
106765
+ if (!publication.artifactsAvailable) {
106766
+ throw new ValidationError("The revised assessment could not be compiled for the runtime. The current version stays live; try again.");
106767
+ }
106768
+ const result = await this.deps.db.transaction(async (tx) => {
106769
+ const rows = await tx.select().from(gameTimebackAssessmentTests).where(and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.assessmentKey, current.assessmentKey))).for("update");
106770
+ const row = rows[0];
106771
+ if (!row) {
106772
+ throw new NotFoundError(`Assessment not found: ${input.currentQtiTestIdentifier}`);
106773
+ }
106774
+ if (row.qtiTestIdentifier !== input.currentQtiTestIdentifier) {
106775
+ throw revisionConflict(input.currentQtiTestIdentifier, row.qtiTestIdentifier);
106776
+ }
106777
+ assertRevisableAssessment(row, reviewBankIdentifier);
106778
+ const previousQtiTestIdentifier = row.qtiTestIdentifier;
106779
+ const unchanged = publication.qtiTestIdentifier === previousQtiTestIdentifier;
106780
+ if (unchanged) {
106781
+ setAttribute("app.assessment.operation", "revise_unchanged");
106782
+ return { row, previousQtiTestIdentifier, unchanged };
106783
+ }
106784
+ const [updatedRow] = await tx.update(gameTimebackAssessmentTests).set({
106785
+ qtiTestIdentifier: publication.qtiTestIdentifier,
106786
+ updatedAt: new Date
106787
+ }).where(eq(gameTimebackAssessmentTests.id, row.id)).returning();
106788
+ setAttribute("app.assessment.operation", "revise");
106789
+ addEvent("assessment.revised", {
106790
+ "app.assessment.assessment_key": row.assessmentKey,
106791
+ "app.assessment.previous_qti_test_identifier": previousQtiTestIdentifier,
106792
+ "app.assessment.qti_test_identifier": publication.qtiTestIdentifier
106793
+ });
106794
+ return { row: updatedRow ?? row, previousQtiTestIdentifier, unchanged };
106795
+ });
106796
+ let warning;
106797
+ if (!result.unchanged && result.row.purpose === "mastery") {
106798
+ try {
106799
+ const sync = await this.synchronizeReviewBank(integrationId);
106800
+ if (sync.warnings.length > 0) {
106801
+ warning = `Review bank: ${sync.warnings.map((entry) => entry.message).join(" ")}`;
106802
+ }
106803
+ } catch (error88) {
106804
+ addEvent("assessment.revision_review_bank_sync_failed", {
106805
+ "app.assessment.qti_test_identifier": result.row.qtiTestIdentifier,
106806
+ "exception.type": errorType(error88),
106807
+ "app.error.message": errorMessage(error88)
106808
+ });
106809
+ warning = `The review bank could not be synchronized: ${errorMessage(error88)}. Use Sync Review Bank to retry.`;
106810
+ }
106811
+ }
106812
+ return {
106813
+ ...this.associationSummary(result.row, warning),
106814
+ previousQtiTestIdentifier: result.previousQtiTestIdentifier,
106815
+ unchanged: result.unchanged
106816
+ };
106817
+ } finally {
106818
+ await this.cleanupQtiAssessmentCopy(client2, sourceCreateAttempted ? sourceIdentifier : undefined, createdItemIdentifiers);
106819
+ }
106820
+ }
106650
106821
  async removeAssessment(integrationId, qtiTestIdentifier) {
106651
106822
  return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx) => {
106652
106823
  if (qtiTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
@@ -106919,106 +107090,118 @@ class TimebackAssessmentsService {
106919
107090
  assertAllAssessmentAssociationsDraft(associations);
106920
107091
  const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
106921
107092
  const creation = parseQtiQuestionCreationInput(input);
106922
- const itemIdentifier = newPlaycademyQuestionIdentifier(qtiTestIdentifier);
106923
- let title;
106924
- let metadata2;
106925
- let xml;
106926
- let structuredInput;
106927
- let fallbackItem;
106928
- if (creation.kind === "copy") {
106929
- const source = await client2.qtiApi.assessmentItems.get(creation.sourceItemIdentifier);
106930
- const copy = buildQtiQuestionCopyInput({
106931
- source,
106932
- targetIdentifier: itemIdentifier,
106933
- targetTestIdentifier: qtiTestIdentifier,
106934
- gameSlug: ownership.gameSlug,
106935
- integrationId
106936
- });
106937
- title = copy.title;
106938
- metadata2 = copy.metadata;
106939
- xml = copy.xml;
106940
- fallbackItem = {
106941
- ...source,
106942
- identifier: itemIdentifier,
106943
- title,
106944
- rawXml: xml,
106945
- metadata: metadata2
106946
- };
106947
- } else {
106948
- structuredInput = creation.input;
106949
- assertSupportedQtiQuestionInteraction(structuredInput);
106950
- title = typeof structuredInput.title === "string" ? structuredInput.title.trim() : "";
106951
- metadata2 = buildOwnedQtiQuestionMetadata(structuredInput, {
106952
- gameSlug: ownership.gameSlug,
106953
- testIdentifier: qtiTestIdentifier
106954
- });
106955
- xml = buildQuestionXml(structuredInput, itemIdentifier, title);
106956
- fallbackItem = {
106957
- ...structuredInput,
106958
- identifier: itemIdentifier,
106959
- title,
106960
- ...xml ? { rawXml: xml } : {}
106961
- };
106962
- }
106963
- const section = await this.qtiSectionItems(client2, qtiTestIdentifier, undefined, undefined, ownership.test);
106964
- const href = qtiItemHref(client2, itemIdentifier);
106965
- let item;
106966
- let itemCreationAttempted = false;
106967
- let referenceCreationAttempted = false;
106968
- try {
106969
- itemCreationAttempted = true;
106970
- item = xml ? await client2.course.createAssessmentItemXml({ xml, metadata: metadata2 }) : await client2.qtiApi.assessmentItems.create({
106971
- ...structuredInput,
106972
- identifier: itemIdentifier,
106973
- metadata: metadata2
106974
- });
106975
- referenceCreationAttempted = true;
106976
- await section.items.add({
106977
- identifier: itemIdentifier,
106978
- href
106979
- });
106980
- } catch (error88) {
106981
- let referenceRemoved = !referenceCreationAttempted;
106982
- const creationStatus = isApiError(error88) ? error88.statusCode : undefined;
106983
- const itemCreationDefinitelyRejected = !referenceCreationAttempted && typeof creationStatus === "number" && creationStatus >= 400 && creationStatus < 500 && creationStatus !== 408;
106984
- if (referenceCreationAttempted) {
106985
- try {
106986
- await section.items.remove(itemIdentifier);
106987
- referenceRemoved = true;
106988
- } catch (cleanupError) {
106989
- referenceRemoved = isApiError(cleanupError) && cleanupError.statusCode === 404;
106990
- addEvent("assessment.qti_question_reference_cleanup_failed", {
106991
- "app.assessment.qti_test_identifier": qtiTestIdentifier,
106992
- "app.assessment.qti_item_identifier": itemIdentifier,
106993
- "exception.type": errorType(cleanupError),
106994
- "app.error.message": errorMessage(cleanupError)
106995
- });
106996
- }
106997
- }
106998
- if (itemCreationAttempted && !itemCreationDefinitelyRejected && referenceRemoved) {
106999
- try {
107000
- await client2.qtiApi.assessmentItems.delete(itemIdentifier);
107001
- } catch (cleanupError) {
107002
- addEvent("assessment.qti_question_cleanup_failed", {
107003
- "app.assessment.qti_test_identifier": qtiTestIdentifier,
107004
- "app.assessment.qti_item_identifier": itemIdentifier,
107005
- "exception.type": errorType(cleanupError),
107006
- "app.error.message": errorMessage(cleanupError)
107007
- });
107008
- }
107009
- }
107010
- throw error88;
107011
- }
107093
+ const created = await this.createQuestionInOwnedTest({
107094
+ client: client2,
107095
+ integrationId,
107096
+ qtiTestIdentifier,
107097
+ test: ownership.test,
107098
+ gameSlug: ownership.gameSlug,
107099
+ creation
107100
+ });
107012
107101
  setAttribute("app.assessment.operation", creation.kind === "copy" ? "copy_question" : "create_question");
107013
- return buildCreatedQtiQuestionReference({
107014
- item,
107015
- fallbackItem,
107016
- itemIdentifier,
107017
- href,
107018
- partIdentifier: section.partIdentifier,
107019
- sectionIdentifier: section.sectionIdentifier,
107102
+ return created;
107103
+ });
107104
+ }
107105
+ async createQuestionInOwnedTest(input) {
107106
+ const { client: client2, integrationId, qtiTestIdentifier, creation } = input;
107107
+ const itemIdentifier = newPlaycademyQuestionIdentifier(qtiTestIdentifier);
107108
+ let title;
107109
+ let metadata2;
107110
+ let xml;
107111
+ let structuredInput;
107112
+ let fallbackItem;
107113
+ if (creation.kind === "copy") {
107114
+ const source = await client2.qtiApi.assessmentItems.get(creation.sourceItemIdentifier);
107115
+ const copy = buildQtiQuestionCopyInput({
107116
+ source,
107117
+ targetIdentifier: itemIdentifier,
107118
+ targetTestIdentifier: qtiTestIdentifier,
107119
+ gameSlug: input.gameSlug,
107120
+ integrationId
107121
+ });
107122
+ title = copy.title;
107123
+ metadata2 = copy.metadata;
107124
+ xml = copy.xml;
107125
+ fallbackItem = {
107126
+ ...source,
107127
+ identifier: itemIdentifier,
107128
+ title,
107129
+ rawXml: xml,
107130
+ metadata: metadata2
107131
+ };
107132
+ } else {
107133
+ structuredInput = creation.input;
107134
+ assertSupportedQtiQuestionInteraction(structuredInput);
107135
+ title = typeof structuredInput.title === "string" ? structuredInput.title.trim() : "";
107136
+ metadata2 = buildOwnedQtiQuestionMetadata(structuredInput, {
107137
+ gameSlug: input.gameSlug,
107138
+ testIdentifier: qtiTestIdentifier
107139
+ });
107140
+ xml = buildQuestionXml(structuredInput, itemIdentifier, title);
107141
+ fallbackItem = {
107142
+ ...structuredInput,
107143
+ identifier: itemIdentifier,
107144
+ title,
107145
+ ...xml ? { rawXml: xml } : {}
107146
+ };
107147
+ }
107148
+ const section = await this.qtiSectionItems(client2, qtiTestIdentifier, undefined, undefined, input.test);
107149
+ const href = qtiItemHref(client2, itemIdentifier);
107150
+ let item;
107151
+ let itemCreationAttempted = false;
107152
+ let referenceCreationAttempted = false;
107153
+ try {
107154
+ itemCreationAttempted = true;
107155
+ item = xml ? await client2.course.createAssessmentItemXml({ xml, metadata: metadata2 }) : await client2.qtiApi.assessmentItems.create({
107156
+ ...structuredInput,
107157
+ identifier: itemIdentifier,
107020
107158
  metadata: metadata2
107021
107159
  });
107160
+ referenceCreationAttempted = true;
107161
+ await section.items.add({
107162
+ identifier: itemIdentifier,
107163
+ href
107164
+ });
107165
+ } catch (error88) {
107166
+ let referenceRemoved = !referenceCreationAttempted;
107167
+ const creationStatus = isApiError(error88) ? error88.statusCode : undefined;
107168
+ const itemCreationDefinitelyRejected = !referenceCreationAttempted && typeof creationStatus === "number" && creationStatus >= 400 && creationStatus < 500 && creationStatus !== 408;
107169
+ if (referenceCreationAttempted) {
107170
+ try {
107171
+ await section.items.remove(itemIdentifier);
107172
+ referenceRemoved = true;
107173
+ } catch (cleanupError) {
107174
+ referenceRemoved = isApiError(cleanupError) && cleanupError.statusCode === 404;
107175
+ addEvent("assessment.qti_question_reference_cleanup_failed", {
107176
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
107177
+ "app.assessment.qti_item_identifier": itemIdentifier,
107178
+ "exception.type": errorType(cleanupError),
107179
+ "app.error.message": errorMessage(cleanupError)
107180
+ });
107181
+ }
107182
+ }
107183
+ if (itemCreationAttempted && !itemCreationDefinitelyRejected && referenceRemoved) {
107184
+ try {
107185
+ await client2.qtiApi.assessmentItems.delete(itemIdentifier);
107186
+ } catch (cleanupError) {
107187
+ addEvent("assessment.qti_question_cleanup_failed", {
107188
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
107189
+ "app.assessment.qti_item_identifier": itemIdentifier,
107190
+ "exception.type": errorType(cleanupError),
107191
+ "app.error.message": errorMessage(cleanupError)
107192
+ });
107193
+ }
107194
+ }
107195
+ throw error88;
107196
+ }
107197
+ return buildCreatedQtiQuestionReference({
107198
+ item,
107199
+ fallbackItem,
107200
+ itemIdentifier,
107201
+ href,
107202
+ partIdentifier: section.partIdentifier,
107203
+ sectionIdentifier: section.sectionIdentifier,
107204
+ metadata: metadata2
107022
107205
  });
107023
107206
  }
107024
107207
  async updateQuestion(integrationId, qtiTestIdentifier, itemIdentifier, input) {
@@ -107266,6 +107449,22 @@ class TimebackAssessmentsService {
107266
107449
  }
107267
107450
  return row;
107268
107451
  }
107452
+ async requireCurrentRevisionRow(integrationId, qtiTestIdentifier) {
107453
+ const row = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
107454
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier))
107455
+ });
107456
+ if (row) {
107457
+ return row;
107458
+ }
107459
+ const assessmentKey = assessmentKeyFromManagedQtiIdentifier(qtiTestIdentifier);
107460
+ const successor = assessmentKey ? await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
107461
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.assessmentKey, assessmentKey))
107462
+ }) : undefined;
107463
+ if (successor) {
107464
+ throw revisionConflict(qtiTestIdentifier, successor.qtiTestIdentifier);
107465
+ }
107466
+ throw new NotFoundError(`Assessment not found: ${qtiTestIdentifier}`);
107467
+ }
107269
107468
  async requireDraftAssessmentRow(integrationId, qtiTestIdentifier, lockedRow) {
107270
107469
  const row = lockedRow ?? await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
107271
107470
  assertDraftAssessment(row);
@@ -107413,9 +107612,21 @@ class TimebackAssessmentsService {
107413
107612
  itemHref: (identifier) => qtiItemHref(client2, identifier)
107414
107613
  });
107415
107614
  const artifactSource = await this.ensureManagedQtiPublication(client2, plan);
107416
- const artifactsAvailable = await this.storeManagedArtifacts(artifactSource, managedAssessmentDiagnosticRoutingManifest(artifactSource.test, plan.assessmentKey));
107615
+ const artifactsAvailable = await this.managedArtifactsStored(plan.qtiTestIdentifier) || await this.storeManagedArtifacts(artifactSource, managedAssessmentDiagnosticRoutingManifest(artifactSource.test, plan.assessmentKey));
107417
107616
  const warning = assessmentArtifactPublicationWarning(artifactsAvailable);
107418
- return { ...plan, ...warning ? { warning } : {} };
107617
+ return { ...plan, artifactsAvailable, ...warning ? { warning } : {} };
107618
+ }
107619
+ async managedArtifactsStored(qtiTestIdentifier) {
107620
+ try {
107621
+ return await this.deps.assessmentArtifacts.loadManagedPlayable(qtiTestIdentifier) !== null;
107622
+ } catch (error88) {
107623
+ addEvent("assessment.artifact_lookup_failed", {
107624
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
107625
+ "exception.type": errorType(error88),
107626
+ "app.error.message": errorMessage(error88)
107627
+ });
107628
+ return false;
107629
+ }
107419
107630
  }
107420
107631
  async cleanupQtiAssessmentCopy(client2, testIdentifier, itemIdentifiers) {
107421
107632
  let testDeleted = !testIdentifier;
@@ -107542,6 +107753,7 @@ var init_timeback_assessments_service = __esm(async () => {
107542
107753
  init_timeback_assessment_diagnostic_util();
107543
107754
  init_timeback_assessment_import_util();
107544
107755
  init_timeback_assessment_publication_util();
107756
+ init_timeback_assessment_revision_util();
107545
107757
  init_timeback_assessment_rules_util();
107546
107758
  init_timeback_qti_authoring_util();
107547
107759
  init_timeback_qti_hydration_util();
@@ -167551,7 +167763,7 @@ function parseQtiLibraryParams(searchParams) {
167551
167763
  limit
167552
167764
  };
167553
167765
  }
167554
- var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, prepareRuntimeAssessment, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, resumeRuntimeAssessment, saveRuntimeAssessment, recordRuntimeAssessmentProgress, submitRuntimeAssessmentItem, submitRuntimeAssessment, finalizeRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, attachExistingAssessments, updateAssessment, synchronizeReviewBank, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
167766
+ var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, prepareRuntimeAssessment, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, resumeRuntimeAssessment, saveRuntimeAssessment, recordRuntimeAssessmentProgress, submitRuntimeAssessmentItem, submitRuntimeAssessment, finalizeRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, attachExistingAssessments, updateAssessment, reviseAssessment, synchronizeReviewBank, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
167555
167767
  var init_timeback_controller = __esm(() => {
167556
167768
  init_esm();
167557
167769
  init_src();
@@ -168296,6 +168508,20 @@ var init_timeback_controller = __esm(() => {
168296
168508
  const body2 = await parseRequestBody(ctx.request, UpdateAssessmentRequestSchema);
168297
168509
  return ctx.services.timebackAssessments.updateAssessment(integrationId, testIdentifier, body2);
168298
168510
  });
168511
+ reviseAssessment = requireDeveloper(async (ctx) => {
168512
+ const { gameId, courseId } = ctx.params;
168513
+ if (!gameId || !courseId) {
168514
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
168515
+ }
168516
+ const body2 = await parseRequestBody(ctx.request, ReviseAssessmentRequestSchema);
168517
+ const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
168518
+ return ctx.services.timebackAssessments.reviseAssessment(integrationId, {
168519
+ currentQtiTestIdentifier: body2.currentQtiTestIdentifier,
168520
+ sourceTestIdentifier: newPlaycademyTestIdentifier(),
168521
+ title: body2.title,
168522
+ questions: body2.questions
168523
+ });
168524
+ });
168299
168525
  synchronizeReviewBank = requireDeveloper(async (ctx) => {
168300
168526
  const { gameId, courseId } = ctx.params;
168301
168527
  if (!gameId || !courseId) {
@@ -168446,6 +168672,7 @@ var init_timeback_controller = __esm(() => {
168446
168672
  createAssessment,
168447
168673
  attachExistingAssessments,
168448
168674
  updateAssessment,
168675
+ reviseAssessment,
168449
168676
  synchronizeReviewBank,
168450
168677
  removeAssessment,
168451
168678
  reorderQuestions,
package/dist/server.js CHANGED
@@ -1127,7 +1127,7 @@ var package_default;
1127
1127
  var init_package = __esm(() => {
1128
1128
  package_default = {
1129
1129
  name: "@playcademy/sandbox",
1130
- version: "0.10.1-beta.4",
1130
+ version: "0.10.1-beta.5",
1131
1131
  description: "Local development server for Playcademy game development",
1132
1132
  type: "module",
1133
1133
  exports: {
@@ -47988,7 +47988,7 @@ function rejectSystemManagedReview(input, context2) {
47988
47988
  });
47989
47989
  }
47990
47990
  }
47991
- var TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, AssessmentPurposeSchema, AssessmentStatusSchema, AssessmentKeySchema, DiagnosticKeySchema, DiagnosticRoutingManifestSchema, DiagnosticAssessmentDefinitionInputSchema, AssessmentStandardRefSchema3, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, AssessmentAssociationImportEntrySchema, AttachExistingAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
47991
+ var TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, UnenrollCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, VerifyTimebackMetricDiscrepancyRequestSchema, AssessmentPurposeSchema, AssessmentStatusSchema, AssessmentKeySchema, DiagnosticKeySchema, DiagnosticRoutingManifestSchema, DiagnosticAssessmentDefinitionInputSchema, AssessmentStandardRefSchema3, CreateAssessmentRequestSchema, UpdateAssessmentRequestSchema, CopyAssessmentRequestSchema, ReviseAssessmentRequestSchema, AssessmentAssociationImportEntrySchema, AttachExistingAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
47992
47992
  var init_schemas4 = __esm(() => {
47993
47993
  init_esm();
47994
47994
  init_src();
@@ -48271,6 +48271,11 @@ var init_schemas4 = __esm(() => {
48271
48271
  requireMasteryStandard(input, context2);
48272
48272
  rejectSystemManagedReview(input, context2);
48273
48273
  });
48274
+ ReviseAssessmentRequestSchema = exports_external.object({
48275
+ currentQtiTestIdentifier: exports_external.string().trim().min(1, "The current assessment identifier is required"),
48276
+ title: exports_external.string().trim().min(1, "Assessment title is required"),
48277
+ questions: exports_external.array(exports_external.record(exports_external.string(), exports_external.unknown())).min(1, "A revision must contain at least one question").max(500, "A revision cannot contain more than 500 questions")
48278
+ });
48274
48279
  AssessmentAssociationImportEntrySchema = exports_external.object({
48275
48280
  assessmentKey: AssessmentKeySchema,
48276
48281
  qtiTestIdentifier: exports_external.string().trim().min(1, "Assessment identifier is required"),
@@ -106221,6 +106226,69 @@ var init_timeback_assessment_import_util = __esm(() => {
106221
106226
  init_timeback_qti_authoring_util();
106222
106227
  });
106223
106228
 
106229
+ // ../api-core/src/utils/timeback-assessment-revision.util.ts
106230
+ function assertRevisableAssessment(row, reviewBankIdentifier) {
106231
+ if (row.purpose === "review" || row.qtiTestIdentifier === reviewBankIdentifier) {
106232
+ throw new ValidationError("The review bank is system-managed. Revise its mastery sources instead.");
106233
+ }
106234
+ if (row.purpose === "diagnostic") {
106235
+ throw new ValidationError("Diagnostics are revised by importing a new revision and archiving the current one.");
106236
+ }
106237
+ if (row.status !== "live") {
106238
+ throw new ValidationError(row.status === "draft" ? "Draft assessments are edited directly. Only live assessments can be revised in place." : "Archived assessments cannot be revised. Restore it to live first.");
106239
+ }
106240
+ if (!row.assessmentKey) {
106241
+ throw new ValidationError("This unmanaged assessment cannot be revised. Republish it with an explicit assessment key.");
106242
+ }
106243
+ }
106244
+ function assertRevisableTestStructure(current) {
106245
+ const parts2 = qtiTestParts(current);
106246
+ const sectionCount = parts2.reduce((count, part) => count + part["qti-assessment-section"].length, 0);
106247
+ if (parts2.length !== 1 || sectionCount !== 1) {
106248
+ throw new ValidationError(`Assessment ${current.identifier} has ${parts2.length} test part(s) and ${sectionCount} section(s). Revisions can only replace content in single-section assessments.`);
106249
+ }
106250
+ }
106251
+ function buildRevisionSourceTestInput(input) {
106252
+ assertRevisableTestStructure(input.current);
106253
+ const { "qti-outcome-declaration": outcomeDeclarations, ...structure } = buildQtiTestStructureInput(input.current, input.sourceIdentifier);
106254
+ return {
106255
+ identifier: input.sourceIdentifier,
106256
+ title: input.title,
106257
+ ...qtiTestOptionalAttributes(input.current),
106258
+ metadata: {
106259
+ ...sourceMetadata(input.current.metadata),
106260
+ ownerSystem: PLAYCADEMY_QTI_OWNER_SYSTEM,
106261
+ ownerGameSlug: input.gameSlug,
106262
+ assessmentKey: input.assessmentKey,
106263
+ integrationId: input.integrationId,
106264
+ subject: input.subject,
106265
+ grade: String(input.grade),
106266
+ [PLAYCADEMY_REVIEW_BANK_MANIFEST_METADATA_KEY]: emptyReviewBankManifest(input.sourceIdentifier)
106267
+ },
106268
+ ...outcomeDeclarations && outcomeDeclarations.length > 0 ? { "qti-outcome-declaration": outcomeDeclarations } : {},
106269
+ "qti-test-part": structure["qti-test-part"].map((part) => ({
106270
+ ...part,
106271
+ "qti-assessment-section": part["qti-assessment-section"].map((section) => ({
106272
+ ...section,
106273
+ "qti-assessment-item-ref": []
106274
+ }))
106275
+ }))
106276
+ };
106277
+ }
106278
+ function revisionConflict(requestedIdentifier, currentIdentifier) {
106279
+ return new ConflictError("This assessment was revised by someone else. Reload to see the current version before revising again.", {
106280
+ requestedQtiTestIdentifier: requestedIdentifier,
106281
+ currentQtiTestIdentifier: currentIdentifier
106282
+ });
106283
+ }
106284
+ var init_timeback_assessment_revision_util = __esm(() => {
106285
+ init_assessment_runtime2();
106286
+ init_timeback3();
106287
+ init_errors2();
106288
+ init_timeback_assessment_publication_util();
106289
+ init_timeback_qti_authoring_util();
106290
+ });
106291
+
106224
106292
  // ../api-core/src/utils/timeback-qti-library.util.ts
106225
106293
  function newPlaycademyTestIdentifier() {
106226
106294
  return `${PLAYCADEMY_QTI_SOURCE_PREFIX}${crypto.randomUUID()}`;
@@ -106646,6 +106714,109 @@ class TimebackAssessmentsService {
106646
106714
  throw error88;
106647
106715
  }
106648
106716
  }
106717
+ async reviseAssessment(integrationId, input) {
106718
+ const client2 = this.requireClient();
106719
+ const reviewBankIdentifier = reviewBankSinkIdentifier(integrationId);
106720
+ const current = await this.requireCurrentRevisionRow(integrationId, input.currentQtiTestIdentifier);
106721
+ assertRevisableAssessment(current, reviewBankIdentifier);
106722
+ const ownership = await this.requireQtiTestOwnershipContext(integrationId);
106723
+ const { integration } = ownership;
106724
+ const creations = input.questions.map((question) => {
106725
+ const creation = parseQtiQuestionCreationInput(question);
106726
+ if (creation.kind === "authoring") {
106727
+ assertSupportedQtiQuestionInteraction(creation.input);
106728
+ }
106729
+ return creation;
106730
+ });
106731
+ assertAssessmentHasQuestions(creations);
106732
+ const currentTest = await client2.qtiApi.assessmentTests.get(current.qtiTestIdentifier);
106733
+ const sourceIdentifier = input.sourceTestIdentifier;
106734
+ const sourceInput = buildRevisionSourceTestInput({
106735
+ sourceIdentifier,
106736
+ title: input.title,
106737
+ current: currentTest,
106738
+ assessmentKey: current.assessmentKey,
106739
+ integrationId,
106740
+ gameSlug: ownership.gameSlug,
106741
+ subject: integration.subject,
106742
+ grade: integration.grade
106743
+ });
106744
+ const createdItemIdentifiers = [];
106745
+ let sourceCreateAttempted = false;
106746
+ try {
106747
+ sourceCreateAttempted = true;
106748
+ await client2.qtiApi.assessmentTests.create(sourceInput);
106749
+ const sourceTest = await client2.qtiApi.assessmentTests.get(sourceIdentifier);
106750
+ for (const creation of creations) {
106751
+ const created = await this.createQuestionInOwnedTest({
106752
+ client: client2,
106753
+ integrationId,
106754
+ qtiTestIdentifier: sourceIdentifier,
106755
+ test: sourceTest,
106756
+ gameSlug: ownership.gameSlug,
106757
+ creation
106758
+ });
106759
+ createdItemIdentifiers.push(created.reference.identifier);
106760
+ }
106761
+ const loaded = await loadHydratedQtiTest(client2, sourceIdentifier);
106762
+ await this.validateAssessmentHasQuestions(loaded);
106763
+ const publication = await this.publishManagedAssessment(current, current.purpose, null, loaded);
106764
+ if (!publication.artifactsAvailable) {
106765
+ throw new ValidationError("The revised assessment could not be compiled for the runtime. The current version stays live; try again.");
106766
+ }
106767
+ const result = await this.deps.db.transaction(async (tx) => {
106768
+ const rows = await tx.select().from(gameTimebackAssessmentTests).where(and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.assessmentKey, current.assessmentKey))).for("update");
106769
+ const row = rows[0];
106770
+ if (!row) {
106771
+ throw new NotFoundError(`Assessment not found: ${input.currentQtiTestIdentifier}`);
106772
+ }
106773
+ if (row.qtiTestIdentifier !== input.currentQtiTestIdentifier) {
106774
+ throw revisionConflict(input.currentQtiTestIdentifier, row.qtiTestIdentifier);
106775
+ }
106776
+ assertRevisableAssessment(row, reviewBankIdentifier);
106777
+ const previousQtiTestIdentifier = row.qtiTestIdentifier;
106778
+ const unchanged = publication.qtiTestIdentifier === previousQtiTestIdentifier;
106779
+ if (unchanged) {
106780
+ setAttribute("app.assessment.operation", "revise_unchanged");
106781
+ return { row, previousQtiTestIdentifier, unchanged };
106782
+ }
106783
+ const [updatedRow] = await tx.update(gameTimebackAssessmentTests).set({
106784
+ qtiTestIdentifier: publication.qtiTestIdentifier,
106785
+ updatedAt: new Date
106786
+ }).where(eq(gameTimebackAssessmentTests.id, row.id)).returning();
106787
+ setAttribute("app.assessment.operation", "revise");
106788
+ addEvent("assessment.revised", {
106789
+ "app.assessment.assessment_key": row.assessmentKey,
106790
+ "app.assessment.previous_qti_test_identifier": previousQtiTestIdentifier,
106791
+ "app.assessment.qti_test_identifier": publication.qtiTestIdentifier
106792
+ });
106793
+ return { row: updatedRow ?? row, previousQtiTestIdentifier, unchanged };
106794
+ });
106795
+ let warning;
106796
+ if (!result.unchanged && result.row.purpose === "mastery") {
106797
+ try {
106798
+ const sync = await this.synchronizeReviewBank(integrationId);
106799
+ if (sync.warnings.length > 0) {
106800
+ warning = `Review bank: ${sync.warnings.map((entry) => entry.message).join(" ")}`;
106801
+ }
106802
+ } catch (error88) {
106803
+ addEvent("assessment.revision_review_bank_sync_failed", {
106804
+ "app.assessment.qti_test_identifier": result.row.qtiTestIdentifier,
106805
+ "exception.type": errorType(error88),
106806
+ "app.error.message": errorMessage(error88)
106807
+ });
106808
+ warning = `The review bank could not be synchronized: ${errorMessage(error88)}. Use Sync Review Bank to retry.`;
106809
+ }
106810
+ }
106811
+ return {
106812
+ ...this.associationSummary(result.row, warning),
106813
+ previousQtiTestIdentifier: result.previousQtiTestIdentifier,
106814
+ unchanged: result.unchanged
106815
+ };
106816
+ } finally {
106817
+ await this.cleanupQtiAssessmentCopy(client2, sourceCreateAttempted ? sourceIdentifier : undefined, createdItemIdentifiers);
106818
+ }
106819
+ }
106649
106820
  async removeAssessment(integrationId, qtiTestIdentifier) {
106650
106821
  return this.withLockedAssessmentAssociations(integrationId, qtiTestIdentifier, async (row, tx) => {
106651
106822
  if (qtiTestIdentifier === reviewBankSinkIdentifier(integrationId)) {
@@ -106918,106 +107089,118 @@ class TimebackAssessmentsService {
106918
107089
  assertAllAssessmentAssociationsDraft(associations);
106919
107090
  const ownership = await this.requireOwnedDraftAssessment(integrationId, qtiTestIdentifier, row, tx);
106920
107091
  const creation = parseQtiQuestionCreationInput(input);
106921
- const itemIdentifier = newPlaycademyQuestionIdentifier(qtiTestIdentifier);
106922
- let title;
106923
- let metadata2;
106924
- let xml;
106925
- let structuredInput;
106926
- let fallbackItem;
106927
- if (creation.kind === "copy") {
106928
- const source = await client2.qtiApi.assessmentItems.get(creation.sourceItemIdentifier);
106929
- const copy = buildQtiQuestionCopyInput({
106930
- source,
106931
- targetIdentifier: itemIdentifier,
106932
- targetTestIdentifier: qtiTestIdentifier,
106933
- gameSlug: ownership.gameSlug,
106934
- integrationId
106935
- });
106936
- title = copy.title;
106937
- metadata2 = copy.metadata;
106938
- xml = copy.xml;
106939
- fallbackItem = {
106940
- ...source,
106941
- identifier: itemIdentifier,
106942
- title,
106943
- rawXml: xml,
106944
- metadata: metadata2
106945
- };
106946
- } else {
106947
- structuredInput = creation.input;
106948
- assertSupportedQtiQuestionInteraction(structuredInput);
106949
- title = typeof structuredInput.title === "string" ? structuredInput.title.trim() : "";
106950
- metadata2 = buildOwnedQtiQuestionMetadata(structuredInput, {
106951
- gameSlug: ownership.gameSlug,
106952
- testIdentifier: qtiTestIdentifier
106953
- });
106954
- xml = buildQuestionXml(structuredInput, itemIdentifier, title);
106955
- fallbackItem = {
106956
- ...structuredInput,
106957
- identifier: itemIdentifier,
106958
- title,
106959
- ...xml ? { rawXml: xml } : {}
106960
- };
106961
- }
106962
- const section = await this.qtiSectionItems(client2, qtiTestIdentifier, undefined, undefined, ownership.test);
106963
- const href = qtiItemHref(client2, itemIdentifier);
106964
- let item;
106965
- let itemCreationAttempted = false;
106966
- let referenceCreationAttempted = false;
106967
- try {
106968
- itemCreationAttempted = true;
106969
- item = xml ? await client2.course.createAssessmentItemXml({ xml, metadata: metadata2 }) : await client2.qtiApi.assessmentItems.create({
106970
- ...structuredInput,
106971
- identifier: itemIdentifier,
106972
- metadata: metadata2
106973
- });
106974
- referenceCreationAttempted = true;
106975
- await section.items.add({
106976
- identifier: itemIdentifier,
106977
- href
106978
- });
106979
- } catch (error88) {
106980
- let referenceRemoved = !referenceCreationAttempted;
106981
- const creationStatus = isApiError(error88) ? error88.statusCode : undefined;
106982
- const itemCreationDefinitelyRejected = !referenceCreationAttempted && typeof creationStatus === "number" && creationStatus >= 400 && creationStatus < 500 && creationStatus !== 408;
106983
- if (referenceCreationAttempted) {
106984
- try {
106985
- await section.items.remove(itemIdentifier);
106986
- referenceRemoved = true;
106987
- } catch (cleanupError) {
106988
- referenceRemoved = isApiError(cleanupError) && cleanupError.statusCode === 404;
106989
- addEvent("assessment.qti_question_reference_cleanup_failed", {
106990
- "app.assessment.qti_test_identifier": qtiTestIdentifier,
106991
- "app.assessment.qti_item_identifier": itemIdentifier,
106992
- "exception.type": errorType(cleanupError),
106993
- "app.error.message": errorMessage(cleanupError)
106994
- });
106995
- }
106996
- }
106997
- if (itemCreationAttempted && !itemCreationDefinitelyRejected && referenceRemoved) {
106998
- try {
106999
- await client2.qtiApi.assessmentItems.delete(itemIdentifier);
107000
- } catch (cleanupError) {
107001
- addEvent("assessment.qti_question_cleanup_failed", {
107002
- "app.assessment.qti_test_identifier": qtiTestIdentifier,
107003
- "app.assessment.qti_item_identifier": itemIdentifier,
107004
- "exception.type": errorType(cleanupError),
107005
- "app.error.message": errorMessage(cleanupError)
107006
- });
107007
- }
107008
- }
107009
- throw error88;
107010
- }
107092
+ const created = await this.createQuestionInOwnedTest({
107093
+ client: client2,
107094
+ integrationId,
107095
+ qtiTestIdentifier,
107096
+ test: ownership.test,
107097
+ gameSlug: ownership.gameSlug,
107098
+ creation
107099
+ });
107011
107100
  setAttribute("app.assessment.operation", creation.kind === "copy" ? "copy_question" : "create_question");
107012
- return buildCreatedQtiQuestionReference({
107013
- item,
107014
- fallbackItem,
107015
- itemIdentifier,
107016
- href,
107017
- partIdentifier: section.partIdentifier,
107018
- sectionIdentifier: section.sectionIdentifier,
107101
+ return created;
107102
+ });
107103
+ }
107104
+ async createQuestionInOwnedTest(input) {
107105
+ const { client: client2, integrationId, qtiTestIdentifier, creation } = input;
107106
+ const itemIdentifier = newPlaycademyQuestionIdentifier(qtiTestIdentifier);
107107
+ let title;
107108
+ let metadata2;
107109
+ let xml;
107110
+ let structuredInput;
107111
+ let fallbackItem;
107112
+ if (creation.kind === "copy") {
107113
+ const source = await client2.qtiApi.assessmentItems.get(creation.sourceItemIdentifier);
107114
+ const copy = buildQtiQuestionCopyInput({
107115
+ source,
107116
+ targetIdentifier: itemIdentifier,
107117
+ targetTestIdentifier: qtiTestIdentifier,
107118
+ gameSlug: input.gameSlug,
107119
+ integrationId
107120
+ });
107121
+ title = copy.title;
107122
+ metadata2 = copy.metadata;
107123
+ xml = copy.xml;
107124
+ fallbackItem = {
107125
+ ...source,
107126
+ identifier: itemIdentifier,
107127
+ title,
107128
+ rawXml: xml,
107129
+ metadata: metadata2
107130
+ };
107131
+ } else {
107132
+ structuredInput = creation.input;
107133
+ assertSupportedQtiQuestionInteraction(structuredInput);
107134
+ title = typeof structuredInput.title === "string" ? structuredInput.title.trim() : "";
107135
+ metadata2 = buildOwnedQtiQuestionMetadata(structuredInput, {
107136
+ gameSlug: input.gameSlug,
107137
+ testIdentifier: qtiTestIdentifier
107138
+ });
107139
+ xml = buildQuestionXml(structuredInput, itemIdentifier, title);
107140
+ fallbackItem = {
107141
+ ...structuredInput,
107142
+ identifier: itemIdentifier,
107143
+ title,
107144
+ ...xml ? { rawXml: xml } : {}
107145
+ };
107146
+ }
107147
+ const section = await this.qtiSectionItems(client2, qtiTestIdentifier, undefined, undefined, input.test);
107148
+ const href = qtiItemHref(client2, itemIdentifier);
107149
+ let item;
107150
+ let itemCreationAttempted = false;
107151
+ let referenceCreationAttempted = false;
107152
+ try {
107153
+ itemCreationAttempted = true;
107154
+ item = xml ? await client2.course.createAssessmentItemXml({ xml, metadata: metadata2 }) : await client2.qtiApi.assessmentItems.create({
107155
+ ...structuredInput,
107156
+ identifier: itemIdentifier,
107019
107157
  metadata: metadata2
107020
107158
  });
107159
+ referenceCreationAttempted = true;
107160
+ await section.items.add({
107161
+ identifier: itemIdentifier,
107162
+ href
107163
+ });
107164
+ } catch (error88) {
107165
+ let referenceRemoved = !referenceCreationAttempted;
107166
+ const creationStatus = isApiError(error88) ? error88.statusCode : undefined;
107167
+ const itemCreationDefinitelyRejected = !referenceCreationAttempted && typeof creationStatus === "number" && creationStatus >= 400 && creationStatus < 500 && creationStatus !== 408;
107168
+ if (referenceCreationAttempted) {
107169
+ try {
107170
+ await section.items.remove(itemIdentifier);
107171
+ referenceRemoved = true;
107172
+ } catch (cleanupError) {
107173
+ referenceRemoved = isApiError(cleanupError) && cleanupError.statusCode === 404;
107174
+ addEvent("assessment.qti_question_reference_cleanup_failed", {
107175
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
107176
+ "app.assessment.qti_item_identifier": itemIdentifier,
107177
+ "exception.type": errorType(cleanupError),
107178
+ "app.error.message": errorMessage(cleanupError)
107179
+ });
107180
+ }
107181
+ }
107182
+ if (itemCreationAttempted && !itemCreationDefinitelyRejected && referenceRemoved) {
107183
+ try {
107184
+ await client2.qtiApi.assessmentItems.delete(itemIdentifier);
107185
+ } catch (cleanupError) {
107186
+ addEvent("assessment.qti_question_cleanup_failed", {
107187
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
107188
+ "app.assessment.qti_item_identifier": itemIdentifier,
107189
+ "exception.type": errorType(cleanupError),
107190
+ "app.error.message": errorMessage(cleanupError)
107191
+ });
107192
+ }
107193
+ }
107194
+ throw error88;
107195
+ }
107196
+ return buildCreatedQtiQuestionReference({
107197
+ item,
107198
+ fallbackItem,
107199
+ itemIdentifier,
107200
+ href,
107201
+ partIdentifier: section.partIdentifier,
107202
+ sectionIdentifier: section.sectionIdentifier,
107203
+ metadata: metadata2
107021
107204
  });
107022
107205
  }
107023
107206
  async updateQuestion(integrationId, qtiTestIdentifier, itemIdentifier, input) {
@@ -107265,6 +107448,22 @@ class TimebackAssessmentsService {
107265
107448
  }
107266
107449
  return row;
107267
107450
  }
107451
+ async requireCurrentRevisionRow(integrationId, qtiTestIdentifier) {
107452
+ const row = await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
107453
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.qtiTestIdentifier, qtiTestIdentifier))
107454
+ });
107455
+ if (row) {
107456
+ return row;
107457
+ }
107458
+ const assessmentKey = assessmentKeyFromManagedQtiIdentifier(qtiTestIdentifier);
107459
+ const successor = assessmentKey ? await this.deps.db.query.gameTimebackAssessmentTests.findFirst({
107460
+ where: and(eq(gameTimebackAssessmentTests.integrationId, integrationId), eq(gameTimebackAssessmentTests.assessmentKey, assessmentKey))
107461
+ }) : undefined;
107462
+ if (successor) {
107463
+ throw revisionConflict(qtiTestIdentifier, successor.qtiTestIdentifier);
107464
+ }
107465
+ throw new NotFoundError(`Assessment not found: ${qtiTestIdentifier}`);
107466
+ }
107268
107467
  async requireDraftAssessmentRow(integrationId, qtiTestIdentifier, lockedRow) {
107269
107468
  const row = lockedRow ?? await this.requireAssessmentRow(integrationId, qtiTestIdentifier);
107270
107469
  assertDraftAssessment(row);
@@ -107412,9 +107611,21 @@ class TimebackAssessmentsService {
107412
107611
  itemHref: (identifier) => qtiItemHref(client2, identifier)
107413
107612
  });
107414
107613
  const artifactSource = await this.ensureManagedQtiPublication(client2, plan);
107415
- const artifactsAvailable = await this.storeManagedArtifacts(artifactSource, managedAssessmentDiagnosticRoutingManifest(artifactSource.test, plan.assessmentKey));
107614
+ const artifactsAvailable = await this.managedArtifactsStored(plan.qtiTestIdentifier) || await this.storeManagedArtifacts(artifactSource, managedAssessmentDiagnosticRoutingManifest(artifactSource.test, plan.assessmentKey));
107416
107615
  const warning = assessmentArtifactPublicationWarning(artifactsAvailable);
107417
- return { ...plan, ...warning ? { warning } : {} };
107616
+ return { ...plan, artifactsAvailable, ...warning ? { warning } : {} };
107617
+ }
107618
+ async managedArtifactsStored(qtiTestIdentifier) {
107619
+ try {
107620
+ return await this.deps.assessmentArtifacts.loadManagedPlayable(qtiTestIdentifier) !== null;
107621
+ } catch (error88) {
107622
+ addEvent("assessment.artifact_lookup_failed", {
107623
+ "app.assessment.qti_test_identifier": qtiTestIdentifier,
107624
+ "exception.type": errorType(error88),
107625
+ "app.error.message": errorMessage(error88)
107626
+ });
107627
+ return false;
107628
+ }
107418
107629
  }
107419
107630
  async cleanupQtiAssessmentCopy(client2, testIdentifier, itemIdentifiers) {
107420
107631
  let testDeleted = !testIdentifier;
@@ -107541,6 +107752,7 @@ var init_timeback_assessments_service = __esm(async () => {
107541
107752
  init_timeback_assessment_diagnostic_util();
107542
107753
  init_timeback_assessment_import_util();
107543
107754
  init_timeback_assessment_publication_util();
107755
+ init_timeback_assessment_revision_util();
107544
107756
  init_timeback_assessment_rules_util();
107545
107757
  init_timeback_qti_authoring_util();
107546
107758
  init_timeback_qti_hydration_util();
@@ -167550,7 +167762,7 @@ function parseQtiLibraryParams(searchParams) {
167550
167762
  limit
167551
167763
  };
167552
167764
  }
167553
- var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, prepareRuntimeAssessment, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, resumeRuntimeAssessment, saveRuntimeAssessment, recordRuntimeAssessmentProgress, submitRuntimeAssessmentItem, submitRuntimeAssessment, finalizeRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, attachExistingAssessments, updateAssessment, synchronizeReviewBank, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
167765
+ var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, prepareRuntimeAssessment, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, resumeRuntimeAssessment, saveRuntimeAssessment, recordRuntimeAssessmentProgress, submitRuntimeAssessmentItem, submitRuntimeAssessment, finalizeRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, attachExistingAssessments, updateAssessment, reviseAssessment, synchronizeReviewBank, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
167554
167766
  var init_timeback_controller = __esm(() => {
167555
167767
  init_esm();
167556
167768
  init_src();
@@ -168295,6 +168507,20 @@ var init_timeback_controller = __esm(() => {
168295
168507
  const body2 = await parseRequestBody(ctx.request, UpdateAssessmentRequestSchema);
168296
168508
  return ctx.services.timebackAssessments.updateAssessment(integrationId, testIdentifier, body2);
168297
168509
  });
168510
+ reviseAssessment = requireDeveloper(async (ctx) => {
168511
+ const { gameId, courseId } = ctx.params;
168512
+ if (!gameId || !courseId) {
168513
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
168514
+ }
168515
+ const body2 = await parseRequestBody(ctx.request, ReviseAssessmentRequestSchema);
168516
+ const integrationId = await ctx.services.timebackAssessments.resolveIntegrationId(gameId, courseId, ctx.user);
168517
+ return ctx.services.timebackAssessments.reviseAssessment(integrationId, {
168518
+ currentQtiTestIdentifier: body2.currentQtiTestIdentifier,
168519
+ sourceTestIdentifier: newPlaycademyTestIdentifier(),
168520
+ title: body2.title,
168521
+ questions: body2.questions
168522
+ });
168523
+ });
168298
168524
  synchronizeReviewBank = requireDeveloper(async (ctx) => {
168299
168525
  const { gameId, courseId } = ctx.params;
168300
168526
  if (!gameId || !courseId) {
@@ -168445,6 +168671,7 @@ var init_timeback_controller = __esm(() => {
168445
168671
  createAssessment,
168446
168672
  attachExistingAssessments,
168447
168673
  updateAssessment,
168674
+ reviseAssessment,
168448
168675
  synchronizeReviewBank,
168449
168676
  removeAssessment,
168450
168677
  reorderQuestions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.10.1-beta.4",
3
+ "version": "0.10.1-beta.5",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {