-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Streaks: Initial Implementation and Documentation (#222)
* docs: Add API Schema for the Streak Endpoint * Add: Streak Controller * PRETTIER * Add more tests * fix: Unused variable
- Loading branch information
1 parent
8aac149
commit 06e0bc7
Showing
15 changed files
with
476 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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[]; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
|
Oops, something went wrong.