@playcademy/vite-plugin 0.2.24-beta.3 → 0.2.24-beta.5

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.
Files changed (2) hide show
  1. package/dist/index.js +948 -571
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -24406,7 +24406,8 @@ var TIMEBACK_COMPONENT_RESOURCE_DEFAULTS;
24406
24406
  var init_timeback2 = __esm(() => {
24407
24407
  TIMEBACK_ROUTES = {
24408
24408
  END_ACTIVITY: "/integrations/timeback/end-activity",
24409
- GET_XP: "/integrations/timeback/xp"
24409
+ GET_XP: "/integrations/timeback/xp",
24410
+ HEARTBEAT: "/integrations/timeback/heartbeat"
24410
24411
  };
24411
24412
  TIMEBACK_COURSE_DEFAULTS = {
24412
24413
  gradingScheme: "STANDARD",
@@ -25335,7 +25336,7 @@ var package_default;
25335
25336
  var init_package = __esm(() => {
25336
25337
  package_default = {
25337
25338
  name: "@playcademy/sandbox",
25338
- version: "0.3.17-beta.6",
25339
+ version: "0.3.17-beta.8",
25339
25340
  description: "Local development server for Playcademy game development",
25340
25341
  type: "module",
25341
25342
  exports: {
@@ -35611,7 +35612,7 @@ var init_table6 = __esm(() => {
35611
35612
  init_drizzle_orm();
35612
35613
  init_pg_core();
35613
35614
  init_table5();
35614
- userRoleEnum = pgEnum("user_role", ["admin", "player", "developer"]);
35615
+ userRoleEnum = pgEnum("user_role", ["admin", "player", "developer", "teacher"]);
35615
35616
  developerStatusEnum = pgEnum("developer_status", ["none", "pending", "approved"]);
35616
35617
  users = pgTable("user", {
35617
35618
  id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
@@ -50583,10 +50584,13 @@ var init_game_service = __esm(() => {
50583
50584
  });
50584
50585
  }
50585
50586
  async listManageable(user) {
50586
- this.validateDeveloperStatus(user);
50587
+ const seesAllGames = user.role === "admin" || user.role === "teacher";
50588
+ if (!seesAllGames) {
50589
+ this.validateDeveloperStatus(user);
50590
+ }
50587
50591
  const db2 = this.deps.db;
50588
50592
  return db2.query.games.findMany({
50589
- where: user.role === "admin" ? undefined : eq(games.developerId, user.id),
50593
+ where: seesAllGames ? undefined : eq(games.developerId, user.id),
50590
50594
  orderBy: [desc(games.createdAt)]
50591
50595
  });
50592
50596
  }
@@ -50990,6 +50994,19 @@ var init_game_service = __esm(() => {
50990
50994
  throw new NotFoundError("Game", gameId);
50991
50995
  }
50992
50996
  }
50997
+ async validateGameManagementAccess(user, gameId) {
50998
+ if (user.role === "admin" || user.role === "teacher") {
50999
+ const gameExists = await this.deps.db.query.games.findFirst({
51000
+ where: eq(games.id, gameId),
51001
+ columns: { id: true }
51002
+ });
51003
+ if (!gameExists) {
51004
+ throw new NotFoundError("Game", gameId);
51005
+ }
51006
+ return;
51007
+ }
51008
+ return this.validateDeveloperAccess(user, gameId);
51009
+ }
50993
51010
  async validateDeveloperAccessBySlug(user, slug) {
50994
51011
  this.validateDeveloperStatus(user);
50995
51012
  const db2 = this.deps.db;
@@ -51058,6 +51075,7 @@ function createGameServices(deps) {
51058
51075
  validators: {
51059
51076
  validateDeveloperAccessBySlug: (user, slug) => game.validateDeveloperAccessBySlug(user, slug),
51060
51077
  validateDeveloperAccess: (user, gameId) => game.validateDeveloperAccess(user, gameId),
51078
+ validateGameManagementAccess: (user, gameId) => game.validateGameManagementAccess(user, gameId),
51061
51079
  validateOwnership: (user, gameId) => game.validateOwnership(user, gameId)
51062
51080
  }
51063
51081
  };
@@ -52683,7 +52701,8 @@ var init_constants3 = __esm(() => {
52683
52701
  HEALTH: "/api/health",
52684
52702
  TIMEBACK: {
52685
52703
  END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
52686
- GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`
52704
+ GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
52705
+ HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`
52687
52706
  }
52688
52707
  };
52689
52708
  });
@@ -54499,9 +54518,13 @@ class TimebackAdminService {
54499
54518
  });
54500
54519
  });
54501
54520
  }
54502
- async resolveAdminMutationContext(gameId, courseId, user, studentId) {
54521
+ async resolveAdminMutationContext(gameId, courseId, user, studentId, accessLevel = "developer") {
54503
54522
  const client = this.requireClient();
54504
- await this.deps.validateDeveloperAccess(user, gameId);
54523
+ if (accessLevel === "dashboard") {
54524
+ await this.deps.validateGameManagementAccess(user, gameId);
54525
+ } else {
54526
+ await this.deps.validateDeveloperAccess(user, gameId);
54527
+ }
54505
54528
  const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
54506
54529
  where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
54507
54530
  });
@@ -54761,7 +54784,7 @@ class TimebackAdminService {
54761
54784
  }
54762
54785
  async listStudentsForCourse(gameId, courseId, user) {
54763
54786
  const client = this.requireClient();
54764
- await this.deps.validateDeveloperAccess(user, gameId);
54787
+ await this.deps.validateGameManagementAccess(user, gameId);
54765
54788
  const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
54766
54789
  where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
54767
54790
  });
@@ -54799,7 +54822,7 @@ class TimebackAdminService {
54799
54822
  }
54800
54823
  async getStudentOverview(gameId, studentId, user, courseId) {
54801
54824
  const client = this.requireClient();
54802
- await this.deps.validateDeveloperAccess(user, gameId);
54825
+ await this.deps.validateGameManagementAccess(user, gameId);
54803
54826
  const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
54804
54827
  where: courseId ? and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId)) : eq(gameTimebackIntegrations.gameId, gameId)
54805
54828
  });
@@ -54853,7 +54876,7 @@ class TimebackAdminService {
54853
54876
  const client = this.requireClient();
54854
54877
  const safeLimit = Math.max(1, Math.min(limit, TimebackAdminService.MAX_STUDENT_ACTIVITY_LIMIT));
54855
54878
  const safeOffset = Math.max(0, Math.min(offset, TimebackAdminService.MAX_STUDENT_ACTIVITY_OFFSET));
54856
- await this.deps.validateDeveloperAccess(user, gameId);
54879
+ await this.deps.validateGameManagementAccess(user, gameId);
54857
54880
  const [integration, sensorUrl] = await Promise.all([
54858
54881
  this.deps.db.query.gameTimebackIntegrations.findFirst({
54859
54882
  where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
@@ -54917,7 +54940,7 @@ class TimebackAdminService {
54917
54940
  return { status: "ok" };
54918
54941
  }
54919
54942
  async toggleCourseCompletion(data, user) {
54920
- const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
54943
+ const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId, "dashboard");
54921
54944
  const historyClient = client;
54922
54945
  const ids = deriveSourcedIds(data.courseId);
54923
54946
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
@@ -55010,6 +55033,77 @@ class TimebackAdminService {
55010
55033
  }
55011
55034
  return { status: "ok" };
55012
55035
  }
55036
+ async searchStudentsForEnrollment(gameId, courseId, query, user) {
55037
+ const client = this.requireClient();
55038
+ await this.deps.validateGameManagementAccess(user, gameId);
55039
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55040
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.courseId, courseId))
55041
+ });
55042
+ if (!integration) {
55043
+ throw new NotFoundError("Timeback integration", `${gameId}:${courseId}`);
55044
+ }
55045
+ const trimmedQuery = query.trim();
55046
+ if (trimmedQuery.length < 2) {
55047
+ return { students: [] };
55048
+ }
55049
+ const filterParts = [
55050
+ `givenName~'${escapeFilterValue(trimmedQuery)}'`,
55051
+ `familyName~'${escapeFilterValue(trimmedQuery)}'`,
55052
+ `email~'${escapeFilterValue(trimmedQuery)}'`
55053
+ ];
55054
+ const filter = filterParts.join(" OR ");
55055
+ const params = new URLSearchParams({ filter, limit: "25" });
55056
+ const endpoint = `/ims/oneroster/rostering/v1p2/users?${params}`;
55057
+ let allUsers = [];
55058
+ try {
55059
+ const response = await client["request"](endpoint, "GET");
55060
+ allUsers = response.users || [];
55061
+ } catch (error) {
55062
+ logger16.warn("Failed to search OneRoster users", {
55063
+ query: trimmedQuery,
55064
+ error: error instanceof Error ? error.message : String(error)
55065
+ });
55066
+ return { students: [] };
55067
+ }
55068
+ const roster = await client.oneroster.enrollments.listByCourse(courseId, {
55069
+ role: "student",
55070
+ includeUsers: false
55071
+ });
55072
+ const enrolledStudentIds = new Set(roster.map((entry) => entry.enrollment.user.sourcedId));
55073
+ const students = allUsers.filter((entry) => Boolean(entry.sourcedId) && entry.roles?.some((role) => role.role === "student") === true).map((entry) => ({
55074
+ studentId: entry.sourcedId,
55075
+ name: `${entry.givenName || ""} ${entry.familyName || ""}`.trim() || entry.sourcedId,
55076
+ email: entry.email || null,
55077
+ alreadyEnrolled: enrolledStudentIds.has(entry.sourcedId)
55078
+ }));
55079
+ return { students };
55080
+ }
55081
+ async enrollStudent(data, user) {
55082
+ const client = this.requireClient();
55083
+ await this.deps.validateGameManagementAccess(user, data.gameId);
55084
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55085
+ where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
55086
+ });
55087
+ if (!integration) {
55088
+ throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
55089
+ }
55090
+ await client.edubridge.enrollments.enroll(data.studentId, data.courseId, {
55091
+ role: "student"
55092
+ });
55093
+ return { status: "ok" };
55094
+ }
55095
+ async unenrollStudent(data, user) {
55096
+ const client = this.requireClient();
55097
+ await this.deps.validateGameManagementAccess(user, data.gameId);
55098
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55099
+ where: and(eq(gameTimebackIntegrations.gameId, data.gameId), eq(gameTimebackIntegrations.courseId, data.courseId))
55100
+ });
55101
+ if (!integration) {
55102
+ throw new NotFoundError("Timeback integration", `${data.gameId}:${data.courseId}`);
55103
+ }
55104
+ await client.edubridge.enrollments.unenroll(data.studentId, data.courseId);
55105
+ return { status: "ok" };
55106
+ }
55013
55107
  async getCompletionStatus(client, courseId, studentId) {
55014
55108
  const ids = deriveSourcedIds(courseId);
55015
55109
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
@@ -55073,590 +55167,704 @@ var init_timeback_admin_service = __esm(() => {
55073
55167
  init_timeback_util();
55074
55168
  logger16 = log.scope("TimebackAdminService");
55075
55169
  });
55076
-
55077
- class TimebackService {
55078
- deps;
55079
- constructor(deps) {
55080
- this.deps = deps;
55081
- }
55082
- requireClient() {
55083
- if (!this.deps.timeback) {
55084
- logger17.error("Timeback client not available in context");
55085
- throw new ValidationError("Timeback integration not available in this environment");
55170
+ var logger17;
55171
+ var TimebackService;
55172
+ var init_timeback_service = __esm(() => {
55173
+ init_drizzle_orm();
55174
+ init_src();
55175
+ init_tables_index();
55176
+ init_src2();
55177
+ init_types4();
55178
+ init_src4();
55179
+ init_errors();
55180
+ init_timeback_util();
55181
+ logger17 = log.scope("TimebackService");
55182
+ TimebackService = class TimebackService2 {
55183
+ static HEARTBEAT_DEDUPE_TTL_MS = 300000;
55184
+ static processedHeartbeatWindows = new Map;
55185
+ static inFlightHeartbeatWindows = new Map;
55186
+ deps;
55187
+ static cleanHeartbeatDedupeCache(now2 = Date.now()) {
55188
+ for (const [key, timestamp3] of this.processedHeartbeatWindows) {
55189
+ if (now2 - timestamp3 > this.HEARTBEAT_DEDUPE_TTL_MS) {
55190
+ this.processedHeartbeatWindows.delete(key);
55191
+ }
55192
+ }
55086
55193
  }
55087
- return this.deps.timeback;
55088
- }
55089
- async getTodayXp(userId, date3, timezone2) {
55090
- const db2 = this.deps.db;
55091
- const tz = timezone2 || PLATFORM_TIMEZONE;
55092
- const base = date3 ? new Date(date3) : new Date;
55093
- if (isNaN(base.getTime())) {
55094
- throw new ValidationError("Invalid date format. Use ISO 8601 format.");
55194
+ static isDuplicateHeartbeatWindow(key) {
55195
+ this.cleanHeartbeatDedupeCache();
55196
+ return this.processedHeartbeatWindows.has(key);
55095
55197
  }
55096
- try {
55097
- new Intl.DateTimeFormat(undefined, { timeZone: tz });
55098
- } catch {
55099
- throw new ValidationError(`Invalid timezone: ${tz}`);
55198
+ static getInFlightHeartbeatWindow(key) {
55199
+ return this.inFlightHeartbeatWindows.get(key);
55100
55200
  }
55101
- if (tz === PLATFORM_TIMEZONE) {
55102
- const todayMidnight = getUtcInstantForMidnight(base, tz);
55103
- 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);
55104
- if (result2.length === 0) {
55105
- return { xp: 0, date: todayMidnight.toISOString() };
55106
- }
55107
- return { xp: result2[0].xp, date: result2[0].date.toISOString() };
55201
+ static markHeartbeatWindowProcessed(key) {
55202
+ this.processedHeartbeatWindows.set(key, Date.now());
55108
55203
  }
55109
- const { startOfDay, endOfDay } = getDayBoundariesInTimezone(base, tz);
55110
- 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))));
55111
- return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
55112
- }
55113
- async getTotalXp(userId) {
55114
- const db2 = this.deps.db;
55115
- const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
55116
- return { totalXp: Number(result[0]?.totalXp) || 0 };
55117
- }
55118
- async updateTodayXp(userId, data) {
55119
- const db2 = this.deps.db;
55120
- const { xp, userTimestamp } = data;
55121
- let targetDate;
55122
- if (userTimestamp) {
55123
- targetDate = new Date(userTimestamp);
55124
- if (isNaN(targetDate.getTime())) {
55125
- throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
55126
- }
55127
- targetDate.setHours(0, 0, 0, 0);
55128
- } else {
55129
- targetDate = new Date;
55130
- targetDate.setUTCHours(0, 0, 0, 0);
55204
+ static markHeartbeatWindowInFlight(key, promise) {
55205
+ this.inFlightHeartbeatWindows.set(key, promise);
55131
55206
  }
55132
- const [result] = await db2.insert(timebackDailyXp).values({ userId, date: targetDate, xp }).onConflictDoUpdate({
55133
- target: [timebackDailyXp.userId, timebackDailyXp.date],
55134
- set: { xp: sql`excluded.xp`, updatedAt: new Date }
55135
- }).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
55136
- if (!result) {
55137
- logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
55138
- throw new InternalError("Failed to update daily XP record");
55207
+ static clearInFlightHeartbeatWindow(key) {
55208
+ this.inFlightHeartbeatWindows.delete(key);
55139
55209
  }
55140
- return { xp: result.xp, date: result.date.toISOString() };
55141
- }
55142
- async getXpHistory(userId, startDate, endDate) {
55143
- const db2 = this.deps.db;
55144
- const whereConditions = [eq(timebackDailyXp.userId, userId)];
55145
- if (startDate) {
55146
- const start2 = new Date(startDate);
55147
- start2.setUTCHours(0, 0, 0, 0);
55148
- whereConditions.push(gte(timebackDailyXp.date, start2));
55210
+ constructor(deps) {
55211
+ this.deps = deps;
55149
55212
  }
55150
- if (endDate) {
55151
- const end = new Date(endDate);
55152
- end.setUTCHours(23, 59, 59, 999);
55153
- whereConditions.push(lte(timebackDailyXp.date, end));
55213
+ requireClient() {
55214
+ if (!this.deps.timeback) {
55215
+ logger17.error("Timeback client not available in context");
55216
+ throw new ValidationError("Timeback integration not available in this environment");
55217
+ }
55218
+ return this.deps.timeback;
55154
55219
  }
55155
- const result = await db2.select({ date: timebackDailyXp.date, xp: timebackDailyXp.xp }).from(timebackDailyXp).where(and(...whereConditions)).orderBy(timebackDailyXp.date);
55156
- return {
55157
- history: result.map((row) => ({ date: row.date.toISOString(), xp: row.xp }))
55158
- };
55159
- }
55160
- async populateStudent(user, providedNames) {
55161
- const client = this.requireClient();
55162
- const db2 = this.deps.db;
55163
- const dbUser = await db2.query.users.findFirst({
55164
- where: eq(users.id, user.id),
55165
- columns: { id: true, timebackId: true }
55166
- });
55167
- if (dbUser?.timebackId) {
55168
- logger17.info("Student already onboarded", { userId: user.id });
55169
- return { status: "already_populated" };
55220
+ async getTodayXp(userId, date3, timezone2) {
55221
+ const db2 = this.deps.db;
55222
+ const tz = timezone2 || PLATFORM_TIMEZONE;
55223
+ const base = date3 ? new Date(date3) : new Date;
55224
+ if (isNaN(base.getTime())) {
55225
+ throw new ValidationError("Invalid date format. Use ISO 8601 format.");
55226
+ }
55227
+ try {
55228
+ new Intl.DateTimeFormat(undefined, { timeZone: tz });
55229
+ } catch {
55230
+ throw new ValidationError(`Invalid timezone: ${tz}`);
55231
+ }
55232
+ if (tz === PLATFORM_TIMEZONE) {
55233
+ const todayMidnight = getUtcInstantForMidnight(base, tz);
55234
+ 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);
55235
+ if (result2.length === 0) {
55236
+ return { xp: 0, date: todayMidnight.toISOString() };
55237
+ }
55238
+ return { xp: result2[0].xp, date: result2[0].date.toISOString() };
55239
+ }
55240
+ const { startOfDay, endOfDay } = getDayBoundariesInTimezone(base, tz);
55241
+ 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))));
55242
+ return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
55170
55243
  }
55171
- let timebackId;
55172
- let name3;
55173
- try {
55174
- const existingUser = await client.oneroster.users.findByEmail(user.email);
55175
- timebackId = existingUser.sourcedId;
55176
- name3 = `${existingUser.givenName} ${existingUser.familyName}`;
55177
- logger17.info("Found existing student in OneRoster", {
55178
- userId: user.id,
55179
- timebackId
55180
- });
55181
- } catch {
55182
- if (!providedNames?.firstName || !providedNames?.lastName) {
55183
- return { status: "no_record" };
55244
+ async getTotalXp(userId) {
55245
+ const db2 = this.deps.db;
55246
+ const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
55247
+ return { totalXp: Number(result[0]?.totalXp) || 0 };
55248
+ }
55249
+ async updateTodayXp(userId, data) {
55250
+ const db2 = this.deps.db;
55251
+ const { xp, userTimestamp } = data;
55252
+ let targetDate;
55253
+ if (userTimestamp) {
55254
+ targetDate = new Date(userTimestamp);
55255
+ if (isNaN(targetDate.getTime())) {
55256
+ throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
55257
+ }
55258
+ targetDate.setHours(0, 0, 0, 0);
55259
+ } else {
55260
+ targetDate = new Date;
55261
+ targetDate.setUTCHours(0, 0, 0, 0);
55184
55262
  }
55185
- const sourcedId = crypto.randomUUID();
55186
- const response = await client.oneroster.users.create({
55187
- sourcedId,
55188
- status: "active",
55189
- enabledUser: true,
55190
- givenName: providedNames.firstName,
55191
- familyName: providedNames.lastName,
55192
- email: user.email,
55193
- roles: [
55194
- {
55195
- roleType: "primary",
55196
- role: "student",
55197
- org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
55198
- }
55199
- ]
55200
- });
55201
- if (!response.sourcedIdPairs?.allocatedSourcedId) {
55202
- return { status: "error", message: "Timeback did not return allocatedSourcedId" };
55263
+ const [result] = await db2.insert(timebackDailyXp).values({ userId, date: targetDate, xp }).onConflictDoUpdate({
55264
+ target: [timebackDailyXp.userId, timebackDailyXp.date],
55265
+ set: { xp: sql`excluded.xp`, updatedAt: new Date }
55266
+ }).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
55267
+ if (!result) {
55268
+ logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
55269
+ throw new InternalError("Failed to update daily XP record");
55203
55270
  }
55204
- timebackId = response.sourcedIdPairs.allocatedSourcedId;
55205
- name3 = `${providedNames.firstName} ${providedNames.lastName}`;
55206
- logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
55271
+ return { xp: result.xp, date: result.date.toISOString() };
55207
55272
  }
55208
- const assessments = await this.fetchAssessments(timebackId);
55209
- await db2.transaction(async (tx) => {
55210
- if (assessments.length > 0) {
55211
- const events = mapAssessmentsToXpEvents(user.id, assessments);
55212
- for (const event of events) {
55213
- try {
55214
- await tx.insert(timebackXpEvents).values(event);
55215
- } catch {}
55273
+ async getXpHistory(userId, startDate, endDate) {
55274
+ const db2 = this.deps.db;
55275
+ const whereConditions = [eq(timebackDailyXp.userId, userId)];
55276
+ if (startDate) {
55277
+ const start2 = new Date(startDate);
55278
+ start2.setUTCHours(0, 0, 0, 0);
55279
+ whereConditions.push(gte(timebackDailyXp.date, start2));
55280
+ }
55281
+ if (endDate) {
55282
+ const end = new Date(endDate);
55283
+ end.setUTCHours(23, 59, 59, 999);
55284
+ whereConditions.push(lte(timebackDailyXp.date, end));
55285
+ }
55286
+ const result = await db2.select({ date: timebackDailyXp.date, xp: timebackDailyXp.xp }).from(timebackDailyXp).where(and(...whereConditions)).orderBy(timebackDailyXp.date);
55287
+ return {
55288
+ history: result.map((row) => ({ date: row.date.toISOString(), xp: row.xp }))
55289
+ };
55290
+ }
55291
+ async populateStudent(user, providedNames) {
55292
+ const client = this.requireClient();
55293
+ const db2 = this.deps.db;
55294
+ const dbUser = await db2.query.users.findFirst({
55295
+ where: eq(users.id, user.id),
55296
+ columns: { id: true, timebackId: true }
55297
+ });
55298
+ if (dbUser?.timebackId) {
55299
+ logger17.info("Student already onboarded", { userId: user.id });
55300
+ return { status: "already_populated" };
55301
+ }
55302
+ let timebackId;
55303
+ let name3;
55304
+ try {
55305
+ const existingUser = await client.oneroster.users.findByEmail(user.email);
55306
+ timebackId = existingUser.sourcedId;
55307
+ name3 = `${existingUser.givenName} ${existingUser.familyName}`;
55308
+ logger17.info("Found existing student in OneRoster", {
55309
+ userId: user.id,
55310
+ timebackId
55311
+ });
55312
+ } catch {
55313
+ if (!providedNames?.firstName || !providedNames?.lastName) {
55314
+ return { status: "no_record" };
55216
55315
  }
55217
- const dailyMap = new Map;
55218
- for (const a of assessments) {
55219
- const xp = a.metadata?.xp;
55220
- if (typeof xp === "number" && a.scoreDate) {
55221
- const day = getUtcInstantForMidnight(new Date(a.scoreDate), PLATFORM_TIMEZONE);
55222
- const key = day.toISOString();
55223
- dailyMap.set(key, (dailyMap.get(key) || 0) + xp);
55316
+ const sourcedId = crypto.randomUUID();
55317
+ const response = await client.oneroster.users.create({
55318
+ sourcedId,
55319
+ status: "active",
55320
+ enabledUser: true,
55321
+ givenName: providedNames.firstName,
55322
+ familyName: providedNames.lastName,
55323
+ email: user.email,
55324
+ roles: [
55325
+ {
55326
+ roleType: "primary",
55327
+ role: "student",
55328
+ org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
55329
+ }
55330
+ ]
55331
+ });
55332
+ if (!response.sourcedIdPairs?.allocatedSourcedId) {
55333
+ return { status: "error", message: "Timeback did not return allocatedSourcedId" };
55334
+ }
55335
+ timebackId = response.sourcedIdPairs.allocatedSourcedId;
55336
+ name3 = `${providedNames.firstName} ${providedNames.lastName}`;
55337
+ logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
55338
+ }
55339
+ const assessments = await this.fetchAssessments(timebackId);
55340
+ await db2.transaction(async (tx) => {
55341
+ if (assessments.length > 0) {
55342
+ const events = mapAssessmentsToXpEvents(user.id, assessments);
55343
+ for (const event of events) {
55344
+ try {
55345
+ await tx.insert(timebackXpEvents).values(event);
55346
+ } catch {}
55347
+ }
55348
+ const dailyMap = new Map;
55349
+ for (const a of assessments) {
55350
+ const xp = a.metadata?.xp;
55351
+ if (typeof xp === "number" && a.scoreDate) {
55352
+ const day = getUtcInstantForMidnight(new Date(a.scoreDate), PLATFORM_TIMEZONE);
55353
+ const key = day.toISOString();
55354
+ dailyMap.set(key, (dailyMap.get(key) || 0) + xp);
55355
+ }
55356
+ }
55357
+ if (dailyMap.size > 0) {
55358
+ const dailyRecords = [...dailyMap.entries()].map(([iso, xp]) => ({
55359
+ userId: user.id,
55360
+ date: new Date(iso),
55361
+ xp
55362
+ }));
55363
+ await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
55364
+ target: [timebackDailyXp.userId, timebackDailyXp.date],
55365
+ set: { xp: sql`excluded.xp`, updatedAt: new Date }
55366
+ });
55224
55367
  }
55225
55368
  }
55226
- if (dailyMap.size > 0) {
55227
- const dailyRecords = [...dailyMap.entries()].map(([iso, xp]) => ({
55369
+ const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
55370
+ if (!updated) {
55371
+ logger17.error("User Timeback ID update returned no rows", {
55228
55372
  userId: user.id,
55229
- date: new Date(iso),
55230
- xp
55231
- }));
55232
- await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
55233
- target: [timebackDailyXp.userId, timebackDailyXp.date],
55234
- set: { xp: sql`excluded.xp`, updatedAt: new Date }
55373
+ timebackId
55235
55374
  });
55375
+ throw new InternalError("Failed to update user with Timeback ID");
55236
55376
  }
55237
- }
55238
- const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
55239
- if (!updated) {
55240
- logger17.error("User Timeback ID update returned no rows", {
55241
- userId: user.id,
55242
- timebackId
55243
- });
55244
- throw new InternalError("Failed to update user with Timeback ID");
55245
- }
55246
- });
55247
- return { status: "ok" };
55248
- }
55249
- async fetchAssessments(studentSourcedId) {
55250
- const client = this.requireClient();
55251
- const allAssessments = [];
55252
- const limit = 3000;
55253
- const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
55254
- let offset = 0;
55255
- try {
55256
- while (true) {
55257
- const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
55258
- allAssessments.push(...results);
55259
- if (results.length < limit) {
55260
- break;
55261
- }
55262
- offset += limit;
55263
- }
55264
- logger17.debug("Fetched assessments", {
55265
- studentSourcedId,
55266
- totalCount: allAssessments.length
55267
55377
  });
55268
- return allAssessments;
55269
- } catch (error) {
55270
- logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
55271
- return [];
55378
+ return { status: "ok" };
55272
55379
  }
55273
- }
55274
- async getUserData(userId, gameId) {
55275
- const db2 = this.deps.db;
55276
- const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
55277
- if (!userData) {
55278
- throw new NotFoundError("User", userId);
55279
- }
55280
- if (!userData.timebackId) {
55281
- throw new NotFoundError("Timeback account not found for user");
55282
- }
55283
- const [profile, allEnrollments] = await Promise.all([
55284
- this.fetchStudentProfile(userData.timebackId),
55285
- this.fetchEnrollments(userData.timebackId)
55286
- ]);
55287
- const enrollments = gameId ? allEnrollments.filter((e) => e.gameId === gameId) : allEnrollments;
55288
- const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
55289
- const organizations = gameId && enrollmentOrgIds.size > 0 ? profile.organizations.filter((o) => enrollmentOrgIds.has(o.id)) : profile.organizations;
55290
- return { id: userData.timebackId, role: profile.role, enrollments, organizations };
55291
- }
55292
- async getUserDataByTimebackId(timebackId) {
55293
- const [profile, enrollments] = await Promise.all([
55294
- this.fetchStudentProfile(timebackId),
55295
- this.fetchEnrollments(timebackId)
55296
- ]);
55297
- return {
55298
- id: timebackId,
55299
- role: profile.role,
55300
- enrollments,
55301
- organizations: profile.organizations
55302
- };
55303
- }
55304
- async fetchStudentProfile(timebackId) {
55305
- const client = this.requireClient();
55306
- try {
55307
- const user = await client.oneroster.users.get(timebackId);
55308
- const primaryRole = user.roles.find((r) => r.roleType === "primary");
55309
- const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
55310
- const orgMap = new Map;
55311
- if (user.primaryOrg) {
55312
- orgMap.set(user.primaryOrg.sourcedId, {
55313
- id: user.primaryOrg.sourcedId,
55314
- name: user.primaryOrg.name ?? null,
55315
- type: user.primaryOrg.type || "school",
55316
- isPrimary: true
55380
+ async fetchAssessments(studentSourcedId) {
55381
+ const client = this.requireClient();
55382
+ const allAssessments = [];
55383
+ const limit = 3000;
55384
+ const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
55385
+ let offset = 0;
55386
+ try {
55387
+ while (true) {
55388
+ const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
55389
+ allAssessments.push(...results);
55390
+ if (results.length < limit) {
55391
+ break;
55392
+ }
55393
+ offset += limit;
55394
+ }
55395
+ logger17.debug("Fetched assessments", {
55396
+ studentSourcedId,
55397
+ totalCount: allAssessments.length
55317
55398
  });
55399
+ return allAssessments;
55400
+ } catch (error) {
55401
+ logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
55402
+ return [];
55318
55403
  }
55319
- for (const r of user.roles) {
55320
- if (r.org && !orgMap.has(r.org.sourcedId)) {
55321
- orgMap.set(r.org.sourcedId, {
55322
- id: r.org.sourcedId,
55323
- name: null,
55324
- type: "school",
55325
- isPrimary: false
55404
+ }
55405
+ async getUserData(userId, gameId) {
55406
+ const db2 = this.deps.db;
55407
+ const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
55408
+ if (!userData) {
55409
+ throw new NotFoundError("User", userId);
55410
+ }
55411
+ if (!userData.timebackId) {
55412
+ throw new NotFoundError("Timeback account not found for user");
55413
+ }
55414
+ const [profile, allEnrollments] = await Promise.all([
55415
+ this.fetchStudentProfile(userData.timebackId),
55416
+ this.fetchEnrollments(userData.timebackId)
55417
+ ]);
55418
+ const enrollments = gameId ? allEnrollments.filter((e) => e.gameId === gameId) : allEnrollments;
55419
+ const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
55420
+ const organizations = gameId && enrollmentOrgIds.size > 0 ? profile.organizations.filter((o) => enrollmentOrgIds.has(o.id)) : profile.organizations;
55421
+ return { id: userData.timebackId, role: profile.role, enrollments, organizations };
55422
+ }
55423
+ async getUserDataByTimebackId(timebackId) {
55424
+ const [profile, enrollments] = await Promise.all([
55425
+ this.fetchStudentProfile(timebackId),
55426
+ this.fetchEnrollments(timebackId)
55427
+ ]);
55428
+ return {
55429
+ id: timebackId,
55430
+ role: profile.role,
55431
+ enrollments,
55432
+ organizations: profile.organizations
55433
+ };
55434
+ }
55435
+ async fetchStudentProfile(timebackId) {
55436
+ const client = this.requireClient();
55437
+ try {
55438
+ const user = await client.oneroster.users.get(timebackId);
55439
+ const primaryRole = user.roles.find((r) => r.roleType === "primary");
55440
+ const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
55441
+ const orgMap = new Map;
55442
+ if (user.primaryOrg) {
55443
+ orgMap.set(user.primaryOrg.sourcedId, {
55444
+ id: user.primaryOrg.sourcedId,
55445
+ name: user.primaryOrg.name ?? null,
55446
+ type: user.primaryOrg.type || "school",
55447
+ isPrimary: true
55326
55448
  });
55327
55449
  }
55450
+ for (const r of user.roles) {
55451
+ if (r.org && !orgMap.has(r.org.sourcedId)) {
55452
+ orgMap.set(r.org.sourcedId, {
55453
+ id: r.org.sourcedId,
55454
+ name: null,
55455
+ type: "school",
55456
+ isPrimary: false
55457
+ });
55458
+ }
55459
+ }
55460
+ return { role, organizations: [...orgMap.values()] };
55461
+ } catch {
55462
+ return { role: "student", organizations: [] };
55328
55463
  }
55329
- return { role, organizations: [...orgMap.values()] };
55330
- } catch {
55331
- return { role: "student", organizations: [] };
55332
55464
  }
55333
- }
55334
- async fetchEnrollments(timebackId) {
55335
- const client = this.requireClient();
55336
- const db2 = this.deps.db;
55337
- try {
55338
- const enrollments = await client.getEnrollments(timebackId);
55339
- const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
55340
- if (courseIds.length === 0) {
55465
+ async fetchEnrollments(timebackId) {
55466
+ const client = this.requireClient();
55467
+ const db2 = this.deps.db;
55468
+ try {
55469
+ const enrollments = await client.getEnrollments(timebackId);
55470
+ const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
55471
+ if (courseIds.length === 0) {
55472
+ return [];
55473
+ }
55474
+ const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
55475
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
55476
+ where: inArray(gameTimebackIntegrations.courseId, courseIds)
55477
+ });
55478
+ return integrations.map((i2) => ({
55479
+ gameId: i2.gameId,
55480
+ grade: i2.grade,
55481
+ subject: i2.subject,
55482
+ courseId: i2.courseId,
55483
+ orgId: courseToSchool.get(i2.courseId)
55484
+ }));
55485
+ } catch {
55341
55486
  return [];
55342
55487
  }
55343
- const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
55344
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55345
- where: inArray(gameTimebackIntegrations.courseId, courseIds)
55346
- });
55347
- return integrations.map((i2) => ({
55348
- gameId: i2.gameId,
55349
- grade: i2.grade,
55350
- subject: i2.subject,
55351
- courseId: i2.courseId,
55352
- orgId: courseToSchool.get(i2.courseId)
55353
- }));
55354
- } catch {
55355
- return [];
55356
55488
  }
55357
- }
55358
- async setupIntegration(gameId, request, user) {
55359
- const client = this.requireClient();
55360
- const db2 = this.deps.db;
55361
- await this.deps.validateDeveloperAccess(user, gameId);
55362
- const { courses, baseConfig, verbose } = request;
55363
- const existing = await db2.query.gameTimebackIntegrations.findMany({
55364
- where: eq(gameTimebackIntegrations.gameId, gameId)
55365
- });
55366
- const integrations = [];
55367
- const verboseData = [];
55368
- for (const courseConfig of courses) {
55369
- let applySuffix = function(text3) {
55370
- return suffix ? `${text3} ${suffix}` : text3;
55371
- };
55372
- const {
55373
- subject: subjectInput,
55374
- grade,
55375
- title,
55376
- courseCode,
55377
- level,
55378
- metadata: metadata2,
55379
- totalXp: derivedTotalXp,
55380
- masterableUnits: derivedMasterableUnits
55381
- } = courseConfig;
55382
- if (!isTimebackSubject(subjectInput)) {
55383
- logger17.warn("Invalid Timeback subject in course config", {
55489
+ async setupIntegration(gameId, request, user) {
55490
+ const client = this.requireClient();
55491
+ const db2 = this.deps.db;
55492
+ await this.deps.validateDeveloperAccess(user, gameId);
55493
+ const { courses, baseConfig, verbose } = request;
55494
+ const existing = await db2.query.gameTimebackIntegrations.findMany({
55495
+ where: eq(gameTimebackIntegrations.gameId, gameId)
55496
+ });
55497
+ const integrations = [];
55498
+ const verboseData = [];
55499
+ for (const courseConfig of courses) {
55500
+ let applySuffix = function(text3) {
55501
+ return suffix ? `${text3} ${suffix}` : text3;
55502
+ };
55503
+ const {
55384
55504
  subject: subjectInput,
55385
- courseCode,
55386
- title
55387
- });
55388
- throw new ValidationError(`Invalid subject "${subjectInput}"`);
55389
- }
55390
- if (!isTimebackGrade(grade)) {
55391
- logger17.warn("Invalid Timeback grade in course config", {
55392
55505
  grade,
55393
- courseCode,
55394
- title
55395
- });
55396
- throw new ValidationError(`Invalid grade "${grade}"`);
55397
- }
55398
- const subject = subjectInput;
55399
- const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
55400
- const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
55401
- const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
55402
- if (typeof totalXp !== "number") {
55403
- logger17.warn("Course missing totalXp in Timeback config", {
55404
- courseCode,
55405
- title
55406
- });
55407
- throw new ValidationError(`Course "${title}" is missing totalXp`);
55408
- }
55409
- const suffix = baseConfig.component.titleSuffix || "";
55410
- const fullConfig = {
55411
- organization: baseConfig.organization,
55412
- course: {
55413
55506
  title,
55414
- subjects: [subject],
55415
- grades: [grade],
55416
55507
  courseCode,
55417
55508
  level,
55418
- gradingScheme: "STANDARD",
55419
- metadata: metadata2
55420
- },
55421
- component: {
55422
- ...baseConfig.component,
55423
- title: applySuffix(baseConfig.component.title || `${title} Activities`)
55424
- },
55425
- resource: {
55426
- ...baseConfig.resource,
55427
- title: applySuffix(baseConfig.resource.title || `${title} Game`),
55428
- metadata: buildResourceMetadata({
55429
- baseMetadata: baseConfig.resource.metadata,
55430
- subject,
55431
- grade,
55432
- totalXp,
55433
- masterableUnits
55434
- })
55435
- },
55436
- componentResource: {
55437
- ...baseConfig.componentResource,
55438
- title: applySuffix(baseConfig.componentResource.title || "")
55439
- }
55440
- };
55441
- const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
55442
- if (existingIntegration) {
55443
- await client.update(existingIntegration.courseId, fullConfig);
55444
- const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
55445
- if (updated) {
55446
- integrations.push(this.toGameTimebackIntegration(updated));
55509
+ metadata: metadata2,
55510
+ totalXp: derivedTotalXp,
55511
+ masterableUnits: derivedMasterableUnits
55512
+ } = courseConfig;
55513
+ if (!isTimebackSubject(subjectInput)) {
55514
+ logger17.warn("Invalid Timeback subject in course config", {
55515
+ subject: subjectInput,
55516
+ courseCode,
55517
+ title
55518
+ });
55519
+ throw new ValidationError(`Invalid subject "${subjectInput}"`);
55447
55520
  }
55448
- } else {
55449
- const result = await client.setup(fullConfig, { verbose });
55450
- const [integration] = await db2.insert(gameTimebackIntegrations).values({ gameId, courseId: result.courseId, grade, subject, totalXp }).returning();
55451
- if (integration) {
55452
- const dto = this.toGameTimebackIntegration(integration);
55453
- integrations.push(dto);
55454
- if (verbose && result.verboseData) {
55455
- verboseData.push({ integration: dto, config: result.verboseData });
55521
+ if (!isTimebackGrade(grade)) {
55522
+ logger17.warn("Invalid Timeback grade in course config", {
55523
+ grade,
55524
+ courseCode,
55525
+ title
55526
+ });
55527
+ throw new ValidationError(`Invalid grade "${grade}"`);
55528
+ }
55529
+ const subject = subjectInput;
55530
+ const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
55531
+ const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
55532
+ const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
55533
+ if (typeof totalXp !== "number") {
55534
+ logger17.warn("Course missing totalXp in Timeback config", {
55535
+ courseCode,
55536
+ title
55537
+ });
55538
+ throw new ValidationError(`Course "${title}" is missing totalXp`);
55539
+ }
55540
+ const suffix = baseConfig.component.titleSuffix || "";
55541
+ const fullConfig = {
55542
+ organization: baseConfig.organization,
55543
+ course: {
55544
+ title,
55545
+ subjects: [subject],
55546
+ grades: [grade],
55547
+ courseCode,
55548
+ level,
55549
+ gradingScheme: "STANDARD",
55550
+ metadata: metadata2
55551
+ },
55552
+ component: {
55553
+ ...baseConfig.component,
55554
+ title: applySuffix(baseConfig.component.title || `${title} Activities`)
55555
+ },
55556
+ resource: {
55557
+ ...baseConfig.resource,
55558
+ title: applySuffix(baseConfig.resource.title || `${title} Game`),
55559
+ metadata: buildResourceMetadata({
55560
+ baseMetadata: baseConfig.resource.metadata,
55561
+ subject,
55562
+ grade,
55563
+ totalXp,
55564
+ masterableUnits
55565
+ })
55566
+ },
55567
+ componentResource: {
55568
+ ...baseConfig.componentResource,
55569
+ title: applySuffix(baseConfig.componentResource.title || "")
55570
+ }
55571
+ };
55572
+ const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
55573
+ if (existingIntegration) {
55574
+ await client.update(existingIntegration.courseId, fullConfig);
55575
+ const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
55576
+ if (updated) {
55577
+ integrations.push(this.toGameTimebackIntegration(updated));
55578
+ }
55579
+ } else {
55580
+ const result = await client.setup(fullConfig, { verbose });
55581
+ const [integration] = await db2.insert(gameTimebackIntegrations).values({ gameId, courseId: result.courseId, grade, subject, totalXp }).returning();
55582
+ if (integration) {
55583
+ const dto = this.toGameTimebackIntegration(integration);
55584
+ integrations.push(dto);
55585
+ if (verbose && result.verboseData) {
55586
+ verboseData.push({ integration: dto, config: result.verboseData });
55587
+ }
55456
55588
  }
55457
55589
  }
55458
55590
  }
55591
+ return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
55459
55592
  }
55460
- return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
55461
- }
55462
- async getIntegrations(gameId, user) {
55463
- await this.deps.validateDeveloperAccess(user, gameId);
55464
- const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
55465
- where: eq(gameTimebackIntegrations.gameId, gameId)
55466
- });
55467
- return rows.map((row) => this.toGameTimebackIntegration(row));
55468
- }
55469
- async verifyIntegration(gameId, user) {
55470
- const client = this.requireClient();
55471
- const db2 = this.deps.db;
55472
- await this.deps.validateDeveloperAccess(user, gameId);
55473
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55474
- where: eq(gameTimebackIntegrations.gameId, gameId)
55475
- });
55476
- if (integrations.length === 0) {
55477
- throw new NotFoundError("Timeback integration", gameId);
55593
+ async getIntegrations(gameId, user) {
55594
+ await this.deps.validateGameManagementAccess(user, gameId);
55595
+ const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
55596
+ where: eq(gameTimebackIntegrations.gameId, gameId)
55597
+ });
55598
+ return rows.map((row) => this.toGameTimebackIntegration(row));
55478
55599
  }
55479
- const now2 = new Date;
55480
- const results = await Promise.all(integrations.map(async (integration) => {
55481
- const resources = await client.verify(integration.courseId);
55482
- const resourceValues = Object.values(resources);
55483
- const allFound = resourceValues.every((r) => r.found);
55484
- const errors3 = Object.entries(resources).filter(([_2, r]) => !r.found).map(([name3]) => `${name3} not found`);
55485
- const status = allFound ? "success" : "error";
55486
- return {
55487
- integration: this.toGameTimebackIntegration({
55488
- ...integration,
55489
- lastVerifiedAt: now2
55490
- }),
55491
- resources,
55492
- status,
55493
- ...errors3.length > 0 && { errors: errors3 }
55494
- };
55495
- }));
55496
- await db2.update(gameTimebackIntegrations).set({ lastVerifiedAt: now2 }).where(eq(gameTimebackIntegrations.gameId, gameId));
55497
- const overallStatus = results.every((r) => r.status === "success") ? "success" : "error";
55498
- return { status: overallStatus, results };
55499
- }
55500
- async getConfig(gameId, user) {
55501
- const client = this.requireClient();
55502
- await this.deps.validateDeveloperAccess(user, gameId);
55503
- const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55504
- where: eq(gameTimebackIntegrations.gameId, gameId)
55505
- });
55506
- if (!integration) {
55507
- throw new NotFoundError("Timeback integration", gameId);
55600
+ async verifyIntegration(gameId, user) {
55601
+ const client = this.requireClient();
55602
+ const db2 = this.deps.db;
55603
+ await this.deps.validateDeveloperAccess(user, gameId);
55604
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
55605
+ where: eq(gameTimebackIntegrations.gameId, gameId)
55606
+ });
55607
+ if (integrations.length === 0) {
55608
+ throw new NotFoundError("Timeback integration", gameId);
55609
+ }
55610
+ const now2 = new Date;
55611
+ const results = await Promise.all(integrations.map(async (integration) => {
55612
+ const resources = await client.verify(integration.courseId);
55613
+ const resourceValues = Object.values(resources);
55614
+ const allFound = resourceValues.every((r) => r.found);
55615
+ const errors3 = Object.entries(resources).filter(([_2, r]) => !r.found).map(([name3]) => `${name3} not found`);
55616
+ const status = allFound ? "success" : "error";
55617
+ return {
55618
+ integration: this.toGameTimebackIntegration({
55619
+ ...integration,
55620
+ lastVerifiedAt: now2
55621
+ }),
55622
+ resources,
55623
+ status,
55624
+ ...errors3.length > 0 && { errors: errors3 }
55625
+ };
55626
+ }));
55627
+ await db2.update(gameTimebackIntegrations).set({ lastVerifiedAt: now2 }).where(eq(gameTimebackIntegrations.gameId, gameId));
55628
+ const overallStatus = results.every((r) => r.status === "success") ? "success" : "error";
55629
+ return { status: overallStatus, results };
55630
+ }
55631
+ async getConfig(gameId, user) {
55632
+ const client = this.requireClient();
55633
+ await this.deps.validateDeveloperAccess(user, gameId);
55634
+ const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55635
+ where: eq(gameTimebackIntegrations.gameId, gameId)
55636
+ });
55637
+ if (!integration) {
55638
+ throw new NotFoundError("Timeback integration", gameId);
55639
+ }
55640
+ return client.getConfig(integration.courseId);
55508
55641
  }
55509
- return client.getConfig(integration.courseId);
55510
- }
55511
- async deleteIntegrations(gameId, user) {
55512
- const client = this.requireClient();
55513
- const db2 = this.deps.db;
55514
- await this.deps.validateDeveloperAccess(user, gameId);
55515
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55516
- where: eq(gameTimebackIntegrations.gameId, gameId)
55517
- });
55518
- if (integrations.length === 0) {
55519
- throw new NotFoundError("Timeback integration", gameId);
55642
+ async deleteIntegrations(gameId, user) {
55643
+ const client = this.requireClient();
55644
+ const db2 = this.deps.db;
55645
+ await this.deps.validateDeveloperAccess(user, gameId);
55646
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
55647
+ where: eq(gameTimebackIntegrations.gameId, gameId)
55648
+ });
55649
+ if (integrations.length === 0) {
55650
+ throw new NotFoundError("Timeback integration", gameId);
55651
+ }
55652
+ for (const integration of integrations) {
55653
+ await client.cleanup(integration.courseId);
55654
+ }
55655
+ await db2.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
55520
55656
  }
55521
- for (const integration of integrations) {
55522
- await client.cleanup(integration.courseId);
55657
+ toGameTimebackIntegration(integration) {
55658
+ return {
55659
+ id: integration.id,
55660
+ gameId: integration.gameId,
55661
+ courseId: integration.courseId,
55662
+ grade: integration.grade,
55663
+ subject: integration.subject,
55664
+ totalXp: integration.totalXp ?? null,
55665
+ createdAt: integration.createdAt,
55666
+ updatedAt: integration.updatedAt,
55667
+ lastVerifiedAt: integration.lastVerifiedAt ?? null
55668
+ };
55523
55669
  }
55524
- await db2.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
55525
- }
55526
- toGameTimebackIntegration(integration) {
55527
- return {
55528
- id: integration.id,
55529
- gameId: integration.gameId,
55530
- courseId: integration.courseId,
55531
- grade: integration.grade,
55532
- subject: integration.subject,
55533
- totalXp: integration.totalXp ?? null,
55534
- createdAt: integration.createdAt,
55535
- updatedAt: integration.updatedAt,
55536
- lastVerifiedAt: integration.lastVerifiedAt ?? null
55537
- };
55538
- }
55539
- async endActivity({
55540
- gameId,
55541
- studentId,
55542
- activityData,
55543
- scoreData,
55544
- timingData,
55545
- xpEarned,
55546
- masteredUnits,
55547
- extensions,
55548
- user
55549
- }) {
55550
- const client = this.requireClient();
55551
- const db2 = this.deps.db;
55552
- await this.deps.validateDeveloperAccess(user, gameId);
55553
- const integration = await db2.query.gameTimebackIntegrations.findFirst({
55554
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
55555
- });
55556
- if (!integration) {
55557
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
55558
- }
55559
- const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
55560
- const result = await client.recordProgress(integration.courseId, studentId, {
55561
- score: scorePercentage,
55562
- totalQuestions: scoreData.totalQuestions,
55563
- correctQuestions: scoreData.correctQuestions,
55564
- durationSeconds: timingData.durationSeconds,
55670
+ async endActivity({
55671
+ gameId,
55672
+ studentId,
55673
+ runId,
55674
+ activityData,
55675
+ scoreData,
55676
+ timingData,
55677
+ sessionTimingData,
55565
55678
  xpEarned,
55566
55679
  masteredUnits,
55567
55680
  extensions,
55568
- activityId: activityData.activityId,
55569
- activityName: activityData.activityName,
55570
- subject: activityData.subject,
55571
- appName: activityData.appName,
55572
- sensorUrl: activityData.sensorUrl,
55573
- courseId: activityData.courseId,
55574
- courseName: activityData.courseName,
55575
- studentEmail: activityData.studentEmail,
55576
- courseTotalXp: integration.totalXp
55577
- });
55578
- await client.recordSessionEnd(integration.courseId, studentId, {
55579
- activeTimeSeconds: timingData.durationSeconds,
55580
- activityId: activityData.activityId,
55581
- activityName: activityData.activityName,
55582
- subject: activityData.subject,
55583
- appName: activityData.appName,
55584
- sensorUrl: activityData.sensorUrl,
55585
- courseId: activityData.courseId,
55586
- courseName: activityData.courseName,
55587
- studentEmail: activityData.studentEmail
55588
- });
55589
- logger17.info("Recorded activity completion", {
55681
+ user
55682
+ }) {
55683
+ const client = this.requireClient();
55684
+ const db2 = this.deps.db;
55685
+ await this.deps.validateDeveloperAccess(user, gameId);
55686
+ const integration = await db2.query.gameTimebackIntegrations.findFirst({
55687
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
55688
+ });
55689
+ if (!integration) {
55690
+ throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
55691
+ }
55692
+ const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
55693
+ const result = await client.recordProgress(integration.courseId, studentId, {
55694
+ score: scorePercentage,
55695
+ totalQuestions: scoreData.totalQuestions,
55696
+ correctQuestions: scoreData.correctQuestions,
55697
+ durationSeconds: timingData.durationSeconds,
55698
+ xpEarned,
55699
+ masteredUnits,
55700
+ extensions,
55701
+ activityId: activityData.activityId,
55702
+ activityName: activityData.activityName,
55703
+ subject: activityData.subject,
55704
+ appName: activityData.appName,
55705
+ sensorUrl: activityData.sensorUrl,
55706
+ courseId: activityData.courseId,
55707
+ courseName: activityData.courseName,
55708
+ studentEmail: activityData.studentEmail,
55709
+ courseTotalXp: integration.totalXp,
55710
+ ...runId ? { runId } : {}
55711
+ });
55712
+ const sessionEndActiveSeconds = sessionTimingData?.activeSeconds ?? timingData.durationSeconds;
55713
+ const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
55714
+ if (sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0) {
55715
+ await client.recordSessionEnd(integration.courseId, studentId, {
55716
+ activeTimeSeconds: sessionEndActiveSeconds,
55717
+ ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
55718
+ activityId: activityData.activityId,
55719
+ activityName: activityData.activityName,
55720
+ subject: activityData.subject,
55721
+ appName: activityData.appName,
55722
+ sensorUrl: activityData.sensorUrl,
55723
+ courseId: activityData.courseId,
55724
+ courseName: activityData.courseName,
55725
+ studentEmail: activityData.studentEmail,
55726
+ ...runId ? { runId } : {}
55727
+ });
55728
+ }
55729
+ logger17.info("Recorded activity completion", {
55730
+ gameId,
55731
+ courseId: integration.courseId,
55732
+ studentId,
55733
+ runId,
55734
+ score: scorePercentage
55735
+ });
55736
+ return {
55737
+ status: "ok",
55738
+ courseId: integration.courseId,
55739
+ xpAwarded: result.xpAwarded,
55740
+ masteredUnits: result.masteredUnitsApplied,
55741
+ pctCompleteApp: result.pctCompleteApp,
55742
+ scoreStatus: result.scoreStatus,
55743
+ inProgress: result.inProgress
55744
+ };
55745
+ }
55746
+ async recordHeartbeat({
55590
55747
  gameId,
55591
- courseId: integration.courseId,
55592
55748
  studentId,
55593
- score: scorePercentage
55594
- });
55595
- return {
55596
- status: "ok",
55597
- courseId: integration.courseId,
55598
- xpAwarded: result.xpAwarded,
55599
- masteredUnits: result.masteredUnitsApplied,
55600
- pctCompleteApp: result.pctCompleteApp,
55601
- scoreStatus: result.scoreStatus,
55602
- inProgress: result.inProgress
55603
- };
55604
- }
55605
- async getStudentXp(timebackId, user, options) {
55606
- const client = this.requireClient();
55607
- const db2 = this.deps.db;
55608
- let courseIds = [];
55609
- if (options?.gameId) {
55610
- await this.deps.validateDeveloperAccess(user, options.gameId);
55611
- const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
55612
- if (options.grade !== undefined && options.subject) {
55613
- conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
55614
- conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
55749
+ runId,
55750
+ activityData,
55751
+ timingData,
55752
+ windowSequence,
55753
+ isFinal,
55754
+ user
55755
+ }) {
55756
+ const client = this.requireClient();
55757
+ const db2 = this.deps.db;
55758
+ const heartbeatWindowKey = `${runId}:${windowSequence}`;
55759
+ if (TimebackService2.isDuplicateHeartbeatWindow(heartbeatWindowKey)) {
55760
+ logger17.debug("Skipping duplicate heartbeat window", {
55761
+ gameId,
55762
+ studentId,
55763
+ runId,
55764
+ windowSequence,
55765
+ isFinal
55766
+ });
55767
+ return { status: "ok" };
55615
55768
  }
55616
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55617
- where: and(...conditions2)
55618
- });
55619
- courseIds = integrations.map((i2) => i2.courseId);
55620
- if (courseIds.length === 0) {
55621
- logger17.debug("No integrations found for game, returning 0 XP", {
55622
- timebackId,
55623
- gameId: options.gameId,
55624
- grade: options.grade,
55625
- subject: options.subject
55769
+ await this.deps.validateDeveloperAccess(user, gameId);
55770
+ const inFlightHeartbeat = TimebackService2.getInFlightHeartbeatWindow(heartbeatWindowKey);
55771
+ if (inFlightHeartbeat) {
55772
+ logger17.debug("Joining in-flight heartbeat window", {
55773
+ gameId,
55774
+ studentId,
55775
+ runId,
55776
+ windowSequence,
55777
+ isFinal
55626
55778
  });
55627
- return {
55628
- totalXp: 0,
55629
- ...options?.include?.today && { todayXp: 0 },
55630
- ...options?.include?.perCourse && { courses: [] }
55631
- };
55779
+ return inFlightHeartbeat;
55780
+ }
55781
+ const pendingHeartbeat = (async () => {
55782
+ const integration = await db2.query.gameTimebackIntegrations.findFirst({
55783
+ where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
55784
+ });
55785
+ if (!integration) {
55786
+ throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
55787
+ }
55788
+ const activeTimeSeconds = timingData.activeMs / 1000;
55789
+ const inactiveTimeSeconds = timingData.pausedMs / 1000;
55790
+ if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
55791
+ await client.recordSessionEnd(integration.courseId, studentId, {
55792
+ activeTimeSeconds,
55793
+ ...inactiveTimeSeconds > 0 ? { inactiveTimeSeconds } : {},
55794
+ activityId: activityData.activityId,
55795
+ activityName: activityData.activityName,
55796
+ subject: activityData.subject,
55797
+ appName: activityData.appName,
55798
+ sensorUrl: activityData.sensorUrl,
55799
+ courseId: activityData.courseId,
55800
+ courseName: activityData.courseName,
55801
+ studentEmail: activityData.studentEmail,
55802
+ ...runId ? { runId } : {}
55803
+ });
55804
+ }
55805
+ TimebackService2.markHeartbeatWindowProcessed(heartbeatWindowKey);
55806
+ logger17.debug("Recorded heartbeat", {
55807
+ gameId,
55808
+ courseId: integration.courseId,
55809
+ studentId,
55810
+ runId,
55811
+ windowSequence,
55812
+ activeTimeSeconds,
55813
+ isFinal
55814
+ });
55815
+ return { status: "ok" };
55816
+ })();
55817
+ TimebackService2.markHeartbeatWindowInFlight(heartbeatWindowKey, pendingHeartbeat);
55818
+ try {
55819
+ return await pendingHeartbeat;
55820
+ } finally {
55821
+ TimebackService2.clearInFlightHeartbeatWindow(heartbeatWindowKey);
55632
55822
  }
55633
55823
  }
55634
- const result = await client.getStudentXp(timebackId, {
55635
- courseIds: courseIds.length > 0 ? courseIds : undefined,
55636
- include: options?.include
55637
- });
55638
- logger17.debug("Retrieved student XP", {
55639
- timebackId,
55640
- gameId: options?.gameId,
55641
- grade: options?.grade,
55642
- subject: options?.subject,
55643
- totalXp: result.totalXp,
55644
- courseCount: result.courses?.length
55645
- });
55646
- return result;
55647
- }
55648
- }
55649
- var logger17;
55650
- var init_timeback_service = __esm(() => {
55651
- init_drizzle_orm();
55652
- init_src();
55653
- init_tables_index();
55654
- init_src2();
55655
- init_types4();
55656
- init_src4();
55657
- init_errors();
55658
- init_timeback_util();
55659
- logger17 = log.scope("TimebackService");
55824
+ async getStudentXp(timebackId, user, options) {
55825
+ const client = this.requireClient();
55826
+ const db2 = this.deps.db;
55827
+ let courseIds = [];
55828
+ if (options?.gameId) {
55829
+ await this.deps.validateDeveloperAccess(user, options.gameId);
55830
+ const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
55831
+ if (options.grade !== undefined && options.subject) {
55832
+ conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
55833
+ conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
55834
+ }
55835
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
55836
+ where: and(...conditions2)
55837
+ });
55838
+ courseIds = integrations.map((i2) => i2.courseId);
55839
+ if (courseIds.length === 0) {
55840
+ logger17.debug("No integrations found for game, returning 0 XP", {
55841
+ timebackId,
55842
+ gameId: options.gameId,
55843
+ grade: options.grade,
55844
+ subject: options.subject
55845
+ });
55846
+ return {
55847
+ totalXp: 0,
55848
+ ...options?.include?.today && { todayXp: 0 },
55849
+ ...options?.include?.perCourse && { courses: [] }
55850
+ };
55851
+ }
55852
+ }
55853
+ const result = await client.getStudentXp(timebackId, {
55854
+ courseIds: courseIds.length > 0 ? courseIds : undefined,
55855
+ include: options?.include
55856
+ });
55857
+ logger17.debug("Retrieved student XP", {
55858
+ timebackId,
55859
+ gameId: options?.gameId,
55860
+ grade: options?.grade,
55861
+ subject: options?.subject,
55862
+ totalXp: result.totalXp,
55863
+ courseCount: result.courses?.length
55864
+ });
55865
+ return result;
55866
+ }
55867
+ };
55660
55868
  });
55661
55869
 
55662
55870
  class UploadService {
@@ -55712,6 +55920,7 @@ function createPlatformServices(deps) {
55712
55920
  alerts,
55713
55921
  validateDeveloperAccessBySlug,
55714
55922
  validateDeveloperAccess,
55923
+ validateGameManagementAccess,
55715
55924
  validateOwnership
55716
55925
  } = deps;
55717
55926
  const bucket = new BucketService({
@@ -55746,12 +55955,14 @@ function createPlatformServices(deps) {
55746
55955
  const timeback2 = new TimebackService({
55747
55956
  db: db2,
55748
55957
  timeback: timebackClient,
55749
- validateDeveloperAccess
55958
+ validateDeveloperAccess,
55959
+ validateGameManagementAccess
55750
55960
  });
55751
55961
  const timebackAdmin = new TimebackAdminService({
55752
55962
  db: db2,
55753
55963
  timeback: timebackClient,
55754
- validateDeveloperAccess
55964
+ validateDeveloperAccess,
55965
+ validateGameManagementAccess
55755
55966
  });
55756
55967
  return {
55757
55968
  bucket,
@@ -58739,6 +58950,7 @@ function createCaliperNamespace(client) {
58739
58950
  email: data.studentEmail
58740
58951
  },
58741
58952
  action: TIMEBACK_ACTIONS4.completed,
58953
+ ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
58742
58954
  object: {
58743
58955
  id: data.objectId || caliper.buildActivityUrl(data),
58744
58956
  type: TIMEBACK_TYPES4.activityContext,
@@ -58802,6 +59014,7 @@ function createCaliperNamespace(client) {
58802
59014
  email: data.studentEmail
58803
59015
  },
58804
59016
  action: TIMEBACK_ACTIONS4.spentTime,
59017
+ ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
58805
59018
  object: {
58806
59019
  id: caliper.buildActivityUrl(data),
58807
59020
  type: TIMEBACK_TYPES4.activityContext,
@@ -58835,7 +59048,7 @@ function createCaliperNamespace(client) {
58835
59048
  },
58836
59049
  buildActivityUrl: (data) => {
58837
59050
  const base = data.sensorUrl.replace(/\/$/, "");
58838
- return `${base}/activities/${data.courseId}/${data.activityId}/${crypto.randomUUID()}`;
59051
+ return `${base}/activities/${encodeURIComponent(data.courseId)}/${encodeURIComponent(data.activityId)}`;
58839
59052
  }
58840
59053
  };
58841
59054
  return caliper;
@@ -58845,6 +59058,34 @@ function createEduBridgeNamespace(client) {
58845
59058
  listByUser: async (userId) => {
58846
59059
  const response = await client["request"](`/edubridge/enrollments/user/${userId}`, "GET");
58847
59060
  return response.data;
59061
+ },
59062
+ enroll: async (userId, courseId, options) => {
59063
+ const segments = [userId, courseId];
59064
+ if (options?.schoolId) {
59065
+ segments.push(options.schoolId);
59066
+ }
59067
+ const body2 = {};
59068
+ if (options?.role) {
59069
+ body2.role = options.role;
59070
+ }
59071
+ if (options?.sourcedId) {
59072
+ body2.sourcedId = options.sourcedId;
59073
+ }
59074
+ if (options?.beginDate) {
59075
+ body2.beginDate = options.beginDate;
59076
+ }
59077
+ if (options?.metadata) {
59078
+ body2.metadata = options.metadata;
59079
+ }
59080
+ const response = await client["request"](`/edubridge/enrollments/enroll/${segments.join("/")}`, "POST", body2);
59081
+ return response.data;
59082
+ },
59083
+ unenroll: async (userId, courseId, options) => {
59084
+ const segments = [userId, courseId];
59085
+ if (options?.schoolId) {
59086
+ segments.push(options.schoolId);
59087
+ }
59088
+ await client["request"](`/edubridge/enrollments/unenroll/${segments.join("/")}`, "DELETE");
58848
59089
  }
58849
59090
  };
58850
59091
  const analytics = {
@@ -59020,6 +59261,10 @@ function createOneRosterNamespace(client) {
59020
59261
  logTimebackError("list course roster", error, { courseSourcedId });
59021
59262
  throw error;
59022
59263
  }
59264
+ },
59265
+ create: async (data) => client["request"](ONEROSTER_ENDPOINTS4.enrollments, "POST", { enrollment: data }),
59266
+ delete: async (sourcedId) => {
59267
+ await client["request"](`${ONEROSTER_ENDPOINTS4.enrollments}/${sourcedId}`, "DELETE");
59023
59268
  }
59024
59269
  },
59025
59270
  organizations: {
@@ -59853,7 +60098,8 @@ class ProgressRecorder {
59853
60098
  masteredUnits,
59854
60099
  attemptNumber: currentAttemptNumber,
59855
60100
  progressData,
59856
- extensions
60101
+ extensions,
60102
+ runId: progressData.runId
59857
60103
  });
59858
60104
  return {
59859
60105
  xpAwarded: calculatedXp,
@@ -59991,7 +60237,8 @@ class ProgressRecorder {
59991
60237
  masteredUnits,
59992
60238
  attemptNumber,
59993
60239
  progressData,
59994
- extensions
60240
+ extensions,
60241
+ runId
59995
60242
  }) {
59996
60243
  await this.caliperNamespace.emitActivityEvent({
59997
60244
  studentId,
@@ -60008,7 +60255,8 @@ class ProgressRecorder {
60008
60255
  subject: progressData.subject,
60009
60256
  appName: progressData.appName,
60010
60257
  sensorUrl: progressData.sensorUrl,
60011
- extensions: extensions || progressData.extensions
60258
+ extensions: extensions || progressData.extensions,
60259
+ ...runId ? { runId } : {}
60012
60260
  }).catch((error) => {
60013
60261
  log.error("[ProgressRecorder] Failed to emit activity event", { error });
60014
60262
  });
@@ -60062,7 +60310,7 @@ class SessionRecorder {
60062
60310
  const courseName = sessionData.courseName || "Game Course";
60063
60311
  const student = await this.studentResolver.resolve(studentIdentifier, sessionData.studentEmail);
60064
60312
  const { id: studentId, email: studentEmail } = student;
60065
- const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions } = sessionData;
60313
+ const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions, runId } = sessionData;
60066
60314
  await this.caliperNamespace.emitTimeSpentEvent({
60067
60315
  studentId,
60068
60316
  studentEmail,
@@ -60076,6 +60324,7 @@ class SessionRecorder {
60076
60324
  subject: sessionData.subject,
60077
60325
  appName: sessionData.appName,
60078
60326
  sensorUrl: sessionData.sensorUrl,
60327
+ ...runId ? { runId } : {},
60079
60328
  ...extensions ? { extensions } : {}
60080
60329
  });
60081
60330
  }
@@ -120039,7 +120288,9 @@ var TIMEBACK_SUBJECTS5;
120039
120288
  var TimebackGradeSchema;
120040
120289
  var TimebackSubjectSchema;
120041
120290
  var UpdateTimebackXpRequestSchema;
120291
+ var TimebackActivityDataSchema;
120042
120292
  var EndActivityRequestSchema;
120293
+ var HeartbeatRequestSchema;
120043
120294
  var PopulateStudentRequestSchema;
120044
120295
  var DerivedPlatformCourseConfigSchema;
120045
120296
  var TimebackBaseConfigSchema;
@@ -120050,6 +120301,8 @@ var GrantTimebackXpRequestSchema;
120050
120301
  var AdjustTimebackTimeRequestSchema;
120051
120302
  var AdjustTimebackMasteryRequestSchema;
120052
120303
  var ToggleCourseCompletionRequestSchema;
120304
+ var EnrollStudentRequestSchema;
120305
+ var UnenrollStudentRequestSchema;
120053
120306
  var init_schemas11 = __esm(() => {
120054
120307
  init_esm();
120055
120308
  TIMEBACK_GRADES = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
@@ -120072,31 +120325,49 @@ var init_schemas11 = __esm(() => {
120072
120325
  xp: exports_external.number().min(0, "XP must be a non-negative number"),
120073
120326
  userTimestamp: exports_external.string().datetime().optional()
120074
120327
  });
120328
+ TimebackActivityDataSchema = exports_external.object({
120329
+ activityId: exports_external.string().min(1),
120330
+ activityName: exports_external.string().optional(),
120331
+ grade: TimebackGradeSchema,
120332
+ subject: TimebackSubjectSchema,
120333
+ appName: exports_external.string().optional(),
120334
+ sensorUrl: exports_external.string().url().optional(),
120335
+ courseId: exports_external.string().optional(),
120336
+ courseName: exports_external.string().optional(),
120337
+ studentEmail: exports_external.string().email().optional()
120338
+ });
120075
120339
  EndActivityRequestSchema = exports_external.object({
120076
120340
  gameId: exports_external.string().uuid(),
120077
120341
  studentId: exports_external.string().min(1),
120078
- activityData: exports_external.object({
120079
- activityId: exports_external.string().min(1),
120080
- activityName: exports_external.string().optional(),
120081
- grade: TimebackGradeSchema,
120082
- subject: TimebackSubjectSchema,
120083
- appName: exports_external.string().optional(),
120084
- sensorUrl: exports_external.string().url().optional(),
120085
- courseId: exports_external.string().optional(),
120086
- courseName: exports_external.string().optional(),
120087
- studentEmail: exports_external.string().email().optional()
120088
- }),
120342
+ runId: exports_external.string().uuid().optional(),
120343
+ activityData: TimebackActivityDataSchema,
120089
120344
  scoreData: exports_external.object({
120090
120345
  correctQuestions: exports_external.number().int().min(0),
120091
120346
  totalQuestions: exports_external.number().int().min(0)
120092
120347
  }),
120093
120348
  timingData: exports_external.object({
120094
- durationSeconds: exports_external.number().positive()
120349
+ durationSeconds: exports_external.number().nonnegative()
120095
120350
  }),
120351
+ sessionTimingData: exports_external.object({
120352
+ activeSeconds: exports_external.number().nonnegative(),
120353
+ inactiveSeconds: exports_external.number().nonnegative().optional()
120354
+ }).optional(),
120096
120355
  xpEarned: exports_external.number().optional(),
120097
120356
  masteredUnits: exports_external.number().nonnegative().optional(),
120098
120357
  extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
120099
120358
  });
120359
+ HeartbeatRequestSchema = exports_external.object({
120360
+ gameId: exports_external.string().uuid(),
120361
+ studentId: exports_external.string().min(1),
120362
+ runId: exports_external.string().uuid(),
120363
+ activityData: TimebackActivityDataSchema,
120364
+ timingData: exports_external.object({
120365
+ activeMs: exports_external.number().nonnegative(),
120366
+ pausedMs: exports_external.number().nonnegative()
120367
+ }),
120368
+ windowSequence: exports_external.number().int().nonnegative(),
120369
+ isFinal: exports_external.boolean().optional()
120370
+ });
120100
120371
  PopulateStudentRequestSchema = exports_external.object({
120101
120372
  firstName: exports_external.string().min(1).optional(),
120102
120373
  lastName: exports_external.string().min(1).optional()
@@ -120192,6 +120463,16 @@ var init_schemas11 = __esm(() => {
120192
120463
  studentId: exports_external.string().min(1),
120193
120464
  action: exports_external.enum(["complete", "resume"])
120194
120465
  });
120466
+ EnrollStudentRequestSchema = exports_external.object({
120467
+ gameId: exports_external.string().uuid(),
120468
+ courseId: exports_external.string().min(1),
120469
+ studentId: exports_external.string().min(1)
120470
+ });
120471
+ UnenrollStudentRequestSchema = exports_external.object({
120472
+ gameId: exports_external.string().uuid(),
120473
+ courseId: exports_external.string().min(1),
120474
+ studentId: exports_external.string().min(1)
120475
+ });
120195
120476
  });
120196
120477
  var init_schemas_index = __esm(() => {
120197
120478
  init_schemas();
@@ -120210,6 +120491,9 @@ function isAuthenticated(ctx) {
120210
120491
  return ctx.user != null;
120211
120492
  }
120212
120493
  var init_types9 = () => {};
120494
+ function hasGameManagementAccess(user) {
120495
+ return user.role === "admin" || user.role === "teacher" || user.role === "developer" && user.developerStatus === "approved";
120496
+ }
120213
120497
  function requireAuth(handler) {
120214
120498
  return async (ctx) => {
120215
120499
  if (!isAuthenticated(ctx)) {
@@ -120253,6 +120537,17 @@ function requireDeveloper(handler) {
120253
120537
  return handler(ctx);
120254
120538
  };
120255
120539
  }
120540
+ function requireGameManagementAccess(handler) {
120541
+ return async (ctx) => {
120542
+ if (!isAuthenticated(ctx)) {
120543
+ throw ApiError.unauthorized("Valid session or bearer token required");
120544
+ }
120545
+ if (!hasGameManagementAccess(ctx.user)) {
120546
+ throw ApiError.forbidden("Game management access required");
120547
+ }
120548
+ return handler(ctx);
120549
+ };
120550
+ }
120256
120551
  var init_auth_util = __esm(() => {
120257
120552
  init_errors();
120258
120553
  init_types9();
@@ -122378,6 +122673,7 @@ var verifyIntegration;
122378
122673
  var getConfig2;
122379
122674
  var deleteIntegrations;
122380
122675
  var endActivity;
122676
+ var heartbeat;
122381
122677
  var getStudentXp;
122382
122678
  var getRoster;
122383
122679
  var getStudentOverview;
@@ -122386,6 +122682,9 @@ var grantXp;
122386
122682
  var adjustTime;
122387
122683
  var adjustMastery;
122388
122684
  var toggleCompletion;
122685
+ var searchStudents;
122686
+ var enrollStudent;
122687
+ var unenrollStudent;
122389
122688
  var timeback2;
122390
122689
  var init_timeback_controller = __esm(() => {
122391
122690
  init_esm();
@@ -122476,7 +122775,7 @@ var init_timeback_controller = __esm(() => {
122476
122775
  });
122477
122776
  return ctx.services.timeback.setupIntegration(body2.gameId, body2, ctx.user);
122478
122777
  });
122479
- getIntegrations = requireDeveloper(async (ctx) => {
122778
+ getIntegrations = requireGameManagementAccess(async (ctx) => {
122480
122779
  const gameId = ctx.params.gameId;
122481
122780
  if (!gameId) {
122482
122781
  throw ApiError.badRequest("Missing gameId");
@@ -122536,9 +122835,11 @@ var init_timeback_controller = __esm(() => {
122536
122835
  const {
122537
122836
  gameId,
122538
122837
  studentId,
122838
+ runId,
122539
122839
  activityData,
122540
122840
  scoreData,
122541
122841
  timingData,
122842
+ sessionTimingData,
122542
122843
  xpEarned,
122543
122844
  masteredUnits,
122544
122845
  extensions
@@ -122547,15 +122848,50 @@ var init_timeback_controller = __esm(() => {
122547
122848
  return ctx.services.timeback.endActivity({
122548
122849
  gameId,
122549
122850
  studentId,
122851
+ runId,
122550
122852
  activityData,
122551
122853
  scoreData,
122552
122854
  timingData,
122855
+ sessionTimingData,
122553
122856
  xpEarned,
122554
122857
  masteredUnits,
122555
122858
  extensions,
122556
122859
  user: ctx.user
122557
122860
  });
122558
122861
  });
122862
+ heartbeat = requireDeveloper(async (ctx) => {
122863
+ let body2;
122864
+ try {
122865
+ const json4 = await ctx.request.json();
122866
+ body2 = HeartbeatRequestSchema.parse(json4);
122867
+ } catch (error2) {
122868
+ if (error2 instanceof exports_external.ZodError) {
122869
+ const details = formatZodError(error2);
122870
+ logger63.warn("Heartbeat validation failed", { details });
122871
+ throw ApiError.unprocessableEntity("Validation failed", details);
122872
+ }
122873
+ throw ApiError.badRequest("Invalid JSON body");
122874
+ }
122875
+ const { gameId, studentId, runId, activityData, timingData, windowSequence, isFinal } = body2;
122876
+ logger63.debug("Recording heartbeat", {
122877
+ userId: ctx.user.id,
122878
+ gameId,
122879
+ runId,
122880
+ windowSequence,
122881
+ activeMs: timingData.activeMs,
122882
+ isFinal
122883
+ });
122884
+ return ctx.services.timeback.recordHeartbeat({
122885
+ gameId,
122886
+ studentId,
122887
+ runId,
122888
+ activityData,
122889
+ timingData,
122890
+ windowSequence,
122891
+ isFinal,
122892
+ user: ctx.user
122893
+ });
122894
+ });
122559
122895
  getStudentXp = requireDeveloper(async (ctx) => {
122560
122896
  const timebackId = ctx.params.timebackId;
122561
122897
  if (!timebackId) {
@@ -122601,7 +122937,7 @@ var init_timeback_controller = __esm(() => {
122601
122937
  include
122602
122938
  });
122603
122939
  });
122604
- getRoster = requireDeveloper(async (ctx) => {
122940
+ getRoster = requireGameManagementAccess(async (ctx) => {
122605
122941
  const gameId = ctx.params.gameId;
122606
122942
  const courseId = ctx.params.courseId;
122607
122943
  if (!gameId || !courseId) {
@@ -122614,7 +122950,7 @@ var init_timeback_controller = __esm(() => {
122614
122950
  });
122615
122951
  return ctx.services.timebackAdmin.listStudentsForCourse(gameId, courseId, ctx.user);
122616
122952
  });
122617
- getStudentOverview = requireDeveloper(async (ctx) => {
122953
+ getStudentOverview = requireGameManagementAccess(async (ctx) => {
122618
122954
  const timebackId = ctx.params.timebackId;
122619
122955
  const gameId = ctx.url.searchParams.get("gameId") || undefined;
122620
122956
  const courseId = ctx.url.searchParams.get("courseId") || undefined;
@@ -122629,7 +122965,7 @@ var init_timeback_controller = __esm(() => {
122629
122965
  });
122630
122966
  return ctx.services.timebackAdmin.getStudentOverview(gameId, timebackId, ctx.user, courseId);
122631
122967
  });
122632
- getStudentActivity = requireDeveloper(async (ctx) => {
122968
+ getStudentActivity = requireGameManagementAccess(async (ctx) => {
122633
122969
  const timebackId = ctx.params.timebackId;
122634
122970
  const courseId = ctx.params.courseId;
122635
122971
  const gameId = ctx.url.searchParams.get("gameId") || undefined;
@@ -122692,7 +123028,7 @@ var init_timeback_controller = __esm(() => {
122692
123028
  });
122693
123029
  return ctx.services.timebackAdmin.adjustMasteredUnits(body2, ctx.user);
122694
123030
  });
122695
- toggleCompletion = requireDeveloper(async (ctx) => {
123031
+ toggleCompletion = requireGameManagementAccess(async (ctx) => {
122696
123032
  const body2 = await parseRequestBody(ctx.request, ToggleCourseCompletionRequestSchema);
122697
123033
  logger63.debug("Toggling course completion", {
122698
123034
  requesterId: ctx.user.id,
@@ -122703,6 +123039,41 @@ var init_timeback_controller = __esm(() => {
122703
123039
  });
122704
123040
  return ctx.services.timebackAdmin.toggleCourseCompletion(body2, ctx.user);
122705
123041
  });
123042
+ searchStudents = requireGameManagementAccess(async (ctx) => {
123043
+ const gameId = ctx.params.gameId;
123044
+ const courseId = ctx.params.courseId;
123045
+ const query = ctx.url.searchParams.get("q") || "";
123046
+ if (!gameId || !courseId) {
123047
+ throw ApiError.badRequest("Missing gameId or courseId parameter");
123048
+ }
123049
+ logger63.debug("Searching students for enrollment", {
123050
+ requesterId: ctx.user.id,
123051
+ gameId,
123052
+ courseId,
123053
+ query
123054
+ });
123055
+ return ctx.services.timebackAdmin.searchStudentsForEnrollment(gameId, courseId, query, ctx.user);
123056
+ });
123057
+ enrollStudent = requireGameManagementAccess(async (ctx) => {
123058
+ const body2 = await parseRequestBody(ctx.request, EnrollStudentRequestSchema);
123059
+ logger63.debug("Enrolling student", {
123060
+ requesterId: ctx.user.id,
123061
+ gameId: body2.gameId,
123062
+ courseId: body2.courseId,
123063
+ studentId: body2.studentId
123064
+ });
123065
+ return ctx.services.timebackAdmin.enrollStudent(body2, ctx.user);
123066
+ });
123067
+ unenrollStudent = requireGameManagementAccess(async (ctx) => {
123068
+ const body2 = await parseRequestBody(ctx.request, UnenrollStudentRequestSchema);
123069
+ logger63.debug("Unenrolling student", {
123070
+ requesterId: ctx.user.id,
123071
+ gameId: body2.gameId,
123072
+ courseId: body2.courseId,
123073
+ studentId: body2.studentId
123074
+ });
123075
+ return ctx.services.timebackAdmin.unenrollStudent(body2, ctx.user);
123076
+ });
122706
123077
  timeback2 = {
122707
123078
  getTodayXp,
122708
123079
  getTotalXp,
@@ -122717,6 +123088,7 @@ var init_timeback_controller = __esm(() => {
122717
123088
  getConfig: getConfig2,
122718
123089
  deleteIntegrations,
122719
123090
  endActivity,
123091
+ heartbeat,
122720
123092
  getStudentXp,
122721
123093
  getRoster,
122722
123094
  getStudentOverview,
@@ -122724,7 +123096,10 @@ var init_timeback_controller = __esm(() => {
122724
123096
  grantXp,
122725
123097
  adjustTime,
122726
123098
  adjustMastery,
122727
- toggleCompletion
123099
+ toggleCompletion,
123100
+ searchStudents,
123101
+ enrollStudent,
123102
+ unenrollStudent
122728
123103
  };
122729
123104
  });
122730
123105
  var logger64;
@@ -123700,6 +124075,7 @@ var init_timeback6 = __esm(() => {
123700
124075
  timebackRouter.get("/config/:gameId", handle2(timeback2.getConfig));
123701
124076
  timebackRouter.delete("/integrations/:gameId", handle2(timeback2.deleteIntegrations, { status: 204 }));
123702
124077
  timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
124078
+ timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
123703
124079
  timebackRouter.get("/user", async (c2) => {
123704
124080
  const user = c2.get("user");
123705
124081
  const gameId = c2.get("gameId");
@@ -124634,7 +125010,8 @@ var TIMEBACK_COMPONENT_RESOURCE_DEFAULTS2;
124634
125010
  var init_timeback7 = __esm7(() => {
124635
125011
  TIMEBACK_ROUTES2 = {
124636
125012
  END_ACTIVITY: "/integrations/timeback/end-activity",
124637
- GET_XP: "/integrations/timeback/xp"
125013
+ GET_XP: "/integrations/timeback/xp",
125014
+ HEARTBEAT: "/integrations/timeback/heartbeat"
124638
125015
  };
124639
125016
  TIMEBACK_COURSE_DEFAULTS2 = {
124640
125017
  gradingScheme: "STANDARD",
@@ -125286,7 +125663,7 @@ var import_picocolors12 = __toESM(require_picocolors(), 1);
125286
125663
  // package.json
125287
125664
  var package_default2 = {
125288
125665
  name: "@playcademy/vite-plugin",
125289
- version: "0.2.24-beta.3",
125666
+ version: "0.2.24-beta.5",
125290
125667
  type: "module",
125291
125668
  exports: {
125292
125669
  ".": {