@playcademy/vite-plugin 0.2.24-beta.1 → 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",
@@ -25335,7 +25336,7 @@ var package_default;
25335
25336
  var init_package = __esm(() => {
25336
25337
  package_default = {
25337
25338
  name: "@playcademy/sandbox",
25338
- version: "0.3.17-beta.4",
25339
+ version: "0.3.17-beta.13",
25339
25340
  description: "Local development server for Playcademy game development",
25340
25341
  type: "module",
25341
25342
  exports: {
@@ -29950,6 +29951,7 @@ var init_esm = __esm(() => {
29950
29951
  function createMinimalConfig(overrides) {
29951
29952
  return apiConfigSchema.parse({
29952
29953
  stage: "local",
29954
+ isLocal: false,
29953
29955
  ...overrides
29954
29956
  });
29955
29957
  }
@@ -29977,6 +29979,7 @@ var init_schema = __esm(() => {
29977
29979
  });
29978
29980
  apiConfigSchema = exports_external.object({
29979
29981
  stage: stageSchema,
29982
+ isLocal: exports_external.boolean().default(false),
29980
29983
  baseUrl: exports_external.string().url().optional(),
29981
29984
  gameDomain: exports_external.string().optional(),
29982
29985
  lti: ltiConfigSchema.optional(),
@@ -35611,7 +35614,7 @@ var init_table6 = __esm(() => {
35611
35614
  init_drizzle_orm();
35612
35615
  init_pg_core();
35613
35616
  init_table5();
35614
- userRoleEnum = pgEnum("user_role", ["admin", "player", "developer"]);
35617
+ userRoleEnum = pgEnum("user_role", ["admin", "player", "developer", "teacher"]);
35615
35618
  developerStatusEnum = pgEnum("developer_status", ["none", "pending", "approved"]);
35616
35619
  users = pgTable("user", {
35617
35620
  id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
@@ -50498,453 +50501,555 @@ var init_developer_service = __esm(() => {
50498
50501
  init_errors();
50499
50502
  logger4 = log.scope("DeveloperService");
50500
50503
  });
50501
-
50502
- class GameService {
50503
- deps;
50504
- static MANIFEST_FETCH_TIMEOUT_MS = 5000;
50505
- static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
50506
- constructor(deps) {
50507
- this.deps = deps;
50508
- }
50509
- static getManifestHost(manifestUrl) {
50510
- try {
50511
- return new URL(manifestUrl).host;
50512
- } catch {
50513
- return manifestUrl;
50514
- }
50504
+ function sleep(ms) {
50505
+ if (ms <= 0) {
50506
+ return Promise.resolve();
50515
50507
  }
50516
- static getFetchErrorMessage(error) {
50517
- let raw;
50518
- if (error instanceof Error) {
50519
- raw = error.message;
50520
- } else if (typeof error === "string") {
50521
- raw = error;
50522
- }
50523
- if (!raw) {
50524
- 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
+ }
50525
50538
  }
50526
- const normalized = raw.replace(/\s+/g, " ").trim();
50527
- if (!normalized) {
50528
- 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);
50529
50554
  }
50530
- return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
50531
- }
50532
- static isRetryableStatus(status) {
50533
- return status === 429 || status >= 500;
50534
- }
50535
- async list(caller) {
50536
- const db2 = this.deps.db;
50537
- const isAdmin = caller?.role === "admin";
50538
- const isDeveloper = caller?.role === "developer";
50539
- let whereClause;
50540
- if (isAdmin) {
50541
- whereClause = undefined;
50542
- } else if (isDeveloper && caller?.id) {
50543
- whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
50544
- } else {
50545
- whereClause = ne(games.visibility, "internal");
50555
+ static isRetryableStatus(status) {
50556
+ return status === 429 || status >= 500;
50546
50557
  }
50547
- return db2.query.games.findMany({
50548
- where: whereClause,
50549
- orderBy: [desc(games.createdAt)]
50550
- });
50551
- }
50552
- async listManageable(user) {
50553
- this.validateDeveloperStatus(user);
50554
- const db2 = this.deps.db;
50555
- return db2.query.games.findMany({
50556
- where: user.role === "admin" ? undefined : eq(games.developerId, user.id),
50557
- orderBy: [desc(games.createdAt)]
50558
- });
50559
- }
50560
- async getSubjects() {
50561
- const db2 = this.deps.db;
50562
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
50563
- columns: { gameId: true, subject: true },
50564
- orderBy: [asc(gameTimebackIntegrations.createdAt)]
50565
- });
50566
- const subjectMap = {};
50567
- for (const integration of integrations) {
50568
- if (!(integration.gameId in subjectMap)) {
50569
- 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;
50570
50562
  }
50563
+ return backoff[Math.min(attemptIndex, backoff.length - 1)] ?? 0;
50571
50564
  }
50572
- return subjectMap;
50573
- }
50574
- async getById(gameId, caller) {
50575
- const db2 = this.deps.db;
50576
- const game = await db2.query.games.findFirst({
50577
- where: eq(games.id, gameId)
50578
- });
50579
- if (!game) {
50580
- throw new NotFoundError("Game", gameId);
50565
+ static normalizeDeploymentUrl(deploymentUrl) {
50566
+ return deploymentUrl.replace(/\/$/, "");
50581
50567
  }
50582
- this.enforceVisibility(game, caller, gameId);
50583
- return game;
50584
- }
50585
- async getBySlug(slug, caller) {
50586
- const db2 = this.deps.db;
50587
- const game = await db2.query.games.findFirst({
50588
- where: eq(games.slug, slug)
50589
- });
50590
- if (!game) {
50591
- throw new NotFoundError("Game", slug);
50568
+ static getManifestCacheKey(deploymentUrl) {
50569
+ return `${GameService2.MANIFEST_CACHE_KEY_PREFIX}:${deploymentUrl}`;
50592
50570
  }
50593
- this.enforceVisibility(game, caller, slug);
50594
- return game;
50595
- }
50596
- async getManifest(gameId, caller) {
50597
- const game = await this.getById(gameId, caller);
50598
- if (game.gameType !== "hosted" || !game.deploymentUrl) {
50599
- throw new BadRequestError("Game does not have a deployment manifest");
50600
- }
50601
- const deploymentUrl = game.deploymentUrl;
50602
- const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
50603
- const manifestHost = GameService.getManifestHost(manifestUrl);
50604
- const startedAt = Date.now();
50605
- const controller = new AbortController;
50606
- const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_TIMEOUT_MS);
50607
- function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
50608
- return {
50609
- manifestUrl,
50610
- manifestHost,
50611
- deploymentUrl,
50612
- fetchOutcome,
50613
- retryCount: 0,
50614
- durationMs: Date.now() - startedAt,
50615
- manifestErrorKind,
50616
- ...extra
50617
- };
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
+ });
50618
50587
  }
50619
- let response;
50620
- try {
50621
- response = await fetch(manifestUrl, {
50622
- method: "GET",
50623
- headers: {
50624
- Accept: "application/json"
50625
- },
50626
- 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)]
50627
50597
  });
50628
- } catch (error) {
50629
- clearTimeout(timeout);
50630
- const fetchErrorMessage = GameService.getFetchErrorMessage(error);
50631
- const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
50632
- logger5.error("Failed to fetch game manifest", {
50633
- gameId,
50634
- manifestUrl,
50635
- error,
50636
- 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)]
50637
50604
  });
50638
- if (error instanceof Error && error.name === "AbortError") {
50639
- 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
+ }
50640
50610
  }
50641
- throw new ServiceUnavailableError("Failed to load game manifest", details);
50642
- } finally {
50643
- clearTimeout(timeout);
50611
+ return subjectMap;
50644
50612
  }
50645
- if (!response.ok) {
50646
- const resolvedManifestUrl = response.url || manifestUrl;
50647
- const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
50648
- const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
50649
- const details = buildDetails("bad_status", manifestErrorKind, {
50650
- manifestUrl: resolvedManifestUrl,
50651
- manifestHost: resolvedManifestHost,
50652
- status: response.status,
50653
- contentType: response.headers.get("content-type") ?? undefined,
50654
- cfRay: response.headers.get("cf-ray") ?? undefined,
50655
- redirected: response.redirected,
50656
- ...response.redirected ? {
50657
- originalManifestUrl: manifestUrl,
50658
- originalManifestHost: manifestHost
50659
- } : {}
50660
- });
50661
- const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
50662
- logger5.error("Game manifest returned non-ok response", {
50663
- gameId,
50664
- manifestUrl,
50665
- status: response.status,
50666
- 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)
50667
50617
  });
50668
- if (manifestErrorKind === "temporary") {
50669
- throw new ServiceUnavailableError(message, details);
50618
+ if (!game) {
50619
+ throw new NotFoundError("Game", gameId);
50670
50620
  }
50671
- throw new BadRequestError(message, details);
50621
+ this.enforceVisibility(game, caller, gameId);
50622
+ return game;
50672
50623
  }
50673
- try {
50674
- return await response.json();
50675
- } catch (error) {
50676
- const resolvedManifestUrl = response.url || manifestUrl;
50677
- const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
50678
- const details = buildDetails("invalid_body", "permanent", {
50679
- manifestUrl: resolvedManifestUrl,
50680
- manifestHost: resolvedManifestHost,
50681
- status: response.status,
50682
- contentType: response.headers.get("content-type") ?? undefined,
50683
- cfRay: response.headers.get("cf-ray") ?? undefined,
50684
- redirected: response.redirected,
50685
- ...response.redirected ? {
50686
- originalManifestUrl: manifestUrl,
50687
- originalManifestHost: manifestHost
50688
- } : {}
50689
- });
50690
- logger5.error("Failed to parse game manifest", {
50691
- gameId,
50692
- manifestUrl,
50693
- error,
50694
- 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)
50695
50628
  });
50696
- throw new BadRequestError("Failed to parse game manifest", details);
50697
- }
50698
- }
50699
- enforceVisibility(game, caller, lookupIdentifier) {
50700
- if (game.visibility !== "internal") {
50701
- return;
50702
- }
50703
- const isAdmin = caller?.role === "admin";
50704
- const isOwner = caller?.id != null && caller.id === game.developerId;
50705
- if (!isAdmin && !isOwner) {
50706
- throw new NotFoundError("Game", lookupIdentifier);
50707
- }
50708
- }
50709
- async upsertBySlug(slug, data, user) {
50710
- const db2 = this.deps.db;
50711
- const existingGame = await db2.query.games.findFirst({
50712
- where: eq(games.slug, slug)
50713
- });
50714
- const isUpdate = Boolean(existingGame);
50715
- const gameId = existingGame?.id ?? crypto.randomUUID();
50716
- if (isUpdate) {
50717
- await this.validateDeveloperAccess(user, gameId);
50718
- } else {
50719
- this.validateDeveloperStatus(user);
50720
- }
50721
- const gameDataForDb = {
50722
- displayName: data.displayName,
50723
- platform: data.platform,
50724
- metadata: data.metadata,
50725
- mapElementId: data.mapElementId,
50726
- gameType: data.gameType,
50727
- ...data.visibility && { visibility: data.visibility },
50728
- externalUrl: data.externalUrl || null,
50729
- updatedAt: new Date
50730
- };
50731
- let gameResponse;
50732
- if (isUpdate) {
50733
- const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
50734
- if (!updatedGame) {
50735
- logger5.error("Game update returned no rows", { gameId, slug });
50736
- throw new InternalError("DB update failed to return result for existing game");
50737
- }
50738
- gameResponse = updatedGame;
50739
- } else {
50740
- const insertData = {
50741
- ...gameDataForDb,
50742
- id: gameId,
50743
- slug,
50744
- developerId: user.id,
50745
- metadata: data.metadata || {},
50746
- version: data.gameType === "external" ? "external" : "",
50747
- deploymentUrl: null,
50748
- createdAt: new Date
50749
- };
50750
- const [createdGame] = await db2.insert(games).values(insertData).returning();
50751
- if (!createdGame) {
50752
- logger5.error("Game insert returned no rows", { slug, developerId: user.id });
50753
- throw new InternalError("DB insert failed to return result for new game");
50629
+ if (!game) {
50630
+ throw new NotFoundError("Game", slug);
50754
50631
  }
50755
- gameResponse = createdGame;
50632
+ this.enforceVisibility(game, caller, slug);
50633
+ return game;
50756
50634
  }
50757
- if (data.mapElementId) {
50758
- try {
50759
- await db2.update(mapElements).set({
50760
- interactionType: "game_entry",
50761
- gameId: gameResponse.id
50762
- }).where(eq(mapElements.id, data.mapElementId));
50763
- } catch (mapError) {
50764
- logger5.warn("Failed to update map element", {
50765
- mapElementId: data.mapElementId,
50766
- 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
50767
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);
50768
50710
  }
50711
+ throw new InternalError("Exhausted manifest fetch retries without result");
50769
50712
  }
50770
- logger5.info("Upserted game", {
50771
- gameId: gameResponse.id,
50772
- slug: gameResponse.slug,
50773
- operation: isUpdate ? "update" : "create",
50774
- displayName: gameResponse.displayName
50775
- });
50776
- return gameResponse;
50777
- }
50778
- async delete(gameId, user) {
50779
- await this.validateDeveloperAccess(user, gameId);
50780
- const db2 = this.deps.db;
50781
- const gameToDelete = await db2.query.games.findFirst({
50782
- where: eq(games.id, gameId),
50783
- columns: { id: true, slug: true, displayName: true }
50784
- });
50785
- if (!gameToDelete?.slug) {
50786
- throw new NotFoundError("Game", gameId);
50787
- }
50788
- const activeDeployment = await db2.query.gameDeployments.findFirst({
50789
- where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
50790
- columns: { deploymentId: true, provider: true, resources: true }
50791
- });
50792
- const customHostnames = await db2.select({
50793
- hostname: gameCustomHostnames.hostname,
50794
- cloudflareId: gameCustomHostnames.cloudflareId,
50795
- environment: gameCustomHostnames.environment
50796
- }).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
50797
- const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
50798
- if (result.length === 0) {
50799
- throw new NotFoundError("Game", gameId);
50800
- }
50801
- logger5.info("Deleted game", {
50802
- gameId: result[0].id,
50803
- slug: gameToDelete.slug,
50804
- hadActiveDeployment: Boolean(activeDeployment),
50805
- customDomainsCount: customHostnames.length
50806
- });
50807
- this.deps.alerts.notifyGameDeletion({
50808
- slug: gameToDelete.slug,
50809
- displayName: gameToDelete.displayName,
50810
- developer: { id: user.id, email: user.email }
50811
- }).catch((error) => {
50812
- logger5.warn("Failed to send deletion alert", { error });
50813
- });
50814
- 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;
50815
50730
  try {
50816
- await this.deps.cloudflare.delete(activeDeployment.deploymentId, {
50817
- deleteBindings: true,
50818
- resources: activeDeployment.resources ?? undefined,
50819
- customDomains: customHostnames.length > 0 ? customHostnames : undefined,
50820
- gameSlug: gameToDelete.slug
50821
- });
50822
- logger5.info("Cleaned up Cloudflare resources", {
50823
- gameId,
50824
- deploymentId: activeDeployment.deploymentId,
50825
- customDomainsDeleted: customHostnames.length
50731
+ response = await fetch(manifestUrl, {
50732
+ method: "GET",
50733
+ headers: {
50734
+ Accept: "application/json"
50735
+ },
50736
+ signal: controller.signal
50826
50737
  });
50827
- } catch (cfError) {
50828
- logger5.warn("Failed to cleanup Cloudflare resources", {
50829
- gameId,
50830
- deploymentId: activeDeployment.deploymentId,
50831
- 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
+ } : {}
50832
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
+ };
50833
50770
  }
50834
50771
  try {
50835
- const deletedKeyId = await this.deps.deleteApiKeyByName(getGameWorkerApiKeyName(gameToDelete.slug), user.id);
50836
- if (deletedKeyId) {
50837
- logger5.info("Cleaned up API key for deleted game", {
50838
- gameId,
50839
- slug: gameToDelete.slug,
50840
- 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
50841
50866
  });
50842
50867
  }
50843
- } catch (keyError) {
50844
- logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
50845
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;
50846
50876
  }
50847
- return {
50848
- slug: gameToDelete.slug,
50849
- displayName: gameToDelete.displayName
50850
- };
50851
- }
50852
- async validateOwnership(user, gameId) {
50853
- if (user.role === "admin") {
50854
- 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({
50855
50881
  where: eq(games.id, gameId),
50856
- columns: { id: true }
50882
+ columns: { id: true, slug: true, displayName: true }
50857
50883
  });
50858
- if (!gameExists) {
50884
+ if (!gameToDelete?.slug) {
50859
50885
  throw new NotFoundError("Game", gameId);
50860
50886
  }
50861
- 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
+ };
50862
50950
  }
50863
- const db2 = this.deps.db;
50864
- const gameOwnership = await db2.query.games.findFirst({
50865
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50866
- columns: { id: true }
50867
- });
50868
- if (!gameOwnership) {
50869
- const gameExists = await db2.query.games.findFirst({
50870
- 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)),
50871
50965
  columns: { id: true }
50872
50966
  });
50873
- if (!gameExists) {
50874
- 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");
50875
50976
  }
50876
- throw new AccessDeniedError("You do not own this game");
50877
50977
  }
50878
- }
50879
- async validateDeveloperAccess(user, gameId) {
50880
- this.validateDeveloperStatus(user);
50881
- if (user.role === "admin") {
50882
- const gameExists = await this.deps.db.query.games.findFirst({
50883
- 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)),
50884
50993
  columns: { id: true }
50885
50994
  });
50886
- if (!gameExists) {
50995
+ if (!existingGame) {
50887
50996
  throw new NotFoundError("Game", gameId);
50888
50997
  }
50889
- return;
50890
50998
  }
50891
- const db2 = this.deps.db;
50892
- const existingGame = await db2.query.games.findFirst({
50893
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50894
- columns: { id: true }
50895
- });
50896
- if (!existingGame) {
50897
- 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);
50898
51011
  }
50899
- }
50900
- async validateDeveloperAccessBySlug(user, slug) {
50901
- this.validateDeveloperStatus(user);
50902
- const db2 = this.deps.db;
50903
- if (user.role === "admin") {
50904
- const game2 = await db2.query.games.findFirst({
50905
- 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))
50906
51026
  });
50907
- if (!game2) {
51027
+ if (!game) {
50908
51028
  throw new NotFoundError("Game", slug);
50909
51029
  }
50910
- return game2;
50911
- }
50912
- const game = await db2.query.games.findFirst({
50913
- where: and(eq(games.slug, slug), eq(games.developerId, user.id))
50914
- });
50915
- if (!game) {
50916
- throw new NotFoundError("Game", slug);
50917
- }
50918
- return game;
50919
- }
50920
- validateDeveloperStatus(user) {
50921
- if (user.role === "admin") {
50922
- return;
51030
+ return game;
50923
51031
  }
50924
- if (user.developerStatus !== "approved") {
50925
- const status = user.developerStatus || "none";
50926
- if (status === "pending") {
50927
- throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
50928
- } else {
50929
- 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
+ }
50930
51043
  }
50931
51044
  }
50932
- }
50933
- }
50934
- var logger5;
50935
- var init_game_service = __esm(() => {
50936
- init_drizzle_orm();
50937
- init_tables_index();
50938
- init_src2();
50939
- init_errors();
50940
- init_deployment_util();
50941
- logger5 = log.scope("GameService");
51045
+ };
50942
51046
  });
