@playcademy/vite-plugin 0.2.24-beta.4 → 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 +737 -554
  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.7",
25339
+ version: "0.3.17-beta.8",
25339
25340
  description: "Local development server for Playcademy game development",
25340
25341
  type: "module",
25341
25342
  exports: {
@@ -52700,7 +52701,8 @@ var init_constants3 = __esm(() => {
52700
52701
  HEALTH: "/api/health",
52701
52702
  TIMEBACK: {
52702
52703
  END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
52703
- GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`
52704
+ GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
52705
+ HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`
52704
52706
  }
52705
52707
  };
52706
52708
  });
@@ -55165,590 +55167,704 @@ var init_timeback_admin_service = __esm(() => {
55165
55167
  init_timeback_util();
55166
55168
  logger16 = log.scope("TimebackAdminService");
55167
55169
  });
55168
-
55169
- class TimebackService {
55170
- deps;
55171
- constructor(deps) {
55172
- this.deps = deps;
55173
- }
55174
- requireClient() {
55175
- if (!this.deps.timeback) {
55176
- logger17.error("Timeback client not available in context");
55177
- 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
+ }
55178
55193
  }
55179
- return this.deps.timeback;
55180
- }
55181
- async getTodayXp(userId, date3, timezone2) {
55182
- const db2 = this.deps.db;
55183
- const tz = timezone2 || PLATFORM_TIMEZONE;
55184
- const base = date3 ? new Date(date3) : new Date;
55185
- if (isNaN(base.getTime())) {
55186
- throw new ValidationError("Invalid date format. Use ISO 8601 format.");
55194
+ static isDuplicateHeartbeatWindow(key) {
55195
+ this.cleanHeartbeatDedupeCache();
55196
+ return this.processedHeartbeatWindows.has(key);
55187
55197
  }
55188
- try {
55189
- new Intl.DateTimeFormat(undefined, { timeZone: tz });
55190
- } catch {
55191
- throw new ValidationError(`Invalid timezone: ${tz}`);
55198
+ static getInFlightHeartbeatWindow(key) {
55199
+ return this.inFlightHeartbeatWindows.get(key);
55192
55200
  }
55193
- if (tz === PLATFORM_TIMEZONE) {
55194
- const todayMidnight = getUtcInstantForMidnight(base, tz);
55195
- 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);
55196
- if (result2.length === 0) {
55197
- return { xp: 0, date: todayMidnight.toISOString() };
55198
- }
55199
- return { xp: result2[0].xp, date: result2[0].date.toISOString() };
55201
+ static markHeartbeatWindowProcessed(key) {
55202
+ this.processedHeartbeatWindows.set(key, Date.now());
55200
55203
  }
55201
- const { startOfDay, endOfDay } = getDayBoundariesInTimezone(base, tz);
55202
- 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))));
55203
- return { xp: Number(result[0]?.totalXp) || 0, date: startOfDay.toISOString() };
55204
- }
55205
- async getTotalXp(userId) {
55206
- const db2 = this.deps.db;
55207
- const result = await db2.select({ totalXp: sum(timebackDailyXp.xp) }).from(timebackDailyXp).where(eq(timebackDailyXp.userId, userId));
55208
- return { totalXp: Number(result[0]?.totalXp) || 0 };
55209
- }
55210
- async updateTodayXp(userId, data) {
55211
- const db2 = this.deps.db;
55212
- const { xp, userTimestamp } = data;
55213
- let targetDate;
55214
- if (userTimestamp) {
55215
- targetDate = new Date(userTimestamp);
55216
- if (isNaN(targetDate.getTime())) {
55217
- throw new ValidationError("Invalid userTimestamp format. Use ISO 8601 format.");
55218
- }
55219
- targetDate.setHours(0, 0, 0, 0);
55220
- } else {
55221
- targetDate = new Date;
55222
- targetDate.setUTCHours(0, 0, 0, 0);
55204
+ static markHeartbeatWindowInFlight(key, promise) {
55205
+ this.inFlightHeartbeatWindows.set(key, promise);
55223
55206
  }
55224
- const [result] = await db2.insert(timebackDailyXp).values({ userId, date: targetDate, xp }).onConflictDoUpdate({
55225
- target: [timebackDailyXp.userId, timebackDailyXp.date],
55226
- set: { xp: sql`excluded.xp`, updatedAt: new Date }
55227
- }).returning({ xp: timebackDailyXp.xp, date: timebackDailyXp.date });
55228
- if (!result) {
55229
- logger17.error("Daily XP upsert returned no rows", { userId, date: targetDate });
55230
- throw new InternalError("Failed to update daily XP record");
55207
+ static clearInFlightHeartbeatWindow(key) {
55208
+ this.inFlightHeartbeatWindows.delete(key);
55231
55209
  }
55232
- return { xp: result.xp, date: result.date.toISOString() };
55233
- }
55234
- async getXpHistory(userId, startDate, endDate) {
55235
- const db2 = this.deps.db;
55236
- const whereConditions = [eq(timebackDailyXp.userId, userId)];
55237
- if (startDate) {
55238
- const start2 = new Date(startDate);
55239
- start2.setUTCHours(0, 0, 0, 0);
55240
- whereConditions.push(gte(timebackDailyXp.date, start2));
55210
+ constructor(deps) {
55211
+ this.deps = deps;
55241
55212
  }
55242
- if (endDate) {
55243
- const end = new Date(endDate);
55244
- end.setUTCHours(23, 59, 59, 999);
55245
- 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;
55246
55219
  }
55247
- const result = await db2.select({ date: timebackDailyXp.date, xp: timebackDailyXp.xp }).from(timebackDailyXp).where(and(...whereConditions)).orderBy(timebackDailyXp.date);
55248
- return {
55249
- history: result.map((row) => ({ date: row.date.toISOString(), xp: row.xp }))
55250
- };
55251
- }
55252
- async populateStudent(user, providedNames) {
55253
- const client = this.requireClient();
55254
- const db2 = this.deps.db;
55255
- const dbUser = await db2.query.users.findFirst({
55256
- where: eq(users.id, user.id),
55257
- columns: { id: true, timebackId: true }
55258
- });
55259
- if (dbUser?.timebackId) {
55260
- logger17.info("Student already onboarded", { userId: user.id });
55261
- 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() };
55262
55243
  }
55263
- let timebackId;
55264
- let name3;
55265
- try {
55266
- const existingUser = await client.oneroster.users.findByEmail(user.email);
55267
- timebackId = existingUser.sourcedId;
55268
- name3 = `${existingUser.givenName} ${existingUser.familyName}`;
55269
- logger17.info("Found existing student in OneRoster", {
55270
- userId: user.id,
55271
- timebackId
55272
- });
55273
- } catch {
55274
- if (!providedNames?.firstName || !providedNames?.lastName) {
55275
- 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);
55276
55262
  }
55277
- const sourcedId = crypto.randomUUID();
55278
- const response = await client.oneroster.users.create({
55279
- sourcedId,
55280
- status: "active",
55281
- enabledUser: true,
55282
- givenName: providedNames.firstName,
55283
- familyName: providedNames.lastName,
55284
- email: user.email,
55285
- roles: [
55286
- {
55287
- roleType: "primary",
55288
- role: "student",
55289
- org: { sourcedId: TIMEBACK_ORG_SOURCED_ID }
55290
- }
55291
- ]
55292
- });
55293
- if (!response.sourcedIdPairs?.allocatedSourcedId) {
55294
- 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");
55295
55270
  }
55296
- timebackId = response.sourcedIdPairs.allocatedSourcedId;
55297
- name3 = `${providedNames.firstName} ${providedNames.lastName}`;
55298
- logger17.info("Created student in OneRoster", { userId: user.id, timebackId });
55271
+ return { xp: result.xp, date: result.date.toISOString() };
55299
55272
  }
55300
- const assessments = await this.fetchAssessments(timebackId);
55301
- await db2.transaction(async (tx) => {
55302
- if (assessments.length > 0) {
55303
- const events = mapAssessmentsToXpEvents(user.id, assessments);
55304
- for (const event of events) {
55305
- try {
55306
- await tx.insert(timebackXpEvents).values(event);
55307
- } 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" };
55308
55315
  }
55309
- const dailyMap = new Map;
55310
- for (const a of assessments) {
55311
- const xp = a.metadata?.xp;
55312
- if (typeof xp === "number" && a.scoreDate) {
55313
- const day = getUtcInstantForMidnight(new Date(a.scoreDate), PLATFORM_TIMEZONE);
55314
- const key = day.toISOString();
55315
- 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
+ });
55316
55367
  }
55317
55368
  }
55318
- if (dailyMap.size > 0) {
55319
- 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", {
55320
55372
  userId: user.id,
55321
- date: new Date(iso),
55322
- xp
55323
- }));
55324
- await tx.insert(timebackDailyXp).values(dailyRecords).onConflictDoUpdate({
55325
- target: [timebackDailyXp.userId, timebackDailyXp.date],
55326
- set: { xp: sql`excluded.xp`, updatedAt: new Date }
55373
+ timebackId
55327
55374
  });
55375
+ throw new InternalError("Failed to update user with Timeback ID");
55328
55376
  }
