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