feat(peak-sessions): add PeakPulse session sync module — types, repository, routes, 20 tests
This commit is contained in:
parent
dcabe46de2
commit
b20e1c6165
@ -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' },
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
131
services/platform-service/src/modules/peak-sessions/routes.ts
Normal file
131
services/platform-service/src/modules/peak-sessions/routes.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
174
services/platform-service/src/modules/peak-sessions/types.ts
Normal file
174
services/platform-service/src/modules/peak-sessions/types.ts
Normal 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;
|
||||
}
|
||||
@ -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' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user