git init
This commit is contained in:
5
api/.dockerignore
Normal file
5
api/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
.git
|
||||
56
api/.gitignore
vendored
Normal file
56
api/.gitignore
vendored
Normal 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
4
api/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
20
api/Dockerfile
Normal file
20
api/Dockerfile
Normal 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
98
api/README.md
Normal 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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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
35
api/eslint.config.mjs
Normal 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
8
api/nest-cli.json
Normal 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
10591
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
77
api/package.json
Normal file
77
api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
16
api/src/analytics/analytics.controller.ts
Normal file
16
api/src/analytics/analytics.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
api/src/analytics/analytics.module.ts
Normal file
12
api/src/analytics/analytics.module.ts
Normal 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 {}
|
||||
123
api/src/analytics/analytics.service.spec.ts
Normal file
123
api/src/analytics/analytics.service.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
248
api/src/analytics/analytics.service.ts
Normal file
248
api/src/analytics/analytics.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
api/src/analytics/analytics.types.ts
Normal file
51
api/src/analytics/analytics.types.ts
Normal 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[];
|
||||
}
|
||||
22
api/src/app.controller.spec.ts
Normal file
22
api/src/app.controller.spec.ts
Normal 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
12
api/src/app.controller.ts
Normal 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
26
api/src/app.module.ts
Normal 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
8
api/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHealth(): { status: string } {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
6
api/src/database/entities/index.ts
Normal file
6
api/src/database/entities/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
129
api/src/database/entities/strava-activity.entity.ts
Normal file
129
api/src/database/entities/strava-activity.entity.ts
Normal 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;
|
||||
}
|
||||
73
api/src/database/entities/strava-athlete.entity.ts
Normal file
73
api/src/database/entities/strava-athlete.entity.ts
Normal 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;
|
||||
}
|
||||
44
api/src/database/entities/strava-sync-job-item.entity.ts
Normal file
44
api/src/database/entities/strava-sync-job-item.entity.ts
Normal 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;
|
||||
}
|
||||
67
api/src/database/entities/strava-sync-job.entity.ts
Normal file
67
api/src/database/entities/strava-sync-job.entity.ts
Normal 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;
|
||||
}
|
||||
53
api/src/database/entities/strava-token.entity.ts
Normal file
53
api/src/database/entities/strava-token.entity.ts
Normal 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;
|
||||
}
|
||||
36
api/src/database/typeorm.options.ts
Normal file
36
api/src/database/typeorm.options.ts
Normal 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
11
api/src/main.ts
Normal 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();
|
||||
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');
|
||||
}
|
||||
}
|
||||
29
api/test/app.e2e-spec.ts
Normal file
29
api/test/app.e2e-spec.ts
Normal 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
9
api/test/jest-e2e.json
Normal 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
4
api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
25
api/tsconfig.json
Normal file
25
api/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user