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

@@ -16,6 +16,10 @@ DATABASE_NAME=strava_app
TYPEORM_SYNCHRONIZE=true TYPEORM_SYNCHRONIZE=true
TYPEORM_LOGGING=false TYPEORM_LOGGING=false
STRAVA_SYNC_ON_START=true
STRAVA_SYNC_INTERVAL_MINUTES=60
STRAVA_SYNC_LOOKBACK_DAYS=14
STRAVA_CLIENT_ID=change-me STRAVA_CLIENT_ID=change-me
STRAVA_CLIENT_SECRET=change-me STRAVA_CLIENT_SECRET=change-me
STRAVA_REDIRECT_URI=http://localhost:3000/auth/strava/callback STRAVA_REDIRECT_URI=http://localhost:3000/auth/strava/callback

View File

@@ -1,6 +1,8 @@
export { StravaActivityEntity } from './strava-activity.entity'; export { StravaActivityEntity } from './strava-activity.entity';
export { StravaActivityStreamPointEntity } from './strava-activity-stream-point.entity'; export { StravaActivityStreamPointEntity } from './strava-activity-stream-point.entity';
export { StravaAthleteEntity } from './strava-athlete.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 { StravaSyncJobEntity } from './strava-sync-job.entity';
export { StravaSyncJobItemEntity } from './strava-sync-job-item.entity'; export { StravaSyncJobItemEntity } from './strava-sync-job-item.entity';
export { StravaTokenEntity } from './strava-token.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, StravaActivityEntity,
StravaActivityStreamPointEntity, StravaActivityStreamPointEntity,
StravaAthleteEntity, StravaAthleteEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
StravaSyncJobEntity, StravaSyncJobEntity,
StravaSyncJobItemEntity, StravaSyncJobItemEntity,
StravaTokenEntity, StravaTokenEntity,
@@ -28,6 +30,8 @@ export const createTypeOrmOptions = (): DataSourceOptions => ({
StravaTokenEntity, StravaTokenEntity,
StravaActivityEntity, StravaActivityEntity,
StravaActivityStreamPointEntity, StravaActivityStreamPointEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
StravaSyncJobEntity, StravaSyncJobEntity,
StravaSyncJobItemEntity, StravaSyncJobItemEntity,
], ],

View File