55329
- }
55330
- const [updated] = await tx.update(users).set({ timebackId, name: name3 }).where(eq(users.id, user.id)).returning({ id: users.id });
55331
- if (!updated) {
55332
- logger17.error("User Timeback ID update returned no rows", {
55333
- userId: user.id,
55334
- timebackId
55335
- });
55336
- throw new InternalError("Failed to update user with Timeback ID");
55337
- }
55338
- });
55339
- return { status: "ok" };
55340
- }
55341
- async fetchAssessments(studentSourcedId) {
55342
- const client = this.requireClient();
55343
- const allAssessments = [];
55344
- const limit = 3000;
55345
- const fields = "sourcedId,assessmentLineItem,student,score,textScore,scoreDate,scoreStatus,scorePercentile,comment,metadata,inProgress,incomplete,late,missing";
55346
- let offset = 0;
55347
- try {
55348
- while (true) {
55349
- const results = await client.oneroster.assessmentResults.listByStudent(studentSourcedId, { limit, offset, fields });
55350
- allAssessments.push(...results);
55351
- if (results.length < limit) {
55352
- break;
55353
- }
55354
- offset += limit;
55355
- }
55356
- logger17.debug("Fetched assessments", {
55357
- studentSourcedId,
55358
- totalCount: allAssessments.length
55359
55377
  });
55360
- return allAssessments;
55361
- } catch (error) {
55362
- logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
55363
- return [];
55364
- }
55365
- }
55366
- async getUserData(userId, gameId) {
55367
- const db2 = this.deps.db;
55368
- const userData = await db2.query.users.findFirst({ where: eq(users.id, userId) });
55369
- if (!userData) {
55370
- throw new NotFoundError("User", userId);
55371
- }
55372
- if (!userData.timebackId) {
55373
- throw new NotFoundError("Timeback account not found for user");
55378
+ return { status: "ok" };
55374
55379
  }
55375
- const [profile, allEnrollments] = await Promise.all([
55376
- this.fetchStudentProfile(userData.timebackId),
55377
- this.fetchEnrollments(userData.timebackId)
55378
- ]);
55379
- const enrollments = gameId ? allEnrollments.filter((e) => e.gameId === gameId) : allEnrollments;
55380
- const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
55381
- const organizations = gameId && enrollmentOrgIds.size > 0 ? profile.organizations.filter((o) => enrollmentOrgIds.has(o.id)) : profile.organizations;
55382
- return { id: userData.timebackId, role: profile.role, enrollments, organizations };
55383
- }
55384
- async getUserDataByTimebackId(timebackId) {
55385
- const [profile, enrollments] = await Promise.all([
55386
- this.fetchStudentProfile(timebackId),
55387
- this.fetchEnrollments(timebackId)
55388
- ]);
55389
- return {
55390
- id: timebackId,
55391
- role: profile.role,
55392
- enrollments,
55393
- organizations: profile.organizations
55394
- };
55395
- }
55396
- async fetchStudentProfile(timebackId) {
55397
- const client = this.requireClient();
55398
- try {
55399
- const user = await client.oneroster.users.get(timebackId);
55400
- const primaryRole = user.roles.find((r) => r.roleType === "primary");
55401
- const role = primaryRole?.role ?? user.roles[0]?.role ?? "student";
55402
- const orgMap = new Map;
55403
- if (user.primaryOrg) {
55404
- orgMap.set(user.primaryOrg.sourcedId, {
55405
- id: user.primaryOrg.sourcedId,
55406
- name: user.primaryOrg.name ?? null,
55407
- type: user.primaryOrg.type || "school",
55408
- 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
55409
55398
  });
55399
+ return allAssessments;
55400
+ } catch (error) {
55401
+ logger17.warn("Failed to fetch assessments", { studentSourcedId, error });
55402
+ return [];
55410
55403
  }
55411
- for (const r of user.roles) {
55412
- if (r.org && !orgMap.has(r.org.sourcedId)) {
55413
- orgMap.set(r.org.sourcedId, {
55414
- id: r.org.sourcedId,
55415
- name: null,
55416
- type: "school",
55417
- 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
55418
55448
  });
55419
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: [] };
55420
55463
  }
55421
- return { role, organizations: [...orgMap.values()] };
55422
- } catch {
55423
- return { role: "student", organizations: [] };
55424
55464
  }
55425
- }
55426
- async fetchEnrollments(timebackId) {
55427
- const client = this.requireClient();
55428
- const db2 = this.deps.db;
55429
- try {
55430
- const enrollments = await client.getEnrollments(timebackId);
55431
- const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
55432
- 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 {
55433
55486
  return [];
55434
55487
  }
55435
- const courseToSchool = new Map(enrollments.filter((e) => e.school?.id).map((e) => [e.courseId, e.school.id]));
55436
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55437
- where: inArray(gameTimebackIntegrations.courseId, courseIds)
55438
- });
55439
- return integrations.map((i2) => ({
55440
- gameId: i2.gameId,
55441
- grade: i2.grade,
55442
- subject: i2.subject,
55443
- courseId: i2.courseId,
55444
- orgId: courseToSchool.get(i2.courseId)
55445
- }));
55446
- } catch {
55447
- return [];
55448
55488
  }