50943
51047
  function createGameServices(deps) {
50944
51048
  const { db: db2, config: config2, cloudflare, auth: auth2, storage, cache, alerts } = deps;
50945
51049
  const game = new GameService({
50946
51050
  db: db2,
50947
51051
  alerts,
51052
+ cache,
50948
51053
  cloudflare,
50949
51054
  deleteApiKeyByName: auth2.deleteApiKeyByName.bind(auth2)
50950
51055
  });
@@ -50972,6 +51077,7 @@ function createGameServices(deps) {
50972
51077
  validators: {
50973
51078
  validateDeveloperAccessBySlug: (user, slug) => game.validateDeveloperAccessBySlug(user, slug),
50974
51079
  validateDeveloperAccess: (user, gameId) => game.validateDeveloperAccess(user, gameId),
51080
+ validateGameManagementAccess: (user, gameId) => game.validateGameManagementAccess(user, gameId),
50975
51081
  validateOwnership: (user, gameId) => game.validateOwnership(user, gameId)
50976
51082
  }
50977
51083
  };
@@ -52597,7 +52703,8 @@ var init_constants3 = __esm(() => {
52597
52703
  HEALTH: "/api/health",
52598
52704
  TIMEBACK: {
52599
52705
  END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
52600
- GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`
52706
+ GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
52707
+ HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`
52601
52708
  }
52602
52709
  };
52603
52710
  });
@@ -54091,6 +54198,36 @@ var init_pure = __esm(() => {
54091
54198
  var init_src4 = __esm(() => {
54092
54199
  init_pure();
54093
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
+ });
54094
54231
  function isRecord2(value) {
54095
54232
  return typeof value === "object" && value !== null;
54096
54233
  }
@@ -54135,14 +54272,6 @@ function getPlaycademyMetadata(event) {
54135
54272
  const extensions = getMergedCaliperExtensions(event);
54136
54273
  return isRecord2(extensions.playcademy) ? extensions.playcademy : undefined;
54137
54274
  }
54138
- function getAssessmentPlaycademyMetadata(assessment) {
54139
- return isRecord2(assessment.metadata?.playcademy) ? assessment.metadata.playcademy : undefined;
54140
- }
54141
- function isRemediationAssessmentResult(assessment) {
54142
- const playcademy = getAssessmentPlaycademyMetadata(assessment);
54143
- const eventKind = getStringValue(playcademy?.eventKind);
54144
- return eventKind === "remediation-xp" || eventKind === "remediation-time" || eventKind === "remediation-mastery";
54145
- }
54146
54275
  function getActivityId(event, playcademy) {
54147
54276
  const metadataActivityId = getStringValue(playcademy?.activityId);
54148
54277
  if (metadataActivityId) {
@@ -54159,8 +54288,8 @@ function getActivityId(event, playcademy) {
54159
54288
  const trimmed = objectId.replace(/\/$/, "");
54160
54289
  const segments = trimmed.split("/");
54161
54290
  const activityIndex = segments.lastIndexOf("activities");
54162
- if (activityIndex !== -1 && segments.length >= activityIndex + 4) {
54163
- const candidate = segments[activityIndex + 3];
54291
+ if (activityIndex !== -1 && segments.length >= activityIndex + 3) {
54292
+ const candidate = segments[activityIndex + 2];
54164
54293
  return candidate ? decodeURIComponent(candidate) : undefined;
54165
54294
  }
54166
54295
  return;
@@ -54213,38 +54342,96 @@ function mapAssessmentsToXpEvents(userId, assessments) {
54213
54342
  };
54214
54343
  });
54215
54344
  }
54216
- function isMasteryCompletionEntry(assessment) {
54217
- 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);
54218
54362
  }
54219
- function mapAssessmentResultToRecentActivity(assessment, relevantCourseIds, courseIdByLineItemId) {
54220
- 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) {
54221
54383
  return null;
54222
54384
  }
54223
- 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) {
54224
54389
  return null;
54225
54390
  }
54226
- const courseId = courseIdByLineItemId?.get(assessment.assessmentLineItem.sourcedId) || [...relevantCourseIds].find((course) => assessment.assessmentLineItem.sourcedId.startsWith(`${course}-`));
54227
- if (!courseId) {
54391
+ const ctx = parseCaliperEventContext(contextSource, relevantCourseIds);
54392
+ if (!ctx) {
54228
54393
  return null;
54229
54394
  }
54230
- 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) {
54231
54419
  return null;
54232
54420
  }
54233
- const metadata2 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
54234
- const activityName = getStringValue(metadata2?.activityName);
54235
- const xpEarned = typeof metadata2?.xp === "number" && Number.isFinite(metadata2.xp) ? metadata2.xp : undefined;
54236
- const masteredUnits = typeof metadata2?.masteredUnits === "number" && Number.isFinite(metadata2.masteredUnits) ? metadata2.masteredUnits : undefined;
54237
- const durationSeconds = typeof metadata2?.durationSeconds === "number" && Number.isFinite(metadata2.durationSeconds) ? metadata2.durationSeconds : undefined;
54238
54421
  return {
54239
- id: assessment.sourcedId || `${assessment.assessmentLineItem.sourcedId}:${assessment.scoreDate}`,
54240
- kind: "activity",
54241
- occurredAt: assessment.scoreDate,
54242
- courseId,
54243
- title: activityName || "Activity completed",
54244
- ...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 } : {},
54245
54430
  ...xpEarned !== undefined ? { xpDelta: xpEarned } : {},
54246
54431
  ...masteredUnits !== undefined ? { masteredUnitsDelta: masteredUnits } : {},
54247
- ...durationSeconds !== undefined ? { timeDeltaSeconds: durationSeconds } : {}
54432
+ ...totalActiveTimeSeconds !== undefined ? { timeDeltaSeconds: totalActiveTimeSeconds } : {},
54433
+ ...runId ? { runId } : {},
54434
+ ...sessionCount > 0 ? { sessionCount } : {}
54248
54435
  };
