diff --git a/backend/prisma/migrations/20240614204325_/migration.sql b/backend/prisma/migrations/20240614204325_/migration.sql new file mode 100644 index 0000000..6d9ddd7 --- /dev/null +++ b/backend/prisma/migrations/20240614204325_/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "Goal" ( + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "source" TEXT NOT NULL, + "target" REAL NOT NULL, + "value" REAL NOT NULL, + "metric" TEXT NOT NULL, + "synced" DATETIME NOT NULL, + + PRIMARY KEY ("userId", "type"), + CONSTRAINT "Goal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8f12abd..d81ec50 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -20,6 +20,7 @@ model User { providers FitnessProviderCredential[] notificationMethod String @default("EMAIL") Points Points[] + Goal Goal[] } model FitnessProviderCredential { @@ -53,3 +54,16 @@ model Points { @@id([userId, day]) } + +model Goal { + userId String + owner User @relation(fields: [userId], references: [id]) + type String + source String + target Float + value Float + metric String + synced DateTime + + @@id([userId, type]) +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d286b60..a988dee 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,7 @@ import configuration from './config/configuration'; import { NotificationModule } from './notification/notification.module'; import { TaskModule } from './app/tasks/task.module'; import { StreakModule } from './app/streaks/streak.module'; +import { GoalModule } from './app/goals/goal.module'; @Module({ imports: [ @@ -23,6 +24,7 @@ import { StreakModule } from './app/streaks/streak.module'; NotificationModule, TaskModule, StreakModule, + GoalModule, ], controllers: [AppController, UserController], providers: [ diff --git a/backend/src/app/goals/goal.controller.ts b/backend/src/app/goals/goal.controller.ts new file mode 100644 index 0000000..3e86ac0 --- /dev/null +++ b/backend/src/app/goals/goal.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; +import { NestRequest } from '../../types/request.type'; +import { Response } from 'express'; +import { FitnessDataNotAvailable, GoalService } from './goal.service'; +import { AutoGuard } from '../../auth/auto.guard'; +import { Goal } from '@prisma/client'; + +@Controller('/goal') +export class GoalController { + constructor(private goalService: GoalService) {} + + private transformGoal(goal: Goal) { + return { + type: goal.type, + creator: goal.source, + target: goal.target, + value: goal.value, + metric: goal.metric, + }; + } + + @Get('/') + @UseGuards(AutoGuard) + public async get(@Req() request: NestRequest, @Res() response: Response) { + try { + const goals = await this.goalService.getGoalsForUser(request.user.id); + + return response.status(200).json(goals.map((g) => this.transformGoal(g))); + } catch (e) { + if (e instanceof FitnessDataNotAvailable) { + return response + .status(400) + .json({ error: 'No Fitness Provider is available' }); + } + + if (e instanceof Error) { + console.log(e.stack); + } + + return response.status(500).json({ error: 'Unknown error' }); + } + } +} diff --git a/backend/src/app/goals/goal.module.ts b/backend/src/app/goals/goal.module.ts new file mode 100644 index 0000000..7f14d77 --- /dev/null +++ b/backend/src/app/goals/goal.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../db/prisma.module'; +import { GoalService } from './goal.service'; +import { GoalController } from './goal.controller'; +import FitnessModule from '../../integration/fitness/fitness.module'; + +@Module({ + imports: [PrismaModule, FitnessModule], + providers: [GoalService], + controllers: [GoalController], +}) +export class GoalModule {} diff --git a/backend/src/app/goals/goal.service.spec.ts b/backend/src/app/goals/goal.service.spec.ts new file mode 100644 index 0000000..d2bb83e --- /dev/null +++ b/backend/src/app/goals/goal.service.spec.ts @@ -0,0 +1,93 @@ +import { Test } from '@nestjs/testing'; +import { GoalService } from './goal.service'; +import { GoalRepository } from '../../db/repositories/goal.repository'; +import { FitnessService } from '../../integration/fitness/fitness.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { Goal } from '@prisma/client'; +import { TestConstants } from '../../../test/lib/constants'; +import * as dayjs from 'dayjs'; +import { FitnessData } from '../../integration/fitness/fitness.data'; +import { FitnessGoal } from '../../integration/fitness/fitness.goal'; + +describe('GoalService', () => { + let goalService: GoalService; + let goalRepository: DeepMockProxy; + let fitnessService: DeepMockProxy; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [GoalService, GoalRepository, FitnessService], + }) + .overrideProvider(GoalRepository) + .useValue(mockDeep()) + .overrideProvider(FitnessService) + .useValue(mockDeep()) + .compile(); + + goalService = moduleRef.get(GoalService); + goalRepository = moduleRef.get(GoalRepository); + fitnessService = moduleRef.get(FitnessService); + }); + + describe('getGoalsForUser', () => { + it('should return cached goals, if they are not too old', async () => { + const goal = { + userId: TestConstants.database.users.exampleUser.id, + type: 'steps', + target: 200, + value: 100, + metric: 'steps', + synced: dayjs().subtract(10, 'minutes').toDate(), + } as Goal; + + goalRepository.getGoals.mockResolvedValue([goal]); + + const goals = await goalService.getGoalsForUser( + TestConstants.database.users.exampleUser.id, + ); + + expect(goals.length).toBe(1); + expect(goals[0]).toStrictEqual(goal); + }); + + it('should refresh goals if the old goals are to old', async () => { + const goal = { + userId: TestConstants.database.users.exampleUser.id, + type: 'steps', + target: 200, + value: 100, + metric: 'steps', + synced: dayjs().subtract(2, 'hours').toDate(), + } as Goal; + + goalRepository.getGoals.mockResolvedValue([goal]); + + fitnessService.getFitnessDataForUser.mockResolvedValue({ + goals: [ + { + type: 'steps', + goal: 200, + value: 100, + unit: 1, + } as FitnessGoal, + ], + } as FitnessData); + + goalRepository.updateGoal.mockResolvedValue({ + userId: TestConstants.database.users.exampleUser.id, + type: 'steps', + target: 200, + value: 100, + metric: 'steps', + synced: dayjs().subtract(2, 'hours').toDate(), + } as Goal); + + const goals = await goalService.getGoalsForUser( + TestConstants.database.users.exampleUser.id, + ); + + expect(goals.length).toBe(1); + expect(goals[0]).toStrictEqual(goal); + }); + }); +}); diff --git a/backend/src/app/goals/goal.service.ts b/backend/src/app/goals/goal.service.ts new file mode 100644 index 0000000..5ac16a9 --- /dev/null +++ b/backend/src/app/goals/goal.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { GoalRepository } from '../../db/repositories/goal.repository'; +import { Goal, Prisma } from '@prisma/client'; +import * as dayjs from 'dayjs'; +import { FitnessService } from '../../integration/fitness/fitness.service'; + +export class FitnessDataNotAvailable extends Error {} + +@Injectable() +export class GoalService { + constructor( + private goalRepository: GoalRepository, + private fitnessService: FitnessService, + ) {} + + private atLeastOneGoalIsTooOld(goals: Goal[]): boolean { + for (const goal of goals) { + if (dayjs().subtract(1, 'hour').isAfter(goal.synced)) { + return true; + } + } + + return false; + } + + public async refreshGoals(userId, existing: Goal[]): Promise { + const fitnessData = await this.fitnessService.getFitnessDataForUser(userId); + + if (!fitnessData) { + throw new FitnessDataNotAvailable(); + } + + const goals: Goal[] = []; + + for (const rawGoal of fitnessData.goals) { + if (!existing.find((g) => g.type == rawGoal.type)) { + const goal = await this.goalRepository.createGoal({ + owner: { connect: { id: userId } }, + type: rawGoal.type, + source: 'provider', + value: rawGoal.value, + target: rawGoal.goal, + metric: rawGoal.unit.toString(), + synced: new Date(), + } as Prisma.GoalCreateInput); + + goals.push(goal); + } else { + const goal = await this.goalRepository.updateGoal( + userId, + rawGoal.type, + { + value: rawGoal.value, + target: rawGoal.goal, + metric: rawGoal.unit.toString(), + synced: new Date(), + } as Prisma.GoalCreateInput, + ); + + goals.push(goal); + } + } + + return goals; + } + + public async getGoalsForUser(userId): Promise { + // Get goals from the database + const goals = await this.goalRepository.getGoals(userId); + + if (goals.length < 1 || this.atLeastOneGoalIsTooOld(goals)) { + return await this.refreshGoals(userId, goals); + } + + return goals; + } +} diff --git a/backend/src/db/prisma.module.ts b/backend/src/db/prisma.module.ts index 9793b9f..af88847 100644 --- a/backend/src/db/prisma.module.ts +++ b/backend/src/db/prisma.module.ts @@ -4,6 +4,7 @@ import { PrismaService } from './prisma.service'; import { FitnessRepository } from './repositories/fitness.repository'; import { TaskRepository } from './repositories/task.repository'; import { StreakRepository } from './repositories/streak.repository'; +import { GoalRepository } from './repositories/goal.repository'; @Module({ providers: [ PrismaService, @@ -11,6 +12,7 @@ import { StreakRepository } from './repositories/streak.repository'; FitnessRepository, StreakRepository, TaskRepository, + GoalRepository, ], exports: [ PrismaService, @@ -18,6 +20,7 @@ import { StreakRepository } from './repositories/streak.repository'; FitnessRepository, StreakRepository, TaskRepository, + GoalRepository, ], }) export class PrismaModule {} diff --git a/backend/src/db/repositories/goal.repository.ts b/backend/src/db/repositories/goal.repository.ts new file mode 100644 index 0000000..ba74e38 --- /dev/null +++ b/backend/src/db/repositories/goal.repository.ts @@ -0,0 +1,46 @@ +import { Goal, Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class GoalRepository { + constructor(private client: PrismaService) {} + + public async getGoals(userId: string): Promise { + return await this.client.goal.findMany({ + where: { + userId, + }, + }); + } + + public async updateGoal( + userId: string, + type: string, + goal: Prisma.GoalUpdateInput, + ): Promise { + return await this.client.goal.update({ + where: { + userId_type: { + userId, + type, + }, + }, + data: goal, + }); + } + + public async createGoal(goal: Prisma.GoalCreateInput): Promise { + return await this.client.goal.create({ + data: goal, + }); + } + + public async deleteGoalsForUser(userId): Promise { + return await this.client.goal.deleteMany({ + where: { + userId, + }, + }); + } +}