55449
- }
55450
- async setupIntegration(gameId, request, user) {
55451
- const client = this.requireClient();
55452
- const db2 = this.deps.db;
55453
- await this.deps.validateDeveloperAccess(user, gameId);
55454
- const { courses, baseConfig, verbose } = request;
55455
- const existing = await db2.query.gameTimebackIntegrations.findMany({
55456
- where: eq(gameTimebackIntegrations.gameId, gameId)
55457
- });
55458
- const integrations = [];
55459
- const verboseData = [];
55460
- for (const courseConfig of courses) {
55461
- let applySuffix = function(text3) {
55462
- return suffix ? `${text3} ${suffix}` : text3;
55463
- };
55464
- const {
55465
- subject: subjectInput,
55466
- grade,
55467
- title,
55468
- courseCode,
55469
- level,
55470
- metadata: metadata2,
55471
- totalXp: derivedTotalXp,
55472
- masterableUnits: derivedMasterableUnits
55473
- } = courseConfig;
55474
- if (!isTimebackSubject(subjectInput)) {
55475
- 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 {
55476
55504
  subject: subjectInput,
55477
- courseCode,
55478
- title
55479
- });
55480
- throw new ValidationError(`Invalid subject "${subjectInput}"`);
55481
- }
55482
- if (!isTimebackGrade(grade)) {
55483
- logger17.warn("Invalid Timeback grade in course config", {
55484
55505
  grade,
55485
- courseCode,
55486
- title
55487
- });
55488
- throw new ValidationError(`Invalid grade "${grade}"`);
55489
- }
55490
- const subject = subjectInput;
55491
- const courseMetadata = isCourseMetadata(metadata2) ? metadata2 : undefined;
55492
- const totalXp = derivedTotalXp ?? courseMetadata?.metrics?.totalXp;
55493
- const masterableUnits = derivedMasterableUnits ?? (isPlaycademyResourceMetadata(courseMetadata?.playcademy) ? courseMetadata?.playcademy?.mastery?.masterableUnits : undefined);
55494
- if (typeof totalXp !== "number") {
55495
- logger17.warn("Course missing totalXp in Timeback config", {
55496
- courseCode,
55497
- title
55498
- });
55499
- throw new ValidationError(`Course "${title}" is missing totalXp`);
55500
- }
55501
- const suffix = baseConfig.component.titleSuffix || "";
55502
- const fullConfig = {
55503
- organization: baseConfig.organization,
55504
- course: {
55505
55506
  title,
55506
- subjects: [subject],
55507
- grades: [grade],
55508
55507
  courseCode,
55509
55508
  level,
55510
- gradingScheme: "STANDARD",
55511
- metadata: metadata2
55512
- },
55513
- component: {
55514
- ...baseConfig.component,
55515
- title: applySuffix(baseConfig.component.title || `${title} Activities`)
55516
- },
55517
- resource: {
55518
- ...baseConfig.resource,
55519
- title: applySuffix(baseConfig.resource.title || `${title} Game`),
55520
- metadata: buildResourceMetadata({
55521
- baseMetadata: baseConfig.resource.metadata,
55522
- subject,
55523
- grade,
55524
- totalXp,
55525
- masterableUnits
55526
- })
55527
- },
55528
- componentResource: {
55529
- ...baseConfig.componentResource,
55530
- title: applySuffix(baseConfig.componentResource.title || "")
55531
- }
55532
- };
55533
- const existingIntegration = existing.find((i2) => i2.grade === grade && i2.subject === subject);
55534
- if (existingIntegration) {
55535
- await client.update(existingIntegration.courseId, fullConfig);
55536
- const [updated] = await db2.update(gameTimebackIntegrations).set({ totalXp, updatedAt: new Date }).where(eq(gameTimebackIntegrations.id, existingIntegration.id)).returning();
55537
- if (updated) {
55538
- 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}"`);
55539
55520
  }
55540
- } else {
55541
- const result = await client.setup(fullConfig, { verbose });
55542
- const [integration] = await db2.insert(gameTimebackIntegrations).values({ gameId, courseId: result.courseId, grade, subject, totalXp }).returning();
55543
- if (integration) {
55544
- const dto = this.toGameTimebackIntegration(integration);
55545
- integrations.push(dto);
55546
- if (verbose && result.verboseData) {
55547
- 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
+ }
55548
55588
  }
55549
55589
  }
