This commit is contained in:
Bastian Wagner
2026-06-16 12:15:29 +02:00
commit 38141c0358
80 changed files with 23444 additions and 0 deletions

5
api/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
coverage
.env
.git

56
api/.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

4
api/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

20
api/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/main.js"]

98
api/README.md Normal file
View File

@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

35
api/eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

10591
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

77
api/package.json Normal file
View File

@@ -0,0 +1,77 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.2",
"axios": "^1.18.0",
"mysql2": "^3.22.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^1.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,16 @@
import { Controller, Get, Query } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import { AnalyticsDashboard } from './analytics.types';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Get('dashboard')
dashboard(
@Query('weeks') weeks?: string,
@Query('sportType') sportType?: string,
): Promise<AnalyticsDashboard> {
return this.analyticsService.getDashboard(Number(weeks ?? 12), sportType);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StravaActivityEntity } from '../database/entities';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [TypeOrmModule.forFeature([StravaActivityEntity])],
controllers: [AnalyticsController],
providers: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,123 @@
import { Repository } from 'typeorm';
import { StravaActivityEntity } from '../database/entities';
import { AnalyticsService } from './analytics.service';
describe('AnalyticsService', () => {
const createService = (activities: StravaActivityEntity[]) => {
const repository = {
find: jest.fn().mockResolvedValue(activities),
} as unknown as Repository<StravaActivityEntity>;
return {
service: new AnalyticsService(repository),
repository,
};
};
const activity = (
input: Partial<StravaActivityEntity>,
): StravaActivityEntity =>
({
id: input.id ?? `activity-${input.stravaActivityId ?? '1'}`,
stravaActivityId: input.stravaActivityId ?? '1',
name: input.name ?? 'Morning Ride',
sportType: input.sportType ?? 'Ride',
startDate: input.startDate ?? new Date(),
distance: input.distance ?? 10000,
movingTime: input.movingTime ?? 1800,
totalElevationGain: input.totalElevationGain ?? 120,
calories: input.calories ?? 400,
averageSpeed: input.averageSpeed ?? 5.5,
averageHeartrate: input.averageHeartrate ?? null,
averageWatts: input.averageWatts ?? null,
averageCadence: input.averageCadence ?? null,
}) as StravaActivityEntity;
it('returns empty dashboard buckets when there are no activities', async () => {
const { service } = createService([]);
const dashboard = await service.getDashboard(12);
expect(dashboard.totals.activityCount).toBe(0);
expect(dashboard.availableSports).toEqual([]);
expect(dashboard.weekly).toHaveLength(12);
expect(dashboard.sports).toEqual([]);
expect(dashboard.recentActivities).toEqual([]);
});
it('aggregates totals, averages, and sport summaries', async () => {
const { service } = createService([
activity({
stravaActivityId: '1',
sportType: 'Ride',
distance: 20000,
movingTime: 3600,
totalElevationGain: 200,
calories: 700,
averageHeartrate: 140,
averageWatts: 210,
}),
activity({
stravaActivityId: '2',
sportType: 'Run',
distance: 5000,
movingTime: 1500,
totalElevationGain: 40,
calories: 350,
averageHeartrate: 150,
}),
]);
const dashboard = await service.getDashboard(12);
expect(dashboard.totals).toEqual({
activityCount: 2,
distanceMeters: 25000,
movingTimeSeconds: 5100,
elevationGainMeters: 240,
calories: 1050,
});
expect(dashboard.averages.heartRate).toBe(145);
expect(dashboard.averages.watts).toBe(210);
expect(dashboard.sports.map((sport) => sport.sportType)).toEqual([
'Ride',
'Run',
]);
});
it('clamps the requested number of weeks', async () => {
const { service } = createService([]);
await expect(service.getDashboard(999)).resolves.toMatchObject({
weeks: 104,
});
await expect(service.getDashboard(0)).resolves.toMatchObject({
weeks: 1,
});
});
it('filters dashboard values by sport type while keeping available sports', async () => {
const { service } = createService([
activity({
stravaActivityId: '1',
sportType: 'Ride',
distance: 20000,
movingTime: 3600,
}),
activity({
stravaActivityId: '2',
sportType: 'Run',
distance: 5000,
movingTime: 1500,
}),
]);
const dashboard = await service.getDashboard(12, 'Run');
expect(dashboard.selectedSportType).toBe('Run');
expect(dashboard.availableSports).toEqual(['Ride', 'Run']);
expect(dashboard.totals.activityCount).toBe(1);
expect(dashboard.totals.distanceMeters).toBe(5000);
expect(dashboard.sports.map((sport) => sport.sportType)).toEqual(['Run']);
});
});

