@playcademy/sandbox 0.3.17-beta.1 → 0.3.17-beta.11
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/cli.js +1695 -1145
- package/dist/constants.js +3 -2
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1691 -1145
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -399,7 +399,8 @@ var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME =
|
|
|
399
399
|
var init_timeback2 = __esm(() => {
|
|
400
400
|
TIMEBACK_ROUTES = {
|
|
401
401
|
END_ACTIVITY: "/integrations/timeback/end-activity",
|
|
402
|
-
GET_XP: "/integrations/timeback/xp"
|
|
402
|
+
GET_XP: "/integrations/timeback/xp",
|
|
403
|
+
HEARTBEAT: "/integrations/timeback/heartbeat"
|
|
403
404
|
};
|
|
404
405
|
TIMEBACK_COURSE_DEFAULTS = {
|
|
405
406
|
gradingScheme: "STANDARD",
|
|
@@ -440,7 +441,7 @@ var init_timeback2 = __esm(() => {
|
|
|
440
441
|
});
|
|
441
442
|
|
|
442
443
|
// ../constants/src/workers.ts
|
|
443
|
-
var WORKER_NAMING, SECRETS_PREFIX = "secrets_";
|
|
444
|
+
var WORKER_NAMING, SECRETS_PREFIX = "secrets_", CLOUDFLARE_COMPATIBILITY_DATE = "2025-10-11";
|
|
444
445
|
var init_workers = __esm(() => {
|
|
445
446
|
WORKER_NAMING = {
|
|
446
447
|
STAGING_PREFIX: "staging-",
|
|
@@ -1310,7 +1311,7 @@ var package_default;
|
|
|
1310
1311
|
var init_package = __esm(() => {
|
|
1311
1312
|
package_default = {
|
|
1312
1313
|
name: "@playcademy/sandbox",
|
|
1313
|
-
version: "0.3.17-beta.
|
|
1314
|
+
version: "0.3.17-beta.11",
|
|
1314
1315
|
description: "Local development server for Playcademy game development",
|
|
1315
1316
|
type: "module",
|
|
1316
1317
|
exports: {
|
|
@@ -5833,6 +5834,7 @@ var init_esm = __esm(() => {
|
|
|
5833
5834
|
function createMinimalConfig(overrides) {
|
|
5834
5835
|
return apiConfigSchema.parse({
|
|
5835
5836
|
stage: "local",
|
|
5837
|
+
isLocal: false,
|
|
5836
5838
|
...overrides
|
|
5837
5839
|
});
|
|
5838
5840
|
}
|
|
@@ -5857,6 +5859,7 @@ var init_schema = __esm(() => {
|
|
|
5857
5859
|
});
|
|
5858
5860
|
apiConfigSchema = exports_external.object({
|
|
5859
5861
|
stage: stageSchema,
|
|
5862
|
+
isLocal: exports_external.boolean().default(false),
|
|
5860
5863
|
baseUrl: exports_external.string().url().optional(),
|
|
5861
5864
|
gameDomain: exports_external.string().optional(),
|
|
5862
5865
|
lti: ltiConfigSchema.optional(),
|
|
@@ -11550,7 +11553,7 @@ var init_table6 = __esm(() => {
|
|
|
11550
11553
|
init_drizzle_orm();
|
|
11551
11554
|
init_pg_core();
|
|
11552
11555
|
init_table5();
|
|
11553
|
-
userRoleEnum = pgEnum("user_role", ["admin", "player", "developer"]);
|
|
11556
|
+
userRoleEnum = pgEnum("user_role", ["admin", "player", "developer", "teacher"]);
|
|
11554
11557
|
developerStatusEnum = pgEnum("developer_status", ["none", "pending", "approved"]);
|
|
11555
11558
|
users = pgTable("user", {
|
|
11556
11559
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
@@ -23744,13 +23747,11 @@ var init_dedent = __esm(() => {
|
|
|
23744
23747
|
});
|
|
23745
23748
|
|
|
23746
23749
|
// ../cloudflare/src/core/namespaces/workers.ts
|
|
23747
|
-
var DEFAULT_COMPATIBILITY_DATE;
|
|
23748
23750
|
var init_workers2 = __esm(() => {
|
|
23749
23751
|
init_dedent();
|
|
23750
23752
|
init_src2();
|
|
23751
23753
|
init_assets();
|
|
23752
23754
|
init_multipart();
|
|
23753
|
-
DEFAULT_COMPATIBILITY_DATE = new Date().toISOString().slice(0, 10);
|
|
23754
23755
|
});
|
|
23755
23756
|
|
|
23756
23757
|
// ../cloudflare/src/core/namespaces/index.ts
|
|
@@ -23772,10 +23773,9 @@ var init_core = __esm(() => {
|
|
|
23772
23773
|
});
|
|
23773
23774
|
|
|
23774
23775
|
// ../cloudflare/src/playcademy/constants.ts
|
|
23775
|
-
var CUSTOM_DOMAINS_KV_NAME = "cademy-custom-domains", QUEUE_NAME_PREFIX = "playcademy",
|
|
23776
|
+
var CUSTOM_DOMAINS_KV_NAME = "cademy-custom-domains", QUEUE_NAME_PREFIX = "playcademy", GAME_WORKER_DOMAIN_PRODUCTION, GAME_WORKER_DOMAIN_STAGING;
|
|
23776
23777
|
var init_constants2 = __esm(() => {
|
|
23777
23778
|
init_src();
|
|
23778
|
-
DEFAULT_COMPATIBILITY_DATE2 = new Date().toISOString().slice(0, 10);
|
|
23779
23779
|
GAME_WORKER_DOMAIN_PRODUCTION = GAME_WORKER_DOMAINS.production;
|
|
23780
23780
|
GAME_WORKER_DOMAIN_STAGING = GAME_WORKER_DOMAINS.staging;
|
|
23781
23781
|
});
|
|
@@ -26479,6 +26479,8 @@ class DeployService {
|
|
|
26479
26479
|
try {
|
|
26480
26480
|
result = await this.timeStep("Cloudflare deploy", () => cf.deploy(deploymentId, request.code, env, {
|
|
26481
26481
|
...deploymentOptions,
|
|
26482
|
+
compatibilityDate: request.compatibilityDate ?? CLOUDFLARE_COMPATIBILITY_DATE,
|
|
26483
|
+
compatibilityFlags: request.compatibilityFlags,
|
|
26482
26484
|
existingResources: activeDeployment?.resources ?? undefined,
|
|
26483
26485
|
assetsPath: frontendAssetsPath,
|
|
26484
26486
|
keepAssets
|
|
@@ -26579,6 +26581,7 @@ var logger3;
|
|
|
26579
26581
|
var init_deploy_service = __esm(() => {
|
|
26580
26582
|
init_drizzle_orm();
|
|
26581
26583
|
init_playcademy();
|
|
26584
|
+
init_src();
|
|
26582
26585
|
init_tables_index();
|
|
26583
26586
|
init_src2();
|
|
26584
26587
|
init_config2();
|
|
@@ -26647,447 +26650,549 @@ var init_developer_service = __esm(() => {
|
|
|
26647
26650
|
logger4 = log.scope("DeveloperService");
|
|
26648
26651
|
});
|
|
26649
26652
|
|
|
26650
|
-
// ../
|
|
26651
|
-
|
|
26652
|
-
|
|
26653
|
-
|
|
26654
|
-
static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
|
|
26655
|
-
constructor(deps) {
|
|
26656
|
-
this.deps = deps;
|
|
26657
|
-
}
|
|
26658
|
-
static getManifestHost(manifestUrl) {
|
|
26659
|
-
try {
|
|
26660
|
-
return new URL(manifestUrl).host;
|
|
26661
|
-
} catch {
|
|
26662
|
-
return manifestUrl;
|
|
26663
|
-
}
|
|
26653
|
+
// ../utils/src/fns.ts
|
|
26654
|
+
function sleep(ms) {
|
|
26655
|
+
if (ms <= 0) {
|
|
26656
|
+
return Promise.resolve();
|
|
26664
26657
|
}
|
|
26665
|
-
|
|
26666
|
-
|
|
26667
|
-
|
|
26668
|
-
|
|
26669
|
-
|
|
26670
|
-
|
|
26671
|
-
|
|
26672
|
-
|
|
26673
|
-
|
|
26658
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26659
|
+
}
|
|
26660
|
+
|
|
26661
|
+
// ../api-core/src/services/game.service.ts
|
|
26662
|
+
var logger5, inFlightManifestFetches, GameService;
|
|
26663
|
+
var init_game_service = __esm(() => {
|
|
26664
|
+
init_drizzle_orm();
|
|
26665
|
+
init_tables_index();
|
|
26666
|
+
init_src2();
|
|
26667
|
+
init_errors();
|
|
26668
|
+
init_deployment_util();
|
|
26669
|
+
logger5 = log.scope("GameService");
|
|
26670
|
+
inFlightManifestFetches = new Map;
|
|
26671
|
+
GameService = class GameService {
|
|
26672
|
+
deps;
|
|
26673
|
+
static MANIFEST_FETCH_ATTEMPT_TIMEOUT_MS = 1e4;
|
|
26674
|
+
static MANIFEST_FETCH_MAX_RETRIES = 2;
|
|
26675
|
+
static MANIFEST_FETCH_RETRY_BACKOFF_MS = [250, 750];
|
|
26676
|
+
static MANIFEST_CACHE_TTL_SECONDS = 60;
|
|
26677
|
+
static MANIFEST_CACHE_KEY_PREFIX = "game:manifest";
|
|
26678
|
+
static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
|
|
26679
|
+
constructor(deps) {
|
|
26680
|
+
this.deps = deps;
|
|
26681
|
+
}
|
|
26682
|
+
static getManifestHost(manifestUrl) {
|
|
26683
|
+
try {
|
|
26684
|
+
return new URL(manifestUrl).host;
|
|
26685
|
+
} catch {
|
|
26686
|
+
return manifestUrl;
|
|
26687
|
+
}
|
|
26674
26688
|
}
|
|
26675
|
-
|
|
26676
|
-
|
|
26677
|
-
|
|
26689
|
+
static getFetchErrorMessage(error) {
|
|
26690
|
+
let raw;
|
|
26691
|
+
if (error instanceof Error) {
|
|
26692
|
+
raw = error.message;
|
|
26693
|
+
} else if (typeof error === "string") {
|
|
26694
|
+
raw = error;
|
|
26695
|
+
}
|
|
26696
|
+
if (!raw) {
|
|
26697
|
+
return;
|
|
26698
|
+
}
|
|
26699
|
+
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
26700
|
+
if (!normalized) {
|
|
26701
|
+
return;
|
|
26702
|
+
}
|
|
26703
|
+
return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
|
|
26678
26704
|
}
|
|
26679
|
-
|
|
26680
|
-
|
|
26681
|
-
static isRetryableStatus(status) {
|
|
26682
|
-
return status === 429 || status >= 500;
|
|
26683
|
-
}
|
|
26684
|
-
async list(caller) {
|
|
26685
|
-
const db2 = this.deps.db;
|
|
26686
|
-
const isAdmin = caller?.role === "admin";
|
|
26687
|
-
const isDeveloper = caller?.role === "developer";
|
|
26688
|
-
let whereClause;
|
|
26689
|
-
if (isAdmin) {
|
|
26690
|
-
whereClause = undefined;
|
|
26691
|
-
} else if (isDeveloper && caller?.id) {
|
|
26692
|
-
whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
|
|
26693
|
-
} else {
|
|
26694
|
-
whereClause = ne(games.visibility, "internal");
|
|
26705
|
+
static isRetryableStatus(status) {
|
|
26706
|
+
return status === 429 || status >= 500;
|
|
26695
26707
|
}
|
|
26696
|
-
|
|
26697
|
-
|
|
26698
|
-
|
|
26699
|
-
|
|
26700
|
-
}
|
|
26701
|
-
async listManageable(user) {
|
|
26702
|
-
this.validateDeveloperStatus(user);
|
|
26703
|
-
const db2 = this.deps.db;
|
|
26704
|
-
return db2.query.games.findMany({
|
|
26705
|
-
where: user.role === "admin" ? undefined : eq(games.developerId, user.id),
|
|
26706
|
-
orderBy: [desc(games.createdAt)]
|
|
26707
|
-
});
|
|
26708
|
-
}
|
|
26709
|
-
async getSubjects() {
|
|
26710
|
-
const db2 = this.deps.db;
|
|
26711
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
26712
|
-
columns: { gameId: true, subject: true },
|
|
26713
|
-
orderBy: [asc(gameTimebackIntegrations.createdAt)]
|
|
26714
|
-
});
|
|
26715
|
-
const subjectMap = {};
|
|
26716
|
-
for (const integration of integrations) {
|
|
26717
|
-
if (!(integration.gameId in subjectMap)) {
|
|
26718
|
-
subjectMap[integration.gameId] = integration.subject;
|
|
26708
|
+
static getRetryBackoffMs(attemptIndex) {
|
|
26709
|
+
const backoff = GameService.MANIFEST_FETCH_RETRY_BACKOFF_MS;
|
|
26710
|
+
if (backoff.length === 0) {
|
|
26711
|
+
return 0;
|
|
26719
26712
|
}
|
|
26713
|
+
return backoff[Math.min(attemptIndex, backoff.length - 1)] ?? 0;
|
|
26720
26714
|
}
|
|
26721
|
-
|
|
26722
|
-
|
|
26723
|
-
async getById(gameId, caller) {
|
|
26724
|
-
const db2 = this.deps.db;
|
|
26725
|
-
const game = await db2.query.games.findFirst({
|
|
26726
|
-
where: eq(games.id, gameId)
|
|
26727
|
-
});
|
|
26728
|
-
if (!game) {
|
|
26729
|
-
throw new NotFoundError("Game", gameId);
|
|
26715
|
+
static normalizeDeploymentUrl(deploymentUrl) {
|
|
26716
|
+
return deploymentUrl.replace(/\/$/, "");
|
|
26730
26717
|
}
|
|
26731
|
-
|
|
26732
|
-
|
|
26733
|
-
}
|
|
26734
|
-
async getBySlug(slug, caller) {
|
|
26735
|
-
const db2 = this.deps.db;
|
|
26736
|
-
const game = await db2.query.games.findFirst({
|
|
26737
|
-
where: eq(games.slug, slug)
|
|
26738
|
-
});
|
|
26739
|
-
if (!game) {
|
|
26740
|
-
throw new NotFoundError("Game", slug);
|
|
26741
|
-
}
|
|
26742
|
-
this.enforceVisibility(game, caller, slug);
|
|
26743
|
-
return game;
|
|
26744
|
-
}
|
|
26745
|
-
async getManifest(gameId, caller) {
|
|
26746
|
-
const game = await this.getById(gameId, caller);
|
|
26747
|
-
if (game.gameType !== "hosted" || !game.deploymentUrl) {
|
|
26748
|
-
throw new BadRequestError("Game does not have a deployment manifest");
|
|
26718
|
+
static getManifestCacheKey(deploymentUrl) {
|
|
26719
|
+
return `${GameService.MANIFEST_CACHE_KEY_PREFIX}:${deploymentUrl}`;
|
|
26749
26720
|
}
|
|
26750
|
-
|
|
26751
|
-
|
|
26752
|
-
|
|
26753
|
-
|
|
26754
|
-
|
|
26755
|
-
|
|
26756
|
-
|
|
26757
|
-
|
|
26758
|
-
|
|
26759
|
-
|
|
26760
|
-
|
|
26761
|
-
|
|
26762
|
-
|
|
26763
|
-
|
|
26764
|
-
|
|
26765
|
-
|
|
26766
|
-
};
|
|
26721
|
+
async list(caller) {
|
|
26722
|
+
const db2 = this.deps.db;
|
|
26723
|
+
const isAdmin = caller?.role === "admin";
|
|
26724
|
+
const isDeveloper = caller?.role === "developer";
|
|
26725
|
+
let whereClause;
|
|
26726
|
+
if (isAdmin) {
|
|
26727
|
+
whereClause = undefined;
|
|
26728
|
+
} else if (isDeveloper && caller?.id) {
|
|
26729
|
+
whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
|
|
26730
|
+
} else {
|
|
26731
|
+
whereClause = ne(games.visibility, "internal");
|
|
26732
|
+
}
|
|
26733
|
+
return db2.query.games.findMany({
|
|
26734
|
+
where: whereClause,
|
|
26735
|
+
orderBy: [desc(games.createdAt)]
|
|
26736
|
+
});
|
|
26767
26737
|
}
|
|
26768
|
-
|
|
26769
|
-
|
|
26770
|
-
|
|
26771
|
-
|
|
26772
|
-
|
|
26773
|
-
|
|
26774
|
-
|
|
26775
|
-
|
|
26738
|
+
async listManageable(user) {
|
|
26739
|
+
const seesAllGames = user.role === "admin" || user.role === "teacher";
|
|
26740
|
+
if (!seesAllGames) {
|
|
26741
|
+
this.validateDeveloperStatus(user);
|
|
26742
|
+
}
|
|
26743
|
+
const db2 = this.deps.db;
|
|
26744
|
+
return db2.query.games.findMany({
|
|
26745
|
+
where: seesAllGames ? undefined : eq(games.developerId, user.id),
|
|
26746
|
+
orderBy: [desc(games.createdAt)]
|
|
26776
26747
|
});
|
|
26777
|
-
}
|
|
26778
|
-
|
|
26779
|
-
const
|
|
26780
|
-
const
|
|
26781
|
-
|
|
26782
|
-
|
|
26783
|
-
manifestUrl,
|
|
26784
|
-
error,
|
|
26785
|
-
details
|
|
26748
|
+
}
|
|
26749
|
+
async getSubjects() {
|
|
26750
|
+
const db2 = this.deps.db;
|
|
26751
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
26752
|
+
columns: { gameId: true, subject: true },
|
|
26753
|
+
orderBy: [asc(gameTimebackIntegrations.createdAt)]
|
|
26786
26754
|
});
|
|
26787
|
-
|
|
26788
|
-
|
|
26755
|
+
const subjectMap = {};
|
|
26756
|
+
for (const integration of integrations) {
|
|
26757
|
+
if (!(integration.gameId in subjectMap)) {
|
|
26758
|
+
subjectMap[integration.gameId] = integration.subject;
|
|
26759
|
+
}
|
|
26789
26760
|
}
|
|
26790
|
-
|
|
26791
|
-
} finally {
|
|
26792
|
-
clearTimeout(timeout);
|
|
26761
|
+
return subjectMap;
|
|
26793
26762
|
}
|
|
26794
|
-
|
|
26795
|
-
const
|
|
26796
|
-
const
|
|
26797
|
-
|
|
26798
|
-
const details = buildDetails("bad_status", manifestErrorKind, {
|
|
26799
|
-
manifestUrl: resolvedManifestUrl,
|
|
26800
|
-
manifestHost: resolvedManifestHost,
|
|
26801
|
-
status: response.status,
|
|
26802
|
-
contentType: response.headers.get("content-type") ?? undefined,
|
|
26803
|
-
cfRay: response.headers.get("cf-ray") ?? undefined,
|
|
26804
|
-
redirected: response.redirected,
|
|
26805
|
-
...response.redirected ? {
|
|
26806
|
-
originalManifestUrl: manifestUrl,
|
|
26807
|
-
originalManifestHost: manifestHost
|
|
26808
|
-
} : {}
|
|
26809
|
-
});
|
|
26810
|
-
const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
|
|
26811
|
-
logger5.error("Game manifest returned non-ok response", {
|
|
26812
|
-
gameId,
|
|
26813
|
-
manifestUrl,
|
|
26814
|
-
status: response.status,
|
|
26815
|
-
details
|
|
26763
|
+
async getById(gameId, caller) {
|
|
26764
|
+
const db2 = this.deps.db;
|
|
26765
|
+
const game = await db2.query.games.findFirst({
|
|
26766
|
+
where: eq(games.id, gameId)
|
|
26816
26767
|
});
|
|
26817
|
-
if (
|
|
26818
|
-
throw new
|
|
26768
|
+
if (!game) {
|
|
26769
|
+
throw new NotFoundError("Game", gameId);
|
|
26819
26770
|
}
|
|
26820
|
-
|
|
26771
|
+
this.enforceVisibility(game, caller, gameId);
|
|
26772
|
+
return game;
|
|
26821
26773
|
}
|
|
26822
|
-
|
|
26823
|
-
|
|
26824
|
-
|
|
26825
|
-
|
|
26826
|
-
const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
|
|
26827
|
-
const details = buildDetails("invalid_body", "permanent", {
|
|
26828
|
-
manifestUrl: resolvedManifestUrl,
|
|
26829
|
-
manifestHost: resolvedManifestHost,
|
|
26830
|
-
status: response.status,
|
|
26831
|
-
contentType: response.headers.get("content-type") ?? undefined,
|
|
26832
|
-
cfRay: response.headers.get("cf-ray") ?? undefined,
|
|
26833
|
-
redirected: response.redirected,
|
|
26834
|
-
...response.redirected ? {
|
|
26835
|
-
originalManifestUrl: manifestUrl,
|
|
26836
|
-
originalManifestHost: manifestHost
|
|
26837
|
-
} : {}
|
|
26838
|
-
});
|
|
26839
|
-
logger5.error("Failed to parse game manifest", {
|
|
26840
|
-
gameId,
|
|
26841
|
-
manifestUrl,
|
|
26842
|
-
error,
|
|
26843
|
-
details
|
|
26774
|
+
async getBySlug(slug, caller) {
|
|
26775
|
+
const db2 = this.deps.db;
|
|
26776
|
+
const game = await db2.query.games.findFirst({
|
|
26777
|
+
where: eq(games.slug, slug)
|
|
26844
26778
|
});
|
|
26845
|
-
|
|
26846
|
-
|
|
26847
|
-
}
|
|
26848
|
-
enforceVisibility(game, caller, lookupIdentifier) {
|
|
26849
|
-
if (game.visibility !== "internal") {
|
|
26850
|
-
return;
|
|
26851
|
-
}
|
|
26852
|
-
const isAdmin = caller?.role === "admin";
|
|
26853
|
-
const isOwner = caller?.id != null && caller.id === game.developerId;
|
|
26854
|
-
if (!isAdmin && !isOwner) {
|
|
26855
|
-
throw new NotFoundError("Game", lookupIdentifier);
|
|
26856
|
-
}
|
|
26857
|
-
}
|
|
26858
|
-
async upsertBySlug(slug, data, user) {
|
|
26859
|
-
const db2 = this.deps.db;
|
|
26860
|
-
const existingGame = await db2.query.games.findFirst({
|
|
26861
|
-
where: eq(games.slug, slug)
|
|
26862
|
-
});
|
|
26863
|
-
const isUpdate = Boolean(existingGame);
|
|
26864
|
-
const gameId = existingGame?.id ?? crypto.randomUUID();
|
|
26865
|
-
if (isUpdate) {
|
|
26866
|
-
await this.validateDeveloperAccess(user, gameId);
|
|
26867
|
-
} else {
|
|
26868
|
-
this.validateDeveloperStatus(user);
|
|
26869
|
-
}
|
|
26870
|
-
const gameDataForDb = {
|
|
26871
|
-
displayName: data.displayName,
|
|
26872
|
-
platform: data.platform,
|
|
26873
|
-
metadata: data.metadata,
|
|
26874
|
-
mapElementId: data.mapElementId,
|
|
26875
|
-
gameType: data.gameType,
|
|
26876
|
-
...data.visibility && { visibility: data.visibility },
|
|
26877
|
-
externalUrl: data.externalUrl || null,
|
|
26878
|
-
updatedAt: new Date
|
|
26879
|
-
};
|
|
26880
|
-
let gameResponse;
|
|
26881
|
-
if (isUpdate) {
|
|
26882
|
-
const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
|
|
26883
|
-
if (!updatedGame) {
|
|
26884
|
-
logger5.error("Game update returned no rows", { gameId, slug });
|
|
26885
|
-
throw new InternalError("DB update failed to return result for existing game");
|
|
26886
|
-
}
|
|
26887
|
-
gameResponse = updatedGame;
|
|
26888
|
-
} else {
|
|
26889
|
-
const insertData = {
|
|
26890
|
-
...gameDataForDb,
|
|
26891
|
-
id: gameId,
|
|
26892
|
-
slug,
|
|
26893
|
-
developerId: user.id,
|
|
26894
|
-
metadata: data.metadata || {},
|
|
26895
|
-
version: data.gameType === "external" ? "external" : "",
|
|
26896
|
-
deploymentUrl: null,
|
|
26897
|
-
createdAt: new Date
|
|
26898
|
-
};
|
|
26899
|
-
const [createdGame] = await db2.insert(games).values(insertData).returning();
|
|
26900
|
-
if (!createdGame) {
|
|
26901
|
-
logger5.error("Game insert returned no rows", { slug, developerId: user.id });
|
|
26902
|
-
throw new InternalError("DB insert failed to return result for new game");
|
|
26779
|
+
if (!game) {
|
|
26780
|
+
throw new NotFoundError("Game", slug);
|
|
26903
26781
|
}
|
|
26904
|
-
|
|
26782
|
+
this.enforceVisibility(game, caller, slug);
|
|
26783
|
+
return game;
|
|
26905
26784
|
}
|
|
26906
|
-
|
|
26907
|
-
|
|
26908
|
-
|
|
26909
|
-
|
|
26910
|
-
|
|
26911
|
-
|
|
26912
|
-
|
|
26913
|
-
|
|
26914
|
-
|
|
26915
|
-
|
|
26785
|
+
async getManifest(gameId, caller) {
|
|
26786
|
+
const game = await this.getById(gameId, caller);
|
|
26787
|
+
if (game.gameType !== "hosted" || !game.deploymentUrl) {
|
|
26788
|
+
throw new BadRequestError("Game does not have a deployment manifest");
|
|
26789
|
+
}
|
|
26790
|
+
const deploymentUrl = GameService.normalizeDeploymentUrl(game.deploymentUrl);
|
|
26791
|
+
const cacheKey2 = GameService.getManifestCacheKey(deploymentUrl);
|
|
26792
|
+
const cached = await this.deps.cache.get(cacheKey2);
|
|
26793
|
+
if (cached) {
|
|
26794
|
+
return cached;
|
|
26795
|
+
}
|
|
26796
|
+
const inFlight = inFlightManifestFetches.get(deploymentUrl);
|
|
26797
|
+
if (inFlight) {
|
|
26798
|
+
return inFlight;
|
|
26799
|
+
}
|
|
26800
|
+
const promise = this.fetchManifestFromOrigin({ gameId, deploymentUrl }).then(async (manifest) => {
|
|
26801
|
+
try {
|
|
26802
|
+
await this.deps.cache.set(cacheKey2, manifest, GameService.MANIFEST_CACHE_TTL_SECONDS);
|
|
26803
|
+
} catch (cacheError) {
|
|
26804
|
+
logger5.warn("Failed to cache game manifest", {
|
|
26805
|
+
gameId,
|
|
26806
|
+
deploymentUrl,
|
|
26807
|
+
cacheKey: cacheKey2,
|
|
26808
|
+
error: cacheError
|
|
26809
|
+
});
|
|
26810
|
+
}
|
|
26811
|
+
return manifest;
|
|
26812
|
+
}).finally(() => {
|
|
26813
|
+
inFlightManifestFetches.delete(deploymentUrl);
|
|
26814
|
+
});
|
|
26815
|
+
inFlightManifestFetches.set(deploymentUrl, promise);
|
|
26816
|
+
return promise;
|
|
26817
|
+
}
|
|
26818
|
+
async fetchManifestFromOrigin(args2) {
|
|
26819
|
+
const { gameId, deploymentUrl } = args2;
|
|
26820
|
+
const manifestUrl = `${deploymentUrl}/playcademy.manifest.json`;
|
|
26821
|
+
const manifestHost = GameService.getManifestHost(manifestUrl);
|
|
26822
|
+
const startedAt = Date.now();
|
|
26823
|
+
const maxAttempts = GameService.MANIFEST_FETCH_MAX_RETRIES + 1;
|
|
26824
|
+
for (let attempt = 0;attempt < maxAttempts; attempt++) {
|
|
26825
|
+
const isLastAttempt = attempt === maxAttempts - 1;
|
|
26826
|
+
const outcome = await this.attemptManifestFetch({
|
|
26827
|
+
manifestUrl,
|
|
26828
|
+
manifestHost,
|
|
26829
|
+
deploymentUrl,
|
|
26830
|
+
startedAt,
|
|
26831
|
+
retryCount: attempt
|
|
26916
26832
|
});
|
|
26833
|
+
if (outcome.kind === "success") {
|
|
26834
|
+
return outcome.manifest;
|
|
26835
|
+
}
|
|
26836
|
+
if (!outcome.retryable || isLastAttempt) {
|
|
26837
|
+
logger5.error("Failed to fetch game manifest", {
|
|
26838
|
+
gameId,
|
|
26839
|
+
manifestUrl,
|
|
26840
|
+
attempt: attempt + 1,
|
|
26841
|
+
maxAttempts,
|
|
26842
|
+
retryable: outcome.retryable,
|
|
26843
|
+
details: outcome.details,
|
|
26844
|
+
throwable: outcome.throwable,
|
|
26845
|
+
cause: outcome.cause
|
|
26846
|
+
});
|
|
26847
|
+
throw outcome.throwable;
|
|
26848
|
+
}
|
|
26849
|
+
const backoffMs = GameService.getRetryBackoffMs(attempt);
|
|
26850
|
+
logger5.warn("Retrying game manifest fetch after transient failure", {
|
|
26851
|
+
gameId,
|
|
26852
|
+
manifestUrl,
|
|
26853
|
+
attempt: attempt + 1,
|
|
26854
|
+
maxAttempts,
|
|
26855
|
+
backoffMs,
|
|
26856
|
+
details: outcome.details,
|
|
26857
|
+
cause: outcome.cause
|
|
26858
|
+
});
|
|
26859
|
+
await sleep(backoffMs);
|
|
26917
26860
|
}
|
|
26861
|
+
throw new InternalError("Exhausted manifest fetch retries without result");
|
|
26918
26862
|
}
|
|
26919
|
-
|
|
26920
|
-
|
|
26921
|
-
|
|
26922
|
-
|
|
26923
|
-
|
|
26924
|
-
|
|
26925
|
-
|
|
26926
|
-
|
|
26927
|
-
|
|
26928
|
-
|
|
26929
|
-
|
|
26930
|
-
|
|
26931
|
-
|
|
26932
|
-
|
|
26933
|
-
|
|
26934
|
-
|
|
26935
|
-
|
|
26936
|
-
}
|
|
26937
|
-
const activeDeployment = await db2.query.gameDeployments.findFirst({
|
|
26938
|
-
where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
|
|
26939
|
-
columns: { deploymentId: true, provider: true, resources: true }
|
|
26940
|
-
});
|
|
26941
|
-
const customHostnames = await db2.select({
|
|
26942
|
-
hostname: gameCustomHostnames.hostname,
|
|
26943
|
-
cloudflareId: gameCustomHostnames.cloudflareId,
|
|
26944
|
-
environment: gameCustomHostnames.environment
|
|
26945
|
-
}).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
|
|
26946
|
-
const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
|
|
26947
|
-
if (result.length === 0) {
|
|
26948
|
-
throw new NotFoundError("Game", gameId);
|
|
26949
|
-
}
|
|
26950
|
-
logger5.info("Deleted game", {
|
|
26951
|
-
gameId: result[0].id,
|
|
26952
|
-
slug: gameToDelete.slug,
|
|
26953
|
-
hadActiveDeployment: Boolean(activeDeployment),
|
|
26954
|
-
customDomainsCount: customHostnames.length
|
|
26955
|
-
});
|
|
26956
|
-
this.deps.alerts.notifyGameDeletion({
|
|
26957
|
-
slug: gameToDelete.slug,
|
|
26958
|
-
displayName: gameToDelete.displayName,
|
|
26959
|
-
developer: { id: user.id, email: user.email }
|
|
26960
|
-
}).catch((error) => {
|
|
26961
|
-
logger5.warn("Failed to send deletion alert", { error });
|
|
26962
|
-
});
|
|
26963
|
-
if (activeDeployment?.provider === "cloudflare" && this.deps.cloudflare) {
|
|
26863
|
+
async attemptManifestFetch(args2) {
|
|
26864
|
+
const { manifestUrl, manifestHost, deploymentUrl, startedAt, retryCount } = args2;
|
|
26865
|
+
function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
|
|
26866
|
+
return {
|
|
26867
|
+
manifestUrl,
|
|
26868
|
+
manifestHost,
|
|
26869
|
+
deploymentUrl,
|
|
26870
|
+
fetchOutcome,
|
|
26871
|
+
retryCount,
|
|
26872
|
+
durationMs: Date.now() - startedAt,
|
|
26873
|
+
manifestErrorKind,
|
|
26874
|
+
...extra
|
|
26875
|
+
};
|
|
26876
|
+
}
|
|
26877
|
+
const controller = new AbortController;
|
|
26878
|
+
const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_ATTEMPT_TIMEOUT_MS);
|
|
26879
|
+
let response;
|
|
26964
26880
|
try {
|
|
26965
|
-
await
|
|
26966
|
-
|
|
26967
|
-
|
|
26968
|
-
|
|
26969
|
-
|
|
26970
|
-
|
|
26971
|
-
logger5.info("Cleaned up Cloudflare resources", {
|
|
26972
|
-
gameId,
|
|
26973
|
-
deploymentId: activeDeployment.deploymentId,
|
|
26974
|
-
customDomainsDeleted: customHostnames.length
|
|
26881
|
+
response = await fetch(manifestUrl, {
|
|
26882
|
+
method: "GET",
|
|
26883
|
+
headers: {
|
|
26884
|
+
Accept: "application/json"
|
|
26885
|
+
},
|
|
26886
|
+
signal: controller.signal
|
|
26975
26887
|
});
|
|
26976
|
-
} catch (
|
|
26977
|
-
|
|
26978
|
-
|
|
26979
|
-
|
|
26980
|
-
|
|
26888
|
+
} catch (error) {
|
|
26889
|
+
const fetchErrorMessage = GameService.getFetchErrorMessage(error);
|
|
26890
|
+
const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
|
|
26891
|
+
const throwable = error instanceof Error && error.name === "AbortError" ? new TimeoutError("Timed out loading game manifest", details) : new ServiceUnavailableError("Failed to load game manifest", details);
|
|
26892
|
+
return { kind: "failure", retryable: true, throwable, details, cause: error };
|
|
26893
|
+
} finally {
|
|
26894
|
+
clearTimeout(timeout);
|
|
26895
|
+
}
|
|
26896
|
+
if (!response.ok) {
|
|
26897
|
+
const resolvedManifestUrl = response.url || manifestUrl;
|
|
26898
|
+
const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
|
|
26899
|
+
const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
|
|
26900
|
+
const details = buildDetails("bad_status", manifestErrorKind, {
|
|
26901
|
+
manifestUrl: resolvedManifestUrl,
|
|
26902
|
+
manifestHost: resolvedManifestHost,
|
|
26903
|
+
status: response.status,
|
|
26904
|
+
contentType: response.headers.get("content-type") ?? undefined,
|
|
26905
|
+
cfRay: response.headers.get("cf-ray") ?? undefined,
|
|
26906
|
+
redirected: response.redirected,
|
|
26907
|
+
...response.redirected ? {
|
|
26908
|
+
originalManifestUrl: manifestUrl,
|
|
26909
|
+
originalManifestHost: manifestHost
|
|
26910
|
+
} : {}
|
|
26981
26911
|
});
|
|
26912
|
+
const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
|
|
26913
|
+
const throwable = manifestErrorKind === "temporary" ? new ServiceUnavailableError(message, details) : new BadRequestError(message, details);
|
|
26914
|
+
return {
|
|
26915
|
+
kind: "failure",
|
|
26916
|
+
retryable: manifestErrorKind === "temporary",
|
|
26917
|
+
throwable,
|
|
26918
|
+
details
|
|
26919
|
+
};
|
|
26982
26920
|
}
|
|
26983
26921
|
try {
|
|
26984
|
-
const
|
|
26985
|
-
|
|
26986
|
-
|
|
26987
|
-
|
|
26988
|
-
|
|
26989
|
-
|
|
26922
|
+
const manifest = await response.json();
|
|
26923
|
+
return { kind: "success", manifest };
|
|
26924
|
+
} catch (error) {
|
|
26925
|
+
const resolvedManifestUrl = response.url || manifestUrl;
|
|
26926
|
+
const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
|
|
26927
|
+
const details = buildDetails("invalid_body", "permanent", {
|
|
26928
|
+
manifestUrl: resolvedManifestUrl,
|
|
26929
|
+
manifestHost: resolvedManifestHost,
|
|
26930
|
+
status: response.status,
|
|
26931
|
+
contentType: response.headers.get("content-type") ?? undefined,
|
|
26932
|
+
cfRay: response.headers.get("cf-ray") ?? undefined,
|
|
26933
|
+
redirected: response.redirected,
|
|
26934
|
+
...response.redirected ? {
|
|
26935
|
+
originalManifestUrl: manifestUrl,
|
|
26936
|
+
originalManifestHost: manifestHost
|
|
26937
|
+
} : {}
|
|
26938
|
+
});
|
|
26939
|
+
return {
|
|
26940
|
+
kind: "failure",
|
|
26941
|
+
retryable: false,
|
|
26942
|
+
throwable: new BadRequestError("Failed to parse game manifest", details),
|
|
26943
|
+
details,
|
|
26944
|
+
cause: error
|
|
26945
|
+
};
|
|
26946
|
+
}
|
|
26947
|
+
}
|
|
26948
|
+
enforceVisibility(game, caller, lookupIdentifier) {
|
|
26949
|
+
if (game.visibility !== "internal") {
|
|
26950
|
+
return;
|
|
26951
|
+
}
|
|
26952
|
+
const isAdmin = caller?.role === "admin";
|
|
26953
|
+
const isOwner = caller?.id != null && caller.id === game.developerId;
|
|
26954
|
+
if (!isAdmin && !isOwner) {
|
|
26955
|
+
throw new NotFoundError("Game", lookupIdentifier);
|
|
26956
|
+
}
|
|
26957
|
+
}
|
|
26958
|
+
async upsertBySlug(slug, data, user) {
|
|
26959
|
+
const db2 = this.deps.db;
|
|
26960
|
+
const existingGame = await db2.query.games.findFirst({
|
|
26961
|
+
where: eq(games.slug, slug)
|
|
26962
|
+
});
|
|
26963
|
+
const isUpdate = Boolean(existingGame);
|
|
26964
|
+
const gameId = existingGame?.id ?? crypto.randomUUID();
|
|
26965
|
+
if (isUpdate) {
|
|
26966
|
+
await this.validateDeveloperAccess(user, gameId);
|
|
26967
|
+
} else {
|
|
26968
|
+
this.validateDeveloperStatus(user);
|
|
26969
|
+
}
|
|
26970
|
+
const gameDataForDb = {
|
|
26971
|
+
displayName: data.displayName,
|
|
26972
|
+
platform: data.platform,
|
|
26973
|
+
metadata: data.metadata,
|
|
26974
|
+
mapElementId: data.mapElementId,
|
|
26975
|
+
gameType: data.gameType,
|
|
26976
|
+
...data.visibility && { visibility: data.visibility },
|
|
26977
|
+
externalUrl: data.externalUrl || null,
|
|
26978
|
+
updatedAt: new Date
|
|
26979
|
+
};
|
|
26980
|
+
let gameResponse;
|
|
26981
|
+
if (isUpdate) {
|
|
26982
|
+
const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
|
|
26983
|
+
if (!updatedGame) {
|
|
26984
|
+
logger5.error("Game update returned no rows", { gameId, slug });
|
|
26985
|
+
throw new InternalError("DB update failed to return result for existing game");
|
|
26986
|
+
}
|
|
26987
|
+
gameResponse = updatedGame;
|
|
26988
|
+
} else {
|
|
26989
|
+
const insertData = {
|
|
26990
|
+
...gameDataForDb,
|
|
26991
|
+
id: gameId,
|
|
26992
|
+
slug,
|
|
26993
|
+
developerId: user.id,
|
|
26994
|
+
metadata: data.metadata || {},
|
|
26995
|
+
version: data.gameType === "external" ? "external" : "",
|
|
26996
|
+
deploymentUrl: null,
|
|
26997
|
+
createdAt: new Date
|
|
26998
|
+
};
|
|
26999
|
+
const [createdGame] = await db2.insert(games).values(insertData).returning();
|
|
27000
|
+
if (!createdGame) {
|
|
27001
|
+
logger5.error("Game insert returned no rows", { slug, developerId: user.id });
|
|
27002
|
+
throw new InternalError("DB insert failed to return result for new game");
|
|
27003
|
+
}
|
|
27004
|
+
gameResponse = createdGame;
|
|
27005
|
+
}
|
|
27006
|
+
if (data.mapElementId) {
|
|
27007
|
+
try {
|
|
27008
|
+
await db2.update(mapElements).set({
|
|
27009
|
+
interactionType: "game_entry",
|
|
27010
|
+
gameId: gameResponse.id
|
|
27011
|
+
}).where(eq(mapElements.id, data.mapElementId));
|
|
27012
|
+
} catch (mapError) {
|
|
27013
|
+
logger5.warn("Failed to update map element", {
|
|
27014
|
+
mapElementId: data.mapElementId,
|
|
27015
|
+
error: mapError
|
|
26990
27016
|
});
|
|
26991
27017
|
}
|
|
26992
|
-
} catch (keyError) {
|
|
26993
|
-
logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
|
|
26994
27018
|
}
|
|
27019
|
+
logger5.info("Upserted game", {
|
|
27020
|
+
gameId: gameResponse.id,
|
|
27021
|
+
slug: gameResponse.slug,
|
|
27022
|
+
operation: isUpdate ? "update" : "create",
|
|
27023
|
+
displayName: gameResponse.displayName
|
|
27024
|
+
});
|
|
27025
|
+
return gameResponse;
|
|
26995
27026
|
}
|
|
26996
|
-
|
|
26997
|
-
|
|
26998
|
-
|
|
26999
|
-
|
|
27000
|
-
}
|
|
27001
|
-
async validateOwnership(user, gameId) {
|
|
27002
|
-
if (user.role === "admin") {
|
|
27003
|
-
const gameExists = await this.deps.db.query.games.findFirst({
|
|
27027
|
+
async delete(gameId, user) {
|
|
27028
|
+
await this.validateDeveloperAccess(user, gameId);
|
|
27029
|
+
const db2 = this.deps.db;
|
|
27030
|
+
const gameToDelete = await db2.query.games.findFirst({
|
|
27004
27031
|
where: eq(games.id, gameId),
|
|
27005
|
-
columns: { id: true }
|
|
27032
|
+
columns: { id: true, slug: true, displayName: true }
|
|
27006
27033
|
});
|
|
27007
|
-
if (!
|
|
27034
|
+
if (!gameToDelete?.slug) {
|
|
27008
27035
|
throw new NotFoundError("Game", gameId);
|
|
27009
27036
|
}
|
|
27010
|
-
|
|
27037
|
+
const activeDeployment = await db2.query.gameDeployments.findFirst({
|
|
27038
|
+
where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
|
|
27039
|
+
columns: { deploymentId: true, provider: true, resources: true }
|
|
27040
|
+
});
|
|
27041
|
+
const customHostnames = await db2.select({
|
|
27042
|
+
hostname: gameCustomHostnames.hostname,
|
|
27043
|
+
cloudflareId: gameCustomHostnames.cloudflareId,
|
|
27044
|
+
environment: gameCustomHostnames.environment
|
|
27045
|
+
}).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
|
|
27046
|
+
const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
|
|
27047
|
+
if (result.length === 0) {
|
|
27048
|
+
throw new NotFoundError("Game", gameId);
|
|
27049
|
+
}
|
|
27050
|
+
logger5.info("Deleted game", {
|
|
27051
|
+
gameId: result[0].id,
|
|
27052
|
+
slug: gameToDelete.slug,
|
|
27053
|
+
hadActiveDeployment: Boolean(activeDeployment),
|
|
27054
|
+
customDomainsCount: customHostnames.length
|
|
27055
|
+
});
|
|
27056
|
+
this.deps.alerts.notifyGameDeletion({
|
|
27057
|
+
slug: gameToDelete.slug,
|
|
27058
|
+
displayName: gameToDelete.displayName,
|
|
27059
|
+
developer: { id: user.id, email: user.email }
|
|
27060
|
+
}).catch((error) => {
|
|
27061
|
+
logger5.warn("Failed to send deletion alert", { error });
|
|
27062
|
+
});
|
|
27063
|
+
if (activeDeployment?.provider === "cloudflare" && this.deps.cloudflare) {
|
|
27064
|
+
try {
|
|
27065
|
+
await this.deps.cloudflare.delete(activeDeployment.deploymentId, {
|
|
27066
|
+
deleteBindings: true,
|
|
27067
|
+
resources: activeDeployment.resources ?? undefined,
|
|
27068
|
+
customDomains: customHostnames.length > 0 ? customHostnames : undefined,
|
|
27069
|
+
gameSlug: gameToDelete.slug
|
|
27070
|
+
});
|
|
27071
|
+
logger5.info("Cleaned up Cloudflare resources", {
|
|
27072
|
+
gameId,
|
|
27073
|
+
deploymentId: activeDeployment.deploymentId,
|
|
27074
|
+
customDomainsDeleted: customHostnames.length
|
|
27075
|
+
});
|
|
27076
|
+
} catch (cfError) {
|
|
27077
|
+
logger5.warn("Failed to cleanup Cloudflare resources", {
|
|
27078
|
+
gameId,
|
|
27079
|
+
deploymentId: activeDeployment.deploymentId,
|
|
27080
|
+
error: cfError
|
|
27081
|
+
});
|
|
27082
|
+
}
|
|
27083
|
+
try {
|
|
27084
|
+
const deletedKeyId = await this.deps.deleteApiKeyByName(getGameWorkerApiKeyName(gameToDelete.slug), user.id);
|
|
27085
|
+
if (deletedKeyId) {
|
|
27086
|
+
logger5.info("Cleaned up API key for deleted game", {
|
|
27087
|
+
gameId,
|
|
27088
|
+
slug: gameToDelete.slug,
|
|
27089
|
+
keyId: deletedKeyId
|
|
27090
|
+
});
|
|
27091
|
+
}
|
|
27092
|
+
} catch (keyError) {
|
|
27093
|
+
logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
|
|
27094
|
+
}
|
|
27095
|
+
}
|
|
27096
|
+
return {
|
|
27097
|
+
slug: gameToDelete.slug,
|
|
27098
|
+
displayName: gameToDelete.displayName
|
|
27099
|
+
};
|
|
27011
27100
|
}
|
|
27012
|
-
|
|
27013
|
-
|
|
27014
|
-
|
|
27015
|
-
|
|
27016
|
-
|
|
27017
|
-
|
|
27018
|
-
|
|
27019
|
-
|
|
27101
|
+
async validateOwnership(user, gameId) {
|
|
27102
|
+
if (user.role === "admin") {
|
|
27103
|
+
const gameExists = await this.deps.db.query.games.findFirst({
|
|
27104
|
+
where: eq(games.id, gameId),
|
|
27105
|
+
columns: { id: true }
|
|
27106
|
+
});
|
|
27107
|
+
if (!gameExists) {
|
|
27108
|
+
throw new NotFoundError("Game", gameId);
|
|
27109
|
+
}
|
|
27110
|
+
return;
|
|
27111
|
+
}
|
|
27112
|
+
const db2 = this.deps.db;
|
|
27113
|
+
const gameOwnership = await db2.query.games.findFirst({
|
|
27114
|
+
where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
|
|
27020
27115
|
columns: { id: true }
|
|
27021
27116
|
});
|
|
27022
|
-
if (!
|
|
27023
|
-
|
|
27117
|
+
if (!gameOwnership) {
|
|
27118
|
+
const gameExists = await db2.query.games.findFirst({
|
|
27119
|
+
where: eq(games.id, gameId),
|
|
27120
|
+
columns: { id: true }
|
|
27121
|
+
});
|
|
27122
|
+
if (!gameExists) {
|
|
27123
|
+
throw new NotFoundError("Game", gameId);
|
|
27124
|
+
}
|
|
27125
|
+
throw new AccessDeniedError("You do not own this game");
|
|
27024
27126
|
}
|
|
27025
|
-
throw new AccessDeniedError("You do not own this game");
|
|
27026
27127
|
}
|
|
27027
|
-
|
|
27028
|
-
|
|
27029
|
-
|
|
27030
|
-
|
|
27031
|
-
|
|
27032
|
-
|
|
27128
|
+
async validateDeveloperAccess(user, gameId) {
|
|
27129
|
+
this.validateDeveloperStatus(user);
|
|
27130
|
+
if (user.role === "admin") {
|
|
27131
|
+
const gameExists = await this.deps.db.query.games.findFirst({
|
|
27132
|
+
where: eq(games.id, gameId),
|
|
27133
|
+
columns: { id: true }
|
|
27134
|
+
});
|
|
27135
|
+
if (!gameExists) {
|
|
27136
|
+
throw new NotFoundError("Game", gameId);
|
|
27137
|
+
}
|
|
27138
|
+
return;
|
|
27139
|
+
}
|
|
27140
|
+
const db2 = this.deps.db;
|
|
27141
|
+
const existingGame = await db2.query.games.findFirst({
|
|
27142
|
+
where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
|
|
27033
27143
|
columns: { id: true }
|
|
27034
27144
|
});
|
|
27035
|
-
if (!
|
|
27145
|
+
if (!existingGame) {
|
|
27036
27146
|
throw new NotFoundError("Game", gameId);
|
|
27037
27147
|
}
|
|
27038
|
-
return;
|
|
27039
27148
|
}
|
|
27040
|
-
|
|
27041
|
-
|
|
27042
|
-
|
|
27043
|
-
|
|
27044
|
-
|
|
27045
|
-
|
|
27046
|
-
|
|
27149
|
+
async validateGameManagementAccess(user, gameId) {
|
|
27150
|
+
if (user.role === "admin" || user.role === "teacher") {
|
|
27151
|
+
const gameExists = await this.deps.db.query.games.findFirst({
|
|
27152
|
+
where: eq(games.id, gameId),
|
|
27153
|
+
columns: { id: true }
|
|
27154
|
+
});
|
|
27155
|
+
if (!gameExists) {
|
|
27156
|
+
throw new NotFoundError("Game", gameId);
|
|
27157
|
+
}
|
|
27158
|
+
return;
|
|
27159
|
+
}
|
|
27160
|
+
return this.validateDeveloperAccess(user, gameId);
|
|
27047
27161
|
}
|
|
27048
|
-
|
|
27049
|
-
|
|
27050
|
-
|
|
27051
|
-
|
|
27052
|
-
|
|
27053
|
-
|
|
27054
|
-
|
|
27162
|
+
async validateDeveloperAccessBySlug(user, slug) {
|
|
27163
|
+
this.validateDeveloperStatus(user);
|
|
27164
|
+
const db2 = this.deps.db;
|
|
27165
|
+
if (user.role === "admin") {
|
|
27166
|
+
const game2 = await db2.query.games.findFirst({
|
|
27167
|
+
where: eq(games.slug, slug)
|
|
27168
|
+
});
|
|
27169
|
+
if (!game2) {
|
|
27170
|
+
throw new NotFoundError("Game", slug);
|
|
27171
|
+
}
|
|
27172
|
+
return game2;
|
|
27173
|
+
}
|
|
27174
|
+
const game = await db2.query.games.findFirst({
|
|
27175
|
+
where: and(eq(games.slug, slug), eq(games.developerId, user.id))
|
|
27055
27176
|
});
|
|
27056
|
-
if (!
|
|
27177
|
+
if (!game) {
|
|
27057
27178
|
throw new NotFoundError("Game", slug);
|
|
27058
27179
|
}
|
|
27059
|
-
return
|
|
27180
|
+
return game;
|
|
27060
27181
|
}
|
|
27061
|
-
|
|
27062
|
-
|
|
27063
|
-
|
|
27064
|
-
|
|
27065
|
-
|
|
27066
|
-
|
|
27067
|
-
|
|
27068
|
-
|
|
27069
|
-
|
|
27070
|
-
|
|
27071
|
-
|
|
27072
|
-
}
|
|
27073
|
-
if (user.developerStatus !== "approved") {
|
|
27074
|
-
const status = user.developerStatus || "none";
|
|
27075
|
-
if (status === "pending") {
|
|
27076
|
-
throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
|
|
27077
|
-
} else {
|
|
27078
|
-
throw new AccessDeniedError("Developer status required. Apply for developer access to create and manage games.");
|
|
27182
|
+
validateDeveloperStatus(user) {
|
|
27183
|
+
if (user.role === "admin") {
|
|
27184
|
+
return;
|
|
27185
|
+
}
|
|
27186
|
+
if (user.developerStatus !== "approved") {
|
|
27187
|
+
const status = user.developerStatus || "none";
|
|
27188
|
+
if (status === "pending") {
|
|
27189
|
+
throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
|
|
27190
|
+
} else {
|
|
27191
|
+
throw new AccessDeniedError("Developer status required. Apply for developer access to create and manage games.");
|
|
27192
|
+
}
|
|
27079
27193
|
}
|
|
27080
27194
|
}
|
|
27081
|
-
}
|
|
27082
|
-
}
|
|
27083
|
-
var logger5;
|
|
27084
|
-
var init_game_service = __esm(() => {
|
|
27085
|
-
init_drizzle_orm();
|
|
27086
|
-
init_tables_index();
|
|
27087
|
-
init_src2();
|
|
27088
|
-
init_errors();
|
|
27089
|
-
init_deployment_util();
|
|
27090
|
-
logger5 = log.scope("GameService");
|
|
27195
|
+
};
|
|
27091
27196
|
});
|
|
27092
27197
|
|
|
27093
27198
|
// ../api-core/src/services/factory/game.ts
|
|
@@ -27096,6 +27201,7 @@ function createGameServices(deps) {
|
|
|
27096
27201
|
const game = new GameService({
|
|
27097
27202
|
db: db2,
|
|
27098
27203
|
alerts,
|
|
27204
|
+
cache,
|
|
27099
27205
|
cloudflare,
|
|
27100
27206
|
deleteApiKeyByName: auth2.deleteApiKeyByName.bind(auth2)
|
|
27101
27207
|
});
|
|
@@ -27123,6 +27229,7 @@ function createGameServices(deps) {
|
|
|
27123
27229
|
validators: {
|
|
27124
27230
|
validateDeveloperAccessBySlug: (user, slug) => game.validateDeveloperAccessBySlug(user, slug),
|
|
27125
27231
|
validateDeveloperAccess: (user, gameId) => game.validateDeveloperAccess(user, gameId),
|
|
27232
|
+
validateGameManagementAccess: (user, gameId) => game.validateGameManagementAccess(user, gameId),
|
|
27126
27233
|
validateOwnership: (user, gameId) => game.validateOwnership(user, gameId)
|
|
27127
27234
|
}
|
|
27128
27235
|
};
|
|
@@ -28773,11 +28880,11 @@ var init_constants3 = __esm(() => {
|
|
|
28773
28880
|
HEALTH: "/api/health",
|
|
28774
28881
|
TIMEBACK: {
|
|
28775
28882
|
END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
|
|
28776
|
-
GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}
|
|
28883
|
+
GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
|
|
28884
|
+
HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`
|
|
28777
28885
|
}
|
|
28778
28886
|
};
|
|
28779
28887
|
});
|
|
28780
|
-
|
|
28781
28888
|
// ../edge-play/src/entry/setup.ts
|
|
28782
28889
|
function prefixSecrets(secrets) {
|
|
28783
28890
|
const prefixed = {};
|
|
@@ -28914,7 +29021,8 @@ class SeedService {
|
|
|
28914
29021
|
PLAYCADEMY_BASE_URL: ""
|
|
28915
29022
|
}, {
|
|
28916
29023
|
bindings: { d1: [deploymentId], r2: [], kv: [] },
|
|
28917
|
-
keepAssets: false
|
|
29024
|
+
keepAssets: false,
|
|
29025
|
+
compatibilityDate: CLOUDFLARE_COMPATIBILITY_DATE
|
|
28918
29026
|
});
|
|
28919
29027
|
logger14.info("Worker deployed", { seedDeploymentId, url: result.url });
|
|
28920
29028
|
if (secrets && Object.keys(secrets).length > 0) {
|
|
@@ -29044,6 +29152,7 @@ class SeedService {
|
|
|
29044
29152
|
}
|
|
29045
29153
|
var logger14;
|
|
29046
29154
|
var init_seed_service = __esm(() => {
|
|
29155
|
+
init_src();
|
|
29047
29156
|
init_setup2();
|
|
29048
29157
|
init_src2();
|
|
29049
29158
|
init_config2();
|
|
@@ -30200,6 +30309,38 @@ var init_src4 = __esm(() => {
|
|
|
30200
30309
|
init_pure();
|
|
30201
30310
|
});
|
|
30202
30311
|
|
|
30312
|
+
// ../api-core/src/utils/timeback-admin.util.ts
|
|
30313
|
+
function toAttributionEventTime(date3) {
|
|
30314
|
+
if (!date3) {
|
|
30315
|
+
return;
|
|
30316
|
+
}
|
|
30317
|
+
const match = date3.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
30318
|
+
if (!match) {
|
|
30319
|
+
throw new ValidationError("Date must be in YYYY-MM-DD format");
|
|
30320
|
+
}
|
|
30321
|
+
const [, yearStr, monthStr, dayStr] = match;
|
|
30322
|
+
const year = Number(yearStr);
|
|
30323
|
+
const month = Number(monthStr);
|
|
30324
|
+
const day = Number(dayStr);
|
|
30325
|
+
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
|
30326
|
+
throw new ValidationError("Date must be in YYYY-MM-DD format");
|
|
30327
|
+
}
|
|
30328
|
+
const eventTime = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
|
|
30329
|
+
if (eventTime.getUTCFullYear() !== year || eventTime.getUTCMonth() + 1 !== month || eventTime.getUTCDate() !== day) {
|
|
30330
|
+
throw new ValidationError("Date must be a valid calendar date");
|
|
30331
|
+
}
|
|
30332
|
+
return eventTime.toISOString();
|
|
30333
|
+
}
|
|
30334
|
+
function resolveAdminEventTime(data) {
|
|
30335
|
+
if (data.useCurrentTime) {
|
|
30336
|
+
return new Date().toISOString();
|
|
30337
|
+
}
|
|
30338
|
+
return toAttributionEventTime(data.date);
|
|
30339
|
+
}
|
|
30340
|
+
var init_timeback_admin_util = __esm(() => {
|
|
30341
|
+
init_errors();
|
|
30342
|
+
});
|
|
30343
|
+
|
|
30203
30344
|
// ../api-core/src/utils/timeback.util.ts
|
|
30204
30345
|
function isRecord2(value) {
|
|
30205
30346
|
return typeof value === "object" && value !== null;
|
|
@@ -30245,14 +30386,6 @@ function getPlaycademyMetadata(event) {
|
|
|
30245
30386
|
const extensions = getMergedCaliperExtensions(event);
|
|
30246
30387
|
return isRecord2(extensions.playcademy) ? extensions.playcademy : undefined;
|
|
30247
30388
|
}
|
|
30248
|
-
function getAssessmentPlaycademyMetadata(assessment) {
|
|
30249
|
-
return isRecord2(assessment.metadata?.playcademy) ? assessment.metadata.playcademy : undefined;
|
|
30250
|
-
}
|
|
30251
|
-
function isRemediationAssessmentResult(assessment) {
|
|
30252
|
-
const playcademy = getAssessmentPlaycademyMetadata(assessment);
|
|
30253
|
-
const eventKind = getStringValue(playcademy?.eventKind);
|
|
30254
|
-
return eventKind === "remediation-xp" || eventKind === "remediation-time" || eventKind === "remediation-mastery";
|
|
30255
|
-
}
|
|
30256
30389
|
function getActivityId(event, playcademy) {
|
|
30257
30390
|
const metadataActivityId = getStringValue(playcademy?.activityId);
|
|
30258
30391
|
if (metadataActivityId) {
|
|
@@ -30269,8 +30402,8 @@ function getActivityId(event, playcademy) {
|
|
|
30269
30402
|
const trimmed = objectId.replace(/\/$/, "");
|
|
30270
30403
|
const segments = trimmed.split("/");
|
|
30271
30404
|
const activityIndex = segments.lastIndexOf("activities");
|
|
30272
|
-
if (activityIndex !== -1 && segments.length >= activityIndex +
|
|
30273
|
-
const candidate = segments[activityIndex +
|
|
30405
|
+
if (activityIndex !== -1 && segments.length >= activityIndex + 3) {
|
|
30406
|
+
const candidate = segments[activityIndex + 2];
|
|
30274
30407
|
return candidate ? decodeURIComponent(candidate) : undefined;
|
|
30275
30408
|
}
|
|
30276
30409
|
return;
|
|
@@ -30323,38 +30456,96 @@ function mapAssessmentsToXpEvents(userId, assessments) {
|
|
|
30323
30456
|
};
|
|
30324
30457
|
});
|
|
30325
30458
|
}
|
|
30326
|
-
function
|
|
30327
|
-
|
|
30459
|
+
function getDurationSecondsFromExtensions(event) {
|
|
30460
|
+
const extensions = getMergedCaliperExtensions(event);
|
|
30461
|
+
const playcademy = isRecord2(extensions.playcademy) ? extensions.playcademy : undefined;
|
|
30462
|
+
const rawValue = extensions.durationSeconds ?? playcademy?.durationSeconds;
|
|
30463
|
+
const value = typeof rawValue === "number" ? rawValue : Number(rawValue);
|
|
30464
|
+
return Number.isFinite(value) ? value : undefined;
|
|
30465
|
+
}
|
|
30466
|
+
function getCanonicalRunId(session2) {
|
|
30467
|
+
const sessionId = getStringValue(session2?.id);
|
|
30468
|
+
if (!sessionId) {
|
|
30469
|
+
return;
|
|
30470
|
+
}
|
|
30471
|
+
return sessionId.replace(/^urn:uuid:/, "");
|
|
30472
|
+
}
|
|
30473
|
+
function getResumeId(event) {
|
|
30474
|
+
const playcademy = getPlaycademyMetadata(event);
|
|
30475
|
+
return getStringValue(playcademy?.resumeId);
|
|
30476
|
+
}
|
|
30477
|
+
function isCaliperRemediationOrCompletionEvent(event) {
|
|
30478
|
+
const playcademy = getPlaycademyMetadata(event);
|
|
30479
|
+
return REMEDIATION_OR_COMPLETION_EVENT_KINDS.has(getStringValue(playcademy?.eventKind) || "");
|
|
30480
|
+
}
|
|
30481
|
+
function groupCaliperEventsByRun(events) {
|
|
30482
|
+
const groups = new Map;
|
|
30483
|
+
for (const event of events) {
|
|
30484
|
+
const objectId = getStringValue(event.object.id) || "unknown-activity";
|
|
30485
|
+
const groupKey = `${objectId}::${getStringValue(event.session?.id) || event.externalId}`;
|
|
30486
|
+
const existing = groups.get(groupKey);
|
|
30487
|
+
if (existing) {
|
|
30488
|
+
existing.push(event);
|
|
30489
|
+
} else {
|
|
30490
|
+
groups.set(groupKey, [event]);
|
|
30491
|
+
}
|
|
30492
|
+
}
|
|
30493
|
+
return groups;
|
|
30328
30494
|
}
|
|
30329
|
-
function
|
|
30330
|
-
if (
|
|
30495
|
+
function mapCaliperEventGroupToActivity(events, relevantCourseIds) {
|
|
30496
|
+
if (events.length === 0) {
|
|
30331
30497
|
return null;
|
|
30332
30498
|
}
|
|
30333
|
-
|
|
30499
|
+
const sortedEvents = events.toSorted((a, b) => a.eventTime.localeCompare(b.eventTime));
|
|
30500
|
+
const activityEvent = [...sortedEvents].toReversed().find((event) => event.type === "ActivityEvent");
|
|
30501
|
+
const contextSource = activityEvent || sortedEvents.at(-1);
|
|
30502
|
+
if (!contextSource) {
|
|
30334
30503
|
return null;
|
|
30335
30504
|
}
|
|
30336
|
-
const
|
|
30337
|
-
if (!
|
|
30505
|
+
const ctx = parseCaliperEventContext(contextSource, relevantCourseIds);
|
|
30506
|
+
if (!ctx) {
|
|
30338
30507
|
return null;
|
|
30339
30508
|
}
|
|
30340
|
-
|
|
30509
|
+
const score = activityEvent !== undefined ? (() => {
|
|
30510
|
+
const totalQuestions = getGeneratedMetricValue(activityEvent, "totalQuestions");
|
|
30511
|
+
const correctQuestions = getGeneratedMetricValue(activityEvent, "correctQuestions");
|
|
30512
|
+
if (totalQuestions === undefined || correctQuestions === undefined || totalQuestions <= 0) {
|
|
30513
|
+
return;
|
|
30514
|
+
}
|
|
30515
|
+
return correctQuestions / totalQuestions * 100;
|
|
30516
|
+
})() : undefined;
|
|
30517
|
+
const xpEarned = activityEvent !== undefined ? getGeneratedMetricValue(activityEvent, "xpEarned") : undefined;
|
|
30518
|
+
const masteredUnits = activityEvent !== undefined ? getGeneratedMetricValue(activityEvent, "masteredUnits") : undefined;
|
|
30519
|
+
const timeSpentEvents = sortedEvents.filter((event) => event.type === "TimeSpentEvent");
|
|
30520
|
+
let totalActiveTimeSeconds;
|
|
30521
|
+
if (timeSpentEvents.length > 0) {
|
|
30522
|
+
totalActiveTimeSeconds = timeSpentEvents.reduce((sum2, event) => sum2 + (getGeneratedMetricValue(event, "active") ?? 0), 0);
|
|
30523
|
+
} else if (activityEvent !== undefined) {
|
|
30524
|
+
totalActiveTimeSeconds = getDurationSecondsFromExtensions(activityEvent);
|
|
30525
|
+
}
|
|
30526
|
+
const fallbackActivityId = getActivityId(contextSource, getPlaycademyMetadata(contextSource));
|
|
30527
|
+
const occurredAt = getStringValue(activityEvent?.eventTime) || getStringValue(sortedEvents.at(-1)?.eventTime);
|
|
30528
|
+
const runId = getCanonicalRunId(contextSource.session);
|
|
30529
|
+
const resumeIds = new Set(sortedEvents.map((event) => getResumeId(event)).filter((resumeId) => resumeId !== undefined));
|
|
30530
|
+
const sessionCount = resumeIds.size > 0 ? resumeIds.size : 1;
|
|
30531
|
+
const kind = activityEvent !== undefined ? "activity" : "activity-in-progress";
|
|
30532
|
+
if (!occurredAt) {
|
|
30341
30533
|
return null;
|
|
30342
30534
|
}
|
|
30343
|
-
const metadata2 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
|
|
30344
|
-
const activityName = getStringValue(metadata2?.activityName);
|
|
30345
|
-
const xpEarned = typeof metadata2?.xp === "number" && Number.isFinite(metadata2.xp) ? metadata2.xp : undefined;
|
|
30346
|
-
const masteredUnits = typeof metadata2?.masteredUnits === "number" && Number.isFinite(metadata2.masteredUnits) ? metadata2.masteredUnits : undefined;
|
|
30347
|
-
const durationSeconds = typeof metadata2?.durationSeconds === "number" && Number.isFinite(metadata2.durationSeconds) ? metadata2.durationSeconds : undefined;
|
|
30348
30535
|
return {
|
|
30349
|
-
id:
|
|
30350
|
-
kind
|
|
30351
|
-
occurredAt
|
|
30352
|
-
courseId,
|
|
30353
|
-
title:
|
|
30354
|
-
...
|
|
30536
|
+
id: activityEvent?.externalId || sortedEvents.at(-1)?.externalId || events[0].externalId,
|
|
30537
|
+
kind,
|
|
30538
|
+
occurredAt,
|
|
30539
|
+
courseId: ctx.courseId,
|
|
30540
|
+
title: getStringValue(activityEvent?.object.activity?.name) || ctx.titleFromEvent || (fallbackActivityId ? kebabToTitleCase(fallbackActivityId) : "Activity completed"),
|
|
30541
|
+
...ctx.activityId ? { activityId: ctx.activityId } : {},
|
|
30542
|
+
...ctx.appName ? { appName: ctx.appName } : {},
|
|
30543
|
+
...score !== undefined ? { score } : {},
|
|
30355
30544
|
...xpEarned !== undefined ? { xpDelta: xpEarned } : {},
|
|
30356
30545
|
...masteredUnits !== undefined ? { masteredUnitsDelta: masteredUnits } : {},
|
|
30357
|
-
...
|
|
30546
|
+
...totalActiveTimeSeconds !== undefined ? { timeDeltaSeconds: totalActiveTimeSeconds } : {},
|
|
30547
|
+
...runId ? { runId } : {},
|
|
30548
|
+
...sessionCount > 0 ? { sessionCount } : {}
|
|
30358
30549
|
};
|
|
30359
30550
|
}
|
|
30360
30551
|
function parseCaliperEventContext(event, relevantCourseIds) {
|
|
@@ -30462,8 +30653,16 @@ function mapCaliperEventToRemediationActivity(event, relevantCourseIds) {
|
|
|
30462
30653
|
}
|
|
30463
30654
|
return null;
|
|
30464
30655
|
}
|
|
30656
|
+
var REMEDIATION_OR_COMPLETION_EVENT_KINDS;
|
|
30465
30657
|
var init_timeback_util = __esm(() => {
|
|
30466
30658
|
init_types4();
|
|
30659
|
+
REMEDIATION_OR_COMPLETION_EVENT_KINDS = new Set([
|
|
30660
|
+
"remediation-xp",
|
|
30661
|
+
"remediation-time",
|
|
30662
|
+
"remediation-mastery",
|
|
30663
|
+
"course-completed",
|
|
30664
|
+
"course-resumed"
|
|
30665
|
+
]);
|
|
30467
30666
|
});
|
|
30468
30667
|
|
|
30469
30668
|
// ../api-core/src/services/timeback-admin.service.ts
|
|
@@ -30473,11 +30672,9 @@ class TimebackAdminService {
|
|
|
30473
30672
|
static RECENT_ACTIVITY_LIMIT = 20;
|
|
30474
30673
|
static MAX_STUDENT_ACTIVITY_LIMIT = 200;
|
|
30475
30674
|
static MAX_STUDENT_ACTIVITY_OFFSET = 1000;
|
|
30675
|
+
static MAX_RECENT_ACTIVITY_EVENT_FETCH = 4000;
|
|
30476
30676
|
static ANALYTICS_CONCURRENCY = 8;
|
|
30477
30677
|
static MASTERABLE_UNITS_CONCURRENCY = 4;
|
|
30478
|
-
static RECENT_ACTIVITY_FETCH_CONCURRENCY = 4;
|
|
30479
|
-
static ASSESSMENT_LINE_ITEM_PAGE_SIZE = 1000;
|
|
30480
|
-
static ASSESSMENT_RESULT_LINE_ITEM_CHUNK_SIZE = 20;
|
|
30481
30678
|
constructor(deps) {
|
|
30482
30679
|
this.deps = deps;
|
|
30483
30680
|
}
|
|
@@ -30485,27 +30682,6 @@ class TimebackAdminService {
|
|
|
30485
30682
|
const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
|
|
30486
30683
|
return Object.is(rounded, -0) ? 0 : rounded;
|
|
30487
30684
|
}
|
|
30488
|
-
static toAttributionEventTime(date3) {
|
|
30489
|
-
if (!date3) {
|
|
30490
|
-
return;
|
|
30491
|
-
}
|
|
30492
|
-
const match = date3.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
30493
|
-
if (!match) {
|
|
30494
|
-
throw new ValidationError("Date must be in YYYY-MM-DD format");
|
|
30495
|
-
}
|
|
30496
|
-
const [, yearStr, monthStr, dayStr] = match;
|
|
30497
|
-
const year = Number(yearStr);
|
|
30498
|
-
const month = Number(monthStr);
|
|
30499
|
-
const day = Number(dayStr);
|
|
30500
|
-
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
|
30501
|
-
throw new ValidationError("Date must be in YYYY-MM-DD format");
|
|
30502
|
-
}
|
|
30503
|
-
const eventTime = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
|
|
30504
|
-
if (eventTime.getUTCFullYear() !== year || eventTime.getUTCMonth() + 1 !== month || eventTime.getUTCDate() !== day) {
|
|
30505
|
-
throw new ValidationError("Date must be a valid calendar date");
|
|
30506
|
-
}
|
|
30507
|
-
return eventTime.toISOString();
|
|
30508
|
-
}
|
|
30509
30685
|
requireClient() {
|
|
30510
30686
|
if (!this.deps.timeback) {
|
|
30511
30687
|
logger16.error("Timeback client not available in context");
|
|
@@ -30524,9 +30700,13 @@ class TimebackAdminService {
|
|
|
30524
30700
|
});
|
|
30525
30701
|
});
|
|
30526
30702
|
}
|
|
30527
|
-
async resolveAdminMutationContext(gameId, courseId, user, studentId) {
|
|
30703
|
+
async resolveAdminMutationContext(gameId, courseId, user, studentId, accessLevel = "developer") {
|
|
30528
30704
|
const client = this.requireClient();
|
|
30529
|
-
|
|
30705
|
+
if (accessLevel === "dashboard") {
|
|
30706
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
30707
|
+
} else {
|
|
30708
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
30709
|
+
}
|
|
30530
30710
|
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
30531
30711
|
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
30532
30712
|
});
|
|
@@ -30649,7 +30829,7 @@ class TimebackAdminService {
|
|
|
30649
30829
|
throw new ValidationError(`Game "${game.slug}" has an invalid deploymentUrl: ${game.deploymentUrl}`);
|
|
30650
30830
|
}
|
|
30651
30831
|
}
|
|
30652
|
-
async
|
|
30832
|
+
async getGameActivitySource(gameId) {
|
|
30653
30833
|
const game = await this.deps.db.query.games.findFirst({
|
|
30654
30834
|
where: eq(games.id, gameId),
|
|
30655
30835
|
columns: { slug: true, deploymentUrl: true }
|
|
@@ -30657,7 +30837,17 @@ class TimebackAdminService {
|
|
|
30657
30837
|
if (!game) {
|
|
30658
30838
|
throw new NotFoundError("Game", gameId);
|
|
30659
30839
|
}
|
|
30660
|
-
return
|
|
30840
|
+
return {
|
|
30841
|
+
gameId,
|
|
30842
|
+
sensorUrl: this.deriveGameSensorUrl(game),
|
|
30843
|
+
sourceMode: this.deps.config.isLocal ? "development" : "production"
|
|
30844
|
+
};
|
|
30845
|
+
}
|
|
30846
|
+
static mapRecentActivityItems(events, relevantCourseIds) {
|
|
30847
|
+
const gameplayEvents = events.filter((event) => (event.type === "ActivityEvent" || event.type === "TimeSpentEvent") && !isCaliperRemediationOrCompletionEvent(event));
|
|
30848
|
+
const groupedGameplayItems = [...groupCaliperEventsByRun(gameplayEvents).values()].map((group) => mapCaliperEventGroupToActivity(group, relevantCourseIds)).filter((item) => Boolean(item));
|
|
30849
|
+
const remediationItems = events.map((event) => mapCaliperEventToRemediationActivity(event, relevantCourseIds)).filter((item) => Boolean(item));
|
|
30850
|
+
return [...groupedGameplayItems, ...remediationItems].toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
|
30661
30851
|
}
|
|
30662
30852
|
async getStudentEnrollmentsByCourseId(client, studentId, courseIds) {
|
|
30663
30853
|
const relevantCourseIds = new Set(courseIds);
|
|
@@ -30688,105 +30878,35 @@ class TimebackAdminService {
|
|
|
30688
30878
|
});
|
|
30689
30879
|
return new Map(results);
|
|
30690
30880
|
}
|
|
30691
|
-
async
|
|
30692
|
-
const lineItemEntries = await TimebackAdminService.runWithConcurrency([...relevantCourseIds], TimebackAdminService.RECENT_ACTIVITY_FETCH_CONCURRENCY, async (courseId) => {
|
|
30693
|
-
const entries = [];
|
|
30694
|
-
let offset = 0;
|
|
30695
|
-
try {
|
|
30696
|
-
while (true) {
|
|
30697
|
-
const items2 = await client.oneroster.assessmentLineItems.list({
|
|
30698
|
-
limit: TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE,
|
|
30699
|
-
offset,
|
|
30700
|
-
filter: `course.sourcedId='${escapeFilterValue(courseId)}'`,
|
|
30701
|
-
fields: "sourcedId,course"
|
|
30702
|
-
});
|
|
30703
|
-
for (const item of items2) {
|
|
30704
|
-
if (item.sourcedId) {
|
|
30705
|
-
entries.push([
|
|
30706
|
-
item.sourcedId,
|
|
30707
|
-
item.course?.sourcedId || courseId
|
|
30708
|
-
]);
|
|
30709
|
-
}
|
|
30710
|
-
}
|
|
30711
|
-
if (items2.length < TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE) {
|
|
30712
|
-
break;
|
|
30713
|
-
}
|
|
30714
|
-
offset += TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE;
|
|
30715
|
-
}
|
|
30716
|
-
} catch (error) {
|
|
30717
|
-
logger16.warn("Failed to load assessment line items for course", {
|
|
30718
|
-
courseId,
|
|
30719
|
-
error: error instanceof Error ? error.message : String(error)
|
|
30720
|
-
});
|
|
30721
|
-
}
|
|
30722
|
-
return entries;
|
|
30723
|
-
});
|
|
30724
|
-
return new Map(lineItemEntries.flat());
|
|
30725
|
-
}
|
|
30726
|
-
static buildAssessmentResultsFilter(studentId, lineItemIds) {
|
|
30727
|
-
const studentFilter = `student.sourcedId='${escapeFilterValue(studentId)}'`;
|
|
30728
|
-
if (lineItemIds.length === 1) {
|
|
30729
|
-
return `${studentFilter} AND ` + `assessmentLineItem.sourcedId='${escapeFilterValue(lineItemIds[0])}'`;
|
|
30730
|
-
}
|
|
30731
|
-
return `${studentFilter} AND ` + `assessmentLineItem.sourcedId@'${lineItemIds.map(escapeFilterValue).join(",")}'`;
|
|
30732
|
-
}
|
|
30733
|
-
async listRecentAssessmentResultsForStudent(client, studentId, courseIdByLineItemId, perChunkLimit = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
|
|
30734
|
-
const lineItemIds = [...courseIdByLineItemId.keys()];
|
|
30735
|
-
if (lineItemIds.length === 0) {
|
|
30736
|
-
return [];
|
|
30737
|
-
}
|
|
30738
|
-
const resultPages = await TimebackAdminService.runWithConcurrency(TimebackAdminService.chunkItems(lineItemIds, TimebackAdminService.ASSESSMENT_RESULT_LINE_ITEM_CHUNK_SIZE), TimebackAdminService.RECENT_ACTIVITY_FETCH_CONCURRENCY, async (lineItemChunk) => {
|
|
30739
|
-
try {
|
|
30740
|
-
return await client.oneroster.assessmentResults.list({
|
|
30741
|
-
limit: perChunkLimit,
|
|
30742
|
-
sort: "scoreDate",
|
|
30743
|
-
orderBy: "desc",
|
|
30744
|
-
fields: "sourcedId,assessmentLineItem,score,scoreDate,metadata",
|
|
30745
|
-
filter: TimebackAdminService.buildAssessmentResultsFilter(studentId, lineItemChunk)
|
|
30746
|
-
});
|
|
30747
|
-
} catch (error) {
|
|
30748
|
-
logger16.warn("Failed to load recent assessment results for student", {
|
|
30749
|
-
studentId,
|
|
30750
|
-
lineItemCount: lineItemChunk.length,
|
|
30751
|
-
error: error instanceof Error ? error.message : String(error)
|
|
30752
|
-
});
|
|
30753
|
-
return [];
|
|
30754
|
-
}
|
|
30755
|
-
});
|
|
30756
|
-
const uniqueResults = new Map;
|
|
30757
|
-
for (const result of resultPages.flat()) {
|
|
30758
|
-
const key = result.sourcedId || `${result.assessmentLineItem?.sourcedId || "unknown"}:${result.scoreDate || ""}`;
|
|
30759
|
-
uniqueResults.set(key, result);
|
|
30760
|
-
}
|
|
30761
|
-
return [...uniqueResults.values()].toSorted((a, b) => (b.scoreDate || "").localeCompare(a.scoreDate || "")).slice(0, perChunkLimit);
|
|
30762
|
-
}
|
|
30763
|
-
async listRecentActivityForStudent(client, studentId, sensorUrl, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
|
|
30881
|
+
async listRecentActivityForStudent(client, studentId, source, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
|
|
30764
30882
|
if (relevantCourseIds.size === 0) {
|
|
30765
30883
|
return [];
|
|
30766
30884
|
}
|
|
30767
|
-
const courseIdByLineItemId = await this.listAssessmentLineItemCourseMap(client, relevantCourseIds);
|
|
30768
|
-
const assessments = await this.listRecentAssessmentResultsForStudent(client, studentId, courseIdByLineItemId, maxResults);
|
|
30769
|
-
const assessmentRecentItems = assessments.map((assessment) => mapAssessmentResultToRecentActivity(assessment, relevantCourseIds, courseIdByLineItemId)).filter((activity) => Boolean(activity));
|
|
30770
|
-
let caliperRecentItems = [];
|
|
30771
30885
|
try {
|
|
30772
30886
|
const actorId = `${client.getBaseUrl().replace(/\/$/, "")}/ims/oneroster/rostering/v1p2/users/${studentId}`;
|
|
30887
|
+
const eventLimit = Math.min(Math.max(200, maxResults * 20), TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
|
|
30773
30888
|
const { events } = await client.caliper.events.list({
|
|
30774
|
-
limit:
|
|
30889
|
+
limit: eventLimit,
|
|
30775
30890
|
actorId,
|
|
30776
|
-
sensor: sensorUrl
|
|
30891
|
+
...source.sourceMode === "production" ? { sensor: source.sensorUrl } : {},
|
|
30892
|
+
extensions: {
|
|
30893
|
+
gameId: source.gameId
|
|
30894
|
+
}
|
|
30777
30895
|
});
|
|
30778
|
-
|
|
30896
|
+
return TimebackAdminService.mapRecentActivityItems(events, relevantCourseIds).slice(0, maxResults);
|
|
30779
30897
|
} catch (error) {
|
|
30780
30898
|
logger16.warn("Failed to load recent Caliper activity", {
|
|
30781
30899
|
studentId,
|
|
30900
|
+
gameId: source.gameId,
|
|
30901
|
+
sourceMode: source.sourceMode,
|
|
30782
30902
|
error: error instanceof Error ? error.message : String(error)
|
|
30783
30903
|
});
|
|
30904
|
+
return [];
|
|
30784
30905
|
}
|
|
30785
|
-
return [...assessmentRecentItems, ...caliperRecentItems].toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt)).slice(0, maxResults);
|
|
30786
30906
|
}
|
|
30787
30907
|
async listStudentsForCourse(gameId, courseId, user) {
|
|
30788
30908
|
const client = this.requireClient();
|
|
30789
|
-
await this.deps.
|
|
30909
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
30790
30910
|
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
30791
30911
|
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
30792
30912
|
});
|
|
@@ -30824,7 +30944,7 @@ class TimebackAdminService {
|
|
|
30824
30944
|
}
|
|
30825
30945
|
async getStudentOverview(gameId, studentId, user, courseId) {
|
|
30826
30946
|
const client = this.requireClient();
|
|
30827
|
-
await this.deps.
|
|
30947
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
30828
30948
|
const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
|
|
30829
30949
|
where: courseId ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId)) : eq(gameTimebackIntegrations.gameId, gameId)
|
|
30830
30950
|
});
|
|
@@ -30878,12 +30998,12 @@ class TimebackAdminService {
|
|
|
30878
30998
|
const client = this.requireClient();
|
|
30879
30999
|
const safeLimit = Math.max(1, Math.min(limit, TimebackAdminService.MAX_STUDENT_ACTIVITY_LIMIT));
|
|
30880
31000
|
const safeOffset = Math.max(0, Math.min(offset, TimebackAdminService.MAX_STUDENT_ACTIVITY_OFFSET));
|
|
30881
|
-
await this.deps.
|
|
30882
|
-
const [integration,
|
|
31001
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
31002
|
+
const [integration, gameSource] = await Promise.all([
|
|
30883
31003
|
this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
30884
31004
|
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
30885
31005
|
}),
|
|
30886
|
-
this.
|
|
31006
|
+
this.getGameActivitySource(gameId)
|
|
30887
31007
|
]);
|
|
30888
31008
|
if (!integration) {
|
|
30889
31009
|
throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
|
|
@@ -30891,7 +31011,7 @@ class TimebackAdminService {
|
|
|
30891
31011
|
await this.assertStudentEnrolledInCourse(client, studentId, courseId);
|
|
30892
31012
|
const relevantCourseIds = new Set([courseId]);
|
|
30893
31013
|
const fetchLimit = Math.min(safeOffset + safeLimit + 1, TimebackAdminService.MAX_STUDENT_ACTIVITY_OFFSET + TimebackAdminService.MAX_STUDENT_ACTIVITY_LIMIT + 1);
|
|
30894
|
-
const allActivities = await this.listRecentActivityForStudent(client, studentId,
|
|
31014
|
+
const allActivities = await this.listRecentActivityForStudent(client, studentId, gameSource, relevantCourseIds, fetchLimit);
|
|
30895
31015
|
const activities = allActivities.slice(safeOffset, safeOffset + safeLimit);
|
|
30896
31016
|
const hasMore = allActivities.length > safeOffset + safeLimit;
|
|
30897
31017
|
return { activities, hasMore };
|
|
@@ -30903,7 +31023,7 @@ class TimebackAdminService {
|
|
|
30903
31023
|
courseId: data.courseId,
|
|
30904
31024
|
studentId: data.studentId,
|
|
30905
31025
|
xpEarned: data.xp,
|
|
30906
|
-
eventTime:
|
|
31026
|
+
eventTime: resolveAdminEventTime(data),
|
|
30907
31027
|
reason: data.reason,
|
|
30908
31028
|
actor,
|
|
30909
31029
|
appName,
|
|
@@ -30918,7 +31038,7 @@ class TimebackAdminService {
|
|
|
30918
31038
|
courseId: data.courseId,
|
|
30919
31039
|
studentId: data.studentId,
|
|
30920
31040
|
activeTimeSeconds: data.seconds,
|
|
30921
|
-
eventTime:
|
|
31041
|
+
eventTime: resolveAdminEventTime(data),
|
|
30922
31042
|
reason: data.reason,
|
|
30923
31043
|
actor,
|
|
30924
31044
|
appName,
|
|
@@ -30933,7 +31053,7 @@ class TimebackAdminService {
|
|
|
30933
31053
|
courseId: data.courseId,
|
|
30934
31054
|
studentId: data.studentId,
|
|
30935
31055
|
masteredUnits: data.units,
|
|
30936
|
-
eventTime:
|
|
31056
|
+
eventTime: resolveAdminEventTime(data),
|
|
30937
31057
|
reason: data.reason,
|
|
30938
31058
|
actor,
|
|
30939
31059
|
appName,
|
|
@@ -30942,7 +31062,7 @@ class TimebackAdminService {
|
|
|
30942
31062
|
return { status: "ok" };
|
|
30943
31063
|
}
|
|
30944
31064
|
async toggleCourseCompletion(data, user) {
|
|
30945
|
-
const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
|
|
31065
|
+
const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId, "dashboard");
|
|
30946
31066
|
const historyClient = client;
|
|
30947
31067
|
const ids = deriveSourcedIds(data.courseId);
|
|
30948
31068
|
const lineItemId = `${ids.course}-mastery-completion-assessment`;
|
|
@@ -31035,6 +31155,77 @@ class TimebackAdminService {
|
|
|
31035
31155
|
}
|
|
31036
31156
|
return { status: "ok" };
|
|
31037
31157
|
}
|
|
31158
|
+
async searchStudentsForEnrollment(gameId, courseId, query, user) {
|
|
31159
|
+
const client = this.requireClient();
|
|
31160
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
31161
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31162
|
+
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
31163
|
+
});
|
|
31164
|
+
if (!integration) {
|
|
31165
|
+
throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
|
|
31166
|
+
}
|
|
31167
|
+
const trimmedQuery = query.trim();
|
|
31168
|
+
if (trimmedQuery.length < 2) {
|
|
31169
|
+
return { students: [] };
|
|
31170
|
+
}
|
|
31171
|
+
const filterParts = [
|
|
31172
|
+
`givenName~'${escapeFilterValue(trimmedQuery)}'`,
|
|
31173
|
+
`familyName~'${escapeFilterValue(trimmedQuery)}'`,
|
|
31174
|
+
`email~'${escapeFilterValue(trimmedQuery)}'`
|
|
31175
|
+
];
|
|
31176
|
+
const filter = filterParts.join(" OR ");
|
|
31177
|
+
const params = new URLSearchParams({ filter, limit: "25" });
|
|
31178
|
+
const endpoint = `/ims/oneroster/rostering/v1p2/users?${params}`;
|
|
31179
|
+
let allUsers = [];
|
|
31180
|
+
try {
|
|
31181
|
+
const response = await client["request"](endpoint, "GET");
|
|
31182
|
+
allUsers = response.users || [];
|
|
31183
|
+
} catch (error) {
|
|
31184
|
+
logger16.warn("Failed to search OneRoster users", {
|
|
31185
|
+
query: trimmedQuery,
|
|
31186
|
+
error: error instanceof Error ? error.message : String(error)
|
|
31187
|
+
});
|
|
31188
|
+
return { students: [] };
|
|
31189
|
+
}
|
|
31190
|
+
const roster = await client.oneroster.enrollments.listByCourse(courseId, {
|
|
31191
|
+
role: "student",
|
|
31192
|
+
includeUsers: false
|
|
31193
|
+
});
|
|
31194
|
+
const enrolledStudentIds = new Set(roster.map((entry) => entry.enrollment.user.sourcedId));
|
|
31195
|
+
const students = allUsers.filter((entry) => Boolean(entry.sourcedId) && entry.roles?.some((role) => role.role === "student") === true).map((entry) => ({
|
|
31196
|
+
studentId: entry.sourcedId,
|
|
31197
|
+
name: `${entry.givenName || ""} ${entry.familyName || ""}`.trim() || entry.sourcedId,
|
|
31198
|
+
email: entry.email || null,
|
|
31199
|
+
alreadyEnrolled: enrolledStudentIds.has(entry.sourcedId)
|
|
31200
|
+
}));
|
|
31201
|
+
return { students };
|
|
31202
|
+
}
|
|
31203
|
+
async enrollStudent(data, user) {
|
|
31204
|
+
const client = this.requireClient();
|
|
31205
|
+
await this.deps.validateGameManagementAccess(user, data.gameId);
|
|
31206
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31207
|
+
where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
|
|
31208
|
+
});
|
|
31209
|
+
if (!integration) {
|
|
31210
|
+
throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
|
|
31211
|
+
}
|
|
31212
|
+
await client.edubridge.enrollments.enroll(data.studentId, data.courseId, {
|
|
31213
|
+
role: "student"
|
|
31214
|
+
});
|
|
31215
|
+
return { status: "ok" };
|
|
31216
|
+
}
|
|
31217
|
+
async unenrollStudent(data, user) {
|
|
31218
|
+
const client = this.requireClient();
|
|
31219
|
+
await this.deps.validateGameManagementAccess(user, data.gameId);
|
|
31220
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31221
|
+
where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
|
|
31222
|
+
});
|
|
31223
|
+
if (!integration) {
|
|
31224
|
+
throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
|
|
31225
|
+
}
|
|
31226
|
+
await client.edubridge.enrollments.unenroll(data.studentId, data.courseId);
|
|
31227
|
+
return { status: "ok" };
|
|
31228
|
+
}
|
|
31038
31229
|
async getCompletionStatus(client, courseId, studentId) {
|
|
31039
31230
|
const ids = deriveSourcedIds(courseId);
|
|
31040
31231
|
const lineItemId = `${ids.course}-mastery-completion-assessment`;
|
|
@@ -31073,17 +31264,6 @@ class TimebackAdminService {
|
|
|
31073
31264
|
}));
|
|
31074
31265
|
return results;
|
|
31075
31266
|
}
|
|
31076
|
-
static chunkItems(items2, chunkSize) {
|
|
31077
|
-
if (items2.length === 0) {
|
|
31078
|
-
return [];
|
|
31079
|
-
}
|
|
31080
|
-
const effectiveChunkSize = Math.max(1, chunkSize);
|
|
31081
|
-
const chunks = [];
|
|
31082
|
-
for (let index2 = 0;index2 < items2.length; index2 += effectiveChunkSize) {
|
|
31083
|
-
chunks.push(items2.slice(index2, index2 + effectiveChunkSize));
|
|
31084
|
-
}
|
|
31085
|
-
return chunks;
|
|
31086
|
-
}
|
|
31087
31267
|
}
|
|
31088
31268
|
var logger16;
|
|
31089
31269
|
var init_timeback_admin_service = __esm(() => {
|
|
@@ -31095,594 +31275,740 @@ var init_timeback_admin_service = __esm(() => {
|
|
|
31095
31275
|
init_utils6();
|
|
31096
31276
|
init_src4();
|
|
31097
31277
|
init_errors();
|
|
31278
|
+
init_timeback_admin_util();
|
|
31098
31279
|
init_timeback_util();
|
|
31099
31280
|
logger16 = log.scope("TimebackAdminService");
|
|
31100
31281
|
});
|
|
31101
31282
|
|
|
31102
31283
|
// ../api-core/src/services/timeback.service.ts
|
|
31103
|
-
|
|
31104
|
-
|
|
31105
|
-
|
|
31106
|
-
|
|
31107
|
-
|
|
31108
|
-
|
|
31109
|
-
|
|
31110
|
-
|
|
31111
|
-
|
|
31284
|
+
var logger17, TimebackService;
|
|
31285
|
+
var init_timeback_service = __esm(() => {
|
|
31286
|
+
init_drizzle_orm();
|
|
31287
|
+
init_src();
|
|
31288
|
+
init_tables_index();
|
|
31289
|
+
init_src2();
|
|
31290
|
+
init_types4();
|
|
31291
|
+
init_src4();
|
|
31292
|
+
init_errors();
|
|
31293
|
+
init_timeback_util();
|
|
31294
|
+
logger17 = log.scope("TimebackService");
|
|
31295
|
+
TimebackService = class TimebackService {
|
|
31296
|
+
static HEARTBEAT_DEDUPE_TTL_MS = 5 * 60 * 1000;
|
|
31297
|
+
static processedHeartbeatWindows = new Map;
|
|
31298
|
+
static inFlightHeartbeatWindows = new Map;
|
|
31299
|
+
deps;
|
|
31300
|
+
static cleanHeartbeatDedupeCache(now2 = Date.now()) {
|
|
31301
|
+
for (const [key, timestamp3] of this.processedHeartbeatWindows) {
|
|
31302
|
+
if (now2 - timestamp3 > this.HEARTBEAT_DEDUPE_TTL_MS) {
|
|
31303
|
+
this.processedHeartbeatWindows.delete(key);
|
|
31304
|
+
}
|
|
31305
|
+
}
|
|
31112
31306
|
}
|
|
31113
|
-
|
|
31114
|
-
|
|
31115
|
-
|
|
31116
|
-
const db2 = this.deps.db;
|
|
31117
|
-
const tz = timezone2 || PLATFORM_TIMEZONE;
|
|
31118
|
-
const base = date3 ? new Date(date3) : new Date;
|
|
31119
|
-
if (isNaN(base.getTime())) {
|
|
31120
|
-
throw new ValidationError("Invalid date format. Use ISO 8601 format.");
|
|
31307
|
+
static isDuplicateHeartbeatWindow(key) {
|
|
31308
|
+
this.cleanHeartbeatDedupeCache();
|
|
31309
|
+
return this.processedHeartbeatWindows.has(key);
|
|
31121
31310
|
}
|
|
31122
|
-
|
|
31123
|
-
|
|
31124
|
-
} catch {
|
|
31125
|
-
throw new ValidationError(`Invalid timezone: ${tz}`);
|
|
31311
|
+
static getInFlightHeartbeatWindow(key) {
|
|
31312
|
+
return this.inFlightHeartbeatWindows.get(key);
|
|
31126
31313
|
}
|
|
31127
|
-
|
|
31128
|
-
|
|
31129
|
-
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);
|
|
31130
|
-
if (result2.length === 0) {
|
|
31131
|
-
return { xp: 0, date: todayMidnight.toISOString() };
|
|
31132
|
-
}
|
|
31133
|
-
return { xp: result2[0].xp, date: result2[0].date.toISOString() };
|
|
31314
|
+
static markHeartbeatWindowProcessed(key) {
|
|
31315
|
+
this.processedHeartbeatWindows.set(key, Date.now());
|
|
31134
31316
|
}
|
|
31135
|
-
|
|
31136
|
-
|
|
31137
|
-
return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
|
|
31138
|
-
}
|
|
31139
|
-
async getTotalXp(userId) {
|
|
31140
|
-
const db2 = this.deps.db;
|
|
31141
|
-
const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
|
|
31142
|
-
return { totalXp: Number(result[0]?.totalXp) || 0 };
|
|
31143
|
-
}
|
|
31144
|
-
async updateTodayXp(userId, data) {
|
|
31145
|
-
const db2 = this.deps.db;
|
|
31146
|
-
const { xp, userTimestamp } = data;
|
|
31147
|
-
let targetDate;
|
|
31148
|
-
if (userTimestamp) {
|
|
31149
|
-
targetDate = new Date(userTimestamp);
|
|
31150
|
-
if (isNaN(targetDate.getTime())) {
|
|
31151
|
-
throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
|
|
31152
|
-
}
|
|
31153
|
-
targetDate.setHours(0, 0, 0, 0);
|
|
31154
|
-
} else {
|
|
31155
|
-
targetDate = new Date;
|
|
31156
|
-
targetDate.setUTCHours(0, 0, 0, 0);
|
|
31317
|
+
static markHeartbeatWindowInFlight(key, promise) {
|
|
31318
|
+
this.inFlightHeartbeatWindows.set(key, promise);
|
|
31157
31319
|
}
|
|
31158
|
-
|
|
31159
|
-
|
|
31160
|
-
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31161
|
-
}).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
|
|
31162
|
-
if (!result) {
|
|
31163
|
-
logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
|
|
31164
|
-
throw new InternalError("Failed to update daily XP record");
|
|
31320
|
+
static clearInFlightHeartbeatWindow(key) {
|
|
31321
|
+
this.inFlightHeartbeatWindows.delete(key);
|
|
31165
31322
|
}
|
|
31166
|
-
|
|
31167
|
-
|
|
31168
|
-
|
|
31169
|
-
|
|
31170
|
-
|
|
31171
|
-
|
|
31172
|
-
|
|
31173
|
-
|
|
31174
|
-
|
|
31323
|
+
static addResumeIdToExtensions(extensions, resumeId) {
|
|
31324
|
+
const base = extensions ?? {};
|
|
31325
|
+
const existingPlaycademy = base.playcademy;
|
|
31326
|
+
const playcademy = typeof existingPlaycademy === "object" && existingPlaycademy !== null && !Array.isArray(existingPlaycademy) ? existingPlaycademy : {};
|
|
31327
|
+
return {
|
|
31328
|
+
...base,
|
|
31329
|
+
playcademy: {
|
|
31330
|
+
...playcademy,
|
|
31331
|
+
resumeId
|
|
31332
|
+
}
|
|
31333
|
+
};
|
|
31175
31334
|
}
|
|
31176
|
-
|
|
31177
|
-
|
|
31178
|
-
end.setUTCHours(23, 59, 59, 999);
|
|
31179
|
-
whereConditions.push(lte(timebackDailyXp.date, end));
|
|
31335
|
+
constructor(deps) {
|
|
31336
|
+
this.deps = deps;
|
|
31180
31337
|
}
|
|
31181
|
-
|
|
31182
|
-
|
|
31183
|
-
|
|
31184
|
-
|
|
31185
|
-
|
|
31186
|
-
|
|
31187
|
-
const client = this.requireClient();
|
|
31188
|
-
const db2 = this.deps.db;
|
|
31189
|
-
const dbUser = await db2.query.users.findFirst({
|
|
31190
|
-
where: eq(users.id, user.id),
|
|
31191
|
-
columns: { id: true, timebackId: true }
|
|
31192
|
-
});
|
|
31193
|
-
if (dbUser?.timebackId) {
|
|
31194
|
-
logger17.info("Student already onboarded", { userId: user.id });
|
|
31195
|
-
return { status: "already_populated" };
|
|
31338
|
+
requireClient() {
|
|
31339
|
+
if (!this.deps.timeback) {
|
|
31340
|
+
logger17.error("Timeback client not available in context");
|
|
31341
|
+
throw new ValidationError("Timeback integration not available in this environment");
|
|
31342
|
+
}
|
|
31343
|
+
return this.deps.timeback;
|
|
31196
31344
|
}
|
|
31197
|
-
|
|
31198
|
-
|
|
31199
|
-
|
|
31200
|
-
const
|
|
31201
|
-
|
|
31202
|
-
|
|
31203
|
-
logger17.info("Found existing student in OneRoster", {
|
|
31204
|
-
userId: user.id,
|
|
31205
|
-
timebackId
|
|
31206
|
-
});
|
|
31207
|
-
} catch {
|
|
31208
|
-
if (!providedNames?.firstName || !providedNames?.lastName) {
|
|
31209
|
-
return { status: "no_record" };
|
|
31345
|
+
async getTodayXp(userId, date3, timezone2) {
|
|
31346
|
+
const db2 = this.deps.db;
|
|
31347
|
+
const tz = timezone2 || PLATFORM_TIMEZONE;
|
|
31348
|
+
const base = date3 ? new Date(date3) : new Date;
|
|
31349
|
+
if (isNaN(base.getTime())) {
|
|
31350
|
+
throw new ValidationError("Invalid date format. Use ISO 8601 format.");
|
|
31210
31351
|
}
|
|
31211
|
-
|
|
31212
|
-
|
|
31213
|
-
|
|
31214
|
-
|
|
31215
|
-
|
|
31216
|
-
|
|
31217
|
-
|
|
31218
|
-
|
|
31219
|
-
|
|
31220
|
-
{
|
|
31221
|
-
|
|
31222
|
-
|
|
31223
|
-
|
|
31224
|
-
|
|
31225
|
-
|
|
31226
|
-
}
|
|
31227
|
-
|
|
31228
|
-
|
|
31352
|
+
try {
|
|
31353
|
+
new Intl.DateTimeFormat(undefined, { timeZone: tz });
|
|
31354
|
+
} catch {
|
|
31355
|
+
throw new ValidationError(`Invalid timezone: ${tz}`);
|
|
31356
|
+
}
|
|
31357
|
+
if (tz === PLATFORM_TIMEZONE) {
|
|
31358
|
+
const todayMidnight = getUtcInstantForMidnight(base, tz);
|
|
31359
|
+
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);
|
|
31360
|
+
if (result2.length === 0) {
|
|
31361
|
+
return { xp: 0, date: todayMidnight.toISOString() };
|
|
31362
|
+
}
|
|
31363
|
+
return { xp: result2[0].xp, date: result2[0].date.toISOString() };
|
|
31364
|
+
}
|
|
31365
|
+
const { startOfDay, endOfDay } = getDayBoundariesInTimezone(base, tz);
|
|
31366
|
+
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))));
|
|
31367
|
+
return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
|
|
31368
|
+
}
|
|
31369
|
+
async getTotalXp(userId) {
|
|
31370
|
+
const db2 = this.deps.db;
|
|
31371
|
+
const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
|
|
31372
|
+
return { totalXp: Number(result[0]?.totalXp) || 0 };
|
|
31373
|
+
}
|
|
31374
|
+
async updateTodayXp(userId, data) {
|
|
31375
|
+
const db2 = this.deps.db;
|
|
31376
|
+
const { xp, userTimestamp } = data;
|
|
31377
|
+
let targetDate;
|
|
31378
|
+
if (userTimestamp) {
|
|
31379
|
+
targetDate = new Date(userTimestamp);
|
|
31380
|
+
if (isNaN(targetDate.getTime())) {
|
|
31381
|
+
throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
|
|
31382
|
+
}
|
|
31383
|
+
targetDate.setHours(0, 0, 0, 0);
|
|
31384
|
+
} else {
|
|
31385
|
+
targetDate = new Date;
|
|
31386
|
+
targetDate.setUTCHours(0, 0, 0, 0);
|
|
31229
31387
|
}
|
|
31230
|
-
|
|
31231
|
-
|
|
31232
|
-
|
|
31388
|
+
const [result] = await db2.insert(timebackDailyXp).values({ userId, date: targetDate, xp }).onConflictDoUpdate({
|
|
31389
|
+
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31390
|
+
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31391
|
+
}).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
|
|
31392
|
+
if (!result) {
|
|
31393
|
+
logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
|
|
31394
|
+
throw new InternalError("Failed to update daily XP record");
|
|
31395
|
+
}
|
|
31396
|
+
return { xp: result.xp, date: result.date.toISOString() };
|
|
31233
31397
|
}
|
|
31234
|
-
|
|
31235
|
-
|
|
31236
|
-
|
|
31237
|
-
|
|
31238
|
-
|
|
31239
|
-
|
|
31240
|
-
|
|
31241
|
-
|
|
31398
|
+
async getXpHistory(userId, startDate, endDate) {
|
|
31399
|
+
const db2 = this.deps.db;
|
|
31400
|
+
const whereConditions = [eq(timebackDailyXp.userId, userId)];
|
|
31401
|
+
if (startDate) {
|
|
31402
|
+
const start2 = new Date(startDate);
|
|
31403
|
+
start2.setUTCHours(0, 0, 0, 0);
|
|
31404
|
+
whereConditions.push(gte(timebackDailyXp.date, start2));
|
|
31405
|
+
}
|
|
31406
|
+
if (endDate) {
|
|
31407
|
+
const end = new Date(endDate);
|
|
31408
|
+
end.setUTCHours(23, 59, 59, 999);
|
|
31409
|
+
whereConditions.push(lte(timebackDailyXp.date, end));
|
|
31410
|
+
}
|
|
31411
|
+
const result = await db2.select({ date: timebackDailyXp.date, xp: timebackDailyXp.xp }).from(timebackDailyXp).where(and(...whereConditions)).orderBy(timebackDailyXp.date);
|
|
31412
|
+
return {
|
|
31413
|
+
history: result.map((row) => ({ date: row.date.toISOString(), xp: row.xp }))
|
|
31414
|
+
};
|
|
31415
|
+
}
|
|
31416
|
+
async populateStudent(user, providedNames) {
|
|
31417
|
+
const client = this.requireClient();
|
|
31418
|
+
const db2 = this.deps.db;
|
|
31419
|
+
const dbUser = await db2.query.users.findFirst({
|
|
31420
|
+
where: eq(users.id, user.id),
|
|
31421
|
+
columns: { id: true, timebackId: true }
|
|
31422
|
+
});
|
|
31423
|
+
if (dbUser?.timebackId) {
|
|
31424
|
+
logger17.info("Student already onboarded", { userId: user.id });
|
|
31425
|
+
return { status: "already_populated" };
|
|
31426
|
+
}
|
|
31427
|
+
let timebackId;
|
|
31428
|
+
let name3;
|
|
31429
|
+
try {
|
|
31430
|
+
const existingUser = await client.oneroster.users.findByEmail(user.email);
|
|
31431
|
+
timebackId = existingUser.sourcedId;
|
|
31432
|
+
name3 = `${existingUser.givenName} ${existingUser.familyName}`;
|
|
31433
|
+
logger17.info("Found existing student in OneRoster", {
|
|
31434
|
+
userId: user.id,
|
|
31435
|
+
timebackId
|
|
31436
|
+
});
|
|
31437
|
+
} catch {
|
|
31438
|
+
if (!providedNames?.firstName || !providedNames?.lastName) {
|
|
31439
|
+
return { status: "no_record" };
|
|
31242
31440
|
}
|
|
31243
|
-
const
|
|
31244
|
-
|
|
31245
|
-
|
|
31246
|
-
|
|
31247
|
-
|
|
31248
|
-
|
|
31249
|
-
|
|
31441
|
+
const sourcedId = crypto.randomUUID();
|
|
31442
|
+
const response = await client.oneroster.users.create({
|
|
31443
|
+
sourcedId,
|
|
31444
|
+
status: "active",
|
|
31445
|
+
enabledUser: true,
|
|
31446
|
+
givenName: providedNames.firstName,
|
|
31447
|
+
familyName: providedNames.lastName,
|
|
31448
|
+
email: user.email,
|
|
31449
|
+
roles: [
|
|
31450
|
+
{
|
|
31451
|
+
roleType: "primary",
|
|
31452
|
+
role: "student",
|
|
31453
|
+
org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
|
|
31454
|
+
}
|
|
31455
|
+
]
|
|
31456
|
+
});
|
|
31457
|
+
if (!response.sourcedIdPairs?.allocatedSourcedId) {
|
|
31458
|
+
return { status: "error", message: "Timeback did not return allocatedSourcedId" };
|
|
31459
|
+
}
|
|
31460
|
+
timebackId = response.sourcedIdPairs.allocatedSourcedId;
|
|
31461
|
+
name3 = `${providedNames.firstName} ${providedNames.lastName}`;
|
|
31462
|
+
logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
|
|
31463
|
+
}
|
|
31464
|
+
const assessments = await this.fetchAssessments(timebackId);
|
|
31465
|
+
await db2.transaction(async (tx) => {
|
|
31466
|
+
if (assessments.length > 0) {
|
|
31467
|
+
const events = mapAssessmentsToXpEvents(user.id, assessments);
|
|
31468
|
+
for (const event of events) {
|
|
31469
|
+
try {
|
|
31470
|
+
await tx.insert(timebackXpEvents).values(event);
|
|
31471
|
+
} catch {}
|
|
31472
|
+
}
|
|
31473
|
+
const dailyMap = new Map;
|
|
31474
|
+
for (const a of assessments) {
|
|
31475
|
+
const xp = a.metadata?.xp;
|
|
31476
|
+
if (typeof xp === "number" && a.scoreDate) {
|
|
31477
|
+
const day = getUtcInstantForMidnight(new Date(a.scoreDate), PLATFORM_TIMEZONE);
|
|
31478
|
+
const key = day.toISOString();
|
|
31479
|
+
dailyMap.set(key, (dailyMap.get(key) || 0) + xp);
|
|
31480
|
+
}
|
|
31481
|
+
}
|
|
31482
|
+
if (dailyMap.size > 0) {
|
|
31483
|
+
const dailyRecords = [...dailyMap.entries()].map(([iso, xp]) => ({
|
|
31484
|
+
userId: user.id,
|
|
31485
|
+
date: new Date(iso),
|
|
31486
|
+
xp
|
|
31487
|
+
}));
|
|
31488
|
+
await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
|
|
31489
|
+
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31490
|
+
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31491
|
+
});
|
|
31250
31492
|
}
|
|
31251
31493
|
}
|
|
31252
|
-
|
|
31253
|
-
|
|
31494
|
+
const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
|
|
31495
|
+
if (!updated) {
|
|
31496
|
+
logger17.error("User Timeback ID update returned no rows", {
|
|
31254
31497
|
userId: user.id,
|
|
31255
|
-
|
|
31256
|
-
xp
|
|
31257
|
-
}));
|
|
31258
|
-
await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
|
|
31259
|
-
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31260
|
-
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31498
|
+
timebackId
|
|
31261
31499
|
});
|
|
31500
|
+
throw new InternalError("Failed to update user with Timeback ID");
|
|
31262
31501
|
}
|
|
31263
|
-
}
|
|
31264
|
-
const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
|
|
31265
|
-
if (!updated) {
|
|
31266
|
-
logger17.error("User Timeback ID update returned no rows", {
|
|
31267
|
-
userId: user.id,
|
|
31268
|
-
timebackId
|
|
31269
|
-
});
|
|
31270
|
-
throw new InternalError("Failed to update user with Timeback ID");
|
|
31271
|
-
}
|
|
31272
|
-
});
|
|
31273
|
-
return { status: "ok" };
|
|
31274
|
-
}
|
|
31275
|
-
async fetchAssessments(studentSourcedId) {
|
|
31276
|
-
const client = this.requireClient();
|
|
31277
|
-
const allAssessments = [];
|
|
31278
|
-
const limit = 3000;
|
|
31279
|
-
const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
|
|
31280
|
-
let offset = 0;
|
|
31281
|
-
try {
|
|
31282
|
-
while (true) {
|
|
31283
|
-
const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
|
|
31284
|
-
allAssessments.push(...results);
|
|
31285
|
-
if (results.length < limit) {
|
|
31286
|
-
break;
|
|
31287
|
-
}
|
|
31288
|
-
offset += limit;
|
|
31289
|
-
}
|
|
31290
|
-
logger17.debug("Fetched assessments", {
|
|
31291
|
-
studentSourcedId,
|
|
31292
|
-
totalCount: allAssessments.length
|
|
31293
31502
|
});
|
|
31294
|
-
return
|
|
31295
|
-
} catch (error) {
|
|
31296
|
-
logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
|
|
31297
|
-
return [];
|
|
31298
|
-
}
|
|
31299
|
-
}
|
|
31300
|
-
async getUserData(userId, gameId) {
|
|
31301
|
-
const db2 = this.deps.db;
|
|
31302
|
-
const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
|
|
31303
|
-
if (!userData) {
|
|
31304
|
-
throw new NotFoundError("User", userId);
|
|
31305
|
-
}
|
|
31306
|
-
if (!userData.timebackId) {
|
|
31307
|
-
throw new NotFoundError("Timeback account not found for user");
|
|
31503
|
+
return { status: "ok" };
|
|
31308
31504
|
}
|
|
31309
|
-
|
|
31310
|
-
this.
|
|
31311
|
-
|
|
31312
|
-
|
|
31313
|
-
|
|
31314
|
-
|
|
31315
|
-
|
|
31316
|
-
|
|
31317
|
-
|
|
31318
|
-
|
|
31319
|
-
|
|
31320
|
-
|
|
31321
|
-
|
|
31322
|
-
|
|
31323
|
-
|
|
31324
|
-
|
|
31325
|
-
|
|
31326
|
-
|
|
31327
|
-
organizations: profile.organizations
|
|
31328
|
-
};
|
|
31329
|
-
}
|
|
31330
|
-
async fetchStudentProfile(timebackId) {
|
|
31331
|
-
const client = this.requireClient();
|
|
31332
|
-
try {
|
|
31333
|
-
const user = await client.oneroster.users.get(timebackId);
|
|
31334
|
-
const primaryRole = user.roles.find((r) => r.roleType === "primary");
|
|
31335
|
-
const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
|
|
31336
|
-
const orgMap = new Map;
|
|
31337
|
-
if (user.primaryOrg) {
|
|
31338
|
-
orgMap.set(user.primaryOrg.sourcedId, {
|
|
31339
|
-
id: user.primaryOrg.sourcedId,
|
|
31340
|
-
name: user.primaryOrg.name ?? null,
|
|
31341
|
-
type: user.primaryOrg.type || "school",
|
|
31342
|
-
isPrimary: true
|
|
31505
|
+
async fetchAssessments(studentSourcedId) {
|
|
31506
|
+
const client = this.requireClient();
|
|
31507
|
+
const allAssessments = [];
|
|
31508
|
+
const limit = 3000;
|
|
31509
|
+
const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
|
|
31510
|
+
let offset = 0;
|
|
31511
|
+
try {
|
|
31512
|
+
while (true) {
|
|
31513
|
+
const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
|
|
31514
|
+
allAssessments.push(...results);
|
|
31515
|
+
if (results.length < limit) {
|
|
31516
|
+
break;
|
|
31517
|
+
}
|
|
31518
|
+
offset += limit;
|
|
31519
|
+
}
|
|
31520
|
+
logger17.debug("Fetched assessments", {
|
|
31521
|
+
studentSourcedId,
|
|
31522
|
+
totalCount: allAssessments.length
|
|
31343
31523
|
});
|
|
31524
|
+
return allAssessments;
|
|
31525
|
+
} catch (error) {
|
|
31526
|
+
logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
|
|
31527
|
+
return [];
|
|
31344
31528
|
}
|
|
31345
|
-
|
|
31346
|
-
|
|
31347
|
-
|
|
31348
|
-
|
|
31349
|
-
|
|
31350
|
-
|
|
31351
|
-
|
|
31529
|
+
}
|
|
31530
|
+
async getUserData(userId, gameId) {
|
|
31531
|
+
const db2 = this.deps.db;
|
|
31532
|
+
const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
|
|
31533
|
+
if (!userData) {
|
|
31534
|
+
throw new NotFoundError("User", userId);
|
|
31535
|
+
}
|
|
31536
|
+
if (!userData.timebackId) {
|
|
31537
|
+
throw new NotFoundError("Timeback account not found for user");
|
|
31538
|
+
}
|
|
31539
|
+
const [profile, allEnrollments] = await Promise.all([
|
|
31540
|
+
this.fetchStudentProfile(userData.timebackId),
|
|
31541
|
+
this.fetchEnrollments(userData.timebackId)
|
|
31542
|
+
]);
|
|
31543
|
+
const enrollments = gameId ? allEnrollments.filter((e) => e.gameId === gameId) : allEnrollments;
|
|
31544
|
+
const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
|
|
31545
|
+
const organizations = gameId && enrollmentOrgIds.size > 0 ? profile.organizations.filter((o) => enrollmentOrgIds.has(o.id)) : profile.organizations;
|
|
31546
|
+
return { id: userData.timebackId, role: profile.role, enrollments, organizations };
|
|
31547
|
+
}
|
|
31548
|
+
async getUserDataByTimebackId(timebackId) {
|
|
31549
|
+
const [profile, enrollments] = await Promise.all([
|
|
31550
|
+
this.fetchStudentProfile(timebackId),
|
|
31551
|
+
this.fetchEnrollments(timebackId)
|
|
31552
|
+
]);
|
|
31553
|
+
return {
|
|
31554
|
+
id: timebackId,
|
|
31555
|
+
role: profile.role,
|
|
31556
|
+
enrollments,
|
|
31557
|
+
organizations: profile.organizations
|
|
31558
|
+
};
|
|
31559
|
+
}
|
|
31560
|
+
async fetchStudentProfile(timebackId) {
|
|
31561
|
+
const client = this.requireClient();
|
|
31562
|
+
try {
|
|
31563
|
+
const user = await client.oneroster.users.get(timebackId);
|
|
31564
|
+
const primaryRole = user.roles.find((r) => r.roleType === "primary");
|
|
31565
|
+
const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
|
|
31566
|
+
const orgMap = new Map;
|
|
31567
|
+
if (user.primaryOrg) {
|
|
31568
|
+
orgMap.set(user.primaryOrg.sourcedId, {
|
|
31569
|
+
id: user.primaryOrg.sourcedId,
|
|
31570
|
+
name: user.primaryOrg.name ?? null,
|
|
31571
|
+
type: user.primaryOrg.type || "school",
|
|
31572
|
+
isPrimary: true
|
|
31352
31573
|
});
|
|
31353
31574
|
}
|
|
31575
|
+
for (const r of user.roles) {
|
|
31576
|
+
if (r.org && !orgMap.has(r.org.sourcedId)) {
|
|
31577
|
+
orgMap.set(r.org.sourcedId, {
|
|
31578
|
+
id: r.org.sourcedId,
|
|
31579
|
+
name: null,
|
|
31580
|
+
type: "school",
|
|
31581
|
+
isPrimary: false
|
|
31582
|
+
});
|
|
31583
|
+
}
|
|
31584
|
+
}
|
|
31585
|
+
return { role, organizations: [...orgMap.values()] };
|
|
31586
|
+
} catch {
|
|
31587
|
+
return { role: "student", organizations: [] };
|
|
31354
31588
|
}
|
|
31355
|
-
return { role, organizations: [...orgMap.values()] };
|
|
31356
|
-
} catch {
|
|
31357
|
-
return { role: "student", organizations: [] };
|
|
31358
31589
|
}
|
|
31359
|
-
|
|
31360
|
-
|
|
31361
|
-
|
|
31362
|
-
|
|
31363
|
-
|
|
31364
|
-
|
|
31365
|
-
|
|
31366
|
-
|
|
31590
|
+
async fetchEnrollments(timebackId) {
|
|
31591
|
+
const client = this.requireClient();
|
|
31592
|
+
const db2 = this.deps.db;
|
|
31593
|
+
try {
|
|
31594
|
+
const enrollments = await client.getEnrollments(timebackId);
|
|
31595
|
+
const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
|
|
31596
|
+
if (courseIds.length === 0) {
|
|
31597
|
+
return [];
|
|
31598
|
+
}
|
|
31599
|
+
const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
|
|
31600
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31601
|
+
where: inArray(gameTimebackIntegrations.courseId, courseIds)
|
|
31602
|
+
});
|
|
31603
|
+
return integrations.map((i2) => ({
|
|
31604
|
+
gameId: i2.gameId,
|
|
31605
|
+
grade: i2.grade,
|
|
31606
|
+
subject: i2.subject,
|
|
31607
|
+
courseId: i2.courseId,
|
|
31608
|
+
orgId: courseToSchool.get(i2.courseId)
|
|
31609
|
+
}));
|
|
31610
|
+
} catch {
|
|
31367
31611
|
return [];
|
|
31368
31612
|
}
|
|
31369
|
-
const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
|
|
31370
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31371
|
-
where: inArray(gameTimebackIntegrations.courseId, courseIds)
|
|
31372
|
-
});
|
|
31373
|
-
return integrations.map((i2) => ({
|
|
31374
|
-
gameId: i2.gameId,
|
|
31375
|
-
grade: i2.grade,
|
|
31376
|
-
subject: i2.subject,
|
|
31377
|
-
courseId: i2.courseId,
|
|
31378
|
-
orgId: courseToSchool.get(i2.courseId)
|
|
31379
|
-
}));
|
|
31380
|
-
} catch {
|
|
31381
|
-
return [];
|
|
31382
31613
|
}
|
|
31383
|
-
|
|
31384
|
-
|
|
31385
|
-
|
|
31386
|
-
|
|
31387
|
-
|
|
31388
|
-
|
|
31389
|
-
|
|
31390
|
-
|
|
31391
|
-
|
|
31392
|
-
|
|
31393
|
-
|
|
31394
|
-
|
|
31395
|
-
|
|
31396
|
-
|
|
31397
|
-
|
|
31398
|
-
const {
|
|
31399
|
-
subject: subjectInput,
|
|
31400
|
-
grade,
|
|
31401
|
-
title,
|
|
31402
|
-
courseCode,
|
|
31403
|
-
level,
|
|
31404
|
-
metadata: metadata2,
|
|
31405
|
-
totalXp: derivedTotalXp,
|
|
31406
|
-
masterableUnits: derivedMasterableUnits
|
|
31407
|
-
} = courseConfig;
|
|
31408
|
-
if (!isTimebackSubject(subjectInput)) {
|
|
31409
|
-
logger17.warn("Invalid Timeback subject in course config", {
|
|
31614
|
+
async setupIntegration(gameId, request, user) {
|
|
31615
|
+
const client = this.requireClient();
|
|
31616
|
+
const db2 = this.deps.db;
|
|
31617
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31618
|
+
const { courses, baseConfig, verbose } = request;
|
|
31619
|
+
const existing = await db2.query.gameTimebackIntegrations.findMany({
|
|
31620
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31621
|
+
});
|
|
31622
|
+
const integrations = [];
|
|
31623
|
+
const verboseData = [];
|
|
31624
|
+
for (const courseConfig of courses) {
|
|
31625
|
+
let applySuffix = function(text3) {
|
|
31626
|
+
return suffix ? `${text3} ${suffix}` : text3;
|
|
31627
|
+
};
|
|
31628
|
+
const {
|
|
31410
31629
|
subject: subjectInput,
|
|
31411
|
-
courseCode,
|
|
31412
|
-
title
|
|
31413
|
-
});
|
|
31414
|
-
throw new ValidationError(`Invalid subject "${subjectInput}"`);
|
|
31415
|
-
}
|
|
31416
|
-
if (!isTimebackGrade(grade)) {
|
|
31417
|
-
logger17.warn("Invalid Timeback grade in course config", {
|
|
31418
31630
|
grade,
|
|
31419
|
-
courseCode,
|
|
31420
|
-
title
|
|
31421
|
-
});
|
|
31422
|
-
throw new ValidationError(`Invalid grade "${grade}"`);
|
|
31423
|
-
}
|
|
31424
|
-
const subject = subjectInput;
|
|
31425
|
-
const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
|
|
31426
|
-
const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
|
|
31427
|
-
const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
|
|
31428
|
-
if (typeof totalXp !== "number") {
|
|
31429
|
-
logger17.warn("Course missing totalXp in Timeback config", {
|
|
31430
|
-
courseCode,
|
|
31431
|
-
title
|
|
31432
|
-
});
|
|
31433
|
-
throw new ValidationError(`Course "${title}" is missing totalXp`);
|
|
31434
|
-
}
|
|
31435
|
-
const suffix = baseConfig.component.titleSuffix || "";
|
|
31436
|
-
const fullConfig = {
|
|
31437
|
-
organization: baseConfig.organization,
|
|
31438
|
-
course: {
|
|
31439
31631
|
title,
|
|
31440
|
-
subjects: [subject],
|
|
31441
|
-
grades: [grade],
|
|
31442
31632
|
courseCode,
|
|
31443
31633
|
level,
|
|
31444
|
-
|
|
31445
|
-
|
|
31446
|
-
|
|
31447
|
-
|
|
31448
|
-
|
|
31449
|
-
|
|
31450
|
-
|
|
31451
|
-
|
|
31452
|
-
|
|
31453
|
-
|
|
31454
|
-
|
|
31455
|
-
baseMetadata: baseConfig.resource.metadata,
|
|
31456
|
-
subject,
|
|
31457
|
-
grade,
|
|
31458
|
-
totalXp,
|
|
31459
|
-
masterableUnits
|
|
31460
|
-
})
|
|
31461
|
-
},
|
|
31462
|
-
componentResource: {
|
|
31463
|
-
...baseConfig.componentResource,
|
|
31464
|
-
title: applySuffix(baseConfig.componentResource.title || "")
|
|
31465
|
-
}
|
|
31466
|
-
};
|
|
31467
|
-
const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
|
|
31468
|
-
if (existingIntegration) {
|
|
31469
|
-
await client.update(existingIntegration.courseId, fullConfig);
|
|
31470
|
-
const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
|
|
31471
|
-
if (updated) {
|
|
31472
|
-
integrations.push(this.toGameTimebackIntegration(updated));
|
|
31634
|
+
metadata: metadata2,
|
|
31635
|
+
totalXp: derivedTotalXp,
|
|
31636
|
+
masterableUnits: derivedMasterableUnits
|
|
31637
|
+
} = courseConfig;
|
|
31638
|
+
if (!isTimebackSubject(subjectInput)) {
|
|
31639
|
+
logger17.warn("Invalid Timeback subject in course config", {
|
|
31640
|
+
subject: subjectInput,
|
|
31641
|
+
courseCode,
|
|
31642
|
+
title
|
|
31643
|
+
});
|
|
31644
|
+
throw new ValidationError(`Invalid subject "${subjectInput}"`);
|
|
31473
31645
|
}
|
|
31474
|
-
|
|
31475
|
-
|
|
31476
|
-
|
|
31477
|
-
|
|
31478
|
-
|
|
31479
|
-
|
|
31480
|
-
|
|
31481
|
-
|
|
31646
|
+
if (!isTimebackGrade(grade)) {
|
|
31647
|
+
logger17.warn("Invalid Timeback grade in course config", {
|
|
31648
|
+
grade,
|
|
31649
|
+
courseCode,
|
|
31650
|
+
title
|
|
31651
|
+
});
|
|
31652
|
+
throw new ValidationError(`Invalid grade "${grade}"`);
|
|
31653
|
+
}
|
|
31654
|
+
const subject = subjectInput;
|
|
31655
|
+
const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
|
|
31656
|
+
const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
|
|
31657
|
+
const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
|
|
31658
|
+
if (typeof totalXp !== "number") {
|
|
31659
|
+
logger17.warn("Course missing totalXp in Timeback config", {
|
|
31660
|
+
courseCode,
|
|
31661
|
+
title
|
|
31662
|
+
});
|
|
31663
|
+
throw new ValidationError(`Course "${title}" is missing totalXp`);
|
|
31664
|
+
}
|
|
31665
|
+
const suffix = baseConfig.component.titleSuffix || "";
|
|
31666
|
+
const fullConfig = {
|
|
31667
|
+
organization: baseConfig.organization,
|
|
31668
|
+
course: {
|
|
31669
|
+
title,
|
|
31670
|
+
subjects: [subject],
|
|
31671
|
+
grades: [grade],
|
|
31672
|
+
courseCode,
|
|
31673
|
+
level,
|
|
31674
|
+
gradingScheme: "STANDARD",
|
|
31675
|
+
metadata: metadata2
|
|
31676
|
+
},
|
|
31677
|
+
component: {
|
|
31678
|
+
...baseConfig.component,
|
|
31679
|
+
title: applySuffix(baseConfig.component.title || `${title} Activities`)
|
|
31680
|
+
},
|
|
31681
|
+
resource: {
|
|
31682
|
+
...baseConfig.resource,
|
|
31683
|
+
title: applySuffix(baseConfig.resource.title || `${title} Game`),
|
|
31684
|
+
metadata: buildResourceMetadata({
|
|
31685
|
+
baseMetadata: baseConfig.resource.metadata,
|
|
31686
|
+
subject,
|
|
31687
|
+
grade,
|
|
31688
|
+
totalXp,
|
|
31689
|
+
masterableUnits
|
|
31690
|
+
})
|
|
31691
|
+
},
|
|
31692
|
+
componentResource: {
|
|
31693
|
+
...baseConfig.componentResource,
|
|
31694
|
+
title: applySuffix(baseConfig.componentResource.title || "")
|
|
31695
|
+
}
|
|
31696
|
+
};
|
|
31697
|
+
const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
|
|
31698
|
+
if (existingIntegration) {
|
|
31699
|
+
await client.update(existingIntegration.courseId, fullConfig);
|
|
31700
|
+
const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
|
|
31701
|
+
if (updated) {
|
|
31702
|
+
integrations.push(this.toGameTimebackIntegration(updated));
|
|
31703
|
+
}
|
|
31704
|
+
} else {
|
|
31705
|
+
const result = await client.setup(fullConfig, { verbose });
|
|
31706
|
+
const [integration] = await db2.insert(gameTimebackIntegrations).values({ gameId, courseId: result.courseId, grade, subject, totalXp }).returning();
|
|
31707
|
+
if (integration) {
|
|
31708
|
+
const dto = this.toGameTimebackIntegration(integration);
|
|
31709
|
+
integrations.push(dto);
|
|
31710
|
+
if (verbose && result.verboseData) {
|
|
31711
|
+
verboseData.push({ integration: dto, config: result.verboseData });
|
|
31712
|
+
}
|
|
31482
31713
|
}
|
|
31483
31714
|
}
|
|
31484
31715
|
}
|
|
31716
|
+
return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
|
|
31485
31717
|
}
|
|
31486
|
-
|
|
31487
|
-
|
|
31488
|
-
|
|
31489
|
-
|
|
31490
|
-
|
|
31491
|
-
|
|
31492
|
-
});
|
|
31493
|
-
return rows.map((row) => this.toGameTimebackIntegration(row));
|
|
31494
|
-
}
|
|
31495
|
-
async verifyIntegration(gameId, user) {
|
|
31496
|
-
const client = this.requireClient();
|
|
31497
|
-
const db2 = this.deps.db;
|
|
31498
|
-
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31499
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31500
|
-
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31501
|
-
});
|
|
31502
|
-
if (integrations.length === 0) {
|
|
31503
|
-
throw new NotFoundError("Timeback integration", gameId);
|
|
31718
|
+
async getIntegrations(gameId, user) {
|
|
31719
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
31720
|
+
const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
|
|
31721
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31722
|
+
});
|
|
31723
|
+
return rows.map((row) => this.toGameTimebackIntegration(row));
|
|
31504
31724
|
}
|
|
31505
|
-
|
|
31506
|
-
|
|
31507
|
-
const
|
|
31508
|
-
|
|
31509
|
-
const
|
|
31510
|
-
|
|
31511
|
-
|
|
31512
|
-
|
|
31513
|
-
integration
|
|
31514
|
-
|
|
31515
|
-
|
|
31516
|
-
|
|
31517
|
-
resources
|
|
31518
|
-
|
|
31519
|
-
|
|
31520
|
-
|
|
31521
|
-
|
|
31522
|
-
|
|
31523
|
-
|
|
31524
|
-
|
|
31525
|
-
|
|
31526
|
-
|
|
31527
|
-
|
|
31528
|
-
|
|
31529
|
-
|
|
31530
|
-
|
|
31531
|
-
|
|
31532
|
-
|
|
31533
|
-
|
|
31725
|
+
async verifyIntegration(gameId, user) {
|
|
31726
|
+
const client = this.requireClient();
|
|
31727
|
+
const db2 = this.deps.db;
|
|
31728
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31729
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31730
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31731
|
+
});
|
|
31732
|
+
if (integrations.length === 0) {
|
|
31733
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31734
|
+
}
|
|
31735
|
+
const now2 = new Date;
|
|
31736
|
+
const results = await Promise.all(integrations.map(async (integration) => {
|
|
31737
|
+
const resources = await client.verify(integration.courseId);
|
|
31738
|
+
const resourceValues = Object.values(resources);
|
|
31739
|
+
const allFound = resourceValues.every((r) => r.found);
|
|
31740
|
+
const errors3 = Object.entries(resources).filter(([_, r]) => !r.found).map(([name3]) => `${name3} not found`);
|
|
31741
|
+
const status = allFound ? "success" : "error";
|
|
31742
|
+
return {
|
|
31743
|
+
integration: this.toGameTimebackIntegration({
|
|
31744
|
+
...integration,
|
|
31745
|
+
lastVerifiedAt: now2
|
|
31746
|
+
}),
|
|
31747
|
+
resources,
|
|
31748
|
+
status,
|
|
31749
|
+
...errors3.length > 0 && { errors: errors3 }
|
|
31750
|
+
};
|
|
31751
|
+
}));
|
|
31752
|
+
await db2.update(gameTimebackIntegrations).set({ lastVerifiedAt: now2 }).where(eq(gameTimebackIntegrations.gameId, gameId));
|
|
31753
|
+
const overallStatus = results.every((r) => r.status === "success") ? "success" : "error";
|
|
31754
|
+
return { status: overallStatus, results };
|
|
31755
|
+
}
|
|
31756
|
+
async getConfig(gameId, user) {
|
|
31757
|
+
const client = this.requireClient();
|
|
31758
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31759
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31760
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31761
|
+
});
|
|
31762
|
+
if (!integration) {
|
|
31763
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31764
|
+
}
|
|
31765
|
+
return client.getConfig(integration.courseId);
|
|
31534
31766
|
}
|
|
31535
|
-
|
|
31536
|
-
|
|
31537
|
-
|
|
31538
|
-
|
|
31539
|
-
|
|
31540
|
-
|
|
31541
|
-
|
|
31542
|
-
|
|
31543
|
-
|
|
31544
|
-
|
|
31545
|
-
|
|
31767
|
+
async deleteIntegrations(gameId, user) {
|
|
31768
|
+
const client = this.requireClient();
|
|
31769
|
+
const db2 = this.deps.db;
|
|
31770
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31771
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31772
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31773
|
+
});
|
|
31774
|
+
if (integrations.length === 0) {
|
|
31775
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31776
|
+
}
|
|
31777
|
+
for (const integration of integrations) {
|
|
31778
|
+
await client.cleanup(integration.courseId);
|
|
31779
|
+
}
|
|
31780
|
+
await db2.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
|
|
31546
31781
|
}
|
|
31547
|
-
|
|
31548
|
-
|
|
31782
|
+
toGameTimebackIntegration(integration) {
|
|
31783
|
+
return {
|
|
31784
|
+
id: integration.id,
|
|
31785
|
+
gameId: integration.gameId,
|
|
31786
|
+
courseId: integration.courseId,
|
|
31787
|
+
grade: integration.grade,
|
|
31788
|
+
subject: integration.subject,
|
|
31789
|
+
totalXp: integration.totalXp ?? null,
|
|
31790
|
+
createdAt: integration.createdAt,
|
|
31791
|
+
updatedAt: integration.updatedAt,
|
|
31792
|
+
lastVerifiedAt: integration.lastVerifiedAt ?? null
|
|
31793
|
+
};
|
|
31549
31794
|
}
|
|
31550
|
-
|
|
31551
|
-
|
|
31552
|
-
|
|
31553
|
-
|
|
31554
|
-
|
|
31555
|
-
|
|
31556
|
-
|
|
31557
|
-
|
|
31558
|
-
|
|
31559
|
-
totalXp: integration.totalXp ?? null,
|
|
31560
|
-
createdAt: integration.createdAt,
|
|
31561
|
-
updatedAt: integration.updatedAt,
|
|
31562
|
-
lastVerifiedAt: integration.lastVerifiedAt ?? null
|
|
31563
|
-
};
|
|
31564
|
-
}
|
|
31565
|
-
async endActivity({
|
|
31566
|
-
gameId,
|
|
31567
|
-
studentId,
|
|
31568
|
-
activityData,
|
|
31569
|
-
scoreData,
|
|
31570
|
-
timingData,
|
|
31571
|
-
xpEarned,
|
|
31572
|
-
masteredUnits,
|
|
31573
|
-
extensions,
|
|
31574
|
-
user
|
|
31575
|
-
}) {
|
|
31576
|
-
const client = this.requireClient();
|
|
31577
|
-
const db2 = this.deps.db;
|
|
31578
|
-
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31579
|
-
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31580
|
-
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31581
|
-
});
|
|
31582
|
-
if (!integration) {
|
|
31583
|
-
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31584
|
-
}
|
|
31585
|
-
const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
|
|
31586
|
-
const result = await client.recordProgress(integration.courseId, studentId, {
|
|
31587
|
-
score: scorePercentage,
|
|
31588
|
-
totalQuestions: scoreData.totalQuestions,
|
|
31589
|
-
correctQuestions: scoreData.correctQuestions,
|
|
31590
|
-
durationSeconds: timingData.durationSeconds,
|
|
31795
|
+
async endActivity({
|
|
31796
|
+
gameId,
|
|
31797
|
+
studentId,
|
|
31798
|
+
runId,
|
|
31799
|
+
resumeId,
|
|
31800
|
+
activityData,
|
|
31801
|
+
scoreData,
|
|
31802
|
+
timingData,
|
|
31803
|
+
sessionTimingData,
|
|
31591
31804
|
xpEarned,
|
|
31592
31805
|
masteredUnits,
|
|
31593
31806
|
extensions,
|
|
31594
|
-
|
|
31595
|
-
|
|
31596
|
-
|
|
31597
|
-
|
|
31598
|
-
|
|
31599
|
-
|
|
31600
|
-
|
|
31601
|
-
|
|
31602
|
-
|
|
31603
|
-
|
|
31604
|
-
|
|
31605
|
-
|
|
31606
|
-
|
|
31607
|
-
|
|
31608
|
-
|
|
31609
|
-
|
|
31610
|
-
|
|
31611
|
-
|
|
31612
|
-
|
|
31613
|
-
|
|
31614
|
-
|
|
31615
|
-
|
|
31807
|
+
user
|
|
31808
|
+
}) {
|
|
31809
|
+
const client = this.requireClient();
|
|
31810
|
+
const db2 = this.deps.db;
|
|
31811
|
+
const effectiveResumeId = resumeId ?? runId ?? crypto.randomUUID();
|
|
31812
|
+
const extensionsWithResumeId = TimebackService.addResumeIdToExtensions(extensions, effectiveResumeId);
|
|
31813
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31814
|
+
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31815
|
+
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31816
|
+
});
|
|
31817
|
+
if (!integration) {
|
|
31818
|
+
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31819
|
+
}
|
|
31820
|
+
const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
|
|
31821
|
+
const result = await client.recordProgress(integration.courseId, studentId, {
|
|
31822
|
+
gameId,
|
|
31823
|
+
score: scorePercentage,
|
|
31824
|
+
totalQuestions: scoreData.totalQuestions,
|
|
31825
|
+
correctQuestions: scoreData.correctQuestions,
|
|
31826
|
+
durationSeconds: timingData.durationSeconds,
|
|
31827
|
+
xpEarned,
|
|
31828
|
+
masteredUnits,
|
|
31829
|
+
extensions: extensionsWithResumeId,
|
|
31830
|
+
activityId: activityData.activityId,
|
|
31831
|
+
activityName: activityData.activityName,
|
|
31832
|
+
subject: activityData.subject,
|
|
31833
|
+
appName: activityData.appName,
|
|
31834
|
+
sensorUrl: activityData.sensorUrl,
|
|
31835
|
+
courseId: activityData.courseId,
|
|
31836
|
+
courseName: activityData.courseName,
|
|
31837
|
+
studentEmail: activityData.studentEmail,
|
|
31838
|
+
courseTotalXp: integration.totalXp,
|
|
31839
|
+
...runId ? { runId } : {}
|
|
31840
|
+
});
|
|
31841
|
+
const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
|
|
31842
|
+
const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
|
|
31843
|
+
if (sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0) {
|
|
31844
|
+
await client.recordSessionEnd(integration.courseId, studentId, {
|
|
31845
|
+
gameId,
|
|
31846
|
+
activeTimeSeconds: sessionEndActiveSeconds,
|
|
31847
|
+
...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
|
|
31848
|
+
activityId: activityData.activityId,
|
|
31849
|
+
activityName: activityData.activityName,
|
|
31850
|
+
subject: activityData.subject,
|
|
31851
|
+
appName: activityData.appName,
|
|
31852
|
+
sensorUrl: activityData.sensorUrl,
|
|
31853
|
+
courseId: activityData.courseId,
|
|
31854
|
+
courseName: activityData.courseName,
|
|
31855
|
+
studentEmail: activityData.studentEmail,
|
|
31856
|
+
extensions: extensionsWithResumeId,
|
|
31857
|
+
...runId ? { runId } : {}
|
|
31858
|
+
});
|
|
31859
|
+
}
|
|
31860
|
+
logger17.info("Recorded activity completion", {
|
|
31861
|
+
gameId,
|
|
31862
|
+
courseId: integration.courseId,
|
|
31863
|
+
studentId,
|
|
31864
|
+
runId,
|
|
31865
|
+
score: scorePercentage
|
|
31866
|
+
});
|
|
31867
|
+
return {
|
|
31868
|
+
status: "ok",
|
|
31869
|
+
courseId: integration.courseId,
|
|
31870
|
+
xpAwarded: result.xpAwarded,
|
|
31871
|
+
masteredUnits: result.masteredUnitsApplied,
|
|
31872
|
+
pctCompleteApp: result.pctCompleteApp,
|
|
31873
|
+
scoreStatus: result.scoreStatus,
|
|
31874
|
+
inProgress: result.inProgress
|
|
31875
|
+
};
|
|
31876
|
+
}
|
|
31877
|
+
async recordHeartbeat({
|
|
31616
31878
|
gameId,
|
|
31617
|
-
courseId: integration.courseId,
|
|
31618
31879
|
studentId,
|
|
31619
|
-
|
|
31620
|
-
|
|
31621
|
-
|
|
31622
|
-
|
|
31623
|
-
|
|
31624
|
-
|
|
31625
|
-
|
|
31626
|
-
|
|
31627
|
-
|
|
31628
|
-
|
|
31629
|
-
|
|
31630
|
-
|
|
31631
|
-
|
|
31632
|
-
|
|
31633
|
-
|
|
31634
|
-
|
|
31635
|
-
|
|
31636
|
-
|
|
31637
|
-
|
|
31638
|
-
|
|
31639
|
-
|
|
31640
|
-
|
|
31880
|
+
runId,
|
|
31881
|
+
resumeId,
|
|
31882
|
+
activityData,
|
|
31883
|
+
timingData,
|
|
31884
|
+
windowStartedAtMs,
|
|
31885
|
+
windowSequence,
|
|
31886
|
+
isFinal,
|
|
31887
|
+
user
|
|
31888
|
+
}) {
|
|
31889
|
+
const client = this.requireClient();
|
|
31890
|
+
const db2 = this.deps.db;
|
|
31891
|
+
const hasWindowStartedAtMs = windowStartedAtMs !== undefined;
|
|
31892
|
+
const hasWindowSequence = windowSequence !== undefined;
|
|
31893
|
+
if (hasWindowStartedAtMs === hasWindowSequence) {
|
|
31894
|
+
throw new ValidationError("Provide exactly one of windowStartedAtMs or windowSequence");
|
|
31895
|
+
}
|
|
31896
|
+
const heartbeatWindowKey = hasWindowStartedAtMs ? `${runId}:t:${windowStartedAtMs}` : `${runId}:s:${windowSequence}`;
|
|
31897
|
+
const effectiveResumeId = resumeId ?? runId;
|
|
31898
|
+
if (TimebackService.isDuplicateHeartbeatWindow(heartbeatWindowKey)) {
|
|
31899
|
+
logger17.debug("Skipping duplicate heartbeat window", {
|
|
31900
|
+
gameId,
|
|
31901
|
+
studentId,
|
|
31902
|
+
runId,
|
|
31903
|
+
windowStartedAtMs,
|
|
31904
|
+
windowSequence,
|
|
31905
|
+
isFinal
|
|
31906
|
+
});
|
|
31907
|
+
return { status: "ok" };
|
|
31641
31908
|
}
|
|
31642
|
-
|
|
31643
|
-
|
|
31644
|
-
|
|
31645
|
-
|
|
31646
|
-
|
|
31647
|
-
|
|
31648
|
-
|
|
31649
|
-
|
|
31650
|
-
|
|
31651
|
-
|
|
31909
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31910
|
+
const inFlightHeartbeat = TimebackService.getInFlightHeartbeatWindow(heartbeatWindowKey);
|
|
31911
|
+
if (inFlightHeartbeat) {
|
|
31912
|
+
logger17.debug("Joining in-flight heartbeat window", {
|
|
31913
|
+
gameId,
|
|
31914
|
+
studentId,
|
|
31915
|
+
runId,
|
|
31916
|
+
windowStartedAtMs,
|
|
31917
|
+
windowSequence,
|
|
31918
|
+
isFinal
|
|
31652
31919
|
});
|
|
31653
|
-
return
|
|
31654
|
-
totalXp: 0,
|
|
31655
|
-
...options?.include?.today && { todayXp: 0 },
|
|
31656
|
-
...options?.include?.perCourse && { courses: [] }
|
|
31657
|
-
};
|
|
31920
|
+
return inFlightHeartbeat;
|
|
31658
31921
|
}
|
|
31922
|
+
const pendingHeartbeat = (async () => {
|
|
31923
|
+
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31924
|
+
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31925
|
+
});
|
|
31926
|
+
if (!integration) {
|
|
31927
|
+
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31928
|
+
}
|
|
31929
|
+
const activeTimeSeconds = timingData.activeMs / 1000;
|
|
31930
|
+
const inactiveTimeSeconds = timingData.pausedMs / 1000;
|
|
31931
|
+
if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
|
|
31932
|
+
await client.recordSessionEnd(integration.courseId, studentId, {
|
|
31933
|
+
gameId,
|
|
31934
|
+
activeTimeSeconds,
|
|
31935
|
+
...inactiveTimeSeconds > 0 ? { inactiveTimeSeconds } : {},
|
|
31936
|
+
activityId: activityData.activityId,
|
|
31937
|
+
activityName: activityData.activityName,
|
|
31938
|
+
subject: activityData.subject,
|
|
31939
|
+
appName: activityData.appName,
|
|
31940
|
+
sensorUrl: activityData.sensorUrl,
|
|
31941
|
+
courseId: activityData.courseId,
|
|
31942
|
+
courseName: activityData.courseName,
|
|
31943
|
+
studentEmail: activityData.studentEmail,
|
|
31944
|
+
extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId),
|
|
31945
|
+
...runId ? { runId } : {}
|
|
31946
|
+
});
|
|
31947
|
+
}
|
|
31948
|
+
TimebackService.markHeartbeatWindowProcessed(heartbeatWindowKey);
|
|
31949
|
+
logger17.debug("Recorded heartbeat", {
|
|
31950
|
+
gameId,
|
|
31951
|
+
courseId: integration.courseId,
|
|
31952
|
+
studentId,
|
|
31953
|
+
runId,
|
|
31954
|
+
windowStartedAtMs,
|
|
31955
|
+
windowSequence,
|
|
31956
|
+
activeTimeSeconds,
|
|
31957
|
+
isFinal
|
|
31958
|
+
});
|
|
31959
|
+
return { status: "ok" };
|
|
31960
|
+
})();
|
|
31961
|
+
TimebackService.markHeartbeatWindowInFlight(heartbeatWindowKey, pendingHeartbeat);
|
|
31962
|
+
try {
|
|
31963
|
+
return await pendingHeartbeat;
|
|
31964
|
+
} finally {
|
|
31965
|
+
TimebackService.clearInFlightHeartbeatWindow(heartbeatWindowKey);
|
|
31966
|
+
}
|
|
31967
|
+
}
|
|
31968
|
+
async getStudentXp(timebackId, user, options) {
|
|
31969
|
+
const client = this.requireClient();
|
|
31970
|
+
const db2 = this.deps.db;
|
|
31971
|
+
let courseIds = [];
|
|
31972
|
+
if (options?.gameId) {
|
|
31973
|
+
await this.deps.validateDeveloperAccess(user, options.gameId);
|
|
31974
|
+
const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
|
|
31975
|
+
if (options.grade !== undefined && options.subject) {
|
|
31976
|
+
conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
|
|
31977
|
+
conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
|
|
31978
|
+
}
|
|
31979
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31980
|
+
where: and(...conditions2)
|
|
31981
|
+
});
|
|
31982
|
+
courseIds = integrations.map((i2) => i2.courseId);
|
|
31983
|
+
if (courseIds.length === 0) {
|
|
31984
|
+
logger17.debug("No integrations found for game, returning 0 XP", {
|
|
31985
|
+
timebackId,
|
|
31986
|
+
gameId: options.gameId,
|
|
31987
|
+
grade: options.grade,
|
|
31988
|
+
subject: options.subject
|
|
31989
|
+
});
|
|
31990
|
+
return {
|
|
31991
|
+
totalXp: 0,
|
|
31992
|
+
...options?.include?.today && { todayXp: 0 },
|
|
31993
|
+
...options?.include?.perCourse && { courses: [] }
|
|
31994
|
+
};
|
|
31995
|
+
}
|
|
31996
|
+
}
|
|
31997
|
+
const result = await client.getStudentXp(timebackId, {
|
|
31998
|
+
courseIds: courseIds.length > 0 ? courseIds : undefined,
|
|
31999
|
+
include: options?.include
|
|
32000
|
+
});
|
|
32001
|
+
logger17.debug("Retrieved student XP", {
|
|
32002
|
+
timebackId,
|
|
32003
|
+
gameId: options?.gameId,
|
|
32004
|
+
grade: options?.grade,
|
|
32005
|
+
subject: options?.subject,
|
|
32006
|
+
totalXp: result.totalXp,
|
|
32007
|
+
courseCount: result.courses?.length
|
|
32008
|
+
});
|
|
32009
|
+
return result;
|
|
31659
32010
|
}
|
|
31660
|
-
|
|
31661
|
-
courseIds: courseIds.length > 0 ? courseIds : undefined,
|
|
31662
|
-
include: options?.include
|
|
31663
|
-
});
|
|
31664
|
-
logger17.debug("Retrieved student XP", {
|
|
31665
|
-
timebackId,
|
|
31666
|
-
gameId: options?.gameId,
|
|
31667
|
-
grade: options?.grade,
|
|
31668
|
-
subject: options?.subject,
|
|
31669
|
-
totalXp: result.totalXp,
|
|
31670
|
-
courseCount: result.courses?.length
|
|
31671
|
-
});
|
|
31672
|
-
return result;
|
|
31673
|
-
}
|
|
31674
|
-
}
|
|
31675
|
-
var logger17;
|
|
31676
|
-
var init_timeback_service = __esm(() => {
|
|
31677
|
-
init_drizzle_orm();
|
|
31678
|
-
init_src();
|
|
31679
|
-
init_tables_index();
|
|
31680
|
-
init_src2();
|
|
31681
|
-
init_types4();
|
|
31682
|
-
init_src4();
|
|
31683
|
-
init_errors();
|
|
31684
|
-
init_timeback_util();
|
|
31685
|
-
logger17 = log.scope("TimebackService");
|
|
32011
|
+
};
|
|
31686
32012
|
});
|
|
31687
32013
|
|
|
31688
32014
|
// ../api-core/src/services/upload.service.ts
|
|
@@ -31741,6 +32067,7 @@ function createPlatformServices(deps) {
|
|
|
31741
32067
|
alerts,
|
|
31742
32068
|
validateDeveloperAccessBySlug,
|
|
31743
32069
|
validateDeveloperAccess,
|
|
32070
|
+
validateGameManagementAccess,
|
|
31744
32071
|
validateOwnership
|
|
31745
32072
|
} = deps;
|
|
31746
32073
|
const bucket = new BucketService({
|
|
@@ -31775,12 +32102,15 @@ function createPlatformServices(deps) {
|
|
|
31775
32102
|
const timeback2 = new TimebackService({
|
|
31776
32103
|
db: db2,
|
|
31777
32104
|
timeback: timebackClient,
|
|
31778
|
-
validateDeveloperAccess
|
|
32105
|
+
validateDeveloperAccess,
|
|
32106
|
+
validateGameManagementAccess
|
|
31779
32107
|
});
|
|
31780
32108
|
const timebackAdmin = new TimebackAdminService({
|
|
32109
|
+
config: config2,
|
|
31781
32110
|
db: db2,
|
|
31782
32111
|
timeback: timebackClient,
|
|
31783
|
-
validateDeveloperAccess
|
|
32112
|
+
validateDeveloperAccess,
|
|
32113
|
+
validateGameManagementAccess
|
|
31784
32114
|
});
|
|
31785
32115
|
return {
|
|
31786
32116
|
bucket,
|
|
@@ -34745,6 +35075,16 @@ async function requestCaliper(options) {
|
|
|
34745
35075
|
baseUrl: caliperUrl
|
|
34746
35076
|
});
|
|
34747
35077
|
}
|
|
35078
|
+
function buildEventExtensions({
|
|
35079
|
+
eventExtensions,
|
|
35080
|
+
gameId
|
|
35081
|
+
}) {
|
|
35082
|
+
const mergedExtensions = {
|
|
35083
|
+
...eventExtensions,
|
|
35084
|
+
...gameId ? { gameId } : {}
|
|
35085
|
+
};
|
|
35086
|
+
return Object.keys(mergedExtensions).length > 0 ? mergedExtensions : undefined;
|
|
35087
|
+
}
|
|
34748
35088
|
function createCaliperNamespace(client) {
|
|
34749
35089
|
const urls = createOneRosterUrls(client.getBaseUrl());
|
|
34750
35090
|
const caliper = {
|
|
@@ -34789,11 +35129,20 @@ function createCaliperNamespace(client) {
|
|
|
34789
35129
|
if (params.actorEmail) {
|
|
34790
35130
|
query.set("actorEmail", params.actorEmail);
|
|
34791
35131
|
}
|
|
35132
|
+
if (params.extensions) {
|
|
35133
|
+
for (const [key, value] of Object.entries(params.extensions)) {
|
|
35134
|
+
query.set(`extensions.${key}`, value);
|
|
35135
|
+
}
|
|
35136
|
+
}
|
|
34792
35137
|
const requestPath = `${CALIPER_ENDPOINTS4.events}?${query.toString()}`;
|
|
34793
35138
|
return client["requestCaliper"](requestPath, "GET");
|
|
34794
35139
|
}
|
|
34795
35140
|
},
|
|
34796
35141
|
emitActivityEvent: async (data) => {
|
|
35142
|
+
const eventExtensions = buildEventExtensions({
|
|
35143
|
+
eventExtensions: data.eventExtensions,
|
|
35144
|
+
gameId: data.gameId
|
|
35145
|
+
});
|
|
34797
35146
|
const event = {
|
|
34798
35147
|
"@context": CALIPER_CONSTANTS4.context,
|
|
34799
35148
|
id: `urn:uuid:${crypto.randomUUID()}`,
|
|
@@ -34806,6 +35155,7 @@ function createCaliperNamespace(client) {
|
|
|
34806
35155
|
email: data.studentEmail
|
|
34807
35156
|
},
|
|
34808
35157
|
action: TIMEBACK_ACTIONS4.completed,
|
|
35158
|
+
...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
|
|
34809
35159
|
object: {
|
|
34810
35160
|
id: data.objectId || caliper.buildActivityUrl(data),
|
|
34811
35161
|
type: TIMEBACK_TYPES4.activityContext,
|
|
@@ -34852,11 +35202,15 @@ function createCaliperNamespace(client) {
|
|
|
34852
35202
|
}
|
|
34853
35203
|
} : {}
|
|
34854
35204
|
},
|
|
34855
|
-
...
|
|
35205
|
+
...eventExtensions ? { extensions: eventExtensions } : {}
|
|
34856
35206
|
};
|
|
34857
35207
|
return caliper.emit(event, data.sensorUrl);
|
|
34858
35208
|
},
|
|
34859
35209
|
emitTimeSpentEvent: async (data) => {
|
|
35210
|
+
const eventExtensions = buildEventExtensions({
|
|
35211
|
+
eventExtensions: data.eventExtensions,
|
|
35212
|
+
gameId: data.gameId
|
|
35213
|
+
});
|
|
34860
35214
|
const event = {
|
|
34861
35215
|
"@context": CALIPER_CONSTANTS4.context,
|
|
34862
35216
|
id: `urn:uuid:${crypto.randomUUID()}`,
|
|
@@ -34869,6 +35223,7 @@ function createCaliperNamespace(client) {
|
|
|
34869
35223
|
email: data.studentEmail
|
|
34870
35224
|
},
|
|
34871
35225
|
action: TIMEBACK_ACTIONS4.spentTime,
|
|
35226
|
+
...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
|
|
34872
35227
|
object: {
|
|
34873
35228
|
id: caliper.buildActivityUrl(data),
|
|
34874
35229
|
type: TIMEBACK_TYPES4.activityContext,
|
|
@@ -34896,13 +35251,14 @@ function createCaliperNamespace(client) {
|
|
|
34896
35251
|
...data.wasteTimeSeconds !== undefined ? [{ type: TIME_METRIC_TYPES4.waste, value: data.wasteTimeSeconds }] : []
|
|
34897
35252
|
],
|
|
34898
35253
|
...data.extensions ? { extensions: data.extensions } : {}
|
|
34899
|
-
}
|
|
35254
|
+
},
|
|
35255
|
+
...eventExtensions ? { extensions: eventExtensions } : {}
|
|
34900
35256
|
};
|
|
34901
35257
|
return caliper.emit(event, data.sensorUrl);
|
|
34902
35258
|
},
|
|
34903
35259
|
buildActivityUrl: (data) => {
|
|
34904
35260
|
const base = data.sensorUrl.replace(/\/$/, "");
|
|
34905
|
-
return `${base}/activities/${data.courseId}/${data.activityId
|
|
35261
|
+
return `${base}/activities/${encodeURIComponent(data.courseId)}/${encodeURIComponent(data.activityId)}`;
|
|
34906
35262
|
}
|
|
34907
35263
|
};
|
|
34908
35264
|
return caliper;
|
|
@@ -34912,6 +35268,34 @@ function createEduBridgeNamespace(client) {
|
|
|
34912
35268
|
listByUser: async (userId) => {
|
|
34913
35269
|
const response = await client["request"](`/edubridge/enrollments/user/${userId}`, "GET");
|
|
34914
35270
|
return response.data;
|
|
35271
|
+
},
|
|
35272
|
+
enroll: async (userId, courseId, options) => {
|
|
35273
|
+
const segments = [userId, courseId];
|
|
35274
|
+
if (options?.schoolId) {
|
|
35275
|
+
segments.push(options.schoolId);
|
|
35276
|
+
}
|
|
35277
|
+
const body2 = {};
|
|
35278
|
+
if (options?.role) {
|
|
35279
|
+
body2.role = options.role;
|
|
35280
|
+
}
|
|
35281
|
+
if (options?.sourcedId) {
|
|
35282
|
+
body2.sourcedId = options.sourcedId;
|
|
35283
|
+
}
|
|
35284
|
+
if (options?.beginDate) {
|
|
35285
|
+
body2.beginDate = options.beginDate;
|
|
35286
|
+
}
|
|
35287
|
+
if (options?.metadata) {
|
|
35288
|
+
body2.metadata = options.metadata;
|
|
35289
|
+
}
|
|
35290
|
+
const response = await client["request"](`/edubridge/enrollments/enroll/${segments.join("/")}`, "POST", body2);
|
|
35291
|
+
return response.data;
|
|
35292
|
+
},
|
|
35293
|
+
unenroll: async (userId, courseId, options) => {
|
|
35294
|
+
const segments = [userId, courseId];
|
|
35295
|
+
if (options?.schoolId) {
|
|
35296
|
+
segments.push(options.schoolId);
|
|
35297
|
+
}
|
|
35298
|
+
await client["request"](`/edubridge/enrollments/unenroll/${segments.join("/")}`, "DELETE");
|
|
34915
35299
|
}
|
|
34916
35300
|
};
|
|
34917
35301
|
const analytics = {
|
|
@@ -35087,6 +35471,10 @@ function createOneRosterNamespace(client) {
|
|
|
35087
35471
|
logTimebackError("list course roster", error, { courseSourcedId });
|
|
35088
35472
|
throw error;
|
|
35089
35473
|
}
|
|
35474
|
+
},
|
|
35475
|
+
create: async (data) => client["request"](ONEROSTER_ENDPOINTS4.enrollments, "POST", { enrollment: data }),
|
|
35476
|
+
delete: async (sourcedId) => {
|
|
35477
|
+
await client["request"](`${ONEROSTER_ENDPOINTS4.enrollments}/${sourcedId}`, "DELETE");
|
|
35090
35478
|
}
|
|
35091
35479
|
},
|
|
35092
35480
|
organizations: {
|
|
@@ -35357,6 +35745,7 @@ class AdminEventRecorder {
|
|
|
35357
35745
|
await this.caliper.emitActivityEvent({
|
|
35358
35746
|
studentId: ctx.student.id,
|
|
35359
35747
|
studentEmail: ctx.student.email,
|
|
35748
|
+
gameId: data.gameId,
|
|
35360
35749
|
activityId: ctx.activityId,
|
|
35361
35750
|
activityName: data.activityName || `Manual XP Assignment - ${ctx.courseContext.subject}`,
|
|
35362
35751
|
courseId: data.courseId,
|
|
@@ -35383,6 +35772,7 @@ class AdminEventRecorder {
|
|
|
35383
35772
|
await this.caliper.emitTimeSpentEvent({
|
|
35384
35773
|
studentId: ctx.student.id,
|
|
35385
35774
|
studentEmail: ctx.student.email,
|
|
35775
|
+
gameId: data.gameId,
|
|
35386
35776
|
activityId: ctx.activityId,
|
|
35387
35777
|
activityName: data.activityName || "Playcademy Admin Time Adjustment",
|
|
35388
35778
|
courseId: data.courseId,
|
|
@@ -35404,6 +35794,7 @@ class AdminEventRecorder {
|
|
|
35404
35794
|
await this.caliper.emitActivityEvent({
|
|
35405
35795
|
studentId: ctx.student.id,
|
|
35406
35796
|
studentEmail: ctx.student.email,
|
|
35797
|
+
gameId: data.gameId,
|
|
35407
35798
|
activityId: ctx.activityId,
|
|
35408
35799
|
activityName: data.activityName || "Playcademy Admin Mastery Adjustment",
|
|
35409
35800
|
courseId: data.courseId,
|
|
@@ -35429,6 +35820,7 @@ class AdminEventRecorder {
|
|
|
35429
35820
|
await this.caliper.emitActivityEvent({
|
|
35430
35821
|
studentId: ctx.student.id,
|
|
35431
35822
|
studentEmail: ctx.student.email,
|
|
35823
|
+
gameId: data.gameId,
|
|
35432
35824
|
activityId: ctx.activityId,
|
|
35433
35825
|
activityName: isResume ? "Course resumed" : "Course marked complete",
|
|
35434
35826
|
courseId: data.courseId,
|
|
@@ -35877,15 +36269,13 @@ class ProgressRecorder {
|
|
|
35877
36269
|
studentId,
|
|
35878
36270
|
attemptNumber: currentAttemptNumber,
|
|
35879
36271
|
score,
|
|
35880
|
-
totalQuestions,
|
|
35881
|
-
correctQuestions,
|
|
35882
36272
|
xp: calculatedXp,
|
|
35883
|
-
masteredUnits,
|
|
35884
36273
|
scoreStatus,
|
|
35885
36274
|
inProgress,
|
|
35886
36275
|
appName: progressData.appName,
|
|
35887
|
-
|
|
35888
|
-
|
|
36276
|
+
totalQuestions,
|
|
36277
|
+
correctQuestions,
|
|
36278
|
+
masteredUnits
|
|
35889
36279
|
});
|
|
35890
36280
|
} else {
|
|
35891
36281
|
log.warn("[ProgressRecorder] Score not provided, skipping gradebook entry", {
|
|
@@ -35899,6 +36289,7 @@ class ProgressRecorder {
|
|
|
35899
36289
|
await this.emitCourseCompletionHistoryEvent({
|
|
35900
36290
|
studentId,
|
|
35901
36291
|
studentEmail,
|
|
36292
|
+
gameId: progressData.gameId,
|
|
35902
36293
|
activityId,
|
|
35903
36294
|
courseId: ids.course,
|
|
35904
36295
|
courseName,
|
|
@@ -35910,6 +36301,7 @@ class ProgressRecorder {
|
|
|
35910
36301
|
await this.emitCaliperEvent({
|
|
35911
36302
|
studentId,
|
|
35912
36303
|
studentEmail,
|
|
36304
|
+
gameId: progressData.gameId,
|
|
35913
36305
|
activityId,
|
|
35914
36306
|
activityName,
|
|
35915
36307
|
courseId: ids.course,
|
|
@@ -35920,7 +36312,8 @@ class ProgressRecorder {
|
|
|
35920
36312
|
masteredUnits,
|
|
35921
36313
|
attemptNumber: currentAttemptNumber,
|
|
35922
36314
|
progressData,
|
|
35923
|
-
extensions
|
|
36315
|
+
extensions,
|
|
36316
|
+
runId: progressData.runId
|
|
35924
36317
|
});
|
|
35925
36318
|
return {
|
|
35926
36319
|
xpAwarded: calculatedXp,
|
|
@@ -36010,15 +36403,13 @@ class ProgressRecorder {
|
|
|
36010
36403
|
studentId,
|
|
36011
36404
|
attemptNumber,
|
|
36012
36405
|
score,
|
|
36013
|
-
totalQuestions,
|
|
36014
|
-
correctQuestions,
|
|
36015
36406
|
xp,
|
|
36016
|
-
masteredUnits,
|
|
36017
36407
|
scoreStatus,
|
|
36018
36408
|
inProgress,
|
|
36019
36409
|
appName,
|
|
36020
|
-
|
|
36021
|
-
|
|
36410
|
+
totalQuestions,
|
|
36411
|
+
correctQuestions,
|
|
36412
|
+
masteredUnits
|
|
36022
36413
|
}) {
|
|
36023
36414
|
const timestamp3 = Date.now().toString(36);
|
|
36024
36415
|
const resultId = `${lineItemId}:${studentId}:${timestamp3}`;
|
|
@@ -36033,21 +36424,18 @@ class ProgressRecorder {
|
|
|
36033
36424
|
inProgress,
|
|
36034
36425
|
metadata: {
|
|
36035
36426
|
xp,
|
|
36036
|
-
totalQuestions,
|
|
36037
|
-
correctQuestions,
|
|
36038
|
-
accuracy: totalQuestions && correctQuestions ? correctQuestions / totalQuestions * 100 : undefined,
|
|
36039
36427
|
attemptNumber,
|
|
36040
|
-
lastUpdated: new Date().toISOString(),
|
|
36041
|
-
masteredUnits,
|
|
36042
36428
|
appName,
|
|
36043
|
-
|
|
36044
|
-
|
|
36429
|
+
...totalQuestions !== undefined ? { totalQuestions } : {},
|
|
36430
|
+
...correctQuestions !== undefined ? { correctQuestions } : {},
|
|
36431
|
+
...masteredUnits !== undefined ? { masteredUnits } : {}
|
|
36045
36432
|
}
|
|
36046
36433
|
});
|
|
36047
36434
|
}
|
|
36048
36435
|
async emitCaliperEvent({
|
|
36049
36436
|
studentId,
|
|
36050
36437
|
studentEmail,
|
|
36438
|
+
gameId,
|
|
36051
36439
|
activityId,
|
|
36052
36440
|
activityName,
|
|
36053
36441
|
courseId,
|
|
@@ -36058,11 +36446,13 @@ class ProgressRecorder {
|
|
|
36058
36446
|
masteredUnits,
|
|
36059
36447
|
attemptNumber,
|
|
36060
36448
|
progressData,
|
|
36061
|
-
extensions
|
|
36449
|
+
extensions,
|
|
36450
|
+
runId
|
|
36062
36451
|
}) {
|
|
36063
36452
|
await this.caliperNamespace.emitActivityEvent({
|
|
36064
36453
|
studentId,
|
|
36065
36454
|
studentEmail,
|
|
36455
|
+
gameId,
|
|
36066
36456
|
activityId,
|
|
36067
36457
|
activityName,
|
|
36068
36458
|
courseId,
|
|
@@ -36075,7 +36465,8 @@ class ProgressRecorder {
|
|
|
36075
36465
|
subject: progressData.subject,
|
|
36076
36466
|
appName: progressData.appName,
|
|
36077
36467
|
sensorUrl: progressData.sensorUrl,
|
|
36078
|
-
extensions: extensions || progressData.extensions
|
|
36468
|
+
extensions: extensions || progressData.extensions,
|
|
36469
|
+
...runId ? { runId } : {}
|
|
36079
36470
|
}).catch((error) => {
|
|
36080
36471
|
log.error("[ProgressRecorder] Failed to emit activity event", { error });
|
|
36081
36472
|
});
|
|
@@ -36084,6 +36475,7 @@ class ProgressRecorder {
|
|
|
36084
36475
|
await this.caliperNamespace.emitActivityEvent({
|
|
36085
36476
|
studentId: data.studentId,
|
|
36086
36477
|
studentEmail: data.studentEmail,
|
|
36478
|
+
gameId: data.gameId,
|
|
36087
36479
|
activityId: data.activityId,
|
|
36088
36480
|
activityName: "Course completed",
|
|
36089
36481
|
courseId: data.courseId,
|
|
@@ -36129,10 +36521,11 @@ class SessionRecorder {
|
|
|
36129
36521
|
const courseName = sessionData.courseName || "Game Course";
|
|
36130
36522
|
const student = await this.studentResolver.resolve(studentIdentifier, sessionData.studentEmail);
|
|
36131
36523
|
const { id: studentId, email: studentEmail } = student;
|
|
36132
|
-
const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions } = sessionData;
|
|
36524
|
+
const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions, runId } = sessionData;
|
|
36133
36525
|
await this.caliperNamespace.emitTimeSpentEvent({
|
|
36134
36526
|
studentId,
|
|
36135
36527
|
studentEmail,
|
|
36528
|
+
gameId: sessionData.gameId,
|
|
36136
36529
|
activityId,
|
|
36137
36530
|
activityName,
|
|
36138
36531
|
courseId: ids.course,
|
|
@@ -36143,6 +36536,7 @@ class SessionRecorder {
|
|
|
36143
36536
|
subject: sessionData.subject,
|
|
36144
36537
|
appName: sessionData.appName,
|
|
36145
36538
|
sensorUrl: sessionData.sensorUrl,
|
|
36539
|
+
...runId ? { runId } : {},
|
|
36146
36540
|
...extensions ? { extensions } : {}
|
|
36147
36541
|
});
|
|
36148
36542
|
}
|
|
@@ -92051,18 +92445,23 @@ async function seedCoreGames(db2) {
|
|
|
92051
92445
|
}
|
|
92052
92446
|
async function seedCurrentProjectGame(db2, project) {
|
|
92053
92447
|
const now2 = new Date;
|
|
92448
|
+
const desiredGameId = project.gameId?.trim() || undefined;
|
|
92054
92449
|
try {
|
|
92055
92450
|
const existingGame = await db2.query.games.findFirst({
|
|
92056
|
-
where: (row,
|
|
92451
|
+
where: (row, operators) => operators.eq(row.slug, project.slug)
|
|
92057
92452
|
});
|
|
92058
92453
|
if (existingGame) {
|
|
92059
|
-
if (
|
|
92060
|
-
await
|
|
92454
|
+
if (desiredGameId && existingGame.id !== desiredGameId) {
|
|
92455
|
+
await db2.delete(games).where(eq(games.id, existingGame.id));
|
|
92456
|
+
} else {
|
|
92457
|
+
if (project.timebackCourses && project.timebackCourses.length > 0) {
|
|
92458
|
+
await seedTimebackIntegrations(db2, existingGame.id, project.timebackCourses);
|
|
92459
|
+
}
|
|
92460
|
+
return existingGame;
|
|
92061
92461
|
}
|
|
92062
|
-
return existingGame;
|
|
92063
92462
|
}
|
|
92064
92463
|
const gameRecord = {
|
|
92065
|
-
id: crypto.randomUUID(),
|
|
92464
|
+
id: desiredGameId ?? crypto.randomUUID(),
|
|
92066
92465
|
developerId: DEMO_USERS.developer.id,
|
|
92067
92466
|
slug: project.slug,
|
|
92068
92467
|
displayName: project.displayName,
|
|
@@ -92091,6 +92490,7 @@ async function seedCurrentProjectGame(db2, project) {
|
|
|
92091
92490
|
}
|
|
92092
92491
|
}
|
|
92093
92492
|
var init_games = __esm(() => {
|
|
92493
|
+
init_drizzle_orm();
|
|
92094
92494
|
init_src();
|
|
92095
92495
|
init_tables_index();
|
|
92096
92496
|
init_constants();
|
|
@@ -93152,6 +93552,8 @@ var init_schemas2 = __esm(() => {
|
|
|
93152
93552
|
code: exports_external.string().optional(),
|
|
93153
93553
|
codeUploadToken: exports_external.string().optional(),
|
|
93154
93554
|
config: exports_external.unknown().optional(),
|
|
93555
|
+
compatibilityDate: exports_external.string().optional(),
|
|
93556
|
+
compatibilityFlags: exports_external.array(exports_external.string()).optional(),
|
|
93155
93557
|
bindings: exports_external.object({
|
|
93156
93558
|
database: exports_external.array(exports_external.string()).optional(),
|
|
93157
93559
|
keyValue: exports_external.array(exports_external.string()).optional(),
|
|
@@ -93417,7 +93819,7 @@ function isValidAdminAttributionDate(value) {
|
|
|
93417
93819
|
const date4 = new Date(Date.UTC(year3, month - 1, day, 12, 0, 0));
|
|
93418
93820
|
return date4.getUTCFullYear() === year3 && date4.getUTCMonth() + 1 === month && date4.getUTCDate() === day;
|
|
93419
93821
|
}
|
|
93420
|
-
var TIMEBACK_GRADES, TIMEBACK_SUBJECTS5, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, EndActivityRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema;
|
|
93822
|
+
var TIMEBACK_GRADES, TIMEBACK_SUBJECTS5, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema;
|
|
93421
93823
|
var init_schemas11 = __esm(() => {
|
|
93422
93824
|
init_esm();
|
|
93423
93825
|
TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
|
@@ -93440,31 +93842,55 @@ var init_schemas11 = __esm(() => {
|
|
|
93440
93842
|
xp: exports_external.number().min(0, "XP must be a non-negative number"),
|
|
93441
93843
|
userTimestamp: exports_external.string().datetime().optional()
|
|
93442
93844
|
});
|
|
93845
|
+
TimebackActivityDataSchema = exports_external.object({
|
|
93846
|
+
activityId: exports_external.string().min(1),
|
|
93847
|
+
activityName: exports_external.string().optional(),
|
|
93848
|
+
grade: TimebackGradeSchema,
|
|
93849
|
+
subject: TimebackSubjectSchema,
|
|
93850
|
+
appName: exports_external.string().optional(),
|
|
93851
|
+
sensorUrl: exports_external.string().url().optional(),
|
|
93852
|
+
courseId: exports_external.string().optional(),
|
|
93853
|
+
courseName: exports_external.string().optional(),
|
|
93854
|
+
studentEmail: exports_external.string().email().optional()
|
|
93855
|
+
});
|
|
93443
93856
|
EndActivityRequestSchema = exports_external.object({
|
|
93444
93857
|
gameId: exports_external.string().uuid(),
|
|
93445
93858
|
studentId: exports_external.string().min(1),
|
|
93446
|
-
|
|
93447
|
-
|
|
93448
|
-
|
|
93449
|
-
grade: TimebackGradeSchema,
|
|
93450
|
-
subject: TimebackSubjectSchema,
|
|
93451
|
-
appName: exports_external.string().optional(),
|
|
93452
|
-
sensorUrl: exports_external.string().url().optional(),
|
|
93453
|
-
courseId: exports_external.string().optional(),
|
|
93454
|
-
courseName: exports_external.string().optional(),
|
|
93455
|
-
studentEmail: exports_external.string().email().optional()
|
|
93456
|
-
}),
|
|
93859
|
+
runId: exports_external.string().uuid().optional(),
|
|
93860
|
+
resumeId: exports_external.string().uuid().optional(),
|
|
93861
|
+
activityData: TimebackActivityDataSchema,
|
|
93457
93862
|
scoreData: exports_external.object({
|
|
93458
93863
|
correctQuestions: exports_external.number().int().min(0),
|
|
93459
93864
|
totalQuestions: exports_external.number().int().min(0)
|
|
93460
93865
|
}),
|
|
93461
93866
|
timingData: exports_external.object({
|
|
93462
|
-
durationSeconds: exports_external.number().
|
|
93867
|
+
durationSeconds: exports_external.number().nonnegative()
|
|
93463
93868
|
}),
|
|
93869
|
+
sessionTimingData: exports_external.object({
|
|
93870
|
+
activeSeconds: exports_external.number().nonnegative(),
|
|
93871
|
+
inactiveSeconds: exports_external.number().nonnegative().optional()
|
|
93872
|
+
}).optional(),
|
|
93464
93873
|
xpEarned: exports_external.number().optional(),
|
|
93465
93874
|
masteredUnits: exports_external.number().nonnegative().optional(),
|
|
93466
93875
|
extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
|
|
93467
93876
|
});
|
|
93877
|
+
HeartbeatRequestSchema = exports_external.object({
|
|
93878
|
+
gameId: exports_external.string().uuid(),
|
|
93879
|
+
studentId: exports_external.string().min(1),
|
|
93880
|
+
runId: exports_external.string().uuid(),
|
|
93881
|
+
resumeId: exports_external.string().uuid().optional(),
|
|
93882
|
+
activityData: TimebackActivityDataSchema,
|
|
93883
|
+
timingData: exports_external.object({
|
|
93884
|
+
activeMs: exports_external.number().nonnegative(),
|
|
93885
|
+
pausedMs: exports_external.number().nonnegative()
|
|
93886
|
+
}),
|
|
93887
|
+
windowStartedAtMs: exports_external.number().int().nonnegative().optional(),
|
|
93888
|
+
windowSequence: exports_external.number().int().nonnegative().optional(),
|
|
93889
|
+
isFinal: exports_external.boolean().optional()
|
|
93890
|
+
}).refine((value) => value.windowStartedAtMs !== undefined !== (value.windowSequence !== undefined), {
|
|
93891
|
+
message: "Provide exactly one of windowStartedAtMs or windowSequence",
|
|
93892
|
+
path: ["windowStartedAtMs"]
|
|
93893
|
+
});
|
|
93468
93894
|
PopulateStudentRequestSchema = exports_external.object({
|
|
93469
93895
|
firstName: exports_external.string().min(1).optional(),
|
|
93470
93896
|
lastName: exports_external.string().min(1).optional()
|
|
@@ -93544,15 +93970,18 @@ var init_schemas11 = __esm(() => {
|
|
|
93544
93970
|
});
|
|
93545
93971
|
GrantTimebackXpRequestSchema = AdminTimebackMutationBaseSchema.extend({
|
|
93546
93972
|
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" }),
|
|
93547
|
-
date: AdminAttributionDateSchema.optional()
|
|
93973
|
+
date: AdminAttributionDateSchema.optional(),
|
|
93974
|
+
useCurrentTime: exports_external.boolean().optional()
|
|
93548
93975
|
});
|
|
93549
93976
|
AdjustTimebackTimeRequestSchema = AdminTimebackMutationBaseSchema.extend({
|
|
93550
93977
|
seconds: exports_external.number().refine((value) => value !== 0, { message: "Time amount cannot be 0" }),
|
|
93551
|
-
date: AdminAttributionDateSchema.optional()
|
|
93978
|
+
date: AdminAttributionDateSchema.optional(),
|
|
93979
|
+
useCurrentTime: exports_external.boolean().optional()
|
|
93552
93980
|
});
|
|
93553
93981
|
AdjustTimebackMasteryRequestSchema = AdminTimebackMutationBaseSchema.extend({
|
|
93554
93982
|
units: exports_external.number().refine((value) => value !== 0, { message: "Units cannot be 0" }),
|
|
93555
|
-
date: AdminAttributionDateSchema.optional()
|
|
93983
|
+
date: AdminAttributionDateSchema.optional(),
|
|
93984
|
+
useCurrentTime: exports_external.boolean().optional()
|
|
93556
93985
|
});
|
|
93557
93986
|
ToggleCourseCompletionRequestSchema = exports_external.object({
|
|
93558
93987
|
gameId: exports_external.string().uuid(),
|
|
@@ -93560,6 +93989,16 @@ var init_schemas11 = __esm(() => {
|
|
|
93560
93989
|
studentId: exports_external.string().min(1),
|
|
93561
93990
|
action: exports_external.enum(["complete", "resume"])
|
|
93562
93991
|
});
|
|
93992
|
+
EnrollStudentRequestSchema = exports_external.object({
|
|
93993
|
+
gameId: exports_external.string().uuid(),
|
|
93994
|
+
courseId: exports_external.string().min(1),
|
|
93995
|
+
studentId: exports_external.string().min(1)
|
|
93996
|
+
});
|
|
93997
|
+
UnenrollStudentRequestSchema = exports_external.object({
|
|
93998
|
+
gameId: exports_external.string().uuid(),
|
|
93999
|
+
courseId: exports_external.string().min(1),
|
|
94000
|
+
studentId: exports_external.string().min(1)
|
|
94001
|
+
});
|
|
93563
94002
|
});
|
|
93564
94003
|
|
|
93565
94004
|
// ../data/src/schemas.index.ts
|
|
@@ -93586,6 +94025,9 @@ function isAuthenticated(ctx) {
|
|
|
93586
94025
|
var init_types9 = () => {};
|
|
93587
94026
|
|
|
93588
94027
|
// ../api-core/src/utils/auth.util.ts
|
|
94028
|
+
function hasGameManagementAccess(user) {
|
|
94029
|
+
return user.role === "admin" || user.role === "teacher" || user.role === "developer" && user.developerStatus === "approved";
|
|
94030
|
+
}
|
|
93589
94031
|
function requireAuth(handler) {
|
|
93590
94032
|
return async (ctx) => {
|
|
93591
94033
|
if (!isAuthenticated(ctx)) {
|
|
@@ -93629,6 +94071,17 @@ function requireDeveloper(handler) {
|
|
|
93629
94071
|
return handler(ctx);
|
|
93630
94072
|
};
|
|
93631
94073
|
}
|
|
94074
|
+
function requireGameManagementAccess(handler) {
|
|
94075
|
+
return async (ctx) => {
|
|
94076
|
+
if (!isAuthenticated(ctx)) {
|
|
94077
|
+
throw ApiError.unauthorized("Valid session or bearer token required");
|
|
94078
|
+
}
|
|
94079
|
+
if (!hasGameManagementAccess(ctx.user)) {
|
|
94080
|
+
throw ApiError.forbidden("Game management access required");
|
|
94081
|
+
}
|
|
94082
|
+
return handler(ctx);
|
|
94083
|
+
};
|
|
94084
|
+
}
|
|
93632
94085
|
var init_auth_util = __esm(() => {
|
|
93633
94086
|
init_errors();
|
|
93634
94087
|
init_types9();
|
|
@@ -95686,7 +96139,7 @@ var init_sprite_controller = __esm(() => {
|
|
|
95686
96139
|
});
|
|
95687
96140
|
|
|
95688
96141
|
// ../api-core/src/controllers/timeback.controller.ts
|
|
95689
|
-
var logger63, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, getStudentXp, getRoster, getStudentOverview, getStudentActivity, grantXp, adjustTime, adjustMastery, toggleCompletion, timeback2;
|
|
96142
|
+
var logger63, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, heartbeat, getStudentXp, getRoster, getStudentOverview, getStudentActivity, grantXp, adjustTime, adjustMastery, toggleCompletion, searchStudents, enrollStudent, unenrollStudent, timeback2;
|
|
95690
96143
|
var init_timeback_controller = __esm(() => {
|
|
95691
96144
|
init_esm();
|
|
95692
96145
|
init_schemas_index();
|
|
@@ -95776,7 +96229,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95776
96229
|
});
|
|
95777
96230
|
return ctx.services.timeback.setupIntegration(body2.gameId, body2, ctx.user);
|
|
95778
96231
|
});
|
|
95779
|
-
getIntegrations =
|
|
96232
|
+
getIntegrations = requireGameManagementAccess(async (ctx) => {
|
|
95780
96233
|
const gameId = ctx.params.gameId;
|
|
95781
96234
|
if (!gameId) {
|
|
95782
96235
|
throw ApiError.badRequest("Missing gameId");
|
|
@@ -95836,9 +96289,12 @@ var init_timeback_controller = __esm(() => {
|
|
|
95836
96289
|
const {
|
|
95837
96290
|
gameId,
|
|
95838
96291
|
studentId,
|
|
96292
|
+
runId,
|
|
96293
|
+
resumeId,
|
|
95839
96294
|
activityData,
|
|
95840
96295
|
scoreData,
|
|
95841
96296
|
timingData,
|
|
96297
|
+
sessionTimingData,
|
|
95842
96298
|
xpEarned,
|
|
95843
96299
|
masteredUnits,
|
|
95844
96300
|
extensions
|
|
@@ -95847,15 +96303,65 @@ var init_timeback_controller = __esm(() => {
|
|
|
95847
96303
|
return ctx.services.timeback.endActivity({
|
|
95848
96304
|
gameId,
|
|
95849
96305
|
studentId,
|
|
96306
|
+
runId,
|
|
96307
|
+
resumeId,
|
|
95850
96308
|
activityData,
|
|
95851
96309
|
scoreData,
|
|
95852
96310
|
timingData,
|
|
96311
|
+
sessionTimingData,
|
|
95853
96312
|
xpEarned,
|
|
95854
96313
|
masteredUnits,
|
|
95855
96314
|
extensions,
|
|
95856
96315
|
user: ctx.user
|
|
95857
96316
|
});
|
|
95858
96317
|
});
|
|
96318
|
+
heartbeat = requireDeveloper(async (ctx) => {
|
|
96319
|
+
let body2;
|
|
96320
|
+
try {
|
|
96321
|
+
const json4 = await ctx.request.json();
|
|
96322
|
+
body2 = HeartbeatRequestSchema.parse(json4);
|
|
96323
|
+
} catch (error2) {
|
|
96324
|
+
if (error2 instanceof exports_external.ZodError) {
|
|
96325
|
+
const details = formatZodError(error2);
|
|
96326
|
+
logger63.warn("Heartbeat validation failed", { details });
|
|
96327
|
+
throw ApiError.unprocessableEntity("Validation failed", details);
|
|
96328
|
+
}
|
|
96329
|
+
throw ApiError.badRequest("Invalid JSON body");
|
|
96330
|
+
}
|
|
96331
|
+
const {
|
|
96332
|
+
gameId,
|
|
96333
|
+
studentId,
|
|
96334
|
+
runId,
|
|
96335
|
+
resumeId,
|
|
96336
|
+
activityData,
|
|
96337
|
+
timingData,
|
|
96338
|
+
windowStartedAtMs,
|
|
96339
|
+
windowSequence,
|
|
96340
|
+
isFinal
|
|
96341
|
+
} = body2;
|
|
96342
|
+
logger63.debug("Recording heartbeat", {
|
|
96343
|
+
userId: ctx.user.id,
|
|
96344
|
+
gameId,
|
|
96345
|
+
runId,
|
|
96346
|
+
resumeId,
|
|
96347
|
+
windowStartedAtMs,
|
|
96348
|
+
windowSequence,
|
|
96349
|
+
activeMs: timingData.activeMs,
|
|
96350
|
+
isFinal
|
|
96351
|
+
});
|
|
96352
|
+
return ctx.services.timeback.recordHeartbeat({
|
|
96353
|
+
gameId,
|
|
96354
|
+
studentId,
|
|
96355
|
+
runId,
|
|
96356
|
+
resumeId,
|
|
96357
|
+
activityData,
|
|
96358
|
+
timingData,
|
|
96359
|
+
windowStartedAtMs,
|
|
96360
|
+
windowSequence,
|
|
96361
|
+
isFinal,
|
|
96362
|
+
user: ctx.user
|
|
96363
|
+
});
|
|
96364
|
+
});
|
|
95859
96365
|
getStudentXp = requireDeveloper(async (ctx) => {
|
|
95860
96366
|
const timebackId = ctx.params.timebackId;
|
|
95861
96367
|
if (!timebackId) {
|
|
@@ -95901,7 +96407,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95901
96407
|
include
|
|
95902
96408
|
});
|
|
95903
96409
|
});
|
|
95904
|
-
getRoster =
|
|
96410
|
+
getRoster = requireGameManagementAccess(async (ctx) => {
|
|
95905
96411
|
const gameId = ctx.params.gameId;
|
|
95906
96412
|
const courseId = ctx.params.courseId;
|
|
95907
96413
|
if (!gameId || !courseId) {
|
|
@@ -95914,7 +96420,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95914
96420
|
});
|
|
95915
96421
|
return ctx.services.timebackAdmin.listStudentsForCourse(gameId, courseId, ctx.user);
|
|
95916
96422
|
});
|
|
95917
|
-
getStudentOverview =
|
|
96423
|
+
getStudentOverview = requireGameManagementAccess(async (ctx) => {
|
|
95918
96424
|
const timebackId = ctx.params.timebackId;
|
|
95919
96425
|
const gameId = ctx.url.searchParams.get("gameId") || undefined;
|
|
95920
96426
|
const courseId = ctx.url.searchParams.get("courseId") || undefined;
|
|
@@ -95929,7 +96435,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95929
96435
|
});
|
|
95930
96436
|
return ctx.services.timebackAdmin.getStudentOverview(gameId, timebackId, ctx.user, courseId);
|
|
95931
96437
|
});
|
|
95932
|
-
getStudentActivity =
|
|
96438
|
+
getStudentActivity = requireGameManagementAccess(async (ctx) => {
|
|
95933
96439
|
const timebackId = ctx.params.timebackId;
|
|
95934
96440
|
const courseId = ctx.params.courseId;
|
|
95935
96441
|
const gameId = ctx.url.searchParams.get("gameId") || undefined;
|
|
@@ -95992,7 +96498,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95992
96498
|
});
|
|
95993
96499
|
return ctx.services.timebackAdmin.adjustMasteredUnits(body2, ctx.user);
|
|
95994
96500
|
});
|
|
95995
|
-
toggleCompletion =
|
|
96501
|
+
toggleCompletion = requireGameManagementAccess(async (ctx) => {
|
|
95996
96502
|
const body2 = await parseRequestBody(ctx.request, ToggleCourseCompletionRequestSchema);
|
|
95997
96503
|
logger63.debug("Toggling course completion", {
|
|
95998
96504
|
requesterId: ctx.user.id,
|
|
@@ -96003,6 +96509,41 @@ var init_timeback_controller = __esm(() => {
|
|
|
96003
96509
|
});
|
|
96004
96510
|
return ctx.services.timebackAdmin.toggleCourseCompletion(body2, ctx.user);
|
|
96005
96511
|
});
|
|
96512
|
+
searchStudents = requireGameManagementAccess(async (ctx) => {
|
|
96513
|
+
const gameId = ctx.params.gameId;
|
|
96514
|
+
const courseId = ctx.params.courseId;
|
|
96515
|
+
const query = ctx.url.searchParams.get("q") || "";
|
|
96516
|
+
if (!gameId || !courseId) {
|
|
96517
|
+
throw ApiError.badRequest("Missing gameId or courseId parameter");
|
|
96518
|
+
}
|
|
96519
|
+
logger63.debug("Searching students for enrollment", {
|
|
96520
|
+
requesterId: ctx.user.id,
|
|
96521
|
+
gameId,
|
|
96522
|
+
courseId,
|
|
96523
|
+
query
|
|
96524
|
+
});
|
|
96525
|
+
return ctx.services.timebackAdmin.searchStudentsForEnrollment(gameId, courseId, query, ctx.user);
|
|
96526
|
+
});
|
|
96527
|
+
enrollStudent = requireGameManagementAccess(async (ctx) => {
|
|
96528
|
+
const body2 = await parseRequestBody(ctx.request, EnrollStudentRequestSchema);
|
|
96529
|
+
logger63.debug("Enrolling student", {
|
|
96530
|
+
requesterId: ctx.user.id,
|
|
96531
|
+
gameId: body2.gameId,
|
|
96532
|
+
courseId: body2.courseId,
|
|
96533
|
+
studentId: body2.studentId
|
|
96534
|
+
});
|
|
96535
|
+
return ctx.services.timebackAdmin.enrollStudent(body2, ctx.user);
|
|
96536
|
+
});
|
|
96537
|
+
unenrollStudent = requireGameManagementAccess(async (ctx) => {
|
|
96538
|
+
const body2 = await parseRequestBody(ctx.request, UnenrollStudentRequestSchema);
|
|
96539
|
+
logger63.debug("Unenrolling student", {
|
|
96540
|
+
requesterId: ctx.user.id,
|
|
96541
|
+
gameId: body2.gameId,
|
|
96542
|
+
courseId: body2.courseId,
|
|
96543
|
+
studentId: body2.studentId
|
|
96544
|
+
});
|
|
96545
|
+
return ctx.services.timebackAdmin.unenrollStudent(body2, ctx.user);
|
|
96546
|
+
});
|
|
96006
96547
|
timeback2 = {
|
|
96007
96548
|
getTodayXp,
|
|
96008
96549
|
getTotalXp,
|
|
@@ -96017,6 +96558,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
96017
96558
|
getConfig: getConfig2,
|
|
96018
96559
|
deleteIntegrations,
|
|
96019
96560
|
endActivity,
|
|
96561
|
+
heartbeat,
|
|
96020
96562
|
getStudentXp,
|
|
96021
96563
|
getRoster,
|
|
96022
96564
|
getStudentOverview,
|
|
@@ -96024,7 +96566,10 @@ var init_timeback_controller = __esm(() => {
|
|
|
96024
96566
|
grantXp,
|
|
96025
96567
|
adjustTime,
|
|
96026
96568
|
adjustMastery,
|
|
96027
|
-
toggleCompletion
|
|
96569
|
+
toggleCompletion,
|
|
96570
|
+
searchStudents,
|
|
96571
|
+
enrollStudent,
|
|
96572
|
+
unenrollStudent
|
|
96028
96573
|
};
|
|
96029
96574
|
});
|
|
96030
96575
|
|
|
@@ -97065,6 +97610,7 @@ var init_timeback6 = __esm(() => {
|
|
|
97065
97610
|
timebackRouter.get("/config/:gameId", handle2(timeback2.getConfig));
|
|
97066
97611
|
timebackRouter.delete("/integrations/:gameId", handle2(timeback2.deleteIntegrations, { status: 204 }));
|
|
97067
97612
|
timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
|
|
97613
|
+
timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
|
|
97068
97614
|
timebackRouter.get("/user", async (c2) => {
|
|
97069
97615
|
const user = c2.get("user");
|
|
97070
97616
|
const gameId = c2.get("gameId");
|
|
@@ -99285,6 +99831,7 @@ function printBanner(options) {
|
|
|
99285
99831
|
}
|
|
99286
99832
|
|
|
99287
99833
|
// src/cli/options.ts
|
|
99834
|
+
init_src4();
|
|
99288
99835
|
import { resolve as resolve4 } from "node:path";
|
|
99289
99836
|
|
|
99290
99837
|
// ../utils/src/file-loader.ts
|
|
@@ -99472,7 +100019,10 @@ async function parseProjectInfo(options) {
|
|
|
99472
100019
|
}
|
|
99473
100020
|
const config2 = await loadPlaycademyConfig(options.configPath);
|
|
99474
100021
|
const timebackCourses = config2 ? extractTimebackCourses(config2) : undefined;
|
|
100022
|
+
const envGameId = process.env.SANDBOX_GAME_ID;
|
|
100023
|
+
const gameId = envGameId && isValidUUID(envGameId) ? envGameId : undefined;
|
|
99475
100024
|
return {
|
|
100025
|
+
gameId,
|
|
99476
100026
|
slug: options.projectSlug,
|
|
99477
100027
|
displayName: options.projectName,
|
|
99478
100028
|
version: "1.0.0",
|