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

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