View File

@@ -0,0 +1,248 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThanOrEqual, Repository } from 'typeorm';
import { StravaActivityEntity } from '../database/entities';
import {
AnalyticsAverages,
AnalyticsDashboard,
AnalyticsRecentActivity,
AnalyticsSportSummary,
AnalyticsTotals,
AnalyticsWeeklyBucket,
} from './analytics.types';
@Injectable()
export class AnalyticsService {
constructor(
@InjectRepository(StravaActivityEntity)
private readonly activityRepository: Repository<StravaActivityEntity>,
) {}
async getDashboard(
weeksInput = 12,
sportTypeInput?: string,
): Promise<AnalyticsDashboard> {
const weeks = this.clampWeeks(weeksInput);
const selectedSportType = this.normalizeSportType(sportTypeInput);
const now = new Date();
const rangeStart = this.startOfWeek(this.addDays(now, -(weeks - 1) * 7));
const rangeEnd = this.endOfDay(now);
const activities = await this.activityRepository.find({
where: {
startDate: MoreThanOrEqual(rangeStart),
},
order: {
startDate: 'DESC',
},
});
const availableSports = this.availableSports(activities);
const filteredActivities = selectedSportType
? activities.filter(
(activity) =>
(activity.sportType ?? 'Unbekannt') === selectedSportType,
)
: activities;
const totals = this.createTotals();
const weekly = this.createWeeklyBuckets(rangeStart, weeks);
const sports = new Map<string, AnalyticsSportSummary>();
const heartRates: number[] = [];
const watts: number[] = [];
const cadences: number[] = [];
for (const activity of filteredActivities) {
this.addActivity(totals, activity);
const week = weekly.get(
this.dateKey(this.startOfWeek(activity.startDate)),
);
if (week) {
this.addActivity(week, activity);
}
const sportType = activity.sportType ?? 'Unbekannt';
const sport = sports.get(sportType) ?? {
...this.createTotals(),
sportType,
};
this.addActivity(sport, activity);
sports.set(sportType, sport);
this.pushIfNumber(heartRates, activity.averageHeartrate);
this.pushIfNumber(watts, activity.averageWatts);
this.pushIfNumber(cadences, activity.averageCadence);
}
return {
weeks,
selectedSportType,
availableSports,
rangeStart: this.dateKey(rangeStart),
rangeEnd: this.dateKey(rangeEnd),
totals,
averages: this.createAverages(totals, heartRates, watts, cadences),
weekly: Array.from(weekly.values()),
sports: Array.from(sports.values()).sort(
(left, right) => right.distanceMeters - left.distanceMeters,
),
recentActivities: filteredActivities
.slice(0, 8)
.map((activity) => this.toRecentActivity(activity)),
};
}
private availableSports(activities: StravaActivityEntity[]): string[] {
return Array.from(
new Set(activities.map((activity) => activity.sportType ?? 'Unbekannt')),
).sort((left, right) => left.localeCompare(right));
}
private addActivity(
totals: AnalyticsTotals,
activity: StravaActivityEntity,
): void {
totals.activityCount += 1;
totals.distanceMeters += activity.distance ?? 0;
totals.movingTimeSeconds += activity.movingTime ?? 0;
totals.elevationGainMeters += activity.totalElevationGain ?? 0;
totals.calories += activity.calories ?? 0;
}
private createAverages(
totals: AnalyticsTotals,
heartRates: number[],
watts: number[],
cadences: number[],
): AnalyticsAverages {
const speed =
totals.movingTimeSeconds > 0
? totals.distanceMeters / totals.movingTimeSeconds
: null;
const pace =
totals.distanceMeters > 0
? totals.movingTimeSeconds / (totals.distanceMeters / 1000)
: null;
return {
speedMetersPerSecond: this.roundNullable(speed, 2),
paceSecondsPerKm: this.roundNullable(pace, 0),
heartRate: this.average(heartRates),
watts: this.average(watts),
cadence: this.average(cadences),
};
}
private createWeeklyBuckets(
rangeStart: Date,
weeks: number,
): Map<string, AnalyticsWeeklyBucket> {
const buckets = new Map<string, AnalyticsWeeklyBucket>();
for (let index = 0; index < weeks; index += 1) {
const weekStart = this.addDays(rangeStart, index * 7);
const weekEnd = this.addDays(weekStart, 6);
buckets.set(this.dateKey(weekStart), {
...this.createTotals(),
weekStart: this.dateKey(weekStart),
weekEnd: this.dateKey(weekEnd),
});
}
return buckets;
}
private toRecentActivity(
activity: StravaActivityEntity,
): AnalyticsRecentActivity {
return {
id: activity.id,
stravaActivityId: activity.stravaActivityId,
name: activity.name,
sportType: activity.sportType,
startDate: activity.startDate ? activity.startDate.toISOString() : null,
distanceMeters: activity.distance,
movingTimeSeconds: activity.movingTime,
elevationGainMeters: activity.totalElevationGain,
averageSpeedMetersPerSecond: activity.averageSpeed,
averageHeartrate: activity.averageHeartrate,
averageWatts: activity.averageWatts,
};
}
private createTotals(): AnalyticsTotals {
return {
activityCount: 0,
distanceMeters: 0,
movingTimeSeconds: 0,
elevationGainMeters: 0,
calories: 0,
};
}
private average(values: number[]): number | null {
if (values.length === 0) {
return null;
}
return Math.round(
values.reduce((total, value) => total + value, 0) / values.length,
);
}
private pushIfNumber(values: number[], value: number | null): void {
if (typeof value === 'number' && Number.isFinite(value)) {
values.push(value);
}
}
private roundNullable(value: number | null, digits: number): number | null {
if (value === null) {
return null;
}
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
private clampWeeks(value: number): number {
if (!Number.isFinite(value)) {
return 12;
}
return Math.min(Math.max(Math.trunc(value), 1), 104);
}
private normalizeSportType(value: string | undefined): string | null {
if (!value || value === 'all') {
return null;
}
return value.trim() || null;
}
private startOfWeek(value: Date | null): Date {
const date = value ? new Date(value) : new Date();
const day = date.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + mondayOffset);
return date;
}
private endOfDay(value: Date): Date {
const date = new Date(value);
date.setHours(23, 59, 59, 999);
return date;
}
private addDays(value: Date, days: number): Date {
const date = new Date(value);
date.setDate(date.getDate() + days);
return date;
}
private dateKey(value: Date): string {
return value.toISOString().slice(0, 10);
}
}

