This commit is contained in:
Bastian Wagner
2026-06-17 15:10:26 +02:00
parent 486c8fff2e
commit 8c7a5cbb63
14 changed files with 953 additions and 39 deletions

View File

@@ -1,6 +1,8 @@
export { StravaActivityEntity } from './strava-activity.entity';
export { StravaActivityStreamPointEntity } from './strava-activity-stream-point.entity';
export { StravaAthleteEntity } from './strava-athlete.entity';
export { StravaStrengthTrainingDetailEntity } from './strava-strength-training-detail.entity';
export { StravaSwimActivityDetailEntity } from './strava-swim-activity-detail.entity';
export { StravaSyncJobEntity } from './strava-sync-job.entity';
export { StravaSyncJobItemEntity } from './strava-sync-job-item.entity';
export { StravaTokenEntity } from './strava-token.entity';

View File

@@ -0,0 +1,74 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { StravaActivityEntity } from './strava-activity.entity';
@Entity({ name: 'strava_strength_training_details' })
@Index(['activityId'], { unique: true })
@Index(['stravaActivityId'], { unique: true })
export class StravaStrengthTrainingDetailEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'activity_id', type: 'varchar', length: 36 })
activityId!: string;
@OneToOne(() => StravaActivityEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'activity_id' })
activity!: StravaActivityEntity;
@Column({ name: 'strava_activity_id', type: 'varchar', length: 32 })
stravaActivityId!: string;
@Column({ name: 'sport_type', type: 'varchar', length: 100, nullable: true })
sportType!: string | null;
@Column({ type: 'varchar', length: 500 })
name!: string;
@Column({ name: 'start_date', type: 'datetime', nullable: true })
startDate!: Date | null;
@Column({ name: 'start_date_local', type: 'datetime', nullable: true })
startDateLocal!: Date | null;
@Column({ name: 'moving_time', type: 'int', nullable: true })
movingTime!: number | null;
@Column({ name: 'elapsed_time', type: 'int', nullable: true })
elapsedTime!: number | null;
@Column({ type: 'double', nullable: true })
calories!: number | null;
@Column({ name: 'average_heartrate', type: 'double', nullable: true })
averageHeartrate!: number | null;
@Column({ name: 'max_heartrate', type: 'double', nullable: true })
maxHeartrate!: number | null;
@Column({ name: 'perceived_exertion', type: 'double', nullable: true })
perceivedExertion!: number | null;
@Column({ name: 'workout_type', type: 'int', nullable: true })
workoutType!: number | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ name: 'raw_payload', type: 'json', nullable: true })
rawPayload!: Record<string, unknown> | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,86 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { StravaActivityEntity } from './strava-activity.entity';
@Entity({ name: 'strava_swim_activity_details' })
@Index(['activityId'], { unique: true })
@Index(['stravaActivityId'], { unique: true })
export class StravaSwimActivityDetailEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'activity_id', type: 'varchar', length: 36 })
activityId!: string;
@OneToOne(() => StravaActivityEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'activity_id' })
activity!: StravaActivityEntity;
@Column({ name: 'strava_activity_id', type: 'varchar', length: 32 })
stravaActivityId!: string;
@Column({ name: 'sport_type', type: 'varchar', length: 100, nullable: true })
sportType!: string | null;
@Column({ type: 'varchar', length: 500 })
name!: string;
@Column({ name: 'start_date', type: 'datetime', nullable: true })
startDate!: Date | null;
@Column({ name: 'start_date_local', type: 'datetime', nullable: true })
startDateLocal!: Date | null;
@Column({ type: 'double', nullable: true })
distance!: number | null;
@Column({ name: 'moving_time', type: 'int', nullable: true })
movingTime!: number | null;
@Column({ name: 'elapsed_time', type: 'int', nullable: true })
elapsedTime!: number | null;
@Column({ name: 'average_speed', type: 'double', nullable: true })
averageSpeed!: number | null;
@Column({ name: 'max_speed', type: 'double', nullable: true })
maxSpeed!: number | null;
@Column({ type: 'double', nullable: true })
calories!: number | null;
@Column({ name: 'average_heartrate', type: 'double', nullable: true })
averageHeartrate!: number | null;
@Column({ name: 'max_heartrate', type: 'double', nullable: true })
maxHeartrate!: number | null;
@Column({ name: 'average_cadence', type: 'double', nullable: true })
averageCadence!: number | null;
@Column({ name: 'pool_length', type: 'double', nullable: true })
poolLength!: number | null;
@Column({ name: 'laps', type: 'json', nullable: true })
laps!: Record<string, unknown>[] | null;
@Column({ name: 'laps_error', type: 'text', nullable: true })
lapsError!: string | null;
@Column({ name: 'raw_payload', type: 'json', nullable: true })
rawPayload!: Record<string, unknown> | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}

