details
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
48
api/src/strava/strava-strength-training-detail.mapper.ts
Normal file
48
api/src/strava/strava-strength-training-detail.mapper.ts
Normal 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;
|
||||||
43
api/src/strava/strava-swim-activity-detail.mapper.ts
Normal file
43
api/src/strava/strava-swim-activity-detail.mapper.ts
Normal 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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
|
|||||||
Reference in New Issue
Block a user