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