From 601a8fa37cfb3d2059e7f36551a6cfc60616e873 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Wed, 8 May 2024 11:49:44 +0200 Subject: [PATCH 01/14] Add fitness schema Signed-off-by: Henry Brink --- backend/prisma/schema.prisma | 8 +++++ .../src/db/repositories/fitness.repository.ts | 31 ++++++++++++++++++ .../integration/fitness/fitness.activity.ts | 10 ++++++ .../src/integration/fitness/fitness.data.ts | 12 +++++++ .../src/integration/fitness/fitness.goal.ts | 8 +++++ .../integration/fitness/fitness.provider.ts | 5 +++ .../integration/fitness/fitness.service.ts | 32 +++++++++++++++++++ 7 files changed, 106 insertions(+) create mode 100644 backend/src/db/repositories/fitness.repository.ts create mode 100644 backend/src/integration/fitness/fitness.activity.ts create mode 100644 backend/src/integration/fitness/fitness.data.ts create mode 100644 backend/src/integration/fitness/fitness.goal.ts create mode 100644 backend/src/integration/fitness/fitness.provider.ts create mode 100644 backend/src/integration/fitness/fitness.service.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b25911a..0c75b43 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -18,3 +18,11 @@ model User { enabled Boolean @default(true) verified Boolean @default(false) } + +model FitnessProvider { + id String @id @default(uuid()) + type String + refreshToken String + owner String + enabled Boolean +} diff --git a/backend/src/db/repositories/fitness.repository.ts b/backend/src/db/repositories/fitness.repository.ts new file mode 100644 index 0000000..f9a058a --- /dev/null +++ b/backend/src/db/repositories/fitness.repository.ts @@ -0,0 +1,31 @@ +import { FitnessProvider, Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma.service'; + +export class FitnessRepository { + constructor(private prisma: PrismaService) {} + + public async getProviderForUser( + userId: string, + ): Promise { + return await this.prisma.fitnessProvider.findFirst({ + where: { + owner: userId, + }, + }); + } + + public async createProvider(provider: Prisma.FitnessProviderCreateArgs) { + return await this.prisma.fitnessProvider.create(provider); + } + + public async deleteProvider(provider: Prisma.FitnessProviderDeleteArgs) { + return await this.prisma.fitnessProvider.delete(provider); + } + + public async updateProvider(provider: Prisma.FitnessProviderUpdateInput) { + return await this.prisma.fitnessProvider.update({ + where: { id: provider.id as string }, + data: provider, + }); + } +} diff --git a/backend/src/integration/fitness/fitness.activity.ts b/backend/src/integration/fitness/fitness.activity.ts new file mode 100644 index 0000000..383439f --- /dev/null +++ b/backend/src/integration/fitness/fitness.activity.ts @@ -0,0 +1,10 @@ +export class FitnessActivity { + constructor( + public type: 'walk' | 'run', + public start: Date, + public end: Date, + public steps: number, + public elevation: number, + public reporting: 'tracked' | 'manual' | 'other', + ) {} +} diff --git a/backend/src/integration/fitness/fitness.data.ts b/backend/src/integration/fitness/fitness.data.ts new file mode 100644 index 0000000..38d45d4 --- /dev/null +++ b/backend/src/integration/fitness/fitness.data.ts @@ -0,0 +1,12 @@ +import { FitnessActivity } from './fitness.activity'; +import { FitnessGoal } from './fitness.goal'; + +export class FitnessData { + constructor( + public dataSource: 'fitbit' | 'manual' | 'other', + public syncDate: Date, + public owner: string, + public activities: FitnessActivity[], + public goals: FitnessGoal[], + ) {} +} diff --git a/backend/src/integration/fitness/fitness.goal.ts b/backend/src/integration/fitness/fitness.goal.ts new file mode 100644 index 0000000..ca03d18 --- /dev/null +++ b/backend/src/integration/fitness/fitness.goal.ts @@ -0,0 +1,8 @@ +export class FitnessGoal { + constructor( + public type: 'steps' | 'distance', + public goal: number, + public value: number, + public unit: number, + ) {} +} diff --git a/backend/src/integration/fitness/fitness.provider.ts b/backend/src/integration/fitness/fitness.provider.ts new file mode 100644 index 0000000..54ba660 --- /dev/null +++ b/backend/src/integration/fitness/fitness.provider.ts @@ -0,0 +1,5 @@ +import { FitnessData } from './fitness.data'; + +export interface FitnessProvider { + getFitnessData(start: Date, end: Date): Promise; +} diff --git a/backend/src/integration/fitness/fitness.service.ts b/backend/src/integration/fitness/fitness.service.ts new file mode 100644 index 0000000..21834c9 --- /dev/null +++ b/backend/src/integration/fitness/fitness.service.ts @@ -0,0 +1,32 @@ +import { User } from '@prisma/client'; +import { UserRepository } from '../../db/repositories/user.repository'; +import { FitnessProvider } from './fitness.provider'; +import { FitnessRepository } from '../../db/repositories/fitness.repository'; + +export class FitnessService { + constructor(private fitnessRepository: FitnessRepository) {} + + /** + * Returns the configured fitness service + * for the user + * + * @param userId UUID + */ + public async getServiceForUser( + userId: string, + ): Promise { + const providerData = + await this.fitnessRepository.getProviderForUser(userId); + + // Try to create an instance for the user + if (!providerData) { + return null; + } + + return null; + } + + // public async syncUser(userId: string): Promise {} + + public async syncAll(): Promise {} +} From 1569e556e01e517c11090b952bc5f0e1b6d57e3b Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Tue, 14 May 2024 19:56:34 +0200 Subject: [PATCH 02/14] added providers Signed-off-by: Henry Brink --- backend/prisma/schema.prisma | 22 ++++---- .../src/integration/fitness/fitness.module.ts | 4 ++ .../fitness/providers/fitbit.provider.ts | 52 +++++++++++++++++++ 3 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 backend/src/integration/fitness/fitness.module.ts create mode 100644 backend/src/integration/fitness/providers/fitbit.provider.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0c75b43..0f08260 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -11,18 +11,22 @@ datasource db { } model User { - id String @id @default(uuid()) + id String @id @default(uuid()) displayName String - email String @unique + email String @unique password String - enabled Boolean @default(true) - verified Boolean @default(false) + enabled Boolean @default(true) + verified Boolean @default(false) + providers FitnessProvider[] } model FitnessProvider { - id String @id @default(uuid()) - type String - refreshToken String - owner String - enabled Boolean + id String @id @default(uuid()) + type String + refreshToken String + accessToken String + accessTokenExpires DateTime + owner User @relation(fields: [userId], references: [id]) + userId String + enabled Boolean } diff --git a/backend/src/integration/fitness/fitness.module.ts b/backend/src/integration/fitness/fitness.module.ts new file mode 100644 index 0000000..792f002 --- /dev/null +++ b/backend/src/integration/fitness/fitness.module.ts @@ -0,0 +1,4 @@ +import { Module } from '@nestjs/common'; + +@Module({}) +export default class FitnessModule {} diff --git a/backend/src/integration/fitness/providers/fitbit.provider.ts b/backend/src/integration/fitness/providers/fitbit.provider.ts new file mode 100644 index 0000000..fc0b819 --- /dev/null +++ b/backend/src/integration/fitness/providers/fitbit.provider.ts @@ -0,0 +1,52 @@ +import { FitnessRepository } from '../../../db/repositories/fitness.repository'; +import { FitnessData } from '../fitness.data'; + +export class FitBitProvider { + + private const FITBIT_API = 'https://www.fitbit.com/'; + + constructor( + private fitnessRepository: FitnessRepository + ) {} + + private async getAccessToken(user: string): Promise { + const credentials = await this.fitnessRepository.getProviderForUser(user); + + if (!credentials) + throw new Error('No credentials found for user') + + // Construct the URL + const authorizeURL = new URL(`${this.FITBIT_API}/oauth2/token`) + authorizeURL.searchParams.append('client_id', process.env.FITBIT_CLIENT_ID); + authorizeURL.searchParams.append('refresh_token', credentials?.refreshToken); + authorizeURL.searchParams.append('grant_type', 'authorization_code'); + + // Retrieve the access token from the server + const response = await fetch(authorizeURL.toString(), { + headers: { + 'Authorization': btoa(`${process.env.FITBIT_CLIENT_ID}:${process.env.FITBIT_CLIENT_SECRET}`) + } + }); + + if (!response.ok) + throw new Error('No access token received from server') + + const { access_token, refresh_token, expires_in } = await response.json(); + + // Persist the access token in the background, to make the request faster + setTimeout(async () => { + // Persist the access token & refresh token + // const provider = this.fitnessRepository.update + }); + } + + public getUserActivities(user: string): Promise { + const credentials = await this.getAccessToken(user); + + // Retrieve fitness goals + const goalResponse = + + throw Error("Invalid"); + } + +} \ No newline at end of file From 69f1c5e24c7382ab23e6178696d384ef58ab75f4 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Sat, 18 May 2024 20:18:19 +0200 Subject: [PATCH 03/14] Zwischenstand Signed-off-by: Henry Brink --- backend/package-lock.json | 50 ++++- backend/package.json | 1 + .../20240402121953_init/migration.sql | 13 -- .../migrations/20240518081406_/migration.sql | 30 +++ backend/prisma/schema.prisma | 6 +- .../api/datasource/datasource.controller.ts | 19 ++ backend/src/app.module.ts | 13 +- backend/src/config/configuration.ts | 8 + backend/src/db/prisma.module.ts | 5 +- .../src/db/repositories/fitness.repository.ts | 10 +- .../integration/fitness/fitness.provider.ts | 2 +- .../integration/fitness/fitness.service.ts | 3 +- .../fitness/providers/fitbit.provider.ts | 181 ++++++++++++++++-- 13 files changed, 292 insertions(+), 49 deletions(-) delete mode 100644 backend/prisma/migrations/20240402121953_init/migration.sql create mode 100644 backend/prisma/migrations/20240518081406_/migration.sql create mode 100644 backend/src/api/datasource/datasource.controller.ts create mode 100644 backend/src/config/configuration.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 17cd9f6..d1352ef 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.0.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.7", @@ -1863,6 +1864,21 @@ } } }, + "node_modules/@nestjs/config": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.2.2.tgz", + "integrity": "sha512-vGICPOui5vE6kPz1iwQ7oCnp3qWgqxldPmBQ9onkVoKlBtyc83KJCr7CjuVtf4OdovMAVcux1d8Q6jglU2ZphA==", + "dependencies": { + "dotenv": "16.4.5", + "dotenv-expand": "10.0.0", + "lodash": "4.17.21", + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/core": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.5.tgz", @@ -4079,6 +4095,25 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "engines": { + "node": ">=12" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -6523,8 +6558,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -8969,6 +9003,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 6759cf2..744d706 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.0.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.7", diff --git a/backend/prisma/migrations/20240402121953_init/migration.sql b/backend/prisma/migrations/20240402121953_init/migration.sql deleted file mode 100644 index 72dc774..0000000 --- a/backend/prisma/migrations/20240402121953_init/migration.sql +++ /dev/null @@ -1,13 +0,0 @@ --- CreateTable -CREATE TABLE - "User" ( - "id" TEXT NOT NULL PRIMARY KEY, - "displayName" TEXT NOT NULL, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL, - "enabled" BOOLEAN NOT NULL DEFAULT true, - "verified" BOOLEAN NOT NULL DEFAULT false - ); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User" ("email"); \ No newline at end of file diff --git a/backend/prisma/migrations/20240518081406_/migration.sql b/backend/prisma/migrations/20240518081406_/migration.sql new file mode 100644 index 0000000..159bad8 --- /dev/null +++ b/backend/prisma/migrations/20240518081406_/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "displayName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "verified" BOOLEAN NOT NULL DEFAULT false +); + +-- CreateTable +CREATE TABLE "FitnessProvider" ( + "type" TEXT NOT NULL, + "refreshToken" TEXT NOT NULL, + "accessToken" TEXT NOT NULL, + "accessTokenExpires" DATETIME NOT NULL, + "userId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL, + "providerUserId" TEXT NOT NULL, + CONSTRAINT "FitnessProvider_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "FitnessProvider_type_key" ON "FitnessProvider"("type"); + +-- CreateIndex +CREATE UNIQUE INDEX "FitnessProvider_userId_key" ON "FitnessProvider"("userId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0f08260..852012a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -21,12 +21,12 @@ model User { } model FitnessProvider { - id String @id @default(uuid()) - type String + type String @unique refreshToken String accessToken String accessTokenExpires DateTime owner User @relation(fields: [userId], references: [id]) - userId String + userId String @unique enabled Boolean + providerUserId String } diff --git a/backend/src/api/datasource/datasource.controller.ts b/backend/src/api/datasource/datasource.controller.ts new file mode 100644 index 0000000..2e3bd4f --- /dev/null +++ b/backend/src/api/datasource/datasource.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { FitnessRepository } from '../../db/repositories/fitness.repository'; +import { NestRequest } from '../../types/request.type'; +import { AutoGuard } from '../../auth/auto.guard'; + +@Controller('datasource') +export class DatasourceController { + constructor(private fitnessRepository: FitnessRepository) {} + + @Get() + @UseGuards(AutoGuard) + public getDatasourcesForUser(@Req() request: NestRequest) { + const datasourcesForUser = this.fitnessRepository.getProviderForUser( + request.user.id, + ); + + return datasourcesForUser; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 367053f..9367c89 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,10 +5,19 @@ import { AuthModule } from './auth/auth.module'; import { UserController } from './api/user/user.controller'; import { PrismaModule } from './db/prisma.module'; import { LOGGER_SERVICE } from './logger/logger.service'; +import { ConfigModule } from '@nestjs/config'; +import configuration from './config/configuration'; +import { DatasourceController } from './api/datasource/datasource.controller'; @Module({ - imports: [AuthModule, PrismaModule], - controllers: [AppController, UserController], + imports: [ + AuthModule, + PrismaModule, + ConfigModule.forRoot({ + load: [configuration], + }), + ], + controllers: [AppController, UserController, DatasourceController], providers: [ AppService, { diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts new file mode 100644 index 0000000..a00edc8 --- /dev/null +++ b/backend/src/config/configuration.ts @@ -0,0 +1,8 @@ +export default () => ({ + providers: { + fitbit: { + client_id: process.env.FITBIT_CLIENT_ID, + client_secret: process.env.FITBIT_CLIENT_SECRET, + }, + }, +}); diff --git a/backend/src/db/prisma.module.ts b/backend/src/db/prisma.module.ts index 72d1cd8..cab7aaa 100644 --- a/backend/src/db/prisma.module.ts +++ b/backend/src/db/prisma.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { UserRepository } from './repositories/user.repository'; import { PrismaService } from './prisma.service'; +import { FitnessRepository } from './repositories/fitness.repository'; @Module({ - providers: [PrismaService, UserRepository], - exports: [PrismaService, UserRepository], + providers: [PrismaService, UserRepository, FitnessRepository], + exports: [PrismaService, UserRepository, FitnessRepository], }) export class PrismaModule {} diff --git a/backend/src/db/repositories/fitness.repository.ts b/backend/src/db/repositories/fitness.repository.ts index f9a058a..d43015a 100644 --- a/backend/src/db/repositories/fitness.repository.ts +++ b/backend/src/db/repositories/fitness.repository.ts @@ -9,7 +9,7 @@ export class FitnessRepository { ): Promise { return await this.prisma.fitnessProvider.findFirst({ where: { - owner: userId, + userId, }, }); } @@ -22,9 +22,13 @@ export class FitnessRepository { return await this.prisma.fitnessProvider.delete(provider); } - public async updateProvider(provider: Prisma.FitnessProviderUpdateInput) { + public async updateProvider( + type: string, + user: string, + provider: Prisma.FitnessProviderUpdateInput, + ) { return await this.prisma.fitnessProvider.update({ - where: { id: provider.id as string }, + where: { type, userId: user }, data: provider, }); } diff --git a/backend/src/integration/fitness/fitness.provider.ts b/backend/src/integration/fitness/fitness.provider.ts index 54ba660..dd1946c 100644 --- a/backend/src/integration/fitness/fitness.provider.ts +++ b/backend/src/integration/fitness/fitness.provider.ts @@ -1,5 +1,5 @@ import { FitnessData } from './fitness.data'; export interface FitnessProvider { - getFitnessData(start: Date, end: Date): Promise; + getFitnessData(user: string, start: Date, end: Date): Promise; } diff --git a/backend/src/integration/fitness/fitness.service.ts b/backend/src/integration/fitness/fitness.service.ts index 21834c9..6c885f4 100644 --- a/backend/src/integration/fitness/fitness.service.ts +++ b/backend/src/integration/fitness/fitness.service.ts @@ -1,7 +1,6 @@ -import { User } from '@prisma/client'; -import { UserRepository } from '../../db/repositories/user.repository'; import { FitnessProvider } from './fitness.provider'; import { FitnessRepository } from '../../db/repositories/fitness.repository'; +import { FitBitProvider } from './providers/fitbit.provider'; export class FitnessService { constructor(private fitnessRepository: FitnessRepository) {} diff --git a/backend/src/integration/fitness/providers/fitbit.provider.ts b/backend/src/integration/fitness/providers/fitbit.provider.ts index fc0b819..4e6a1b9 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.ts @@ -1,52 +1,191 @@ +import { ConfigService } from '@nestjs/config'; import { FitnessRepository } from '../../../db/repositories/fitness.repository'; +import { FitnessProvider } from '../fitness.provider'; import { FitnessData } from '../fitness.data'; +import { FitnessGoal } from '../fitness.goal'; -export class FitBitProvider { +type FitbitCredentials = { + accessToken: string; + userId: string; +}; - private const FITBIT_API = 'https://www.fitbit.com/'; +export class FitBitProvider implements FitnessProvider { + private FITBIT_API = 'https://api.fitbit.com'; + private FITBIT_API_AUTH = 'https://www.fitbit.com'; + private FITBIT_TYPE = 'fitbit'; constructor( - private fitnessRepository: FitnessRepository + private fitnessRepository: FitnessRepository, + private configService: ConfigService, ) {} - private async getAccessToken(user: string): Promise { + /** + * Exchanges a code recieved after an authorized to an access token + * and persists the client + * + * @param user internal id of the user + * @param code received authorization code + * @returns api credentials + */ + private async getAccessTokenFromCode( + user: string, + code: string, + ): Promise { + const client_id = this.configService.get( + 'providers.fitbit.client_id', + ); + const client_secret = this.configService.get( + 'providers.fitbit.client_secret', + ); + + const authorizeURL = new URL(`${this.FITBIT_API_AUTH}/oauth2/token`); + authorizeURL.searchParams.append( + 'client_id', + this.configService.get('providers.fitbit.client_id')!, + ); + authorizeURL.searchParams.append('code', code); + authorizeURL.searchParams.append('grant_type', 'authorization_code'); + + // Retrieve the access token from the server + const response = await fetch(authorizeURL.toString(), { + headers: { + Authorization: btoa(`${client_id}:${client_secret}`), + }, + }); + + if (!response.ok) throw new Error('Invalid response from FitBit'); + + const { access_token, refresh_token, expires_in, user_id } = + await response.json(); + + await this.fitnessRepository.createProvider({ + data: { + type: 'fitbit', + userId: user, + providerUserId: user_id, + accessToken: access_token, + accessTokenExpires: expires_in, + refreshToken: refresh_token, + enabled: true, + }, + }); + + return { accessToken: access_token, userId: user_id }; + } + + /** + * Retrieves a new access token for a configured datasource. + * The datasource needs to be configured otherwise this will fail. + * + * @param user internal id of the user + * @returns api credentials + */ + private async getAccessToken(user: string): Promise { const credentials = await this.fitnessRepository.getProviderForUser(user); - if (!credentials) - throw new Error('No credentials found for user') + if (!credentials) throw new Error('No credentials found for user'); + + const client_id = this.configService.get( + 'providers.fitbit.client_id', + ); + const client_secret = this.configService.get( + 'providers.fitbit.client_secret', + ); // Construct the URL - const authorizeURL = new URL(`${this.FITBIT_API}/oauth2/token`) - authorizeURL.searchParams.append('client_id', process.env.FITBIT_CLIENT_ID); - authorizeURL.searchParams.append('refresh_token', credentials?.refreshToken); + const authorizeURL = new URL(`${this.FITBIT_API_AUTH}/oauth2/token`); + authorizeURL.searchParams.append( + 'client_id', + this.configService.get('providers.fitbit.client_id')!, + ); + authorizeURL.searchParams.append( + 'refresh_token', + credentials?.refreshToken, + ); authorizeURL.searchParams.append('grant_type', 'authorization_code'); // Retrieve the access token from the server const response = await fetch(authorizeURL.toString(), { headers: { - 'Authorization': btoa(`${process.env.FITBIT_CLIENT_ID}:${process.env.FITBIT_CLIENT_SECRET}`) - } + Authorization: btoa(`${client_id}:${client_secret}`), + }, }); - if (!response.ok) - throw new Error('No access token received from server') + if (!response.ok) throw new Error('No access token received from server'); const { access_token, refresh_token, expires_in } = await response.json(); // Persist the access token in the background, to make the request faster setTimeout(async () => { - // Persist the access token & refresh token - // const provider = this.fitnessRepository.update + // Update provider + await this.fitnessRepository.updateProvider(this.FITBIT_TYPE, user, { + accessToken: access_token, + refreshToken: refresh_token, + accessTokenExpires: expires_in, + }); }); + + return { accessToken: access_token, userId: credentials.providerUserId }; } - public getUserActivities(user: string): Promise { - const credentials = await this.getAccessToken(user); + /** + * Daily goals from FitBit + * Currently only supports steps + * + * @param credentials api credentials + * @returns fitness goals + */ + private async getDailyGoals( + credentials: FitbitCredentials, + ): Promise { + const response = await fetch( + `${this.FITBIT_API}/1/user/${credentials.userId}/activities/goals/daily.json`, + { + headers: { + Authentication: `Bearer ${credentials.userId}`, + }, + }, + ); - // Retrieve fitness goals - const goalResponse = + if (!response.ok) throw new Error('Invalid response received from FitBit'); - throw Error("Invalid"); + const json = await response.json(); + + const goals: FitnessGoal[] = [ + { + type: 'steps', + goal: json['goal']['steps'], + value: 0, + unit: 0, + }, + ]; + + return goals; } -} \ No newline at end of file + /** + * Synchronize fitness data for a given timerange + * + * @param user internal user id + * @param start start date + * @param end end date + * @returns Fitness Data + */ + async getFitnessData( + user: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + start: Date, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + end: Date, + ): Promise { + const credentials = await this.getAccessToken(user); + + return { + dataSource: 'fitbit', + syncDate: new Date(), + owner: user, + activities: [], + goals: await this.getDailyGoals(credentials), + }; + } +} From f1cfa8f4ed563f35324f5a15adf305a3411849a6 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Sun, 19 May 2024 10:27:50 +0200 Subject: [PATCH 04/14] Save --- .../api/datasource/datasource.controller.ts | 71 +++++++++++++++++-- .../src/db/repositories/fitness.repository.ts | 20 +++++- .../integration/fitness/fitness.provider.ts | 5 -- .../integration/fitness/fitness.service.ts | 26 ++----- .../fitness/providers/fitbit.provider.ts | 12 +++- 5 files changed, 99 insertions(+), 35 deletions(-) delete mode 100644 backend/src/integration/fitness/fitness.provider.ts diff --git a/backend/src/api/datasource/datasource.controller.ts b/backend/src/api/datasource/datasource.controller.ts index 2e3bd4f..b8dd5fa 100644 --- a/backend/src/api/datasource/datasource.controller.ts +++ b/backend/src/api/datasource/datasource.controller.ts @@ -1,19 +1,82 @@ -import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { + Controller, + Delete, + Get, + Param, + Req, + Res, + UseGuards, +} from '@nestjs/common'; import { FitnessRepository } from '../../db/repositories/fitness.repository'; import { NestRequest } from '../../types/request.type'; import { AutoGuard } from '../../auth/auto.guard'; +import { Response } from 'express'; +import { FitnessService } from 'src/integration/fitness/fitness.service'; @Controller('datasource') export class DatasourceController { - constructor(private fitnessRepository: FitnessRepository) {} + constructor( + private fitnessRepository: FitnessRepository, + private fitnessService: FitnessService, + ) {} @Get() @UseGuards(AutoGuard) - public getDatasourcesForUser(@Req() request: NestRequest) { - const datasourcesForUser = this.fitnessRepository.getProviderForUser( + public async getDatasourcesForUser(@Req() request: NestRequest) { + const datasourcesForUser = await this.fitnessRepository.getProvidersForUser( request.user.id, ); return datasourcesForUser; } + + @Get('/:id') + @UseGuards(AutoGuard) + public async getDatasourceForUser( + @Req() request: NestRequest, + @Param() params: any, + ) { + const datasourceForUser = + await this.fitnessRepository.getProviderForUserById( + request.user.id, + params.id, + ); + + return datasourceForUser; + } + + @Delete('/:id') + @UseGuards(AutoGuard) + public async deleteDatasource( + @Req() request: NestRequest, + @Param() params: any, + @Res() res: Response, + ) { + // Verify if the datasource exists + const datasource = await this.fitnessRepository.getProviderForUserById( + request.user.id, + params.id, + ); + + if (!datasource) { + res.status(400).json({ + error: 'Datasource was not enabled', + }); + } + + await this.fitnessRepository.deleteProvider({ + where: { userId: request.user.id, type: params.id }, + }); + + res.status(204); + } + + @Get('/:id/authorize') + @UseGuards(AutoGuard) + public async getAuthorizeURL( + @Req() request: NestRequest, + @Param() params: any, + ) { + return this.fitnessService.getFitbitProvider().getAuthorizeURL(); + } } diff --git a/backend/src/db/repositories/fitness.repository.ts b/backend/src/db/repositories/fitness.repository.ts index d43015a..d0b3bfb 100644 --- a/backend/src/db/repositories/fitness.repository.ts +++ b/backend/src/db/repositories/fitness.repository.ts @@ -1,19 +1,33 @@ import { FitnessProvider, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma.service'; +import { Injectable } from '@nestjs/common'; +@Injectable() export class FitnessRepository { constructor(private prisma: PrismaService) {} - public async getProviderForUser( + public async getProvidersForUser( userId: string, - ): Promise { - return await this.prisma.fitnessProvider.findFirst({ + ): Promise { + return await this.prisma.fitnessProvider.findMany({ where: { userId, }, }); } + public async getProviderForUserById( + userId: string, + provider: string + ) { + return await this.prisma.fitnessProvider.findFirst({ + where: { + userId, + type: provider + } + }); + } + public async createProvider(provider: Prisma.FitnessProviderCreateArgs) { return await this.prisma.fitnessProvider.create(provider); } diff --git a/backend/src/integration/fitness/fitness.provider.ts b/backend/src/integration/fitness/fitness.provider.ts deleted file mode 100644 index dd1946c..0000000 --- a/backend/src/integration/fitness/fitness.provider.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { FitnessData } from './fitness.data'; - -export interface FitnessProvider { - getFitnessData(user: string, start: Date, end: Date): Promise; -} diff --git a/backend/src/integration/fitness/fitness.service.ts b/backend/src/integration/fitness/fitness.service.ts index 6c885f4..ff586b5 100644 --- a/backend/src/integration/fitness/fitness.service.ts +++ b/backend/src/integration/fitness/fitness.service.ts @@ -1,28 +1,14 @@ -import { FitnessProvider } from './fitness.provider'; import { FitnessRepository } from '../../db/repositories/fitness.repository'; import { FitBitProvider } from './providers/fitbit.provider'; export class FitnessService { - constructor(private fitnessRepository: FitnessRepository) {} + constructor( + private fitnessRepository: FitnessRepository, + private fitbitProvider: FitBitProvider, + ) {} - /** - * Returns the configured fitness service - * for the user - * - * @param userId UUID - */ - public async getServiceForUser( - userId: string, - ): Promise { - const providerData = - await this.fitnessRepository.getProviderForUser(userId); - - // Try to create an instance for the user - if (!providerData) { - return null; - } - - return null; + public getFitbitProvider(): FitBitProvider { + return this.fitbitProvider; } // public async syncUser(userId: string): Promise {} diff --git a/backend/src/integration/fitness/providers/fitbit.provider.ts b/backend/src/integration/fitness/providers/fitbit.provider.ts index 4e6a1b9..32a25c3 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.ts @@ -1,6 +1,5 @@ import { ConfigService } from '@nestjs/config'; import { FitnessRepository } from '../../../db/repositories/fitness.repository'; -import { FitnessProvider } from '../fitness.provider'; import { FitnessData } from '../fitness.data'; import { FitnessGoal } from '../fitness.goal'; @@ -9,7 +8,7 @@ type FitbitCredentials = { userId: string; }; -export class FitBitProvider implements FitnessProvider { +export class FitBitProvider { private FITBIT_API = 'https://api.fitbit.com'; private FITBIT_API_AUTH = 'https://www.fitbit.com'; private FITBIT_TYPE = 'fitbit'; @@ -81,7 +80,7 @@ export class FitBitProvider implements FitnessProvider { * @returns api credentials */ private async getAccessToken(user: string): Promise { - const credentials = await this.fitnessRepository.getProviderForUser(user); + const credentials = await this.fitnessRepository.getProviderForUserById(user, 'fitbit'); if (!credentials) throw new Error('No credentials found for user'); @@ -188,4 +187,11 @@ export class FitBitProvider implements FitnessProvider { goals: await this.getDailyGoals(credentials), }; } + + public getAuthorizeURL(): string { + const client_id = this.configService.get('providers.fitbit.client_id')!; + + return `https://www.fitbit.com/oauth2/authorize?client_id=${client_id}&response_type=code&scope=activity` + } + } From 463aa909eaff8a1e7d1b7808584c6062e60755ef Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Sun, 19 May 2024 22:46:48 +0200 Subject: [PATCH 05/14] Working Authentication --- backend/package-lock.json | 7 ++ backend/package.json | 1 + .../migration.sql | 8 +- backend/prisma/schema.prisma | 12 +- .../api/datasource/datasource.controller.ts | 59 ++++++++- backend/src/app.module.ts | 4 +- .../src/db/repositories/fitness.repository.ts | 35 +++--- .../src/integration/fitness/fitness.module.ts | 17 ++- .../integration/fitness/fitness.service.ts | 78 +++++++++++- .../fitness/providers/fitbit.provider.ts | 113 +++++++++++------- .../fitness/providers/provider.interface.ts | 20 ++++ 11 files changed, 272 insertions(+), 82 deletions(-) rename backend/prisma/migrations/{20240518081406_ => 20240519124445_}/migration.sql (61%) create mode 100644 backend/src/integration/fitness/providers/provider.interface.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index d1352ef..35e72d0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "dayjs": "^1.11.11", "passport": "^0.7.0", "passport-http": "^0.3.0", "reflect-metadata": "^0.2.0", @@ -3922,6 +3923,12 @@ "node": ">= 8" } }, + "node_modules/dayjs": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", + "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/backend/package.json b/backend/package.json index 744d706..6dda82f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,6 +35,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "dayjs": "^1.11.11", "passport": "^0.7.0", "passport-http": "^0.3.0", "reflect-metadata": "^0.2.0", diff --git a/backend/prisma/migrations/20240518081406_/migration.sql b/backend/prisma/migrations/20240519124445_/migration.sql similarity index 61% rename from backend/prisma/migrations/20240518081406_/migration.sql rename to backend/prisma/migrations/20240519124445_/migration.sql index 159bad8..a8aec2c 100644 --- a/backend/prisma/migrations/20240518081406_/migration.sql +++ b/backend/prisma/migrations/20240519124445_/migration.sql @@ -9,7 +9,7 @@ CREATE TABLE "User" ( ); -- CreateTable -CREATE TABLE "FitnessProvider" ( +CREATE TABLE "FitnessProviderCredential" ( "type" TEXT NOT NULL, "refreshToken" TEXT NOT NULL, "accessToken" TEXT NOT NULL, @@ -17,14 +17,14 @@ CREATE TABLE "FitnessProvider" ( "userId" TEXT NOT NULL, "enabled" BOOLEAN NOT NULL, "providerUserId" TEXT NOT NULL, - CONSTRAINT "FitnessProvider_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE + CONSTRAINT "FitnessProviderCredential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE ); -- CreateIndex CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); -- CreateIndex -CREATE UNIQUE INDEX "FitnessProvider_type_key" ON "FitnessProvider"("type"); +CREATE UNIQUE INDEX "FitnessProviderCredential_type_key" ON "FitnessProviderCredential"("type"); -- CreateIndex -CREATE UNIQUE INDEX "FitnessProvider_userId_key" ON "FitnessProvider"("userId"); +CREATE UNIQUE INDEX "FitnessProviderCredential_userId_key" ON "FitnessProviderCredential"("userId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 852012a..e990259 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -11,16 +11,16 @@ datasource db { } model User { - id String @id @default(uuid()) + id String @id @default(uuid()) displayName String - email String @unique + email String @unique password String - enabled Boolean @default(true) - verified Boolean @default(false) - providers FitnessProvider[] + enabled Boolean @default(true) + verified Boolean @default(false) + providers FitnessProviderCredential[] } -model FitnessProvider { +model FitnessProviderCredential { type String @unique refreshToken String accessToken String diff --git a/backend/src/api/datasource/datasource.controller.ts b/backend/src/api/datasource/datasource.controller.ts index b8dd5fa..f1cb05b 100644 --- a/backend/src/api/datasource/datasource.controller.ts +++ b/backend/src/api/datasource/datasource.controller.ts @@ -23,11 +23,11 @@ export class DatasourceController { @Get() @UseGuards(AutoGuard) public async getDatasourcesForUser(@Req() request: NestRequest) { - const datasourcesForUser = await this.fitnessRepository.getProvidersForUser( + const datasourcesForUser = await this.fitnessService.getDatasourcesForUser( request.user.id, ); - return datasourcesForUser; + return datasourcesForUser.map((provider) => provider.getInfo()); } @Get('/:id') @@ -62,6 +62,7 @@ export class DatasourceController { res.status(400).json({ error: 'Datasource was not enabled', }); + return; } await this.fitnessRepository.deleteProvider({ @@ -76,7 +77,59 @@ export class DatasourceController { public async getAuthorizeURL( @Req() request: NestRequest, @Param() params: any, + @Res() response: Response, ) { - return this.fitnessService.getFitbitProvider().getAuthorizeURL(); + const providers = await this.fitnessService.getDatasourcesForUser( + request.user.id, + ); + + const responsibleProvider = providers.find( + (provider) => provider.getInfo().name == params.id, + ); + + if (!responsibleProvider) { + response.status(400).json({ + error: 'Provider not available', + }); + return; + } + + response.status(200).json({ + url: responsibleProvider.getAuthorizeURL(), + }); + } + + @Get('/:id/redirect') + @UseGuards(AutoGuard) + public async redirect( + @Req() request: NestRequest, + @Param('id') id: string, + @Res() response: Response, + ) { + const responsibleProvider = + await this.fitnessService.getProviderForUserById(request.user.id, id); + + if (!responsibleProvider) { + response.status(400).json({ + error: 'Provider not available', + }); + return; + } + + const code = (request.query.code as string).split('#_=_')[0]; + + const result = await responsibleProvider.getAccessTokenFromCode( + request.user.id, + code, + ); + + if (!result) { + response.status(400).json({ + error: 'Token was not accepted', + }); + return; + } + + response.status(200).json(responsibleProvider.getInfo()); } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9367c89..a1dba3f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { LOGGER_SERVICE } from './logger/logger.service'; import { ConfigModule } from '@nestjs/config'; import configuration from './config/configuration'; import { DatasourceController } from './api/datasource/datasource.controller'; +import FitnessModule from './integration/fitness/fitness.module'; @Module({ imports: [ @@ -16,8 +17,9 @@ import { DatasourceController } from './api/datasource/datasource.controller'; ConfigModule.forRoot({ load: [configuration], }), + FitnessModule, ], - controllers: [AppController, UserController, DatasourceController], + controllers: [AppController, UserController], providers: [ AppService, { diff --git a/backend/src/db/repositories/fitness.repository.ts b/backend/src/db/repositories/fitness.repository.ts index d0b3bfb..517969f 100644 --- a/backend/src/db/repositories/fitness.repository.ts +++ b/backend/src/db/repositories/fitness.repository.ts @@ -1,4 +1,4 @@ -import { FitnessProvider, Prisma } from '@prisma/client'; +import { FitnessProviderCredential, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma.service'; import { Injectable } from '@nestjs/common'; @@ -8,40 +8,43 @@ export class FitnessRepository { public async getProvidersForUser( userId: string, - ): Promise { - return await this.prisma.fitnessProvider.findMany({ + ): Promise { + return await this.prisma.fitnessProviderCredential.findMany({ where: { userId, }, }); } - public async getProviderForUserById( - userId: string, - provider: string - ) { - return await this.prisma.fitnessProvider.findFirst({ + public async getProviderForUserById(userId: string, provider: string) { + return await this.prisma.fitnessProviderCredential.findFirst({ where: { userId, - type: provider - } + type: provider, + }, }); } - public async createProvider(provider: Prisma.FitnessProviderCreateArgs) { - return await this.prisma.fitnessProvider.create(provider); + public async createProvider( + provider: Prisma.FitnessProviderCredentialCreateInput, + ) { + return await this.prisma.fitnessProviderCredential.create({ + data: provider, + }); } - public async deleteProvider(provider: Prisma.FitnessProviderDeleteArgs) { - return await this.prisma.fitnessProvider.delete(provider); + public async deleteProvider( + provider: Prisma.FitnessProviderCredentialDeleteArgs, + ) { + return await this.prisma.fitnessProviderCredential.delete(provider); } public async updateProvider( type: string, user: string, - provider: Prisma.FitnessProviderUpdateInput, + provider: Prisma.FitnessProviderCredentialUpdateInput, ) { - return await this.prisma.fitnessProvider.update({ + return await this.prisma.fitnessProviderCredential.update({ where: { type, userId: user }, data: provider, }); diff --git a/backend/src/integration/fitness/fitness.module.ts b/backend/src/integration/fitness/fitness.module.ts index 792f002..7a961fb 100644 --- a/backend/src/integration/fitness/fitness.module.ts +++ b/backend/src/integration/fitness/fitness.module.ts @@ -1,4 +1,19 @@ import { Module } from '@nestjs/common'; +import { FitnessService } from './fitness.service'; +import { ConfigModule } from '@nestjs/config'; +import configuration from 'src/config/configuration'; +import { PrismaModule } from 'src/db/prisma.module'; +import { DatasourceController } from 'src/api/datasource/datasource.controller'; -@Module({}) +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [configuration], + }), + PrismaModule, + ], + controllers: [DatasourceController], + providers: [FitnessService], + exports: [FitnessService], +}) export default class FitnessModule {} diff --git a/backend/src/integration/fitness/fitness.service.ts b/backend/src/integration/fitness/fitness.service.ts index ff586b5..f042f2e 100644 --- a/backend/src/integration/fitness/fitness.service.ts +++ b/backend/src/integration/fitness/fitness.service.ts @@ -1,17 +1,85 @@ +import { ConfigService } from '@nestjs/config'; import { FitnessRepository } from '../../db/repositories/fitness.repository'; import { FitBitProvider } from './providers/fitbit.provider'; +import { FitnessProvider, ProviderInfo } from './providers/provider.interface'; +import { Injectable } from '@nestjs/common'; +@Injectable() export class FitnessService { constructor( private fitnessRepository: FitnessRepository, - private fitbitProvider: FitBitProvider, + private configService: ConfigService, ) {} - public getFitbitProvider(): FitBitProvider { - return this.fitbitProvider; - } - // public async syncUser(userId: string): Promise {} public async syncAll(): Promise {} + + /** + * Retrieves a list of all available datasources which can be configured + * by a user. + * + * @returns + */ + public getAvailableDatasources() { + const providers: FitnessProvider[] = []; + + // Credentials for Fitbit + const fitbit_client_id = this.configService.get('FITBIT_CLIENT_ID'); + const fitbit_client_secret = this.configService.get( + 'FITBIT_CLIENT_SECRET', + ); + + if (fitbit_client_id && fitbit_client_secret) { + providers.push( + new FitBitProvider( + this.fitnessRepository, + fitbit_client_id, + fitbit_client_secret, + ), + ); + } + + return providers; + } + + public async getDatasourcesForUser( + userId: string, + ): Promise { + const availableProviders = this.getAvailableDatasources(); + + // Retrieve the configured credentials for the user + const userCredentials = + await this.fitnessRepository.getProvidersForUser(userId); + + for (const provider of availableProviders) { + const info = provider.getInfo(); + + // Find the credential for the provider + const credential = userCredentials?.find( + (cred) => cred.type == info.name, + ); + + if (credential) { + provider.setUserCredentials('enabled', credential); + } else { + provider.setUserCredentials('disabled', null); + } + } + + return availableProviders; + } + + public async getProviderForUserById( + userId: string, + name: string, + ): Promise { + const providers = await this.getDatasourcesForUser(userId); + + const responsibleProvider = providers.find( + (provider) => provider.getInfo().name == name, + ); + + return responsibleProvider ?? null; + } } diff --git a/backend/src/integration/fitness/providers/fitbit.provider.ts b/backend/src/integration/fitness/providers/fitbit.provider.ts index 32a25c3..dfdaa51 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.ts @@ -1,23 +1,54 @@ -import { ConfigService } from '@nestjs/config'; +import { FitnessProviderCredential } from '@prisma/client'; import { FitnessRepository } from '../../../db/repositories/fitness.repository'; import { FitnessData } from '../fitness.data'; import { FitnessGoal } from '../fitness.goal'; +import { + FitnessProvider, + ProviderInfo, + ProviderStatus, +} from './provider.interface'; +import * as dayjs from 'dayjs'; type FitbitCredentials = { accessToken: string; userId: string; }; -export class FitBitProvider { +export class FitBitProvider implements FitnessProvider { private FITBIT_API = 'https://api.fitbit.com'; private FITBIT_API_AUTH = 'https://www.fitbit.com'; private FITBIT_TYPE = 'fitbit'; + private userCredentials: FitnessProviderCredential | null; + private userStatus: ProviderStatus = 'unknown'; + constructor( private fitnessRepository: FitnessRepository, - private configService: ConfigService, + private client_id: string, + private client_secret: string, ) {} + setUserCredentials( + status: ProviderStatus, + credentials: FitnessProviderCredential | null, + ) { + this.userStatus = status; + this.userCredentials = credentials; + } + + /** + * Information about the provider + * + * @returns + */ + getInfo(): ProviderInfo { + return { + name: this.FITBIT_TYPE, + description: 'Import credentials from Fitbit', + status: this.userStatus, + }; + } + /** * Exchanges a code recieved after an authorized to an access token * and persists the client @@ -26,46 +57,46 @@ export class FitBitProvider { * @param code received authorization code * @returns api credentials */ - private async getAccessTokenFromCode( + public async getAccessTokenFromCode( user: string, code: string, ): Promise { - const client_id = this.configService.get( - 'providers.fitbit.client_id', - ); - const client_secret = this.configService.get( - 'providers.fitbit.client_secret', - ); - - const authorizeURL = new URL(`${this.FITBIT_API_AUTH}/oauth2/token`); - authorizeURL.searchParams.append( - 'client_id', - this.configService.get('providers.fitbit.client_id')!, - ); - authorizeURL.searchParams.append('code', code); - authorizeURL.searchParams.append('grant_type', 'authorization_code'); + const searchParams = new URLSearchParams(); + searchParams.append('client_id', this.client_id); + searchParams.append('code', code); + searchParams.append('grant_type', 'authorization_code'); // Retrieve the access token from the server - const response = await fetch(authorizeURL.toString(), { + const response = await fetch(`${this.FITBIT_API}/oauth2/token`, { + method: 'POST', headers: { - Authorization: btoa(`${client_id}:${client_secret}`), + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + `${this.client_id}:${this.client_secret}`, + ).toString('base64')}`, }, + body: searchParams.toString(), }); - if (!response.ok) throw new Error('Invalid response from FitBit'); + if (!response.ok) { + console.log(await response.text()); + throw new Error('Invalid response from FitBit'); + } const { access_token, refresh_token, expires_in, user_id } = await response.json(); await this.fitnessRepository.createProvider({ - data: { - type: 'fitbit', - userId: user, - providerUserId: user_id, - accessToken: access_token, - accessTokenExpires: expires_in, - refreshToken: refresh_token, - enabled: true, + type: 'fitbit', + providerUserId: user_id, + accessToken: access_token, + accessTokenExpires: dayjs().add(expires_in, 'seconds').toDate(), + refreshToken: refresh_token, + enabled: true, + owner: { + connect: { + id: user, + }, }, }); @@ -80,23 +111,16 @@ export class FitBitProvider { * @returns api credentials */ private async getAccessToken(user: string): Promise { - const credentials = await this.fitnessRepository.getProviderForUserById(user, 'fitbit'); + const credentials = await this.fitnessRepository.getProviderForUserById( + user, + 'fitbit', + ); if (!credentials) throw new Error('No credentials found for user'); - const client_id = this.configService.get( - 'providers.fitbit.client_id', - ); - const client_secret = this.configService.get( - 'providers.fitbit.client_secret', - ); - // Construct the URL const authorizeURL = new URL(`${this.FITBIT_API_AUTH}/oauth2/token`); - authorizeURL.searchParams.append( - 'client_id', - this.configService.get('providers.fitbit.client_id')!, - ); + authorizeURL.searchParams.append('client_id', this.client_id); authorizeURL.searchParams.append( 'refresh_token', credentials?.refreshToken, @@ -106,7 +130,7 @@ export class FitBitProvider { // Retrieve the access token from the server const response = await fetch(authorizeURL.toString(), { headers: { - Authorization: btoa(`${client_id}:${client_secret}`), + Authorization: btoa(`${this.client_id}:${this.client_secret}`), }, }); @@ -189,9 +213,6 @@ export class FitBitProvider { } public getAuthorizeURL(): string { - const client_id = this.configService.get('providers.fitbit.client_id')!; - - return `https://www.fitbit.com/oauth2/authorize?client_id=${client_id}&response_type=code&scope=activity` + return `https://www.fitbit.com/oauth2/authorize?client_id=${this.client_id}&response_type=code&scope=activity`; } - } diff --git a/backend/src/integration/fitness/providers/provider.interface.ts b/backend/src/integration/fitness/providers/provider.interface.ts new file mode 100644 index 0000000..e78c54b --- /dev/null +++ b/backend/src/integration/fitness/providers/provider.interface.ts @@ -0,0 +1,20 @@ +import { FitnessProviderCredential } from '@prisma/client'; + +export type ProviderStatus = 'enabled' | 'disabled' | 'error' | 'unknown'; + +export type ProviderInfo = { + name: string; + description: string; + status: ProviderStatus; +}; + +export interface FitnessProvider { + getInfo(): ProviderInfo; + setUserCredentials( + status: ProviderStatus, + credentials: FitnessProviderCredential | null, + ); + + getAuthorizeURL(): string; + getAccessTokenFromCode(user: string, code: string): Promise; +} From f830856571998f8110f11939211631d4f599a56b Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Wed, 22 May 2024 23:21:15 +0200 Subject: [PATCH 06/14] Requesting steps is now working --- backend/Dockerfile | 3 + backend/package.json | 5 +- .../api/datasource/datasource.controller.ts | 35 ++++++++++ .../credentials/credential.service.ts | 16 +++++ .../src/integration/fitness/fitness.module.ts | 3 +- .../integration/fitness/fitness.service.ts | 3 + .../fitness/providers/fitbit.provider.ts | 68 ++++++++++++++----- .../fitness/providers/provider.interface.ts | 1 + 8 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 backend/src/integration/credentials/credential.service.ts diff --git a/backend/Dockerfile b/backend/Dockerfile index 962831b..0c3046f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,5 +15,8 @@ ENV NODE_ENV production RUN npm run build +RUN npm run db:migrate + EXPOSE 3000 + CMD [ "npm", "run", "start:prod" ] \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 6dda82f..60af617 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,7 @@ "verify": "npm run build && npm run check && npm run test:cov && npm run test:e2e", "build": "prisma generate && nest build", "start": "nest start", - "start:dev": "npm run db:reset && nest start --watch", + "start:dev": "npm run db:migrate && nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", @@ -22,7 +22,8 @@ "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", - "db:reset": "prisma migrate reset --force" + "db:reset": "prisma migrate reset --force", + "db:migrate": "prisma migrate deploy" }, "dependencies": { "@nestjs/common": "^10.0.0", diff --git a/backend/src/api/datasource/datasource.controller.ts b/backend/src/api/datasource/datasource.controller.ts index f1cb05b..e0b2cbf 100644 --- a/backend/src/api/datasource/datasource.controller.ts +++ b/backend/src/api/datasource/datasource.controller.ts @@ -12,6 +12,8 @@ import { NestRequest } from '../../types/request.type'; import { AutoGuard } from '../../auth/auto.guard'; import { Response } from 'express'; import { FitnessService } from 'src/integration/fitness/fitness.service'; +import { FitBitProvider } from 'src/integration/fitness/providers/fitbit.provider'; +import * as dayjs from 'dayjs'; @Controller('datasource') export class DatasourceController { @@ -132,4 +134,37 @@ export class DatasourceController { response.status(200).json(responsibleProvider.getInfo()); } + + /** + * This endpoint returns the fitness goals set in fitbit. + * It is designed as a simple check if the API is working correctly and thus not documented in the API scheme. + */ + @Get('/:id/daily') + @UseGuards(AutoGuard) + public async getGoals( + @Req() request: NestRequest, + @Param('id') id: string, + @Res() response: Response, + ) { + const responsibleProvider = + (await this.fitnessService.getProviderForUserById( + request.user.id, + id, + )) as FitBitProvider | null; + + if (!responsibleProvider) { + response.status(400).json({ + error: 'Provider not available', + }); + return; + } + + const goals = await responsibleProvider.getFitnessData( + request.user.id, + new Date(), + new Date(), + ); + + response.status(200).json(goals); + } } diff --git a/backend/src/integration/credentials/credential.service.ts b/backend/src/integration/credentials/credential.service.ts new file mode 100644 index 0000000..4e15c02 --- /dev/null +++ b/backend/src/integration/credentials/credential.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class CredentialService { + private credentials: Map = new Map(); + + constructor() {} + + public saveCredential(key: string, value: any) { + this.credentials[key] = value; + } + + public getCredential(key: string): any | null { + return this.credentials[key]; + } +} diff --git a/backend/src/integration/fitness/fitness.module.ts b/backend/src/integration/fitness/fitness.module.ts index 7a961fb..2953634 100644 --- a/backend/src/integration/fitness/fitness.module.ts +++ b/backend/src/integration/fitness/fitness.module.ts @@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config'; import configuration from 'src/config/configuration'; import { PrismaModule } from 'src/db/prisma.module'; import { DatasourceController } from 'src/api/datasource/datasource.controller'; +import { CredentialService } from '../credentials/credential.service'; @Module({ imports: [ @@ -13,7 +14,7 @@ import { DatasourceController } from 'src/api/datasource/datasource.controller'; PrismaModule, ], controllers: [DatasourceController], - providers: [FitnessService], + providers: [FitnessService, CredentialService], exports: [FitnessService], }) export default class FitnessModule {} diff --git a/backend/src/integration/fitness/fitness.service.ts b/backend/src/integration/fitness/fitness.service.ts index f042f2e..a8c1ff6 100644 --- a/backend/src/integration/fitness/fitness.service.ts +++ b/backend/src/integration/fitness/fitness.service.ts @@ -3,12 +3,14 @@ import { FitnessRepository } from '../../db/repositories/fitness.repository'; import { FitBitProvider } from './providers/fitbit.provider'; import { FitnessProvider, ProviderInfo } from './providers/provider.interface'; import { Injectable } from '@nestjs/common'; +import { CredentialService } from '../credentials/credential.service'; @Injectable() export class FitnessService { constructor( private fitnessRepository: FitnessRepository, private configService: ConfigService, + private credentialService: CredentialService, ) {} // public async syncUser(userId: string): Promise {} @@ -34,6 +36,7 @@ export class FitnessService { providers.push( new FitBitProvider( this.fitnessRepository, + this.credentialService, fitbit_client_id, fitbit_client_secret, ), diff --git a/backend/src/integration/fitness/providers/fitbit.provider.ts b/backend/src/integration/fitness/providers/fitbit.provider.ts index dfdaa51..49aa0d5 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.ts @@ -8,6 +8,7 @@ import { ProviderStatus, } from './provider.interface'; import * as dayjs from 'dayjs'; +import { CredentialService } from 'src/integration/credentials/credential.service'; type FitbitCredentials = { accessToken: string; @@ -24,6 +25,7 @@ export class FitBitProvider implements FitnessProvider { constructor( private fitnessRepository: FitnessRepository, + private credentialStore: CredentialService, private client_id: string, private client_secret: string, ) {} @@ -111,6 +113,12 @@ export class FitBitProvider implements FitnessProvider { * @returns api credentials */ private async getAccessToken(user: string): Promise { + // First check, whether the access token is still cached + const credentialsCache = this.getCredentialsFromCache(user); + if (credentialsCache) { + return credentialsCache; + } + const credentials = await this.fitnessRepository.getProviderForUserById( user, 'fitbit', @@ -119,32 +127,40 @@ export class FitBitProvider implements FitnessProvider { if (!credentials) throw new Error('No credentials found for user'); // Construct the URL - const authorizeURL = new URL(`${this.FITBIT_API_AUTH}/oauth2/token`); - authorizeURL.searchParams.append('client_id', this.client_id); - authorizeURL.searchParams.append( - 'refresh_token', - credentials?.refreshToken, - ); - authorizeURL.searchParams.append('grant_type', 'authorization_code'); + const searchParams = new URLSearchParams(); + searchParams.append('client_id', this.client_id); + searchParams.append('refresh_token', credentials.refreshToken); + searchParams.append('grant_type', 'refresh_token'); // Retrieve the access token from the server - const response = await fetch(authorizeURL.toString(), { + const response = await fetch(`${this.FITBIT_API}/oauth2/token`, { + method: 'POST', headers: { - Authorization: btoa(`${this.client_id}:${this.client_secret}`), + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + `${this.client_id}:${this.client_secret}`, + ).toString('base64')}`, }, + body: searchParams.toString(), }); - if (!response.ok) throw new Error('No access token received from server'); + if (!response.ok) { + console.log(await response.text()); + throw new Error('No access token received from server'); + } const { access_token, refresh_token, expires_in } = await response.json(); // Persist the access token in the background, to make the request faster setTimeout(async () => { + // Save the credentials in the local cache + this.saveCredentials(user, credentials); + // Update provider await this.fitnessRepository.updateProvider(this.FITBIT_TYPE, user, { accessToken: access_token, refreshToken: refresh_token, - accessTokenExpires: expires_in, + accessTokenExpires: dayjs().add(expires_in, 'seconds').toDate(), }); }); @@ -161,24 +177,29 @@ export class FitBitProvider implements FitnessProvider { private async getDailyGoals( credentials: FitbitCredentials, ): Promise { + const currentDay = dayjs().format('YYYY-MM-DD'); + const response = await fetch( - `${this.FITBIT_API}/1/user/${credentials.userId}/activities/goals/daily.json`, + `${this.FITBIT_API}/1/user/${credentials.userId}/activities/date/${currentDay}.json`, { headers: { - Authentication: `Bearer ${credentials.userId}`, + Authorization: `Bearer ${credentials.accessToken}`, }, }, ); - if (!response.ok) throw new Error('Invalid response received from FitBit'); + if (!response.ok) { + console.log(await response.text()); + throw new Error('Invalid response received from FitBit'); + } const json = await response.json(); const goals: FitnessGoal[] = [ { type: 'steps', - goal: json['goal']['steps'], - value: 0, + goal: json['goals']['steps'], + value: json['summary']['steps'], unit: 0, }, ]; @@ -212,7 +233,20 @@ export class FitBitProvider implements FitnessProvider { }; } + private saveCredentials(user: string, credentials: FitbitCredentials) { + this.credentialStore.saveCredential( + `fitbit-credential-${user}`, + credentials, + ); + } + + private getCredentialsFromCache(user: string): FitbitCredentials | null { + return this.credentialStore.getCredential( + `fitbit-credential-${user}`, + ) as FitbitCredentials | null; + } + public getAuthorizeURL(): string { - return `https://www.fitbit.com/oauth2/authorize?client_id=${this.client_id}&response_type=code&scope=activity`; + return `https://www.fitbit.com/oauth2/authorize?client_id=${this.client_id}&response_type=code&scope=activity%20profile&20settings`; } } diff --git a/backend/src/integration/fitness/providers/provider.interface.ts b/backend/src/integration/fitness/providers/provider.interface.ts index e78c54b..f7ba385 100644 --- a/backend/src/integration/fitness/providers/provider.interface.ts +++ b/backend/src/integration/fitness/providers/provider.interface.ts @@ -1,4 +1,5 @@ import { FitnessProviderCredential } from '@prisma/client'; +import { FitnessGoal } from '../fitness.goal'; export type ProviderStatus = 'enabled' | 'disabled' | 'error' | 'unknown'; From 2cbda5532ab719d64d48a7f1c28b6dba358fb2c3 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Mon, 27 May 2024 13:20:42 +0200 Subject: [PATCH 07/14] Begin with test Signed-off-by: Henry Brink --- .../fitness/providers/fitbit.provider.spec.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/src/integration/fitness/providers/fitbit.provider.spec.ts diff --git a/backend/src/integration/fitness/providers/fitbit.provider.spec.ts b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts new file mode 100644 index 0000000..233373f --- /dev/null +++ b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts @@ -0,0 +1,31 @@ +import { Test } from '@nestjs/testing'; +import { FitnessRepository } from '../../../db/repositories/fitness.repository'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { ConfigService } from '@nestjs/config'; + +describe('Fitbit Provider tests', () => { + let fitnessRepository: DeepMockProxy; + let configService: DeepMockProxy; + + beforeAll(async () => { + const testModule = await Test.createTestingModule({ + providers: [FitnessRepository], + }) + .overrideProvider(FitnessRepository) + .useValue(mockDeep()) + .overrideProvider(ConfigService) + .useValue(mockDeep()) + .compile(); + + fitnessRepository = + testModule.get>(FitnessRepository); + configService = testModule.get>(ConfigService); + }); + + it('Should resolve an access token', async () => { + // Mock the fitbit environment variables + configService.get.bind(() => { + return 'MOCKED'; + }); + }); +}); From 0cdbe404ec91df6137da6089c7353af17b3f1e09 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Tue, 28 May 2024 23:08:04 +0200 Subject: [PATCH 08/14] Increased test coverage Signed-off-by: Henry Brink --- backend/package.json | 5 +- .../datasource/datasource.controller.spec.ts | 1 + .../api/datasource/datasource.controller.ts | 5 +- backend/src/app.module.ts | 3 +- .../src/integration/fitness/fitness.module.ts | 6 +- .../fitness/providers/fitbit.provider.spec.ts | 188 +++++++++++++++++- .../fitness/providers/fitbit.provider.ts | 5 +- backend/test/lib/constants.ts | 4 +- backend/test/lib/database.constants.ts | 8 + backend/tsconfig.json | 5 +- 10 files changed, 210 insertions(+), 20 deletions(-) create mode 100644 backend/src/api/datasource/datasource.controller.spec.ts diff --git a/backend/package.json b/backend/package.json index 60af617..a70f3de 100644 --- a/backend/package.json +++ b/backend/package.json @@ -93,7 +93,10 @@ "coveragePathIgnorePatterns": [ "src/db/prisma.seed.ts", "main.ts" - ] + ], + "moduleNameMapper": { + "src/(.*)": "/src/" + } }, "prisma": { "seed": "ts-node src/db/prisma.seed.ts" diff --git a/backend/src/api/datasource/datasource.controller.spec.ts b/backend/src/api/datasource/datasource.controller.spec.ts new file mode 100644 index 0000000..3db504f --- /dev/null +++ b/backend/src/api/datasource/datasource.controller.spec.ts @@ -0,0 +1 @@ +describe('Datasource controller', () => {}); diff --git a/backend/src/api/datasource/datasource.controller.ts b/backend/src/api/datasource/datasource.controller.ts index e0b2cbf..85a47b3 100644 --- a/backend/src/api/datasource/datasource.controller.ts +++ b/backend/src/api/datasource/datasource.controller.ts @@ -11,9 +11,8 @@ import { FitnessRepository } from '../../db/repositories/fitness.repository'; import { NestRequest } from '../../types/request.type'; import { AutoGuard } from '../../auth/auto.guard'; import { Response } from 'express'; -import { FitnessService } from 'src/integration/fitness/fitness.service'; -import { FitBitProvider } from 'src/integration/fitness/providers/fitbit.provider'; -import * as dayjs from 'dayjs'; +import { FitnessService } from '../../integration/fitness/fitness.service'; +import { FitBitProvider } from '../../integration/fitness/providers/fitbit.provider'; @Controller('datasource') export class DatasourceController { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a1dba3f..97d2b3f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -6,9 +6,8 @@ import { UserController } from './api/user/user.controller'; import { PrismaModule } from './db/prisma.module'; import { LOGGER_SERVICE } from './logger/logger.service'; import { ConfigModule } from '@nestjs/config'; -import configuration from './config/configuration'; -import { DatasourceController } from './api/datasource/datasource.controller'; import FitnessModule from './integration/fitness/fitness.module'; +import configuration from './config/configuration'; @Module({ imports: [ diff --git a/backend/src/integration/fitness/fitness.module.ts b/backend/src/integration/fitness/fitness.module.ts index 2953634..f3abcdb 100644 --- a/backend/src/integration/fitness/fitness.module.ts +++ b/backend/src/integration/fitness/fitness.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; import { FitnessService } from './fitness.service'; import { ConfigModule } from '@nestjs/config'; -import configuration from 'src/config/configuration'; -import { PrismaModule } from 'src/db/prisma.module'; -import { DatasourceController } from 'src/api/datasource/datasource.controller'; import { CredentialService } from '../credentials/credential.service'; +import configuration from '../../config/configuration'; +import { PrismaModule } from '../../db/prisma.module'; +import { DatasourceController } from '../../api/datasource/datasource.controller'; @Module({ imports: [ diff --git a/backend/src/integration/fitness/providers/fitbit.provider.spec.ts b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts index 233373f..1e4e956 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.spec.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts @@ -2,30 +2,206 @@ import { Test } from '@nestjs/testing'; import { FitnessRepository } from '../../../db/repositories/fitness.repository'; import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; import { ConfigService } from '@nestjs/config'; +import { FitBitProvider } from './fitbit.provider'; +import { TestConstants } from '../../../../test/lib/constants'; +import { PrismaModule } from '../../../db/prisma.module'; +import { CredentialService } from '../../credentials/credential.service'; describe('Fitbit Provider tests', () => { let fitnessRepository: DeepMockProxy; - let configService: DeepMockProxy; + let credentialService: DeepMockProxy; + let fitbitProvider: FitBitProvider; beforeAll(async () => { const testModule = await Test.createTestingModule({ - providers: [FitnessRepository], + imports: [PrismaModule], + providers: [FitnessRepository, CredentialService], }) .overrideProvider(FitnessRepository) .useValue(mockDeep()) - .overrideProvider(ConfigService) - .useValue(mockDeep()) + .overrideProvider(CredentialService) + .useValue(mockDeep()) .compile(); fitnessRepository = testModule.get>(FitnessRepository); - configService = testModule.get>(ConfigService); + credentialService = + testModule.get>(CredentialService); + fitbitProvider = new FitBitProvider( + fitnessRepository, + credentialService, + 'MOCKED', + 'MOCKED', + ); }); it('Should resolve an access token', async () => { // Mock the fitbit environment variables - configService.get.bind(() => { + credentialService.getCredential.bind(() => { return 'MOCKED'; }); + + global.fetch = async () => { + const responseMock = mockDeep(); + responseMock.json.mockResolvedValue({ + access_token: 'MOCK_AT', + refresh_token: 'MOCK', + expires_in: 0, + user_id: 'MOCK_USER', + }); + + return responseMock; + }; + + fitnessRepository.createProvider.mockResolvedValue( + TestConstants.database.fitnessCredentials.fitbit, + ); + + // Call the function + const result = await fitbitProvider.getAccessTokenFromCode('MOCK', 'MOCK'); + + // Expect that the credential have been saved in the database + expect(fitnessRepository.createProvider).toHaveBeenCalledTimes(1); + + // Expect the mock credentials to have been returned + expect(result).toBeDefined(); + expect(result.accessToken).toBe('MOCK_AT'); + expect(result.userId).toBe('MOCK_USER'); + }); + + it('should retrieve fitness data with a cached access token', async () => { + // Mock the access token + credentialService.getCredential.mockReturnValue('MOCK_AT'); + + // Mock the response from fitbit + global.fetch = async () => { + const response = mockDeep(); + + response.json.mockResolvedValue(fitbitDailySummaryMock); + + return response; + }; + + const result = await fitbitProvider.getFitnessData( + 'MOCK', + new Date(), + new Date(), + ); + + expect(result.goals[0].type).toBe('steps'); + expect(result.goals[0].goal).toBe(10000); + expect(result.goals[0].value).toBe(1698); }); }); + +const fitbitDailySummaryMock = { + activities: [], + goals: { + activeMinutes: 30, + caloriesOut: 1950, + distance: 8.05, + floors: 10, + steps: 10000, + }, + summary: { + activeScore: -1, + activityCalories: 525, + calorieEstimationMu: 2241, + caloriesBMR: 1973, + caloriesOut: 2628, + caloriesOutUnestimated: 2628, + customHeartRateZones: [ + { + caloriesOut: 2616.7788, + max: 140, + min: 30, + minutes: 1432, + name: 'Below', + }, + { + caloriesOut: 0, + max: 165, + min: 140, + minutes: 0, + name: 'Custom Zone', + }, + { + caloriesOut: 0, + max: 220, + min: 165, + minutes: 0, + name: 'Above', + }, + ], + distances: [ + { + activity: 'total', + distance: 1.26, + }, + { + activity: 'tracker', + distance: 1.26, + }, + { + activity: 'loggedActivities', + distance: 0, + }, + { + activity: 'veryActive', + distance: 0, + }, + { + activity: 'moderatelyActive', + distance: 0, + }, + { + activity: 'lightlyActive', + distance: 1.25, + }, + { + activity: 'sedentaryActive', + distance: 0, + }, + ], + elevation: 0, + fairlyActiveMinutes: 0, + floors: 0, + heartRateZones: [ + { + caloriesOut: 1200.33336, + max: 86, + min: 30, + minutes: 812, + name: 'Out of Range', + }, + { + caloriesOut: 1409.4564, + max: 121, + min: 86, + minutes: 619, + name: 'Fat Burn', + }, + { + caloriesOut: 6.98904, + max: 147, + min: 121, + minutes: 1, + name: 'Cardio', + }, + { + caloriesOut: 0, + max: 220, + min: 147, + minutes: 0, + name: 'Peak', + }, + ], + lightlyActiveMinutes: 110, + marginalCalories: 281, + restingHeartRate: 77, + sedentaryMinutes: 802, + steps: 1698, + useEstimation: true, + veryActiveMinutes: 0, + }, +}; diff --git a/backend/src/integration/fitness/providers/fitbit.provider.ts b/backend/src/integration/fitness/providers/fitbit.provider.ts index 49aa0d5..74ed114 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.ts @@ -8,16 +8,17 @@ import { ProviderStatus, } from './provider.interface'; import * as dayjs from 'dayjs'; -import { CredentialService } from 'src/integration/credentials/credential.service'; +import { CredentialService } from '../../credentials/credential.service'; +import { Injectable } from '@nestjs/common'; type FitbitCredentials = { accessToken: string; userId: string; }; +@Injectable() export class FitBitProvider implements FitnessProvider { private FITBIT_API = 'https://api.fitbit.com'; - private FITBIT_API_AUTH = 'https://www.fitbit.com'; private FITBIT_TYPE = 'fitbit'; private userCredentials: FitnessProviderCredential | null; diff --git a/backend/test/lib/constants.ts b/backend/test/lib/constants.ts index de83ff9..db2857e 100644 --- a/backend/test/lib/constants.ts +++ b/backend/test/lib/constants.ts @@ -1,5 +1,5 @@ -import { Users } from './database.constants'; +import { FitnessCredetials, Users } from './database.constants'; export const TestConstants = { - database: { users: Users }, + database: { users: Users, fitnessCredentials: FitnessCredetials }, }; diff --git a/backend/test/lib/database.constants.ts b/backend/test/lib/database.constants.ts index 95d2ed2..e1581a0 100644 --- a/backend/test/lib/database.constants.ts +++ b/backend/test/lib/database.constants.ts @@ -1,3 +1,5 @@ +import { FitnessProviderCredential } from '@prisma/client'; + export const Users = { exampleUser: { id: '0', @@ -8,3 +10,9 @@ export const Users = { verified: false, }, }; + +export const FitnessCredetials = { + fitbit: { + type: 'fitbit', + } as FitnessProviderCredential, +}; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 4b82434..a478ad1 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -16,6 +16,9 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "paths": { + "src/*": ["src/*"] + } } } From 2669e1f9996374402075bcd6bc4eb7ce6a0b1ed6 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Wed, 29 May 2024 18:12:37 +0200 Subject: [PATCH 09/14] Added further tests Signed-off-by: Henry Brink --- .../datasource/datasource.controller.spec.ts | 156 +++++++++++++++++- .../api/datasource/datasource.controller.ts | 23 ++- .../fitness/providers/fitbit.provider.spec.ts | 1 - .../fitness/providers/mock.provider.ts | 56 +++++++ .../fitness/providers/provider.interface.ts | 3 +- backend/test/lib/database.constants.ts | 6 + 6 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 backend/src/integration/fitness/providers/mock.provider.ts diff --git a/backend/src/api/datasource/datasource.controller.spec.ts b/backend/src/api/datasource/datasource.controller.spec.ts index 3db504f..32f2dfd 100644 --- a/backend/src/api/datasource/datasource.controller.spec.ts +++ b/backend/src/api/datasource/datasource.controller.spec.ts @@ -1 +1,155 @@ -describe('Datasource controller', () => {}); +import { Test } from '@nestjs/testing'; +import { PrismaModule } from '../../db/prisma.module'; +import { FitnessService } from '../../integration/fitness/fitness.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { MockProvider } from '../../integration/fitness/providers/mock.provider'; +import { DatasourceController } from './datasource.controller'; +import { NestRequest } from '../../types/request.type'; +import { FitnessRepository } from '../../db/repositories/fitness.repository'; +import { Response } from 'express'; +import { TestConstants } from '../../../test/lib/constants'; + +describe('Datasource controller', () => { + let fitnessService: DeepMockProxy; + let fitnessController: DeepMockProxy; + let fitnessRepository: DeepMockProxy; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [PrismaModule], + providers: [FitnessService], + controllers: [DatasourceController], + }) + .overrideProvider(FitnessService) + .useValue(mockDeep()) + .overrideProvider(FitnessRepository) + .useValue(mockDeep()) + .compile(); + + fitnessService = module.get(FitnessService); + fitnessController = module.get(DatasourceController); + fitnessRepository = module.get(FitnessRepository); + }); + + it('It should return the datasources for a user', async () => { + // Mock the datasources + fitnessService.getDatasourcesForUser.mockResolvedValue([ + new MockProvider(), + ]); + + const mockRequest = { + user: { + id: 'MOCK', + }, + } as NestRequest; + + const userProviders = + await fitnessController.getDatasourcesForUser(mockRequest); + + expect(userProviders.length).toBe(1); + }); + + it('Should return a specific datasource for the user', async () => { + fitnessService.getProviderForUserById.mockResolvedValue(new MockProvider()); + + const mockRequest = { + user: { + id: 'MOCK', + }, + } as NestRequest; + + const mockProvider = await fitnessController.getDatasourceForUser( + mockRequest, + { id: 'Mock' }, + ); + + expect(mockProvider).toBeInstanceOf(MockProvider); + }); + + it('Should be able to delete a fitness provider', async () => { + fitnessRepository.getProviderForUserById.mockResolvedValue( + TestConstants.database.fitnessCredentials.fitbit, + ); + + const mockRequest = { + user: { + id: 'MOCK', + }, + } as NestRequest; + + const mockResponse = mockDeep(); + + await fitnessController.deleteDatasource( + mockRequest, + { id: 'MOCK' }, + mockResponse, + ); + + expect(mockResponse.status).toHaveBeenCalledWith(204); + }); + + it('should resolve the authorize url of the provider', async () => { + fitnessService.getProviderForUserById.mockResolvedValue(new MockProvider()); + + const mockRequest = { + user: { + id: 'MOCK', + }, + } as NestRequest; + + const mockResponse = mockDeep(); + mockResponse.status.mockReturnThis(); + + await fitnessController.getAuthorizeURL( + mockRequest, + { id: 'MOCK' }, + mockResponse, + ); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + url: 'MOCK_AURL', + }); + }); + + it('should provide the access token by the user', async () => { + const mockedProvider = new MockProvider(); + fitnessService.getProviderForUserById.mockResolvedValue(mockedProvider); + + const mockRequest = { + user: { + id: 'MOCK', + }, + query: { + code: 'MOCKED_AP', + } as any, + } as NestRequest; + + const mockResponse = mockDeep(); + mockResponse.status.mockReturnThis(); + + await fitnessController.redirect(mockRequest, 'MOCK', mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + }); + + it('should return the daily goals', async () => { + fitnessService.getProviderForUserById.mockResolvedValue(new MockProvider()); + + const mockRequest = { + user: { + id: 'MOCK', + }, + query: { + code: 'MOCKED_AP', + } as any, + } as NestRequest; + + const mockResponse = mockDeep(); + mockResponse.status.mockReturnThis(); + + await fitnessController.getGoals(mockRequest, 'MOCK', mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/backend/src/api/datasource/datasource.controller.ts b/backend/src/api/datasource/datasource.controller.ts index 85a47b3..43d5b45 100644 --- a/backend/src/api/datasource/datasource.controller.ts +++ b/backend/src/api/datasource/datasource.controller.ts @@ -37,13 +37,12 @@ export class DatasourceController { @Req() request: NestRequest, @Param() params: any, ) { - const datasourceForUser = - await this.fitnessRepository.getProviderForUserById( - request.user.id, - params.id, - ); + const datasource = this.fitnessService.getProviderForUserById( + request.user.id, + params.id, + ); - return datasourceForUser; + return datasource; } @Delete('/:id') @@ -80,13 +79,11 @@ export class DatasourceController { @Param() params: any, @Res() response: Response, ) { - const providers = await this.fitnessService.getDatasourcesForUser( - request.user.id, - ); - - const responsibleProvider = providers.find( - (provider) => provider.getInfo().name == params.id, - ); + const responsibleProvider = + await this.fitnessService.getProviderForUserById( + request.user.id, + params.id, + ); if (!responsibleProvider) { response.status(400).json({ diff --git a/backend/src/integration/fitness/providers/fitbit.provider.spec.ts b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts index 1e4e956..d1ff61d 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.spec.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts @@ -1,7 +1,6 @@ import { Test } from '@nestjs/testing'; import { FitnessRepository } from '../../../db/repositories/fitness.repository'; import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; -import { ConfigService } from '@nestjs/config'; import { FitBitProvider } from './fitbit.provider'; import { TestConstants } from '../../../../test/lib/constants'; import { PrismaModule } from '../../../db/prisma.module'; diff --git a/backend/src/integration/fitness/providers/mock.provider.ts b/backend/src/integration/fitness/providers/mock.provider.ts new file mode 100644 index 0000000..ba5ad2e --- /dev/null +++ b/backend/src/integration/fitness/providers/mock.provider.ts @@ -0,0 +1,56 @@ +import { FitnessData } from '../fitness.data'; +import { + FitnessProvider, + ProviderInfo, + ProviderStatus, +} from './provider.interface'; + +export class MockProvider implements FitnessProvider { + async getFitnessData( + user: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + start: Date, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + end: Date, + ): Promise { + return { + dataSource: 'fitbit', + syncDate: new Date(), + owner: user, + activities: [], + goals: [], + }; + } + getInfo(): ProviderInfo { + return { + name: 'MockProvider', + description: 'MOCK', + status: 'enabled', + }; + } + + setUserCredentials( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + status: ProviderStatus, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + credentials: { + type: string; + refreshToken: string; + accessToken: string; + accessTokenExpires: Date; + userId: string; + enabled: boolean; + providerUserId: string; + } | null, + ) { + throw new Error('Method not implemented.'); + } + getAuthorizeURL(): string { + return 'MOCK_AURL'; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getAccessTokenFromCode(user: string, code: string): Promise { + return true; + } +} diff --git a/backend/src/integration/fitness/providers/provider.interface.ts b/backend/src/integration/fitness/providers/provider.interface.ts index f7ba385..15a9cd5 100644 --- a/backend/src/integration/fitness/providers/provider.interface.ts +++ b/backend/src/integration/fitness/providers/provider.interface.ts @@ -1,5 +1,5 @@ import { FitnessProviderCredential } from '@prisma/client'; -import { FitnessGoal } from '../fitness.goal'; +import { FitnessData } from '../fitness.data'; export type ProviderStatus = 'enabled' | 'disabled' | 'error' | 'unknown'; @@ -18,4 +18,5 @@ export interface FitnessProvider { getAuthorizeURL(): string; getAccessTokenFromCode(user: string, code: string): Promise; + getFitnessData(user: string, start: Date, end: Date): Promise; } diff --git a/backend/test/lib/database.constants.ts b/backend/test/lib/database.constants.ts index e1581a0..9b995d6 100644 --- a/backend/test/lib/database.constants.ts +++ b/backend/test/lib/database.constants.ts @@ -14,5 +14,11 @@ export const Users = { export const FitnessCredetials = { fitbit: { type: 'fitbit', + accessToken: 'MOCK_AT', + refreshToken: 'MOCK_RF', + accessTokenExpires: new Date(), + userId: 'MOCK_UID', + enabled: true, + providerUserId: 'MOCK_PUID', } as FitnessProviderCredential, }; From b72fd9e5aadb7e56fce282e140f60fc8a556bff6 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Sun, 2 Jun 2024 15:46:49 +0200 Subject: [PATCH 10/14] fix: Credentials have not been cached correctly Signed-off-by: Henry Brink --- .../datasource/datasource.controller.spec.ts | 7 +++- .../api/datasource/datasource.controller.ts | 33 +++++++++++++++---- backend/src/app.module.spec.ts | 8 +++++ .../src/integration/fitness/fitness.module.ts | 9 +++-- .../integration/fitness/fitness.service.ts | 8 +++-- .../fitness/providers/fitbit.provider.ts | 14 ++++++-- 6 files changed, 65 insertions(+), 14 deletions(-) diff --git a/backend/src/api/datasource/datasource.controller.spec.ts b/backend/src/api/datasource/datasource.controller.spec.ts index 32f2dfd..846c389 100644 --- a/backend/src/api/datasource/datasource.controller.spec.ts +++ b/backend/src/api/datasource/datasource.controller.spec.ts @@ -8,6 +8,8 @@ import { NestRequest } from '../../types/request.type'; import { FitnessRepository } from '../../db/repositories/fitness.repository'; import { Response } from 'express'; import { TestConstants } from '../../../test/lib/constants'; +import { ConsoleLogger } from '@nestjs/common'; +import { LOGGER_SERVICE } from '../../logger/logger.service'; describe('Datasource controller', () => { let fitnessService: DeepMockProxy; @@ -17,7 +19,10 @@ describe('Datasource controller', () => { beforeAll(async () => { const module = await Test.createTestingModule({ imports: [PrismaModule], - providers: [FitnessService], + providers: [ + FitnessService, + { useClass: ConsoleLogger, provide: LOGGER_SERVICE }, + ], controllers: [DatasourceController], }) .overrideProvider(FitnessService) diff --git a/backend/src/api/datasource/datasource.controller.ts b/backend/src/api/datasource/datasource.controller.ts index 43d5b45..60edf4b 100644 --- a/backend/src/api/datasource/datasource.controller.ts +++ b/backend/src/api/datasource/datasource.controller.ts @@ -2,6 +2,7 @@ import { Controller, Delete, Get, + Inject, Param, Req, Res, @@ -13,12 +14,15 @@ import { AutoGuard } from '../../auth/auto.guard'; import { Response } from 'express'; import { FitnessService } from '../../integration/fitness/fitness.service'; import { FitBitProvider } from '../../integration/fitness/providers/fitbit.provider'; +import { LOGGER_SERVICE, LoggerService } from '../../logger/logger.service'; @Controller('datasource') export class DatasourceController { constructor( private fitnessRepository: FitnessRepository, private fitnessService: FitnessService, + @Inject(LOGGER_SERVICE) + private loggerService: LoggerService, ) {} @Get() @@ -122,12 +126,16 @@ export class DatasourceController { ); if (!result) { + this.loggerService.warn('[Fitbit]: Access code has not been accepted'); response.status(400).json({ error: 'Token was not accepted', }); return; } + this.loggerService.debug( + '[Fitbit]: Connection to fitbit has been configured successfull', + ); response.status(200).json(responsibleProvider.getInfo()); } @@ -155,12 +163,25 @@ export class DatasourceController { return; } - const goals = await responsibleProvider.getFitnessData( - request.user.id, - new Date(), - new Date(), - ); + try { + const goals = await responsibleProvider.getFitnessData( + request.user.id, + new Date(), + new Date(), + ); - response.status(200).json(goals); + response.status(200).json(goals); + } catch (error: any) { + this.loggerService.error( + '[Fitbit]: Unable to retrieve fitness data from fitbit', + ); + + // In future, it might be better to differentiate between correctable errors (i.e. not reachable), and not correctable errors + // (i.e. refresh token invalid) and further update the datasource + + response.status(500).json({ + error: 'Unable to retrieve fitness data from Fitbit', + }); + } } } diff --git a/backend/src/app.module.spec.ts b/backend/src/app.module.spec.ts index 4d263dc..9170deb 100644 --- a/backend/src/app.module.spec.ts +++ b/backend/src/app.module.spec.ts @@ -1,10 +1,18 @@ import { Test } from '@nestjs/testing'; import { AppModule } from './app.module'; +import { ConsoleLogger } from '@nestjs/common'; +import { LOGGER_SERVICE } from './logger/logger.service'; describe('AppModule testing', () => { it('should exist', async () => { const module = await Test.createTestingModule({ imports: [AppModule], + providers: [ + { + useClass: ConsoleLogger, + provide: LOGGER_SERVICE, + }, + ], }).compile(); expect(module).toBeDefined(); diff --git a/backend/src/integration/fitness/fitness.module.ts b/backend/src/integration/fitness/fitness.module.ts index f3abcdb..8f39e33 100644 --- a/backend/src/integration/fitness/fitness.module.ts +++ b/backend/src/integration/fitness/fitness.module.ts @@ -1,10 +1,11 @@ -import { Module } from '@nestjs/common'; +import { ConsoleLogger, Module } from '@nestjs/common'; import { FitnessService } from './fitness.service'; import { ConfigModule } from '@nestjs/config'; import { CredentialService } from '../credentials/credential.service'; import configuration from '../../config/configuration'; import { PrismaModule } from '../../db/prisma.module'; import { DatasourceController } from '../../api/datasource/datasource.controller'; +import { LOGGER_SERVICE } from '../../logger/logger.service'; @Module({ imports: [ @@ -14,7 +15,11 @@ import { DatasourceController } from '../../api/datasource/datasource.controller PrismaModule, ], controllers: [DatasourceController], - providers: [FitnessService, CredentialService], + providers: [ + FitnessService, + CredentialService, + { useClass: ConsoleLogger, provide: LOGGER_SERVICE }, + ], exports: [FitnessService], }) export default class FitnessModule {} diff --git a/backend/src/integration/fitness/fitness.service.ts b/backend/src/integration/fitness/fitness.service.ts index a8c1ff6..8f8ff1b 100644 --- a/backend/src/integration/fitness/fitness.service.ts +++ b/backend/src/integration/fitness/fitness.service.ts @@ -1,9 +1,10 @@ import { ConfigService } from '@nestjs/config'; import { FitnessRepository } from '../../db/repositories/fitness.repository'; import { FitBitProvider } from './providers/fitbit.provider'; -import { FitnessProvider, ProviderInfo } from './providers/provider.interface'; -import { Injectable } from '@nestjs/common'; +import { FitnessProvider } from './providers/provider.interface'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { CredentialService } from '../credentials/credential.service'; +import { LOGGER_SERVICE } from '../../logger/logger.service'; @Injectable() export class FitnessService { @@ -11,6 +12,8 @@ export class FitnessService { private fitnessRepository: FitnessRepository, private configService: ConfigService, private credentialService: CredentialService, + @Inject(LOGGER_SERVICE) + private loggerService: LoggerService, ) {} // public async syncUser(userId: string): Promise {} @@ -37,6 +40,7 @@ export class FitnessService { new FitBitProvider( this.fitnessRepository, this.credentialService, + this.loggerService, fitbit_client_id, fitbit_client_secret, ), diff --git a/backend/src/integration/fitness/providers/fitbit.provider.ts b/backend/src/integration/fitness/providers/fitbit.provider.ts index 74ed114..3cbe6a1 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.ts @@ -9,7 +9,7 @@ import { } from './provider.interface'; import * as dayjs from 'dayjs'; import { CredentialService } from '../../credentials/credential.service'; -import { Injectable } from '@nestjs/common'; +import { Injectable, LoggerService } from '@nestjs/common'; type FitbitCredentials = { accessToken: string; @@ -27,6 +27,7 @@ export class FitBitProvider implements FitnessProvider { constructor( private fitnessRepository: FitnessRepository, private credentialStore: CredentialService, + private loggerService: LoggerService, private client_id: string, private client_secret: string, ) {} @@ -89,6 +90,8 @@ export class FitBitProvider implements FitnessProvider { const { access_token, refresh_token, expires_in, user_id } = await response.json(); + this.userStatus = 'enabled'; + await this.fitnessRepository.createProvider({ type: 'fitbit', providerUserId: user_id, @@ -155,7 +158,10 @@ export class FitBitProvider implements FitnessProvider { // Persist the access token in the background, to make the request faster setTimeout(async () => { // Save the credentials in the local cache - this.saveCredentials(user, credentials); + this.saveCredentials(user, { + accessToken: credentials.accessToken, + userId: credentials.providerUserId, + }); // Update provider await this.fitnessRepository.updateProvider(this.FITBIT_TYPE, user, { @@ -190,7 +196,9 @@ export class FitBitProvider implements FitnessProvider { ); if (!response.ok) { - console.log(await response.text()); + this.loggerService.error( + `[Fitbit]: Unable to retrieve daily activities via ${response.url}`, + ); throw new Error('Invalid response received from FitBit'); } From 8ed0bd41ab0821e69842b4a4bc71ce8f8ce1ba16 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Sun, 2 Jun 2024 15:56:54 +0200 Subject: [PATCH 11/14] further minor fixes Signed-off-by: Henry Brink --- backend/src/api/datasource/datasource.controller.spec.ts | 2 +- backend/src/api/datasource/datasource.controller.ts | 4 ++-- .../src/integration/fitness/providers/fitbit.provider.spec.ts | 2 ++ backend/src/integration/fitness/providers/fitbit.provider.ts | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/src/api/datasource/datasource.controller.spec.ts b/backend/src/api/datasource/datasource.controller.spec.ts index 846c389..b157ba3 100644 --- a/backend/src/api/datasource/datasource.controller.spec.ts +++ b/backend/src/api/datasource/datasource.controller.spec.ts @@ -68,7 +68,7 @@ describe('Datasource controller', () => { { id: 'Mock' }, ); - expect(mockProvider).toBeInstanceOf(MockProvider); + expect(mockProvider).toStrictEqual(new MockProvider().getInfo()); }); it('Should be able to delete a fitness provider', async () => { diff --git a/backend/src/api/datasource/datasource.controller.ts b/backend/src/api/datasource/datasource.controller.ts index 60edf4b..ae79e67 100644 --- a/backend/src/api/datasource/datasource.controller.ts +++ b/backend/src/api/datasource/datasource.controller.ts @@ -41,12 +41,12 @@ export class DatasourceController { @Req() request: NestRequest, @Param() params: any, ) { - const datasource = this.fitnessService.getProviderForUserById( + const datasource = await this.fitnessService.getProviderForUserById( request.user.id, params.id, ); - return datasource; + return datasource?.getInfo(); } @Delete('/:id') diff --git a/backend/src/integration/fitness/providers/fitbit.provider.spec.ts b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts index d1ff61d..0130b2f 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.spec.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts @@ -5,6 +5,7 @@ import { FitBitProvider } from './fitbit.provider'; import { TestConstants } from '../../../../test/lib/constants'; import { PrismaModule } from '../../../db/prisma.module'; import { CredentialService } from '../../credentials/credential.service'; +import { ConsoleLogger } from '@nestjs/common'; describe('Fitbit Provider tests', () => { let fitnessRepository: DeepMockProxy; @@ -29,6 +30,7 @@ describe('Fitbit Provider tests', () => { fitbitProvider = new FitBitProvider( fitnessRepository, credentialService, + mockDeep(), 'MOCKED', 'MOCKED', ); diff --git a/backend/src/integration/fitness/providers/fitbit.provider.ts b/backend/src/integration/fitness/providers/fitbit.provider.ts index 3cbe6a1..1d9b82e 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.ts @@ -48,7 +48,8 @@ export class FitBitProvider implements FitnessProvider { getInfo(): ProviderInfo { return { name: this.FITBIT_TYPE, - description: 'Import credentials from Fitbit', + description: + 'Access Activities, Steps and further Data from Fitbit. This supports all devices supported by the Fitbit App, including the Google Pixel Watch', status: this.userStatus, }; } From 432f0b0c2cf08c3ae11141a34920eb12169a2483 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Mon, 3 Jun 2024 19:02:34 +0200 Subject: [PATCH 12/14] changes requested by @benedictweis --- .../datasource/datasource.controller.spec.ts | 55 +++++-------------- .../fitness/providers/fitbit.provider.ts | 30 +++++++++- .../fitness/providers/mock.provider.ts | 2 +- backend/tsconfig.json | 3 - 4 files changed, 42 insertions(+), 48 deletions(-) diff --git a/backend/src/api/datasource/datasource.controller.spec.ts b/backend/src/api/datasource/datasource.controller.spec.ts index b157ba3..9dc322e 100644 --- a/backend/src/api/datasource/datasource.controller.spec.ts +++ b/backend/src/api/datasource/datasource.controller.spec.ts @@ -15,6 +15,7 @@ describe('Datasource controller', () => { let fitnessService: DeepMockProxy; let fitnessController: DeepMockProxy; let fitnessRepository: DeepMockProxy; + let mockRequest: NestRequest; beforeAll(async () => { const module = await Test.createTestingModule({ @@ -34,6 +35,12 @@ describe('Datasource controller', () => { fitnessService = module.get(FitnessService); fitnessController = module.get(DatasourceController); fitnessRepository = module.get(FitnessRepository); + + mockRequest = { + user: { + id: 'MOCK', + }, + } as NestRequest; }); it('It should return the datasources for a user', async () => { @@ -42,12 +49,6 @@ describe('Datasource controller', () => { new MockProvider(), ]); - const mockRequest = { - user: { - id: 'MOCK', - }, - } as NestRequest; - const userProviders = await fitnessController.getDatasourcesForUser(mockRequest); @@ -57,15 +58,9 @@ describe('Datasource controller', () => { it('Should return a specific datasource for the user', async () => { fitnessService.getProviderForUserById.mockResolvedValue(new MockProvider()); - const mockRequest = { - user: { - id: 'MOCK', - }, - } as NestRequest; - const mockProvider = await fitnessController.getDatasourceForUser( mockRequest, - { id: 'Mock' }, + { id: 'MOCK' }, ); expect(mockProvider).toStrictEqual(new MockProvider().getInfo()); @@ -76,12 +71,6 @@ describe('Datasource controller', () => { TestConstants.database.fitnessCredentials.fitbit, ); - const mockRequest = { - user: { - id: 'MOCK', - }, - } as NestRequest; - const mockResponse = mockDeep(); await fitnessController.deleteDatasource( @@ -96,12 +85,6 @@ describe('Datasource controller', () => { it('should resolve the authorize url of the provider', async () => { fitnessService.getProviderForUserById.mockResolvedValue(new MockProvider()); - const mockRequest = { - user: { - id: 'MOCK', - }, - } as NestRequest; - const mockResponse = mockDeep(); mockResponse.status.mockReturnThis(); @@ -121,14 +104,9 @@ describe('Datasource controller', () => { const mockedProvider = new MockProvider(); fitnessService.getProviderForUserById.mockResolvedValue(mockedProvider); - const mockRequest = { - user: { - id: 'MOCK', - }, - query: { - code: 'MOCKED_AP', - } as any, - } as NestRequest; + mockRequest.query = { + code: 'MOCKED_AP', + } as any; const mockResponse = mockDeep(); mockResponse.status.mockReturnThis(); @@ -141,14 +119,9 @@ describe('Datasource controller', () => { it('should return the daily goals', async () => { fitnessService.getProviderForUserById.mockResolvedValue(new MockProvider()); - const mockRequest = { - user: { - id: 'MOCK', - }, - query: { - code: 'MOCKED_AP', - } as any, - } as NestRequest; + mockRequest.query = { + code: 'MOCKED_AP', + } as any; const mockResponse = mockDeep(); mockResponse.status.mockReturnThis(); diff --git a/backend/src/integration/fitness/providers/fitbit.provider.ts b/backend/src/integration/fitness/providers/fitbit.provider.ts index 1d9b82e..a28d418 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.ts @@ -205,7 +205,18 @@ export class FitBitProvider implements FitnessProvider { const json = await response.json(); - const goals: FitnessGoal[] = [ + // The goals are transformed into the required format + return this.formatGoals(json); + } + + /** + * Transforms Fitbit Goals into the regular goals. + * + * @param json + * @returns + */ + private formatGoals(json: object): FitnessGoal[] { + return [ { type: 'steps', goal: json['goals']['steps'], @@ -213,8 +224,6 @@ export class FitBitProvider implements FitnessProvider { unit: 0, }, ]; - - return goals; } /** @@ -243,6 +252,14 @@ export class FitBitProvider implements FitnessProvider { }; } + /** + * Stores credentials in the local cache. + * The cache is only valid per instance and not shared. + * The credentials are saved in plaintext in memory. + * + * @param user + * @param credentials + */ private saveCredentials(user: string, credentials: FitbitCredentials) { this.credentialStore.saveCredential( `fitbit-credential-${user}`, @@ -250,6 +267,13 @@ export class FitBitProvider implements FitnessProvider { ); } + /** + * Retrieves credentials from the local cache. + * The cache is only valid per instance and not shared. + * + * @param user + * @returns + */ private getCredentialsFromCache(user: string): FitbitCredentials | null { return this.credentialStore.getCredential( `fitbit-credential-${user}`, diff --git a/backend/src/integration/fitness/providers/mock.provider.ts b/backend/src/integration/fitness/providers/mock.provider.ts index ba5ad2e..0db2902 100644 --- a/backend/src/integration/fitness/providers/mock.provider.ts +++ b/backend/src/integration/fitness/providers/mock.provider.ts @@ -43,7 +43,7 @@ export class MockProvider implements FitnessProvider { providerUserId: string; } | null, ) { - throw new Error('Method not implemented.'); + return null; } getAuthorizeURL(): string { return 'MOCK_AURL'; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index a478ad1..129d4ed 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -17,8 +17,5 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, - "paths": { - "src/*": ["src/*"] - } } } From d1f2c9b63d2a730c8e4d8e02d5d2fd11f11ca538 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Mon, 3 Jun 2024 19:19:06 +0200 Subject: [PATCH 13/14] Further refactoring & bug fixing --- .../api/datasource/datasource.controller.ts | 8 +-- .../integration/fitness/fitness.service.ts | 4 +- .../fitness/providers/fitbit.provider.spec.ts | 6 +-- .../fitness/providers/fitbit.provider.ts | 53 ++++++++++++------- .../fitness/providers/mock.provider.ts | 2 +- .../fitness/providers/provider.interface.ts | 2 +- 6 files changed, 46 insertions(+), 29 deletions(-) diff --git a/backend/src/api/datasource/datasource.controller.ts b/backend/src/api/datasource/datasource.controller.ts index ae79e67..9b33a92 100644 --- a/backend/src/api/datasource/datasource.controller.ts +++ b/backend/src/api/datasource/datasource.controller.ts @@ -13,7 +13,7 @@ import { NestRequest } from '../../types/request.type'; import { AutoGuard } from '../../auth/auto.guard'; import { Response } from 'express'; import { FitnessService } from '../../integration/fitness/fitness.service'; -import { FitBitProvider } from '../../integration/fitness/providers/fitbit.provider'; +import { FitbitProvider } from '../../integration/fitness/providers/fitbit.provider'; import { LOGGER_SERVICE, LoggerService } from '../../logger/logger.service'; @Controller('datasource') @@ -73,7 +73,7 @@ export class DatasourceController { where: { userId: request.user.id, type: params.id }, }); - res.status(204); + res.status(204).send(); } @Get('/:id/authorize') @@ -120,7 +120,7 @@ export class DatasourceController { const code = (request.query.code as string).split('#_=_')[0]; - const result = await responsibleProvider.getAccessTokenFromCode( + const result = await responsibleProvider.authorizeCallback( request.user.id, code, ); @@ -154,7 +154,7 @@ export class DatasourceController { (await this.fitnessService.getProviderForUserById( request.user.id, id, - )) as FitBitProvider | null; + )) as FitbitProvider | null; if (!responsibleProvider) { response.status(400).json({ diff --git a/backend/src/integration/fitness/fitness.service.ts b/backend/src/integration/fitness/fitness.service.ts index 8f8ff1b..9f6cd5f 100644 --- a/backend/src/integration/fitness/fitness.service.ts +++ b/backend/src/integration/fitness/fitness.service.ts @@ -1,6 +1,6 @@ import { ConfigService } from '@nestjs/config'; import { FitnessRepository } from '../../db/repositories/fitness.repository'; -import { FitBitProvider } from './providers/fitbit.provider'; +import { FitbitProvider } from './providers/fitbit.provider'; import { FitnessProvider } from './providers/provider.interface'; import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { CredentialService } from '../credentials/credential.service'; @@ -37,7 +37,7 @@ export class FitnessService { if (fitbit_client_id && fitbit_client_secret) { providers.push( - new FitBitProvider( + new FitbitProvider( this.fitnessRepository, this.credentialService, this.loggerService, diff --git a/backend/src/integration/fitness/providers/fitbit.provider.spec.ts b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts index 0130b2f..7e3a92f 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.spec.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts @@ -1,7 +1,7 @@ import { Test } from '@nestjs/testing'; import { FitnessRepository } from '../../../db/repositories/fitness.repository'; import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; -import { FitBitProvider } from './fitbit.provider'; +import { FitbitProvider } from './fitbit.provider'; import { TestConstants } from '../../../../test/lib/constants'; import { PrismaModule } from '../../../db/prisma.module'; import { CredentialService } from '../../credentials/credential.service'; @@ -10,7 +10,7 @@ import { ConsoleLogger } from '@nestjs/common'; describe('Fitbit Provider tests', () => { let fitnessRepository: DeepMockProxy; let credentialService: DeepMockProxy; - let fitbitProvider: FitBitProvider; + let fitbitProvider: FitbitProvider; beforeAll(async () => { const testModule = await Test.createTestingModule({ @@ -27,7 +27,7 @@ describe('Fitbit Provider tests', () => { testModule.get>(FitnessRepository); credentialService = testModule.get>(CredentialService); - fitbitProvider = new FitBitProvider( + fitbitProvider = new FitbitProvider( fitnessRepository, credentialService, mockDeep(), diff --git a/backend/src/integration/fitness/providers/fitbit.provider.ts b/backend/src/integration/fitness/providers/fitbit.provider.ts index a28d418..c9ddca3 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.ts @@ -17,7 +17,7 @@ type FitbitCredentials = { }; @Injectable() -export class FitBitProvider implements FitnessProvider { +export class FitbitProvider implements FitnessProvider { private FITBIT_API = 'https://api.fitbit.com'; private FITBIT_TYPE = 'fitbit'; @@ -54,6 +54,34 @@ export class FitBitProvider implements FitnessProvider { }; } + public async authorizeCallback( + user: string, + code: string, + ): Promise { + // First, exchange the code against an access token + const credentials = await this.getAccessTokenFromCode(user, code); + + // Set the user status to enabled, as we now have the credentials + this.userStatus = 'enabled'; + + // Save the credentials in the database + await this.fitnessRepository.createProvider({ + type: 'fitbit', + providerUserId: credentials.userId, + accessToken: credentials.accessToken, + accessTokenExpires: dayjs().add(credentials.expires, 'seconds').toDate(), + refreshToken: credentials.refreshToken, + enabled: true, + owner: { + connect: { + id: user, + }, + }, + }); + + return { accessToken: credentials.accessToken, userId: credentials.userId }; + } + /** * Exchanges a code recieved after an authorized to an access token * and persists the client @@ -62,10 +90,10 @@ export class FitBitProvider implements FitnessProvider { * @param code received authorization code * @returns api credentials */ - public async getAccessTokenFromCode( + private async getAccessTokenFromCode( user: string, code: string, - ): Promise { + ): Promise { const searchParams = new URLSearchParams(); searchParams.append('client_id', this.client_id); searchParams.append('code', code); @@ -91,23 +119,12 @@ export class FitBitProvider implements FitnessProvider { const { access_token, refresh_token, expires_in, user_id } = await response.json(); - this.userStatus = 'enabled'; - - await this.fitnessRepository.createProvider({ - type: 'fitbit', - providerUserId: user_id, + return { accessToken: access_token, - accessTokenExpires: dayjs().add(expires_in, 'seconds').toDate(), + userId: user_id, refreshToken: refresh_token, - enabled: true, - owner: { - connect: { - id: user, - }, - }, - }); - - return { accessToken: access_token, userId: user_id }; + expires: expires_in, + }; } /** diff --git a/backend/src/integration/fitness/providers/mock.provider.ts b/backend/src/integration/fitness/providers/mock.provider.ts index 0db2902..995f2e3 100644 --- a/backend/src/integration/fitness/providers/mock.provider.ts +++ b/backend/src/integration/fitness/providers/mock.provider.ts @@ -50,7 +50,7 @@ export class MockProvider implements FitnessProvider { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - async getAccessTokenFromCode(user: string, code: string): Promise { + async authorizeCallback(user: string, code: string): Promise { return true; } } diff --git a/backend/src/integration/fitness/providers/provider.interface.ts b/backend/src/integration/fitness/providers/provider.interface.ts index 15a9cd5..82a6f74 100644 --- a/backend/src/integration/fitness/providers/provider.interface.ts +++ b/backend/src/integration/fitness/providers/provider.interface.ts @@ -17,6 +17,6 @@ export interface FitnessProvider { ); getAuthorizeURL(): string; - getAccessTokenFromCode(user: string, code: string): Promise; + authorizeCallback(user: string, code: string): Promise; getFitnessData(user: string, start: Date, end: Date): Promise; } From 8b4ab444f07b6b92e4ac4ca01277e8a8e6d99412 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Mon, 3 Jun 2024 19:23:29 +0200 Subject: [PATCH 14/14] fix: Refactoring not completly reflected in unit tests + minor bug --- backend/src/api/datasource/datasource.controller.spec.ts | 2 ++ .../integration/fitness/providers/fitbit.provider.spec.ts | 5 ++++- .../src/integration/fitness/providers/fitbit.provider.ts | 8 ++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/src/api/datasource/datasource.controller.spec.ts b/backend/src/api/datasource/datasource.controller.spec.ts index 9dc322e..953e35c 100644 --- a/backend/src/api/datasource/datasource.controller.spec.ts +++ b/backend/src/api/datasource/datasource.controller.spec.ts @@ -73,6 +73,8 @@ describe('Datasource controller', () => { const mockResponse = mockDeep(); + mockResponse.status.mockReturnThis(); + await fitnessController.deleteDatasource( mockRequest, { id: 'MOCK' }, diff --git a/backend/src/integration/fitness/providers/fitbit.provider.spec.ts b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts index 7e3a92f..c5ccbdf 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.spec.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.spec.ts @@ -57,9 +57,12 @@ describe('Fitbit Provider tests', () => { fitnessRepository.createProvider.mockResolvedValue( TestConstants.database.fitnessCredentials.fitbit, ); + fitnessRepository.deleteProvider.mockResolvedValue( + TestConstants.database.fitnessCredentials.fitbit, + ); // Call the function - const result = await fitbitProvider.getAccessTokenFromCode('MOCK', 'MOCK'); + const result = await fitbitProvider.authorizeCallback('MOCK', 'MOCK'); // Expect that the credential have been saved in the database expect(fitnessRepository.createProvider).toHaveBeenCalledTimes(1); diff --git a/backend/src/integration/fitness/providers/fitbit.provider.ts b/backend/src/integration/fitness/providers/fitbit.provider.ts index c9ddca3..89c16a6 100644 --- a/backend/src/integration/fitness/providers/fitbit.provider.ts +++ b/backend/src/integration/fitness/providers/fitbit.provider.ts @@ -59,11 +59,16 @@ export class FitbitProvider implements FitnessProvider { code: string, ): Promise { // First, exchange the code against an access token - const credentials = await this.getAccessTokenFromCode(user, code); + const credentials = await this.getAccessTokenFromCode(code); // Set the user status to enabled, as we now have the credentials this.userStatus = 'enabled'; + // First delete any configured provider for this type and user + await this.fitnessRepository.deleteProvider({ + where: { userId: user, type: this.FITBIT_TYPE }, + }); + // Save the credentials in the database await this.fitnessRepository.createProvider({ type: 'fitbit', @@ -91,7 +96,6 @@ export class FitbitProvider implements FitnessProvider { * @returns api credentials */ private async getAccessTokenFromCode( - user: string, code: string, ): Promise { const searchParams = new URLSearchParams();