@playcademy/sandbox 0.3.17-beta.6 → 0.3.17-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +938 -570
- package/dist/constants.js +2 -1
- package/dist/server.js +938 -570
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -399,7 +399,8 @@ var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME =
|
|
|
399
399
|
var init_timeback2 = __esm(() => {
|
|
400
400
|
TIMEBACK_ROUTES = {
|
|
401
401
|
END_ACTIVITY: "/integrations/timeback/end-activity",
|
|
402
|
-
GET_XP: "/integrations/timeback/xp"
|
|
402
|
+
GET_XP: "/integrations/timeback/xp",
|
|
403
|
+
HEARTBEAT: "/integrations/timeback/heartbeat"
|
|
403
404
|
};
|
|
404
405
|
TIMEBACK_COURSE_DEFAULTS = {
|
|
405
406
|
gradingScheme: "STANDARD",
|
|
@@ -1310,7 +1311,7 @@ var package_default;
|
|
|
1310
1311
|
var init_package = __esm(() => {
|
|
1311
1312
|
package_default = {
|
|
1312
1313
|
name: "@playcademy/sandbox",
|
|
1313
|
-
version: "0.3.17-beta.
|
|
1314
|
+
version: "0.3.17-beta.8",
|
|
1314
1315
|
description: "Local development server for Playcademy game development",
|
|
1315
1316
|
type: "module",
|
|
1316
1317
|
exports: {
|
|
@@ -11550,7 +11551,7 @@ var init_table6 = __esm(() => {
|
|
|
11550
11551
|
init_drizzle_orm();
|
|
11551
11552
|
init_pg_core();
|
|
11552
11553
|
init_table5();
|
|
11553
|
-
userRoleEnum = pgEnum("user_role", ["admin", "player", "developer"]);
|
|
11554
|
+
userRoleEnum = pgEnum("user_role", ["admin", "player", "developer", "teacher"]);
|
|
11554
11555
|
developerStatusEnum = pgEnum("developer_status", ["none", "pending", "approved"]);
|
|
11555
11556
|
users = pgTable("user", {
|
|
11556
11557
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
|
@@ -26733,10 +26734,13 @@ var init_game_service = __esm(() => {
|
|
|
26733
26734
|
});
|
|
26734
26735
|
}
|
|
26735
26736
|
async listManageable(user) {
|
|
26736
|
-
|
|
26737
|
+
const seesAllGames = user.role === "admin" || user.role === "teacher";
|
|
26738
|
+
if (!seesAllGames) {
|
|
26739
|
+
this.validateDeveloperStatus(user);
|
|
26740
|
+
}
|
|
26737
26741
|
const db2 = this.deps.db;
|
|
26738
26742
|
return db2.query.games.findMany({
|
|
26739
|
-
where:
|
|
26743
|
+
where: seesAllGames ? undefined : eq(games.developerId, user.id),
|
|
26740
26744
|
orderBy: [desc(games.createdAt)]
|
|
26741
26745
|
});
|
|
26742
26746
|
}
|
|
@@ -27140,6 +27144,19 @@ var init_game_service = __esm(() => {
|
|
|
27140
27144
|
throw new NotFoundError("Game", gameId);
|
|
27141
27145
|
}
|
|
27142
27146
|
}
|
|
27147
|
+
async validateGameManagementAccess(user, gameId) {
|
|
27148
|
+
if (user.role === "admin" || user.role === "teacher") {
|
|
27149
|
+
const gameExists = await this.deps.db.query.games.findFirst({
|
|
27150
|
+
where: eq(games.id, gameId),
|
|
27151
|
+
columns: { id: true }
|
|
27152
|
+
});
|
|
27153
|
+
if (!gameExists) {
|
|
27154
|
+
throw new NotFoundError("Game", gameId);
|
|
27155
|
+
}
|
|
27156
|
+
return;
|
|
27157
|
+
}
|
|
27158
|
+
return this.validateDeveloperAccess(user, gameId);
|
|
27159
|
+
}
|
|
27143
27160
|
async validateDeveloperAccessBySlug(user, slug) {
|
|
27144
27161
|
this.validateDeveloperStatus(user);
|
|
27145
27162
|
const db2 = this.deps.db;
|
|
@@ -27210,6 +27227,7 @@ function createGameServices(deps) {
|
|
|
27210
27227
|
validators: {
|
|
27211
27228
|
validateDeveloperAccessBySlug: (user, slug) => game.validateDeveloperAccessBySlug(user, slug),
|
|
27212
27229
|
validateDeveloperAccess: (user, gameId) => game.validateDeveloperAccess(user, gameId),
|
|
27230
|
+
validateGameManagementAccess: (user, gameId) => game.validateGameManagementAccess(user, gameId),
|
|
27213
27231
|
validateOwnership: (user, gameId) => game.validateOwnership(user, gameId)
|
|
27214
27232
|
}
|
|
27215
27233
|
};
|
|
@@ -28860,7 +28878,8 @@ var init_constants3 = __esm(() => {
|
|
|
28860
28878
|
HEALTH: "/api/health",
|
|
28861
28879
|
TIMEBACK: {
|
|
28862
28880
|
END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
|
|
28863
|
-
GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}
|
|
28881
|
+
GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
|
|
28882
|
+
HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`
|
|
28864
28883
|
}
|
|
28865
28884
|
};
|
|
28866
28885
|
});
|
|
@@ -30612,9 +30631,13 @@ class TimebackAdminService {
|
|
|
30612
30631
|
});
|
|
30613
30632
|
});
|
|
30614
30633
|
}
|
|
30615
|
-
async resolveAdminMutationContext(gameId, courseId, user, studentId) {
|
|
30634
|
+
async resolveAdminMutationContext(gameId, courseId, user, studentId, accessLevel = "developer") {
|
|
30616
30635
|
const client = this.requireClient();
|
|
30617
|
-
|
|
30636
|
+
if (accessLevel === "dashboard") {
|
|
30637
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
30638
|
+
} else {
|
|
30639
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
30640
|
+
}
|
|
30618
30641
|
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
30619
30642
|
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
30620
30643
|
});
|
|
@@ -30874,7 +30897,7 @@ class TimebackAdminService {
|
|
|
30874
30897
|
}
|
|
30875
30898
|
async listStudentsForCourse(gameId, courseId, user) {
|
|
30876
30899
|
const client = this.requireClient();
|
|
30877
|
-
await this.deps.
|
|
30900
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
30878
30901
|
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
30879
30902
|
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
30880
30903
|
});
|
|
@@ -30912,7 +30935,7 @@ class TimebackAdminService {
|
|
|
30912
30935
|
}
|
|
30913
30936
|
async getStudentOverview(gameId, studentId, user, courseId) {
|
|
30914
30937
|
const client = this.requireClient();
|
|
30915
|
-
await this.deps.
|
|
30938
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
30916
30939
|
const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
|
|
30917
30940
|
where: courseId ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId)) : eq(gameTimebackIntegrations.gameId, gameId)
|
|
30918
30941
|
});
|
|
@@ -30966,7 +30989,7 @@ class TimebackAdminService {
|
|
|
30966
30989
|
const client = this.requireClient();
|
|
30967
30990
|
const safeLimit = Math.max(1, Math.min(limit, TimebackAdminService.MAX_STUDENT_ACTIVITY_LIMIT));
|
|
30968
30991
|
const safeOffset = Math.max(0, Math.min(offset, TimebackAdminService.MAX_STUDENT_ACTIVITY_OFFSET));
|
|
30969
|
-
await this.deps.
|
|
30992
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
30970
30993
|
const [integration, sensorUrl] = await Promise.all([
|
|
30971
30994
|
this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
30972
30995
|
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
@@ -31030,7 +31053,7 @@ class TimebackAdminService {
|
|
|
31030
31053
|
return { status: "ok" };
|
|
31031
31054
|
}
|
|
31032
31055
|
async toggleCourseCompletion(data, user) {
|
|
31033
|
-
const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
|
|
31056
|
+
const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId, "dashboard");
|
|
31034
31057
|
const historyClient = client;
|
|
31035
31058
|
const ids = deriveSourcedIds(data.courseId);
|
|
31036
31059
|
const lineItemId = `${ids.course}-mastery-completion-assessment`;
|
|
@@ -31123,6 +31146,77 @@ class TimebackAdminService {
|
|
|
31123
31146
|
}
|
|
31124
31147
|
return { status: "ok" };
|
|
31125
31148
|
}
|
|
31149
|
+
async searchStudentsForEnrollment(gameId, courseId, query, user) {
|
|
31150
|
+
const client = this.requireClient();
|
|
31151
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
31152
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31153
|
+
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
|
|
31154
|
+
});
|
|
31155
|
+
if (!integration) {
|
|
31156
|
+
throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
|
|
31157
|
+
}
|
|
31158
|
+
const trimmedQuery = query.trim();
|
|
31159
|
+
if (trimmedQuery.length < 2) {
|
|
31160
|
+
return { students: [] };
|
|
31161
|
+
}
|
|
31162
|
+
const filterParts = [
|
|
31163
|
+
`givenName~'${escapeFilterValue(trimmedQuery)}'`,
|
|
31164
|
+
`familyName~'${escapeFilterValue(trimmedQuery)}'`,
|
|
31165
|
+
`email~'${escapeFilterValue(trimmedQuery)}'`
|
|
31166
|
+
];
|
|
31167
|
+
const filter = filterParts.join(" OR ");
|
|
31168
|
+
const params = new URLSearchParams({ filter, limit: "25" });
|
|
31169
|
+
const endpoint = `/ims/oneroster/rostering/v1p2/users?${params}`;
|
|
31170
|
+
let allUsers = [];
|
|
31171
|
+
try {
|
|
31172
|
+
const response = await client["request"](endpoint, "GET");
|
|
31173
|
+
allUsers = response.users || [];
|
|
31174
|
+
} catch (error) {
|
|
31175
|
+
logger16.warn("Failed to search OneRoster users", {
|
|
31176
|
+
query: trimmedQuery,
|
|
31177
|
+
error: error instanceof Error ? error.message : String(error)
|
|
31178
|
+
});
|
|
31179
|
+
return { students: [] };
|
|
31180
|
+
}
|
|
31181
|
+
const roster = await client.oneroster.enrollments.listByCourse(courseId, {
|
|
31182
|
+
role: "student",
|
|
31183
|
+
includeUsers: false
|
|
31184
|
+
});
|
|
31185
|
+
const enrolledStudentIds = new Set(roster.map((entry) => entry.enrollment.user.sourcedId));
|
|
31186
|
+
const students = allUsers.filter((entry) => Boolean(entry.sourcedId) && entry.roles?.some((role) => role.role === "student") === true).map((entry) => ({
|
|
31187
|
+
studentId: entry.sourcedId,
|
|
31188
|
+
name: `${entry.givenName || ""} ${entry.familyName || ""}`.trim() || entry.sourcedId,
|
|
31189
|
+
email: entry.email || null,
|
|
31190
|
+
alreadyEnrolled: enrolledStudentIds.has(entry.sourcedId)
|
|
31191
|
+
}));
|
|
31192
|
+
return { students };
|
|
31193
|
+
}
|
|
31194
|
+
async enrollStudent(data, user) {
|
|
31195
|
+
const client = this.requireClient();
|
|
31196
|
+
await this.deps.validateGameManagementAccess(user, data.gameId);
|
|
31197
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31198
|
+
where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
|
|
31199
|
+
});
|
|
31200
|
+
if (!integration) {
|
|
31201
|
+
throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
|
|
31202
|
+
}
|
|
31203
|
+
await client.edubridge.enrollments.enroll(data.studentId, data.courseId, {
|
|
31204
|
+
role: "student"
|
|
31205
|
+
});
|
|
31206
|
+
return { status: "ok" };
|
|
31207
|
+
}
|
|
31208
|
+
async unenrollStudent(data, user) {
|
|
31209
|
+
const client = this.requireClient();
|
|
31210
|
+
await this.deps.validateGameManagementAccess(user, data.gameId);
|
|
31211
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31212
|
+
where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
|
|
31213
|
+
});
|
|
31214
|
+
if (!integration) {
|
|
31215
|
+
throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
|
|
31216
|
+
}
|
|
31217
|
+
await client.edubridge.enrollments.unenroll(data.studentId, data.courseId);
|
|
31218
|
+
return { status: "ok" };
|
|
31219
|
+
}
|
|
31126
31220
|
async getCompletionStatus(client, courseId, studentId) {
|
|
31127
31221
|
const ids = deriveSourcedIds(courseId);
|
|
31128
31222
|
const lineItemId = `${ids.course}-mastery-completion-assessment`;
|
|
@@ -31188,589 +31282,703 @@ var init_timeback_admin_service = __esm(() => {
|
|
|
31188
31282
|
});
|
|
31189
31283
|
|
|
31190
31284
|
// ../api-core/src/services/timeback.service.ts
|
|
31191
|
-
|
|
31192
|
-
|
|
31193
|
-
|
|
31194
|
-
|
|
31195
|
-
|
|
31196
|
-
|
|
31197
|
-
|
|
31198
|
-
|
|
31199
|
-
|
|
31285
|
+
var logger17, TimebackService;
|
|
31286
|
+
var init_timeback_service = __esm(() => {
|
|
31287
|
+
init_drizzle_orm();
|
|
31288
|
+
init_src();
|
|
31289
|
+
init_tables_index();
|
|
31290
|
+
init_src2();
|
|
31291
|
+
init_types4();
|
|
31292
|
+
init_src4();
|
|
31293
|
+
init_errors();
|
|
31294
|
+
init_timeback_util();
|
|
31295
|
+
logger17 = log.scope("TimebackService");
|
|
31296
|
+
TimebackService = class TimebackService {
|
|
31297
|
+
static HEARTBEAT_DEDUPE_TTL_MS = 5 * 60 * 1000;
|
|
31298
|
+
static processedHeartbeatWindows = new Map;
|
|
31299
|
+
static inFlightHeartbeatWindows = new Map;
|
|
31300
|
+
deps;
|
|
31301
|
+
static cleanHeartbeatDedupeCache(now2 = Date.now()) {
|
|
31302
|
+
for (const [key, timestamp3] of this.processedHeartbeatWindows) {
|
|
31303
|
+
if (now2 - timestamp3 > this.HEARTBEAT_DEDUPE_TTL_MS) {
|
|
31304
|
+
this.processedHeartbeatWindows.delete(key);
|
|
31305
|
+
}
|
|
31306
|
+
}
|
|
31200
31307
|
}
|
|
31201
|
-
|
|
31202
|
-
|
|
31203
|
-
|
|
31204
|
-
const db2 = this.deps.db;
|
|
31205
|
-
const tz = timezone2 || PLATFORM_TIMEZONE;
|
|
31206
|
-
const base = date3 ? new Date(date3) : new Date;
|
|
31207
|
-
if (isNaN(base.getTime())) {
|
|
31208
|
-
throw new ValidationError("Invalid date format. Use ISO 8601 format.");
|
|
31308
|
+
static isDuplicateHeartbeatWindow(key) {
|
|
31309
|
+
this.cleanHeartbeatDedupeCache();
|
|
31310
|
+
return this.processedHeartbeatWindows.has(key);
|
|
31209
31311
|
}
|
|
31210
|
-
|
|
31211
|
-
|
|
31212
|
-
} catch {
|
|
31213
|
-
throw new ValidationError(`Invalid timezone: ${tz}`);
|
|
31312
|
+
static getInFlightHeartbeatWindow(key) {
|
|
31313
|
+
return this.inFlightHeartbeatWindows.get(key);
|
|
31214
31314
|
}
|
|
31215
|
-
|
|
31216
|
-
|
|
31217
|
-
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);
|
|
31218
|
-
if (result2.length === 0) {
|
|
31219
|
-
return { xp: 0, date: todayMidnight.toISOString() };
|
|
31220
|
-
}
|
|
31221
|
-
return { xp: result2[0].xp, date: result2[0].date.toISOString() };
|
|
31315
|
+
static markHeartbeatWindowProcessed(key) {
|
|
31316
|
+
this.processedHeartbeatWindows.set(key, Date.now());
|
|
31222
31317
|
}
|
|
31223
|
-
|
|
31224
|
-
|
|
31225
|
-
return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
|
|
31226
|
-
}
|
|
31227
|
-
async getTotalXp(userId) {
|
|
31228
|
-
const db2 = this.deps.db;
|
|
31229
|
-
const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
|
|
31230
|
-
return { totalXp: Number(result[0]?.totalXp) || 0 };
|
|
31231
|
-
}
|
|
31232
|
-
async updateTodayXp(userId, data) {
|
|
31233
|
-
const db2 = this.deps.db;
|
|
31234
|
-
const { xp, userTimestamp } = data;
|
|
31235
|
-
let targetDate;
|
|
31236
|
-
if (userTimestamp) {
|
|
31237
|
-
targetDate = new Date(userTimestamp);
|
|
31238
|
-
if (isNaN(targetDate.getTime())) {
|
|
31239
|
-
throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
|
|
31240
|
-
}
|
|
31241
|
-
targetDate.setHours(0, 0, 0, 0);
|
|
31242
|
-
} else {
|
|
31243
|
-
targetDate = new Date;
|
|
31244
|
-
targetDate.setUTCHours(0, 0, 0, 0);
|
|
31318
|
+
static markHeartbeatWindowInFlight(key, promise) {
|
|
31319
|
+
this.inFlightHeartbeatWindows.set(key, promise);
|
|
31245
31320
|
}
|
|
31246
|
-
|
|
31247
|
-
|
|
31248
|
-
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31249
|
-
}).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
|
|
31250
|
-
if (!result) {
|
|
31251
|
-
logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
|
|
31252
|
-
throw new InternalError("Failed to update daily XP record");
|
|
31321
|
+
static clearInFlightHeartbeatWindow(key) {
|
|
31322
|
+
this.inFlightHeartbeatWindows.delete(key);
|
|
31253
31323
|
}
|
|
31254
|
-
|
|
31255
|
-
|
|
31256
|
-
async getXpHistory(userId, startDate, endDate) {
|
|
31257
|
-
const db2 = this.deps.db;
|
|
31258
|
-
const whereConditions = [eq(timebackDailyXp.userId, userId)];
|
|
31259
|
-
if (startDate) {
|
|
31260
|
-
const start2 = new Date(startDate);
|
|
31261
|
-
start2.setUTCHours(0, 0, 0, 0);
|
|
31262
|
-
whereConditions.push(gte(timebackDailyXp.date, start2));
|
|
31324
|
+
constructor(deps) {
|
|
31325
|
+
this.deps = deps;
|
|
31263
31326
|
}
|
|
31264
|
-
|
|
31265
|
-
|
|
31266
|
-
|
|
31267
|
-
|
|
31327
|
+
requireClient() {
|
|
31328
|
+
if (!this.deps.timeback) {
|
|
31329
|
+
logger17.error("Timeback client not available in context");
|
|
31330
|
+
throw new ValidationError("Timeback integration not available in this environment");
|
|
31331
|
+
}
|
|
31332
|
+
return this.deps.timeback;
|
|
31268
31333
|
}
|
|
31269
|
-
|
|
31270
|
-
|
|
31271
|
-
|
|
31272
|
-
|
|
31273
|
-
|
|
31274
|
-
|
|
31275
|
-
|
|
31276
|
-
|
|
31277
|
-
|
|
31278
|
-
|
|
31279
|
-
|
|
31280
|
-
|
|
31281
|
-
|
|
31282
|
-
|
|
31283
|
-
|
|
31334
|
+
async getTodayXp(userId, date3, timezone2) {
|
|
31335
|
+
const db2 = this.deps.db;
|
|
31336
|
+
const tz = timezone2 || PLATFORM_TIMEZONE;
|
|
31337
|
+
const base = date3 ? new Date(date3) : new Date;
|
|
31338
|
+
if (isNaN(base.getTime())) {
|
|
31339
|
+
throw new ValidationError("Invalid date format. Use ISO 8601 format.");
|
|
31340
|
+
}
|
|
31341
|
+
try {
|
|
31342
|
+
new Intl.DateTimeFormat(undefined, { timeZone: tz });
|
|
31343
|
+
} catch {
|
|
31344
|
+
throw new ValidationError(`Invalid timezone: ${tz}`);
|
|
31345
|
+
}
|
|
31346
|
+
if (tz === PLATFORM_TIMEZONE) {
|
|
31347
|
+
const todayMidnight = getUtcInstantForMidnight(base, tz);
|
|
31348
|
+
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);
|
|
31349
|
+
if (result2.length === 0) {
|
|
31350
|
+
return { xp: 0, date: todayMidnight.toISOString() };
|
|
31351
|
+
}
|
|
31352
|
+
return { xp: result2[0].xp, date: result2[0].date.toISOString() };
|
|
31353
|
+
}
|
|
31354
|
+
const { startOfDay, endOfDay } = getDayBoundariesInTimezone(base, tz);
|
|
31355
|
+
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))));
|
|
31356
|
+
return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
|
|
31284
31357
|
}
|
|
31285
|
-
|
|
31286
|
-
|
|
31287
|
-
|
|
31288
|
-
|
|
31289
|
-
|
|
31290
|
-
|
|
31291
|
-
|
|
31292
|
-
|
|
31293
|
-
|
|
31294
|
-
|
|
31295
|
-
|
|
31296
|
-
|
|
31297
|
-
|
|
31358
|
+
async getTotalXp(userId) {
|
|
31359
|
+
const db2 = this.deps.db;
|
|
31360
|
+
const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
|
|
31361
|
+
return { totalXp: Number(result[0]?.totalXp) || 0 };
|
|
31362
|
+
}
|
|
31363
|
+
async updateTodayXp(userId, data) {
|
|
31364
|
+
const db2 = this.deps.db;
|
|
31365
|
+
const { xp, userTimestamp } = data;
|
|
31366
|
+
let targetDate;
|
|
31367
|
+
if (userTimestamp) {
|
|
31368
|
+
targetDate = new Date(userTimestamp);
|
|
31369
|
+
if (isNaN(targetDate.getTime())) {
|
|
31370
|
+
throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
|
|
31371
|
+
}
|
|
31372
|
+
targetDate.setHours(0, 0, 0, 0);
|
|
31373
|
+
} else {
|
|
31374
|
+
targetDate = new Date;
|
|
31375
|
+
targetDate.setUTCHours(0, 0, 0, 0);
|
|
31298
31376
|
}
|
|
31299
|
-
const
|
|
31300
|
-
|
|
31301
|
-
|
|
31302
|
-
|
|
31303
|
-
|
|
31304
|
-
|
|
31305
|
-
|
|
31306
|
-
email: user.email,
|
|
31307
|
-
roles: [
|
|
31308
|
-
{
|
|
31309
|
-
roleType: "primary",
|
|
31310
|
-
role: "student",
|
|
31311
|
-
org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
|
|
31312
|
-
}
|
|
31313
|
-
]
|
|
31314
|
-
});
|
|
31315
|
-
if (!response.sourcedIdPairs?.allocatedSourcedId) {
|
|
31316
|
-
return { status: "error", message: "Timeback did not return allocatedSourcedId" };
|
|
31377
|
+
const [result] = await db2.insert(timebackDailyXp).values({ userId, date: targetDate, xp }).onConflictDoUpdate({
|
|
31378
|
+
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31379
|
+
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31380
|
+
}).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
|
|
31381
|
+
if (!result) {
|
|
31382
|
+
logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
|
|
31383
|
+
throw new InternalError("Failed to update daily XP record");
|
|
31317
31384
|
}
|
|
31318
|
-
|
|
31319
|
-
name3 = `${providedNames.firstName} ${providedNames.lastName}`;
|
|
31320
|
-
logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
|
|
31385
|
+
return { xp: result.xp, date: result.date.toISOString() };
|
|
31321
31386
|
}
|
|
31322
|
-
|
|
31323
|
-
|
|
31324
|
-
|
|
31325
|
-
|
|
31326
|
-
|
|
31327
|
-
|
|
31328
|
-
|
|
31329
|
-
|
|
31387
|
+
async getXpHistory(userId, startDate, endDate) {
|
|
31388
|
+
const db2 = this.deps.db;
|
|
31389
|
+
const whereConditions = [eq(timebackDailyXp.userId, userId)];
|
|
31390
|
+
if (startDate) {
|
|
31391
|
+
const start2 = new Date(startDate);
|
|
31392
|
+
start2.setUTCHours(0, 0, 0, 0);
|
|
31393
|
+
whereConditions.push(gte(timebackDailyXp.date, start2));
|
|
31394
|
+
}
|
|
31395
|
+
if (endDate) {
|
|
31396
|
+
const end = new Date(endDate);
|
|
31397
|
+
end.setUTCHours(23, 59, 59, 999);
|
|
31398
|
+
whereConditions.push(lte(timebackDailyXp.date, end));
|
|
31399
|
+
}
|
|
31400
|
+
const result = await db2.select({ date: timebackDailyXp.date, xp: timebackDailyXp.xp }).from(timebackDailyXp).where(and(...whereConditions)).orderBy(timebackDailyXp.date);
|
|
31401
|
+
return {
|
|
31402
|
+
history: result.map((row) => ({ date: row.date.toISOString(), xp: row.xp }))
|
|
31403
|
+
};
|
|
31404
|
+
}
|
|
31405
|
+
async populateStudent(user, providedNames) {
|
|
31406
|
+
const client = this.requireClient();
|
|
31407
|
+
const db2 = this.deps.db;
|
|
31408
|
+
const dbUser = await db2.query.users.findFirst({
|
|
31409
|
+
where: eq(users.id, user.id),
|
|
31410
|
+
columns: { id: true, timebackId: true }
|
|
31411
|
+
});
|
|
31412
|
+
if (dbUser?.timebackId) {
|
|
31413
|
+
logger17.info("Student already onboarded", { userId: user.id });
|
|
31414
|
+
return { status: "already_populated" };
|
|
31415
|
+
}
|
|
31416
|
+
let timebackId;
|
|
31417
|
+
let name3;
|
|
31418
|
+
try {
|
|
31419
|
+
const existingUser = await client.oneroster.users.findByEmail(user.email);
|
|
31420
|
+
timebackId = existingUser.sourcedId;
|
|
31421
|
+
name3 = `${existingUser.givenName} ${existingUser.familyName}`;
|
|
31422
|
+
logger17.info("Found existing student in OneRoster", {
|
|
31423
|
+
userId: user.id,
|
|
31424
|
+
timebackId
|
|
31425
|
+
});
|
|
31426
|
+
} catch {
|
|
31427
|
+
if (!providedNames?.firstName || !providedNames?.lastName) {
|
|
31428
|
+
return { status: "no_record" };
|
|
31330
31429
|
}
|
|
31331
|
-
const
|
|
31332
|
-
|
|
31333
|
-
|
|
31334
|
-
|
|
31335
|
-
|
|
31336
|
-
|
|
31337
|
-
|
|
31430
|
+
const sourcedId = crypto.randomUUID();
|
|
31431
|
+
const response = await client.oneroster.users.create({
|
|
31432
|
+
sourcedId,
|
|
31433
|
+
status: "active",
|
|
31434
|
+
enabledUser: true,
|
|
31435
|
+
givenName: providedNames.firstName,
|
|
31436
|
+
familyName: providedNames.lastName,
|
|
31437
|
+
email: user.email,
|
|
31438
|
+
roles: [
|
|
31439
|
+
{
|
|
31440
|
+
roleType: "primary",
|
|
31441
|
+
role: "student",
|
|
31442
|
+
org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
|
|
31443
|
+
}
|
|
31444
|
+
]
|
|
31445
|
+
});
|
|
31446
|
+
if (!response.sourcedIdPairs?.allocatedSourcedId) {
|
|
31447
|
+
return { status: "error", message: "Timeback did not return allocatedSourcedId" };
|
|
31448
|
+
}
|
|
31449
|
+
timebackId = response.sourcedIdPairs.allocatedSourcedId;
|
|
31450
|
+
name3 = `${providedNames.firstName} ${providedNames.lastName}`;
|
|
31451
|
+
logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
|
|
31452
|
+
}
|
|
31453
|
+
const assessments = await this.fetchAssessments(timebackId);
|
|
31454
|
+
await db2.transaction(async (tx) => {
|
|
31455
|
+
if (assessments.length > 0) {
|
|
31456
|
+
const events = mapAssessmentsToXpEvents(user.id, assessments);
|
|
31457
|
+
for (const event of events) {
|
|
31458
|
+
try {
|
|
31459
|
+
await tx.insert(timebackXpEvents).values(event);
|
|
31460
|
+
} catch {}
|
|
31461
|
+
}
|
|
31462
|
+
const dailyMap = new Map;
|
|
31463
|
+
for (const a of assessments) {
|
|
31464
|
+
const xp = a.metadata?.xp;
|
|
31465
|
+
if (typeof xp === "number" && a.scoreDate) {
|
|
31466
|
+
const day = getUtcInstantForMidnight(new Date(a.scoreDate), PLATFORM_TIMEZONE);
|
|
31467
|
+
const key = day.toISOString();
|
|
31468
|
+
dailyMap.set(key, (dailyMap.get(key) || 0) + xp);
|
|
31469
|
+
}
|
|
31470
|
+
}
|
|
31471
|
+
if (dailyMap.size > 0) {
|
|
31472
|
+
const dailyRecords = [...dailyMap.entries()].map(([iso, xp]) => ({
|
|
31473
|
+
userId: user.id,
|
|
31474
|
+
date: new Date(iso),
|
|
31475
|
+
xp
|
|
31476
|
+
}));
|
|
31477
|
+
await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
|
|
31478
|
+
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31479
|
+
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31480
|
+
});
|
|
31338
31481
|
}
|
|
31339
31482
|
}
|
|
31340
|
-
|
|
31341
|
-
|
|
31483
|
+
const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
|
|
31484
|
+
if (!updated) {
|
|
31485
|
+
logger17.error("User Timeback ID update returned no rows", {
|
|
31342
31486
|
userId: user.id,
|
|
31343
|
-
|
|
31344
|
-
xp
|
|
31345
|
-
}));
|
|
31346
|
-
await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
|
|
31347
|
-
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31348
|
-
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31487
|
+
timebackId
|
|
31349
31488
|
});
|
|
31489
|
+
throw new InternalError("Failed to update user with Timeback ID");
|
|
31350
31490
|
}
|
|
31351
|
-
}
|
|
31352
|
-
const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
|
|
31353
|
-
if (!updated) {
|
|
31354
|
-
logger17.error("User Timeback ID update returned no rows", {
|
|
31355
|
-
userId: user.id,
|
|
31356
|
-
timebackId
|
|
31357
|
-
});
|
|
31358
|
-
throw new InternalError("Failed to update user with Timeback ID");
|
|
31359
|
-
}
|
|
31360
|
-
});
|
|
31361
|
-
return { status: "ok" };
|
|
31362
|
-
}
|
|
31363
|
-
async fetchAssessments(studentSourcedId) {
|
|
31364
|
-
const client = this.requireClient();
|
|
31365
|
-
const allAssessments = [];
|
|
31366
|
-
const limit = 3000;
|
|
31367
|
-
const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
|
|
31368
|
-
let offset = 0;
|
|
31369
|
-
try {
|
|
31370
|
-
while (true) {
|
|
31371
|
-
const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
|
|
31372
|
-
allAssessments.push(...results);
|
|
31373
|
-
if (results.length < limit) {
|
|
31374
|
-
break;
|
|
31375
|
-
}
|
|
31376
|
-
offset += limit;
|
|
31377
|
-
}
|
|
31378
|
-
logger17.debug("Fetched assessments", {
|
|
31379
|
-
studentSourcedId,
|
|
31380
|
-
totalCount: allAssessments.length
|
|
31381
31491
|
});
|
|
31382
|
-
return
|
|
31383
|
-
} catch (error) {
|
|
31384
|
-
logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
|
|
31385
|
-
return [];
|
|
31492
|
+
return { status: "ok" };
|
|
31386
31493
|
}
|
|
31387
|
-
|
|
31388
|
-
|
|
31389
|
-
|
|
31390
|
-
|
|
31391
|
-
|
|
31392
|
-
|
|
31393
|
-
|
|
31394
|
-
|
|
31395
|
-
|
|
31396
|
-
|
|
31397
|
-
|
|
31398
|
-
|
|
31399
|
-
|
|
31400
|
-
|
|
31401
|
-
|
|
31402
|
-
|
|
31403
|
-
|
|
31404
|
-
|
|
31405
|
-
}
|
|
31406
|
-
async getUserDataByTimebackId(timebackId) {
|
|
31407
|
-
const [profile, enrollments] = await Promise.all([
|
|
31408
|
-
this.fetchStudentProfile(timebackId),
|
|
31409
|
-
this.fetchEnrollments(timebackId)
|
|
31410
|
-
]);
|
|
31411
|
-
return {
|
|
31412
|
-
id: timebackId,
|
|
31413
|
-
role: profile.role,
|
|
31414
|
-
enrollments,
|
|
31415
|
-
organizations: profile.organizations
|
|
31416
|
-
};
|
|
31417
|
-
}
|
|
31418
|
-
async fetchStudentProfile(timebackId) {
|
|
31419
|
-
const client = this.requireClient();
|
|
31420
|
-
try {
|
|
31421
|
-
const user = await client.oneroster.users.get(timebackId);
|
|
31422
|
-
const primaryRole = user.roles.find((r) => r.roleType === "primary");
|
|
31423
|
-
const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
|
|
31424
|
-
const orgMap = new Map;
|
|
31425
|
-
if (user.primaryOrg) {
|
|
31426
|
-
orgMap.set(user.primaryOrg.sourcedId, {
|
|
31427
|
-
id: user.primaryOrg.sourcedId,
|
|
31428
|
-
name: user.primaryOrg.name ?? null,
|
|
31429
|
-
type: user.primaryOrg.type || "school",
|
|
31430
|
-
isPrimary: true
|
|
31494
|
+
async fetchAssessments(studentSourcedId) {
|
|
31495
|
+
const client = this.requireClient();
|
|
31496
|
+
const allAssessments = [];
|
|
31497
|
+
const limit = 3000;
|
|
31498
|
+
const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
|
|
31499
|
+
let offset = 0;
|
|
31500
|
+
try {
|
|
31501
|
+
while (true) {
|
|
31502
|
+
const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
|
|
31503
|
+
allAssessments.push(...results);
|
|
31504
|
+
if (results.length < limit) {
|
|
31505
|
+
break;
|
|
31506
|
+
}
|
|
31507
|
+
offset += limit;
|
|
31508
|
+
}
|
|
31509
|
+
logger17.debug("Fetched assessments", {
|
|
31510
|
+
studentSourcedId,
|
|
31511
|
+
totalCount: allAssessments.length
|
|
31431
31512
|
});
|
|
31513
|
+
return allAssessments;
|
|
31514
|
+
} catch (error) {
|
|
31515
|
+
logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
|
|
31516
|
+
return [];
|
|
31432
31517
|
}
|
|
31433
|
-
|
|
31434
|
-
|
|
31435
|
-
|
|
31436
|
-
|
|
31437
|
-
|
|
31438
|
-
|
|
31439
|
-
|
|
31518
|
+
}
|
|
31519
|
+
async getUserData(userId, gameId) {
|
|
31520
|
+
const db2 = this.deps.db;
|
|
31521
|
+
const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
|
|
31522
|
+
if (!userData) {
|
|
31523
|
+
throw new NotFoundError("User", userId);
|
|
31524
|
+
}
|
|
31525
|
+
if (!userData.timebackId) {
|
|
31526
|
+
throw new NotFoundError("Timeback account not found for user");
|
|
31527
|
+
}
|
|
31528
|
+
const [profile, allEnrollments] = await Promise.all([
|
|
31529
|
+
this.fetchStudentProfile(userData.timebackId),
|
|
31530
|
+
this.fetchEnrollments(userData.timebackId)
|
|
31531
|
+
]);
|
|
31532
|
+
const enrollments = gameId ? allEnrollments.filter((e) => e.gameId === gameId) : allEnrollments;
|
|
31533
|
+
const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
|
|
31534
|
+
const organizations = gameId && enrollmentOrgIds.size > 0 ? profile.organizations.filter((o) => enrollmentOrgIds.has(o.id)) : profile.organizations;
|
|
31535
|
+
return { id: userData.timebackId, role: profile.role, enrollments, organizations };
|
|
31536
|
+
}
|
|
31537
|
+
async getUserDataByTimebackId(timebackId) {
|
|
31538
|
+
const [profile, enrollments] = await Promise.all([
|
|
31539
|
+
this.fetchStudentProfile(timebackId),
|
|
31540
|
+
this.fetchEnrollments(timebackId)
|
|
31541
|
+
]);
|
|
31542
|
+
return {
|
|
31543
|
+
id: timebackId,
|
|
31544
|
+
role: profile.role,
|
|
31545
|
+
enrollments,
|
|
31546
|
+
organizations: profile.organizations
|
|
31547
|
+
};
|
|
31548
|
+
}
|
|
31549
|
+
async fetchStudentProfile(timebackId) {
|
|
31550
|
+
const client = this.requireClient();
|
|
31551
|
+
try {
|
|
31552
|
+
const user = await client.oneroster.users.get(timebackId);
|
|
31553
|
+
const primaryRole = user.roles.find((r) => r.roleType === "primary");
|
|
31554
|
+
const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
|
|
31555
|
+
const orgMap = new Map;
|
|
31556
|
+
if (user.primaryOrg) {
|
|
31557
|
+
orgMap.set(user.primaryOrg.sourcedId, {
|
|
31558
|
+
id: user.primaryOrg.sourcedId,
|
|
31559
|
+
name: user.primaryOrg.name ?? null,
|
|
31560
|
+
type: user.primaryOrg.type || "school",
|
|
31561
|
+
isPrimary: true
|
|
31440
31562
|
});
|
|
31441
31563
|
}
|
|
31564
|
+
for (const r of user.roles) {
|
|
31565
|
+
if (r.org && !orgMap.has(r.org.sourcedId)) {
|
|
31566
|
+
orgMap.set(r.org.sourcedId, {
|
|
31567
|
+
id: r.org.sourcedId,
|
|
31568
|
+
name: null,
|
|
31569
|
+
type: "school",
|
|
31570
|
+
isPrimary: false
|
|
31571
|
+
});
|
|
31572
|
+
}
|
|
31573
|
+
}
|
|
31574
|
+
return { role, organizations: [...orgMap.values()] };
|
|
31575
|
+
} catch {
|
|
31576
|
+
return { role: "student", organizations: [] };
|
|
31442
31577
|
}
|
|
31443
|
-
return { role, organizations: [...orgMap.values()] };
|
|
31444
|
-
} catch {
|
|
31445
|
-
return { role: "student", organizations: [] };
|
|
31446
31578
|
}
|
|
31447
|
-
|
|
31448
|
-
|
|
31449
|
-
|
|
31450
|
-
|
|
31451
|
-
|
|
31452
|
-
|
|
31453
|
-
|
|
31454
|
-
|
|
31579
|
+
async fetchEnrollments(timebackId) {
|
|
31580
|
+
const client = this.requireClient();
|
|
31581
|
+
const db2 = this.deps.db;
|
|
31582
|
+
try {
|
|
31583
|
+
const enrollments = await client.getEnrollments(timebackId);
|
|
31584
|
+
const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
|
|
31585
|
+
if (courseIds.length === 0) {
|
|
31586
|
+
return [];
|
|
31587
|
+
}
|
|
31588
|
+
const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
|
|
31589
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31590
|
+
where: inArray(gameTimebackIntegrations.courseId, courseIds)
|
|
31591
|
+
});
|
|
31592
|
+
return integrations.map((i2) => ({
|
|
31593
|
+
gameId: i2.gameId,
|
|
31594
|
+
grade: i2.grade,
|
|
31595
|
+
subject: i2.subject,
|
|
31596
|
+
courseId: i2.courseId,
|
|
31597
|
+
orgId: courseToSchool.get(i2.courseId)
|
|
31598
|
+
}));
|
|
31599
|
+
} catch {
|
|
31455
31600
|
return [];
|
|
31456
31601
|
}
|
|
31457
|
-
const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
|
|
31458
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31459
|
-
where: inArray(gameTimebackIntegrations.courseId, courseIds)
|
|
31460
|
-
});
|
|
31461
|
-
return integrations.map((i2) => ({
|
|
31462
|
-
gameId: i2.gameId,
|
|
31463
|
-
grade: i2.grade,
|
|
31464
|
-
subject: i2.subject,
|
|
31465
|
-
courseId: i2.courseId,
|
|
31466
|
-
orgId: courseToSchool.get(i2.courseId)
|
|
31467
|
-
}));
|
|
31468
|
-
} catch {
|
|
31469
|
-
return [];
|
|
31470
31602
|
}
|
|
31471
|
-
|
|
31472
|
-
|
|
31473
|
-
|
|
31474
|
-
|
|
31475
|
-
|
|
31476
|
-
|
|
31477
|
-
|
|
31478
|
-
|
|
31479
|
-
|
|
31480
|
-
|
|
31481
|
-
|
|
31482
|
-
|
|
31483
|
-
|
|
31484
|
-
|
|
31485
|
-
|
|
31486
|
-
const {
|
|
31487
|
-
subject: subjectInput,
|
|
31488
|
-
grade,
|
|
31489
|
-
title,
|
|
31490
|
-
courseCode,
|
|
31491
|
-
level,
|
|
31492
|
-
metadata: metadata2,
|
|
31493
|
-
totalXp: derivedTotalXp,
|
|
31494
|
-
masterableUnits: derivedMasterableUnits
|
|
31495
|
-
} = courseConfig;
|
|
31496
|
-
if (!isTimebackSubject(subjectInput)) {
|
|
31497
|
-
logger17.warn("Invalid Timeback subject in course config", {
|
|
31603
|
+
async setupIntegration(gameId, request, user) {
|
|
31604
|
+
const client = this.requireClient();
|
|
31605
|
+
const db2 = this.deps.db;
|
|
31606
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31607
|
+
const { courses, baseConfig, verbose } = request;
|
|
31608
|
+
const existing = await db2.query.gameTimebackIntegrations.findMany({
|
|
31609
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31610
|
+
});
|
|
31611
|
+
const integrations = [];
|
|
31612
|
+
const verboseData = [];
|
|
31613
|
+
for (const courseConfig of courses) {
|
|
31614
|
+
let applySuffix = function(text3) {
|
|
31615
|
+
return suffix ? `${text3} ${suffix}` : text3;
|
|
31616
|
+
};
|
|
31617
|
+
const {
|
|
31498
31618
|
subject: subjectInput,
|
|
31499
|
-
courseCode,
|
|
31500
|
-
title
|
|
31501
|
-
});
|
|
31502
|
-
throw new ValidationError(`Invalid subject "${subjectInput}"`);
|
|
31503
|
-
}
|
|
31504
|
-
if (!isTimebackGrade(grade)) {
|
|
31505
|
-
logger17.warn("Invalid Timeback grade in course config", {
|
|
31506
31619
|
grade,
|
|
31507
|
-
courseCode,
|
|
31508
|
-
title
|
|
31509
|
-
});
|
|
31510
|
-
throw new ValidationError(`Invalid grade "${grade}"`);
|
|
31511
|
-
}
|
|
31512
|
-
const subject = subjectInput;
|
|
31513
|
-
const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
|
|
31514
|
-
const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
|
|
31515
|
-
const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
|
|
31516
|
-
if (typeof totalXp !== "number") {
|
|
31517
|
-
logger17.warn("Course missing totalXp in Timeback config", {
|
|
31518
|
-
courseCode,
|
|
31519
|
-
title
|
|
31520
|
-
});
|
|
31521
|
-
throw new ValidationError(`Course "${title}" is missing totalXp`);
|
|
31522
|
-
}
|
|
31523
|
-
const suffix = baseConfig.component.titleSuffix || "";
|
|
31524
|
-
const fullConfig = {
|
|
31525
|
-
organization: baseConfig.organization,
|
|
31526
|
-
course: {
|
|
31527
31620
|
title,
|
|
31528
|
-
subjects: [subject],
|
|
31529
|
-
grades: [grade],
|
|
31530
31621
|
courseCode,
|
|
31531
31622
|
level,
|
|
31532
|
-
|
|
31533
|
-
|
|
31534
|
-
|
|
31535
|
-
|
|
31536
|
-
|
|
31537
|
-
|
|
31538
|
-
|
|
31539
|
-
|
|
31540
|
-
|
|
31541
|
-
|
|
31542
|
-
|
|
31543
|
-
baseMetadata: baseConfig.resource.metadata,
|
|
31544
|
-
subject,
|
|
31545
|
-
grade,
|
|
31546
|
-
totalXp,
|
|
31547
|
-
masterableUnits
|
|
31548
|
-
})
|
|
31549
|
-
},
|
|
31550
|
-
componentResource: {
|
|
31551
|
-
...baseConfig.componentResource,
|
|
31552
|
-
title: applySuffix(baseConfig.componentResource.title || "")
|
|
31553
|
-
}
|
|
31554
|
-
};
|
|
31555
|
-
const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
|
|
31556
|
-
if (existingIntegration) {
|
|
31557
|
-
await client.update(existingIntegration.courseId, fullConfig);
|
|
31558
|
-
const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
|
|
31559
|
-
if (updated) {
|
|
31560
|
-
integrations.push(this.toGameTimebackIntegration(updated));
|
|
31623
|
+
metadata: metadata2,
|
|
31624
|
+
totalXp: derivedTotalXp,
|
|
31625
|
+
masterableUnits: derivedMasterableUnits
|
|
31626
|
+
} = courseConfig;
|
|
31627
|
+
if (!isTimebackSubject(subjectInput)) {
|
|
31628
|
+
logger17.warn("Invalid Timeback subject in course config", {
|
|
31629
|
+
subject: subjectInput,
|
|
31630
|
+
courseCode,
|
|
31631
|
+
title
|
|
31632
|
+
});
|
|
31633
|
+
throw new ValidationError(`Invalid subject "${subjectInput}"`);
|
|
31561
31634
|
}
|
|
31562
|
-
|
|
31563
|
-
|
|
31564
|
-
|
|
31565
|
-
|
|
31566
|
-
|
|
31567
|
-
|
|
31568
|
-
|
|
31569
|
-
|
|
31635
|
+
if (!isTimebackGrade(grade)) {
|
|
31636
|
+
logger17.warn("Invalid Timeback grade in course config", {
|
|
31637
|
+
grade,
|
|
31638
|
+
courseCode,
|
|
31639
|
+
title
|
|
31640
|
+
});
|
|
31641
|
+
throw new ValidationError(`Invalid grade "${grade}"`);
|
|
31642
|
+
}
|
|
31643
|
+
const subject = subjectInput;
|
|
31644
|
+
const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
|
|
31645
|
+
const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
|
|
31646
|
+
const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
|
|
31647
|
+
if (typeof totalXp !== "number") {
|
|
31648
|
+
logger17.warn("Course missing totalXp in Timeback config", {
|
|
31649
|
+
courseCode,
|
|
31650
|
+
title
|
|
31651
|
+
});
|
|
31652
|
+
throw new ValidationError(`Course "${title}" is missing totalXp`);
|
|
31653
|
+
}
|
|
31654
|
+
const suffix = baseConfig.component.titleSuffix || "";
|
|
31655
|
+
const fullConfig = {
|
|
31656
|
+
organization: baseConfig.organization,
|
|
31657
|
+
course: {
|
|
31658
|
+
title,
|
|
31659
|
+
subjects: [subject],
|
|
31660
|
+
grades: [grade],
|
|
31661
|
+
courseCode,
|
|
31662
|
+
level,
|
|
31663
|
+
gradingScheme: "STANDARD",
|
|
31664
|
+
metadata: metadata2
|
|
31665
|
+
},
|
|
31666
|
+
component: {
|
|
31667
|
+
...baseConfig.component,
|
|
31668
|
+
title: applySuffix(baseConfig.component.title || `${title} Activities`)
|
|
31669
|
+
},
|
|
31670
|
+
resource: {
|
|
31671
|
+
...baseConfig.resource,
|
|
31672
|
+
title: applySuffix(baseConfig.resource.title || `${title} Game`),
|
|
31673
|
+
metadata: buildResourceMetadata({
|
|
31674
|
+
baseMetadata: baseConfig.resource.metadata,
|
|
31675
|
+
subject,
|
|
31676
|
+
grade,
|
|
31677
|
+
totalXp,
|
|
31678
|
+
masterableUnits
|
|
31679
|
+
})
|
|
31680
|
+
},
|
|
31681
|
+
componentResource: {
|
|
31682
|
+
...baseConfig.componentResource,
|
|
31683
|
+
title: applySuffix(baseConfig.componentResource.title || "")
|
|
31684
|
+
}
|
|
31685
|
+
};
|
|
31686
|
+
const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
|
|
31687
|
+
if (existingIntegration) {
|
|
31688
|
+
await client.update(existingIntegration.courseId, fullConfig);
|
|
31689
|
+
const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
|
|
31690
|
+
if (updated) {
|
|
31691
|
+
integrations.push(this.toGameTimebackIntegration(updated));
|
|
31692
|
+
}
|
|
31693
|
+
} else {
|
|
31694
|
+
const result = await client.setup(fullConfig, { verbose });
|
|
31695
|
+
const [integration] = await db2.insert(gameTimebackIntegrations).values({ gameId, courseId: result.courseId, grade, subject, totalXp }).returning();
|
|
31696
|
+
if (integration) {
|
|
31697
|
+
const dto = this.toGameTimebackIntegration(integration);
|
|
31698
|
+
integrations.push(dto);
|
|
31699
|
+
if (verbose && result.verboseData) {
|
|
31700
|
+
verboseData.push({ integration: dto, config: result.verboseData });
|
|
31701
|
+
}
|
|
31570
31702
|
}
|
|
31571
31703
|
}
|
|
31572
31704
|
}
|
|
31705
|
+
return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
|
|
31573
31706
|
}
|
|
31574
|
-
|
|
31575
|
-
|
|
31576
|
-
|
|
31577
|
-
|
|
31578
|
-
|
|
31579
|
-
|
|
31580
|
-
});
|
|
31581
|
-
return rows.map((row) => this.toGameTimebackIntegration(row));
|
|
31582
|
-
}
|
|
31583
|
-
async verifyIntegration(gameId, user) {
|
|
31584
|
-
const client = this.requireClient();
|
|
31585
|
-
const db2 = this.deps.db;
|
|
31586
|
-
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31587
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31588
|
-
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31589
|
-
});
|
|
31590
|
-
if (integrations.length === 0) {
|
|
31591
|
-
throw new NotFoundError("Timeback integration", gameId);
|
|
31707
|
+
async getIntegrations(gameId, user) {
|
|
31708
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
31709
|
+
const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
|
|
31710
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31711
|
+
});
|
|
31712
|
+
return rows.map((row) => this.toGameTimebackIntegration(row));
|
|
31592
31713
|
}
|
|
31593
|
-
|
|
31594
|
-
|
|
31595
|
-
const
|
|
31596
|
-
|
|
31597
|
-
const
|
|
31598
|
-
|
|
31599
|
-
|
|
31600
|
-
|
|
31601
|
-
integration
|
|
31602
|
-
|
|
31603
|
-
|
|
31604
|
-
|
|
31605
|
-
resources
|
|
31606
|
-
|
|
31607
|
-
|
|
31608
|
-
|
|
31609
|
-
|
|
31610
|
-
|
|
31611
|
-
|
|
31612
|
-
|
|
31613
|
-
|
|
31614
|
-
|
|
31615
|
-
|
|
31616
|
-
|
|
31617
|
-
|
|
31618
|
-
|
|
31619
|
-
|
|
31620
|
-
|
|
31621
|
-
|
|
31714
|
+
async verifyIntegration(gameId, user) {
|
|
31715
|
+
const client = this.requireClient();
|
|
31716
|
+
const db2 = this.deps.db;
|
|
31717
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31718
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31719
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31720
|
+
});
|
|
31721
|
+
if (integrations.length === 0) {
|
|
31722
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31723
|
+
}
|
|
31724
|
+
const now2 = new Date;
|
|
31725
|
+
const results = await Promise.all(integrations.map(async (integration) => {
|
|
31726
|
+
const resources = await client.verify(integration.courseId);
|
|
31727
|
+
const resourceValues = Object.values(resources);
|
|
31728
|
+
const allFound = resourceValues.every((r) => r.found);
|
|
31729
|
+
const errors3 = Object.entries(resources).filter(([_, r]) => !r.found).map(([name3]) => `${name3} not found`);
|
|
31730
|
+
const status = allFound ? "success" : "error";
|
|
31731
|
+
return {
|
|
31732
|
+
integration: this.toGameTimebackIntegration({
|
|
31733
|
+
...integration,
|
|
31734
|
+
lastVerifiedAt: now2
|
|
31735
|
+
}),
|
|
31736
|
+
resources,
|
|
31737
|
+
status,
|
|
31738
|
+
...errors3.length > 0 && { errors: errors3 }
|
|
31739
|
+
};
|
|
31740
|
+
}));
|
|
31741
|
+
await db2.update(gameTimebackIntegrations).set({ lastVerifiedAt: now2 }).where(eq(gameTimebackIntegrations.gameId, gameId));
|
|
31742
|
+
const overallStatus = results.every((r) => r.status === "success") ? "success" : "error";
|
|
31743
|
+
return { status: overallStatus, results };
|
|
31744
|
+
}
|
|
31745
|
+
async getConfig(gameId, user) {
|
|
31746
|
+
const client = this.requireClient();
|
|
31747
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31748
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31749
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31750
|
+
});
|
|
31751
|
+
if (!integration) {
|
|
31752
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31753
|
+
}
|
|
31754
|
+
return client.getConfig(integration.courseId);
|
|
31622
31755
|
}
|
|
31623
|
-
|
|
31624
|
-
|
|
31625
|
-
|
|
31626
|
-
|
|
31627
|
-
|
|
31628
|
-
|
|
31629
|
-
|
|
31630
|
-
|
|
31631
|
-
|
|
31632
|
-
|
|
31633
|
-
|
|
31756
|
+
async deleteIntegrations(gameId, user) {
|
|
31757
|
+
const client = this.requireClient();
|
|
31758
|
+
const db2 = this.deps.db;
|
|
31759
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31760
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31761
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31762
|
+
});
|
|
31763
|
+
if (integrations.length === 0) {
|
|
31764
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31765
|
+
}
|
|
31766
|
+
for (const integration of integrations) {
|
|
31767
|
+
await client.cleanup(integration.courseId);
|
|
31768
|
+
}
|
|
31769
|
+
await db2.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
|
|
31634
31770
|
}
|
|
31635
|
-
|
|
31636
|
-
|
|
31771
|
+
toGameTimebackIntegration(integration) {
|
|
31772
|
+
return {
|
|
31773
|
+
id: integration.id,
|
|
31774
|
+
gameId: integration.gameId,
|
|
31775
|
+
courseId: integration.courseId,
|
|
31776
|
+
grade: integration.grade,
|
|
31777
|
+
subject: integration.subject,
|
|
31778
|
+
totalXp: integration.totalXp ?? null,
|
|
31779
|
+
createdAt: integration.createdAt,
|
|
31780
|
+
updatedAt: integration.updatedAt,
|
|
31781
|
+
lastVerifiedAt: integration.lastVerifiedAt ?? null
|
|
31782
|
+
};
|
|
31637
31783
|
}
|
|
31638
|
-
|
|
31639
|
-
|
|
31640
|
-
|
|
31641
|
-
|
|
31642
|
-
|
|
31643
|
-
|
|
31644
|
-
|
|
31645
|
-
|
|
31646
|
-
subject: integration.subject,
|
|
31647
|
-
totalXp: integration.totalXp ?? null,
|
|
31648
|
-
createdAt: integration.createdAt,
|
|
31649
|
-
updatedAt: integration.updatedAt,
|
|
31650
|
-
lastVerifiedAt: integration.lastVerifiedAt ?? null
|
|
31651
|
-
};
|
|
31652
|
-
}
|
|
31653
|
-
async endActivity({
|
|
31654
|
-
gameId,
|
|
31655
|
-
studentId,
|
|
31656
|
-
activityData,
|
|
31657
|
-
scoreData,
|
|
31658
|
-
timingData,
|
|
31659
|
-
xpEarned,
|
|
31660
|
-
masteredUnits,
|
|
31661
|
-
extensions,
|
|
31662
|
-
user
|
|
31663
|
-
}) {
|
|
31664
|
-
const client = this.requireClient();
|
|
31665
|
-
const db2 = this.deps.db;
|
|
31666
|
-
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31667
|
-
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31668
|
-
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31669
|
-
});
|
|
31670
|
-
if (!integration) {
|
|
31671
|
-
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31672
|
-
}
|
|
31673
|
-
const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
|
|
31674
|
-
const result = await client.recordProgress(integration.courseId, studentId, {
|
|
31675
|
-
score: scorePercentage,
|
|
31676
|
-
totalQuestions: scoreData.totalQuestions,
|
|
31677
|
-
correctQuestions: scoreData.correctQuestions,
|
|
31678
|
-
durationSeconds: timingData.durationSeconds,
|
|
31784
|
+
async endActivity({
|
|
31785
|
+
gameId,
|
|
31786
|
+
studentId,
|
|
31787
|
+
runId,
|
|
31788
|
+
activityData,
|
|
31789
|
+
scoreData,
|
|
31790
|
+
timingData,
|
|
31791
|
+
sessionTimingData,
|
|
31679
31792
|
xpEarned,
|
|
31680
31793
|
masteredUnits,
|
|
31681
31794
|
extensions,
|
|
31682
|
-
|
|
31683
|
-
|
|
31684
|
-
|
|
31685
|
-
|
|
31686
|
-
|
|
31687
|
-
|
|
31688
|
-
|
|
31689
|
-
|
|
31690
|
-
|
|
31691
|
-
|
|
31692
|
-
|
|
31693
|
-
|
|
31694
|
-
|
|
31695
|
-
|
|
31696
|
-
|
|
31697
|
-
|
|
31698
|
-
|
|
31699
|
-
|
|
31700
|
-
|
|
31701
|
-
|
|
31702
|
-
|
|
31703
|
-
|
|
31795
|
+
user
|
|
31796
|
+
}) {
|
|
31797
|
+
const client = this.requireClient();
|
|
31798
|
+
const db2 = this.deps.db;
|
|
31799
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31800
|
+
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31801
|
+
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31802
|
+
});
|
|
31803
|
+
if (!integration) {
|
|
31804
|
+
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31805
|
+
}
|
|
31806
|
+
const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
|
|
31807
|
+
const result = await client.recordProgress(integration.courseId, studentId, {
|
|
31808
|
+
score: scorePercentage,
|
|
31809
|
+
totalQuestions: scoreData.totalQuestions,
|
|
31810
|
+
correctQuestions: scoreData.correctQuestions,
|
|
31811
|
+
durationSeconds: timingData.durationSeconds,
|
|
31812
|
+
xpEarned,
|
|
31813
|
+
masteredUnits,
|
|
31814
|
+
extensions,
|
|
31815
|
+
activityId: activityData.activityId,
|
|
31816
|
+
activityName: activityData.activityName,
|
|
31817
|
+
subject: activityData.subject,
|
|
31818
|
+
appName: activityData.appName,
|
|
31819
|
+
sensorUrl: activityData.sensorUrl,
|
|
31820
|
+
courseId: activityData.courseId,
|
|
31821
|
+
courseName: activityData.courseName,
|
|
31822
|
+
studentEmail: activityData.studentEmail,
|
|
31823
|
+
courseTotalXp: integration.totalXp,
|
|
31824
|
+
...runId ? { runId } : {}
|
|
31825
|
+
});
|
|
31826
|
+
const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
|
|
31827
|
+
const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
|
|
31828
|
+
if (sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0) {
|
|
31829
|
+
await client.recordSessionEnd(integration.courseId, studentId, {
|
|
31830
|
+
activeTimeSeconds: sessionEndActiveSeconds,
|
|
31831
|
+
...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
|
|
31832
|
+
activityId: activityData.activityId,
|
|
31833
|
+
activityName: activityData.activityName,
|
|
31834
|
+
subject: activityData.subject,
|
|
31835
|
+
appName: activityData.appName,
|
|
31836
|
+
sensorUrl: activityData.sensorUrl,
|
|
31837
|
+
courseId: activityData.courseId,
|
|
31838
|
+
courseName: activityData.courseName,
|
|
31839
|
+
studentEmail: activityData.studentEmail,
|
|
31840
|
+
...runId ? { runId } : {}
|
|
31841
|
+
});
|
|
31842
|
+
}
|
|
31843
|
+
logger17.info("Recorded activity completion", {
|
|
31844
|
+
gameId,
|
|
31845
|
+
courseId: integration.courseId,
|
|
31846
|
+
studentId,
|
|
31847
|
+
runId,
|
|
31848
|
+
score: scorePercentage
|
|
31849
|
+
});
|
|
31850
|
+
return {
|
|
31851
|
+
status: "ok",
|
|
31852
|
+
courseId: integration.courseId,
|
|
31853
|
+
xpAwarded: result.xpAwarded,
|
|
31854
|
+
masteredUnits: result.masteredUnitsApplied,
|
|
31855
|
+
pctCompleteApp: result.pctCompleteApp,
|
|
31856
|
+
scoreStatus: result.scoreStatus,
|
|
31857
|
+
inProgress: result.inProgress
|
|
31858
|
+
};
|
|
31859
|
+
}
|
|
31860
|
+
async recordHeartbeat({
|
|
31704
31861
|
gameId,
|
|
31705
|
-
courseId: integration.courseId,
|
|
31706
31862
|
studentId,
|
|
31707
|
-
|
|
31708
|
-
|
|
31709
|
-
|
|
31710
|
-
|
|
31711
|
-
|
|
31712
|
-
|
|
31713
|
-
|
|
31714
|
-
|
|
31715
|
-
|
|
31716
|
-
|
|
31717
|
-
|
|
31718
|
-
|
|
31719
|
-
|
|
31720
|
-
|
|
31721
|
-
|
|
31722
|
-
|
|
31723
|
-
|
|
31724
|
-
|
|
31725
|
-
|
|
31726
|
-
if (options.grade !== undefined && options.subject) {
|
|
31727
|
-
conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
|
|
31728
|
-
conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
|
|
31863
|
+
runId,
|
|
31864
|
+
activityData,
|
|
31865
|
+
timingData,
|
|
31866
|
+
windowSequence,
|
|
31867
|
+
isFinal,
|
|
31868
|
+
user
|
|
31869
|
+
}) {
|
|
31870
|
+
const client = this.requireClient();
|
|
31871
|
+
const db2 = this.deps.db;
|
|
31872
|
+
const heartbeatWindowKey = `${runId}:${windowSequence}`;
|
|
31873
|
+
if (TimebackService.isDuplicateHeartbeatWindow(heartbeatWindowKey)) {
|
|
31874
|
+
logger17.debug("Skipping duplicate heartbeat window", {
|
|
31875
|
+
gameId,
|
|
31876
|
+
studentId,
|
|
31877
|
+
runId,
|
|
31878
|
+
windowSequence,
|
|
31879
|
+
isFinal
|
|
31880
|
+
});
|
|
31881
|
+
return { status: "ok" };
|
|
31729
31882
|
}
|
|
31730
|
-
|
|
31731
|
-
|
|
31732
|
-
|
|
31733
|
-
|
|
31734
|
-
|
|
31735
|
-
|
|
31736
|
-
|
|
31737
|
-
|
|
31738
|
-
|
|
31739
|
-
subject: options.subject
|
|
31883
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31884
|
+
const inFlightHeartbeat = TimebackService.getInFlightHeartbeatWindow(heartbeatWindowKey);
|
|
31885
|
+
if (inFlightHeartbeat) {
|
|
31886
|
+
logger17.debug("Joining in-flight heartbeat window", {
|
|
31887
|
+
gameId,
|
|
31888
|
+
studentId,
|
|
31889
|
+
runId,
|
|
31890
|
+
windowSequence,
|
|
31891
|
+
isFinal
|
|
31740
31892
|
});
|
|
31741
|
-
return
|
|
31742
|
-
|
|
31743
|
-
|
|
31744
|
-
|
|
31745
|
-
|
|
31893
|
+
return inFlightHeartbeat;
|
|
31894
|
+
}
|
|
31895
|
+
const pendingHeartbeat = (async () => {
|
|
31896
|
+
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31897
|
+
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31898
|
+
});
|
|
31899
|
+
if (!integration) {
|
|
31900
|
+
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31901
|
+
}
|
|
31902
|
+
const activeTimeSeconds = timingData.activeMs / 1000;
|
|
31903
|
+
const inactiveTimeSeconds = timingData.pausedMs / 1000;
|
|
31904
|
+
if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
|
|
31905
|
+
await client.recordSessionEnd(integration.courseId, studentId, {
|
|
31906
|
+
activeTimeSeconds,
|
|
31907
|
+
...inactiveTimeSeconds > 0 ? { inactiveTimeSeconds } : {},
|
|
31908
|
+
activityId: activityData.activityId,
|
|
31909
|
+
activityName: activityData.activityName,
|
|
31910
|
+
subject: activityData.subject,
|
|
31911
|
+
appName: activityData.appName,
|
|
31912
|
+
sensorUrl: activityData.sensorUrl,
|
|
31913
|
+
courseId: activityData.courseId,
|
|
31914
|
+
courseName: activityData.courseName,
|
|
31915
|
+
studentEmail: activityData.studentEmail,
|
|
31916
|
+
...runId ? { runId } : {}
|
|
31917
|
+
});
|
|
31918
|
+
}
|
|
31919
|
+
TimebackService.markHeartbeatWindowProcessed(heartbeatWindowKey);
|
|
31920
|
+
logger17.debug("Recorded heartbeat", {
|
|
31921
|
+
gameId,
|
|
31922
|
+
courseId: integration.courseId,
|
|
31923
|
+
studentId,
|
|
31924
|
+
runId,
|
|
31925
|
+
windowSequence,
|
|
31926
|
+
activeTimeSeconds,
|
|
31927
|
+
isFinal
|
|
31928
|
+
});
|
|
31929
|
+
return { status: "ok" };
|
|
31930
|
+
})();
|
|
31931
|
+
TimebackService.markHeartbeatWindowInFlight(heartbeatWindowKey, pendingHeartbeat);
|
|
31932
|
+
try {
|
|
31933
|
+
return await pendingHeartbeat;
|
|
31934
|
+
} finally {
|
|
31935
|
+
TimebackService.clearInFlightHeartbeatWindow(heartbeatWindowKey);
|
|
31746
31936
|
}
|
|
31747
31937
|
}
|
|
31748
|
-
|
|
31749
|
-
|
|
31750
|
-
|
|
31751
|
-
|
|
31752
|
-
|
|
31753
|
-
|
|
31754
|
-
|
|
31755
|
-
|
|
31756
|
-
|
|
31757
|
-
|
|
31758
|
-
|
|
31759
|
-
|
|
31760
|
-
|
|
31761
|
-
|
|
31762
|
-
|
|
31763
|
-
|
|
31764
|
-
|
|
31765
|
-
|
|
31766
|
-
|
|
31767
|
-
|
|
31768
|
-
|
|
31769
|
-
|
|
31770
|
-
|
|
31771
|
-
|
|
31772
|
-
|
|
31773
|
-
|
|
31938
|
+
async getStudentXp(timebackId, user, options) {
|
|
31939
|
+
const client = this.requireClient();
|
|
31940
|
+
const db2 = this.deps.db;
|
|
31941
|
+
let courseIds = [];
|
|
31942
|
+
if (options?.gameId) {
|
|
31943
|
+
await this.deps.validateDeveloperAccess(user, options.gameId);
|
|
31944
|
+
const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
|
|
31945
|
+
if (options.grade !== undefined && options.subject) {
|
|
31946
|
+
conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
|
|
31947
|
+
conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
|
|
31948
|
+
}
|
|
31949
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31950
|
+
where: and(...conditions2)
|
|
31951
|
+
});
|
|
31952
|
+
courseIds = integrations.map((i2) => i2.courseId);
|
|
31953
|
+
if (courseIds.length === 0) {
|
|
31954
|
+
logger17.debug("No integrations found for game, returning 0 XP", {
|
|
31955
|
+
timebackId,
|
|
31956
|
+
gameId: options.gameId,
|
|
31957
|
+
grade: options.grade,
|
|
31958
|
+
subject: options.subject
|
|
31959
|
+
});
|
|
31960
|
+
return {
|
|
31961
|
+
totalXp: 0,
|
|
31962
|
+
...options?.include?.today && { todayXp: 0 },
|
|
31963
|
+
...options?.include?.perCourse && { courses: [] }
|
|
31964
|
+
};
|
|
31965
|
+
}
|
|
31966
|
+
}
|
|
31967
|
+
const result = await client.getStudentXp(timebackId, {
|
|
31968
|
+
courseIds: courseIds.length > 0 ? courseIds : undefined,
|
|
31969
|
+
include: options?.include
|
|
31970
|
+
});
|
|
31971
|
+
logger17.debug("Retrieved student XP", {
|
|
31972
|
+
timebackId,
|
|
31973
|
+
gameId: options?.gameId,
|
|
31974
|
+
grade: options?.grade,
|
|
31975
|
+
subject: options?.subject,
|
|
31976
|
+
totalXp: result.totalXp,
|
|
31977
|
+
courseCount: result.courses?.length
|
|
31978
|
+
});
|
|
31979
|
+
return result;
|
|
31980
|
+
}
|
|
31981
|
+
};
|
|
31774
31982
|
});
|
|
31775
31983
|
|
|
31776
31984
|
// ../api-core/src/services/upload.service.ts
|
|
@@ -31829,6 +32037,7 @@ function createPlatformServices(deps) {
|
|
|
31829
32037
|
alerts,
|
|
31830
32038
|
validateDeveloperAccessBySlug,
|
|
31831
32039
|
validateDeveloperAccess,
|
|
32040
|
+
validateGameManagementAccess,
|
|
31832
32041
|
validateOwnership
|
|
31833
32042
|
} = deps;
|
|
31834
32043
|
const bucket = new BucketService({
|
|
@@ -31863,12 +32072,14 @@ function createPlatformServices(deps) {
|
|
|
31863
32072
|
const timeback2 = new TimebackService({
|
|
31864
32073
|
db: db2,
|
|
31865
32074
|
timeback: timebackClient,
|
|
31866
|
-
validateDeveloperAccess
|
|
32075
|
+
validateDeveloperAccess,
|
|
32076
|
+
validateGameManagementAccess
|
|
31867
32077
|
});
|
|
31868
32078
|
const timebackAdmin = new TimebackAdminService({
|
|
31869
32079
|
db: db2,
|
|
31870
32080
|
timeback: timebackClient,
|
|
31871
|
-
validateDeveloperAccess
|
|
32081
|
+
validateDeveloperAccess,
|
|
32082
|
+
validateGameManagementAccess
|
|
31872
32083
|
});
|
|
31873
32084
|
return {
|
|
31874
32085
|
bucket,
|
|
@@ -34894,6 +35105,7 @@ function createCaliperNamespace(client) {
|
|
|
34894
35105
|
email: data.studentEmail
|
|
34895
35106
|
},
|
|
34896
35107
|
action: TIMEBACK_ACTIONS4.completed,
|
|
35108
|
+
...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
|
|
34897
35109
|
object: {
|
|
34898
35110
|
id: data.objectId || caliper.buildActivityUrl(data),
|
|
34899
35111
|
type: TIMEBACK_TYPES4.activityContext,
|
|
@@ -34957,6 +35169,7 @@ function createCaliperNamespace(client) {
|
|
|
34957
35169
|
email: data.studentEmail
|
|
34958
35170
|
},
|
|
34959
35171
|
action: TIMEBACK_ACTIONS4.spentTime,
|
|
35172
|
+
...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
|
|
34960
35173
|
object: {
|
|
34961
35174
|
id: caliper.buildActivityUrl(data),
|
|
34962
35175
|
type: TIMEBACK_TYPES4.activityContext,
|
|
@@ -34990,7 +35203,7 @@ function createCaliperNamespace(client) {
|
|
|
34990
35203
|
},
|
|
34991
35204
|
buildActivityUrl: (data) => {
|
|
34992
35205
|
const base = data.sensorUrl.replace(/\/$/, "");
|
|
34993
|
-
return `${base}/activities/${data.courseId}/${data.activityId
|
|
35206
|
+
return `${base}/activities/${encodeURIComponent(data.courseId)}/${encodeURIComponent(data.activityId)}`;
|
|
34994
35207
|
}
|
|
34995
35208
|
};
|
|
34996
35209
|
return caliper;
|
|
@@ -35000,6 +35213,34 @@ function createEduBridgeNamespace(client) {
|
|
|
35000
35213
|
listByUser: async (userId) => {
|
|
35001
35214
|
const response = await client["request"](`/edubridge/enrollments/user/${userId}`, "GET");
|
|
35002
35215
|
return response.data;
|
|
35216
|
+
},
|
|
35217
|
+
enroll: async (userId, courseId, options) => {
|
|
35218
|
+
const segments = [userId, courseId];
|
|
35219
|
+
if (options?.schoolId) {
|
|
35220
|
+
segments.push(options.schoolId);
|
|
35221
|
+
}
|
|
35222
|
+
const body2 = {};
|
|
35223
|
+
if (options?.role) {
|
|
35224
|
+
body2.role = options.role;
|
|
35225
|
+
}
|
|
35226
|
+
if (options?.sourcedId) {
|
|
35227
|
+
body2.sourcedId = options.sourcedId;
|
|
35228
|
+
}
|
|
35229
|
+
if (options?.beginDate) {
|
|
35230
|
+
body2.beginDate = options.beginDate;
|
|
35231
|
+
}
|
|
35232
|
+
if (options?.metadata) {
|
|
35233
|
+
body2.metadata = options.metadata;
|
|
35234
|
+
}
|
|
35235
|
+
const response = await client["request"](`/edubridge/enrollments/enroll/${segments.join("/")}`, "POST", body2);
|
|
35236
|
+
return response.data;
|
|
35237
|
+
},
|
|
35238
|
+
unenroll: async (userId, courseId, options) => {
|
|
35239
|
+
const segments = [userId, courseId];
|
|
35240
|
+
if (options?.schoolId) {
|
|
35241
|
+
segments.push(options.schoolId);
|
|
35242
|
+
}
|
|
35243
|
+
await client["request"](`/edubridge/enrollments/unenroll/${segments.join("/")}`, "DELETE");
|
|
35003
35244
|
}
|
|
35004
35245
|
};
|
|
35005
35246
|
const analytics = {
|
|
@@ -35175,6 +35416,10 @@ function createOneRosterNamespace(client) {
|
|
|
35175
35416
|
logTimebackError("list course roster", error, { courseSourcedId });
|
|
35176
35417
|
throw error;
|
|
35177
35418
|
}
|
|
35419
|
+
},
|
|
35420
|
+
create: async (data) => client["request"](ONEROSTER_ENDPOINTS4.enrollments, "POST", { enrollment: data }),
|
|
35421
|
+
delete: async (sourcedId) => {
|
|
35422
|
+
await client["request"](`${ONEROSTER_ENDPOINTS4.enrollments}/${sourcedId}`, "DELETE");
|
|
35178
35423
|
}
|
|
35179
35424
|
},
|
|
35180
35425
|
organizations: {
|
|
@@ -36008,7 +36253,8 @@ class ProgressRecorder {
|
|
|
36008
36253
|
masteredUnits,
|
|
36009
36254
|
attemptNumber: currentAttemptNumber,
|
|
36010
36255
|
progressData,
|
|
36011
|
-
extensions
|
|
36256
|
+
extensions,
|
|
36257
|
+
runId: progressData.runId
|
|
36012
36258
|
});
|
|
36013
36259
|
return {
|
|
36014
36260
|
xpAwarded: calculatedXp,
|
|
@@ -36146,7 +36392,8 @@ class ProgressRecorder {
|
|
|
36146
36392
|
masteredUnits,
|
|
36147
36393
|
attemptNumber,
|
|
36148
36394
|
progressData,
|
|
36149
|
-
extensions
|
|
36395
|
+
extensions,
|
|
36396
|
+
runId
|
|
36150
36397
|
}) {
|
|
36151
36398
|
await this.caliperNamespace.emitActivityEvent({
|
|
36152
36399
|
studentId,
|
|
@@ -36163,7 +36410,8 @@ class ProgressRecorder {
|
|
|
36163
36410
|
subject: progressData.subject,
|
|
36164
36411
|
appName: progressData.appName,
|
|
36165
36412
|
sensorUrl: progressData.sensorUrl,
|
|
36166
|
-
extensions: extensions || progressData.extensions
|
|
36413
|
+
extensions: extensions || progressData.extensions,
|
|
36414
|
+
...runId ? { runId } : {}
|
|
36167
36415
|
}).catch((error) => {
|
|
36168
36416
|
log.error("[ProgressRecorder] Failed to emit activity event", { error });
|
|
36169
36417
|
});
|
|
@@ -36217,7 +36465,7 @@ class SessionRecorder {
|
|
|
36217
36465
|
const courseName = sessionData.courseName || "Game Course";
|
|
36218
36466
|
const student = await this.studentResolver.resolve(studentIdentifier, sessionData.studentEmail);
|
|
36219
36467
|
const { id: studentId, email: studentEmail } = student;
|
|
36220
|
-
const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions } = sessionData;
|
|
36468
|
+
const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions, runId } = sessionData;
|
|
36221
36469
|
await this.caliperNamespace.emitTimeSpentEvent({
|
|
36222
36470
|
studentId,
|
|
36223
36471
|
studentEmail,
|
|
@@ -36231,6 +36479,7 @@ class SessionRecorder {
|
|
|
36231
36479
|
subject: sessionData.subject,
|
|
36232
36480
|
appName: sessionData.appName,
|
|
36233
36481
|
sensorUrl: sessionData.sensorUrl,
|
|
36482
|
+
...runId ? { runId } : {},
|
|
36234
36483
|
...extensions ? { extensions } : {}
|
|
36235
36484
|
});
|
|
36236
36485
|
}
|
|
@@ -93507,7 +93756,7 @@ function isValidAdminAttributionDate(value) {
|
|
|
93507
93756
|
const date4 = new Date(Date.UTC(year3, month - 1, day, 12, 0, 0));
|
|
93508
93757
|
return date4.getUTCFullYear() === year3 && date4.getUTCMonth() + 1 === month && date4.getUTCDate() === day;
|
|
93509
93758
|
}
|
|
93510
|
-
var TIMEBACK_GRADES, TIMEBACK_SUBJECTS5, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, EndActivityRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema;
|
|
93759
|
+
var TIMEBACK_GRADES, TIMEBACK_SUBJECTS5, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema;
|
|
93511
93760
|
var init_schemas11 = __esm(() => {
|
|
93512
93761
|
init_esm();
|
|
93513
93762
|
TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
|
@@ -93530,31 +93779,49 @@ var init_schemas11 = __esm(() => {
|
|
|
93530
93779
|
xp: exports_external.number().min(0, "XP must be a non-negative number"),
|
|
93531
93780
|
userTimestamp: exports_external.string().datetime().optional()
|
|
93532
93781
|
});
|
|
93782
|
+
TimebackActivityDataSchema = exports_external.object({
|
|
93783
|
+
activityId: exports_external.string().min(1),
|
|
93784
|
+
activityName: exports_external.string().optional(),
|
|
93785
|
+
grade: TimebackGradeSchema,
|
|
93786
|
+
subject: TimebackSubjectSchema,
|
|
93787
|
+
appName: exports_external.string().optional(),
|
|
93788
|
+
sensorUrl: exports_external.string().url().optional(),
|
|
93789
|
+
courseId: exports_external.string().optional(),
|
|
93790
|
+
courseName: exports_external.string().optional(),
|
|
93791
|
+
studentEmail: exports_external.string().email().optional()
|
|
93792
|
+
});
|
|
93533
93793
|
EndActivityRequestSchema = exports_external.object({
|
|
93534
93794
|
gameId: exports_external.string().uuid(),
|
|
93535
93795
|
studentId: exports_external.string().min(1),
|
|
93536
|
-
|
|
93537
|
-
|
|
93538
|
-
activityName: exports_external.string().optional(),
|
|
93539
|
-
grade: TimebackGradeSchema,
|
|
93540
|
-
subject: TimebackSubjectSchema,
|
|
93541
|
-
appName: exports_external.string().optional(),
|
|
93542
|
-
sensorUrl: exports_external.string().url().optional(),
|
|
93543
|
-
courseId: exports_external.string().optional(),
|
|
93544
|
-
courseName: exports_external.string().optional(),
|
|
93545
|
-
studentEmail: exports_external.string().email().optional()
|
|
93546
|
-
}),
|
|
93796
|
+
runId: exports_external.string().uuid().optional(),
|
|
93797
|
+
activityData: TimebackActivityDataSchema,
|
|
93547
93798
|
scoreData: exports_external.object({
|
|
93548
93799
|
correctQuestions: exports_external.number().int().min(0),
|
|
93549
93800
|
totalQuestions: exports_external.number().int().min(0)
|
|
93550
93801
|
}),
|
|
93551
93802
|
timingData: exports_external.object({
|
|
93552
|
-
durationSeconds: exports_external.number().
|
|
93803
|
+
durationSeconds: exports_external.number().nonnegative()
|
|
93553
93804
|
}),
|
|
93805
|
+
sessionTimingData: exports_external.object({
|
|
93806
|
+
activeSeconds: exports_external.number().nonnegative(),
|
|
93807
|
+
inactiveSeconds: exports_external.number().nonnegative().optional()
|
|
93808
|
+
}).optional(),
|
|
93554
93809
|
xpEarned: exports_external.number().optional(),
|
|
93555
93810
|
masteredUnits: exports_external.number().nonnegative().optional(),
|
|
93556
93811
|
extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
|
|
93557
93812
|
});
|
|
93813
|
+
HeartbeatRequestSchema = exports_external.object({
|
|
93814
|
+
gameId: exports_external.string().uuid(),
|
|
93815
|
+
studentId: exports_external.string().min(1),
|
|
93816
|
+
runId: exports_external.string().uuid(),
|
|
93817
|
+
activityData: TimebackActivityDataSchema,
|
|
93818
|
+
timingData: exports_external.object({
|
|
93819
|
+
activeMs: exports_external.number().nonnegative(),
|
|
93820
|
+
pausedMs: exports_external.number().nonnegative()
|
|
93821
|
+
}),
|
|
93822
|
+
windowSequence: exports_external.number().int().nonnegative(),
|
|
93823
|
+
isFinal: exports_external.boolean().optional()
|
|
93824
|
+
});
|
|
93558
93825
|
PopulateStudentRequestSchema = exports_external.object({
|
|
93559
93826
|
firstName: exports_external.string().min(1).optional(),
|
|
93560
93827
|
lastName: exports_external.string().min(1).optional()
|
|
@@ -93650,6 +93917,16 @@ var init_schemas11 = __esm(() => {
|
|
|
93650
93917
|
studentId: exports_external.string().min(1),
|
|
93651
93918
|
action: exports_external.enum(["complete", "resume"])
|
|
93652
93919
|
});
|
|
93920
|
+
EnrollStudentRequestSchema = exports_external.object({
|
|
93921
|
+
gameId: exports_external.string().uuid(),
|
|
93922
|
+
courseId: exports_external.string().min(1),
|
|
93923
|
+
studentId: exports_external.string().min(1)
|
|
93924
|
+
});
|
|
93925
|
+
UnenrollStudentRequestSchema = exports_external.object({
|
|
93926
|
+
gameId: exports_external.string().uuid(),
|
|
93927
|
+
courseId: exports_external.string().min(1),
|
|
93928
|
+
studentId: exports_external.string().min(1)
|
|
93929
|
+
});
|
|
93653
93930
|
});
|
|
93654
93931
|
|
|
93655
93932
|
// ../data/src/schemas.index.ts
|
|
@@ -93676,6 +93953,9 @@ function isAuthenticated(ctx) {
|
|
|
93676
93953
|
var init_types9 = () => {};
|
|
93677
93954
|
|
|
93678
93955
|
// ../api-core/src/utils/auth.util.ts
|
|
93956
|
+
function hasGameManagementAccess(user) {
|
|
93957
|
+
return user.role === "admin" || user.role === "teacher" || user.role === "developer" && user.developerStatus === "approved";
|
|
93958
|
+
}
|
|
93679
93959
|
function requireAuth(handler) {
|
|
93680
93960
|
return async (ctx) => {
|
|
93681
93961
|
if (!isAuthenticated(ctx)) {
|
|
@@ -93719,6 +93999,17 @@ function requireDeveloper(handler) {
|
|
|
93719
93999
|
return handler(ctx);
|
|
93720
94000
|
};
|
|
93721
94001
|
}
|
|
94002
|
+
function requireGameManagementAccess(handler) {
|
|
94003
|
+
return async (ctx) => {
|
|
94004
|
+
if (!isAuthenticated(ctx)) {
|
|
94005
|
+
throw ApiError.unauthorized("Valid session or bearer token required");
|
|
94006
|
+
}
|
|
94007
|
+
if (!hasGameManagementAccess(ctx.user)) {
|
|
94008
|
+
throw ApiError.forbidden("Game management access required");
|
|
94009
|
+
}
|
|
94010
|
+
return handler(ctx);
|
|
94011
|
+
};
|
|
94012
|
+
}
|
|
93722
94013
|
var init_auth_util = __esm(() => {
|
|
93723
94014
|
init_errors();
|
|
93724
94015
|
init_types9();
|
|
@@ -95776,7 +96067,7 @@ var init_sprite_controller = __esm(() => {
|
|
|
95776
96067
|
});
|
|
95777
96068
|
|
|
95778
96069
|
// ../api-core/src/controllers/timeback.controller.ts
|
|
95779
|
-
var logger63, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, getStudentXp, getRoster, getStudentOverview, getStudentActivity, grantXp, adjustTime, adjustMastery, toggleCompletion, timeback2;
|
|
96070
|
+
var logger63, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, heartbeat, getStudentXp, getRoster, getStudentOverview, getStudentActivity, grantXp, adjustTime, adjustMastery, toggleCompletion, searchStudents, enrollStudent, unenrollStudent, timeback2;
|
|
95780
96071
|
var init_timeback_controller = __esm(() => {
|
|
95781
96072
|
init_esm();
|
|
95782
96073
|
init_schemas_index();
|
|
@@ -95866,7 +96157,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95866
96157
|
});
|
|
95867
96158
|
return ctx.services.timeback.setupIntegration(body2.gameId, body2, ctx.user);
|
|
95868
96159
|
});
|
|
95869
|
-
getIntegrations =
|
|
96160
|
+
getIntegrations = requireGameManagementAccess(async (ctx) => {
|
|
95870
96161
|
const gameId = ctx.params.gameId;
|
|
95871
96162
|
if (!gameId) {
|
|
95872
96163
|
throw ApiError.badRequest("Missing gameId");
|
|
@@ -95926,9 +96217,11 @@ var init_timeback_controller = __esm(() => {
|
|
|
95926
96217
|
const {
|
|
95927
96218
|
gameId,
|
|
95928
96219
|
studentId,
|
|
96220
|
+
runId,
|
|
95929
96221
|
activityData,
|
|
95930
96222
|
scoreData,
|
|
95931
96223
|
timingData,
|
|
96224
|
+
sessionTimingData,
|
|
95932
96225
|
xpEarned,
|
|
95933
96226
|
masteredUnits,
|
|
95934
96227
|
extensions
|
|
@@ -95937,15 +96230,50 @@ var init_timeback_controller = __esm(() => {
|
|
|
95937
96230
|
return ctx.services.timeback.endActivity({
|
|
95938
96231
|
gameId,
|
|
95939
96232
|
studentId,
|
|
96233
|
+
runId,
|
|
95940
96234
|
activityData,
|
|
95941
96235
|
scoreData,
|
|
95942
96236
|
timingData,
|
|
96237
|
+
sessionTimingData,
|
|
95943
96238
|
xpEarned,
|
|
95944
96239
|
masteredUnits,
|
|
95945
96240
|
extensions,
|
|
95946
96241
|
user: ctx.user
|
|
95947
96242
|
});
|
|
95948
96243
|
});
|
|
96244
|
+
heartbeat = requireDeveloper(async (ctx) => {
|
|
96245
|
+
let body2;
|
|
96246
|
+
try {
|
|
96247
|
+
const json4 = await ctx.request.json();
|
|
96248
|
+
body2 = HeartbeatRequestSchema.parse(json4);
|
|
96249
|
+
} catch (error2) {
|
|
96250
|
+
if (error2 instanceof exports_external.ZodError) {
|
|
96251
|
+
const details = formatZodError(error2);
|
|
96252
|
+
logger63.warn("Heartbeat validation failed", { details });
|
|
96253
|
+
throw ApiError.unprocessableEntity("Validation failed", details);
|
|
96254
|
+
}
|
|
96255
|
+
throw ApiError.badRequest("Invalid JSON body");
|
|
96256
|
+
}
|
|
96257
|
+
const { gameId, studentId, runId, activityData, timingData, windowSequence, isFinal } = body2;
|
|
96258
|
+
logger63.debug("Recording heartbeat", {
|
|
96259
|
+
userId: ctx.user.id,
|
|
96260
|
+
gameId,
|
|
96261
|
+
runId,
|
|
96262
|
+
windowSequence,
|
|
96263
|
+
activeMs: timingData.activeMs,
|
|
96264
|
+
isFinal
|
|
96265
|
+
});
|
|
96266
|
+
return ctx.services.timeback.recordHeartbeat({
|
|
96267
|
+
gameId,
|
|
96268
|
+
studentId,
|
|
96269
|
+
runId,
|
|
96270
|
+
activityData,
|
|
96271
|
+
timingData,
|
|
96272
|
+
windowSequence,
|
|
96273
|
+
isFinal,
|
|
96274
|
+
user: ctx.user
|
|
96275
|
+
});
|
|
96276
|
+
});
|
|
95949
96277
|
getStudentXp = requireDeveloper(async (ctx) => {
|
|
95950
96278
|
const timebackId = ctx.params.timebackId;
|
|
95951
96279
|
if (!timebackId) {
|
|
@@ -95991,7 +96319,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
95991
96319
|
include
|
|
95992
96320
|
});
|
|
95993
96321
|
});
|
|
95994
|
-
getRoster =
|
|
96322
|
+
getRoster = requireGameManagementAccess(async (ctx) => {
|
|
95995
96323
|
const gameId = ctx.params.gameId;
|
|
95996
96324
|
const courseId = ctx.params.courseId;
|
|
95997
96325
|
if (!gameId || !courseId) {
|
|
@@ -96004,7 +96332,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
96004
96332
|
});
|
|
96005
96333
|
return ctx.services.timebackAdmin.listStudentsForCourse(gameId, courseId, ctx.user);
|
|
96006
96334
|
});
|
|
96007
|
-
getStudentOverview =
|
|
96335
|
+
getStudentOverview = requireGameManagementAccess(async (ctx) => {
|
|
96008
96336
|
const timebackId = ctx.params.timebackId;
|
|
96009
96337
|
const gameId = ctx.url.searchParams.get("gameId") || undefined;
|
|
96010
96338
|
const courseId = ctx.url.searchParams.get("courseId") || undefined;
|
|
@@ -96019,7 +96347,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
96019
96347
|
});
|
|
96020
96348
|
return ctx.services.timebackAdmin.getStudentOverview(gameId, timebackId, ctx.user, courseId);
|
|
96021
96349
|
});
|
|
96022
|
-
getStudentActivity =
|
|
96350
|
+
getStudentActivity = requireGameManagementAccess(async (ctx) => {
|
|
96023
96351
|
const timebackId = ctx.params.timebackId;
|
|
96024
96352
|
const courseId = ctx.params.courseId;
|
|
96025
96353
|
const gameId = ctx.url.searchParams.get("gameId") || undefined;
|
|
@@ -96082,7 +96410,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
96082
96410
|
});
|
|
96083
96411
|
return ctx.services.timebackAdmin.adjustMasteredUnits(body2, ctx.user);
|
|
96084
96412
|
});
|
|
96085
|
-
toggleCompletion =
|
|
96413
|
+
toggleCompletion = requireGameManagementAccess(async (ctx) => {
|
|
96086
96414
|
const body2 = await parseRequestBody(ctx.request, ToggleCourseCompletionRequestSchema);
|
|
96087
96415
|
logger63.debug("Toggling course completion", {
|
|
96088
96416
|
requesterId: ctx.user.id,
|
|
@@ -96093,6 +96421,41 @@ var init_timeback_controller = __esm(() => {
|
|
|
96093
96421
|
});
|
|
96094
96422
|
return ctx.services.timebackAdmin.toggleCourseCompletion(body2, ctx.user);
|
|
96095
96423
|
});
|
|
96424
|
+
searchStudents = requireGameManagementAccess(async (ctx) => {
|
|
96425
|
+
const gameId = ctx.params.gameId;
|
|
96426
|
+
const courseId = ctx.params.courseId;
|
|
96427
|
+
const query = ctx.url.searchParams.get("q") || "";
|
|
96428
|
+
if (!gameId || !courseId) {
|
|
96429
|
+
throw ApiError.badRequest("Missing gameId or courseId parameter");
|
|
96430
|
+
}
|
|
96431
|
+
logger63.debug("Searching students for enrollment", {
|
|
96432
|
+
requesterId: ctx.user.id,
|
|
96433
|
+
gameId,
|
|
96434
|
+
courseId,
|
|
96435
|
+
query
|
|
96436
|
+
});
|
|
96437
|
+
return ctx.services.timebackAdmin.searchStudentsForEnrollment(gameId, courseId, query, ctx.user);
|
|
96438
|
+
});
|
|
96439
|
+
enrollStudent = requireGameManagementAccess(async (ctx) => {
|
|
96440
|
+
const body2 = await parseRequestBody(ctx.request, EnrollStudentRequestSchema);
|
|
96441
|
+
logger63.debug("Enrolling student", {
|
|
96442
|
+
requesterId: ctx.user.id,
|
|
96443
|
+
gameId: body2.gameId,
|
|
96444
|
+
courseId: body2.courseId,
|
|
96445
|
+
studentId: body2.studentId
|
|
96446
|
+
});
|
|
96447
|
+
return ctx.services.timebackAdmin.enrollStudent(body2, ctx.user);
|
|
96448
|
+
});
|
|
96449
|
+
unenrollStudent = requireGameManagementAccess(async (ctx) => {
|
|
96450
|
+
const body2 = await parseRequestBody(ctx.request, UnenrollStudentRequestSchema);
|
|
96451
|
+
logger63.debug("Unenrolling student", {
|
|
96452
|
+
requesterId: ctx.user.id,
|
|
96453
|
+
gameId: body2.gameId,
|
|
96454
|
+
courseId: body2.courseId,
|
|
96455
|
+
studentId: body2.studentId
|
|
96456
|
+
});
|
|
96457
|
+
return ctx.services.timebackAdmin.unenrollStudent(body2, ctx.user);
|
|
96458
|
+
});
|
|
96096
96459
|
timeback2 = {
|
|
96097
96460
|
getTodayXp,
|
|
96098
96461
|
getTotalXp,
|
|
@@ -96107,6 +96470,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
96107
96470
|
getConfig: getConfig2,
|
|
96108
96471
|
deleteIntegrations,
|
|
96109
96472
|
endActivity,
|
|
96473
|
+
heartbeat,
|
|
96110
96474
|
getStudentXp,
|
|
96111
96475
|
getRoster,
|
|
96112
96476
|
getStudentOverview,
|
|
@@ -96114,7 +96478,10 @@ var init_timeback_controller = __esm(() => {
|
|
|
96114
96478
|
grantXp,
|
|
96115
96479
|
adjustTime,
|
|
96116
96480
|
adjustMastery,
|
|
96117
|
-
toggleCompletion
|
|
96481
|
+
toggleCompletion,
|
|
96482
|
+
searchStudents,
|
|
96483
|
+
enrollStudent,
|
|
96484
|
+
unenrollStudent
|
|
96118
96485
|
};
|
|
96119
96486
|
});
|
|
96120
96487
|
|
|
@@ -97155,6 +97522,7 @@ var init_timeback6 = __esm(() => {
|
|
|
97155
97522
|
timebackRouter.get("/config/:gameId", handle2(timeback2.getConfig));
|
|
97156
97523
|
timebackRouter.delete("/integrations/:gameId", handle2(timeback2.deleteIntegrations, { status: 204 }));
|
|
97157
97524
|
timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
|
|
97525
|
+
timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
|
|
97158
97526
|
timebackRouter.get("/user", async (c2) => {
|
|
97159
97527
|
const user = c2.get("user");
|
|
97160
97528
|
const gameId = c2.get("gameId");
|