55550
55590
  }
55591
+ return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
55551
55592
  }
55552
- return { integrations, ...verbose && verboseData.length > 0 && { verbose: verboseData } };
55553
- }
55554
- async getIntegrations(gameId, user) {
55555
- await this.deps.validateGameManagementAccess(user, gameId);
55556
- const rows = await this.deps.db.query.gameTimebackIntegrations.findMany({
55557
- where: eq(gameTimebackIntegrations.gameId, gameId)
55558
- });
55559
- return rows.map((row) => this.toGameTimebackIntegration(row));
55560
- }
55561
- async verifyIntegration(gameId, user) {
55562
- const client = this.requireClient();
55563
- const db2 = this.deps.db;
55564
- await this.deps.validateDeveloperAccess(user, gameId);
55565
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55566
- where: eq(gameTimebackIntegrations.gameId, gameId)
55567
- });
55568
- if (integrations.length === 0) {
55569
- 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));
55570
55599
  }
55571
- const now2 = new Date;
55572
- const results = await Promise.all(integrations.map(async (integration) => {
55573
- const resources = await client.verify(integration.courseId);
55574
- const resourceValues = Object.values(resources);
55575
- const allFound = resourceValues.every((r) => r.found);
55576
- const errors3 = Object.entries(resources).filter(([_2, r]) => !r.found).map(([name3]) => `${name3} not found`);
55577
- const status = allFound ? "success" : "error";
55578
- return {
55579
- integration: this.toGameTimebackIntegration({
55580
- ...integration,
55581
- lastVerifiedAt: now2
55582
- }),
55583
- resources,
55584
- status,
55585
- ...errors3.length > 0 && { errors: errors3 }
55586
- };
55587
- }));
55588
- await db2.update(gameTimebackIntegrations).set({ lastVerifiedAt: now2 }).where(eq(gameTimebackIntegrations.gameId, gameId));
55589
- const overallStatus = results.every((r) => r.status === "success") ? "success" : "error";
55590
- return { status: overallStatus, results };
55591
- }
55592
- async getConfig(gameId, user) {
55593
- const client = this.requireClient();
55594
- await this.deps.validateDeveloperAccess(user, gameId);
55595
- const integration = await this.deps.db.query.gameTimebackIntegrations.findFirst({
55596
- where: eq(gameTimebackIntegrations.gameId, gameId)
55597
- });
55598
- if (!integration) {
55599
- 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 };
55600
55630
  }
