From b20e1c6165210b699f394ff0e7bcb0e7231c4081 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 1 Mar 2026 07:12:28 -0800 Subject: [PATCH] =?UTF-8?q?feat(peak-sessions):=20add=20PeakPulse=20sessio?= =?UTF-8?q?n=20sync=20module=20=E2=80=94=20types,=20repository,=20routes,?= =?UTF-8?q?=2020=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform-service/src/lib/cosmos-init.ts | 2 + .../peak-sessions/peak-sessions.test.ts | 223 ++++++++++++++++++ .../src/modules/peak-sessions/repository.ts | 139 +++++++++++ .../src/modules/peak-sessions/routes.ts | 131 ++++++++++ .../src/modules/peak-sessions/types.ts | 174 ++++++++++++++ services/platform-service/src/server.ts | 4 + 6 files changed, 673 insertions(+) create mode 100644 services/platform-service/src/modules/peak-sessions/peak-sessions.test.ts create mode 100644 services/platform-service/src/modules/peak-sessions/repository.ts create mode 100644 services/platform-service/src/modules/peak-sessions/routes.ts create mode 100644 services/platform-service/src/modules/peak-sessions/types.ts diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 8a0bd63e..35e66937 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -96,6 +96,8 @@ const CONTAINER_DEFS: Record = { changelog: { partitionKeyPath: '/productId' }, // Push notification triggers (NomGap) push_triggers: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 }, + // PeakPulse modules + peak_sessions: { partitionKeyPath: '/userId' }, // JarvisJr modules (agents, sessions, memory) jarvis_agents: { partitionKeyPath: '/userId' }, jarvis_sessions: { partitionKeyPath: '/userId' }, diff --git a/services/platform-service/src/modules/peak-sessions/peak-sessions.test.ts b/services/platform-service/src/modules/peak-sessions/peak-sessions.test.ts new file mode 100644 index 00000000..14675051 --- /dev/null +++ b/services/platform-service/src/modules/peak-sessions/peak-sessions.test.ts @@ -0,0 +1,223 @@ +/** + * PeakPulse sessions module unit tests — validates schema parsing and constants. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreatePeakSessionSchema, + UpdatePeakSessionSchema, + PeakSessionQuerySchema, + ACTIVITY_TYPES, + SESSION_STATUSES, +} from './types.js'; + +// ── CreatePeakSessionSchema ── + +describe('CreatePeakSessionSchema', () => { + const validMinimal = { + activityType: 'hiking', + startTime: '2025-06-15T08:30:00.000Z', + durationSeconds: 3600, + distanceMeters: 5200, + maxSpeedMps: 2.5, + averageSpeedMps: 1.4, + startElevationMeters: 450, + maxElevationMeters: 1200, + minElevationMeters: 400, + elevationGainMeters: 750, + elevationLossMeters: 200, + startLatitude: 37.7749, + startLongitude: -122.4194, + }; + + it('accepts minimal valid input', () => { + const result = CreatePeakSessionSchema.safeParse(validMinimal); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.activityType).toBe('hiking'); + expect(result.data.status).toBe('completed'); + expect(result.data.barometerUsed).toBe(false); + expect(result.data.unitPreference).toBe('metric'); + expect(result.data.trackPointCount).toBe(0); + expect(result.data.hapticMilestoneCount).toBe(0); + expect(result.data.savedToHealthKit).toBe(false); + } + }); + + it('accepts full input with all optional fields', () => { + const result = CreatePeakSessionSchema.safeParse({ + ...validMinimal, + activityType: 'skiing', + status: 'completed', + endTime: '2025-06-15T12:30:00.000Z', + locationName: 'Whistler Blackcomb', + barometerUsed: true, + unitPreference: 'imperial', + notes: 'Great powder day!', + trackPointCount: 1500, + hapticMilestoneCount: 8, + savedToHealthKit: true, + weather: { + temperatureCelsius: -5, + conditionSymbol: 'snow', + conditionDescription: 'Heavy Snow', + windSpeedKmh: 25, + uvIndex: 3, + }, + skiMetrics: { + runCount: 12, + totalVerticalDescentMeters: 8500, + liftTimeSeconds: 7200, + skiTimeSeconds: 7200, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.activityType).toBe('skiing'); + expect(result.data.locationName).toBe('Whistler Blackcomb'); + expect(result.data.weather?.temperatureCelsius).toBe(-5); + expect(result.data.skiMetrics?.runCount).toBe(12); + expect(result.data.trackPointCount).toBe(1500); + } + }); + + it('rejects invalid activity type', () => { + const result = CreatePeakSessionSchema.safeParse({ + ...validMinimal, + activityType: 'swimming', + }); + expect(result.success).toBe(false); + }); + + it('rejects negative distance', () => { + const result = CreatePeakSessionSchema.safeParse({ + ...validMinimal, + distanceMeters: -100, + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid latitude', () => { + const result = CreatePeakSessionSchema.safeParse({ + ...validMinimal, + startLatitude: 95, + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid longitude', () => { + const result = CreatePeakSessionSchema.safeParse({ + ...validMinimal, + startLongitude: -200, + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid start time format', () => { + const result = CreatePeakSessionSchema.safeParse({ + ...validMinimal, + startTime: 'not-a-date', + }); + expect(result.success).toBe(false); + }); +}); + +// ── UpdatePeakSessionSchema ── + +describe('UpdatePeakSessionSchema', () => { + it('accepts partial update with notes only', () => { + const result = UpdatePeakSessionSchema.safeParse({ notes: 'Updated notes' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.notes).toBe('Updated notes'); + } + }); + + it('accepts HealthKit flag update', () => { + const result = UpdatePeakSessionSchema.safeParse({ savedToHealthKit: true }); + expect(result.success).toBe(true); + }); + + it('accepts weather update', () => { + const result = UpdatePeakSessionSchema.safeParse({ + weather: { temperatureCelsius: 22, windSpeedKmh: 10 }, + }); + expect(result.success).toBe(true); + }); + + it('accepts empty update (no fields)', () => { + const result = UpdatePeakSessionSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('rejects notes exceeding max length', () => { + const result = UpdatePeakSessionSchema.safeParse({ notes: 'x'.repeat(5001) }); + expect(result.success).toBe(false); + }); +}); + +// ── PeakSessionQuerySchema ── + +describe('PeakSessionQuerySchema', () => { + it('applies defaults for empty query', () => { + const result = PeakSessionQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortBy).toBe('startTime'); + expect(result.data.sortOrder).toBe('desc'); + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(0); + } + }); + + it('accepts activity type filter', () => { + const result = PeakSessionQuerySchema.safeParse({ activityType: 'skiing' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.activityType).toBe('skiing'); + } + }); + + it('accepts all sort options', () => { + for (const sortBy of [ + 'startTime', + 'durationSeconds', + 'elevationGainMeters', + 'distanceMeters', + 'createdAt', + ]) { + const result = PeakSessionQuerySchema.safeParse({ sortBy }); + expect(result.success).toBe(true); + } + }); + + it('rejects invalid sort field', () => { + const result = PeakSessionQuerySchema.safeParse({ sortBy: 'invalid' }); + expect(result.success).toBe(false); + }); + + it('clamps limit within bounds', () => { + const result = PeakSessionQuerySchema.safeParse({ limit: '25' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(25); + } + }); + + it('rejects limit over 100', () => { + const result = PeakSessionQuerySchema.safeParse({ limit: '101' }); + expect(result.success).toBe(false); + }); +}); + +// ── Constants ── + +describe('Constants', () => { + it('has correct activity types', () => { + expect(ACTIVITY_TYPES).toEqual(['hiking', 'skiing']); + }); + + it('has correct session statuses', () => { + expect(SESSION_STATUSES).toEqual(['completed', 'partial', 'imported']); + }); +}); diff --git a/services/platform-service/src/modules/peak-sessions/repository.ts b/services/platform-service/src/modules/peak-sessions/repository.ts new file mode 100644 index 00000000..2b5dc467 --- /dev/null +++ b/services/platform-service/src/modules/peak-sessions/repository.ts @@ -0,0 +1,139 @@ +/** + * PeakPulse sessions repository — Cosmos DB CRUD + stats aggregation. + * + * Container: peak_sessions (partition key: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { PeakSessionDoc, PeakSessionQuery, UserPeakStats } from './types.js'; + +function container() { + return getContainer('peak_sessions'); +} + +export async function createSession(doc: PeakSessionDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as PeakSessionDoc; +} + +export async function getSession( + userId: string, + sessionId: string +): Promise { + try { + const { resource } = await container().item(sessionId, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function listSessions( + userId: string, + query: PeakSessionQuery +): Promise<{ items: PeakSessionDoc[]; total: number }> { + const conditions: string[] = ['c.userId = @userId']; + const params: { name: string; value: string | number }[] = [{ name: '@userId', value: userId }]; + + if (query.activityType) { + conditions.push('c.activityType = @activityType'); + params.push({ name: '@activityType', value: query.activityType }); + } + if (query.status) { + conditions.push('c.status = @status'); + params.push({ name: '@status', value: query.status }); + } + if (query.startDate) { + conditions.push('c.startTime >= @startDate'); + params.push({ name: '@startDate', value: query.startDate }); + } + if (query.endDate) { + conditions.push('c.startTime <= @endDate'); + params.push({ name: '@endDate', value: query.endDate }); + } + + const where = `WHERE ${conditions.join(' AND ')}`; + const sortField = `c.${query.sortBy}`; + const orderDir = query.sortOrder.toUpperCase(); + + // Count query + const countResult = await container() + .items.query({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: params, + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + // Data query with pagination + const { resources } = await container() + .items.query({ + query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`, + parameters: [ + ...params, + { name: '@offset', value: query.offset }, + { name: '@limit', value: query.limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function updateSession( + userId: string, + sessionId: string, + updates: Partial +): Promise { + try { + const { resource: existing } = await container().item(sessionId, userId).read(); + if (!existing) return null; + const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + const { resource } = await container().item(sessionId, userId).replace(merged); + return resource as PeakSessionDoc; + } catch { + return null; + } +} + +export async function deleteSession(userId: string, sessionId: string): Promise { + try { + await container().item(sessionId, userId).delete(); + return true; + } catch { + return false; + } +} + +export async function getUserStats(userId: string): Promise { + const { resources: allSessions } = await container() + .items.query({ + query: 'SELECT * FROM c WHERE c.userId = @userId', + parameters: [{ name: '@userId', value: userId }], + }) + .fetchAll(); + + const hiking = allSessions.filter(s => s.activityType === 'hiking'); + const skiing = allSessions.filter(s => s.activityType === 'skiing'); + + const totalDistanceM = allSessions.reduce((sum, s) => sum + s.distanceMeters, 0); + const totalGain = allSessions.reduce((sum, s) => sum + s.elevationGainMeters, 0); + const totalDurationS = allSessions.reduce((sum, s) => sum + s.durationSeconds, 0); + const topSpeed = allSessions.length > 0 ? Math.max(...allSessions.map(s => s.maxSpeedMps)) : 0; + const maxElev = + allSessions.length > 0 ? Math.max(...allSessions.map(s => s.maxElevationMeters)) : 0; + const avgDurationMin = allSessions.length > 0 ? totalDurationS / allSessions.length / 60 : 0; + + return { + userId, + totalSessions: allSessions.length, + totalDistanceKm: Math.round((totalDistanceM / 1000) * 100) / 100, + totalElevationGainMeters: Math.round(totalGain), + totalDurationHours: Math.round((totalDurationS / 3600) * 100) / 100, + topSpeedMps: Math.round(topSpeed * 100) / 100, + maxElevationMeters: Math.round(maxElev), + hikingSessions: hiking.length, + skiingSessions: skiing.length, + averageSessionDurationMinutes: Math.round(avgDurationMin), + }; +} diff --git a/services/platform-service/src/modules/peak-sessions/routes.ts b/services/platform-service/src/modules/peak-sessions/routes.ts new file mode 100644 index 00000000..c25e03fa --- /dev/null +++ b/services/platform-service/src/modules/peak-sessions/routes.ts @@ -0,0 +1,131 @@ +/** + * PeakPulse sessions REST endpoints. + * + * POST /peak/sessions — create or sync a session + * GET /peak/sessions — list with pagination + filters + * GET /peak/sessions/:id — single session + * PUT /peak/sessions/:id — update (notes, HealthKit flag) + * DELETE /peak/sessions/:id — delete session + * GET /peak/stats — aggregated user stats + */ + +import type { FastifyInstance } from 'fastify'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError, NotFoundError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { + CreatePeakSessionSchema, + UpdatePeakSessionSchema, + PeakSessionQuerySchema, + type PeakSessionDoc, +} from './types.js'; + +export async function peakSessionRoutes(app: FastifyInstance) { + // Stats — must be registered before :id param route + app.get('/peak/stats', async req => { + const auth = await extractAuth(req); + const stats = await repo.getUserStats(auth.sub); + return stats; + }); + + // List sessions + app.get('/peak/sessions', async req => { + const auth = await extractAuth(req); + const parsed = PeakSessionQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const { items, total } = await repo.listSessions(auth.sub, parsed.data); + return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + // Get session + app.get('/peak/sessions/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const session = await repo.getSession(auth.sub, id); + if (!session) throw new NotFoundError('Peak session not found'); + return session; + }); + + // Create session + app.post('/peak/sessions', async (req, reply) => { + const auth = await extractAuth(req); + const pid = getRequestProductId(req); + const parsed = CreatePeakSessionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const input = parsed.data; + const now = new Date().toISOString(); + + const doc: PeakSessionDoc = { + id: `ps_${crypto.randomUUID()}`, + userId: auth.sub, + productId: pid, + activityType: input.activityType, + status: input.status, + startTime: input.startTime, + endTime: input.endTime, + durationSeconds: input.durationSeconds, + distanceMeters: input.distanceMeters, + maxSpeedMps: input.maxSpeedMps, + averageSpeedMps: input.averageSpeedMps, + startElevationMeters: input.startElevationMeters, + maxElevationMeters: input.maxElevationMeters, + minElevationMeters: input.minElevationMeters, + elevationGainMeters: input.elevationGainMeters, + elevationLossMeters: input.elevationLossMeters, + locationName: input.locationName, + startLatitude: input.startLatitude, + startLongitude: input.startLongitude, + barometerUsed: input.barometerUsed, + unitPreference: input.unitPreference, + notes: input.notes, + weather: input.weather, + skiMetrics: input.skiMetrics, + trackPointCount: input.trackPointCount, + hapticMilestoneCount: input.hapticMilestoneCount, + savedToHealthKit: input.savedToHealthKit, + createdAt: now, + updatedAt: now, + }; + + req.log.info({ sessionId: doc.id, activityType: doc.activityType }, 'Creating peak session'); + const created = await repo.createSession(doc); + reply.code(201); + return created; + }); + + // Update session + app.put('/peak/sessions/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const existing = await repo.getSession(auth.sub, id); + if (!existing) throw new NotFoundError('Peak session not found'); + + const parsed = UpdatePeakSessionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + req.log.info({ sessionId: id, updates: Object.keys(parsed.data) }, 'Updating peak session'); + const updated = await repo.updateSession(auth.sub, id, parsed.data); + if (!updated) throw new NotFoundError('Peak session update failed'); + return updated; + }); + + // Delete session + app.delete('/peak/sessions/:id', async (req, reply) => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const existing = await repo.getSession(auth.sub, id); + if (!existing) throw new NotFoundError('Peak session not found'); + + req.log.info({ sessionId: id }, 'Deleting peak session'); + const deleted = await repo.deleteSession(auth.sub, id); + if (!deleted) throw new NotFoundError('Peak session delete failed'); + reply.code(204); + }); +} diff --git a/services/platform-service/src/modules/peak-sessions/types.ts b/services/platform-service/src/modules/peak-sessions/types.ts new file mode 100644 index 00000000..66e3099b --- /dev/null +++ b/services/platform-service/src/modules/peak-sessions/types.ts @@ -0,0 +1,174 @@ +/** + * PeakPulse session types — adventure tracking sessions. + * + * Cosmos container: `peak_sessions` (partition key: `/userId`) + * Product-agnostic: every document includes `productId`. + */ + +import { z } from 'zod'; + +// ── Enums / constants ── + +export const ACTIVITY_TYPES = ['hiking', 'skiing'] as const; +export type ActivityType = (typeof ACTIVITY_TYPES)[number]; + +export const SESSION_STATUSES = ['completed', 'partial', 'imported'] as const; +export type SessionStatus = (typeof SESSION_STATUSES)[number]; + +// ── Sub-document interfaces ── + +export interface TrackPointDoc { + timestamp: number; + lat: number; + lon: number; + altitude: number; + gpsAltitude: number; + barometerRelativeAltitude?: number; + speedMps: number; + course: number; + hAccuracy: number; + vAccuracy: number; + isPaused: boolean; +} + +export interface HapticEventDoc { + timestamp: number; + type: string; + value: number; + elevationAtEvent: number; +} + +export interface WeatherSnapshotDoc { + temperatureCelsius?: number; + conditionSymbol?: string; + conditionDescription?: string; + windSpeedKmh?: number; + uvIndex?: number; +} + +export interface SkiMetricsDoc { + runCount: number; + totalVerticalDescentMeters: number; + liftTimeSeconds: number; + skiTimeSeconds: number; +} + +// ── Main document ── + +export interface PeakSessionDoc { + id: string; + userId: string; + productId: string; + activityType: ActivityType; + status: SessionStatus; + startTime: string; // ISO 8601 + endTime?: string; + durationSeconds: number; + distanceMeters: number; + maxSpeedMps: number; + averageSpeedMps: number; + startElevationMeters: number; + maxElevationMeters: number; + minElevationMeters: number; + elevationGainMeters: number; + elevationLossMeters: number; + locationName?: string; + startLatitude: number; + startLongitude: number; + barometerUsed: boolean; + unitPreference: string; + notes?: string; + weather?: WeatherSnapshotDoc; + skiMetrics?: SkiMetricsDoc; + trackPointCount: number; + hapticMilestoneCount: number; + savedToHealthKit: boolean; + createdAt: string; + updatedAt: string; +} + +// ── Zod schemas ── + +const WeatherSnapshotSchema = z.object({ + temperatureCelsius: z.number().optional(), + conditionSymbol: z.string().max(100).optional(), + conditionDescription: z.string().max(200).optional(), + windSpeedKmh: z.number().optional(), + uvIndex: z.number().int().min(0).max(15).optional(), +}); + +const SkiMetricsSchema = z.object({ + runCount: z.number().int().min(0), + totalVerticalDescentMeters: z.number().min(0), + liftTimeSeconds: z.number().min(0), + skiTimeSeconds: z.number().min(0), +}); + +export const CreatePeakSessionSchema = z.object({ + activityType: z.enum(ACTIVITY_TYPES), + status: z.enum(SESSION_STATUSES).default('completed'), + startTime: z.string().datetime(), + endTime: z.string().datetime().optional(), + durationSeconds: z.number().min(0), + distanceMeters: z.number().min(0), + maxSpeedMps: z.number().min(0), + averageSpeedMps: z.number().min(0), + startElevationMeters: z.number(), + maxElevationMeters: z.number(), + minElevationMeters: z.number(), + elevationGainMeters: z.number().min(0), + elevationLossMeters: z.number().min(0), + locationName: z.string().max(200).optional(), + startLatitude: z.number().min(-90).max(90), + startLongitude: z.number().min(-180).max(180), + barometerUsed: z.boolean().default(false), + unitPreference: z.string().max(20).default('metric'), + notes: z.string().max(5000).optional(), + weather: WeatherSnapshotSchema.optional(), + skiMetrics: SkiMetricsSchema.optional(), + trackPointCount: z.number().int().min(0).default(0), + hapticMilestoneCount: z.number().int().min(0).default(0), + savedToHealthKit: z.boolean().default(false), +}); + +export const UpdatePeakSessionSchema = z.object({ + locationName: z.string().max(200).optional(), + notes: z.string().max(5000).optional(), + savedToHealthKit: z.boolean().optional(), + weather: WeatherSnapshotSchema.optional(), + skiMetrics: SkiMetricsSchema.optional(), +}); + +export const PeakSessionQuerySchema = z.object({ + activityType: z.enum(ACTIVITY_TYPES).optional(), + status: z.enum(SESSION_STATUSES).optional(), + startDate: z.coerce.string().optional(), + endDate: z.coerce.string().optional(), + sortBy: z + .enum(['startTime', 'durationSeconds', 'elevationGainMeters', 'distanceMeters', 'createdAt']) + .default('startTime'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +// ── Inferred types ── + +export type CreatePeakSessionInput = z.infer; +export type UpdatePeakSessionInput = z.infer; +export type PeakSessionQuery = z.infer; + +// ── Stats interfaces ── + +export interface UserPeakStats { + userId: string; + totalSessions: number; + totalDistanceKm: number; + totalElevationGainMeters: number; + totalDurationHours: number; + topSpeedMps: number; + maxElevationMeters: number; + hikingSessions: number; + skiingSessions: number; + averageSessionDurationMinutes: number; +} diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 784ae143..a6d3c61b 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -74,6 +74,8 @@ import { feedbackRoutes } from './modules/feedback/routes.js'; import { impersonationRoutes } from './modules/impersonation/routes.js'; import { changelogRoutes } from './modules/changelog/routes.js'; import { pushTriggerRoutes } from './modules/push-triggers/routes.js'; +// PeakPulse modules +import { peakSessionRoutes } from './modules/peak-sessions/routes.js'; // JarvisJr modules import { jarvisAgentRoutes } from './modules/jarvis-agents/routes.js'; import { jarvisSessionRoutes } from './modules/jarvis-sessions/routes.js'; @@ -195,6 +197,8 @@ await app.register(impersonationRoutes, { prefix: '/api' }); await app.register(changelogRoutes, { prefix: '/api' }); // Push notification triggers (NomGap) await app.register(pushTriggerRoutes, { prefix: '/api' }); +// PeakPulse modules +await app.register(peakSessionRoutes, { prefix: '/api' }); // JarvisJr modules (agents, sessions, memory) await app.register(jarvisAgentRoutes, { prefix: '/api' }); await app.register(jarvisSessionRoutes, { prefix: '/api' });