54249
54436
  }
54250
54437
  function parseCaliperEventContext(event, relevantCourseIds) {
@@ -54352,8 +54539,16 @@ function mapCaliperEventToRemediationActivity(event, relevantCourseIds) {
54352
54539
  }
54353
54540
  return null;
54354
54541
  }
54542
+ var REMEDIATION_OR_COMPLETION_EVENT_KINDS;
54355
54543
  var init_timeback_util = __esm(() => {
54356
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
+ ]);
54357
54552
  });
54358
54553
 
54359
54554
  class TimebackAdminService {
@@ -54362,11 +54557,9 @@ class TimebackAdminService {
54362
54557
  static RECENT_ACTIVITY_LIMIT = 20;
54363
54558
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
54364
54559
  static MAX_STUDENT_ACTIVITY_OFFSET = 1000;
54560
+ static MAX_RECENT_ACTIVITY_EVENT_FETCH = 4000;
54365
54561
  static ANALYTICS_CONCURRENCY = 8;
54366
54562
  static MASTERABLE_UNITS_CONCURRENCY = 4;
54367
- static RECENT_ACTIVITY_FETCH_CONCURRENCY = 4;
54368
- static ASSESSMENT_LINE_ITEM_PAGE_SIZE = 1000;
54369
- static ASSESSMENT_RESULT_LINE_ITEM_CHUNK_SIZE = 20;
54370
54563
  constructor(deps) {
54371
54564
  this.deps = deps;
54372
54565
  }
@@ -54374,27 +54567,6 @@ class TimebackAdminService {
54374
54567
  const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
54375
54568
  return Object.is(rounded, -0) ? 0 : rounded;
54376
54569
  }
54377
- static toAttributionEventTime(date3) {
54378
- if (!date3) {
54379
- return;
54380
- }
54381
- const match = date3.match(/^(\d{4})-(\d{2})-(\d{2})$/);
54382
- if (!match) {
54383
- throw new ValidationError("Date must be in YYYY-MM-DD format");
54384
- }
54385
- const [, yearStr, monthStr, dayStr] = match;
54386
- const year = Number(yearStr);
54387
- const month = Number(monthStr);
54388
- const day = Number(dayStr);
54389
- if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
54390
- throw new ValidationError("Date must be in YYYY-MM-DD format");
54391
- }
54392
- const eventTime = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
54393
- if (eventTime.getUTCFullYear() !== year || eventTime.getUTCMonth() + 1 !== month || eventTime.getUTCDate() !== day) {
54394
- throw new ValidationError("Date must be a valid calendar date");
54395
- }
54396
- return eventTime.toISOString();
54397
- }
54398
54570
  requireClient() {
54399
54571
  if (!this.deps.timeback) {
54400
54572
  logger16.error("Timeback client not available in context");
@@ -54413,9 +54585,13 @@ class TimebackAdminService {
54413
54585
  });
54414
54586
  });
54415
54587
  }
54416
- async resolveAdminMutationContext(gameId, courseId, user, studentId) {
54588
+ async resolveAdminMutationContext(gameId, courseId, user, studentId, accessLevel = "developer") {
54417
54589
  const client = this.requireClient();
54418
- 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
+ }
54419
54595
  const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
54420
54596
  where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
54421
54597
  });
@@ -54538,7 +54714,7 @@ class TimebackAdminService {
54538
54714
  throw new ValidationError(`Game "${game.slug}" has an invalid deploymentUrl: ${game.deploymentUrl}`);
54539
54715
  }
54540
54716
  }
54541
- async getGameSensorUrl(gameId) {
54717
+ async getGameActivitySource(gameId) {
54542
54718
  const game = await this.deps.db.query.games.findFirst({
54543
54719
  where: eq(games.id, gameId),
54544
54720
  columns: { slug: true, deploymentUrl: true }
@@ -54546,7 +54722,17 @@ class TimebackAdminService {
54546
54722
  if (!game) {
54547
54723
  throw new NotFoundError("Game", gameId);
54548
54724
  }
54549
- 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));
54550
54736
  }
54551
54737
  async getStudentEnrollmentsByCourseId(client, studentId, courseIds) {
54552
54738
  const relevantCourseIds = new Set(courseIds);
@@ -54577,105 +54763,35 @@ class TimebackAdminService {
54577
54763
  });
54578
54764
  return new Map(results);
54579
54765
  }
54580
- async listAssessmentLineItemCourseMap(client, relevantCourseIds) {
54581
- const lineItemEntries = await TimebackAdminService.runWithConcurrency([...relevantCourseIds], TimebackAdminService.RECENT_ACTIVITY_FETCH_CONCURRENCY, async (courseId) => {
54582
- const entries = [];
54583
- let offset = 0;
54584
- try {
54585
- while (true) {
54586
- const items2 = await client.oneroster.assessmentLineItems.list({
54587
- limit: TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE,
54588
- offset,
54589
- filter: `course.sourcedId='${escapeFilterValue(courseId)}'`,
54590
- fields: "sourcedId,course"
54591
- });
54592
- for (const item of items2) {
54593
- if (item.sourcedId) {
54594
- entries.push([
54595
- item.sourcedId,
54596
- item.course?.sourcedId || courseId
54597
- ]);
54598
- }
54599
- }
54600
- if (items2.length < TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE) {
54601
- break;
54602
- }
54603
- offset += TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE;
54604
- }
54605
- } catch (error) {
54606
- logger16.warn("Failed to load assessment line items for course", {
54607
- courseId,
54608
- error: error instanceof Error ? error.message : String(error)
54609
- });
54610
- }
54611
- return entries;
54612
- });
54613
- return new Map(lineItemEntries.flat());
54614
- }
54615
- static buildAssessmentResultsFilter(studentId, lineItemIds) {
54616
- const studentFilter = `student.sourcedId='${escapeFilterValue(studentId)}'`;
54617
- if (lineItemIds.length === 1) {
54618
- return `${studentFilter} AND assessmentLineItem.sourcedId='${escapeFilterValue(lineItemIds[0])}'`;
54619
- }
54620
- return `${studentFilter} AND assessmentLineItem.sourcedId@'${lineItemIds.map(escapeFilterValue).join(",")}'`;
54621
- }
54622
- async listRecentAssessmentResultsForStudent(client, studentId, courseIdByLineItemId, perChunkLimit = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
54623
- const lineItemIds = [...courseIdByLineItemId.keys()];
54624
- if (lineItemIds.length === 0) {
54625
- return [];
54626
- }
54627
- const resultPages = await TimebackAdminService.runWithConcurrency(TimebackAdminService.chunkItems(lineItemIds, TimebackAdminService.ASSESSMENT_RESULT_LINE_ITEM_CHUNK_SIZE), TimebackAdminService.RECENT_ACTIVITY_FETCH_CONCURRENCY, async (lineItemChunk) => {
54628
- try {
54629
- return await client.oneroster.assessmentResults.list({
54630
- limit: perChunkLimit,
54631
- sort: "scoreDate",
54632
- orderBy: "desc",
54633
- fields: "sourcedId,assessmentLineItem,score,scoreDate,metadata",
54634
- filter: TimebackAdminService.buildAssessmentResultsFilter(studentId, lineItemChunk)
54635
- });
54636
- } catch (error) {
54637
- logger16.warn("Failed to load recent assessment results for student", {
54638
- studentId,
54639
- lineItemCount: lineItemChunk.length,
54640
- error: error instanceof Error ? error.message : String(error)
54641
- });
54642
- return [];
54643
- }
54644
- });
54645
- const uniqueResults = new Map;
54646
- for (const result of resultPages.flat()) {
54647
- const key = result.sourcedId || `${result.assessmentLineItem?.sourcedId || "unknown"}:${result.scoreDate || ""}`;
54648
- uniqueResults.set(key, result);
54649
- }
54650
- return [...uniqueResults.values()].toSorted((a, b) => (b.scoreDate || "").localeCompare(a.scoreDate || "")).slice(0, perChunkLimit);
54651
- }
54652
- async listRecentActivityForStudent(client, studentId, sensorUrl, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
54766
+ async listRecentActivityForStudent(client, studentId, source, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
54653
54767
  if (relevantCourseIds.size === 0) {
54654
54768
  return [];
54655
54769
  }
54656
- const courseIdByLineItemId = await this.listAssessmentLineItemCourseMap(client, relevantCourseIds);
54657
- const assessments = await this.listRecentAssessmentResultsForStudent(client, studentId, courseIdByLineItemId, maxResults);
54658
- const assessmentRecentItems = assessments.map((assessment) => mapAssessmentResultToRecentActivity(assessment, relevantCourseIds, courseIdByLineItemId)).filter((activity) => Boolean(activity));
54659
- let caliperRecentItems = [];
54660
54770
  try {
54661
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);
54662
54773
  const { events } = await client.caliper.events.list({
54663
- limit: Math.max(100, maxResults),
54774
+ limit: eventLimit,
54664
54775
  actorId,
54665
- sensor: sensorUrl
54776
+ ...source.sourceMode === "production" ? { sensor: source.sensorUrl } : {},
54777
+ extensions: {
54778
+ gameId: source.gameId
54779
+ }
54666
54780
  });
54667
- caliperRecentItems = events.map((event) => mapCaliperEventToRemediationActivity(event, relevantCourseIds)).filter((item) => Boolean(item));
54781
+ return TimebackAdminService.mapRecentActivityItems(events, relevantCourseIds).slice(0, maxResults);
54668
54782
  } catch (error) {
54669
54783
  logger16.warn("Failed to load recent Caliper activity", {
54670
54784
  studentId,
54785
+ gameId: source.gameId,
54786
+ sourceMode: source.sourceMode,
54671
54787
  error: error instanceof Error ? error.message : String(error)
54672
54788
  });
54789
+ return [];
54673
54790
  }
54674
- return [...assessmentRecentItems, ...caliperRecentItems].toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt)).slice(0, maxResults);
54675
54791
  }
54676
54792
  async listStudentsForCourse(gameId, courseId, user) {
54677
54793
  const client = this.requireClient();
54678
- await this.deps.validateDeveloperAccess(user, gameId);
54794
+ await this.deps.validateGameManagementAccess(user, gameId);
54679
54795
  const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
54680
54796
  where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
54681
54797
  });
@@ -54713,7 +54829,7 @@ class TimebackAdminService {
54713
54829
  }
54714
54830
  async getStudentOverview(gameId, studentId, user, courseId) {
54715
54831
  const client = this.requireClient();
54716
- await this.deps.validateDeveloperAccess(user, gameId);
54832
+ await this.deps.validateGameManagementAccess(user, gameId);
54717
54833
  const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
54718
54834
  where: courseId ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId)) : eq(gameTimebackIntegrations.gameId, gameId)
54719
54835
  });