55601
- return client.getConfig(integration.courseId);
55602
- }
55603
- async deleteIntegrations(gameId, user) {
55604
- const client = this.requireClient();
55605
- const db2 = this.deps.db;
55606
- await this.deps.validateDeveloperAccess(user, gameId);
55607
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55608
- where: eq(gameTimebackIntegrations.gameId, gameId)
55609
- });
55610
- if (integrations.length === 0) {
55611
- throw new NotFoundError("Timeback integration", gameId);
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);
55612
55641
  }
55613
- for (const integration of integrations) {
55614
- await client.cleanup(integration.courseId);
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));
55615
55656
  }
55616
- await db2.delete(gameTimebackIntegrations).where(eq(gameTimebackIntegrations.gameId, gameId));
55617
- }
55618
- toGameTimebackIntegration(integration) {
55619
- return {
55620
- id: integration.id,
55621
- gameId: integration.gameId,
55622
- courseId: integration.courseId,
55623
- grade: integration.grade,
55624
- subject: integration.subject,
55625
- totalXp: integration.totalXp ?? null,
55626
- createdAt: integration.createdAt,
55627
- updatedAt: integration.updatedAt,
55628
- lastVerifiedAt: integration.lastVerifiedAt ?? null
55629
- };
55630
- }
55631
- async endActivity({
55632
- gameId,
55633
- studentId,
55634
- activityData,
55635
- scoreData,
55636
- timingData,
55637
- xpEarned,
55638
- masteredUnits,
55639
- extensions,
55640
- user
55641
- }) {
55642
- const client = this.requireClient();
55643
- const db2 = this.deps.db;
55644
- await this.deps.validateDeveloperAccess(user, gameId);
55645
- const integration = await db2.query.gameTimebackIntegrations.findFirst({
55646
- where: and(eq(gameTimebackIntegrations.gameId, gameId), eq(gameTimebackIntegrations.grade, activityData.grade), eq(gameTimebackIntegrations.subject, activityData.subject))
55647
- });
55648
- if (!integration) {
55649
- throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
55650
- }
55651
- const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
55652
- const result = await client.recordProgress(integration.courseId, studentId, {
55653
- score: scorePercentage,
55654
- totalQuestions: scoreData.totalQuestions,
55655
- correctQuestions: scoreData.correctQuestions,
55656
- durationSeconds: timingData.durationSeconds,
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
+ };
55669
+ }
55670
+ async endActivity({
55671
+ gameId,
55672
+ studentId,
55673
+ runId,
55674
+ activityData,
55675
+ scoreData,
55676
+ timingData,
55677
+ sessionTimingData,
55657
55678
  xpEarned,
55658
55679
  masteredUnits,
55659
55680
  extensions,
55660
- activityId: activityData.activityId,
55661
- activityName: activityData.activityName,
55662
- subject: activityData.subject,
55663
- appName: activityData.appName,
55664
- sensorUrl: activityData.sensorUrl,
55665
- courseId: activityData.courseId,
55666
- courseName: activityData.courseName,
55667
- studentEmail: activityData.studentEmail,
55668
- courseTotalXp: integration.totalXp
55669
- });
55670
- await client.recordSessionEnd(integration.courseId, studentId, {
55671
- activeTimeSeconds: timingData.durationSeconds,
55672
- activityId: activityData.activityId,
55673
- activityName: activityData.activityName,
55674
- subject: activityData.subject,
55675
- appName: activityData.appName,
55676
- sensorUrl: activityData.sensorUrl,
55677
- courseId: activityData.courseId,
55678
- courseName: activityData.courseName,
55679
- studentEmail: activityData.studentEmail
55680
- });
55681
- 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({
55682
55747
  gameId,
55683
- courseId: integration.courseId,
55684
55748
  studentId,
55685
- score: scorePercentage
55686
- });
55687
- return {
55688
- status: "ok",
55689
- courseId: integration.courseId,
55690
- xpAwarded: result.xpAwarded,
55691
- masteredUnits: result.masteredUnitsApplied,
55692
- pctCompleteApp: result.pctCompleteApp,
55693
- scoreStatus: result.scoreStatus,
55694
- inProgress: result.inProgress
55695
- };
55696
- }
55697
- async getStudentXp(timebackId, user, options) {
55698
- const client = this.requireClient();
55699
- const db2 = this.deps.db;
55700
- let courseIds = [];
55701
- if (options?.gameId) {
55702
- await this.deps.validateDeveloperAccess(user, options.gameId);
55703
- const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
55704
- if (options.grade !== undefined && options.subject) {
55705
- conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
55706
- 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" };
55707
55768
  }
55708
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
55709
- where: and(...conditions2)
55710
- });
55711
- courseIds = integrations.map((i2) => i2.courseId);
55712
- if (courseIds.length === 0) {
55713
- logger17.debug("No integrations found for game, returning 0 XP", {
55714
- timebackId,
55715
- gameId: options.gameId,
55716
- grade: options.grade,
55717
- 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
55718
55778
  });
55719
- return {
55720
- totalXp: 0,
55721
- ...options?.include?.today && { todayXp: 0 },
55722
- ...options?.include?.perCourse && { courses: [] }
55723
- };
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);
55724
55822
  }
