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