diff --git a/README.md b/README.md index 09e3b15..1b67392 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ ## Change Logs +- `1.52.1`: Sent current date's guessing game related date from the table avoiding random generation - `1.52.0`: API to get randomly chosen meanings (part of the guessing game) - `1.51.0`: Created a separate DB (Docker container) for the automated test with `fsync=off`, `synchronous_commit=off` and `full_page_writes=off` - `1.50.0`: Empty array when a vocab does not have a definition using the FILTER clause diff --git a/migrations/1717321421822-word-meaning-in-guessing-game-table.ts b/migrations/1717321421822-word-meaning-in-guessing-game-table.ts new file mode 100644 index 0000000..21864ee --- /dev/null +++ b/migrations/1717321421822-word-meaning-in-guessing-game-table.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class WordMeaningInGuessingGameTable1717321421822 implements MigrationInterface { + name = 'WordMeaningInGuessingGameTable1717321421822'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "GuessingGame" + ADD "meaning" character varying NULL + `); + await queryRunner.query(` + ALTER TABLE "GuessingGame" + ADD "word" character varying NULL + `); + + await queryRunner.query(` + with "FilteredGuessingGame" as + (select "GuessingGame"."definitionId" + from "GuessingGame"), + "FilteredDefinition" as (select "Definition".id, "Definition".meaning, "Vocabulary".word + from "Definition" + inner join "Vocabulary" on "Definition"."vocabularyId" = "Vocabulary".id + where "Definition".id in (select "definitionId" from "FilteredGuessingGame")) + update "GuessingGame" + set word = "FilteredDefinition".word, + meaning = "FilteredDefinition".meaning + from "FilteredDefinition" + where "GuessingGame"."definitionId" = "FilteredDefinition".id; + `); + + await queryRunner.query(` + ALTER TABLE "GuessingGame" + ALTER COLUMN "meaning" SET NOT NULL + `); + await queryRunner.query(` + ALTER TABLE "GuessingGame" + ALTER COLUMN "word" SET NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "GuessingGame" + DROP COLUMN "word" + `); + await queryRunner.query(` + ALTER TABLE "GuessingGame" + DROP COLUMN "meaning" + `); + } +} diff --git a/package-lock.json b/package-lock.json index a3a0b9e..282eda1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vocabulary-flashcard-backend", - "version": "1.52.0", + "version": "1.52.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vocabulary-flashcard-backend", - "version": "1.52.0", + "version": "1.52.1", "license": "UNLICENSED", "dependencies": { "@nestjs/axios": "^3.0.2", diff --git a/package.json b/package.json index 9a6dd04..9bbc0bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vocabulary-flashcard-backend", - "version": "1.52.0", + "version": "1.52.1", "author": { "name": "Emtiaj Hasan", "email": "271emtiaj@gmail.com", diff --git a/src/vocabulary/domains/GuessingGame.ts b/src/vocabulary/domains/GuessingGame.ts index 6f51261..dd3c6fc 100644 --- a/src/vocabulary/domains/GuessingGame.ts +++ b/src/vocabulary/domains/GuessingGame.ts @@ -11,6 +11,12 @@ export default class GuessingGame { @Column({ type: 'uuid', nullable: false }) userId: string; + @Column({ type: 'varchar', nullable: false }) + meaning: string; + + @Column({ type: 'varchar', nullable: false }) + word: string; + @CreateDateColumn({ type: 'timestamp with time zone' }) createdAt: Date; } diff --git a/src/vocabulary/repositories/GuessingGameRepository.ts b/src/vocabulary/repositories/GuessingGameRepository.ts index a938b10..f4d89c2 100644 --- a/src/vocabulary/repositories/GuessingGameRepository.ts +++ b/src/vocabulary/repositories/GuessingGameRepository.ts @@ -8,20 +8,38 @@ export default class GuessingGameRepository extends Repository { super(GuessingGame, dataSource.createEntityManager()); } - async insertMultiples(definitionIds: string[], userId: string): Promise { + async insertMultiples(guessingGames: Partial[], userId: string): Promise { await this.insert( - definitionIds.map((definitionId) => { + guessingGames.map((guessingGame) => { return { userId, - definitionId, + definitionId: guessingGame.definitionId, + word: guessingGame.word, + meaning: guessingGame.meaning, }; }), ); } - async getDefinitionIdsByUserId(userId: string): Promise { - const guessingGames = await this.find({ where: { userId }, select: ['definitionId'] }); - return guessingGames.map(({ definitionId }) => definitionId); + getByUserId(userId: string): Promise< + { + definitionId: string; + word: string; + meaning: string; + isCreationDateToday: boolean; + }[] + > { + return this.query( + ` + select "definitionId", + word, + meaning, + ("createdAt"::date = now()::date) as "isCreationDateToday" + from "GuessingGame" + where "userId" = $1; + `, + [userId], + ); } async deleteOlder(): Promise { diff --git a/src/vocabulary/services/GuessingGameService.ts b/src/vocabulary/services/GuessingGameService.ts index ed95905..87cf8a8 100644 --- a/src/vocabulary/services/GuessingGameService.ts +++ b/src/vocabulary/services/GuessingGameService.ts @@ -1,7 +1,10 @@ import { Injectable } from '@nestjs/common'; import DefinitionRepository from '@/vocabulary/repositories/DefinitionRepository'; import { shuffle } from 'lodash'; -import { RandomlyChosenMeaningResponse } from '@/vocabulary/domains/RandomlyChosenMeaningResponse'; +import { + RandomlyChosenMeaningQueryResponse, + RandomlyChosenMeaningResponse, +} from '@/vocabulary/domains/RandomlyChosenMeaningResponse'; import WordsApiAdapter from '@/vocabulary/adapters/WordsApiAdapter'; import GuessingGameRepository from '@/vocabulary/repositories/GuessingGameRepository'; @@ -14,16 +17,30 @@ export default class GuessingGameService { ) {} async getRandomlyChosenMeanings(cohortId: string, userId: string): Promise { - const [randomlyChosenMeaningResponsesFromExternalService, previousDefinitionIds] = await Promise.all([ + const [randomlyChosenMeaningResponsesFromExternalService, previousDefinitions] = await Promise.all([ this.wordsApiAdapter.getRandomWords(), - this.guessingGameRepository.getDefinitionIdsByUserId(userId), + this.guessingGameRepository.getByUserId(userId), ]); - let randomlyChosenMeaningResponses = await this.definitionRepository.getRandomlyChosenMeanings( - cohortId, - userId, - previousDefinitionIds, - ); + let definitionFromTodayExists = false; + const previousDefinitionIds: string[] = []; + const definitionsFromToday: RandomlyChosenMeaningQueryResponse[] = []; + + previousDefinitions.forEach((previousDefinition) => { + previousDefinitionIds.push(previousDefinition.definitionId); + if (previousDefinition.isCreationDateToday) { + definitionFromTodayExists = true; + definitionsFromToday.push({ + definitionId: previousDefinition.definitionId, + word: previousDefinition.word, + meaning: previousDefinition.meaning, + }); + } + }); + + let randomlyChosenMeaningResponses = definitionFromTodayExists + ? definitionsFromToday + : await this.definitionRepository.getRandomlyChosenMeanings(cohortId, userId, previousDefinitionIds); if (randomlyChosenMeaningResponses.length === 0) { randomlyChosenMeaningResponses = await this.definitionRepository.getAnyMeanings( @@ -32,11 +49,9 @@ export default class GuessingGameService { ); } - const definitionIds = randomlyChosenMeaningResponses.map((randomlyChosenMeaningResponse) => { - return randomlyChosenMeaningResponse.definitionId; - }); - - this.guessingGameRepository.insertMultiples(definitionIds, userId).finally(); + if (!definitionFromTodayExists) { + this.save(randomlyChosenMeaningResponses, userId).finally(); + } return shuffle([ ...randomlyChosenMeaningResponsesFromExternalService, @@ -56,4 +71,11 @@ export default class GuessingGameService { async deleteByUserId(userId: string): Promise { await this.guessingGameRepository.deleteByUserId(userId); } + + private async save( + randomlyChosenMeaningResponses: RandomlyChosenMeaningQueryResponse[], + userId: string, + ): Promise { + await this.guessingGameRepository.insertMultiples(randomlyChosenMeaningResponses, userId); + } } diff --git a/test/e2e/user-controller-delete.ts b/test/e2e/user-controller-delete.ts index 64be146..d2e68f5 100644 --- a/test/e2e/user-controller-delete.ts +++ b/test/e2e/user-controller-delete.ts @@ -287,6 +287,8 @@ describe('DELETE /v1/users/self', () => { await app.get(GuessingGameRepository).insert({ userId: requester.id, definitionId: uuid.v4(), + word: 'word', + meaning: 'meaning', }); // Act diff --git a/test/e2e/vocabulary-controller-search-random-definition.ts b/test/e2e/vocabulary-controller-search-random-definition.ts index 0b4ead5..ab11ce9 100644 --- a/test/e2e/vocabulary-controller-search-random-definition.ts +++ b/test/e2e/vocabulary-controller-search-random-definition.ts @@ -20,6 +20,7 @@ import { RandomlyChosenMeaningResponse } from '@/vocabulary/domains/RandomlyChos import WordsApiAdapter from '@/vocabulary/adapters/WordsApiAdapter'; import GuessingGameRepository from '@/vocabulary/repositories/GuessingGameRepository'; import MomentUnit, { makeItOlder } from '@/common/utils/moment-util'; +import DefinitionRepository from '@/vocabulary/repositories/DefinitionRepository'; describe('GET /v1/vocabularies/definitions/random-search', () => { let app: INestApplication; @@ -97,6 +98,8 @@ describe('GET /v1/vocabularies/definitions/random-search', () => { userId: requester.id, definitionId: previousVocabulary.definitions[0].id, createdAt: makeItOlder(new Date(), MomentUnit.DAYS, 3), + word: previousVocabulary.word, + meaning: previousVocabulary.definitions[0].meaning, }); // Act @@ -114,5 +117,44 @@ describe('GET /v1/vocabularies/definitions/random-search', () => { }, ]); }); + + it('SHOULD return 200 OK with definitions from today', async () => { + // Arrange + const getRandomlyChosenMeaningsMock = jest.spyOn( + DefinitionRepository.prototype, + 'getRandomlyChosenMeanings', + ); + const insertMultiplesMock = jest.spyOn(GuessingGameRepository.prototype, 'insertMultiples'); + const previousVocabulary = await createVocabulary(getVocabularyWithDefinitions(), cohort.id); + + await app.get(GuessingGameRepository).insert({ + userId: requester.id, + definitionId: previousVocabulary.definitions[0].id, + createdAt: new Date(), + word: previousVocabulary.word, + meaning: previousVocabulary.definitions[0].meaning, + }); + + // Act + const { status, body } = await makeApiRequest(requester); + + // Assert + expect(status).toBe(200); + expect(getRandomWordMock).not.toHaveBeenCalled(); + expect(getRandomlyChosenMeaningsMock).not.toHaveBeenCalled(); + expect(insertMultiplesMock).not.toHaveBeenCalled(); + const response = body as RandomlyChosenMeaningResponse[]; + expect(response).not.toHaveLength(0); + expect(response).toStrictEqual([ + { + word: previousVocabulary.word, + meaning: previousVocabulary.definitions[0].meaning, + }, + ]); + + // Post-Assert + getRandomlyChosenMeaningsMock.mockRestore(); + insertMultiplesMock.mockRestore(); + }); }); }); diff --git a/test/integration/delete-old-random-definitions-job.ts b/test/integration/delete-old-random-definitions-job.ts index 6d31988..23cd2c6 100644 --- a/test/integration/delete-old-random-definitions-job.ts +++ b/test/integration/delete-old-random-definitions-job.ts @@ -28,6 +28,8 @@ describe('DeleteOldRandomDefinitionsJob', () => { userId: uuid.v4(), definitionId: uuid.v4(), createdAt: makeItOlder(new Date(), MomentUnit.DAYS, 3), + word: 'word', + meaning: 'meaning', }); const id = generatedMaps[0].id; @@ -46,6 +48,8 @@ describe('DeleteOldRandomDefinitionsJob', () => { userId: uuid.v4(), definitionId: uuid.v4(), createdAt: makeItOlder(new Date(), MomentUnit.DAYS, 16), + word: 'word', + meaning: 'meaning', }); const id = generatedMaps[0].id;