Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into dev/backend/126-tasks
Browse files Browse the repository at this point in the history
  • Loading branch information
henrybrink committed Jun 11, 2024
2 parents 5175d36 + 5e22774 commit fb5c6f6
Show file tree
Hide file tree
Showing 64 changed files with 1,091 additions and 254 deletions.
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
"main.ts"
],
"moduleNameMapper": {
"src/(.*)": "<rootDir>/src/"
"src/*": "<rootDir>/src/$1"
}
},
"prisma": {
Expand Down
10 changes: 10 additions & 0 deletions backend/prisma/migrations/20240605184619_/migration.sql
Original file line number Diff line number Diff line change
@@ -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
);
11 changes: 11 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ model User {
verified Boolean @default(false)
providers FitnessProviderCredential[]
notificationMethod String @default("EMAIL")
Points Points[]
}

model FitnessProviderCredential {
Expand All @@ -42,3 +43,13 @@ model TaskLog {
@@id([userId, task])
}

model Points {
userId String
owner User @relation(fields: [userId], references: [id])
day Int
points Int
streak Int
@@id([userId, day])
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import FitnessModule from './integration/fitness/fitness.module';
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';

@Module({
imports: [
Expand All @@ -21,6 +22,7 @@ import { TaskModule } from './app/tasks/task.module';
FitnessModule,
NotificationModule,
TaskModule,
StreakModule,
],
controllers: [AppController, UserController],
providers: [
Expand Down
15 changes: 15 additions & 0 deletions backend/src/app/streaks/streak.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 11 additions & 0 deletions backend/src/app/streaks/streak.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
112 changes: 112 additions & 0 deletions backend/src/app/streaks/streak.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<StreakRepository>;

beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [PrismaModule],
providers: [StreakService],
})
.overrideProvider(StreakRepository)
.useValue(mockDeep<StreakRepository>())
.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();
});
});
91 changes: 91 additions & 0 deletions backend/src/app/streaks/streak.service.ts
Original file line number Diff line number Diff line change
@@ -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<Streak> {
// 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,
);
}
}
11 changes: 11 additions & 0 deletions backend/src/app/streaks/streak.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type StreakHistory = {
day: number;
points: number;
streak: number;
};

export type Streak = {
points: number;
streak: number;
history: StreakHistory[];
};
18 changes: 15 additions & 3 deletions backend/src/db/prisma.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,21 @@ import { UserRepository } from './repositories/user.repository';
import { PrismaService } from './prisma.service';
import { FitnessRepository } from './repositories/fitness.repository';
import { TaskRepository } from './repositories/task.repository';

import { StreakRepository } from './repositories/streak.repository';
@Module({
providers: [PrismaService, UserRepository, FitnessRepository, TaskRepository],
exports: [PrismaService, UserRepository, FitnessRepository, TaskRepository],
providers: [
PrismaService,
UserRepository,
FitnessRepository,
StreakRepository,
TaskRepository,
],
exports: [
PrismaService,
UserRepository,
FitnessRepository,
StreakRepository,
TaskRepository,
],
})
export class PrismaModule {}
18 changes: 17 additions & 1 deletion backend/src/db/prisma.seed.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]',
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()
Expand Down
Loading

0 comments on commit fb5c6f6

Please sign in to comment.