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