@playcademy/sandbox 0.12.2-beta.1 → 0.12.2-beta.3

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 +640 -194
  2. package/dist/server.js +640 -194
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2517,7 +2517,7 @@ var package_default;
2517
2517
  var init_package = __esm(() => {
2518
2518
  package_default = {
2519
2519
  name: "@playcademy/sandbox",
2520
- version: "0.12.2-beta.1",
2520
+ version: "0.12.2-beta.3",
2521
2521
  description: "Local development server for Playcademy game development",
2522
2522
  type: "module",
2523
2523
  exports: {
@@ -14774,6 +14774,7 @@ var init_schema = __esm(() => {
14774
14774
  assessmentArtifactBucket: stringType().optional(),
14775
14775
  cdnOrigin: stringType().url().startsWith("https://").optional(),
14776
14776
  queueIngressSecret: stringType().optional(),
14777
+ embedAdminToken: stringType().optional(),
14777
14778
  secretsManifestPepper: stringType().optional()
14778
14779
  }).superRefine((config, ctx) => {
14779
14780
  if (config.isLocal && !config.baseUrl) {
@@ -14940,6 +14941,17 @@ var init_table_utils = __esm(() => {
14940
14941
  });
14941
14942
 
14942
14943
  // ../../node_modules/.bun/drizzle-orm@0.45.3+c063d24bf874cbdc/node_modules/drizzle-orm/pg-core/foreign-keys.js
14944
+ function foreignKey(config) {
14945
+ function mappedConfig() {
14946
+ const { name: name2, columns, foreignColumns } = config;
14947
+ return {
14948
+ name: name2,
14949
+ columns,
14950
+ foreignColumns
14951
+ };
14952
+ }
14953
+ return new ForeignKeyBuilder(mappedConfig);
14954
+ }
14943
14955
  var ForeignKeyBuilder, ForeignKey;
14944
14956
  var init_foreign_keys = __esm(() => {
14945
14957
  init_entity();
@@ -17812,6 +17824,16 @@ function ilike(column, value) {
17812
17824
  function notIlike(column, value) {
17813
17825
  return sql`${column} not ilike ${value}`;
17814
17826
  }
17827
+ function arrayContains(column, values) {
17828
+ if (Array.isArray(values)) {
17829
+ if (values.length === 0) {
17830
+ throw new Error("arrayContains requires at least one value");
17831
+ }
17832
+ const array = sql`${bindIfParam(values, column)}`;
17833
+ return sql`${column} @> ${array}`;
17834
+ }
17835
+ return sql`${column} @> ${bindIfParam(values, column)}`;
17836
+ }
17815
17837
  var eq = (left, right) => {
17816
17838
  return sql`${left} = ${bindIfParam(right, left)}`;
17817
17839
  }, ne = (left, right) => {
@@ -20742,7 +20764,7 @@ var init_table6 = __esm(() => {
20742
20764
  });
20743
20765
 
20744
20766
  // ../data/src/domains/timeback/table.ts
20745
- var gameTimebackIntegrationStatusEnum, gameTimebackAssessmentPurposeEnum, gameTimebackAssessmentStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications, gameTimebackActivityCompletions;
20767
+ var gameTimebackIntegrationStatusEnum, gameTimebackIntegrationKindEnum, gameTimebackAssessmentPurposeEnum, gameTimebackAssessmentStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications, gameTimebackActivityCompletions;
20746
20768
  var init_table7 = __esm(() => {
20747
20769
  init_drizzle_orm();
20748
20770
  init_pg_core();
@@ -20753,6 +20775,10 @@ var init_table7 = __esm(() => {
20753
20775
  "active",
20754
20776
  "deactivated"
20755
20777
  ]);
20778
+ gameTimebackIntegrationKindEnum = pgEnum("game_timeback_integration_kind", [
20779
+ "base",
20780
+ "hole_filling"
20781
+ ]);
20756
20782
  gameTimebackAssessmentPurposeEnum = pgEnum("game_timeback_assessment_purpose", ASSESSMENT_PURPOSES);
20757
20783
  gameTimebackAssessmentStatusEnum = pgEnum("game_timeback_assessment_status", [
20758
20784
  "draft",
@@ -20766,6 +20792,8 @@ var init_table7 = __esm(() => {
20766
20792
  grade: integer("grade").notNull(),
20767
20793
  subject: text("subject").notNull(),
20768
20794
  status: gameTimebackIntegrationStatusEnum("status"),
20795
+ kind: gameTimebackIntegrationKindEnum("kind").notNull().default("base"),
20796
+ baseIntegrationId: uuid("base_integration_id"),
20769
20797
  totalXp: integer("total_xp"),
20770
20798
  lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true }),
20771
20799
  deactivatedAt: timestamp("deactivated_at", { withTimezone: true }),
@@ -20773,7 +20801,19 @@ var init_table7 = __esm(() => {
20773
20801
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
20774
20802
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
20775
20803
  }, (table) => [
20776
- uniqueIndex("game_timeback_integrations_game_grade_subject_idx").on(table.gameId, table.grade, table.subject).where(sql`${table.status} IS DISTINCT FROM 'deactivated'`)
20804
+ uniqueIndex("game_timeback_integrations_game_grade_subject_idx").on(table.gameId, table.grade, table.subject).where(sql`${table.status} IS DISTINCT FROM 'deactivated' AND ${table.kind} = 'base'`),
20805
+ uniqueIndex("game_timeback_integrations_hole_filling_course_idx").on(table.gameId, table.courseId).where(sql`${table.kind} = 'hole_filling'`),
20806
+ index("game_timeback_integrations_base_integration_idx").on(table.baseIntegrationId),
20807
+ foreignKey({
20808
+ name: "game_timeback_integrations_base_integration_id_fk",
20809
+ columns: [table.baseIntegrationId],
20810
+ foreignColumns: [table.id]
20811
+ }),
20812
+ check("game_timeback_integrations_kind_base_check", sql`(
20813
+ (${table.kind}::text = 'base' AND ${table.baseIntegrationId} IS NULL)
20814
+ OR
20815
+ (${table.kind}::text = 'hole_filling' AND ${table.baseIntegrationId} IS NOT NULL)
20816
+ )`)
20777
20817
  ]);
20778
20818
  gameTimebackAssessmentTests = pgTable("game_timeback_assessment_tests", {
20779
20819
  id: uuid("id").primaryKey().defaultRandom(),
@@ -20855,7 +20895,7 @@ var init_table7 = __esm(() => {
20855
20895
  });
20856
20896
 
20857
20897
  // ../data/src/domains/embed/table.ts
20858
- var embedPartners, embedPartnerGames, embedLaunches, embedPartnersRelations, embedPartnerGamesRelations, embedLaunchesRelations;
20898
+ var embedPartners, embedLaunches, embedLaunchesRelations;
20859
20899
  var init_table8 = __esm(() => {
20860
20900
  init_drizzle_orm();
20861
20901
  init_pg_core();
@@ -20870,15 +20910,6 @@ var init_table8 = __esm(() => {
20870
20910
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
20871
20911
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
20872
20912
  });
20873
- embedPartnerGames = pgTable("embed_partner_games", {
20874
- id: uuid("id").primaryKey().defaultRandom(),
20875
- partnerId: uuid("partner_id").notNull().references(() => embedPartners.id, { onDelete: "cascade" }),
20876
- gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
20877
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
20878
- }, (table) => [
20879
- uniqueIndex("embed_partner_games_partner_game_idx").on(table.partnerId, table.gameId),
20880
- index("embed_partner_games_game_idx").on(table.gameId)
20881
- ]);
20882
20913
  embedLaunches = pgTable("embed_launches", {
20883
20914
  id: uuid("id").primaryKey().defaultRandom(),
20884
20915
  partnerId: uuid("partner_id").notNull().references(() => embedPartners.id, { onDelete: "restrict" }),
@@ -20897,19 +20928,6 @@ var init_table8 = __esm(() => {
20897
20928
  index("embed_launches_user_idx").on(table.userId),
20898
20929
  index("embed_launches_game_idx").on(table.gameId)
20899
20930
  ]);
20900
- embedPartnersRelations = relations(embedPartners, ({ many }) => ({
20901
- games: many(embedPartnerGames)
20902
- }));
20903
- embedPartnerGamesRelations = relations(embedPartnerGames, ({ one }) => ({
20904
- partner: one(embedPartners, {
20905
- fields: [embedPartnerGames.partnerId],
20906
- references: [embedPartners.id]
20907
- }),
20908
- game: one(games, {
20909
- fields: [embedPartnerGames.gameId],
20910
- references: [games.id]
20911
- })
20912
- }));
20913
20931
  embedLaunchesRelations = relations(embedLaunches, ({ one }) => ({
20914
20932
  partner: one(embedPartners, {
20915
20933
  fields: [embedLaunches.partnerId],
@@ -20940,10 +20958,7 @@ __export(exports_tables_index, {
20940
20958
  developerStatusEnum: () => developerStatusEnum,
20941
20959
  embedLaunches: () => embedLaunches,
20942
20960
  embedLaunchesRelations: () => embedLaunchesRelations,
20943
- embedPartnerGames: () => embedPartnerGames,
20944
- embedPartnerGamesRelations: () => embedPartnerGamesRelations,
20945
20961
  embedPartners: () => embedPartners,
20946
- embedPartnersRelations: () => embedPartnersRelations,
20947
20962
  gameCustomHostnames: () => gameCustomHostnames,
20948
20963
  gameDashboardUserRoleEnum: () => gameDashboardUserRoleEnum,
20949
20964
  gameDashboardUsers: () => gameDashboardUsers,
@@ -20962,6 +20977,7 @@ __export(exports_tables_index, {
20962
20977
  gameTimebackAssessmentPurposeEnum: () => gameTimebackAssessmentPurposeEnum,
20963
20978
  gameTimebackAssessmentStatusEnum: () => gameTimebackAssessmentStatusEnum,
20964
20979
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
20980
+ gameTimebackIntegrationKindEnum: () => gameTimebackIntegrationKindEnum,
20965
20981
  gameTimebackIntegrationStatusEnum: () => gameTimebackIntegrationStatusEnum,
20966
20982
  gameTimebackIntegrations: () => gameTimebackIntegrations,
20967
20983
  gameTimebackMetricDiscrepancyVerifications: () => gameTimebackMetricDiscrepancyVerifications,
@@ -22869,10 +22885,19 @@ var init_helpers = __esm(() => {
22869
22885
  });
22870
22886
 
22871
22887
  // ../data/src/domains/timeback/helpers.ts
22888
+ function isBaseGameTimebackIntegration() {
22889
+ return sql`${gameTimebackIntegrations.kind} = ${BASE_GAME_TIMEBACK_INTEGRATION_KIND}`;
22890
+ }
22891
+ function isBaseGameTimebackIntegrationRow(integration) {
22892
+ return integration.kind === BASE_GAME_TIMEBACK_INTEGRATION_KIND;
22893
+ }
22872
22894
  function isActiveGameTimebackIntegrationStatus(statusColumn = gameTimebackIntegrations.status) {
22873
22895
  return sql`${statusColumn} IS DISTINCT FROM ${DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS}`;
22874
22896
  }
22875
- var ACTIVE_GAME_TIMEBACK_INTEGRATION_STATUS = "active", DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS = "deactivated";
22897
+ function isActiveBaseGameTimebackIntegration() {
22898
+ return sql`(${isActiveGameTimebackIntegrationStatus()} AND ${isBaseGameTimebackIntegration()})`;
22899
+ }
22900
+ var ACTIVE_GAME_TIMEBACK_INTEGRATION_STATUS = "active", DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS = "deactivated", BASE_GAME_TIMEBACK_INTEGRATION_KIND = "base", HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND = "hole_filling";
22876
22901
  var init_helpers2 = __esm(() => {
22877
22902
  init_drizzle_orm();
22878
22903
  init_table7();
@@ -39449,12 +39474,12 @@ function resolveIntegrationSubject(patchSubject, liveSubject, storedSubject) {
39449
39474
  function filterEnrollmentsByGame(enrollments, gameId) {
39450
39475
  return enrollments.filter((enrollment) => enrollment.gameId === gameId).map(({ gameId: _, ...enrollment }) => enrollment);
39451
39476
  }
39452
- function mapEnrollmentsToUserEnrollments(enrollments, integrations) {
39477
+ function mapEnrollmentsToUserEnrollments(enrollments, integrations, baseCourseIdByIntegrationId = new Map) {
39453
39478
  const enrollmentByCourse = new Map(enrollments.map((enrollment) => [enrollment.courseId, enrollment]));
39454
39479
  const courseToSchool = new Map(enrollments.filter((enrollment) => enrollment.school?.id).map((enrollment) => [enrollment.courseId, enrollment.school.id]));
39455
39480
  return integrations.map((integration) => {
39456
39481
  const enrollment = enrollmentByCourse.get(integration.courseId);
39457
- return {
39482
+ const userEnrollment = {
39458
39483
  gameId: integration.gameId,
39459
39484
  grade: integration.grade,
39460
39485
  subject: integration.subject,
@@ -39462,6 +39487,17 @@ function mapEnrollmentsToUserEnrollments(enrollments, integrations) {
39462
39487
  orgId: courseToSchool.get(integration.courseId),
39463
39488
  ...enrollment ? { id: enrollment.sourcedId } : {}
39464
39489
  };
39490
+ if (integration.kind !== HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND) {
39491
+ return userEnrollment;
39492
+ }
39493
+ const baseCourseId = integration.baseIntegrationId ? baseCourseIdByIntegrationId.get(integration.baseIntegrationId) : undefined;
39494
+ return {
39495
+ ...userEnrollment,
39496
+ kind: HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND,
39497
+ ...baseCourseId && { baseCourseId },
39498
+ ...enrollment?.beginDate !== undefined && { beginDate: enrollment.beginDate },
39499
+ ...enrollment?.metadata && { enrollmentMetadata: enrollment.metadata }
39500
+ };
39465
39501
  });
39466
39502
  }
39467
39503
  function buildGameTimebackSummaries(integrations) {
@@ -39944,7 +39980,7 @@ var init_game_service = __esm(() => {
39944
39980
  const db = this.deps.db;
39945
39981
  const integrations = await db.query.gameTimebackIntegrations.findMany({
39946
39982
  columns: { gameId: true, subject: true },
39947
- where: isActiveGameTimebackIntegrationStatus(),
39983
+ where: isActiveBaseGameTimebackIntegration(),
39948
39984
  orderBy: [asc(gameTimebackIntegrations.createdAt)]
39949
39985
  });
39950
39986
  const subjectMap = {};
@@ -39968,9 +40004,7 @@ var init_game_service = __esm(() => {
39968
40004
  }
39969
40005
  const integrations = await db.query.gameTimebackIntegrations.findMany({
39970
40006
  columns: { gameId: true, subject: true, status: true },
39971
- ...accessibleGameIds && {
39972
- where: inArray(gameTimebackIntegrations.gameId, [...accessibleGameIds])
39973
- },
40007
+ where: and(accessibleGameIds ? inArray(gameTimebackIntegrations.gameId, [...accessibleGameIds]) : undefined, isBaseGameTimebackIntegration()),
39974
40008
  orderBy: [asc(gameTimebackIntegrations.createdAt)]
39975
40009
  });
39976
40010
  return buildGameTimebackSummaries(integrations);
@@ -50401,7 +50435,7 @@ function rejectSystemManagedReview(input, context) {
50401
50435
  });
50402
50436
  }
50403
50437
  }
50404
- 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;
50438
+ var TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, CreateGameTimebackIntegrationRequestSchema, RESERVED_TIMEBACK_COURSE_SOURCED_IDS, TimebackCourseSourcedIdSchema, CreateGameTimebackHoleFillingCourseRequestSchema, 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;
50405
50439
  var init_schemas4 = __esm(() => {
50406
50440
  init_esm();
50407
50441
  init_src();
@@ -50437,6 +50471,26 @@ var init_schemas4 = __esm(() => {
50437
50471
  masterableUnits: numberType().int().nonnegative(),
50438
50472
  level: stringType().trim().min(1).optional()
50439
50473
  });
50474
+ RESERVED_TIMEBACK_COURSE_SOURCED_IDS = new Set([
50475
+ "hole-filling",
50476
+ "removed",
50477
+ "components",
50478
+ "component-resources",
50479
+ "structure"
50480
+ ]);
50481
+ TimebackCourseSourcedIdSchema = stringType().trim().min(3).max(100).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, {
50482
+ message: "Use letters, numbers, dots, dashes, or underscores, starting with a letter or number"
50483
+ }).refine((value) => !RESERVED_TIMEBACK_COURSE_SOURCED_IDS.has(value.toLowerCase()), {
50484
+ message: "This course ID is reserved"
50485
+ });
50486
+ CreateGameTimebackHoleFillingCourseRequestSchema = objectType({
50487
+ baseCourseId: stringType().trim().min(1),
50488
+ title: stringType().trim().min(1),
50489
+ courseCode: stringType().trim().min(1),
50490
+ totalXp: numberType().int().positive(),
50491
+ masterableUnits: numberType().int().nonnegative(),
50492
+ courseId: TimebackCourseSourcedIdSchema.optional()
50493
+ });
50440
50494
  TimebackActivityDataSchema = objectType({
50441
50495
  activityId: stringType().min(1),
50442
50496
  activityName: stringType().optional(),
@@ -50445,6 +50499,7 @@ var init_schemas4 = __esm(() => {
50445
50499
  appName: stringType().optional(),
50446
50500
  sensorUrl: stringType().url().optional(),
50447
50501
  courseId: stringType().min(1).optional(),
50502
+ enrollmentId: stringType().min(1).optional(),
50448
50503
  courseName: stringType().optional(),
50449
50504
  studentEmail: stringType().email().optional()
50450
50505
  });
@@ -50768,21 +50823,20 @@ function isCanonicalOrigin(value) {
50768
50823
  }
50769
50824
  return url.protocol === "https:" || url.protocol === "http:" && isLocalHostname(url.hostname);
50770
50825
  }
50771
- var LOCAL_HOSTNAMES, EmbedOriginSchema, CreateEmbedPartnerSchema, UpdateEmbedPartnerSchema, CreateEmbedLaunchSchema, RedeemEmbedPassSchema;
50826
+ var LOCAL_HOSTNAMES, EmbedOriginSchema, EmbedPartnerClientIdSchema, EnsureEmbedPartnerSchema, AddEmbedPartnerOriginSchema, CreateEmbedLaunchSchema, RedeemEmbedPassSchema;
50772
50827
  var init_schemas5 = __esm(() => {
50773
50828
  init_esm();
50774
50829
  LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1"]);
50775
50830
  EmbedOriginSchema = stringType().refine(isCanonicalOrigin, {
50776
50831
  message: "Origin must be a bare https origin like https://example.com, with no path or slash"
50777
50832
  });
50778
- CreateEmbedPartnerSchema = objectType({
50779
- name: stringType().trim().min(1).max(100),
50780
- clientId: stringType().trim().min(1).max(200),
50781
- allowedOrigins: arrayType(EmbedOriginSchema).max(20).default([]),
50782
- allowedGameIds: arrayType(stringType().uuid().transform((id) => id.toLowerCase())).max(200).default([]),
50783
- enabled: booleanType().default(true)
50833
+ EmbedPartnerClientIdSchema = stringType().trim().min(1).max(200);
50834
+ EnsureEmbedPartnerSchema = objectType({
50835
+ name: stringType().trim().min(1).max(100)
50836
+ }).strict();
50837
+ AddEmbedPartnerOriginSchema = objectType({
50838
+ origin: EmbedOriginSchema
50784
50839
  }).strict();
50785
- UpdateEmbedPartnerSchema = CreateEmbedPartnerSchema.omit({ clientId: true }).partial();
50786
50840
  CreateEmbedLaunchSchema = objectType({
50787
50841
  timebackId: stringType().trim().min(1).max(200),
50788
50842
  gameSlug: stringType().trim().min(1).max(100),
@@ -77940,6 +77994,7 @@ class CourseAssessments {
77940
77994
  }
77941
77995
  function buildCoursePayload(config) {
77942
77996
  return {
77997
+ ...config.course.sourcedId && { sourcedId: config.course.sourcedId },
77943
77998
  status: "active",
77944
77999
  title: config.course.title,
77945
78000
  courseCode: config.course.courseCode,
@@ -77986,6 +78041,16 @@ function buildComponentResourcePayload(config, sourcedIds) {
77986
78041
  lessonType: config.componentResource.lessonType
77987
78042
  };
77988
78043
  }
78044
+ async function findOrUndefined(request) {
78045
+ try {
78046
+ return await request;
78047
+ } catch (error) {
78048
+ if (isApiError(error) && error.statusCode === 404) {
78049
+ return;
78050
+ }
78051
+ throw error;
78052
+ }
78053
+ }
77989
78054
 
77990
78055
  class CourseIntegration {
77991
78056
  core;
@@ -78024,6 +78089,32 @@ class CourseIntegration {
78024
78089
  ...verboseData && { verboseData }
78025
78090
  };
78026
78091
  }
78092
+ async findCourse(courseId) {
78093
+ return findOrUndefined(this.core.api.oneroster.courses.get(courseId));
78094
+ }
78095
+ async resumeSetup(courseId, config) {
78096
+ const oneroster = this.core.api.oneroster;
78097
+ const sourcedIds = deriveSourcedIds(courseId);
78098
+ if (!await findOrUndefined(oneroster.courses.getComponent(sourcedIds.component))) {
78099
+ await oneroster.courses.createComponent(buildComponentPayload(config, sourcedIds));
78100
+ addEvent("timeback.component_created", {
78101
+ "app.timeback.component_id": sourcedIds.component
78102
+ });
78103
+ }
78104
+ if (!await findOrUndefined(oneroster.resources.get(sourcedIds.resource))) {
78105
+ await oneroster.resources.create(buildResourcePayload(config, sourcedIds.resource));
78106
+ addEvent("timeback.resource_created", {
78107
+ "app.timeback.resource_id": sourcedIds.resource
78108
+ });
78109
+ }
78110
+ if (!await findOrUndefined(oneroster.courses.getComponentResource(sourcedIds.componentResource))) {
78111
+ await oneroster.courses.createComponentResource(buildComponentResourcePayload(config, sourcedIds));
78112
+ addEvent("timeback.component_resource_link_created", {
78113
+ "app.timeback.component_resource_id": sourcedIds.componentResource
78114
+ });
78115
+ }
78116
+ await this.update(courseId, config);
78117
+ }
78027
78118
  async update(courseId, config) {
78028
78119
  const oneroster = this.core.api.oneroster;
78029
78120
  const sourcedIds = deriveSourcedIds(courseId);
@@ -78163,6 +78254,8 @@ function createCourseNamespace(core) {
78163
78254
  update: (courseId, config) => integration.update(courseId, config),
78164
78255
  verify: (courseId) => integration.verify(courseId),
78165
78256
  getConfig: (courseId) => integration.getConfig(courseId),
78257
+ findCourse: (courseId) => integration.findCourse(courseId),
78258
+ resumeSetup: (courseId, config) => integration.resumeSetup(courseId, config),
78166
78259
  cleanup: (courseId) => integration.cleanup(courseId),
78167
78260
  deactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "tobedeleted"),
78168
78261
  reactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "active"),
@@ -78247,7 +78340,9 @@ class RosterEnrollments {
78247
78340
  status: "active",
78248
78341
  grades,
78249
78342
  subjects,
78250
- school: enrollment.school
78343
+ school: enrollment.school,
78344
+ beginDate: enrollment.beginDate,
78345
+ ...enrollment.metadata && { metadata: enrollment.metadata }
78251
78346
  };
78252
78347
  });
78253
78348
  this.core.caches.enrollments.set(studentId, enrollments);
@@ -89051,7 +89146,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
89051
89146
  }) {
89052
89147
  await this.deps.validateDeveloperAccess(user, gameId);
89053
89148
  const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
89054
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
89149
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration())
89055
89150
  });
89056
89151
  const matchingIntegrations = new Map;
89057
89152
  for (const integration of integrations) {
@@ -89440,7 +89535,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
89440
89535
  }) {
89441
89536
  await this.deps.validateDeveloperAccess(user, gameId);
89442
89537
  const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
89443
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
89538
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration())
89444
89539
  });
89445
89540
  const orderedIntegrations = integrations.toSorted((left, right) => {
89446
89541
  const subjectDifference = left.subject.localeCompare(right.subject);
@@ -89519,7 +89614,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
89519
89614
  async candidateIntegrations(gameId, studentId, input, db = this.deps.db) {
89520
89615
  const filters = [
89521
89616
  eq(gameTimebackIntegrations.gameId, gameId),
89522
- isActiveGameTimebackIntegrationStatus()
89617
+ isActiveBaseGameTimebackIntegration()
89523
89618
  ];
89524
89619
  if (input.subject) {
89525
89620
  filters.push(eq(gameTimebackIntegrations.subject, input.subject));
@@ -92419,6 +92514,22 @@ var init_timeback_assessments_service = __esm(async () => {
92419
92514
  await init_errors8();
92420
92515
  });
92421
92516
 
92517
+ // ../api-core/src/utils/timeback-attribution.util.ts
92518
+ function resolveRuntimeAttribution(base, holeFilling, requestedCourseId, recordedCourseId) {
92519
+ const recorded = [base, ...holeFilling].find((integration) => integration.courseId === recordedCourseId);
92520
+ if (recorded) {
92521
+ return { integration: recorded, requestedCourseIdStatus: "pinned_by_run" };
92522
+ }
92523
+ if (requestedCourseId === undefined) {
92524
+ return { integration: base, requestedCourseIdStatus: "absent" };
92525
+ }
92526
+ if (requestedCourseId === base.courseId) {
92527
+ return { integration: base, requestedCourseIdStatus: "accepted" };
92528
+ }
92529
+ const requestedHoleFilling = holeFilling.find((integration) => integration.courseId === requestedCourseId);
92530
+ return requestedHoleFilling ? { integration: requestedHoleFilling, requestedCourseIdStatus: "accepted_hole_filling" } : { integration: base, requestedCourseIdStatus: "ignored_mismatch" };
92531
+ }
92532
+
92422
92533
  // ../api-core/src/utils/timeback-create-integration.util.ts
92423
92534
  function buildTimebackBaseConfigFromExistingConfig(config) {
92424
92535
  return {
@@ -92461,6 +92572,50 @@ function timebackConfigMatchesCreateIntegrationRequest(config, request) {
92461
92572
  const requestedLevel = request.level ?? deriveTimebackCourseLevelFromGrade(request.grade);
92462
92573
  return subject === request.subject && grade === request.grade && config.course.title === request.title && config.course.courseCode === request.courseCode && config.course.level === requestedLevel && (getTotalXpFromTimebackConfig(config) ?? null) === request.totalXp && (getMasterableUnitsFromTimebackConfig(config) ?? null) === request.masterableUnits;
92463
92574
  }
92575
+ function buildHoleFillingCourseConfig(baseConfig, request) {
92576
+ const baseCourseMetadata = isRecord4(baseConfig.course.metadata) ? baseConfig.course.metadata : {};
92577
+ const baseMetrics = isRecord4(baseCourseMetadata.metrics) ? baseCourseMetadata.metrics : {};
92578
+ const baseResourceMetadata = isRecord4(baseConfig.resource.metadata) ? baseConfig.resource.metadata : {};
92579
+ const basePlaycademy = isPlaycademyResourceMetadata(baseResourceMetadata.playcademy) ? baseResourceMetadata.playcademy : undefined;
92580
+ return {
92581
+ organization: baseConfig.organization,
92582
+ course: {
92583
+ ...baseConfig.course,
92584
+ ...request.courseId && { sourcedId: request.courseId },
92585
+ title: request.title,
92586
+ courseCode: request.courseCode,
92587
+ metadata: {
92588
+ ...baseCourseMetadata,
92589
+ courseType: HOLE_FILLING_COURSE_TYPE,
92590
+ baseCourseSourcedId: request.baseCourseId,
92591
+ metrics: {
92592
+ ...baseMetrics,
92593
+ totalXp: request.totalXp,
92594
+ totalLessons: request.masterableUnits,
92595
+ totalGrades: 1
92596
+ }
92597
+ }
92598
+ },
92599
+ component: { ...baseConfig.component, title: `${request.title} Activities` },
92600
+ resource: {
92601
+ ...baseConfig.resource,
92602
+ title: `${request.title} Game`,
92603
+ metadata: {
92604
+ ...baseResourceMetadata,
92605
+ xp: request.totalXp,
92606
+ playcademy: {
92607
+ ...basePlaycademy,
92608
+ mastery: {
92609
+ ...basePlaycademy?.mastery,
92610
+ masterableUnits: request.masterableUnits
92611
+ }
92612
+ }
92613
+ }
92614
+ },
92615
+ componentResource: { ...baseConfig.componentResource, title: `${request.title} Lesson` }
92616
+ };
92617
+ }
92618
+ var HOLE_FILLING_COURSE_TYPE = "hole-filling";
92464
92619
  var init_timeback_create_integration_util = __esm(() => {
92465
92620
  init_constants2();
92466
92621
  init_types2();
@@ -92476,7 +92631,7 @@ async function promoteCompletedCourse({
92476
92631
  enrollments: prefetchedEnrollments
92477
92632
  }) {
92478
92633
  const subjectIntegrations = await db.query.gameTimebackIntegrations.findMany({
92479
- where: and(eq(gameTimebackIntegrations.gameId, currentIntegration.gameId), eq(gameTimebackIntegrations.subject, currentIntegration.subject), isActiveGameTimebackIntegrationStatus())
92634
+ where: and(eq(gameTimebackIntegrations.gameId, currentIntegration.gameId), eq(gameTimebackIntegrations.subject, currentIntegration.subject), isActiveBaseGameTimebackIntegration())
92480
92635
  });
92481
92636
  const nextIntegration = subjectIntegrations.filter((integration) => integration.grade > currentIntegration.grade).toSorted((left, right) => left.grade - right.grade)[0];
92482
92637
  if (!nextIntegration) {
@@ -92543,6 +92698,8 @@ function toGameTimebackIntegration(integration) {
92543
92698
  courseId: integration.courseId,
92544
92699
  grade: integration.grade,
92545
92700
  subject: integration.subject,
92701
+ kind: integration.kind,
92702
+ baseIntegrationId: integration.baseIntegrationId,
92546
92703
  totalXp: integration.totalXp ?? null,
92547
92704
  status: integration.status ?? ACTIVE_GAME_TIMEBACK_INTEGRATION_STATUS,
92548
92705
  deactivatedAt: integration.deactivatedAt ?? null,
@@ -92580,6 +92737,9 @@ var init_timeback_removed_integration_util = __esm(() => {
92580
92737
 
92581
92738
  // ../api-core/src/services/timeback.service.ts
92582
92739
  import { createHash as createHash3 } from "node:crypto";
92740
+ function isUnregisteredHoleFillingOf(course, base) {
92741
+ return course.metadata?.courseType === HOLE_FILLING_COURSE_TYPE && course.metadata.baseCourseSourcedId === base.courseId;
92742
+ }
92583
92743
  async function findGameTimebackIntegrationForUpdate(db, condition) {
92584
92744
  const [integration] = await db.select().from(gameTimebackIntegrations).where(condition).limit(1).for("update");
92585
92745
  return integration;
@@ -92629,13 +92789,13 @@ var init_timeback_service = __esm(async () => {
92629
92789
  static clearInFlightHeartbeatWindow(key) {
92630
92790
  this.inFlightHeartbeatWindows.delete(key);
92631
92791
  }
92632
- static buildCompletionCaliperEventId(key, kind, discriminator = "completion") {
92792
+ static buildCompletionCaliperEventId(key, kind, baseCourseId, discriminator = "completion") {
92633
92793
  const hash = createHash3("sha256").update([
92634
92794
  "playcademy",
92635
92795
  "timeback",
92636
92796
  kind,
92637
92797
  key.gameId,
92638
- key.courseId,
92798
+ baseCourseId,
92639
92799
  key.studentId,
92640
92800
  key.runId,
92641
92801
  key.activityId,
@@ -92701,11 +92861,14 @@ var init_timeback_service = __esm(async () => {
92701
92861
  } : null
92702
92862
  };
92703
92863
  }
92704
- static async acquireCompletionReservation(db, key) {
92705
- let reserveOutcome = await this.reserveCompletion(db, key);
92864
+ static async acquireCompletionReservation(db, key, otherCourseIds = []) {
92865
+ function reserve() {
92866
+ return otherCourseIds.length > 0 ? TimebackService.reserveCompletionAcrossCourses(db, key, otherCourseIds) : TimebackService.reserveCompletion(db, key);
92867
+ }
92868
+ let reserveOutcome = await reserve();
92706
92869
  for (let settleAttempt = 0;reserveOutcome.status === "in_flight" && settleAttempt < this.COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS; settleAttempt++) {
92707
92870
  await sleep(this.COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS);
92708
- reserveOutcome = await this.reserveCompletion(db, key);
92871
+ reserveOutcome = await reserve();
92709
92872
  }
92710
92873
  if (reserveOutcome.status === "duplicate") {
92711
92874
  setAttribute("app.timeback.end_activity_status", "blocked_by_dedupe_guard");
@@ -92724,8 +92887,24 @@ var init_timeback_service = __esm(async () => {
92724
92887
  }
92725
92888
  return { status: "reserved", token: reserveOutcome.token };
92726
92889
  }
92727
- static async runWithCompletionReservation(db, key, emit, awardFromResult) {
92728
- const acquired = await this.acquireCompletionReservation(db, key);
92890
+ static async reserveCompletionAcrossCourses(db, key, otherCourseIds) {
92891
+ return db.transaction(async (tx) => {
92892
+ const database = tx;
92893
+ const runKey = `timeback-completion:${key.gameId.toLowerCase()}:${key.studentId}:${key.runId.toLowerCase()}:${key.activityId}`;
92894
+ await database.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${runKey}, 0))`);
92895
+ const onOtherCourse = await database.query.gameTimebackActivityCompletions.findFirst({
92896
+ columns: { id: true },
92897
+ where: and(eq(gameTimebackActivityCompletions.gameId, key.gameId), eq(gameTimebackActivityCompletions.studentId, key.studentId), eq(gameTimebackActivityCompletions.runId, key.runId), eq(gameTimebackActivityCompletions.activityId, key.activityId), inArray(gameTimebackActivityCompletions.courseId, [...otherCourseIds]))
92898
+ });
92899
+ if (onOtherCourse) {
92900
+ setAttribute("app.timeback.end_activity_status", "completion_reserved_on_other_course");
92901
+ throw new ServiceUnavailableError("An end-activity submission for this run is already in flight — retry shortly");
92902
+ }
92903
+ return this.reserveCompletion(database, key);
92904
+ });
92905
+ }
92906
+ static async runWithCompletionReservation(db, key, emit, awardFromResult, otherCourseIds = []) {
92907
+ const acquired = await this.acquireCompletionReservation(db, key, otherCourseIds);
92729
92908
  if (acquired.status === "duplicate") {
92730
92909
  return acquired;
92731
92910
  }
@@ -92760,6 +92939,9 @@ var init_timeback_service = __esm(async () => {
92760
92939
  await db.delete(gameTimebackActivityCompletions).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
92761
92940
  }
92762
92941
  static addResumeIdToExtensions(extensions, resumeId) {
92942
+ return TimebackService.addPlaycademyExtensions(extensions, { resumeId });
92943
+ }
92944
+ static addPlaycademyExtensions(extensions, fields) {
92763
92945
  const base = extensions ?? {};
92764
92946
  const existingPlaycademy = base.playcademy;
92765
92947
  const playcademy = typeof existingPlaycademy === "object" && existingPlaycademy !== null && !Array.isArray(existingPlaycademy) ? existingPlaycademy : {};
@@ -92767,28 +92949,71 @@ var init_timeback_service = __esm(async () => {
92767
92949
  ...base,
92768
92950
  playcademy: {
92769
92951
  ...playcademy,
92770
- resumeId
92952
+ ...fields
92771
92953
  }
92772
92954
  };
92773
92955
  }
92774
- static resolveRuntimeCourse(resolvedCourseId, requestedCourseId, courseName) {
92775
- let requestedCourseIdStatus = "absent";
92776
- if (requestedCourseId === resolvedCourseId) {
92777
- requestedCourseIdStatus = "accepted";
92778
- } else if (requestedCourseId !== undefined) {
92779
- requestedCourseIdStatus = "ignored_mismatch";
92956
+ async resolveRuntimeCourse({
92957
+ gameId,
92958
+ studentId,
92959
+ runId,
92960
+ activityData,
92961
+ extensions
92962
+ }) {
92963
+ const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
92964
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveGameTimebackIntegrationStatus())
92965
+ });
92966
+ const base = integrations.find(isBaseGameTimebackIntegrationRow);
92967
+ if (!base) {
92968
+ throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
92780
92969
  }
92970
+ const holeFilling = integrations.filter((integration) => !isBaseGameTimebackIntegrationRow(integration));
92971
+ const recordedCourseId = holeFilling.length > 0 && runId ? await this.findRecordedRunCourse({
92972
+ gameId,
92973
+ studentId,
92974
+ runId,
92975
+ activityId: activityData.activityId,
92976
+ courseIds: integrations.map((i2) => i2.courseId)
92977
+ }) : undefined;
92978
+ const { integration, requestedCourseIdStatus } = resolveRuntimeAttribution(base, holeFilling, activityData.courseId, recordedCourseId);
92979
+ const hintUsed = activityData.courseId === integration.courseId;
92980
+ const enrollmentId = hintUsed ? activityData.enrollmentId : undefined;
92981
+ const isHoleFilling = !isBaseGameTimebackIntegrationRow(integration);
92781
92982
  return {
92782
- courseId: resolvedCourseId,
92783
- courseName: requestedCourseIdStatus === "ignored_mismatch" ? undefined : courseName,
92983
+ integration,
92984
+ courseId: integration.courseId,
92985
+ baseCourseId: base.courseId,
92986
+ otherCourseIds: holeFilling.length > 0 ? integrations.map((other) => other.courseId).filter((courseId) => courseId !== integration.courseId) : [],
92987
+ courseName: activityData.courseId === undefined || hintUsed ? activityData.courseName : undefined,
92988
+ extensions: isHoleFilling ? TimebackService.addPlaycademyExtensions(extensions, {
92989
+ courseKind: HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND,
92990
+ baseCourseId: base.courseId,
92991
+ ...enrollmentId && { enrollmentId }
92992
+ }) : extensions,
92784
92993
  attributes: {
92785
- "app.timeback.course_id": resolvedCourseId,
92786
- "app.timeback.resolved_course_id": resolvedCourseId,
92787
- "app.timeback.requested_course_id": requestedCourseId,
92788
- "app.timeback.requested_course_id_status": requestedCourseIdStatus
92994
+ "app.timeback.course_id": integration.courseId,
92995
+ "app.timeback.resolved_course_id": integration.courseId,
92996
+ "app.timeback.requested_course_id": activityData.courseId,
92997
+ "app.timeback.requested_course_id_status": requestedCourseIdStatus,
92998
+ "app.timeback.course_kind": integration.kind,
92999
+ "app.timeback.enrollment_id": enrollmentId
92789
93000
  }
92790
93001
  };
92791
93002
  }
93003
+ async findRecordedRunCourse({
93004
+ gameId,
93005
+ studentId,
93006
+ runId,
93007
+ activityId,
93008
+ courseIds
93009
+ }) {
93010
+ const recorded = await this.deps.db.query.gameTimebackActivityCompletions.findFirst({
93011
+ columns: { courseId: true },
93012
+ where: and(eq(gameTimebackActivityCompletions.gameId, gameId), eq(gameTimebackActivityCompletions.studentId, studentId), eq(gameTimebackActivityCompletions.runId, runId), eq(gameTimebackActivityCompletions.activityId, activityId), inArray(gameTimebackActivityCompletions.courseId, courseIds)),
93013
+ orderBy: (table, { asc }) => [asc(table.createdAt)]
93014
+ });
93015
+ return recorded?.courseId;
93016
+ }
92792
93017
  static recordRuntimeContext({
92793
93018
  operation,
92794
93019
  studentId,
@@ -92986,13 +93211,29 @@ var init_timeback_service = __esm(async () => {
92986
93211
  const integrations = await db.query.gameTimebackIntegrations.findMany({
92987
93212
  where: and(inArray(gameTimebackIntegrations.courseId, courseIds), isActiveGameTimebackIntegrationStatus())
92988
93213
  });
92989
- return mapEnrollmentsToUserEnrollments(enrollments, integrations);
93214
+ return mapEnrollmentsToUserEnrollments(enrollments, integrations, await this.loadBaseCourseIds(integrations));
92990
93215
  } catch (error) {
92991
93216
  setAttribute("app.timeback.enrollment_fetch", "failed");
92992
93217
  setAttribute("app.timeback.enrollment_fetch_error", errorMessage(error));
92993
93218
  return [];
92994
93219
  }
92995
93220
  }
93221
+ async loadBaseCourseIds(integrations) {
93222
+ const courseIdById = new Map(integrations.map((row) => [row.id, row.courseId]));
93223
+ const missingBaseIds = [
93224
+ ...new Set(integrations.map((row) => row.baseIntegrationId).filter((id) => id !== null && !courseIdById.has(id)))
93225
+ ];
93226
+ if (missingBaseIds.length > 0) {
93227
+ const baseRows = await this.deps.db.query.gameTimebackIntegrations.findMany({
93228
+ columns: { id: true, courseId: true },
93229
+ where: inArray(gameTimebackIntegrations.id, missingBaseIds)
93230
+ });
93231
+ for (const row of baseRows) {
93232
+ courseIdById.set(row.id, row.courseId);
93233
+ }
93234
+ }
93235
+ return courseIdById;
93236
+ }
92996
93237
  async setupIntegration(gameId, request, user) {
92997
93238
  return this.withClientTelemetry(async () => {
92998
93239
  const client = this.requireClient();
@@ -93003,7 +93244,7 @@ var init_timeback_service = __esm(async () => {
93003
93244
  "app.timeback.course_count": courses.length
93004
93245
  });
93005
93246
  const existing = await db.query.gameTimebackIntegrations.findMany({
93006
- where: eq(gameTimebackIntegrations.gameId, gameId)
93247
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isBaseGameTimebackIntegration())
93007
93248
  });
93008
93249
  const integrations = [];
93009
93250
  const verboseData = [];
@@ -93151,13 +93392,13 @@ var init_timeback_service = __esm(async () => {
93151
93392
  "app.timeback.subject": request.subject
93152
93393
  });
93153
93394
  const activeConflict = await db.query.gameTimebackIntegrations.findFirst({
93154
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, request.grade), eq(gameTimebackIntegrations.subject, request.subject), isActiveGameTimebackIntegrationStatus())
93395
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, request.grade), eq(gameTimebackIntegrations.subject, request.subject), isActiveBaseGameTimebackIntegration())
93155
93396
  });
93156
93397
  if (activeConflict) {
93157
93398
  throw new ConflictError(`A TimeBack integration already exists for ${request.subject} Grade ${request.grade}`);
93158
93399
  }
93159
93400
  const removedCandidates = await db.query.gameTimebackIntegrations.findMany({
93160
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, request.grade), eq(gameTimebackIntegrations.subject, request.subject), eq(gameTimebackIntegrations.status, DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS))
93401
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, request.grade), eq(gameTimebackIntegrations.subject, request.subject), eq(gameTimebackIntegrations.status, DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS), isBaseGameTimebackIntegration())
93161
93402
  });
93162
93403
  for (const candidate of removedCandidates) {
93163
93404
  const config = await client.course.getConfig(candidate.courseId).catch((error) => {
@@ -93195,10 +93436,10 @@ var init_timeback_service = __esm(async () => {
93195
93436
  }
93196
93437
  }
93197
93438
  const reference = await db.query.gameTimebackIntegrations.findFirst({
93198
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus()),
93439
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration()),
93199
93440
  orderBy: (table, { asc }) => [asc(table.createdAt)]
93200
93441
  }) ?? await db.query.gameTimebackIntegrations.findFirst({
93201
- where: eq(gameTimebackIntegrations.gameId, gameId),
93442
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isBaseGameTimebackIntegration()),
93202
93443
  orderBy: (table, { asc }) => [asc(table.createdAt)]
93203
93444
  });
93204
93445
  if (!reference) {
@@ -93228,10 +93469,77 @@ var init_timeback_service = __esm(async () => {
93228
93469
  return integration;
93229
93470
  });
93230
93471
  }
93472
+ async createHoleFillingCourse(gameId, user, request) {
93473
+ return this.withClientTelemetry(async () => {
93474
+ const client = this.requireClient();
93475
+ const db = this.deps.db;
93476
+ await this.deps.validateDeveloperAccess(user, gameId);
93477
+ TimebackService.recordIntegrationOperation("create_hole_filling_course", {
93478
+ "app.timeback.base_course_id": request.baseCourseId,
93479
+ "app.timeback.custom_course_id": Boolean(request.courseId)
93480
+ });
93481
+ const integration = await db.transaction(async (tx) => {
93482
+ const database = tx;
93483
+ const base = await findGameTimebackIntegrationForUpdate(database, and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, request.baseCourseId), isActiveBaseGameTimebackIntegration()));
93484
+ if (!base) {
93485
+ throw new NotFoundError("Active base course", `${gameId}:${request.baseCourseId}`);
93486
+ }
93487
+ const config = buildHoleFillingCourseConfig(await client.course.getConfig(base.courseId), request);
93488
+ let courseId = request.courseId;
93489
+ if (courseId) {
93490
+ const takenLocally = await database.query.gameTimebackIntegrations.findFirst({
93491
+ columns: { id: true },
93492
+ where: eq(gameTimebackIntegrations.courseId, courseId)
93493
+ });
93494
+ const remote = takenLocally ? undefined : await client.course.findCourse(courseId);
93495
+ if (takenLocally || remote && !isUnregisteredHoleFillingOf(remote, base)) {
93496
+ throw new ConflictError(`A Timeback course with ID "${courseId}" already exists`);
93497
+ }
93498
+ if (remote) {
93499
+ setAttribute("app.timeback.course_create_mode", "resumed");
93500
+ await client.course.resumeSetup(courseId, config);
93501
+ } else {
93502
+ courseId = undefined;
93503
+ }
93504
+ }
93505
+ if (!courseId) {
93506
+ const created = await client.course.setup(config);
93507
+ courseId = created.courseId;
93508
+ }
93509
+ setAttribute("app.timeback.course_id", courseId);
93510
+ const [inserted] = await database.insert(gameTimebackIntegrations).values({
93511
+ gameId,
93512
+ courseId,
93513
+ grade: base.grade,
93514
+ subject: base.subject,
93515
+ totalXp: request.totalXp,
93516
+ kind: HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND,
93517
+ baseIntegrationId: base.id
93518
+ }).returning();
93519
+ return inserted;
93520
+ });
93521
+ if (!integration) {
93522
+ throw new InternalError("Hole-filling course creation did not return an integration");
93523
+ }
93524
+ return toGameTimebackIntegration(integration);
93525
+ });
93526
+ }
93527
+ async getHoleFillingCourses(gameId, user) {
93528
+ await this.deps.validateGameManagementAccess(user, gameId);
93529
+ const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
93530
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.kind, HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND), isActiveGameTimebackIntegrationStatus()),
93531
+ orderBy: (table, { asc }) => [
93532
+ asc(table.subject),
93533
+ asc(table.grade),
93534
+ asc(table.createdAt)
93535
+ ]
93536
+ });
93537
+ return rows.map((row) => toGameTimebackIntegration(row));
93538
+ }
93231
93539
  async getIntegrations(gameId, user) {
93232
93540
  await this.deps.validateGameManagementAccess(user, gameId);
93233
93541
  const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
93234
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
93542
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration())
93235
93543
  });
93236
93544
  return rows.map((row) => toGameTimebackIntegration(row));
93237
93545
  }
@@ -93240,7 +93548,7 @@ var init_timeback_service = __esm(async () => {
93240
93548
  const client = this.requireClient();
93241
93549
  await this.deps.validateGameManagementAccess(user, gameId);
93242
93550
  const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
93243
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.status, DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS)),
93551
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.status, DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS), isBaseGameTimebackIntegration()),
93244
93552
  orderBy: (table, { asc }) => [asc(table.subject), asc(table.grade)]
93245
93553
  });
93246
93554
  setAttribute("app.timeback.removed_integration_count", rows.length);
@@ -93271,6 +93579,17 @@ var init_timeback_service = __esm(async () => {
93271
93579
  if (!integration) {
93272
93580
  throw new NotFoundError("Timeback course", `${gameId}:${courseId}`);
93273
93581
  }
93582
+ if (integration.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND) {
93583
+ throw new ConflictError("This is a hole-filling course and may be referenced by Timeback, so it can't be removed.");
93584
+ }
93585
+ const activeHoleFilling = await database.query.gameTimebackIntegrations.findFirst({
93586
+ columns: { id: true },
93587
+ where: and(eq(gameTimebackIntegrations.baseIntegrationId, integration.id), isActiveGameTimebackIntegrationStatus())
93588
+ });
93589
+ if (activeHoleFilling) {
93590
+ setAttribute("app.timeback.removal_blocked", "hole_filling_courses");
93591
+ throw new ConflictError("This course has hole-filling courses that may be referenced by Timeback, so it can't be removed.");
93592
+ }
93274
93593
  const activeEnrollments = await client.roster.getCourseRoster(courseId, {
93275
93594
  role: "student",
93276
93595
  includeUsers: false
@@ -93308,11 +93627,18 @@ var init_timeback_service = __esm(async () => {
93308
93627
  if (!integration) {
93309
93628
  throw new NotFoundError("Removed Timeback course", `${gameId}:${courseId}`);
93310
93629
  }
93311
- const activeConflict = await database.query.gameTimebackIntegrations.findFirst({
93312
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, integration.grade), eq(gameTimebackIntegrations.subject, integration.subject), isActiveGameTimebackIntegrationStatus())
93313
- });
93314
- if (activeConflict) {
93315
- throw new ConflictError(`An active TimeBack course already exists for ${integration.subject} Grade ${integration.grade}`);
93630
+ if (integration.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND) {
93631
+ const base = integration.baseIntegrationId ? await findGameTimebackIntegrationForUpdate(database, and(eq(gameTimebackIntegrations.id, integration.baseIntegrationId), isActiveBaseGameTimebackIntegration())) : undefined;
93632
+ if (!base) {
93633
+ throw new ConflictError("Restore the base course first: a hole-filling course can only be active beside its base course.");
93634
+ }
93635
+ } else {
93636
+ const activeConflict = await database.query.gameTimebackIntegrations.findFirst({
93637
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, integration.grade), eq(gameTimebackIntegrations.subject, integration.subject), isActiveBaseGameTimebackIntegration())
93638
+ });
93639
+ if (activeConflict) {
93640
+ throw new ConflictError(`An active TimeBack course already exists for ${integration.subject} Grade ${integration.grade}`);
93641
+ }
93316
93642
  }
93317
93643
  const now = new Date;
93318
93644
  const [updatedIntegration] = await database.update(gameTimebackIntegrations).set({
@@ -93351,52 +93677,66 @@ var init_timeback_service = __esm(async () => {
93351
93677
  TimebackService.recordIntegrationOperation("update_integration", {
93352
93678
  "app.timeback.course_id": courseId
93353
93679
  });
93354
- const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
93355
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId), isActiveGameTimebackIntegrationStatus())
93356
- });
93357
- if (!integration) {
93358
- throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
93359
- }
93360
- const timebackConfig = await client.course.getConfig(courseId);
93361
- const liveSubject = timebackConfig.course.subjects[0];
93362
- const subject = resolveIntegrationSubject(patch.subject, liveSubject, integration.subject);
93363
- if (!isTimebackSubject(subject)) {
93364
- throw new ValidationError(`Invalid subject "${subject}"`);
93365
- }
93366
- setAttributes({
93367
- "app.timeback.grade": integration.grade,
93368
- "app.timeback.previous_subject": integration.subject,
93369
- "app.timeback.next_subject": subject
93370
- });
93371
- if (subject !== integration.subject) {
93372
- const subjectConflict = await this.deps.db.query.gameTimebackIntegrations.findFirst({
93373
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, integration.grade), eq(gameTimebackIntegrations.subject, subject), isActiveGameTimebackIntegrationStatus())
93680
+ const updated = await this.deps.db.transaction(async (tx) => {
93681
+ const database = tx;
93682
+ const integration = await findGameTimebackIntegrationForUpdate(database, and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId), isActiveGameTimebackIntegrationStatus()));
93683
+ if (!integration) {
93684
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
93685
+ }
93686
+ const timebackConfig = await client.course.getConfig(courseId);
93687
+ const liveSubject = timebackConfig.course.subjects[0];
93688
+ const subject = resolveIntegrationSubject(patch.subject, liveSubject, integration.subject);
93689
+ if (!isTimebackSubject(subject)) {
93690
+ throw new ValidationError(`Invalid subject "${subject}"`);
93691
+ }
93692
+ setAttributes({
93693
+ "app.timeback.grade": integration.grade,
93694
+ "app.timeback.previous_subject": integration.subject,
93695
+ "app.timeback.next_subject": subject
93374
93696
  });
93375
- if (subjectConflict && subjectConflict.id !== integration.id) {
93376
- throw new ValidationError(`A TimeBack integration already exists for ${subject} Grade ${integration.grade}`);
93697
+ if (subject !== integration.subject) {
93698
+ if (integration.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND) {
93699
+ throw new ValidationError("A hole-filling course must keep the subject of its base course");
93700
+ }
93701
+ const linkedHoleFilling = await database.query.gameTimebackIntegrations.findFirst({
93702
+ columns: { id: true },
93703
+ where: eq(gameTimebackIntegrations.baseIntegrationId, integration.id)
93704
+ });
93705
+ if (linkedHoleFilling) {
93706
+ throw new ValidationError("This course has hole-filling courses, so its subject cannot change");
93707
+ }
93377
93708
  }
93378
- }
93379
- const totalXp = "totalXp" in patch ? patch.totalXp ?? null : getTotalXpFromTimebackConfig(timebackConfig) ?? integration.totalXp ?? null;
93380
- const masterableUnits = "masterableUnits" in patch ? patch.masterableUnits ?? null : getMasterableUnitsFromTimebackConfig(timebackConfig);
93381
- setAttributes({
93382
- "app.timeback.total_xp": totalXp,
93383
- "app.timeback.masterable_units": masterableUnits
93709
+ if (subject !== integration.subject) {
93710
+ const subjectConflict = await database.query.gameTimebackIntegrations.findFirst({
93711
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, integration.grade), eq(gameTimebackIntegrations.subject, subject), isActiveBaseGameTimebackIntegration())
93712
+ });
93713
+ if (subjectConflict && subjectConflict.id !== integration.id) {
93714
+ throw new ValidationError(`A TimeBack integration already exists for ${subject} Grade ${integration.grade}`);
93715
+ }
93716
+ }
93717
+ const totalXp = "totalXp" in patch ? patch.totalXp ?? null : getTotalXpFromTimebackConfig(timebackConfig) ?? integration.totalXp ?? null;
93718
+ const masterableUnits = "masterableUnits" in patch ? patch.masterableUnits ?? null : getMasterableUnitsFromTimebackConfig(timebackConfig);
93719
+ setAttributes({
93720
+ "app.timeback.total_xp": totalXp,
93721
+ "app.timeback.masterable_units": masterableUnits
93722
+ });
93723
+ await client.course.update(courseId, TimebackService.patchTimebackConfig(timebackConfig, {
93724
+ ...patch,
93725
+ subject,
93726
+ totalXp,
93727
+ masterableUnits,
93728
+ grade: integration.grade
93729
+ }));
93730
+ const [row] = await database.update(gameTimebackIntegrations).set({
93731
+ subject,
93732
+ totalXp,
93733
+ updatedAt: new Date
93734
+ }).where(eq(gameTimebackIntegrations.id, integration.id)).returning();
93735
+ if (!row) {
93736
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
93737
+ }
93738
+ return row;
93384
93739
  });
93385
- await client.course.update(courseId, TimebackService.patchTimebackConfig(timebackConfig, {
93386
- ...patch,
93387
- subject,
93388
- totalXp,
93389
- masterableUnits,
93390
- grade: integration.grade
93391
- }));
93392
- const [updated] = await this.deps.db.update(gameTimebackIntegrations).set({
93393
- subject,
93394
- totalXp,
93395
- updatedAt: new Date
93396
- }).where(eq(gameTimebackIntegrations.id, integration.id)).returning();
93397
- if (!updated) {
93398
- throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
93399
- }
93400
93740
  return toGameTimebackIntegration(updated);
93401
93741
  });
93402
93742
  }
@@ -93407,7 +93747,7 @@ var init_timeback_service = __esm(async () => {
93407
93747
  await this.deps.validateDeveloperAccess(user, gameId);
93408
93748
  TimebackService.recordIntegrationOperation("verify_integration");
93409
93749
  const integrations = await db.query.gameTimebackIntegrations.findMany({
93410
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
93750
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration())
93411
93751
  });
93412
93752
  if (integrations.length === 0) {
93413
93753
  throw new NotFoundError("Timeback integration", gameId);
@@ -93445,7 +93785,7 @@ var init_timeback_service = __esm(async () => {
93445
93785
  const client = this.requireClient();
93446
93786
  await this.deps.validateDeveloperAccess(user, gameId);
93447
93787
  const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
93448
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
93788
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration())
93449
93789
  });
93450
93790
  if (!integration) {
93451
93791
  throw new NotFoundError("Timeback integration", gameId);
@@ -93459,17 +93799,24 @@ var init_timeback_service = __esm(async () => {
93459
93799
  const db = this.deps.db;
93460
93800
  await this.deps.validateDeveloperAccess(user, gameId);
93461
93801
  TimebackService.recordIntegrationOperation("delete_integrations");
93462
- const integrations = await db.query.gameTimebackIntegrations.findMany({
93463
- where: eq(gameTimebackIntegrations.gameId, gameId)
93802
+ await db.transaction(async (tx) => {
93803
+ const database = tx;
93804
+ await database.select({ id: gameTimebackIntegrations.id }).from(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId)).for("update");
93805
+ const integrations = await database.select().from(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
93806
+ if (integrations.length === 0) {
93807
+ throw new NotFoundError("Timeback integration", gameId);
93808
+ }
93809
+ setAttribute("app.timeback.integration_count", integrations.length);
93810
+ const holeFillingCount = integrations.filter((integration) => integration.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND).length;
93811
+ if (holeFillingCount > 0) {
93812
+ setAttribute("app.timeback.hole_filling_count", holeFillingCount);
93813
+ throw new ConflictError(`This game has ${holeFillingCount} hole-filling course(s) that may be referenced by Timeback, so its Timeback courses can't be removed automatically. Contact a Playcademy admin.`);
93814
+ }
93815
+ for (const integration of integrations) {
93816
+ await client.course.cleanup(integration.courseId);
93817
+ }
93818
+ await database.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
93464
93819
  });
93465
- if (integrations.length === 0) {
93466
- throw new NotFoundError("Timeback integration", gameId);
93467
- }
93468
- setAttribute("app.timeback.integration_count", integrations.length);
93469
- for (const integration of integrations) {
93470
- await client.course.cleanup(integration.courseId);
93471
- }
93472
- await db.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
93473
93820
  });
93474
93821
  }
93475
93822
  static patchCourseMetadata(metadata2, totalXp, options) {
@@ -93648,17 +93995,21 @@ var init_timeback_service = __esm(async () => {
93648
93995
  activityData
93649
93996
  });
93650
93997
  await this.deps.validateDeveloperAccess(user, gameId);
93651
- const integration = await db.query.gameTimebackIntegrations.findFirst({
93652
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveGameTimebackIntegrationStatus())
93653
- });
93654
- if (!integration) {
93655
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
93656
- }
93657
93998
  const {
93999
+ integration,
93658
94000
  courseId: runtimeCourseId,
94001
+ baseCourseId,
94002
+ otherCourseIds,
93659
94003
  courseName: runtimeCourseName,
94004
+ extensions: runtimeExtensions,
93660
94005
  attributes: courseAttributes
93661
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
94006
+ } = await this.resolveRuntimeCourse({
94007
+ gameId,
94008
+ studentId,
94009
+ runId,
94010
+ activityData,
94011
+ extensions: extensionsWithResumeId
94012
+ });
93662
94013
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
93663
94014
  const dedupeKey = runId !== undefined ? {
93664
94015
  gameId,
@@ -93687,12 +94038,12 @@ var init_timeback_service = __esm(async () => {
93687
94038
  courseId: runtimeCourseId,
93688
94039
  courseName: runtimeCourseName,
93689
94040
  studentEmail: activityData.studentEmail,
93690
- extensions: extensionsWithResumeId,
94041
+ extensions: runtimeExtensions,
93691
94042
  ...runId ? { runId } : {}
93692
94043
  };
93693
- const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion") : undefined;
93694
- const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", effectiveResumeId) : undefined;
93695
- const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed") : undefined;
94044
+ const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion", baseCourseId) : undefined;
94045
+ const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", baseCourseId, effectiveResumeId) : undefined;
94046
+ const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed", baseCourseId) : undefined;
93696
94047
  function emitRecord() {
93697
94048
  return client.activity.record(runtimeCourseId, studentId, {
93698
94049
  ...activityEventContext,
@@ -93716,7 +94067,7 @@ var init_timeback_service = __esm(async () => {
93716
94067
  xpAwarded: emitted.xpAwarded,
93717
94068
  masteredUnitsApplied: emitted.masteredUnitsApplied,
93718
94069
  pctCompleteApp: emitted.pctCompleteApp ?? null
93719
- }));
94070
+ }), otherCourseIds);
93720
94071
  if (outcome.status === "duplicate") {
93721
94072
  blockedByDedupeGuard = true;
93722
94073
  duplicateAward = outcome.award;
@@ -93764,6 +94115,7 @@ var init_timeback_service = __esm(async () => {
93764
94115
  return {
93765
94116
  status: "ok",
93766
94117
  courseId: runtimeCourseId,
94118
+ courseKind: integration.kind,
93767
94119
  xpAwarded: duplicateAward?.xpAwarded ?? 0,
93768
94120
  masteredUnits: duplicateAward?.masteredUnitsApplied ?? 0,
93769
94121
  ...duplicateAward?.pctCompleteApp != null ? { pctCompleteApp: duplicateAward.pctCompleteApp } : {},
@@ -93776,6 +94128,7 @@ var init_timeback_service = __esm(async () => {
93776
94128
  return {
93777
94129
  status: "ok",
93778
94130
  courseId: runtimeCourseId,
94131
+ courseKind: integration.kind,
93779
94132
  xpAwarded: result.xpAwarded,
93780
94133
  masteredUnits: result.masteredUnitsApplied,
93781
94134
  pctCompleteApp: result.pctCompleteApp,
@@ -93792,7 +94145,7 @@ var init_timeback_service = __esm(async () => {
93792
94145
  const client = this.requireClient();
93793
94146
  const db = this.deps.db;
93794
94147
  const integrations = await db.query.gameTimebackIntegrations.findMany({
93795
- where: subject ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.subject, subject), isActiveGameTimebackIntegrationStatus()) : and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
94148
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), subject ? eq(gameTimebackIntegrations.subject, subject) : undefined, isActiveBaseGameTimebackIntegration())
93796
94149
  });
93797
94150
  if (integrations.length === 0) {
93798
94151
  throw new NotFoundError(subject ? `Timeback integrations for game (subject ${subject})` : "Timeback integrations for game");
@@ -93952,7 +94305,6 @@ var init_timeback_service = __esm(async () => {
93952
94305
  }) {
93953
94306
  return this.withClientTelemetry(async () => {
93954
94307
  const client = this.requireClient();
93955
- const db = this.deps.db;
93956
94308
  const hasWindowStartedAtMs = windowStartedAtMs !== undefined;
93957
94309
  const hasWindowSequence = windowSequence !== undefined;
93958
94310
  if (hasWindowStartedAtMs === hasWindowSequence) {
@@ -93980,17 +94332,17 @@ var init_timeback_service = __esm(async () => {
93980
94332
  return inFlightHeartbeat;
93981
94333
  }
93982
94334
  const pendingHeartbeat = (async () => {
93983
- const integration = await db.query.gameTimebackIntegrations.findFirst({
93984
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveGameTimebackIntegrationStatus())
93985
- });
93986
- if (!integration) {
93987
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
93988
- }
93989
94335
  const {
93990
94336
  courseId: runtimeCourseId,
93991
94337
  courseName: runtimeCourseName,
94338
+ extensions: runtimeExtensions,
93992
94339
  attributes: courseAttributes
93993
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
94340
+ } = await this.resolveRuntimeCourse({
94341
+ gameId,
94342
+ studentId,
94343
+ activityData,
94344
+ extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId)
94345
+ });
93994
94346
  const activeTimeSeconds = timingData.activeMs / 1000;
93995
94347
  const inactiveTimeSeconds = timingData.pausedMs / 1000;
93996
94348
  if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
@@ -94006,7 +94358,7 @@ var init_timeback_service = __esm(async () => {
94006
94358
  courseId: runtimeCourseId,
94007
94359
  courseName: runtimeCourseName,
94008
94360
  studentEmail: activityData.studentEmail,
94009
- extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId),
94361
+ extensions: runtimeExtensions,
94010
94362
  ...runId ? { runId } : {}
94011
94363
  });
94012
94364
  }
@@ -94111,7 +94463,7 @@ var init_timeback_service = __esm(async () => {
94111
94463
  const db = this.deps.db;
94112
94464
  await this.deps.validateDeveloperAccess(user, options.gameId);
94113
94465
  const integration = await db.query.gameTimebackIntegrations.findFirst({
94114
- where: and(eq(gameTimebackIntegrations.gameId, options.gameId), eq(gameTimebackIntegrations.subject, options.subject), isActiveGameTimebackIntegrationStatus())
94466
+ where: and(eq(gameTimebackIntegrations.gameId, options.gameId), eq(gameTimebackIntegrations.subject, options.subject), isActiveBaseGameTimebackIntegration())
94115
94467
  });
94116
94468
  if (!integration) {
94117
94469
  throw new ValidationError(`Subject "${options.subject}" is not configured for game ${options.gameId}`);
@@ -94980,9 +95332,47 @@ var init_account_service = __esm(async () => {
94980
95332
  await init_errors8();
94981
95333
  });
94982
95334
 
95335
+ // ../api-core/src/services/embed-partner.service.ts
95336
+ class EmbedPartnerService {
95337
+ deps;
95338
+ constructor(deps) {
95339
+ this.deps = deps;
95340
+ }
95341
+ async ensurePartner(clientId, name2) {
95342
+ const [partner] = await this.deps.db.insert(embedPartners).values({ clientId, name: name2 }).onConflictDoUpdate({
95343
+ target: embedPartners.clientId,
95344
+ set: { name: name2, updatedAt: new Date }
95345
+ }).returning();
95346
+ if (!partner) {
95347
+ throw new InternalError("Failed to ensure embed partner");
95348
+ }
95349
+ return partner;
95350
+ }
95351
+ addOrigin(clientId, origin) {
95352
+ return this.setOrigins(clientId, sql`CASE WHEN ${origin} = ANY(${embedPartners.allowedOrigins})
95353
+ THEN ${embedPartners.allowedOrigins}
95354
+ ELSE array_append(${embedPartners.allowedOrigins}, ${origin}) END`);
95355
+ }
95356
+ removeOrigin(clientId, origin) {
95357
+ return this.setOrigins(clientId, sql`array_remove(${embedPartners.allowedOrigins}, ${origin})`);
95358
+ }
95359
+ async setOrigins(clientId, origins) {
95360
+ const [partner] = await this.deps.db.update(embedPartners).set({ allowedOrigins: origins, updatedAt: new Date }).where(eq(embedPartners.clientId, clientId)).returning();
95361
+ if (!partner) {
95362
+ throw new NotFoundError("Embed partner", clientId);
95363
+ }
95364
+ return partner;
95365
+ }
95366
+ }
95367
+ var init_embed_partner_service = __esm(() => {
95368
+ init_drizzle_orm();
95369
+ init_tables_index();
95370
+ init_errors2();
95371
+ });
95372
+
94983
95373
  // ../api-core/src/utils/embed.util.ts
94984
- function canPartnerLaunch(partner, origin, gameId) {
94985
- return partner.enabled && partner.allowedOrigins.includes(origin) && partner.games.some((grant) => grant.gameId === gameId);
95374
+ function canPartnerLaunch(partner, origin) {
95375
+ return partner.enabled && partner.allowedOrigins.includes(origin);
94986
95376
  }
94987
95377
  function embedPageUrl(hubOrigin, slug, parentOrigin) {
94988
95378
  const url = new URL(`${EMBED_PAGE_PATH}/${encodeURIComponent(slug)}`, hubOrigin);
@@ -95010,12 +95400,9 @@ class EmbedService {
95010
95400
  return partner;
95011
95401
  }
95012
95402
  async isFrameOriginAllowed(origin, slug) {
95013
- const grants = await this.deps.db.select({
95014
- enabled: embedPartners.enabled,
95015
- allowedOrigins: embedPartners.allowedOrigins,
95016
- gameId: embedPartnerGames.gameId
95017
- }).from(embedPartnerGames).innerJoin(embedPartners, eq(embedPartners.id, embedPartnerGames.partnerId)).innerJoin(games, eq(games.id, embedPartnerGames.gameId)).where(eq(games.slug, slug));
95018
- return grants.some((grant) => canPartnerLaunch({ ...grant, games: [grant] }, origin, grant.gameId));
95403
+ const game = this.deps.db.select({ id: games.id }).from(games).where(eq(games.slug, slug));
95404
+ const [partner] = await this.deps.db.select({ id: embedPartners.id }).from(embedPartners).where(and(eq(embedPartners.enabled, true), arrayContains(embedPartners.allowedOrigins, [origin]), exists(game))).limit(1);
95405
+ return partner !== undefined;
95019
95406
  }
95020
95407
  async createLaunch(partner, input) {
95021
95408
  const game = await this.deps.db.query.games.findFirst({
@@ -95026,9 +95413,8 @@ class EmbedService {
95026
95413
  throw new NotFoundError("Game", input.gameSlug);
95027
95414
  }
95028
95415
  setAttributes({ "app.game.id": game.id, "app.game.slug": game.slug });
95029
- const grants = await this.grantsFor(partner.id, game.id);
95030
- if (!canPartnerLaunch({ ...partner, games: grants }, input.parentOrigin, game.id)) {
95031
- throw new AccessDeniedError("This partner may not launch that game into that origin");
95416
+ if (!canPartnerLaunch(partner, input.parentOrigin)) {
95417
+ throw new AccessDeniedError("This partner may not launch into that origin");
95032
95418
  }
95033
95419
  setAttributes({ "app.user.timeback_id": input.timebackId });
95034
95420
  const user = await this.deps.resolveTimebackStudent(input.timebackId);
@@ -95090,20 +95476,13 @@ class EmbedService {
95090
95476
  throw new NotFoundError("Embed launch", launchId);
95091
95477
  }
95092
95478
  setAttributes({ "app.game.id": launch.gameId, "app.user.id": launch.userId });
95093
- const grants = await this.grantsFor(partner.id, launch.gameId);
95094
- const outcome = renewalRefusal(launch, () => canPartnerLaunch({ ...partner, games: grants }, launch.parentOrigin, launch.gameId));
95479
+ const outcome = renewalRefusal(launch, () => canPartnerLaunch(partner, launch.parentOrigin));
95095
95480
  setAttributes({ "app.embed.outcome": outcome ?? "renewed" });
95096
95481
  if (outcome) {
95097
95482
  throw new AccessDeniedError("This launch has ended");
95098
95483
  }
95099
95484
  return this.deps.mintGameToken(launch.gameId, launch.userId);
95100
95485
  }
95101
- grantsFor(partnerId, gameId) {
95102
- return this.deps.db.query.embedPartnerGames.findMany({
95103
- where: and(eq(embedPartnerGames.partnerId, partnerId), eq(embedPartnerGames.gameId, gameId)),
95104
- columns: { gameId: true }
95105
- });
95106
- }
95107
95486
  async endLaunch(launchId) {
95108
95487
  await this.deps.db.update(embedLaunches).set({ endedAt: sql`coalesce(${embedLaunches.endedAt}, now())`, passHash: null }).where(eq(embedLaunches.id, launchId));
95109
95488
  }
@@ -95120,15 +95499,12 @@ class EmbedService {
95120
95499
  "app.embed.partner_id": launch.partnerId,
95121
95500
  "app.user.id": launch.userId
95122
95501
  });
95123
- const [partner, grants] = await Promise.all([
95124
- this.deps.db.query.embedPartners.findFirst({
95125
- where: eq(embedPartners.id, launch.partnerId),
95126
- columns: { enabled: true, allowedOrigins: true }
95127
- }),
95128
- this.grantsFor(launch.partnerId, launch.gameId)
95129
- ]);
95502
+ const partner = await this.deps.db.query.embedPartners.findFirst({
95503
+ where: eq(embedPartners.id, launch.partnerId),
95504
+ columns: { enabled: true, allowedOrigins: true }
95505
+ });
95130
95506
  const originMatches = launch.parentOrigin === input.parentOrigin;
95131
- const stillAllowed = partner !== undefined && canPartnerLaunch({ ...partner, games: grants }, launch.parentOrigin, launch.gameId);
95507
+ const stillAllowed = partner !== undefined && canPartnerLaunch(partner, launch.parentOrigin);
95132
95508
  if (!originMatches || !stillAllowed) {
95133
95509
  await this.endLaunch(launch.id);
95134
95510
  setAttributes({
@@ -95546,14 +95922,16 @@ function createStandaloneServices(deps) {
95546
95922
  return { ...profile, timeback: timebackData };
95547
95923
  }
95548
95924
  });
95925
+ const embedPartner = new EmbedPartnerService({ db });
95549
95926
  const verify = new VerifyService({
95550
95927
  db,
95551
95928
  validateGameToken: auth.validateGameToken.bind(auth)
95552
95929
  });
95553
- return { account, embed, lti, session, user, verify };
95930
+ return { account, embed, embedPartner, lti, session, user, verify };
95554
95931
  }
95555
95932
  var init_standalone = __esm(async () => {
95556
95933
  init_spans();
95934
+ init_embed_partner_service();
95557
95935
  init_embed_service();
95558
95936
  init_lti_service();
95559
95937
  init_session_service();
@@ -111998,7 +112376,7 @@ function ilike2(column6, value) {
111998
112376
  function notIlike2(column6, value) {
111999
112377
  return sql4`${column6} not ilike ${value}`;
112000
112378
  }
112001
- function arrayContains(column6, values) {
112379
+ function arrayContains2(column6, values) {
112002
112380
  if (Array.isArray(values)) {
112003
112381
  if (values.length === 0) {
112004
112382
  throw new Error("arrayContains requires at least one value");
@@ -135317,7 +135695,7 @@ params: ${params}`);
135317
135695
  and: () => and2,
135318
135696
  applyMixins: () => applyMixins2,
135319
135697
  arrayContained: () => arrayContained,
135320
- arrayContains: () => arrayContains,
135698
+ arrayContains: () => arrayContains2,
135321
135699
  arrayOverlaps: () => arrayOverlaps,
135322
135700
  asc: () => asc2,
135323
135701
  avg: () => avg,
@@ -151887,15 +152265,36 @@ function requireUuidParam(value, name2) {
151887
152265
  }
151888
152266
  return value;
151889
152267
  }
152268
+ function bearerToken(request) {
152269
+ const header = request.headers.get("Authorization");
152270
+ return header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
152271
+ }
151890
152272
  async function requirePartner(ctx) {
151891
- const header = ctx.request.headers.get("Authorization");
151892
- const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
152273
+ const token = bearerToken(ctx.request);
151893
152274
  const verified = token ? await ctx.providers.auth.verifyEmbedPartnerToken(token) : null;
151894
152275
  if (!verified) {
151895
152276
  throw ApiError.unauthorized("A valid embed partner access token is required");
151896
152277
  }
151897
152278
  return ctx.services.embed.getPartnerByClientId(verified.clientId);
151898
152279
  }
152280
+ async function requireEmbedAdmin(ctx) {
152281
+ const expected = ctx.config.embedAdminToken;
152282
+ if (!expected) {
152283
+ throw ApiError.serviceUnavailable("Embed partner management is not configured");
152284
+ }
152285
+ const token = bearerToken(ctx.request);
152286
+ const [given, wanted] = token ? await Promise.all([sha256Hex(token), sha256Hex(expected)]) : [];
152287
+ if (!given || given !== wanted) {
152288
+ throw ApiError.unauthorized("A valid embed admin token is required");
152289
+ }
152290
+ }
152291
+ function requireClientIdParam(ctx) {
152292
+ const clientId = EmbedPartnerClientIdSchema.safeParse(ctx.params.clientId);
152293
+ if (!clientId.success) {
152294
+ throw ApiError.badRequest("Missing or invalid clientId");
152295
+ }
152296
+ return clientId.data;
152297
+ }
151899
152298
  async function createLaunch(ctx) {
151900
152299
  const partner = await requirePartner(ctx);
151901
152300
  const body2 = await parseRequestBody(ctx.request, CreateEmbedLaunchSchema);
@@ -151925,6 +152324,27 @@ async function redeemPass(ctx) {
151925
152324
  timeback: launch.profile.timeback
151926
152325
  };
151927
152326
  }
152327
+ async function ensurePartner(ctx) {
152328
+ await requireEmbedAdmin(ctx);
152329
+ const clientId = requireClientIdParam(ctx);
152330
+ const body2 = await parseRequestBody(ctx.request, EnsureEmbedPartnerSchema);
152331
+ return ctx.services.embedPartner.ensurePartner(clientId, body2.name);
152332
+ }
152333
+ async function addPartnerOrigin(ctx) {
152334
+ await requireEmbedAdmin(ctx);
152335
+ const clientId = requireClientIdParam(ctx);
152336
+ const body2 = await parseRequestBody(ctx.request, AddEmbedPartnerOriginSchema);
152337
+ return ctx.services.embedPartner.addOrigin(clientId, body2.origin);
152338
+ }
152339
+ async function removePartnerOrigin(ctx) {
152340
+ await requireEmbedAdmin(ctx);
152341
+ const clientId = requireClientIdParam(ctx);
152342
+ const origin = EmbedOriginSchema.safeParse(ctx.url.searchParams.get("origin"));
152343
+ if (!origin.success) {
152344
+ throw ApiError.badRequest("Missing or invalid origin");
152345
+ }
152346
+ return ctx.services.embedPartner.removeOrigin(clientId, origin.data);
152347
+ }
151928
152348
  var embed2;
151929
152349
  var init_embed_controller = __esm(() => {
151930
152350
  init_src();
@@ -151934,8 +152354,11 @@ var init_embed_controller = __esm(() => {
151934
152354
  init_utils7();
151935
152355
  init_embed_util();
151936
152356
  embed2 = defineControllerNames("embed", {
152357
+ addPartnerOrigin,
151937
152358
  createLaunch,
152359
+ ensurePartner,
151938
152360
  redeemPass,
152361
+ removePartnerOrigin,
151939
152362
  renewToken
151940
152363
  });
151941
152364
  });
@@ -152542,7 +152965,7 @@ function parseQtiLibraryParams(searchParams) {
152542
152965
  limit
152543
152966
  };
152544
152967
  }
152545
- 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;
152968
+ var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, getHoleFillingCourses, createHoleFillingCourse, 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;
152546
152969
  var init_timeback_controller = __esm(() => {
152547
152970
  init_esm();
152548
152971
  init_src();
@@ -152621,6 +153044,27 @@ var init_timeback_controller = __esm(() => {
152621
153044
  const body2 = await parseRequestBody(ctx.request, CreateGameTimebackIntegrationRequestSchema);
152622
153045
  return ctx.services.timeback.createIntegration(gameId, ctx.user, body2);
152623
153046
  });
153047
+ getHoleFillingCourses = requireGameManagementAccess(async (ctx) => {
153048
+ const gameId = ctx.params.gameId;
153049
+ if (!gameId) {
153050
+ throw ApiError.badRequest("Missing gameId");
153051
+ }
153052
+ if (!isValidUUID(gameId)) {
153053
+ throw ApiError.unprocessableEntity("Invalid gameId format");
153054
+ }
153055
+ return ctx.services.timeback.getHoleFillingCourses(gameId, ctx.user);
153056
+ });
153057
+ createHoleFillingCourse = requireDeveloper(async (ctx) => {
153058
+ const gameId = ctx.params.gameId;
153059
+ if (!gameId) {
153060
+ throw ApiError.badRequest("Missing gameId");
153061
+ }
153062
+ if (!isValidUUID(gameId)) {
153063
+ throw ApiError.unprocessableEntity("Invalid gameId format");
153064
+ }
153065
+ const body2 = await parseRequestBody(ctx.request, CreateGameTimebackHoleFillingCourseRequestSchema);
153066
+ return ctx.services.timeback.createHoleFillingCourse(gameId, ctx.user, body2);
153067
+ });
152624
153068
  updateIntegration = requireDeveloper(async (ctx) => {
152625
153069
  const { gameId, courseId } = ctx.params;
152626
153070
  if (!gameId || !courseId) {
@@ -153405,6 +153849,8 @@ var init_timeback_controller = __esm(() => {
153405
153849
  getIntegrations,
153406
153850
  getRemovedIntegrations,
153407
153851
  createIntegration,
153852
+ getHoleFillingCourses,
153853
+ createHoleFillingCourse,
153408
153854
  updateIntegration,
153409
153855
  deactivateCourse,
153410
153856
  reactivateCourse,