@@ -54767,12 +54883,12 @@ class TimebackAdminService {
54767
54883
  const client = this.requireClient();
54768
54884
  const safeLimit = Math.max(1, Math.min(limit, TimebackAdminService.MAX_STUDENT_ACTIVITY_LIMIT));
54769
54885
  const safeOffset = Math.max(0, Math.min(offset, TimebackAdminService.MAX_STUDENT_ACTIVITY_OFFSET));
54770
- await this.deps.validateDeveloperAccess(user, gameId);
54771
- const [integration, sensorUrl] = await Promise.all([
54886
+ await this.deps.validateGameManagementAccess(user, gameId);
54887
+ const [integration, gameSource] = await Promise.all([
54772
54888
  this.deps.db.query.gameTimebackIntegrations.findFirst({
54773
54889
  where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
54774
54890
  }),
54775
- this.getGameSensorUrl(gameId)
54891
+ this.getGameActivitySource(gameId)
54776
54892
  ]);
54777
54893
  if (!integration) {
54778
54894
  throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
@@ -54780,7 +54896,7 @@ class TimebackAdminService {
54780
54896
  await this.assertStudentEnrolledInCourse(client, studentId, courseId);
54781
54897
  const relevantCourseIds = new Set([courseId]);
54782
54898
  const fetchLimit = Math.min(safeOffset + safeLimit + 1, TimebackAdminService.MAX_STUDENT_ACTIVITY_OFFSET + TimebackAdminService.MAX_STUDENT_ACTIVITY_LIMIT + 1);
54783
- const allActivities = await this.listRecentActivityForStudent(client, studentId, sensorUrl, relevantCourseIds, fetchLimit);
54899
+ const allActivities = await this.listRecentActivityForStudent(client, studentId, gameSource, relevantCourseIds, fetchLimit);
54784
54900
  const activities = allActivities.slice(safeOffset, safeOffset + safeLimit);
54785
54901
  const hasMore = allActivities.length > safeOffset + safeLimit;
54786
54902
  return { activities, hasMore };
@@ -54792,7 +54908,7 @@ class TimebackAdminService {
54792
54908
  courseId: data.courseId,
54793
54909
  studentId: data.studentId,
54794
54910
  xpEarned: data.xp,
54795
- eventTime: TimebackAdminService.toAttributionEventTime(data.date),
54911
+ eventTime: resolveAdminEventTime(data),
54796
54912
  reason: data.reason,
54797
54913
  actor,
54798
54914
  appName,
@@ -54807,7 +54923,7 @@ class TimebackAdminService {
54807
54923
  courseId: data.courseId,
54808
54924
  studentId: data.studentId,
54809
54925
  activeTimeSeconds: data.seconds,
54810
- eventTime: TimebackAdminService.toAttributionEventTime(data.date),
54926
+ eventTime: resolveAdminEventTime(data),
54811
54927
  reason: data.reason,
54812
54928
  actor,
54813
54929
  appName,
@@ -54822,7 +54938,7 @@ class TimebackAdminService {
54822
54938
  courseId: data.courseId,
54823
54939
  studentId: data.studentId,
54824
54940
  masteredUnits: data.units,
54825
- eventTime: TimebackAdminService.toAttributionEventTime(data.date),
54941
+ eventTime: resolveAdminEventTime(data),
54826
54942
  reason: data.reason,
54827
54943
  actor,
54828
54944
  appName,
@@ -54831,7 +54947,7 @@ class TimebackAdminService {
54831
54947
  return { status: "ok" };
54832
54948
  }
54833
54949
  async toggleCourseCompletion(data, user) {
54834
- 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");
54835
54951
  const historyClient = client;
54836
54952
  const ids = deriveSourcedIds(data.courseId);
54837
54953
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
@@ -54924,6 +55040,77 @@ class TimebackAdminService {
54924
55040
  }
54925
55041
  return { status: "ok" };
54926
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
+ }
54927
55114
  async getCompletionStatus(client, courseId, studentId) {
54928
55115
  const ids = deriveSourcedIds(courseId);
54929
55116
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
@@ -54962,17 +55149,6 @@ class TimebackAdminService {
54962
55149
  }));
54963
55150
  return results;
54964
55151
  }
54965
- static chunkItems(items2, chunkSize) {
54966
- if (items2.length === 0) {
54967
- return [];
54968
- }
54969
- const effectiveChunkSize = Math.max(1, chunkSize);
54970
- const chunks = [];
54971
- for (let index2 = 0;index2 < items2.length; index2 += effectiveChunkSize) {
54972
- chunks.push(items2.slice(index2, index2 + effectiveChunkSize));
54973
- }
54974
- return chunks;
54975
- }
54976
55152
  }
54977
55153
  var logger16;
54978
55154
  var init_timeback_admin_service = __esm(() => {
@@ -54984,593 +55160,739 @@ var init_timeback_admin_service = __esm(() => {
54984
55160
  init_utils6();
54985
55161
  init_src4();
54986
55162
  init_errors();
55163
+ init_timeback_admin_util();
54987
55164
  init_timeback_util();
54988
55165
  logger16 = log.scope("TimebackAdminService");
54989
55166
  });
54990
-
54991
- class TimebackService {
54992
- deps;
54993
- constructor(deps) {
54994
- this.deps = deps;
54995
- }
54996
- requireClient() {
54997
- if (!this.deps.timeback) {
54998
- logger17.error("Timeback client not available in context");
54999
- 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
+ }
55000
55190
  }
55001
- return this.deps.timeback;
55002
- }
55003
- async getTodayXp(userId, date3, timezone2) {
55004
- const db2 = this.deps.db;
55005
- const tz = timezone2 || PLATFORM_TIMEZONE;
55006
- const base = date3 ? new Date(date3) : new Date;
55007
- if (isNaN(base.getTime())) {
55008
- throw new ValidationError("Invalid date format. Use ISO 8601 format.");
55191
+ static isDuplicateHeartbeatWindow(key) {
55192
+ this.cleanHeartbeatDedupeCache();
55193
+ return this.processedHeartbeatWindows.has(key);
55009
55194
  }
55010
- try {
55011
- new Intl.DateTimeFormat(undefined, { timeZone: tz });
55012
- } catch {
55013
- throw new ValidationError(`Invalid timezone: ${tz}`);
55195
+ static getInFlightHeartbeatWindow(key) {
55196
+ return this.inFlightHeartbeatWindows.get(key);
55014
55197
  }
55015
- if (tz === PLATFORM_TIMEZONE) {
55016
- const todayMidnight = getUtcInstantForMidnight(base, tz);
55017
- 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);
55018
- if (result2.length === 0) {
55019
- return { xp: 0, date: todayMidnight.toISOString() };
55020
- }
55021
- return { xp: result2[0].xp, date: result2[0].date.toISOString() };
55198
+ static markHeartbeatWindowProcessed(key) {
55199
+ this.processedHeartbeatWindows.set(key, Date.now());
55022
55200
  }
55023
- const { startOfDay, endOfDay } = getDayBoundariesInTimezone(base, tz);
55024
- 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))));
55025
- return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
55026
- }
55027
- async getTotalXp(userId) {
55028
- const db2 = this.deps.db;
55029
- const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
55030
- return { totalXp: Number(result[0]?.totalXp) || 0 };
55031
- }
55032
- async updateTodayXp(userId, data) {
55033
- const db2 = this.deps.db;
55034
- const { xp, userTimestamp } = data;
55035
- let targetDate;
55036
- if (userTimestamp) {
55037
- targetDate = new Date(userTimestamp);
55038
- if (isNaN(targetDate.getTime())) {
55039
- throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
55040
- }
55041
- targetDate.setHours(0, 0, 0, 0);
55042
- } else {
55043
- targetDate = new Date;
55044
- targetDate.setUTCHours(0, 0, 0, 0);
55201
+ static markHeartbeatWindowInFlight(key, promise) {
55202
+ this.inFlightHeartbeatWindows.set(key, promise);
55045
55203
  }
55046
- const [result] = await db2.insert(timebackDailyXp).values({ userId, date: targetDate, xp }).onConflictDoUpdate({
55047
- target: [timebackDailyXp.userId, timebackDailyXp.date],
55048
- set: { xp: sql`excluded.xp`, updatedAt: new Date }
55049
- }).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
55050
- if (!result) {
55051
- logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
55052
- throw new InternalError("Failed to update daily XP record");
55204
+ static clearInFlightHeartbeatWindow(key) {
55205
+ this.inFlightHeartbeatWindows.delete(key);
55053
55206
  }
55054
- return { xp: result.xp, date: result.date.toISOString() };
55055
- }
55056
- async getXpHistory(userId, startDate, endDate) {
55057
- const db2 = this.deps.db;
55058
- const whereConditions = [eq(timebackDailyXp.userId, userId)];
55059
- if (startDate) {
55060
- const start2 = new Date(startDate);
55061
- start2.setUTCHours(0, 0, 0, 0);
55062
- 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
+ };
55063
55218
  }
55064
- if (endDate) {
55065
- const end = new Date(endDate);
55066
- end.setUTCHours(23, 59, 59, 999);
55067
- whereConditions.push(lte(timebackDailyXp.date, end));
55219
+ constructor(deps) {
55220
+ this.deps = deps;
55068
55221
  }
55069
- const result = await db2.select({ date: timebackDailyXp.date, xp: timebackDailyXp.xp }).from(timebackDailyXp).where(and(...whereConditions)).orderBy(timebackDailyXp.date);
55070
- return {
55071
- history: result.map((row) => ({ date: row.date.toISOString(), xp: row.xp }))
55072
- };
55073
- }
55074
- async populateStudent(user, providedNames) {
55075
- const client = this.requireClient();
55076
- const db2 = this.deps.db;
55077
- const dbUser = await db2.query.users.findFirst({
55078
- where: eq(users.id, user.id),
55079
- columns: { id: true, timebackId: true }
55080
- });
55081
- if (dbUser?.timebackId) {
55082
- logger17.info("Student already onboarded", { userId: user.id });
55083
- 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;
55084
55228
  }
55085
- let timebackId;
55086
- let name3;
55087
- try {
55088
- const existingUser = await client.oneroster.users.findByEmail(user.email);
55089
- timebackId = existingUser.sourcedId;
55090
- name3 = `${existingUser.givenName} ${existingUser.familyName}`;
55091
- logger17.info("Found existing student in OneRoster", {
55092
- userId: user.id,
55093
- timebackId
55094
- });
55095
- } catch {
55096
- if (!providedNames?.firstName || !providedNames?.lastName) {
55097
- 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.");
55098
55235
  }
55099
- const sourcedId = crypto.randomUUID();
55100
- const response = await client.oneroster.users.create({
55101
- sourcedId,
55102
- status: "active",
55103
- enabledUser: true,
55104
- givenName: providedNames.firstName,
55105
- familyName: providedNames.lastName,
55106
- email: user.email,
55107
- roles: [
55108
- {
55109
- roleType: "primary",
55110
- role: "student",
55111
- org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
55112
- }
55113
- ]
55114
- });
55115
- if (!response.sourcedIdPairs?.allocatedSourcedId) {
55116
- 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);
55117
55271
  }
55118
- timebackId = response.sourcedIdPairs.allocatedSourcedId;
55119
- name3 = `${providedNames.firstName} ${providedNames.lastName}`;
55120
- logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
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");
55279
+ }
55280
+ return { xp: result.xp, date: result.date.toISOString() };
55121
55281
  }
55122
- const assessments = await this.fetchAssessments(timebackId);
55123
- await db2.transaction(async (tx) => {
55124
- if (assessments.length > 0) {
55125
- const events = mapAssessmentsToXpEvents(user.id, assessments);
55126
- for (const event of events) {
55127
- try {
55128
- await tx.insert(timebackXpEvents).values(event);
55129
- } 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" };
55130
55324
  }
55131
- const dailyMap = new Map;
55132
- for (const a of assessments) {
55133
- const xp = a.metadata?.xp;
55134
- if (typeof xp === "number" && a.scoreDate) {
55135
- const day = getUtcInstantForMidnight(new Date(a.scoreDate), PLATFORM_TIMEZONE);
55136
- const key = day.toISOString();
55137
- 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
+ });
55138
55376
  }
55139
55377
  }
55140
- if (dailyMap.size > 0) {
55141
- 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", {
55142
55381
  userId: user.id,
55143
- date: new Date(iso),
55144
- xp
55145
- }));
55146
- await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
55147
- target: [timebackDailyXp.userId, timebackDailyXp.date],
55148
- set: { xp: sql`excluded.xp`, updatedAt: new Date }
55382
+ timebackId
55149
55383
  });
55384
+ throw new InternalError("Failed to update user with Timeback ID");
55150
55385
  }
55151
- }
55152
- const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
55153
- if (!updated) {
55154
- logger17.error("User Timeback ID update returned no rows", {
55155
- userId: user.id,
55156
- timebackId
55157
- });
55158
- throw new InternalError("Failed to update user with Timeback ID");
55159
- }
55160
- });
55161
- return { status: "ok" };
55162
- }
55163
- async fetchAssessments(studentSourcedId) {
55164
- const client = this.requireClient();
55165
- const allAssessments = [];
55166
- const limit = 3000;
55167
- const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
55168
- let offset = 0;
55169
- try {
55170
- while (true) {
55171
- const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
55172
- allAssessments.push(...results);
55173
- if (results.length < limit) {
55174
- break;
55175
- }
55176
- offset += limit;
55177
- }
55178
- logger17.debug("Fetched assessments", {
55179
- studentSourcedId,
55180
- totalCount: allAssessments.length
55181
55386
  });
55182
- return allAssessments;
55183
- } catch (error) {
55184
- logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
55185
- return [];
55186
- }
55187
- }
55188
- async getUserData(userId, gameId) {
55189
- const db2 = this.deps.db;
55190
- const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
55191
- if (!userData) {
55192
- throw new NotFoundError("User", userId);
55387
+ return { status: "ok" };
55193
55388
  }
55194
- if (!userData.timebackId) {
55195
- throw new NotFoundError("Timeback account not found for user");
55196
- }
55197
- const [profile, allEnrollments] = await Promise.all([
55198
- this.fetchStudentProfile(userData.timebackId),
55199
- this.fetchEnrollments(userData.timebackId)
55200
- ]);
55201
- const enrollments = gameId ? allEnrollments.filter((e) => e.gameId === gameId) : allEnrollments;
55202
- const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
55203
- const organizations = gameId && enrollmentOrgIds.size > 0 ? profile.organizations.filter((o) => enrollmentOrgIds.has(o.id)) : profile.organizations;
55204
- return { id: userData.timebackId, role: profile.role, enrollments, organizations };
55205
- }
55206
- async getUserDataByTimebackId(timebackId) {
55207
- const [profile, enrollments] = await Promise.all([
55208
- this.fetchStudentProfile(timebackId),
55209
- this.fetchEnrollments(timebackId)
55210
- ]);
55211
- return {
55212
- id: timebackId,
55213
- role: profile.role,
55214
- enrollments,
55215
- organizations: profile.organizations
55216
- };
55217
- }
55218
- async fetchStudentProfile(timebackId) {
55219
- const client = this.requireClient();
55220
- try {
55221
- const user = await client.oneroster.users.get(timebackId);
55222
- const primaryRole = user.roles.find((r) => r.roleType === "primary");
55223
- const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
55224
- const orgMap = new Map;
55225
- if (user.primaryOrg) {
55226
- orgMap.set(user.primaryOrg.sourcedId, {
55227
- id: user.primaryOrg.sourcedId,
55228
- name: user.primaryOrg.name ?? null,
55229
- type: user.primaryOrg.type || "school",
55230
- 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
55231
55407
  });
55408
+ return allAssessments;
55409
+ } catch (error) {
55410
+ logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
55411
+ return [];
55232
55412
  }
55233
- for (const r of user.roles) {
55234
- if (r.org && !orgMap.has(r.org.sourcedId)) {
55235
- orgMap.set(r.org.sourcedId, {
55236
- id: r.org.sourcedId,
55237
- name: null,
55238
- type: "school",
55239
- 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
55240
55457
  });
55241
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: [] };
55242
55472
  }
55243
- return { role, organizations: [...orgMap.values()] };
55244
- } catch {
55245
- return { role: "student", organizations: [] };
55246
55473
  }
55247
- }
55248
- async fetchEnrollments(timebackId) {
55249
- const client = this.requireClient();
55250
- const db2 = this.deps.db;
55251
- try {
55252
- const enrollments = await client.getEnrollments(timebackId);
55253
- const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
55254
- 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 {
55255
55495
  return [];
55256
55496
  }
55257
- const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
55258
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55259
- where: inArray(gameTimebackIntegrations.courseId, courseIds)
55260
- });
55261
- return integrations.map((i2) => ({
55262
- gameId: i2.gameId,
55263
- grade: i2.grade,
55264
- subject: i2.subject,
55265
- courseId: i2.courseId,
55266
- orgId: courseToSchool.get(i2.courseId)
55267
- }));
55268
- } catch {
55269
- return [];
55270
55497
  }
