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