@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 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.7",
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
- class TimebackService {
31284
- deps;
31285
- constructor(deps) {
31286
- this.deps = deps;
31287
- }
31288
- requireClient() {
31289
- if (!this.deps.timeback) {
31290
- logger17.error("Timeback client not available in context");
31291
- throw new ValidationError("Timeback integration not available in this environment");
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
- return this.deps.timeback;
31294
- }
31295
- async getTodayXp(userId, date3, timezone2) {
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
- try {
31303
- new Intl.DateTimeFormat(undefined, { timeZone: tz });
31304
- } catch {
31305
- throw new ValidationError(`Invalid timezone: ${tz}`);
31312
+ static getInFlightHeartbeatWindow(key) {
31313
+ return this.inFlightHeartbeatWindows.get(key);
31306
31314
  }
31307
- if (tz === PLATFORM_TIMEZONE) {
31308
- const todayMidnight = getUtcInstantForMidnight(base, tz);
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
- const { startOfDay, endOfDay } = getDayBoundariesInTimezone(base, tz);
31316
- 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))));
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
- const [result] = await db2.insert(timebackDailyXp).values({ userId, date: targetDate, xp }).onConflictDoUpdate({
31339
- target: [timebackDailyXp.userId, timebackDailyXp.date],
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
- return { xp: result.xp, date: result.date.toISOString() };
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
- if (endDate) {
31357
- const end = new Date(endDate);
31358
- end.setUTCHours(23, 59, 59, 999);
31359
- whereConditions.push(lte(timebackDailyXp.date, end));
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
- const result = await db2.select({ date: timebackDailyXp.date, xp: timebackDailyXp.xp }).from(timebackDailyXp).where(and(...whereConditions)).orderBy(timebackDailyXp.date);
31362
- return {
31363
- history: result.map((row) => ({ date: row.date.toISOString(), xp: row.xp }))
31364
- };
31365
- }
31366
- async populateStudent(user, providedNames) {
31367
- const client = this.requireClient();
31368
- const db2 = this.deps.db;
31369
- const dbUser = await db2.query.users.findFirst({
31370
- where: eq(users.id, user.id),
31371
- columns: { id: true, timebackId: true }
31372
- });
31373
- if (dbUser?.timebackId) {
31374
- logger17.info("Student already onboarded", { userId: user.id });
31375
- return { status: "already_populated" };
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
- let timebackId;
31378
- let name3;
31379
- try {
31380
- const existingUser = await client.oneroster.users.findByEmail(user.email);
31381
- timebackId = existingUser.sourcedId;
31382
- name3 = `${existingUser.givenName} ${existingUser.familyName}`;
31383
- logger17.info("Found existing student in OneRoster", {
31384
- userId: user.id,
31385
- timebackId
31386
- });
31387
- } catch {
31388
- if (!providedNames?.firstName || !providedNames?.lastName) {
31389
- return { status: "no_record" };
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 sourcedId = crypto.randomUUID();
31392
- const response = await client.oneroster.users.create({
31393
- sourcedId,
31394
- status: "active",
31395
- enabledUser: true,
31396
- givenName: providedNames.firstName,
31397
- familyName: providedNames.lastName,
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
- timebackId = response.sourcedIdPairs.allocatedSourcedId;
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
- const assessments = await this.fetchAssessments(timebackId);
31415
- await db2.transaction(async (tx) => {
31416
- if (assessments.length > 0) {
31417
- const events = mapAssessmentsToXpEvents(user.id, assessments);
31418
- for (const event of events) {
31419
- try {
31420
- await tx.insert(timebackXpEvents).values(event);
31421
- } catch {}
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 dailyMap = new Map;
31424
- for (const a of assessments) {
31425
- const xp = a.metadata?.xp;
31426
- if (typeof xp === "number" && a.scoreDate) {
31427
- const day = getUtcInstantForMidnight(new Date(a.scoreDate), PLATFORM_TIMEZONE);
31428
- const key = day.toISOString();
31429
- dailyMap.set(key, (dailyMap.get(key) || 0) + xp);
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
- if (dailyMap.size > 0) {
31433
- const dailyRecords = [...dailyMap.entries()].map(([iso, xp]) => ({
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
- date: new Date(iso),
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 allAssessments;
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
- if (!userData.timebackId) {
31487
- throw new NotFoundError("Timeback account not found for user");
31488
- }
31489
- const [profile, allEnrollments] = await Promise.all([
31490
- this.fetchStudentProfile(userData.timebackId),
31491
- this.fetchEnrollments(userData.timebackId)
31492
- ]);
31493
- const enrollments = gameId ? allEnrollments.filter((e) => e.gameId === gameId) : allEnrollments;
31494
- const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
31495
- const organizations = gameId && enrollmentOrgIds.size > 0 ? profile.organizations.filter((o) => enrollmentOrgIds.has(o.id)) : profile.organizations;
31496
- return { id: userData.timebackId, role: profile.role, enrollments, organizations };
31497
- }
31498
- async getUserDataByTimebackId(timebackId) {
31499
- const [profile, enrollments] = await Promise.all([
31500
- this.fetchStudentProfile(timebackId),
31501
- this.fetchEnrollments(timebackId)
31502
- ]);
31503
- return {
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
- for (const r of user.roles) {
31526
- if (r.org && !orgMap.has(r.org.sourcedId)) {
31527
- orgMap.set(r.org.sourcedId, {
31528
- id: r.org.sourcedId,
31529
- name: null,
31530
- type: "school",
31531
- isPrimary: false
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
- async fetchEnrollments(timebackId) {
31541
- const client = this.requireClient();
31542
- const db2 = this.deps.db;
31543
- try {
31544
- const enrollments = await client.getEnrollments(timebackId);
31545
- const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
31546
- if (courseIds.length === 0) {
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
- async setupIntegration(gameId, request, user) {
31565
- const client = this.requireClient();
31566
- const db2 = this.deps.db;
31567
- await this.deps.validateDeveloperAccess(user, gameId);
31568
- const { courses, baseConfig, verbose } = request;
31569
- const existing = await db2.query.gameTimebackIntegrations.findMany({
31570
- where: eq(gameTimebackIntegrations.gameId, gameId)
31571
- });
31572
- const integrations = [];
31573
- const verboseData = [];
31574
- for (const courseConfig of courses) {
31575
- let applySuffix = function(text3) {
31576
- return suffix ? `${text3} ${suffix}` : text3;
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
- gradingScheme: "STANDARD",
31625
- metadata: metadata2
31626
- },
31627
- component: {
31628
- ...baseConfig.component,
31629
- title: applySuffix(baseConfig.component.title || `${title} Activities`)
31630
- },
31631
- resource: {
31632
- ...baseConfig.resource,
31633
- title: applySuffix(baseConfig.resource.title || `${title} Game`),
31634
- metadata: buildResourceMetadata({
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
- } else {
31655
- const result = await client.setup(fullConfig, { verbose });
31656
- const [integration] = await db2.insert(gameTimebackIntegrations).values({ gameId, courseId: result.courseId, grade, subject, totalXp }).returning();
31657
- if (integration) {
31658
- const dto = this.toGameTimebackIntegration(integration);
31659
- integrations.push(dto);
31660
- if (verbose && result.verboseData) {
31661
- verboseData.push({ integration: dto, config: result.verboseData });
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
- return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
31667
- }
31668
- async getIntegrations(gameId, user) {
31669
- await this.deps.validateGameManagementAccess(user, gameId);
31670
- const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
31671
- where: eq(gameTimebackIntegrations.gameId, gameId)
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
- const now2 = new Date;
31686
- const results = await Promise.all(integrations.map(async (integration) => {
31687
- const resources = await client.verify(integration.courseId);
31688
- const resourceValues = Object.values(resources);
31689
- const allFound = resourceValues.every((r) => r.found);
31690
- const errors3 = Object.entries(resources).filter(([_, r]) => !r.found).map(([name3]) => `${name3} not found`);
31691
- const status = allFound ? "success" : "error";
31692
- return {
31693
- integration: this.toGameTimebackIntegration({
31694
- ...integration,
31695
- lastVerifiedAt: now2
31696
- }),
31697
- resources,
31698
- status,
31699
- ...errors3.length > 0 && { errors: errors3 }
31700
- };
31701
- }));
31702
- await db2.update(gameTimebackIntegrations).set({ lastVerifiedAt: now2 }).where(eq(gameTimebackIntegrations.gameId, gameId));
31703
- const overallStatus = results.every((r) => r.status === "success") ? "success" : "error";
31704
- return { status: overallStatus, results };
31705
- }
31706
- async getConfig(gameId, user) {
31707
- const client = this.requireClient();
31708
- await this.deps.validateDeveloperAccess(user, gameId);
31709
- const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
31710
- where: eq(gameTimebackIntegrations.gameId, gameId)
31711
- });
31712
- if (!integration) {
31713
- throw new NotFoundError("Timeback integration", gameId);
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
- return client.getConfig(integration.courseId);
31716
- }
31717
- async deleteIntegrations(gameId, user) {
31718
- const client = this.requireClient();
31719
- const db2 = this.deps.db;
31720
- await this.deps.validateDeveloperAccess(user, gameId);
31721
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
31722
- where: eq(gameTimebackIntegrations.gameId, gameId)
31723
- });
31724
- if (integrations.length === 0) {
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
- for (const integration of integrations) {
31728
- await client.cleanup(integration.courseId);
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
- await db2.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
31731
- }
31732
- toGameTimebackIntegration(integration) {
31733
- return {
31734
- id: integration.id,
31735
- gameId: integration.gameId,
31736
- courseId: integration.courseId,
31737
- grade: integration.grade,
31738
- subject: integration.subject,
31739
- totalXp: integration.totalXp ?? null,
31740
- createdAt: integration.createdAt,
31741
- updatedAt: integration.updatedAt,
31742
- lastVerifiedAt: integration.lastVerifiedAt ?? null
31743
- };
31744
- }
31745
- async endActivity({
31746
- gameId,
31747
- studentId,
31748
- activityData,
31749
- scoreData,
31750
- timingData,
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
- activityId: activityData.activityId,
31775
- activityName: activityData.activityName,
31776
- subject: activityData.subject,
31777
- appName: activityData.appName,
31778
- sensorUrl: activityData.sensorUrl,
31779
- courseId: activityData.courseId,
31780
- courseName: activityData.courseName,
31781
- studentEmail: activityData.studentEmail,
31782
- courseTotalXp: integration.totalXp
31783
- });
31784
- await client.recordSessionEnd(integration.courseId, studentId, {
31785
- activeTimeSeconds: timingData.durationSeconds,
31786
- activityId: activityData.activityId,
31787
- activityName: activityData.activityName,
31788
- subject: activityData.subject,
31789
- appName: activityData.appName,
31790
- sensorUrl: activityData.sensorUrl,
31791
- courseId: activityData.courseId,
31792
- courseName: activityData.courseName,
31793
- studentEmail: activityData.studentEmail
31794
- });
31795
- logger17.info("Recorded activity completion", {
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
- score: scorePercentage
31800
- });
31801
- return {
31802
- status: "ok",
31803
- courseId: integration.courseId,
31804
- xpAwarded: result.xpAwarded,
31805
- masteredUnits: result.masteredUnitsApplied,
31806
- pctCompleteApp: result.pctCompleteApp,
31807
- scoreStatus: result.scoreStatus,
31808
- inProgress: result.inProgress
31809
- };
31810
- }
31811
- async getStudentXp(timebackId, user, options) {
31812
- const client = this.requireClient();
31813
- const db2 = this.deps.db;
31814
- let courseIds = [];
31815
- if (options?.gameId) {
31816
- await this.deps.validateDeveloperAccess(user, options.gameId);
31817
- const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
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
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
31823
- where: and(...conditions2)
31824
- });
31825
- courseIds = integrations.map((i2) => i2.courseId);
31826
- if (courseIds.length === 0) {
31827
- logger17.debug("No integrations found for game, returning 0 XP", {
31828
- timebackId,
31829
- gameId: options.gameId,
31830
- grade: options.grade,
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
- totalXp: 0,
31835
- ...options?.include?.today && { todayXp: 0 },
31836
- ...options?.include?.perCourse && { courses: [] }
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
- const result = await client.getStudentXp(timebackId, {
31841
- courseIds: courseIds.length > 0 ? courseIds : undefined,
31842
- include: options?.include
31843
- });
31844
- logger17.debug("Retrieved student XP", {
31845
- timebackId,
31846
- gameId: options?.gameId,
31847
- grade: options?.grade,
31848
- subject: options?.subject,
31849
- totalXp: result.totalXp,
31850
- courseCount: result.courses?.length
31851
- });
31852
- return result;
31853
- }
31854
- }
31855
- var logger17;
31856
- var init_timeback_service = __esm(() => {
31857
- init_drizzle_orm();
31858
- init_src();
31859
- init_tables_index();
31860
- init_src2();
31861
- init_types4();
31862
- init_src4();
31863
- init_errors();
31864
- init_timeback_util();
31865
- logger17 = log.scope("TimebackService");
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}/${crypto.randomUUID()}`;
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
- activityData: exports_external.object({
93664
- activityId: exports_external.string().min(1),
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().positive()
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");