@@ -5,6 +5,8 @@ import {
StravaActivityEntity, StravaActivityEntity,
StravaActivityStreamPointEntity, StravaActivityStreamPointEntity,
StravaAthleteEntity, StravaAthleteEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
} from '../database/entities'; } from '../database/entities';
export interface ListActivitiesInput { export interface ListActivitiesInput {
@@ -32,6 +34,10 @@ export class McpDataService {
private readonly activityRepository: Repository<StravaActivityEntity>, private readonly activityRepository: Repository<StravaActivityEntity>,
@InjectRepository(StravaActivityStreamPointEntity) @InjectRepository(StravaActivityStreamPointEntity)
private readonly streamPointRepository: Repository<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>> { async getAthleteProfile(): Promise<Record<string, unknown>> {
@@ -150,6 +156,13 @@ export class McpDataService {
const streamPointCount = await this.streamPointRepository.count({ const streamPointCount = await this.streamPointRepository.count({
where: { activityId: activity.id }, 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> = { const detail: Record<string, unknown> = {
...this.toActivitySummary(activity), ...this.toActivitySummary(activity),
elapsedTimeSeconds: activity.elapsedTime, elapsedTimeSeconds: activity.elapsedTime,
@@ -168,6 +181,12 @@ export class McpDataService {
mapId: activity.mapId, mapId: activity.mapId,
summaryPolyline: activity.summaryPolyline, summaryPolyline: activity.summaryPolyline,
streamPointCount, streamPointCount,
strengthTrainingDetail: strengthTrainingDetail
? this.toStrengthTrainingDetail(strengthTrainingDetail)
: null,
swimActivityDetail: swimActivityDetail
? this.toSwimActivityDetail(swimActivityDetail)
: null,
createdAt: this.toIso(activity.createdAt), createdAt: this.toIso(activity.createdAt),
updatedAt: this.toIso(activity.updatedAt), updatedAt: this.toIso(activity.updatedAt),
}; };
@@ -213,6 +232,64 @@ export class McpDataService {
return detail; 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( private toActivitySummary(
activity: StravaActivityEntity, activity: StravaActivityEntity,
): Record<string, unknown> { ): Record<string, unknown> {

View File

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

View File

@@ -6,6 +6,7 @@ import { firstValueFrom } from 'rxjs';
import { StravaRateLimitError } from './strava-rate-limit.error'; import { StravaRateLimitError } from './strava-rate-limit.error';
import { import {
StravaActivityPayload, StravaActivityPayload,
StravaLapPayload,
StravaStreamPayload, StravaStreamPayload,
StravaStreamsResponse, StravaStreamsResponse,
StravaTokenPayload, StravaTokenPayload,
@@ -61,12 +62,17 @@ export class StravaClientService {
accessToken: string, accessToken: string,
page: number, page: number,
perPage = 100, perPage = 100,
after?: number,
): Promise<StravaActivityPayload[]> { ): Promise<StravaActivityPayload[]> {
return this.request<StravaActivityPayload[]>({ return this.request<StravaActivityPayload[]>({
method: 'GET', method: 'GET',
url: 'https://www.strava.com/api/v3/athlete/activities', url: 'https://www.strava.com/api/v3/athlete/activities',
headers: this.authHeaders(accessToken), 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); 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( private async postToken(
body: Record<string, string>, body: Record<string, string>,
): Promise<StravaTokenPayload> { ): 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); return Promise.resolve(job);
}), }),
}; };
const activityRepository = {
findOne: jest.fn().mockResolvedValue(null),
};
const stravaTokenService = { const stravaTokenService = {
getValidAccessToken: jest.fn().mockResolvedValue('access-token'), getValidAccessToken: jest.fn().mockResolvedValue('access-token'),
}; };
@@ -44,6 +47,8 @@ describe('StravaSyncService', () => {
}; };
const service = new StravaSyncService( const service = new StravaSyncService(
{} as never,
activityRepository as never,
{} as never, {} as never,
{} as never, {} as never,
jobRepository as never, jobRepository as never,
@@ -66,4 +71,351 @@ describe('StravaSyncService', () => {
expect(job.status).toBe('completed'); expect(job.status).toBe('completed');
expect(job.finishedAt).toBeInstanceOf(Date); 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 { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { import {
StravaActivityEntity, StravaActivityEntity,
StravaAthleteEntity, StravaAthleteEntity,
StravaStrengthTrainingDetailEntity,
StravaSwimActivityDetailEntity,
StravaSyncJobEntity, StravaSyncJobEntity,
StravaSyncJobItemEntity, StravaSyncJobItemEntity,
} from '../database/entities'; } from '../database/entities';
import { mapStravaActivity } from './strava-activity.mapper'; 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 { StravaClientService } from './strava-client.service';
import { StravaRateLimitError } from './strava-rate-limit.error'; import { StravaRateLimitError } from './strava-rate-limit.error';
import { StravaStreamImportService } from './strava-stream-import.service'; import { StravaStreamImportService } from './strava-stream-import.service';
import { StravaTokenService } from './strava-token.service'; import { StravaTokenService } from './strava-token.service';
import { StravaActivityPayload } from './strava.types'; import { StravaActivityPayload, StravaLapPayload } from './strava.types';
@Injectable() @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 retryDelayMs = 15 * 60 * 1000;
private readonly retryTimers = new Map<string, NodeJS.Timeout>(); private readonly retryTimers = new Map<string, NodeJS.Timeout>();
private readonly runningJobIds = new Set<string>();
private autoSyncTimer: NodeJS.Timeout | null = null;
constructor( constructor(
@InjectRepository(StravaAthleteEntity) @InjectRepository(StravaAthleteEntity)
private readonly athleteRepository: Repository<StravaAthleteEntity>, private readonly athleteRepository: Repository<StravaAthleteEntity>,
@InjectRepository(StravaActivityEntity) @InjectRepository(StravaActivityEntity)
private readonly activityRepository: Repository<StravaActivityEntity>, private readonly activityRepository: Repository<StravaActivityEntity>,
@InjectRepository(StravaStrengthTrainingDetailEntity)
private readonly strengthTrainingDetailRepository: Repository<StravaStrengthTrainingDetailEntity>,
@InjectRepository(StravaSwimActivityDetailEntity)
private readonly swimActivityDetailRepository: Repository<StravaSwimActivityDetailEntity>,
@InjectRepository(StravaSyncJobEntity) @InjectRepository(StravaSyncJobEntity)
private readonly jobRepository: Repository<StravaSyncJobEntity>, private readonly jobRepository: Repository<StravaSyncJobEntity>,
@InjectRepository(StravaSyncJobItemEntity) @InjectRepository(StravaSyncJobItemEntity)
@@ -34,11 +57,30 @@ export class StravaSyncService implements OnModuleInit {
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
const waitingJobs = await this.jobRepository.find({ const activeJobs = await this.jobRepository.find({
where: { status: 'rate_limited' }, 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> { async startSync(): Promise<StravaSyncJobEntity> {
@@ -52,7 +94,11 @@ export class StravaSyncService implements OnModuleInit {
}); });
if (activeJob) { if (activeJob) {
if (activeJob.status === 'rate_limited') {
this.scheduleRetry(activeJob); this.scheduleRetry(activeJob);
} else {
void this.runJob(activeJob.id);
}
return activeJob; return activeJob;
} }
@@ -97,9 +143,16 @@ export class StravaSyncService implements OnModuleInit {
} }
async runJob(jobId: string): Promise<void> { async runJob(jobId: string): Promise<void> {
if (this.runningJobIds.has(jobId)) {
return;
}
this.runningJobIds.add(jobId);
this.clearRetry(jobId); this.clearRetry(jobId);
try {
const job = await this.getJob(jobId); const job = await this.getJob(jobId);
const after = await this.resolveSyncAfter(job.stravaAthleteId);
job.status = 'running'; job.status = 'running';
job.startedAt = new Date(); job.startedAt = new Date();
job.finishedAt = null; job.finishedAt = null;
@@ -121,6 +174,7 @@ export class StravaSyncService implements OnModuleInit {
accessToken, accessToken,
page, page,
perPage, perPage,
after,
); );
for (const summary of summaries) { for (const summary of summaries) {
@@ -137,6 +191,9 @@ export class StravaSyncService implements OnModuleInit {
} catch (error) { } catch (error) {
await this.failJob(job, error); await this.failJob(job, error);
} }
} finally {
this.runningJobIds.delete(jobId);
}
} }
private async importActivity( private async importActivity(
@@ -170,6 +227,8 @@ export class StravaSyncService implements OnModuleInit {
activity = await this.activityRepository.save( activity = await this.activityRepository.save(
mapStravaActivity(job.stravaAthleteId, detail, activity), mapStravaActivity(job.stravaAthleteId, detail, activity),
); );
await this.upsertStrengthTrainingDetail(activity, detail);
await this.upsertSwimActivityDetail(activity, detail, accessToken);
job.detailCount += 1; job.detailCount += 1;
job.streamPointCount += job.streamPointCount +=
@@ -229,6 +288,121 @@ export class StravaSyncService implements OnModuleInit {
return athlete; 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( private async failJob(
job: StravaSyncJobEntity, job: StravaSyncJobEntity,
error: unknown, error: unknown,

View File

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

View File

@@ -64,6 +64,31 @@ export interface StravaStreamPayload {
resolution?: string; 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 = export type StravaStreamsResponse =
| StravaStreamPayload[] | StravaStreamPayload[]
| Record<string, Omit<StravaStreamPayload, 'type'> & { type?: string }>; | Record<string, Omit<StravaStreamPayload, 'type'> & { type?: string }>;