View File

@@ -0,0 +1,51 @@
export interface AnalyticsTotals {
activityCount: number;
distanceMeters: number;
movingTimeSeconds: number;
elevationGainMeters: number;
calories: number;
}
export interface AnalyticsAverages {
speedMetersPerSecond: number | null;
paceSecondsPerKm: number | null;
heartRate: number | null;
watts: number | null;
cadence: number | null;
}
export interface AnalyticsWeeklyBucket extends AnalyticsTotals {
weekStart: string;
weekEnd: string;
}
export interface AnalyticsSportSummary extends AnalyticsTotals {
sportType: string;
}
export interface AnalyticsRecentActivity {
id: string;
stravaActivityId: string;
name: string;
sportType: string | null;
startDate: string | null;
distanceMeters: number | null;
movingTimeSeconds: number | null;
elevationGainMeters: number | null;
averageSpeedMetersPerSecond: number | null;
averageHeartrate: number | null;
averageWatts: number | null;
}
export interface AnalyticsDashboard {
weeks: number;
selectedSportType: string | null;
availableSports: string[];
rangeStart: string;
rangeEnd: string;
totals: AnalyticsTotals;
averages: AnalyticsAverages;
weekly: AnalyticsWeeklyBucket[];
sports: AnalyticsSportSummary[];
recentActivities: AnalyticsRecentActivity[];
}

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return health status', () => {
expect(appController.getHealth()).toEqual({ status: 'ok' });
});
});
});

