diff --git a/backend/.env.example b/backend/.env.example index 56fef94..542a838 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,4 +2,6 @@ DATABASE_URL=file:./database.dev.sqlite EMAIL_HOST= EMAIL_USER= -EMAIL_PASS= \ No newline at end of file +EMAIL_PASS= + +WEBCRON_TOKEN= \ No newline at end of file diff --git a/backend/prisma/migrations/20240618115304_/migration.sql b/backend/prisma/migrations/20240618115304_/migration.sql new file mode 100644 index 0000000..08837a7 --- /dev/null +++ b/backend/prisma/migrations/20240618115304_/migration.sql @@ -0,0 +1,17 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Points" ( + "userId" TEXT NOT NULL, + "day" INTEGER NOT NULL, + "points" INTEGER NOT NULL, + "streak" INTEGER NOT NULL, + "goalReached" BOOLEAN NOT NULL DEFAULT false, + + PRIMARY KEY ("userId", "day"), + CONSTRAINT "Points_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Points" ("day", "points", "streak", "userId") SELECT "day", "points", "streak", "userId" FROM "Points"; +DROP TABLE "Points"; +ALTER TABLE "new_Points" RENAME TO "Points"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d81ec50..d50d828 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -46,11 +46,12 @@ model TaskLog { } model Points { - userId String - owner User @relation(fields: [userId], references: [id]) - day Int - points Int - streak Int + userId String + owner User @relation(fields: [userId], references: [id]) + day Int + points Int + streak Int + goalReached Boolean @default(false) @@id([userId, day]) } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a988dee..b87bd02 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ 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'; +import { WebcronModule } from './app/webcron/webcron.module'; @Module({ imports: [ @@ -25,6 +26,7 @@ import { GoalModule } from './app/goals/goal.module'; TaskModule, StreakModule, GoalModule, + WebcronModule, ], controllers: [AppController, UserController], providers: [ diff --git a/backend/src/app/goals/goal.module.ts b/backend/src/app/goals/goal.module.ts index 7f14d77..f4b392c 100644 --- a/backend/src/app/goals/goal.module.ts +++ b/backend/src/app/goals/goal.module.ts @@ -7,6 +7,7 @@ import FitnessModule from '../../integration/fitness/fitness.module'; @Module({ imports: [PrismaModule, FitnessModule], providers: [GoalService], + exports: [GoalService], controllers: [GoalController], }) export class GoalModule {} diff --git a/backend/src/app/streaks/streak.module.ts b/backend/src/app/streaks/streak.module.ts index 39a4cbe..3d8b514 100644 --- a/backend/src/app/streaks/streak.module.ts +++ b/backend/src/app/streaks/streak.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { StreakService } from './streak.service'; import { PrismaModule } from '../../db/prisma.module'; import { StreakController } from './streak.controller'; +import { GoalModule } from '../goals/goal.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, GoalModule], providers: [StreakService], controllers: [StreakController], exports: [StreakService], diff --git a/backend/src/app/streaks/streak.service.spec.ts b/backend/src/app/streaks/streak.service.spec.ts index 8a6d20d..1823658 100644 --- a/backend/src/app/streaks/streak.service.spec.ts +++ b/backend/src/app/streaks/streak.service.spec.ts @@ -7,6 +7,7 @@ import { Points } from '@prisma/client'; import { TestConstants } from '../../../test/lib/constants'; import * as dayjs from 'dayjs'; import { Streak } from './streak.type'; +import { GoalModule } from '../goals/goal.module'; describe('streak service testing', () => { let cut: StreakService; @@ -14,7 +15,7 @@ describe('streak service testing', () => { beforeAll(async () => { const module = await Test.createTestingModule({ - imports: [PrismaModule], + imports: [PrismaModule, GoalModule], providers: [StreakService], }) .overrideProvider(StreakRepository) @@ -64,6 +65,7 @@ describe('streak service testing', () => { day: yesterday, }, ], + dailyGoalsReached: false, } as Streak); }); @@ -81,6 +83,7 @@ describe('streak service testing', () => { points: 0, streak: 0, history: [], + dailyGoalsReached: false, } as Streak); }); diff --git a/backend/src/app/streaks/streak.service.ts b/backend/src/app/streaks/streak.service.ts index dccadb6..3e5cb39 100644 --- a/backend/src/app/streaks/streak.service.ts +++ b/backend/src/app/streaks/streak.service.ts @@ -2,10 +2,16 @@ import { Injectable } from '@nestjs/common'; import { Streak, StreakHistory } from './streak.type'; import * as dayjs from 'dayjs'; import { StreakRepository } from '../../db/repositories/streak.repository'; +import { GoalService } from '../goals/goal.service'; +import { UserRepository } from '../../db/repositories/user.repository'; @Injectable() export class StreakService { - constructor(private repository: StreakRepository) {} + constructor( + private repository: StreakRepository, + private goalService: GoalService, + private userRepository: UserRepository, + ) {} /** * Returns the streak information for a specific user @@ -22,6 +28,7 @@ export class StreakService { // Same applies to the streak value let streak = 0; let isStreak = false; + let dailyGoalsReached = false; if (streakEntries.length > 0) { // Verify that the streak is still active @@ -31,6 +38,7 @@ export class StreakService { isStreak = true; points = streakEntries[0].points; streak = streakEntries[0].streak; + dailyGoalsReached = streakEntries[0].goalReached == true; } } @@ -46,6 +54,7 @@ export class StreakService { } as StreakHistory; }) : [], + dailyGoalsReached: dailyGoalsReached, }; } @@ -55,7 +64,11 @@ export class StreakService { * @param user * @param points */ - public async addPoints(user: string, points: number) { + public async addPoints( + user: string, + points: number, + dailyGoalReached = false, + ) { const history = await this.repository.getStreakHistory(user, 0, 1); if (history.length == 0) { @@ -67,11 +80,22 @@ export class StreakService { const yesterday = parseInt(dayjs().subtract(1, 'day').format('YYMMDD')); if (history[0].day == today) { - this.repository.updatePoints(user, today, history[0].points + points); + this.repository.updatePoints( + user, + today, + history[0].points + points, + dailyGoalReached, + ); } else if (history[0].day == yesterday) { - this.repository.createStreak(user, points, history[0].streak); + this.repository.createStreak( + user, + today, + points, + history[0].streak, + dailyGoalReached, + ); } else { - this.createStreak(user, points, 0); + this.createStreak(user, points); } } @@ -90,4 +114,40 @@ export class StreakService { streak, ); } + + /** + * Verifies if a single user has met at least one of his goals + * + * @param user ID of the user + */ + private async verifyDailyGoalsForUser(user: string) { + // Verify that the daily goals have not been met yet. + const streak = await this.getStreakOf(user); + + if (streak.dailyGoalsReached == true) { + return; + } + + const goals = await this.goalService.getGoalsForUser(user); + + if (goals.length > 0) { + for (const goal of goals) { + if (goal.value > goal.target) { + await this.addPoints(user, 10, true); + } + } + } + } + + /** + * Verifies the goals for all users + * + */ + public async verifyGoalsOfUsers() { + const users = await this.userRepository.findAllEnabledUsers(); + + for (const user of users) { + await this.verifyDailyGoalsForUser(user.id); + } + } } diff --git a/backend/src/app/streaks/streak.type.ts b/backend/src/app/streaks/streak.type.ts index 95e8268..e3ff39a 100644 --- a/backend/src/app/streaks/streak.type.ts +++ b/backend/src/app/streaks/streak.type.ts @@ -8,4 +8,5 @@ export type Streak = { points: number; streak: number; history: StreakHistory[]; + dailyGoalsReached?: boolean; }; diff --git a/backend/src/app/webcron/webcron.controller.ts b/backend/src/app/webcron/webcron.controller.ts new file mode 100644 index 0000000..6fec8ce --- /dev/null +++ b/backend/src/app/webcron/webcron.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get, Param, Res } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Response } from 'express'; +import { StreakService } from '../streaks/streak.service'; +import { NestRequest } from '../../types/request.type'; + +@Controller('/webcron') +export class WebcronController { + constructor( + private configService: ConfigService, + private streakService: StreakService, + ) {} + + @Get('/execute/:token') + public async execute( + @Res() request: NestRequest, + @Param('token') token: string, + @Res() response: Response, + ) { + // This endpoint can be only called when a token was set + const actualToken = this.configService.get('WEBCRON_TOKEN'); + + if (!actualToken || actualToken == '' || actualToken == ' ') { + return response.status(403).json({ error: 'Unauthorized' }); + } + + if (!token || actualToken != token) { + return response.status(403).json({ error: 'Unauthorized' }); + } + + // If the token was verified, execute the desired functions + setTimeout(() => { + try { + this.streakService.verifyGoalsOfUsers(); + } catch (e) {} + }, 0); + + response.status(204).json({}); + } +} diff --git a/backend/src/app/webcron/webcron.module.ts b/backend/src/app/webcron/webcron.module.ts new file mode 100644 index 0000000..b172faa --- /dev/null +++ b/backend/src/app/webcron/webcron.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { WebcronController } from './webcron.controller'; +import { StreakModule } from '../streaks/streak.module'; +import { ConfigModule } from '@nestjs/config'; +import configuration from '../../config/configuration'; + +@Module({ + imports: [ + StreakModule, + ConfigModule.forRoot({ + load: [configuration], + }), + ], + exports: [], + controllers: [WebcronController], +}) +export class WebcronModule {} diff --git a/backend/src/db/repositories/streak.repository.ts b/backend/src/db/repositories/streak.repository.ts index 1d34062..71f71de 100644 --- a/backend/src/db/repositories/streak.repository.ts +++ b/backend/src/db/repositories/streak.repository.ts @@ -44,6 +44,7 @@ export class StreakRepository { day: number, points: number, streak = 0, + dailyGoalReached = false, ): Promise { return await this.prisma.points.create({ data: { @@ -51,6 +52,7 @@ export class StreakRepository { points, day, streak, + goalReached: dailyGoalReached, }, }); } @@ -67,6 +69,7 @@ export class StreakRepository { user: string, day: number, points: number, + dailyGoalReached = false, ): Promise { return await this.prisma.points.update({ where: { @@ -77,6 +80,9 @@ export class StreakRepository { }, data: { points, + // There is only the option to set this, but not to unset this, so that the goalReached + // is not deleted, when a task log is verified. + goalReached: dailyGoalReached ? dailyGoalReached : undefined, }, }); } diff --git a/backend/src/db/repositories/user.repository.ts b/backend/src/db/repositories/user.repository.ts index c464b88..b22811a 100644 --- a/backend/src/db/repositories/user.repository.ts +++ b/backend/src/db/repositories/user.repository.ts @@ -73,4 +73,17 @@ export class UserRepository { where, }); } + + /** + * Returns all enabled users + * + * @returns All enabled users + */ + public async findAllEnabledUsers(): Promise { + return await this.prisma.user.findMany({ + where: { + enabled: true, + }, + }); + } } diff --git a/backend/test/lib/database.constants.ts b/backend/test/lib/database.constants.ts index 2f1c185..1699215 100644 --- a/backend/test/lib/database.constants.ts +++ b/backend/test/lib/database.constants.ts @@ -42,18 +42,21 @@ export const PointEntries = { day: parseInt(dayjs().subtract(1, 'day').format('YYMMDD')), points: 10, streak: 0, + goalReached: false, } as Points, streakTwoDaysAgo: { userId: Users.exampleUser.id, day: parseInt(dayjs().subtract(2, 'days').format('YYMMDD')), points: 10, streak: 0, + goalReached: false, } as Points, noStreakToday: { userId: Users.exampleUser.id, day: parseInt(dayjs().format('YYMMDD')), points: 10, streak: 0, + goalReached: false, }, };