@playcademy/sandbox 0.3.17-beta.7 → 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 +732 -553
- package/dist/constants.js +2 -1
- package/dist/server.js +732 -553
- 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: {
|
|
@@ -28876,7 +28877,8 @@ var init_constants3 = __esm(() => {
|
|
|
28876
28877
|
HEALTH: "/api/health",
|
|
28877
28878
|
TIMEBACK: {
|
|
28878
28879
|
END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
|
|
28879
|
-
GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}
|
|
28880
|
+
GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
|
|
28881
|
+
HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`
|
|
28880
28882
|
}
|
|
28881
28883
|
};
|
|
28882
28884
|
});
|
|
@@ -31279,589 +31281,703 @@ var init_timeback_admin_service = __esm(() => {
|
|
|
31279
31281
|
});
|
|
31280
31282
|
|
|
31281
31283
|
// ../api-core/src/services/timeback.service.ts
|
|
31282
|
-
|
|
31283
|
-
|
|
31284
|
-
|
|
31285
|
-
|
|
31286
|
-
|
|
31287
|
-
|
|
31288
|
-
|
|
31289
|
-
|
|
31290
|
-
|
|
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
|
+
}
|
|
31291
31306
|
}
|
|
31292
|
-
|
|
31293
|
-
|
|
31294
|
-
|
|
31295
|
-
const db2 = this.deps.db;
|
|
31296
|
-
const tz = timezone2 || PLATFORM_TIMEZONE;
|
|
31297
|
-
const base = date3 ? new Date(date3) : new Date;
|
|
31298
|
-
if (isNaN(base.getTime())) {
|
|
31299
|
-
throw new ValidationError("Invalid date format. Use ISO 8601 format.");
|
|
31307
|
+
static isDuplicateHeartbeatWindow(key) {
|
|
31308
|
+
this.cleanHeartbeatDedupeCache();
|
|
31309
|
+
return this.processedHeartbeatWindows.has(key);
|
|
31300
31310
|
}
|
|
31301
|
-
|
|
31302
|
-
|
|
31303
|
-
} catch {
|
|
31304
|
-
throw new ValidationError(`Invalid timezone: ${tz}`);
|
|
31311
|
+
static getInFlightHeartbeatWindow(key) {
|
|
31312
|
+
return this.inFlightHeartbeatWindows.get(key);
|
|
31305
31313
|
}
|
|
31306
|
-
|
|
31307
|
-
|
|
31308
|
-
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);
|
|
31309
|
-
if (result2.length === 0) {
|
|
31310
|
-
return { xp: 0, date: todayMidnight.toISOString() };
|
|
31311
|
-
}
|
|
31312
|
-
return { xp: result2[0].xp, date: result2[0].date.toISOString() };
|
|
31314
|
+
static markHeartbeatWindowProcessed(key) {
|
|
31315
|
+
this.processedHeartbeatWindows.set(key, Date.now());
|
|
31313
31316
|
}
|
|
31314
|
-
|
|
31315
|
-
|
|
31316
|
-
return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
|
|
31317
|
-
}
|
|
31318
|
-
async getTotalXp(userId) {
|
|
31319
|
-
const db2 = this.deps.db;
|
|
31320
|
-
const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
|
|
31321
|
-
return { totalXp: Number(result[0]?.totalXp) || 0 };
|
|
31322
|
-
}
|
|
31323
|
-
async updateTodayXp(userId, data) {
|
|
31324
|
-
const db2 = this.deps.db;
|
|
31325
|
-
const { xp, userTimestamp } = data;
|
|
31326
|
-
let targetDate;
|
|
31327
|
-
if (userTimestamp) {
|
|
31328
|
-
targetDate = new Date(userTimestamp);
|
|
31329
|
-
if (isNaN(targetDate.getTime())) {
|
|
31330
|
-
throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
|
|
31331
|
-
}
|
|
31332
|
-
targetDate.setHours(0, 0, 0, 0);
|
|
31333
|
-
} else {
|
|
31334
|
-
targetDate = new Date;
|
|
31335
|
-
targetDate.setUTCHours(0, 0, 0, 0);
|
|
31317
|
+
static markHeartbeatWindowInFlight(key, promise) {
|
|
31318
|
+
this.inFlightHeartbeatWindows.set(key, promise);
|
|
31336
31319
|
}
|
|
31337
|
-
|
|
31338
|
-
|
|
31339
|
-
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31340
|
-
}).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
|
|
31341
|
-
if (!result) {
|
|
31342
|
-
logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
|
|
31343
|
-
throw new InternalError("Failed to update daily XP record");
|
|
31320
|
+
static clearInFlightHeartbeatWindow(key) {
|
|
31321
|
+
this.inFlightHeartbeatWindows.delete(key);
|
|
31344
31322
|
}
|
|
31345
|
-
|
|
31346
|
-
|
|
31347
|
-
async getXpHistory(userId, startDate, endDate) {
|
|
31348
|
-
const db2 = this.deps.db;
|
|
31349
|
-
const whereConditions = [eq(timebackDailyXp.userId, userId)];
|
|
31350
|
-
if (startDate) {
|
|
31351
|
-
const start2 = new Date(startDate);
|
|
31352
|
-
start2.setUTCHours(0, 0, 0, 0);
|
|
31353
|
-
whereConditions.push(gte(timebackDailyXp.date, start2));
|
|
31323
|
+
constructor(deps) {
|
|
31324
|
+
this.deps = deps;
|
|
31354
31325
|
}
|
|
31355
|
-
|
|
31356
|
-
|
|
31357
|
-
|
|
31358
|
-
|
|
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;
|
|
31359
31332
|
}
|
|
31360
|
-
|
|
31361
|
-
|
|
31362
|
-
|
|
31363
|
-
|
|
31364
|
-
|
|
31365
|
-
|
|
31366
|
-
|
|
31367
|
-
|
|
31368
|
-
|
|
31369
|
-
|
|
31370
|
-
|
|
31371
|
-
|
|
31372
|
-
|
|
31373
|
-
|
|
31374
|
-
|
|
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() };
|
|
31375
31356
|
}
|
|
31376
|
-
|
|
31377
|
-
|
|
31378
|
-
|
|
31379
|
-
|
|
31380
|
-
|
|
31381
|
-
|
|
31382
|
-
|
|
31383
|
-
|
|
31384
|
-
|
|
31385
|
-
|
|
31386
|
-
|
|
31387
|
-
|
|
31388
|
-
|
|
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);
|
|
31389
31375
|
}
|
|
31390
|
-
const
|
|
31391
|
-
|
|
31392
|
-
|
|
31393
|
-
|
|
31394
|
-
|
|
31395
|
-
|
|
31396
|
-
|
|
31397
|
-
email: user.email,
|
|
31398
|
-
roles: [
|
|
31399
|
-
{
|
|
31400
|
-
roleType: "primary",
|
|
31401
|
-
role: "student",
|
|
31402
|
-
org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
|
|
31403
|
-
}
|
|
31404
|
-
]
|
|
31405
|
-
});
|
|
31406
|
-
if (!response.sourcedIdPairs?.allocatedSourcedId) {
|
|
31407
|
-
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");
|
|
31408
31383
|
}
|
|
31409
|
-
|
|
31410
|
-
name3 = `${providedNames.firstName} ${providedNames.lastName}`;
|
|
31411
|
-
logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
|
|
31384
|
+
return { xp: result.xp, date: result.date.toISOString() };
|
|
31412
31385
|
}
|
|
31413
|
-
|
|
31414
|
-
|
|
31415
|
-
|
|
31416
|
-
|
|
31417
|
-
|
|
31418
|
-
|
|
31419
|
-
|
|
31420
|
-
|
|
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" };
|
|
31421
31428
|
}
|
|
31422
|
-
const
|
|
31423
|
-
|
|
31424
|
-
|
|
31425
|
-
|
|
31426
|
-
|
|
31427
|
-
|
|
31428
|
-
|
|
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
|
+
});
|
|
31429
31480
|
}
|
|
31430
31481
|
}
|
|
31431
|
-
|
|
31432
|
-
|
|
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", {
|
|
31433
31485
|
userId: user.id,
|
|
31434
|
-
|
|
31435
|
-
xp
|
|
31436
|
-
}));
|
|
31437
|
-
await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
|
|
31438
|
-
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31439
|
-
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31486
|
+
timebackId
|
|
31440
31487
|
});
|
|
31488
|
+
throw new InternalError("Failed to update user with Timeback ID");
|
|
31441
31489
|
}
|
|
31442
|
-
}
|
|
31443
|
-
const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
|
|
31444
|
-
if (!updated) {
|
|
31445
|
-
logger17.error("User Timeback ID update returned no rows", {
|
|
31446
|
-
userId: user.id,
|
|
31447
|
-
timebackId
|
|
31448
|
-
});
|
|
31449
|
-
throw new InternalError("Failed to update user with Timeback ID");
|
|
31450
|
-
}
|
|
31451
|
-
});
|
|
31452
|
-
return { status: "ok" };
|
|
31453
|
-
}
|
|
31454
|
-
async fetchAssessments(studentSourcedId) {
|
|
31455
|
-
const client = this.requireClient();
|
|
31456
|
-
const allAssessments = [];
|
|
31457
|
-
const limit = 3000;
|
|
31458
|
-
const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
|
|
31459
|
-
let offset = 0;
|
|
31460
|
-
try {
|
|
31461
|
-
while (true) {
|
|
31462
|
-
const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
|
|
31463
|
-
allAssessments.push(...results);
|
|
31464
|
-
if (results.length < limit) {
|
|
31465
|
-
break;
|
|
31466
|
-
}
|
|
31467
|
-
offset += limit;
|
|
31468
|
-
}
|
|
31469
|
-
logger17.debug("Fetched assessments", {
|
|
31470
|
-
studentSourcedId,
|
|
31471
|
-
totalCount: allAssessments.length
|
|
31472
31490
|
});
|
|
31473
|
-
return
|
|
31474
|
-
} catch (error) {
|
|
31475
|
-
logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
|
|
31476
|
-
return [];
|
|
31477
|
-
}
|
|
31478
|
-
}
|
|
31479
|
-
async getUserData(userId, gameId) {
|
|
31480
|
-
const db2 = this.deps.db;
|
|
31481
|
-
const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
|
|
31482
|
-
if (!userData) {
|
|
31483
|
-
throw new NotFoundError("User", userId);
|
|
31491
|
+
return { status: "ok" };
|
|
31484
31492
|
}
|
|
31485
|
-
|
|
31486
|
-
|
|
31487
|
-
|
|
31488
|
-
|
|
31489
|
-
|
|
31490
|
-
|
|
31491
|
-
|
|
31492
|
-
|
|
31493
|
-
|
|
31494
|
-
|
|
31495
|
-
|
|
31496
|
-
|
|
31497
|
-
|
|
31498
|
-
|
|
31499
|
-
|
|
31500
|
-
|
|
31501
|
-
|
|
31502
|
-
|
|
31503
|
-
id: timebackId,
|
|
31504
|
-
role: profile.role,
|
|
31505
|
-
enrollments,
|
|
31506
|
-
organizations: profile.organizations
|
|
31507
|
-
};
|
|
31508
|
-
}
|
|
31509
|
-
async fetchStudentProfile(timebackId) {
|
|
31510
|
-
const client = this.requireClient();
|
|
31511
|
-
try {
|
|
31512
|
-
const user = await client.oneroster.users.get(timebackId);
|
|
31513
|
-
const primaryRole = user.roles.find((r) => r.roleType === "primary");
|
|
31514
|
-
const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
|
|
31515
|
-
const orgMap = new Map;
|
|
31516
|
-
if (user.primaryOrg) {
|
|
31517
|
-
orgMap.set(user.primaryOrg.sourcedId, {
|
|
31518
|
-
id: user.primaryOrg.sourcedId,
|
|
31519
|
-
name: user.primaryOrg.name ?? null,
|
|
31520
|
-
type: user.primaryOrg.type || "school",
|
|
31521
|
-
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
|
|
31522
31511
|
});
|
|
31512
|
+
return allAssessments;
|
|
31513
|
+
} catch (error) {
|
|
31514
|
+
logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
|
|
31515
|
+
return [];
|
|
31523
31516
|
}
|
|
31524
|
-
|
|
31525
|
-
|
|
31526
|
-
|
|
31527
|
-
|
|
31528
|
-
|
|
31529
|
-
|
|
31530
|
-
|
|
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
|
|
31531
31561
|
});
|
|
31532
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: [] };
|
|
31533
31576
|
}
|
|
31534
|
-
return { role, organizations: [...orgMap.values()] };
|
|
31535
|
-
} catch {
|
|
31536
|
-
return { role: "student", organizations: [] };
|
|
31537
31577
|
}
|
|
31538
|
-
|
|
31539
|
-
|
|
31540
|
-
|
|
31541
|
-
|
|
31542
|
-
|
|
31543
|
-
|
|
31544
|
-
|
|
31545
|
-
|
|
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 {
|
|
31546
31599
|
return [];
|
|
31547
31600
|
}
|
|
31548
|
-
const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
|
|
31549
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31550
|
-
where: inArray(gameTimebackIntegrations.courseId, courseIds)
|
|
31551
|
-
});
|
|
31552
|
-
return integrations.map((i2) => ({
|
|
31553
|
-
gameId: i2.gameId,
|
|
31554
|
-
grade: i2.grade,
|
|
31555
|
-
subject: i2.subject,
|
|
31556
|
-
courseId: i2.courseId,
|
|
31557
|
-
orgId: courseToSchool.get(i2.courseId)
|
|
31558
|
-
}));
|
|
31559
|
-
} catch {
|
|
31560
|
-
return [];
|
|
31561
31601
|
}
|
|
31562
|
-
|
|
31563
|
-
|
|
31564
|
-
|
|
31565
|
-
|
|
31566
|
-
|
|
31567
|
-
|
|
31568
|
-
|
|
31569
|
-
|
|
31570
|
-
|
|
31571
|
-
|
|
31572
|
-
|
|
31573
|
-
|
|
31574
|
-
|
|
31575
|
-
|
|
31576
|
-
|
|
31577
|
-
const {
|
|
31578
|
-
subject: subjectInput,
|
|
31579
|
-
grade,
|
|
31580
|
-
title,
|
|
31581
|
-
courseCode,
|
|
31582
|
-
level,
|
|
31583
|
-
metadata: metadata2,
|
|
31584
|
-
totalXp: derivedTotalXp,
|
|
31585
|
-
masterableUnits: derivedMasterableUnits
|
|
31586
|
-
} = courseConfig;
|
|
31587
|
-
if (!isTimebackSubject(subjectInput)) {
|
|
31588
|
-
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 {
|
|
31589
31617
|
subject: subjectInput,
|
|
31590
|
-
courseCode,
|
|
31591
|
-
title
|
|
31592
|
-
});
|
|
31593
|
-
throw new ValidationError(`Invalid subject "${subjectInput}"`);
|
|
31594
|
-
}
|
|
31595
|
-
if (!isTimebackGrade(grade)) {
|
|
31596
|
-
logger17.warn("Invalid Timeback grade in course config", {
|
|
31597
31618
|
grade,
|
|
31598
|
-
courseCode,
|
|
31599
|
-
title
|
|
31600
|
-
});
|
|
31601
|
-
throw new ValidationError(`Invalid grade "${grade}"`);
|
|
31602
|
-
}
|
|
31603
|
-
const subject = subjectInput;
|
|
31604
|
-
const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
|
|
31605
|
-
const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
|
|
31606
|
-
const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
|
|
31607
|
-
if (typeof totalXp !== "number") {
|
|
31608
|
-
logger17.warn("Course missing totalXp in Timeback config", {
|
|
31609
|
-
courseCode,
|
|
31610
|
-
title
|
|
31611
|
-
});
|
|
31612
|
-
throw new ValidationError(`Course "${title}" is missing totalXp`);
|
|
31613
|
-
}
|
|
31614
|
-
const suffix = baseConfig.component.titleSuffix || "";
|
|
31615
|
-
const fullConfig = {
|
|
31616
|
-
organization: baseConfig.organization,
|
|
31617
|
-
course: {
|
|
31618
31619
|
title,
|
|
31619
|
-
subjects: [subject],
|
|
31620
|
-
grades: [grade],
|
|
31621
31620
|
courseCode,
|
|
31622
31621
|
level,
|
|
31623
|
-
|
|
31624
|
-
|
|
31625
|
-
|
|
31626
|
-
|
|
31627
|
-
|
|
31628
|
-
|
|
31629
|
-
|
|
31630
|
-
|
|
31631
|
-
|
|
31632
|
-
|
|
31633
|
-
|
|
31634
|
-
baseMetadata: baseConfig.resource.metadata,
|
|
31635
|
-
subject,
|
|
31636
|
-
grade,
|
|
31637
|
-
totalXp,
|
|
31638
|
-
masterableUnits
|
|
31639
|
-
})
|
|
31640
|
-
},
|
|
31641
|
-
componentResource: {
|
|
31642
|
-
...baseConfig.componentResource,
|
|
31643
|
-
title: applySuffix(baseConfig.componentResource.title || "")
|
|
31644
|
-
}
|
|
31645
|
-
};
|
|
31646
|
-
const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
|
|
31647
|
-
if (existingIntegration) {
|
|
31648
|
-
await client.update(existingIntegration.courseId, fullConfig);
|
|
31649
|
-
const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
|
|
31650
|
-
if (updated) {
|
|
31651
|
-
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}"`);
|
|
31652
31633
|
}
|
|
31653
|
-
|
|
31654
|
-
|
|
31655
|
-
|
|
31656
|
-
|
|
31657
|
-
|
|
31658
|
-
|
|
31659
|
-
|
|
31660
|
-
|
|
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
|
+
}
|
|
31661
31701
|
}
|
|
31662
31702
|
}
|
|
31663
31703
|
}
|
|
31704
|
+
return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
|
|
31664
31705
|
}
|
|
31665
|
-
|
|
31666
|
-
|
|
31667
|
-
|
|
31668
|
-
|
|
31669
|
-
|
|
31670
|
-
|
|
31671
|
-
});
|
|
31672
|
-
return rows.map((row) => this.toGameTimebackIntegration(row));
|
|
31673
|
-
}
|
|
31674
|
-
async verifyIntegration(gameId, user) {
|
|
31675
|
-
const client = this.requireClient();
|
|
31676
|
-
const db2 = this.deps.db;
|
|
31677
|
-
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31678
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31679
|
-
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31680
|
-
});
|
|
31681
|
-
if (integrations.length === 0) {
|
|
31682
|
-
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));
|
|
31683
31712
|
}
|
|
31684
|
-
|
|
31685
|
-
|
|
31686
|
-
const
|
|
31687
|
-
|
|
31688
|
-
const
|
|
31689
|
-
|
|
31690
|
-
|
|
31691
|
-
|
|
31692
|
-
integration
|
|
31693
|
-
|
|
31694
|
-
|
|
31695
|
-
|
|
31696
|
-
resources
|
|
31697
|
-
|
|
31698
|
-
|
|
31699
|
-
|
|
31700
|
-
|
|
31701
|
-
|
|
31702
|
-
|
|
31703
|
-
|
|
31704
|
-
|
|
31705
|
-
|
|
31706
|
-
|
|
31707
|
-
|
|
31708
|
-
|
|
31709
|
-
|
|
31710
|
-
|
|
31711
|
-
|
|
31712
|
-
|
|
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 };
|
|
31713
31743
|
}
|
|
31714
|
-
|
|
31715
|
-
|
|
31716
|
-
|
|
31717
|
-
|
|
31718
|
-
|
|
31719
|
-
|
|
31720
|
-
|
|
31721
|
-
|
|
31722
|
-
|
|
31723
|
-
|
|
31724
|
-
throw new NotFoundError("Timeback integration", gameId);
|
|
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);
|
|
31725
31754
|
}
|
|
31726
|
-
|
|
31727
|
-
|
|
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));
|
|
31728
31769
|
}
|
|
31729
|
-
|
|
31730
|
-
|
|
31731
|
-
|
|
31732
|
-
|
|
31733
|
-
|
|
31734
|
-
|
|
31735
|
-
|
|
31736
|
-
|
|
31737
|
-
|
|
31738
|
-
|
|
31739
|
-
|
|
31740
|
-
|
|
31741
|
-
|
|
31742
|
-
|
|
31743
|
-
|
|
31744
|
-
|
|
31745
|
-
|
|
31746
|
-
|
|
31747
|
-
|
|
31748
|
-
|
|
31749
|
-
|
|
31750
|
-
xpEarned,
|
|
31751
|
-
masteredUnits,
|
|
31752
|
-
extensions,
|
|
31753
|
-
user
|
|
31754
|
-
}) {
|
|
31755
|
-
const client = this.requireClient();
|
|
31756
|
-
const db2 = this.deps.db;
|
|
31757
|
-
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31758
|
-
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31759
|
-
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31760
|
-
});
|
|
31761
|
-
if (!integration) {
|
|
31762
|
-
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31763
|
-
}
|
|
31764
|
-
const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
|
|
31765
|
-
const result = await client.recordProgress(integration.courseId, studentId, {
|
|
31766
|
-
score: scorePercentage,
|
|
31767
|
-
totalQuestions: scoreData.totalQuestions,
|
|
31768
|
-
correctQuestions: scoreData.correctQuestions,
|
|
31769
|
-
durationSeconds: timingData.durationSeconds,
|
|
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
|
+
};
|
|
31782
|
+
}
|
|
31783
|
+
async endActivity({
|
|
31784
|
+
gameId,
|
|
31785
|
+
studentId,
|
|
31786
|
+
runId,
|
|
31787
|
+
activityData,
|
|
31788
|
+
scoreData,
|
|
31789
|
+
timingData,
|
|
31790
|
+
sessionTimingData,
|
|
31770
31791
|
xpEarned,
|
|
31771
31792
|
masteredUnits,
|
|
31772
31793
|
extensions,
|
|
31773
|
-
|
|
31774
|
-
|
|
31775
|
-
|
|
31776
|
-
|
|
31777
|
-
|
|
31778
|
-
|
|
31779
|
-
|
|
31780
|
-
|
|
31781
|
-
|
|
31782
|
-
|
|
31783
|
-
|
|
31784
|
-
|
|
31785
|
-
|
|
31786
|
-
|
|
31787
|
-
|
|
31788
|
-
|
|
31789
|
-
|
|
31790
|
-
|
|
31791
|
-
|
|
31792
|
-
|
|
31793
|
-
|
|
31794
|
-
|
|
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({
|
|
31795
31860
|
gameId,
|
|
31796
|
-
courseId: integration.courseId,
|
|
31797
31861
|
studentId,
|
|
31798
|
-
|
|
31799
|
-
|
|
31800
|
-
|
|
31801
|
-
|
|
31802
|
-
|
|
31803
|
-
|
|
31804
|
-
|
|
31805
|
-
|
|
31806
|
-
|
|
31807
|
-
|
|
31808
|
-
|
|
31809
|
-
|
|
31810
|
-
|
|
31811
|
-
|
|
31812
|
-
|
|
31813
|
-
|
|
31814
|
-
|
|
31815
|
-
|
|
31816
|
-
|
|
31817
|
-
if (options.grade !== undefined && options.subject) {
|
|
31818
|
-
conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
|
|
31819
|
-
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" };
|
|
31820
31881
|
}
|
|
31821
|
-
|
|
31822
|
-
|
|
31823
|
-
|
|
31824
|
-
|
|
31825
|
-
|
|
31826
|
-
|
|
31827
|
-
|
|
31828
|
-
|
|
31829
|
-
|
|
31830
|
-
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
|
|
31831
31891
|
});
|
|
31832
|
-
return
|
|
31833
|
-
|
|
31834
|
-
|
|
31835
|
-
|
|
31836
|
-
|
|
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);
|
|
31837
31935
|
}
|
|
31838
31936
|
}
|
|
31839
|
-
|
|
31840
|
-
|
|
31841
|
-
|
|
31842
|
-
|
|
31843
|
-
|
|
31844
|
-
|
|
31845
|
-
|
|
31846
|
-
|
|
31847
|
-
|
|
31848
|
-
|
|
31849
|
-
|
|
31850
|
-
|
|
31851
|
-
|
|
31852
|
-
|
|
31853
|
-
|
|
31854
|
-
|
|
31855
|
-
|
|
31856
|
-
|
|
31857
|
-
|
|
31858
|
-
|
|
31859
|
-
|
|
31860
|
-
|
|
31861
|
-
|
|
31862
|
-
|
|
31863
|
-
|
|
31864
|
-
|
|
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
|
+
};
|
|
31865
31981
|
});
|
|
31866
31982
|
|
|
31867
31983
|
// ../api-core/src/services/upload.service.ts
|
|
@@ -34988,6 +35104,7 @@ function createCaliperNamespace(client) {
|
|
|
34988
35104
|
email: data.studentEmail
|
|
34989
35105
|
},
|
|
34990
35106
|
action: TIMEBACK_ACTIONS4.completed,
|
|
35107
|
+
...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
|
|
34991
35108
|
object: {
|
|
34992
35109
|
id: data.objectId || caliper.buildActivityUrl(data),
|
|
34993
35110
|
type: TIMEBACK_TYPES4.activityContext,
|
|
@@ -35051,6 +35168,7 @@ function createCaliperNamespace(client) {
|
|
|
35051
35168
|
email: data.studentEmail
|
|
35052
35169
|
},
|
|
35053
35170
|
action: TIMEBACK_ACTIONS4.spentTime,
|
|
35171
|
+
...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
|
|
35054
35172
|
object: {
|
|
35055
35173
|
id: caliper.buildActivityUrl(data),
|
|
35056
35174
|
type: TIMEBACK_TYPES4.activityContext,
|
|
@@ -35084,7 +35202,7 @@ function createCaliperNamespace(client) {
|
|
|
35084
35202
|
},
|
|
35085
35203
|
buildActivityUrl: (data) => {
|
|
35086
35204
|
const base = data.sensorUrl.replace(/\/$/, "");
|
|
35087
|
-
return `${base}/activities/${data.courseId}/${data.activityId
|
|
35205
|
+
return `${base}/activities/${encodeURIComponent(data.courseId)}/${encodeURIComponent(data.activityId)}`;
|
|
35088
35206
|
}
|
|
35089
35207
|
};
|
|
35090
35208
|
return caliper;
|
|
@@ -36134,7 +36252,8 @@ class ProgressRecorder {
|
|
|
36134
36252
|
masteredUnits,
|
|
36135
36253
|
attemptNumber: currentAttemptNumber,
|
|
36136
36254
|
progressData,
|
|
36137
|
-
extensions
|
|
36255
|
+
extensions,
|
|
36256
|
+
runId: progressData.runId
|
|
36138
36257
|
});
|
|
36139
36258
|
return {
|
|
36140
36259
|
xpAwarded: calculatedXp,
|
|
@@ -36272,7 +36391,8 @@ class ProgressRecorder {
|
|
|
36272
36391
|
masteredUnits,
|
|
36273
36392
|
attemptNumber,
|
|
36274
36393
|
progressData,
|
|
36275
|
-
extensions
|
|
36394
|
+
extensions,
|
|
36395
|
+
runId
|
|
36276
36396
|
}) {
|
|
36277
36397
|
await this.caliperNamespace.emitActivityEvent({
|
|
36278
36398
|
studentId,
|
|
@@ -36289,7 +36409,8 @@ class ProgressRecorder {
|
|
|
36289
36409
|
subject: progressData.subject,
|
|
36290
36410
|
appName: progressData.appName,
|
|
36291
36411
|
sensorUrl: progressData.sensorUrl,
|
|
36292
|
-
extensions: extensions || progressData.extensions
|
|
36412
|
+
extensions: extensions || progressData.extensions,
|
|
36413
|
+
...runId ? { runId } : {}
|
|
36293
36414
|
}).catch((error) => {
|
|
36294
36415
|
log.error("[ProgressRecorder] Failed to emit activity event", { error });
|
|
36295
36416
|
});
|
|
@@ -36343,7 +36464,7 @@ class SessionRecorder {
|
|
|
36343
36464
|
const courseName = sessionData.courseName || "Game Course";
|
|
36344
36465
|
const student = await this.studentResolver.resolve(studentIdentifier, sessionData.studentEmail);
|
|
36345
36466
|
const { id: studentId, email: studentEmail } = student;
|
|
36346
|
-
const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions } = sessionData;
|
|
36467
|
+
const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions, runId } = sessionData;
|
|
36347
36468
|
await this.caliperNamespace.emitTimeSpentEvent({
|
|
36348
36469
|
studentId,
|
|
36349
36470
|
studentEmail,
|
|
@@ -36357,6 +36478,7 @@ class SessionRecorder {
|
|
|
36357
36478
|
subject: sessionData.subject,
|
|
36358
36479
|
appName: sessionData.appName,
|
|
36359
36480
|
sensorUrl: sessionData.sensorUrl,
|
|
36481
|
+
...runId ? { runId } : {},
|
|
36360
36482
|
...extensions ? { extensions } : {}
|
|
36361
36483
|
});
|
|
36362
36484
|
}
|
|
@@ -93633,7 +93755,7 @@ function isValidAdminAttributionDate(value) {
|
|
|
93633
93755
|
const date4 = new Date(Date.UTC(year3, month - 1, day, 12, 0, 0));
|
|
93634
93756
|
return date4.getUTCFullYear() === year3 && date4.getUTCMonth() + 1 === month && date4.getUTCDate() === day;
|
|
93635
93757
|
}
|
|
93636
|
-
var TIMEBACK_GRADES, TIMEBACK_SUBJECTS5, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, EndActivityRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema;
|
|
93758
|
+
var TIMEBACK_GRADES, TIMEBACK_SUBJECTS5, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema;
|
|
93637
93759
|
var init_schemas11 = __esm(() => {
|
|
93638
93760
|
init_esm();
|
|
93639
93761
|
TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
|
@@ -93656,31 +93778,49 @@ var init_schemas11 = __esm(() => {
|
|
|
93656
93778
|
xp: exports_external.number().min(0, "XP must be a non-negative number"),
|
|
93657
93779
|
userTimestamp: exports_external.string().datetime().optional()
|
|
93658
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
|
+
});
|
|
93659
93792
|
EndActivityRequestSchema = exports_external.object({
|
|
93660
93793
|
gameId: exports_external.string().uuid(),
|
|
93661
93794
|
studentId: exports_external.string().min(1),
|
|
93662
|
-
|
|
93663
|
-
|
|
93664
|
-
activityName: exports_external.string().optional(),
|
|
93665
|
-
grade: TimebackGradeSchema,
|
|
93666
|
-
subject: TimebackSubjectSchema,
|
|
93667
|
-
appName: exports_external.string().optional(),
|
|
93668
|
-
sensorUrl: exports_external.string().url().optional(),
|
|
93669
|
-
courseId: exports_external.string().optional(),
|
|
93670
|
-
courseName: exports_external.string().optional(),
|
|
93671
|
-
studentEmail: exports_external.string().email().optional()
|
|
93672
|
-
}),
|
|
93795
|
+
runId: exports_external.string().uuid().optional(),
|
|
93796
|
+
activityData: TimebackActivityDataSchema,
|
|
93673
93797
|
scoreData: exports_external.object({
|
|
93674
93798
|
correctQuestions: exports_external.number().int().min(0),
|
|
93675
93799
|
totalQuestions: exports_external.number().int().min(0)
|
|
93676
93800
|
}),
|
|
93677
93801
|
timingData: exports_external.object({
|
|
93678
|
-
durationSeconds: exports_external.number().
|
|
93802
|
+
durationSeconds: exports_external.number().nonnegative()
|
|
93679
93803
|
}),
|
|
93804
|
+
sessionTimingData: exports_external.object({
|
|
93805
|
+
activeSeconds: exports_external.number().nonnegative(),
|
|
93806
|
+
inactiveSeconds: exports_external.number().nonnegative().optional()
|
|
93807
|
+
}).optional(),
|
|
93680
93808
|
xpEarned: exports_external.number().optional(),
|
|
93681
93809
|
masteredUnits: exports_external.number().nonnegative().optional(),
|
|
93682
93810
|
extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
|
|
93683
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
|
+
});
|
|
93684
93824
|
PopulateStudentRequestSchema = exports_external.object({
|
|
93685
93825
|
firstName: exports_external.string().min(1).optional(),
|
|
93686
93826
|
lastName: exports_external.string().min(1).optional()
|
|
@@ -95926,7 +96066,7 @@ var init_sprite_controller = __esm(() => {
|
|
|
95926
96066
|
});
|
|
95927
96067
|
|
|
95928
96068
|
// ../api-core/src/controllers/timeback.controller.ts
|
|
95929
|
-
var logger63, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, getStudentXp, getRoster, getStudentOverview, getStudentActivity, grantXp, adjustTime, adjustMastery, toggleCompletion, searchStudents, enrollStudent, unenrollStudent, 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;
|
|
95930
96070
|
var init_timeback_controller = __esm(() => {
|
|
95931
96071
|
init_esm();
|
|
95932
96072
|
init_schemas_index();
|
|
@@ -96076,9 +96216,11 @@ var init_timeback_controller = __esm(() => {
|
|
|
96076
96216
|
const {
|
|
96077
96217
|
gameId,
|
|
96078
96218
|
studentId,
|
|
96219
|
+
runId,
|
|
96079
96220
|
activityData,
|
|
96080
96221
|
scoreData,
|
|
96081
96222
|
timingData,
|
|
96223
|
+
sessionTimingData,
|
|
96082
96224
|
xpEarned,
|
|
96083
96225
|
masteredUnits,
|
|
96084
96226
|
extensions
|
|
@@ -96087,15 +96229,50 @@ var init_timeback_controller = __esm(() => {
|
|
|
96087
96229
|
return ctx.services.timeback.endActivity({
|
|
96088
96230
|
gameId,
|
|
96089
96231
|
studentId,
|
|
96232
|
+
runId,
|
|
96090
96233
|
activityData,
|
|
96091
96234
|
scoreData,
|
|
96092
96235
|
timingData,
|
|
96236
|
+
sessionTimingData,
|
|
96093
96237
|
xpEarned,
|
|
96094
96238
|
masteredUnits,
|
|
96095
96239
|
extensions,
|
|
96096
96240
|
user: ctx.user
|
|
96097
96241
|
});
|
|
96098
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
|
+
});
|
|
96099
96276
|
getStudentXp = requireDeveloper(async (ctx) => {
|
|
96100
96277
|
const timebackId = ctx.params.timebackId;
|
|
96101
96278
|
if (!timebackId) {
|
|
@@ -96292,6 +96469,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
96292
96469
|
getConfig: getConfig2,
|
|
96293
96470
|
deleteIntegrations,
|
|
96294
96471
|
endActivity,
|
|
96472
|
+
heartbeat,
|
|
96295
96473
|
getStudentXp,
|
|
96296
96474
|
getRoster,
|
|
96297
96475
|
getStudentOverview,
|
|
@@ -97343,6 +97521,7 @@ var init_timeback6 = __esm(() => {
|
|
|
97343
97521
|
timebackRouter.get("/config/:gameId", handle2(timeback2.getConfig));
|
|
97344
97522
|
timebackRouter.delete("/integrations/:gameId", handle2(timeback2.deleteIntegrations, { status: 204 }));
|
|
97345
97523
|
timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
|
|
97524
|
+
timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
|
|
97346
97525
|
timebackRouter.get("/user", async (c2) => {
|
|
97347
97526
|
const user = c2.get("user");
|
|
97348
97527
|
const gameId = c2.get("gameId");
|