12
api/src/app.controller.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHealth(): { status: string } {
return this.appService.getHealth();
}
}

26
api/src/app.module.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AnalyticsModule } from './analytics/analytics.module';
import { createTypeOrmOptions } from './database/typeorm.options';
import { StravaModule } from './strava/strava.module';
const featureImports =
process.env.NODE_ENV === 'test'
? []
: [
TypeOrmModule.forRootAsync({
useFactory: () => createTypeOrmOptions(),
}),
StravaModule,
AnalyticsModule,
];
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true }), ...featureImports],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

8
api/src/app.service.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHealth(): { status: string } {
return { status: 'ok' };
}
}

View File

@@ -0,0 +1,6 @@
export { StravaActivityEntity } from './strava-activity.entity';
export { StravaActivityStreamPointEntity } from './strava-activity-stream-point.entity';
export { StravaAthleteEntity } from './strava-athlete.entity';
export { StravaSyncJobEntity } from './strava-sync-job.entity';
export { StravaSyncJobItemEntity } from './strava-sync-job-item.entity';
export { StravaTokenEntity } from './strava-token.entity';

View File

@@ -0,0 +1,68 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { StravaActivityEntity } from './strava-activity.entity';
@Entity({ name: 'strava_activity_stream_points' })
@Index(['activityId', 'pointIndex'], { unique: true })
export class StravaActivityStreamPointEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'activity_id', type: 'varchar', length: 36 })
activityId!: string;
@ManyToOne(() => StravaActivityEntity, (activity) => activity.streamPoints, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'activity_id' })
activity!: StravaActivityEntity;
@Column({ name: 'point_index', type: 'int' })
pointIndex!: number;
@Column({ name: 'time_seconds', type: 'int', nullable: true })
timeSeconds!: number | null;
@Column({ type: 'double', nullable: true })
distance!: number | null;
@Column({ type: 'double', nullable: true })
latitude!: number | null;
@Column({ type: 'double', nullable: true })
longitude!: number | null;
@Column({ type: 'double', nullable: true })
altitude!: number | null;
@Column({ name: 'velocity_smooth', type: 'double', nullable: true })
velocitySmooth!: number | null;
@Column({ name: 'heart_rate', type: 'int', nullable: true })
heartRate!: number | null;
@Column({ type: 'double', nullable: true })
cadence!: number | null;
@Column({ type: 'double', nullable: true })
watts!: number | null;
@Column({ type: 'double', nullable: true })
temperature!: number | null;
@Column({ type: 'boolean', nullable: true })
moving!: boolean | null;
@Column({ name: 'grade_smooth', type: 'double', nullable: true })
gradeSmooth!: number | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}

View File

