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' },
|
changelog: { partitionKeyPath: '/productId' },
|
||||||
// Push notification triggers (NomGap)
|
// Push notification triggers (NomGap)
|
||||||
push_triggers: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 },
|
push_triggers: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 },
|
||||||
|
// PeakPulse modules
|
||||||
|
peak_sessions: { partitionKeyPath: '/userId' },
|
||||||
// JarvisJr modules (agents, sessions, memory)
|
// JarvisJr modules (agents, sessions, memory)
|
||||||
jarvis_agents: { partitionKeyPath: '/userId' },
|
jarvis_agents: { partitionKeyPath: '/userId' },
|
||||||
jarvis_sessions: { 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 { impersonationRoutes } from './modules/impersonation/routes.js';
|
||||||
import { changelogRoutes } from './modules/changelog/routes.js';
|
import { changelogRoutes } from './modules/changelog/routes.js';
|
||||||
import { pushTriggerRoutes } from './modules/push-triggers/routes.js';
|
import { pushTriggerRoutes } from './modules/push-triggers/routes.js';
|
||||||
|
// PeakPulse modules
|
||||||
|
import { peakSessionRoutes } from './modules/peak-sessions/routes.js';
|
||||||
// JarvisJr modules
|
// JarvisJr modules
|
||||||
import { jarvisAgentRoutes } from './modules/jarvis-agents/routes.js';
|
import { jarvisAgentRoutes } from './modules/jarvis-agents/routes.js';
|
||||||
import { jarvisSessionRoutes } from './modules/jarvis-sessions/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' });
|
await app.register(changelogRoutes, { prefix: '/api' });
|
||||||
// Push notification triggers (NomGap)
|
// Push notification triggers (NomGap)
|
||||||
await app.register(pushTriggerRoutes, { prefix: '/api' });
|
await app.register(pushTriggerRoutes, { prefix: '/api' });
|
||||||
|
// PeakPulse modules
|
||||||
|
await app.register(peakSessionRoutes, { prefix: '/api' });
|
||||||
// JarvisJr modules (agents, sessions, memory)
|
// JarvisJr modules (agents, sessions, memory)
|
||||||
await app.register(jarvisAgentRoutes, { prefix: '/api' });
|
await app.register(jarvisAgentRoutes, { prefix: '/api' });
|
||||||
await app.register(jarvisSessionRoutes, { prefix: '/api' });
|
await app.register(jarvisSessionRoutes, { prefix: '/api' });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user