55271
- }
55272
- async setupIntegration(gameId, request, user) {
55273
- const client = this.requireClient();
55274
- const db2 = this.deps.db;
55275
- await this.deps.validateDeveloperAccess(user, gameId);
55276
- const { courses, baseConfig, verbose } = request;
55277
- const existing = await db2.query.gameTimebackIntegrations.findMany({
55278
- where: eq(gameTimebackIntegrations.gameId, gameId)
55279
- });
55280
- const integrations = [];
55281
- const verboseData = [];
55282
- for (const courseConfig of courses) {
55283
- let applySuffix = function(text3) {
55284
- return suffix ? `${text3} ${suffix}` : text3;
55285
- };
55286
- const {
55287
- subject: subjectInput,
55288
- grade,
55289
- title,
55290
- courseCode,
55291
- level,
55292
- metadata: metadata2,
55293
- totalXp: derivedTotalXp,
55294
- masterableUnits: derivedMasterableUnits
55295
- } = courseConfig;
55296
- if (!isTimebackSubject(subjectInput)) {
55297
- 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 {
55298
55513
  subject: subjectInput,
55299
- courseCode,
55300
- title
55301
- });
55302
- throw new ValidationError(`Invalid subject "${subjectInput}"`);
55303
- }
55304
- if (!isTimebackGrade(grade)) {
55305
- logger17.warn("Invalid Timeback grade in course config", {
55306
55514
  grade,
55307
- courseCode,
55308
- title
55309
- });
55310
- throw new ValidationError(`Invalid grade "${grade}"`);
55311
- }
55312
- const subject = subjectInput;
55313
- const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
55314
- const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
55315
- const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
55316
- if (typeof totalXp !== "number") {
55317
- logger17.warn("Course missing totalXp in Timeback config", {
55318
- courseCode,
55319
- title
55320
- });
55321
- throw new ValidationError(`Course "${title}" is missing totalXp`);
55322
- }
55323
- const suffix = baseConfig.component.titleSuffix || "";
55324
- const fullConfig = {
55325
- organization: baseConfig.organization,
55326
- course: {
55327
55515
  title,
55328
- subjects: [subject],
55329
- grades: [grade],
55330
55516
  courseCode,
55331
55517
  level,
55332
- gradingScheme: "STANDARD",
55333
- metadata: metadata2
55334
- },
55335
- component: {
55336
- ...baseConfig.component,
55337
- title: applySuffix(baseConfig.component.title || `${title} Activities`)
55338
- },
55339
- resource: {
55340
- ...baseConfig.resource,
55341
- title: applySuffix(baseConfig.resource.title || `${title} Game`),
55342
- metadata: buildResourceMetadata({
55343
- baseMetadata: baseConfig.resource.metadata,
55344
- subject,
55345
- grade,
55346
- totalXp,
55347
- masterableUnits
55348
- })
55349
- },
55350
- componentResource: {
55351
- ...baseConfig.componentResource,
55352
- title: applySuffix(baseConfig.componentResource.title || "")
55353
- }
55354
- };
55355
- const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
55356
- if (existingIntegration) {
55357
- await client.update(existingIntegration.courseId, fullConfig);
55358
- const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
55359
- if (updated) {
55360
- 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}"`);
55361
55529
  }
55362
- } else {
55363
- const result = await client.setup(fullConfig, { verbose });
55364
- const [integration] = await db2.insert(gameTimebackIntegrations).values({ gameId, courseId: result.courseId, grade, subject, totalXp }).returning();
55365
- if (integration) {
55366
- const dto = this.toGameTimebackIntegration(integration);
55367
- integrations.push(dto);
55368
- if (verbose && result.verboseData) {
55369
- 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
+ }
55370
55597
  }
55371
55598
  }
55372
55599
  }
55600
+ return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
55373
55601
  }
55374
- return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
55375
- }
55376
- async getIntegrations(gameId, user) {
55377
- await this.deps.validateDeveloperAccess(user, gameId);
55378
- const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
55379
- where: eq(gameTimebackIntegrations.gameId, gameId)
55380
- });
55381
- return rows.map((row) => this.toGameTimebackIntegration(row));
55382
- }
55383
- async verifyIntegration(gameId, user) {
55384
- const client = this.requireClient();
55385
- const db2 = this.deps.db;
55386
- await this.deps.validateDeveloperAccess(user, gameId);
55387
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55388
- where: eq(gameTimebackIntegrations.gameId, gameId)
55389
- });
55390
- if (integrations.length === 0) {
55391
- 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));
55392
55608
  }
55393
- const now2 = new Date;
55394
- const results = await Promise.all(integrations.map(async (integration) => {
55395
- const resources = await client.verify(integration.courseId);
55396
- const resourceValues = Object.values(resources);
55397
- const allFound = resourceValues.every((r) => r.found);
55398
- const errors3 = Object.entries(resources).filter(([_2, r]) => !r.found).map(([name3]) => `${name3} not found`);
55399
- const status = allFound ? "success" : "error";
55400
- return {
55401
- integration: this.toGameTimebackIntegration({
55402
- ...integration,
55403
- lastVerifiedAt: now2
55404
- }),
55405
- resources,
55406
- status,
55407
- ...errors3.length > 0 && { errors: errors3 }
55408
- };
55409
- }));
55410
- await db2.update(gameTimebackIntegrations).set({ lastVerifiedAt: now2 }).where(eq(gameTimebackIntegrations.gameId, gameId));
55411
- const overallStatus = results.every((r) => r.status === "success") ? "success" : "error";
55412
- return { status: overallStatus, results };
55413
- }
55414
- async getConfig(gameId, user) {
55415
- const client = this.requireClient();
55416
- await this.deps.validateDeveloperAccess(user, gameId);
55417
- const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55418
- where: eq(gameTimebackIntegrations.gameId, gameId)
55419
- });
55420
- if (!integration) {
55421
- 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);
55422
55650
  }
55423
- return client.getConfig(integration.courseId);
55424
- }
55425
- async deleteIntegrations(gameId, user) {
55426
- const client = this.requireClient();
55427
- const db2 = this.deps.db;
55428
- await this.deps.validateDeveloperAccess(user, gameId);
55429
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55430
- where: eq(gameTimebackIntegrations.gameId, gameId)
55431
- });
55432
- if (integrations.length === 0) {
55433
- 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));
55434
55665
  }
55435
- for (const integration of integrations) {
55436
- 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
+ };
55437
55678
  }
