@playcademy/sandbox 0.3.17-beta.7 → 0.3.17-beta.9
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/cli.js
CHANGED
|
@@ -399,7 +399,8 @@ var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME =
|
|
|
399
399
|
var init_timeback2 = __esm(() => {
|
|
400
400
|
TIMEBACK_ROUTES = {
|
|
401
401
|
END_ACTIVITY: "/integrations/timeback/end-activity",
|
|
402
|
-
GET_XP: "/integrations/timeback/xp"
|
|
402
|
+
GET_XP: "/integrations/timeback/xp",
|
|
403
|
+
HEARTBEAT: "/integrations/timeback/heartbeat"
|
|
403
404
|
};
|
|
404
405
|
TIMEBACK_COURSE_DEFAULTS = {
|
|
405
406
|
gradingScheme: "STANDARD",
|
|
@@ -1310,7 +1311,7 @@ var package_default;
|
|
|
1310
1311
|
var init_package = __esm(() => {
|
|
1311
1312
|
package_default = {
|
|
1312
1313
|
name: "@playcademy/sandbox",
|
|
1313
|
-
version: "0.3.17-beta.
|
|
1314
|
+
version: "0.3.17-beta.9",
|
|
1314
1315
|
description: "Local development server for Playcademy game development",
|
|
1315
1316
|
type: "module",
|
|
1316
1317
|
exports: {
|
|
@@ -28877,7 +28878,8 @@ var init_constants3 = __esm(() => {
|
|
|
28877
28878
|
HEALTH: "/api/health",
|
|
28878
28879
|
TIMEBACK: {
|
|
28879
28880
|
END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
|
|
28880
|
-
GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}
|
|
28881
|
+
GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
|
|
28882
|
+
HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`
|
|
28881
28883
|
}
|
|
28882
28884
|
};
|
|
28883
28885
|
});
|
|
@@ -31280,589 +31282,703 @@ var init_timeback_admin_service = __esm(() => {
|
|
|
31280
31282
|
});
|
|
31281
31283
|
|
|
31282
31284
|
// ../api-core/src/services/timeback.service.ts
|
|
31283
|
-
|
|
31284
|
-
|
|
31285
|
-
|
|
31286
|
-
|
|
31287
|
-
|
|
31288
|
-
|
|
31289
|
-
|
|
31290
|
-
|
|
31291
|
-
|
|
31285
|
+
var logger17, TimebackService;
|
|
31286
|
+
var init_timeback_service = __esm(() => {
|
|
31287
|
+
init_drizzle_orm();
|
|
31288
|
+
init_src();
|
|
31289
|
+
init_tables_index();
|
|
31290
|
+
init_src2();
|
|
31291
|
+
init_types4();
|
|
31292
|
+
init_src4();
|
|
31293
|
+
init_errors();
|
|
31294
|
+
init_timeback_util();
|
|
31295
|
+
logger17 = log.scope("TimebackService");
|
|
31296
|
+
TimebackService = class TimebackService {
|
|
31297
|
+
static HEARTBEAT_DEDUPE_TTL_MS = 5 * 60 * 1000;
|
|
31298
|
+
static processedHeartbeatWindows = new Map;
|
|
31299
|
+
static inFlightHeartbeatWindows = new Map;
|
|
31300
|
+
deps;
|
|
31301
|
+
static cleanHeartbeatDedupeCache(now2 = Date.now()) {
|
|
31302
|
+
for (const [key, timestamp3] of this.processedHeartbeatWindows) {
|
|
31303
|
+
if (now2 - timestamp3 > this.HEARTBEAT_DEDUPE_TTL_MS) {
|
|
31304
|
+
this.processedHeartbeatWindows.delete(key);
|
|
31305
|
+
}
|
|
31306
|
+
}
|
|
31292
31307
|
}
|
|
31293
|
-
|
|
31294
|
-
|
|
31295
|
-
|
|
31296
|
-
const db2 = this.deps.db;
|
|
31297
|
-
const tz = timezone2 || PLATFORM_TIMEZONE;
|
|
31298
|
-
const base = date3 ? new Date(date3) : new Date;
|
|
31299
|
-
if (isNaN(base.getTime())) {
|
|
31300
|
-
throw new ValidationError("Invalid date format. Use ISO 8601 format.");
|
|
31308
|
+
static isDuplicateHeartbeatWindow(key) {
|
|
31309
|
+
this.cleanHeartbeatDedupeCache();
|
|
31310
|
+
return this.processedHeartbeatWindows.has(key);
|
|
31301
31311
|
}
|
|
31302
|
-
|
|
31303
|
-
|
|
31304
|
-
} catch {
|
|
31305
|
-
throw new ValidationError(`Invalid timezone: ${tz}`);
|
|
31312
|
+
static getInFlightHeartbeatWindow(key) {
|
|
31313
|
+
return this.inFlightHeartbeatWindows.get(key);
|
|
31306
31314
|
}
|
|
31307
|
-
|
|
31308
|
-
|
|
31309
|
-
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);
|
|
31310
|
-
if (result2.length === 0) {
|
|
31311
|
-
return { xp: 0, date: todayMidnight.toISOString() };
|
|
31312
|
-
}
|
|
31313
|
-
return { xp: result2[0].xp, date: result2[0].date.toISOString() };
|
|
31315
|
+
static markHeartbeatWindowProcessed(key) {
|
|
31316
|
+
this.processedHeartbeatWindows.set(key, Date.now());
|
|
31314
31317
|
}
|
|
31315
|
-
|
|
31316
|
-
|
|
31317
|
-
return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
|
|
31318
|
-
}
|
|
31319
|
-
async getTotalXp(userId) {
|
|
31320
|
-
const db2 = this.deps.db;
|
|
31321
|
-
const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
|
|
31322
|
-
return { totalXp: Number(result[0]?.totalXp) || 0 };
|
|
31323
|
-
}
|
|
31324
|
-
async updateTodayXp(userId, data) {
|
|
31325
|
-
const db2 = this.deps.db;
|
|
31326
|
-
const { xp, userTimestamp } = data;
|
|
31327
|
-
let targetDate;
|
|
31328
|
-
if (userTimestamp) {
|
|
31329
|
-
targetDate = new Date(userTimestamp);
|
|
31330
|
-
if (isNaN(targetDate.getTime())) {
|
|
31331
|
-
throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
|
|
31332
|
-
}
|
|
31333
|
-
targetDate.setHours(0, 0, 0, 0);
|
|
31334
|
-
} else {
|
|
31335
|
-
targetDate = new Date;
|
|
31336
|
-
targetDate.setUTCHours(0, 0, 0, 0);
|
|
31318
|
+
static markHeartbeatWindowInFlight(key, promise) {
|
|
31319
|
+
this.inFlightHeartbeatWindows.set(key, promise);
|
|
31337
31320
|
}
|
|
31338
|
-
|
|
31339
|
-
|
|
31340
|
-
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31341
|
-
}).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
|
|
31342
|
-
if (!result) {
|
|
31343
|
-
logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
|
|
31344
|
-
throw new InternalError("Failed to update daily XP record");
|
|
31321
|
+
static clearInFlightHeartbeatWindow(key) {
|
|
31322
|
+
this.inFlightHeartbeatWindows.delete(key);
|
|
31345
31323
|
}
|
|
31346
|
-
|
|
31347
|
-
|
|
31348
|
-
async getXpHistory(userId, startDate, endDate) {
|
|
31349
|
-
const db2 = this.deps.db;
|
|
31350
|
-
const whereConditions = [eq(timebackDailyXp.userId, userId)];
|
|
31351
|
-
if (startDate) {
|
|
31352
|
-
const start2 = new Date(startDate);
|
|
31353
|
-
start2.setUTCHours(0, 0, 0, 0);
|
|
31354
|
-
whereConditions.push(gte(timebackDailyXp.date, start2));
|
|
31324
|
+
constructor(deps) {
|
|
31325
|
+
this.deps = deps;
|
|
31355
31326
|
}
|
|
31356
|
-
|
|
31357
|
-
|
|
31358
|
-
|
|
31359
|
-
|
|
31327
|
+
requireClient() {
|
|
31328
|
+
if (!this.deps.timeback) {
|
|
31329
|
+
logger17.error("Timeback client not available in context");
|
|
31330
|
+
throw new ValidationError("Timeback integration not available in this environment");
|
|
31331
|
+
}
|
|
31332
|
+
return this.deps.timeback;
|
|
31360
31333
|
}
|
|
31361
|
-
|
|
31362
|
-
|
|
31363
|
-
|
|
31364
|
-
|
|
31365
|
-
|
|
31366
|
-
|
|
31367
|
-
|
|
31368
|
-
|
|
31369
|
-
|
|
31370
|
-
|
|
31371
|
-
|
|
31372
|
-
|
|
31373
|
-
|
|
31374
|
-
|
|
31375
|
-
|
|
31334
|
+
async getTodayXp(userId, date3, timezone2) {
|
|
31335
|
+
const db2 = this.deps.db;
|
|
31336
|
+
const tz = timezone2 || PLATFORM_TIMEZONE;
|
|
31337
|
+
const base = date3 ? new Date(date3) : new Date;
|
|
31338
|
+
if (isNaN(base.getTime())) {
|
|
31339
|
+
throw new ValidationError("Invalid date format. Use ISO 8601 format.");
|
|
31340
|
+
}
|
|
31341
|
+
try {
|
|
31342
|
+
new Intl.DateTimeFormat(undefined, { timeZone: tz });
|
|
31343
|
+
} catch {
|
|
31344
|
+
throw new ValidationError(`Invalid timezone: ${tz}`);
|
|
31345
|
+
}
|
|
31346
|
+
if (tz === PLATFORM_TIMEZONE) {
|
|
31347
|
+
const todayMidnight = getUtcInstantForMidnight(base, tz);
|
|
31348
|
+
const result2 = await db2.select({ xp: timebackDailyXp.xp, date: timebackDailyXp.date }).from(timebackDailyXp).where(and(eq(timebackDailyXp.userId, userId), eq(timebackDailyXp.date, todayMidnight))).limit(1);
|
|
31349
|
+
if (result2.length === 0) {
|
|
31350
|
+
return { xp: 0, date: todayMidnight.toISOString() };
|
|
31351
|
+
}
|
|
31352
|
+
return { xp: result2[0].xp, date: result2[0].date.toISOString() };
|
|
31353
|
+
}
|
|
31354
|
+
const { startOfDay, endOfDay } = getDayBoundariesInTimezone(base, tz);
|
|
31355
|
+
const result = await db2.select({ totalXp: sum(timebackXpEvents.xpDelta) }).from(timebackXpEvents).where(and(eq(timebackXpEvents.userId, userId), gte(timebackXpEvents.occurredAt, startOfDay), lte(timebackXpEvents.occurredAt, new Date(endOfDay.getTime() - 1))));
|
|
31356
|
+
return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
|
|
31376
31357
|
}
|
|
31377
|
-
|
|
31378
|
-
|
|
31379
|
-
|
|
31380
|
-
|
|
31381
|
-
|
|
31382
|
-
|
|
31383
|
-
|
|
31384
|
-
|
|
31385
|
-
|
|
31386
|
-
|
|
31387
|
-
|
|
31388
|
-
|
|
31389
|
-
|
|
31358
|
+
async getTotalXp(userId) {
|
|
31359
|
+
const db2 = this.deps.db;
|
|
31360
|
+
const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
|
|
31361
|
+
return { totalXp: Number(result[0]?.totalXp) || 0 };
|
|
31362
|
+
}
|
|
31363
|
+
async updateTodayXp(userId, data) {
|
|
31364
|
+
const db2 = this.deps.db;
|
|
31365
|
+
const { xp, userTimestamp } = data;
|
|
31366
|
+
let targetDate;
|
|
31367
|
+
if (userTimestamp) {
|
|
31368
|
+
targetDate = new Date(userTimestamp);
|
|
31369
|
+
if (isNaN(targetDate.getTime())) {
|
|
31370
|
+
throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
|
|
31371
|
+
}
|
|
31372
|
+
targetDate.setHours(0, 0, 0, 0);
|
|
31373
|
+
} else {
|
|
31374
|
+
targetDate = new Date;
|
|
31375
|
+
targetDate.setUTCHours(0, 0, 0, 0);
|
|
31390
31376
|
}
|
|
31391
|
-
const
|
|
31392
|
-
|
|
31393
|
-
|
|
31394
|
-
|
|
31395
|
-
|
|
31396
|
-
|
|
31397
|
-
|
|
31398
|
-
email: user.email,
|
|
31399
|
-
roles: [
|
|
31400
|
-
{
|
|
31401
|
-
roleType: "primary",
|
|
31402
|
-
role: "student",
|
|
31403
|
-
org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
|
|
31404
|
-
}
|
|
31405
|
-
]
|
|
31406
|
-
});
|
|
31407
|
-
if (!response.sourcedIdPairs?.allocatedSourcedId) {
|
|
31408
|
-
return { status: "error", message: "Timeback did not return allocatedSourcedId" };
|
|
31377
|
+
const [result] = await db2.insert(timebackDailyXp).values({ userId, date: targetDate, xp }).onConflictDoUpdate({
|
|
31378
|
+
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31379
|
+
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31380
|
+
}).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
|
|
31381
|
+
if (!result) {
|
|
31382
|
+
logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
|
|
31383
|
+
throw new InternalError("Failed to update daily XP record");
|
|
31409
31384
|
}
|
|
31410
|
-
|
|
31411
|
-
name3 = `${providedNames.firstName} ${providedNames.lastName}`;
|
|
31412
|
-
logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
|
|
31385
|
+
return { xp: result.xp, date: result.date.toISOString() };
|
|
31413
31386
|
}
|
|
31414
|
-
|
|
31415
|
-
|
|
31416
|
-
|
|
31417
|
-
|
|
31418
|
-
|
|
31419
|
-
|
|
31420
|
-
|
|
31421
|
-
|
|
31387
|
+
async getXpHistory(userId, startDate, endDate) {
|
|
31388
|
+
const db2 = this.deps.db;
|
|
31389
|
+
const whereConditions = [eq(timebackDailyXp.userId, userId)];
|
|
31390
|
+
if (startDate) {
|
|
31391
|
+
const start2 = new Date(startDate);
|
|
31392
|
+
start2.setUTCHours(0, 0, 0, 0);
|
|
31393
|
+
whereConditions.push(gte(timebackDailyXp.date, start2));
|
|
31394
|
+
}
|
|
31395
|
+
if (endDate) {
|
|
31396
|
+
const end = new Date(endDate);
|
|
31397
|
+
end.setUTCHours(23, 59, 59, 999);
|
|
31398
|
+
whereConditions.push(lte(timebackDailyXp.date, end));
|
|
31399
|
+
}
|
|
31400
|
+
const result = await db2.select({ date: timebackDailyXp.date, xp: timebackDailyXp.xp }).from(timebackDailyXp).where(and(...whereConditions)).orderBy(timebackDailyXp.date);
|
|
31401
|
+
return {
|
|
31402
|
+
history: result.map((row) => ({ date: row.date.toISOString(), xp: row.xp }))
|
|
31403
|
+
};
|
|
31404
|
+
}
|
|
31405
|
+
async populateStudent(user, providedNames) {
|
|
31406
|
+
const client = this.requireClient();
|
|
31407
|
+
const db2 = this.deps.db;
|
|
31408
|
+
const dbUser = await db2.query.users.findFirst({
|
|
31409
|
+
where: eq(users.id, user.id),
|
|
31410
|
+
columns: { id: true, timebackId: true }
|
|
31411
|
+
});
|
|
31412
|
+
if (dbUser?.timebackId) {
|
|
31413
|
+
logger17.info("Student already onboarded", { userId: user.id });
|
|
31414
|
+
return { status: "already_populated" };
|
|
31415
|
+
}
|
|
31416
|
+
let timebackId;
|
|
31417
|
+
let name3;
|
|
31418
|
+
try {
|
|
31419
|
+
const existingUser = await client.oneroster.users.findByEmail(user.email);
|
|
31420
|
+
timebackId = existingUser.sourcedId;
|
|
31421
|
+
name3 = `${existingUser.givenName} ${existingUser.familyName}`;
|
|
31422
|
+
logger17.info("Found existing student in OneRoster", {
|
|
31423
|
+
userId: user.id,
|
|
31424
|
+
timebackId
|
|
31425
|
+
});
|
|
31426
|
+
} catch {
|
|
31427
|
+
if (!providedNames?.firstName || !providedNames?.lastName) {
|
|
31428
|
+
return { status: "no_record" };
|
|
31422
31429
|
}
|
|
31423
|
-
const
|
|
31424
|
-
|
|
31425
|
-
|
|
31426
|
-
|
|
31427
|
-
|
|
31428
|
-
|
|
31429
|
-
|
|
31430
|
+
const sourcedId = crypto.randomUUID();
|
|
31431
|
+
const response = await client.oneroster.users.create({
|
|
31432
|
+
sourcedId,
|
|
31433
|
+
status: "active",
|
|
31434
|
+
enabledUser: true,
|
|
31435
|
+
givenName: providedNames.firstName,
|
|
31436
|
+
familyName: providedNames.lastName,
|
|
31437
|
+
email: user.email,
|
|
31438
|
+
roles: [
|
|
31439
|
+
{
|
|
31440
|
+
roleType: "primary",
|
|
31441
|
+
role: "student",
|
|
31442
|
+
org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
|
|
31443
|
+
}
|
|
31444
|
+
]
|
|
31445
|
+
});
|
|
31446
|
+
if (!response.sourcedIdPairs?.allocatedSourcedId) {
|
|
31447
|
+
return { status: "error", message: "Timeback did not return allocatedSourcedId" };
|
|
31448
|
+
}
|
|
31449
|
+
timebackId = response.sourcedIdPairs.allocatedSourcedId;
|
|
31450
|
+
name3 = `${providedNames.firstName} ${providedNames.lastName}`;
|
|
31451
|
+
logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
|
|
31452
|
+
}
|
|
31453
|
+
const assessments = await this.fetchAssessments(timebackId);
|
|
31454
|
+
await db2.transaction(async (tx) => {
|
|
31455
|
+
if (assessments.length > 0) {
|
|
31456
|
+
const events = mapAssessmentsToXpEvents(user.id, assessments);
|
|
31457
|
+
for (const event of events) {
|
|
31458
|
+
try {
|
|
31459
|
+
await tx.insert(timebackXpEvents).values(event);
|
|
31460
|
+
} catch {}
|
|
31461
|
+
}
|
|
31462
|
+
const dailyMap = new Map;
|
|
31463
|
+
for (const a of assessments) {
|
|
31464
|
+
const xp = a.metadata?.xp;
|
|
31465
|
+
if (typeof xp === "number" && a.scoreDate) {
|
|
31466
|
+
const day = getUtcInstantForMidnight(new Date(a.scoreDate), PLATFORM_TIMEZONE);
|
|
31467
|
+
const key = day.toISOString();
|
|
31468
|
+
dailyMap.set(key, (dailyMap.get(key) || 0) + xp);
|
|
31469
|
+
}
|
|
31470
|
+
}
|
|
31471
|
+
if (dailyMap.size > 0) {
|
|
31472
|
+
const dailyRecords = [...dailyMap.entries()].map(([iso, xp]) => ({
|
|
31473
|
+
userId: user.id,
|
|
31474
|
+
date: new Date(iso),
|
|
31475
|
+
xp
|
|
31476
|
+
}));
|
|
31477
|
+
await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
|
|
31478
|
+
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31479
|
+
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31480
|
+
});
|
|
31430
31481
|
}
|
|
31431
31482
|
}
|
|
31432
|
-
|
|
31433
|
-
|
|
31483
|
+
const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
|
|
31484
|
+
if (!updated) {
|
|
31485
|
+
logger17.error("User Timeback ID update returned no rows", {
|
|
31434
31486
|
userId: user.id,
|
|
31435
|
-
|
|
31436
|
-
xp
|
|
31437
|
-
}));
|
|
31438
|
-
await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
|
|
31439
|
-
target: [timebackDailyXp.userId, timebackDailyXp.date],
|
|
31440
|
-
set: { xp: sql`excluded.xp`, updatedAt: new Date }
|
|
31487
|
+
timebackId
|
|
31441
31488
|
});
|
|
31489
|
+
throw new InternalError("Failed to update user with Timeback ID");
|
|
31442
31490
|
}
|
|
31443
|
-
}
|
|
31444
|
-
const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
|
|
31445
|
-
if (!updated) {
|
|
31446
|
-
logger17.error("User Timeback ID update returned no rows", {
|
|
31447
|
-
userId: user.id,
|
|
31448
|
-
timebackId
|
|
31449
|
-
});
|
|
31450
|
-
throw new InternalError("Failed to update user with Timeback ID");
|
|
31451
|
-
}
|
|
31452
|
-
});
|
|
31453
|
-
return { status: "ok" };
|
|
31454
|
-
}
|
|
31455
|
-
async fetchAssessments(studentSourcedId) {
|
|
31456
|
-
const client = this.requireClient();
|
|
31457
|
-
const allAssessments = [];
|
|
31458
|
-
const limit = 3000;
|
|
31459
|
-
const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
|
|
31460
|
-
let offset = 0;
|
|
31461
|
-
try {
|
|
31462
|
-
while (true) {
|
|
31463
|
-
const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
|
|
31464
|
-
allAssessments.push(...results);
|
|
31465
|
-
if (results.length < limit) {
|
|
31466
|
-
break;
|
|
31467
|
-
}
|
|
31468
|
-
offset += limit;
|
|
31469
|
-
}
|
|
31470
|
-
logger17.debug("Fetched assessments", {
|
|
31471
|
-
studentSourcedId,
|
|
31472
|
-
totalCount: allAssessments.length
|
|
31473
31491
|
});
|
|
31474
|
-
return
|
|
31475
|
-
} catch (error) {
|
|
31476
|
-
logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
|
|
31477
|
-
return [];
|
|
31478
|
-
}
|
|
31479
|
-
}
|
|
31480
|
-
async getUserData(userId, gameId) {
|
|
31481
|
-
const db2 = this.deps.db;
|
|
31482
|
-
const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
|
|
31483
|
-
if (!userData) {
|
|
31484
|
-
throw new NotFoundError("User", userId);
|
|
31492
|
+
return { status: "ok" };
|
|
31485
31493
|
}
|
|
31486
|
-
|
|
31487
|
-
|
|
31488
|
-
|
|
31489
|
-
|
|
31490
|
-
|
|
31491
|
-
|
|
31492
|
-
|
|
31493
|
-
|
|
31494
|
-
|
|
31495
|
-
|
|
31496
|
-
|
|
31497
|
-
|
|
31498
|
-
|
|
31499
|
-
|
|
31500
|
-
|
|
31501
|
-
|
|
31502
|
-
|
|
31503
|
-
|
|
31504
|
-
id: timebackId,
|
|
31505
|
-
role: profile.role,
|
|
31506
|
-
enrollments,
|
|
31507
|
-
organizations: profile.organizations
|
|
31508
|
-
};
|
|
31509
|
-
}
|
|
31510
|
-
async fetchStudentProfile(timebackId) {
|
|
31511
|
-
const client = this.requireClient();
|
|
31512
|
-
try {
|
|
31513
|
-
const user = await client.oneroster.users.get(timebackId);
|
|
31514
|
-
const primaryRole = user.roles.find((r) => r.roleType === "primary");
|
|
31515
|
-
const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
|
|
31516
|
-
const orgMap = new Map;
|
|
31517
|
-
if (user.primaryOrg) {
|
|
31518
|
-
orgMap.set(user.primaryOrg.sourcedId, {
|
|
31519
|
-
id: user.primaryOrg.sourcedId,
|
|
31520
|
-
name: user.primaryOrg.name ?? null,
|
|
31521
|
-
type: user.primaryOrg.type || "school",
|
|
31522
|
-
isPrimary: true
|
|
31494
|
+
async fetchAssessments(studentSourcedId) {
|
|
31495
|
+
const client = this.requireClient();
|
|
31496
|
+
const allAssessments = [];
|
|
31497
|
+
const limit = 3000;
|
|
31498
|
+
const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
|
|
31499
|
+
let offset = 0;
|
|
31500
|
+
try {
|
|
31501
|
+
while (true) {
|
|
31502
|
+
const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
|
|
31503
|
+
allAssessments.push(...results);
|
|
31504
|
+
if (results.length < limit) {
|
|
31505
|
+
break;
|
|
31506
|
+
}
|
|
31507
|
+
offset += limit;
|
|
31508
|
+
}
|
|
31509
|
+
logger17.debug("Fetched assessments", {
|
|
31510
|
+
studentSourcedId,
|
|
31511
|
+
totalCount: allAssessments.length
|
|
31523
31512
|
});
|
|
31513
|
+
return allAssessments;
|
|
31514
|
+
} catch (error) {
|
|
31515
|
+
logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
|
|
31516
|
+
return [];
|
|
31524
31517
|
}
|
|
31525
|
-
|
|
31526
|
-
|
|
31527
|
-
|
|
31528
|
-
|
|
31529
|
-
|
|
31530
|
-
|
|
31531
|
-
|
|
31518
|
+
}
|
|
31519
|
+
async getUserData(userId, gameId) {
|
|
31520
|
+
const db2 = this.deps.db;
|
|
31521
|
+
const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
|
|
31522
|
+
if (!userData) {
|
|
31523
|
+
throw new NotFoundError("User", userId);
|
|
31524
|
+
}
|
|
31525
|
+
if (!userData.timebackId) {
|
|
31526
|
+
throw new NotFoundError("Timeback account not found for user");
|
|
31527
|
+
}
|
|
31528
|
+
const [profile, allEnrollments] = await Promise.all([
|
|
31529
|
+
this.fetchStudentProfile(userData.timebackId),
|
|
31530
|
+
this.fetchEnrollments(userData.timebackId)
|
|
31531
|
+
]);
|
|
31532
|
+
const enrollments = gameId ? allEnrollments.filter((e) => e.gameId === gameId) : allEnrollments;
|
|
31533
|
+
const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
|
|
31534
|
+
const organizations = gameId && enrollmentOrgIds.size > 0 ? profile.organizations.filter((o) => enrollmentOrgIds.has(o.id)) : profile.organizations;
|
|
31535
|
+
return { id: userData.timebackId, role: profile.role, enrollments, organizations };
|
|
31536
|
+
}
|
|
31537
|
+
async getUserDataByTimebackId(timebackId) {
|
|
31538
|
+
const [profile, enrollments] = await Promise.all([
|
|
31539
|
+
this.fetchStudentProfile(timebackId),
|
|
31540
|
+
this.fetchEnrollments(timebackId)
|
|
31541
|
+
]);
|
|
31542
|
+
return {
|
|
31543
|
+
id: timebackId,
|
|
31544
|
+
role: profile.role,
|
|
31545
|
+
enrollments,
|
|
31546
|
+
organizations: profile.organizations
|
|
31547
|
+
};
|
|
31548
|
+
}
|
|
31549
|
+
async fetchStudentProfile(timebackId) {
|
|
31550
|
+
const client = this.requireClient();
|
|
31551
|
+
try {
|
|
31552
|
+
const user = await client.oneroster.users.get(timebackId);
|
|
31553
|
+
const primaryRole = user.roles.find((r) => r.roleType === "primary");
|
|
31554
|
+
const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
|
|
31555
|
+
const orgMap = new Map;
|
|
31556
|
+
if (user.primaryOrg) {
|
|
31557
|
+
orgMap.set(user.primaryOrg.sourcedId, {
|
|
31558
|
+
id: user.primaryOrg.sourcedId,
|
|
31559
|
+
name: user.primaryOrg.name ?? null,
|
|
31560
|
+
type: user.primaryOrg.type || "school",
|
|
31561
|
+
isPrimary: true
|
|
31532
31562
|
});
|
|
31533
31563
|
}
|
|
31564
|
+
for (const r of user.roles) {
|
|
31565
|
+
if (r.org && !orgMap.has(r.org.sourcedId)) {
|
|
31566
|
+
orgMap.set(r.org.sourcedId, {
|
|
31567
|
+
id: r.org.sourcedId,
|
|
31568
|
+
name: null,
|
|
31569
|
+
type: "school",
|
|
31570
|
+
isPrimary: false
|
|
31571
|
+
});
|
|
31572
|
+
}
|
|
31573
|
+
}
|
|
31574
|
+
return { role, organizations: [...orgMap.values()] };
|
|
31575
|
+
} catch {
|
|
31576
|
+
return { role: "student", organizations: [] };
|
|
31534
31577
|
}
|
|
31535
|
-
return { role, organizations: [...orgMap.values()] };
|
|
31536
|
-
} catch {
|
|
31537
|
-
return { role: "student", organizations: [] };
|
|
31538
31578
|
}
|
|
31539
|
-
|
|
31540
|
-
|
|
31541
|
-
|
|
31542
|
-
|
|
31543
|
-
|
|
31544
|
-
|
|
31545
|
-
|
|
31546
|
-
|
|
31579
|
+
async fetchEnrollments(timebackId) {
|
|
31580
|
+
const client = this.requireClient();
|
|
31581
|
+
const db2 = this.deps.db;
|
|
31582
|
+
try {
|
|
31583
|
+
const enrollments = await client.getEnrollments(timebackId);
|
|
31584
|
+
const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
|
|
31585
|
+
if (courseIds.length === 0) {
|
|
31586
|
+
return [];
|
|
31587
|
+
}
|
|
31588
|
+
const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
|
|
31589
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31590
|
+
where: inArray(gameTimebackIntegrations.courseId, courseIds)
|
|
31591
|
+
});
|
|
31592
|
+
return integrations.map((i2) => ({
|
|
31593
|
+
gameId: i2.gameId,
|
|
31594
|
+
grade: i2.grade,
|
|
31595
|
+
subject: i2.subject,
|
|
31596
|
+
courseId: i2.courseId,
|
|
31597
|
+
orgId: courseToSchool.get(i2.courseId)
|
|
31598
|
+
}));
|
|
31599
|
+
} catch {
|
|
31547
31600
|
return [];
|
|
31548
31601
|
}
|
|
31549
|
-
const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
|
|
31550
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31551
|
-
where: inArray(gameTimebackIntegrations.courseId, courseIds)
|
|
31552
|
-
});
|
|
31553
|
-
return integrations.map((i2) => ({
|
|
31554
|
-
gameId: i2.gameId,
|
|
31555
|
-
grade: i2.grade,
|
|
31556
|
-
subject: i2.subject,
|
|
31557
|
-
courseId: i2.courseId,
|
|
31558
|
-
orgId: courseToSchool.get(i2.courseId)
|
|
31559
|
-
}));
|
|
31560
|
-
} catch {
|
|
31561
|
-
return [];
|
|
31562
31602
|
}
|
|
31563
|
-
|
|
31564
|
-
|
|
31565
|
-
|
|
31566
|
-
|
|
31567
|
-
|
|
31568
|
-
|
|
31569
|
-
|
|
31570
|
-
|
|
31571
|
-
|
|
31572
|
-
|
|
31573
|
-
|
|
31574
|
-
|
|
31575
|
-
|
|
31576
|
-
|
|
31577
|
-
|
|
31578
|
-
const {
|
|
31579
|
-
subject: subjectInput,
|
|
31580
|
-
grade,
|
|
31581
|
-
title,
|
|
31582
|
-
courseCode,
|
|
31583
|
-
level,
|
|
31584
|
-
metadata: metadata2,
|
|
31585
|
-
totalXp: derivedTotalXp,
|
|
31586
|
-
masterableUnits: derivedMasterableUnits
|
|
31587
|
-
} = courseConfig;
|
|
31588
|
-
if (!isTimebackSubject(subjectInput)) {
|
|
31589
|
-
logger17.warn("Invalid Timeback subject in course config", {
|
|
31603
|
+
async setupIntegration(gameId, request, user) {
|
|
31604
|
+
const client = this.requireClient();
|
|
31605
|
+
const db2 = this.deps.db;
|
|
31606
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31607
|
+
const { courses, baseConfig, verbose } = request;
|
|
31608
|
+
const existing = await db2.query.gameTimebackIntegrations.findMany({
|
|
31609
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31610
|
+
});
|
|
31611
|
+
const integrations = [];
|
|
31612
|
+
const verboseData = [];
|
|
31613
|
+
for (const courseConfig of courses) {
|
|
31614
|
+
let applySuffix = function(text3) {
|
|
31615
|
+
return suffix ? `${text3} ${suffix}` : text3;
|
|
31616
|
+
};
|
|
31617
|
+
const {
|
|
31590
31618
|
subject: subjectInput,
|
|
31591
|
-
courseCode,
|
|
31592
|
-
title
|
|
31593
|
-
});
|
|
31594
|
-
throw new ValidationError(`Invalid subject "${subjectInput}"`);
|
|
31595
|
-
}
|
|
31596
|
-
if (!isTimebackGrade(grade)) {
|
|
31597
|
-
logger17.warn("Invalid Timeback grade in course config", {
|
|
31598
31619
|
grade,
|
|
31599
|
-
courseCode,
|
|
31600
|
-
title
|
|
31601
|
-
});
|
|
31602
|
-
throw new ValidationError(`Invalid grade "${grade}"`);
|
|
31603
|
-
}
|
|
31604
|
-
const subject = subjectInput;
|
|
31605
|
-
const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
|
|
31606
|
-
const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
|
|
31607
|
-
const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
|
|
31608
|
-
if (typeof totalXp !== "number") {
|
|
31609
|
-
logger17.warn("Course missing totalXp in Timeback config", {
|
|
31610
|
-
courseCode,
|
|
31611
|
-
title
|
|
31612
|
-
});
|
|
31613
|
-
throw new ValidationError(`Course "${title}" is missing totalXp`);
|
|
31614
|
-
}
|
|
31615
|
-
const suffix = baseConfig.component.titleSuffix || "";
|
|
31616
|
-
const fullConfig = {
|
|
31617
|
-
organization: baseConfig.organization,
|
|
31618
|
-
course: {
|
|
31619
31620
|
title,
|
|
31620
|
-
subjects: [subject],
|
|
31621
|
-
grades: [grade],
|
|
31622
31621
|
courseCode,
|
|
31623
31622
|
level,
|
|
31624
|
-
|
|
31625
|
-
|
|
31626
|
-
|
|
31627
|
-
|
|
31628
|
-
|
|
31629
|
-
|
|
31630
|
-
|
|
31631
|
-
|
|
31632
|
-
|
|
31633
|
-
|
|
31634
|
-
|
|
31635
|
-
baseMetadata: baseConfig.resource.metadata,
|
|
31636
|
-
subject,
|
|
31637
|
-
grade,
|
|
31638
|
-
totalXp,
|
|
31639
|
-
masterableUnits
|
|
31640
|
-
})
|
|
31641
|
-
},
|
|
31642
|
-
componentResource: {
|
|
31643
|
-
...baseConfig.componentResource,
|
|
31644
|
-
title: applySuffix(baseConfig.componentResource.title || "")
|
|
31645
|
-
}
|
|
31646
|
-
};
|
|
31647
|
-
const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
|
|
31648
|
-
if (existingIntegration) {
|
|
31649
|
-
await client.update(existingIntegration.courseId, fullConfig);
|
|
31650
|
-
const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
|
|
31651
|
-
if (updated) {
|
|
31652
|
-
integrations.push(this.toGameTimebackIntegration(updated));
|
|
31623
|
+
metadata: metadata2,
|
|
31624
|
+
totalXp: derivedTotalXp,
|
|
31625
|
+
masterableUnits: derivedMasterableUnits
|
|
31626
|
+
} = courseConfig;
|
|
31627
|
+
if (!isTimebackSubject(subjectInput)) {
|
|
31628
|
+
logger17.warn("Invalid Timeback subject in course config", {
|
|
31629
|
+
subject: subjectInput,
|
|
31630
|
+
courseCode,
|
|
31631
|
+
title
|
|
31632
|
+
});
|
|
31633
|
+
throw new ValidationError(`Invalid subject "${subjectInput}"`);
|
|
31653
31634
|
}
|
|
31654
|
-
|
|
31655
|
-
|
|
31656
|
-
|
|
31657
|
-
|
|
31658
|
-
|
|
31659
|
-
|
|
31660
|
-
|
|
31661
|
-
|
|
31635
|
+
if (!isTimebackGrade(grade)) {
|
|
31636
|
+
logger17.warn("Invalid Timeback grade in course config", {
|
|
31637
|
+
grade,
|
|
31638
|
+
courseCode,
|
|
31639
|
+
title
|
|
31640
|
+
});
|
|
31641
|
+
throw new ValidationError(`Invalid grade "${grade}"`);
|
|
31642
|
+
}
|
|
31643
|
+
const subject = subjectInput;
|
|
31644
|
+
const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
|
|
31645
|
+
const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
|
|
31646
|
+
const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
|
|
31647
|
+
if (typeof totalXp !== "number") {
|
|
31648
|
+
logger17.warn("Course missing totalXp in Timeback config", {
|
|
31649
|
+
courseCode,
|
|
31650
|
+
title
|
|
31651
|
+
});
|
|
31652
|
+
throw new ValidationError(`Course "${title}" is missing totalXp`);
|
|
31653
|
+
}
|
|
31654
|
+
const suffix = baseConfig.component.titleSuffix || "";
|
|
31655
|
+
const fullConfig = {
|
|
31656
|
+
organization: baseConfig.organization,
|
|
31657
|
+
course: {
|
|
31658
|
+
title,
|
|
31659
|
+
subjects: [subject],
|
|
31660
|
+
grades: [grade],
|
|
31661
|
+
courseCode,
|
|
31662
|
+
level,
|
|
31663
|
+
gradingScheme: "STANDARD",
|
|
31664
|
+
metadata: metadata2
|
|
31665
|
+
},
|
|
31666
|
+
component: {
|
|
31667
|
+
...baseConfig.component,
|
|
31668
|
+
title: applySuffix(baseConfig.component.title || `${title} Activities`)
|
|
31669
|
+
},
|
|
31670
|
+
resource: {
|
|
31671
|
+
...baseConfig.resource,
|
|
31672
|
+
title: applySuffix(baseConfig.resource.title || `${title} Game`),
|
|
31673
|
+
metadata: buildResourceMetadata({
|
|
31674
|
+
baseMetadata: baseConfig.resource.metadata,
|
|
31675
|
+
subject,
|
|
31676
|
+
grade,
|
|
31677
|
+
totalXp,
|
|
31678
|
+
masterableUnits
|
|
31679
|
+
})
|
|
31680
|
+
},
|
|
31681
|
+
componentResource: {
|
|
31682
|
+
...baseConfig.componentResource,
|
|
31683
|
+
title: applySuffix(baseConfig.componentResource.title || "")
|
|
31684
|
+
}
|
|
31685
|
+
};
|
|
31686
|
+
const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
|
|
31687
|
+
if (existingIntegration) {
|
|
31688
|
+
await client.update(existingIntegration.courseId, fullConfig);
|
|
31689
|
+
const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
|
|
31690
|
+
if (updated) {
|
|
31691
|
+
integrations.push(this.toGameTimebackIntegration(updated));
|
|
31692
|
+
}
|
|
31693
|
+
} else {
|
|
31694
|
+
const result = await client.setup(fullConfig, { verbose });
|
|
31695
|
+
const [integration] = await db2.insert(gameTimebackIntegrations).values({ gameId, courseId: result.courseId, grade, subject, totalXp }).returning();
|
|
31696
|
+
if (integration) {
|
|
31697
|
+
const dto = this.toGameTimebackIntegration(integration);
|
|
31698
|
+
integrations.push(dto);
|
|
31699
|
+
if (verbose && result.verboseData) {
|
|
31700
|
+
verboseData.push({ integration: dto, config: result.verboseData });
|
|
31701
|
+
}
|
|
31662
31702
|
}
|
|
31663
31703
|
}
|
|
31664
31704
|
}
|
|
31705
|
+
return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
|
|
31665
31706
|
}
|
|
31666
|
-
|
|
31667
|
-
|
|
31668
|
-
|
|
31669
|
-
|
|
31670
|
-
|
|
31671
|
-
|
|
31672
|
-
});
|
|
31673
|
-
return rows.map((row) => this.toGameTimebackIntegration(row));
|
|
31674
|
-
}
|
|
31675
|
-
async verifyIntegration(gameId, user) {
|
|
31676
|
-
const client = this.requireClient();
|
|
31677
|
-
const db2 = this.deps.db;
|
|
31678
|
-
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31679
|
-
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31680
|
-
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31681
|
-
});
|
|
31682
|
-
if (integrations.length === 0) {
|
|
31683
|
-
throw new NotFoundError("Timeback integration", gameId);
|
|
31707
|
+
async getIntegrations(gameId, user) {
|
|
31708
|
+
await this.deps.validateGameManagementAccess(user, gameId);
|
|
31709
|
+
const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
|
|
31710
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31711
|
+
});
|
|
31712
|
+
return rows.map((row) => this.toGameTimebackIntegration(row));
|
|
31684
31713
|
}
|
|
31685
|
-
|
|
31686
|
-
|
|
31687
|
-
const
|
|
31688
|
-
|
|
31689
|
-
const
|
|
31690
|
-
|
|
31691
|
-
|
|
31692
|
-
|
|
31693
|
-
integration
|
|
31694
|
-
|
|
31695
|
-
|
|
31696
|
-
|
|
31697
|
-
resources
|
|
31698
|
-
|
|
31699
|
-
|
|
31700
|
-
|
|
31701
|
-
|
|
31702
|
-
|
|
31703
|
-
|
|
31704
|
-
|
|
31705
|
-
|
|
31706
|
-
|
|
31707
|
-
|
|
31708
|
-
|
|
31709
|
-
|
|
31710
|
-
|
|
31711
|
-
|
|
31712
|
-
|
|
31713
|
-
|
|
31714
|
+
async verifyIntegration(gameId, user) {
|
|
31715
|
+
const client = this.requireClient();
|
|
31716
|
+
const db2 = this.deps.db;
|
|
31717
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31718
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31719
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31720
|
+
});
|
|
31721
|
+
if (integrations.length === 0) {
|
|
31722
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31723
|
+
}
|
|
31724
|
+
const now2 = new Date;
|
|
31725
|
+
const results = await Promise.all(integrations.map(async (integration) => {
|
|
31726
|
+
const resources = await client.verify(integration.courseId);
|
|
31727
|
+
const resourceValues = Object.values(resources);
|
|
31728
|
+
const allFound = resourceValues.every((r) => r.found);
|
|
31729
|
+
const errors3 = Object.entries(resources).filter(([_, r]) => !r.found).map(([name3]) => `${name3} not found`);
|
|
31730
|
+
const status = allFound ? "success" : "error";
|
|
31731
|
+
return {
|
|
31732
|
+
integration: this.toGameTimebackIntegration({
|
|
31733
|
+
...integration,
|
|
31734
|
+
lastVerifiedAt: now2
|
|
31735
|
+
}),
|
|
31736
|
+
resources,
|
|
31737
|
+
status,
|
|
31738
|
+
...errors3.length > 0 && { errors: errors3 }
|
|
31739
|
+
};
|
|
31740
|
+
}));
|
|
31741
|
+
await db2.update(gameTimebackIntegrations).set({ lastVerifiedAt: now2 }).where(eq(gameTimebackIntegrations.gameId, gameId));
|
|
31742
|
+
const overallStatus = results.every((r) => r.status === "success") ? "success" : "error";
|
|
31743
|
+
return { status: overallStatus, results };
|
|
31714
31744
|
}
|
|
31715
|
-
|
|
31716
|
-
|
|
31717
|
-
|
|
31718
|
-
|
|
31719
|
-
|
|
31720
|
-
|
|
31721
|
-
|
|
31722
|
-
|
|
31723
|
-
|
|
31724
|
-
|
|
31725
|
-
throw new NotFoundError("Timeback integration", gameId);
|
|
31745
|
+
async getConfig(gameId, user) {
|
|
31746
|
+
const client = this.requireClient();
|
|
31747
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31748
|
+
const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
|
|
31749
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31750
|
+
});
|
|
31751
|
+
if (!integration) {
|
|
31752
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31753
|
+
}
|
|
31754
|
+
return client.getConfig(integration.courseId);
|
|
31726
31755
|
}
|
|
31727
|
-
|
|
31728
|
-
|
|
31756
|
+
async deleteIntegrations(gameId, user) {
|
|
31757
|
+
const client = this.requireClient();
|
|
31758
|
+
const db2 = this.deps.db;
|
|
31759
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31760
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31761
|
+
where: eq(gameTimebackIntegrations.gameId, gameId)
|
|
31762
|
+
});
|
|
31763
|
+
if (integrations.length === 0) {
|
|
31764
|
+
throw new NotFoundError("Timeback integration", gameId);
|
|
31765
|
+
}
|
|
31766
|
+
for (const integration of integrations) {
|
|
31767
|
+
await client.cleanup(integration.courseId);
|
|
31768
|
+
}
|
|
31769
|
+
await db2.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
|
|
31729
31770
|
}
|
|
31730
|
-
|
|
31731
|
-
|
|
31732
|
-
|
|
31733
|
-
|
|
31734
|
-
|
|
31735
|
-
|
|
31736
|
-
|
|
31737
|
-
|
|
31738
|
-
|
|
31739
|
-
|
|
31740
|
-
|
|
31741
|
-
|
|
31742
|
-
|
|
31743
|
-
|
|
31744
|
-
|
|
31745
|
-
|
|
31746
|
-
|
|
31747
|
-
|
|
31748
|
-
|
|
31749
|
-
|
|
31750
|
-
|
|
31751
|
-
xpEarned,
|
|
31752
|
-
masteredUnits,
|
|
31753
|
-
extensions,
|
|
31754
|
-
user
|
|
31755
|
-
}) {
|
|
31756
|
-
const client = this.requireClient();
|
|
31757
|
-
const db2 = this.deps.db;
|
|
31758
|
-
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31759
|
-
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31760
|
-
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31761
|
-
});
|
|
31762
|
-
if (!integration) {
|
|
31763
|
-
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31764
|
-
}
|
|
31765
|
-
const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
|
|
31766
|
-
const result = await client.recordProgress(integration.courseId, studentId, {
|
|
31767
|
-
score: scorePercentage,
|
|
31768
|
-
totalQuestions: scoreData.totalQuestions,
|
|
31769
|
-
correctQuestions: scoreData.correctQuestions,
|
|
31770
|
-
durationSeconds: timingData.durationSeconds,
|
|
31771
|
+
toGameTimebackIntegration(integration) {
|
|
31772
|
+
return {
|
|
31773
|
+
id: integration.id,
|
|
31774
|
+
gameId: integration.gameId,
|
|
31775
|
+
courseId: integration.courseId,
|
|
31776
|
+
grade: integration.grade,
|
|
31777
|
+
subject: integration.subject,
|
|
31778
|
+
totalXp: integration.totalXp ?? null,
|
|
31779
|
+
createdAt: integration.createdAt,
|
|
31780
|
+
updatedAt: integration.updatedAt,
|
|
31781
|
+
lastVerifiedAt: integration.lastVerifiedAt ?? null
|
|
31782
|
+
};
|
|
31783
|
+
}
|
|
31784
|
+
async endActivity({
|
|
31785
|
+
gameId,
|
|
31786
|
+
studentId,
|
|
31787
|
+
runId,
|
|
31788
|
+
activityData,
|
|
31789
|
+
scoreData,
|
|
31790
|
+
timingData,
|
|
31791
|
+
sessionTimingData,
|
|
31771
31792
|
xpEarned,
|
|
31772
31793
|
masteredUnits,
|
|
31773
31794
|
extensions,
|
|
31774
|
-
|
|
31775
|
-
|
|
31776
|
-
|
|
31777
|
-
|
|
31778
|
-
|
|
31779
|
-
|
|
31780
|
-
|
|
31781
|
-
|
|
31782
|
-
|
|
31783
|
-
|
|
31784
|
-
|
|
31785
|
-
|
|
31786
|
-
|
|
31787
|
-
|
|
31788
|
-
|
|
31789
|
-
|
|
31790
|
-
|
|
31791
|
-
|
|
31792
|
-
|
|
31793
|
-
|
|
31794
|
-
|
|
31795
|
-
|
|
31795
|
+
user
|
|
31796
|
+
}) {
|
|
31797
|
+
const client = this.requireClient();
|
|
31798
|
+
const db2 = this.deps.db;
|
|
31799
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31800
|
+
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31801
|
+
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31802
|
+
});
|
|
31803
|
+
if (!integration) {
|
|
31804
|
+
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31805
|
+
}
|
|
31806
|
+
const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
|
|
31807
|
+
const result = await client.recordProgress(integration.courseId, studentId, {
|
|
31808
|
+
score: scorePercentage,
|
|
31809
|
+
totalQuestions: scoreData.totalQuestions,
|
|
31810
|
+
correctQuestions: scoreData.correctQuestions,
|
|
31811
|
+
durationSeconds: timingData.durationSeconds,
|
|
31812
|
+
xpEarned,
|
|
31813
|
+
masteredUnits,
|
|
31814
|
+
extensions,
|
|
31815
|
+
activityId: activityData.activityId,
|
|
31816
|
+
activityName: activityData.activityName,
|
|
31817
|
+
subject: activityData.subject,
|
|
31818
|
+
appName: activityData.appName,
|
|
31819
|
+
sensorUrl: activityData.sensorUrl,
|
|
31820
|
+
courseId: activityData.courseId,
|
|
31821
|
+
courseName: activityData.courseName,
|
|
31822
|
+
studentEmail: activityData.studentEmail,
|
|
31823
|
+
courseTotalXp: integration.totalXp,
|
|
31824
|
+
...runId ? { runId } : {}
|
|
31825
|
+
});
|
|
31826
|
+
const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
|
|
31827
|
+
const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
|
|
31828
|
+
if (sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0) {
|
|
31829
|
+
await client.recordSessionEnd(integration.courseId, studentId, {
|
|
31830
|
+
activeTimeSeconds: sessionEndActiveSeconds,
|
|
31831
|
+
...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
|
|
31832
|
+
activityId: activityData.activityId,
|
|
31833
|
+
activityName: activityData.activityName,
|
|
31834
|
+
subject: activityData.subject,
|
|
31835
|
+
appName: activityData.appName,
|
|
31836
|
+
sensorUrl: activityData.sensorUrl,
|
|
31837
|
+
courseId: activityData.courseId,
|
|
31838
|
+
courseName: activityData.courseName,
|
|
31839
|
+
studentEmail: activityData.studentEmail,
|
|
31840
|
+
...runId ? { runId } : {}
|
|
31841
|
+
});
|
|
31842
|
+
}
|
|
31843
|
+
logger17.info("Recorded activity completion", {
|
|
31844
|
+
gameId,
|
|
31845
|
+
courseId: integration.courseId,
|
|
31846
|
+
studentId,
|
|
31847
|
+
runId,
|
|
31848
|
+
score: scorePercentage
|
|
31849
|
+
});
|
|
31850
|
+
return {
|
|
31851
|
+
status: "ok",
|
|
31852
|
+
courseId: integration.courseId,
|
|
31853
|
+
xpAwarded: result.xpAwarded,
|
|
31854
|
+
masteredUnits: result.masteredUnitsApplied,
|
|
31855
|
+
pctCompleteApp: result.pctCompleteApp,
|
|
31856
|
+
scoreStatus: result.scoreStatus,
|
|
31857
|
+
inProgress: result.inProgress
|
|
31858
|
+
};
|
|
31859
|
+
}
|
|
31860
|
+
async recordHeartbeat({
|
|
31796
31861
|
gameId,
|
|
31797
|
-
courseId: integration.courseId,
|
|
31798
31862
|
studentId,
|
|
31799
|
-
|
|
31800
|
-
|
|
31801
|
-
|
|
31802
|
-
|
|
31803
|
-
|
|
31804
|
-
|
|
31805
|
-
|
|
31806
|
-
|
|
31807
|
-
|
|
31808
|
-
|
|
31809
|
-
|
|
31810
|
-
|
|
31811
|
-
|
|
31812
|
-
|
|
31813
|
-
|
|
31814
|
-
|
|
31815
|
-
|
|
31816
|
-
|
|
31817
|
-
|
|
31818
|
-
if (options.grade !== undefined && options.subject) {
|
|
31819
|
-
conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
|
|
31820
|
-
conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
|
|
31863
|
+
runId,
|
|
31864
|
+
activityData,
|
|
31865
|
+
timingData,
|
|
31866
|
+
windowSequence,
|
|
31867
|
+
isFinal,
|
|
31868
|
+
user
|
|
31869
|
+
}) {
|
|
31870
|
+
const client = this.requireClient();
|
|
31871
|
+
const db2 = this.deps.db;
|
|
31872
|
+
const heartbeatWindowKey = `${runId}:${windowSequence}`;
|
|
31873
|
+
if (TimebackService.isDuplicateHeartbeatWindow(heartbeatWindowKey)) {
|
|
31874
|
+
logger17.debug("Skipping duplicate heartbeat window", {
|
|
31875
|
+
gameId,
|
|
31876
|
+
studentId,
|
|
31877
|
+
runId,
|
|
31878
|
+
windowSequence,
|
|
31879
|
+
isFinal
|
|
31880
|
+
});
|
|
31881
|
+
return { status: "ok" };
|
|
31821
31882
|
}
|
|
31822
|
-
|
|
31823
|
-
|
|
31824
|
-
|
|
31825
|
-
|
|
31826
|
-
|
|
31827
|
-
|
|
31828
|
-
|
|
31829
|
-
|
|
31830
|
-
|
|
31831
|
-
subject: options.subject
|
|
31883
|
+
await this.deps.validateDeveloperAccess(user, gameId);
|
|
31884
|
+
const inFlightHeartbeat = TimebackService.getInFlightHeartbeatWindow(heartbeatWindowKey);
|
|
31885
|
+
if (inFlightHeartbeat) {
|
|
31886
|
+
logger17.debug("Joining in-flight heartbeat window", {
|
|
31887
|
+
gameId,
|
|
31888
|
+
studentId,
|
|
31889
|
+
runId,
|
|
31890
|
+
windowSequence,
|
|
31891
|
+
isFinal
|
|
31832
31892
|
});
|
|
31833
|
-
return
|
|
31834
|
-
|
|
31835
|
-
|
|
31836
|
-
|
|
31837
|
-
|
|
31893
|
+
return inFlightHeartbeat;
|
|
31894
|
+
}
|
|
31895
|
+
const pendingHeartbeat = (async () => {
|
|
31896
|
+
const integration = await db2.query.gameTimebackIntegrations.findFirst({
|
|
31897
|
+
where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
|
|
31898
|
+
});
|
|
31899
|
+
if (!integration) {
|
|
31900
|
+
throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
|
|
31901
|
+
}
|
|
31902
|
+
const activeTimeSeconds = timingData.activeMs / 1000;
|
|
31903
|
+
const inactiveTimeSeconds = timingData.pausedMs / 1000;
|
|
31904
|
+
if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
|
|
31905
|
+
await client.recordSessionEnd(integration.courseId, studentId, {
|
|
31906
|
+
activeTimeSeconds,
|
|
31907
|
+
...inactiveTimeSeconds > 0 ? { inactiveTimeSeconds } : {},
|
|
31908
|
+
activityId: activityData.activityId,
|
|
31909
|
+
activityName: activityData.activityName,
|
|
31910
|
+
subject: activityData.subject,
|
|
31911
|
+
appName: activityData.appName,
|
|
31912
|
+
sensorUrl: activityData.sensorUrl,
|
|
31913
|
+
courseId: activityData.courseId,
|
|
31914
|
+
courseName: activityData.courseName,
|
|
31915
|
+
studentEmail: activityData.studentEmail,
|
|
31916
|
+
...runId ? { runId } : {}
|
|
31917
|
+
});
|
|
31918
|
+
}
|
|
31919
|
+
TimebackService.markHeartbeatWindowProcessed(heartbeatWindowKey);
|
|
31920
|
+
logger17.debug("Recorded heartbeat", {
|
|
31921
|
+
gameId,
|
|
31922
|
+
courseId: integration.courseId,
|
|
31923
|
+
studentId,
|
|
31924
|
+
runId,
|
|
31925
|
+
windowSequence,
|
|
31926
|
+
activeTimeSeconds,
|
|
31927
|
+
isFinal
|
|
31928
|
+
});
|
|
31929
|
+
return { status: "ok" };
|
|
31930
|
+
})();
|
|
31931
|
+
TimebackService.markHeartbeatWindowInFlight(heartbeatWindowKey, pendingHeartbeat);
|
|
31932
|
+
try {
|
|
31933
|
+
return await pendingHeartbeat;
|
|
31934
|
+
} finally {
|
|
31935
|
+
TimebackService.clearInFlightHeartbeatWindow(heartbeatWindowKey);
|
|
31838
31936
|
}
|
|
31839
31937
|
}
|
|
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
|
-
|
|
31865
|
-
|
|
31938
|
+
async getStudentXp(timebackId, user, options) {
|
|
31939
|
+
const client = this.requireClient();
|
|
31940
|
+
const db2 = this.deps.db;
|
|
31941
|
+
let courseIds = [];
|
|
31942
|
+
if (options?.gameId) {
|
|
31943
|
+
await this.deps.validateDeveloperAccess(user, options.gameId);
|
|
31944
|
+
const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
|
|
31945
|
+
if (options.grade !== undefined && options.subject) {
|
|
31946
|
+
conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
|
|
31947
|
+
conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
|
|
31948
|
+
}
|
|
31949
|
+
const integrations = await db2.query.gameTimebackIntegrations.findMany({
|
|
31950
|
+
where: and(...conditions2)
|
|
31951
|
+
});
|
|
31952
|
+
courseIds = integrations.map((i2) => i2.courseId);
|
|
31953
|
+
if (courseIds.length === 0) {
|
|
31954
|
+
logger17.debug("No integrations found for game, returning 0 XP", {
|
|
31955
|
+
timebackId,
|
|
31956
|
+
gameId: options.gameId,
|
|
31957
|
+
grade: options.grade,
|
|
31958
|
+
subject: options.subject
|
|
31959
|
+
});
|
|
31960
|
+
return {
|
|
31961
|
+
totalXp: 0,
|
|
31962
|
+
...options?.include?.today && { todayXp: 0 },
|
|
31963
|
+
...options?.include?.perCourse && { courses: [] }
|
|
31964
|
+
};
|
|
31965
|
+
}
|
|
31966
|
+
}
|
|
31967
|
+
const result = await client.getStudentXp(timebackId, {
|
|
31968
|
+
courseIds: courseIds.length > 0 ? courseIds : undefined,
|
|
31969
|
+
include: options?.include
|
|
31970
|
+
});
|
|
31971
|
+
logger17.debug("Retrieved student XP", {
|
|
31972
|
+
timebackId,
|
|
31973
|
+
gameId: options?.gameId,
|
|
31974
|
+
grade: options?.grade,
|
|
31975
|
+
subject: options?.subject,
|
|
31976
|
+
totalXp: result.totalXp,
|
|
31977
|
+
courseCount: result.courses?.length
|
|
31978
|
+
});
|
|
31979
|
+
return result;
|
|
31980
|
+
}
|
|
31981
|
+
};
|
|
31866
31982
|
});
|
|
31867
31983
|
|
|
31868
31984
|
// ../api-core/src/services/upload.service.ts
|
|
@@ -34989,6 +35105,7 @@ function createCaliperNamespace(client) {
|
|
|
34989
35105
|
email: data.studentEmail
|
|
34990
35106
|
},
|
|
34991
35107
|
action: TIMEBACK_ACTIONS4.completed,
|
|
35108
|
+
...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
|
|
34992
35109
|
object: {
|
|
34993
35110
|
id: data.objectId || caliper.buildActivityUrl(data),
|
|
34994
35111
|
type: TIMEBACK_TYPES4.activityContext,
|
|
@@ -35052,6 +35169,7 @@ function createCaliperNamespace(client) {
|
|
|
35052
35169
|
email: data.studentEmail
|
|
35053
35170
|
},
|
|
35054
35171
|
action: TIMEBACK_ACTIONS4.spentTime,
|
|
35172
|
+
...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
|
|
35055
35173
|
object: {
|
|
35056
35174
|
id: caliper.buildActivityUrl(data),
|
|
35057
35175
|
type: TIMEBACK_TYPES4.activityContext,
|
|
@@ -35085,7 +35203,7 @@ function createCaliperNamespace(client) {
|
|
|
35085
35203
|
},
|
|
35086
35204
|
buildActivityUrl: (data) => {
|
|
35087
35205
|
const base = data.sensorUrl.replace(/\/$/, "");
|
|
35088
|
-
return `${base}/activities/${data.courseId}/${data.activityId
|
|
35206
|
+
return `${base}/activities/${encodeURIComponent(data.courseId)}/${encodeURIComponent(data.activityId)}`;
|
|
35089
35207
|
}
|
|
35090
35208
|
};
|
|
35091
35209
|
return caliper;
|
|
@@ -36135,7 +36253,8 @@ class ProgressRecorder {
|
|
|
36135
36253
|
masteredUnits,
|
|
36136
36254
|
attemptNumber: currentAttemptNumber,
|
|
36137
36255
|
progressData,
|
|
36138
|
-
extensions
|
|
36256
|
+
extensions,
|
|
36257
|
+
runId: progressData.runId
|
|
36139
36258
|
});
|
|
36140
36259
|
return {
|
|
36141
36260
|
xpAwarded: calculatedXp,
|
|
@@ -36273,7 +36392,8 @@ class ProgressRecorder {
|
|
|
36273
36392
|
masteredUnits,
|
|
36274
36393
|
attemptNumber,
|
|
36275
36394
|
progressData,
|
|
36276
|
-
extensions
|
|
36395
|
+
extensions,
|
|
36396
|
+
runId
|
|
36277
36397
|
}) {
|
|
36278
36398
|
await this.caliperNamespace.emitActivityEvent({
|
|
36279
36399
|
studentId,
|
|
@@ -36290,7 +36410,8 @@ class ProgressRecorder {
|
|
|
36290
36410
|
subject: progressData.subject,
|
|
36291
36411
|
appName: progressData.appName,
|
|
36292
36412
|
sensorUrl: progressData.sensorUrl,
|
|
36293
|
-
extensions: extensions || progressData.extensions
|
|
36413
|
+
extensions: extensions || progressData.extensions,
|
|
36414
|
+
...runId ? { runId } : {}
|
|
36294
36415
|
}).catch((error) => {
|
|
36295
36416
|
log.error("[ProgressRecorder] Failed to emit activity event", { error });
|
|
36296
36417
|
});
|
|
@@ -36344,7 +36465,7 @@ class SessionRecorder {
|
|
|
36344
36465
|
const courseName = sessionData.courseName || "Game Course";
|
|
36345
36466
|
const student = await this.studentResolver.resolve(studentIdentifier, sessionData.studentEmail);
|
|
36346
36467
|
const { id: studentId, email: studentEmail } = student;
|
|
36347
|
-
const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions } = sessionData;
|
|
36468
|
+
const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions, runId } = sessionData;
|
|
36348
36469
|
await this.caliperNamespace.emitTimeSpentEvent({
|
|
36349
36470
|
studentId,
|
|
36350
36471
|
studentEmail,
|
|
@@ -36358,6 +36479,7 @@ class SessionRecorder {
|
|
|
36358
36479
|
subject: sessionData.subject,
|
|
36359
36480
|
appName: sessionData.appName,
|
|
36360
36481
|
sensorUrl: sessionData.sensorUrl,
|
|
36482
|
+
...runId ? { runId } : {},
|
|
36361
36483
|
...extensions ? { extensions } : {}
|
|
36362
36484
|
});
|
|
36363
36485
|
}
|
|
@@ -93634,7 +93756,7 @@ function isValidAdminAttributionDate(value) {
|
|
|
93634
93756
|
const date4 = new Date(Date.UTC(year3, month - 1, day, 12, 0, 0));
|
|
93635
93757
|
return date4.getUTCFullYear() === year3 && date4.getUTCMonth() + 1 === month && date4.getUTCDate() === day;
|
|
93636
93758
|
}
|
|
93637
|
-
var TIMEBACK_GRADES, TIMEBACK_SUBJECTS5, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, EndActivityRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema;
|
|
93759
|
+
var TIMEBACK_GRADES, TIMEBACK_SUBJECTS5, TimebackGradeSchema, TimebackSubjectSchema, UpdateTimebackXpRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ToggleCourseCompletionRequestSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema;
|
|
93638
93760
|
var init_schemas11 = __esm(() => {
|
|
93639
93761
|
init_esm();
|
|
93640
93762
|
TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
|
@@ -93657,31 +93779,49 @@ var init_schemas11 = __esm(() => {
|
|
|
93657
93779
|
xp: exports_external.number().min(0, "XP must be a non-negative number"),
|
|
93658
93780
|
userTimestamp: exports_external.string().datetime().optional()
|
|
93659
93781
|
});
|
|
93782
|
+
TimebackActivityDataSchema = exports_external.object({
|
|
93783
|
+
activityId: exports_external.string().min(1),
|
|
93784
|
+
activityName: exports_external.string().optional(),
|
|
93785
|
+
grade: TimebackGradeSchema,
|
|
93786
|
+
subject: TimebackSubjectSchema,
|
|
93787
|
+
appName: exports_external.string().optional(),
|
|
93788
|
+
sensorUrl: exports_external.string().url().optional(),
|
|
93789
|
+
courseId: exports_external.string().optional(),
|
|
93790
|
+
courseName: exports_external.string().optional(),
|
|
93791
|
+
studentEmail: exports_external.string().email().optional()
|
|
93792
|
+
});
|
|
93660
93793
|
EndActivityRequestSchema = exports_external.object({
|
|
93661
93794
|
gameId: exports_external.string().uuid(),
|
|
93662
93795
|
studentId: exports_external.string().min(1),
|
|
93663
|
-
|
|
93664
|
-
|
|
93665
|
-
activityName: exports_external.string().optional(),
|
|
93666
|
-
grade: TimebackGradeSchema,
|
|
93667
|
-
subject: TimebackSubjectSchema,
|
|
93668
|
-
appName: exports_external.string().optional(),
|
|
93669
|
-
sensorUrl: exports_external.string().url().optional(),
|
|
93670
|
-
courseId: exports_external.string().optional(),
|
|
93671
|
-
courseName: exports_external.string().optional(),
|
|
93672
|
-
studentEmail: exports_external.string().email().optional()
|
|
93673
|
-
}),
|
|
93796
|
+
runId: exports_external.string().uuid().optional(),
|
|
93797
|
+
activityData: TimebackActivityDataSchema,
|
|
93674
93798
|
scoreData: exports_external.object({
|
|
93675
93799
|
correctQuestions: exports_external.number().int().min(0),
|
|
93676
93800
|
totalQuestions: exports_external.number().int().min(0)
|
|
93677
93801
|
}),
|
|
93678
93802
|
timingData: exports_external.object({
|
|
93679
|
-
durationSeconds: exports_external.number().
|
|
93803
|
+
durationSeconds: exports_external.number().nonnegative()
|
|
93680
93804
|
}),
|
|
93805
|
+
sessionTimingData: exports_external.object({
|
|
93806
|
+
activeSeconds: exports_external.number().nonnegative(),
|
|
93807
|
+
inactiveSeconds: exports_external.number().nonnegative().optional()
|
|
93808
|
+
}).optional(),
|
|
93681
93809
|
xpEarned: exports_external.number().optional(),
|
|
93682
93810
|
masteredUnits: exports_external.number().nonnegative().optional(),
|
|
93683
93811
|
extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
|
|
93684
93812
|
});
|
|
93813
|
+
HeartbeatRequestSchema = exports_external.object({
|
|
93814
|
+
gameId: exports_external.string().uuid(),
|
|
93815
|
+
studentId: exports_external.string().min(1),
|
|
93816
|
+
runId: exports_external.string().uuid(),
|
|
93817
|
+
activityData: TimebackActivityDataSchema,
|
|
93818
|
+
timingData: exports_external.object({
|
|
93819
|
+
activeMs: exports_external.number().nonnegative(),
|
|
93820
|
+
pausedMs: exports_external.number().nonnegative()
|
|
93821
|
+
}),
|
|
93822
|
+
windowSequence: exports_external.number().int().nonnegative(),
|
|
93823
|
+
isFinal: exports_external.boolean().optional()
|
|
93824
|
+
});
|
|
93685
93825
|
PopulateStudentRequestSchema = exports_external.object({
|
|
93686
93826
|
firstName: exports_external.string().min(1).optional(),
|
|
93687
93827
|
lastName: exports_external.string().min(1).optional()
|
|
@@ -95927,7 +96067,7 @@ var init_sprite_controller = __esm(() => {
|
|
|
95927
96067
|
});
|
|
95928
96068
|
|
|
95929
96069
|
// ../api-core/src/controllers/timeback.controller.ts
|
|
95930
|
-
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;
|
|
96070
|
+
var logger63, getTodayXp, getTotalXp, updateTodayXp, getXpHistory, populateStudent, getUser, getUserById, setupIntegration, getIntegrations, verifyIntegration, getConfig2, deleteIntegrations, endActivity, heartbeat, getStudentXp, getRoster, getStudentOverview, getStudentActivity, grantXp, adjustTime, adjustMastery, toggleCompletion, searchStudents, enrollStudent, unenrollStudent, timeback2;
|
|
95931
96071
|
var init_timeback_controller = __esm(() => {
|
|
95932
96072
|
init_esm();
|
|
95933
96073
|
init_schemas_index();
|
|
@@ -96077,9 +96217,11 @@ var init_timeback_controller = __esm(() => {
|
|
|
96077
96217
|
const {
|
|
96078
96218
|
gameId,
|
|
96079
96219
|
studentId,
|
|
96220
|
+
runId,
|
|
96080
96221
|
activityData,
|
|
96081
96222
|
scoreData,
|
|
96082
96223
|
timingData,
|
|
96224
|
+
sessionTimingData,
|
|
96083
96225
|
xpEarned,
|
|
96084
96226
|
masteredUnits,
|
|
96085
96227
|
extensions
|
|
@@ -96088,15 +96230,50 @@ var init_timeback_controller = __esm(() => {
|
|
|
96088
96230
|
return ctx.services.timeback.endActivity({
|
|
96089
96231
|
gameId,
|
|
96090
96232
|
studentId,
|
|
96233
|
+
runId,
|
|
96091
96234
|
activityData,
|
|
96092
96235
|
scoreData,
|
|
96093
96236
|
timingData,
|
|
96237
|
+
sessionTimingData,
|
|
96094
96238
|
xpEarned,
|
|
96095
96239
|
masteredUnits,
|
|
96096
96240
|
extensions,
|
|
96097
96241
|
user: ctx.user
|
|
96098
96242
|
});
|
|
96099
96243
|
});
|
|
96244
|
+
heartbeat = requireDeveloper(async (ctx) => {
|
|
96245
|
+
let body2;
|
|
96246
|
+
try {
|
|
96247
|
+
const json4 = await ctx.request.json();
|
|
96248
|
+
body2 = HeartbeatRequestSchema.parse(json4);
|
|
96249
|
+
} catch (error2) {
|
|
96250
|
+
if (error2 instanceof exports_external.ZodError) {
|
|
96251
|
+
const details = formatZodError(error2);
|
|
96252
|
+
logger63.warn("Heartbeat validation failed", { details });
|
|
96253
|
+
throw ApiError.unprocessableEntity("Validation failed", details);
|
|
96254
|
+
}
|
|
96255
|
+
throw ApiError.badRequest("Invalid JSON body");
|
|
96256
|
+
}
|
|
96257
|
+
const { gameId, studentId, runId, activityData, timingData, windowSequence, isFinal } = body2;
|
|
96258
|
+
logger63.debug("Recording heartbeat", {
|
|
96259
|
+
userId: ctx.user.id,
|
|
96260
|
+
gameId,
|
|
96261
|
+
runId,
|
|
96262
|
+
windowSequence,
|
|
96263
|
+
activeMs: timingData.activeMs,
|
|
96264
|
+
isFinal
|
|
96265
|
+
});
|
|
96266
|
+
return ctx.services.timeback.recordHeartbeat({
|
|
96267
|
+
gameId,
|
|
96268
|
+
studentId,
|
|
96269
|
+
runId,
|
|
96270
|
+
activityData,
|
|
96271
|
+
timingData,
|
|
96272
|
+
windowSequence,
|
|
96273
|
+
isFinal,
|
|
96274
|
+
user: ctx.user
|
|
96275
|
+
});
|
|
96276
|
+
});
|
|
96100
96277
|
getStudentXp = requireDeveloper(async (ctx) => {
|
|
96101
96278
|
const timebackId = ctx.params.timebackId;
|
|
96102
96279
|
if (!timebackId) {
|
|
@@ -96293,6 +96470,7 @@ var init_timeback_controller = __esm(() => {
|
|
|
96293
96470
|
getConfig: getConfig2,
|
|
96294
96471
|
deleteIntegrations,
|
|
96295
96472
|
endActivity,
|
|
96473
|
+
heartbeat,
|
|
96296
96474
|
getStudentXp,
|
|
96297
96475
|
getRoster,
|
|
96298
96476
|
getStudentOverview,
|
|
@@ -97344,6 +97522,7 @@ var init_timeback6 = __esm(() => {
|
|
|
97344
97522
|
timebackRouter.get("/config/:gameId", handle2(timeback2.getConfig));
|
|
97345
97523
|
timebackRouter.delete("/integrations/:gameId", handle2(timeback2.deleteIntegrations, { status: 204 }));
|
|
97346
97524
|
timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
|
|
97525
|
+
timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
|
|
97347
97526
|
timebackRouter.get("/user", async (c2) => {
|
|
97348
97527
|
const user = c2.get("user");
|
|
97349
97528
|
const gameId = c2.get("gameId");
|