View File

@@ -3,6 +3,8 @@ import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaAthleteEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
StravaSyncJobEntity,
StravaSyncJobItemEntity,
StravaTokenEntity,
@@ -28,6 +30,8 @@ export const createTypeOrmOptions = (): DataSourceOptions => ({
StravaTokenEntity,
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
StravaSyncJobEntity,
StravaSyncJobItemEntity,
],

View File

@@ -5,6 +5,8 @@ import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaAthleteEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
} from '../database/entities';
export interface ListActivitiesInput {
@@ -32,6 +34,10 @@ export class McpDataService {
private readonly activityRepository: Repository<StravaActivityEntity>,
@InjectRepository(StravaActivityStreamPointEntity)
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
@InjectRepository(StravaStrengthTrainingDetailEntity)
private readonly strengthTrainingDetailRepository: Repository<StravaStrengthTrainingDetailEntity>,
@InjectRepository(StravaSwimActivityDetailEntity)
private readonly swimActivityDetailRepository: Repository<StravaSwimActivityDetailEntity>,
) {}
async getAthleteProfile(): Promise<Record<string, unknown>> {
@@ -150,6 +156,13 @@ export class McpDataService {
const streamPointCount = await this.streamPointRepository.count({
where: { activityId: activity.id },
});
const strengthTrainingDetail =
await this.strengthTrainingDetailRepository.findOne({
where: { activityId: activity.id },
});
const swimActivityDetail = await this.swimActivityDetailRepository.findOne({
where: { activityId: activity.id },
});
const detail: Record<string, unknown> = {
...this.toActivitySummary(activity),
elapsedTimeSeconds: activity.elapsedTime,
@@ -168,6 +181,12 @@ export class McpDataService {
mapId: activity.mapId,
summaryPolyline: activity.summaryPolyline,
streamPointCount,
strengthTrainingDetail: strengthTrainingDetail
? this.toStrengthTrainingDetail(strengthTrainingDetail)
: null,
swimActivityDetail: swimActivityDetail
? this.toSwimActivityDetail(swimActivityDetail)
: null,
createdAt: this.toIso(activity.createdAt),
updatedAt: this.toIso(activity.updatedAt),
};
@@ -213,6 +232,64 @@ export class McpDataService {
return detail;
}
private toStrengthTrainingDetail(
detail: StravaStrengthTrainingDetailEntity,
): Record<string, unknown> {
return {
id: detail.id,
activityId: detail.activityId,
stravaActivityId: detail.stravaActivityId,
sportType: detail.sportType,
name: detail.name,
startDate: this.toIso(detail.startDate),
startDateLocal: this.toIso(detail.startDateLocal),
movingTimeSeconds: detail.movingTime,
elapsedTimeSeconds: detail.elapsedTime,
calories: detail.calories,
averageHeartrate: detail.averageHeartrate,
maxHeartrate: detail.maxHeartrate,
perceivedExertion: detail.perceivedExertion,
workoutType: detail.workoutType,
description: detail.description,
rawPayload: detail.rawPayload,
createdAt: this.toIso(detail.createdAt),
updatedAt: this.toIso(detail.updatedAt),
};
}
private toSwimActivityDetail(
detail: StravaSwimActivityDetailEntity,
): Record<string, unknown> {
return {
id: detail.id,
activityId: detail.activityId,
stravaActivityId: detail.stravaActivityId,
sportType: detail.sportType,
name: detail.name,
startDate: this.toIso(detail.startDate),
startDateLocal: this.toIso(detail.startDateLocal),
distanceMeters: detail.distance,
movingTimeSeconds: detail.movingTime,
elapsedTimeSeconds: detail.elapsedTime,
averageSpeedMetersPerSecond: detail.averageSpeed,
maxSpeedMetersPerSecond: detail.maxSpeed,
paceSecondsPer100m:
detail.distance && detail.distance > 0 && detail.movingTime
? Math.round(detail.movingTime / (detail.distance / 100))
: null,
calories: detail.calories,
averageHeartrate: detail.averageHeartrate,
maxHeartrate: detail.maxHeartrate,
averageCadence: detail.averageCadence,
poolLength: detail.poolLength,
laps: detail.laps,
lapsError: detail.lapsError,
rawPayload: detail.rawPayload,
createdAt: this.toIso(detail.createdAt),
updatedAt: this.toIso(detail.updatedAt),
};
}
private toActivitySummary(
activity: StravaActivityEntity,
): Record<string, unknown> {

View File

@@ -5,6 +5,8 @@ import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaAthleteEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
} from '../database/entities';
import { StravaModule } from '../strava/strava.module';
import { McpDataService } from './mcp-data.service';
@@ -18,6 +20,8 @@ import { McpServerService } from './mcp-server.service';
StravaAthleteEntity,
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
]),
],
providers: [McpDataService, McpServerService],

View File

@@ -6,6 +6,7 @@ import { firstValueFrom } from 'rxjs';
import { StravaRateLimitError } from './strava-rate-limit.error';
import {
StravaActivityPayload,
StravaLapPayload,
StravaStreamPayload,
StravaStreamsResponse,
StravaTokenPayload,
@@ -61,12 +62,17 @@ export class StravaClientService {
accessToken: string,
page: number,
perPage = 100,
after?: number,
): Promise<StravaActivityPayload[]> {
return this.request<StravaActivityPayload[]>({
method: 'GET',
url: 'https://www.strava.com/api/v3/athlete/activities',
headers: this.authHeaders(accessToken),
params: { page, per_page: perPage },
params: {
page,
per_page: perPage,
...(after ? { after } : {}),
},
});
}
@@ -98,6 +104,17 @@ export class StravaClientService {
return this.normalizeStreamsResponse(streams);
}
async getActivityLaps(
accessToken: string,
stravaActivityId: string,
): Promise<StravaLapPayload[]> {
return this.request<StravaLapPayload[]>({
method: 'GET',
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}/laps`,
headers: this.authHeaders(accessToken),
});
}
private async postToken(
body: Record<string, string>,
): Promise<StravaTokenPayload> {

View File

@@ -0,0 +1,48 @@
import {
StravaActivityEntity,
StravaStrengthTrainingDetailEntity,
} from '../database/entities';
import { StravaActivityPayload } from './strava.types';
const strengthSportTypes = new Set([
'crossfit',
'highintensityintervaltraining',
'weighttraining',
'workout',
]);
export const isStrengthTrainingActivity = (
sportType: string | null | undefined,
): boolean => strengthSportTypes.has((sportType ?? '').toLowerCase());
export const mapStravaStrengthTrainingDetail = (
activity: StravaActivityEntity,
payload: StravaActivityPayload,
existing?: StravaStrengthTrainingDetailEntity | null,
): StravaStrengthTrainingDetailEntity => {
const detail = existing ?? new StravaStrengthTrainingDetailEntity();
detail.activityId = activity.id;
detail.stravaActivityId = activity.stravaActivityId;
detail.sportType = activity.sportType;
detail.name = activity.name;
detail.startDate = activity.startDate;
detail.startDateLocal = activity.startDateLocal;
detail.movingTime = activity.movingTime;
detail.elapsedTime = activity.elapsedTime;
detail.calories = activity.calories;
detail.averageHeartrate = activity.averageHeartrate;
detail.maxHeartrate = activity.maxHeartrate;
detail.description = stringOrNull(payload.description);
detail.perceivedExertion = numberOrNull(payload.perceived_exertion);
detail.workoutType = numberOrNull(payload.workout_type);
detail.rawPayload = payload;
return detail;
};
const numberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const stringOrNull = (value: unknown): string | null =>
typeof value === 'string' && value.trim() ? value : null;

View File

@@ -0,0 +1,43 @@
import {
StravaActivityEntity,
StravaSwimActivityDetailEntity,
} from '../database/entities';
import { StravaActivityPayload, StravaLapPayload } from './strava.types';
export const isSwimActivity = (sportType: string | null | undefined): boolean =>
(sportType ?? '').toLowerCase() === 'swim';
export const mapStravaSwimActivityDetail = (
activity: StravaActivityEntity,
payload: StravaActivityPayload,
laps: StravaLapPayload[],
lapsError: string | null,
existing?: StravaSwimActivityDetailEntity | null,
): StravaSwimActivityDetailEntity => {
const detail = existing ?? new StravaSwimActivityDetailEntity();
detail.activityId = activity.id;
detail.stravaActivityId = activity.stravaActivityId;
detail.sportType = activity.sportType;
detail.name = activity.name;
detail.startDate = activity.startDate;
detail.startDateLocal = activity.startDateLocal;
detail.distance = activity.distance;
detail.movingTime = activity.movingTime;
detail.elapsedTime = activity.elapsedTime;
detail.averageSpeed = activity.averageSpeed;
detail.maxSpeed = activity.maxSpeed;
detail.calories = activity.calories;
detail.averageHeartrate = activity.averageHeartrate;
detail.maxHeartrate = activity.maxHeartrate;
detail.averageCadence = activity.averageCadence;
detail.poolLength = numberOrNull(payload.pool_length);
detail.laps = laps;
detail.lapsError = lapsError;
detail.rawPayload = payload;
return detail;
};
const numberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;

View File

@@ -33,6 +33,9 @@ describe('StravaSyncService', () => {
return Promise.resolve(job);
}),
};
const activityRepository = {
findOne: jest.fn().mockResolvedValue(null),
};
const stravaTokenService = {
getValidAccessToken: jest.fn().mockResolvedValue('access-token'),
};
@@ -44,6 +47,8 @@ describe('StravaSyncService', () => {
};
const service = new StravaSyncService(
{} as never,
activityRepository as never,
{} as never,
{} as never,
jobRepository as never,
@@ -66,4 +71,351 @@ describe('StravaSyncService', () => {
expect(job.status).toBe('completed');
expect(job.finishedAt).toBeInstanceOf(Date);
});
it('uses a lookback window from the latest imported activity for incremental syncs', async () => {
const latestActivity = {
startDate: new Date('2026-06-16T12:00:00.000Z'),
};
const job = {
id: 'job-1',
stravaAthleteId: 'athlete-1',
status: 'queued',
activityCount: 0,
detailCount: 0,
streamPointCount: 0,
errorMessage: null,
retryAfter: null,
startedAt: null,
finishedAt: null,
items: [],
} as StravaSyncJobEntity;
const jobRepository = {
findOne: jest.fn().mockResolvedValue(job),
save: jest.fn((entity: Partial<StravaSyncJobEntity>) => {
Object.assign(job, entity);
return Promise.resolve(job);
}),
};
const activityRepository = {
findOne: jest.fn().mockResolvedValue(latestActivity),
};
const stravaTokenService = {
getValidAccessToken: jest.fn().mockResolvedValue('access-token'),
};
const stravaClientService = {
listActivities: jest.fn().mockResolvedValue([]),
};
const service = new StravaSyncService(
{} as never,
activityRepository as never,
{} as never,
{} as never,
jobRepository as never,
{} as never,
stravaTokenService as never,
stravaClientService as never,
{} as never,
);
await service.runJob(job.id);
expect(stravaClientService.listActivities).toHaveBeenCalledWith(
'access-token',
1,
100,
1780401600,
);
});
it('stores strength training details in their own table', async () => {
const job = {
id: 'job-1',
stravaAthleteId: 'athlete-1',
status: 'queued',
activityCount: 0,
detailCount: 0,
streamPointCount: 0,
errorMessage: null,
retryAfter: null,
startedAt: null,
finishedAt: null,
items: [],
} as StravaSyncJobEntity;
const jobRepository = {
findOne: jest.fn().mockResolvedValue(job),
save: jest.fn((entity: Partial<StravaSyncJobEntity>) => {
Object.assign(job, entity);
return Promise.resolve(job);
}),
};
const activityRepository = {
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn((activity) =>
Promise.resolve({
...activity,
id: 'activity-1',
}),
),
};
const strengthTrainingDetailRepository = {
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn((detail) => Promise.resolve(detail)),
};
const jobItemRepository = {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((item) => item),
save: jest.fn((item) => Promise.resolve(item)),
};
const stravaTokenService = {
getValidAccessToken: jest.fn().mockResolvedValue('access-token'),
};
const stravaClientService = {
listActivities: jest.fn().mockResolvedValue([
{
id: 'strength-1',
name: 'Push Day',
sport_type: 'WeightTraining',
start_date: '2026-06-16T12:00:00.000Z',
elapsed_time: 3600,
},
]),
getActivity: jest.fn().mockResolvedValue({
id: 'strength-1',
name: 'Push Day',
sport_type: 'WeightTraining',
start_date: '2026-06-16T12:00:00.000Z',
elapsed_time: 3600,
moving_time: 3400,
calories: 420,
average_heartrate: 118,
max_heartrate: 155,
perceived_exertion: 7,
description: 'Bench press 5x5',
}),
};
const stravaStreamImportService = {
importStreamsForActivity: jest.fn().mockResolvedValue(0),
};
const service = new StravaSyncService(
{} as never,
activityRepository as never,
strengthTrainingDetailRepository as never,
{} as never,
jobRepository as never,
jobItemRepository as never,
stravaTokenService as never,
stravaClientService as never,
stravaStreamImportService as never,
);
await service.runJob(job.id);
expect(strengthTrainingDetailRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
activityId: 'activity-1',
stravaActivityId: 'strength-1',
sportType: 'WeightTraining',
name: 'Push Day',
elapsedTime: 3600,
calories: 420,
averageHeartrate: 118,
maxHeartrate: 155,
perceivedExertion: 7,
description: 'Bench press 5x5',
}),
);
});
it('stores swim details and laps in their own table', async () => {
const job = {
id: 'job-1',
stravaAthleteId: 'athlete-1',
status: 'queued',
activityCount: 0,
detailCount: 0,
streamPointCount: 0,
errorMessage: null,
retryAfter: null,
startedAt: null,
finishedAt: null,
items: [],
} as StravaSyncJobEntity;
const jobRepository = {
findOne: jest.fn().mockResolvedValue(job),
save: jest.fn((entity: Partial<StravaSyncJobEntity>) => {
Object.assign(job, entity);
return Promise.resolve(job);
}),
};
const activityRepository = {
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn((activity) =>
Promise.resolve({
...activity,
id: 'activity-1',
}),
),
};
const swimActivityDetailRepository = {
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn((detail) => Promise.resolve(detail)),
};
const jobItemRepository = {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((item) => item),
save: jest.fn((item) => Promise.resolve(item)),
};
const stravaTokenService = {
getValidAccessToken: jest.fn().mockResolvedValue('access-token'),
};
const laps = [
{
id: 'lap-1',
lap_index: 1,
distance: 100,
elapsed_time: 95,
},
];
const stravaClientService = {
listActivities: jest.fn().mockResolvedValue([
{
id: 'swim-1',
name: 'Morning Swim',
sport_type: 'Swim',
start_date: '2026-06-16T12:00:00.000Z',
elapsed_time: 1800,
distance: 1000,
},
]),
getActivity: jest.fn().mockResolvedValue({
id: 'swim-1',
name: 'Morning Swim',
sport_type: 'Swim',
start_date: '2026-06-16T12:00:00.000Z',
elapsed_time: 1800,
moving_time: 1700,
distance: 1000,
average_speed: 0.58,
calories: 350,
average_heartrate: 132,
pool_length: 25,
}),
getActivityLaps: jest.fn().mockResolvedValue(laps),
};
const stravaStreamImportService = {
importStreamsForActivity: jest.fn().mockResolvedValue(0),
};
const service = new StravaSyncService(
{} as never,
activityRepository as never,
{} as never,
swimActivityDetailRepository as never,
jobRepository as never,
jobItemRepository as never,
stravaTokenService as never,
stravaClientService as never,
stravaStreamImportService as never,
);
await service.runJob(job.id);
expect(stravaClientService.getActivityLaps).toHaveBeenCalledWith(
'access-token',
'swim-1',
);
expect(swimActivityDetailRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
activityId: 'activity-1',
stravaActivityId: 'swim-1',
sportType: 'Swim',
name: 'Morning Swim',
distance: 1000,
elapsedTime: 1800,
movingTime: 1700,
averageSpeed: 0.58,
calories: 350,
averageHeartrate: 132,
poolLength: 25,
laps,
lapsError: null,
}),
);
});
it('resumes stale queued and running jobs on module init', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-16T12:00:00.000Z'));
const queuedJob = {
id: 'queued-job',
status: 'queued',
retryAfter: null,
} as StravaSyncJobEntity;
const runningJob = {
id: 'running-job',
status: 'running',
retryAfter: null,
} as StravaSyncJobEntity;
const rateLimitedJob = {
id: 'rate-limited-job',
status: 'rate_limited',
retryAfter: new Date('2026-06-16T12:15:00.000Z'),
} as StravaSyncJobEntity;
const jobRepository = {
find: jest
.fn()
.mockResolvedValue([queuedJob, runningJob, rateLimitedJob]),
};
const service = new StravaSyncService(
{} as never,
{} as never,
{} as never,
{} as never,
jobRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
const runJob = jest.spyOn(service, 'runJob').mockResolvedValue(undefined);
await service.onModuleInit();
expect(runJob).toHaveBeenCalledWith(queuedJob.id);
expect(runJob).toHaveBeenCalledWith(runningJob.id);
expect(runJob).not.toHaveBeenCalledWith(rateLimitedJob.id);
expect(jest.getTimerCount()).toBe(1);
});
it('restarts a stale active job when sync is requested again', async () => {
const athlete = { id: 'athlete-1', accountKey: 'primary' };
const activeJob = {
id: 'job-1',
stravaAthleteId: athlete.id,
status: 'running',
} as StravaSyncJobEntity;
const athleteRepository = {
findOne: jest.fn().mockResolvedValue(athlete),
};
const jobRepository = {
findOne: jest.fn().mockResolvedValue(activeJob),
};
const service = new StravaSyncService(
athleteRepository as never,
{} as never,
{} as never,
{} as never,
jobRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
const runJob = jest.spyOn(service, 'runJob').mockResolvedValue(undefined);
const result = await service.startSync();
expect(result).toBe(activeJob);
expect(runJob).toHaveBeenCalledWith(activeJob.id);
});
});

View File

@@ -1,29 +1,52 @@
import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common';
import {
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import {
StravaActivityEntity,
StravaAthleteEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
StravaSyncJobEntity,
StravaSyncJobItemEntity,
} from '../database/entities';
import { mapStravaActivity } from './strava-activity.mapper';
import {
isStrengthTrainingActivity,
mapStravaStrengthTrainingDetail,
} from './strava-strength-training-detail.mapper';
import {
isSwimActivity,
mapStravaSwimActivityDetail,
} from './strava-swim-activity-detail.mapper';
import { StravaClientService } from './strava-client.service';
import { StravaRateLimitError } from './strava-rate-limit.error';
import { StravaStreamImportService } from './strava-stream-import.service';
import { StravaTokenService } from './strava-token.service';
import { StravaActivityPayload } from './strava.types';
import { StravaActivityPayload, StravaLapPayload } from './strava.types';
@Injectable()
export class StravaSyncService implements OnModuleInit {
export class StravaSyncService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(StravaSyncService.name);
private readonly retryDelayMs = 15 * 60 * 1000;
private readonly retryTimers = new Map<string, NodeJS.Timeout>();
private readonly runningJobIds = new Set<string>();
private autoSyncTimer: NodeJS.Timeout | null = null;
constructor(
@InjectRepository(StravaAthleteEntity)
private readonly athleteRepository: Repository<StravaAthleteEntity>,
@InjectRepository(StravaActivityEntity)
private readonly activityRepository: Repository<StravaActivityEntity>,
@InjectRepository(StravaStrengthTrainingDetailEntity)
private readonly strengthTrainingDetailRepository: Repository<StravaStrengthTrainingDetailEntity>,
@InjectRepository(StravaSwimActivityDetailEntity)
private readonly swimActivityDetailRepository: Repository<StravaSwimActivityDetailEntity>,
@InjectRepository(StravaSyncJobEntity)
private readonly jobRepository: Repository<StravaSyncJobEntity>,
@InjectRepository(StravaSyncJobItemEntity)
@@ -34,11 +57,30 @@ export class StravaSyncService implements OnModuleInit {
) {}
async onModuleInit(): Promise<void> {
const waitingJobs = await this.jobRepository.find({
where: { status: 'rate_limited' },
const activeJobs = await this.jobRepository.find({
where: { status: In(['queued', 'running', 'rate_limited']) },
});
waitingJobs.forEach((job) => this.scheduleRetry(job));
activeJobs.forEach((job) => {
if (job.status === 'rate_limited') {
this.scheduleRetry(job);
return;
}
void this.runJob(job.id);
});
this.configureAutoSync();
}
onModuleDestroy(): void {
if (this.autoSyncTimer) {
clearInterval(this.autoSyncTimer);
this.autoSyncTimer = null;
}
this.retryTimers.forEach((timer) => clearTimeout(timer));
this.retryTimers.clear();
}
async startSync(): Promise<StravaSyncJobEntity> {
@@ -52,7 +94,11 @@ export class StravaSyncService implements OnModuleInit {
});
if (activeJob) {
this.scheduleRetry(activeJob);
if (activeJob.status === 'rate_limited') {
this.scheduleRetry(activeJob);
} else {
void this.runJob(activeJob.id);
}
return activeJob;
}
@@ -97,45 +143,56 @@ export class StravaSyncService implements OnModuleInit {
}
async runJob(jobId: string): Promise<void> {
if (this.runningJobIds.has(jobId)) {
return;
}
this.runningJobIds.add(jobId);
this.clearRetry(jobId);
const job = await this.getJob(jobId);
job.status = 'running';
job.startedAt = new Date();
job.finishedAt = null;
job.retryAfter = null;
job.errorMessage = null;
await this.jobRepository.save(job);
try {
const accessToken = await this.stravaTokenService.getValidAccessToken(
job.stravaAthleteId,
);
const job = await this.getJob(jobId);
const after = await this.resolveSyncAfter(job.stravaAthleteId);
job.status = 'running';
job.startedAt = new Date();
job.finishedAt = null;
job.retryAfter = null;
job.errorMessage = null;
await this.jobRepository.save(job);
let page = 1;
const perPage = 100;
let summaries: StravaActivityPayload[] = [];
do {
summaries = await this.stravaClientService.listActivities(
accessToken,
page,
perPage,
try {
const accessToken = await this.stravaTokenService.getValidAccessToken(
job.stravaAthleteId,
);
for (const summary of summaries) {
await this.importActivity(job, accessToken, summary);
}
let page = 1;
const perPage = 100;
let summaries: StravaActivityPayload[] = [];
page += 1;
} while (summaries.length === perPage);
do {
summaries = await this.stravaClientService.listActivities(
accessToken,
page,
perPage,
after,
);
job.status = 'completed';
job.finishedAt = new Date();
job.retryAfter = null;
await this.jobRepository.save(job);
} catch (error) {
await this.failJob(job, error);
for (const summary of summaries) {
await this.importActivity(job, accessToken, summary);
}
page += 1;
} while (summaries.length === perPage);
job.status = 'completed';
job.finishedAt = new Date();
job.retryAfter = null;
await this.jobRepository.save(job);
} catch (error) {
await this.failJob(job, error);
}
} finally {
this.runningJobIds.delete(jobId);
}
}
@@ -170,6 +227,8 @@ export class StravaSyncService implements OnModuleInit {
activity = await this.activityRepository.save(
mapStravaActivity(job.stravaAthleteId, detail, activity),
);
await this.upsertStrengthTrainingDetail(activity, detail);
await this.upsertSwimActivityDetail(activity, detail, accessToken);
job.detailCount += 1;
job.streamPointCount +=
@@ -229,6 +288,121 @@ export class StravaSyncService implements OnModuleInit {
return athlete;
}
private async upsertStrengthTrainingDetail(
activity: StravaActivityEntity,
detail: StravaActivityPayload,
): Promise<void> {
if (!isStrengthTrainingActivity(activity.sportType)) {
return;
}
const existing =
await this.strengthTrainingDetailRepository.findOne({
where: { activityId: activity.id },
});
await this.strengthTrainingDetailRepository.save(
mapStravaStrengthTrainingDetail(activity, detail, existing),
);
}
private async upsertSwimActivityDetail(
activity: StravaActivityEntity,
detail: StravaActivityPayload,
accessToken: string,
): Promise<void> {
if (!isSwimActivity(activity.sportType)) {
return;
}
let laps: StravaLapPayload[] = [];
let lapsError: string | null = null;
try {
laps = await this.stravaClientService.getActivityLaps(
accessToken,
activity.stravaActivityId,
);
} catch (error) {
if (error instanceof StravaRateLimitError) {
throw error;
}
lapsError = this.errorMessage(error);
this.logger.warn(
`Could not import swim laps for activity ${activity.stravaActivityId}: ${lapsError}`,
);
}
const existing = await this.swimActivityDetailRepository.findOne({
where: { activityId: activity.id },
});
await this.swimActivityDetailRepository.save(
mapStravaSwimActivityDetail(activity, detail, laps, lapsError, existing),
);
}
private async resolveSyncAfter(
stravaAthleteId: string,
): Promise<number | undefined> {
const latestActivity = await this.activityRepository.findOne({
where: { stravaAthleteId },
order: { startDate: 'DESC' },
});
if (!latestActivity?.startDate) {
return undefined;
}
const lookbackDays = this.numberFromEnv('STRAVA_SYNC_LOOKBACK_DAYS', 14);
const lookbackMs = Math.max(0, lookbackDays) * 24 * 60 * 60 * 1000;
return Math.floor((latestActivity.startDate.getTime() - lookbackMs) / 1000);
}
private configureAutoSync(): void {
if (process.env.NODE_ENV === 'test') {
return;
}
if (process.env.STRAVA_SYNC_ON_START !== 'false') {
setTimeout(() => void this.startSyncSafely(), 0).unref?.();
}
const intervalMinutes = this.numberFromEnv(
'STRAVA_SYNC_INTERVAL_MINUTES',
60,
);
if (intervalMinutes <= 0) {
return;
}
this.autoSyncTimer = setInterval(
() => void this.startSyncSafely(),
intervalMinutes * 60 * 1000,
);
this.autoSyncTimer.unref?.();
}
private async startSyncSafely(): Promise<void> {
try {
await this.startSync();
} catch (error) {
if (error instanceof NotFoundException) {
return;
}
this.logger.warn(
`Automatic Strava sync could not be started: ${this.errorMessage(error)}`,
);
}
}
private numberFromEnv(key: string, fallback: number): number {
const value = Number(process.env[key]);
return Number.isFinite(value) ? value : fallback;
}
private async failJob(
job: StravaSyncJobEntity,
error: unknown,

View File

@@ -5,6 +5,8 @@ import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaAthleteEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
StravaSyncJobEntity,
StravaSyncJobItemEntity,
StravaTokenEntity,
@@ -27,6 +29,8 @@ import { TokenCryptoService } from './token-crypto.service';
StravaTokenEntity,
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
StravaSyncJobEntity,
StravaSyncJobItemEntity,
]),

View File

@@ -64,6 +64,31 @@ export interface StravaStreamPayload {
resolution?: string;
}
export interface StravaLapPayload {
id?: number | string;
name?: string;
activity?: unknown;
athlete?: unknown;
elapsed_time?: number;
moving_time?: number;
start_date?: string;
start_date_local?: string;
distance?: number;
start_index?: number;
end_index?: number;
lap_index?: number;
split?: number;
average_speed?: number;
max_speed?: number;
average_cadence?: number;
average_watts?: number;
average_heartrate?: number;
max_heartrate?: number;
total_elevation_gain?: number;
pace_zone?: number;
[key: string]: unknown;
}
export type StravaStreamsResponse =
| StravaStreamPayload[]
| Record<string, Omit<StravaStreamPayload, 'type'> & { type?: string }>;