@playcademy/vite-plugin 0.2.23 → 0.2.24-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -24406,7 +24406,8 @@ var TIMEBACK_COMPONENT_RESOURCE_DEFAULTS;
24406
24406
  var init_timeback2 = __esm(() => {
24407
24407
  TIMEBACK_ROUTES = {
24408
24408
  END_ACTIVITY: "/integrations/timeback/end-activity",
24409
- GET_XP: "/integrations/timeback/xp"
24409
+ GET_XP: "/integrations/timeback/xp",
24410
+ HEARTBEAT: "/integrations/timeback/heartbeat"
24410
24411
  };
24411
24412
  TIMEBACK_COURSE_DEFAULTS = {
24412
24413
  gradingScheme: "STANDARD",
@@ -24447,6 +24448,7 @@ var init_timeback2 = __esm(() => {
24447
24448
  });
24448
24449
  var WORKER_NAMING;
24449
24450
  var SECRETS_PREFIX = "secrets_";
24451
+ var CLOUDFLARE_COMPATIBILITY_DATE = "2025-10-11";
24450
24452
  var init_workers = __esm(() => {
24451
24453
  WORKER_NAMING = {
24452
24454
  STAGING_PREFIX: "staging-",
@@ -25334,7 +25336,7 @@ var package_default;
25334
25336
  var init_package = __esm(() => {
25335
25337
  package_default = {
25336
25338
  name: "@playcademy/sandbox",
25337
- version: "0.3.16",
25339
+ version: "0.3.17-beta.13",
25338
25340
  description: "Local development server for Playcademy game development",
25339
25341
  type: "module",
25340
25342
  exports: {
@@ -29949,6 +29951,7 @@ var init_esm = __esm(() => {
29949
29951
  function createMinimalConfig(overrides) {
29950
29952
  return apiConfigSchema.parse({
29951
29953
  stage: "local",
29954
+ isLocal: false,
29952
29955
  ...overrides
29953
29956
  });
29954
29957
  }
@@ -29976,6 +29979,7 @@ var init_schema = __esm(() => {
29976
29979
  });
29977
29980
  apiConfigSchema = exports_external.object({
29978
29981
  stage: stageSchema,
29982
+ isLocal: exports_external.boolean().default(false),
29979
29983
  baseUrl: exports_external.string().url().optional(),
29980
29984
  gameDomain: exports_external.string().optional(),
29981
29985
  lti: ltiConfigSchema.optional(),
@@ -35610,7 +35614,7 @@ var init_table6 = __esm(() => {
35610
35614
  init_drizzle_orm();
35611
35615
  init_pg_core();
35612
35616
  init_table5();
35613
- userRoleEnum = pgEnum("user_role", ["admin", "player", "developer"]);
35617
+ userRoleEnum = pgEnum("user_role", ["admin", "player", "developer", "teacher"]);
35614
35618
  developerStatusEnum = pgEnum("developer_status", ["none", "pending", "approved"]);
35615
35619
  users = pgTable("user", {
35616
35620
  id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
@@ -47627,13 +47631,11 @@ var dedent;
47627
47631
  var init_dedent = __esm(() => {
47628
47632
  dedent = createDedent({});
47629
47633
  });
47630
- var DEFAULT_COMPATIBILITY_DATE;
47631
47634
  var init_workers2 = __esm(() => {
47632
47635
  init_dedent();
47633
47636
  init_src2();
47634
47637
  init_assets();
47635
47638
  init_multipart();
47636
- DEFAULT_COMPATIBILITY_DATE = new Date().toISOString().slice(0, 10);
47637
47639
  });
47638
47640
  var init_namespaces = __esm(() => {
47639
47641
  init_d1();
@@ -47650,12 +47652,10 @@ var init_core = __esm(() => {
47650
47652
  });
47651
47653
  var CUSTOM_DOMAINS_KV_NAME = "cademy-custom-domains";
47652
47654
  var QUEUE_NAME_PREFIX = "playcademy";
47653
- var DEFAULT_COMPATIBILITY_DATE2;
47654
47655
  var GAME_WORKER_DOMAIN_PRODUCTION;
47655
47656
  var GAME_WORKER_DOMAIN_STAGING;
47656
47657
  var init_constants2 = __esm(() => {
47657
47658
  init_src();
47658
- DEFAULT_COMPATIBILITY_DATE2 = new Date().toISOString().slice(0, 10);
47659
47659
  GAME_WORKER_DOMAIN_PRODUCTION = GAME_WORKER_DOMAINS.production;
47660
47660
  GAME_WORKER_DOMAIN_STAGING = GAME_WORKER_DOMAINS.staging;
47661
47661
  });
@@ -50332,6 +50332,7 @@ class DeployService {
50332
50332
  try {
50333
50333
  result = await this.timeStep("Cloudflare deploy", () => cf.deploy(deploymentId, request.code, env, {
50334
50334
  ...deploymentOptions,
50335
+ compatibilityDate: request.compatibilityDate ?? CLOUDFLARE_COMPATIBILITY_DATE,
50335
50336
  compatibilityFlags: request.compatibilityFlags,
50336
50337
  existingResources: activeDeployment?.resources ?? undefined,
50337
50338
  assetsPath: frontendAssetsPath,
@@ -50433,6 +50434,7 @@ var logger3;
50433
50434
  var init_deploy_service = __esm(() => {
50434
50435
  init_drizzle_orm();
50435
50436
  init_playcademy();
50437
+ init_src();
50436
50438
  init_tables_index();
50437
50439
  init_src2();
50438
50440
  init_config2();
@@ -50499,453 +50501,555 @@ var init_developer_service = __esm(() => {
50499
50501
  init_errors();
50500
50502
  logger4 = log.scope("DeveloperService");
50501
50503
  });
50502
-
50503
- class GameService {
50504
- deps;
50505
- static MANIFEST_FETCH_TIMEOUT_MS = 5000;
50506
- static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
50507
- constructor(deps) {
50508
- this.deps = deps;
50509
- }
50510
- static getManifestHost(manifestUrl) {
50511
- try {
50512
- return new URL(manifestUrl).host;
50513
- } catch {
50514
- return manifestUrl;
50515
- }
50504
+ function sleep(ms) {
50505
+ if (ms <= 0) {
50506
+ return Promise.resolve();
50516
50507
  }
50517
- static getFetchErrorMessage(error) {
50518
- let raw;
50519
- if (error instanceof Error) {
50520
- raw = error.message;
50521
- } else if (typeof error === "string") {
50522
- raw = error;
50523
- }
50524
- if (!raw) {
50525
- return;
50508
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
50509
+ }
50510
+ var logger5;
50511
+ var inFlightManifestFetches;
50512
+ var GameService;
50513
+ var init_game_service = __esm(() => {
50514
+ init_drizzle_orm();
50515
+ init_tables_index();
50516
+ init_src2();
50517
+ init_errors();
50518
+ init_deployment_util();
50519
+ logger5 = log.scope("GameService");
50520
+ inFlightManifestFetches = new Map;
50521
+ GameService = class GameService2 {
50522
+ deps;
50523
+ static MANIFEST_FETCH_ATTEMPT_TIMEOUT_MS = 1e4;
50524
+ static MANIFEST_FETCH_MAX_RETRIES = 2;
50525
+ static MANIFEST_FETCH_RETRY_BACKOFF_MS = [250, 750];
50526
+ static MANIFEST_CACHE_TTL_SECONDS = 60;
50527
+ static MANIFEST_CACHE_KEY_PREFIX = "game:manifest";
50528
+ static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
50529
+ constructor(deps) {
50530
+ this.deps = deps;
50531
+ }
50532
+ static getManifestHost(manifestUrl) {
50533
+ try {
50534
+ return new URL(manifestUrl).host;
50535
+ } catch {
50536
+ return manifestUrl;
50537
+ }
50526
50538
  }
50527
- const normalized = raw.replace(/\s+/g, " ").trim();
50528
- if (!normalized) {
50529
- return;
50539
+ static getFetchErrorMessage(error) {
50540
+ let raw;
50541
+ if (error instanceof Error) {
50542
+ raw = error.message;
50543
+ } else if (typeof error === "string") {
50544
+ raw = error;
50545
+ }
50546
+ if (!raw) {
50547
+ return;
50548
+ }
50549
+ const normalized = raw.replace(/\s+/g, " ").trim();
50550
+ if (!normalized) {
50551
+ return;
50552
+ }
50553
+ return normalized.slice(0, GameService2.MAX_FETCH_ERROR_MESSAGE_LENGTH);
50530
50554
  }
50531
- return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
50532
- }
50533
- static isRetryableStatus(status) {
50534
- return status === 429 || status >= 500;
50535
- }
50536
- async list(caller) {
50537
- const db2 = this.deps.db;
50538
- const isAdmin = caller?.role === "admin";
50539
- const isDeveloper = caller?.role === "developer";
50540
- let whereClause;
50541
- if (isAdmin) {
50542
- whereClause = undefined;
50543
- } else if (isDeveloper && caller?.id) {
50544
- whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
50545
- } else {
50546
- whereClause = ne(games.visibility, "internal");
50555
+ static isRetryableStatus(status) {
50556
+ return status === 429 || status >= 500;
50547
50557
  }
50548
- return db2.query.games.findMany({
50549
- where: whereClause,
50550
- orderBy: [desc(games.createdAt)]
50551
- });
50552
- }
50553
- async listManageable(user) {
50554
- this.validateDeveloperStatus(user);
50555
- const db2 = this.deps.db;
50556
- return db2.query.games.findMany({
50557
- where: user.role === "admin" ? undefined : eq(games.developerId, user.id),
50558
- orderBy: [desc(games.createdAt)]
50559
- });
50560
- }
50561
- async getSubjects() {
50562
- const db2 = this.deps.db;
50563
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
50564
- columns: { gameId: true, subject: true },
50565
- orderBy: [asc(gameTimebackIntegrations.createdAt)]
50566
- });
50567
- const subjectMap = {};
50568
- for (const integration of integrations) {
50569
- if (!(integration.gameId in subjectMap)) {
50570
- subjectMap[integration.gameId] = integration.subject;
50558
+ static getRetryBackoffMs(attemptIndex) {
50559
+ const backoff = GameService2.MANIFEST_FETCH_RETRY_BACKOFF_MS;
50560
+ if (backoff.length === 0) {
50561
+ return 0;
50571
50562
  }
50563
+ return backoff[Math.min(attemptIndex, backoff.length - 1)] ?? 0;
50572
50564
  }
50573
- return subjectMap;
50574
- }
50575
- async getById(gameId, caller) {
50576
- const db2 = this.deps.db;
50577
- const game = await db2.query.games.findFirst({
50578
- where: eq(games.id, gameId)
50579
- });
50580
- if (!game) {
50581
- throw new NotFoundError("Game", gameId);
50565
+ static normalizeDeploymentUrl(deploymentUrl) {
50566
+ return deploymentUrl.replace(/\/$/, "");
50582
50567
  }
50583
- this.enforceVisibility(game, caller, gameId);
50584
- return game;
50585
- }
50586
- async getBySlug(slug, caller) {
50587
- const db2 = this.deps.db;
50588
- const game = await db2.query.games.findFirst({
50589
- where: eq(games.slug, slug)
50590
- });
50591
- if (!game) {
50592
- throw new NotFoundError("Game", slug);
50568
+ static getManifestCacheKey(deploymentUrl) {
50569
+ return `${GameService2.MANIFEST_CACHE_KEY_PREFIX}:${deploymentUrl}`;
50593
50570
  }
50594
- this.enforceVisibility(game, caller, slug);
50595
- return game;
50596
- }
50597
- async getManifest(gameId, caller) {
50598
- const game = await this.getById(gameId, caller);
50599
- if (game.gameType !== "hosted" || !game.deploymentUrl) {
50600
- throw new BadRequestError("Game does not have a deployment manifest");
50601
- }
50602
- const deploymentUrl = game.deploymentUrl;
50603
- const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
50604
- const manifestHost = GameService.getManifestHost(manifestUrl);
50605
- const startedAt = Date.now();
50606
- const controller = new AbortController;
50607
- const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_TIMEOUT_MS);
50608
- function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
50609
- return {
50610
- manifestUrl,
50611
- manifestHost,
50612
- deploymentUrl,
50613
- fetchOutcome,
50614
- retryCount: 0,
50615
- durationMs: Date.now() - startedAt,
50616
- manifestErrorKind,
50617
- ...extra
50618
- };
50571
+ async list(caller) {
50572
+ const db2 = this.deps.db;
50573
+ const isAdmin = caller?.role === "admin";
50574
+ const isDeveloper = caller?.role === "developer";
50575
+ let whereClause;
50576
+ if (isAdmin) {
50577
+ whereClause = undefined;
50578
+ } else if (isDeveloper && caller?.id) {
50579
+ whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
50580
+ } else {
50581
+ whereClause = ne(games.visibility, "internal");
50582
+ }
50583
+ return db2.query.games.findMany({
50584
+ where: whereClause,
50585
+ orderBy: [desc(games.createdAt)]
50586
+ });
50619
50587
  }
50620
- let response;
50621
- try {
50622
- response = await fetch(manifestUrl, {
50623
- method: "GET",
50624
- headers: {
50625
- Accept: "application/json"
50626
- },
50627
- signal: controller.signal
50588
+ async listManageable(user) {
50589
+ const seesAllGames = user.role === "admin" || user.role === "teacher";
50590
+ if (!seesAllGames) {
50591
+ this.validateDeveloperStatus(user);
50592
+ }
50593
+ const db2 = this.deps.db;
50594
+ return db2.query.games.findMany({
50595
+ where: seesAllGames ? undefined : eq(games.developerId, user.id),
50596
+ orderBy: [desc(games.createdAt)]
50628
50597
  });
50629
- } catch (error) {
50630
- clearTimeout(timeout);
50631
- const fetchErrorMessage = GameService.getFetchErrorMessage(error);
50632
- const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
50633
- logger5.error("Failed to fetch game manifest", {
50634
- gameId,
50635
- manifestUrl,
50636
- error,
50637
- details
50598
+ }
50599
+ async getSubjects() {
50600
+ const db2 = this.deps.db;
50601
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
50602
+ columns: { gameId: true, subject: true },
50603
+ orderBy: [asc(gameTimebackIntegrations.createdAt)]
50638
50604
  });
50639
- if (error instanceof Error && error.name === "AbortError") {
50640
- throw new TimeoutError("Timed out loading game manifest", details);
50605
+ const subjectMap = {};
50606
+ for (const integration of integrations) {
50607
+ if (!(integration.gameId in subjectMap)) {
50608
+ subjectMap[integration.gameId] = integration.subject;
50609
+ }
50641
50610
  }
50642
- throw new ServiceUnavailableError("Failed to load game manifest", details);
50643
- } finally {
50644
- clearTimeout(timeout);
50611
+ return subjectMap;
50645
50612
  }
50646
- if (!response.ok) {
50647
- const resolvedManifestUrl = response.url || manifestUrl;
50648
- const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
50649
- const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
50650
- const details = buildDetails("bad_status", manifestErrorKind, {
50651
- manifestUrl: resolvedManifestUrl,
50652
- manifestHost: resolvedManifestHost,
50653
- status: response.status,
50654
- contentType: response.headers.get("content-type") ?? undefined,
50655
- cfRay: response.headers.get("cf-ray") ?? undefined,
50656
- redirected: response.redirected,
50657
- ...response.redirected ? {
50658
- originalManifestUrl: manifestUrl,
50659
- originalManifestHost: manifestHost
50660
- } : {}
50661
- });
50662
- const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
50663
- logger5.error("Game manifest returned non-ok response", {
50664
- gameId,
50665
- manifestUrl,
50666
- status: response.status,
50667
- details
50613
+ async getById(gameId, caller) {
50614
+ const db2 = this.deps.db;
50615
+ const game = await db2.query.games.findFirst({
50616
+ where: eq(games.id, gameId)
50668
50617
  });
50669
- if (manifestErrorKind === "temporary") {
50670
- throw new ServiceUnavailableError(message, details);
50618
+ if (!game) {
50619
+ throw new NotFoundError("Game", gameId);
50671
50620
  }
50672
- throw new BadRequestError(message, details);
50621
+ this.enforceVisibility(game, caller, gameId);
50622
+ return game;
50673
50623
  }
50674
- try {
50675
- return await response.json();
50676
- } catch (error) {
50677
- const resolvedManifestUrl = response.url || manifestUrl;
50678
- const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
50679
- const details = buildDetails("invalid_body", "permanent", {
50680
- manifestUrl: resolvedManifestUrl,
50681
- manifestHost: resolvedManifestHost,
50682
- status: response.status,
50683
- contentType: response.headers.get("content-type") ?? undefined,
50684
- cfRay: response.headers.get("cf-ray") ?? undefined,
50685
- redirected: response.redirected,
50686
- ...response.redirected ? {
50687
- originalManifestUrl: manifestUrl,
50688
- originalManifestHost: manifestHost
50689
- } : {}
50690
- });
50691
- logger5.error("Failed to parse game manifest", {
50692
- gameId,
50693
- manifestUrl,
50694
- error,
50695
- details
50624
+ async getBySlug(slug, caller) {
50625
+ const db2 = this.deps.db;
50626
+ const game = await db2.query.games.findFirst({
50627
+ where: eq(games.slug, slug)
50696
50628
  });
50697
- throw new BadRequestError("Failed to parse game manifest", details);
50698
- }
50699
- }
50700
- enforceVisibility(game, caller, lookupIdentifier) {
50701
- if (game.visibility !== "internal") {
50702
- return;
50703
- }
50704
- const isAdmin = caller?.role === "admin";
50705
- const isOwner = caller?.id != null && caller.id === game.developerId;
50706
- if (!isAdmin && !isOwner) {
50707
- throw new NotFoundError("Game", lookupIdentifier);
50708
- }
50709
- }
50710
- async upsertBySlug(slug, data, user) {
50711
- const db2 = this.deps.db;
50712
- const existingGame = await db2.query.games.findFirst({
50713
- where: eq(games.slug, slug)
50714
- });
50715
- const isUpdate = Boolean(existingGame);
50716
- const gameId = existingGame?.id ?? crypto.randomUUID();
50717
- if (isUpdate) {
50718
- await this.validateDeveloperAccess(user, gameId);
50719
- } else {
50720
- this.validateDeveloperStatus(user);
50721
- }
50722
- const gameDataForDb = {
50723
- displayName: data.displayName,
50724
- platform: data.platform,
50725
- metadata: data.metadata,
50726
- mapElementId: data.mapElementId,
50727
- gameType: data.gameType,
50728
- ...data.visibility && { visibility: data.visibility },
50729
- externalUrl: data.externalUrl || null,
50730
- updatedAt: new Date
50731
- };
50732
- let gameResponse;
50733
- if (isUpdate) {
50734
- const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
50735
- if (!updatedGame) {
50736
- logger5.error("Game update returned no rows", { gameId, slug });
50737
- throw new InternalError("DB update failed to return result for existing game");
50738
- }
50739
- gameResponse = updatedGame;
50740
- } else {
50741
- const insertData = {
50742
- ...gameDataForDb,
50743
- id: gameId,
50744
- slug,
50745
- developerId: user.id,
50746
- metadata: data.metadata || {},
50747
- version: data.gameType === "external" ? "external" : "",
50748
- deploymentUrl: null,
50749
- createdAt: new Date
50750
- };
50751
- const [createdGame] = await db2.insert(games).values(insertData).returning();
50752
- if (!createdGame) {
50753
- logger5.error("Game insert returned no rows", { slug, developerId: user.id });
50754
- throw new InternalError("DB insert failed to return result for new game");
50629
+ if (!game) {
50630
+ throw new NotFoundError("Game", slug);
50755
50631
  }
50756
- gameResponse = createdGame;
50632
+ this.enforceVisibility(game, caller, slug);
50633
+ return game;
50757
50634
  }
50758
- if (data.mapElementId) {
50759
- try {
50760
- await db2.update(mapElements).set({
50761
- interactionType: "game_entry",
50762
- gameId: gameResponse.id
50763
- }).where(eq(mapElements.id, data.mapElementId));
50764
- } catch (mapError) {
50765
- logger5.warn("Failed to update map element", {
50766
- mapElementId: data.mapElementId,
50767
- error: mapError
50635
+ async getManifest(gameId, caller) {
50636
+ const game = await this.getById(gameId, caller);
50637
+ if (game.gameType !== "hosted" || !game.deploymentUrl) {
50638
+ throw new BadRequestError("Game does not have a deployment manifest");
50639
+ }
50640
+ const deploymentUrl = GameService2.normalizeDeploymentUrl(game.deploymentUrl);
50641
+ const cacheKey2 = GameService2.getManifestCacheKey(deploymentUrl);
50642
+ const cached = await this.deps.cache.get(cacheKey2);
50643
+ if (cached) {
50644
+ return cached;
50645
+ }
50646
+ const inFlight = inFlightManifestFetches.get(deploymentUrl);
50647
+ if (inFlight) {
50648
+ return inFlight;
50649
+ }
50650
+ const promise = this.fetchManifestFromOrigin({ gameId, deploymentUrl }).then(async (manifest) => {
50651
+ try {
50652
+ await this.deps.cache.set(cacheKey2, manifest, GameService2.MANIFEST_CACHE_TTL_SECONDS);
50653
+ } catch (cacheError) {
50654
+ logger5.warn("Failed to cache game manifest", {
50655
+ gameId,
50656
+ deploymentUrl,
50657
+ cacheKey: cacheKey2,
50658
+ error: cacheError
50659
+ });
50660
+ }
50661
+ return manifest;
50662
+ }).finally(() => {
50663
+ inFlightManifestFetches.delete(deploymentUrl);
50664
+ });
50665
+ inFlightManifestFetches.set(deploymentUrl, promise);
50666
+ return promise;
50667
+ }
50668
+ async fetchManifestFromOrigin(args2) {
50669
+ const { gameId, deploymentUrl } = args2;
50670
+ const manifestUrl = `${deploymentUrl}/playcademy.manifest.json`;
50671
+ const manifestHost = GameService2.getManifestHost(manifestUrl);
50672
+ const startedAt = Date.now();
50673
+ const maxAttempts = GameService2.MANIFEST_FETCH_MAX_RETRIES + 1;
50674
+ for (let attempt = 0;attempt < maxAttempts; attempt++) {
50675
+ const isLastAttempt = attempt === maxAttempts - 1;
50676
+ const outcome = await this.attemptManifestFetch({
50677
+ manifestUrl,
50678
+ manifestHost,
50679
+ deploymentUrl,
50680
+ startedAt,
50681
+ retryCount: attempt
50768
50682
  });
50683
+ if (outcome.kind === "success") {
50684
+ return outcome.manifest;
50685
+ }
50686
+ if (!outcome.retryable || isLastAttempt) {
50687
+ logger5.error("Failed to fetch game manifest", {
50688
+ gameId,
50689
+ manifestUrl,
50690
+ attempt: attempt + 1,
50691
+ maxAttempts,
50692
+ retryable: outcome.retryable,
50693
+ details: outcome.details,
50694
+ throwable: outcome.throwable,
50695
+ cause: outcome.cause
50696
+ });
50697
+ throw outcome.throwable;
50698
+ }
50699
+ const backoffMs = GameService2.getRetryBackoffMs(attempt);
50700
+ logger5.warn("Retrying game manifest fetch after transient failure", {
50701
+ gameId,
50702
+ manifestUrl,
50703
+ attempt: attempt + 1,
50704
+ maxAttempts,
50705
+ backoffMs,
50706
+ details: outcome.details,
50707
+ cause: outcome.cause
50708
+ });
50709
+ await sleep(backoffMs);
50769
50710
  }
50711
+ throw new InternalError("Exhausted manifest fetch retries without result");
50770
50712
  }
50771
- logger5.info("Upserted game", {
50772
- gameId: gameResponse.id,
50773
- slug: gameResponse.slug,
50774
- operation: isUpdate ? "update" : "create",
50775
- displayName: gameResponse.displayName
50776
- });
50777
- return gameResponse;
50778
- }
50779
- async delete(gameId, user) {
50780
- await this.validateDeveloperAccess(user, gameId);
50781
- const db2 = this.deps.db;
50782
- const gameToDelete = await db2.query.games.findFirst({
50783
- where: eq(games.id, gameId),
50784
- columns: { id: true, slug: true, displayName: true }
50785
- });
50786
- if (!gameToDelete?.slug) {
50787
- throw new NotFoundError("Game", gameId);
50788
- }
50789
- const activeDeployment = await db2.query.gameDeployments.findFirst({
50790
- where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
50791
- columns: { deploymentId: true, provider: true, resources: true }
50792
- });
50793
- const customHostnames = await db2.select({
50794
- hostname: gameCustomHostnames.hostname,
50795
- cloudflareId: gameCustomHostnames.cloudflareId,
50796
- environment: gameCustomHostnames.environment
50797
- }).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
50798
- const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
50799
- if (result.length === 0) {
50800
- throw new NotFoundError("Game", gameId);
50801
- }
50802
- logger5.info("Deleted game", {
50803
- gameId: result[0].id,
50804
- slug: gameToDelete.slug,
50805
- hadActiveDeployment: Boolean(activeDeployment),
50806
- customDomainsCount: customHostnames.length
50807
- });
50808
- this.deps.alerts.notifyGameDeletion({
50809
- slug: gameToDelete.slug,
50810
- displayName: gameToDelete.displayName,
50811
- developer: { id: user.id, email: user.email }
50812
- }).catch((error) => {
50813
- logger5.warn("Failed to send deletion alert", { error });
50814
- });
50815
- if (activeDeployment?.provider === "cloudflare" && this.deps.cloudflare) {
50713
+ async attemptManifestFetch(args2) {
50714
+ const { manifestUrl, manifestHost, deploymentUrl, startedAt, retryCount } = args2;
50715
+ function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
50716
+ return {
50717
+ manifestUrl,
50718
+ manifestHost,
50719
+ deploymentUrl,
50720
+ fetchOutcome,
50721
+ retryCount,
50722
+ durationMs: Date.now() - startedAt,
50723
+ manifestErrorKind,
50724
+ ...extra
50725
+ };
50726
+ }
50727
+ const controller = new AbortController;
50728
+ const timeout = setTimeout(() => controller.abort(), GameService2.MANIFEST_FETCH_ATTEMPT_TIMEOUT_MS);
50729
+ let response;
50816
50730
  try {
50817
- await this.deps.cloudflare.delete(activeDeployment.deploymentId, {
50818
- deleteBindings: true,
50819
- resources: activeDeployment.resources ?? undefined,
50820
- customDomains: customHostnames.length > 0 ? customHostnames : undefined,
50821
- gameSlug: gameToDelete.slug
50822
- });
50823
- logger5.info("Cleaned up Cloudflare resources", {
50824
- gameId,
50825
- deploymentId: activeDeployment.deploymentId,
50826
- customDomainsDeleted: customHostnames.length
50731
+ response = await fetch(manifestUrl, {
50732
+ method: "GET",
50733
+ headers: {
50734
+ Accept: "application/json"
50735
+ },
50736
+ signal: controller.signal
50827
50737
  });
50828
- } catch (cfError) {
50829
- logger5.warn("Failed to cleanup Cloudflare resources", {
50830
- gameId,
50831
- deploymentId: activeDeployment.deploymentId,
50832
- error: cfError
50738
+ } catch (error) {
50739
+ const fetchErrorMessage = GameService2.getFetchErrorMessage(error);
50740
+ const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
50741
+ const throwable = error instanceof Error && error.name === "AbortError" ? new TimeoutError("Timed out loading game manifest", details) : new ServiceUnavailableError("Failed to load game manifest", details);
50742
+ return { kind: "failure", retryable: true, throwable, details, cause: error };
50743
+ } finally {
50744
+ clearTimeout(timeout);
50745
+ }
50746
+ if (!response.ok) {
50747
+ const resolvedManifestUrl = response.url || manifestUrl;
50748
+ const resolvedManifestHost = GameService2.getManifestHost(resolvedManifestUrl);
50749
+ const manifestErrorKind = GameService2.isRetryableStatus(response.status) ? "temporary" : "permanent";
50750
+ const details = buildDetails("bad_status", manifestErrorKind, {
50751
+ manifestUrl: resolvedManifestUrl,
50752
+ manifestHost: resolvedManifestHost,
50753
+ status: response.status,
50754
+ contentType: response.headers.get("content-type") ?? undefined,
50755
+ cfRay: response.headers.get("cf-ray") ?? undefined,
50756
+ redirected: response.redirected,
50757
+ ...response.redirected ? {
50758
+ originalManifestUrl: manifestUrl,
50759
+ originalManifestHost: manifestHost
50760
+ } : {}
50833
50761
  });
50762
+ const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
50763
+ const throwable = manifestErrorKind === "temporary" ? new ServiceUnavailableError(message, details) : new BadRequestError(message, details);
50764
+ return {
50765
+ kind: "failure",
50766
+ retryable: manifestErrorKind === "temporary",
50767
+ throwable,
50768
+ details
50769
+ };
50834
50770
  }
50835
50771
  try {
50836
- const deletedKeyId = await this.deps.deleteApiKeyByName(getGameWorkerApiKeyName(gameToDelete.slug), user.id);
50837
- if (deletedKeyId) {
50838
- logger5.info("Cleaned up API key for deleted game", {
50839
- gameId,
50840
- slug: gameToDelete.slug,
50841
- keyId: deletedKeyId
50772
+ const manifest = await response.json();
50773
+ return { kind: "success", manifest };
50774
+ } catch (error) {
50775
+ const resolvedManifestUrl = response.url || manifestUrl;
50776
+ const resolvedManifestHost = GameService2.getManifestHost(resolvedManifestUrl);
50777
+ const details = buildDetails("invalid_body", "permanent", {
50778
+ manifestUrl: resolvedManifestUrl,
50779
+ manifestHost: resolvedManifestHost,
50780
+ status: response.status,
50781
+ contentType: response.headers.get("content-type") ?? undefined,
50782
+ cfRay: response.headers.get("cf-ray") ?? undefined,
50783
+ redirected: response.redirected,
50784
+ ...response.redirected ? {
50785
+ originalManifestUrl: manifestUrl,
50786
+ originalManifestHost: manifestHost
50787
+ } : {}
50788
+ });
50789
+ return {
50790
+ kind: "failure",
50791
+ retryable: false,
50792
+ throwable: new BadRequestError("Failed to parse game manifest", details),
50793
+ details,
50794
+ cause: error
50795
+ };
50796
+ }
50797
+ }
50798
+ enforceVisibility(game, caller, lookupIdentifier) {
50799
+ if (game.visibility !== "internal") {
50800
+ return;
50801
+ }
50802
+ const isAdmin = caller?.role === "admin";
50803
+ const isOwner = caller?.id != null && caller.id === game.developerId;
50804
+ if (!isAdmin && !isOwner) {
50805
+ throw new NotFoundError("Game", lookupIdentifier);
50806
+ }
50807
+ }
50808
+ async upsertBySlug(slug, data, user) {
50809
+ const db2 = this.deps.db;
50810
+ const existingGame = await db2.query.games.findFirst({
50811
+ where: eq(games.slug, slug)
50812
+ });
50813
+ const isUpdate = Boolean(existingGame);
50814
+ const gameId = existingGame?.id ?? crypto.randomUUID();
50815
+ if (isUpdate) {
50816
+ await this.validateDeveloperAccess(user, gameId);
50817
+ } else {
50818
+ this.validateDeveloperStatus(user);
50819
+ }
50820
+ const gameDataForDb = {
50821
+ displayName: data.displayName,
50822
+ platform: data.platform,
50823
+ metadata: data.metadata,
50824
+ mapElementId: data.mapElementId,
50825
+ gameType: data.gameType,
50826
+ ...data.visibility && { visibility: data.visibility },
50827
+ externalUrl: data.externalUrl || null,
50828
+ updatedAt: new Date
50829
+ };
50830
+ let gameResponse;
50831
+ if (isUpdate) {
50832
+ const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
50833
+ if (!updatedGame) {
50834
+ logger5.error("Game update returned no rows", { gameId, slug });
50835
+ throw new InternalError("DB update failed to return result for existing game");
50836
+ }
50837
+ gameResponse = updatedGame;
50838
+ } else {
50839
+ const insertData = {
50840
+ ...gameDataForDb,
50841
+ id: gameId,
50842
+ slug,
50843
+ developerId: user.id,
50844
+ metadata: data.metadata || {},
50845
+ version: data.gameType === "external" ? "external" : "",
50846
+ deploymentUrl: null,
50847
+ createdAt: new Date
50848
+ };
50849
+ const [createdGame] = await db2.insert(games).values(insertData).returning();
50850
+ if (!createdGame) {
50851
+ logger5.error("Game insert returned no rows", { slug, developerId: user.id });
50852
+ throw new InternalError("DB insert failed to return result for new game");
50853
+ }
50854
+ gameResponse = createdGame;
50855
+ }
50856
+ if (data.mapElementId) {
50857
+ try {
50858
+ await db2.update(mapElements).set({
50859
+ interactionType: "game_entry",
50860
+ gameId: gameResponse.id
50861
+ }).where(eq(mapElements.id, data.mapElementId));
50862
+ } catch (mapError) {
50863
+ logger5.warn("Failed to update map element", {
50864
+ mapElementId: data.mapElementId,
50865
+ error: mapError
50842
50866
  });
50843
50867
  }
50844
- } catch (keyError) {
50845
- logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
50846
50868
  }
50869
+ logger5.info("Upserted game", {
50870
+ gameId: gameResponse.id,
50871
+ slug: gameResponse.slug,
50872
+ operation: isUpdate ? "update" : "create",
50873
+ displayName: gameResponse.displayName
50874
+ });
50875
+ return gameResponse;
50847
50876
  }
50848
- return {
50849
- slug: gameToDelete.slug,
50850
- displayName: gameToDelete.displayName
50851
- };
50852
- }
50853
- async validateOwnership(user, gameId) {
50854
- if (user.role === "admin") {
50855
- const gameExists = await this.deps.db.query.games.findFirst({
50877
+ async delete(gameId, user) {
50878
+ await this.validateDeveloperAccess(user, gameId);
50879
+ const db2 = this.deps.db;
50880
+ const gameToDelete = await db2.query.games.findFirst({
50856
50881
  where: eq(games.id, gameId),
50857
- columns: { id: true }
50882
+ columns: { id: true, slug: true, displayName: true }
50858
50883
  });
50859
- if (!gameExists) {
50884
+ if (!gameToDelete?.slug) {
50860
50885
  throw new NotFoundError("Game", gameId);
50861
50886
  }
50862
- return;
50887
+ const activeDeployment = await db2.query.gameDeployments.findFirst({
50888
+ where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
50889
+ columns: { deploymentId: true, provider: true, resources: true }
50890
+ });
50891
+ const customHostnames = await db2.select({
50892
+ hostname: gameCustomHostnames.hostname,
50893
+ cloudflareId: gameCustomHostnames.cloudflareId,
50894
+ environment: gameCustomHostnames.environment
50895
+ }).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
50896
+ const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
50897
+ if (result.length === 0) {
50898
+ throw new NotFoundError("Game", gameId);
50899
+ }
50900
+ logger5.info("Deleted game", {
50901
+ gameId: result[0].id,
50902
+ slug: gameToDelete.slug,
50903
+ hadActiveDeployment: Boolean(activeDeployment),
50904
+ customDomainsCount: customHostnames.length
50905
+ });
50906
+ this.deps.alerts.notifyGameDeletion({
50907
+ slug: gameToDelete.slug,
50908
+ displayName: gameToDelete.displayName,
50909
+ developer: { id: user.id, email: user.email }
50910
+ }).catch((error) => {
50911
+ logger5.warn("Failed to send deletion alert", { error });
50912
+ });
50913
+ if (activeDeployment?.provider === "cloudflare" && this.deps.cloudflare) {
50914
+ try {
50915
+ await this.deps.cloudflare.delete(activeDeployment.deploymentId, {
50916
+ deleteBindings: true,
50917
+ resources: activeDeployment.resources ?? undefined,
50918
+ customDomains: customHostnames.length > 0 ? customHostnames : undefined,
50919
+ gameSlug: gameToDelete.slug
50920
+ });
50921
+ logger5.info("Cleaned up Cloudflare resources", {
50922
+ gameId,
50923
+ deploymentId: activeDeployment.deploymentId,
50924
+ customDomainsDeleted: customHostnames.length
50925
+ });
50926
+ } catch (cfError) {
50927
+ logger5.warn("Failed to cleanup Cloudflare resources", {
50928
+ gameId,
50929
+ deploymentId: activeDeployment.deploymentId,
50930
+ error: cfError
50931
+ });
50932
+ }
50933
+ try {
50934
+ const deletedKeyId = await this.deps.deleteApiKeyByName(getGameWorkerApiKeyName(gameToDelete.slug), user.id);
50935
+ if (deletedKeyId) {
50936
+ logger5.info("Cleaned up API key for deleted game", {
50937
+ gameId,
50938
+ slug: gameToDelete.slug,
50939
+ keyId: deletedKeyId
50940
+ });
50941
+ }
50942
+ } catch (keyError) {
50943
+ logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
50944
+ }
50945
+ }
50946
+ return {
50947
+ slug: gameToDelete.slug,
50948
+ displayName: gameToDelete.displayName
50949
+ };
50863
50950
  }
50864
- const db2 = this.deps.db;
50865
- const gameOwnership = await db2.query.games.findFirst({
50866
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50867
- columns: { id: true }
50868
- });
50869
- if (!gameOwnership) {
50870
- const gameExists = await db2.query.games.findFirst({
50871
- where: eq(games.id, gameId),
50951
+ async validateOwnership(user, gameId) {
50952
+ if (user.role === "admin") {
50953
+ const gameExists = await this.deps.db.query.games.findFirst({
50954
+ where: eq(games.id, gameId),
50955
+ columns: { id: true }
50956
+ });
50957
+ if (!gameExists) {
50958
+ throw new NotFoundError("Game", gameId);
50959
+ }
50960
+ return;
50961
+ }
50962
+ const db2 = this.deps.db;
50963
+ const gameOwnership = await db2.query.games.findFirst({
50964
+ where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50872
50965
  columns: { id: true }
50873
50966
  });
50874
- if (!gameExists) {
50875
- throw new NotFoundError("Game", gameId);
50967
+ if (!gameOwnership) {
50968
+ const gameExists = await db2.query.games.findFirst({
50969
+ where: eq(games.id, gameId),
50970
+ columns: { id: true }
50971
+ });
50972
+ if (!gameExists) {
50973
+ throw new NotFoundError("Game", gameId);
50974
+ }
50975
+ throw new AccessDeniedError("You do not own this game");
50876
50976
  }
50877
- throw new AccessDeniedError("You do not own this game");
50878
50977
  }
50879
- }
50880
- async validateDeveloperAccess(user, gameId) {
50881
- this.validateDeveloperStatus(user);
50882
- if (user.role === "admin") {
50883
- const gameExists = await this.deps.db.query.games.findFirst({
50884
- where: eq(games.id, gameId),
50978
+ async validateDeveloperAccess(user, gameId) {
50979
+ this.validateDeveloperStatus(user);
50980
+ if (user.role === "admin") {
50981
+ const gameExists = await this.deps.db.query.games.findFirst({
50982
+ where: eq(games.id, gameId),
50983
+ columns: { id: true }
50984
+ });
50985
+ if (!gameExists) {
50986
+ throw new NotFoundError("Game", gameId);
50987
+ }
50988
+ return;
50989
+ }
50990
+ const db2 = this.deps.db;
50991
+ const existingGame = await db2.query.games.findFirst({
50992
+ where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50885
50993
  columns: { id: true }
50886
50994
  });
50887
- if (!gameExists) {
50995
+ if (!existingGame) {
50888
50996
  throw new NotFoundError("Game", gameId);
50889
50997
  }
50890
- return;
50891
50998
  }
50892
- const db2 = this.deps.db;
50893
- const existingGame = await db2.query.games.findFirst({
50894
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50895
- columns: { id: true }
50896
- });
50897
- if (!existingGame) {
50898
- throw new NotFoundError("Game", gameId);
50999
+ async validateGameManagementAccess(user, gameId) {
51000
+ if (user.role === "admin" || user.role === "teacher") {
51001
+ const gameExists = await this.deps.db.query.games.findFirst({
51002
+ where: eq(games.id, gameId),
51003
+ columns: { id: true }
51004
+ });
51005
+ if (!gameExists) {
51006
+ throw new NotFoundError("Game", gameId);
51007
+ }
51008
+ return;
51009
+ }
51010
+ return this.validateDeveloperAccess(user, gameId);
50899
51011
  }
50900
- }
50901
- async validateDeveloperAccessBySlug(user, slug) {
50902
- this.validateDeveloperStatus(user);
50903
- const db2 = this.deps.db;
50904
- if (user.role === "admin") {
50905
- const game2 = await db2.query.games.findFirst({
50906
- where: eq(games.slug, slug)
51012
+ async validateDeveloperAccessBySlug(user, slug) {
51013
+ this.validateDeveloperStatus(user);
51014
+ const db2 = this.deps.db;
51015
+ if (user.role === "admin") {
51016
+ const game2 = await db2.query.games.findFirst({
51017
+ where: eq(games.slug, slug)
51018
+ });
51019
+ if (!game2) {
51020
+ throw new NotFoundError("Game", slug);
51021
+ }
51022
+ return game2;
51023
+ }
51024
+ const game = await db2.query.games.findFirst({
51025
+ where: and(eq(games.slug, slug), eq(games.developerId, user.id))
50907
51026
  });
50908
- if (!game2) {
51027
+ if (!game) {
50909
51028
  throw new NotFoundError("Game", slug);
50910
51029
  }
50911
- return game2;
50912
- }
50913
- const game = await db2.query.games.findFirst({
50914
- where: and(eq(games.slug, slug), eq(games.developerId, user.id))
50915
- });
50916
- if (!game) {
50917
- throw new NotFoundError("Game", slug);
50918
- }
50919
- return game;
50920
- }
50921
- validateDeveloperStatus(user) {
50922
- if (user.role === "admin") {
50923
- return;
51030
+ return game;
50924
51031
  }
50925
- if (user.developerStatus !== "approved") {
50926
- const status = user.developerStatus || "none";
50927
- if (status === "pending") {
50928
- throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
50929
- } else {
50930
- throw new AccessDeniedError("Developer status required. Apply for developer access to create and manage games.");
51032
+ validateDeveloperStatus(user) {
51033
+ if (user.role === "admin") {
51034
+ return;
51035
+ }
51036
+ if (user.developerStatus !== "approved") {
51037
+ const status = user.developerStatus || "none";
51038
+ if (status === "pending") {
51039
+ throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
51040
+ } else {
51041
+ throw new AccessDeniedError("Developer status required. Apply for developer access to create and manage games.");
51042
+ }
50931
51043
  }
50932
51044
  }
50933
- }
50934
- }
50935
- var logger5;
50936
- var init_game_service = __esm(() => {
50937
- init_drizzle_orm();
50938
- init_tables_index();
50939
- init_src2();
50940
- init_errors();
50941
- init_deployment_util();
50942
- logger5 = log.scope("GameService");
51045
+ };
50943
51046
  });
50944
51047
  function createGameServices(deps) {
50945
51048
  const { db: db2, config: config2, cloudflare, auth: auth2, storage, cache, alerts } = deps;
50946
51049
  const game = new GameService({
50947
51050
  db: db2,
50948
51051
  alerts,
51052
+ cache,
50949
51053
  cloudflare,
50950
51054
  deleteApiKeyByName: auth2.deleteApiKeyByName.bind(auth2)
50951
51055
  });
@@ -50973,6 +51077,7 @@ function createGameServices(deps) {
50973
51077
  validators: {
50974
51078
  validateDeveloperAccessBySlug: (user, slug) => game.validateDeveloperAccessBySlug(user, slug),
50975
51079
  validateDeveloperAccess: (user, gameId) => game.validateDeveloperAccess(user, gameId),
51080
+ validateGameManagementAccess: (user, gameId) => game.validateGameManagementAccess(user, gameId),
50976
51081
  validateOwnership: (user, gameId) => game.validateOwnership(user, gameId)
50977
51082
  }
50978
51083
  };
@@ -52598,7 +52703,8 @@ var init_constants3 = __esm(() => {
52598
52703
  HEALTH: "/api/health",
52599
52704
  TIMEBACK: {
52600
52705
  END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
52601
- GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`
52706
+ GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
52707
+ HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`
52602
52708
  }
52603
52709
  };
52604
52710
  });
@@ -52736,7 +52842,8 @@ class SeedService {
52736
52842
  PLAYCADEMY_BASE_URL: ""
52737
52843
  }, {
52738
52844
  bindings: { d1: [deploymentId], r2: [], kv: [] },
52739
- keepAssets: false
52845
+ keepAssets: false,
52846
+ compatibilityDate: CLOUDFLARE_COMPATIBILITY_DATE
52740
52847
  });
52741
52848
  logger14.info("Worker deployed", { seedDeploymentId, url: result.url });
52742
52849
  if (secrets && Object.keys(secrets).length > 0) {
@@ -52866,6 +52973,7 @@ class SeedService {
52866
52973
  }
52867
52974
  var logger14;
52868
52975
  var init_seed_service = __esm(() => {
52976
+ init_src();
52869
52977
  init_setup2();
52870
52978
  init_src2();
52871
52979
  init_config2();
@@ -54090,6 +54198,36 @@ var init_pure = __esm(() => {
54090
54198
  var init_src4 = __esm(() => {
54091
54199
  init_pure();
54092
54200
  });
54201
+ function toAttributionEventTime(date3) {
54202
+ if (!date3) {
54203
+ return;
54204
+ }
54205
+ const match = date3.match(/^(\d{4})-(\d{2})-(\d{2})$/);
54206
+ if (!match) {
54207
+ throw new ValidationError("Date must be in YYYY-MM-DD format");
54208
+ }
54209
+ const [, yearStr, monthStr, dayStr] = match;
54210
+ const year = Number(yearStr);
54211
+ const month = Number(monthStr);
54212
+ const day = Number(dayStr);
54213
+ if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
54214
+ throw new ValidationError("Date must be in YYYY-MM-DD format");
54215
+ }
54216
+ const eventTime = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
54217
+ if (eventTime.getUTCFullYear() !== year || eventTime.getUTCMonth() + 1 !== month || eventTime.getUTCDate() !== day) {
54218
+ throw new ValidationError("Date must be a valid calendar date");
54219
+ }
54220
+ return eventTime.toISOString();
54221
+ }
54222
+ function resolveAdminEventTime(data) {
54223
+ if (data.useCurrentTime) {
54224
+ return new Date().toISOString();
54225
+ }
54226
+ return toAttributionEventTime(data.date);
54227
+ }
54228
+ var init_timeback_admin_util = __esm(() => {
54229
+ init_errors();
54230
+ });
54093
54231
  function isRecord2(value) {
54094
54232
  return typeof value === "object" && value !== null;
54095
54233
  }
@@ -54134,14 +54272,6 @@ function getPlaycademyMetadata(event) {
54134
54272
  const extensions = getMergedCaliperExtensions(event);
54135
54273
  return isRecord2(extensions.playcademy) ? extensions.playcademy : undefined;
54136
54274
  }
54137
- function getAssessmentPlaycademyMetadata(assessment) {
54138
- return isRecord2(assessment.metadata?.playcademy) ? assessment.metadata.playcademy : undefined;
54139
- }
54140
- function isRemediationAssessmentResult(assessment) {
54141
- const playcademy = getAssessmentPlaycademyMetadata(assessment);
54142
- const eventKind = getStringValue(playcademy?.eventKind);
54143
- return eventKind === "remediation-xp" || eventKind === "remediation-time" || eventKind === "remediation-mastery";
54144
- }
54145
54275
  function getActivityId(event, playcademy) {
54146
54276
  const metadataActivityId = getStringValue(playcademy?.activityId);
54147
54277
  if (metadataActivityId) {
@@ -54158,8 +54288,8 @@ function getActivityId(event, playcademy) {
54158
54288
  const trimmed = objectId.replace(/\/$/, "");
54159
54289
  const segments = trimmed.split("/");
54160
54290
  const activityIndex = segments.lastIndexOf("activities");
54161
- if (activityIndex !== -1 && segments.length >= activityIndex + 4) {
54162
- const candidate = segments[activityIndex + 3];
54291
+ if (activityIndex !== -1 && segments.length >= activityIndex + 3) {
54292
+ const candidate = segments[activityIndex + 2];
54163
54293
  return candidate ? decodeURIComponent(candidate) : undefined;
54164
54294
  }
54165
54295
  return;
@@ -54212,38 +54342,96 @@ function mapAssessmentsToXpEvents(userId, assessments) {
54212
54342
  };
54213
54343
  });
54214
54344
  }
54215
- function isMasteryCompletionEntry(assessment) {
54216
- return isRecord2(assessment.metadata) && assessment.metadata.isMasteryCompletion === true;
54345
+ function getDurationSecondsFromExtensions(event) {
54346
+ const extensions = getMergedCaliperExtensions(event);
54347
+ const playcademy = isRecord2(extensions.playcademy) ? extensions.playcademy : undefined;
54348
+ const rawValue = extensions.durationSeconds ?? playcademy?.durationSeconds;
54349
+ const value = typeof rawValue === "number" ? rawValue : Number(rawValue);
54350
+ return Number.isFinite(value) ? value : undefined;
54351
+ }
54352
+ function getCanonicalRunId(session2) {
54353
+ const sessionId = getStringValue(session2?.id);
54354
+ if (!sessionId) {
54355
+ return;
54356
+ }
54357
+ return sessionId.replace(/^urn:uuid:/, "");
54358
+ }
54359
+ function getResumeId(event) {
54360
+ const playcademy = getPlaycademyMetadata(event);
54361
+ return getStringValue(playcademy?.resumeId);
54217
54362
  }
54218
- function mapAssessmentResultToRecentActivity(assessment, relevantCourseIds, courseIdByLineItemId) {
54219
- if (!assessment.scoreDate || !assessment.assessmentLineItem?.sourcedId) {
54363
+ function isCaliperRemediationOrCompletionEvent(event) {
54364
+ const playcademy = getPlaycademyMetadata(event);
54365
+ return REMEDIATION_OR_COMPLETION_EVENT_KINDS.has(getStringValue(playcademy?.eventKind) || "");
54366
+ }
54367
+ function groupCaliperEventsByRun(events) {
54368
+ const groups = new Map;
54369
+ for (const event of events) {
54370
+ const objectId = getStringValue(event.object.id) || "unknown-activity";
54371
+ const groupKey = `${objectId}::${getStringValue(event.session?.id) || event.externalId}`;
54372
+ const existing = groups.get(groupKey);
54373
+ if (existing) {
54374
+ existing.push(event);
54375
+ } else {
54376
+ groups.set(groupKey, [event]);
54377
+ }
54378
+ }
54379
+ return groups;
54380
+ }
54381
+ function mapCaliperEventGroupToActivity(events, relevantCourseIds) {
54382
+ if (events.length === 0) {
54220
54383
  return null;
54221
54384
  }
54222
- if (isRemediationAssessmentResult(assessment)) {
54385
+ const sortedEvents = events.toSorted((a, b) => a.eventTime.localeCompare(b.eventTime));
54386
+ const activityEvent = [...sortedEvents].toReversed().find((event) => event.type === "ActivityEvent");
54387
+ const contextSource = activityEvent || sortedEvents.at(-1);
54388
+ if (!contextSource) {
54223
54389
  return null;
54224
54390
  }
54225
- const courseId = courseIdByLineItemId?.get(assessment.assessmentLineItem.sourcedId) || [...relevantCourseIds].find((course) => assessment.assessmentLineItem.sourcedId.startsWith(`${course}-`));
54226
- if (!courseId) {
54391
+ const ctx = parseCaliperEventContext(contextSource, relevantCourseIds);
54392
+ if (!ctx) {
54227
54393
  return null;
54228
54394
  }
54229
- if (isMasteryCompletionEntry(assessment)) {
54395
+ const score = activityEvent !== undefined ? (() => {
54396
+ const totalQuestions = getGeneratedMetricValue(activityEvent, "totalQuestions");
54397
+ const correctQuestions = getGeneratedMetricValue(activityEvent, "correctQuestions");
54398
+ if (totalQuestions === undefined || correctQuestions === undefined || totalQuestions <= 0) {
54399
+ return;
54400
+ }
54401
+ return correctQuestions / totalQuestions * 100;
54402
+ })() : undefined;
54403
+ const xpEarned = activityEvent !== undefined ? getGeneratedMetricValue(activityEvent, "xpEarned") : undefined;
54404
+ const masteredUnits = activityEvent !== undefined ? getGeneratedMetricValue(activityEvent, "masteredUnits") : undefined;
54405
+ const timeSpentEvents = sortedEvents.filter((event) => event.type === "TimeSpentEvent");
54406
+ let totalActiveTimeSeconds;
54407
+ if (timeSpentEvents.length > 0) {
54408
+ totalActiveTimeSeconds = timeSpentEvents.reduce((sum2, event) => sum2 + (getGeneratedMetricValue(event, "active") ?? 0), 0);
54409
+ } else if (activityEvent !== undefined) {
54410
+ totalActiveTimeSeconds = getDurationSecondsFromExtensions(activityEvent);
54411
+ }
54412
+ const fallbackActivityId = getActivityId(contextSource, getPlaycademyMetadata(contextSource));
54413
+ const occurredAt = getStringValue(activityEvent?.eventTime) || getStringValue(sortedEvents.at(-1)?.eventTime);
54414
+ const runId = getCanonicalRunId(contextSource.session);
54415
+ const resumeIds = new Set(sortedEvents.map((event) => getResumeId(event)).filter((resumeId) => resumeId !== undefined));
54416
+ const sessionCount = resumeIds.size > 0 ? resumeIds.size : 1;
54417
+ const kind = activityEvent !== undefined ? "activity" : "activity-in-progress";
54418
+ if (!occurredAt) {
54230
54419
  return null;
54231
54420
  }
54232
- const metadata2 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
54233
- const activityName = getStringValue(metadata2?.activityName);
54234
- const xpEarned = typeof metadata2?.xp === "number" && Number.isFinite(metadata2.xp) ? metadata2.xp : undefined;
54235
- const masteredUnits = typeof metadata2?.masteredUnits === "number" && Number.isFinite(metadata2.masteredUnits) ? metadata2.masteredUnits : undefined;
54236
- const durationSeconds = typeof metadata2?.durationSeconds === "number" && Number.isFinite(metadata2.durationSeconds) ? metadata2.durationSeconds : undefined;
54237
54421
  return {
54238
- id: assessment.sourcedId || `${assessment.assessmentLineItem.sourcedId}:${assessment.scoreDate}`,
54239
- kind: "activity",
54240
- occurredAt: assessment.scoreDate,
54241
- courseId,
54242
- title: activityName || "Activity completed",
54243
- ...typeof assessment.score === "number" ? { score: assessment.score } : {},
54422
+ id: activityEvent?.externalId || sortedEvents.at(-1)?.externalId || events[0].externalId,
54423
+ kind,
54424
+ occurredAt,
54425
+ courseId: ctx.courseId,
54426
+ title: getStringValue(activityEvent?.object.activity?.name) || ctx.titleFromEvent || (fallbackActivityId ? kebabToTitleCase(fallbackActivityId) : "Activity completed"),
54427
+ ...ctx.activityId ? { activityId: ctx.activityId } : {},
54428
+ ...ctx.appName ? { appName: ctx.appName } : {},
54429
+ ...score !== undefined ? { score } : {},
54244
54430
  ...xpEarned !== undefined ? { xpDelta: xpEarned } : {},
54245
54431
  ...masteredUnits !== undefined ? { masteredUnitsDelta: masteredUnits } : {},
54246
- ...durationSeconds !== undefined ? { timeDeltaSeconds: durationSeconds } : {}
54432
+ ...totalActiveTimeSeconds !== undefined ? { timeDeltaSeconds: totalActiveTimeSeconds } : {},
54433
+ ...runId ? { runId } : {},
54434
+ ...sessionCount > 0 ? { sessionCount } : {}
54247
54435
  };
54248
54436
  }
54249
54437
  function parseCaliperEventContext(event, relevantCourseIds) {
@@ -54351,8 +54539,16 @@ function mapCaliperEventToRemediationActivity(event, relevantCourseIds) {
54351
54539
  }
54352
54540
  return null;
54353
54541
  }
54542
+ var REMEDIATION_OR_COMPLETION_EVENT_KINDS;
54354
54543
  var init_timeback_util = __esm(() => {
54355
54544
  init_types4();
54545
+ REMEDIATION_OR_COMPLETION_EVENT_KINDS = new Set([
54546
+ "remediation-xp",
54547
+ "remediation-time",
54548
+ "remediation-mastery",
54549
+ "course-completed",
54550
+ "course-resumed"
54551
+ ]);
54356
54552
  });
54357
54553
 
54358
54554
  class TimebackAdminService {
@@ -54361,11 +54557,9 @@ class TimebackAdminService {
54361
54557
  static RECENT_ACTIVITY_LIMIT = 20;
54362
54558
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
54363
54559
  static MAX_STUDENT_ACTIVITY_OFFSET = 1000;
54560
+ static MAX_RECENT_ACTIVITY_EVENT_FETCH = 4000;
54364
54561
  static ANALYTICS_CONCURRENCY = 8;
54365
54562
  static MASTERABLE_UNITS_CONCURRENCY = 4;
54366
- static RECENT_ACTIVITY_FETCH_CONCURRENCY = 4;
54367
- static ASSESSMENT_LINE_ITEM_PAGE_SIZE = 1000;
54368
- static ASSESSMENT_RESULT_LINE_ITEM_CHUNK_SIZE = 20;
54369
54563
  constructor(deps) {
54370
54564
  this.deps = deps;
54371
54565
  }
@@ -54373,27 +54567,6 @@ class TimebackAdminService {
54373
54567
  const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
54374
54568
  return Object.is(rounded, -0) ? 0 : rounded;
54375
54569
  }
54376
- static toAttributionEventTime(date3) {
54377
- if (!date3) {
54378
- return;
54379
- }
54380
- const match = date3.match(/^(\d{4})-(\d{2})-(\d{2})$/);
54381
- if (!match) {
54382
- throw new ValidationError("Date must be in YYYY-MM-DD format");
54383
- }
54384
- const [, yearStr, monthStr, dayStr] = match;
54385
- const year = Number(yearStr);
54386
- const month = Number(monthStr);
54387
- const day = Number(dayStr);
54388
- if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
54389
- throw new ValidationError("Date must be in YYYY-MM-DD format");
54390
- }
54391
- const eventTime = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
54392
- if (eventTime.getUTCFullYear() !== year || eventTime.getUTCMonth() + 1 !== month || eventTime.getUTCDate() !== day) {
54393
- throw new ValidationError("Date must be a valid calendar date");
54394
- }
54395
- return eventTime.toISOString();
54396
- }
54397
54570
  requireClient() {
54398
54571
  if (!this.deps.timeback) {
54399
54572
  logger16.error("Timeback client not available in context");
@@ -54412,9 +54585,13 @@ class TimebackAdminService {
54412
54585
  });
54413
54586
  });
54414
54587
  }
54415
- async resolveAdminMutationContext(gameId, courseId, user, studentId) {
54588
+ async resolveAdminMutationContext(gameId, courseId, user, studentId, accessLevel = "developer") {
54416
54589
  const client = this.requireClient();
54417
- await this.deps.validateDeveloperAccess(user, gameId);
54590
+ if (accessLevel === "dashboard") {
54591
+ await this.deps.validateGameManagementAccess(user, gameId);
54592
+ } else {
54593
+ await this.deps.validateDeveloperAccess(user, gameId);
54594
+ }
54418
54595
  const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
54419
54596
  where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
54420
54597
  });
@@ -54537,7 +54714,7 @@ class TimebackAdminService {
54537
54714
  throw new ValidationError(`Game "${game.slug}" has an invalid deploymentUrl: ${game.deploymentUrl}`);
54538
54715
  }
54539
54716
  }
54540
- async getGameSensorUrl(gameId) {
54717
+ async getGameActivitySource(gameId) {
54541
54718
  const game = await this.deps.db.query.games.findFirst({
54542
54719
  where: eq(games.id, gameId),
54543
54720
  columns: { slug: true, deploymentUrl: true }
@@ -54545,7 +54722,17 @@ class TimebackAdminService {
54545
54722
  if (!game) {
54546
54723
  throw new NotFoundError("Game", gameId);
54547
54724
  }
54548
- return this.deriveGameSensorUrl(game);
54725
+ return {
54726
+ gameId,
54727
+ sensorUrl: this.deriveGameSensorUrl(game),
54728
+ sourceMode: this.deps.config.isLocal ? "development" : "production"
54729
+ };
54730
+ }
54731
+ static mapRecentActivityItems(events, relevantCourseIds) {
54732
+ const gameplayEvents = events.filter((event) => (event.type === "ActivityEvent" || event.type === "TimeSpentEvent") && !isCaliperRemediationOrCompletionEvent(event));
54733
+ const groupedGameplayItems = [...groupCaliperEventsByRun(gameplayEvents).values()].map((group) => mapCaliperEventGroupToActivity(group, relevantCourseIds)).filter((item) => Boolean(item));
54734
+ const remediationItems = events.map((event) => mapCaliperEventToRemediationActivity(event, relevantCourseIds)).filter((item) => Boolean(item));
54735
+ return [...groupedGameplayItems, ...remediationItems].toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt));
54549
54736
  }
54550
54737
  async getStudentEnrollmentsByCourseId(client, studentId, courseIds) {
54551
54738
  const relevantCourseIds = new Set(courseIds);
@@ -54576,105 +54763,35 @@ class TimebackAdminService {
54576
54763
  });
54577
54764
  return new Map(results);
54578
54765
  }
54579
- async listAssessmentLineItemCourseMap(client, relevantCourseIds) {
54580
- const lineItemEntries = await TimebackAdminService.runWithConcurrency([...relevantCourseIds], TimebackAdminService.RECENT_ACTIVITY_FETCH_CONCURRENCY, async (courseId) => {
54581
- const entries = [];
54582
- let offset = 0;
54583
- try {
54584
- while (true) {
54585
- const items2 = await client.oneroster.assessmentLineItems.list({
54586
- limit: TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE,
54587
- offset,
54588
- filter: `course.sourcedId='${escapeFilterValue(courseId)}'`,
54589
- fields: "sourcedId,course"
54590
- });
54591
- for (const item of items2) {
54592
- if (item.sourcedId) {
54593
- entries.push([
54594
- item.sourcedId,
54595
- item.course?.sourcedId || courseId
54596
- ]);
54597
- }
54598
- }
54599
- if (items2.length < TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE) {
54600
- break;
54601
- }
54602
- offset += TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE;
54603
- }
54604
- } catch (error) {
54605
- logger16.warn("Failed to load assessment line items for course", {
54606
- courseId,
54607
- error: error instanceof Error ? error.message : String(error)
54608
- });
54609
- }
54610
- return entries;
54611
- });
54612
- return new Map(lineItemEntries.flat());
54613
- }
54614
- static buildAssessmentResultsFilter(studentId, lineItemIds) {
54615
- const studentFilter = `student.sourcedId='${escapeFilterValue(studentId)}'`;
54616
- if (lineItemIds.length === 1) {
54617
- return `${studentFilter} AND assessmentLineItem.sourcedId='${escapeFilterValue(lineItemIds[0])}'`;
54618
- }
54619
- return `${studentFilter} AND assessmentLineItem.sourcedId@'${lineItemIds.map(escapeFilterValue).join(",")}'`;
54620
- }
54621
- async listRecentAssessmentResultsForStudent(client, studentId, courseIdByLineItemId, perChunkLimit = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
54622
- const lineItemIds = [...courseIdByLineItemId.keys()];
54623
- if (lineItemIds.length === 0) {
54624
- return [];
54625
- }
54626
- const resultPages = await TimebackAdminService.runWithConcurrency(TimebackAdminService.chunkItems(lineItemIds, TimebackAdminService.ASSESSMENT_RESULT_LINE_ITEM_CHUNK_SIZE), TimebackAdminService.RECENT_ACTIVITY_FETCH_CONCURRENCY, async (lineItemChunk) => {
54627
- try {
54628
- return await client.oneroster.assessmentResults.list({
54629
- limit: perChunkLimit,
54630
- sort: "scoreDate",
54631
- orderBy: "desc",
54632
- fields: "sourcedId,assessmentLineItem,score,scoreDate,metadata",
54633
- filter: TimebackAdminService.buildAssessmentResultsFilter(studentId, lineItemChunk)
54634
- });
54635
- } catch (error) {
54636
- logger16.warn("Failed to load recent assessment results for student", {
54637
- studentId,
54638
- lineItemCount: lineItemChunk.length,
54639
- error: error instanceof Error ? error.message : String(error)
54640
- });
54641
- return [];
54642
- }
54643
- });
54644
- const uniqueResults = new Map;
54645
- for (const result of resultPages.flat()) {
54646
- const key = result.sourcedId || `${result.assessmentLineItem?.sourcedId || "unknown"}:${result.scoreDate || ""}`;
54647
- uniqueResults.set(key, result);
54648
- }
54649
- return [...uniqueResults.values()].toSorted((a, b) => (b.scoreDate || "").localeCompare(a.scoreDate || "")).slice(0, perChunkLimit);
54650
- }
54651
- async listRecentActivityForStudent(client, studentId, sensorUrl, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
54766
+ async listRecentActivityForStudent(client, studentId, source, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
54652
54767
  if (relevantCourseIds.size === 0) {
54653
54768
  return [];
54654
54769
  }
54655
- const courseIdByLineItemId = await this.listAssessmentLineItemCourseMap(client, relevantCourseIds);
54656
- const assessments = await this.listRecentAssessmentResultsForStudent(client, studentId, courseIdByLineItemId, maxResults);
54657
- const assessmentRecentItems = assessments.map((assessment) => mapAssessmentResultToRecentActivity(assessment, relevantCourseIds, courseIdByLineItemId)).filter((activity) => Boolean(activity));
54658
- let caliperRecentItems = [];
54659
54770
  try {
54660
54771
  const actorId = `${client.getBaseUrl().replace(/\/$/, "")}/ims/oneroster/rostering/v1p2/users/${studentId}`;
54772
+ const eventLimit = Math.min(Math.max(200, maxResults * 20), TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
54661
54773
  const { events } = await client.caliper.events.list({
54662
- limit: Math.max(100, maxResults),
54774
+ limit: eventLimit,
54663
54775
  actorId,
54664
- sensor: sensorUrl
54776
+ ...source.sourceMode === "production" ? { sensor: source.sensorUrl } : {},
54777
+ extensions: {
54778
+ gameId: source.gameId
54779
+ }
54665
54780
  });
54666
- caliperRecentItems = events.map((event) => mapCaliperEventToRemediationActivity(event, relevantCourseIds)).filter((item) => Boolean(item));
54781
+ return TimebackAdminService.mapRecentActivityItems(events, relevantCourseIds).slice(0, maxResults);
54667
54782
  } catch (error) {
54668
54783
  logger16.warn("Failed to load recent Caliper activity", {
54669
54784
  studentId,
54785
+ gameId: source.gameId,
54786
+ sourceMode: source.sourceMode,
54670
54787
  error: error instanceof Error ? error.message : String(error)
54671
54788
  });
54789
+ return [];
54672
54790
  }
54673
- return [...assessmentRecentItems, ...caliperRecentItems].toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt)).slice(0, maxResults);
54674
54791
  }
54675
54792
  async listStudentsForCourse(gameId, courseId, user) {
54676
54793
  const client = this.requireClient();
54677
- await this.deps.validateDeveloperAccess(user, gameId);
54794
+ await this.deps.validateGameManagementAccess(user, gameId);
54678
54795
  const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
54679
54796
  where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
54680
54797
  });
@@ -54712,7 +54829,7 @@ class TimebackAdminService {
54712
54829
  }
54713
54830
  async getStudentOverview(gameId, studentId, user, courseId) {
54714
54831
  const client = this.requireClient();
54715
- await this.deps.validateDeveloperAccess(user, gameId);
54832
+ await this.deps.validateGameManagementAccess(user, gameId);
54716
54833
  const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
54717
54834
  where: courseId ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId)) : eq(gameTimebackIntegrations.gameId, gameId)
54718
54835
  });
@@ -54766,12 +54883,12 @@ class TimebackAdminService {
54766
54883
  const client = this.requireClient();
54767
54884
  const safeLimit = Math.max(1, Math.min(limit, TimebackAdminService.MAX_STUDENT_ACTIVITY_LIMIT));
54768
54885
  const safeOffset = Math.max(0, Math.min(offset, TimebackAdminService.MAX_STUDENT_ACTIVITY_OFFSET));
54769
- await this.deps.validateDeveloperAccess(user, gameId);
54770
- const [integration, sensorUrl] = await Promise.all([
54886
+ await this.deps.validateGameManagementAccess(user, gameId);
54887
+ const [integration, gameSource] = await Promise.all([
54771
54888
  this.deps.db.query.gameTimebackIntegrations.findFirst({
54772
54889
  where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
54773
54890
  }),
54774
- this.getGameSensorUrl(gameId)
54891
+ this.getGameActivitySource(gameId)
54775
54892
  ]);
54776
54893
  if (!integration) {
54777
54894
  throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
@@ -54779,7 +54896,7 @@ class TimebackAdminService {
54779
54896
  await this.assertStudentEnrolledInCourse(client, studentId, courseId);
54780
54897
  const relevantCourseIds = new Set([courseId]);
54781
54898
  const fetchLimit = Math.min(safeOffset + safeLimit + 1, TimebackAdminService.MAX_STUDENT_ACTIVITY_OFFSET + TimebackAdminService.MAX_STUDENT_ACTIVITY_LIMIT + 1);
54782
- const allActivities = await this.listRecentActivityForStudent(client, studentId, sensorUrl, relevantCourseIds, fetchLimit);
54899
+ const allActivities = await this.listRecentActivityForStudent(client, studentId, gameSource, relevantCourseIds, fetchLimit);
54783
54900
  const activities = allActivities.slice(safeOffset, safeOffset + safeLimit);
54784
54901
  const hasMore = allActivities.length > safeOffset + safeLimit;
54785
54902
  return { activities, hasMore };
@@ -54791,7 +54908,7 @@ class TimebackAdminService {
54791
54908
  courseId: data.courseId,
54792
54909
  studentId: data.studentId,
54793
54910
  xpEarned: data.xp,
54794
- eventTime: TimebackAdminService.toAttributionEventTime(data.date),
54911
+ eventTime: resolveAdminEventTime(data),
54795
54912
  reason: data.reason,
54796
54913
  actor,
54797
54914
  appName,
@@ -54806,7 +54923,7 @@ class TimebackAdminService {
54806
54923
  courseId: data.courseId,
54807
54924
  studentId: data.studentId,
54808
54925
  activeTimeSeconds: data.seconds,
54809
- eventTime: TimebackAdminService.toAttributionEventTime(data.date),
54926
+ eventTime: resolveAdminEventTime(data),
54810
54927
  reason: data.reason,
54811
54928
  actor,
54812
54929
  appName,
@@ -54821,7 +54938,7 @@ class TimebackAdminService {
54821
54938
  courseId: data.courseId,
54822
54939
  studentId: data.studentId,
54823
54940
  masteredUnits: data.units,
54824
- eventTime: TimebackAdminService.toAttributionEventTime(data.date),
54941
+ eventTime: resolveAdminEventTime(data),
54825
54942
  reason: data.reason,
54826
54943
  actor,
54827
54944
  appName,
@@ -54830,7 +54947,7 @@ class TimebackAdminService {
54830
54947
  return { status: "ok" };
54831
54948
  }
54832
54949
  async toggleCourseCompletion(data, user) {
54833
- const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
54950
+ const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId, "dashboard");
54834
54951
  const historyClient = client;
54835
54952
  const ids = deriveSourcedIds(data.courseId);
54836
54953
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
@@ -54923,6 +55040,77 @@ class TimebackAdminService {
54923
55040
  }
54924
55041
  return { status: "ok" };
54925
55042
  }
55043
+ async searchStudentsForEnrollment(gameId, courseId, query, user) {
55044
+ const client = this.requireClient();
55045
+ await this.deps.validateGameManagementAccess(user, gameId);
55046
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55047
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
55048
+ });
55049
+ if (!integration) {
55050
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
55051
+ }
55052
+ const trimmedQuery = query.trim();
55053
+ if (trimmedQuery.length < 2) {
55054
+ return { students: [] };
55055
+ }
55056
+ const filterParts = [
55057
+ `givenName~'${escapeFilterValue(trimmedQuery)}'`,
55058
+ `familyName~'${escapeFilterValue(trimmedQuery)}'`,
55059
+ `email~'${escapeFilterValue(trimmedQuery)}'`
55060
+ ];
55061
+ const filter = filterParts.join(" OR ");
55062
+ const params = new URLSearchParams({ filter, limit: "25" });
55063
+ const endpoint = `/ims/oneroster/rostering/v1p2/users?${params}`;
55064
+ let allUsers = [];
55065
+ try {
55066
+ const response = await client["request"](endpoint, "GET");
55067
+ allUsers = response.users || [];
55068
+ } catch (error) {
55069
+ logger16.warn("Failed to search OneRoster users", {
55070
+ query: trimmedQuery,
55071
+ error: error instanceof Error ? error.message : String(error)
55072
+ });
55073
+ return { students: [] };
55074
+ }
55075
+ const roster = await client.oneroster.enrollments.listByCourse(courseId, {
55076
+ role: "student",
55077
+ includeUsers: false
55078
+ });
55079
+ const enrolledStudentIds = new Set(roster.map((entry) => entry.enrollment.user.sourcedId));
55080
+ const students = allUsers.filter((entry) => Boolean(entry.sourcedId) && entry.roles?.some((role) => role.role === "student") === true).map((entry) => ({
55081
+ studentId: entry.sourcedId,
55082
+ name: `${entry.givenName || ""} ${entry.familyName || ""}`.trim() || entry.sourcedId,
55083
+ email: entry.email || null,
55084
+ alreadyEnrolled: enrolledStudentIds.has(entry.sourcedId)
55085
+ }));
55086
+ return { students };
55087
+ }
55088
+ async enrollStudent(data, user) {
55089
+ const client = this.requireClient();
55090
+ await this.deps.validateGameManagementAccess(user, data.gameId);
55091
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55092
+ where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
55093
+ });
55094
+ if (!integration) {
55095
+ throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
55096
+ }
55097
+ await client.edubridge.enrollments.enroll(data.studentId, data.courseId, {
55098
+ role: "student"
55099
+ });
55100
+ return { status: "ok" };
55101
+ }
55102
+ async unenrollStudent(data, user) {
55103
+ const client = this.requireClient();
55104
+ await this.deps.validateGameManagementAccess(user, data.gameId);
55105
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55106
+ where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
55107
+ });
55108
+ if (!integration) {
55109
+ throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
55110
+ }
55111
+ await client.edubridge.enrollments.unenroll(data.studentId, data.courseId);
55112
+ return { status: "ok" };
55113
+ }
54926
55114
  async getCompletionStatus(client, courseId, studentId) {
54927
55115
  const ids = deriveSourcedIds(courseId);
54928
55116
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
@@ -54961,17 +55149,6 @@ class TimebackAdminService {
54961
55149
  }));
54962
55150
  return results;
54963
55151
  }
54964
- static chunkItems(items2, chunkSize) {
54965
- if (items2.length === 0) {
54966
- return [];
54967
- }
54968
- const effectiveChunkSize = Math.max(1, chunkSize);
54969
- const chunks = [];
54970
- for (let index2 = 0;index2 < items2.length; index2 += effectiveChunkSize) {
54971
- chunks.push(items2.slice(index2, index2 + effectiveChunkSize));
54972
- }
54973
- return chunks;
54974
- }
54975
55152
  }
54976
55153
  var logger16;
54977
55154
  var init_timeback_admin_service = __esm(() => {
@@ -54983,593 +55160,739 @@ var init_timeback_admin_service = __esm(() => {
54983
55160
  init_utils6();
54984
55161
  init_src4();
54985
55162
  init_errors();
55163
+ init_timeback_admin_util();
54986
55164
  init_timeback_util();
54987
55165
  logger16 = log.scope("TimebackAdminService");
54988
55166
  });
54989
-
54990
- class TimebackService {
54991
- deps;
54992
- constructor(deps) {
54993
- this.deps = deps;
54994
- }
54995
- requireClient() {
54996
- if (!this.deps.timeback) {
54997
- logger17.error("Timeback client not available in context");
54998
- throw new ValidationError("Timeback integration not available in this environment");
55167
+ var logger17;
55168
+ var TimebackService;
55169
+ var init_timeback_service = __esm(() => {
55170
+ init_drizzle_orm();
55171
+ init_src();
55172
+ init_tables_index();
55173
+ init_src2();
55174
+ init_types4();
55175
+ init_src4();
55176
+ init_errors();
55177
+ init_timeback_util();
55178
+ logger17 = log.scope("TimebackService");
55179
+ TimebackService = class TimebackService2 {
55180
+ static HEARTBEAT_DEDUPE_TTL_MS = 300000;
55181
+ static processedHeartbeatWindows = new Map;
55182
+ static inFlightHeartbeatWindows = new Map;
55183
+ deps;
55184
+ static cleanHeartbeatDedupeCache(now2 = Date.now()) {
55185
+ for (const [key, timestamp3] of this.processedHeartbeatWindows) {
55186
+ if (now2 - timestamp3 > this.HEARTBEAT_DEDUPE_TTL_MS) {
55187
+ this.processedHeartbeatWindows.delete(key);
55188
+ }
55189
+ }
54999
55190
  }
55000
- return this.deps.timeback;
55001
- }
55002
- async getTodayXp(userId, date3, timezone2) {
55003
- const db2 = this.deps.db;
55004
- const tz = timezone2 || PLATFORM_TIMEZONE;
55005
- const base = date3 ? new Date(date3) : new Date;
55006
- if (isNaN(base.getTime())) {
55007
- throw new ValidationError("Invalid date format. Use ISO 8601 format.");
55191
+ static isDuplicateHeartbeatWindow(key) {
55192
+ this.cleanHeartbeatDedupeCache();
55193
+ return this.processedHeartbeatWindows.has(key);
55008
55194
  }
55009
- try {
55010
- new Intl.DateTimeFormat(undefined, { timeZone: tz });
55011
- } catch {
55012
- throw new ValidationError(`Invalid timezone: ${tz}`);
55195
+ static getInFlightHeartbeatWindow(key) {
55196
+ return this.inFlightHeartbeatWindows.get(key);
55013
55197
  }
55014
- if (tz === PLATFORM_TIMEZONE) {
55015
- const todayMidnight = getUtcInstantForMidnight(base, tz);
55016
- const result2 = await db2.select({ xp: timebackDailyXp.xp, date: timebackDailyXp.date }).from(timebackDailyXp).where(and(eq(timebackDailyXp.userId, userId), eq(timebackDailyXp.date, todayMidnight))).limit(1);
55017
- if (result2.length === 0) {
55018
- return { xp: 0, date: todayMidnight.toISOString() };
55019
- }
55020
- return { xp: result2[0].xp, date: result2[0].date.toISOString() };
55198
+ static markHeartbeatWindowProcessed(key) {
55199
+ this.processedHeartbeatWindows.set(key, Date.now());
55021
55200
  }
55022
- const { startOfDay, endOfDay } = getDayBoundariesInTimezone(base, tz);
55023
- const result = await db2.select({ totalXp: sum(timebackXpEvents.xpDelta) }).from(timebackXpEvents).where(and(eq(timebackXpEvents.userId, userId), gte(timebackXpEvents.occurredAt, startOfDay), lte(timebackXpEvents.occurredAt, new Date(endOfDay.getTime() - 1))));
55024
- return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
55025
- }
55026
- async getTotalXp(userId) {
55027
- const db2 = this.deps.db;
55028
- const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
55029
- return { totalXp: Number(result[0]?.totalXp) || 0 };
55030
- }
55031
- async updateTodayXp(userId, data) {
55032
- const db2 = this.deps.db;
55033
- const { xp, userTimestamp } = data;
55034
- let targetDate;
55035
- if (userTimestamp) {
55036
- targetDate = new Date(userTimestamp);
55037
- if (isNaN(targetDate.getTime())) {
55038
- throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
55039
- }
55040
- targetDate.setHours(0, 0, 0, 0);
55041
- } else {
55042
- targetDate = new Date;
55043
- targetDate.setUTCHours(0, 0, 0, 0);
55201
+ static markHeartbeatWindowInFlight(key, promise) {
55202
+ this.inFlightHeartbeatWindows.set(key, promise);
55044
55203
  }
55045
- const [result] = await db2.insert(timebackDailyXp).values({ userId, date: targetDate, xp }).onConflictDoUpdate({
55046
- target: [timebackDailyXp.userId, timebackDailyXp.date],
55047
- set: { xp: sql`excluded.xp`, updatedAt: new Date }
55048
- }).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
55049
- if (!result) {
55050
- logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
55051
- throw new InternalError("Failed to update daily XP record");
55204
+ static clearInFlightHeartbeatWindow(key) {
55205
+ this.inFlightHeartbeatWindows.delete(key);
55052
55206
  }
55053
- return { xp: result.xp, date: result.date.toISOString() };
55054
- }
55055
- async getXpHistory(userId, startDate, endDate) {
55056
- const db2 = this.deps.db;
55057
- const whereConditions = [eq(timebackDailyXp.userId, userId)];
55058
- if (startDate) {
55059
- const start2 = new Date(startDate);
55060
- start2.setUTCHours(0, 0, 0, 0);
55061
- whereConditions.push(gte(timebackDailyXp.date, start2));
55207
+ static addResumeIdToExtensions(extensions, resumeId) {
55208
+ const base = extensions ?? {};
55209
+ const existingPlaycademy = base.playcademy;
55210
+ const playcademy = typeof existingPlaycademy === "object" && existingPlaycademy !== null && !Array.isArray(existingPlaycademy) ? existingPlaycademy : {};
55211
+ return {
55212
+ ...base,
55213
+ playcademy: {
55214
+ ...playcademy,
55215
+ resumeId
55216
+ }
55217
+ };
55062
55218
  }
55063
- if (endDate) {
55064
- const end = new Date(endDate);
55065
- end.setUTCHours(23, 59, 59, 999);
55066
- whereConditions.push(lte(timebackDailyXp.date, end));
55219
+ constructor(deps) {
55220
+ this.deps = deps;
55067
55221
  }
55068
- const result = await db2.select({ date: timebackDailyXp.date, xp: timebackDailyXp.xp }).from(timebackDailyXp).where(and(...whereConditions)).orderBy(timebackDailyXp.date);
55069
- return {
55070
- history: result.map((row) => ({ date: row.date.toISOString(), xp: row.xp }))
55071
- };
55072
- }
55073
- async populateStudent(user, providedNames) {
55074
- const client = this.requireClient();
55075
- const db2 = this.deps.db;
55076
- const dbUser = await db2.query.users.findFirst({
55077
- where: eq(users.id, user.id),
55078
- columns: { id: true, timebackId: true }
55079
- });
55080
- if (dbUser?.timebackId) {
55081
- logger17.info("Student already onboarded", { userId: user.id });
55082
- return { status: "already_populated" };
55222
+ requireClient() {
55223
+ if (!this.deps.timeback) {
55224
+ logger17.error("Timeback client not available in context");
55225
+ throw new ValidationError("Timeback integration not available in this environment");
55226
+ }
55227
+ return this.deps.timeback;
55083
55228
  }
55084
- let timebackId;
55085
- let name3;
55086
- try {
55087
- const existingUser = await client.oneroster.users.findByEmail(user.email);
55088
- timebackId = existingUser.sourcedId;
55089
- name3 = `${existingUser.givenName} ${existingUser.familyName}`;
55090
- logger17.info("Found existing student in OneRoster", {
55091
- userId: user.id,
55092
- timebackId
55093
- });
55094
- } catch {
55095
- if (!providedNames?.firstName || !providedNames?.lastName) {
55096
- return { status: "no_record" };
55229
+ async getTodayXp(userId, date3, timezone2) {
55230
+ const db2 = this.deps.db;
55231
+ const tz = timezone2 || PLATFORM_TIMEZONE;
55232
+ const base = date3 ? new Date(date3) : new Date;
55233
+ if (isNaN(base.getTime())) {
55234
+ throw new ValidationError("Invalid date format. Use ISO 8601 format.");
55097
55235
  }
55098
- const sourcedId = crypto.randomUUID();
55099
- const response = await client.oneroster.users.create({
55100
- sourcedId,
55101
- status: "active",
55102
- enabledUser: true,
55103
- givenName: providedNames.firstName,
55104
- familyName: providedNames.lastName,
55105
- email: user.email,
55106
- roles: [
55107
- {
55108
- roleType: "primary",
55109
- role: "student",
55110
- org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
55111
- }
55112
- ]
55113
- });
55114
- if (!response.sourcedIdPairs?.allocatedSourcedId) {
55115
- return { status: "error", message: "Timeback did not return allocatedSourcedId" };
55236
+ try {
55237
+ new Intl.DateTimeFormat(undefined, { timeZone: tz });
55238
+ } catch {
55239
+ throw new ValidationError(`Invalid timezone: ${tz}`);
55240
+ }
55241
+ if (tz === PLATFORM_TIMEZONE) {
55242
+ const todayMidnight = getUtcInstantForMidnight(base, tz);
55243
+ const result2 = await db2.select({ xp: timebackDailyXp.xp, date: timebackDailyXp.date }).from(timebackDailyXp).where(and(eq(timebackDailyXp.userId, userId), eq(timebackDailyXp.date, todayMidnight))).limit(1);
55244
+ if (result2.length === 0) {
55245
+ return { xp: 0, date: todayMidnight.toISOString() };
55246
+ }
55247
+ return { xp: result2[0].xp, date: result2[0].date.toISOString() };
55248
+ }
55249
+ const { startOfDay, endOfDay } = getDayBoundariesInTimezone(base, tz);
55250
+ const result = await db2.select({ totalXp: sum(timebackXpEvents.xpDelta) }).from(timebackXpEvents).where(and(eq(timebackXpEvents.userId, userId), gte(timebackXpEvents.occurredAt, startOfDay), lte(timebackXpEvents.occurredAt, new Date(endOfDay.getTime() - 1))));
55251
+ return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
55252
+ }
55253
+ async getTotalXp(userId) {
55254
+ const db2 = this.deps.db;
55255
+ const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
55256
+ return { totalXp: Number(result[0]?.totalXp) || 0 };
55257
+ }
55258
+ async updateTodayXp(userId, data) {
55259
+ const db2 = this.deps.db;
55260
+ const { xp, userTimestamp } = data;
55261
+ let targetDate;
55262
+ if (userTimestamp) {
55263
+ targetDate = new Date(userTimestamp);
55264
+ if (isNaN(targetDate.getTime())) {
55265
+ throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
55266
+ }
55267
+ targetDate.setHours(0, 0, 0, 0);
55268
+ } else {
55269
+ targetDate = new Date;
55270
+ targetDate.setUTCHours(0, 0, 0, 0);
55271
+ }
55272
+ const [result] = await db2.insert(timebackDailyXp).values({ userId, date: targetDate, xp }).onConflictDoUpdate({
55273
+ target: [timebackDailyXp.userId, timebackDailyXp.date],
55274
+ set: { xp: sql`excluded.xp`, updatedAt: new Date }
55275
+ }).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
55276
+ if (!result) {
55277
+ logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
55278
+ throw new InternalError("Failed to update daily XP record");
55116
55279
  }
55117
- timebackId = response.sourcedIdPairs.allocatedSourcedId;
55118
- name3 = `${providedNames.firstName} ${providedNames.lastName}`;
55119
- logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
55280
+ return { xp: result.xp, date: result.date.toISOString() };
55120
55281
  }
55121
- const assessments = await this.fetchAssessments(timebackId);
55122
- await db2.transaction(async (tx) => {
55123
- if (assessments.length > 0) {
55124
- const events = mapAssessmentsToXpEvents(user.id, assessments);
55125
- for (const event of events) {
55126
- try {
55127
- await tx.insert(timebackXpEvents).values(event);
55128
- } catch {}
55282
+ async getXpHistory(userId, startDate, endDate) {
55283
+ const db2 = this.deps.db;
55284
+ const whereConditions = [eq(timebackDailyXp.userId, userId)];
55285
+ if (startDate) {
55286
+ const start2 = new Date(startDate);
55287
+ start2.setUTCHours(0, 0, 0, 0);
55288
+ whereConditions.push(gte(timebackDailyXp.date, start2));
55289
+ }
55290
+ if (endDate) {
55291
+ const end = new Date(endDate);
55292
+ end.setUTCHours(23, 59, 59, 999);
55293
+ whereConditions.push(lte(timebackDailyXp.date, end));
55294
+ }
55295
+ const result = await db2.select({ date: timebackDailyXp.date, xp: timebackDailyXp.xp }).from(timebackDailyXp).where(and(...whereConditions)).orderBy(timebackDailyXp.date);
55296
+ return {
55297
+ history: result.map((row) => ({ date: row.date.toISOString(), xp: row.xp }))
55298
+ };
55299
+ }
55300
+ async populateStudent(user, providedNames) {
55301
+ const client = this.requireClient();
55302
+ const db2 = this.deps.db;
55303
+ const dbUser = await db2.query.users.findFirst({
55304
+ where: eq(users.id, user.id),
55305
+ columns: { id: true, timebackId: true }
55306
+ });
55307
+ if (dbUser?.timebackId) {
55308
+ logger17.info("Student already onboarded", { userId: user.id });
55309
+ return { status: "already_populated" };
55310
+ }
55311
+ let timebackId;
55312
+ let name3;
55313
+ try {
55314
+ const existingUser = await client.oneroster.users.findByEmail(user.email);
55315
+ timebackId = existingUser.sourcedId;
55316
+ name3 = `${existingUser.givenName} ${existingUser.familyName}`;
55317
+ logger17.info("Found existing student in OneRoster", {
55318
+ userId: user.id,
55319
+ timebackId
55320
+ });
55321
+ } catch {
55322
+ if (!providedNames?.firstName || !providedNames?.lastName) {
55323
+ return { status: "no_record" };
55129
55324
  }
55130
- const dailyMap = new Map;
55131
- for (const a of assessments) {
55132
- const xp = a.metadata?.xp;
55133
- if (typeof xp === "number" && a.scoreDate) {
55134
- const day = getUtcInstantForMidnight(new Date(a.scoreDate), PLATFORM_TIMEZONE);
55135
- const key = day.toISOString();
55136
- dailyMap.set(key, (dailyMap.get(key) || 0) + xp);
55325
+ const sourcedId = crypto.randomUUID();
55326
+ const response = await client.oneroster.users.create({
55327
+ sourcedId,
55328
+ status: "active",
55329
+ enabledUser: true,
55330
+ givenName: providedNames.firstName,
55331
+ familyName: providedNames.lastName,
55332
+ email: user.email,
55333
+ roles: [
55334
+ {
55335
+ roleType: "primary",
55336
+ role: "student",
55337
+ org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
55338
+ }
55339
+ ]
55340
+ });
55341
+ if (!response.sourcedIdPairs?.allocatedSourcedId) {
55342
+ return { status: "error", message: "Timeback did not return allocatedSourcedId" };
55343
+ }
55344
+ timebackId = response.sourcedIdPairs.allocatedSourcedId;
55345
+ name3 = `${providedNames.firstName} ${providedNames.lastName}`;
55346
+ logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
55347
+ }
55348
+ const assessments = await this.fetchAssessments(timebackId);
55349
+ await db2.transaction(async (tx) => {
55350
+ if (assessments.length > 0) {
55351
+ const events = mapAssessmentsToXpEvents(user.id, assessments);
55352
+ for (const event of events) {
55353
+ try {
55354
+ await tx.insert(timebackXpEvents).values(event);
55355
+ } catch {}
55356
+ }
55357
+ const dailyMap = new Map;
55358
+ for (const a of assessments) {
55359
+ const xp = a.metadata?.xp;
55360
+ if (typeof xp === "number" && a.scoreDate) {
55361
+ const day = getUtcInstantForMidnight(new Date(a.scoreDate), PLATFORM_TIMEZONE);
55362
+ const key = day.toISOString();
55363
+ dailyMap.set(key, (dailyMap.get(key) || 0) + xp);
55364
+ }
55365
+ }
55366
+ if (dailyMap.size > 0) {
55367
+ const dailyRecords = [...dailyMap.entries()].map(([iso, xp]) => ({
55368
+ userId: user.id,
55369
+ date: new Date(iso),
55370
+ xp
55371
+ }));
55372
+ await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
55373
+ target: [timebackDailyXp.userId, timebackDailyXp.date],
55374
+ set: { xp: sql`excluded.xp`, updatedAt: new Date }
55375
+ });
55137
55376
  }
55138
55377
  }
55139
- if (dailyMap.size > 0) {
55140
- const dailyRecords = [...dailyMap.entries()].map(([iso, xp]) => ({
55378
+ const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
55379
+ if (!updated) {
55380
+ logger17.error("User Timeback ID update returned no rows", {
55141
55381
  userId: user.id,
55142
- date: new Date(iso),
55143
- xp
55144
- }));
55145
- await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
55146
- target: [timebackDailyXp.userId, timebackDailyXp.date],
55147
- set: { xp: sql`excluded.xp`, updatedAt: new Date }
55382
+ timebackId
55148
55383
  });
55384
+ throw new InternalError("Failed to update user with Timeback ID");
55149
55385
  }
55150
- }
55151
- const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
55152
- if (!updated) {
55153
- logger17.error("User Timeback ID update returned no rows", {
55154
- userId: user.id,
55155
- timebackId
55156
- });
55157
- throw new InternalError("Failed to update user with Timeback ID");
55158
- }
55159
- });
55160
- return { status: "ok" };
55161
- }
55162
- async fetchAssessments(studentSourcedId) {
55163
- const client = this.requireClient();
55164
- const allAssessments = [];
55165
- const limit = 3000;
55166
- const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
55167
- let offset = 0;
55168
- try {
55169
- while (true) {
55170
- const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
55171
- allAssessments.push(...results);
55172
- if (results.length < limit) {
55173
- break;
55174
- }
55175
- offset += limit;
55176
- }
55177
- logger17.debug("Fetched assessments", {
55178
- studentSourcedId,
55179
- totalCount: allAssessments.length
55180
55386
  });
55181
- return allAssessments;
55182
- } catch (error) {
55183
- logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
55184
- return [];
55185
- }
55186
- }
55187
- async getUserData(userId, gameId) {
55188
- const db2 = this.deps.db;
55189
- const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
55190
- if (!userData) {
55191
- throw new NotFoundError("User", userId);
55192
- }
55193
- if (!userData.timebackId) {
55194
- throw new NotFoundError("Timeback account not found for user");
55387
+ return { status: "ok" };
55195
55388
  }
55196
- const [profile, allEnrollments] = await Promise.all([
55197
- this.fetchStudentProfile(userData.timebackId),
55198
- this.fetchEnrollments(userData.timebackId)
55199
- ]);
55200
- const enrollments = gameId ? allEnrollments.filter((e) => e.gameId === gameId) : allEnrollments;
55201
- const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
55202
- const organizations = gameId && enrollmentOrgIds.size > 0 ? profile.organizations.filter((o) => enrollmentOrgIds.has(o.id)) : profile.organizations;
55203
- return { id: userData.timebackId, role: profile.role, enrollments, organizations };
55204
- }
55205
- async getUserDataByTimebackId(timebackId) {
55206
- const [profile, enrollments] = await Promise.all([
55207
- this.fetchStudentProfile(timebackId),
55208
- this.fetchEnrollments(timebackId)
55209
- ]);
55210
- return {
55211
- id: timebackId,
55212
- role: profile.role,
55213
- enrollments,
55214
- organizations: profile.organizations
55215
- };
55216
- }
55217
- async fetchStudentProfile(timebackId) {
55218
- const client = this.requireClient();
55219
- try {
55220
- const user = await client.oneroster.users.get(timebackId);
55221
- const primaryRole = user.roles.find((r) => r.roleType === "primary");
55222
- const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
55223
- const orgMap = new Map;
55224
- if (user.primaryOrg) {
55225
- orgMap.set(user.primaryOrg.sourcedId, {
55226
- id: user.primaryOrg.sourcedId,
55227
- name: user.primaryOrg.name ?? null,
55228
- type: user.primaryOrg.type || "school",
55229
- isPrimary: true
55389
+ async fetchAssessments(studentSourcedId) {
55390
+ const client = this.requireClient();
55391
+ const allAssessments = [];
55392
+ const limit = 3000;
55393
+ const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
55394
+ let offset = 0;
55395
+ try {
55396
+ while (true) {
55397
+ const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
55398
+ allAssessments.push(...results);
55399
+ if (results.length < limit) {
55400
+ break;
55401
+ }
55402
+ offset += limit;
55403
+ }
55404
+ logger17.debug("Fetched assessments", {
55405
+ studentSourcedId,
55406
+ totalCount: allAssessments.length
55230
55407
  });
55408
+ return allAssessments;
55409
+ } catch (error) {
55410
+ logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
55411
+ return [];
55231
55412
  }
55232
- for (const r of user.roles) {
55233
- if (r.org && !orgMap.has(r.org.sourcedId)) {
55234
- orgMap.set(r.org.sourcedId, {
55235
- id: r.org.sourcedId,
55236
- name: null,
55237
- type: "school",
55238
- isPrimary: false
55413
+ }
55414
+ async getUserData(userId, gameId) {
55415
+ const db2 = this.deps.db;
55416
+ const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
55417
+ if (!userData) {
55418
+ throw new NotFoundError("User", userId);
55419
+ }
55420
+ if (!userData.timebackId) {
55421
+ throw new NotFoundError("Timeback account not found for user");
55422
+ }
55423
+ const [profile, allEnrollments] = await Promise.all([
55424
+ this.fetchStudentProfile(userData.timebackId),
55425
+ this.fetchEnrollments(userData.timebackId)
55426
+ ]);
55427
+ const enrollments = gameId ? allEnrollments.filter((e) => e.gameId === gameId) : allEnrollments;
55428
+ const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
55429
+ const organizations = gameId && enrollmentOrgIds.size > 0 ? profile.organizations.filter((o) => enrollmentOrgIds.has(o.id)) : profile.organizations;
55430
+ return { id: userData.timebackId, role: profile.role, enrollments, organizations };
55431
+ }
55432
+ async getUserDataByTimebackId(timebackId) {
55433
+ const [profile, enrollments] = await Promise.all([
55434
+ this.fetchStudentProfile(timebackId),
55435
+ this.fetchEnrollments(timebackId)
55436
+ ]);
55437
+ return {
55438
+ id: timebackId,
55439
+ role: profile.role,
55440
+ enrollments,
55441
+ organizations: profile.organizations
55442
+ };
55443
+ }
55444
+ async fetchStudentProfile(timebackId) {
55445
+ const client = this.requireClient();
55446
+ try {
55447
+ const user = await client.oneroster.users.get(timebackId);
55448
+ const primaryRole = user.roles.find((r) => r.roleType === "primary");
55449
+ const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
55450
+ const orgMap = new Map;
55451
+ if (user.primaryOrg) {
55452
+ orgMap.set(user.primaryOrg.sourcedId, {
55453
+ id: user.primaryOrg.sourcedId,
55454
+ name: user.primaryOrg.name ?? null,
55455
+ type: user.primaryOrg.type || "school",
55456
+ isPrimary: true
55239
55457
  });
55240
55458
  }
55459
+ for (const r of user.roles) {
55460
+ if (r.org && !orgMap.has(r.org.sourcedId)) {
55461
+ orgMap.set(r.org.sourcedId, {
55462
+ id: r.org.sourcedId,
55463
+ name: null,
55464
+ type: "school",
55465
+ isPrimary: false
55466
+ });
55467
+ }
55468
+ }
55469
+ return { role, organizations: [...orgMap.values()] };
55470
+ } catch {
55471
+ return { role: "student", organizations: [] };
55241
55472
  }
55242
- return { role, organizations: [...orgMap.values()] };
55243
- } catch {
55244
- return { role: "student", organizations: [] };
55245
55473
  }
55246
- }
55247
- async fetchEnrollments(timebackId) {
55248
- const client = this.requireClient();
55249
- const db2 = this.deps.db;
55250
- try {
55251
- const enrollments = await client.getEnrollments(timebackId);
55252
- const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
55253
- if (courseIds.length === 0) {
55474
+ async fetchEnrollments(timebackId) {
55475
+ const client = this.requireClient();
55476
+ const db2 = this.deps.db;
55477
+ try {
55478
+ const enrollments = await client.getEnrollments(timebackId);
55479
+ const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
55480
+ if (courseIds.length === 0) {
55481
+ return [];
55482
+ }
55483
+ const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
55484
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
55485
+ where: inArray(gameTimebackIntegrations.courseId, courseIds)
55486
+ });
55487
+ return integrations.map((i2) => ({
55488
+ gameId: i2.gameId,
55489
+ grade: i2.grade,
55490
+ subject: i2.subject,
55491
+ courseId: i2.courseId,
55492
+ orgId: courseToSchool.get(i2.courseId)
55493
+ }));
55494
+ } catch {
55254
55495
  return [];
55255
55496
  }
55256
- const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
55257
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55258
- where: inArray(gameTimebackIntegrations.courseId, courseIds)
55259
- });
55260
- return integrations.map((i2) => ({
55261
- gameId: i2.gameId,
55262
- grade: i2.grade,
55263
- subject: i2.subject,
55264
- courseId: i2.courseId,
55265
- orgId: courseToSchool.get(i2.courseId)
55266
- }));
55267
- } catch {
55268
- return [];
55269
55497
  }
55270
- }
55271
- async setupIntegration(gameId, request, user) {
55272
- const client = this.requireClient();
55273
- const db2 = this.deps.db;
55274
- await this.deps.validateDeveloperAccess(user, gameId);
55275
- const { courses, baseConfig, verbose } = request;
55276
- const existing = await db2.query.gameTimebackIntegrations.findMany({
55277
- where: eq(gameTimebackIntegrations.gameId, gameId)
55278
- });
55279
- const integrations = [];
55280
- const verboseData = [];
55281
- for (const courseConfig of courses) {
55282
- let applySuffix = function(text3) {
55283
- return suffix ? `${text3} ${suffix}` : text3;
55284
- };
55285
- const {
55286
- subject: subjectInput,
55287
- grade,
55288
- title,
55289
- courseCode,
55290
- level,
55291
- metadata: metadata2,
55292
- totalXp: derivedTotalXp,
55293
- masterableUnits: derivedMasterableUnits
55294
- } = courseConfig;
55295
- if (!isTimebackSubject(subjectInput)) {
55296
- logger17.warn("Invalid Timeback subject in course config", {
55498
+ async setupIntegration(gameId, request, user) {
55499
+ const client = this.requireClient();
55500
+ const db2 = this.deps.db;
55501
+ await this.deps.validateDeveloperAccess(user, gameId);
55502
+ const { courses, baseConfig, verbose } = request;
55503
+ const existing = await db2.query.gameTimebackIntegrations.findMany({
55504
+ where: eq(gameTimebackIntegrations.gameId, gameId)
55505
+ });
55506
+ const integrations = [];
55507
+ const verboseData = [];
55508
+ for (const courseConfig of courses) {
55509
+ let applySuffix = function(text3) {
55510
+ return suffix ? `${text3} ${suffix}` : text3;
55511
+ };
55512
+ const {
55297
55513
  subject: subjectInput,
55298
- courseCode,
55299
- title
55300
- });
55301
- throw new ValidationError(`Invalid subject "${subjectInput}"`);
55302
- }
55303
- if (!isTimebackGrade(grade)) {
55304
- logger17.warn("Invalid Timeback grade in course config", {
55305
55514
  grade,
55306
- courseCode,
55307
- title
55308
- });
55309
- throw new ValidationError(`Invalid grade "${grade}"`);
55310
- }
55311
- const subject = subjectInput;
55312
- const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
55313
- const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
55314
- const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
55315
- if (typeof totalXp !== "number") {
55316
- logger17.warn("Course missing totalXp in Timeback config", {
55317
- courseCode,
55318
- title
55319
- });
55320
- throw new ValidationError(`Course "${title}" is missing totalXp`);
55321
- }
55322
- const suffix = baseConfig.component.titleSuffix || "";
55323
- const fullConfig = {
55324
- organization: baseConfig.organization,
55325
- course: {
55326
55515
  title,
55327
- subjects: [subject],
55328
- grades: [grade],
55329
55516
  courseCode,
55330
55517
  level,
55331
- gradingScheme: "STANDARD",
55332
- metadata: metadata2
55333
- },
55334
- component: {
55335
- ...baseConfig.component,
55336
- title: applySuffix(baseConfig.component.title || `${title} Activities`)
55337
- },
55338
- resource: {
55339
- ...baseConfig.resource,
55340
- title: applySuffix(baseConfig.resource.title || `${title} Game`),
55341
- metadata: buildResourceMetadata({
55342
- baseMetadata: baseConfig.resource.metadata,
55343
- subject,
55344
- grade,
55345
- totalXp,
55346
- masterableUnits
55347
- })
55348
- },
55349
- componentResource: {
55350
- ...baseConfig.componentResource,
55351
- title: applySuffix(baseConfig.componentResource.title || "")
55352
- }
55353
- };
55354
- const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
55355
- if (existingIntegration) {
55356
- await client.update(existingIntegration.courseId, fullConfig);
55357
- const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
55358
- if (updated) {
55359
- integrations.push(this.toGameTimebackIntegration(updated));
55518
+ metadata: metadata2,
55519
+ totalXp: derivedTotalXp,
55520
+ masterableUnits: derivedMasterableUnits
55521
+ } = courseConfig;
55522
+ if (!isTimebackSubject(subjectInput)) {
55523
+ logger17.warn("Invalid Timeback subject in course config", {
55524
+ subject: subjectInput,
55525
+ courseCode,
55526
+ title
55527
+ });
55528
+ throw new ValidationError(`Invalid subject "${subjectInput}"`);
55360
55529
  }
55361
- } else {
55362
- const result = await client.setup(fullConfig, { verbose });
55363
- const [integration] = await db2.insert(gameTimebackIntegrations).values({ gameId, courseId: result.courseId, grade, subject, totalXp }).returning();
55364
- if (integration) {
55365
- const dto = this.toGameTimebackIntegration(integration);
55366
- integrations.push(dto);
55367
- if (verbose && result.verboseData) {
55368
- verboseData.push({ integration: dto, config: result.verboseData });
55530
+ if (!isTimebackGrade(grade)) {
55531
+ logger17.warn("Invalid Timeback grade in course config", {
55532
+ grade,
55533
+ courseCode,
55534
+ title
55535
+ });
55536
+ throw new ValidationError(`Invalid grade "${grade}"`);
55537
+ }
55538
+ const subject = subjectInput;
55539
+ const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
55540
+ const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
55541
+ const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
55542
+ if (typeof totalXp !== "number") {
55543
+ logger17.warn("Course missing totalXp in Timeback config", {
55544
+ courseCode,
55545
+ title
55546
+ });
55547
+ throw new ValidationError(`Course "${title}" is missing totalXp`);
55548
+ }
55549
+ const suffix = baseConfig.component.titleSuffix || "";
55550
+ const fullConfig = {
55551
+ organization: baseConfig.organization,
55552
+ course: {
55553
+ title,
55554
+ subjects: [subject],
55555
+ grades: [grade],
55556
+ courseCode,
55557
+ level,
55558
+ gradingScheme: "STANDARD",
55559
+ metadata: metadata2
55560
+ },
55561
+ component: {
55562
+ ...baseConfig.component,
55563
+ title: applySuffix(baseConfig.component.title || `${title} Activities`)
55564
+ },
55565
+ resource: {
55566
+ ...baseConfig.resource,
55567
+ title: applySuffix(baseConfig.resource.title || `${title} Game`),
55568
+ metadata: buildResourceMetadata({
55569
+ baseMetadata: baseConfig.resource.metadata,
55570
+ subject,
55571
+ grade,
55572
+ totalXp,
55573
+ masterableUnits
55574
+ })
55575
+ },
55576
+ componentResource: {
55577
+ ...baseConfig.componentResource,
55578
+ title: applySuffix(baseConfig.componentResource.title || "")
55579
+ }
55580
+ };
55581
+ const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
55582
+ if (existingIntegration) {
55583
+ await client.update(existingIntegration.courseId, fullConfig);
55584
+ const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
55585
+ if (updated) {
55586
+ integrations.push(this.toGameTimebackIntegration(updated));
55587
+ }
55588
+ } else {
55589
+ const result = await client.setup(fullConfig, { verbose });
55590
+ const [integration] = await db2.insert(gameTimebackIntegrations).values({ gameId, courseId: result.courseId, grade, subject, totalXp }).returning();
55591
+ if (integration) {
55592
+ const dto = this.toGameTimebackIntegration(integration);
55593
+ integrations.push(dto);
55594
+ if (verbose && result.verboseData) {
55595
+ verboseData.push({ integration: dto, config: result.verboseData });
55596
+ }
55369
55597
  }
55370
55598
  }
55371
55599
  }
55600
+ return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
55372
55601
  }
55373
- return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
55374
- }
55375
- async getIntegrations(gameId, user) {
55376
- await this.deps.validateDeveloperAccess(user, gameId);
55377
- const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
55378
- where: eq(gameTimebackIntegrations.gameId, gameId)
55379
- });
55380
- return rows.map((row) => this.toGameTimebackIntegration(row));
55381
- }
55382
- async verifyIntegration(gameId, user) {
55383
- const client = this.requireClient();
55384
- const db2 = this.deps.db;
55385
- await this.deps.validateDeveloperAccess(user, gameId);
55386
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55387
- where: eq(gameTimebackIntegrations.gameId, gameId)
55388
- });
55389
- if (integrations.length === 0) {
55390
- throw new NotFoundError("Timeback integration", gameId);
55602
+ async getIntegrations(gameId, user) {
55603
+ await this.deps.validateGameManagementAccess(user, gameId);
55604
+ const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
55605
+ where: eq(gameTimebackIntegrations.gameId, gameId)
55606
+ });
55607
+ return rows.map((row) => this.toGameTimebackIntegration(row));
55391
55608
  }
55392
- const now2 = new Date;
55393
- const results = await Promise.all(integrations.map(async (integration) => {
55394
- const resources = await client.verify(integration.courseId);
55395
- const resourceValues = Object.values(resources);
55396
- const allFound = resourceValues.every((r) => r.found);
55397
- const errors3 = Object.entries(resources).filter(([_2, r]) => !r.found).map(([name3]) => `${name3} not found`);
55398
- const status = allFound ? "success" : "error";
55399
- return {
55400
- integration: this.toGameTimebackIntegration({
55401
- ...integration,
55402
- lastVerifiedAt: now2
55403
- }),
55404
- resources,
55405
- status,
55406
- ...errors3.length > 0 && { errors: errors3 }
55407
- };
55408
- }));
55409
- await db2.update(gameTimebackIntegrations).set({ lastVerifiedAt: now2 }).where(eq(gameTimebackIntegrations.gameId, gameId));
55410
- const overallStatus = results.every((r) => r.status === "success") ? "success" : "error";
55411
- return { status: overallStatus, results };
55412
- }
55413
- async getConfig(gameId, user) {
55414
- const client = this.requireClient();
55415
- await this.deps.validateDeveloperAccess(user, gameId);
55416
- const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55417
- where: eq(gameTimebackIntegrations.gameId, gameId)
55418
- });
55419
- if (!integration) {
55420
- throw new NotFoundError("Timeback integration", gameId);
55609
+ async verifyIntegration(gameId, user) {
55610
+ const client = this.requireClient();
55611
+ const db2 = this.deps.db;
55612
+ await this.deps.validateDeveloperAccess(user, gameId);
55613
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
55614
+ where: eq(gameTimebackIntegrations.gameId, gameId)
55615
+ });
55616
+ if (integrations.length === 0) {
55617
+ throw new NotFoundError("Timeback integration", gameId);
55618
+ }
55619
+ const now2 = new Date;
55620
+ const results = await Promise.all(integrations.map(async (integration) => {
55621
+ const resources = await client.verify(integration.courseId);
55622
+ const resourceValues = Object.values(resources);
55623
+ const allFound = resourceValues.every((r) => r.found);
55624
+ const errors3 = Object.entries(resources).filter(([_2, r]) => !r.found).map(([name3]) => `${name3} not found`);
55625
+ const status = allFound ? "success" : "error";
55626
+ return {
55627
+ integration: this.toGameTimebackIntegration({
55628
+ ...integration,
55629
+ lastVerifiedAt: now2
55630
+ }),
55631
+ resources,
55632
+ status,
55633
+ ...errors3.length > 0 && { errors: errors3 }
55634
+ };
55635
+ }));
55636
+ await db2.update(gameTimebackIntegrations).set({ lastVerifiedAt: now2 }).where(eq(gameTimebackIntegrations.gameId, gameId));
55637
+ const overallStatus = results.every((r) => r.status === "success") ? "success" : "error";
55638
+ return { status: overallStatus, results };
55639
+ }
55640
+ async getConfig(gameId, user) {
55641
+ const client = this.requireClient();
55642
+ await this.deps.validateDeveloperAccess(user, gameId);
55643
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55644
+ where: eq(gameTimebackIntegrations.gameId, gameId)
55645
+ });
55646
+ if (!integration) {
55647
+ throw new NotFoundError("Timeback integration", gameId);
55648
+ }
55649
+ return client.getConfig(integration.courseId);
55421
55650
  }
55422
- return client.getConfig(integration.courseId);
55423
- }
55424
- async deleteIntegrations(gameId, user) {
55425
- const client = this.requireClient();
55426
- const db2 = this.deps.db;
55427
- await this.deps.validateDeveloperAccess(user, gameId);
55428
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55429
- where: eq(gameTimebackIntegrations.gameId, gameId)
55430
- });
55431
- if (integrations.length === 0) {
55432
- throw new NotFoundError("Timeback integration", gameId);
55651
+ async deleteIntegrations(gameId, user) {
55652
+ const client = this.requireClient();
55653
+ const db2 = this.deps.db;
55654
+ await this.deps.validateDeveloperAccess(user, gameId);
55655
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
55656
+ where: eq(gameTimebackIntegrations.gameId, gameId)
55657
+ });
55658
+ if (integrations.length === 0) {
55659
+ throw new NotFoundError("Timeback integration", gameId);
55660
+ }
55661
+ for (const integration of integrations) {
55662
+ await client.cleanup(integration.courseId);
55663
+ }
55664
+ await db2.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
55433
55665
  }
55434
- for (const integration of integrations) {
55435
- await client.cleanup(integration.courseId);
55666
+ toGameTimebackIntegration(integration) {
55667
+ return {
55668
+ id: integration.id,
55669
+ gameId: integration.gameId,
55670
+ courseId: integration.courseId,
55671
+ grade: integration.grade,
55672
+ subject: integration.subject,
55673
+ totalXp: integration.totalXp ?? null,
55674
+ createdAt: integration.createdAt,
55675
+ updatedAt: integration.updatedAt,
55676
+ lastVerifiedAt: integration.lastVerifiedAt ?? null
55677
+ };
55436
55678
  }
55437
- await db2.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
55438
- }
55439
- toGameTimebackIntegration(integration) {
55440
- return {
55441
- id: integration.id,
55442
- gameId: integration.gameId,
55443
- courseId: integration.courseId,
55444
- grade: integration.grade,
55445
- subject: integration.subject,
55446
- totalXp: integration.totalXp ?? null,
55447
- createdAt: integration.createdAt,
55448
- updatedAt: integration.updatedAt,
55449
- lastVerifiedAt: integration.lastVerifiedAt ?? null
55450
- };
55451
- }
55452
- async endActivity({
55453
- gameId,
55454
- studentId,
55455
- activityData,
55456
- scoreData,
55457
- timingData,
55458
- xpEarned,
55459
- masteredUnits,
55460
- extensions,
55461
- user
55462
- }) {
55463
- const client = this.requireClient();
55464
- const db2 = this.deps.db;
55465
- await this.deps.validateDeveloperAccess(user, gameId);
55466
- const integration = await db2.query.gameTimebackIntegrations.findFirst({
55467
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
55468
- });
55469
- if (!integration) {
55470
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
55471
- }
55472
- const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
55473
- const result = await client.recordProgress(integration.courseId, studentId, {
55474
- score: scorePercentage,
55475
- totalQuestions: scoreData.totalQuestions,
55476
- correctQuestions: scoreData.correctQuestions,
55477
- durationSeconds: timingData.durationSeconds,
55679
+ async endActivity({
55680
+ gameId,
55681
+ studentId,
55682
+ runId,
55683
+ resumeId,
55684
+ activityData,
55685
+ scoreData,
55686
+ timingData,
55687
+ sessionTimingData,
55478
55688
  xpEarned,
55479
55689
  masteredUnits,
55480
55690
  extensions,
55481
- activityId: activityData.activityId,
55482
- activityName: activityData.activityName,
55483
- subject: activityData.subject,
55484
- appName: activityData.appName,
55485
- sensorUrl: activityData.sensorUrl,
55486
- courseId: activityData.courseId,
55487
- courseName: activityData.courseName,
55488
- studentEmail: activityData.studentEmail,
55489
- courseTotalXp: integration.totalXp
55490
- });
55491
- await client.recordSessionEnd(integration.courseId, studentId, {
55492
- activeTimeSeconds: timingData.durationSeconds,
55493
- activityId: activityData.activityId,
55494
- activityName: activityData.activityName,
55495
- subject: activityData.subject,
55496
- appName: activityData.appName,
55497
- sensorUrl: activityData.sensorUrl,
55498
- courseId: activityData.courseId,
55499
- courseName: activityData.courseName,
55500
- studentEmail: activityData.studentEmail
55501
- });
55502
- logger17.info("Recorded activity completion", {
55691
+ user
55692
+ }) {
55693
+ const client = this.requireClient();
55694
+ const db2 = this.deps.db;
55695
+ const effectiveResumeId = resumeId ?? runId ?? crypto.randomUUID();
55696
+ const extensionsWithResumeId = TimebackService2.addResumeIdToExtensions(extensions, effectiveResumeId);
55697
+ await this.deps.validateDeveloperAccess(user, gameId);
55698
+ const integration = await db2.query.gameTimebackIntegrations.findFirst({
55699
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
55700
+ });
55701
+ if (!integration) {
55702
+ throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
55703
+ }
55704
+ const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
55705
+ const result = await client.recordProgress(integration.courseId, studentId, {
55706
+ gameId,
55707
+ score: scorePercentage,
55708
+ totalQuestions: scoreData.totalQuestions,
55709
+ correctQuestions: scoreData.correctQuestions,
55710
+ durationSeconds: timingData.durationSeconds,
55711
+ xpEarned,
55712
+ masteredUnits,
55713
+ extensions: extensionsWithResumeId,
55714
+ activityId: activityData.activityId,
55715
+ activityName: activityData.activityName,
55716
+ subject: activityData.subject,
55717
+ appName: activityData.appName,
55718
+ sensorUrl: activityData.sensorUrl,
55719
+ courseId: activityData.courseId,
55720
+ courseName: activityData.courseName,
55721
+ studentEmail: activityData.studentEmail,
55722
+ courseTotalXp: integration.totalXp,
55723
+ ...runId ? { runId } : {}
55724
+ });
55725
+ const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
55726
+ const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
55727
+ if (sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0) {
55728
+ await client.recordSessionEnd(integration.courseId, studentId, {
55729
+ gameId,
55730
+ activeTimeSeconds: sessionEndActiveSeconds,
55731
+ ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
55732
+ activityId: activityData.activityId,
55733
+ activityName: activityData.activityName,
55734
+ subject: activityData.subject,
55735
+ appName: activityData.appName,
55736
+ sensorUrl: activityData.sensorUrl,
55737
+ courseId: activityData.courseId,
55738
+ courseName: activityData.courseName,
55739
+ studentEmail: activityData.studentEmail,
55740
+ extensions: extensionsWithResumeId,
55741
+ ...runId ? { runId } : {}
55742
+ });
55743
+ }
55744
+ logger17.info("Recorded activity completion", {
55745
+ gameId,
55746
+ courseId: integration.courseId,
55747
+ studentId,
55748
+ runId,
55749
+ score: scorePercentage
55750
+ });
55751
+ return {
55752
+ status: "ok",
55753
+ courseId: integration.courseId,
55754
+ xpAwarded: result.xpAwarded,
55755
+ masteredUnits: result.masteredUnitsApplied,
55756
+ pctCompleteApp: result.pctCompleteApp,
55757
+ scoreStatus: result.scoreStatus,
55758
+ inProgress: result.inProgress
55759
+ };
55760
+ }
55761
+ async recordHeartbeat({
55503
55762
  gameId,
55504
- courseId: integration.courseId,
55505
55763
  studentId,
55506
- score: scorePercentage
55507
- });
55508
- return {
55509
- status: "ok",
55510
- courseId: integration.courseId,
55511
- xpAwarded: result.xpAwarded,
55512
- masteredUnits: result.masteredUnitsApplied,
55513
- pctCompleteApp: result.pctCompleteApp,
55514
- scoreStatus: result.scoreStatus,
55515
- inProgress: result.inProgress
55516
- };
55517
- }
55518
- async getStudentXp(timebackId, user, options) {
55519
- const client = this.requireClient();
55520
- const db2 = this.deps.db;
55521
- let courseIds = [];
55522
- if (options?.gameId) {
55523
- await this.deps.validateDeveloperAccess(user, options.gameId);
55524
- const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
55525
- if (options.grade !== undefined && options.subject) {
55526
- conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
55527
- conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
55764
+ runId,
55765
+ resumeId,
55766
+ activityData,
55767
+ timingData,
55768
+ windowStartedAtMs,
55769
+ windowSequence,
55770
+ isFinal,
55771
+ user
55772
+ }) {
55773
+ const client = this.requireClient();
55774
+ const db2 = this.deps.db;
55775
+ const hasWindowStartedAtMs = windowStartedAtMs !== undefined;
55776
+ const hasWindowSequence = windowSequence !== undefined;
55777
+ if (hasWindowStartedAtMs === hasWindowSequence) {
55778
+ throw new ValidationError("Provide exactly one of windowStartedAtMs or windowSequence");
55779
+ }
55780
+ const heartbeatWindowKey = hasWindowStartedAtMs ? `${runId}:t:${windowStartedAtMs}` : `${runId}:s:${windowSequence}`;
55781
+ const effectiveResumeId = resumeId ?? runId;
55782
+ if (TimebackService2.isDuplicateHeartbeatWindow(heartbeatWindowKey)) {
55783
+ logger17.debug("Skipping duplicate heartbeat window", {
55784
+ gameId,
55785
+ studentId,
55786
+ runId,
55787
+ windowStartedAtMs,
55788
+ windowSequence,
55789
+ isFinal
55790
+ });
55791
+ return { status: "ok" };
55528
55792
  }
55529
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55530
- where: and(...conditions2)
55531
- });
55532
- courseIds = integrations.map((i2) => i2.courseId);
55533
- if (courseIds.length === 0) {
55534
- logger17.debug("No integrations found for game, returning 0 XP", {
55535
- timebackId,
55536
- gameId: options.gameId,
55537
- grade: options.grade,
55538
- subject: options.subject
55793
+ await this.deps.validateDeveloperAccess(user, gameId);
55794
+ const inFlightHeartbeat = TimebackService2.getInFlightHeartbeatWindow(heartbeatWindowKey);
55795
+ if (inFlightHeartbeat) {
55796
+ logger17.debug("Joining in-flight heartbeat window", {
55797
+ gameId,
55798
+ studentId,
55799
+ runId,
55800
+ windowStartedAtMs,
55801
+ windowSequence,
55802
+ isFinal
55539
55803
  });
55540
- return {
55541
- totalXp: 0,
55542
- ...options?.include?.today && { todayXp: 0 },
55543
- ...options?.include?.perCourse && { courses: [] }
55544
- };
55804
+ return inFlightHeartbeat;
55805
+ }
55806
+ const pendingHeartbeat = (async () => {
55807
+ const integration = await db2.query.gameTimebackIntegrations.findFirst({
55808
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
55809
+ });
55810
+ if (!integration) {
55811
+ throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
55812
+ }
55813
+ const activeTimeSeconds = timingData.activeMs / 1000;
55814
+ const inactiveTimeSeconds = timingData.pausedMs / 1000;
55815
+ if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
55816
+ await client.recordSessionEnd(integration.courseId, studentId, {
55817
+ gameId,
55818
+ activeTimeSeconds,
55819
+ ...inactiveTimeSeconds > 0 ? { inactiveTimeSeconds } : {},
55820
+ activityId: activityData.activityId,
55821
+ activityName: activityData.activityName,
55822
+ subject: activityData.subject,
55823
+ appName: activityData.appName,
55824
+ sensorUrl: activityData.sensorUrl,
55825
+ courseId: activityData.courseId,
55826
+ courseName: activityData.courseName,
55827
+ studentEmail: activityData.studentEmail,
55828
+ extensions: TimebackService2.addResumeIdToExtensions(undefined, effectiveResumeId),
55829
+ ...runId ? { runId } : {}
55830
+ });
55831
+ }
55832
+ TimebackService2.markHeartbeatWindowProcessed(heartbeatWindowKey);
55833
+ logger17.debug("Recorded heartbeat", {
55834
+ gameId,
55835
+ courseId: integration.courseId,
55836
+ studentId,
55837
+ runId,
55838
+ windowStartedAtMs,
55839
+ windowSequence,
55840
+ activeTimeSeconds,
55841
+ isFinal
55842
+ });
55843
+ return { status: "ok" };
55844
+ })();
55845
+ TimebackService2.markHeartbeatWindowInFlight(heartbeatWindowKey, pendingHeartbeat);
55846
+ try {
55847
+ return await pendingHeartbeat;
55848
+ } finally {
55849
+ TimebackService2.clearInFlightHeartbeatWindow(heartbeatWindowKey);
55850
+ }
55851
+ }
55852
+ async getStudentXp(timebackId, user, options) {
55853
+ const client = this.requireClient();
55854
+ const db2 = this.deps.db;
55855
+ let courseIds = [];
55856
+ if (options?.gameId) {
55857
+ await this.deps.validateDeveloperAccess(user, options.gameId);
55858
+ const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
55859
+ if (options.grade !== undefined && options.subject) {
55860
+ conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
55861
+ conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
55862
+ }
55863
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
55864
+ where: and(...conditions2)
55865
+ });
55866
+ courseIds = integrations.map((i2) => i2.courseId);
55867
+ if (courseIds.length === 0) {
55868
+ logger17.debug("No integrations found for game, returning 0 XP", {
55869
+ timebackId,
55870
+ gameId: options.gameId,
55871
+ grade: options.grade,
55872
+ subject: options.subject
55873
+ });
55874
+ return {
55875
+ totalXp: 0,
55876
+ ...options?.include?.today && { todayXp: 0 },
55877
+ ...options?.include?.perCourse && { courses: [] }
55878
+ };
55879
+ }
55545
55880
  }
55881
+ const result = await client.getStudentXp(timebackId, {
55882
+ courseIds: courseIds.length > 0 ? courseIds : undefined,
55883
+ include: options?.include
55884
+ });
55885
+ logger17.debug("Retrieved student XP", {
55886
+ timebackId,
55887
+ gameId: options?.gameId,
55888
+ grade: options?.grade,
55889
+ subject: options?.subject,
55890
+ totalXp: result.totalXp,
55891
+ courseCount: result.courses?.length
55892
+ });
55893
+ return result;
55546
55894
  }
55547
- const result = await client.getStudentXp(timebackId, {
55548
- courseIds: courseIds.length > 0 ? courseIds : undefined,
55549
- include: options?.include
55550
- });
55551
- logger17.debug("Retrieved student XP", {
55552
- timebackId,
55553
- gameId: options?.gameId,
55554
- grade: options?.grade,
55555
- subject: options?.subject,
55556
- totalXp: result.totalXp,
55557
- courseCount: result.courses?.length
55558
- });
55559
- return result;
55560
- }
55561
- }
55562
- var logger17;
55563
- var init_timeback_service = __esm(() => {
55564
- init_drizzle_orm();
55565
- init_src();
55566
- init_tables_index();
55567
- init_src2();
55568
- init_types4();
55569
- init_src4();
55570
- init_errors();
55571
- init_timeback_util();
55572
- logger17 = log.scope("TimebackService");
55895
+ };
55573
55896
  });
55574
55897
 
55575
55898
  class UploadService {
@@ -55625,6 +55948,7 @@ function createPlatformServices(deps) {
55625
55948
  alerts,
55626
55949
  validateDeveloperAccessBySlug,
55627
55950
  validateDeveloperAccess,
55951
+ validateGameManagementAccess,
55628
55952
  validateOwnership
55629
55953
  } = deps;
55630
55954
  const bucket = new BucketService({
@@ -55659,12 +55983,15 @@ function createPlatformServices(deps) {
55659
55983
  const timeback2 = new TimebackService({
55660
55984
  db: db2,
55661
55985
  timeback: timebackClient,
55662
- validateDeveloperAccess
55986
+ validateDeveloperAccess,
55987
+ validateGameManagementAccess
55663
55988
  });
55664
55989
  const timebackAdmin = new TimebackAdminService({
55990
+ config: config2,
55665
55991
  db: db2,
55666
55992
  timeback: timebackClient,
55667
- validateDeveloperAccess
55993
+ validateDeveloperAccess,
55994
+ validateGameManagementAccess
55668
55995
  });
55669
55996
  return {
55670
55997
  bucket,
@@ -58591,6 +58918,16 @@ async function requestCaliper(options) {
58591
58918
  baseUrl: caliperUrl
58592
58919
  });
58593
58920
  }
58921
+ function buildEventExtensions({
58922
+ eventExtensions,
58923
+ gameId
58924
+ }) {
58925
+ const mergedExtensions = {
58926
+ ...eventExtensions,
58927
+ ...gameId ? { gameId } : {}
58928
+ };
58929
+ return Object.keys(mergedExtensions).length > 0 ? mergedExtensions : undefined;
58930
+ }
58594
58931
  function createCaliperNamespace(client) {
58595
58932
  const urls = createOneRosterUrls(client.getBaseUrl());
58596
58933
  const caliper = {
@@ -58635,11 +58972,20 @@ function createCaliperNamespace(client) {
58635
58972
  if (params.actorEmail) {
58636
58973
  query.set("actorEmail", params.actorEmail);
58637
58974
  }
58975
+ if (params.extensions) {
58976
+ for (const [key, value] of Object.entries(params.extensions)) {
58977
+ query.set(`extensions.${key}`, value);
58978
+ }
58979
+ }
58638
58980
  const requestPath = `${CALIPER_ENDPOINTS4.events}?${query.toString()}`;
58639
58981
  return client["requestCaliper"](requestPath, "GET");
58640
58982
  }
58641
58983
  },
58642
58984
  emitActivityEvent: async (data) => {
58985
+ const eventExtensions = buildEventExtensions({
58986
+ eventExtensions: data.eventExtensions,
58987
+ gameId: data.gameId
58988
+ });
58643
58989
  const event = {
58644
58990
  "@context": CALIPER_CONSTANTS4.context,
58645
58991
  id: `urn:uuid:${crypto.randomUUID()}`,
@@ -58652,6 +58998,7 @@ function createCaliperNamespace(client) {
58652
58998
  email: data.studentEmail
58653
58999
  },
58654
59000
  action: TIMEBACK_ACTIONS4.completed,
59001
+ ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
58655
59002
  object: {
58656
59003
  id: data.objectId || caliper.buildActivityUrl(data),
58657
59004
  type: TIMEBACK_TYPES4.activityContext,
@@ -58698,11 +59045,15 @@ function createCaliperNamespace(client) {
58698
59045
  }
58699
59046
  } : {}
58700
59047
  },
58701
- ...data.eventExtensions ? { extensions: data.eventExtensions } : {}
59048
+ ...eventExtensions ? { extensions: eventExtensions } : {}
58702
59049
  };
58703
59050
  return caliper.emit(event, data.sensorUrl);
58704
59051
  },
58705
59052
  emitTimeSpentEvent: async (data) => {
59053
+ const eventExtensions = buildEventExtensions({
59054
+ eventExtensions: data.eventExtensions,
59055
+ gameId: data.gameId
59056
+ });
58706
59057
  const event = {
58707
59058
  "@context": CALIPER_CONSTANTS4.context,
58708
59059
  id: `urn:uuid:${crypto.randomUUID()}`,
@@ -58715,6 +59066,7 @@ function createCaliperNamespace(client) {
58715
59066
  email: data.studentEmail
58716
59067
  },
58717
59068
  action: TIMEBACK_ACTIONS4.spentTime,
59069
+ ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
58718
59070
  object: {
58719
59071
  id: caliper.buildActivityUrl(data),
58720
59072
  type: TIMEBACK_TYPES4.activityContext,
@@ -58742,13 +59094,14 @@ function createCaliperNamespace(client) {
58742
59094
  ...data.wasteTimeSeconds !== undefined ? [{ type: TIME_METRIC_TYPES4.waste, value: data.wasteTimeSeconds }] : []
58743
59095
  ],
58744
59096
  ...data.extensions ? { extensions: data.extensions } : {}
58745
- }
59097
+ },
59098
+ ...eventExtensions ? { extensions: eventExtensions } : {}
58746
59099
  };
58747
59100
  return caliper.emit(event, data.sensorUrl);
58748
59101
  },
58749
59102
  buildActivityUrl: (data) => {
58750
59103
  const base = data.sensorUrl.replace(/\/$/, "");
58751
- return `${base}/activities/${data.courseId}/${data.activityId}/${crypto.randomUUID()}`;
59104
+ return `${base}/activities/${encodeURIComponent(data.courseId)}/${encodeURIComponent(data.activityId)}`;
58752
59105
  }
58753
59106
  };
58754
59107
  return caliper;
@@ -58758,6 +59111,34 @@ function createEduBridgeNamespace(client) {
58758
59111
  listByUser: async (userId) => {
58759
59112
  const response = await client["request"](`/edubridge/enrollments/user/${userId}`, "GET");
58760
59113
  return response.data;
59114
+ },
59115
+ enroll: async (userId, courseId, options) => {
59116
+ const segments = [userId, courseId];
59117
+ if (options?.schoolId) {
59118
+ segments.push(options.schoolId);
59119
+ }
59120
+ const body2 = {};
59121
+ if (options?.role) {
59122
+ body2.role = options.role;
59123
+ }
59124
+ if (options?.sourcedId) {
59125
+ body2.sourcedId = options.sourcedId;
59126
+ }
59127
+ if (options?.beginDate) {
59128
+ body2.beginDate = options.beginDate;
59129
+ }
59130
+ if (options?.metadata) {
59131
+ body2.metadata = options.metadata;
59132
+ }
59133
+ const response = await client["request"](`/edubridge/enrollments/enroll/${segments.join("/")}`, "POST", body2);
59134
+ return response.data;
59135
+ },
59136
+ unenroll: async (userId, courseId, options) => {
59137
+ const segments = [userId, courseId];
59138
+ if (options?.schoolId) {
59139
+ segments.push(options.schoolId);
59140
+ }
59141
+ await client["request"](`/edubridge/enrollments/unenroll/${segments.join("/")}`, "DELETE");
58761
59142
  }
58762
59143
  };
58763
59144
  const analytics = {
@@ -58933,6 +59314,10 @@ function createOneRosterNamespace(client) {
58933
59314
  logTimebackError("list course roster", error, { courseSourcedId });
58934
59315
  throw error;
58935
59316
  }
59317
+ },
59318
+ create: async (data) => client["request"](ONEROSTER_ENDPOINTS4.enrollments, "POST", { enrollment: data }),
59319
+ delete: async (sourcedId) => {
59320
+ await client["request"](`${ONEROSTER_ENDPOINTS4.enrollments}/${sourcedId}`, "DELETE");
58936
59321
  }
58937
59322
  },
58938
59323
  organizations: {
@@ -59203,6 +59588,7 @@ class AdminEventRecorder {
59203
59588
  await this.caliper.emitActivityEvent({
59204
59589
  studentId: ctx.student.id,
59205
59590
  studentEmail: ctx.student.email,
59591
+ gameId: data.gameId,
59206
59592
  activityId: ctx.activityId,
59207
59593
  activityName: data.activityName || `Manual XP Assignment - ${ctx.courseContext.subject}`,
59208
59594
  courseId: data.courseId,
@@ -59229,6 +59615,7 @@ class AdminEventRecorder {
59229
59615
  await this.caliper.emitTimeSpentEvent({
59230
59616
  studentId: ctx.student.id,
59231
59617
  studentEmail: ctx.student.email,
59618
+ gameId: data.gameId,
59232
59619
  activityId: ctx.activityId,
59233
59620
  activityName: data.activityName || "Playcademy Admin Time Adjustment",
59234
59621
  courseId: data.courseId,
@@ -59250,6 +59637,7 @@ class AdminEventRecorder {
59250
59637
  await this.caliper.emitActivityEvent({
59251
59638
  studentId: ctx.student.id,
59252
59639
  studentEmail: ctx.student.email,
59640
+ gameId: data.gameId,
59253
59641
  activityId: ctx.activityId,
59254
59642
  activityName: data.activityName || "Playcademy Admin Mastery Adjustment",
59255
59643
  courseId: data.courseId,
@@ -59275,6 +59663,7 @@ class AdminEventRecorder {
59275
59663
  await this.caliper.emitActivityEvent({
59276
59664
  studentId: ctx.student.id,
59277
59665
  studentEmail: ctx.student.email,
59666
+ gameId: data.gameId,
59278
59667
  activityId: ctx.activityId,
59279
59668
  activityName: isResume ? "Course resumed" : "Course marked complete",
59280
59669
  courseId: data.courseId,
@@ -59723,15 +60112,13 @@ class ProgressRecorder {
59723
60112
  studentId,
59724
60113
  attemptNumber: currentAttemptNumber,
59725
60114
  score,
59726
- totalQuestions,
59727
- correctQuestions,
59728
60115
  xp: calculatedXp,
59729
- masteredUnits,
59730
60116
  scoreStatus,
59731
60117
  inProgress,
59732
60118
  appName: progressData.appName,
59733
- activityName,
59734
- durationSeconds: progressData.durationSeconds
60119
+ totalQuestions,
60120
+ correctQuestions,
60121
+ masteredUnits
59735
60122
  });
59736
60123
  } else {
59737
60124
  log.warn("[ProgressRecorder] Score not provided, skipping gradebook entry", {
@@ -59745,6 +60132,7 @@ class ProgressRecorder {
59745
60132
  await this.emitCourseCompletionHistoryEvent({
59746
60133
  studentId,
59747
60134
  studentEmail,
60135
+ gameId: progressData.gameId,
59748
60136
  activityId,
59749
60137
  courseId: ids.course,
59750
60138
  courseName,
@@ -59756,6 +60144,7 @@ class ProgressRecorder {
59756
60144
  await this.emitCaliperEvent({
59757
60145
  studentId,
59758
60146
  studentEmail,
60147
+ gameId: progressData.gameId,
59759
60148
  activityId,
59760
60149
  activityName,
59761
60150
  courseId: ids.course,
@@ -59766,7 +60155,8 @@ class ProgressRecorder {
59766
60155
  masteredUnits,
59767
60156
  attemptNumber: currentAttemptNumber,
59768
60157
  progressData,
59769
- extensions
60158
+ extensions,
60159
+ runId: progressData.runId
59770
60160
  });
59771
60161
  return {
59772
60162
  xpAwarded: calculatedXp,
@@ -59856,15 +60246,13 @@ class ProgressRecorder {
59856
60246
  studentId,
59857
60247
  attemptNumber,
59858
60248
  score,
59859
- totalQuestions,
59860
- correctQuestions,
59861
60249
  xp,
59862
- masteredUnits,
59863
60250
  scoreStatus,
59864
60251
  inProgress,
59865
60252
  appName,
59866
- activityName,
59867
- durationSeconds
60253
+ totalQuestions,
60254
+ correctQuestions,
60255
+ masteredUnits
59868
60256
  }) {
59869
60257
  const timestamp3 = Date.now().toString(36);
59870
60258
  const resultId = `${lineItemId}:${studentId}:${timestamp3}`;
@@ -59879,21 +60267,18 @@ class ProgressRecorder {
59879
60267
  inProgress,
59880
60268
  metadata: {
59881
60269
  xp,
59882
- totalQuestions,
59883
- correctQuestions,
59884
- accuracy: totalQuestions && correctQuestions ? correctQuestions / totalQuestions * 100 : undefined,
59885
60270
  attemptNumber,
59886
- lastUpdated: new Date().toISOString(),
59887
- masteredUnits,
59888
60271
  appName,
59889
- activityName,
59890
- durationSeconds
60272
+ ...totalQuestions !== undefined ? { totalQuestions } : {},
60273
+ ...correctQuestions !== undefined ? { correctQuestions } : {},
60274
+ ...masteredUnits !== undefined ? { masteredUnits } : {}
59891
60275
  }
59892
60276
  });
59893
60277
  }
59894
60278
  async emitCaliperEvent({
59895
60279
  studentId,
59896
60280
  studentEmail,
60281
+ gameId,
59897
60282
  activityId,
59898
60283
  activityName,
59899
60284
  courseId,
@@ -59904,11 +60289,13 @@ class ProgressRecorder {
59904
60289
  masteredUnits,
59905
60290
  attemptNumber,
59906
60291
  progressData,
59907
- extensions
60292
+ extensions,
60293
+ runId
59908
60294
  }) {
59909
60295
  await this.caliperNamespace.emitActivityEvent({
59910
60296
  studentId,
59911
60297
  studentEmail,
60298
+ gameId,
59912
60299
  activityId,
59913
60300
  activityName,
59914
60301
  courseId,
@@ -59921,7 +60308,8 @@ class ProgressRecorder {
59921
60308
  subject: progressData.subject,
59922
60309
  appName: progressData.appName,
59923
60310
  sensorUrl: progressData.sensorUrl,
59924
- extensions: extensions || progressData.extensions
60311
+ extensions: extensions || progressData.extensions,
60312
+ ...runId ? { runId } : {}
59925
60313
  }).catch((error) => {
59926
60314
  log.error("[ProgressRecorder] Failed to emit activity event", { error });
59927
60315
  });
@@ -59930,6 +60318,7 @@ class ProgressRecorder {
59930
60318
  await this.caliperNamespace.emitActivityEvent({
59931
60319
  studentId: data.studentId,
59932
60320
  studentEmail: data.studentEmail,
60321
+ gameId: data.gameId,
59933
60322
  activityId: data.activityId,
59934
60323
  activityName: "Course completed",
59935
60324
  courseId: data.courseId,
@@ -59975,10 +60364,11 @@ class SessionRecorder {
59975
60364
  const courseName = sessionData.courseName || "Game Course";
59976
60365
  const student = await this.studentResolver.resolve(studentIdentifier, sessionData.studentEmail);
59977
60366
  const { id: studentId, email: studentEmail } = student;
59978
- const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions } = sessionData;
60367
+ const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions, runId } = sessionData;
59979
60368
  await this.caliperNamespace.emitTimeSpentEvent({
59980
60369
  studentId,
59981
60370
  studentEmail,
60371
+ gameId: sessionData.gameId,
59982
60372
  activityId,
59983
60373
  activityName,
59984
60374
  courseId: ids.course,
@@ -59989,6 +60379,7 @@ class SessionRecorder {
59989
60379
  subject: sessionData.subject,
59990
60380
  appName: sessionData.appName,
59991
60381
  sensorUrl: sessionData.sensorUrl,
60382
+ ...runId ? { runId } : {},
59992
60383
  ...extensions ? { extensions } : {}
59993
60384
  });
59994
60385
  }
@@ -118566,18 +118957,23 @@ async function seedCoreGames(db2) {
118566
118957
  }
118567
118958
  async function seedCurrentProjectGame(db2, project) {
118568
118959
  const now2 = new Date;
118960
+ const desiredGameId = project.gameId?.trim() || undefined;
118569
118961
  try {
118570
118962
  const existingGame = await db2.query.games.findFirst({
118571
- where: (row, { eq: eq3 }) => eq3(row.slug, project.slug)
118963
+ where: (row, operators) => operators.eq(row.slug, project.slug)
118572
118964
  });
118573
118965
  if (existingGame) {
118574
- if (project.timebackCourses && project.timebackCourses.length > 0) {
118575
- await seedTimebackIntegrations(db2, existingGame.id, project.timebackCourses);
118966
+ if (desiredGameId && existingGame.id !== desiredGameId) {
118967
+ await db2.delete(games).where(eq(games.id, existingGame.id));
118968
+ } else {
118969
+ if (project.timebackCourses && project.timebackCourses.length > 0) {
118970
+ await seedTimebackIntegrations(db2, existingGame.id, project.timebackCourses);
118971
+ }
118972
+ return existingGame;
118576
118973
  }
118577
- return existingGame;
118578
118974
  }
118579
118975
  const gameRecord = {
118580
- id: crypto.randomUUID(),
118976
+ id: desiredGameId ?? crypto.randomUUID(),
118581
118977
  developerId: DEMO_USERS.developer.id,
118582
118978
  slug: project.slug,
118583
118979
  displayName: project.displayName,
@@ -118606,6 +119002,7 @@ async function seedCurrentProjectGame(db2, project) {
118606
119002
  }
118607
119003
  }
118608
119004
  var init_games = __esm(() => {
119005
+ init_drizzle_orm();
118609
119006
  init_src();
118610
119007
  init_tables_index();
118611
119008
  init_constants();
@@ -119670,6 +120067,7 @@ var init_schemas2 = __esm(() => {
119670
120067
  code: exports_external.string().optional(),
119671
120068
  codeUploadToken: exports_external.string().optional(),
119672
120069
  config: exports_external.unknown().optional(),
120070
+ compatibilityDate: exports_external.string().optional(),
119673
120071
  compatibilityFlags: exports_external.array(exports_external.string()).optional(),
119674
120072
  bindings: exports_external.object({
119675
120073
  database: exports_external.array(exports_external.string()).optional(),
@@ -119951,7 +120349,9 @@ var TIMEBACK_SUBJECTS5;
119951
120349
  var TimebackGradeSchema;
119952
120350
  var TimebackSubjectSchema;
119953
120351
  var UpdateTimebackXpRequestSchema;
120352
+ var TimebackActivityDataSchema;
119954
120353
  var EndActivityRequestSchema;
120354
+ var HeartbeatRequestSchema;
119955
120355
  var PopulateStudentRequestSchema;
119956
120356
  var DerivedPlatformCourseConfigSchema;
119957
120357
  var TimebackBaseConfigSchema;
@@ -119962,6 +120362,8 @@ var GrantTimebackXpRequestSchema;
119962
120362
  var AdjustTimebackTimeRequestSchema;
119963
120363
  var AdjustTimebackMasteryRequestSchema;
119964
120364
  var ToggleCourseCompletionRequestSchema;
120365
+ var EnrollStudentRequestSchema;
120366
+ var UnenrollStudentRequestSchema;
119965
120367
  var init_schemas11 = __esm(() => {
119966
120368
  init_esm();
119967
120369
  TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
@@ -119984,31 +120386,55 @@ var init_schemas11 = __esm(() => {
119984
120386
  xp: exports_external.number().min(0, "XP must be a non-negative number"),
119985
120387
  userTimestamp: exports_external.string().datetime().optional()
119986
120388
  });
120389
+ TimebackActivityDataSchema = exports_external.object({
120390
+ activityId: exports_external.string().min(1),
120391
+ activityName: exports_external.string().optional(),
120392
+ grade: TimebackGradeSchema,
120393
+ subject: TimebackSubjectSchema,
120394
+ appName: exports_external.string().optional(),
120395
+ sensorUrl: exports_external.string().url().optional(),
120396
+ courseId: exports_external.string().optional(),
120397
+ courseName: exports_external.string().optional(),
120398
+ studentEmail: exports_external.string().email().optional()
120399
+ });
119987
120400
  EndActivityRequestSchema = exports_external.object({
119988
120401
  gameId: exports_external.string().uuid(),
119989
120402
  studentId: exports_external.string().min(1),
119990
- activityData: exports_external.object({
119991
- activityId: exports_external.string().min(1),
119992
- activityName: exports_external.string().optional(),
119993
- grade: TimebackGradeSchema,
119994
- subject: TimebackSubjectSchema,
119995
- appName: exports_external.string().optional(),
119996
- sensorUrl: exports_external.string().url().optional(),
119997
- courseId: exports_external.string().optional(),
119998
- courseName: exports_external.string().optional(),
119999
- studentEmail: exports_external.string().email().optional()
120000
- }),
120403
+ runId: exports_external.string().uuid().optional(),
120404
+ resumeId: exports_external.string().uuid().optional(),
120405
+ activityData: TimebackActivityDataSchema,
120001
120406
  scoreData: exports_external.object({
120002
120407
  correctQuestions: exports_external.number().int().min(0),
120003
120408
  totalQuestions: exports_external.number().int().min(0)
120004
120409
  }),
120005
120410
  timingData: exports_external.object({
120006
- durationSeconds: exports_external.number().positive()
120411
+ durationSeconds: exports_external.number().nonnegative()
120007
120412
  }),
120413
+ sessionTimingData: exports_external.object({
120414
+ activeSeconds: exports_external.number().nonnegative(),
120415
+ inactiveSeconds: exports_external.number().nonnegative().optional()
120416
+ }).optional(),
120008
120417
  xpEarned: exports_external.number().optional(),
120009
120418
  masteredUnits: exports_external.number().nonnegative().optional(),
120010
120419
  extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
120011
120420
  });
120421
+ HeartbeatRequestSchema = exports_external.object({
120422
+ gameId: exports_external.string().uuid(),
120423
+ studentId: exports_external.string().min(1),
120424
+ runId: exports_external.string().uuid(),
120425
+ resumeId: exports_external.string().uuid().optional(),
120426
+ activityData: TimebackActivityDataSchema,
120427
+ timingData: exports_external.object({
120428
+ activeMs: exports_external.number().nonnegative(),
120429
+ pausedMs: exports_external.number().nonnegative()
120430
+ }),
120431
+ windowStartedAtMs: exports_external.number().int().nonnegative().optional(),
120432
+ windowSequence: exports_external.number().int().nonnegative().optional(),
120433
+ isFinal: exports_external.boolean().optional()
120434
+ }).refine((value) => value.windowStartedAtMs !== undefined !== (value.windowSequence !== undefined), {
120435
+ message: "Provide exactly one of windowStartedAtMs or windowSequence",
120436
+ path: ["windowStartedAtMs"]
120437
+ });
120012
120438
  PopulateStudentRequestSchema = exports_external.object({
120013
120439
  firstName: exports_external.string().min(1).optional(),
120014
120440
  lastName: exports_external.string().min(1).optional()
@@ -120088,15 +120514,18 @@ var init_schemas11 = __esm(() => {
120088
120514
  });
120089
120515
  GrantTimebackXpRequestSchema = AdminTimebackMutationBaseSchema.extend({
120090
120516
  xp: exports_external.number().min(-100, "Amount must be between -100 and 100").max(100, "Amount must be between -100 and 100").refine((value) => value !== 0, { message: "Amount cannot be 0" }),
120091
- date: AdminAttributionDateSchema.optional()
120517
+ date: AdminAttributionDateSchema.optional(),
120518
+ useCurrentTime: exports_external.boolean().optional()
120092
120519
  });
120093
120520
  AdjustTimebackTimeRequestSchema = AdminTimebackMutationBaseSchema.extend({
120094
120521
  seconds: exports_external.number().refine((value) => value !== 0, { message: "Time amount cannot be 0" }),
120095
- date: AdminAttributionDateSchema.optional()
120522
+ date: AdminAttributionDateSchema.optional(),
120523
+ useCurrentTime: exports_external.boolean().optional()
120096
120524
  });
120097
120525
  AdjustTimebackMasteryRequestSchema = AdminTimebackMutationBaseSchema.extend({
120098
120526
  units: exports_external.number().refine((value) => value !== 0, { message: "Units cannot be 0" }),
120099
- date: AdminAttributionDateSchema.optional()
120527
+ date: AdminAttributionDateSchema.optional(),
120528
+ useCurrentTime: exports_external.boolean().optional()
120100
120529
  });
120101
120530
  ToggleCourseCompletionRequestSchema = exports_external.object({
120102
120531
  gameId: exports_external.string().uuid(),
@@ -120104,6 +120533,16 @@ var init_schemas11 = __esm(() => {
120104
120533
  studentId: exports_external.string().min(1),
120105
120534
  action: exports_external.enum(["complete", "resume"])
120106
120535
  });
120536
+ EnrollStudentRequestSchema = exports_external.object({
120537
+ gameId: exports_external.string().uuid(),
120538
+ courseId: exports_external.string().min(1),
120539
+ studentId: exports_external.string().min(1)
120540
+ });
120541
+ UnenrollStudentRequestSchema = exports_external.object({
120542
+ gameId: exports_external.string().uuid(),
120543
+ courseId: exports_external.string().min(1),
120544
+ studentId: exports_external.string().min(1)
120545
+ });
120107
120546
  });
120108
120547
  var init_schemas_index = __esm(() => {
120109
120548
  init_schemas();
@@ -120122,6 +120561,9 @@ function isAuthenticated(ctx) {
120122
120561
  return ctx.user != null;
120123
120562
  }
120124
120563
  var init_types9 = () => {};
120564
+ function hasGameManagementAccess(user) {
120565
+ return user.role === "admin" || user.role === "teacher" || user.role === "developer" && user.developerStatus === "approved";
120566
+ }
120125
120567
  function requireAuth(handler) {
120126
120568
  return async (ctx) => {
120127
120569
  if (!isAuthenticated(ctx)) {
@@ -120165,6 +120607,17 @@ function requireDeveloper(handler) {
120165
120607
  return handler(ctx);
120166
120608
  };
120167
120609
  }
120610
+ function requireGameManagementAccess(handler) {
120611
+ return async (ctx) => {
120612
+ if (!isAuthenticated(ctx)) {
120613
+ throw ApiError.unauthorized("Valid session or bearer token required");
120614
+ }
120615
+ if (!hasGameManagementAccess(ctx.user)) {
120616
+ throw ApiError.forbidden("Game management access required");
120617
+ }
120618
+ return handler(ctx);
120619
+ };
120620
+ }
120168
120621
  var init_auth_util = __esm(() => {
120169
120622
  init_errors();
120170
120623
  init_types9();
@@ -122290,6 +122743,7 @@ var verifyIntegration;
122290
122743
  var getConfig2;
122291
122744
  var deleteIntegrations;
122292
122745
  var endActivity;
122746
+ var heartbeat;
122293
122747
  var getStudentXp;
122294
122748
  var getRoster;
122295
122749
  var getStudentOverview;
@@ -122298,6 +122752,9 @@ var grantXp;
122298
122752
  var adjustTime;
122299
122753
  var adjustMastery;
122300
122754
  var toggleCompletion;
122755
+ var searchStudents;
122756
+ var enrollStudent;
122757
+ var unenrollStudent;
122301
122758
  var timeback2;
122302
122759
  var init_timeback_controller = __esm(() => {
122303
122760
  init_esm();
@@ -122388,7 +122845,7 @@ var init_timeback_controller = __esm(() => {
122388
122845
  });
122389
122846
  return ctx.services.timeback.setupIntegration(body2.gameId, body2, ctx.user);
122390
122847
  });
122391
- getIntegrations = requireDeveloper(async (ctx) => {
122848
+ getIntegrations = requireGameManagementAccess(async (ctx) => {
122392
122849
  const gameId = ctx.params.gameId;
122393
122850
  if (!gameId) {
122394
122851
  throw ApiError.badRequest("Missing gameId");
@@ -122448,9 +122905,12 @@ var init_timeback_controller = __esm(() => {
122448
122905
  const {
122449
122906
  gameId,
122450
122907
  studentId,
122908
+ runId,
122909
+ resumeId,
122451
122910
  activityData,
122452
122911
  scoreData,
122453
122912
  timingData,
122913
+ sessionTimingData,
122454
122914
  xpEarned,
122455
122915
  masteredUnits,
122456
122916
  extensions
@@ -122459,15 +122919,65 @@ var init_timeback_controller = __esm(() => {
122459
122919
  return ctx.services.timeback.endActivity({
122460
122920
  gameId,
122461
122921
  studentId,
122922
+ runId,
122923
+ resumeId,
122462
122924
  activityData,
122463
122925
  scoreData,
122464
122926
  timingData,
122927
+ sessionTimingData,
122465
122928
  xpEarned,
122466
122929
  masteredUnits,
122467
122930
  extensions,
122468
122931
  user: ctx.user
122469
122932
  });
122470
122933
  });
122934
+ heartbeat = requireDeveloper(async (ctx) => {
122935
+ let body2;
122936
+ try {
122937
+ const json4 = await ctx.request.json();
122938
+ body2 = HeartbeatRequestSchema.parse(json4);
122939
+ } catch (error2) {
122940
+ if (error2 instanceof exports_external.ZodError) {
122941
+ const details = formatZodError(error2);
122942
+ logger63.warn("Heartbeat validation failed", { details });
122943
+ throw ApiError.unprocessableEntity("Validation failed", details);
122944
+ }
122945
+ throw ApiError.badRequest("Invalid JSON body");
122946
+ }
122947
+ const {
122948
+ gameId,
122949
+ studentId,
122950
+ runId,
122951
+ resumeId,
122952
+ activityData,
122953
+ timingData,
122954
+ windowStartedAtMs,
122955
+ windowSequence,
122956
+ isFinal
122957
+ } = body2;
122958
+ logger63.debug("Recording heartbeat", {
122959
+ userId: ctx.user.id,
122960
+ gameId,
122961
+ runId,
122962
+ resumeId,
122963
+ windowStartedAtMs,
122964
+ windowSequence,
122965
+ activeMs: timingData.activeMs,
122966
+ isFinal
122967
+ });
122968
+ return ctx.services.timeback.recordHeartbeat({
122969
+ gameId,
122970
+ studentId,
122971
+ runId,
122972
+ resumeId,
122973
+ activityData,
122974
+ timingData,
122975
+ windowStartedAtMs,
122976
+ windowSequence,
122977
+ isFinal,
122978
+ user: ctx.user
122979
+ });
122980
+ });
122471
122981
  getStudentXp = requireDeveloper(async (ctx) => {
122472
122982
  const timebackId = ctx.params.timebackId;
122473
122983
  if (!timebackId) {
@@ -122513,7 +123023,7 @@ var init_timeback_controller = __esm(() => {
122513
123023
  include
122514
123024
  });
122515
123025
  });
122516
- getRoster = requireDeveloper(async (ctx) => {
123026
+ getRoster = requireGameManagementAccess(async (ctx) => {
122517
123027
  const gameId = ctx.params.gameId;
122518
123028
  const courseId = ctx.params.courseId;
122519
123029
  if (!gameId || !courseId) {
@@ -122526,7 +123036,7 @@ var init_timeback_controller = __esm(() => {
122526
123036
  });
122527
123037
  return ctx.services.timebackAdmin.listStudentsForCourse(gameId, courseId, ctx.user);
122528
123038
  });
122529
- getStudentOverview = requireDeveloper(async (ctx) => {
123039
+ getStudentOverview = requireGameManagementAccess(async (ctx) => {
122530
123040
  const timebackId = ctx.params.timebackId;
122531
123041
  const gameId = ctx.url.searchParams.get("gameId") || undefined;
122532
123042
  const courseId = ctx.url.searchParams.get("courseId") || undefined;
@@ -122541,7 +123051,7 @@ var init_timeback_controller = __esm(() => {
122541
123051
  });
122542
123052
  return ctx.services.timebackAdmin.getStudentOverview(gameId, timebackId, ctx.user, courseId);
122543
123053
  });
122544
- getStudentActivity = requireDeveloper(async (ctx) => {
123054
+ getStudentActivity = requireGameManagementAccess(async (ctx) => {
122545
123055
  const timebackId = ctx.params.timebackId;
122546
123056
  const courseId = ctx.params.courseId;
122547
123057
  const gameId = ctx.url.searchParams.get("gameId") || undefined;
@@ -122604,7 +123114,7 @@ var init_timeback_controller = __esm(() => {
122604
123114
  });
122605
123115
  return ctx.services.timebackAdmin.adjustMasteredUnits(body2, ctx.user);
122606
123116
  });
122607
- toggleCompletion = requireDeveloper(async (ctx) => {
123117
+ toggleCompletion = requireGameManagementAccess(async (ctx) => {
122608
123118
  const body2 = await parseRequestBody(ctx.request, ToggleCourseCompletionRequestSchema);
122609
123119
  logger63.debug("Toggling course completion", {
122610
123120
  requesterId: ctx.user.id,
@@ -122615,6 +123125,41 @@ var init_timeback_controller = __esm(() => {
122615
123125
  });
122616
123126
  return ctx.services.timebackAdmin.toggleCourseCompletion(body2, ctx.user);
122617
123127
  });
123128
+ searchStudents = requireGameManagementAccess(async (ctx) => {
123129
+ const gameId = ctx.params.gameId;
123130
+ const courseId = ctx.params.courseId;
123131
+ const query = ctx.url.searchParams.get("q") || "";
123132
+ if (!gameId || !courseId) {
123133
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
123134
+ }
123135
+ logger63.debug("Searching students for enrollment", {
123136
+ requesterId: ctx.user.id,
123137
+ gameId,
123138
+ courseId,
123139
+ query
123140
+ });
123141
+ return ctx.services.timebackAdmin.searchStudentsForEnrollment(gameId, courseId, query, ctx.user);
123142
+ });
123143
+ enrollStudent = requireGameManagementAccess(async (ctx) => {
123144
+ const body2 = await parseRequestBody(ctx.request, EnrollStudentRequestSchema);
123145
+ logger63.debug("Enrolling student", {
123146
+ requesterId: ctx.user.id,
123147
+ gameId: body2.gameId,
123148
+ courseId: body2.courseId,
123149
+ studentId: body2.studentId
123150
+ });
123151
+ return ctx.services.timebackAdmin.enrollStudent(body2, ctx.user);
123152
+ });
123153
+ unenrollStudent = requireGameManagementAccess(async (ctx) => {
123154
+ const body2 = await parseRequestBody(ctx.request, UnenrollStudentRequestSchema);
123155
+ logger63.debug("Unenrolling student", {
123156
+ requesterId: ctx.user.id,
123157
+ gameId: body2.gameId,
123158
+ courseId: body2.courseId,
123159
+ studentId: body2.studentId
123160
+ });
123161
+ return ctx.services.timebackAdmin.unenrollStudent(body2, ctx.user);
123162
+ });
122618
123163
  timeback2 = {
122619
123164
  getTodayXp,
122620
123165
  getTotalXp,
@@ -122629,6 +123174,7 @@ var init_timeback_controller = __esm(() => {
122629
123174
  getConfig: getConfig2,
122630
123175
  deleteIntegrations,
122631
123176
  endActivity,
123177
+ heartbeat,
122632
123178
  getStudentXp,
122633
123179
  getRoster,
122634
123180
  getStudentOverview,
@@ -122636,7 +123182,10 @@ var init_timeback_controller = __esm(() => {
122636
123182
  grantXp,
122637
123183
  adjustTime,
122638
123184
  adjustMastery,
122639
- toggleCompletion
123185
+ toggleCompletion,
123186
+ searchStudents,
123187
+ enrollStudent,
123188
+ unenrollStudent
122640
123189
  };
122641
123190
  });
122642
123191
  var logger64;
@@ -123612,6 +124161,7 @@ var init_timeback6 = __esm(() => {
123612
124161
  timebackRouter.get("/config/:gameId", handle2(timeback2.getConfig));
123613
124162
  timebackRouter.delete("/integrations/:gameId", handle2(timeback2.deleteIntegrations, { status: 204 }));
123614
124163
  timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
124164
+ timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
123615
124165
  timebackRouter.get("/user", async (c2) => {
123616
124166
  const user = c2.get("user");
123617
124167
  const gameId = c2.get("gameId");
@@ -124007,6 +124557,203 @@ function printBanner(viteConfig, options) {
124007
124557
  import fs5 from "node:fs";
124008
124558
  import path3 from "node:path";
124009
124559
  import { loadPlaycademyConfig } from "playcademy/utils";
124560
+
124561
+ // ../utils/src/uuid.ts
124562
+ var UUID_REGEX2 = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
124563
+ function isValidUUID2(value) {
124564
+ if (!value || typeof value !== "string") {
124565
+ return false;
124566
+ }
124567
+ return UUID_REGEX2.test(value);
124568
+ }
124569
+ // ../utils/src/ansi.ts
124570
+ var colors3 = {
124571
+ black: "\x1B[30m",
124572
+ red: "\x1B[31m",
124573
+ green: "\x1B[32m",
124574
+ yellow: "\x1B[33m",
124575
+ blue: "\x1B[34m",
124576
+ magenta: "\x1B[35m",
124577
+ cyan: "\x1B[36m",
124578
+ white: "\x1B[37m",
124579
+ gray: "\x1B[90m"
124580
+ };
124581
+ var styles3 = {
124582
+ reset: "\x1B[0m",
124583
+ bold: "\x1B[1m",
124584
+ dim: "\x1B[2m",
124585
+ italic: "\x1B[3m",
124586
+ underline: "\x1B[4m"
124587
+ };
124588
+ var cursor2 = {
124589
+ hide: "\x1B[?25l",
124590
+ show: "\x1B[?25h",
124591
+ up: (n3) => `\x1B[${n3}A`,
124592
+ down: (n3) => `\x1B[${n3}B`,
124593
+ forward: (n3) => `\x1B[${n3}C`,
124594
+ back: (n3) => `\x1B[${n3}D`,
124595
+ clearLine: "\x1B[K",
124596
+ clearScreen: "\x1B[2J",
124597
+ home: "\x1B[H"
124598
+ };
124599
+ var isInteractive2 = typeof process !== "undefined" && process.stdout?.isTTY && !process.env.CI && process.env.TERM !== "dumb";
124600
+ function stripAnsi2(text2) {
124601
+ return text2.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
124602
+ }
124603
+
124604
+ // ../utils/src/spinner.ts
124605
+ import { stdout as stdout2 } from "process";
124606
+ var SPINNER_FRAMES2 = [
124607
+ 10251,
124608
+ 10265,
124609
+ 10297,
124610
+ 10296,
124611
+ 10300,
124612
+ 10292,
124613
+ 10278,
124614
+ 10279,
124615
+ 10247,
124616
+ 10255
124617
+ ].map((code) => String.fromCodePoint(code));
124618
+ var CHECK_MARK2 = String.fromCodePoint(10004);
124619
+ var CROSS_MARK2 = String.fromCodePoint(10006);
124620
+ var CANCEL_MARK2 = String.fromCodePoint(9675);
124621
+ var SPINNER_INTERVAL2 = 80;
124622
+
124623
+ class Spinner3 {
124624
+ tasks = new Map;
124625
+ frameIndex = 0;
124626
+ intervalId = null;
124627
+ renderCount = 0;
124628
+ previousLineCount = 0;
124629
+ printedTasks = new Set;
124630
+ indent;
124631
+ constructor(taskIds, texts, options) {
124632
+ this.indent = options?.indent ?? 0;
124633
+ taskIds.forEach((id, index6) => {
124634
+ this.tasks.set(id, {
124635
+ text: texts[index6] || "",
124636
+ status: "pending"
124637
+ });
124638
+ });
124639
+ }
124640
+ start() {
124641
+ if (isInteractive2) {
124642
+ stdout2.write(cursor2.hide);
124643
+ this.render();
124644
+ this.intervalId = setInterval(() => {
124645
+ this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES2.length;
124646
+ this.render();
124647
+ }, SPINNER_INTERVAL2);
124648
+ }
124649
+ }
124650
+ clear() {
124651
+ if (this.intervalId) {
124652
+ clearInterval(this.intervalId);
124653
+ this.intervalId = null;
124654
+ }
124655
+ if (isInteractive2 && this.previousLineCount > 0) {
124656
+ stdout2.write(cursor2.up(this.previousLineCount));
124657
+ for (let i3 = 0;i3 < this.previousLineCount; i3++) {
124658
+ stdout2.write(`\r${cursor2.clearLine}
124659
+ `);
124660
+ }
124661
+ stdout2.write(cursor2.up(this.previousLineCount));
124662
+ stdout2.write(cursor2.show);
124663
+ }
124664
+ this.previousLineCount = 0;
124665
+ }
124666
+ updateTask(taskId, status, finalText) {
124667
+ const task = this.tasks.get(taskId);
124668
+ if (task) {
124669
+ task.status = status;
124670
+ if (finalText) {
124671
+ task.finalText = finalText;
124672
+ }
124673
+ if (!isInteractive2) {
124674
+ this.renderNonInteractive(taskId, task);
124675
+ }
124676
+ }
124677
+ }
124678
+ renderNonInteractive(taskId, task) {
124679
+ const key = `${taskId}-${task.status}`;
124680
+ if (this.printedTasks.has(key)) {
124681
+ return;
124682
+ }
124683
+ this.printedTasks.add(key);
124684
+ const indentStr = " ".repeat(this.indent);
124685
+ let line2 = "";
124686
+ switch (task.status) {
124687
+ case "running": {
124688
+ line2 = `${indentStr}[RUNNING] ${stripAnsi2(task.text)}`;
124689
+ break;
124690
+ }
124691
+ case "success": {
124692
+ line2 = `${indentStr}[SUCCESS] ${stripAnsi2(task.finalText || task.text)}`;
124693
+ break;
124694
+ }
124695
+ case "error": {
124696
+ line2 = `${indentStr}[ERROR] Failed: ${stripAnsi2(task.text)}`;
124697
+ break;
124698
+ }
124699
+ case "cancelled": {
124700
+ line2 = `${indentStr}[CANCELLED] ${stripAnsi2(task.finalText || task.text)}`;
124701
+ break;
124702
+ }
124703
+ }
124704
+ console.log(line2);
124705
+ }
124706
+ render() {
124707
+ if (this.previousLineCount > 0) {
124708
+ stdout2.write(cursor2.up(this.previousLineCount));
124709
+ }
124710
+ const spinner = SPINNER_FRAMES2[this.frameIndex];
124711
+ const indentStr = " ".repeat(this.indent);
124712
+ const visibleTasks = [...this.tasks.values()].filter((task) => task.status !== "pending");
124713
+ for (const task of visibleTasks) {
124714
+ stdout2.write(`\r${cursor2.clearLine}`);
124715
+ let line2 = "";
124716
+ switch (task.status) {
124717
+ case "running": {
124718
+ line2 = `${indentStr}${colors3.blue}${spinner}${styles3.reset} ${task.text}`;
124719
+ break;
124720
+ }
124721
+ case "success": {
124722
+ line2 = `${indentStr}${colors3.green}${CHECK_MARK2}${styles3.reset} ${task.finalText || task.text}`;
124723
+ break;
124724
+ }
124725
+ case "error": {
124726
+ line2 = `${indentStr}${colors3.red}${CROSS_MARK2}${styles3.reset} Failed: ${task.text}`;
124727
+ break;
124728
+ }
124729
+ case "cancelled": {
124730
+ line2 = `${indentStr}${colors3.gray}${CANCEL_MARK2}${styles3.reset} Cancelled: ${task.finalText || task.text}`;
124731
+ break;
124732
+ }
124733
+ }
124734
+ console.log(line2);
124735
+ }
124736
+ this.previousLineCount = visibleTasks.length;
124737
+ this.renderCount++;
124738
+ }
124739
+ stop() {
124740
+ if (this.intervalId) {
124741
+ clearInterval(this.intervalId);
124742
+ this.intervalId = null;
124743
+ }
124744
+ if (isInteractive2) {
124745
+ this.render();
124746
+ stdout2.write(cursor2.show);
124747
+ } else {
124748
+ this.tasks.forEach((task, taskId) => {
124749
+ if (task.status !== "pending") {
124750
+ this.renderNonInteractive(taskId, task);
124751
+ }
124752
+ });
124753
+ }
124754
+ }
124755
+ }
124756
+ // src/lib/sandbox/project-info.ts
124010
124757
  function extractTimebackCourses(config2, timebackOptions) {
124011
124758
  const courses = config2?.integrations?.timeback?.courses;
124012
124759
  if (!courses || courses.length === 0) {
@@ -124040,17 +124787,20 @@ async function extractProjectInfo(viteConfig, timebackOptions) {
124040
124787
  packageJson = JSON.parse(packageJsonContent);
124041
124788
  }
124042
124789
  } catch {}
124043
- const name2 = config2?.name || packageJson.name || "";
124044
- let slug = name2;
124045
- if (slug.includes("/")) {
124046
- slug = slug.split("/")[1] || slug;
124790
+ const name4 = config2?.name || packageJson.name || "";
124791
+ let slug2 = name4;
124792
+ if (slug2.includes("/")) {
124793
+ slug2 = slug2.split("/")[1] || slug2;
124047
124794
  }
124048
- if (!slug) {
124049
- slug = directoryName;
124795
+ if (!slug2) {
124796
+ slug2 = directoryName;
124050
124797
  }
124051
- const displayName = slug.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
124798
+ const displayName = slug2.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
124799
+ const envGameId = process.env.SANDBOX_GAME_ID;
124800
+ const gameId = envGameId && isValidUUID2(envGameId) ? envGameId : undefined;
124052
124801
  return {
124053
- slug,
124802
+ gameId,
124803
+ slug: slug2,
124054
124804
  displayName,
124055
124805
  version: packageJson.version || "dev",
124056
124806
  description: packageJson.description,
@@ -124546,7 +125296,8 @@ var TIMEBACK_COMPONENT_RESOURCE_DEFAULTS2;
124546
125296
  var init_timeback7 = __esm7(() => {
124547
125297
  TIMEBACK_ROUTES2 = {
124548
125298
  END_ACTIVITY: "/integrations/timeback/end-activity",
124549
- GET_XP: "/integrations/timeback/xp"
125299
+ GET_XP: "/integrations/timeback/xp",
125300
+ HEARTBEAT: "/integrations/timeback/heartbeat"
124550
125301
  };
124551
125302
  TIMEBACK_COURSE_DEFAULTS2 = {
124552
125303
  gradingScheme: "STANDARD",
@@ -125084,7 +125835,7 @@ var DEBOUNCE_MS = 500;
125084
125835
  var VITE_CONFIG_NAMES = ["vite.config.ts", "vite.config.js", "vite.config.mjs"];
125085
125836
  var debounceTimer = null;
125086
125837
  function findExistingFiles(projectRoot, fileNames) {
125087
- return fileNames.map((name2) => path4.join(projectRoot, name2)).filter((file) => fs7.existsSync(file));
125838
+ return fileNames.map((name4) => path4.join(projectRoot, name4)).filter((file) => fs7.existsSync(file));
125088
125839
  }
125089
125840
  function createChangeHandler(server, viteConfig, platformModeOptions, watchedFiles) {
125090
125841
  return async (changedPath) => {
@@ -125152,7 +125903,7 @@ function cyclePlatformRoleHotkey(options) {
125152
125903
 
125153
125904
  // src/server/hotkeys/cycle-timeback-role.ts
125154
125905
  var import_picocolors9 = __toESM(require_picocolors(), 1);
125155
- var { bold: bold4, cyan: cyan4, green: green4, red: red3, yellow: yellow3 } = import_picocolors9.default;
125906
+ var { bold: bold5, cyan: cyan4, green: green4, red: red3, yellow: yellow3 } = import_picocolors9.default;
125156
125907
  function cycleTimebackRole(logger) {
125157
125908
  const currentRole = getTimebackRoleOverride() ?? "student";
125158
125909
  const currentIndex = TIMEBACK_ROLES.indexOf(currentRole);
@@ -125171,14 +125922,14 @@ function cycleTimebackRole(logger) {
125171
125922
  function cycleTimebackRoleHotkey(options) {
125172
125923
  return {
125173
125924
  key: "t",
125174
- description: `${cyan4(bold4("[playcademy]"))} cycle Timeback role`,
125925
+ description: `${cyan4(bold5("[playcademy]"))} cycle Timeback role`,
125175
125926
  action: () => cycleTimebackRole(options.viteConfig.logger)
125176
125927
  };
125177
125928
  }
125178
125929
 
125179
125930
  // src/server/hotkeys/recreate-database.ts
125180
125931
  var import_picocolors10 = __toESM(require_picocolors(), 1);
125181
- var { bold: bold5, cyan: cyan5 } = import_picocolors10.default;
125932
+ var { bold: bold6, cyan: cyan5 } = import_picocolors10.default;
125182
125933
  async function recreateSandboxDatabase(options) {
125183
125934
  await recreateSandbox({
125184
125935
  viteConfig: options.viteConfig,
@@ -125188,7 +125939,7 @@ async function recreateSandboxDatabase(options) {
125188
125939
  function recreateDatabaseHotkey(options) {
125189
125940
  return {
125190
125941
  key: "d",
125191
- description: `${cyan5(bold5("[playcademy]"))} recreate sandbox database`,
125942
+ description: `${cyan5(bold6("[playcademy]"))} recreate sandbox database`,
125192
125943
  action: () => recreateSandboxDatabase(options)
125193
125944
  };
125194
125945
  }
@@ -125198,7 +125949,7 @@ var import_picocolors12 = __toESM(require_picocolors(), 1);
125198
125949
  // package.json
125199
125950
  var package_default2 = {
125200
125951
  name: "@playcademy/vite-plugin",
125201
- version: "0.2.23",
125952
+ version: "0.2.24-beta.10",
125202
125953
  type: "module",
125203
125954
  exports: {
125204
125955
  ".": {