55438
- await db2.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
55439
- }
55440
- toGameTimebackIntegration(integration) {
55441
- return {
55442
- id: integration.id,
55443
- gameId: integration.gameId,
55444
- courseId: integration.courseId,
55445
- grade: integration.grade,
55446
- subject: integration.subject,
55447
- totalXp: integration.totalXp ?? null,
55448
- createdAt: integration.createdAt,
55449
- updatedAt: integration.updatedAt,
55450
- lastVerifiedAt: integration.lastVerifiedAt ?? null
55451
- };
55452
- }
55453
- async endActivity({
55454
- gameId,
55455
- studentId,
55456
- activityData,
55457
- scoreData,
55458
- timingData,
55459
- xpEarned,
55460
- masteredUnits,
55461
- extensions,
55462
- user
55463
- }) {
55464
- const client = this.requireClient();
55465
- const db2 = this.deps.db;
55466
- await this.deps.validateDeveloperAccess(user, gameId);
55467
- const integration = await db2.query.gameTimebackIntegrations.findFirst({
55468
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
55469
- });
55470
- if (!integration) {
55471
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
55472
- }
55473
- const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
55474
- const result = await client.recordProgress(integration.courseId, studentId, {
55475
- score: scorePercentage,
55476
- totalQuestions: scoreData.totalQuestions,
55477
- correctQuestions: scoreData.correctQuestions,
55478
- durationSeconds: timingData.durationSeconds,
55679
+ async endActivity({
55680
+ gameId,
55681
+ studentId,
55682
+ runId,
55683
+ resumeId,
55684
+ activityData,
55685
+ scoreData,
55686
+ timingData,
55687
+ sessionTimingData,
55479
55688
  xpEarned,
55480
55689
  masteredUnits,
55481
55690
  extensions,
55482
- activityId: activityData.activityId,
55483
- activityName: activityData.activityName,
55484
- subject: activityData.subject,
55485
- appName: activityData.appName,
55486
- sensorUrl: activityData.sensorUrl,
55487
- courseId: activityData.courseId,
55488
- courseName: activityData.courseName,
55489
- studentEmail: activityData.studentEmail,
55490
- courseTotalXp: integration.totalXp
55491
- });
55492
- await client.recordSessionEnd(integration.courseId, studentId, {
55493
- activeTimeSeconds: timingData.durationSeconds,
55494
- activityId: activityData.activityId,
55495
- activityName: activityData.activityName,
55496
- subject: activityData.subject,
55497
- appName: activityData.appName,
55498
- sensorUrl: activityData.sensorUrl,
55499
- courseId: activityData.courseId,
55500
- courseName: activityData.courseName,
55501
- studentEmail: activityData.studentEmail
55502
- });
55503
- 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({
55504
55762
  gameId,
55505
- courseId: integration.courseId,
55506
55763
  studentId,
55507
- score: scorePercentage
55508
- });
55509
- return {
55510
- status: "ok",
55511
- courseId: integration.courseId,
55512
- xpAwarded: result.xpAwarded,
55513
- masteredUnits: result.masteredUnitsApplied,
55514
- pctCompleteApp: result.pctCompleteApp,
55515
- scoreStatus: result.scoreStatus,
55516
- inProgress: result.inProgress
55517
- };
55518
- }
55519
- async getStudentXp(timebackId, user, options) {
55520
- const client = this.requireClient();
55521
- const db2 = this.deps.db;
55522
- let courseIds = [];
55523
- if (options?.gameId) {
55524
- await this.deps.validateDeveloperAccess(user, options.gameId);
55525
- const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
55526
- if (options.grade !== undefined && options.subject) {
55527
- conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
55528
- 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" };
55529
55792
  }
55530
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55531
- where: and(...conditions2)
55532
- });
55533
- courseIds = integrations.map((i2) => i2.courseId);
55534
- if (courseIds.length === 0) {
55535
- logger17.debug("No integrations found for game, returning 0 XP", {
55536
- timebackId,
55537
- gameId: options.gameId,
55538
- grade: options.grade,
55539
- 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
55540
55803
  });
55541
- return {
55542
- totalXp: 0,
55543
- ...options?.include?.today && { todayXp: 0 },
55544
- ...options?.include?.perCourse && { courses: [] }
55545
- };
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
+ }
55546
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;
55547
55894
  }
55548
- const result = await client.getStudentXp(timebackId, {
55549
- courseIds: courseIds.length > 0 ? courseIds : undefined,
55550
- include: options?.include
55551
- });
55552
- logger17.debug("Retrieved student XP", {
55553
- timebackId,
55554
- gameId: options?.gameId,
55555
- grade: options?.grade,
55556
- subject: options?.subject,
55557
- totalXp: result.totalXp,
55558
- courseCount: result.courses?.length
55559
- });
55560
- return result;
55561
- }
55562
- }
55563
- var logger17;
55564
- var init_timeback_service = __esm(() => {
55565
- init_drizzle_orm();
55566
- init_src();
55567
- init_tables_index();
55568
- init_src2();
55569
- init_types4();
55570
- init_src4();
55571
- init_errors();
55572
- init_timeback_util();
55573
- logger17 = log.scope("TimebackService");
55895
+ };
55574
55896
  });
55575
55897
 