@@ -0,0 +1,129 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { StravaActivityStreamPointEntity } from './strava-activity-stream-point.entity';
import { StravaAthleteEntity } from './strava-athlete.entity';
@Entity({ name: 'strava_activities' })
@Index(['stravaAthleteId', 'stravaActivityId'], { unique: true })
@Index(['stravaAthleteId', 'startDate'])
export class StravaActivityEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'strava_athlete_id', type: 'varchar', length: 36 })
stravaAthleteId!: string;
@ManyToOne(() => StravaAthleteEntity, (athlete) => athlete.activities, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'strava_athlete_id' })
athlete!: StravaAthleteEntity;
@Column({ name: 'strava_activity_id', type: 'varchar', length: 32 })
stravaActivityId!: string;
@Column({ type: 'varchar', length: 500 })
name!: string;
@Column({ name: 'sport_type', type: 'varchar', length: 100, nullable: true })
sportType!: string | 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: 'total_elevation_gain', type: 'double', nullable: true })
totalElevationGain!: number | null;
@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: 'varchar', length: 255, nullable: true })
timezone!: string | null;
@Column({ name: 'utc_offset', type: 'int', nullable: true })
utcOffset!: 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({ name: 'average_heartrate', type: 'double', nullable: true })
averageHeartrate!: number | null;
@Column({ name: 'max_heartrate', type: 'double', nullable: true })
maxHeartrate!: number | null;
@Column({ name: 'average_watts', type: 'double', nullable: true })
averageWatts!: number | null;
@Column({ name: 'max_watts', type: 'double', nullable: true })
maxWatts!: number | null;
@Column({ name: 'weighted_average_watts', type: 'double', nullable: true })
weightedAverageWatts!: number | null;
@Column({ name: 'average_cadence', type: 'double', nullable: true })
averageCadence!: number | null;
@Column({ type: 'double', nullable: true })
calories!: number | null;
@Column({ name: 'gear_id', type: 'varchar', length: 64, nullable: true })
gearId!: string | null;
@Column({ name: 'is_trainer', default: false })
trainer!: boolean;
@Column({ name: 'is_commute', default: false })
commute!: boolean;
@Column({ name: 'is_manual', default: false })
manual!: boolean;
@Column({ name: 'is_private', default: false })
private!: boolean;
@Column({ name: 'visibility', type: 'varchar', length: 64, nullable: true })
visibility!: string | null;
@Column({ name: 'map_id', type: 'varchar', length: 64, nullable: true })
mapId!: string | null;
@Column({ name: 'summary_polyline', type: 'text', nullable: true })
summaryPolyline!: string | null;
@Column({ name: 'resource_state', type: 'int', nullable: true })
resourceState!: number | null;
@Column({ name: 'raw_payload', type: 'json', nullable: true })
rawPayload!: Record<string, unknown> | null;
@OneToMany(() => StravaActivityStreamPointEntity, (point) => point.activity)
streamPoints!: StravaActivityStreamPointEntity[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,73 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { StravaActivityEntity } from './strava-activity.entity';
import { StravaTokenEntity } from './strava-token.entity';
@Entity({ name: 'strava_athletes' })
@Index(['stravaAthleteId'], { unique: true })
@Index(['accountKey'], { unique: true })
export class StravaAthleteEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({
name: 'account_key',
type: 'varchar',
length: 32,
default: 'primary',
})
accountKey!: string;
@Column({ name: 'strava_athlete_id', type: 'varchar', length: 32 })
stravaAthleteId!: string;
@Column({ type: 'varchar', length: 255, nullable: true })
username!: string | null;
@Column({ name: 'first_name', type: 'varchar', length: 255, nullable: true })
firstName!: string | null;
@Column({ name: 'last_name', type: 'varchar', length: 255, nullable: true })
lastName!: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
city!: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
state!: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
country!: string | null;
@Column({ type: 'varchar', length: 64, nullable: true })
sex!: string | null;
@Column({ name: 'profile_medium', type: 'text', nullable: true })
profileMedium!: string | null;
@Column({ type: 'text', nullable: true })
profile!: string | null;
@Column({ name: 'raw_payload', type: 'json', nullable: true })
rawPayload!: Record<string, unknown> | null;
@OneToOne(() => StravaTokenEntity, (token) => token.athlete)
token!: StravaTokenEntity;
@OneToMany(() => StravaActivityEntity, (activity) => activity.athlete)
activities!: StravaActivityEntity[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,44 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { StravaSyncJobEntity } from './strava-sync-job.entity';
export type StravaSyncJobItemStatus = 'pending' | 'completed' | 'failed';
@Entity({ name: 'strava_sync_job_items' })
@Index(['jobId', 'stravaActivityId'], { unique: true })
export class StravaSyncJobItemEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'job_id', type: 'varchar', length: 36 })
jobId!: string;
@ManyToOne(() => StravaSyncJobEntity, (job) => job.items, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'job_id' })
job!: StravaSyncJobEntity;
@Column({ name: 'strava_activity_id', type: 'varchar', length: 32 })
stravaActivityId!: string;
@Column({ type: 'varchar', length: 32, default: 'pending' })
status!: StravaSyncJobItemStatus;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage!: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,67 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { StravaAthleteEntity } from './strava-athlete.entity';
import { StravaSyncJobItemEntity } from './strava-sync-job-item.entity';
export type StravaSyncJobStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed'
| 'rate_limited';
@Entity({ name: 'strava_sync_jobs' })
@Index(['stravaAthleteId', 'createdAt'])
export class StravaSyncJobEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'strava_athlete_id', type: 'varchar', length: 36 })
stravaAthleteId!: string;
@ManyToOne(() => StravaAthleteEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'strava_athlete_id' })
athlete!: StravaAthleteEntity;
@Column({ type: 'varchar', length: 32, default: 'queued' })
status!: StravaSyncJobStatus;
@Column({ name: 'activity_count', type: 'int', default: 0 })
activityCount!: number;
@Column({ name: 'detail_count', type: 'int', default: 0 })
detailCount!: number;
@Column({ name: 'stream_point_count', type: 'int', default: 0 })
streamPointCount!: number;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage!: string | null;
@Column({ name: 'retry_after', type: 'datetime', nullable: true })
retryAfter!: Date | null;
@Column({ name: 'started_at', type: 'datetime', nullable: true })
startedAt!: Date | null;
@Column({ name: 'finished_at', type: 'datetime', nullable: true })
finishedAt!: Date | null;
@OneToMany(() => StravaSyncJobItemEntity, (item) => item.job)
items!: StravaSyncJobItemEntity[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,53 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { StravaAthleteEntity } from './strava-athlete.entity';
@Entity({ name: 'strava_tokens' })
@Index(['stravaAthleteId'], { unique: true })
export class StravaTokenEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'strava_athlete_id', type: 'varchar', length: 36 })
stravaAthleteId!: string;
@OneToOne(() => StravaAthleteEntity, (athlete) => athlete.token, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'strava_athlete_id' })
athlete!: StravaAthleteEntity;
@Column({ name: 'access_token_ciphertext', type: 'text' })
accessTokenCiphertext!: string;
@Column({ name: 'refresh_token_ciphertext', type: 'text' })
refreshTokenCiphertext!: string;
@Column({ name: 'expires_at', type: 'datetime' })
expiresAt!: Date;
@Column({ name: 'scope', type: 'text', nullable: true })
scope!: string | null;
@Column({
name: 'token_type',
type: 'varchar',
length: 64,
default: 'Bearer',
})
tokenType!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,36 @@
import { DataSourceOptions } from 'typeorm';
import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaAthleteEntity,
StravaSyncJobEntity,
StravaSyncJobItemEntity,
StravaTokenEntity,
} from './entities';
const required = (key: string): string => {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
};
export const createTypeOrmOptions = (): DataSourceOptions => ({
type: 'mysql',
host: required('DATABASE_HOST'),
port: Number(process.env.DATABASE_PORT ?? 3306),
username: required('DATABASE_USER'),
password: required('DATABASE_PASSWORD'),
database: required('DATABASE_NAME'),
entities: [
StravaAthleteEntity,
StravaTokenEntity,
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaSyncJobEntity,
StravaSyncJobItemEntity,
],
synchronize: process.env.TYPEORM_SYNCHRONIZE !== 'false',
logging: process.env.TYPEORM_LOGGING === 'true',
});

11
api/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: process.env.CORS_ORIGIN?.split(',') ?? true,
});
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();

View 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;

View 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'
);
}
}

View 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);
}
}

View 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
);
}
}

View File

@@ -0,0 +1,9 @@
export class StravaRateLimitError extends Error {
constructor(
message: string,
readonly retryAfter: Date | null,
) {
super(message);
this.name = 'StravaRateLimitError';
}
}

View 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,
}),
);
});
});

View 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;
}
}

View 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);
}
}

View 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';
}
}

View 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;
}
}

View 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 {}

View 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;
}

View 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');
}
}

29
api/test/app.e2e-spec.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect({ status: 'ok' });
});
afterEach(async () => {
await app.close();
});
});

9
api/test/jest-e2e.json Normal file
View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

4
api/tsconfig.build.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
api/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}