@playcademy/sandbox 0.3.17-beta.7 → 0.3.17-beta.8

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