From 06e0bc71f93728333149f95ceef0f65137cbbae4 Mon Sep 17 00:00:00 2001 From: Henry Brink Date: Mon, 10 Jun 2024 16:14:56 +0200 Subject: [PATCH] Streaks: Initial Implementation and Documentation (#222) * docs: Add API Schema for the Streak Endpoint * Add: Streak Controller * PRETTIER * Add more tests * fix: Unused variable --- backend/package.json | 2 +- .../migrations/20240605184619_/migration.sql | 10 ++ backend/prisma/schema.prisma | 11 ++ backend/src/app.module.ts | 2 + backend/src/app/streaks/streak.controller.ts | 15 +++ backend/src/app/streaks/streak.module.ts | 11 ++ .../src/app/streaks/streak.service.spec.ts | 112 +++++++++++++++++ backend/src/app/streaks/streak.service.ts | 91 ++++++++++++++ backend/src/app/streaks/streak.type.ts | 11 ++ backend/src/db/prisma.module.ts | 10 +- backend/src/db/prisma.seed.ts | 18 ++- .../src/db/repositories/streak.repository.ts | 115 ++++++++++++++++++ backend/test/lib/constants.ts | 8 +- backend/test/lib/database.constants.ts | 30 ++++- docs/public/api/schema.yaml | 37 ++++++ 15 files changed, 476 insertions(+), 7 deletions(-) create mode 100644 backend/prisma/migrations/20240605184619_/migration.sql create mode 100644 backend/src/app/streaks/streak.controller.ts create mode 100644 backend/src/app/streaks/streak.module.ts create mode 100644 backend/src/app/streaks/streak.service.spec.ts create mode 100644 backend/src/app/streaks/streak.service.ts create mode 100644 backend/src/app/streaks/streak.type.ts create mode 100644 backend/src/db/repositories/streak.repository.ts diff --git a/backend/package.json b/backend/package.json index d5fb75f..d4ec9d0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -90,7 +90,7 @@ "main.ts" ], "moduleNameMapper": { - "src/(.*)": "/src/" + "src/*": "/src/$1" } }, "prisma": { diff --git a/backend/prisma/migrations/20240605184619_/migration.sql b/backend/prisma/migrations/20240605184619_/migration.sql new file mode 100644 index 0000000..485caaf --- /dev/null +++ b/backend/prisma/migrations/20240605184619_/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "Points" ( + "userId" TEXT NOT NULL, + "day" INTEGER NOT NULL, + "points" INTEGER NOT NULL, + "streak" INTEGER NOT NULL, + + PRIMARY KEY ("userId", "day"), + CONSTRAINT "Points_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 f0555b0..bc9560e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -19,6 +19,7 @@ model User { verified Boolean @default(false) providers FitnessProviderCredential[] notificationMethod String @default("EMAIL") + Points Points[] } model FitnessProviderCredential { @@ -31,3 +32,13 @@ model FitnessProviderCredential { enabled Boolean providerUserId String } + +model Points { + userId String + owner User @relation(fields: [userId], references: [id]) + day Int + points Int + streak Int + + @@id([userId, day]) +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 392698b..e398fa8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { ConfigModule } from '@nestjs/config'; import FitnessModule from './integration/fitness/fitness.module'; import configuration from './config/configuration'; import { NotificationModule } from './notification/notification.module'; +import { StreakModule } from './app/streaks/streak.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { NotificationModule } from './notification/notification.module'; }), FitnessModule, NotificationModule, + StreakModule, ], controllers: [AppController, UserController], providers: [ diff --git a/backend/src/app/streaks/streak.controller.ts b/backend/src/app/streaks/streak.controller.ts new file mode 100644 index 0000000..c110266 --- /dev/null +++ b/backend/src/app/streaks/streak.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { StreakService } from './streak.service'; +import { AutoGuard } from '../../auth/auto.guard'; +import { NestRequest } from '../../types/request.type'; + +@Controller() +export class StreakController { + constructor(private streakService: StreakService) {} + + @Get('/user/me/streak') + @UseGuards(AutoGuard) + public async getStreakForUser(@Req() req: NestRequest) { + return await this.streakService.getStreakOf(req.user.id); + } +} diff --git a/backend/src/app/streaks/streak.module.ts b/backend/src/app/streaks/streak.module.ts new file mode 100644 index 0000000..3c2b468 --- /dev/null +++ b/backend/src/app/streaks/streak.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { StreakService } from './streak.service'; +import { PrismaModule } from '../../db/prisma.module'; +import { StreakController } from './streak.controller'; + +@Module({ + imports: [PrismaModule], + providers: [StreakService], + controllers: [StreakController], +}) +export class StreakModule {} diff --git a/backend/src/app/streaks/streak.service.spec.ts b/backend/src/app/streaks/streak.service.spec.ts new file mode 100644 index 0000000..8a6d20d --- /dev/null +++ b/backend/src/app/streaks/streak.service.spec.ts @@ -0,0 +1,112 @@ +import { Test } from '@nestjs/testing'; +import { PrismaModule } from '../../db/prisma.module'; +import { StreakService } from './streak.service'; +import { StreakRepository } from '../../db/repositories/streak.repository'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { Points } from '@prisma/client'; +import { TestConstants } from '../../../test/lib/constants'; +import * as dayjs from 'dayjs'; +import { Streak } from './streak.type'; + +describe('streak service testing', () => { + let cut: StreakService; + let streakRepository: DeepMockProxy; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [PrismaModule], + providers: [StreakService], + }) + .overrideProvider(StreakRepository) + .useValue(mockDeep()) + .compile(); + + cut = module.get(StreakService); + streakRepository = module.get(StreakRepository); + }); + + it('should compute a streak when there is an entry for today', async () => { + const today = parseInt(dayjs().format('YYMMDD')); + const yesterday = parseInt(dayjs().subtract(1, 'days').format('YYMMDD')); + + // Mock streak, two days in a row + streakRepository.getStreakHistory.mockResolvedValue([ + { + userId: TestConstants.database.users.exampleUser.id, + points: 1, + streak: 1, + day: today, + }, + { + userId: TestConstants.database.users.exampleUser.id, + points: 1, + streak: 0, + day: yesterday, + }, + ] as Points[]); + + const streak = await cut.getStreakOf( + TestConstants.database.users.exampleUser.id, + ); + + expect(streak).toStrictEqual({ + points: 1, + streak: 1, + history: [ + { + points: 1, + streak: 1, + day: today, + }, + { + points: 1, + streak: 0, + day: yesterday, + }, + ], + } as Streak); + }); + + it('should not show a streak if the user lost the streak', async () => { + // Mock streak, two days in a row + streakRepository.getStreakHistory.mockResolvedValue([ + TestConstants.database.points.streakTwoDaysAgo, + ] as Points[]); + + const streak = await cut.getStreakOf( + TestConstants.database.users.exampleUser.id, + ); + + expect(streak).toStrictEqual({ + points: 0, + streak: 0, + history: [], + } as Streak); + }); + + it('should create a new points entry when there is none', async () => { + streakRepository.getStreakHistory.mockResolvedValue([]); + streakRepository.createStreak.mockResolvedValue( + TestConstants.database.points.noStreakToday, + ); + + // Add points + await cut.addPoints(TestConstants.database.users.exampleUser.id, 10); + + expect(streakRepository.createStreak).toHaveBeenCalled(); + }); + + it('should update the points of the entry when there is one', async () => { + streakRepository.getStreakHistory.mockResolvedValue([ + TestConstants.database.points.noStreakToday, + ]); + streakRepository.updatePoints.mockResolvedValue( + TestConstants.database.points.noStreakToday, + ); + + // Add points + await cut.addPoints(TestConstants.database.users.exampleUser.id, 10); + + expect(streakRepository.updatePoints).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/app/streaks/streak.service.ts b/backend/src/app/streaks/streak.service.ts new file mode 100644 index 0000000..57e1206 --- /dev/null +++ b/backend/src/app/streaks/streak.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import { Streak, StreakHistory } from './streak.type'; +import * as dayjs from 'dayjs'; +import { StreakRepository } from '../../db/repositories/streak.repository'; + +@Injectable() +export class StreakService { + constructor(private repository: StreakRepository) {} + + /** + * Returns the streak information for a specific user + * + * @param user + * @returns + */ + public async getStreakOf(user: string): Promise { + // Retrieve the n-last streak entries for the user + const streakEntries = await this.repository.getStreakHistory(user); + + // If there is no entry for the current day, the points are set to 0 + let points = 0; + // Same applies to the streak value + let streak = 0; + let isStreak = false; + + if (streakEntries.length > 0) { + // Verify that the streak is still active + const currentDay = parseInt(dayjs().format('YYMMDD')); + + if (streakEntries[0].day == currentDay) { + isStreak = true; + points = streakEntries[0].points; + streak = streakEntries[0].streak; + } + } + + return { + points, + streak, + history: isStreak + ? streakEntries.map((s) => { + return { + day: s.day, + points: s.points, + streak: s.streak, + } as StreakHistory; + }) + : [], + }; + } + + /** + * Adds points for the current user + * + * @param user + * @param points + */ + public async addPoints(user: string, points: number) { + const history = await this.repository.getStreakHistory(user, 0, 1); + + if (history.length == 0) { + await this.createStreak(user, points, 0); + return; + } + + const today = parseInt(dayjs().format('YYMMDD')); + const yesterday = parseInt(dayjs().subtract(1, 'day').format('YYMMDD')); + + if (history[0].day == today) { + this.repository.updatePoints(user, today, history[0].points + points); + } else if (history[0].day == yesterday) { + this.repository.createStreak(user, points, history[0].streak); + } + } + + /** + * Helper to create a streak for today + * + * @param user + * @param points + * @param streak + */ + private async createStreak(user: string, points: number, streak = 0) { + await this.repository.createStreak( + user, + parseInt(dayjs().format('YYMMDD')), + points, + streak, + ); + } +} diff --git a/backend/src/app/streaks/streak.type.ts b/backend/src/app/streaks/streak.type.ts new file mode 100644 index 0000000..95e8268 --- /dev/null +++ b/backend/src/app/streaks/streak.type.ts @@ -0,0 +1,11 @@ +export type StreakHistory = { + day: number; + points: number; + streak: number; +}; + +export type Streak = { + points: number; + streak: number; + history: StreakHistory[]; +}; diff --git a/backend/src/db/prisma.module.ts b/backend/src/db/prisma.module.ts index cab7aaa..02431c2 100644 --- a/backend/src/db/prisma.module.ts +++ b/backend/src/db/prisma.module.ts @@ -2,9 +2,15 @@ import { Module } from '@nestjs/common'; import { UserRepository } from './repositories/user.repository'; import { PrismaService } from './prisma.service'; import { FitnessRepository } from './repositories/fitness.repository'; +import { StreakRepository } from './repositories/streak.repository'; @Module({ - providers: [PrismaService, UserRepository, FitnessRepository], - exports: [PrismaService, UserRepository, FitnessRepository], + providers: [ + PrismaService, + UserRepository, + FitnessRepository, + StreakRepository, + ], + exports: [PrismaService, UserRepository, FitnessRepository, StreakRepository], }) export class PrismaModule {} diff --git a/backend/src/db/prisma.seed.ts b/backend/src/db/prisma.seed.ts index f1d43f4..7aa4189 100644 --- a/backend/src/db/prisma.seed.ts +++ b/backend/src/db/prisma.seed.ts @@ -1,17 +1,33 @@ import { PrismaClient } from '@prisma/client'; import { hashSync } from 'bcrypt'; +import { randomInt } from 'crypto'; +import * as dayjs from 'dayjs'; const prisma = new PrismaClient(); async function main() { // Regular user - await prisma.user.create({ + const testUser1 = await prisma.user.create({ data: { displayName: 'Max Mustermann', email: 'max@example.org', password: hashSync('1234', 1), }, }); + + // Create a streak history for the user + const dayAsNumber = parseInt(dayjs().format('YYMMDD')); + + for (let i = 0; i < 10; i++) { + await prisma.points.create({ + data: { + userId: testUser1.id, + day: dayAsNumber - i, + points: randomInt(0, 15), + streak: 10 - i, + }, + }); + } } main() diff --git a/backend/src/db/repositories/streak.repository.ts b/backend/src/db/repositories/streak.repository.ts new file mode 100644 index 0000000..1d34062 --- /dev/null +++ b/backend/src/db/repositories/streak.repository.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma.service'; +import { Points, Prisma } from '@prisma/client'; + +@Injectable() +export class StreakRepository { + constructor(private prisma: PrismaService) {} + + /** + * Retrieves the complete streak history for the user + * Supports paging via skip and limit, default limit is 10 entries + * The history entries are sorted descending by date + * + * @param user + * @param skip Offset, default 0 + * @param limit Maximum number of history, default 10 + * @returns + */ + public async getStreakHistory( + user: string, + skip = 0, + limit = 10, + ): Promise { + return await this.prisma.points.findMany({ + where: { + userId: user, + }, + orderBy: { day: 'desc' }, + skip: skip, + take: limit, + }); + } + + /** + * Adds a new streak to the users history + * + * @param user + * @param day + * @param points + * @returns + */ + public async createStreak( + user: string, + day: number, + points: number, + streak = 0, + ): Promise { + return await this.prisma.points.create({ + data: { + userId: user, + points, + day, + streak, + }, + }); + } + + /** + * Updates the points of a streak + * + * @param user + * @param day + * @param points + * @returns + */ + public async updatePoints( + user: string, + day: number, + points: number, + ): Promise { + return await this.prisma.points.update({ + where: { + userId_day: { + userId: user, + day: day, + }, + }, + data: { + points, + }, + }); + } + + /** + * Removes a single streak entry + * + * @param user + * @param day + * @returns + */ + public async deleteStreak(user: string, day: number): Promise { + return await this.prisma.points.delete({ + where: { + userId_day: { + userId: user, + day, + }, + }, + }); + } + + /** + * Removes the entire streak history of the user + * + * @param user + * @returns + */ + public async cleanupHistory(user: string): Promise { + return await this.prisma.points.deleteMany({ + where: { + userId: user, + }, + }); + } +} diff --git a/backend/test/lib/constants.ts b/backend/test/lib/constants.ts index db2857e..b69df11 100644 --- a/backend/test/lib/constants.ts +++ b/backend/test/lib/constants.ts @@ -1,5 +1,9 @@ -import { FitnessCredetials, Users } from './database.constants'; +import { FitnessCredetials, PointEntries, Users } from './database.constants'; export const TestConstants = { - database: { users: Users, fitnessCredentials: FitnessCredetials }, + database: { + users: Users, + fitnessCredentials: FitnessCredetials, + points: PointEntries, + }, }; diff --git a/backend/test/lib/database.constants.ts b/backend/test/lib/database.constants.ts index ebee4e0..eb92b15 100644 --- a/backend/test/lib/database.constants.ts +++ b/backend/test/lib/database.constants.ts @@ -1,4 +1,5 @@ -import { FitnessProviderCredential, User } from '@prisma/client'; +import { FitnessProviderCredential, Points, User } from '@prisma/client'; +import * as dayjs from 'dayjs'; export const Users: { exampleUser: User } = { exampleUser: { @@ -23,3 +24,30 @@ export const FitnessCredetials = { providerUserId: 'MOCK_PUID', } as FitnessProviderCredential, }; + +export const PointEntries = { + streakToday: { + userId: Users.exampleUser.id, + day: parseInt(dayjs().format('YYMMDD')), + points: 10, + streak: 1, + } as Points, + streakYesterday: { + userId: Users.exampleUser.id, + day: parseInt(dayjs().subtract(1, 'day').format('YYMMDD')), + points: 10, + streak: 0, + } as Points, + streakTwoDaysAgo: { + userId: Users.exampleUser.id, + day: parseInt(dayjs().subtract(2, 'days').format('YYMMDD')), + points: 10, + streak: 0, + } as Points, + noStreakToday: { + userId: Users.exampleUser.id, + day: parseInt(dayjs().format('YYMMDD')), + points: 10, + streak: 0, + }, +}; diff --git a/docs/public/api/schema.yaml b/docs/public/api/schema.yaml index 1e07fe8..8cba861 100644 --- a/docs/public/api/schema.yaml +++ b/docs/public/api/schema.yaml @@ -63,6 +63,43 @@ paths: description: Invalid content '403': description: Not allowed to change attribute + '/user/me/streak': + get: + summary: Displays information about the Streak of the user + operationId: streakUser + tags: + - User + responses: + '200': + description: Streak of the user + content: + application/json: + schema: + type: object + properties: + points: + type: number + streak: + type: number + history: + type: array + items: + type: object + properties: + day: + type: string + points: + type: number + streak: + type: number + example: + points: 20 + streak: 1 + history: + - day: '2024-06-04' + points: 10 + streak: 0 + '/datasource': get: summary: Get a list of all connected data sources for the current user