@playcademy/sandbox 0.3.16 → 0.3.17-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1718 -1127
- package/dist/constants.js +3 -2
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1714 -1127
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -398,7 +398,8 @@ var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME =
|
|
|
398
398
|
var init_timeback2 = __esm(() => {
|
|
399
399
|
TIMEBACK_ROUTES = {
|
|
400
400
|
END_ACTIVITY: "/integrations/timeback/end-activity",
|
|
401
|
-
GET_XP: "/integrations/timeback/xp"
|
|
401
|
+
GET_XP: "/integrations/timeback/xp",
|
|
402
|
+
HEARTBEAT: "/integrations/timeback/heartbeat"
|
|
402
403
|
};
|
|
403
404
|
TIMEBACK_COURSE_DEFAULTS = {
|
|
404
405
|
gradingScheme: "STANDARD",
|
|
@@ -439,7 +440,7 @@ var init_timeback2 = __esm(() => {
|
|
|
439
440
|
});
|
|
440
441
|
|
|
441
442
|
// ../constants/src/workers.ts
|
|
442
|
-
var WORKER_NAMING, SECRETS_PREFIX = "secrets_";
|
|
443
|
+
var WORKER_NAMING, SECRETS_PREFIX = "secrets_", CLOUDFLARE_COMPATIBILITY_DATE = "2025-10-11";
|
|
443
444
|
var init_workers = __esm(() => {
|
|
444
445
|
WORKER_NAMING = {
|
|
445
446
|
STAGING_PREFIX: "staging-",
|
|
@@ -1309,7 +1310,7 @@ var package_default;
|
|
|
1309
1310
|
var init_package = __esm(() => {
|
|
1310
1311
|
package_default = {
|
|
1311
1312
|
name: "@playcademy/sandbox",
|
|
1312
|
-
version: "0.3.
|
|
1313
|
+
version: "0.3.17-beta.10",
|
|
1313
1314
|
description: "Local development server for Playcademy game development",
|
|
1314
1315
|
type: "module",
|
|
1315
1316
|
exports: {
|
|
@@ -5832,6 +5833,7 @@ var init_esm = __esm(() => {
|
|
|
5832
5833
|
function createMinimalConfig(overrides) {
|
|
5833
5834
|
return apiConfigSchema.parse({
|
|
5834
5835
|
stage: "local",
|
|
5836
|
+
isLocal: false,
|
|
5835
5837
|
...overrides
|
|
5836
5838
|
});
|
|
5837
5839
|
}
|
|
@@ -5856,6 +5858,7 @@ var init_schema = __esm(() => {
|
|
|
5856
5858
|
});
|
|
5857
5859
|
apiConfigSchema = exports_external.object({
|
|
5858
5860
|
stage: stageSchema,
|
|
5861
|
+
isLocal: exports_external.boolean().default(false),
|
|
5859
5862
|
baseUrl: exports_external.string().url().optional(),
|
|
5860
5863
|
gameDomain: exports_external.string().optional(),
|
|
5861
5864
|
lti: ltiConfigSchema.optional(),
|
|
@@ -11549,7 +11552,7 @@ var init_table6 = __esm(() => {
|
|
|
11549
11552
|
init_drizzle_orm();
|
|
11550
11553
|
init_pg_core();
|
|
11551
11554
|
init_table5();
|
|
11552
|
-
userRoleEnum = pgEnum("user_role", ["admin", "player", "developer"]);
|
|
11555
|
+
userRoleEnum = pgEnum("user_role", ["admin", "player", "developer", "teacher"]);
|
|
11553
11556
|
developerStatusEnum = pgEnum("developer_status", ["none", "pending", "approved"]);
|
|
11554
11557
|
users = pgTable("user", {
|
|
11555
11558
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
@@ -23743,13 +23746,11 @@ var init_dedent = __esm(() => {
|
|
|
23743
23746
|
});
|
|
23744
23747
|
|
|
23745
23748
|
// ../cloudflare/src/core/namespaces/workers.ts
|
|
23746
|
-
var DEFAULT_COMPATIBILITY_DATE;
|
|
23747
23749
|
var init_workers2 = __esm(() => {
|
|
23748
23750
|
init_dedent();
|
|
23749
23751
|
init_src2();
|
|
23750
23752
|
init_assets();
|
|
23751
23753
|
init_multipart();
|
|
23752
|
-
DEFAULT_COMPATIBILITY_DATE = new Date().toISOString().slice(0, 10);
|
|
23753
23754
|
});
|
|
23754
23755
|
|
|
23755
23756
|
// ../cloudflare/src/core/namespaces/index.ts
|
|
@@ -23771,10 +23772,9 @@ var init_core = __esm(() => {
|
|
|
23771
23772
|
});
|
|
23772
23773
|
|
|
23773
23774
|
// ../cloudflare/src/playcademy/constants.ts
|
|
23774
|
-
var CUSTOM_DOMAINS_KV_NAME = "cademy-custom-domains", QUEUE_NAME_PREFIX = "playcademy",
|
|
23775
|
+
var CUSTOM_DOMAINS_KV_NAME = "cademy-custom-domains", QUEUE_NAME_PREFIX = "playcademy", GAME_WORKER_DOMAIN_PRODUCTION, GAME_WORKER_DOMAIN_STAGING;
|
|
23775
23776
|
var init_constants2 = __esm(() => {
|
|
23776
23777
|
init_src();
|
|
23777
|
-
DEFAULT_COMPATIBILITY_DATE2 = new Date().toISOString().slice(0, 10);
|
|
23778
23778
|
GAME_WORKER_DOMAIN_PRODUCTION = GAME_WORKER_DOMAINS.production;
|
|
23779
23779
|
GAME_WORKER_DOMAIN_STAGING = GAME_WORKER_DOMAINS.staging;
|
|
23780
23780
|
});
|
|
@@ -26478,6 +26478,8 @@ class DeployService {
|
|
|
26478
26478
|
try {
|
|
26479
26479
|
result = await this.timeStep("Cloudflare deploy", () => cf.deploy(deploymentId, request.code, env, {
|
|
26480
26480
|
...deploymentOptions,
|
|
26481
|
+
compatibilityDate: request.compatibilityDate ?? CLOUDFLARE_COMPATIBILITY_DATE,
|
|
26482
|
+
compatibilityFlags: request.compatibilityFlags,
|
|
26481
26483
|
existingResources: activeDeployment?.resources ?? undefined,
|
|
26482
26484
|
assetsPath: frontendAssetsPath,
|
|
26483
26485
|
keepAssets
|
|
@@ -26578,6 +26580,7 @@ var logger3;
|
|
|
26578
26580
|
var init_deploy_service = __esm(() => {
|
|
26579
26581
|
init_drizzle_orm();
|
|
26580
26582
|
init_playcademy();
|
|
26583
|
+
init_src();
|
|
26581
26584
|
init_tables_index();
|
|
26582
26585
|
init_src2();
|
|
26583
26586
|
init_config2();
|
|
@@ -26646,447 +26649,549 @@ var init_developer_service = __esm(() => {
|
|
|
26646
26649
|
logger4 = log.scope("DeveloperService");
|
|
26647
26650
|
});
|
|
26648
26651
|
|
|
26649
|
-
// ../
|
|
26650
|
-
|
|
26651
|
-
|
|
26652
|
-
|
|
26653
|
-
static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
|
|
26654
|
-
constructor(deps) {
|
|
26655
|
-
this.deps = deps;
|
|
26656
|
-
}
|
|
26657
|
-
static getManifestHost(manifestUrl) {
|
|
26658
|
-
try {
|
|
26659
|
-
return new URL(manifestUrl).host;
|
|
26660
|
-
} catch {
|
|
26661
|
-
return manifestUrl;
|
|
26662
|
-
}
|
|
26652
|
+
// ../utils/src/fns.ts
|
|
26653
|
+
function sleep(ms) {
|
|
26654
|
+
if (ms <= 0) {
|
|
26655
|
+
return Promise.resolve();
|
|
26663
26656
|
}
|
|
26664
|
-
|
|
26665
|
-
|
|
26666
|
-
|
|
26667
|
-
|
|
26668
|
-
|
|
26669
|
-
|
|
26670
|
-
|
|
26671
|
-
|
|
26672
|
-
|
|
26657
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26658
|
+
}
|
|
26659
|
+
|
|
26660
|
+
// ../api-core/src/services/game.service.ts
|
|
26661
|
+
var logger5, inFlightManifestFetches, GameService;
|
|
26662
|
+
var init_game_service = __esm(() => {
|
|
26663
|
+
init_drizzle_orm();
|
|
26664
|
+
init_tables_index();
|
|
26665
|
+
init_src2();
|
|
26666
|
+
init_errors();
|
|
26667
|
+
init_deployment_util();
|
|
26668
|
+
logger5 = log.scope("GameService");
|
|
26669
|
+
inFlightManifestFetches = new Map;
|
|
26670
|
+
GameService = class GameService {
|
|
26671
|
+
deps;
|
|
26672
|
+
static MANIFEST_FETCH_ATTEMPT_TIMEOUT_MS = 1e4;
|
|
26673
|
+
static MANIFEST_FETCH_MAX_RETRIES = 2;
|
|
26674
|
+
static MANIFEST_FETCH_RETRY_BACKOFF_MS = [250, 750];
|
|
26675
|
+
static MANIFEST_CACHE_TTL_SECONDS = 60;
|
|
26676
|
+
static MANIFEST_CACHE_KEY_PREFIX = "game:manifest";
|
|
26677
|
+
static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
|
|
26678
|
+
constructor(deps) {
|
|
26679
|
+
this.deps = deps;
|
|
26680
|
+
}
|
|
26681
|
+
static getManifestHost(manifestUrl) {
|
|
26682
|
+
try {
|
|
26683
|
+
return new URL(manifestUrl).host;
|
|
26684
|
+
} catch {
|
|
26685
|
+
return manifestUrl;
|
|
26686
|
+
}
|
|
26673
26687
|
}
|
|
26674
|
-
|
|
26675
|
-
|
|
26676
|
-
|
|
26688
|
+
static getFetchErrorMessage(error) {
|
|
26689
|
+
let raw;
|
|
26690
|
+
if (error instanceof Error) {
|
|
26691
|
+
raw = error.message;
|
|
26692
|
+
} else if (typeof error === "string") {
|
|
26693
|
+
raw = error;
|
|
26694
|
+
}
|
|
26695
|
+
if (!raw) {
|
|
26696
|
+
return;
|
|
26697
|
+
}
|
|
26698
|
+
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
26699
|
+
if (!normalized) {
|
|
26700
|
+
return;
|
|
26701
|
+
}
|
|
26702
|
+
return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
|
|
26677
26703
|
}
|
|
26678
|
-
|
|
26679
|
-
|
|
26680
|
-
static isRetryableStatus(status) {
|
|
26681
|
-
return status === 429 || status >= 500;
|
|
26682
|
-
}
|
|
26683
|
-
async list(caller) {
|
|
26684
|
-
const db2 = this.deps.db;
|
|
26685
|
-
const isAdmin = caller?.role === "admin";
|
|
26686
|
-
const isDeveloper = caller?.role === "developer";
|
|
26687
|
-
let whereClause;
|
|
26688
|
-
if (isAdmin) {
|
|
26689
|
-
whereClause = undefined;
|
|
26690
|
-
} else if (isDeveloper && caller?.id) {
|
|
26691
|
-
whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
|
|
26692
|
-
} else {
|
|
26693
|
-
whereClause = ne(games.visibility, "internal");
|
|
26704
|
+
static isRetryableStatus(status) {
|
|
26705
|
+
return status === 429 || status >= 500;
|
|
26694
26706
|
}
|
|
26695
|
-
|
|
26696
|
-
|
|
26697
|
-
|
|
26698
|
-
|
|
26699
|
-
}
|
|
26700
|
-
async listManageable(user) {
|
|
26701
|
-
this.validateDeveloperStatus(user);
|
|
26702
|
-
const db2 = this.deps.db;
|
|
26703
|
-
return db2.query.games.findMany({
|
|
26704
|
-
where: user.role === "admin" ? undefined : eq(games.developerId, user.id),
|
|
26705
|
-
orderBy: [desc(games.createdAt)]
|
|
26706
|
-
});
|
|
26707
|
-
}
|
|
26708
|
-
async getSubjects() {
|
|
26709
|
-
const db2 = this.deps.db;
|
|
26710
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
26711
|
-
columns: { gameId: true, subject: true },
|
|
26712
|
-
orderBy: [asc(gameTimebackIntegrations.createdAt)]
|
|
26713
|
-
});
|
|
26714
|
-
const subjectMap = {};
|
|
26715
|
-
for (const integration of integrations) {
|
|
26716
|
-
if (!(integration.gameId in subjectMap)) {
|
|
26717
|
-
subjectMap[integration.gameId] = integration.subject;
|
|
26707
|
+
static getRetryBackoffMs(attemptIndex) {
|
|
26708
|
+
const backoff = GameService.MANIFEST_FETCH_RETRY_BACKOFF_MS;
|
|
26709
|
+
if (backoff.length === 0) {
|
|
26710
|
+
return 0;
|
|
26718
26711
|
}
|
|
26712
|
+
return backoff[Math.min(attemptIndex, backoff.length - 1)] ?? 0;
|
|
26719
26713
|
}
|
|
26720
|
-
|
|
26721
|
-
|
|
26722
|
-
async getById(gameId, caller) {
|
|
26723
|
-
const db2 = this.deps.db;
|
|
26724
|
-
const game = await db2.query.games.findFirst({
|
|
26725
|
-
where: eq(games.id, gameId)
|
|
26726
|
-
});
|
|
26727
|
-
if (!game) {
|
|
26728
|
-
throw new NotFoundError("Game", gameId);
|
|
26714
|
+
static normalizeDeploymentUrl(deploymentUrl) {
|
|
26715
|
+
return deploymentUrl.replace(/\/$/, "");
|
|
26729
26716
|
}
|
|
26730
|
-
|
|
26731
|
-
|
|
26732
|
-
}
|
|
26733
|
-
async getBySlug(slug, caller) {
|
|
26734
|
-
const db2 = this.deps.db;
|
|
26735
|
-
const game = await db2.query.games.findFirst({
|
|
26736
|
-
where: eq(games.slug, slug)
|
|
26737
|
-
});
|
|
26738
|
-
if (!game) {
|
|
26739
|
-
throw new NotFoundError("Game", slug);
|
|
26740
|
-
}
|
|
26741
|
-
this.enforceVisibility(game, caller, slug);
|
|
26742
|
-
return game;
|
|
26743
|
-
}
|
|
26744
|
-
async getManifest(gameId, caller) {
|
|
26745
|
-
const game = await this.getById(gameId, caller);
|
|
26746
|
-
if (game.gameType !== "hosted" || !game.deploymentUrl) {
|
|
26747
|
-
throw new BadRequestError("Game does not have a deployment manifest");
|
|
26717
|
+
static getManifestCacheKey(deploymentUrl) {
|
|
26718
|
+
return `${GameService.MANIFEST_CACHE_KEY_PREFIX}:${deploymentUrl}`;
|
|
26748
26719
|
}
|
|
26749
|
-
|
|
26750
|
-
|
|
26751
|
-
|
|
26752
|
-
|
|
26753
|
-
|
|
26754
|
-
|
|
26755
|
-
|
|
26756
|
-
|
|
26757
|
-
|
|
26758
|
-
|
|
26759
|
-
|
|
26760
|
-
|
|
26761
|
-
|
|
26762
|
-
|
|
26763
|
-
|
|
26764
|
-
|
|
26765
|
-
};
|
|
26720
|
+
async list(caller) {
|
|
26721
|
+
const db2 = this.deps.db;
|
|
26722
|
+
const isAdmin = caller?.role === "admin";
|
|
26723
|
+
const isDeveloper = caller?.role === "developer";
|
|
26724
|
+
let whereClause;
|
|
26725
|
+
if (isAdmin) {
|
|
26726
|
+
whereClause = undefined;
|
|
26727
|
+
} else if (isDeveloper && caller?.id) {
|
|
26728
|
+
whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
|
|
26729
|
+
} else {
|
|
26730
|
+
whereClause = ne(games.visibility, "internal");
|
|
26731
|
+
}
|
|
26732
|
+
return db2.query.games.findMany({
|
|
26733
|
+
where: whereClause,
|
|
26734
|
+
orderBy: [desc(games.createdAt)]
|
|
26735
|
+
});
|
|
26766
26736
|
}
|
|
26767
|
-
|
|
26768
|
-
|
|
26769
|
-
|
|
26770
|
-
|
|
26771
|
-
|
|
26772
|
-
|
|
26773
|
-
|
|
26774
|
-
|
|
26737
|
+
async listManageable(user) {
|
|
26738
|
+
const seesAllGames = user.role === "admin" || user.role === "teacher";
|
|
26739
|
+
if (!seesAllGames) {
|
|
26740
|
+
this.validateDeveloperStatus(user);
|
|
26741
|
+
}
|
|
26742
|
+
const db2 = this.deps.db;
|
|
26743
|
+
return db2.query.games.findMany({
|
|
26744
|
+
where: seesAllGames ? undefined : eq(games.developerId, user.id),
|
|
26745
|
+
orderBy: [desc(games.createdAt)]
|
|
26775
26746
|
});
|
|
26776
|
-
}
|
|
26777
|
-
|
|
26778
|
-
const
|
|
26779
|
-
const
|
|
26780
|
-
|
|
26781
|
-
|
|
26782
|
-
manifestUrl,
|
|
26783
|
-
error,
|
|
26784
|
-
details
|
|
26747
|
+
}
|
|
26748
|
+
async getSubjects() {
|
|
26749
|
+
const db2 = this.deps.db;
|
|
26750
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
26751
|
+
columns: { gameId: true, subject: true },
|
|
26752
|
+
orderBy: [asc(gameTimebackIntegrations.createdAt)]
|
|
26785
26753
|
});
|
|
26786
|
-
|
|
26787
|
-
|
|
26754
|
+
const subjectMap = {};
|
|
26755
|
+
for (const integration of integrations) {
|
|
26756
|
+
if (!(integration.gameId in subjectMap)) {
|
|
26757
|
+
subjectMap[integration.gameId] = integration.subject;
|
|
26758
|
+
}
|
|
26788
26759
|
}
|
|
26789
|
-
|
|
26790
|
-
} finally {
|
|
26791
|
-
clearTimeout(timeout);
|
|
26760
|
+
return subjectMap;
|
|
26792
26761
|
}
|
|
26793
|
-
|
|
26794
|
-
const
|
|
26795
|
-
const
|
|
26796
|
-
|
|
26797
|
-
const details = buildDetails("bad_status", manifestErrorKind, {
|
|
26798
|
-
manifestUrl: resolvedManifestUrl,
|
|
26799
|
-
manifestHost: resolvedManifestHost,
|
|
26800
|
-
status: response.status,
|
|
26801
|
-
contentType: response.headers.get("content-type") ?? undefined,
|
|
26802
|
-
cfRay: response.headers.get("cf-ray") ?? undefined,
|
|
26803
|
-
redirected: response.redirected,
|
|
26804
|
-
...response.redirected ? {
|
|
26805
|
-
originalManifestUrl: manifestUrl,
|
|
26806
|
-
originalManifestHost: manifestHost
|
|
26807
|
-
} : {}
|
|
26808
|
-
});
|
|
26809
|
-
const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
|
|
26810
|
-
logger5.error("Game manifest returned non-ok response", {
|
|
26811
|
-
gameId,
|
|
26812
|
-
manifestUrl,
|
|
26813
|
-
status: response.status,
|
|
26814
|
-
details
|
|
26762
|
+
async getById(gameId, caller) {
|
|
26763
|
+
const db2 = this.deps.db;
|
|
26764
|
+
const game = await db2.query.games.findFirst({
|
|
26765
|
+
where: eq(games.id, gameId)
|
|
26815
26766
|
});
|
|
26816
|
-
if (
|
|
26817
|
-
throw new
|
|
26767
|
+
if (!game) {
|
|
26768
|
+
throw new NotFoundError("Game", gameId);
|
|
26818
26769
|
}
|
|
26819
|
-
|
|
26770
|
+
this.enforceVisibility(game, caller, gameId);
|
|
26771
|
+
return game;
|
|
26820
26772
|
}
|
|
26821
|
-
|
|
26822
|
-
|
|
26823
|
-
|
|
26824
|
-
|
|
26825
|
-
const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
|
|
26826
|
-
const details = buildDetails("invalid_body", "permanent", {
|
|
26827
|
-
manifestUrl: resolvedManifestUrl,
|
|
26828
|
-
manifestHost: resolvedManifestHost,
|
|
26829
|
-
status: response.status,
|
|
26830
|
-
contentType: response.headers.get("content-type") ?? undefined,
|
|
26831
|
-
cfRay: response.headers.get("cf-ray") ?? undefined,
|
|
26832
|
-
redirected: response.redirected,
|
|
26833
|
-
...response.redirected ? {
|
|
26834
|
-
originalManifestUrl: manifestUrl,
|
|
26835
|
-
originalManifestHost: manifestHost
|
|
26836
|
-
} : {}
|
|
26837
|
-
});
|
|
26838
|
-
logger5.error("Failed to parse game manifest", {
|
|
26839
|
-
gameId,
|
|
26840
|
-
manifestUrl,
|
|
26841
|
-
error,
|
|
26842
|
-
details
|
|
26773
|
+
async getBySlug(slug, caller) {
|
|
26774
|
+
const db2 = this.deps.db;
|
|
26775
|
+
const game = await db2.query.games.findFirst({
|
|
26776
|
+
where: eq(games.slug, slug)
|
|
26843
26777
|
});
|
|
26844
|
-
|
|
26845
|
-
|
|
26846
|
-
}
|
|
26847
|
-
enforceVisibility(game, caller, lookupIdentifier) {
|
|
26848
|
-
if (game.visibility !== "internal") {
|
|
26849
|
-
return;
|
|
26850
|
-
}
|
|
26851
|
-
const isAdmin = caller?.role === "admin";
|
|
26852
|
-
const isOwner = caller?.id != null && caller.id === game.developerId;
|
|
26853
|
-
if (!isAdmin && !isOwner) {
|
|
26854
|
-
throw new NotFoundError("Game", lookupIdentifier);
|
|
26855
|
-
}
|
|
26856
|
-
}
|
|
26857
|
-
async upsertBySlug(slug, data, user) {
|
|
26858
|
-
const db2 = this.deps.db;
|
|
26859
|
-
const existingGame = await db2.query.games.findFirst({
|
|
26860
|
-
where: eq(games.slug, slug)
|
|
26861
|
-
});
|
|
26862
|
-
const isUpdate = Boolean(existingGame);
|
|
26863
|
-
const gameId = existingGame?.id ?? crypto.randomUUID();
|
|
26864
|
-
if (isUpdate) {
|
|
26865
|
-
await this.validateDeveloperAccess(user, gameId);
|
|
26866
|
-
} else {
|
|
26867
|
-
this.validateDeveloperStatus(user);
|
|
26868
|
-
}
|
|
26869
|
-
const gameDataForDb = {
|
|
26870
|
-
displayName: data.displayName,
|
|
26871
|
-
platform: data.platform,
|
|
26872
|
-
metadata: data.metadata,
|
|
26873
|
-
mapElementId: data.mapElementId,
|
|
26874
|
-
gameType: data.gameType,
|
|
26875
|
-
...data.visibility && { visibility: data.visibility },
|
|
26876
|
-
externalUrl: data.externalUrl || null,
|
|
26877
|
-
updatedAt: new Date
|
|
26878
|
-
};
|
|
26879
|
-
let gameResponse;
|
|
26880
|
-
if (isUpdate) {
|
|
26881
|
-
const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
|
|
26882
|
-
if (!updatedGame) {
|
|
26883
|
-
logger5.error("Game update returned no rows", { gameId, slug });
|
|
26884
|
-
throw new InternalError("DB update failed to return result for existing game");
|
|
26885
|
-
}
|
|
26886
|
-
gameResponse = updatedGame;
|
|
26887
|
-
} else {
|
|
26888
|
-
const insertData = {
|
|
26889
|
-
...gameDataForDb,
|
|
26890
|
-
id: gameId,
|
|
26891
|
-
slug,
|
|
26892
|
-
developerId: user.id,
|
|
26893
|
-
metadata: data.metadata || {},
|
|
26894
|
-
version: data.gameType === "external" ? "external" : "",
|
|
26895
|
-
deploymentUrl: null,
|
|
26896
|
-
createdAt: new Date
|
|
26897
|
-
};
|
|
26898
|
-
const [createdGame] = await db2.insert(games).values(insertData).returning();
|
|
26899
|
-
if (!createdGame) {
|
|
26900
|
-
logger5.error("Game insert returned no rows", { slug, developerId: user.id });
|
|
26901
|
-
throw new InternalError("DB insert failed to return result for new game");
|
|
26778
|
+
if (!game) {
|
|
26779
|
+
throw new NotFoundError("Game", slug);
|
|
26902
26780
|
}
|
|
26903
|
-
|
|
26781
|
+
this.enforceVisibility(game, caller, slug);
|
|
26782
|
+
return game;
|
|
26904
26783
|
}
|
|
26905
|
-
|
|
26906
|
-
|
|
26907
|
-
|
|
26908
|
-
|
|
26909
|
-
|
|
26910
|
-
|
|
26911
|
-
|
|
26912
|
-
|
|
26913
|
-
|
|
26914
|
-
|
|
26784
|
+
async getManifest(gameId, caller) {
|
|
26785
|
+
const game = await this.getById(gameId, caller);
|
|
26786
|
+
if (game.gameType !== "hosted" || !game.deploymentUrl) {
|
|
26787
|
+
throw new BadRequestError("Game does not have a deployment manifest");
|
|
26788
|
+
}
|
|
26789
|
+
const deploymentUrl = GameService.normalizeDeploymentUrl(game.deploymentUrl);
|
|
26790
|
+
const cacheKey2 = GameService.getManifestCacheKey(deploymentUrl);
|
|
26791
|
+
const cached = await this.deps.cache.get(cacheKey2);
|
|
26792
|
+
if (cached) {
|
|
26793
|
+
return cached;
|
|
26794
|
+
}
|
|
26795
|
+
const inFlight = inFlightManifestFetches.get(deploymentUrl);
|
|
26796
|
+
if (inFlight) {
|
|
26797
|
+
return inFlight;
|
|
26798
|
+
}
|
|
26799
|
+
const promise = this.fetchManifestFromOrigin({ gameId, deploymentUrl }).then(async (manifest) => {
|
|
26800
|
+
try {
|
|
26801
|
+
await this.deps.cache.set(cacheKey2, manifest, GameService.MANIFEST_CACHE_TTL_SECONDS);
|
|
26802
|
+
} catch (cacheError) {
|
|
26803
|
+
logger5.warn("Failed to cache game manifest", {
|
|
26804
|
+
gameId,
|
|
26805
|
+
deploymentUrl,
|
|
26806
|
+
cacheKey: cacheKey2,
|
|
26807
|
+
error: cacheError
|
|
26808
|
+
});
|
|
26809
|
+
}
|
|
26810
|
+
return manifest;
|
|
26811
|
+
}).finally(() => {
|
|
26812
|
+
inFlightManifestFetches.delete(deploymentUrl);
|
|
26813
|
+
});
|
|
26814
|
+
inFlightManifestFetches.set(deploymentUrl, promise);
|
|
26815
|
+
return promise;
|
|
26816
|
+
}
|
|
26817
|
+
async fetchManifestFromOrigin(args2) {
|
|
26818
|
+
const { gameId, deploymentUrl } = args2;
|
|
26819
|
+
const manifestUrl = `${deploymentUrl}/playcademy.manifest.json`;
|
|
26820
|
+
const manifestHost = GameService.getManifestHost(manifestUrl);
|
|
26821
|
+
const startedAt = Date.now();
|
|
26822
|
+
const maxAttempts = GameService.MANIFEST_FETCH_MAX_RETRIES + 1;
|
|
26823
|
+
for (let attempt = 0;attempt < maxAttempts; attempt++) {
|
|
26824
|
+
const isLastAttempt = attempt === maxAttempts - 1;
|
|
26825
|
+
const outcome = await this.attemptManifestFetch({
|
|
26826
|
+
manifestUrl,
|
|
26827
|
+
manifestHost,
|
|
26828
|
+
deploymentUrl,
|
|
26829
|
+
startedAt,
|
|
26830
|
+
retryCount: attempt
|
|
26915
26831
|
});
|
|
26832
|
+
if (outcome.kind === "success") {
|
|
26833
|
+
return outcome.manifest;
|
|
26834
|
+
}
|
|
26835
|
+
if (!outcome.retryable || isLastAttempt) {
|
|
26836
|
+
logger5.error("Failed to fetch game manifest", {
|
|
26837
|
+
gameId,
|
|
26838
|
+
manifestUrl,
|
|
26839
|
+
attempt: attempt + 1,
|
|
26840
|
+
maxAttempts,
|
|
26841
|
+
retryable: outcome.retryable,
|
|
26842
|
+
details: outcome.details,
|
|
26843
|
+
throwable: outcome.throwable,
|
|
26844
|
+
cause: outcome.cause
|
|
26845
|
+
});
|
|
26846
|
+
throw outcome.throwable;
|
|
26847
|
+
}
|
|
26848
|
+
const backoffMs = GameService.getRetryBackoffMs(attempt);
|
|
26849
|
+
logger5.warn("Retrying game manifest fetch after transient failure", {
|
|
26850
|
+
gameId,
|
|
26851
|
+
manifestUrl,
|
|
26852
|
+
attempt: attempt + 1,
|
|
26853
|
+
maxAttempts,
|
|
26854
|
+
backoffMs,
|
|
26855
|
+
details: outcome.details,
|
|
26856
|
+
cause: outcome.cause
|
|
26857
|
+
});
|
|
26858
|
+
await sleep(backoffMs);
|
|
26916
26859
|
}
|
|
26860
|
+
throw new InternalError("Exhausted manifest fetch retries without result");
|
|
26917
26861
|
}
|
|
26918
|
-
|
|
26919
|
-
|
|
26920
|
-
|
|
26921
|
-
|
|
26922
|
-
|
|
26923
|
-
|
|
26924
|
-
|
|
26925
|
-
|
|
26926
|
-
|
|
26927
|
-
|
|
26928
|
-
|
|
26929
|
-
|
|
26930
|
-
|
|
26931
|
-
|
|
26932
|
-
|
|
26933
|
-
|
|
26934
|
-
|
|
26935
|
-
}
|
|
26936
|
-
const activeDeployment = await db2.query.gameDeployments.findFirst({
|
|
26937
|
-
where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
|
|
26938
|
-
columns: { deploymentId: true, provider: true, resources: true }
|
|
26939
|
-
});
|
|
26940
|
-
const customHostnames = await db2.select({
|
|
26941
|
-
hostname: gameCustomHostnames.hostname,
|
|
26942
|
-
cloudflareId: gameCustomHostnames.cloudflareId,
|
|
26943
|
-
environment: gameCustomHostnames.environment
|
|
26944
|
-
}).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
|
|
26945
|
-
const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
|
|
26946
|
-
if (result.length === 0) {
|
|
26947
|
-
throw new NotFoundError("Game", gameId);
|
|
26948
|
-
}
|
|
26949
|
-
logger5.info("Deleted game", {
|
|
26950
|
-
gameId: result[0].id,
|
|
26951
|
-
slug: gameToDelete.slug,
|
|
26952
|
-
hadActiveDeployment: Boolean(activeDeployment),
|
|
26953
|
-
customDomainsCount: customHostnames.length
|
|
26954
|
-
});
|
|
26955
|
-
this.deps.alerts.notifyGameDeletion({
|
|
26956
|
-
slug: gameToDelete.slug,
|
|
26957
|
-
displayName: gameToDelete.displayName,
|
|
26958
|
-
developer: { id: user.id, email: user.email }
|
|
26959
|
-
}).catch((error) => {
|
|
26960
|
-
logger5.warn("Failed to send deletion alert", { error });
|
|
26961
|
-
});
|
|
26962
|
-
if (activeDeployment?.provider === "cloudflare" && this.deps.cloudflare) {
|
|
26862
|
+
async attemptManifestFetch(args2) {
|
|
26863
|
+
const { manifestUrl, manifestHost, deploymentUrl, startedAt, retryCount } = args2;
|
|
26864
|
+
function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
|
|
26865
|
+
return {
|
|
26866
|
+
manifestUrl,
|
|
26867
|
+
manifestHost,
|
|
26868
|
+
deploymentUrl,
|
|
26869
|
+
fetchOutcome,
|
|
26870
|
+
retryCount,
|
|
26871
|
+
durationMs: Date.now() - startedAt,
|
|
26872
|
+
manifestErrorKind,
|
|
26873
|
+
...extra
|
|
26874
|
+
};
|
|
26875
|
+
}
|
|
26876
|
+
const controller = new AbortController;
|
|
26877
|
+
const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_ATTEMPT_TIMEOUT_MS);
|
|
26878
|
+
let response;
|
|
26963
26879
|
try {
|
|
26964
|
-
await
|
|
26965
|
-
|
|
26966
|
-
|
|
26967
|
-
|
|
26968
|
-
|
|
26969
|
-
|
|
26970
|
-
logger5.info("Cleaned up Cloudflare resources", {
|
|
26971
|
-
gameId,
|
|
26972
|
-
deploymentId: activeDeployment.deploymentId,
|
|
26973
|
-
customDomainsDeleted: customHostnames.length
|
|
26880
|
+
response = await fetch(manifestUrl, {
|
|
26881
|
+
method: "GET",
|
|
26882
|
+
headers: {
|
|
26883
|
+
Accept: "application/json"
|
|
26884
|
+
},
|
|
26885
|
+
signal: controller.signal
|
|
26974
26886
|
});
|
|
26975
|
-
} catch (
|
|
26976
|
-
|
|
26977
|
-
|
|
26978
|
-
|
|
26979
|
-
|
|
26887
|
+
} catch (error) {
|
|
26888
|
+
const fetchErrorMessage = GameService.getFetchErrorMessage(error);
|
|
26889
|
+
const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
|
|
26890
|
+
const throwable = error instanceof Error && error.name === "AbortError" ? new TimeoutError("Timed out loading game manifest", details) : new ServiceUnavailableError("Failed to load game manifest", details);
|
|
26891
|
+
return { kind: "failure", retryable: true, throwable, details, cause: error };
|
|
26892
|
+
} finally {
|
|
26893
|
+
clearTimeout(timeout);
|
|
26894
|
+
}
|
|
26895
|
+
if (!response.ok) {
|
|
26896
|
+
const resolvedManifestUrl = response.url || manifestUrl;
|
|
26897
|
+
const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
|
|
26898
|
+
const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
|
|
26899
|
+
const details = buildDetails("bad_status", manifestErrorKind, {
|
|
26900
|
+
manifestUrl: resolvedManifestUrl,
|
|
26901
|
+
manifestHost: resolvedManifestHost,
|
|
26902
|
+
status: response.status,
|
|
26903
|
+
contentType: response.headers.get("content-type") ?? undefined,
|
|
26904
|
+
cfRay: response.headers.get("cf-ray") ?? undefined,
|
|
26905
|
+
redirected: response.redirected,
|
|
26906
|
+
...response.redirected ? {
|
|
26907
|
+
originalManifestUrl: manifestUrl,
|
|
26908
|
+
originalManifestHost: manifestHost
|
|
26909
|
+
} : {}
|
|
26980
26910
|
});
|
|
26911
|
+
const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
|
|
26912
|
+
const throwable = manifestErrorKind === "temporary" ? new ServiceUnavailableError(message, details) : new BadRequestError(message, details);
|
|
26913
|
+
return {
|
|
26914
|
+
kind: "failure",
|
|
26915
|
+
retryable: manifestErrorKind === "temporary",
|
|
26916
|
+
throwable,
|
|
26917
|
+
details
|
|
26918
|
+
};
|
|
26981
26919
|
}
|
|
26982
26920
|
try {
|
|
26983
|
-
const
|
|
26984
|
-
|
|
26985
|
-
|
|
26986
|
-
|
|
26987
|
-
|
|
26988
|
-
|
|
26921
|
+
const manifest = await response.json();
|
|
26922
|
+
return { kind: "success", manifest };
|
|
26923
|
+
} catch (error) {
|
|
26924
|
+
const resolvedManifestUrl = response.url || manifestUrl;
|
|
26925
|
+
const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
|
|
26926
|
+
const details = buildDetails("invalid_body", "permanent", {
|
|
26927
|
+
manifestUrl: resolvedManifestUrl,
|
|
26928
|
+
manifestHost: resolvedManifestHost,
|
|
26929
|
+
status: response.status,
|
|
26930
|
+
contentType: response.headers.get("content-type") ?? undefined,
|
|
26931
|
+
cfRay: response.headers.get("cf-ray") ?? undefined,
|
|
26932
|
+
redirected: response.redirected,
|
|
26933
|
+
...response.redirected ? {
|
|
26934
|
+
originalManifestUrl: manifestUrl,
|
|
26935
|
+
originalManifestHost: manifestHost
|
|
26936
|
+
} : {}
|
|
26937
|
+
});
|
|
26938
|
+
return {
|
|
26939
|
+
kind: "failure",
|
|
26940
|
+
retryable: false,
|
|
26941
|
+
throwable: new BadRequestError("Failed to parse game manifest", details),
|
|
26942
|
+
details,
|
|
26943
|
+
cause: error
|
|
26944
|
+
};
|
|
26945
|
+
}
|
|
26946
|
+
}
|
|
26947
|
+
enforceVisibility(game, caller, lookupIdentifier) {
|
|
26948
|
+
if (game.visibility !== "internal") {
|
|
26949
|
+
return;
|
|
26950
|
+
}
|
|
26951
|
+
const isAdmin = caller?.role === "admin";
|
|
26952
|
+
const isOwner = caller?.id != null && caller.id === game.developerId;
|
|
26953
|
+
if (!isAdmin && !isOwner) {
|
|
26954
|
+
throw new NotFoundError("Game", lookupIdentifier);
|
|
26955
|
+
}
|
|
26956
|
+
}
|
|
26957
|
+
async upsertBySlug(slug, data, user) {
|
|
26958
|
+
const db2 = this.deps.db;
|
|
26959
|
+
const existingGame = await db2.query.games.findFirst({
|
|
26960
|
+
where: eq(games.slug, slug)
|
|
26961
|
+
});
|
|
26962
|
+
const isUpdate = Boolean(existingGame);
|
|
26963
|
+
const gameId = existingGame?.id ?? crypto.randomUUID();
|
|
26964
|
+
if (isUpdate) {
|
|
26965
|
+
await this.validateDeveloperAccess(user, gameId);
|
|
26966
|
+
} else {
|
|
26967
|
+
this.validateDeveloperStatus(user);
|
|
26968
|
+
}
|
|
26969
|
+
const gameDataForDb = {
|
|
26970
|
+
displayName: data.displayName,
|
|
26971
|
+
platform: data.platform,
|
|
26972
|
+
metadata: data.metadata,
|
|
26973
|
+
mapElementId: data.mapElementId,
|
|
26974
|
+
gameType: data.gameType,
|
|
26975
|
+
...data.visibility && { visibility: data.visibility },
|
|
26976
|
+
externalUrl: data.externalUrl || null,
|
|
26977
|
+
updatedAt: new Date
|
|
26978
|
+
};
|
|
26979
|
+
let gameResponse;
|
|
26980
|
+
if (isUpdate) {
|
|
26981
|
+
const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
|
|
26982
|
+
if (!updatedGame) {
|
|
26983
|
+
logger5.error("Game update returned no rows", { gameId, slug });
|
|
26984
|
+
throw new InternalError("DB update failed to return result for existing game");
|
|
26985
|
+
}
|
|
26986
|
+
gameResponse = updatedGame;
|
|
26987
|
+
} else {
|
|
26988
|
+
const insertData = {
|
|
26989
|
+
...gameDataForDb,
|
|
26990
|
+
id: gameId,
|
|
26991
|
+
slug,
|
|
26992
|
+
developerId: user.id,
|
|
26993
|
+
metadata: data.metadata || {},
|
|
26994
|
+
version: data.gameType === "external" ? "external" : "",
|
|
26995
|
+
deploymentUrl: null,
|
|
26996
|
+
createdAt: new Date
|
|
26997
|
+
};
|
|
26998
|
+
const [createdGame] = await db2.insert(games).values(insertData).returning();
|
|
26999
|
+
if (!createdGame) {
|
|
27000
|
+
logger5.error("Game insert returned no rows", { slug, developerId: user.id });
|
|
27001
|
+
throw new InternalError("DB insert failed to return result for new game");
|
|
27002
|
+
}
|
|
27003
|
+
gameResponse = createdGame;
|
|
27004
|
+
}
|
|
27005
|
+
if (data.mapElementId) {
|
|
27006
|
+
try {
|
|
27007
|
+
await db2.update(mapElements).set({
|
|
27008
|
+
interactionType: "game_entry",
|
|
27009
|
+
gameId: gameResponse.id
|
|
27010
|
+
}).where(eq(mapElements.id, data.mapElementId));
|
|
27011
|
+
} catch (mapError) {
|
|
27012
|
+
logger5.warn("Failed to update map element", {
|
|
27013
|
+
mapElementId: data.mapElementId,
|
|
27014
|
+
error: mapError
|
|
26989
27015
|
});
|
|
26990
27016
|
}
|
|
26991
|
-
} catch (keyError) {
|
|
26992
|
-
logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
|
|
26993
27017
|
}
|
|
27018
|
+
logger5.info("Upserted game", {
|
|
27019
|
+
gameId: gameResponse.id,
|
|
27020
|
+
slug: gameResponse.slug,
|
|
27021
|
+
operation: isUpdate ? "update" : "create",
|
|
27022
|
+
displayName: gameResponse.displayName
|
|
27023
|
+
});
|
|
27024
|
+
return gameResponse;
|
|
26994
27025
|
}
|
|
26995
|
-
|
|
26996
|
-
|
|
26997
|
-
|
|
26998
|
-
|
|
26999
|
-
}
|
|
27000
|
-
async validateOwnership(user, gameId) {
|
|
27001
|
-
if (user.role === "admin") {
|
|
27002
|
-
const gameExists = await this.deps.db.query.games.findFirst({
|
|
27026
|
+
async delete(gameId, user) {
|
|
27027
|
+
await this.validateDeveloperAccess(user, gameId);
|
|
27028
|
+
const db2 = this.deps.db;
|
|
27029
|
+
const gameToDelete = await db2.query.games.findFirst({
|
|
27003
27030
|
where: eq(games.id, gameId),
|
|
27004
|
-
columns: { id: true }
|
|
27031
|
+
columns: { id: true, slug: true, displayName: true }
|
|
27005
27032
|
});
|
|
27006
|
-
if (!
|
|
27033
|
+
if (!gameToDelete?.slug) {
|
|
27007
27034
|
throw new NotFoundError("Game", gameId);
|
|
27008
27035
|
}
|
|
27009
|
-
|
|
27036
|
+
const activeDeployment = await db2.query.gameDeployments.findFirst({
|
|
27037
|
+
where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
|
|
27038
|
+
columns: { deploymentId: true, provider: true, resources: true }
|
|
27039
|
+
});
|
|
27040
|
+
const customHostnames = await db2.select({
|
|
27041
|
+
hostname: gameCustomHostnames.hostname,
|
|
27042
|
+
cloudflareId: gameCustomHostnames.cloudflareId,
|
|
27043
|
+
environment: gameCustomHostnames.environment
|
|
27044
|
+
}).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
|
|
27045
|
+
const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
|
|
27046
|
+
if (result.length === 0) {
|
|
27047
|
+
throw new NotFoundError("Game", gameId);
|
|
27048
|
+
}
|
|
27049
|
+
logger5.info("Deleted game", {
|
|
27050
|
+
gameId: result[0].id,
|
|
27051
|
+
slug: gameToDelete.slug,
|
|
27052
|
+
hadActiveDeployment: Boolean(activeDeployment),
|
|
27053
|
+
customDomainsCount: customHostnames.length
|
|
27054
|
+
});
|
|
27055
|
+
this.deps.alerts.notifyGameDeletion({
|
|
27056
|
+
slug: gameToDelete.slug,
|
|
27057
|
+
displayName: gameToDelete.displayName,
|
|
27058
|
+
developer: { id: user.id, email: user.email }
|
|
27059
|
+
}).catch((error) => {
|
|
27060
|
+
logger5.warn("Failed to send deletion alert", { error });
|
|
27061
|
+
});
|
|
27062
|
+
if (activeDeployment?.provider === "cloudflare" && this.deps.cloudflare) {
|
|
27063
|
+
try {
|
|
27064
|
+
await this.deps.cloudflare.delete(activeDeployment.deploymentId, {
|
|
27065
|
+
deleteBindings: true,
|
|
27066
|
+
resources: activeDeployment.resources ?? undefined,
|
|
27067
|
+
customDomains: customHostnames.length > 0 ? customHostnames : undefined,
|
|
27068
|
+
gameSlug: gameToDelete.slug
|
|
27069
|
+
});
|
|
27070
|
+
logger5.info("Cleaned up Cloudflare resources", {
|
|
27071
|
+
gameId,
|
|
27072
|
+
deploymentId: activeDeployment.deploymentId,
|
|
27073
|
+
customDomainsDeleted: customHostnames.length
|
|
27074
|
+
});
|
|
27075
|
+
} catch (cfError) {
|
|
27076
|
+
logger5.warn("Failed to cleanup Cloudflare resources", {
|
|
27077
|
+
gameId,
|
|
27078
|
+
deploymentId: activeDeployment.deploymentId,
|
|
27079
|
+
error: cfError
|
|
27080
|
+
});
|
|
27081
|
+
}
|
|
27082
|
+
try {
|
|
27083
|
+
const deletedKeyId = await this.deps.deleteApiKeyByName(getGameWorkerApiKeyName(gameToDelete.slug), user.id);
|
|
27084
|
+
if (deletedKeyId) {
|
|
27085
|
+
logger5.info("Cleaned up API key for deleted game", {
|
|
27086
|
+
gameId,
|
|
27087
|
+
slug: gameToDelete.slug,
|
|
27088
|
+
keyId: deletedKeyId
|
|
27089
|
+
});
|
|
27090
|
+
}
|
|
27091
|
+
} catch (keyError) {
|
|
27092
|
+
logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
|
|
27093
|
+
}
|
|
27094
|
+
}
|
|
27095
|
+
return {
|
|
27096
|
+
slug: gameToDelete.slug,
|
|
27097
|
+
displayName: gameToDelete.displayName
|
|
27098
|
+
};
|
|
27010
27099
|
}
|
|
27011
|
-
|
|
27012
|
-
|
|
27013
|
-
|
|
27014
|
-
|
|
27015
|
-
|
|
27016
|
-
|
|
27017
|
-
|
|
27018
|
-
|
|
27100
|
+
async validateOwnership(user, gameId) {
|
|
27101
|
+
if (user.role === "admin") {
|
|
27102
|
+
const gameExists = await this.deps.db.query.games.findFirst({
|
|
27103
|
+
where: eq(games.id, gameId),
|
|
27104
|
+
columns: { id: true }
|
|
27105
|
+
});
|
|
27106
|
+
if (!gameExists) {
|
|
27107
|
+
throw new NotFoundError("Game", gameId);
|
|
27108
|
+
}
|
|
27109
|
+
return;
|
|
27110
|
+
}
|
|
27111
|
+
const db2 = this.deps.db;
|
|
27112
|
+
const gameOwnership = await db2.query.games.findFirst({
|
|
27113
|
+
where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
|
|
27019
27114
|
columns: { id: true }
|
|
27020
27115
|
});
|
|
27021
|
-
if (!
|
|
27022
|
-
|
|
27116
|
+
if (!gameOwnership) {
|
|
27117
|
+
const gameExists = await db2.query.games.findFirst({
|
|
27118
|
+
where: eq(games.id, gameId),
|
|
27119
|
+
columns: { id: true }
|
|
27120
|
+
});
|
|
27121
|
+
if (!gameExists) {
|
|
27122
|
+
throw new NotFoundError("Game", gameId);
|
|
27123
|
+
}
|
|
27124
|
+
throw new AccessDeniedError("You do not own this game");
|
|
27023
27125
|
}
|
|
27024
|
-
throw new AccessDeniedError("You do not own this game");
|
|
27025
27126
|
}
|
|
27026
|
-
|
|
27027
|
-
|
|
27028
|
-
|
|
27029
|
-
|
|
27030
|
-
|
|
27031
|
-
|
|
27127
|
+
async validateDeveloperAccess(user, gameId) {
|
|
27128
|
+
this.validateDeveloperStatus(user);
|
|
27129
|
+
if (user.role === "admin") {
|
|
27130
|
+
const gameExists = await this.deps.db.query.games.findFirst({
|
|
27131
|
+
where: eq(games.id, gameId),
|
|
27132
|
+
columns: { id: true }
|
|
27133
|
+
});
|
|
27134
|
+
if (!gameExists) {
|
|
27135
|
+
throw new NotFoundError("Game", gameId);
|
|
27136
|
+
}
|
|
27137
|
+
return;
|
|
27138
|
+
}
|
|
27139
|
+
const db2 = this.deps.db;
|
|
27140
|
+
const existingGame = await db2.query.games.findFirst({
|
|
27141
|
+
where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
|
|
27032
27142
|
columns: { id: true }
|
|
27033
27143
|
});
|
|
27034
|
-
if (!
|
|
27144
|
+
if (!existingGame) {
|
|
27035
27145
|
throw new NotFoundError("Game", gameId);
|
|
27036
27146
|
}
|
|
27037
|
-
return;
|
|
27038
27147
|
}
|
|
27039
|
-
|
|
27040
|
-
|
|
27041
|
-
|
|
27042
|
-
|
|
27043
|
-
|
|
27044
|
-
|
|
27045
|
-
|
|
27148
|
+
async validateGameManagementAccess(user, gameId) {
|
|
27149
|
+
if (user.role === "admin" || user.role === "teacher") {
|
|
27150
|
+
const gameExists = await this.deps.db.query.games.findFirst({
|
|
27151
|
+
where: eq(games.id, gameId),
|
|
27152
|
+
columns: { id: true }
|
|
27153
|
+
});
|
|
27154
|
+
if (!gameExists) {
|
|
27155
|
+
throw new NotFoundError("Game", gameId);
|
|
27156
|
+
}
|
|
27157
|
+
return;
|
|
27158
|
+
}
|
|
27159
|
+
return this.validateDeveloperAccess(user, gameId);
|
|
27046
27160
|
}
|
|
27047
|
-
|
|
27048
|
-
|
|
27049
|
-
|
|
27050
|
-
|
|
27051
|
-
|
|
27052
|
-
|
|
27053
|
-
|
|
27161
|
+
async validateDeveloperAccessBySlug(user, slug) {
|
|
27162
|
+
this.validateDeveloperStatus(user);
|
|
27163
|
+
const db2 = this.deps.db;
|
|
27164
|
+
if (user.role === "admin") {
|
|
27165
|
+
const game2 = await db2.query.games.findFirst({
|
|
27166
|
+
where: eq(games.slug, slug)
|
|
27167
|
+
});
|
|
27168
|
+
if (!game2) {
|
|
27169
|
+
throw new NotFoundError("Game", slug);
|
|
27170
|
+
}
|
|
27171
|
+
return game2;
|
|
27172
|
+
}
|
|
27173
|
+
const game = await db2.query.games.findFirst({
|
|
27174
|
+
where: and(eq(games.slug, slug), eq(games.developerId, user.id))
|
|
27054
27175
|
});
|
|
27055
|
-
if (!
|
|
27176
|
+
if (!game) {
|
|
27056
27177
|
throw new NotFoundError("Game", slug);
|
|
27057
27178
|
}
|
|
27058
|
-
return
|
|
27179
|
+
return game;
|
|
27059
27180
|
}
|
|
27060
|
-
|
|
27061
|
-
|
|
27062
|
-
|
|
27063
|
-
|
|
27064
|
-
|
|
27065
|
-
|
|
27066
|
-
|
|
27067
|
-
|
|
27068
|
-
|
|
27069
|
-
|
|
27070
|
-
|
|
27071
|
-
}
|
|
27072
|
-
if (user.developerStatus !== "approved") {
|
|
27073
|
-
const status = user.developerStatus || "none";
|
|
27074
|
-
if (status === "pending") {
|
|
27075
|
-
throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
|
|
27076
|
-
} else {
|
|
27077
|
-
throw new AccessDeniedError("Developer status required. Apply for developer access to create and manage games.");
|
|
27181
|
+
validateDeveloperStatus(user) {
|
|
27182
|
+
if (user.role === "admin") {
|
|
27183
|
+
return;
|
|
27184
|
+
}
|
|
27185
|
+
if (user.developerStatus !== "approved") {
|
|
27186
|
+
const status = user.developerStatus || "none";
|
|
27187
|
+
if (status === "pending") {
|
|
27188
|
+
throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
|
|
27189
|
+
} else {
|
|
27190
|
+
throw new AccessDeniedError("Developer status required. Apply for developer access to create and manage games.");
|
|
27191
|
+
}
|
|
27078
27192
|
}
|
|
27079
27193
|
}
|
|
27080
|
-
}
|
|
27081
|
-
}
|
|
27082
|
-
var logger5;
|
|
27083
|
-
var init_game_service = __esm(() => {
|
|
27084
|
-
init_drizzle_orm();
|
|
27085
|
-
init_tables_index();
|
|
27086
|
-
init_src2();
|
|
27087
|
-
init_errors();
|
|
27088
|
-
init_deployment_util();
|
|
27089
|
-
logger5 = log.scope("GameService");
|
|
27194
|
+
};
|
|
27090
27195
|
});
|
|
27091
27196
|
|
|
27092
27197
|
// ../api-core/src/services/factory/game.ts
|
|
@@ -27095,6 +27200,7 @@ function createGameServices(deps) {
|
|
|
27095
27200
|
const game = new GameService({
|
|
27096
27201
|
db: db2,
|
|
27097
27202
|
alerts,
|
|
27203
|
+
cache,
|
|
27098
27204
|
cloudflare,
|
|
27099
27205
|
deleteApiKeyByName: auth2.deleteApiKeyByName.bind(auth2)
|
|
27100
27206
|
});
|
|
@@ -27122,6 +27228,7 @@ function createGameServices(deps) {
|
|
|
27122
27228
|
validators: {
|
|
27123
27229
|
validateDeveloperAccessBySlug: (user, slug) => game.validateDeveloperAccessBySlug(user, slug),
|
|
27124
27230
|
validateDeveloperAccess: (user, gameId) => game.validateDeveloperAccess(user, gameId),
|
|
27231
|
+
validateGameManagementAccess: (user, gameId) => game.validateGameManagementAccess(user, gameId),
|
|
27125
27232
|
validateOwnership: (user, gameId) => game.validateOwnership(user, gameId)
|
|
27126
27233
|
}
|
|
27127
27234
|
};
|
|
@@ -28772,11 +28879,11 @@ var init_constants3 = __esm(() => {
|
|
|
28772
28879
|
HEALTH: "/api/health",
|
|
28773
28880
|
TIMEBACK: {
|
|
28774
28881
|
END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
|
|
28775
|
-
GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}
|
|
28882
|
+
GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
|
|
28883
|
+
HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`
|
|
28776
28884
|
}
|
|
28777
28885
|
};
|
|
28778
28886
|
});
|
|
28779
|
-
|
|
28780
28887
|
// ../edge-play/src/entry/setup.ts
|
|
28781
28888
|
function prefixSecrets(secrets) {
|
|
28782
28889
|
const prefixed = {};
|
|
@@ -28913,7 +29020,8 @@ class SeedService {
|
|
|
28913
29020
|
PLAYCADEMY_BASE_URL: ""
|
|
28914
29021
|
}, {
|
|
28915
29022
|
bindings: { d1: [deploymentId], r2: [], kv: [] },
|
|
28916
|
-
keepAssets: false
|
|
29023
|
+
keepAssets: false,
|
|
29024
|
+
compatibilityDate: CLOUDFLARE_COMPATIBILITY_DATE
|
|
28917
29025
|
});
|
|
28918
29026
|
logger14.info("Worker deployed", { seedDeploymentId, url: result.url });
|
|
28919
29027
|
if (secrets && Object.keys(secrets).length > 0) {
|
|
@@ -29043,6 +29151,7 @@ class SeedService {
|
|
|
29043
29151
|
}
|
|
29044
29152
|
var logger14;
|
|
29045
29153
|
var init_seed_service = __esm(() => {
|
|
29154
|
+
init_src();
|
|
29046
29155
|
init_setup2();
|
|
29047
29156
|
init_src2();
|
|
29048
29157
|
init_config2();
|
|
@@ -30199,6 +30308,38 @@ var init_src4 = __esm(() => {
|
|
|
30199
30308
|
init_pure();
|
|
30200
30309
|
});
|
|
30201
30310
|
|
|
30311
|
+
// ../api-core/src/utils/timeback-admin.util.ts
|
|
30312
|
+
function toAttributionEventTime(date3) {
|
|
30313
|
+
if (!date3) {
|
|
30314
|
+
return;
|
|
30315
|
+
}
|
|
30316
|
+
const match = date3.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
30317
|
+
if (!match) {
|
|
30318
|
+
throw new ValidationError("Date must be in YYYY-MM-DD format");
|
|
30319
|
+
}
|
|
30320
|
+
const [, yearStr, monthStr, dayStr] = match;
|
|
30321
|
+
const year = Number(yearStr);
|
|
30322
|
+
const month = Number(monthStr);
|
|
30323
|
+
const day = Number(dayStr);
|
|
30324
|
+
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
|
30325
|
+
throw new ValidationError("Date must be in YYYY-MM-DD format");
|
|
30326
|
+
}
|
|
30327
|
+
const eventTime = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
|
|
30328
|
+
if (eventTime.getUTCFullYear() !== year || eventTime.getUTCMonth() + 1 !== month || eventTime.getUTCDate() !== day) {
|
|
30329
|
+
throw new ValidationError("Date must be a valid calendar date");
|
|
30330
|
+
}
|
|
30331
|
+
return eventTime.toISOString();
|
|
30332
|
+
}
|
|
30333
|
+
function resolveAdminEventTime(data) {
|
|
30334
|
+
if (data.useCurrentTime) {
|
|
30335
|
+
return new Date().toISOString();
|
|
30336
|
+
}
|
|
30337
|
+
return toAttributionEventTime(data.date);
|
|
30338
|
+
}
|
|
30339
|
+
var init_timeback_admin_util = __esm(() => {
|
|
30340
|
+
init_errors();
|
|
30341
|
+
});
|
|
30342
|
+
|
|
30202
30343
|
// ../api-core/src/utils/timeback.util.ts
|
|
30203
30344
|
function isRecord2(value) {
|
|
30204
30345
|
return typeof value === "object" && value !== null;
|
|
@@ -30244,14 +30385,6 @@ function getPlaycademyMetadata(event) {
|
|
|
30244
30385
|
const extensions = getMergedCaliperExtensions(event);
|
|
30245
30386
|
return isRecord2(extensions.playcademy) ? extensions.playcademy : undefined;
|
|
30246
30387
|
}
|
|
30247
|
-
function getAssessmentPlaycademyMetadata(assessment) {
|
|
30248
|
-
return isRecord2(assessment.metadata?.playcademy) ? assessment.metadata.playcademy : undefined;
|
|
30249
|
-
}
|
|
30250
|
-
function isRemediationAssessmentResult(assessment) {
|
|
30251
|
-
const playcademy = getAssessmentPlaycademyMetadata(assessment);
|
|
30252
|
-
const eventKind = getStringValue(playcademy?.eventKind);
|
|
30253
|
-
return eventKind === "remediation-xp" || eventKind === "remediation-time" || eventKind === "remediation-mastery";
|
|
30254
|
-
}
|
|
30255
30388
|
function getActivityId(event, playcademy) {
|
|
30256
30389
|
const metadataActivityId = getStringValue(playcademy?.activityId);
|
|
30257
30390
|
if (metadataActivityId) {
|
|
@@ -30268,8 +30401,8 @@ function getActivityId(event, playcademy) {
|
|
|
30268
30401
|
const trimmed = objectId.replace(/\/$/, "");
|
|
30269
30402
|
const segments = trimmed.split("/");
|
|
30270
30403
|
const activityIndex = segments.lastIndexOf("activities");
|
|
30271
|
-
if (activityIndex !== -1 && segments.length >= activityIndex +
|
|
30272
|
-
const candidate = segments[activityIndex +
|
|
30404
|
+
if (activityIndex !== -1 && segments.length >= activityIndex + 3) {
|
|
30405
|
+
const candidate = segments[activityIndex + 2];
|
|
30273
30406
|
return candidate ? decodeURIComponent(candidate) : undefined;
|
|
30274
30407
|
}
|
|
30275
30408
|
return;
|
|
@@ -30322,38 +30455,96 @@ function mapAssessmentsToXpEvents(userId, assessments) {
|
|
|
30322
30455
|
};
|
|
30323
30456
|
});
|
|
30324
30457
|
}
|
|
30325
|
-
function
|
|
30326
|
-
|
|
30458
|
+
function getDurationSecondsFromExtensions(event) {
|
|
30459
|
+
const extensions = getMergedCaliperExtensions(event);
|
|
30460
|
+
const playcademy = isRecord2(extensions.playcademy) ? extensions.playcademy : undefined;
|
|
30461
|
+
const rawValue = extensions.durationSeconds ?? playcademy?.durationSeconds;
|
|
30462
|
+
const value = typeof rawValue === "number" ? rawValue : Number(rawValue);
|
|
30463
|
+
return Number.isFinite(value) ? value : undefined;
|
|
30464
|
+
}
|
|
30465
|
+
function getCanonicalRunId(session2) {
|
|
30466
|
+
const sessionId = getStringValue(session2?.id);
|
|
30467
|
+
if (!sessionId) {
|
|
30468
|
+
return;
|
|
30469
|
+
}
|
|
30470
|
+
return sessionId.replace(/^urn:uuid:/, "");
|
|
30471
|
+
}
|
|
30472
|
+
function getResumeId(event) {
|
|
30473
|
+
const playcademy = getPlaycademyMetadata(event);
|
|
30474
|
+
return getStringValue(playcademy?.resumeId);
|
|
30475
|
+
}
|
|
30476
|
+
function isCaliperRemediationOrCompletionEvent(event) {
|
|
30477
|
+
const playcademy = getPlaycademyMetadata(event);
|
|
30478
|
+
return REMEDIATION_OR_COMPLETION_EVENT_KINDS.has(getStringValue(playcademy?.eventKind) || "");
|
|
30479
|
+
}
|
|
30480
|
+
function groupCaliperEventsByRun(events) {
|
|
30481
|
+
const groups = new Map;
|
|
30482
|
+
for (const event of events) {
|
|
30483
|
+
const objectId = getStringValue(event.object.id) || "unknown-activity";
|
|
30484
|
+
const groupKey = `${objectId}::${getStringValue(event.session?.id) || event.externalId}`;
|
|
30485
|
+
const existing = groups.get(groupKey);
|
|
30486
|
+
if (existing) {
|
|
30487
|
+
existing.push(event);
|
|
30488
|
+
} else {
|
|
30489
|
+
groups.set(groupKey, [event]);
|
|
30490
|
+
}
|
|
30491
|
+
}
|
|
30492
|
+
return groups;
|
|
30327
30493
|
}
|
|
30328
|
-
function
|
|
30329
|
-
if (
|
|
30494
|
+
function mapCaliperEventGroupToActivity(events, relevantCourseIds) {
|
|
30495
|
+
if (events.length === 0) {
|
|
30330
30496
|
return null;
|
|
30331
30497
|
}
|
|
30332
|
-
|
|
30498
|
+
const sortedEvents = events.toSorted((a, b) => a.eventTime.localeCompare(b.eventTime));
|
|
30499
|
+
const activityEvent = [...sortedEvents].toReversed().find((event) => event.type === "ActivityEvent");
|
|
30500
|
+
const contextSource = activityEvent || sortedEvents.at(-1);
|
|
30501
|
+
if (!contextSource) {
|
|
30333
30502
|
return null;
|
|
30334
30503
|
}
|
|
30335
|
-
const
|
|
30336
|
-
if (!
|
|
30504
|
+
const ctx = parseCaliperEventContext(contextSource, relevantCourseIds);
|
|
30505
|
+
if (!ctx) {
|
|
30337
30506
|
return null;
|
|
30338
30507
|
}
|
|
30339
|
-
|
|
30508
|
+
const score = activityEvent !== undefined ? (() => {
|
|
30509
|
+
const totalQuestions = getGeneratedMetricValue(activityEvent, "totalQuestions");
|
|
30510
|
+
const correctQuestions = getGeneratedMetricValue(activityEvent, "correctQuestions");
|
|
30511
|
+
if (totalQuestions === undefined || correctQuestions === undefined || totalQuestions <= 0) {
|
|
30512
|
+
return;
|
|
30513
|
+
}
|
|
30514
|
+
return correctQuestions / totalQuestions * 100;
|
|
30515
|
+
})() : undefined;
|
|
30516
|
+
const xpEarned = activityEvent !== undefined ? getGeneratedMetricValue(activityEvent, "xpEarned") : undefined;
|
|
30517
|
+
const masteredUnits = activityEvent !== undefined ? getGeneratedMetricValue(activityEvent, "masteredUnits") : undefined;
|
|
30518
|
+
const timeSpentEvents = sortedEvents.filter((event) => event.type === "TimeSpentEvent");
|
|
30519
|
+
let totalActiveTimeSeconds;
|
|
30520
|
+
if (timeSpentEvents.length > 0) {
|
|
30521
|
+
totalActiveTimeSeconds = timeSpentEvents.reduce((sum2, event) => sum2 + (getGeneratedMetricValue(event, "active") ?? 0), 0);
|
|
30522
|
+
} else if (activityEvent !== undefined) {
|
|
30523
|
+
totalActiveTimeSeconds = getDurationSecondsFromExtensions(activityEvent);
|
|
30524
|
+
}
|
|
30525
|
+
const fallbackActivityId = getActivityId(contextSource, getPlaycademyMetadata(contextSource));
|
|
30526
|
+
const occurredAt = getStringValue(activityEvent?.eventTime) || getStringValue(sortedEvents.at(-1)?.eventTime);
|
|
30527
|
+
const runId = getCanonicalRunId(contextSource.session);
|
|
30528
|
+
const resumeIds = new Set(sortedEvents.map((event) => getResumeId(event)).filter((resumeId) => resumeId !== undefined));
|
|
30529
|
+
const sessionCount = resumeIds.size > 0 ? resumeIds.size : 1;
|
|
30530
|
+
const kind = activityEvent !== undefined ? "activity" : "activity-in-progress";
|
|
30531
|
+
if (!occurredAt) {
|
|
30340
30532
|
return null;
|
|
30341
30533
|
}
|
|
30342
|
-
const metadata2 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
|
|
30343
|
-
const activityName = getStringValue(metadata2?.activityName);
|
|
30344
|
-
const xpEarned = typeof metadata2?.xp === "number" && Number.isFinite(metadata2.xp) ? metadata2.xp : undefined;
|
|
30345
|
-
const masteredUnits = typeof metadata2?.masteredUnits === "number" && Number.isFinite(metadata2.masteredUnits) ? metadata2.masteredUnits : undefined;
|
|
30346
|
-
const durationSeconds = typeof metadata2?.durationSeconds === "number" && Number.isFinite(metadata2.durationSeconds) ? metadata2.durationSeconds : undefined;
|
|
30347
30534
|
return {
|
|
30348
|
-
id:
|
|
30349
|
-
kind
|
|
30350
|
-
occurredAt
|
|
30351
|
-
courseId,
|
|
30352
|
-
title:
|
|
30353
|
-
...
|
|
30535
|
+
id: activityEvent?.externalId || sortedEvents.at(-1)?.externalId || events[0].externalId,
|
|
30536
|
+
kind,
|
|
30537
|
+
occurredAt,
|
|
30538
|
+
courseId: ctx.courseId,
|
|
30539
|
+
title: getStringValue(activityEvent?.object.activity?.name) || ctx.titleFromEvent || (fallbackActivityId ? kebabToTitleCase(fallbackActivityId) : "Activity completed"),
|
|
30540
|
+
...ctx.activityId ? { activityId: ctx.activityId } : {},
|
|
30541
|
+
...ctx.appName ? { appName: ctx.appName } : {},
|
|
30542
|
+
...score !== undefined ? { score } : {},
|
|
30354
30543
|
...xpEarned !== undefined ? { xpDelta: xpEarned } : {},
|
|
30355
30544
|
...masteredUnits !== undefined ? { masteredUnitsDelta: masteredUnits } : {},
|
|
30356
|
-
...
|
|
30545
|
+
...totalActiveTimeSeconds !== undefined ? { timeDeltaSeconds: totalActiveTimeSeconds } : {},
|
|
30546
|
+
...runId ? { runId } : {},
|
|
30547
|
+
...sessionCount > 0 ? { sessionCount } : {}
|
|
30357
30548
|
};
|
|
30358
30549
|
}
|
|
30359
30550
|
function parseCaliperEventContext(event, relevantCourseIds) {
|
|
@@ -30461,8 +30652,16 @@ function mapCaliperEventToRemediationActivity(event, relevantCourseIds) {
|
|
|
30461
30652
|
}
|
|
30462
30653
|
return null;
|
|
30463
30654
|
}
|
|
30655
|
+
var REMEDIATION_OR_COMPLETION_EVENT_KINDS;
|
|
30464
30656
|
var init_timeback_util = __esm(() => {
|
|
30465
30657
|
init_types4();
|
|
30658
|
+
REMEDIATION_OR_COMPLETION_EVENT_KINDS = new Set([
|
|
30659
|
+
"remediation-xp",
|
|
30660
|
+
"remediation-time",
|
|
30661
|
+
"remediation-mastery",
|
|
30662
|
+
"course-completed",
|
|
30663
|
+
"course-resumed"
|
|
30664
|
+
]);
|
|
30466
30665
|
});
|
|
30467
30666
|
|
|
30468
30667
|
// ../api-core/src/services/timeback-admin.service.ts
|
|
@@ -30472,11 +30671,9 @@ class TimebackAdminService {
|
|
|
30472
30671
|
static RECENT_ACTIVITY_LIMIT = 20;
|
|
30473
30672
|
static MAX_STUDENT_ACTIVITY_LIMIT = 200;
|
|
30474
30673
|
static MAX_STUDENT_ACTIVITY_OFFSET = 1000;
|
|
30674
|
+
static MAX_RECENT_ACTIVITY_EVENT_FETCH = 4000;
|
|
30475
30675
|
static ANALYTICS_CONCURRENCY = 8;
|
|
30476
30676
|
static MASTERABLE_UNITS_CONCURRENCY = 4;
|
|
30477
|
-
static RECENT_ACTIVITY_FETCH_CONCURRENCY = 4;
|
|
30478
|
-
static ASSESSMENT_LINE_ITEM_PAGE_SIZE = 1000;
|
|
30479
|
-
static ASSESSMENT_RESULT_LINE_ITEM_CHUNK_SIZE = 20;
|
|
30480
30677
|
constructor(deps) {
|
|
30481
30678
|
this.deps = deps;
|
|
30482
30679
|
}
|
|
@@ -30502,9 +30699,13 @@ class TimebackAdminService {
|
|
|
30502
30699
|
});
|
|
30503
30700
|
});
|
|
30504
30701
|
}
|
|
30505
|
-
async resolveAdminMutationContext(gameId, courseId, user, studentId) {
|
|
30702
|
+
async resolveAdminMutationContext(gameId, courseId, user, studentId, accessLevel = "developer") {
|
|
30506
30703
|
const client = this.requireClient();
|
|
30507
|
-
|
|
30704
|
+
if (accessLevel === "dashboard") {
|
|
30705
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
30706
|
+
} else {
|
|
30707
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
30708
|
+
}
|
|
30508
30709
|
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
30509
30710
|
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
30510
30711
|
});
|
|
@@ -30559,7 +30760,6 @@ class TimebackAdminService {
|
|
|
30559
30760
|
masteredUnitsForDay += masteredUnitsFromFact;
|
|
30560
30761
|
}
|
|
30561
30762
|
}
|
|
30562
|
-
const roundedXpForDay = TimebackAdminService.roundXpToTenths(xpForDay);
|
|
30563
30763
|
totalXpRaw += xpForDay;
|
|
30564
30764
|
activeTimeSeconds += activeSecondsForDay;
|
|
30565
30765
|
masteredUnits += masteredUnitsForDay;
|
|
@@ -30568,7 +30768,7 @@ class TimebackAdminService {
|
|
|
30568
30768
|
}
|
|
30569
30769
|
history.push({
|
|
30570
30770
|
date: date3,
|
|
30571
|
-
xpEarned:
|
|
30771
|
+
xpEarned: TimebackAdminService.roundXpToTenths(xpForDay),
|
|
30572
30772
|
activeTimeSeconds: activeSecondsForDay,
|
|
30573
30773
|
masteredUnits: masteredUnitsForDay
|
|
30574
30774
|
});
|
|
@@ -30628,7 +30828,7 @@ class TimebackAdminService {
|
|
|
30628
30828
|
throw new ValidationError(`Game "${game.slug}" has an invalid deploymentUrl: ${game.deploymentUrl}`);
|
|
30629
30829
|
}
|
|
30630
30830
|
}
|
|
30631
|
-
async
|
|
30831
|
+
async getGameActivitySource(gameId) {
|
|
30632
30832
|
const game = await this.deps.db.query.games.findFirst({
|
|
30633
30833
|
where: eq(games.id, gameId),
|
|
30634
30834
|
columns: { slug: true, deploymentUrl: true }
|
|
@@ -30636,7 +30836,17 @@ class TimebackAdminService {
|
|
|
30636
30836
|
if (!game) {
|
|
30637
30837
|
throw new NotFoundError("Game", gameId);
|
|
30638
30838
|
}
|
|
30639
|
-
return
|
|
30839
|
+
return {
|
|
30840
|
+
gameId,
|
|
30841
|
+
sensorUrl: this.deriveGameSensorUrl(game),
|
|
30842
|
+
sourceMode: this.deps.config.isLocal ? "development" : "production"
|
|
30843
|
+
};
|
|
30844
|
+
}
|
|
30845
|
+
static mapRecentActivityItems(events, relevantCourseIds) {
|
|
30846
|
+
const gameplayEvents = events.filter((event) => (event.type === "ActivityEvent" || event.type === "TimeSpentEvent") && !isCaliperRemediationOrCompletionEvent(event));
|
|
30847
|
+
const groupedGameplayItems = [...groupCaliperEventsByRun(gameplayEvents).values()].map((group) => mapCaliperEventGroupToActivity(group, relevantCourseIds)).filter((item) => Boolean(item));
|
|
30848
|
+
const remediationItems = events.map((event) => mapCaliperEventToRemediationActivity(event, relevantCourseIds)).filter((item) => Boolean(item));
|
|
30849
|
+
return [...groupedGameplayItems, ...remediationItems].toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
|
30640
30850
|
}
|
|
30641
30851
|
async getStudentEnrollmentsByCourseId(client, studentId, courseIds) {
|
|
30642
30852
|
const relevantCourseIds = new Set(courseIds);
|
|
@@ -30667,105 +30877,35 @@ class TimebackAdminService {
|
|
|
30667
30877
|
});
|
|
30668
30878
|
return new Map(results);
|
|
30669
30879
|
}
|
|
30670
|
-
async
|
|
30671
|
-
const lineItemEntries = await TimebackAdminService.runWithConcurrency([...relevantCourseIds], TimebackAdminService.RECENT_ACTIVITY_FETCH_CONCURRENCY, async (courseId) => {
|
|
30672
|
-
const entries = [];
|
|
30673
|
-
let offset = 0;
|
|
30674
|
-
try {
|
|
30675
|
-
while (true) {
|
|
30676
|
-
const items2 = await client.oneroster.assessmentLineItems.list({
|
|
30677
|
-
limit: TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE,
|
|
30678
|
-
offset,
|
|
30679
|
-
filter: `course.sourcedId='${escapeFilterValue(courseId)}'`,
|
|
30680
|
-
fields: "sourcedId,course"
|
|
30681
|
-
});
|
|
30682
|
-
for (const item of items2) {
|
|
30683
|
-
if (item.sourcedId) {
|
|
30684
|
-
entries.push([
|
|
30685
|
-
item.sourcedId,
|
|
30686
|
-
item.course?.sourcedId || courseId
|
|
30687
|
-
]);
|
|
30688
|
-
}
|
|
30689
|
-
}
|
|
30690
|
-
if (items2.length < TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE) {
|
|
30691
|
-
break;
|
|
30692
|
-
}
|
|
30693
|
-
offset += TimebackAdminService.ASSESSMENT_LINE_ITEM_PAGE_SIZE;
|
|
30694
|
-
}
|
|
30695
|
-
} catch (error) {
|
|
30696
|
-
logger16.warn("Failed to load assessment line items for course", {
|
|
30697
|
-
courseId,
|
|
30698
|
-
error: error instanceof Error ? error.message : String(error)
|
|
30699
|
-
});
|
|
30700
|
-
}
|
|
30701
|
-
return entries;
|
|
30702
|
-
});
|
|
30703
|
-
return new Map(lineItemEntries.flat());
|
|
30704
|
-
}
|
|
30705
|
-
static buildAssessmentResultsFilter(studentId, lineItemIds) {
|
|
30706
|
-
const studentFilter = `student.sourcedId='${escapeFilterValue(studentId)}'`;
|
|
30707
|
-
if (lineItemIds.length === 1) {
|
|
30708
|
-
return `${studentFilter} AND ` + `assessmentLineItem.sourcedId='${escapeFilterValue(lineItemIds[0])}'`;
|
|
30709
|
-
}
|
|
30710
|
-
return `${studentFilter} AND ` + `assessmentLineItem.sourcedId@'${lineItemIds.map(escapeFilterValue).join(",")}'`;
|
|
30711
|
-
}
|
|
30712
|
-
async listRecentAssessmentResultsForStudent(client, studentId, courseIdByLineItemId, perChunkLimit = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
|
|
30713
|
-
const lineItemIds = [...courseIdByLineItemId.keys()];
|
|
30714
|
-
if (lineItemIds.length === 0) {
|
|
30715
|
-
return [];
|
|
30716
|
-
}
|
|
30717
|
-
const resultPages = await TimebackAdminService.runWithConcurrency(TimebackAdminService.chunkItems(lineItemIds, TimebackAdminService.ASSESSMENT_RESULT_LINE_ITEM_CHUNK_SIZE), TimebackAdminService.RECENT_ACTIVITY_FETCH_CONCURRENCY, async (lineItemChunk) => {
|
|
30718
|
-
try {
|
|
30719
|
-
return await client.oneroster.assessmentResults.list({
|
|
30720
|
-
limit: perChunkLimit,
|
|
30721
|
-
sort: "scoreDate",
|
|
30722
|
-
orderBy: "desc",
|
|
30723
|
-
fields: "sourcedId,assessmentLineItem,score,scoreDate,metadata",
|
|
30724
|
-
filter: TimebackAdminService.buildAssessmentResultsFilter(studentId, lineItemChunk)
|
|
30725
|
-
});
|
|
30726
|
-
} catch (error) {
|
|
30727
|
-
logger16.warn("Failed to load recent assessment results for student", {
|
|
30728
|
-
studentId,
|
|
30729
|
-
lineItemCount: lineItemChunk.length,
|
|
30730
|
-
error: error instanceof Error ? error.message : String(error)
|
|
30731
|
-
});
|
|
30732
|
-
return [];
|
|
30733
|
-
}
|
|
30734
|
-
});
|
|
30735
|
-
const uniqueResults = new Map;
|
|
30736
|
-
for (const result of resultPages.flat()) {
|
|
30737
|
-
const key = result.sourcedId || `${result.assessmentLineItem?.sourcedId || "unknown"}:${result.scoreDate || ""}`;
|
|
30738
|
-
uniqueResults.set(key, result);
|
|
30739
|
-
}
|
|
30740
|
-
return [...uniqueResults.values()].toSorted((a, b) => (b.scoreDate || "").localeCompare(a.scoreDate || "")).slice(0, perChunkLimit);
|
|
30741
|
-
}
|
|
30742
|
-
async listRecentActivityForStudent(client, studentId, sensorUrl, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
|
|
30880
|
+
async listRecentActivityForStudent(client, studentId, source, relevantCourseIds, maxResults = TimebackAdminService.RECENT_ACTIVITY_LIMIT) {
|
|
30743
30881
|
if (relevantCourseIds.size === 0) {
|
|
30744
30882
|
return [];
|
|
30745
30883
|
}
|
|
30746
|
-
const courseIdByLineItemId = await this.listAssessmentLineItemCourseMap(client, relevantCourseIds);
|
|
30747
|
-
const assessments = await this.listRecentAssessmentResultsForStudent(client, studentId, courseIdByLineItemId, maxResults);
|
|
30748
|
-
const assessmentRecentItems = assessments.map((assessment) => mapAssessmentResultToRecentActivity(assessment, relevantCourseIds, courseIdByLineItemId)).filter((activity) => Boolean(activity));
|
|
30749
|
-
let caliperRecentItems = [];
|
|
30750
30884
|
try {
|
|
30751
30885
|
const actorId = `${client.getBaseUrl().replace(/\/$/, "")}/ims/oneroster/rostering/v1p2/users/${studentId}`;
|
|
30886
|
+
const eventLimit = Math.min(Math.max(200, maxResults * 20), TimebackAdminService.MAX_RECENT_ACTIVITY_EVENT_FETCH);
|
|
30752
30887
|
const { events } = await client.caliper.events.list({
|
|
30753
|
-
limit:
|
|
30888
|
+
limit: eventLimit,
|
|
30754
30889
|
actorId,
|
|
30755
|
-
sensor: sensorUrl
|
|
30890
|
+
...source.sourceMode === "production" ? { sensor: source.sensorUrl } : {},
|
|
30891
|
+
extensions: {
|
|
30892
|
+
gameId: source.gameId
|
|
30893
|
+
}
|
|
30756
30894
|
});
|
|
30757
|
-
|
|
30895
|
+
return TimebackAdminService.mapRecentActivityItems(events, relevantCourseIds).slice(0, maxResults);
|
|
30758
30896
|
} catch (error) {
|
|
30759
30897
|
logger16.warn("Failed to load recent Caliper activity", {
|
|
30760
30898
|
studentId,
|
|
30899
|
+
gameId: source.gameId,
|
|
30900
|
+
sourceMode: source.sourceMode,
|
|
30761
30901
|
error: error instanceof Error ? error.message : String(error)
|
|
30762
30902
|
});
|
|
30903
|
+
return [];
|
|
30763
30904
|
}
|
|
30764
|
-
return [...assessmentRecentItems, ...caliperRecentItems].toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt)).slice(0, maxResults);
|
|
30765
30905
|
}
|
|
30766
30906
|
async listStudentsForCourse(gameId, courseId, user) {
|
|
30767
30907
|
const client = this.requireClient();
|
|
30768
|
-
await this.deps.
|
|
30908
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
30769
30909
|
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
30770
30910
|
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
30771
30911
|
});
|
|
@@ -30803,7 +30943,7 @@ class TimebackAdminService {
|
|
|
30803
30943
|
}
|
|
30804
30944
|
async getStudentOverview(gameId, studentId, user, courseId) {
|
|
30805
30945
|
const client = this.requireClient();
|
|
30806
|
-
await this.deps.
|
|
30946
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
30807
30947
|
const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
|
|
30808
30948
|
where: courseId ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId)) : eq(gameTimebackIntegrations.gameId, gameId)
|
|
30809
30949
|
});
|
|
@@ -30857,12 +30997,12 @@ class TimebackAdminService {
|
|
|
30857
30997
|
const client = this.requireClient();
|
|
30858
30998
|
const safeLimit = Math.max(1, Math.min(limit, TimebackAdminService.MAX_STUDENT_ACTIVITY_LIMIT));
|
|
30859
30999
|
const safeOffset = Math.max(0, Math.min(offset, TimebackAdminService.MAX_STUDENT_ACTIVITY_OFFSET));
|
|
30860
|
-
await this.deps.
|
|
30861
|
-
const [integration,
|
|
31000
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
31001
|
+
const [integration, gameSource] = await Promise.all([
|
|
30862
31002
|
this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
30863
31003
|
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
30864
31004
|
}),
|
|
30865
|
-
this.
|
|
31005
|
+
this.getGameActivitySource(gameId)
|
|
30866
31006
|
]);
|
|
30867
31007
|
if (!integration) {
|
|
30868
31008
|
throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
|
|
@@ -30870,7 +31010,7 @@ class TimebackAdminService {
|
|
|
30870
31010
|
await this.assertStudentEnrolledInCourse(client, studentId, courseId);
|
|
30871
31011
|
const relevantCourseIds = new Set([courseId]);
|
|
30872
31012
|
const fetchLimit = Math.min(safeOffset + safeLimit + 1, TimebackAdminService.MAX_STUDENT_ACTIVITY_OFFSET + TimebackAdminService.MAX_STUDENT_ACTIVITY_LIMIT + 1);
|
|
30873
|
-
const allActivities = await this.listRecentActivityForStudent(client, studentId,
|
|
31013
|
+
const allActivities = await this.listRecentActivityForStudent(client, studentId, gameSource, relevantCourseIds, fetchLimit);
|
|
30874
31014
|
const activities = allActivities.slice(safeOffset, safeOffset + safeLimit);
|
|
30875
31015
|
const hasMore = allActivities.length > safeOffset + safeLimit;
|
|
30876
31016
|
return { activities, hasMore };
|
|
@@ -30882,6 +31022,7 @@ class TimebackAdminService {
|
|
|
30882
31022
|
courseId: data.courseId,
|
|
30883
31023
|
studentId: data.studentId,
|
|
30884
31024
|
xpEarned: data.xp,
|
|
31025
|
+
eventTime: resolveAdminEventTime(data),
|
|
30885
31026
|
reason: data.reason,
|
|
30886
31027
|
actor,
|
|
30887
31028
|
appName,
|
|
@@ -30896,6 +31037,7 @@ class TimebackAdminService {
|
|
|
30896
31037
|
courseId: data.courseId,
|
|
30897
31038
|
studentId: data.studentId,
|
|
30898
31039
|
activeTimeSeconds: data.seconds,
|
|
31040
|
+
eventTime: resolveAdminEventTime(data),
|
|
30899
31041
|
reason: data.reason,
|
|
30900
31042
|
actor,
|
|
30901
31043
|
appName,
|
|
@@ -30910,6 +31052,7 @@ class TimebackAdminService {
|
|
|
30910
31052
|
courseId: data.courseId,
|
|
30911
31053
|
studentId: data.studentId,
|
|
30912
31054
|
masteredUnits: data.units,
|
|
31055
|
+
eventTime: resolveAdminEventTime(data),
|
|
30913
31056
|
reason: data.reason,
|
|
30914
31057
|
actor,
|
|
30915
31058
|
appName,
|
|
@@ -30918,7 +31061,7 @@ class TimebackAdminService {
|
|
|
30918
31061
|
return { status: "ok" };
|
|
30919
31062
|
}
|
|
30920
31063
|
async toggleCourseCompletion(data, user) {
|
|
30921
|
-
const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
|
|
31064
|
+
const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId, "dashboard");
|
|
30922
31065
|
const historyClient = client;
|
|
30923
31066
|
const ids = deriveSourcedIds(data.courseId);
|
|
30924
31067
|
const lineItemId = `${ids.course}-mastery-completion-assessment`;
|
|
@@ -31011,6 +31154,77 @@ class TimebackAdminService {
|
|
|
31011
31154
|
}
|
|
31012
31155
|
return { status: "ok" };
|
|
31013
31156
|
}
|
|
31157
|
+
async searchStudentsForEnrollment(gameId, courseId, query, user) {
|
|
31158
|
+
const client = this.requireClient();
|
|
31159
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
31160
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31161
|
+
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
31162
|
+
});
|
|
31163
|
+
if (!integration) {
|
|
31164
|
+
throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
|
|
31165
|
+
}
|
|
31166
|
+
const trimmedQuery = query.trim();
|
|
31167
|
+
if (trimmedQuery.length < 2) {
|
|
31168
|
+
return { students: [] };
|
|
31169
|
+
}
|
|
31170
|
+
const filterParts = [
|
|
31171
|
+
`givenName~'${escapeFilterValue(trimmedQuery)}'`,
|
|
31172
|
+
`familyName~'${escapeFilterValue(trimmedQuery)}'`,
|
|
31173
|
+
`email~'${escapeFilterValue(trimmedQuery)}'`
|
|
31174
|
+
];
|
|
31175
|
+
const filter = filterParts.join(" OR ");
|
|
31176
|
+
const params = new URLSearchParams({ filter, limit: "25" });
|
|
31177
|
+
const endpoint = `/ims/oneroster/rostering/v1p2/users?${params}`;
|
|
31178
|
+
let allUsers = [];
|
|
31179
|
+
try {
|
|
31180
|
+
const response = await client["request"](endpoint, "GET");
|
|
31181
|
+
allUsers = response.users || [];
|
|
31182
|
+
} catch (error) {
|
|
31183
|
+
logger16.warn("Failed to search OneRoster users", {
|
|
31184
|
+
query: trimmedQuery,
|
|
31185
|
+
error: error instanceof Error ? error.message : String(error)
|
|
31186
|
+
});
|
|
31187
|
+
return { students: [] };
|
|
31188
|
+
}
|
|
31189
|
+
const roster = await client.oneroster.enrollments.listByCourse(courseId, {
|
|
31190
|
+
role: "student",
|
|
31191
|
+
includeUsers: false
|
|
31192
|
+
});
|
|
31193
|
+
const enrolledStudentIds = new Set(roster.map((entry) => entry.enrollment.user.sourcedId));
|
|
31194
|
+
const students = allUsers.filter((entry) => Boolean(entry.sourcedId) && entry.roles?.some((role) => role.role === "student") === true).map((entry) => ({
|
|
31195
|
+
studentId: entry.sourcedId,
|
|
31196
|
+
name: `${entry.givenName || ""} ${entry.familyName || ""}`.trim() || entry.sourcedId,
|
|
31197
|
+
email: entry.email || null,
|
|
31198
|
+
alreadyEnrolled: enrolledStudentIds.has(entry.sourcedId)
|
|
31199
|
+
}));
|
|
31200
|
+
return { students };
|
|
31201
|
+
}
|
|
31202
|
+
async enrollStudent(data, user) {
|
|
31203
|
+
const client = this.requireClient();
|
|
31204
|
+
await this.deps.validateGameManagementAccess(user, data.gameId);
|
|
31205
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31206
|
+
where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
|
|
31207
|
+
});
|
|
31208
|
+
if (!integration) {
|
|
31209
|
+
throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
|
|
31210
|
+
}
|
|
31211
|
+
await client.edubridge.enrollments.enroll(data.studentId, data.courseId, {
|
|
31212
|
+
role: "student"
|
|
31213
|
+
});
|
|
31214
|
+
return { status: "ok" };
|
|
31215
|
+
}
|
|
31216
|
+
async unenrollStudent(data, user) {
|
|
31217
|
+
const client = this.requireClient();
|
|
31218
|
+
await this.deps.validateGameManagementAccess(user, data.gameId);
|
|
31219
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31220
|
+
where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
|
|
31221
|
+
});
|
|
31222
|
+
if (!integration) {
|
|
31223
|
+
throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
|
|
31224
|
+
}
|
|
31225
|
+
await client.edubridge.enrollments.unenroll(data.studentId, data.courseId);
|
|
31226
|
+
return { status: "ok" };
|
|
31227
|
+
}
|
|
31014
31228
|
async getCompletionStatus(client, courseId, studentId) {
|
|
31015
31229
|
const ids = deriveSourcedIds(courseId);
|
|
31016
31230
|
const lineItemId = `${ids.course}-mastery-completion-assessment`;
|
|
@@ -31049,17 +31263,6 @@ class TimebackAdminService {
|
|
|
31049
31263
|
}));
|
|
31050
31264
|
return results;
|
|
31051
31265
|
}
|
|
31052
|
-
static chunkItems(items2, chunkSize) {
|
|
31053
|
-
if (items2.length === 0) {
|
|
31054
|
-
return [];
|
|
31055
|
-
}
|
|
31056
|
-
const effectiveChunkSize = Math.max(1, chunkSize);
|
|
31057
|
-
const chunks = [];
|
|
31058
|
-
for (let index2 = 0;index2 < items2.length; index2 += effectiveChunkSize) {
|
|
31059
|
-
chunks.push(items2.slice(index2, index2 + effectiveChunkSize));
|
|
31060
|
-
}
|
|
31061
|
-
return chunks;
|
|
31062
|
-
}
|
|
31063
31266
|
}
|
|
31064
31267
|
var logger16;
|
|
31065
31268
|
var init_timeback_admin_service = __esm(() => {
|
|
@@ -31071,594 +31274,729 @@ var init_timeback_admin_service = __esm(() => {
|
|
|
31071
31274
|
init_utils6();
|
|
31072
31275
|
init_src4();
|
|
31073
31276
|
init_errors();
|
|
31277
|
+
init_timeback_admin_util();
|
|
31074
31278
|
init_timeback_util();
|
|
31075
31279
|
logger16 = log.scope("TimebackAdminService");
|
|
31076
31280
|
});
|
|
31077
31281
|
|
|
31078
31282
|
// ../api-core/src/services/timeback.service.ts
|
|
31079
|
-
|
|
31080
|
-
|
|
31081
|
-
|
|
31082
|
-
|
|
31083
|
-
|
|
31084
|
-
|
|
31085
|
-
|
|
31086
|
-
|
|
31087
|
-
|
|
31283
|
+
var logger17, TimebackService;
|
|
31284
|
+
var init_timeback_service = __esm(() => {
|
|
31285
|
+
init_drizzle_orm();
|
|
31286
|
+
init_src();
|
|
31287
|
+
init_tables_index();
|
|
31288
|
+
init_src2();
|
|
31289
|
+
init_types4();
|
|
31290
|
+
init_src4();
|
|
31291
|
+
init_errors();
|
|
31292
|
+
init_timeback_util();
|
|
31293
|
+
logger17 = log.scope("TimebackService");
|
|
31294
|
+
TimebackService = class TimebackService {
|
|
31295
|
+
static HEARTBEAT_DEDUPE_TTL_MS = 5 * 60 * 1000;
|
|
31296
|
+
static processedHeartbeatWindows = new Map;
|
|
31297
|
+
static inFlightHeartbeatWindows = new Map;
|
|
31298
|
+
deps;
|
|
31299
|
+
static cleanHeartbeatDedupeCache(now2 = Date.now()) {
|
|
31300
|
+
for (const [key, timestamp3] of this.processedHeartbeatWindows) {
|
|
31301
|
+
if (now2 - timestamp3 > this.HEARTBEAT_DEDUPE_TTL_MS) {
|
|
31302
|
+
this.processedHeartbeatWindows.delete(key);
|
|
31303
|
+
}
|
|
31304
|
+
}
|
|
31088
31305
|
}
|
|
31089
|
-
|
|
31090
|
-
|
|
31091
|
-
|
|
31092
|
-
const db2 = this.deps.db;
|
|
31093
|
-
const tz = timezone2 || PLATFORM_TIMEZONE;
|
|
31094
|
-
const base = date3 ? new Date(date3) : new Date;
|
|
31095
|
-
if (isNaN(base.getTime())) {
|
|
31096
|
-
throw new ValidationError("Invalid date format. Use ISO 8601 format.");
|
|
31306
|
+
static isDuplicateHeartbeatWindow(key) {
|
|
31307
|
+
this.cleanHeartbeatDedupeCache();
|
|
31308
|
+
return this.processedHeartbeatWindows.has(key);
|
|
31097
31309
|
}
|
|
31098
|
-
|
|
31099
|
-
|
|
31100
|
-
} catch {
|
|
31101
|
-
throw new ValidationError(`Invalid timezone: ${tz}`);
|
|
31310
|
+
static getInFlightHeartbeatWindow(key) {
|
|
31311
|
+
return this.inFlightHeartbeatWindows.get(key);
|
|
31102
31312
|
}
|
|
31103
|
-
|
|
31104
|
-
|
|
31105
|
-
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);
|
|
31106
|
-
if (result2.length === 0) {
|
|
31107
|
-
return { xp: 0, date: todayMidnight.toISOString() };
|
|
31108
|
-
}
|
|
31109
|
-
return { xp: result2[0].xp, date: result2[0].date.toISOString() };
|
|
31313
|
+
static markHeartbeatWindowProcessed(key) {
|
|
31314
|
+
this.processedHeartbeatWindows.set(key, Date.now());
|
|
31110
31315
|
}
|
|
31111
|
-
|
|
31112
|
-
|
|
31113
|
-
return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
|
|
31114
|
-
}
|
|
31115
|
-
async getTotalXp(userId) {
|
|
31116
|
-
const db2 = this.deps.db;
|
|
31117
|
-
const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
|
|
31118
|
-
return { totalXp: Number(result[0]?.totalXp) || 0 };
|
|
31119
|
-
}
|
|
31120
|
-
async updateTodayXp(userId, data) {
|
|
31121
|
-
const db2 = this.deps.db;
|
|
31122
|
-
const { xp, userTimestamp } = data;
|
|
31123
|
-
let targetDate;
|
|
31124
|
-
if (userTimestamp) {
|
|
31125
|
-
targetDate = new Date(userTimestamp);
|
|
31126
|
-
if (isNaN(targetDate.getTime())) {
|
|
31127
|
-
throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
|
|
31128
|
-
}
|
|
31129
|
-
targetDate.setHours(0, 0, 0, 0);
|
|
31130
|
-
} else {
|
|
31131
|
-
targetDate = new Date;
|
|
31132
|
-
targetDate.setUTCHours(0, 0, 0, 0);
|
|
31316
|
+
static markHeartbeatWindowInFlight(key, promise) {
|
|
31317
|
+
this.inFlightHeartbeatWindows.set(key, promise);
|
|
31133
31318
|
}
|
|
31134
|
-
|
|
31135
|
-
|
|
31136
|
-
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31137
|
-
}).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
|
|
31138
|
-
if (!result) {
|
|
31139
|
-
logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
|
|
31140
|
-
throw new InternalError("Failed to update daily XP record");
|
|
31319
|
+
static clearInFlightHeartbeatWindow(key) {
|
|
31320
|
+
this.inFlightHeartbeatWindows.delete(key);
|
|
31141
31321
|
}
|
|
31142
|
-
|
|
31143
|
-
|
|
31144
|
-
|
|
31145
|
-
|
|
31146
|
-
|
|
31147
|
-
|
|
31148
|
-
|
|
31149
|
-
|
|
31150
|
-
|
|
31322
|
+
static addResumeIdToExtensions(extensions, resumeId) {
|
|
31323
|
+
const base = extensions ?? {};
|
|
31324
|
+
const existingPlaycademy = base.playcademy;
|
|
31325
|
+
const playcademy = typeof existingPlaycademy === "object" && existingPlaycademy !== null && !Array.isArray(existingPlaycademy) ? existingPlaycademy : {};
|
|
31326
|
+
return {
|
|
31327
|
+
...base,
|
|
31328
|
+
playcademy: {
|
|
31329
|
+
...playcademy,
|
|
31330
|
+
resumeId
|
|
31331
|
+
}
|
|
31332
|
+
};
|
|
31151
31333
|
}
|
|
31152
|
-
|
|
31153
|
-
|
|
31154
|
-
end.setUTCHours(23, 59, 59, 999);
|
|
31155
|
-
whereConditions.push(lte(timebackDailyXp.date, end));
|
|
31334
|
+
constructor(deps) {
|
|
31335
|
+
this.deps = deps;
|
|
31156
31336
|
}
|
|
31157
|
-
|
|
31158
|
-
|
|
31159
|
-
|
|
31160
|
-
|
|
31161
|
-
|
|
31162
|
-
|
|
31163
|
-
const client = this.requireClient();
|
|
31164
|
-
const db2 = this.deps.db;
|
|
31165
|
-
const dbUser = await db2.query.users.findFirst({
|
|
31166
|
-
where: eq(users.id, user.id),
|
|
31167
|
-
columns: { id: true, timebackId: true }
|
|
31168
|
-
});
|
|
31169
|
-
if (dbUser?.timebackId) {
|
|
31170
|
-
logger17.info("Student already onboarded", { userId: user.id });
|
|
31171
|
-
return { status: "already_populated" };
|
|
31337
|
+
requireClient() {
|
|
31338
|
+
if (!this.deps.timeback) {
|
|
31339
|
+
logger17.error("Timeback client not available in context");
|
|
31340
|
+
throw new ValidationError("Timeback integration not available in this environment");
|
|
31341
|
+
}
|
|
31342
|
+
return this.deps.timeback;
|
|
31172
31343
|
}
|
|
31173
|
-
|
|
31174
|
-
|
|
31175
|
-
|
|
31176
|
-
const
|
|
31177
|
-
|
|
31178
|
-
|
|
31179
|
-
logger17.info("Found existing student in OneRoster", {
|
|
31180
|
-
userId: user.id,
|
|
31181
|
-
timebackId
|
|
31182
|
-
});
|
|
31183
|
-
} catch {
|
|
31184
|
-
if (!providedNames?.firstName || !providedNames?.lastName) {
|
|
31185
|
-
return { status: "no_record" };
|
|
31344
|
+
async getTodayXp(userId, date3, timezone2) {
|
|
31345
|
+
const db2 = this.deps.db;
|
|
31346
|
+
const tz = timezone2 || PLATFORM_TIMEZONE;
|
|
31347
|
+
const base = date3 ? new Date(date3) : new Date;
|
|
31348
|
+
if (isNaN(base.getTime())) {
|
|
31349
|
+
throw new ValidationError("Invalid date format. Use ISO 8601 format.");
|
|
31186
31350
|
}
|
|
31187
|
-
|
|
31188
|
-
|
|
31189
|
-
|
|
31190
|
-
|
|
31191
|
-
|
|
31192
|
-
|
|
31193
|
-
|
|
31194
|
-
|
|
31195
|
-
|
|
31196
|
-
{
|
|
31197
|
-
|
|
31198
|
-
|
|
31199
|
-
|
|
31200
|
-
|
|
31201
|
-
|
|
31202
|
-
}
|
|
31203
|
-
|
|
31204
|
-
|
|
31351
|
+
try {
|
|
31352
|
+
new Intl.DateTimeFormat(undefined, { timeZone: tz });
|
|
31353
|
+
} catch {
|
|
31354
|
+
throw new ValidationError(`Invalid timezone: ${tz}`);
|
|
31355
|
+
}
|
|
31356
|
+
if (tz === PLATFORM_TIMEZONE) {
|
|
31357
|
+
const todayMidnight = getUtcInstantForMidnight(base, tz);
|
|
31358
|
+
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);
|
|
31359
|
+
if (result2.length === 0) {
|
|
31360
|
+
return { xp: 0, date: todayMidnight.toISOString() };
|
|
31361
|
+
}
|
|
31362
|
+
return { xp: result2[0].xp, date: result2[0].date.toISOString() };
|
|
31363
|
+
}
|
|
31364
|
+
const { startOfDay, endOfDay } = getDayBoundariesInTimezone(base, tz);
|
|
31365
|
+
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))));
|
|
31366
|
+
return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
|
|
31367
|
+
}
|
|
31368
|
+
async getTotalXp(userId) {
|
|
31369
|
+
const db2 = this.deps.db;
|
|
31370
|
+
const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
|
|
31371
|
+
return { totalXp: Number(result[0]?.totalXp) || 0 };
|
|
31372
|
+
}
|
|
31373
|
+
async updateTodayXp(userId, data) {
|
|
31374
|
+
const db2 = this.deps.db;
|
|
31375
|
+
const { xp, userTimestamp } = data;
|
|
31376
|
+
let targetDate;
|
|
31377
|
+
if (userTimestamp) {
|
|
31378
|
+
targetDate = new Date(userTimestamp);
|
|
31379
|
+
if (isNaN(targetDate.getTime())) {
|
|
31380
|
+
throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
|
|
31381
|
+
}
|
|
31382
|
+
targetDate.setHours(0, 0, 0, 0);
|
|
31383
|
+
} else {
|
|
31384
|
+
targetDate = new Date;
|
|
31385
|
+
targetDate.setUTCHours(0, 0, 0, 0);
|
|
31386
|
+
}
|
|
31387
|
+
const [result] = await db2.insert(timebackDailyXp).values({ userId, date: targetDate, xp }).onConflictDoUpdate({
|
|
31388
|
+
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31389
|
+
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31390
|
+
}).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
|
|
31391
|
+
if (!result) {
|
|
31392
|
+
logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
|
|
31393
|
+
throw new InternalError("Failed to update daily XP record");
|
|
31205
31394
|
}
|
|
31206
|
-
|
|
31207
|
-
name3 = `${providedNames.firstName} ${providedNames.lastName}`;
|
|
31208
|
-
logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
|
|
31395
|
+
return { xp: result.xp, date: result.date.toISOString() };
|
|
31209
31396
|
}
|
|
31210
|
-
|
|
31211
|
-
|
|
31212
|
-
|
|
31213
|
-
|
|
31214
|
-
|
|
31215
|
-
|
|
31216
|
-
|
|
31217
|
-
|
|
31397
|
+
async getXpHistory(userId, startDate, endDate) {
|
|
31398
|
+
const db2 = this.deps.db;
|
|
31399
|
+
const whereConditions = [eq(timebackDailyXp.userId, userId)];
|
|
31400
|
+
if (startDate) {
|
|
31401
|
+
const start2 = new Date(startDate);
|
|
31402
|
+
start2.setUTCHours(0, 0, 0, 0);
|
|
31403
|
+
whereConditions.push(gte(timebackDailyXp.date, start2));
|
|
31404
|
+
}
|
|
31405
|
+
if (endDate) {
|
|
31406
|
+
const end = new Date(endDate);
|
|
31407
|
+
end.setUTCHours(23, 59, 59, 999);
|
|
31408
|
+
whereConditions.push(lte(timebackDailyXp.date, end));
|
|
31409
|
+
}
|
|
31410
|
+
const result = await db2.select({ date: timebackDailyXp.date, xp: timebackDailyXp.xp }).from(timebackDailyXp).where(and(...whereConditions)).orderBy(timebackDailyXp.date);
|
|
31411
|
+
return {
|
|
31412
|
+
history: result.map((row) => ({ date: row.date.toISOString(), xp: row.xp }))
|
|
31413
|
+
};
|
|
31414
|
+
}
|
|
31415
|
+
async populateStudent(user, providedNames) {
|
|
31416
|
+
const client = this.requireClient();
|
|
31417
|
+
const db2 = this.deps.db;
|
|
31418
|
+
const dbUser = await db2.query.users.findFirst({
|
|
31419
|
+
where: eq(users.id, user.id),
|
|
31420
|
+
columns: { id: true, timebackId: true }
|
|
31421
|
+
});
|
|
31422
|
+
if (dbUser?.timebackId) {
|
|
31423
|
+
logger17.info("Student already onboarded", { userId: user.id });
|
|
31424
|
+
return { status: "already_populated" };
|
|
31425
|
+
}
|
|
31426
|
+
let timebackId;
|
|
31427
|
+
let name3;
|
|
31428
|
+
try {
|
|
31429
|
+
const existingUser = await client.oneroster.users.findByEmail(user.email);
|
|
31430
|
+
timebackId = existingUser.sourcedId;
|
|
31431
|
+
name3 = `${existingUser.givenName} ${existingUser.familyName}`;
|
|
31432
|
+
logger17.info("Found existing student in OneRoster", {
|
|
31433
|
+
userId: user.id,
|
|
31434
|
+
timebackId
|
|
31435
|
+
});
|
|
31436
|
+
} catch {
|
|
31437
|
+
if (!providedNames?.firstName || !providedNames?.lastName) {
|
|
31438
|
+
return { status: "no_record" };
|
|
31218
31439
|
}
|
|
31219
|
-
const
|
|
31220
|
-
|
|
31221
|
-
|
|
31222
|
-
|
|
31223
|
-
|
|
31224
|
-
|
|
31225
|
-
|
|
31440
|
+
const sourcedId = crypto.randomUUID();
|
|
31441
|
+
const response = await client.oneroster.users.create({
|
|
31442
|
+
sourcedId,
|
|
31443
|
+
status: "active",
|
|
31444
|
+
enabledUser: true,
|
|
31445
|
+
givenName: providedNames.firstName,
|
|
31446
|
+
familyName: providedNames.lastName,
|
|
31447
|
+
email: user.email,
|
|
31448
|
+
roles: [
|
|
31449
|
+
{
|
|
31450
|
+
roleType: "primary",
|
|
31451
|
+
role: "student",
|
|
31452
|
+
org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
|
|
31453
|
+
}
|
|
31454
|
+
]
|
|
31455
|
+
});
|
|
31456
|
+
if (!response.sourcedIdPairs?.allocatedSourcedId) {
|
|
31457
|
+
return { status: "error", message: "Timeback did not return allocatedSourcedId" };
|
|
31458
|
+
}
|
|
31459
|
+
timebackId = response.sourcedIdPairs.allocatedSourcedId;
|
|
31460
|
+
name3 = `${providedNames.firstName} ${providedNames.lastName}`;
|
|
31461
|
+
logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
|
|
31462
|
+
}
|
|
31463
|
+
const assessments = await this.fetchAssessments(timebackId);
|
|
31464
|
+
await db2.transaction(async (tx) => {
|
|
31465
|
+
if (assessments.length > 0) {
|
|
31466
|
+
const events = mapAssessmentsToXpEvents(user.id, assessments);
|
|
31467
|
+
for (const event of events) {
|
|
31468
|
+
try {
|
|
31469
|
+
await tx.insert(timebackXpEvents).values(event);
|
|
31470
|
+
} catch {}
|
|
31471
|
+
}
|
|
31472
|
+
const dailyMap = new Map;
|
|
31473
|
+
for (const a of assessments) {
|
|
31474
|
+
const xp = a.metadata?.xp;
|
|
31475
|
+
if (typeof xp === "number" && a.scoreDate) {
|
|
31476
|
+
const day = getUtcInstantForMidnight(new Date(a.scoreDate), PLATFORM_TIMEZONE);
|
|
31477
|
+
const key = day.toISOString();
|
|
31478
|
+
dailyMap.set(key, (dailyMap.get(key) || 0) + xp);
|
|
31479
|
+
}
|
|
31480
|
+
}
|
|
31481
|
+
if (dailyMap.size > 0) {
|
|
31482
|
+
const dailyRecords = [...dailyMap.entries()].map(([iso, xp]) => ({
|
|
31483
|
+
userId: user.id,
|
|
31484
|
+
date: new Date(iso),
|
|
31485
|
+
xp
|
|
31486
|
+
}));
|
|
31487
|
+
await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
|
|
31488
|
+
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31489
|
+
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31490
|
+
});
|
|
31226
31491
|
}
|
|
31227
31492
|
}
|
|
31228
|
-
|
|
31229
|
-
|
|
31493
|
+
const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
|
|
31494
|
+
if (!updated) {
|
|
31495
|
+
logger17.error("User Timeback ID update returned no rows", {
|
|
31230
31496
|
userId: user.id,
|
|
31231
|
-
|
|
31232
|
-
xp
|
|
31233
|
-
}));
|
|
31234
|
-
await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
|
|
31235
|
-
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31236
|
-
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31497
|
+
timebackId
|
|
31237
31498
|
});
|
|
31499
|
+
throw new InternalError("Failed to update user with Timeback ID");
|
|
31238
31500
|
}
|
|
31239
|
-
}
|
|
31240
|
-
const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
|
|
31241
|
-
if (!updated) {
|
|
31242
|
-
logger17.error("User Timeback ID update returned no rows", {
|
|
31243
|
-
userId: user.id,
|
|
31244
|
-
timebackId
|
|
31245
|
-
});
|
|
31246
|
-
throw new InternalError("Failed to update user with Timeback ID");
|
|
31247
|
-
}
|
|
31248
|
-
});
|
|
31249
|
-
return { status: "ok" };
|
|
31250
|
-
}
|
|
31251
|
-
async fetchAssessments(studentSourcedId) {
|
|
31252
|
-
const client = this.requireClient();
|
|
31253
|
-
const allAssessments = [];
|
|
31254
|
-
const limit = 3000;
|
|
31255
|
-
const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
|
|
31256
|
-
let offset = 0;
|
|
31257
|
-
try {
|
|
31258
|
-
while (true) {
|
|
31259
|
-
const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
|
|
31260
|
-
allAssessments.push(...results);
|
|
31261
|
-
if (results.length < limit) {
|
|
31262
|
-
break;
|
|
31263
|
-
}
|
|
31264
|
-
offset += limit;
|
|
31265
|
-
}
|
|
31266
|
-
logger17.debug("Fetched assessments", {
|
|
31267
|
-
studentSourcedId,
|
|
31268
|
-
totalCount: allAssessments.length
|
|
31269
31501
|
});
|
|
31270
|
-
return
|
|
31271
|
-
} catch (error) {
|
|
31272
|
-
logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
|
|
31273
|
-
return [];
|
|
31274
|
-
}
|
|
31275
|
-
}
|
|
31276
|
-
async getUserData(userId, gameId) {
|
|
31277
|
-
const db2 = this.deps.db;
|
|
31278
|
-
const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
|
|
31279
|
-
if (!userData) {
|
|
31280
|
-
throw new NotFoundError("User", userId);
|
|
31281
|
-
}
|
|
31282
|
-
if (!userData.timebackId) {
|
|
31283
|
-
throw new NotFoundError("Timeback account not found for user");
|
|
31502
|
+
return { status: "ok" };
|
|
31284
31503
|
}
|
|
31285
|
-
|
|
31286
|
-
this.
|
|
31287
|
-
|
|
31288
|
-
|
|
31289
|
-
|
|
31290
|
-
|
|
31291
|
-
|
|
31292
|
-
|
|
31293
|
-
|
|
31294
|
-
|
|
31295
|
-
|
|
31296
|
-
|
|
31297
|
-
|
|
31298
|
-
|
|
31299
|
-
|
|
31300
|
-
|
|
31301
|
-
|
|
31302
|
-
|
|
31303
|
-
organizations: profile.organizations
|
|
31304
|
-
};
|
|
31305
|
-
}
|
|
31306
|
-
async fetchStudentProfile(timebackId) {
|
|
31307
|
-
const client = this.requireClient();
|
|
31308
|
-
try {
|
|
31309
|
-
const user = await client.oneroster.users.get(timebackId);
|
|
31310
|
-
const primaryRole = user.roles.find((r) => r.roleType === "primary");
|
|
31311
|
-
const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
|
|
31312
|
-
const orgMap = new Map;
|
|
31313
|
-
if (user.primaryOrg) {
|
|
31314
|
-
orgMap.set(user.primaryOrg.sourcedId, {
|
|
31315
|
-
id: user.primaryOrg.sourcedId,
|
|
31316
|
-
name: user.primaryOrg.name ?? null,
|
|
31317
|
-
type: user.primaryOrg.type || "school",
|
|
31318
|
-
isPrimary: true
|
|
31504
|
+
async fetchAssessments(studentSourcedId) {
|
|
31505
|
+
const client = this.requireClient();
|
|
31506
|
+
const allAssessments = [];
|
|
31507
|
+
const limit = 3000;
|
|
31508
|
+
const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
|
|
31509
|
+
let offset = 0;
|
|
31510
|
+
try {
|
|
31511
|
+
while (true) {
|
|
31512
|
+
const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
|
|
31513
|
+
allAssessments.push(...results);
|
|
31514
|
+
if (results.length < limit) {
|
|
31515
|
+
break;
|
|
31516
|
+
}
|
|
31517
|
+
offset += limit;
|
|
31518
|
+
}
|
|
31519
|
+
logger17.debug("Fetched assessments", {
|
|
31520
|
+
studentSourcedId,
|
|
31521
|
+
totalCount: allAssessments.length
|
|
31319
31522
|
});
|
|
31523
|
+
return allAssessments;
|
|
31524
|
+
} catch (error) {
|
|
31525
|
+
logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
|
|
31526
|
+
return [];
|
|
31320
31527
|
}
|
|
31321
|
-
|
|
31322
|
-
|
|
31323
|
-
|
|
31324
|
-
|
|
31325
|
-
|
|
31326
|
-
|
|
31327
|
-
|
|
31528
|
+
}
|
|
31529
|
+
async getUserData(userId, gameId) {
|
|
31530
|
+
const db2 = this.deps.db;
|
|
31531
|
+
const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
|
|
31532
|
+
if (!userData) {
|
|
31533
|
+
throw new NotFoundError("User", userId);
|
|
31534
|
+
}
|
|
31535
|
+
if (!userData.timebackId) {
|
|
31536
|
+
throw new NotFoundError("Timeback account not found for user");
|
|
31537
|
+
}
|
|
31538
|
+
const [profile, allEnrollments] = await Promise.all([
|
|
31539
|
+
this.fetchStudentProfile(userData.timebackId),
|
|
31540
|
+
this.fetchEnrollments(userData.timebackId)
|
|
31541
|
+
]);
|
|
31542
|
+
const enrollments = gameId ? allEnrollments.filter((e) => e.gameId === gameId) : allEnrollments;
|
|
31543
|
+
const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
|
|
31544
|
+
const organizations = gameId && enrollmentOrgIds.size > 0 ? profile.organizations.filter((o) => enrollmentOrgIds.has(o.id)) : profile.organizations;
|
|
31545
|
+
return { id: userData.timebackId, role: profile.role, enrollments, organizations };
|
|
31546
|
+
}
|
|
31547
|
+
async getUserDataByTimebackId(timebackId) {
|
|
31548
|
+
const [profile, enrollments] = await Promise.all([
|
|
31549
|
+
this.fetchStudentProfile(timebackId),
|
|
31550
|
+
this.fetchEnrollments(timebackId)
|
|
31551
|
+
]);
|
|
31552
|
+
return {
|
|
31553
|
+
id: timebackId,
|
|
31554
|
+
role: profile.role,
|
|
31555
|
+
enrollments,
|
|
31556
|
+
organizations: profile.organizations
|
|
31557
|
+
};
|
|
31558
|
+
}
|
|
31559
|
+
async fetchStudentProfile(timebackId) {
|
|
31560
|
+
const client = this.requireClient();
|
|
31561
|
+
try {
|
|
31562
|
+
const user = await client.oneroster.users.get(timebackId);
|
|
31563
|
+
const primaryRole = user.roles.find((r) => r.roleType === "primary");
|
|
31564
|
+
const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
|
|
31565
|
+
const orgMap = new Map;
|
|
31566
|
+
if (user.primaryOrg) {
|
|
31567
|
+
orgMap.set(user.primaryOrg.sourcedId, {
|
|
31568
|
+
id: user.primaryOrg.sourcedId,
|
|
31569
|
+
name: user.primaryOrg.name ?? null,
|
|
31570
|
+
type: user.primaryOrg.type || "school",
|
|
31571
|
+
isPrimary: true
|
|
31328
31572
|
});
|
|
31329
31573
|
}
|
|
31574
|
+
for (const r of user.roles) {
|
|
31575
|
+
if (r.org && !orgMap.has(r.org.sourcedId)) {
|
|
31576
|
+
orgMap.set(r.org.sourcedId, {
|
|
31577
|
+
id: r.org.sourcedId,
|
|
31578
|
+
name: null,
|
|
31579
|
+
type: "school",
|
|
31580
|
+
isPrimary: false
|
|
31581
|
+
});
|
|
31582
|
+
}
|
|
31583
|
+
}
|
|
31584
|
+
return { role, organizations: [...orgMap.values()] };
|
|
31585
|
+
} catch {
|
|
31586
|
+
return { role: "student", organizations: [] };
|
|
31330
31587
|
}
|
|
31331
|
-
return { role, organizations: [...orgMap.values()] };
|
|
31332
|
-
} catch {
|
|
31333
|
-
return { role: "student", organizations: [] };
|
|
31334
31588
|
}
|
|
31335
|
-
|
|
31336
|
-
|
|
31337
|
-
|
|
31338
|
-
|
|
31339
|
-
|
|
31340
|
-
|
|
31341
|
-
|
|
31342
|
-
|
|
31589
|
+
async fetchEnrollments(timebackId) {
|
|
31590
|
+
const client = this.requireClient();
|
|
31591
|
+
const db2 = this.deps.db;
|
|
31592
|
+
try {
|
|
31593
|
+
const enrollments = await client.getEnrollments(timebackId);
|
|
31594
|
+
const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
|
|
31595
|
+
if (courseIds.length === 0) {
|
|
31596
|
+
return [];
|
|
31597
|
+
}
|
|
31598
|
+
const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
|
|
31599
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31600
|
+
where: inArray(gameTimebackIntegrations.courseId, courseIds)
|
|
31601
|
+
});
|
|
31602
|
+
return integrations.map((i2) => ({
|
|
31603
|
+
gameId: i2.gameId,
|
|
31604
|
+
grade: i2.grade,
|
|
31605
|
+
subject: i2.subject,
|
|
31606
|
+
courseId: i2.courseId,
|
|
31607
|
+
orgId: courseToSchool.get(i2.courseId)
|
|
31608
|
+
}));
|
|
31609
|
+
} catch {
|
|
31343
31610
|
return [];
|
|
31344
31611
|
}
|
|
31345
|
-
const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
|
|
31346
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31347
|
-
where: inArray(gameTimebackIntegrations.courseId, courseIds)
|
|
31348
|
-
});
|
|
31349
|
-
return integrations.map((i2) => ({
|
|
31350
|
-
gameId: i2.gameId,
|
|
31351
|
-
grade: i2.grade,
|
|
31352
|
-
subject: i2.subject,
|
|
31353
|
-
courseId: i2.courseId,
|
|
31354
|
-
orgId: courseToSchool.get(i2.courseId)
|
|
31355
|
-
}));
|
|
31356
|
-
} catch {
|
|
31357
|
-
return [];
|
|
31358
31612
|
}
|
|
31359
|
-
|
|
31360
|
-
|
|
31361
|
-
|
|
31362
|
-
|
|
31363
|
-
|
|
31364
|
-
|
|
31365
|
-
|
|
31366
|
-
|
|
31367
|
-
|
|
31368
|
-
|
|
31369
|
-
|
|
31370
|
-
|
|
31371
|
-
|
|
31372
|
-
|
|
31373
|
-
|
|
31374
|
-
const {
|
|
31375
|
-
subject: subjectInput,
|
|
31376
|
-
grade,
|
|
31377
|
-
title,
|
|
31378
|
-
courseCode,
|
|
31379
|
-
level,
|
|
31380
|
-
metadata: metadata2,
|
|
31381
|
-
totalXp: derivedTotalXp,
|
|
31382
|
-
masterableUnits: derivedMasterableUnits
|
|
31383
|
-
} = courseConfig;
|
|
31384
|
-
if (!isTimebackSubject(subjectInput)) {
|
|
31385
|
-
logger17.warn("Invalid Timeback subject in course config", {
|
|
31613
|
+
async setupIntegration(gameId, request, user) {
|
|
31614
|
+
const client = this.requireClient();
|
|
31615
|
+
const db2 = this.deps.db;
|
|
31616
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31617
|
+
const { courses, baseConfig, verbose } = request;
|
|
31618
|
+
const existing = await db2.query.gameTimebackIntegrations.findMany({
|
|
31619
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31620
|
+
});
|
|
31621
|
+
const integrations = [];
|
|
31622
|
+
const verboseData = [];
|
|
31623
|
+
for (const courseConfig of courses) {
|
|
31624
|
+
let applySuffix = function(text3) {
|
|
31625
|
+
return suffix ? `${text3} ${suffix}` : text3;
|
|
31626
|
+
};
|
|
31627
|
+
const {
|
|
31386
31628
|
subject: subjectInput,
|
|
31387
|
-
courseCode,
|
|
31388
|
-
title
|
|
31389
|
-
});
|
|
31390
|
-
throw new ValidationError(`Invalid subject "${subjectInput}"`);
|
|
31391
|
-
}
|
|
31392
|
-
if (!isTimebackGrade(grade)) {
|
|
31393
|
-
logger17.warn("Invalid Timeback grade in course config", {
|
|
31394
31629
|
grade,
|
|
31395
|
-
courseCode,
|
|
31396
|
-
title
|
|
31397
|
-
});
|
|
31398
|
-
throw new ValidationError(`Invalid grade "${grade}"`);
|
|
31399
|
-
}
|
|
31400
|
-
const subject = subjectInput;
|
|
31401
|
-
const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
|
|
31402
|
-
const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
|
|
31403
|
-
const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
|
|
31404
|
-
if (typeof totalXp !== "number") {
|
|
31405
|
-
logger17.warn("Course missing totalXp in Timeback config", {
|
|
31406
|
-
courseCode,
|
|
31407
|
-
title
|
|
31408
|
-
});
|
|
31409
|
-
throw new ValidationError(`Course "${title}" is missing totalXp`);
|
|
31410
|
-
}
|
|
31411
|
-
const suffix = baseConfig.component.titleSuffix || "";
|
|
31412
|
-
const fullConfig = {
|
|
31413
|
-
organization: baseConfig.organization,
|
|
31414
|
-
course: {
|
|
31415
31630
|
title,
|
|
31416
|
-
subjects: [subject],
|
|
31417
|
-
grades: [grade],
|
|
31418
31631
|
courseCode,
|
|
31419
31632
|
level,
|
|
31420
|
-
|
|
31421
|
-
|
|
31422
|
-
|
|
31423
|
-
|
|
31424
|
-
|
|
31425
|
-
|
|
31426
|
-
|
|
31427
|
-
|
|
31428
|
-
|
|
31429
|
-
|
|
31430
|
-
|
|
31431
|
-
baseMetadata: baseConfig.resource.metadata,
|
|
31432
|
-
subject,
|
|
31433
|
-
grade,
|
|
31434
|
-
totalXp,
|
|
31435
|
-
masterableUnits
|
|
31436
|
-
})
|
|
31437
|
-
},
|
|
31438
|
-
componentResource: {
|
|
31439
|
-
...baseConfig.componentResource,
|
|
31440
|
-
title: applySuffix(baseConfig.componentResource.title || "")
|
|
31441
|
-
}
|
|
31442
|
-
};
|
|
31443
|
-
const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
|
|
31444
|
-
if (existingIntegration) {
|
|
31445
|
-
await client.update(existingIntegration.courseId, fullConfig);
|
|
31446
|
-
const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
|
|
31447
|
-
if (updated) {
|
|
31448
|
-
integrations.push(this.toGameTimebackIntegration(updated));
|
|
31633
|
+
metadata: metadata2,
|
|
31634
|
+
totalXp: derivedTotalXp,
|
|
31635
|
+
masterableUnits: derivedMasterableUnits
|
|
31636
|
+
} = courseConfig;
|
|
31637
|
+
if (!isTimebackSubject(subjectInput)) {
|
|
31638
|
+
logger17.warn("Invalid Timeback subject in course config", {
|
|
31639
|
+
subject: subjectInput,
|
|
31640
|
+
courseCode,
|
|
31641
|
+
title
|
|
31642
|
+
});
|
|
31643
|
+
throw new ValidationError(`Invalid subject "${subjectInput}"`);
|
|
31449
31644
|
}
|
|
31450
|
-
|
|
31451
|
-
|
|
31452
|
-
|
|
31453
|
-
|
|
31454
|
-
|
|
31455
|
-
|
|
31456
|
-
|
|
31457
|
-
|
|
31645
|
+
if (!isTimebackGrade(grade)) {
|
|
31646
|
+
logger17.warn("Invalid Timeback grade in course config", {
|
|
31647
|
+
grade,
|
|
31648
|
+
courseCode,
|
|
31649
|
+
title
|
|
31650
|
+
});
|
|
31651
|
+
throw new ValidationError(`Invalid grade "${grade}"`);
|
|
31652
|
+
}
|
|
31653
|
+
const subject = subjectInput;
|
|
31654
|
+
const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
|
|
31655
|
+
const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
|
|
31656
|
+
const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
|
|
31657
|
+
if (typeof totalXp !== "number") {
|
|
31658
|
+
logger17.warn("Course missing totalXp in Timeback config", {
|
|
31659
|
+
courseCode,
|
|
31660
|
+
title
|
|
31661
|
+
});
|
|
31662
|
+
throw new ValidationError(`Course "${title}" is missing totalXp`);
|
|
31663
|
+
}
|
|
31664
|
+
const suffix = baseConfig.component.titleSuffix || "";
|
|
31665
|
+
const fullConfig = {
|
|
31666
|
+
organization: baseConfig.organization,
|
|
31667
|
+
course: {
|
|
31668
|
+
title,
|
|
31669
|
+
subjects: [subject],
|
|
31670
|
+
grades: [grade],
|
|
31671
|
+
courseCode,
|
|
31672
|
+
level,
|
|
31673
|
+
gradingScheme: "STANDARD",
|
|
31674
|
+
metadata: metadata2
|
|
31675
|
+
},
|
|
31676
|
+
component: {
|
|
31677
|
+
...baseConfig.component,
|
|
31678
|
+
title: applySuffix(baseConfig.component.title || `${title} Activities`)
|
|
31679
|
+
},
|
|
31680
|
+
resource: {
|
|
31681
|
+
...baseConfig.resource,
|
|
31682
|
+
title: applySuffix(baseConfig.resource.title || `${title} Game`),
|
|
31683
|
+
metadata: buildResourceMetadata({
|
|
31684
|
+
baseMetadata: baseConfig.resource.metadata,
|
|
31685
|
+
subject,
|
|
31686
|
+
grade,
|
|
31687
|
+
totalXp,
|
|
31688
|
+
masterableUnits
|
|
31689
|
+
})
|
|
31690
|
+
},
|
|
31691
|
+
componentResource: {
|
|
31692
|
+
...baseConfig.componentResource,
|
|
31693
|
+
title: applySuffix(baseConfig.componentResource.title || "")
|
|
31694
|
+
}
|
|
31695
|
+
};
|
|
31696
|
+
const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
|
|
31697
|
+
if (existingIntegration) {
|
|
31698
|
+
await client.update(existingIntegration.courseId, fullConfig);
|
|
31699
|
+
const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
|
|
31700
|
+
if (updated) {
|
|
31701
|
+
integrations.push(this.toGameTimebackIntegration(updated));
|
|
31702
|
+
}
|
|
31703
|
+
} else {
|
|
31704
|
+
const result = await client.setup(fullConfig, { verbose });
|
|
31705
|
+
const [integration] = await db2.insert(gameTimebackIntegrations).values({ gameId, courseId: result.courseId, grade, subject, totalXp }).returning();
|
|
31706
|
+
if (integration) {
|
|
31707
|
+
const dto = this.toGameTimebackIntegration(integration);
|
|
31708
|
+
integrations.push(dto);
|
|
31709
|
+
if (verbose && result.verboseData) {
|
|
31710
|
+
verboseData.push({ integration: dto, config: result.verboseData });
|
|
31711
|
+
}
|
|
31458
31712
|
}
|
|
31459
31713
|
}
|
|
31460
31714
|
}
|
|
31715
|
+
return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
|
|
31461
31716
|
}
|
|
31462
|
-
|
|
31463
|
-
|
|
31464
|
-
|
|
31465
|
-
|
|
31466
|
-
|
|
31467
|
-
|
|
31468
|
-
});
|
|
31469
|
-
return rows.map((row) => this.toGameTimebackIntegration(row));
|
|
31470
|
-
}
|
|
31471
|
-
async verifyIntegration(gameId, user) {
|
|
31472
|
-
const client = this.requireClient();
|
|
31473
|
-
const db2 = this.deps.db;
|
|
31474
|
-
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31475
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31476
|
-
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31477
|
-
});
|
|
31478
|
-
if (integrations.length === 0) {
|
|
31479
|
-
throw new NotFoundError("Timeback integration", gameId);
|
|
31717
|
+
async getIntegrations(gameId, user) {
|
|
31718
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
31719
|
+
const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
|
|
31720
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31721
|
+
});
|
|
31722
|
+
return rows.map((row) => this.toGameTimebackIntegration(row));
|
|
31480
31723
|
}
|
|
31481
|
-
|
|
31482
|
-
|
|
31483
|
-
const
|
|
31484
|
-
|
|
31485
|
-
const
|
|
31486
|
-
|
|
31487
|
-
|
|
31488
|
-
|
|
31489
|
-
integration
|
|
31490
|
-
|
|
31491
|
-
|
|
31492
|
-
|
|
31493
|
-
resources
|
|
31494
|
-
|
|
31495
|
-
|
|
31496
|
-
|
|
31497
|
-
|
|
31498
|
-
|
|
31499
|
-
|
|
31500
|
-
|
|
31501
|
-
|
|
31502
|
-
|
|
31503
|
-
|
|
31504
|
-
|
|
31505
|
-
|
|
31506
|
-
|
|
31507
|
-
|
|
31508
|
-
|
|
31509
|
-
|
|
31724
|
+
async verifyIntegration(gameId, user) {
|
|
31725
|
+
const client = this.requireClient();
|
|
31726
|
+
const db2 = this.deps.db;
|
|
31727
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31728
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31729
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31730
|
+
});
|
|
31731
|
+
if (integrations.length === 0) {
|
|
31732
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31733
|
+
}
|
|
31734
|
+
const now2 = new Date;
|
|
31735
|
+
const results = await Promise.all(integrations.map(async (integration) => {
|
|
31736
|
+
const resources = await client.verify(integration.courseId);
|
|
31737
|
+
const resourceValues = Object.values(resources);
|
|
31738
|
+
const allFound = resourceValues.every((r) => r.found);
|
|
31739
|
+
const errors3 = Object.entries(resources).filter(([_, r]) => !r.found).map(([name3]) => `${name3} not found`);
|
|
31740
|
+
const status = allFound ? "success" : "error";
|
|
31741
|
+
return {
|
|
31742
|
+
integration: this.toGameTimebackIntegration({
|
|
31743
|
+
...integration,
|
|
31744
|
+
lastVerifiedAt: now2
|
|
31745
|
+
}),
|
|
31746
|
+
resources,
|
|
31747
|
+
status,
|
|
31748
|
+
...errors3.length > 0 && { errors: errors3 }
|
|
31749
|
+
};
|
|
31750
|
+
}));
|
|
31751
|
+
await db2.update(gameTimebackIntegrations).set({ lastVerifiedAt: now2 }).where(eq(gameTimebackIntegrations.gameId, gameId));
|
|
31752
|
+
const overallStatus = results.every((r) => r.status === "success") ? "success" : "error";
|
|
31753
|
+
return { status: overallStatus, results };
|
|
31754
|
+
}
|
|
31755
|
+
async getConfig(gameId, user) {
|
|
31756
|
+
const client = this.requireClient();
|
|
31757
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31758
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31759
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31760
|
+
});
|
|
31761
|
+
if (!integration) {
|
|
31762
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31763
|
+
}
|
|
31764
|
+
return client.getConfig(integration.courseId);
|
|
31510
31765
|
}
|
|
31511
|
-
|
|
31512
|
-
|
|
31513
|
-
|
|
31514
|
-
|
|
31515
|
-
|
|
31516
|
-
|
|
31517
|
-
|
|
31518
|
-
|
|
31519
|
-
|
|
31520
|
-
|
|
31521
|
-
|
|
31766
|
+
async deleteIntegrations(gameId, user) {
|
|
31767
|
+
const client = this.requireClient();
|
|
31768
|
+
const db2 = this.deps.db;
|
|
31769
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31770
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31771
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31772
|
+
});
|
|
31773
|
+
if (integrations.length === 0) {
|
|
31774
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31775
|
+
}
|
|
31776
|
+
for (const integration of integrations) {
|
|
31777
|
+
await client.cleanup(integration.courseId);
|
|
31778
|
+
}
|
|
31779
|
+
await db2.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
|
|
31522
31780
|
}
|
|
31523
|
-
|
|
31524
|
-
|
|
31781
|
+
toGameTimebackIntegration(integration) {
|
|
31782
|
+
return {
|
|
31783
|
+
id: integration.id,
|
|
31784
|
+
gameId: integration.gameId,
|
|
31785
|
+
courseId: integration.courseId,
|
|
31786
|
+
grade: integration.grade,
|
|
31787
|
+
subject: integration.subject,
|
|
31788
|
+
totalXp: integration.totalXp ?? null,
|
|
31789
|
+
createdAt: integration.createdAt,
|
|
31790
|
+
updatedAt: integration.updatedAt,
|
|
31791
|
+
lastVerifiedAt: integration.lastVerifiedAt ?? null
|
|
31792
|
+
};
|
|
31525
31793
|
}
|
|
31526
|
-
|
|
31527
|
-
|
|
31528
|
-
|
|
31529
|
-
|
|
31530
|
-
|
|
31531
|
-
|
|
31532
|
-
|
|
31533
|
-
|
|
31534
|
-
|
|
31535
|
-
totalXp: integration.totalXp ?? null,
|
|
31536
|
-
createdAt: integration.createdAt,
|
|
31537
|
-
updatedAt: integration.updatedAt,
|
|
31538
|
-
lastVerifiedAt: integration.lastVerifiedAt ?? null
|
|
31539
|
-
};
|
|
31540
|
-
}
|
|
31541
|
-
async endActivity({
|
|
31542
|
-
gameId,
|
|
31543
|
-
studentId,
|
|
31544
|
-
activityData,
|
|
31545
|
-
scoreData,
|
|
31546
|
-
timingData,
|
|
31547
|
-
xpEarned,
|
|
31548
|
-
masteredUnits,
|
|
31549
|
-
extensions,
|
|
31550
|
-
user
|
|
31551
|
-
}) {
|
|
31552
|
-
const client = this.requireClient();
|
|
31553
|
-
const db2 = this.deps.db;
|
|
31554
|
-
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31555
|
-
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31556
|
-
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31557
|
-
});
|
|
31558
|
-
if (!integration) {
|
|
31559
|
-
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31560
|
-
}
|
|
31561
|
-
const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
|
|
31562
|
-
const result = await client.recordProgress(integration.courseId, studentId, {
|
|
31563
|
-
score: scorePercentage,
|
|
31564
|
-
totalQuestions: scoreData.totalQuestions,
|
|
31565
|
-
correctQuestions: scoreData.correctQuestions,
|
|
31566
|
-
durationSeconds: timingData.durationSeconds,
|
|
31794
|
+
async endActivity({
|
|
31795
|
+
gameId,
|
|
31796
|
+
studentId,
|
|
31797
|
+
runId,
|
|
31798
|
+
resumeId,
|
|
31799
|
+
activityData,
|
|
31800
|
+
scoreData,
|
|
31801
|
+
timingData,
|
|
31802
|
+
sessionTimingData,
|
|
31567
31803
|
xpEarned,
|
|
31568
31804
|
masteredUnits,
|
|
31569
31805
|
extensions,
|
|
31570
|
-
|
|
31571
|
-
|
|
31572
|
-
|
|
31573
|
-
|
|
31574
|
-
|
|
31575
|
-
|
|
31576
|
-
|
|
31577
|
-
|
|
31578
|
-
|
|
31579
|
-
|
|
31580
|
-
|
|
31581
|
-
|
|
31582
|
-
|
|
31583
|
-
|
|
31584
|
-
|
|
31585
|
-
|
|
31586
|
-
|
|
31587
|
-
|
|
31588
|
-
|
|
31589
|
-
|
|
31590
|
-
|
|
31591
|
-
|
|
31806
|
+
user
|
|
31807
|
+
}) {
|
|
31808
|
+
const client = this.requireClient();
|
|
31809
|
+
const db2 = this.deps.db;
|
|
31810
|
+
const extensionsWithResumeId = TimebackService.addResumeIdToExtensions(extensions, resumeId);
|
|
31811
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31812
|
+
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31813
|
+
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31814
|
+
});
|
|
31815
|
+
if (!integration) {
|
|
31816
|
+
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31817
|
+
}
|
|
31818
|
+
const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
|
|
31819
|
+
const result = await client.recordProgress(integration.courseId, studentId, {
|
|
31820
|
+
gameId,
|
|
31821
|
+
score: scorePercentage,
|
|
31822
|
+
totalQuestions: scoreData.totalQuestions,
|
|
31823
|
+
correctQuestions: scoreData.correctQuestions,
|
|
31824
|
+
durationSeconds: timingData.durationSeconds,
|
|
31825
|
+
xpEarned,
|
|
31826
|
+
masteredUnits,
|
|
31827
|
+
extensions: extensionsWithResumeId,
|
|
31828
|
+
activityId: activityData.activityId,
|
|
31829
|
+
activityName: activityData.activityName,
|
|
31830
|
+
subject: activityData.subject,
|
|
31831
|
+
appName: activityData.appName,
|
|
31832
|
+
sensorUrl: activityData.sensorUrl,
|
|
31833
|
+
courseId: activityData.courseId,
|
|
31834
|
+
courseName: activityData.courseName,
|
|
31835
|
+
studentEmail: activityData.studentEmail,
|
|
31836
|
+
courseTotalXp: integration.totalXp,
|
|
31837
|
+
...runId ? { runId } : {}
|
|
31838
|
+
});
|
|
31839
|
+
const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
|
|
31840
|
+
const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
|
|
31841
|
+
if (sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0) {
|
|
31842
|
+
await client.recordSessionEnd(integration.courseId, studentId, {
|
|
31843
|
+
gameId,
|
|
31844
|
+
activeTimeSeconds: sessionEndActiveSeconds,
|
|
31845
|
+
...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
|
|
31846
|
+
activityId: activityData.activityId,
|
|
31847
|
+
activityName: activityData.activityName,
|
|
31848
|
+
subject: activityData.subject,
|
|
31849
|
+
appName: activityData.appName,
|
|
31850
|
+
sensorUrl: activityData.sensorUrl,
|
|
31851
|
+
courseId: activityData.courseId,
|
|
31852
|
+
courseName: activityData.courseName,
|
|
31853
|
+
studentEmail: activityData.studentEmail,
|
|
31854
|
+
extensions: extensionsWithResumeId,
|
|
31855
|
+
...runId ? { runId } : {}
|
|
31856
|
+
});
|
|
31857
|
+
}
|
|
31858
|
+
logger17.info("Recorded activity completion", {
|
|
31859
|
+
gameId,
|
|
31860
|
+
courseId: integration.courseId,
|
|
31861
|
+
studentId,
|
|
31862
|
+
runId,
|
|
31863
|
+
score: scorePercentage
|
|
31864
|
+
});
|
|
31865
|
+
return {
|
|
31866
|
+
status: "ok",
|
|
31867
|
+
courseId: integration.courseId,
|
|
31868
|
+
xpAwarded: result.xpAwarded,
|
|
31869
|
+
masteredUnits: result.masteredUnitsApplied,
|
|
31870
|
+
pctCompleteApp: result.pctCompleteApp,
|
|
31871
|
+
scoreStatus: result.scoreStatus,
|
|
31872
|
+
inProgress: result.inProgress
|
|
31873
|
+
};
|
|
31874
|
+
}
|
|
31875
|
+
async recordHeartbeat({
|
|
31592
31876
|
gameId,
|
|
31593
|
-
courseId: integration.courseId,
|
|
31594
31877
|
studentId,
|
|
31595
|
-
|
|
31596
|
-
|
|
31597
|
-
|
|
31598
|
-
|
|
31599
|
-
|
|
31600
|
-
|
|
31601
|
-
|
|
31602
|
-
|
|
31603
|
-
|
|
31604
|
-
|
|
31605
|
-
|
|
31606
|
-
|
|
31607
|
-
|
|
31608
|
-
|
|
31609
|
-
|
|
31610
|
-
|
|
31611
|
-
|
|
31612
|
-
|
|
31613
|
-
|
|
31614
|
-
|
|
31615
|
-
conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
|
|
31616
|
-
conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
|
|
31878
|
+
runId,
|
|
31879
|
+
resumeId,
|
|
31880
|
+
activityData,
|
|
31881
|
+
timingData,
|
|
31882
|
+
windowStartedAtMs,
|
|
31883
|
+
isFinal,
|
|
31884
|
+
user
|
|
31885
|
+
}) {
|
|
31886
|
+
const client = this.requireClient();
|
|
31887
|
+
const db2 = this.deps.db;
|
|
31888
|
+
const heartbeatWindowKey = `${runId}:${windowStartedAtMs}`;
|
|
31889
|
+
if (TimebackService.isDuplicateHeartbeatWindow(heartbeatWindowKey)) {
|
|
31890
|
+
logger17.debug("Skipping duplicate heartbeat window", {
|
|
31891
|
+
gameId,
|
|
31892
|
+
studentId,
|
|
31893
|
+
runId,
|
|
31894
|
+
windowStartedAtMs,
|
|
31895
|
+
isFinal
|
|
31896
|
+
});
|
|
31897
|
+
return { status: "ok" };
|
|
31617
31898
|
}
|
|
31618
|
-
|
|
31619
|
-
|
|
31620
|
-
|
|
31621
|
-
|
|
31622
|
-
|
|
31623
|
-
|
|
31624
|
-
|
|
31625
|
-
|
|
31626
|
-
|
|
31627
|
-
subject: options.subject
|
|
31899
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31900
|
+
const inFlightHeartbeat = TimebackService.getInFlightHeartbeatWindow(heartbeatWindowKey);
|
|
31901
|
+
if (inFlightHeartbeat) {
|
|
31902
|
+
logger17.debug("Joining in-flight heartbeat window", {
|
|
31903
|
+
gameId,
|
|
31904
|
+
studentId,
|
|
31905
|
+
runId,
|
|
31906
|
+
windowStartedAtMs,
|
|
31907
|
+
isFinal
|
|
31628
31908
|
});
|
|
31629
|
-
return
|
|
31630
|
-
|
|
31631
|
-
|
|
31632
|
-
|
|
31633
|
-
|
|
31909
|
+
return inFlightHeartbeat;
|
|
31910
|
+
}
|
|
31911
|
+
const pendingHeartbeat = (async () => {
|
|
31912
|
+
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31913
|
+
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31914
|
+
});
|
|
31915
|
+
if (!integration) {
|
|
31916
|
+
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31917
|
+
}
|
|
31918
|
+
const activeTimeSeconds = timingData.activeMs / 1000;
|
|
31919
|
+
const inactiveTimeSeconds = timingData.pausedMs / 1000;
|
|
31920
|
+
if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
|
|
31921
|
+
await client.recordSessionEnd(integration.courseId, studentId, {
|
|
31922
|
+
gameId,
|
|
31923
|
+
activeTimeSeconds,
|
|
31924
|
+
...inactiveTimeSeconds > 0 ? { inactiveTimeSeconds } : {},
|
|
31925
|
+
activityId: activityData.activityId,
|
|
31926
|
+
activityName: activityData.activityName,
|
|
31927
|
+
subject: activityData.subject,
|
|
31928
|
+
appName: activityData.appName,
|
|
31929
|
+
sensorUrl: activityData.sensorUrl,
|
|
31930
|
+
courseId: activityData.courseId,
|
|
31931
|
+
courseName: activityData.courseName,
|
|
31932
|
+
studentEmail: activityData.studentEmail,
|
|
31933
|
+
extensions: TimebackService.addResumeIdToExtensions(undefined, resumeId),
|
|
31934
|
+
...runId ? { runId } : {}
|
|
31935
|
+
});
|
|
31936
|
+
}
|
|
31937
|
+
TimebackService.markHeartbeatWindowProcessed(heartbeatWindowKey);
|
|
31938
|
+
logger17.debug("Recorded heartbeat", {
|
|
31939
|
+
gameId,
|
|
31940
|
+
courseId: integration.courseId,
|
|
31941
|
+
studentId,
|
|
31942
|
+
runId,
|
|
31943
|
+
windowStartedAtMs,
|
|
31944
|
+
activeTimeSeconds,
|
|
31945
|
+
isFinal
|
|
31946
|
+
});
|
|
31947
|
+
return { status: "ok" };
|
|
31948
|
+
})();
|
|
31949
|
+
TimebackService.markHeartbeatWindowInFlight(heartbeatWindowKey, pendingHeartbeat);
|
|
31950
|
+
try {
|
|
31951
|
+
return await pendingHeartbeat;
|
|
31952
|
+
} finally {
|
|
31953
|
+
TimebackService.clearInFlightHeartbeatWindow(heartbeatWindowKey);
|
|
31954
|
+
}
|
|
31955
|
+
}
|
|
31956
|
+
async getStudentXp(timebackId, user, options) {
|
|
31957
|
+
const client = this.requireClient();
|
|
31958
|
+
const db2 = this.deps.db;
|
|
31959
|
+
let courseIds = [];
|
|
31960
|
+
if (options?.gameId) {
|
|
31961
|
+
await this.deps.validateDeveloperAccess(user, options.gameId);
|
|
31962
|
+
const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
|
|
31963
|
+
if (options.grade !== undefined && options.subject) {
|
|
31964
|
+
conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
|
|
31965
|
+
conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
|
|
31966
|
+
}
|
|
31967
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31968
|
+
where: and(...conditions2)
|
|
31969
|
+
});
|
|
31970
|
+
courseIds = integrations.map((i2) => i2.courseId);
|
|
31971
|
+
if (courseIds.length === 0) {
|
|
31972
|
+
logger17.debug("No integrations found for game, returning 0 XP", {
|
|
31973
|
+
timebackId,
|
|
31974
|
+
gameId: options.gameId,
|
|
31975
|
+
grade: options.grade,
|
|
31976
|
+
subject: options.subject
|
|
31977
|
+
});
|
|
31978
|
+
return {
|
|
31979
|
+
totalXp: 0,
|
|
31980
|
+
...options?.include?.today && { todayXp: 0 },
|
|
31981
|
+
...options?.include?.perCourse && { courses: [] }
|
|
31982
|
+
};
|
|
31983
|
+
}
|
|
31634
31984
|
}
|
|
31985
|
+
const result = await client.getStudentXp(timebackId, {
|
|
31986
|
+
courseIds: courseIds.length > 0 ? courseIds : undefined,
|
|
31987
|
+
include: options?.include
|
|
31988
|
+
});
|
|
31989
|
+
logger17.debug("Retrieved student XP", {
|
|
31990
|
+
timebackId,
|
|
31991
|
+
gameId: options?.gameId,
|
|
31992
|
+
grade: options?.grade,
|
|
31993
|
+
subject: options?.subject,
|
|
31994
|
+
totalXp: result.totalXp,
|
|
31995
|
+
courseCount: result.courses?.length
|
|
31996
|
+
});
|
|
31997
|
+
return result;
|
|
31635
31998
|
}
|
|
31636
|
-
|
|
31637
|
-
courseIds: courseIds.length > 0 ? courseIds : undefined,
|
|
31638
|
-
include: options?.include
|
|
31639
|
-
});
|
|
31640
|
-
logger17.debug("Retrieved student XP", {
|
|
31641
|
-
timebackId,
|
|
31642
|
-
gameId: options?.gameId,
|
|
31643
|
-
grade: options?.grade,
|
|
31644
|
-
subject: options?.subject,
|
|
31645
|
-
totalXp: result.totalXp,
|
|
31646
|
-
courseCount: result.courses?.length
|
|
31647
|
-
});
|
|
31648
|
-
return result;
|
|
31649
|
-
}
|
|
31650
|
-
}
|
|
31651
|
-
var logger17;
|
|
31652
|
-
var init_timeback_service = __esm(() => {
|
|
31653
|
-
init_drizzle_orm();
|
|
31654
|
-
init_src();
|
|
31655
|
-
init_tables_index();
|
|
31656
|
-
init_src2();
|
|
31657
|
-
init_types4();
|
|
31658
|
-
init_src4();
|
|
31659
|
-
init_errors();
|
|
31660
|
-
init_timeback_util();
|
|
31661
|
-
logger17 = log.scope("TimebackService");
|
|
31999
|
+
};
|
|
31662
32000
|
});
|
|
31663
32001
|
|
|
31664
32002
|
// ../api-core/src/services/upload.service.ts
|
|
@@ -31717,6 +32055,7 @@ function createPlatformServices(deps) {
|
|
|
31717
32055
|
alerts,
|
|
31718
32056
|
validateDeveloperAccessBySlug,
|
|
31719
32057
|
validateDeveloperAccess,
|
|
32058
|
+
validateGameManagementAccess,
|
|
31720
32059
|
validateOwnership
|
|
31721
32060
|
} = deps;
|
|
31722
32061
|
const bucket = new BucketService({
|
|
@@ -31751,12 +32090,15 @@ function createPlatformServices(deps) {
|
|
|
31751
32090
|
const timeback2 = new TimebackService({
|
|
31752
32091
|
db: db2,
|
|
31753
32092
|
timeback: timebackClient,
|
|
31754
|
-
validateDeveloperAccess
|
|
32093
|
+
validateDeveloperAccess,
|
|
32094
|
+
validateGameManagementAccess
|
|
31755
32095
|
});
|
|
31756
32096
|
const timebackAdmin = new TimebackAdminService({
|
|
32097
|
+
config: config2,
|
|
31757
32098
|
db: db2,
|
|
31758
32099
|
timeback: timebackClient,
|
|
31759
|
-
validateDeveloperAccess
|
|
32100
|
+
validateDeveloperAccess,
|
|
32101
|
+
validateGameManagementAccess
|
|
31760
32102
|
});
|
|
31761
32103
|
return {
|
|
31762
32104
|
bucket,
|
|
@@ -34721,6 +35063,16 @@ async function requestCaliper(options) {
|
|
|
34721
35063
|
baseUrl: caliperUrl
|
|
34722
35064
|
});
|
|
34723
35065
|
}
|
|
35066
|
+
function buildEventExtensions({
|
|
35067
|
+
eventExtensions,
|
|
35068
|
+
gameId
|
|
35069
|
+
}) {
|
|
35070
|
+
const mergedExtensions = {
|
|
35071
|
+
...eventExtensions,
|
|
35072
|
+
...gameId ? { gameId } : {}
|
|
35073
|
+
};
|
|
35074
|
+
return Object.keys(mergedExtensions).length > 0 ? mergedExtensions : undefined;
|
|
35075
|
+
}
|
|
34724
35076
|
function createCaliperNamespace(client) {
|
|
34725
35077
|
const urls = createOneRosterUrls(client.getBaseUrl());
|
|
34726
35078
|
const caliper = {
|
|
@@ -34765,11 +35117,20 @@ function createCaliperNamespace(client) {
|
|
|
34765
35117
|
if (params.actorEmail) {
|
|
34766
35118
|
query.set("actorEmail", params.actorEmail);
|
|
34767
35119
|
}
|
|
35120
|
+
if (params.extensions) {
|
|
35121
|
+
for (const [key, value] of Object.entries(params.extensions)) {
|
|
35122
|
+
query.set(`extensions.${key}`, value);
|
|
35123
|
+
}
|
|
35124
|
+
}
|
|
34768
35125
|
const requestPath = `${CALIPER_ENDPOINTS4.events}?${query.toString()}`;
|
|
34769
35126
|
return client["requestCaliper"](requestPath, "GET");
|
|
34770
35127
|
}
|
|
34771
35128
|
},
|
|
34772
35129
|
emitActivityEvent: async (data) => {
|
|
35130
|
+
const eventExtensions = buildEventExtensions({
|
|
35131
|
+
eventExtensions: data.eventExtensions,
|
|
35132
|
+
gameId: data.gameId
|
|
35133
|
+
});
|
|
34773
35134
|
const event = {
|
|
34774
35135
|
"@context": CALIPER_CONSTANTS4.context,
|
|
34775
35136
|
id: `urn:uuid:${crypto.randomUUID()}`,
|
|
@@ -34782,6 +35143,7 @@ function createCaliperNamespace(client) {
|
|
|
34782
35143
|
email: data.studentEmail
|
|
34783
35144
|
},
|
|
34784
35145
|
action: TIMEBACK_ACTIONS4.completed,
|
|
35146
|
+
...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
|
|
34785
35147
|
object: {
|
|
34786
35148
|
id: data.objectId || caliper.buildActivityUrl(data),
|
|
34787
35149
|
type: TIMEBACK_TYPES4.activityContext,
|
|
@@ -34828,16 +35190,20 @@ function createCaliperNamespace(client) {
|
|
|
34828
35190
|
}
|
|
34829
35191
|
} : {}
|
|
34830
35192
|
},
|
|
34831
|
-
...
|
|
35193
|
+
...eventExtensions ? { extensions: eventExtensions } : {}
|
|
34832
35194
|
};
|
|
34833
35195
|
return caliper.emit(event, data.sensorUrl);
|
|
34834
35196
|
},
|
|
34835
35197
|
emitTimeSpentEvent: async (data) => {
|
|
35198
|
+
const eventExtensions = buildEventExtensions({
|
|
35199
|
+
eventExtensions: data.eventExtensions,
|
|
35200
|
+
gameId: data.gameId
|
|
35201
|
+
});
|
|
34836
35202
|
const event = {
|
|
34837
35203
|
"@context": CALIPER_CONSTANTS4.context,
|
|
34838
35204
|
id: `urn:uuid:${crypto.randomUUID()}`,
|
|
34839
35205
|
type: TIMEBACK_EVENT_TYPES4.timeSpentEvent,
|
|
34840
|
-
eventTime: new Date().toISOString(),
|
|
35206
|
+
eventTime: data.eventTime || new Date().toISOString(),
|
|
34841
35207
|
profile: CALIPER_CONSTANTS4.profile,
|
|
34842
35208
|
actor: {
|
|
34843
35209
|
id: urls.user(data.studentId),
|
|
@@ -34845,6 +35211,7 @@ function createCaliperNamespace(client) {
|
|
|
34845
35211
|
email: data.studentEmail
|
|
34846
35212
|
},
|
|
34847
35213
|
action: TIMEBACK_ACTIONS4.spentTime,
|
|
35214
|
+
...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
|
|
34848
35215
|
object: {
|
|
34849
35216
|
id: caliper.buildActivityUrl(data),
|
|
34850
35217
|
type: TIMEBACK_TYPES4.activityContext,
|
|
@@ -34872,13 +35239,14 @@ function createCaliperNamespace(client) {
|
|
|
34872
35239
|
...data.wasteTimeSeconds !== undefined ? [{ type: TIME_METRIC_TYPES4.waste, value: data.wasteTimeSeconds }] : []
|
|
34873
35240
|
],
|
|
34874
35241
|
...data.extensions ? { extensions: data.extensions } : {}
|
|
34875
|
-
}
|
|
35242
|
+
},
|
|
35243
|
+
...eventExtensions ? { extensions: eventExtensions } : {}
|
|
34876
35244
|
};
|
|
34877
35245
|
return caliper.emit(event, data.sensorUrl);
|
|
34878
35246
|
},
|
|
34879
35247
|
buildActivityUrl: (data) => {
|
|
34880
35248
|
const base = data.sensorUrl.replace(/\/$/, "");
|
|
34881
|
-
return `${base}/activities/${data.courseId}/${data.activityId
|
|
35249
|
+
return `${base}/activities/${encodeURIComponent(data.courseId)}/${encodeURIComponent(data.activityId)}`;
|
|
34882
35250
|
}
|
|
34883
35251
|
};
|
|
34884
35252
|
return caliper;
|
|
@@ -34888,6 +35256,34 @@ function createEduBridgeNamespace(client) {
|
|
|
34888
35256
|
listByUser: async (userId) => {
|
|
34889
35257
|
const response = await client["request"](`/edubridge/enrollments/user/${userId}`, "GET");
|
|
34890
35258
|
return response.data;
|
|
35259
|
+
},
|
|
35260
|
+
enroll: async (userId, courseId, options) => {
|
|
35261
|
+
const segments = [userId, courseId];
|
|
35262
|
+
if (options?.schoolId) {
|
|
35263
|
+
segments.push(options.schoolId);
|
|
35264
|
+
}
|
|
35265
|
+
const body2 = {};
|
|
35266
|
+
if (options?.role) {
|
|
35267
|
+
body2.role = options.role;
|
|
35268
|
+
}
|
|
35269
|
+
if (options?.sourcedId) {
|
|
35270
|
+
body2.sourcedId = options.sourcedId;
|
|
35271
|
+
}
|
|
35272
|
+
if (options?.beginDate) {
|
|
35273
|
+
body2.beginDate = options.beginDate;
|
|
35274
|
+
}
|
|
35275
|
+
if (options?.metadata) {
|
|
35276
|
+
body2.metadata = options.metadata;
|
|
35277
|
+
}
|
|
35278
|
+
const response = await client["request"](`/edubridge/enrollments/enroll/${segments.join("/")}`, "POST", body2);
|
|
35279
|
+
return response.data;
|
|
35280
|
+
},
|
|
35281
|
+
unenroll: async (userId, courseId, options) => {
|
|
35282
|
+
const segments = [userId, courseId];
|
|
35283
|
+
if (options?.schoolId) {
|
|
35284
|
+
segments.push(options.schoolId);
|
|
35285
|
+
}
|
|
35286
|
+
await client["request"](`/edubridge/enrollments/unenroll/${segments.join("/")}`, "DELETE");
|
|
34891
35287
|
}
|
|
34892
35288
|
};
|
|
34893
35289
|
const analytics = {
|
|
@@ -35063,6 +35459,10 @@ function createOneRosterNamespace(client) {
|
|
|
35063
35459
|
logTimebackError("list course roster", error, { courseSourcedId });
|
|
35064
35460
|
throw error;
|
|
35065
35461
|
}
|
|
35462
|
+
},
|
|
35463
|
+
create: async (data) => client["request"](ONEROSTER_ENDPOINTS4.enrollments, "POST", { enrollment: data }),
|
|
35464
|
+
delete: async (sourcedId) => {
|
|
35465
|
+
await client["request"](`${ONEROSTER_ENDPOINTS4.enrollments}/${sourcedId}`, "DELETE");
|
|
35066
35466
|
}
|
|
35067
35467
|
},
|
|
35068
35468
|
organizations: {
|
|
@@ -35333,11 +35733,13 @@ class AdminEventRecorder {
|
|
|
35333
35733
|
await this.caliper.emitActivityEvent({
|
|
35334
35734
|
studentId: ctx.student.id,
|
|
35335
35735
|
studentEmail: ctx.student.email,
|
|
35736
|
+
gameId: data.gameId,
|
|
35336
35737
|
activityId: ctx.activityId,
|
|
35337
35738
|
activityName: data.activityName || `Manual XP Assignment - ${ctx.courseContext.subject}`,
|
|
35338
35739
|
courseId: data.courseId,
|
|
35339
35740
|
courseName: ctx.courseContext.courseName,
|
|
35340
35741
|
xpEarned: data.xpEarned,
|
|
35742
|
+
eventTime: data.eventTime,
|
|
35341
35743
|
subject: ctx.courseContext.subject,
|
|
35342
35744
|
appName: ctx.appName,
|
|
35343
35745
|
sensorUrl: ctx.sensorUrl,
|
|
@@ -35358,11 +35760,13 @@ class AdminEventRecorder {
|
|
|
35358
35760
|
await this.caliper.emitTimeSpentEvent({
|
|
35359
35761
|
studentId: ctx.student.id,
|
|
35360
35762
|
studentEmail: ctx.student.email,
|
|
35763
|
+
gameId: data.gameId,
|
|
35361
35764
|
activityId: ctx.activityId,
|
|
35362
35765
|
activityName: data.activityName || "Playcademy Admin Time Adjustment",
|
|
35363
35766
|
courseId: data.courseId,
|
|
35364
35767
|
courseName: ctx.courseContext.courseName,
|
|
35365
35768
|
activeTimeSeconds: data.activeTimeSeconds,
|
|
35769
|
+
eventTime: data.eventTime,
|
|
35366
35770
|
subject: ctx.courseContext.subject,
|
|
35367
35771
|
appName: ctx.appName,
|
|
35368
35772
|
sensorUrl: ctx.sensorUrl,
|
|
@@ -35378,11 +35782,13 @@ class AdminEventRecorder {
|
|
|
35378
35782
|
await this.caliper.emitActivityEvent({
|
|
35379
35783
|
studentId: ctx.student.id,
|
|
35380
35784
|
studentEmail: ctx.student.email,
|
|
35785
|
+
gameId: data.gameId,
|
|
35381
35786
|
activityId: ctx.activityId,
|
|
35382
35787
|
activityName: data.activityName || "Playcademy Admin Mastery Adjustment",
|
|
35383
35788
|
courseId: data.courseId,
|
|
35384
35789
|
courseName: ctx.courseContext.courseName,
|
|
35385
35790
|
masteredUnits: data.masteredUnits,
|
|
35791
|
+
eventTime: data.eventTime,
|
|
35386
35792
|
subject: ctx.courseContext.subject,
|
|
35387
35793
|
appName: ctx.appName,
|
|
35388
35794
|
sensorUrl: ctx.sensorUrl,
|
|
@@ -35402,6 +35808,7 @@ class AdminEventRecorder {
|
|
|
35402
35808
|
await this.caliper.emitActivityEvent({
|
|
35403
35809
|
studentId: ctx.student.id,
|
|
35404
35810
|
studentEmail: ctx.student.email,
|
|
35811
|
+
gameId: data.gameId,
|
|
35405
35812
|
activityId: ctx.activityId,
|
|
35406
35813
|
activityName: isResume ? "Course resumed" : "Course marked complete",
|
|
35407
35814
|
courseId: data.courseId,
|
|
@@ -35850,15 +36257,13 @@ class ProgressRecorder {
|
|
|
35850
36257
|
studentId,
|
|
35851
36258
|
attemptNumber: currentAttemptNumber,
|
|
35852
36259
|
score,
|
|
35853
|
-
totalQuestions,
|
|
35854
|
-
correctQuestions,
|
|
35855
36260
|
xp: calculatedXp,
|
|
35856
|
-
masteredUnits,
|
|
35857
36261
|
scoreStatus,
|
|
35858
36262
|
inProgress,
|
|
35859
36263
|
appName: progressData.appName,
|
|
35860
|
-
|
|
35861
|
-
|
|
36264
|
+
totalQuestions,
|
|
36265
|
+
correctQuestions,
|
|
36266
|
+
masteredUnits
|
|
35862
36267
|
});
|
|
35863
36268
|
} else {
|
|
35864
36269
|
log.warn("[ProgressRecorder] Score not provided, skipping gradebook entry", {
|
|
@@ -35872,6 +36277,7 @@ class ProgressRecorder {
|
|
|
35872
36277
|
await this.emitCourseCompletionHistoryEvent({
|
|
35873
36278
|
studentId,
|
|
35874
36279
|
studentEmail,
|
|
36280
|
+
gameId: progressData.gameId,
|
|
35875
36281
|
activityId,
|
|
35876
36282
|
courseId: ids.course,
|
|
35877
36283
|
courseName,
|
|
@@ -35883,6 +36289,7 @@ class ProgressRecorder {
|
|
|
35883
36289
|
await this.emitCaliperEvent({
|
|
35884
36290
|
studentId,
|
|
35885
36291
|
studentEmail,
|
|
36292
|
+
gameId: progressData.gameId,
|
|
35886
36293
|
activityId,
|
|
35887
36294
|
activityName,
|
|
35888
36295
|
courseId: ids.course,
|
|
@@ -35893,7 +36300,8 @@ class ProgressRecorder {
|
|
|
35893
36300
|
masteredUnits,
|
|
35894
36301
|
attemptNumber: currentAttemptNumber,
|
|
35895
36302
|
progressData,
|
|
35896
|
-
extensions
|
|
36303
|
+
extensions,
|
|
36304
|
+
runId: progressData.runId
|
|
35897
36305
|
});
|
|
35898
36306
|
return {
|
|
35899
36307
|
xpAwarded: calculatedXp,
|
|
@@ -35983,15 +36391,13 @@ class ProgressRecorder {
|
|
|
35983
36391
|
studentId,
|
|
35984
36392
|
attemptNumber,
|
|
35985
36393
|
score,
|
|
35986
|
-
totalQuestions,
|
|
35987
|
-
correctQuestions,
|
|
35988
36394
|
xp,
|
|
35989
|
-
masteredUnits,
|
|
35990
36395
|
scoreStatus,
|
|
35991
36396
|
inProgress,
|
|
35992
36397
|
appName,
|
|
35993
|
-
|
|
35994
|
-
|
|
36398
|
+
totalQuestions,
|
|
36399
|
+
correctQuestions,
|
|
36400
|
+
masteredUnits
|
|
35995
36401
|
}) {
|
|
35996
36402
|
const timestamp3 = Date.now().toString(36);
|
|
35997
36403
|
const resultId = `${lineItemId}:${studentId}:${timestamp3}`;
|
|
@@ -36006,21 +36412,18 @@ class ProgressRecorder {
|
|
|
36006
36412
|
inProgress,
|
|
36007
36413
|
metadata: {
|
|
36008
36414
|
xp,
|
|
36009
|
-
totalQuestions,
|
|
36010
|
-
correctQuestions,
|
|
36011
|
-
accuracy: totalQuestions && correctQuestions ? correctQuestions / totalQuestions * 100 : undefined,
|
|
36012
36415
|
attemptNumber,
|
|
36013
|
-
lastUpdated: new Date().toISOString(),
|
|
36014
|
-
masteredUnits,
|
|
36015
36416
|
appName,
|
|
36016
|
-
|
|
36017
|
-
|
|
36417
|
+
...totalQuestions !== undefined ? { totalQuestions } : {},
|
|
36418
|
+
...correctQuestions !== undefined ? { correctQuestions } : {},
|
|
36419
|
+
...masteredUnits !== undefined ? { masteredUnits } : {}
|
|
36018
36420
|
}
|
|
36019
36421
|
});
|
|
36020
36422
|
}
|
|
36021
36423
|
async emitCaliperEvent({
|
|
36022
36424
|
studentId,
|
|
36023
36425
|
studentEmail,
|
|
36426
|
+
gameId,
|
|
36024
36427
|
activityId,
|
|
36025
36428
|
activityName,
|
|
36026
36429
|
courseId,
|
|
@@ -36031,11 +36434,13 @@ class ProgressRecorder {
|
|
|
36031
36434
|
masteredUnits,
|
|
36032
36435
|
attemptNumber,
|
|
36033
36436
|
progressData,
|
|
36034
|
-
extensions
|
|
36437
|
+
extensions,
|
|
36438
|
+
runId
|
|
36035
36439
|
}) {
|
|
36036
36440
|
await this.caliperNamespace.emitActivityEvent({
|
|
36037
36441
|
studentId,
|
|
36038
36442
|
studentEmail,
|
|
36443
|
+
gameId,
|
|
36039
36444
|
activityId,
|
|
36040
36445
|
activityName,
|
|
36041
36446
|
courseId,
|
|
@@ -36048,7 +36453,8 @@ class ProgressRecorder {
|
|
|
36048
36453
|
subject: progressData.subject,
|
|
36049
36454
|
appName: progressData.appName,
|
|
36050
36455
|
sensorUrl: progressData.sensorUrl,
|
|
36051
|
-
extensions: extensions || progressData.extensions
|
|
36456
|
+
extensions: extensions || progressData.extensions,
|
|
36457
|
+
...runId ? { runId } : {}
|
|
36052
36458
|
}).catch((error) => {
|
|
36053
36459
|
log.error("[ProgressRecorder] Failed to emit activity event", { error });
|
|
36054
36460
|
});
|
|
@@ -36057,6 +36463,7 @@ class ProgressRecorder {
|
|
|
36057
36463
|
await this.caliperNamespace.emitActivityEvent({
|
|
36058
36464
|
studentId: data.studentId,
|
|
36059
36465
|
studentEmail: data.studentEmail,
|
|
36466
|
+
gameId: data.gameId,
|
|
36060
36467
|
activityId: data.activityId,
|
|
36061
36468
|
activityName: "Course completed",
|
|
36062
36469
|
courseId: data.courseId,
|
|
@@ -36102,10 +36509,11 @@ class SessionRecorder {
|
|
|
36102
36509
|
const courseName = sessionData.courseName || "Game Course";
|
|
36103
36510
|
const student = await this.studentResolver.resolve(studentIdentifier, sessionData.studentEmail);
|
|
36104
36511
|
const { id: studentId, email: studentEmail } = student;
|
|
36105
|
-
const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions } = sessionData;
|
|
36512
|
+
const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions, runId } = sessionData;
|
|
36106
36513
|
await this.caliperNamespace.emitTimeSpentEvent({
|
|
36107
36514
|
studentId,
|
|
36108
36515
|
studentEmail,
|
|
36516
|
+
gameId: sessionData.gameId,
|
|
36109
36517
|
activityId,
|
|
36110
36518
|
activityName,
|
|
36111
36519
|
courseId: ids.course,
|
|
@@ -36116,6 +36524,7 @@ class SessionRecorder {
|
|
|
36116
36524
|
subject: sessionData.subject,
|
|
36117
36525
|
appName: sessionData.appName,
|
|
36118
36526
|
sensorUrl: sessionData.sensorUrl,
|
|
36527
|
+
...runId ? { runId } : {},
|
|
36119
36528
|
...extensions ? { extensions } : {}
|
|
36120
36529
|
});
|
|
36121
36530
|
}
|
|
@@ -92024,18 +92433,23 @@ async function seedCoreGames(db2) {
|
|
|
92024
92433
|
}
|
|
92025
92434
|
async function seedCurrentProjectGame(db2, project) {
|
|
92026
92435
|
const now2 = new Date;
|
|
92436
|
+
const desiredGameId = project.gameId?.trim() || undefined;
|
|
92027
92437
|
try {
|
|
92028
92438
|
const existingGame = await db2.query.games.findFirst({
|
|
92029
|
-
where: (row,
|
|
92439
|
+
where: (row, operators) => operators.eq(row.slug, project.slug)
|
|
92030
92440
|
});
|
|
92031
92441
|
if (existingGame) {
|
|
92032
|
-
if (
|
|
92033
|
-
await
|
|
92442
|
+
if (desiredGameId && existingGame.id !== desiredGameId) {
|
|
92443
|
+
await db2.delete(games).where(eq(games.id, existingGame.id));
|
|
92444
|
+
} else {
|
|
92445
|
+
if (project.timebackCourses && project.timebackCourses.length > 0) {
|
|
92446
|
+
await seedTimebackIntegrations(db2, existingGame.id, project.timebackCourses);
|
|
92447
|
+
}
|
|
92448
|
+
return existingGame;
|
|
92034
92449
|
}
|
|
92035
|
-
return existingGame;
|
|
92036
92450
|
}
|
|
92037
92451
|
const gameRecord = {
|
|
92038
|
-
id: crypto.randomUUID(),
|
|
92452
|
+
id: desiredGameId ?? crypto.randomUUID(),
|
|
92039
92453
|
developerId: DEMO_USERS.developer.id,
|
|
92040
92454
|
slug: project.slug,
|
|
92041
92455
|
displayName: project.displayName,
|
|
@@ -92064,6 +92478,7 @@ async function seedCurrentProjectGame(db2, project) {
|
|
|
92064
92478
|
}
|
|
92065
92479
|
}
|
|
92066
92480
|
var init_games = __esm(() => {
|
|
92481
|
+
init_drizzle_orm();
|
|
92067
92482
|
init_src();
|
|
92068
92483
|
init_tables_index();
|
|
92069
92484
|
init_constants();
|
|
@@ -93125,6 +93540,8 @@ var init_schemas2 = __esm(() => {
|
|
|
93125
93540
|
code: exports_external.string().optional(),
|
|
93126
93541
|
codeUploadToken: exports_external.string().optional(),
|
|
93127
93542
|
config: exports_external.unknown().optional(),
|
|
93543
|
+
compatibilityDate: exports_external.string().optional(),
|
|
93544
|
+
compatibilityFlags: exports_external.array(exports_external.string()).optional(),
|
|
93128
93545
|
bindings: exports_external.object({
|
|
93129
93546
|
database: exports_external.array(exports_external.string()).optional(),
|
|
93130
93547
|
keyValue: exports_external.array(exports_external.string()).optional(),
|
|
@@ -93378,7 +93795,19 @@ function isTimebackGrade3(value) {
|
|
|
93378
93795
|
function isTimebackSubject3(value) {
|
|
93379
93796
|
return TIMEBACK_SUBJECTS5.includes(value);
|
|
93380
93797
|
}
|
|
93381
|
-
|
|
93798
|
+
function isValidAdminAttributionDate(value) {
|
|
93799
|
+
const match3 = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
93800
|
+
if (!match3) {
|
|
93801
|
+
return false;
|
|
93802
|
+
}
|
|
93803
|
+
const [, yearStr, monthStr, dayStr] = match3;
|
|
93804
|
+
const year3 = Number(yearStr);
|
|
93805
|
+
const month = Number(monthStr);
|
|
93806
|
+
const day = Number(dayStr);
|
|
93807
|
+
const date4 = new Date(Date.UTC(year3, month - 1, day, 12, 0, 0));
|
|
93808
|
+
return date4.getUTCFullYear() === year3 && date4.getUTCMonth() + 1 === month && date4.getUTCDate() === day;
|
|
93809
|
+
}
|
|
93810
|
+
var TIMEBACK_GRADES, TIMEBACK_SUBJECTS5, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema;
|
|
93382
93811
|
var init_schemas11 = __esm(() => {
|
|
93383
93812
|
init_esm();
|
|
93384
93813
|
TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
|
@@ -93401,31 +93830,51 @@ var init_schemas11 = __esm(() => {
|
|
|
93401
93830
|
xp: exports_external.number().min(0, "XP must be a non-negative number"),
|
|
93402
93831
|
userTimestamp: exports_external.string().datetime().optional()
|
|
93403
93832
|
});
|
|
93833
|
+
TimebackActivityDataSchema = exports_external.object({
|
|
93834
|
+
activityId: exports_external.string().min(1),
|
|
93835
|
+
activityName: exports_external.string().optional(),
|
|
93836
|
+
grade: TimebackGradeSchema,
|
|
93837
|
+
subject: TimebackSubjectSchema,
|
|
93838
|
+
appName: exports_external.string().optional(),
|
|
93839
|
+
sensorUrl: exports_external.string().url().optional(),
|
|
93840
|
+
courseId: exports_external.string().optional(),
|
|
93841
|
+
courseName: exports_external.string().optional(),
|
|
93842
|
+
studentEmail: exports_external.string().email().optional()
|
|
93843
|
+
});
|
|
93404
93844
|
EndActivityRequestSchema = exports_external.object({
|
|
93405
93845
|
gameId: exports_external.string().uuid(),
|
|
93406
93846
|
studentId: exports_external.string().min(1),
|
|
93407
|
-
|
|
93408
|
-
|
|
93409
|
-
|
|
93410
|
-
grade: TimebackGradeSchema,
|
|
93411
|
-
subject: TimebackSubjectSchema,
|
|
93412
|
-
appName: exports_external.string().optional(),
|
|
93413
|
-
sensorUrl: exports_external.string().url().optional(),
|
|
93414
|
-
courseId: exports_external.string().optional(),
|
|
93415
|
-
courseName: exports_external.string().optional(),
|
|
93416
|
-
studentEmail: exports_external.string().email().optional()
|
|
93417
|
-
}),
|
|
93847
|
+
runId: exports_external.string().uuid().optional(),
|
|
93848
|
+
resumeId: exports_external.string().uuid(),
|
|
93849
|
+
activityData: TimebackActivityDataSchema,
|
|
93418
93850
|
scoreData: exports_external.object({
|
|
93419
93851
|
correctQuestions: exports_external.number().int().min(0),
|
|
93420
93852
|
totalQuestions: exports_external.number().int().min(0)
|
|
93421
93853
|
}),
|
|
93422
93854
|
timingData: exports_external.object({
|
|
93423
|
-
durationSeconds: exports_external.number().
|
|
93855
|
+
durationSeconds: exports_external.number().nonnegative()
|
|
93424
93856
|
}),
|
|
93857
|
+
sessionTimingData: exports_external.object({
|
|
93858
|
+
activeSeconds: exports_external.number().nonnegative(),
|
|
93859
|
+
inactiveSeconds: exports_external.number().nonnegative().optional()
|
|
93860
|
+
}).optional(),
|
|
93425
93861
|
xpEarned: exports_external.number().optional(),
|
|
93426
93862
|
masteredUnits: exports_external.number().nonnegative().optional(),
|
|
93427
93863
|
extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
|
|
93428
93864
|
});
|
|
93865
|
+
HeartbeatRequestSchema = exports_external.object({
|
|
93866
|
+
gameId: exports_external.string().uuid(),
|
|
93867
|
+
studentId: exports_external.string().min(1),
|
|
93868
|
+
runId: exports_external.string().uuid(),
|
|
93869
|
+
resumeId: exports_external.string().uuid(),
|
|
93870
|
+
activityData: TimebackActivityDataSchema,
|
|
93871
|
+
timingData: exports_external.object({
|
|
93872
|
+
activeMs: exports_external.number().nonnegative(),
|
|
93873
|
+
pausedMs: exports_external.number().nonnegative()
|
|
93874
|
+
}),
|
|
93875
|
+
windowStartedAtMs: exports_external.number().int().nonnegative(),
|
|
93876
|
+
isFinal: exports_external.boolean().optional()
|
|
93877
|
+
});
|
|
93429
93878
|
PopulateStudentRequestSchema = exports_external.object({
|
|
93430
93879
|
firstName: exports_external.string().min(1).optional(),
|
|
93431
93880
|
lastName: exports_external.string().min(1).optional()
|
|
@@ -93488,14 +93937,35 @@ var init_schemas11 = __esm(() => {
|
|
|
93488
93937
|
studentId: exports_external.string().min(1),
|
|
93489
93938
|
reason: exports_external.string().min(1)
|
|
93490
93939
|
});
|
|
93940
|
+
AdminAttributionDateSchema = exports_external.string().superRefine((value, ctx) => {
|
|
93941
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
93942
|
+
ctx.addIssue({
|
|
93943
|
+
code: exports_external.ZodIssueCode.custom,
|
|
93944
|
+
message: "Date must be in YYYY-MM-DD format"
|
|
93945
|
+
});
|
|
93946
|
+
return;
|
|
93947
|
+
}
|
|
93948
|
+
if (!isValidAdminAttributionDate(value)) {
|
|
93949
|
+
ctx.addIssue({
|
|
93950
|
+
code: exports_external.ZodIssueCode.custom,
|
|
93951
|
+
message: "Date must be a valid calendar date"
|
|
93952
|
+
});
|
|
93953
|
+
}
|
|
93954
|
+
});
|
|
93491
93955
|
GrantTimebackXpRequestSchema = AdminTimebackMutationBaseSchema.extend({
|
|
93492
|
-
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" })
|
|
93956
|
+
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" }),
|
|
93957
|
+
date: AdminAttributionDateSchema.optional(),
|
|
93958
|
+
useCurrentTime: exports_external.boolean().optional()
|
|
93493
93959
|
});
|
|
93494
93960
|
AdjustTimebackTimeRequestSchema = AdminTimebackMutationBaseSchema.extend({
|
|
93495
|
-
seconds: exports_external.number().refine((value) => value !== 0, { message: "Time amount cannot be 0" })
|
|
93961
|
+
seconds: exports_external.number().refine((value) => value !== 0, { message: "Time amount cannot be 0" }),
|
|
93962
|
+
date: AdminAttributionDateSchema.optional(),
|
|
93963
|
+
useCurrentTime: exports_external.boolean().optional()
|
|
93496
93964
|
});
|
|
93497
93965
|
AdjustTimebackMasteryRequestSchema = AdminTimebackMutationBaseSchema.extend({
|
|
93498
|
-
units: exports_external.number().refine((value) => value !== 0, { message: "Units cannot be 0" })
|
|
93966
|
+
units: exports_external.number().refine((value) => value !== 0, { message: "Units cannot be 0" }),
|
|
93967
|
+
date: AdminAttributionDateSchema.optional(),
|
|
93968
|
+
useCurrentTime: exports_external.boolean().optional()
|
|
93499
93969
|
});
|
|
93500
93970
|
ToggleCourseCompletionRequestSchema = exports_external.object({
|
|
93501
93971
|
gameId: exports_external.string().uuid(),
|
|
@@ -93503,6 +93973,16 @@ var init_schemas11 = __esm(() => {
|
|
|
93503
93973
|
studentId: exports_external.string().min(1),
|
|
93504
93974
|
action: exports_external.enum(["complete", "resume"])
|
|
93505
93975
|
});
|
|
93976
|
+
EnrollStudentRequestSchema = exports_external.object({
|
|
93977
|
+
gameId: exports_external.string().uuid(),
|
|
93978
|
+
courseId: exports_external.string().min(1),
|
|
93979
|
+
studentId: exports_external.string().min(1)
|
|
93980
|
+
});
|
|
93981
|
+
UnenrollStudentRequestSchema = exports_external.object({
|
|
93982
|
+
gameId: exports_external.string().uuid(),
|
|
93983
|
+
courseId: exports_external.string().min(1),
|
|
93984
|
+
studentId: exports_external.string().min(1)
|
|
93985
|
+
});
|
|
93506
93986
|
});
|
|
93507
93987
|
|
|
93508
93988
|
// ../data/src/schemas.index.ts
|
|
@@ -93529,6 +94009,9 @@ function isAuthenticated(ctx) {
|
|
|
93529
94009
|
var init_types9 = () => {};
|
|
93530
94010
|
|
|
93531
94011
|
// ../api-core/src/utils/auth.util.ts
|
|
94012
|
+
function hasGameManagementAccess(user) {
|
|
94013
|
+
return user.role === "admin" || user.role === "teacher" || user.role === "developer" && user.developerStatus === "approved";
|
|
94014
|
+
}
|
|
93532
94015
|
function requireAuth(handler) {
|
|
93533
94016
|
return async (ctx) => {
|
|
93534
94017
|
if (!isAuthenticated(ctx)) {
|
|
@@ -93572,6 +94055,17 @@ function requireDeveloper(handler) {
|
|
|
93572
94055
|
return handler(ctx);
|
|
93573
94056
|
};
|
|
93574
94057
|
}
|
|
94058
|
+
function requireGameManagementAccess(handler) {
|
|
94059
|
+
return async (ctx) => {
|
|
94060
|
+
if (!isAuthenticated(ctx)) {
|
|
94061
|
+
throw ApiError.unauthorized("Valid session or bearer token required");
|
|
94062
|
+
}
|
|
94063
|
+
if (!hasGameManagementAccess(ctx.user)) {
|
|
94064
|
+
throw ApiError.forbidden("Game management access required");
|
|
94065
|
+
}
|
|
94066
|
+
return handler(ctx);
|
|
94067
|
+
};
|
|
94068
|
+
}
|
|
93575
94069
|
var init_auth_util = __esm(() => {
|
|
93576
94070
|
init_errors();
|
|
93577
94071
|
init_types9();
|
|
@@ -95629,7 +96123,7 @@ var init_sprite_controller = __esm(() => {
|
|
|
95629
96123
|
});
|
|
95630
96124
|
|
|
95631
96125
|
// ../api-core/src/controllers/timeback.controller.ts
|
|
95632
|
-
var logger63, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, getStudentXp, getRoster, getStudentOverview, getStudentActivity, grantXp, adjustTime, adjustMastery, toggleCompletion, timeback2;
|
|
96126
|
+
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;
|
|
95633
96127
|
var init_timeback_controller = __esm(() => {
|
|
95634
96128
|
init_esm();
|
|
95635
96129
|
init_schemas_index();
|
|
@@ -95719,7 +96213,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95719
96213
|
});
|
|
95720
96214
|
return ctx.services.timeback.setupIntegration(body2.gameId, body2, ctx.user);
|
|
95721
96215
|
});
|
|
95722
|
-
getIntegrations =
|
|
96216
|
+
getIntegrations = requireGameManagementAccess(async (ctx) => {
|
|
95723
96217
|
const gameId = ctx.params.gameId;
|
|
95724
96218
|
if (!gameId) {
|
|
95725
96219
|
throw ApiError.badRequest("Missing gameId");
|
|
@@ -95779,9 +96273,12 @@ var init_timeback_controller = __esm(() => {
|
|
|
95779
96273
|
const {
|
|
95780
96274
|
gameId,
|
|
95781
96275
|
studentId,
|
|
96276
|
+
runId,
|
|
96277
|
+
resumeId,
|
|
95782
96278
|
activityData,
|
|
95783
96279
|
scoreData,
|
|
95784
96280
|
timingData,
|
|
96281
|
+
sessionTimingData,
|
|
95785
96282
|
xpEarned,
|
|
95786
96283
|
masteredUnits,
|
|
95787
96284
|
extensions
|
|
@@ -95790,15 +96287,62 @@ var init_timeback_controller = __esm(() => {
|
|
|
95790
96287
|
return ctx.services.timeback.endActivity({
|
|
95791
96288
|
gameId,
|
|
95792
96289
|
studentId,
|
|
96290
|
+
runId,
|
|
96291
|
+
resumeId,
|
|
95793
96292
|
activityData,
|
|
95794
96293
|
scoreData,
|
|
95795
96294
|
timingData,
|
|
96295
|
+
sessionTimingData,
|
|
95796
96296
|
xpEarned,
|
|
95797
96297
|
masteredUnits,
|
|
95798
96298
|
extensions,
|
|
95799
96299
|
user: ctx.user
|
|
95800
96300
|
});
|
|
95801
96301
|
});
|
|
96302
|
+
heartbeat = requireDeveloper(async (ctx) => {
|
|
96303
|
+
let body2;
|
|
96304
|
+
try {
|
|
96305
|
+
const json4 = await ctx.request.json();
|
|
96306
|
+
body2 = HeartbeatRequestSchema.parse(json4);
|
|
96307
|
+
} catch (error2) {
|
|
96308
|
+
if (error2 instanceof exports_external.ZodError) {
|
|
96309
|
+
const details = formatZodError(error2);
|
|
96310
|
+
logger63.warn("Heartbeat validation failed", { details });
|
|
96311
|
+
throw ApiError.unprocessableEntity("Validation failed", details);
|
|
96312
|
+
}
|
|
96313
|
+
throw ApiError.badRequest("Invalid JSON body");
|
|
96314
|
+
}
|
|
96315
|
+
const {
|
|
96316
|
+
gameId,
|
|
96317
|
+
studentId,
|
|
96318
|
+
runId,
|
|
96319
|
+
resumeId,
|
|
96320
|
+
activityData,
|
|
96321
|
+
timingData,
|
|
96322
|
+
windowStartedAtMs,
|
|
96323
|
+
isFinal
|
|
96324
|
+
} = body2;
|
|
96325
|
+
logger63.debug("Recording heartbeat", {
|
|
96326
|
+
userId: ctx.user.id,
|
|
96327
|
+
gameId,
|
|
96328
|
+
runId,
|
|
96329
|
+
resumeId,
|
|
96330
|
+
windowStartedAtMs,
|
|
96331
|
+
activeMs: timingData.activeMs,
|
|
96332
|
+
isFinal
|
|
96333
|
+
});
|
|
96334
|
+
return ctx.services.timeback.recordHeartbeat({
|
|
96335
|
+
gameId,
|
|
96336
|
+
studentId,
|
|
96337
|
+
runId,
|
|
96338
|
+
resumeId,
|
|
96339
|
+
activityData,
|
|
96340
|
+
timingData,
|
|
96341
|
+
windowStartedAtMs,
|
|
96342
|
+
isFinal,
|
|
96343
|
+
user: ctx.user
|
|
96344
|
+
});
|
|
96345
|
+
});
|
|
95802
96346
|
getStudentXp = requireDeveloper(async (ctx) => {
|
|
95803
96347
|
const timebackId = ctx.params.timebackId;
|
|
95804
96348
|
if (!timebackId) {
|
|
@@ -95844,7 +96388,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95844
96388
|
include
|
|
95845
96389
|
});
|
|
95846
96390
|
});
|
|
95847
|
-
getRoster =
|
|
96391
|
+
getRoster = requireGameManagementAccess(async (ctx) => {
|
|
95848
96392
|
const gameId = ctx.params.gameId;
|
|
95849
96393
|
const courseId = ctx.params.courseId;
|
|
95850
96394
|
if (!gameId || !courseId) {
|
|
@@ -95857,7 +96401,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95857
96401
|
});
|
|
95858
96402
|
return ctx.services.timebackAdmin.listStudentsForCourse(gameId, courseId, ctx.user);
|
|
95859
96403
|
});
|
|
95860
|
-
getStudentOverview =
|
|
96404
|
+
getStudentOverview = requireGameManagementAccess(async (ctx) => {
|
|
95861
96405
|
const timebackId = ctx.params.timebackId;
|
|
95862
96406
|
const gameId = ctx.url.searchParams.get("gameId") || undefined;
|
|
95863
96407
|
const courseId = ctx.url.searchParams.get("courseId") || undefined;
|
|
@@ -95872,7 +96416,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95872
96416
|
});
|
|
95873
96417
|
return ctx.services.timebackAdmin.getStudentOverview(gameId, timebackId, ctx.user, courseId);
|
|
95874
96418
|
});
|
|
95875
|
-
getStudentActivity =
|
|
96419
|
+
getStudentActivity = requireGameManagementAccess(async (ctx) => {
|
|
95876
96420
|
const timebackId = ctx.params.timebackId;
|
|
95877
96421
|
const courseId = ctx.params.courseId;
|
|
95878
96422
|
const gameId = ctx.url.searchParams.get("gameId") || undefined;
|
|
@@ -95906,7 +96450,8 @@ var init_timeback_controller = __esm(() => {
|
|
|
95906
96450
|
gameId: body2.gameId,
|
|
95907
96451
|
courseId: body2.courseId,
|
|
95908
96452
|
studentId: body2.studentId,
|
|
95909
|
-
xp: body2.xp
|
|
96453
|
+
xp: body2.xp,
|
|
96454
|
+
date: body2.date
|
|
95910
96455
|
});
|
|
95911
96456
|
return ctx.services.timebackAdmin.grantManualXp(body2, ctx.user);
|
|
95912
96457
|
});
|
|
@@ -95917,7 +96462,8 @@ var init_timeback_controller = __esm(() => {
|
|
|
95917
96462
|
gameId: body2.gameId,
|
|
95918
96463
|
courseId: body2.courseId,
|
|
95919
96464
|
studentId: body2.studentId,
|
|
95920
|
-
seconds: body2.seconds
|
|
96465
|
+
seconds: body2.seconds,
|
|
96466
|
+
date: body2.date
|
|
95921
96467
|
});
|
|
95922
96468
|
return ctx.services.timebackAdmin.adjustTimeSpent(body2, ctx.user);
|
|
95923
96469
|
});
|
|
@@ -95928,11 +96474,12 @@ var init_timeback_controller = __esm(() => {
|
|
|
95928
96474
|
gameId: body2.gameId,
|
|
95929
96475
|
courseId: body2.courseId,
|
|
95930
96476
|
studentId: body2.studentId,
|
|
95931
|
-
units: body2.units
|
|
96477
|
+
units: body2.units,
|
|
96478
|
+
date: body2.date
|
|
95932
96479
|
});
|
|
95933
96480
|
return ctx.services.timebackAdmin.adjustMasteredUnits(body2, ctx.user);
|
|
95934
96481
|
});
|
|
95935
|
-
toggleCompletion =
|
|
96482
|
+
toggleCompletion = requireGameManagementAccess(async (ctx) => {
|
|
95936
96483
|
const body2 = await parseRequestBody(ctx.request, ToggleCourseCompletionRequestSchema);
|
|
95937
96484
|
logger63.debug("Toggling course completion", {
|
|
95938
96485
|
requesterId: ctx.user.id,
|
|
@@ -95943,6 +96490,41 @@ var init_timeback_controller = __esm(() => {
|
|
|
95943
96490
|
});
|
|
95944
96491
|
return ctx.services.timebackAdmin.toggleCourseCompletion(body2, ctx.user);
|
|
95945
96492
|
});
|
|
96493
|
+
searchStudents = requireGameManagementAccess(async (ctx) => {
|
|
96494
|
+
const gameId = ctx.params.gameId;
|
|
96495
|
+
const courseId = ctx.params.courseId;
|
|
96496
|
+
const query = ctx.url.searchParams.get("q") || "";
|
|
96497
|
+
if (!gameId || !courseId) {
|
|
96498
|
+
throw ApiError.badRequest("Missing gameId or courseId parameter");
|
|
96499
|
+
}
|
|
96500
|
+
logger63.debug("Searching students for enrollment", {
|
|
96501
|
+
requesterId: ctx.user.id,
|
|
96502
|
+
gameId,
|
|
96503
|
+
courseId,
|
|
96504
|
+
query
|
|
96505
|
+
});
|
|
96506
|
+
return ctx.services.timebackAdmin.searchStudentsForEnrollment(gameId, courseId, query, ctx.user);
|
|
96507
|
+
});
|
|
96508
|
+
enrollStudent = requireGameManagementAccess(async (ctx) => {
|
|
96509
|
+
const body2 = await parseRequestBody(ctx.request, EnrollStudentRequestSchema);
|
|
96510
|
+
logger63.debug("Enrolling student", {
|
|
96511
|
+
requesterId: ctx.user.id,
|
|
96512
|
+
gameId: body2.gameId,
|
|
96513
|
+
courseId: body2.courseId,
|
|
96514
|
+
studentId: body2.studentId
|
|
96515
|
+
});
|
|
96516
|
+
return ctx.services.timebackAdmin.enrollStudent(body2, ctx.user);
|
|
96517
|
+
});
|
|
96518
|
+
unenrollStudent = requireGameManagementAccess(async (ctx) => {
|
|
96519
|
+
const body2 = await parseRequestBody(ctx.request, UnenrollStudentRequestSchema);
|
|
96520
|
+
logger63.debug("Unenrolling student", {
|
|
96521
|
+
requesterId: ctx.user.id,
|
|
96522
|
+
gameId: body2.gameId,
|
|
96523
|
+
courseId: body2.courseId,
|
|
96524
|
+
studentId: body2.studentId
|
|
96525
|
+
});
|
|
96526
|
+
return ctx.services.timebackAdmin.unenrollStudent(body2, ctx.user);
|
|
96527
|
+
});
|
|
95946
96528
|
timeback2 = {
|
|
95947
96529
|
getTodayXp,
|
|
95948
96530
|
getTotalXp,
|
|
@@ -95957,6 +96539,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95957
96539
|
getConfig: getConfig2,
|
|
95958
96540
|
deleteIntegrations,
|
|
95959
96541
|
endActivity,
|
|
96542
|
+
heartbeat,
|
|
95960
96543
|
getStudentXp,
|
|
95961
96544
|
getRoster,
|
|
95962
96545
|
getStudentOverview,
|
|
@@ -95964,7 +96547,10 @@ var init_timeback_controller = __esm(() => {
|
|
|
95964
96547
|
grantXp,
|
|
95965
96548
|
adjustTime,
|
|
95966
96549
|
adjustMastery,
|
|
95967
|
-
toggleCompletion
|
|
96550
|
+
toggleCompletion,
|
|
96551
|
+
searchStudents,
|
|
96552
|
+
enrollStudent,
|
|
96553
|
+
unenrollStudent
|
|
95968
96554
|
};
|
|
95969
96555
|
});
|
|
95970
96556
|
|
|
@@ -97005,6 +97591,7 @@ var init_timeback6 = __esm(() => {
|
|
|
97005
97591
|
timebackRouter.get("/config/:gameId", handle2(timeback2.getConfig));
|
|
97006
97592
|
timebackRouter.delete("/integrations/:gameId", handle2(timeback2.deleteIntegrations, { status: 204 }));
|
|
97007
97593
|
timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
|
|
97594
|
+
timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
|
|
97008
97595
|
timebackRouter.get("/user", async (c2) => {
|
|
97009
97596
|
const user = c2.get("user");
|
|
97010
97597
|
const gameId = c2.get("gameId");
|