55725
55823
  }
55726
- const result = await client.getStudentXp(timebackId, {
55727
- courseIds: courseIds.length > 0 ? courseIds : undefined,
55728
- include: options?.include
55729
- });
55730
- logger17.debug("Retrieved student XP", {
55731
- timebackId,
55732
- gameId: options?.gameId,
55733
- grade: options?.grade,
55734
- subject: options?.subject,
55735
- totalXp: result.totalXp,
55736
- courseCount: result.courses?.length
55737
- });
55738
- return result;
55739
- }
55740
- }
55741
- var logger17;
55742
- var init_timeback_service = __esm(() => {
55743
- init_drizzle_orm();
55744
- init_src();
55745
- init_tables_index();
55746
- init_src2();
55747
- init_types4();
55748
- init_src4();
55749
- init_errors();
55750
- init_timeback_util();
55751
- 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
+ };
55752
55868
  });
55753
55869
 
55754
55870
  class UploadService {
@@ -58834,6 +58950,7 @@ function createCaliperNamespace(client) {
58834
58950
  email: data.studentEmail
58835
58951
  },
58836
58952
  action: TIMEBACK_ACTIONS4.completed,
58953
+ ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
58837
58954
  object: {
58838
58955
  id: data.objectId || caliper.buildActivityUrl(data),
58839
58956
  type: TIMEBACK_TYPES4.activityContext,
@@ -58897,6 +59014,7 @@ function createCaliperNamespace(client) {
58897
59014
  email: data.studentEmail
58898
59015
  },
58899
59016
  action: TIMEBACK_ACTIONS4.spentTime,
59017
+ ...data.runId ? { session: `urn:uuid:${data.runId}` } : {},
58900
59018
  object: {
58901
59019
  id: caliper.buildActivityUrl(data),
58902
59020
  type: TIMEBACK_TYPES4.activityContext,
@@ -58930,7 +59048,7 @@ function createCaliperNamespace(client) {
58930
59048
  },
58931
59049
  buildActivityUrl: (data) => {
58932
59050
  const base = data.sensorUrl.replace(/\/$/, "");
58933
- return `${base}/activities/${data.courseId}/${data.activityId}/${crypto.randomUUID()}`;
59051
+ return `${base}/activities/${encodeURIComponent(data.courseId)}/${encodeURIComponent(data.activityId)}`;
58934
59052
  }
58935
59053
  };
58936
59054
  return caliper;
@@ -59980,7 +60098,8 @@ class ProgressRecorder {
59980
60098
  masteredUnits,
59981
60099
  attemptNumber: currentAttemptNumber,
59982
60100
  progressData,
59983
- extensions
60101
+ extensions,
60102
+ runId: progressData.runId
59984
60103
  });
59985
60104
  return {
59986
60105
  xpAwarded: calculatedXp,
@@ -60118,7 +60237,8 @@ class ProgressRecorder {
60118
60237
  masteredUnits,
60119
60238
  attemptNumber,
60120
60239
  progressData,
60121
- extensions
60240
+ extensions,
60241
+ runId
60122
60242
  }) {
60123
60243
  await this.caliperNamespace.emitActivityEvent({
60124
60244
  studentId,
@@ -60135,7 +60255,8 @@ class ProgressRecorder {
60135
60255
  subject: progressData.subject,
60136
60256
  appName: progressData.appName,
60137
60257
  sensorUrl: progressData.sensorUrl,
60138
- extensions: extensions || progressData.extensions
60258
+ extensions: extensions || progressData.extensions,
60259
+ ...runId ? { runId } : {}
60139
60260
  }).catch((error) => {
60140
60261
  log.error("[ProgressRecorder] Failed to emit activity event", { error });
60141
60262
  });
@@ -60189,7 +60310,7 @@ class SessionRecorder {
60189
60310
  const courseName = sessionData.courseName || "Game Course";
60190
60311
  const student = await this.studentResolver.resolve(studentIdentifier, sessionData.studentEmail);
60191
60312
  const { id: studentId, email: studentEmail } = student;
60192
- const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions } = sessionData;
60313
+ const { activeTimeSeconds, inactiveTimeSeconds, wasteTimeSeconds, extensions, runId } = sessionData;
60193
60314
  await this.caliperNamespace.emitTimeSpentEvent({
60194
60315
  studentId,
60195
60316
  studentEmail,
@@ -60203,6 +60324,7 @@ class SessionRecorder {
60203
60324
  subject: sessionData.subject,
60204
60325
  appName: sessionData.appName,
60205
60326
  sensorUrl: sessionData.sensorUrl,
60327
+ ...runId ? { runId } : {},
60206
60328
  ...extensions ? { extensions } : {}
60207
60329
  });
60208
60330
  }
@@ -120166,7 +120288,9 @@ var TIMEBACK_SUBJECTS5;
120166
120288
  var TimebackGradeSchema;
120167
120289
  var TimebackSubjectSchema;
120168
120290
  var UpdateTimebackXpRequestSchema;
120291
+ var TimebackActivityDataSchema;
120169
120292
  var EndActivityRequestSchema;
120293
+ var HeartbeatRequestSchema;
120170
120294
  var PopulateStudentRequestSchema;
120171
120295
  var DerivedPlatformCourseConfigSchema;
120172
120296
  var TimebackBaseConfigSchema;
@@ -120201,31 +120325,49 @@ var init_schemas11 = __esm(() => {
120201
120325
  xp: exports_external.number().min(0, "XP must be a non-negative number"),
120202
120326
  userTimestamp: exports_external.string().datetime().optional()
120203
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
+ });
120204
120339
  EndActivityRequestSchema = exports_external.object({
120205
120340
  gameId: exports_external.string().uuid(),
120206
120341
  studentId: exports_external.string().min(1),
120207
- activityData: exports_external.object({
120208
- activityId: exports_external.string().min(1),
120209
- activityName: exports_external.string().optional(),
120210
- grade: TimebackGradeSchema,
120211
- subject: TimebackSubjectSchema,
120212
- appName: exports_external.string().optional(),
120213
- sensorUrl: exports_external.string().url().optional(),
120214
- courseId: exports_external.string().optional(),
120215
- courseName: exports_external.string().optional(),
120216
- studentEmail: exports_external.string().email().optional()
120217
- }),
120342
+ runId: exports_external.string().uuid().optional(),
120343
+ activityData: TimebackActivityDataSchema,
120218
120344
  scoreData: exports_external.object({
120219
120345
  correctQuestions: exports_external.number().int().min(0),
120220
120346
  totalQuestions: exports_external.number().int().min(0)
120221
120347
  }),
120222
120348
  timingData: exports_external.object({
120223
- durationSeconds: exports_external.number().positive()
120349
+ durationSeconds: exports_external.number().nonnegative()
120224
120350
  }),
120351
+ sessionTimingData: exports_external.object({
120352
+ activeSeconds: exports_external.number().nonnegative(),
120353
+ inactiveSeconds: exports_external.number().nonnegative().optional()
120354
+ }).optional(),
120225
120355
  xpEarned: exports_external.number().optional(),
120226
120356
  masteredUnits: exports_external.number().nonnegative().optional(),
120227
120357
  extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
120228
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
+ });
120229
120371
  PopulateStudentRequestSchema = exports_external.object({
120230
120372
  firstName: exports_external.string().min(1).optional(),
120231
120373
  lastName: exports_external.string().min(1).optional()
@@ -122531,6 +122673,7 @@ var verifyIntegration;
122531
122673
  var getConfig2;
122532
122674
  var deleteIntegrations;
122533
122675
  var endActivity;
122676
+ var heartbeat;
122534
122677
  var getStudentXp;
122535
122678
  var getRoster;
122536
122679
  var getStudentOverview;
@@ -122692,9 +122835,11 @@ var init_timeback_controller = __esm(() => {
122692
122835
  const {
122693
122836
  gameId,
122694
122837
  studentId,
122838
+ runId,
122695
122839
  activityData,
122696
122840
  scoreData,
122697
122841
  timingData,
122842
+ sessionTimingData,
122698
122843
  xpEarned,
122699
122844
  masteredUnits,
122700
122845
  extensions
@@ -122703,15 +122848,50 @@ var init_timeback_controller = __esm(() => {
122703
122848
  return ctx.services.timeback.endActivity({
122704
122849
  gameId,
122705
122850
  studentId,
122851
+ runId,
122706
122852
  activityData,
122707
122853
  scoreData,
122708
122854
  timingData,
122855
+ sessionTimingData,
122709
122856
  xpEarned,
122710
122857
  masteredUnits,
122711
122858
  extensions,
122712
122859
  user: ctx.user
122713
122860
  });
122714
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
+ });
122715
122895
  getStudentXp = requireDeveloper(async (ctx) => {
122716
122896
  const timebackId = ctx.params.timebackId;
122717
122897
  if (!timebackId) {
@@ -122908,6 +123088,7 @@ var init_timeback_controller = __esm(() => {
122908
123088
  getConfig: getConfig2,
122909
123089
  deleteIntegrations,
122910
123090
  endActivity,
123091
+ heartbeat,
122911
123092
  getStudentXp,
122912
123093
  getRoster,
122913
123094
  getStudentOverview,
@@ -123894,6 +124075,7 @@ var init_timeback6 = __esm(() => {
123894
124075
  timebackRouter.get("/config/:gameId", handle2(timeback2.getConfig));
123895
124076
  timebackRouter.delete("/integrations/:gameId", handle2(timeback2.deleteIntegrations, { status: 204 }));
123896
124077
  timebackRouter.post("/end-activity", handle2(timeback2.endActivity));
124078
+ timebackRouter.post("/heartbeat", handle2(timeback2.heartbeat));
123897
124079
  timebackRouter.get("/user", async (c2) => {
123898
124080
  const user = c2.get("user");
123899
124081
  const gameId = c2.get("gameId");
@@ -124828,7 +125010,8 @@ var TIMEBACK_COMPONENT_RESOURCE_DEFAULTS2;
124828
125010
  var init_timeback7 = __esm7(() => {
124829
125011
  TIMEBACK_ROUTES2 = {
124830
125012
  END_ACTIVITY: "/integrations/timeback/end-activity",
124831
- GET_XP: "/integrations/timeback/xp"
125013
+ GET_XP: "/integrations/timeback/xp",
125014
+ HEARTBEAT: "/integrations/timeback/heartbeat"
124832
125015
  };
124833
125016
  TIMEBACK_COURSE_DEFAULTS2 = {
124834
125017
  gradingScheme: "STANDARD",
@@ -125480,7 +125663,7 @@ var import_picocolors12 = __toESM(require_picocolors(), 1);
125480
125663
  // package.json
125481
125664
  var package_default2 = {
125482
125665
  name: "@playcademy/vite-plugin",
125483
- version: "0.2.24-beta.4",
125666
+ version: "0.2.24-beta.5",
125484
125667
  type: "module",
125485
125668
  exports: {
125486
125669
  ".": {