diff --git a/app/src/dailyEvalCount/dailyEvalCount.dto.ts b/app/src/dailyEvalCount/dailyEvalCount.dto.ts new file mode 100644 index 00000000..ed4577f6 --- /dev/null +++ b/app/src/dailyEvalCount/dailyEvalCount.dto.ts @@ -0,0 +1,13 @@ +export type FindEvalCountByDateInput = { + start: Date; + end: Date; +}; + +export type FindUserEvalCountByDateInput = FindEvalCountByDateInput & { + userId: number; +}; + +export type FindEvalCountByDateOutput = { + date: Date; + count: number; +}; diff --git a/app/src/dailyEvalCount/dailyEvalCount.module.ts b/app/src/dailyEvalCount/dailyEvalCount.module.ts new file mode 100644 index 00000000..1d62a44d --- /dev/null +++ b/app/src/dailyEvalCount/dailyEvalCount.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { DailyEvalCountService } from './dailyEvalCount.service'; +import { DailyEvalCountDaoImpl } from './db/dailyEvalCount.database.dao'; +import { + dailyUserEvalCountSchema, + mv_daily_user_scale_team_counts, +} from './db/dailyEvalCount.database.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: mv_daily_user_scale_team_counts.name, + schema: dailyUserEvalCountSchema, + }, + ]), + ], + providers: [DailyEvalCountService, DailyEvalCountDaoImpl], + exports: [DailyEvalCountService], +}) +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class DailyEvalCountModule {} diff --git a/app/src/dailyEvalCount/dailyEvalCount.service.spec.ts b/app/src/dailyEvalCount/dailyEvalCount.service.spec.ts new file mode 100644 index 00000000..7050f3ba --- /dev/null +++ b/app/src/dailyEvalCount/dailyEvalCount.service.spec.ts @@ -0,0 +1,91 @@ +import { Test } from '@nestjs/testing'; +import type { FindEvalCountByDateOutput } from './dailyEvalCount.dto'; +import { DailyEvalCountService } from './dailyEvalCount.service'; +import { + DailyEvalCountDaoImpl, + type DailyEvalCountDao, +} from './db/dailyEvalCount.database.dao'; + +describe(DailyEvalCountService.name, () => { + let dailyEvalCountService: DailyEvalCountService; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [DailyEvalCountService], + }) + .useMocker((token) => { + if (token === DailyEvalCountDaoImpl) { + return { + findEvalCountsByDate: async ({ + start, + end, + }): Promise => { + return [ + { + date: new Date('2022-12-31T15:00:00.000Z'), + count: 1, + }, + { + date: new Date('2023-01-31T15:00:00.000Z'), + count: 2, + }, + { + date: new Date('2023-06-31T15:00:00.000Z'), + count: 3, + }, + { + date: new Date('2024-05-31T15:00:00.000Z'), + count: 4, + }, + ].filter(({ date }) => date >= start && date <= end); + }, + findUserEvalCountsByDatePerMonth: async ({ + start, + end, + }): Promise => { + return [ + { + date: new Date('2022-12-31T15:00:00.000Z'), + count: 1, + }, + { + date: new Date('2023-01-31T15:00:00.000Z'), + count: 2, + }, + { + date: new Date('2023-06-31T15:00:00.000Z'), + count: 3, + }, + { + date: new Date('2024-05-31T15:00:00.000Z'), + count: 4, + }, + ].filter(({ date }) => date >= start && date <= end); + }, + } satisfies DailyEvalCountDao; + } + }) + .compile(); + + dailyEvalCountService = moduleRef.get( + DailyEvalCountService, + ); + }); + + describe('evalCountRecordsByDate', () => { + const testDate = { + start: new Date('2022-12-31T15:00:00.000Z'), + end: new Date('2023-12-31T15:00:00.000Z'), + }; + + it('should return eval count records', async () => { + const evalCountRecords = + await dailyEvalCountService.evalCountRecordsByDate(testDate); + + evalCountRecords.forEach(({ at, value }) => { + expect(isNaN(new Date(at).getTime())).toBe(false); + expect(typeof value).toBe('number'); + }); + }); + }); +}); diff --git a/app/src/dailyEvalCount/dailyEvalCount.service.ts b/app/src/dailyEvalCount/dailyEvalCount.service.ts new file mode 100644 index 00000000..bae65ce7 --- /dev/null +++ b/app/src/dailyEvalCount/dailyEvalCount.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { DailyEvalCountDaoImpl } from './db/dailyEvalCount.database.dao'; +import type { DateRange } from 'src/dateRange/dtos/dateRange.dto'; +import type { IntRecord } from 'src/common/models/common.valueRecord.model'; + +@Injectable() +export class DailyEvalCountService { + constructor(private readonly dailyEvalCountDao: DailyEvalCountDaoImpl) {} + + async evalCountRecordsByDate({ + start, + end, + }: DateRange): Promise { + const evalCounts = await this.dailyEvalCountDao.findEvalCountsByDate({ + start, + end, + }); + + return evalCounts.map(({ date, count }) => ({ at: date, value: count })); + } + + async userEvalCountRecordsByDatePerMonth( + userId: number, + { start, end }: DateRange, + ): Promise { + const userEvalCounts = + await this.dailyEvalCountDao.findUserEvalCountsByDatePerMonth({ + userId, + start, + end, + }); + + return userEvalCounts.map(({ date, count }) => ({ + at: date, + value: count, + })); + } +} diff --git a/app/src/dailyEvalCount/db/dailyEvalCount.database.dao.ts b/app/src/dailyEvalCount/db/dailyEvalCount.database.dao.ts new file mode 100644 index 00000000..5b1437cc --- /dev/null +++ b/app/src/dailyEvalCount/db/dailyEvalCount.database.dao.ts @@ -0,0 +1,101 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import type { Model } from 'mongoose'; +import type { + FindEvalCountByDateInput, + FindEvalCountByDateOutput, + FindUserEvalCountByDateInput, +} from '../dailyEvalCount.dto'; +import { mv_daily_user_scale_team_counts } from './dailyEvalCount.database.schema'; +import { RUNTIME_CONFIG } from 'src/config/runtime'; +import type { ConfigType } from '@nestjs/config'; + +export type DailyEvalCountDao = { + findEvalCountsByDate: ( + args: FindEvalCountByDateInput, + ) => Promise; + findUserEvalCountsByDatePerMonth: ( + args: FindUserEvalCountByDateInput, + ) => Promise; +}; + +@Injectable() +export class DailyEvalCountDaoImpl implements DailyEvalCountDao { + constructor( + @InjectModel(mv_daily_user_scale_team_counts.name) + private readonly dailyUserEvalCountModel: Model, + @Inject(RUNTIME_CONFIG.KEY) + private readonly runtimeConfig: ConfigType, + ) {} + + async findEvalCountsByDate({ + start, + end, + }: FindEvalCountByDateInput): Promise { + return await this.dailyUserEvalCountModel + .aggregate() + .match({ + date: { + $gte: start, + $lt: end, + }, + }) + .group({ + // timezone 달라지면 dateFromParts 등으로 변환할것 + _id: '$date', + count: { + $sum: '$count', + }, + }) + .sort({ _id: 1 }) + .project({ + _id: 0, + date: '$_id', + count: 1, + }); + } + + async findUserEvalCountsByDatePerMonth({ + userId, + start, + end, + }: FindUserEvalCountByDateInput): Promise { + return await this.dailyUserEvalCountModel + .aggregate() + .match({ + userId, + date: { + $gte: start, + $lt: end, + }, + }) + .group({ + _id: { + $dateFromParts: { + year: { + $year: { + date: '$date', + timezone: this.runtimeConfig.TIMEZONE, + }, + }, + month: { + $month: { + date: '$date', + timezone: this.runtimeConfig.TIMEZONE, + }, + }, + timezone: this.runtimeConfig.TIMEZONE, + }, + }, + count: { + $sum: '$count', + }, + }) + .sort({ _id: 1 }) + .project({ + _id: 0, + date: '$_id', + count: 1, + }); + } +} diff --git a/app/src/dailyEvalCount/db/dailyEvalCount.database.schema.ts b/app/src/dailyEvalCount/db/dailyEvalCount.database.schema.ts new file mode 100644 index 00000000..25637aff --- /dev/null +++ b/app/src/dailyEvalCount/db/dailyEvalCount.database.schema.ts @@ -0,0 +1,21 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; + +export type DailyUserEvalCountDocument = + HydratedDocument; + +@Schema({ collection: 'mv_daily_user_scale_team_counts' }) +export class mv_daily_user_scale_team_counts { + @Prop({ required: true }) + count: number; + + @Prop({ required: true }) + date: Date; + + @Prop({ required: true }) + userId: number; +} + +export const dailyUserEvalCountSchema = SchemaFactory.createForClass( + mv_daily_user_scale_team_counts, +); diff --git a/app/src/page/home/eval/home.eval.module.ts b/app/src/page/home/eval/home.eval.module.ts index dc19d511..06148213 100644 --- a/app/src/page/home/eval/home.eval.module.ts +++ b/app/src/page/home/eval/home.eval.module.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; -import { CursusUserModule } from 'src/api/cursusUser/cursusUser.module'; import { ScaleTeamModule } from 'src/api/scaleTeam/scaleTeam.module'; -import { DateRangeModule } from 'src/dateRange/dateRange.module'; +import { CacheUtilModule } from 'src/cache/cache.util.module'; +import { DailyEvalCountModule } from 'src/dailyEvalCount/dailyEvalCount.module'; import { HomeEvalResolver } from './home.eval.resolver'; import { HomeEvalService } from './home.eval.service'; @Module({ - imports: [ScaleTeamModule, CursusUserModule, DateRangeModule], + imports: [ScaleTeamModule, DailyEvalCountModule, CacheUtilModule], providers: [HomeEvalResolver, HomeEvalService], }) // eslint-disable-next-line diff --git a/app/src/page/home/eval/home.eval.resolver.ts b/app/src/page/home/eval/home.eval.resolver.ts index efa21107..0cc54ec5 100644 --- a/app/src/page/home/eval/home.eval.resolver.ts +++ b/app/src/page/home/eval/home.eval.resolver.ts @@ -1,16 +1,23 @@ import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Int, Query, ResolveField, Resolver } from '@nestjs/graphql'; import { StatAuthGuard } from 'src/auth/statAuthGuard'; +import { CacheUtilService } from 'src/cache/cache.util.service'; import { IntRecord } from 'src/common/models/common.valueRecord.model'; +import { DailyEvalCountService } from 'src/dailyEvalCount/dailyEvalCount.service'; +import { DateWrapper } from 'src/dateWrapper/dateWrapper'; import { HttpExceptionFilter } from 'src/http-exception.filter'; import { HomeEvalService } from './home.eval.service'; -import { HomeEval } from './models/home.eval.model'; +import { GetEvalCountRecordsArgs, HomeEval } from './models/home.eval.model'; @UseFilters(HttpExceptionFilter) @UseGuards(StatAuthGuard) @Resolver((_of: unknown) => HomeEval) export class HomeEvalResolver { - constructor(private readonly homeEvalService: HomeEvalService) {} + constructor( + private readonly homeEvalService: HomeEvalService, + private readonly dailyEvalCountService: DailyEvalCountService, + private readonly cacheUtilService: CacheUtilService, + ) {} @Query((_of) => HomeEval) async getHomeEval() { @@ -22,11 +29,31 @@ export class HomeEvalResolver { return await this.homeEvalService.totalEvalCount(); } - @ResolveField((_returns) => [IntRecord], { description: '1 ~ 60 일' }) - async evalCountRecords(@Args('last') last: number): Promise { - return await this.homeEvalService.evalCountRecords( - Math.max(Math.min(last, 60), 1), - ); + @ResolveField((_returns) => [IntRecord], { description: '1 ~ 730 일' }) + async evalCountRecords( + @Args() { last }: GetEvalCountRecordsArgs, + ): Promise { + const nextDay = new DateWrapper().startOfDate().moveDate(1).toDate(); + const start = new DateWrapper() + .startOfDate() + .moveDate(1 - last) + .toDate(); + + const cacheKey = `evalCountRecords:${start.getTime()}:${nextDay.getTime()}`; + + const cached = await this.cacheUtilService.get(cacheKey); + if (cached) { + return cached; + } + + const result = await this.dailyEvalCountService.evalCountRecordsByDate({ + start: start, + end: nextDay, + }); + + await this.cacheUtilService.set(cacheKey, result, DateWrapper.MIN); + + return result; } @ResolveField((_returns) => Int) diff --git a/app/src/page/home/eval/home.eval.service.ts b/app/src/page/home/eval/home.eval.service.ts index 266455f0..e2a9a250 100644 --- a/app/src/page/home/eval/home.eval.service.ts +++ b/app/src/page/home/eval/home.eval.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { CursusUserService } from 'src/api/cursusUser/cursusUser.service'; import { AVERAGE_COMMENT_LENGTH, AVERAGE_FEEDBACK_LENGTH, @@ -7,17 +6,12 @@ import { } from 'src/api/scaleTeam/scaleTeam.cache.service'; import { ScaleTeamService } from 'src/api/scaleTeam/scaleTeam.service'; import { CacheOnReturn } from 'src/cache/decrators/onReturn/cache.decorator.onReturn.symbol'; -import { IntRecord } from 'src/common/models/common.valueRecord.model'; -import { DateRangeService } from 'src/dateRange/dateRange.service'; -import { DateWrapper } from 'src/dateWrapper/dateWrapper'; @Injectable() export class HomeEvalService { constructor( private readonly scaleTeamService: ScaleTeamService, private readonly scaleTeamCacheService: ScaleTeamCacheService, - private readonly cursusUserService: CursusUserService, - private readonly dateRangeService: DateRangeService, ) {} @CacheOnReturn() @@ -25,39 +19,6 @@ export class HomeEvalService { return await this.scaleTeamService.evalCount(); } - async evalCountRecords(last: number): Promise { - const startDate = new DateWrapper() - .startOfDate() - .moveDate(1 - last) - .toDate(); - - const evals: { beginAt: Date }[] = - await this.scaleTeamService.findAllAndLean({ - filter: { beginAt: { $gte: startDate } }, - select: { beginAt: 1 }, - }); - - const res = evals.reduce((acc, { beginAt }) => { - const date = new DateWrapper(beginAt).startOfDate().toDate().getTime(); - - const prev = acc.get(date); - - acc.set(date, (prev ?? 0) + 1); - - return acc; - }, new Map() as Map); - - const records: IntRecord[] = []; - - for (let i = 0; i < last; i++) { - const currDate = new DateWrapper(startDate).moveDate(i).toDate(); - - records.push({ at: currDate, value: res.get(currDate.getTime()) ?? 0 }); - } - - return records; - } - async averageFeedbackLength(): Promise { const cachedLength = await this.scaleTeamCacheService.getAverageReviewLength( diff --git a/app/src/page/home/eval/models/home.eval.model.ts b/app/src/page/home/eval/models/home.eval.model.ts index 043ed7fb..f1c01e6b 100644 --- a/app/src/page/home/eval/models/home.eval.model.ts +++ b/app/src/page/home/eval/models/home.eval.model.ts @@ -1,4 +1,5 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { ArgsType, Field, ObjectType } from '@nestjs/graphql'; +import { Max, Min } from 'class-validator'; import { IntRecord } from 'src/common/models/common.valueRecord.model'; @ObjectType() @@ -15,3 +16,11 @@ export class HomeEval { @Field() averageCommentLength: number; } + +@ArgsType() +export class GetEvalCountRecordsArgs { + @Min(1) + @Max(730) + @Field() + last: number; +} diff --git a/app/src/page/personal/eval/models/personal.eval.model.ts b/app/src/page/personal/eval/models/personal.eval.model.ts index 7ad01fa1..cbfcf436 100644 --- a/app/src/page/personal/eval/models/personal.eval.model.ts +++ b/app/src/page/personal/eval/models/personal.eval.model.ts @@ -1,4 +1,5 @@ -import { Field, Float, ObjectType } from '@nestjs/graphql'; +import { ArgsType, Field, Float, ObjectType } from '@nestjs/graphql'; +import { Max, Min } from 'class-validator'; import { UserRank } from 'src/common/models/common.user.model'; import { IntRecord } from 'src/common/models/common.valueRecord.model'; import { UserProfile } from 'src/page/personal/general/models/personal.general.userProfile.model'; @@ -43,3 +44,11 @@ export class PersonalEval { @Field({ nullable: true }) recentComment?: string; } + +@ArgsType() +export class GetPersonalEvalCountRecordsArgs { + @Min(1) + @Max(120) + @Field({ description: '1 ~ 120 개월' }) + last: number; +} diff --git a/app/src/page/personal/eval/personal.eval.module.ts b/app/src/page/personal/eval/personal.eval.module.ts index 3fdf23b4..41057eb3 100644 --- a/app/src/page/personal/eval/personal.eval.module.ts +++ b/app/src/page/personal/eval/personal.eval.module.ts @@ -3,6 +3,7 @@ import { CursusUserModule } from 'src/api/cursusUser/cursusUser.module'; import { ScaleTeamModule } from 'src/api/scaleTeam/scaleTeam.module'; import { ScoreModule } from 'src/api/score/score.module'; import { TeamModule } from 'src/api/team/team.module'; +import { DailyEvalCountModule } from 'src/dailyEvalCount/dailyEvalCount.module'; import { DateRangeModule } from 'src/dateRange/dateRange.module'; import { PersonalUtilModule } from '../util/personal.util.module'; import { PersonalEvalResolver } from './personal.eval.resolver'; @@ -16,6 +17,7 @@ import { PersonalEvalService } from './personal.eval.service'; CursusUserModule, DateRangeModule, TeamModule, + DailyEvalCountModule, ], providers: [PersonalEvalResolver, PersonalEvalService], }) diff --git a/app/src/page/personal/eval/personal.eval.resolver.ts b/app/src/page/personal/eval/personal.eval.resolver.ts index 8464c1b0..d3ce3838 100644 --- a/app/src/page/personal/eval/personal.eval.resolver.ts +++ b/app/src/page/personal/eval/personal.eval.resolver.ts @@ -10,11 +10,18 @@ import { } from '@nestjs/graphql'; import { MyUserId } from 'src/auth/myContext'; import { StatAuthGuard } from 'src/auth/statAuthGuard'; +import { CacheUtilService } from 'src/cache/cache.util.service'; import { UserRank } from 'src/common/models/common.user.model'; import { IntRecord } from 'src/common/models/common.valueRecord.model'; +import { DailyEvalCountService } from 'src/dailyEvalCount/dailyEvalCount.service'; +import { DateWrapper } from 'src/dateWrapper/dateWrapper'; import { HttpExceptionFilter } from 'src/http-exception.filter'; import { PersonalUtilService } from '../util/personal.util.service'; -import { PersonalEval, PersonalEvalRoot } from './models/personal.eval.model'; +import { + GetPersonalEvalCountRecordsArgs, + PersonalEval, + PersonalEvalRoot, +} from './models/personal.eval.model'; import { PersonalEvalService } from './personal.eval.service'; @UseFilters(HttpExceptionFilter) @@ -24,6 +31,8 @@ export class PersonalEvalResolver { constructor( private readonly personalEvalService: PersonalEvalService, private readonly personalUtilService: PersonalUtilService, + private readonly dailyEvalCountService: DailyEvalCountService, + private readonly cacheUtilService: CacheUtilService, ) {} @Query((_returns) => PersonalEval) @@ -46,15 +55,34 @@ export class PersonalEvalResolver { return await this.personalEvalService.totalCount(root.userProfile.id); } - @ResolveField((_returns) => [IntRecord], { description: '1 ~ 24 개월' }) + @ResolveField((_returns) => [IntRecord], { description: '1 ~ 120 개월' }) async countRecords( @Root() root: PersonalEvalRoot, - @Args('last') last: number, + @Args() { last }: GetPersonalEvalCountRecordsArgs, ): Promise { - return await this.personalEvalService.countRecords( - root.userProfile.id, - Math.max(1, Math.min(last, 24)), - ); + const nextMonth = DateWrapper.nextMonth().toDate(); + const start = DateWrapper.currMonth() + .moveMonth(1 - last) + .toDate(); + + const cacheKey = `personalEvalCountRecords:${ + root.userProfile.id + }:${start.getTime()}:${nextMonth.getTime()}`; + + const cached = await this.cacheUtilService.get(cacheKey); + if (cached) { + return cached; + } + + const result = + await this.dailyEvalCountService.userEvalCountRecordsByDatePerMonth( + root.userProfile.id, + { start, end: nextMonth }, + ); + + await this.cacheUtilService.set(cacheKey, result, DateWrapper.MIN); + + return result; } @ResolveField((_returns) => Int) diff --git a/app/src/schema.gql b/app/src/schema.gql index 19676595..f3442dad 100644 --- a/app/src/schema.gql +++ b/app/src/schema.gql @@ -485,7 +485,10 @@ type PersonalEval { userProfile: UserProfile! correctionPoint: Int! totalCount: Int! - countRecords(last: Int!): [IntRecord!]! + countRecords( + """1 ~ 120 개월""" + last: Int! + ): [IntRecord!]! totalDuration: Int! averageDuration: Int! averageFinalMark: Float!