git init
This commit is contained in:
49
api/src/strava/strava-activity.mapper.ts
Normal file
49
api/src/strava/strava-activity.mapper.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { StravaActivityEntity } from '../database/entities';
|
||||
import { StravaActivityPayload } from './strava.types';
|
||||
|
||||
export const mapStravaActivity = (
|
||||
stravaAthleteId: string,
|
||||
payload: StravaActivityPayload,
|
||||
existing?: StravaActivityEntity | null,
|
||||
): StravaActivityEntity => {
|
||||
const activity = existing ?? new StravaActivityEntity();
|
||||
activity.stravaAthleteId = stravaAthleteId;
|
||||
activity.stravaActivityId = String(payload.id);
|
||||
activity.name = payload.name ?? `Activity ${payload.id}`;
|
||||
activity.sportType = payload.sport_type ?? payload.type ?? null;
|
||||
activity.distance = numberOrNull(payload.distance);
|
||||
activity.movingTime = numberOrNull(payload.moving_time);
|
||||
activity.elapsedTime = numberOrNull(payload.elapsed_time);
|
||||
activity.totalElevationGain = numberOrNull(payload.total_elevation_gain);
|
||||
activity.startDate = dateOrNull(payload.start_date);
|
||||
activity.startDateLocal = dateOrNull(payload.start_date_local);
|
||||
activity.timezone = payload.timezone ?? null;
|
||||
activity.utcOffset = numberOrNull(payload.utc_offset);
|
||||
activity.averageSpeed = numberOrNull(payload.average_speed);
|
||||
activity.maxSpeed = numberOrNull(payload.max_speed);
|
||||
activity.averageHeartrate = numberOrNull(payload.average_heartrate);
|
||||
activity.maxHeartrate = numberOrNull(payload.max_heartrate);
|
||||
activity.averageWatts = numberOrNull(payload.average_watts);
|
||||
activity.maxWatts = numberOrNull(payload.max_watts);
|
||||
activity.weightedAverageWatts = numberOrNull(payload.weighted_average_watts);
|
||||
activity.averageCadence = numberOrNull(payload.average_cadence);
|
||||
activity.calories = numberOrNull(payload.calories);
|
||||
activity.gearId = payload.gear_id ?? null;
|
||||
activity.trainer = Boolean(payload.trainer);
|
||||
activity.commute = Boolean(payload.commute);
|
||||
activity.manual = Boolean(payload.manual);
|
||||
activity.private = Boolean(payload.private);
|
||||
activity.visibility = payload.visibility ?? null;
|
||||
activity.mapId = payload.map?.id ?? null;
|
||||
activity.summaryPolyline = payload.map?.summary_polyline ?? null;
|
||||
activity.resourceState = numberOrNull(payload.resource_state);
|
||||
activity.rawPayload = payload;
|
||||
|
||||
return activity;
|
||||
};
|
||||
|
||||
const numberOrNull = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const dateOrNull = (value: unknown): Date | null =>
|
||||
typeof value === 'string' ? new Date(value) : null;
|
||||
60
api/src/strava/strava-auth.controller.ts
Normal file
60
api/src/strava/strava-auth.controller.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Controller, Get, Query, Redirect } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { StravaAuthService } from './strava-auth.service';
|
||||
|
||||
@Controller('auth/strava')
|
||||
export class StravaAuthController {
|
||||
constructor(
|
||||
private readonly stravaAuthService: StravaAuthService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Get('connect')
|
||||
@Redirect()
|
||||
connect(): { url: string } {
|
||||
return { url: this.stravaAuthService.buildAuthorizationUrl() };
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
@Redirect()
|
||||
async callback(
|
||||
@Query('code') code?: string,
|
||||
@Query('scope') scope?: string,
|
||||
@Query('error') error?: string,
|
||||
): Promise<{ url: string }> {
|
||||
if (error || !code) {
|
||||
return {
|
||||
url: `${this.clientUrl()}?strava=error`,
|
||||
};
|
||||
}
|
||||
|
||||
await this.stravaAuthService.handleCallback(code, scope);
|
||||
return {
|
||||
url: `${this.clientUrl()}?strava=connected`,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
status(): Promise<{
|
||||
connected: boolean;
|
||||
athlete: {
|
||||
id: string;
|
||||
stravaAthleteId: string;
|
||||
username: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
profile: string | null;
|
||||
updatedAt: Date;
|
||||
} | null;
|
||||
}> {
|
||||
return this.stravaAuthService.getStatus();
|
||||
}
|
||||
|
||||
private clientUrl(): string {
|
||||
return (
|
||||
this.configService.get<string>('CLIENT_URL') ??
|
||||
this.configService.get<string>('CORS_ORIGIN')?.split(',')[0] ??
|
||||
'http://localhost:8080'
|
||||
);
|
||||
}
|
||||
}
|
||||
112
api/src/strava/strava-auth.service.ts
Normal file
112
api/src/strava/strava-auth.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { StravaAthleteEntity } from '../database/entities';
|
||||
import { StravaClientService } from './strava-client.service';
|
||||
import { StravaTokenService } from './strava-token.service';
|
||||
import { StravaAthletePayload } from './strava.types';
|
||||
|
||||
@Injectable()
|
||||
export class StravaAuthService {
|
||||
constructor(
|
||||
@InjectRepository(StravaAthleteEntity)
|
||||
private readonly athleteRepository: Repository<StravaAthleteEntity>,
|
||||
private readonly stravaClientService: StravaClientService,
|
||||
private readonly stravaTokenService: StravaTokenService,
|
||||
) {}
|
||||
|
||||
buildAuthorizationUrl(): string {
|
||||
return this.stravaClientService.buildAuthorizeUrl(
|
||||
Buffer.from(
|
||||
JSON.stringify({ issuedAt: new Date().toISOString() }),
|
||||
'utf8',
|
||||
).toString('base64url'),
|
||||
);
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
code: string,
|
||||
scope?: string,
|
||||
): Promise<{
|
||||
stravaAthleteId: string;
|
||||
scope: string | null;
|
||||
}> {
|
||||
const tokenPayload = await this.stravaClientService.exchangeCode(code);
|
||||
if (!tokenPayload.athlete) {
|
||||
throw new Error('Strava OAuth response did not include athlete payload');
|
||||
}
|
||||
|
||||
const athlete = await this.saveAthlete(tokenPayload.athlete);
|
||||
|
||||
await this.stravaTokenService.saveTokens({
|
||||
stravaAthleteId: athlete.id,
|
||||
accessToken: tokenPayload.access_token,
|
||||
refreshToken: tokenPayload.refresh_token,
|
||||
expiresAt: new Date(tokenPayload.expires_at * 1000),
|
||||
scope: scope ?? null,
|
||||
tokenType: tokenPayload.token_type ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
stravaAthleteId: athlete.id,
|
||||
scope: scope ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(): Promise<{
|
||||
connected: boolean;
|
||||
athlete: {
|
||||
id: string;
|
||||
stravaAthleteId: string;
|
||||
username: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
profile: string | null;
|
||||
updatedAt: Date;
|
||||
} | null;
|
||||
}> {
|
||||
const athlete = await this.athleteRepository.findOne({
|
||||
where: { accountKey: 'primary' },
|
||||
});
|
||||
|
||||
return {
|
||||
connected: Boolean(athlete),
|
||||
athlete: athlete
|
||||
? {
|
||||
id: athlete.id,
|
||||
stravaAthleteId: athlete.stravaAthleteId,
|
||||
username: athlete.username,
|
||||
firstName: athlete.firstName,
|
||||
lastName: athlete.lastName,
|
||||
profile: athlete.profile,
|
||||
updatedAt: athlete.updatedAt,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async saveAthlete(
|
||||
payload: StravaAthletePayload,
|
||||
): Promise<StravaAthleteEntity> {
|
||||
const stravaAthleteId = String(payload.id);
|
||||
const existing = await this.athleteRepository.findOne({
|
||||
where: [{ accountKey: 'primary' }, { stravaAthleteId }],
|
||||
});
|
||||
|
||||
const athlete = existing ?? this.athleteRepository.create();
|
||||
athlete.accountKey = 'primary';
|
||||
athlete.stravaAthleteId = stravaAthleteId;
|
||||
athlete.username = payload.username ?? null;
|
||||
athlete.firstName = payload.firstname ?? null;
|
||||
athlete.lastName = payload.lastname ?? null;
|
||||
athlete.city = payload.city ?? null;
|
||||
athlete.state = payload.state ?? null;
|
||||
athlete.country = payload.country ?? null;
|
||||
athlete.sex = payload.sex ?? null;
|
||||
athlete.profileMedium = payload.profile_medium ?? null;
|
||||
athlete.profile = payload.profile ?? null;
|
||||
athlete.rawPayload = payload as unknown as Record<string, unknown>;
|
||||
|
||||
return this.athleteRepository.save(athlete);
|
||||
}
|
||||
}
|
||||
165
api/src/strava/strava-client.service.ts
Normal file
165
api/src/strava/strava-client.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { StravaRateLimitError } from './strava-rate-limit.error';
|
||||
import {
|
||||
StravaActivityPayload,
|
||||
StravaStreamPayload,
|
||||
StravaTokenPayload,
|
||||
} from './strava.types';
|
||||
|
||||
@Injectable()
|
||||
export class StravaClientService {
|
||||
private readonly clientId: string;
|
||||
private readonly clientSecret: string;
|
||||
private readonly redirectUri: string;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
configService: ConfigService,
|
||||
) {
|
||||
this.clientId = this.required(configService, 'STRAVA_CLIENT_ID');
|
||||
this.clientSecret = this.required(configService, 'STRAVA_CLIENT_SECRET');
|
||||
this.redirectUri = this.required(configService, 'STRAVA_REDIRECT_URI');
|
||||
}
|
||||
|
||||
buildAuthorizeUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
redirect_uri: this.redirectUri,
|
||||
response_type: 'code',
|
||||
approval_prompt: 'auto',
|
||||
scope: 'read,activity:read_all',
|
||||
state,
|
||||
});
|
||||
|
||||
return `https://www.strava.com/oauth/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
async exchangeCode(code: string): Promise<StravaTokenPayload> {
|
||||
return this.postToken({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<StravaTokenPayload> {
|
||||
return this.postToken({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
async listActivities(
|
||||
accessToken: string,
|
||||
page: number,
|
||||
perPage = 100,
|
||||
): 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 },
|
||||
});
|
||||
}
|
||||
|
||||
async getActivity(
|
||||
accessToken: string,
|
||||
stravaActivityId: string,
|
||||
): Promise<StravaActivityPayload> {
|
||||
return this.request<StravaActivityPayload>({
|
||||
method: 'GET',
|
||||
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}`,
|
||||
headers: this.authHeaders(accessToken),
|
||||
});
|
||||
}
|
||||
|
||||
async getActivityStreams(
|
||||
accessToken: string,
|
||||
stravaActivityId: string,
|
||||
): Promise<StravaStreamPayload[]> {
|
||||
return this.request<StravaStreamPayload[]>({
|
||||
method: 'GET',
|
||||
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}/streams`,
|
||||
headers: this.authHeaders(accessToken),
|
||||
params: {
|
||||
keys: 'time,distance,latlng,altitude,velocity_smooth,heartrate,cadence,watts,temp,moving,grade_smooth',
|
||||
key_by_type: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async postToken(
|
||||
body: Record<string, string>,
|
||||
): Promise<StravaTokenPayload> {
|
||||
return this.request<StravaTokenPayload>({
|
||||
method: 'POST',
|
||||
url: 'https://www.strava.com/oauth/token',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.request<T>(config),
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (this.isAxiosError(error) && error.response?.status === 429) {
|
||||
throw new StravaRateLimitError(
|
||||
'Strava API rate limit exceeded',
|
||||
this.parseRetryAfter(error),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private parseRetryAfter(error: AxiosError): Date | null {
|
||||
const headers = error.response?.headers as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const value = this.headerValue(headers?.['retry-after']);
|
||||
const seconds = Number(value);
|
||||
|
||||
return Number.isFinite(seconds)
|
||||
? new Date(Date.now() + seconds * 1000)
|
||||
: null;
|
||||
}
|
||||
|
||||
private headerValue(value: unknown): string | number | null {
|
||||
if (Array.isArray(value)) {
|
||||
const values = value as unknown[];
|
||||
return this.headerValue(values[0]);
|
||||
}
|
||||
|
||||
return typeof value === 'string' || typeof value === 'number'
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
private authHeaders(accessToken: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${accessToken}` };
|
||||
}
|
||||
|
||||
private required(configService: ConfigService, key: string): string {
|
||||
const value = configService.get<string>(key);
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private isAxiosError(error: unknown): error is AxiosError {
|
||||
return (
|
||||
typeof error === 'object' && error !== null && 'isAxiosError' in error
|
||||
);
|
||||
}
|
||||
}
|
||||
9
api/src/strava/strava-rate-limit.error.ts
Normal file
9
api/src/strava/strava-rate-limit.error.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export class StravaRateLimitError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly retryAfter: Date | null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'StravaRateLimitError';
|
||||
}
|
||||
}
|
||||
60
api/src/strava/strava-stream-normalizer.service.spec.ts
Normal file
60
api/src/strava/strava-stream-normalizer.service.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
|
||||
|
||||
describe('StravaStreamNormalizerService', () => {
|
||||
const service = new StravaStreamNormalizerService();
|
||||
|
||||
it('normalizes known Strava streams into point rows', () => {
|
||||
const points = service.normalize('activity-1', [
|
||||
{ type: 'time', data: [0, 10] },
|
||||
{ type: 'distance', data: [0, 50] },
|
||||
{
|
||||
type: 'latlng',
|
||||
data: [
|
||||
[48.1, 16.1],
|
||||
[48.2, 16.2],
|
||||
],
|
||||
},
|
||||
{ type: 'heartrate', data: [140, 145] },
|
||||
{ type: 'moving', data: [true, false] },
|
||||
]);
|
||||
|
||||
expect(points).toEqual([
|
||||
expect.objectContaining({
|
||||
activityId: 'activity-1',
|
||||
pointIndex: 0,
|
||||
timeSeconds: 0,
|
||||
distance: 0,
|
||||
latitude: 48.1,
|
||||
longitude: 16.1,
|
||||
heartRate: 140,
|
||||
moving: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pointIndex: 1,
|
||||
timeSeconds: 10,
|
||||
distance: 50,
|
||||
latitude: 48.2,
|
||||
longitude: 16.2,
|
||||
heartRate: 145,
|
||||
moving: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills missing stream values with null', () => {
|
||||
const points = service.normalize('activity-1', [
|
||||
{ type: 'time', data: [0, 10] },
|
||||
{ type: 'watts', data: [250] },
|
||||
]);
|
||||
|
||||
expect(points).toHaveLength(2);
|
||||
expect(points[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
timeSeconds: 10,
|
||||
watts: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
61
api/src/strava/strava-stream-normalizer.service.ts
Normal file
61
api/src/strava/strava-stream-normalizer.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { StravaActivityStreamPointEntity } from '../database/entities';
|
||||
import { StravaStreamPayload } from './strava.types';
|
||||
|
||||
type StreamDataByType = Record<string, unknown[]>;
|
||||
|
||||
@Injectable()
|
||||
export class StravaStreamNormalizerService {
|
||||
normalize(
|
||||
activityId: string,
|
||||
streams: StravaStreamPayload[],
|
||||
): Partial<StravaActivityStreamPointEntity>[] {
|
||||
const streamData = this.byType(streams);
|
||||
const pointCount = Math.max(
|
||||
0,
|
||||
...Object.values(streamData).map((values) => values.length),
|
||||
);
|
||||
|
||||
return Array.from({ length: pointCount }, (_, pointIndex) => {
|
||||
const latlng = this.tuple(streamData.latlng?.[pointIndex]);
|
||||
|
||||
return {
|
||||
activityId,
|
||||
pointIndex,
|
||||
timeSeconds: this.numberOrNull(streamData.time?.[pointIndex]),
|
||||
distance: this.numberOrNull(streamData.distance?.[pointIndex]),
|
||||
latitude: this.numberOrNull(latlng?.[0]),
|
||||
longitude: this.numberOrNull(latlng?.[1]),
|
||||
altitude: this.numberOrNull(streamData.altitude?.[pointIndex]),
|
||||
velocitySmooth: this.numberOrNull(
|
||||
streamData.velocity_smooth?.[pointIndex],
|
||||
),
|
||||
heartRate: this.numberOrNull(streamData.heartrate?.[pointIndex]),
|
||||
cadence: this.numberOrNull(streamData.cadence?.[pointIndex]),
|
||||
watts: this.numberOrNull(streamData.watts?.[pointIndex]),
|
||||
temperature: this.numberOrNull(streamData.temp?.[pointIndex]),
|
||||
moving: this.booleanOrNull(streamData.moving?.[pointIndex]),
|
||||
gradeSmooth: this.numberOrNull(streamData.grade_smooth?.[pointIndex]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private byType(streams: StravaStreamPayload[]): StreamDataByType {
|
||||
return streams.reduce<StreamDataByType>((result, stream) => {
|
||||
result[stream.type] = Array.isArray(stream.data) ? stream.data : [];
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private tuple(value: unknown): unknown[] | null {
|
||||
return Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
private numberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
private booleanOrNull(value: unknown): boolean | null {
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
}
|
||||
18
api/src/strava/strava-sync.controller.ts
Normal file
18
api/src/strava/strava-sync.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { StravaSyncJobEntity } from '../database/entities';
|
||||
import { StravaSyncService } from './strava-sync.service';
|
||||
|
||||
@Controller('strava/sync')
|
||||
export class StravaSyncController {
|
||||
constructor(private readonly stravaSyncService: StravaSyncService) {}
|
||||
|
||||
@Post()
|
||||
start(): Promise<StravaSyncJobEntity> {
|
||||
return this.stravaSyncService.startSync();
|
||||
}
|
||||
|
||||
@Get('jobs/:id')
|
||||
getJob(@Param('id') id: string): Promise<StravaSyncJobEntity> {
|
||||
return this.stravaSyncService.getJob(id);
|
||||
}
|
||||
}
|
||||
218
api/src/strava/strava-sync.service.ts
Normal file
218
api/src/strava/strava-sync.service.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
import {
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
StravaAthleteEntity,
|
||||
StravaSyncJobEntity,
|
||||
StravaSyncJobItemEntity,
|
||||
} from '../database/entities';
|
||||
import { mapStravaActivity } from './strava-activity.mapper';
|
||||
import { StravaClientService } from './strava-client.service';
|
||||
import { StravaRateLimitError } from './strava-rate-limit.error';
|
||||
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
|
||||
import { StravaTokenService } from './strava-token.service';
|
||||
import { StravaActivityPayload } from './strava.types';
|
||||
|
||||
@Injectable()
|
||||
export class StravaSyncService {
|
||||
constructor(
|
||||
@InjectRepository(StravaAthleteEntity)
|
||||
private readonly athleteRepository: Repository<StravaAthleteEntity>,
|
||||
@InjectRepository(StravaActivityEntity)
|
||||
private readonly activityRepository: Repository<StravaActivityEntity>,
|
||||
@InjectRepository(StravaActivityStreamPointEntity)
|
||||
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
|
||||
@InjectRepository(StravaSyncJobEntity)
|
||||
private readonly jobRepository: Repository<StravaSyncJobEntity>,
|
||||
@InjectRepository(StravaSyncJobItemEntity)
|
||||
private readonly jobItemRepository: Repository<StravaSyncJobItemEntity>,
|
||||
private readonly stravaTokenService: StravaTokenService,
|
||||
private readonly stravaClientService: StravaClientService,
|
||||
private readonly streamNormalizer: StravaStreamNormalizerService,
|
||||
) {}
|
||||
|
||||
async startSync(): Promise<StravaSyncJobEntity> {
|
||||
const athlete = await this.resolveAthlete();
|
||||
const job = await this.jobRepository.save(
|
||||
this.jobRepository.create({
|
||||
stravaAthleteId: athlete.id,
|
||||
status: 'queued',
|
||||
}),
|
||||
);
|
||||
|
||||
void this.runJob(job.id);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
async getJob(jobId: string): Promise<StravaSyncJobEntity> {
|
||||
const job = await this.jobRepository.findOne({
|
||||
where: { id: jobId },
|
||||
relations: { items: true },
|
||||
});
|
||||
if (!job) {
|
||||
throw new NotFoundException('Sync job not found');
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
async runJob(jobId: string): Promise<void> {
|
||||
const job = await this.getJob(jobId);
|
||||
job.status = 'running';
|
||||
job.startedAt = new Date();
|
||||
await this.jobRepository.save(job);
|
||||
|
||||
try {
|
||||
const accessToken = await this.stravaTokenService.getValidAccessToken(
|
||||
job.stravaAthleteId,
|
||||
);
|
||||
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
let summaries: StravaActivityPayload[] = [];
|
||||
|
||||
do {
|
||||
summaries = await this.stravaClientService.listActivities(
|
||||
accessToken,
|
||||
page,
|
||||
perPage,
|
||||
);
|
||||
|
||||
for (const summary of summaries) {
|
||||
await this.importActivity(job, accessToken, summary);
|
||||
}
|
||||
|
||||
page += 1;
|
||||
} while (summaries.length === perPage);
|
||||
|
||||
job.status = 'completed';
|
||||
job.finishedAt = new Date();
|
||||
await this.jobRepository.save(job);
|
||||
} catch (error) {
|
||||
await this.failJob(job, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async importActivity(
|
||||
job: StravaSyncJobEntity,
|
||||
accessToken: string,
|
||||
summary: StravaActivityPayload,
|
||||
): Promise<void> {
|
||||
const stravaActivityId = String(summary.id);
|
||||
const item = await this.getOrCreateJobItem(job.id, stravaActivityId);
|
||||
|
||||
try {
|
||||
const existingSummary = await this.activityRepository.findOne({
|
||||
where: {
|
||||
stravaAthleteId: job.stravaAthleteId,
|
||||
stravaActivityId,
|
||||
},
|
||||
});
|
||||
let activity = await this.activityRepository.save(
|
||||
mapStravaActivity(job.stravaAthleteId, summary, existingSummary),
|
||||
);
|
||||
job.activityCount += 1;
|
||||
|
||||
const detail = await this.stravaClientService.getActivity(
|
||||
accessToken,
|
||||
stravaActivityId,
|
||||
);
|
||||
activity = await this.activityRepository.save(
|
||||
mapStravaActivity(job.stravaAthleteId, detail, activity),
|
||||
);
|
||||
job.detailCount += 1;
|
||||
|
||||
const streams = await this.stravaClientService.getActivityStreams(
|
||||
accessToken,
|
||||
stravaActivityId,
|
||||
);
|
||||
await this.streamPointRepository.delete({ activityId: activity.id });
|
||||
const points = this.streamNormalizer.normalize(activity.id, streams);
|
||||
await this.insertStreamPoints(points);
|
||||
job.streamPointCount += points.length;
|
||||
|
||||
item.status = 'completed';
|
||||
item.errorMessage = null;
|
||||
await this.jobItemRepository.save(item);
|
||||
await this.jobRepository.save(job);
|
||||
} catch (error) {
|
||||
if (error instanceof StravaRateLimitError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
item.status = 'failed';
|
||||
item.errorMessage = this.errorMessage(error);
|
||||
await this.jobItemRepository.save(item);
|
||||
await this.jobRepository.save(job);
|
||||
}
|
||||
}
|
||||
|
||||
private async insertStreamPoints(
|
||||
points: Partial<StravaActivityStreamPointEntity>[],
|
||||
): Promise<void> {
|
||||
const chunkSize = 1000;
|
||||
for (let index = 0; index < points.length; index += chunkSize) {
|
||||
await this.streamPointRepository.insert(
|
||||
points.slice(
|
||||
index,
|
||||
index + chunkSize,
|
||||
) as QueryDeepPartialEntity<StravaActivityStreamPointEntity>[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateJobItem(
|
||||
jobId: string,
|
||||
stravaActivityId: string,
|
||||
): Promise<StravaSyncJobItemEntity> {
|
||||
const existing = await this.jobItemRepository.findOne({
|
||||
where: { jobId, stravaActivityId },
|
||||
});
|
||||
if (existing) {
|
||||
existing.status = 'pending';
|
||||
existing.errorMessage = null;
|
||||
return this.jobItemRepository.save(existing);
|
||||
}
|
||||
|
||||
return this.jobItemRepository.save(
|
||||
this.jobItemRepository.create({
|
||||
jobId,
|
||||
stravaActivityId,
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveAthlete(): Promise<StravaAthleteEntity> {
|
||||
const athlete = await this.athleteRepository.findOne({
|
||||
where: { accountKey: 'primary' },
|
||||
});
|
||||
|
||||
if (!athlete) {
|
||||
throw new NotFoundException('No Strava athlete connected');
|
||||
}
|
||||
|
||||
return athlete;
|
||||
}
|
||||
|
||||
private async failJob(
|
||||
job: StravaSyncJobEntity,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
job.status =
|
||||
error instanceof StravaRateLimitError ? 'rate_limited' : 'failed';
|
||||
job.errorMessage = this.errorMessage(error);
|
||||
job.retryAfter =
|
||||
error instanceof StravaRateLimitError ? error.retryAfter : null;
|
||||
job.finishedAt = new Date();
|
||||
await this.jobRepository.save(job);
|
||||
}
|
||||
|
||||
private errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Unknown sync error';
|
||||
}
|
||||
}
|
||||
73
api/src/strava/strava-token.service.ts
Normal file
73
api/src/strava/strava-token.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { StravaTokenEntity } from '../database/entities';
|
||||
import { StravaClientService } from './strava-client.service';
|
||||
import { TokenCryptoService } from './token-crypto.service';
|
||||
|
||||
@Injectable()
|
||||
export class StravaTokenService {
|
||||
constructor(
|
||||
@InjectRepository(StravaTokenEntity)
|
||||
private readonly tokenRepository: Repository<StravaTokenEntity>,
|
||||
private readonly tokenCryptoService: TokenCryptoService,
|
||||
private readonly stravaClientService: StravaClientService,
|
||||
) {}
|
||||
|
||||
async saveTokens(input: {
|
||||
stravaAthleteId: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: Date;
|
||||
scope: string | null;
|
||||
tokenType: string | null;
|
||||
}): Promise<StravaTokenEntity> {
|
||||
const existing = await this.tokenRepository.findOne({
|
||||
where: { stravaAthleteId: input.stravaAthleteId },
|
||||
});
|
||||
|
||||
const token = existing ?? this.tokenRepository.create();
|
||||
token.stravaAthleteId = input.stravaAthleteId;
|
||||
token.accessTokenCiphertext = this.tokenCryptoService.encrypt(
|
||||
input.accessToken,
|
||||
);
|
||||
token.refreshTokenCiphertext = this.tokenCryptoService.encrypt(
|
||||
input.refreshToken,
|
||||
);
|
||||
token.expiresAt = input.expiresAt;
|
||||
token.scope = input.scope;
|
||||
token.tokenType = input.tokenType ?? 'Bearer';
|
||||
|
||||
return this.tokenRepository.save(token);
|
||||
}
|
||||
|
||||
async getValidAccessToken(stravaAthleteId: string): Promise<string> {
|
||||
const token = await this.tokenRepository.findOne({
|
||||
where: { stravaAthleteId },
|
||||
});
|
||||
if (!token) {
|
||||
throw new NotFoundException('No Strava token found for athlete');
|
||||
}
|
||||
|
||||
const refreshBufferMs = 60_000;
|
||||
if (token.expiresAt.getTime() > Date.now() + refreshBufferMs) {
|
||||
return this.tokenCryptoService.decrypt(token.accessTokenCiphertext);
|
||||
}
|
||||
|
||||
const refreshed = await this.stravaClientService.refreshToken(
|
||||
this.tokenCryptoService.decrypt(token.refreshTokenCiphertext),
|
||||
);
|
||||
|
||||
token.accessTokenCiphertext = this.tokenCryptoService.encrypt(
|
||||
refreshed.access_token,
|
||||
);
|
||||
token.refreshTokenCiphertext = this.tokenCryptoService.encrypt(
|
||||
refreshed.refresh_token,
|
||||
);
|
||||
token.expiresAt = new Date(refreshed.expires_at * 1000);
|
||||
token.tokenType = refreshed.token_type ?? token.tokenType;
|
||||
await this.tokenRepository.save(token);
|
||||
|
||||
return refreshed.access_token;
|
||||
}
|
||||
}
|
||||
44
api/src/strava/strava.module.ts
Normal file
44
api/src/strava/strava.module.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import {
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
StravaAthleteEntity,
|
||||
StravaSyncJobEntity,
|
||||
StravaSyncJobItemEntity,
|
||||
StravaTokenEntity,
|
||||
} from '../database/entities';
|
||||
import { StravaAuthController } from './strava-auth.controller';
|
||||
import { StravaAuthService } from './strava-auth.service';
|
||||
import { StravaClientService } from './strava-client.service';
|
||||
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
|
||||
import { StravaSyncController } from './strava-sync.controller';
|
||||
import { StravaSyncService } from './strava-sync.service';
|
||||
import { StravaTokenService } from './strava-token.service';
|
||||
import { TokenCryptoService } from './token-crypto.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
TypeOrmModule.forFeature([
|
||||
StravaAthleteEntity,
|
||||
StravaTokenEntity,
|
||||
StravaActivityEntity,
|
||||
StravaActivityStreamPointEntity,
|
||||
StravaSyncJobEntity,
|
||||
StravaSyncJobItemEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [StravaAuthController, StravaSyncController],
|
||||
providers: [
|
||||
StravaAuthService,
|
||||
StravaClientService,
|
||||
StravaTokenService,
|
||||
TokenCryptoService,
|
||||
StravaStreamNormalizerService,
|
||||
StravaSyncService,
|
||||
],
|
||||
exports: [StravaStreamNormalizerService],
|
||||
})
|
||||
export class StravaModule {}
|
||||
70
api/src/strava/strava.types.ts
Normal file
70
api/src/strava/strava.types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export interface StravaAthletePayload {
|
||||
id: number | string;
|
||||
username?: string | null;
|
||||
firstname?: string | null;
|
||||
lastname?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
country?: string | null;
|
||||
sex?: string | null;
|
||||
profile_medium?: string | null;
|
||||
profile?: string | null;
|
||||
}
|
||||
|
||||
export interface StravaTokenPayload {
|
||||
token_type?: string;
|
||||
expires_at: number;
|
||||
expires_in?: number;
|
||||
refresh_token: string;
|
||||
access_token: string;
|
||||
athlete?: StravaAthletePayload;
|
||||
}
|
||||
|
||||
export interface StravaActivityPayload {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
sport_type?: string;
|
||||
type?: string;
|
||||
distance?: number;
|
||||
moving_time?: number;
|
||||
elapsed_time?: number;
|
||||
total_elevation_gain?: number;
|
||||
start_date?: string;
|
||||
start_date_local?: string;
|
||||
timezone?: string;
|
||||
utc_offset?: number;
|
||||
average_speed?: number;
|
||||
max_speed?: number;
|
||||
average_heartrate?: number;
|
||||
max_heartrate?: number;
|
||||
average_watts?: number;
|
||||
max_watts?: number;
|
||||
weighted_average_watts?: number;
|
||||
average_cadence?: number;
|
||||
calories?: number;
|
||||
gear_id?: string | null;
|
||||
trainer?: boolean;
|
||||
commute?: boolean;
|
||||
manual?: boolean;
|
||||
private?: boolean;
|
||||
visibility?: string | null;
|
||||
resource_state?: number;
|
||||
map?: {
|
||||
id?: string | null;
|
||||
summary_polyline?: string | null;
|
||||
} | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface StravaStreamPayload {
|
||||
type: string;
|
||||
data: unknown[];
|
||||
series_type?: string;
|
||||
original_size?: number;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
export interface StravaRateLimit {
|
||||
limit?: string;
|
||||
usage?: string;
|
||||
}
|
||||
58
api/src/strava/token-crypto.service.ts
Normal file
58
api/src/strava/token-crypto.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class TokenCryptoService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(configService: ConfigService) {
|
||||
const configuredKey = configService.get<string>('APP_ENCRYPTION_KEY');
|
||||
if (!configuredKey) {
|
||||
throw new Error(
|
||||
'Missing required environment variable: APP_ENCRYPTION_KEY',
|
||||
);
|
||||
}
|
||||
this.key = createHash('sha256').update(configuredKey).digest();
|
||||
}
|
||||
|
||||
encrypt(value: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(value, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return [
|
||||
iv.toString('base64url'),
|
||||
authTag.toString('base64url'),
|
||||
encrypted.toString('base64url'),
|
||||
].join('.');
|
||||
}
|
||||
|
||||
decrypt(value: string): string {
|
||||
const [ivValue, authTagValue, encryptedValue] = value.split('.');
|
||||
if (!ivValue || !authTagValue || !encryptedValue) {
|
||||
throw new Error('Invalid encrypted token format');
|
||||
}
|
||||
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
this.key,
|
||||
Buffer.from(ivValue, 'base64url'),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(authTagValue, 'base64url'));
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedValue, 'base64url')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user