feat(peak-sessions): add PeakPulse session sync module — types, repository, routes, 20 tests

This commit is contained in:
saravanakumardb1 2026-03-01 07:12:28 -08:00
parent dcabe46de2
commit b20e1c6165
6 changed files with 673 additions and 0 deletions

View File

@ -96,6 +96,8 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
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' },

View File

@ -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']);
});
});

View File

@ -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<PeakSessionDoc> {
const { resource } = await container().items.create(doc);
return resource as PeakSessionDoc;
}
export async function getSession(
userId: string,
sessionId: string
): Promise<PeakSessionDoc | null> {
try {
const { resource } = await container().item(sessionId, userId).read<PeakSessionDoc>();
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<number>({
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<PeakSessionDoc>({
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<PeakSessionDoc>
): Promise<PeakSessionDoc | null> {
try {
const { resource: existing } = await container().item(sessionId, userId).read<PeakSessionDoc>();
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<boolean> {
try {
await container().item(sessionId, userId).delete();
return true;
} catch {
return false;
}
}
export async function getUserStats(userId: string): Promise<UserPeakStats> {
const { resources: allSessions } = await container()
.items.query<PeakSessionDoc>({
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),
};
}

View File

@ -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);
});
}

View File

@ -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<typeof CreatePeakSessionSchema>;
export type UpdatePeakSessionInput = z.infer<typeof UpdatePeakSessionSchema>;
export type PeakSessionQuery = z.infer<typeof PeakSessionQuerySchema>;
// ── 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;
}

View File

@ -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' });