55576
55898
  class UploadService {
@@ -55626,6 +55948,7 @@ function createPlatformServices(deps) {
55626
55948
  alerts,
55627
55949
  validateDeveloperAccessBySlug,
55628
55950
  validateDeveloperAccess,
55951
+ validateGameManagementAccess,
55629
55952
  validateOwnership
55630
55953
  } = deps;
55631
55954
  const bucket = new BucketService({
@@ -55660,12 +55983,15 @@ function createPlatformServices(deps) {
55660
55983
  const timeback2 = new TimebackService({
55661
55984
  db: db2,
55662
55985
  timeback: timebackClient,
55663
- validateDeveloperAccess
55986
+ validateDeveloperAccess,
55987
+ validateGameManagementAccess
55664
55988
  });
55665
55989
  const timebackAdmin = new TimebackAdminService({
55990
+ config: config2,
55666
55991
  db: db2,
55667
55992
  timeback: timebackClient,
55668
- validateDeveloperAccess
55993
+ validateDeveloperAccess,
55994
+ validateGameManagementAccess
55669
55995
  });
55670
55996
  return {
55671
55997
  bucket,
@@ -58592,6 +58918,16 @@ async function requestCaliper(options) {
58592
58918
  baseUrl: caliperUrl
58593
58919
  });
58594
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
+ }
58595
58931
  function createCaliperNamespace(client) {
58596
58932
  const urls = createOneRosterUrls(client.getBaseUrl());
58597
58933
  const caliper = {
@@ -58636,11 +58972,20 @@ function createCaliperNamespace(client) {
58636
58972
  if (params.actorEmail) {
58637
58973
  query.set("actorEmail", params.actorEmail);
58638
58974
  }
58975
+ if (params.extensions) {
58976
+ for (const [key, value] of Object.entries(params.extensions)) {
58977
+ query.set(`extensions.${key}`, value);
58978
+ }
58979
+ }
58639
58980
  const requestPath = `${CALIPER_ENDPOINTS4.events}?${query.toString()}`;
58640
58981
  return client["requestCaliper"](requestPath, "GET");
58641
58982
  }
58642
58983
  },
58643
58984
  emitActivityEvent: async (data) => {
58985
+ const eventExtensions = buildEventExtensions({
58986
+ eventExtensions: data.eventExtensions,
58987
+ gameId: data.gameId
58988
+ });
58644
58989
  const event = {
58645
58990
  "@context": CALIPER_CONSTANTS4.context,
58646
58991
  id: `urn:uuid:${crypto.randomUUID()}`,
@@ -58653,6 +58998,7 @@ function createCaliperNamespace(client) {
58653
58998
  email: data.studentEmail
58654
58999
  },
58655
59000
  action: TIMEBACK_ACTIONS4.completed,
59001
+ ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
58656
59002
  object: {
58657
59003
  id: data.objectId || caliper.buildActivityUrl(data),
58658
59004
  type: TIMEBACK_TYPES4.activityContext,
@@ -58699,11 +59045,15 @@ function createCaliperNamespace(client) {
58699
59045
  }
58700
59046
  } : {}
58701
59047
  },
58702
- ...data.eventExtensions ? { extensions: data.eventExtensions } : {}
59048
+ ...eventExtensions ? { extensions: eventExtensions } : {}
58703
59049
  };
58704
59050
  return caliper.emit(event, data.sensorUrl);
58705
59051
  },
58706
59052
  emitTimeSpentEvent: async (data) => {
59053
+ const eventExtensions = buildEventExtensions({
59054
+ eventExtensions: data.eventExtensions,
59055
+ gameId: data.gameId
59056
+ });
58707
59057
  const event = {
58708
59058
  "@context": CALIPER_CONSTANTS4.context,
58709
59059
  id: `urn:uuid:${crypto.randomUUID()}`,
@@ -58716,6 +59066,7 @@ function createCaliperNamespace(client) {
58716
59066
  email: data.studentEmail
58717
59067
  },
58718
59068
  action: TIMEBACK_ACTIONS4.spentTime,
59069
+ ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
58719
59070
  object: {
58720
59071
  id: caliper.buildActivityUrl(data),
58721
59072
  type: TIMEBACK_TYPES4.activityContext,
@@ -58743,13 +59094,14 @@ function createCaliperNamespace(client) {
58743
59094
  ...data.wasteTimeSeconds !== undefined ? [{ type: TIME_METRIC_TYPES4.waste, value: data.wasteTimeSeconds }] : []
58744
59095
  ],
58745
59096
  ...data.extensions ? { extensions: data.extensions } : {}
58746
- }
59097
+ },
59098
+ ...eventExtensions ? { extensions: eventExtensions } : {}
58747
59099
  };
58748
59100
  return caliper.emit(event, data.sensorUrl);
58749
59101
  },
58750
59102
  buildActivityUrl: (data) => {
58751
59103
  const base = data.sensorUrl.replace(/\/$/, "");
58752
- return `${base}/activities/${data.courseId}/${data.activityId}/${crypto.randomUUID()}`;
59104
+ return `${base}/activities/${encodeURIComponent(data.courseId)}/${encodeURIComponent(data.activityId)}`;
58753
59105
  }
58754
59106
  };
58755
59107
  return caliper;
@@ -58759,6 +59111,34 @@ function createEduBridgeNamespace(client) {
58759
59111
  listByUser: async (userId) => {
58760
59112
  const response = await client["request"](`/edubridge/enrollments/user/${userId}`, "GET");
58761
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");
58762
59142
  }
58763
59143
  };
58764
59144
  const analytics = {
@@ -58934,6 +59314,10 @@ function createOneRosterNamespace(client) {
58934
59314
  logTimebackError("list course roster", error, { courseSourcedId });
58935
59315
  throw error;
58936
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");
58937
59321
  }
58938
59322
  },
58939
59323
  organizations: {
@@ -59204,6 +59588,7 @@ class AdminEventRecorder {
59204
59588
  await this.caliper.emitActivityEvent({
59205
59589
  studentId: ctx.student.id,
59206
59590
  studentEmail: ctx.student.email,
59591
+ gameId: data.gameId,
59207
59592
  activityId: ctx.activityId,
59208
59593
  activityName: data.activityName || `Manual XP Assignment - ${ctx.courseContext.subject}`,
59209
59594
  courseId: data.courseId,
@@ -59230,6 +59615,7 @@ class AdminEventRecorder {
59230
59615
  await this.caliper.emitTimeSpentEvent({
59231
59616
  studentId: ctx.student.id,
59232
59617
  studentEmail: ctx.student.email,
59618
+ gameId: data.gameId,
59233
59619
  activityId: ctx.activityId,
59234
59620
  activityName: data.activityName || "Playcademy Admin Time Adjustment",
59235
59621
  courseId: data.courseId,
@@ -59251,6 +59637,7 @@ class AdminEventRecorder {
59251
59637
  await this.caliper.emitActivityEvent({
59252
59638
  studentId: ctx.student.id,
59253
59639
  studentEmail: ctx.student.email,
59640
+ gameId: data.gameId,
59254
59641
  activityId: ctx.activityId,
59255
59642
  activityName: data.activityName || "Playcademy Admin Mastery Adjustment",
59256
59643
  courseId: data.courseId,
@@ -59276,6 +59663,7 @@ class AdminEventRecorder {
59276
59663
  await this.caliper.emitActivityEvent({
59277
59664
  studentId: ctx.student.id,
59278
59665
  studentEmail: ctx.student.email,
59666
+ gameId: data.gameId,
59279
59667
  activityId: ctx.activityId,
59280
59668
  activityName: isResume ? "Course resumed" : "Course marked complete",
59281
59669
  courseId: data.courseId,
@@ -59724,15 +60112,13 @@ class ProgressRecorder {
59724
60112
  studentId,
59725
60113
  attemptNumber: currentAttemptNumber,
59726
60114
  score,
59727
- totalQuestions,
59728
- correctQuestions,
59729
60115
  xp: calculatedXp,
59730
- masteredUnits,
59731
60116
  scoreStatus,
59732
60117
  inProgress,
59733
60118
  appName: progressData.appName,
59734
- activityName,
59735
- durationSeconds: progressData.durationSeconds
60119
+ totalQuestions,
60120
+ correctQuestions,
60121
+ masteredUnits
59736
60122
  });
59737
60123
  } else {
59738
60124
  log.warn("[ProgressRecorder] Score not provided, skipping gradebook entry", {
@@ -59746,6 +60132,7 @@ class ProgressRecorder {
59746
60132
  await this.emitCourseCompletionHistoryEvent({
59747
60133
  studentId,
59748
60134
  studentEmail,
60135
+ gameId: progressData.gameId,
59749
60136
  activityId,
59750
60137
  courseId: ids.course,
59751
60138
  courseName,
@@ -59757,6 +60144,7 @@ class ProgressRecorder {
59757
60144
  await this.emitCaliperEvent({
59758
60145
  studentId,
59759
60146
  studentEmail,
60147
+ gameId: progressData.gameId,
59760
60148
  activityId,
59761
60149
  activityName,
59762
60150
  courseId: ids.course,
@@ -59767,7 +60155,8 @@ class ProgressRecorder {
59767
60155
  masteredUnits,
59768
60156
  attemptNumber: currentAttemptNumber,
59769
60157
  progressData,
59770
- extensions
60158
+ extensions,
60159
+ runId: progressData.runId
59771
60160
  });
59772
60161
  return {
59773
60162
  xpAwarded: calculatedXp,
@@ -59857,15 +60246,13 @@ class ProgressRecorder {
59857
60246
  studentId,
59858
60247
  attemptNumber,
59859
60248
  score,
59860
- totalQuestions,
59861
- correctQuestions,
59862
60249
  xp,
59863
- masteredUnits,
59864
60250
  scoreStatus,
59865
60251
  inProgress,
59866
60252
  appName,
59867
- activityName,
59868
- durationSeconds
60253
+ totalQuestions,
60254
+ correctQuestions,
60255
+ masteredUnits
59869
60256
  }) {
59870
60257
  const timestamp3 = Date.now().toString(36);
59871
60258
  const resultId = `${lineItemId}:${studentId}:${timestamp3}`;
@@ -59880,21 +60267,18 @@ class ProgressRecorder {
59880
60267
  inProgress,
59881
60268
  metadata: {
59882
60269
  xp,
59883
- totalQuestions,
59884
- correctQuestions,
59885
- accuracy: totalQuestions && correctQuestions ? correctQuestions / totalQuestions * 100 : undefined,
59886
60270
  attemptNumber,
59887
- lastUpdated: new Date().toISOString(),
59888
- masteredUnits,
59889
60271
  appName,
59890
- activityName,
59891
- durationSeconds
60272
+ ...totalQuestions !== undefined ? { totalQuestions } : {},
60273
+ ...correctQuestions !== undefined ? { correctQuestions } : {},
60274
+ ...masteredUnits !== undefined ? { masteredUnits } : {}
59892
60275
  }
59893
60276
  });
59894
60277
  }
59895
60278
  async emitCaliperEvent({
59896
60279
  studentId,
59897
60280
  studentEmail,
60281
+ gameId,
59898
60282
  activityId,
59899
60283
  activityName,
59900
60284
  courseId,
@@ -59905,11 +60289,13 @@ class ProgressRecorder {
59905
60289
  masteredUnits,
59906
60290
  attemptNumber,
59907
60291
  progressData,
59908
- extensions
60292
+ extensions,
60293
+ runId
59909
60294
  }) {
59910
60295
  await this.caliperNamespace.emitActivityEvent({
59911
60296
  studentId,
59912
60297
  studentEmail,
60298
+ gameId,
59913
60299
  activityId,
59914
60300
  activityName,
59915
60301
  courseId,
@@ -59922,7 +60308,8 @@ class ProgressRecorder {
59922
60308
  subject: progressData.subject,
59923
60309
  appName: progressData.appName,
59924
60310
  sensorUrl: progressData.sensorUrl,
59925
- extensions: extensions || progressData.extensions
60311
+ extensions: extensions || progressData.extensions,
60312
+ ...runId ? { runId } : {}
59926
60313
  }).catch((error) => {
59927
60314
  log.error("[ProgressRecorder] Failed to emit activity event", { error });
59928
60315
  });
@@ -59931,6 +60318,7 @@ class ProgressRecorder {
59931
60318
  await this.caliperNamespace.emitActivityEvent({
59932
60319
  studentId: data.studentId,
59933
60320
  studentEmail: data.studentEmail,
60321
+ gameId: data.gameId,
59934
60322
  activityId: data.activityId,
59935
60323
  activityName: "Course completed",
59936
60324
  courseId: data.courseId,
@@ -59976,10 +60364,11 @@ class SessionRecorder {
59976
60364
  const courseName = sessionData.courseName || "Game Course";
59977
60365
  const student = await this.studentResolver.resolve(studentIdentifier, sessionData.studentEmail);
59978
60366
  const { id: studentId, email: studentEmail } = student;
59979
- const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions } = sessionData;
60367
+ const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions, runId } = sessionData;
59980
60368
  await this.caliperNamespace.emitTimeSpentEvent({
59981
60369
  studentId,
59982
60370
  studentEmail,
60371
+ gameId: sessionData.gameId,
59983
60372
  activityId,
59984
60373
  activityName,
59985
60374
  courseId: ids.course,
@@ -59990,6 +60379,7 @@ class SessionRecorder {
59990
60379
  subject: sessionData.subject,
59991
60380
  appName: sessionData.appName,
59992
60381
  sensorUrl: sessionData.sensorUrl,
60382
+ ...runId ? { runId } : {},
59993
60383
  ...extensions ? { extensions } : {}
59994
60384
  });
59995
60385
  }
@@ -118567,18 +118957,23 @@ async function seedCoreGames(db2) {
118567
118957
  }
118568
118958
  async function seedCurrentProjectGame(db2, project) {
118569
118959
  const now2 = new Date;
118960
+ const desiredGameId = project.gameId?.trim() || undefined;
118570
118961
  try {
118571
118962
  const existingGame = await db2.query.games.findFirst({
118572
- where: (row, { eq: eq3 }) => eq3(row.slug, project.slug)
118963
+ where: (row, operators) => operators.eq(row.slug, project.slug)
118573
118964
  });
118574
118965
  if (existingGame) {
118575
- if (project.timebackCourses && project.timebackCourses.length > 0) {
118576
- 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;
118577
118973
  }
118578
- return existingGame;
118579
118974
  }
118580
118975
  const gameRecord = {
118581
- id: crypto.randomUUID(),
118976
+ id: desiredGameId ?? crypto.randomUUID(),
118582
118977
  developerId: DEMO_USERS.developer.id,
118583
118978
  slug: project.slug,
118584
118979
  displayName: project.displayName,
@@ -118607,6 +119002,7 @@ async function seedCurrentProjectGame(db2, project) {
118607
119002
  }
118608
119003
  }
118609
119004
  var init_games = __esm(() => {
119005
+ init_drizzle_orm();
118610
119006
  init_src();
118611
119007
  init_tables_index();
118612
119008
  init_constants();
@@ -119953,7 +120349,9 @@ var TIMEBACK_SUBJECTS5;
119953
120349
  var TimebackGradeSchema;
119954
120350
  var TimebackSubjectSchema;
119955
120351
  var UpdateTimebackXpRequestSchema;
120352
+ var TimebackActivityDataSchema;
119956
120353
  var EndActivityRequestSchema;
120354
+ var HeartbeatRequestSchema;
119957
120355
  var PopulateStudentRequestSchema;
119958
120356
  var DerivedPlatformCourseConfigSchema;
119959
120357
  var TimebackBaseConfigSchema;
@@ -119964,6 +120362,8 @@ var GrantTimebackXpRequestSchema;
119964
120362
  var AdjustTimebackTimeRequestSchema;
119965
120363
  var AdjustTimebackMasteryRequestSchema;
119966
120364
  var ToggleCourseCompletionRequestSchema;
120365
+ var EnrollStudentRequestSchema;
120366
+ var UnenrollStudentRequestSchema;
119967
120367
  var init_schemas11 = __esm(() => {
119968
120368
  init_esm();
119969
120369
  TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
@@ -119986,31 +120386,55 @@ var init_schemas11 = __esm(() => {
119986
120386
  xp: exports_external.number().min(0, "XP must be a non-negative number"),
119987
120387
  userTimestamp: exports_external.string().datetime().optional()
119988
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
+ });
119989
120400
  EndActivityRequestSchema = exports_external.object({
119990
120401
  gameId: exports_external.string().uuid(),
119991
120402
  studentId: exports_external.string().min(1),
119992
- activityData: exports_external.object({
119993
- activityId: exports_external.string().min(1),
119994
- activityName: exports_external.string().optional(),
119995
- grade: TimebackGradeSchema,
119996
- subject: TimebackSubjectSchema,
119997
- appName: exports_external.string().optional(),
119998
- sensorUrl: exports_external.string().url().optional(),
119999
- courseId: exports_external.string().optional(),
120000
- courseName: exports_external.string().optional(),
120001
- studentEmail: exports_external.string().email().optional()
120002
- }),
120403
+ runId: exports_external.string().uuid().optional(),
120404
+ resumeId: exports_external.string().uuid().optional(),
120405
+ activityData: TimebackActivityDataSchema,
120003
120406
  scoreData: exports_external.object({
120004
120407
  correctQuestions: exports_external.number().int().min(0),
120005
120408
  totalQuestions: exports_external.number().int().min(0)
120006
120409
  }),
120007
120410
  timingData: exports_external.object({
120008
- durationSeconds: exports_external.number().positive()
120411
+ durationSeconds: exports_external.number().nonnegative()
120009
120412
  }),
120413
+ sessionTimingData: exports_external.object({
120414
+ activeSeconds: exports_external.number().nonnegative(),
120415
+ inactiveSeconds: exports_external.number().nonnegative().optional()
120416
+ }).optional(),
120010
120417
  xpEarned: exports_external.number().optional(),
120011
120418
  masteredUnits: exports_external.number().nonnegative().optional(),
120012
120419
  extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
120013
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
+ });
120014
120438
  PopulateStudentRequestSchema = exports_external.object({
120015
120439
  firstName: exports_external.string().min(1).optional(),
120016
120440
  lastName: exports_external.string().min(1).optional()
@@ -120090,15 +120514,18 @@ var init_schemas11 = __esm(() => {
120090
120514
  });
120091
120515
  GrantTimebackXpRequestSchema = AdminTimebackMutationBaseSchema.extend({
120092
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" }),
120093
- date: AdminAttributionDateSchema.optional()
120517
+ date: AdminAttributionDateSchema.optional(),
120518
+ useCurrentTime: exports_external.boolean().optional()
120094
120519
  });
120095
120520
  AdjustTimebackTimeRequestSchema = AdminTimebackMutationBaseSchema.extend({
120096
120521
  seconds: exports_external.number().refine((value) => value !== 0, { message: "Time amount cannot be 0" }),
120097
- date: AdminAttributionDateSchema.optional()
120522
+ date: AdminAttributionDateSchema.optional(),
120523
+ useCurrentTime: exports_external.boolean().optional()
120098
120524
  });
120099
120525
  AdjustTimebackMasteryRequestSchema = AdminTimebackMutationBaseSchema.extend({
120100
120526
  units: exports_external.number().refine((value) => value !== 0, { message: "Units cannot be 0" }),
120101
- date: AdminAttributionDateSchema.optional()
120527
+ date: AdminAttributionDateSchema.optional(),
120528
+ useCurrentTime: exports_external.boolean().optional()
120102
120529
  });
120103
120530
  ToggleCourseCompletionRequestSchema = exports_external.object({
120104
120531
  gameId: exports_external.string().uuid(),
@@ -120106,6 +120533,16 @@ var init_schemas11 = __esm(() => {
120106
120533
  studentId: exports_external.string().min(1),
120107
120534
  action: exports_external.enum(["complete", "resume"])
120108
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
+ });
120109
120546
  });
120110
120547
  var init_schemas_index = __esm(() => {
120111
120548
  init_schemas();
@@ -120124,6 +120561,9 @@ function isAuthenticated(ctx) {
120124
120561
  return ctx.user != null;
120125
120562
  }
120126
120563
  var init_types9 = () => {};
120564
+ function hasGameManagementAccess(user) {
120565
+ return user.role === "admin" || user.role === "teacher" || user.role === "developer" && user.developerStatus === "approved";
120566
+ }
120127
120567
  function requireAuth(handler) {
120128
120568
  return async (ctx) => {
120129
120569
  if (!isAuthenticated(ctx)) {
@@ -120167,6 +120607,17 @@ function requireDeveloper(handler) {
120167
120607
  return handler(ctx);
120168
120608
  };
120169
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
+ }
120170
120621
  var init_auth_util = __esm(() => {
120171
120622
  init_errors();
120172
120623
  init_types9();
@@ -122292,6 +122743,7 @@ var verifyIntegration;
122292
122743
  var getConfig2;
122293
122744
  var deleteIntegrations;
122294
122745
  var endActivity;
122746
+ var heartbeat;
122295
122747
  var getStudentXp;
122296
122748
  var getRoster;
122297
122749
  var getStudentOverview;
@@ -122300,6 +122752,9 @@ var grantXp;
122300
122752
  var adjustTime;
122301
122753
  var adjustMastery;
122302
122754
  var toggleCompletion;
122755
+ var searchStudents;
122756
+ var enrollStudent;
122757
+ var unenrollStudent;
122303
122758
  var timeback2;
122304
122759
  var init_timeback_controller = __esm(() => {
122305
122760
  init_esm();
@@ -122390,7 +122845,7 @@ var init_timeback_controller = __esm(() => {
122390
122845
  });
122391
122846
  return ctx.services.timeback.setupIntegration(body2.gameId, body2, ctx.user);
122392
122847
  });
122393
- getIntegrations = requireDeveloper(async (ctx) => {
122848
+ getIntegrations = requireGameManagementAccess(async (ctx) => {
122394
122849
  const gameId = ctx.params.gameId;
122395
122850
  if (!gameId) {
122396
122851
  throw ApiError.badRequest("Missing gameId");
@@ -122450,9 +122905,12 @@ var init_timeback_controller = __esm(() => {
122450
122905
  const {
122451
122906
  gameId,
122452
122907
  studentId,
122908
+ runId,
122909
+ resumeId,
122453
122910
  activityData,
122454
122911
  scoreData,
122455
122912
  timingData,
122913
+ sessionTimingData,
122456
122914
  xpEarned,
122457
122915
  masteredUnits,
122458
122916
  extensions
@@ -122461,15 +122919,65 @@ var init_timeback_controller = __esm(() => {
122461
122919
  return ctx.services.timeback.endActivity({
122462
122920
  gameId,
122463
122921
  studentId,
122922
+ runId,
122923
+ resumeId,
122464
122924
  activityData,
122465
122925
  scoreData,
122466
122926
  timingData,
122927
+ sessionTimingData,
122467
122928
  xpEarned,
122468
122929
  masteredUnits,
122469
122930
  extensions,
122470
122931
  user: ctx.user
122471
122932
  });
122472
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
+ });
122473
122981
  getStudentXp = requireDeveloper(async (ctx) => {
122474
122982
  const timebackId = ctx.params.timebackId;
122475
122983
  if (!timebackId) {
@@ -122515,7 +123023,7 @@ var init_timeback_controller = __esm(() => {
122515
123023
  include
122516
123024
  });
122517
123025
  });
122518
- getRoster = requireDeveloper(async (ctx) => {
123026
+ getRoster = requireGameManagementAccess(async (ctx) => {
122519
123027
  const gameId = ctx.params.gameId;
122520
123028
  const courseId = ctx.params.courseId;
122521
123029
  if (!gameId || !courseId) {
@@ -122528,7 +123036,7 @@ var init_timeback_controller = __esm(() => {
122528
123036
  });
122529
123037
  return ctx.services.timebackAdmin.listStudentsForCourse(gameId, courseId, ctx.user);
122530
123038
  });
122531
- getStudentOverview = requireDeveloper(async (ctx) => {
123039
+ getStudentOverview = requireGameManagementAccess(async (ctx) => {
122532
123040
  const timebackId = ctx.params.timebackId;
122533
123041
  const gameId = ctx.url.searchParams.get("gameId") || undefined;
122534
123042
  const courseId = ctx.url.searchParams.get("courseId") || undefined;
@@ -122543,7 +123051,7 @@ var init_timeback_controller = __esm(() => {
122543
123051
  });
122544
123052
  return ctx.services.timebackAdmin.getStudentOverview(gameId, timebackId, ctx.user, courseId);
122545
123053
  });
122546
- getStudentActivity = requireDeveloper(async (ctx) => {
123054
+ getStudentActivity = requireGameManagementAccess(async (ctx) => {
122547
123055
  const timebackId = ctx.params.timebackId;
122548
123056
  const courseId = ctx.params.courseId;
122549
123057
  const gameId = ctx.url.searchParams.get("gameId") || undefined;
@@ -122606,7 +123114,7 @@ var init_timeback_controller = __esm(() => {
122606
123114
  });
122607
123115
  return ctx.services.timebackAdmin.adjustMasteredUnits(body2, ctx.user);
122608
123116
  });
122609
- toggleCompletion = requireDeveloper(async (ctx) => {
123117
+ toggleCompletion = requireGameManagementAccess(async (ctx) => {
122610
123118
  const body2 = await parseRequestBody(ctx.request, ToggleCourseCompletionRequestSchema);
122611
123119
  logger63.debug("Toggling course completion", {
122612
123120
  requesterId: ctx.user.id,
@@ -122617,6 +123125,41 @@ var init_timeback_controller = __esm(() => {
122617
123125
  });
122618
123126
  return ctx.services.timebackAdmin.toggleCourseCompletion(body2, ctx.user);
122619
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
+ });
122620
123163
  timeback2 = {
122621
123164
  getTodayXp,
122622
123165
  getTotalXp,
@@ -122631,6 +123174,7 @@ var init_timeback_controller = __esm(() => {
122631
123174
  getConfig: getConfig2,
122632
123175
  deleteIntegrations,
122633
123176
  endActivity,
123177
+ heartbeat,
122634
123178
  getStudentXp,
122635
123179
  getRoster,
122636
123180
  getStudentOverview,
@@ -122638,7 +123182,10 @@ var init_timeback_controller = __esm(() => {
122638
123182
  grantXp,
122639
123183
  adjustTime,
122640
123184
  adjustMastery,
122641
- toggleCompletion
123185
+ toggleCompletion,
123186
+ searchStudents,
123187
+ enrollStudent,
123188
+ unenrollStudent
122642
123189
  };
122643
123190
  });
122644
123191
  var logger64;
@@ -123614,6 +124161,7 @@ var init_timeback6 = __esm(() => {
123614
124161
  timebackRouter.get("/config/:gameId", handle2(timeback2.getConfig));
123615
124162
  timebackRouter.delete("/integrations/:gameId", handle2(timeback2.deleteIntegrations, { status: 204 }));
123616
124163
  timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
124164
+ timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
123617
124165
  timebackRouter.get("/user", async (c2) => {
123618
124166
  const user = c2.get("user");
123619
124167
  const gameId = c2.get("gameId");
@@ -124009,6 +124557,203 @@ function printBanner(viteConfig, options) {
124009
124557
  import fs5 from "node:fs";
124010
124558
  import path3 from "node:path";
124011
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
124012
124757
  function extractTimebackCourses(config2, timebackOptions) {
124013
124758
  const courses = config2?.integrations?.timeback?.courses;
124014
124759
  if (!courses || courses.length === 0) {
@@ -124042,17 +124787,20 @@ async function extractProjectInfo(viteConfig, timebackOptions) {
124042
124787
  packageJson = JSON.parse(packageJsonContent);
124043
124788
  }
124044
124789
  } catch {}
124045
- const name2 = config2?.name || packageJson.name || "";
124046
- let slug = name2;
124047
- if (slug.includes("/")) {
124048
- 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;
124049
124794
  }
124050
- if (!slug) {
124051
- slug = directoryName;
124795
+ if (!slug2) {
124796
+ slug2 = directoryName;
124052
124797
  }
124053
- 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;
124054
124801
  return {
124055
- slug,
124802
+ gameId,
124803
+ slug: slug2,
124056
124804
  displayName,
124057
124805
  version: packageJson.version || "dev",
124058
124806
  description: packageJson.description,
@@ -124548,7 +125296,8 @@ var TIMEBACK_COMPONENT_RESOURCE_DEFAULTS2;
124548
125296
  var init_timeback7 = __esm7(() => {
124549
125297
  TIMEBACK_ROUTES2 = {
124550
125298
  END_ACTIVITY: "/integrations/timeback/end-activity",
124551
- GET_XP: "/integrations/timeback/xp"
125299
+ GET_XP: "/integrations/timeback/xp",
125300
+ HEARTBEAT: "/integrations/timeback/heartbeat"
124552
125301
  };
124553
125302
  TIMEBACK_COURSE_DEFAULTS2 = {
124554
125303
  gradingScheme: "STANDARD",
@@ -125086,7 +125835,7 @@ var DEBOUNCE_MS = 500;
125086
125835
  var VITE_CONFIG_NAMES = ["vite.config.ts", "vite.config.js", "vite.config.mjs"];
125087
125836
  var debounceTimer = null;
125088
125837
  function findExistingFiles(projectRoot, fileNames) {
125089
- 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));
125090
125839
  }
125091
125840
  function createChangeHandler(server, viteConfig, platformModeOptions, watchedFiles) {
125092
125841
  return async (changedPath) => {
@@ -125154,7 +125903,7 @@ function cyclePlatformRoleHotkey(options) {
125154
125903
 
125155
125904
  // src/server/hotkeys/cycle-timeback-role.ts
125156
125905
  var import_picocolors9 = __toESM(require_picocolors(), 1);
125157
- 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;
125158
125907
  function cycleTimebackRole(logger) {
125159
125908
  const currentRole = getTimebackRoleOverride() ?? "student";
125160
125909
  const currentIndex = TIMEBACK_ROLES.indexOf(currentRole);
@@ -125173,14 +125922,14 @@ function cycleTimebackRole(logger) {
125173
125922
  function cycleTimebackRoleHotkey(options) {
125174
125923
  return {
125175
125924
  key: "t",
125176
- description: `${cyan4(bold4("[playcademy]"))} cycle Timeback role`,
125925
+ description: `${cyan4(bold5("[playcademy]"))} cycle Timeback role`,
125177
125926
  action: () => cycleTimebackRole(options.viteConfig.logger)
125178
125927
  };
125179
125928
  }
125180
125929
 
125181
125930
  // src/server/hotkeys/recreate-database.ts
125182
125931
  var import_picocolors10 = __toESM(require_picocolors(), 1);
125183
- var { bold: bold5, cyan: cyan5 } = import_picocolors10.default;
125932
+ var { bold: bold6, cyan: cyan5 } = import_picocolors10.default;
125184
125933
  async function recreateSandboxDatabase(options) {
125185
125934
  await recreateSandbox({
125186
125935
  viteConfig: options.viteConfig,
@@ -125190,7 +125939,7 @@ async function recreateSandboxDatabase(options) {
125190
125939
  function recreateDatabaseHotkey(options) {
125191
125940
  return {
125192
125941
  key: "d",
125193
- description: `${cyan5(bold5("[playcademy]"))} recreate sandbox database`,
125942
+ description: `${cyan5(bold6("[playcademy]"))} recreate sandbox database`,
125194
125943
  action: () => recreateSandboxDatabase(options)
125195
125944
  };
125196
125945
  }
@@ -125200,7 +125949,7 @@ var import_picocolors12 = __toESM(require_picocolors(), 1);
125200
125949
  // package.json
125201
125950
  var package_default2 = {
125202
125951
  name: "@playcademy/vite-plugin",
125203
- version: "0.2.24-beta.1",
125952
+ version: "0.2.24-beta.10",
125204
125953
  type: "module",
125205
125954
  exports: {
125206
125955
  ".": {