@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/server.js CHANGED
@@ -697,7 +697,7 @@ var package_default;
697
697
  var init_package = __esm(() => {
698
698
  package_default = {
699
699
  name: "@playcademy/sandbox",
700
- version: "0.12.2-beta.1",
700
+ version: "0.12.2-beta.3",
701
701
  description: "Local development server for Playcademy game development",
702
702
  type: "module",
703
703
  exports: {
@@ -12954,6 +12954,7 @@ var init_schema = __esm(() => {
12954
12954
  assessmentArtifactBucket: stringType().optional(),
12955
12955
  cdnOrigin: stringType().url().startsWith("https://").optional(),
12956
12956
  queueIngressSecret: stringType().optional(),
12957
+ embedAdminToken: stringType().optional(),
12957
12958
  secretsManifestPepper: stringType().optional()
12958
12959
  }).superRefine((config, ctx) => {
12959
12960
  if (config.isLocal && !config.baseUrl) {
@@ -13120,6 +13121,17 @@ var init_table_utils = __esm(() => {
13120
13121
  });
13121
13122
 
13122
13123
  // ../../node_modules/.bun/drizzle-orm@0.45.3+c063d24bf874cbdc/node_modules/drizzle-orm/pg-core/foreign-keys.js
13124
+ function foreignKey(config) {
13125
+ function mappedConfig() {
13126
+ const { name: name2, columns, foreignColumns } = config;
13127
+ return {
13128
+ name: name2,
13129
+ columns,
13130
+ foreignColumns
13131
+ };
13132
+ }
13133
+ return new ForeignKeyBuilder(mappedConfig);
13134
+ }
13123
13135
  var ForeignKeyBuilder, ForeignKey;
13124
13136
  var init_foreign_keys = __esm(() => {
13125
13137
  init_entity();
@@ -15992,6 +16004,16 @@ function ilike(column, value) {
15992
16004
  function notIlike(column, value) {
15993
16005
  return sql`${column} not ilike ${value}`;
15994
16006
  }
16007
+ function arrayContains(column, values) {
16008
+ if (Array.isArray(values)) {
16009
+ if (values.length === 0) {
16010
+ throw new Error("arrayContains requires at least one value");
16011
+ }
16012
+ const array = sql`${bindIfParam(values, column)}`;
16013
+ return sql`${column} @> ${array}`;
16014
+ }
16015
+ return sql`${column} @> ${bindIfParam(values, column)}`;
16016
+ }
15995
16017
  var eq = (left, right) => {
15996
16018
  return sql`${left} = ${bindIfParam(right, left)}`;
15997
16019
  }, ne = (left, right) => {
@@ -18922,7 +18944,7 @@ var init_table6 = __esm(() => {
18922
18944
  });
18923
18945
 
18924
18946
  // ../data/src/domains/timeback/table.ts
18925
- var gameTimebackIntegrationStatusEnum, gameTimebackAssessmentPurposeEnum, gameTimebackAssessmentStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications, gameTimebackActivityCompletions;
18947
+ var gameTimebackIntegrationStatusEnum, gameTimebackIntegrationKindEnum, gameTimebackAssessmentPurposeEnum, gameTimebackAssessmentStatusEnum, gameTimebackIntegrations, gameTimebackAssessmentTests, gameTimebackMetricDiscrepancyVerifications, gameTimebackActivityCompletions;
18926
18948
  var init_table7 = __esm(() => {
18927
18949
  init_drizzle_orm();
18928
18950
  init_pg_core();
@@ -18933,6 +18955,10 @@ var init_table7 = __esm(() => {
18933
18955
  "active",
18934
18956
  "deactivated"
18935
18957
  ]);
18958
+ gameTimebackIntegrationKindEnum = pgEnum("game_timeback_integration_kind", [
18959
+ "base",
18960
+ "hole_filling"
18961
+ ]);
18936
18962
  gameTimebackAssessmentPurposeEnum = pgEnum("game_timeback_assessment_purpose", ASSESSMENT_PURPOSES);
18937
18963
  gameTimebackAssessmentStatusEnum = pgEnum("game_timeback_assessment_status", [
18938
18964
  "draft",
@@ -18946,6 +18972,8 @@ var init_table7 = __esm(() => {
18946
18972
  grade: integer("grade").notNull(),
18947
18973
  subject: text("subject").notNull(),
18948
18974
  status: gameTimebackIntegrationStatusEnum("status"),
18975
+ kind: gameTimebackIntegrationKindEnum("kind").notNull().default("base"),
18976
+ baseIntegrationId: uuid("base_integration_id"),
18949
18977
  totalXp: integer("total_xp"),
18950
18978
  lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true }),
18951
18979
  deactivatedAt: timestamp("deactivated_at", { withTimezone: true }),
@@ -18953,7 +18981,19 @@ var init_table7 = __esm(() => {
18953
18981
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
18954
18982
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
18955
18983
  }, (table) => [
18956
- uniqueIndex("game_timeback_integrations_game_grade_subject_idx").on(table.gameId, table.grade, table.subject).where(sql`${table.status} IS DISTINCT FROM 'deactivated'`)
18984
+ 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'`),
18985
+ uniqueIndex("game_timeback_integrations_hole_filling_course_idx").on(table.gameId, table.courseId).where(sql`${table.kind} = 'hole_filling'`),
18986
+ index("game_timeback_integrations_base_integration_idx").on(table.baseIntegrationId),
18987
+ foreignKey({
18988
+ name: "game_timeback_integrations_base_integration_id_fk",
18989
+ columns: [table.baseIntegrationId],
18990
+ foreignColumns: [table.id]
18991
+ }),
18992
+ check("game_timeback_integrations_kind_base_check", sql`(
18993
+ (${table.kind}::text = 'base' AND ${table.baseIntegrationId} IS NULL)
18994
+ OR
18995
+ (${table.kind}::text = 'hole_filling' AND ${table.baseIntegrationId} IS NOT NULL)
18996
+ )`)
18957
18997
  ]);
18958
18998
  gameTimebackAssessmentTests = pgTable("game_timeback_assessment_tests", {
18959
18999
  id: uuid("id").primaryKey().defaultRandom(),
@@ -19035,7 +19075,7 @@ var init_table7 = __esm(() => {
19035
19075
  });
19036
19076
 
19037
19077
  // ../data/src/domains/embed/table.ts
19038
- var embedPartners, embedPartnerGames, embedLaunches, embedPartnersRelations, embedPartnerGamesRelations, embedLaunchesRelations;
19078
+ var embedPartners, embedLaunches, embedLaunchesRelations;
19039
19079
  var init_table8 = __esm(() => {
19040
19080
  init_drizzle_orm();
19041
19081
  init_pg_core();
@@ -19050,15 +19090,6 @@ var init_table8 = __esm(() => {
19050
19090
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
19051
19091
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
19052
19092
  });
19053
- embedPartnerGames = pgTable("embed_partner_games", {
19054
- id: uuid("id").primaryKey().defaultRandom(),
19055
- partnerId: uuid("partner_id").notNull().references(() => embedPartners.id, { onDelete: "cascade" }),
19056
- gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
19057
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
19058
- }, (table) => [
19059
- uniqueIndex("embed_partner_games_partner_game_idx").on(table.partnerId, table.gameId),
19060
- index("embed_partner_games_game_idx").on(table.gameId)
19061
- ]);
19062
19093
  embedLaunches = pgTable("embed_launches", {
19063
19094
  id: uuid("id").primaryKey().defaultRandom(),
19064
19095
  partnerId: uuid("partner_id").notNull().references(() => embedPartners.id, { onDelete: "restrict" }),
@@ -19077,19 +19108,6 @@ var init_table8 = __esm(() => {
19077
19108
  index("embed_launches_user_idx").on(table.userId),
19078
19109
  index("embed_launches_game_idx").on(table.gameId)
19079
19110
  ]);
19080
- embedPartnersRelations = relations(embedPartners, ({ many }) => ({
19081
- games: many(embedPartnerGames)
19082
- }));
19083
- embedPartnerGamesRelations = relations(embedPartnerGames, ({ one }) => ({
19084
- partner: one(embedPartners, {
19085
- fields: [embedPartnerGames.partnerId],
19086
- references: [embedPartners.id]
19087
- }),
19088
- game: one(games, {
19089
- fields: [embedPartnerGames.gameId],
19090
- references: [games.id]
19091
- })
19092
- }));
19093
19111
  embedLaunchesRelations = relations(embedLaunches, ({ one }) => ({
19094
19112
  partner: one(embedPartners, {
19095
19113
  fields: [embedLaunches.partnerId],
@@ -19120,10 +19138,7 @@ __export(exports_tables_index, {
19120
19138
  developerStatusEnum: () => developerStatusEnum,
19121
19139
  embedLaunches: () => embedLaunches,
19122
19140
  embedLaunchesRelations: () => embedLaunchesRelations,
19123
- embedPartnerGames: () => embedPartnerGames,
19124
- embedPartnerGamesRelations: () => embedPartnerGamesRelations,
19125
19141
  embedPartners: () => embedPartners,
19126
- embedPartnersRelations: () => embedPartnersRelations,
19127
19142
  gameCustomHostnames: () => gameCustomHostnames,
19128
19143
  gameDashboardUserRoleEnum: () => gameDashboardUserRoleEnum,
19129
19144
  gameDashboardUsers: () => gameDashboardUsers,
@@ -19142,6 +19157,7 @@ __export(exports_tables_index, {
19142
19157
  gameTimebackAssessmentPurposeEnum: () => gameTimebackAssessmentPurposeEnum,
19143
19158
  gameTimebackAssessmentStatusEnum: () => gameTimebackAssessmentStatusEnum,
19144
19159
  gameTimebackAssessmentTests: () => gameTimebackAssessmentTests,
19160
+ gameTimebackIntegrationKindEnum: () => gameTimebackIntegrationKindEnum,
19145
19161
  gameTimebackIntegrationStatusEnum: () => gameTimebackIntegrationStatusEnum,
19146
19162
  gameTimebackIntegrations: () => gameTimebackIntegrations,
19147
19163
  gameTimebackMetricDiscrepancyVerifications: () => gameTimebackMetricDiscrepancyVerifications,
@@ -21049,10 +21065,19 @@ var init_helpers = __esm(() => {
21049
21065
  });
21050
21066
 
21051
21067
  // ../data/src/domains/timeback/helpers.ts
21068
+ function isBaseGameTimebackIntegration() {
21069
+ return sql`${gameTimebackIntegrations.kind} = ${BASE_GAME_TIMEBACK_INTEGRATION_KIND}`;
21070
+ }
21071
+ function isBaseGameTimebackIntegrationRow(integration) {
21072
+ return integration.kind === BASE_GAME_TIMEBACK_INTEGRATION_KIND;
21073
+ }
21052
21074
  function isActiveGameTimebackIntegrationStatus(statusColumn = gameTimebackIntegrations.status) {
21053
21075
  return sql`${statusColumn} IS DISTINCT FROM ${DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS}`;
21054
21076
  }
21055
- var ACTIVE_GAME_TIMEBACK_INTEGRATION_STATUS = "active", DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS = "deactivated";
21077
+ function isActiveBaseGameTimebackIntegration() {
21078
+ return sql`(${isActiveGameTimebackIntegrationStatus()} AND ${isBaseGameTimebackIntegration()})`;
21079
+ }
21080
+ 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";
21056
21081
  var init_helpers2 = __esm(() => {
21057
21082
  init_drizzle_orm();
21058
21083
  init_table7();
@@ -37629,12 +37654,12 @@ function resolveIntegrationSubject(patchSubject, liveSubject, storedSubject) {
37629
37654
  function filterEnrollmentsByGame(enrollments, gameId) {
37630
37655
  return enrollments.filter((enrollment) => enrollment.gameId === gameId).map(({ gameId: _, ...enrollment }) => enrollment);
37631
37656
  }
37632
- function mapEnrollmentsToUserEnrollments(enrollments, integrations) {
37657
+ function mapEnrollmentsToUserEnrollments(enrollments, integrations, baseCourseIdByIntegrationId = new Map) {
37633
37658
  const enrollmentByCourse = new Map(enrollments.map((enrollment) => [enrollment.courseId, enrollment]));
37634
37659
  const courseToSchool = new Map(enrollments.filter((enrollment) => enrollment.school?.id).map((enrollment) => [enrollment.courseId, enrollment.school.id]));
37635
37660
  return integrations.map((integration) => {
37636
37661
  const enrollment = enrollmentByCourse.get(integration.courseId);
37637
- return {
37662
+ const userEnrollment = {
37638
37663
  gameId: integration.gameId,
37639
37664
  grade: integration.grade,
37640
37665
  subject: integration.subject,
@@ -37642,6 +37667,17 @@ function mapEnrollmentsToUserEnrollments(enrollments, integrations) {
37642
37667
  orgId: courseToSchool.get(integration.courseId),
37643
37668
  ...enrollment ? { id: enrollment.sourcedId } : {}
37644
37669
  };
37670
+ if (integration.kind !== HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND) {
37671
+ return userEnrollment;
37672
+ }
37673
+ const baseCourseId = integration.baseIntegrationId ? baseCourseIdByIntegrationId.get(integration.baseIntegrationId) : undefined;
37674
+ return {
37675
+ ...userEnrollment,
37676
+ kind: HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND,
37677
+ ...baseCourseId && { baseCourseId },
37678
+ ...enrollment?.beginDate !== undefined && { beginDate: enrollment.beginDate },
37679
+ ...enrollment?.metadata && { enrollmentMetadata: enrollment.metadata }
37680
+ };
37645
37681
  });
37646
37682
  }
37647
37683
  function buildGameTimebackSummaries(integrations) {
@@ -38124,7 +38160,7 @@ var init_game_service = __esm(() => {
38124
38160
  const db = this.deps.db;
38125
38161
  const integrations = await db.query.gameTimebackIntegrations.findMany({
38126
38162
  columns: { gameId: true, subject: true },
38127
- where: isActiveGameTimebackIntegrationStatus(),
38163
+ where: isActiveBaseGameTimebackIntegration(),
38128
38164
  orderBy: [asc(gameTimebackIntegrations.createdAt)]
38129
38165
  });
38130
38166
  const subjectMap = {};
@@ -38148,9 +38184,7 @@ var init_game_service = __esm(() => {
38148
38184
  }
38149
38185
  const integrations = await db.query.gameTimebackIntegrations.findMany({
38150
38186
  columns: { gameId: true, subject: true, status: true },
38151
- ...accessibleGameIds && {
38152
- where: inArray(gameTimebackIntegrations.gameId, [...accessibleGameIds])
38153
- },
38187
+ where: and(accessibleGameIds ? inArray(gameTimebackIntegrations.gameId, [...accessibleGameIds]) : undefined, isBaseGameTimebackIntegration()),
38154
38188
  orderBy: [asc(gameTimebackIntegrations.createdAt)]
38155
38189
  });
38156
38190
  return buildGameTimebackSummaries(integrations);
@@ -48581,7 +48615,7 @@ function rejectSystemManagedReview(input, context) {
48581
48615
  });
48582
48616
  }
48583
48617
  }
48584
- 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;
48618
+ 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;
48585
48619
  var init_schemas4 = __esm(() => {
48586
48620
  init_esm();
48587
48621
  init_src();
@@ -48617,6 +48651,26 @@ var init_schemas4 = __esm(() => {
48617
48651
  masterableUnits: numberType().int().nonnegative(),
48618
48652
  level: stringType().trim().min(1).optional()
48619
48653
  });
48654
+ RESERVED_TIMEBACK_COURSE_SOURCED_IDS = new Set([
48655
+ "hole-filling",
48656
+ "removed",
48657
+ "components",
48658
+ "component-resources",
48659
+ "structure"
48660
+ ]);
48661
+ TimebackCourseSourcedIdSchema = stringType().trim().min(3).max(100).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, {
48662
+ message: "Use letters, numbers, dots, dashes, or underscores, starting with a letter or number"
48663
+ }).refine((value) => !RESERVED_TIMEBACK_COURSE_SOURCED_IDS.has(value.toLowerCase()), {
48664
+ message: "This course ID is reserved"
48665
+ });
48666
+ CreateGameTimebackHoleFillingCourseRequestSchema = objectType({
48667
+ baseCourseId: stringType().trim().min(1),
48668
+ title: stringType().trim().min(1),
48669
+ courseCode: stringType().trim().min(1),
48670
+ totalXp: numberType().int().positive(),
48671
+ masterableUnits: numberType().int().nonnegative(),
48672
+ courseId: TimebackCourseSourcedIdSchema.optional()
48673
+ });
48620
48674
  TimebackActivityDataSchema = objectType({
48621
48675
  activityId: stringType().min(1),
48622
48676
  activityName: stringType().optional(),
@@ -48625,6 +48679,7 @@ var init_schemas4 = __esm(() => {
48625
48679
  appName: stringType().optional(),
48626
48680
  sensorUrl: stringType().url().optional(),
48627
48681
  courseId: stringType().min(1).optional(),
48682
+ enrollmentId: stringType().min(1).optional(),
48628
48683
  courseName: stringType().optional(),
48629
48684
  studentEmail: stringType().email().optional()
48630
48685
  });
@@ -48948,21 +49003,20 @@ function isCanonicalOrigin(value) {
48948
49003
  }
48949
49004
  return url.protocol === "https:" || url.protocol === "http:" && isLocalHostname(url.hostname);
48950
49005
  }
48951
- var LOCAL_HOSTNAMES, EmbedOriginSchema, CreateEmbedPartnerSchema, UpdateEmbedPartnerSchema, CreateEmbedLaunchSchema, RedeemEmbedPassSchema;
49006
+ var LOCAL_HOSTNAMES, EmbedOriginSchema, EmbedPartnerClientIdSchema, EnsureEmbedPartnerSchema, AddEmbedPartnerOriginSchema, CreateEmbedLaunchSchema, RedeemEmbedPassSchema;
48952
49007
  var init_schemas5 = __esm(() => {
48953
49008
  init_esm();
48954
49009
  LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1"]);
48955
49010
  EmbedOriginSchema = stringType().refine(isCanonicalOrigin, {
48956
49011
  message: "Origin must be a bare https origin like https://example.com, with no path or slash"
48957
49012
  });
48958
- CreateEmbedPartnerSchema = objectType({
48959
- name: stringType().trim().min(1).max(100),
48960
- clientId: stringType().trim().min(1).max(200),
48961
- allowedOrigins: arrayType(EmbedOriginSchema).max(20).default([]),
48962
- allowedGameIds: arrayType(stringType().uuid().transform((id) => id.toLowerCase())).max(200).default([]),
48963
- enabled: booleanType().default(true)
49013
+ EmbedPartnerClientIdSchema = stringType().trim().min(1).max(200);
49014
+ EnsureEmbedPartnerSchema = objectType({
49015
+ name: stringType().trim().min(1).max(100)
49016
+ }).strict();
49017
+ AddEmbedPartnerOriginSchema = objectType({
49018
+ origin: EmbedOriginSchema
48964
49019
  }).strict();
48965
- UpdateEmbedPartnerSchema = CreateEmbedPartnerSchema.omit({ clientId: true }).partial();
48966
49020
  CreateEmbedLaunchSchema = objectType({
48967
49021
  timebackId: stringType().trim().min(1).max(200),
48968
49022
  gameSlug: stringType().trim().min(1).max(100),
@@ -76120,6 +76174,7 @@ class CourseAssessments {
76120
76174
  }
76121
76175
  function buildCoursePayload(config) {
76122
76176
  return {
76177
+ ...config.course.sourcedId && { sourcedId: config.course.sourcedId },
76123
76178
  status: "active",
76124
76179
  title: config.course.title,
76125
76180
  courseCode: config.course.courseCode,
@@ -76166,6 +76221,16 @@ function buildComponentResourcePayload(config, sourcedIds) {
76166
76221
  lessonType: config.componentResource.lessonType
76167
76222
  };
76168
76223
  }
76224
+ async function findOrUndefined(request) {
76225
+ try {
76226
+ return await request;
76227
+ } catch (error) {
76228
+ if (isApiError(error) && error.statusCode === 404) {
76229
+ return;
76230
+ }
76231
+ throw error;
76232
+ }
76233
+ }
76169
76234
 
76170
76235
  class CourseIntegration {
76171
76236
  core;
@@ -76204,6 +76269,32 @@ class CourseIntegration {
76204
76269
  ...verboseData && { verboseData }
76205
76270
  };
76206
76271
  }
76272
+ async findCourse(courseId) {
76273
+ return findOrUndefined(this.core.api.oneroster.courses.get(courseId));
76274
+ }
76275
+ async resumeSetup(courseId, config) {
76276
+ const oneroster = this.core.api.oneroster;
76277
+ const sourcedIds = deriveSourcedIds(courseId);
76278
+ if (!await findOrUndefined(oneroster.courses.getComponent(sourcedIds.component))) {
76279
+ await oneroster.courses.createComponent(buildComponentPayload(config, sourcedIds));
76280
+ addEvent("timeback.component_created", {
76281
+ "app.timeback.component_id": sourcedIds.component
76282
+ });
76283
+ }
76284
+ if (!await findOrUndefined(oneroster.resources.get(sourcedIds.resource))) {
76285
+ await oneroster.resources.create(buildResourcePayload(config, sourcedIds.resource));
76286
+ addEvent("timeback.resource_created", {
76287
+ "app.timeback.resource_id": sourcedIds.resource
76288
+ });
76289
+ }
76290
+ if (!await findOrUndefined(oneroster.courses.getComponentResource(sourcedIds.componentResource))) {
76291
+ await oneroster.courses.createComponentResource(buildComponentResourcePayload(config, sourcedIds));
76292
+ addEvent("timeback.component_resource_link_created", {
76293
+ "app.timeback.component_resource_id": sourcedIds.componentResource
76294
+ });
76295
+ }
76296
+ await this.update(courseId, config);
76297
+ }
76207
76298
  async update(courseId, config) {
76208
76299
  const oneroster = this.core.api.oneroster;
76209
76300
  const sourcedIds = deriveSourcedIds(courseId);
@@ -76343,6 +76434,8 @@ function createCourseNamespace(core) {
76343
76434
  update: (courseId, config) => integration.update(courseId, config),
76344
76435
  verify: (courseId) => integration.verify(courseId),
76345
76436
  getConfig: (courseId) => integration.getConfig(courseId),
76437
+ findCourse: (courseId) => integration.findCourse(courseId),
76438
+ resumeSetup: (courseId, config) => integration.resumeSetup(courseId, config),
76346
76439
  cleanup: (courseId) => integration.cleanup(courseId),
76347
76440
  deactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "tobedeleted"),
76348
76441
  reactivateCourse: (courseId) => integration.updateCourseStatus(courseId, "active"),
@@ -76427,7 +76520,9 @@ class RosterEnrollments {
76427
76520
  status: "active",
76428
76521
  grades,
76429
76522
  subjects,
76430
- school: enrollment.school
76523
+ school: enrollment.school,
76524
+ beginDate: enrollment.beginDate,
76525
+ ...enrollment.metadata && { metadata: enrollment.metadata }
76431
76526
  };
76432
76527
  });
76433
76528
  this.core.caches.enrollments.set(studentId, enrollments);
@@ -87231,7 +87326,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
87231
87326
  }) {
87232
87327
  await this.deps.validateDeveloperAccess(user, gameId);
87233
87328
  const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
87234
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
87329
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration())
87235
87330
  });
87236
87331
  const matchingIntegrations = new Map;
87237
87332
  for (const integration of integrations) {
@@ -87620,7 +87715,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
87620
87715
  }) {
87621
87716
  await this.deps.validateDeveloperAccess(user, gameId);
87622
87717
  const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
87623
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
87718
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration())
87624
87719
  });
87625
87720
  const orderedIntegrations = integrations.toSorted((left, right) => {
87626
87721
  const subjectDifference = left.subject.localeCompare(right.subject);
@@ -87699,7 +87794,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
87699
87794
  async candidateIntegrations(gameId, studentId, input, db = this.deps.db) {
87700
87795
  const filters = [
87701
87796
  eq(gameTimebackIntegrations.gameId, gameId),
87702
- isActiveGameTimebackIntegrationStatus()
87797
+ isActiveBaseGameTimebackIntegration()
87703
87798
  ];
87704
87799
  if (input.subject) {
87705
87800
  filters.push(eq(gameTimebackIntegrations.subject, input.subject));
@@ -90599,6 +90694,22 @@ var init_timeback_assessments_service = __esm(async () => {
90599
90694
  await init_errors8();
90600
90695
  });
90601
90696
 
90697
+ // ../api-core/src/utils/timeback-attribution.util.ts
90698
+ function resolveRuntimeAttribution(base, holeFilling, requestedCourseId, recordedCourseId) {
90699
+ const recorded = [base, ...holeFilling].find((integration) => integration.courseId === recordedCourseId);
90700
+ if (recorded) {
90701
+ return { integration: recorded, requestedCourseIdStatus: "pinned_by_run" };
90702
+ }
90703
+ if (requestedCourseId === undefined) {
90704
+ return { integration: base, requestedCourseIdStatus: "absent" };
90705
+ }
90706
+ if (requestedCourseId === base.courseId) {
90707
+ return { integration: base, requestedCourseIdStatus: "accepted" };
90708
+ }
90709
+ const requestedHoleFilling = holeFilling.find((integration) => integration.courseId === requestedCourseId);
90710
+ return requestedHoleFilling ? { integration: requestedHoleFilling, requestedCourseIdStatus: "accepted_hole_filling" } : { integration: base, requestedCourseIdStatus: "ignored_mismatch" };
90711
+ }
90712
+
90602
90713
  // ../api-core/src/utils/timeback-create-integration.util.ts
90603
90714
  function buildTimebackBaseConfigFromExistingConfig(config) {
90604
90715
  return {
@@ -90641,6 +90752,50 @@ function timebackConfigMatchesCreateIntegrationRequest(config, request) {
90641
90752
  const requestedLevel = request.level ?? deriveTimebackCourseLevelFromGrade(request.grade);
90642
90753
  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;
90643
90754
  }
90755
+ function buildHoleFillingCourseConfig(baseConfig, request) {
90756
+ const baseCourseMetadata = isRecord4(baseConfig.course.metadata) ? baseConfig.course.metadata : {};
90757
+ const baseMetrics = isRecord4(baseCourseMetadata.metrics) ? baseCourseMetadata.metrics : {};
90758
+ const baseResourceMetadata = isRecord4(baseConfig.resource.metadata) ? baseConfig.resource.metadata : {};
90759
+ const basePlaycademy = isPlaycademyResourceMetadata(baseResourceMetadata.playcademy) ? baseResourceMetadata.playcademy : undefined;
90760
+ return {
90761
+ organization: baseConfig.organization,
90762
+ course: {
90763
+ ...baseConfig.course,
90764
+ ...request.courseId && { sourcedId: request.courseId },
90765
+ title: request.title,
90766
+ courseCode: request.courseCode,
90767
+ metadata: {
90768
+ ...baseCourseMetadata,
90769
+ courseType: HOLE_FILLING_COURSE_TYPE,
90770
+ baseCourseSourcedId: request.baseCourseId,
90771
+ metrics: {
90772
+ ...baseMetrics,
90773
+ totalXp: request.totalXp,
90774
+ totalLessons: request.masterableUnits,
90775
+ totalGrades: 1
90776
+ }
90777
+ }
90778
+ },
90779
+ component: { ...baseConfig.component, title: `${request.title} Activities` },
90780
+ resource: {
90781
+ ...baseConfig.resource,
90782
+ title: `${request.title} Game`,
90783
+ metadata: {
90784
+ ...baseResourceMetadata,
90785
+ xp: request.totalXp,
90786
+ playcademy: {
90787
+ ...basePlaycademy,
90788
+ mastery: {
90789
+ ...basePlaycademy?.mastery,
90790
+ masterableUnits: request.masterableUnits
90791
+ }
90792
+ }
90793
+ }
90794
+ },
90795
+ componentResource: { ...baseConfig.componentResource, title: `${request.title} Lesson` }
90796
+ };
90797
+ }
90798
+ var HOLE_FILLING_COURSE_TYPE = "hole-filling";
90644
90799
  var init_timeback_create_integration_util = __esm(() => {
90645
90800
  init_constants2();
90646
90801
  init_types2();
@@ -90656,7 +90811,7 @@ async function promoteCompletedCourse({
90656
90811
  enrollments: prefetchedEnrollments
90657
90812
  }) {
90658
90813
  const subjectIntegrations = await db.query.gameTimebackIntegrations.findMany({
90659
- where: and(eq(gameTimebackIntegrations.gameId, currentIntegration.gameId), eq(gameTimebackIntegrations.subject, currentIntegration.subject), isActiveGameTimebackIntegrationStatus())
90814
+ where: and(eq(gameTimebackIntegrations.gameId, currentIntegration.gameId), eq(gameTimebackIntegrations.subject, currentIntegration.subject), isActiveBaseGameTimebackIntegration())
90660
90815
  });
90661
90816
  const nextIntegration = subjectIntegrations.filter((integration) => integration.grade > currentIntegration.grade).toSorted((left, right) => left.grade - right.grade)[0];
90662
90817
  if (!nextIntegration) {
@@ -90723,6 +90878,8 @@ function toGameTimebackIntegration(integration) {
90723
90878
  courseId: integration.courseId,
90724
90879
  grade: integration.grade,
90725
90880
  subject: integration.subject,
90881
+ kind: integration.kind,
90882
+ baseIntegrationId: integration.baseIntegrationId,
90726
90883
  totalXp: integration.totalXp ?? null,
90727
90884
  status: integration.status ?? ACTIVE_GAME_TIMEBACK_INTEGRATION_STATUS,
90728
90885
  deactivatedAt: integration.deactivatedAt ?? null,
@@ -90760,6 +90917,9 @@ var init_timeback_removed_integration_util = __esm(() => {
90760
90917
 
90761
90918
  // ../api-core/src/services/timeback.service.ts
90762
90919
  import { createHash as createHash3 } from "node:crypto";
90920
+ function isUnregisteredHoleFillingOf(course, base) {
90921
+ return course.metadata?.courseType === HOLE_FILLING_COURSE_TYPE && course.metadata.baseCourseSourcedId === base.courseId;
90922
+ }
90763
90923
  async function findGameTimebackIntegrationForUpdate(db, condition) {
90764
90924
  const [integration] = await db.select().from(gameTimebackIntegrations).where(condition).limit(1).for("update");
90765
90925
  return integration;
@@ -90809,13 +90969,13 @@ var init_timeback_service = __esm(async () => {
90809
90969
  static clearInFlightHeartbeatWindow(key) {
90810
90970
  this.inFlightHeartbeatWindows.delete(key);
90811
90971
  }
90812
- static buildCompletionCaliperEventId(key, kind, discriminator = "completion") {
90972
+ static buildCompletionCaliperEventId(key, kind, baseCourseId, discriminator = "completion") {
90813
90973
  const hash = createHash3("sha256").update([
90814
90974
  "playcademy",
90815
90975
  "timeback",
90816
90976
  kind,
90817
90977
  key.gameId,
90818
- key.courseId,
90978
+ baseCourseId,
90819
90979
  key.studentId,
90820
90980
  key.runId,
90821
90981
  key.activityId,
@@ -90881,11 +91041,14 @@ var init_timeback_service = __esm(async () => {
90881
91041
  } : null
90882
91042
  };
90883
91043
  }
90884
- static async acquireCompletionReservation(db, key) {
90885
- let reserveOutcome = await this.reserveCompletion(db, key);
91044
+ static async acquireCompletionReservation(db, key, otherCourseIds = []) {
91045
+ function reserve() {
91046
+ return otherCourseIds.length > 0 ? TimebackService.reserveCompletionAcrossCourses(db, key, otherCourseIds) : TimebackService.reserveCompletion(db, key);
91047
+ }
91048
+ let reserveOutcome = await reserve();
90886
91049
  for (let settleAttempt = 0;reserveOutcome.status === "in_flight" && settleAttempt < this.COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS; settleAttempt++) {
90887
91050
  await sleep(this.COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS);
90888
- reserveOutcome = await this.reserveCompletion(db, key);
91051
+ reserveOutcome = await reserve();
90889
91052
  }
90890
91053
  if (reserveOutcome.status === "duplicate") {
90891
91054
  setAttribute("app.timeback.end_activity_status", "blocked_by_dedupe_guard");
@@ -90904,8 +91067,24 @@ var init_timeback_service = __esm(async () => {
90904
91067
  }
90905
91068
  return { status: "reserved", token: reserveOutcome.token };
90906
91069
  }
90907
- static async runWithCompletionReservation(db, key, emit, awardFromResult) {
90908
- const acquired = await this.acquireCompletionReservation(db, key);
91070
+ static async reserveCompletionAcrossCourses(db, key, otherCourseIds) {
91071
+ return db.transaction(async (tx) => {
91072
+ const database = tx;
91073
+ const runKey = `timeback-completion:${key.gameId.toLowerCase()}:${key.studentId}:${key.runId.toLowerCase()}:${key.activityId}`;
91074
+ await database.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${runKey}, 0))`);
91075
+ const onOtherCourse = await database.query.gameTimebackActivityCompletions.findFirst({
91076
+ columns: { id: true },
91077
+ 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]))
91078
+ });
91079
+ if (onOtherCourse) {
91080
+ setAttribute("app.timeback.end_activity_status", "completion_reserved_on_other_course");
91081
+ throw new ServiceUnavailableError("An end-activity submission for this run is already in flight — retry shortly");
91082
+ }
91083
+ return this.reserveCompletion(database, key);
91084
+ });
91085
+ }
91086
+ static async runWithCompletionReservation(db, key, emit, awardFromResult, otherCourseIds = []) {
91087
+ const acquired = await this.acquireCompletionReservation(db, key, otherCourseIds);
90909
91088
  if (acquired.status === "duplicate") {
90910
91089
  return acquired;
90911
91090
  }
@@ -90940,6 +91119,9 @@ var init_timeback_service = __esm(async () => {
90940
91119
  await db.delete(gameTimebackActivityCompletions).where(and(eq(gameTimebackActivityCompletions.id, token.id), eq(gameTimebackActivityCompletions.reservedAt, token.reservedAt), isNull(gameTimebackActivityCompletions.completedAt)));
90941
91120
  }
90942
91121
  static addResumeIdToExtensions(extensions, resumeId) {
91122
+ return TimebackService.addPlaycademyExtensions(extensions, { resumeId });
91123
+ }
91124
+ static addPlaycademyExtensions(extensions, fields) {
90943
91125
  const base = extensions ?? {};
90944
91126
  const existingPlaycademy = base.playcademy;
90945
91127
  const playcademy = typeof existingPlaycademy === "object" && existingPlaycademy !== null && !Array.isArray(existingPlaycademy) ? existingPlaycademy : {};
@@ -90947,28 +91129,71 @@ var init_timeback_service = __esm(async () => {
90947
91129
  ...base,
90948
91130
  playcademy: {
90949
91131
  ...playcademy,
90950
- resumeId
91132
+ ...fields
90951
91133
  }
90952
91134
  };
90953
91135
  }
90954
- static resolveRuntimeCourse(resolvedCourseId, requestedCourseId, courseName) {
90955
- let requestedCourseIdStatus = "absent";
90956
- if (requestedCourseId === resolvedCourseId) {
90957
- requestedCourseIdStatus = "accepted";
90958
- } else if (requestedCourseId !== undefined) {
90959
- requestedCourseIdStatus = "ignored_mismatch";
91136
+ async resolveRuntimeCourse({
91137
+ gameId,
91138
+ studentId,
91139
+ runId,
91140
+ activityData,
91141
+ extensions
91142
+ }) {
91143
+ const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
91144
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveGameTimebackIntegrationStatus())
91145
+ });
91146
+ const base = integrations.find(isBaseGameTimebackIntegrationRow);
91147
+ if (!base) {
91148
+ throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
90960
91149
  }
91150
+ const holeFilling = integrations.filter((integration) => !isBaseGameTimebackIntegrationRow(integration));
91151
+ const recordedCourseId = holeFilling.length > 0 && runId ? await this.findRecordedRunCourse({
91152
+ gameId,
91153
+ studentId,
91154
+ runId,
91155
+ activityId: activityData.activityId,
91156
+ courseIds: integrations.map((i2) => i2.courseId)
91157
+ }) : undefined;
91158
+ const { integration, requestedCourseIdStatus } = resolveRuntimeAttribution(base, holeFilling, activityData.courseId, recordedCourseId);
91159
+ const hintUsed = activityData.courseId === integration.courseId;
91160
+ const enrollmentId = hintUsed ? activityData.enrollmentId : undefined;
91161
+ const isHoleFilling = !isBaseGameTimebackIntegrationRow(integration);
90961
91162
  return {
90962
- courseId: resolvedCourseId,
90963
- courseName: requestedCourseIdStatus === "ignored_mismatch" ? undefined : courseName,
91163
+ integration,
91164
+ courseId: integration.courseId,
91165
+ baseCourseId: base.courseId,
91166
+ otherCourseIds: holeFilling.length > 0 ? integrations.map((other) => other.courseId).filter((courseId) => courseId !== integration.courseId) : [],
91167
+ courseName: activityData.courseId === undefined || hintUsed ? activityData.courseName : undefined,
91168
+ extensions: isHoleFilling ? TimebackService.addPlaycademyExtensions(extensions, {
91169
+ courseKind: HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND,
91170
+ baseCourseId: base.courseId,
91171
+ ...enrollmentId && { enrollmentId }
91172
+ }) : extensions,
90964
91173
  attributes: {
90965
- "app.timeback.course_id": resolvedCourseId,
90966
- "app.timeback.resolved_course_id": resolvedCourseId,
90967
- "app.timeback.requested_course_id": requestedCourseId,
90968
- "app.timeback.requested_course_id_status": requestedCourseIdStatus
91174
+ "app.timeback.course_id": integration.courseId,
91175
+ "app.timeback.resolved_course_id": integration.courseId,
91176
+ "app.timeback.requested_course_id": activityData.courseId,
91177
+ "app.timeback.requested_course_id_status": requestedCourseIdStatus,
91178
+ "app.timeback.course_kind": integration.kind,
91179
+ "app.timeback.enrollment_id": enrollmentId
90969
91180
  }
90970
91181
  };
90971
91182
  }
91183
+ async findRecordedRunCourse({
91184
+ gameId,
91185
+ studentId,
91186
+ runId,
91187
+ activityId,
91188
+ courseIds
91189
+ }) {
91190
+ const recorded = await this.deps.db.query.gameTimebackActivityCompletions.findFirst({
91191
+ columns: { courseId: true },
91192
+ where: and(eq(gameTimebackActivityCompletions.gameId, gameId), eq(gameTimebackActivityCompletions.studentId, studentId), eq(gameTimebackActivityCompletions.runId, runId), eq(gameTimebackActivityCompletions.activityId, activityId), inArray(gameTimebackActivityCompletions.courseId, courseIds)),
91193
+ orderBy: (table, { asc }) => [asc(table.createdAt)]
91194
+ });
91195
+ return recorded?.courseId;
91196
+ }
90972
91197
  static recordRuntimeContext({
90973
91198
  operation,
90974
91199
  studentId,
@@ -91166,13 +91391,29 @@ var init_timeback_service = __esm(async () => {
91166
91391
  const integrations = await db.query.gameTimebackIntegrations.findMany({
91167
91392
  where: and(inArray(gameTimebackIntegrations.courseId, courseIds), isActiveGameTimebackIntegrationStatus())
91168
91393
  });
91169
- return mapEnrollmentsToUserEnrollments(enrollments, integrations);
91394
+ return mapEnrollmentsToUserEnrollments(enrollments, integrations, await this.loadBaseCourseIds(integrations));
91170
91395
  } catch (error) {
91171
91396
  setAttribute("app.timeback.enrollment_fetch", "failed");
91172
91397
  setAttribute("app.timeback.enrollment_fetch_error", errorMessage(error));
91173
91398
  return [];
91174
91399
  }
91175
91400
  }
91401
+ async loadBaseCourseIds(integrations) {
91402
+ const courseIdById = new Map(integrations.map((row) => [row.id, row.courseId]));
91403
+ const missingBaseIds = [
91404
+ ...new Set(integrations.map((row) => row.baseIntegrationId).filter((id) => id !== null && !courseIdById.has(id)))
91405
+ ];
91406
+ if (missingBaseIds.length > 0) {
91407
+ const baseRows = await this.deps.db.query.gameTimebackIntegrations.findMany({
91408
+ columns: { id: true, courseId: true },
91409
+ where: inArray(gameTimebackIntegrations.id, missingBaseIds)
91410
+ });
91411
+ for (const row of baseRows) {
91412
+ courseIdById.set(row.id, row.courseId);
91413
+ }
91414
+ }
91415
+ return courseIdById;
91416
+ }
91176
91417
  async setupIntegration(gameId, request, user) {
91177
91418
  return this.withClientTelemetry(async () => {
91178
91419
  const client = this.requireClient();
@@ -91183,7 +91424,7 @@ var init_timeback_service = __esm(async () => {
91183
91424
  "app.timeback.course_count": courses.length
91184
91425
  });
91185
91426
  const existing = await db.query.gameTimebackIntegrations.findMany({
91186
- where: eq(gameTimebackIntegrations.gameId, gameId)
91427
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isBaseGameTimebackIntegration())
91187
91428
  });
91188
91429
  const integrations = [];
91189
91430
  const verboseData = [];
@@ -91331,13 +91572,13 @@ var init_timeback_service = __esm(async () => {
91331
91572
  "app.timeback.subject": request.subject
91332
91573
  });
91333
91574
  const activeConflict = await db.query.gameTimebackIntegrations.findFirst({
91334
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, request.grade), eq(gameTimebackIntegrations.subject, request.subject), isActiveGameTimebackIntegrationStatus())
91575
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, request.grade), eq(gameTimebackIntegrations.subject, request.subject), isActiveBaseGameTimebackIntegration())
91335
91576
  });
91336
91577
  if (activeConflict) {
91337
91578
  throw new ConflictError(`A TimeBack integration already exists for ${request.subject} Grade ${request.grade}`);
91338
91579
  }
91339
91580
  const removedCandidates = await db.query.gameTimebackIntegrations.findMany({
91340
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, request.grade), eq(gameTimebackIntegrations.subject, request.subject), eq(gameTimebackIntegrations.status, DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS))
91581
+ 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())
91341
91582
  });
91342
91583
  for (const candidate of removedCandidates) {
91343
91584
  const config = await client.course.getConfig(candidate.courseId).catch((error) => {
@@ -91375,10 +91616,10 @@ var init_timeback_service = __esm(async () => {
91375
91616
  }
91376
91617
  }
91377
91618
  const reference = await db.query.gameTimebackIntegrations.findFirst({
91378
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus()),
91619
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration()),
91379
91620
  orderBy: (table, { asc }) => [asc(table.createdAt)]
91380
91621
  }) ?? await db.query.gameTimebackIntegrations.findFirst({
91381
- where: eq(gameTimebackIntegrations.gameId, gameId),
91622
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isBaseGameTimebackIntegration()),
91382
91623
  orderBy: (table, { asc }) => [asc(table.createdAt)]
91383
91624
  });
91384
91625
  if (!reference) {
@@ -91408,10 +91649,77 @@ var init_timeback_service = __esm(async () => {
91408
91649
  return integration;
91409
91650
  });
91410
91651
  }
91652
+ async createHoleFillingCourse(gameId, user, request) {
91653
+ return this.withClientTelemetry(async () => {
91654
+ const client = this.requireClient();
91655
+ const db = this.deps.db;
91656
+ await this.deps.validateDeveloperAccess(user, gameId);
91657
+ TimebackService.recordIntegrationOperation("create_hole_filling_course", {
91658
+ "app.timeback.base_course_id": request.baseCourseId,
91659
+ "app.timeback.custom_course_id": Boolean(request.courseId)
91660
+ });
91661
+ const integration = await db.transaction(async (tx) => {
91662
+ const database = tx;
91663
+ const base = await findGameTimebackIntegrationForUpdate(database, and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, request.baseCourseId), isActiveBaseGameTimebackIntegration()));
91664
+ if (!base) {
91665
+ throw new NotFoundError("Active base course", `${gameId}:${request.baseCourseId}`);
91666
+ }
91667
+ const config = buildHoleFillingCourseConfig(await client.course.getConfig(base.courseId), request);
91668
+ let courseId = request.courseId;
91669
+ if (courseId) {
91670
+ const takenLocally = await database.query.gameTimebackIntegrations.findFirst({
91671
+ columns: { id: true },
91672
+ where: eq(gameTimebackIntegrations.courseId, courseId)
91673
+ });
91674
+ const remote = takenLocally ? undefined : await client.course.findCourse(courseId);
91675
+ if (takenLocally || remote && !isUnregisteredHoleFillingOf(remote, base)) {
91676
+ throw new ConflictError(`A Timeback course with ID "${courseId}" already exists`);
91677
+ }
91678
+ if (remote) {
91679
+ setAttribute("app.timeback.course_create_mode", "resumed");
91680
+ await client.course.resumeSetup(courseId, config);
91681
+ } else {
91682
+ courseId = undefined;
91683
+ }
91684
+ }
91685
+ if (!courseId) {
91686
+ const created = await client.course.setup(config);
91687
+ courseId = created.courseId;
91688
+ }
91689
+ setAttribute("app.timeback.course_id", courseId);
91690
+ const [inserted] = await database.insert(gameTimebackIntegrations).values({
91691
+ gameId,
91692
+ courseId,
91693
+ grade: base.grade,
91694
+ subject: base.subject,
91695
+ totalXp: request.totalXp,
91696
+ kind: HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND,
91697
+ baseIntegrationId: base.id
91698
+ }).returning();
91699
+ return inserted;
91700
+ });
91701
+ if (!integration) {
91702
+ throw new InternalError("Hole-filling course creation did not return an integration");
91703
+ }
91704
+ return toGameTimebackIntegration(integration);
91705
+ });
91706
+ }
91707
+ async getHoleFillingCourses(gameId, user) {
91708
+ await this.deps.validateGameManagementAccess(user, gameId);
91709
+ const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
91710
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.kind, HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND), isActiveGameTimebackIntegrationStatus()),
91711
+ orderBy: (table, { asc }) => [
91712
+ asc(table.subject),
91713
+ asc(table.grade),
91714
+ asc(table.createdAt)
91715
+ ]
91716
+ });
91717
+ return rows.map((row) => toGameTimebackIntegration(row));
91718
+ }
91411
91719
  async getIntegrations(gameId, user) {
91412
91720
  await this.deps.validateGameManagementAccess(user, gameId);
91413
91721
  const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
91414
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
91722
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration())
91415
91723
  });
91416
91724
  return rows.map((row) => toGameTimebackIntegration(row));
91417
91725
  }
@@ -91420,7 +91728,7 @@ var init_timeback_service = __esm(async () => {
91420
91728
  const client = this.requireClient();
91421
91729
  await this.deps.validateGameManagementAccess(user, gameId);
91422
91730
  const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
91423
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.status, DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS)),
91731
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.status, DEACTIVATED_GAME_TIMEBACK_INTEGRATION_STATUS), isBaseGameTimebackIntegration()),
91424
91732
  orderBy: (table, { asc }) => [asc(table.subject), asc(table.grade)]
91425
91733
  });
91426
91734
  setAttribute("app.timeback.removed_integration_count", rows.length);
@@ -91451,6 +91759,17 @@ var init_timeback_service = __esm(async () => {
91451
91759
  if (!integration) {
91452
91760
  throw new NotFoundError("Timeback course", `${gameId}:${courseId}`);
91453
91761
  }
91762
+ if (integration.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND) {
91763
+ throw new ConflictError("This is a hole-filling course and may be referenced by Timeback, so it can't be removed.");
91764
+ }
91765
+ const activeHoleFilling = await database.query.gameTimebackIntegrations.findFirst({
91766
+ columns: { id: true },
91767
+ where: and(eq(gameTimebackIntegrations.baseIntegrationId, integration.id), isActiveGameTimebackIntegrationStatus())
91768
+ });
91769
+ if (activeHoleFilling) {
91770
+ setAttribute("app.timeback.removal_blocked", "hole_filling_courses");
91771
+ throw new ConflictError("This course has hole-filling courses that may be referenced by Timeback, so it can't be removed.");
91772
+ }
91454
91773
  const activeEnrollments = await client.roster.getCourseRoster(courseId, {
91455
91774
  role: "student",
91456
91775
  includeUsers: false
@@ -91488,11 +91807,18 @@ var init_timeback_service = __esm(async () => {
91488
91807
  if (!integration) {
91489
91808
  throw new NotFoundError("Removed Timeback course", `${gameId}:${courseId}`);
91490
91809
  }
91491
- const activeConflict = await database.query.gameTimebackIntegrations.findFirst({
91492
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, integration.grade), eq(gameTimebackIntegrations.subject, integration.subject), isActiveGameTimebackIntegrationStatus())
91493
- });
91494
- if (activeConflict) {
91495
- throw new ConflictError(`An active TimeBack course already exists for ${integration.subject} Grade ${integration.grade}`);
91810
+ if (integration.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND) {
91811
+ const base = integration.baseIntegrationId ? await findGameTimebackIntegrationForUpdate(database, and(eq(gameTimebackIntegrations.id, integration.baseIntegrationId), isActiveBaseGameTimebackIntegration())) : undefined;
91812
+ if (!base) {
91813
+ throw new ConflictError("Restore the base course first: a hole-filling course can only be active beside its base course.");
91814
+ }
91815
+ } else {
91816
+ const activeConflict = await database.query.gameTimebackIntegrations.findFirst({
91817
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, integration.grade), eq(gameTimebackIntegrations.subject, integration.subject), isActiveBaseGameTimebackIntegration())
91818
+ });
91819
+ if (activeConflict) {
91820
+ throw new ConflictError(`An active TimeBack course already exists for ${integration.subject} Grade ${integration.grade}`);
91821
+ }
91496
91822
  }
91497
91823
  const now = new Date;
91498
91824
  const [updatedIntegration] = await database.update(gameTimebackIntegrations).set({
@@ -91531,52 +91857,66 @@ var init_timeback_service = __esm(async () => {
91531
91857
  TimebackService.recordIntegrationOperation("update_integration", {
91532
91858
  "app.timeback.course_id": courseId
91533
91859
  });
91534
- const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
91535
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId), isActiveGameTimebackIntegrationStatus())
91536
- });
91537
- if (!integration) {
91538
- throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
91539
- }
91540
- const timebackConfig = await client.course.getConfig(courseId);
91541
- const liveSubject = timebackConfig.course.subjects[0];
91542
- const subject = resolveIntegrationSubject(patch.subject, liveSubject, integration.subject);
91543
- if (!isTimebackSubject(subject)) {
91544
- throw new ValidationError(`Invalid subject "${subject}"`);
91545
- }
91546
- setAttributes({
91547
- "app.timeback.grade": integration.grade,
91548
- "app.timeback.previous_subject": integration.subject,
91549
- "app.timeback.next_subject": subject
91550
- });
91551
- if (subject !== integration.subject) {
91552
- const subjectConflict = await this.deps.db.query.gameTimebackIntegrations.findFirst({
91553
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, integration.grade), eq(gameTimebackIntegrations.subject, subject), isActiveGameTimebackIntegrationStatus())
91860
+ const updated = await this.deps.db.transaction(async (tx) => {
91861
+ const database = tx;
91862
+ const integration = await findGameTimebackIntegrationForUpdate(database, and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId), isActiveGameTimebackIntegrationStatus()));
91863
+ if (!integration) {
91864
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
91865
+ }
91866
+ const timebackConfig = await client.course.getConfig(courseId);
91867
+ const liveSubject = timebackConfig.course.subjects[0];
91868
+ const subject = resolveIntegrationSubject(patch.subject, liveSubject, integration.subject);
91869
+ if (!isTimebackSubject(subject)) {
91870
+ throw new ValidationError(`Invalid subject "${subject}"`);
91871
+ }
91872
+ setAttributes({
91873
+ "app.timeback.grade": integration.grade,
91874
+ "app.timeback.previous_subject": integration.subject,
91875
+ "app.timeback.next_subject": subject
91554
91876
  });
91555
- if (subjectConflict && subjectConflict.id !== integration.id) {
91556
- throw new ValidationError(`A TimeBack integration already exists for ${subject} Grade ${integration.grade}`);
91877
+ if (subject !== integration.subject) {
91878
+ if (integration.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND) {
91879
+ throw new ValidationError("A hole-filling course must keep the subject of its base course");
91880
+ }
91881
+ const linkedHoleFilling = await database.query.gameTimebackIntegrations.findFirst({
91882
+ columns: { id: true },
91883
+ where: eq(gameTimebackIntegrations.baseIntegrationId, integration.id)
91884
+ });
91885
+ if (linkedHoleFilling) {
91886
+ throw new ValidationError("This course has hole-filling courses, so its subject cannot change");
91887
+ }
91557
91888
  }
91558
- }
91559
- const totalXp = "totalXp" in patch ? patch.totalXp ?? null : getTotalXpFromTimebackConfig(timebackConfig) ?? integration.totalXp ?? null;
91560
- const masterableUnits = "masterableUnits" in patch ? patch.masterableUnits ?? null : getMasterableUnitsFromTimebackConfig(timebackConfig);
91561
- setAttributes({
91562
- "app.timeback.total_xp": totalXp,
91563
- "app.timeback.masterable_units": masterableUnits
91889
+ if (subject !== integration.subject) {
91890
+ const subjectConflict = await database.query.gameTimebackIntegrations.findFirst({
91891
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, integration.grade), eq(gameTimebackIntegrations.subject, subject), isActiveBaseGameTimebackIntegration())
91892
+ });
91893
+ if (subjectConflict && subjectConflict.id !== integration.id) {
91894
+ throw new ValidationError(`A TimeBack integration already exists for ${subject} Grade ${integration.grade}`);
91895
+ }
91896
+ }
91897
+ const totalXp = "totalXp" in patch ? patch.totalXp ?? null : getTotalXpFromTimebackConfig(timebackConfig) ?? integration.totalXp ?? null;
91898
+ const masterableUnits = "masterableUnits" in patch ? patch.masterableUnits ?? null : getMasterableUnitsFromTimebackConfig(timebackConfig);
91899
+ setAttributes({
91900
+ "app.timeback.total_xp": totalXp,
91901
+ "app.timeback.masterable_units": masterableUnits
91902
+ });
91903
+ await client.course.update(courseId, TimebackService.patchTimebackConfig(timebackConfig, {
91904
+ ...patch,
91905
+ subject,
91906
+ totalXp,
91907
+ masterableUnits,
91908
+ grade: integration.grade
91909
+ }));
91910
+ const [row] = await database.update(gameTimebackIntegrations).set({
91911
+ subject,
91912
+ totalXp,
91913
+ updatedAt: new Date
91914
+ }).where(eq(gameTimebackIntegrations.id, integration.id)).returning();
91915
+ if (!row) {
91916
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
91917
+ }
91918
+ return row;
91564
91919
  });
91565
- await client.course.update(courseId, TimebackService.patchTimebackConfig(timebackConfig, {
91566
- ...patch,
91567
- subject,
91568
- totalXp,
91569
- masterableUnits,
91570
- grade: integration.grade
91571
- }));
91572
- const [updated] = await this.deps.db.update(gameTimebackIntegrations).set({
91573
- subject,
91574
- totalXp,
91575
- updatedAt: new Date
91576
- }).where(eq(gameTimebackIntegrations.id, integration.id)).returning();
91577
- if (!updated) {
91578
- throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
91579
- }
91580
91920
  return toGameTimebackIntegration(updated);
91581
91921
  });
91582
91922
  }
@@ -91587,7 +91927,7 @@ var init_timeback_service = __esm(async () => {
91587
91927
  await this.deps.validateDeveloperAccess(user, gameId);
91588
91928
  TimebackService.recordIntegrationOperation("verify_integration");
91589
91929
  const integrations = await db.query.gameTimebackIntegrations.findMany({
91590
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
91930
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration())
91591
91931
  });
91592
91932
  if (integrations.length === 0) {
91593
91933
  throw new NotFoundError("Timeback integration", gameId);
@@ -91625,7 +91965,7 @@ var init_timeback_service = __esm(async () => {
91625
91965
  const client = this.requireClient();
91626
91966
  await this.deps.validateDeveloperAccess(user, gameId);
91627
91967
  const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
91628
- where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
91968
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), isActiveBaseGameTimebackIntegration())
91629
91969
  });
91630
91970
  if (!integration) {
91631
91971
  throw new NotFoundError("Timeback integration", gameId);
@@ -91639,17 +91979,24 @@ var init_timeback_service = __esm(async () => {
91639
91979
  const db = this.deps.db;
91640
91980
  await this.deps.validateDeveloperAccess(user, gameId);
91641
91981
  TimebackService.recordIntegrationOperation("delete_integrations");
91642
- const integrations = await db.query.gameTimebackIntegrations.findMany({
91643
- where: eq(gameTimebackIntegrations.gameId, gameId)
91982
+ await db.transaction(async (tx) => {
91983
+ const database = tx;
91984
+ await database.select({ id: gameTimebackIntegrations.id }).from(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId)).for("update");
91985
+ const integrations = await database.select().from(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
91986
+ if (integrations.length === 0) {
91987
+ throw new NotFoundError("Timeback integration", gameId);
91988
+ }
91989
+ setAttribute("app.timeback.integration_count", integrations.length);
91990
+ const holeFillingCount = integrations.filter((integration) => integration.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND).length;
91991
+ if (holeFillingCount > 0) {
91992
+ setAttribute("app.timeback.hole_filling_count", holeFillingCount);
91993
+ 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.`);
91994
+ }
91995
+ for (const integration of integrations) {
91996
+ await client.course.cleanup(integration.courseId);
91997
+ }
91998
+ await database.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
91644
91999
  });
91645
- if (integrations.length === 0) {
91646
- throw new NotFoundError("Timeback integration", gameId);
91647
- }
91648
- setAttribute("app.timeback.integration_count", integrations.length);
91649
- for (const integration of integrations) {
91650
- await client.course.cleanup(integration.courseId);
91651
- }
91652
- await db.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
91653
92000
  });
91654
92001
  }
91655
92002
  static patchCourseMetadata(metadata2, totalXp, options) {
@@ -91828,17 +92175,21 @@ var init_timeback_service = __esm(async () => {
91828
92175
  activityData
91829
92176
  });
91830
92177
  await this.deps.validateDeveloperAccess(user, gameId);
91831
- const integration = await db.query.gameTimebackIntegrations.findFirst({
91832
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveGameTimebackIntegrationStatus())
91833
- });
91834
- if (!integration) {
91835
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
91836
- }
91837
92178
  const {
92179
+ integration,
91838
92180
  courseId: runtimeCourseId,
92181
+ baseCourseId,
92182
+ otherCourseIds,
91839
92183
  courseName: runtimeCourseName,
92184
+ extensions: runtimeExtensions,
91840
92185
  attributes: courseAttributes
91841
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
92186
+ } = await this.resolveRuntimeCourse({
92187
+ gameId,
92188
+ studentId,
92189
+ runId,
92190
+ activityData,
92191
+ extensions: extensionsWithResumeId
92192
+ });
91842
92193
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
91843
92194
  const dedupeKey = runId !== undefined ? {
91844
92195
  gameId,
@@ -91867,12 +92218,12 @@ var init_timeback_service = __esm(async () => {
91867
92218
  courseId: runtimeCourseId,
91868
92219
  courseName: runtimeCourseName,
91869
92220
  studentEmail: activityData.studentEmail,
91870
- extensions: extensionsWithResumeId,
92221
+ extensions: runtimeExtensions,
91871
92222
  ...runId ? { runId } : {}
91872
92223
  };
91873
- const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion") : undefined;
91874
- const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", effectiveResumeId) : undefined;
91875
- const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed") : undefined;
92224
+ const completionEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "completion", baseCourseId) : undefined;
92225
+ const sessionEndEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "session-end", baseCourseId, effectiveResumeId) : undefined;
92226
+ const completionHistoryEventId = dedupeKey ? TimebackService.buildCompletionCaliperEventId(dedupeKey, "course-completed", baseCourseId) : undefined;
91876
92227
  function emitRecord() {
91877
92228
  return client.activity.record(runtimeCourseId, studentId, {
91878
92229
  ...activityEventContext,
@@ -91896,7 +92247,7 @@ var init_timeback_service = __esm(async () => {
91896
92247
  xpAwarded: emitted.xpAwarded,
91897
92248
  masteredUnitsApplied: emitted.masteredUnitsApplied,
91898
92249
  pctCompleteApp: emitted.pctCompleteApp ?? null
91899
- }));
92250
+ }), otherCourseIds);
91900
92251
  if (outcome.status === "duplicate") {
91901
92252
  blockedByDedupeGuard = true;
91902
92253
  duplicateAward = outcome.award;
@@ -91944,6 +92295,7 @@ var init_timeback_service = __esm(async () => {
91944
92295
  return {
91945
92296
  status: "ok",
91946
92297
  courseId: runtimeCourseId,
92298
+ courseKind: integration.kind,
91947
92299
  xpAwarded: duplicateAward?.xpAwarded ?? 0,
91948
92300
  masteredUnits: duplicateAward?.masteredUnitsApplied ?? 0,
91949
92301
  ...duplicateAward?.pctCompleteApp != null ? { pctCompleteApp: duplicateAward.pctCompleteApp } : {},
@@ -91956,6 +92308,7 @@ var init_timeback_service = __esm(async () => {
91956
92308
  return {
91957
92309
  status: "ok",
91958
92310
  courseId: runtimeCourseId,
92311
+ courseKind: integration.kind,
91959
92312
  xpAwarded: result.xpAwarded,
91960
92313
  masteredUnits: result.masteredUnitsApplied,
91961
92314
  pctCompleteApp: result.pctCompleteApp,
@@ -91972,7 +92325,7 @@ var init_timeback_service = __esm(async () => {
91972
92325
  const client = this.requireClient();
91973
92326
  const db = this.deps.db;
91974
92327
  const integrations = await db.query.gameTimebackIntegrations.findMany({
91975
- where: subject ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.subject, subject), isActiveGameTimebackIntegrationStatus()) : and(eq(gameTimebackIntegrations.gameId, gameId), isActiveGameTimebackIntegrationStatus())
92328
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), subject ? eq(gameTimebackIntegrations.subject, subject) : undefined, isActiveBaseGameTimebackIntegration())
91976
92329
  });
91977
92330
  if (integrations.length === 0) {
91978
92331
  throw new NotFoundError(subject ? `Timeback integrations for game (subject ${subject})` : "Timeback integrations for game");
@@ -92132,7 +92485,6 @@ var init_timeback_service = __esm(async () => {
92132
92485
  }) {
92133
92486
  return this.withClientTelemetry(async () => {
92134
92487
  const client = this.requireClient();
92135
- const db = this.deps.db;
92136
92488
  const hasWindowStartedAtMs = windowStartedAtMs !== undefined;
92137
92489
  const hasWindowSequence = windowSequence !== undefined;
92138
92490
  if (hasWindowStartedAtMs === hasWindowSequence) {
@@ -92160,17 +92512,17 @@ var init_timeback_service = __esm(async () => {
92160
92512
  return inFlightHeartbeat;
92161
92513
  }
92162
92514
  const pendingHeartbeat = (async () => {
92163
- const integration = await db.query.gameTimebackIntegrations.findFirst({
92164
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject), isActiveGameTimebackIntegrationStatus())
92165
- });
92166
- if (!integration) {
92167
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
92168
- }
92169
92515
  const {
92170
92516
  courseId: runtimeCourseId,
92171
92517
  courseName: runtimeCourseName,
92518
+ extensions: runtimeExtensions,
92172
92519
  attributes: courseAttributes
92173
- } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
92520
+ } = await this.resolveRuntimeCourse({
92521
+ gameId,
92522
+ studentId,
92523
+ activityData,
92524
+ extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId)
92525
+ });
92174
92526
  const activeTimeSeconds = timingData.activeMs / 1000;
92175
92527
  const inactiveTimeSeconds = timingData.pausedMs / 1000;
92176
92528
  if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
@@ -92186,7 +92538,7 @@ var init_timeback_service = __esm(async () => {
92186
92538
  courseId: runtimeCourseId,
92187
92539
  courseName: runtimeCourseName,
92188
92540
  studentEmail: activityData.studentEmail,
92189
- extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId),
92541
+ extensions: runtimeExtensions,
92190
92542
  ...runId ? { runId } : {}
92191
92543
  });
92192
92544
  }
@@ -92291,7 +92643,7 @@ var init_timeback_service = __esm(async () => {
92291
92643
  const db = this.deps.db;
92292
92644
  await this.deps.validateDeveloperAccess(user, options.gameId);
92293
92645
  const integration = await db.query.gameTimebackIntegrations.findFirst({
92294
- where: and(eq(gameTimebackIntegrations.gameId, options.gameId), eq(gameTimebackIntegrations.subject, options.subject), isActiveGameTimebackIntegrationStatus())
92646
+ where: and(eq(gameTimebackIntegrations.gameId, options.gameId), eq(gameTimebackIntegrations.subject, options.subject), isActiveBaseGameTimebackIntegration())
92295
92647
  });
92296
92648
  if (!integration) {
92297
92649
  throw new ValidationError(`Subject "${options.subject}" is not configured for game ${options.gameId}`);
@@ -93160,9 +93512,47 @@ var init_account_service = __esm(async () => {
93160
93512
  await init_errors8();
93161
93513
  });
93162
93514
 
93515
+ // ../api-core/src/services/embed-partner.service.ts
93516
+ class EmbedPartnerService {
93517
+ deps;
93518
+ constructor(deps) {
93519
+ this.deps = deps;
93520
+ }
93521
+ async ensurePartner(clientId, name2) {
93522
+ const [partner] = await this.deps.db.insert(embedPartners).values({ clientId, name: name2 }).onConflictDoUpdate({
93523
+ target: embedPartners.clientId,
93524
+ set: { name: name2, updatedAt: new Date }
93525
+ }).returning();
93526
+ if (!partner) {
93527
+ throw new InternalError("Failed to ensure embed partner");
93528
+ }
93529
+ return partner;
93530
+ }
93531
+ addOrigin(clientId, origin) {
93532
+ return this.setOrigins(clientId, sql`CASE WHEN ${origin} = ANY(${embedPartners.allowedOrigins})
93533
+ THEN ${embedPartners.allowedOrigins}
93534
+ ELSE array_append(${embedPartners.allowedOrigins}, ${origin}) END`);
93535
+ }
93536
+ removeOrigin(clientId, origin) {
93537
+ return this.setOrigins(clientId, sql`array_remove(${embedPartners.allowedOrigins}, ${origin})`);
93538
+ }
93539
+ async setOrigins(clientId, origins) {
93540
+ const [partner] = await this.deps.db.update(embedPartners).set({ allowedOrigins: origins, updatedAt: new Date }).where(eq(embedPartners.clientId, clientId)).returning();
93541
+ if (!partner) {
93542
+ throw new NotFoundError("Embed partner", clientId);
93543
+ }
93544
+ return partner;
93545
+ }
93546
+ }
93547
+ var init_embed_partner_service = __esm(() => {
93548
+ init_drizzle_orm();
93549
+ init_tables_index();
93550
+ init_errors2();
93551
+ });
93552
+
93163
93553
  // ../api-core/src/utils/embed.util.ts
93164
- function canPartnerLaunch(partner, origin, gameId) {
93165
- return partner.enabled && partner.allowedOrigins.includes(origin) && partner.games.some((grant) => grant.gameId === gameId);
93554
+ function canPartnerLaunch(partner, origin) {
93555
+ return partner.enabled && partner.allowedOrigins.includes(origin);
93166
93556
  }
93167
93557
  function embedPageUrl(hubOrigin, slug, parentOrigin) {
93168
93558
  const url = new URL(`${EMBED_PAGE_PATH}/${encodeURIComponent(slug)}`, hubOrigin);
@@ -93190,12 +93580,9 @@ class EmbedService {
93190
93580
  return partner;
93191
93581
  }
93192
93582
  async isFrameOriginAllowed(origin, slug) {
93193
- const grants = await this.deps.db.select({
93194
- enabled: embedPartners.enabled,
93195
- allowedOrigins: embedPartners.allowedOrigins,
93196
- gameId: embedPartnerGames.gameId
93197
- }).from(embedPartnerGames).innerJoin(embedPartners, eq(embedPartners.id, embedPartnerGames.partnerId)).innerJoin(games, eq(games.id, embedPartnerGames.gameId)).where(eq(games.slug, slug));
93198
- return grants.some((grant) => canPartnerLaunch({ ...grant, games: [grant] }, origin, grant.gameId));
93583
+ const game = this.deps.db.select({ id: games.id }).from(games).where(eq(games.slug, slug));
93584
+ 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);
93585
+ return partner !== undefined;
93199
93586
  }
93200
93587
  async createLaunch(partner, input) {
93201
93588
  const game = await this.deps.db.query.games.findFirst({
@@ -93206,9 +93593,8 @@ class EmbedService {
93206
93593
  throw new NotFoundError("Game", input.gameSlug);
93207
93594
  }
93208
93595
  setAttributes({ "app.game.id": game.id, "app.game.slug": game.slug });
93209
- const grants = await this.grantsFor(partner.id, game.id);
93210
- if (!canPartnerLaunch({ ...partner, games: grants }, input.parentOrigin, game.id)) {
93211
- throw new AccessDeniedError("This partner may not launch that game into that origin");
93596
+ if (!canPartnerLaunch(partner, input.parentOrigin)) {
93597
+ throw new AccessDeniedError("This partner may not launch into that origin");
93212
93598
  }
93213
93599
  setAttributes({ "app.user.timeback_id": input.timebackId });
93214
93600
  const user = await this.deps.resolveTimebackStudent(input.timebackId);
@@ -93270,20 +93656,13 @@ class EmbedService {
93270
93656
  throw new NotFoundError("Embed launch", launchId);
93271
93657
  }
93272
93658
  setAttributes({ "app.game.id": launch.gameId, "app.user.id": launch.userId });
93273
- const grants = await this.grantsFor(partner.id, launch.gameId);
93274
- const outcome = renewalRefusal(launch, () => canPartnerLaunch({ ...partner, games: grants }, launch.parentOrigin, launch.gameId));
93659
+ const outcome = renewalRefusal(launch, () => canPartnerLaunch(partner, launch.parentOrigin));
93275
93660
  setAttributes({ "app.embed.outcome": outcome ?? "renewed" });
93276
93661
  if (outcome) {
93277
93662
  throw new AccessDeniedError("This launch has ended");
93278
93663
  }
93279
93664
  return this.deps.mintGameToken(launch.gameId, launch.userId);
93280
93665
  }
93281
- grantsFor(partnerId, gameId) {
93282
- return this.deps.db.query.embedPartnerGames.findMany({
93283
- where: and(eq(embedPartnerGames.partnerId, partnerId), eq(embedPartnerGames.gameId, gameId)),
93284
- columns: { gameId: true }
93285
- });
93286
- }
93287
93666
  async endLaunch(launchId) {
93288
93667
  await this.deps.db.update(embedLaunches).set({ endedAt: sql`coalesce(${embedLaunches.endedAt}, now())`, passHash: null }).where(eq(embedLaunches.id, launchId));
93289
93668
  }
@@ -93300,15 +93679,12 @@ class EmbedService {
93300
93679
  "app.embed.partner_id": launch.partnerId,
93301
93680
  "app.user.id": launch.userId
93302
93681
  });
93303
- const [partner, grants] = await Promise.all([
93304
- this.deps.db.query.embedPartners.findFirst({
93305
- where: eq(embedPartners.id, launch.partnerId),
93306
- columns: { enabled: true, allowedOrigins: true }
93307
- }),
93308
- this.grantsFor(launch.partnerId, launch.gameId)
93309
- ]);
93682
+ const partner = await this.deps.db.query.embedPartners.findFirst({
93683
+ where: eq(embedPartners.id, launch.partnerId),
93684
+ columns: { enabled: true, allowedOrigins: true }
93685
+ });
93310
93686
  const originMatches = launch.parentOrigin === input.parentOrigin;
93311
- const stillAllowed = partner !== undefined && canPartnerLaunch({ ...partner, games: grants }, launch.parentOrigin, launch.gameId);
93687
+ const stillAllowed = partner !== undefined && canPartnerLaunch(partner, launch.parentOrigin);
93312
93688
  if (!originMatches || !stillAllowed) {
93313
93689
  await this.endLaunch(launch.id);
93314
93690
  setAttributes({
@@ -93726,14 +94102,16 @@ function createStandaloneServices(deps) {
93726
94102
  return { ...profile, timeback: timebackData };
93727
94103
  }
93728
94104
  });
94105
+ const embedPartner = new EmbedPartnerService({ db });
93729
94106
  const verify = new VerifyService({
93730
94107
  db,
93731
94108
  validateGameToken: auth.validateGameToken.bind(auth)
93732
94109
  });
93733
- return { account, embed, lti, session, user, verify };
94110
+ return { account, embed, embedPartner, lti, session, user, verify };
93734
94111
  }
93735
94112
  var init_standalone = __esm(async () => {
93736
94113
  init_spans();
94114
+ init_embed_partner_service();
93737
94115
  init_embed_service();
93738
94116
  init_lti_service();
93739
94117
  init_session_service();
@@ -110178,7 +110556,7 @@ function ilike2(column6, value) {
110178
110556
  function notIlike2(column6, value) {
110179
110557
  return sql4`${column6} not ilike ${value}`;
110180
110558
  }
110181
- function arrayContains(column6, values) {
110559
+ function arrayContains2(column6, values) {
110182
110560
  if (Array.isArray(values)) {
110183
110561
  if (values.length === 0) {
110184
110562
  throw new Error("arrayContains requires at least one value");
@@ -133497,7 +133875,7 @@ params: ${params}`);
133497
133875
  and: () => and2,
133498
133876
  applyMixins: () => applyMixins2,
133499
133877
  arrayContained: () => arrayContained,
133500
- arrayContains: () => arrayContains,
133878
+ arrayContains: () => arrayContains2,
133501
133879
  arrayOverlaps: () => arrayOverlaps,
133502
133880
  asc: () => asc2,
133503
133881
  avg: () => avg,
@@ -150067,15 +150445,36 @@ function requireUuidParam(value, name2) {
150067
150445
  }
150068
150446
  return value;
150069
150447
  }
150448
+ function bearerToken(request) {
150449
+ const header = request.headers.get("Authorization");
150450
+ return header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
150451
+ }
150070
150452
  async function requirePartner(ctx) {
150071
- const header = ctx.request.headers.get("Authorization");
150072
- const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
150453
+ const token = bearerToken(ctx.request);
150073
150454
  const verified = token ? await ctx.providers.auth.verifyEmbedPartnerToken(token) : null;
150074
150455
  if (!verified) {
150075
150456
  throw ApiError.unauthorized("A valid embed partner access token is required");
150076
150457
  }
150077
150458
  return ctx.services.embed.getPartnerByClientId(verified.clientId);
150078
150459
  }
150460
+ async function requireEmbedAdmin(ctx) {
150461
+ const expected = ctx.config.embedAdminToken;
150462
+ if (!expected) {
150463
+ throw ApiError.serviceUnavailable("Embed partner management is not configured");
150464
+ }
150465
+ const token = bearerToken(ctx.request);
150466
+ const [given, wanted] = token ? await Promise.all([sha256Hex(token), sha256Hex(expected)]) : [];
150467
+ if (!given || given !== wanted) {
150468
+ throw ApiError.unauthorized("A valid embed admin token is required");
150469
+ }
150470
+ }
150471
+ function requireClientIdParam(ctx) {
150472
+ const clientId = EmbedPartnerClientIdSchema.safeParse(ctx.params.clientId);
150473
+ if (!clientId.success) {
150474
+ throw ApiError.badRequest("Missing or invalid clientId");
150475
+ }
150476
+ return clientId.data;
150477
+ }
150079
150478
  async function createLaunch(ctx) {
150080
150479
  const partner = await requirePartner(ctx);
150081
150480
  const body2 = await parseRequestBody(ctx.request, CreateEmbedLaunchSchema);
@@ -150105,6 +150504,27 @@ async function redeemPass(ctx) {
150105
150504
  timeback: launch.profile.timeback
150106
150505
  };
150107
150506
  }
150507
+ async function ensurePartner(ctx) {
150508
+ await requireEmbedAdmin(ctx);
150509
+ const clientId = requireClientIdParam(ctx);
150510
+ const body2 = await parseRequestBody(ctx.request, EnsureEmbedPartnerSchema);
150511
+ return ctx.services.embedPartner.ensurePartner(clientId, body2.name);
150512
+ }
150513
+ async function addPartnerOrigin(ctx) {
150514
+ await requireEmbedAdmin(ctx);
150515
+ const clientId = requireClientIdParam(ctx);
150516
+ const body2 = await parseRequestBody(ctx.request, AddEmbedPartnerOriginSchema);
150517
+ return ctx.services.embedPartner.addOrigin(clientId, body2.origin);
150518
+ }
150519
+ async function removePartnerOrigin(ctx) {
150520
+ await requireEmbedAdmin(ctx);
150521
+ const clientId = requireClientIdParam(ctx);
150522
+ const origin = EmbedOriginSchema.safeParse(ctx.url.searchParams.get("origin"));
150523
+ if (!origin.success) {
150524
+ throw ApiError.badRequest("Missing or invalid origin");
150525
+ }
150526
+ return ctx.services.embedPartner.removeOrigin(clientId, origin.data);
150527
+ }
150108
150528
  var embed2;
150109
150529
  var init_embed_controller = __esm(() => {
150110
150530
  init_src();
@@ -150114,8 +150534,11 @@ var init_embed_controller = __esm(() => {
150114
150534
  init_utils7();
150115
150535
  init_embed_util();
150116
150536
  embed2 = defineControllerNames("embed", {
150537
+ addPartnerOrigin,
150117
150538
  createLaunch,
150539
+ ensurePartner,
150118
150540
  redeemPass,
150541
+ removePartnerOrigin,
150119
150542
  renewToken
150120
150543
  });
150121
150544
  });
@@ -150722,7 +151145,7 @@ function parseQtiLibraryParams(searchParams) {
150722
151145
  limit
150723
151146
  };
150724
151147
  }
150725
- 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;
151148
+ 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;
150726
151149
  var init_timeback_controller = __esm(() => {
150727
151150
  init_esm();
150728
151151
  init_src();
@@ -150801,6 +151224,27 @@ var init_timeback_controller = __esm(() => {
150801
151224
  const body2 = await parseRequestBody(ctx.request, CreateGameTimebackIntegrationRequestSchema);
150802
151225
  return ctx.services.timeback.createIntegration(gameId, ctx.user, body2);
150803
151226
  });
151227
+ getHoleFillingCourses = requireGameManagementAccess(async (ctx) => {
151228
+ const gameId = ctx.params.gameId;
151229
+ if (!gameId) {
151230
+ throw ApiError.badRequest("Missing gameId");
151231
+ }
151232
+ if (!isValidUUID(gameId)) {
151233
+ throw ApiError.unprocessableEntity("Invalid gameId format");
151234
+ }
151235
+ return ctx.services.timeback.getHoleFillingCourses(gameId, ctx.user);
151236
+ });
151237
+ createHoleFillingCourse = requireDeveloper(async (ctx) => {
151238
+ const gameId = ctx.params.gameId;
151239
+ if (!gameId) {
151240
+ throw ApiError.badRequest("Missing gameId");
151241
+ }
151242
+ if (!isValidUUID(gameId)) {
151243
+ throw ApiError.unprocessableEntity("Invalid gameId format");
151244
+ }
151245
+ const body2 = await parseRequestBody(ctx.request, CreateGameTimebackHoleFillingCourseRequestSchema);
151246
+ return ctx.services.timeback.createHoleFillingCourse(gameId, ctx.user, body2);
151247
+ });
150804
151248
  updateIntegration = requireDeveloper(async (ctx) => {
150805
151249
  const { gameId, courseId } = ctx.params;
150806
151250
  if (!gameId || !courseId) {
@@ -151585,6 +152029,8 @@ var init_timeback_controller = __esm(() => {
151585
152029
  getIntegrations,
151586
152030
  getRemovedIntegrations,
151587
152031
  createIntegration,
152032
+ getHoleFillingCourses,
152033
+ createHoleFillingCourse,
151588
152034
  updateIntegration,
151589
152035
  deactivateCourse,
151590
152036
  reactivateCourse,