From b14fb390093d5f9818e754dafe44ca9926b21ff8 Mon Sep 17 00:00:00 2001 From: Andres Date: Fri, 14 Jul 2023 14:54:47 +0200 Subject: [PATCH] add smart random --- package.json | 4 +- pnpm-lock.yaml | 125 +++++++++++++----- prisma/schema.prisma | 14 +- .../common/helpers/get-order-by-object.ts | 30 +++++ .../decorators/is-order-by-constraint.ts | 45 +++++++ .../get-current-user-data-use-case.ts | 2 +- src/modules/cards/cards.controller.ts | 4 +- src/modules/cards/cards.module.ts | 4 +- src/modules/cards/dto/get-all-cards.dto.ts | 4 + .../cards/infrastructure/cards.repository.ts | 86 +++++++++--- .../use-cases/delete-card-by-id-use-case.ts | 21 +++ .../use-cases/delete-deck-by-id-use-case.ts | 21 --- .../use-cases/get-deck-by-id-use-case.ts | 2 +- src/modules/cards/use-cases/index.ts | 2 +- .../cards/use-cases/update-deck-use-case.ts | 2 +- src/modules/decks/decks.controller.ts | 20 ++- src/modules/decks/decks.module.ts | 7 +- src/modules/decks/dto/create-grade.dto.ts | 10 ++ src/modules/decks/dto/get-all-decks.dto.ts | 4 + .../decks/infrastructure/decks.repository.ts | 31 ++++- .../decks/infrastructure/grades.repository.ts | 59 +++++++++ .../get-all-cards-in-deck-use-case.ts | 8 +- .../get-random-card-in-deck-use-case.ts | 53 ++++++++ .../decks/use-cases/save-grade-use-case.ts | 41 ++++++ src/prisma.service.ts | 14 +- 25 files changed, 509 insertions(+), 104 deletions(-) create mode 100644 src/infrastructure/common/helpers/get-order-by-object.ts create mode 100644 src/infrastructure/decorators/is-order-by-constraint.ts create mode 100644 src/modules/cards/use-cases/delete-card-by-id-use-case.ts delete mode 100644 src/modules/cards/use-cases/delete-deck-by-id-use-case.ts create mode 100644 src/modules/decks/dto/create-grade.dto.ts create mode 100644 src/modules/decks/infrastructure/grades.repository.ts create mode 100644 src/modules/decks/use-cases/get-random-card-in-deck-use-case.ts create mode 100644 src/modules/decks/use-cases/save-grade-use-case.ts diff --git a/package.json b/package.json index 1adcb3d..74684c6 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@nestjs/platform-fastify": "^9.4.1", "@nestjs/schedule": "^2.2.3", "@nestjs/swagger": "^6.3.0", - "@prisma/client": "^4.15.0", + "@prisma/client": "^5.0.0", "bcrypt": "^5.1.0", "class-transformer": "0.3.1", "class-validator": "^0.14.0", @@ -66,7 +66,7 @@ "eslint-plugin-prettier": "^4.2.1", "jest": "29.5.0", "prettier": "^2.8.8", - "prisma": "^4.15.0", + "prisma": "^5.0.0", "source-map-support": "^0.5.21", "supertest": "^6.3.3", "ts-jest": "29.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6604f40..01a9147 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,8 +45,8 @@ dependencies: specifier: ^6.3.0 version: 6.3.0(@nestjs/common@9.4.1)(@nestjs/core@9.4.1)(class-transformer@0.3.1)(class-validator@0.14.0)(reflect-metadata@0.1.13) '@prisma/client': - specifier: ^4.15.0 - version: 4.15.0(prisma@4.15.0) + specifier: ^5.0.0 + version: 5.0.0(prisma@5.0.0) bcrypt: specifier: ^5.1.0 version: 5.1.0 @@ -137,8 +137,8 @@ devDependencies: specifier: ^2.8.8 version: 2.8.8 prisma: - specifier: ^4.15.0 - version: 4.15.0 + specifier: ^5.0.0 + version: 5.0.0 source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -150,7 +150,7 @@ devDependencies: version: 29.1.0(@babel/core@7.22.5)(jest@29.5.0)(typescript@5.0.4) ts-loader: specifier: ^9.4.2 - version: 9.4.2(typescript@5.0.4)(webpack@5.86.0) + version: 9.4.2(typescript@5.0.4)(webpack@5.88.1) ts-node: specifier: ^10.9.1 version: 10.9.1(@types/node@18.16.12)(typescript@5.0.4) @@ -2073,9 +2073,9 @@ packages: transitivePeerDependencies: - encoding - /@prisma/client@4.15.0(prisma@4.15.0): - resolution: {integrity: sha512-xnROvyABcGiwqRNdrObHVZkD9EjkJYHOmVdlKy1yGgI+XOzvMzJ4tRg3dz1pUlsyhKxXGCnjIQjWW+2ur+YXuw==} - engines: {node: '>=14.17'} + /@prisma/client@5.0.0(prisma@5.0.0): + resolution: {integrity: sha512-XlO5ELNAQ7rV4cXIDJUNBEgdLwX3pjtt9Q/RHqDpGf43szpNJx2hJnggfFs7TKNx0cOFsl6KJCSfqr5duEU/bQ==} + engines: {node: '>=16.13'} requiresBuild: true peerDependencies: prisma: '*' @@ -2083,16 +2083,16 @@ packages: prisma: optional: true dependencies: - '@prisma/engines-version': 4.15.0-28.8fbc245156db7124f997f4cecdd8d1219e360944 - prisma: 4.15.0 + '@prisma/engines-version': 4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584 + prisma: 5.0.0 dev: false - /@prisma/engines-version@4.15.0-28.8fbc245156db7124f997f4cecdd8d1219e360944: - resolution: {integrity: sha512-sVOig4tjGxxlYaFcXgE71f/rtFhzyYrfyfNFUsxCIEJyVKU9rdOWIlIwQ2NQ7PntvGnn+x0XuFo4OC1jvPJKzg==} + /@prisma/engines-version@4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584: + resolution: {integrity: sha512-HHiUF6NixsldsP3JROq07TYBLEjXFKr6PdH8H4gK/XAoTmIplOJBCgrIUMrsRAnEuGyRoRLXKXWUb943+PFoKQ==} dev: false - /@prisma/engines@4.15.0: - resolution: {integrity: sha512-FTaOCGs0LL0OW68juZlGxFtYviZa4xdQj/rQEdat2txw0s3Vu/saAPKjNVXfIgUsGXmQ72HPgNr6935/P8FNAA==} + /@prisma/engines@5.0.0: + resolution: {integrity: sha512-kyT/8fd0OpWmhAU5YnY7eP31brW1q1YrTGoblWrhQJDiN/1K+Z8S1kylcmtjqx5wsUGcP1HBWutayA/jtyt+sg==} requiresBuild: true /@selderee/plugin-htmlparser2@0.10.0: @@ -2621,6 +2621,14 @@ packages: mime-types: 2.1.35 negotiator: 0.6.3 + /acorn-import-assertions@1.9.0(acorn@8.10.0): + resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + peerDependencies: + acorn: ^8 + dependencies: + acorn: 8.10.0 + dev: true + /acorn-import-assertions@1.9.0(acorn@8.8.2): resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} peerDependencies: @@ -2647,6 +2655,12 @@ packages: hasBin: true dev: false + /acorn@8.10.0: + resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + /acorn@8.8.2: resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} engines: {node: '>=0.4.0'} @@ -3000,6 +3014,17 @@ packages: update-browserslist-db: 1.0.11(browserslist@4.21.7) dev: true + /browserslist@4.21.9: + resolution: {integrity: sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001515 + electron-to-chromium: 1.4.457 + node-releases: 2.0.13 + update-browserslist-db: 1.0.11(browserslist@4.21.9) + dev: true + /bs-logger@0.2.6: resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} engines: {node: '>= 6'} @@ -3076,6 +3101,10 @@ packages: resolution: {integrity: sha512-AZ+9tFXw1sS0o0jcpJQIXvFTOB/xGiQ4OQ2t98QX3NDn2EZTSRBC801gxrsGgViuq2ak/NLkNgSNEPtCr5lfKg==} dev: true + /caniuse-lite@1.0.30001515: + resolution: {integrity: sha512-eEFDwUOZbE24sb+Ecsx3+OvNETqjWIdabMy52oOkIgcUtAsQifjUG9q4U9dgTHJM2mfk4uEPxc0+xuFdJ629QA==} + dev: true + /chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -3654,6 +3683,10 @@ packages: resolution: {integrity: sha512-HK3r9l+Jm8dYAm1ctXEWIC+hV60zfcjS9UA5BDlYvnI5S7PU/yytjpvSrTNrSSRRkuu3tDyZhdkwIczh+0DWaw==} dev: true + /electron-to-chromium@1.4.457: + resolution: {integrity: sha512-/g3UyNDmDd6ebeWapmAoiyy+Sy2HyJ+/X8KyvNeHfKRFfHaA2W8oF5fxD5F3tjBDcjpwo0iek6YNgxNXDBoEtA==} + dev: true + /emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} @@ -3685,6 +3718,14 @@ packages: tapable: 2.2.1 dev: true + /enhanced-resolve@5.15.0: + resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} + engines: {node: '>=10.13.0'} + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + dev: true + /entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} dev: false @@ -6225,6 +6266,10 @@ packages: resolution: {integrity: sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==} dev: true + /node-releases@2.0.13: + resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} + dev: true + /nodemailer@6.9.1: resolution: {integrity: sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA==} engines: {node: '>=6.0.0'} @@ -6659,13 +6704,13 @@ packages: - supports-color dev: false - /prisma@4.15.0: - resolution: {integrity: sha512-iKZZpobPl48gTcSZVawLMQ3lEy6BnXwtoMj7hluoGFYu2kQ6F9LBuBrUyF95zRVnNo8/3KzLXJXJ5TEnLSJFiA==} - engines: {node: '>=14.17'} + /prisma@5.0.0: + resolution: {integrity: sha512-KYWk83Fhi1FH59jSpavAYTt2eoMVW9YKgu8ci0kuUnt6Dup5Qy47pcB4/TLmiPAbhGrxxSz7gsSnJcCmkyPANA==} + engines: {node: '>=16.13'} hasBin: true requiresBuild: true dependencies: - '@prisma/engines': 4.15.0 + '@prisma/engines': 5.0.0 /process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -7086,6 +7131,15 @@ packages: ajv-keywords: 3.5.2(ajv@6.12.6) dev: true + /schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/json-schema': 7.0.12 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + dev: true + /secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} dev: false @@ -7473,7 +7527,7 @@ packages: webpack: 5.82.1 dev: true - /terser-webpack-plugin@5.3.9(webpack@5.86.0): + /terser-webpack-plugin@5.3.9(webpack@5.88.1): resolution: {integrity: sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==} engines: {node: '>= 10.13.0'} peerDependencies: @@ -7494,7 +7548,7 @@ packages: schema-utils: 3.2.0 serialize-javascript: 6.0.1 terser: 5.18.0 - webpack: 5.86.0 + webpack: 5.88.1 dev: true /terser@5.18.0: @@ -7612,7 +7666,7 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-loader@9.4.2(typescript@5.0.4)(webpack@5.86.0): + /ts-loader@9.4.2(typescript@5.0.4)(webpack@5.88.1): resolution: {integrity: sha512-OmlC4WVmFv5I0PpaxYb+qGeGOdm5giHU7HwDDUjw59emP2UYMHy9fFSDcYgSNoH8sXcj4hGCSEhlDZ9ULeDraA==} engines: {node: '>=12.0.0'} peerDependencies: @@ -7624,7 +7678,7 @@ packages: micromatch: 4.0.5 semver: 7.5.1 typescript: 5.0.4 - webpack: 5.86.0 + webpack: 5.88.1 dev: true /ts-node@10.9.1(@types/node@18.16.12)(typescript@5.0.4): @@ -7787,6 +7841,17 @@ packages: picocolors: 1.0.0 dev: true + /update-browserslist-db@1.0.11(browserslist@4.21.9): + resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.21.9 + escalade: 3.1.1 + picocolors: 1.0.0 + dev: true + /upper-case@1.1.3: resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} dev: false @@ -7940,8 +8005,8 @@ packages: - uglify-js dev: true - /webpack@5.86.0: - resolution: {integrity: sha512-3BOvworZ8SO/D4GVP+GoRC3fVeg5MO4vzmq8TJJEkdmopxyazGDxN8ClqN12uzrZW9Tv8EED8v5VSb6Sqyi0pg==} + /webpack@5.88.1: + resolution: {integrity: sha512-FROX3TxQnC/ox4N+3xQoWZzvGXSuscxR32rbzjpXgEzWudJFEJBpdlkkob2ylrv5yzzufD1zph1OoFsLtm6stQ==} engines: {node: '>=10.13.0'} hasBin: true peerDependencies: @@ -7955,11 +8020,11 @@ packages: '@webassemblyjs/ast': 1.11.6 '@webassemblyjs/wasm-edit': 1.11.6 '@webassemblyjs/wasm-parser': 1.11.6 - acorn: 8.8.2 - acorn-import-assertions: 1.9.0(acorn@8.8.2) - browserslist: 4.21.7 + acorn: 8.10.0 + acorn-import-assertions: 1.9.0(acorn@8.10.0) + browserslist: 4.21.9 chrome-trace-event: 1.0.3 - enhanced-resolve: 5.14.1 + enhanced-resolve: 5.15.0 es-module-lexer: 1.3.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -7969,9 +8034,9 @@ packages: loader-runner: 4.3.0 mime-types: 2.1.35 neo-async: 2.6.2 - schema-utils: 3.2.0 + schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.9(webpack@5.86.0) + terser-webpack-plugin: 5.3.9(webpack@5.88.1) watchpack: 2.4.0 webpack-sources: 3.2.3 transitivePeerDependencies: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82d0b1e..0a78eac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -84,7 +84,6 @@ model card { userId String question String @db.Text answer String @db.Text - grade Int @default(0) shots Int @default(0) questionImg String? answerImg String? @@ -116,27 +115,26 @@ model deck { isBlocked Boolean? created DateTime @default(now()) updated DateTime @updatedAt - author user @relation(fields: [userId], references: [id]) cardsCount Int @default(0) + author user @relation(fields: [userId], references: [id]) card card[] - grade grade[] + grades grade[] @@index([userId]) } model grade { id String @id @default(cuid()) - deckId String - cardId String - userId String + deckId String @unique + cardId String @unique + userId String @unique grade Int shots Int - moreId String? created DateTime @default(now()) updated DateTime @updatedAt user user @relation(fields: [userId], references: [id]) card card @relation(fields: [cardId], references: [id]) - decks deck @relation(fields: [deckId], references: [id]) + deck deck @relation(fields: [deckId], references: [id]) @@index([userId]) @@index([deckId]) diff --git a/src/infrastructure/common/helpers/get-order-by-object.ts b/src/infrastructure/common/helpers/get-order-by-object.ts new file mode 100644 index 0000000..a0f432e --- /dev/null +++ b/src/infrastructure/common/helpers/get-order-by-object.ts @@ -0,0 +1,30 @@ +type OrderByDirection = 'asc' | 'desc' + +export function createPrismaOrderBy(input: string | null) { + if (!input || input === 'null') { + return undefined + } + const [key, direction] = input.split('-') + + if (!key || !direction) { + throw new Error("Invalid format. Expected format is 'key-direction'") + } + + if (direction !== 'asc' && direction !== 'desc') { + throw new Error("Invalid direction. Expected 'asc' or 'desc'") + } + + if (key.includes('.')) { + const [relation, field] = key.split('.') + + return { + [relation]: { + [field]: direction, + }, + } + } + + return { + [key]: direction as OrderByDirection, + } +} diff --git a/src/infrastructure/decorators/is-order-by-constraint.ts b/src/infrastructure/decorators/is-order-by-constraint.ts new file mode 100644 index 0000000..e93df91 --- /dev/null +++ b/src/infrastructure/decorators/is-order-by-constraint.ts @@ -0,0 +1,45 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator' + +@ValidatorConstraint({ async: false }) +export class IsOrderByConstraint implements ValidatorConstraintInterface { + validate(orderBy: string | null, args: ValidationArguments) { + console.log(orderBy) + if (!orderBy || orderBy === 'null' || orderBy === '') { + return true + } + const [key, direction] = orderBy.split('-') + + if (!key || !direction) { + return false + } + + if (direction !== 'asc' && direction !== 'desc') { + return false + } + + return true + } + + defaultMessage(args: ValidationArguments) { + return 'Invalid format. Expected format is "key-direction". Direction must be "asc" or "desc".' + } +} + +export function IsOrderBy(validationOptions?: ValidationOptions) { + // eslint-disable-next-line @typescript-eslint/ban-types + return function (object: { constructor: Function }, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsOrderByConstraint, + }) + } +} diff --git a/src/modules/auth/use-cases/get-current-user-data-use-case.ts b/src/modules/auth/use-cases/get-current-user-data-use-case.ts index 829e0cb..edb5475 100644 --- a/src/modules/auth/use-cases/get-current-user-data-use-case.ts +++ b/src/modules/auth/use-cases/get-current-user-data-use-case.ts @@ -17,6 +17,6 @@ export class GetCurrentUserDataHandler implements ICommandHandler { + const deleted = await tx.card.delete({ + where: { + id, + }, + }) + await tx.deck.update({ + where: { + id: deleted.deckId, + }, + data: { + cardsCount: { + decrement: 1, + }, + }, + }) + return deleted }) } catch (e) { this.logger.error(e?.message) diff --git a/src/modules/cards/use-cases/delete-card-by-id-use-case.ts b/src/modules/cards/use-cases/delete-card-by-id-use-case.ts new file mode 100644 index 0000000..37e925d --- /dev/null +++ b/src/modules/cards/use-cases/delete-card-by-id-use-case.ts @@ -0,0 +1,21 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { CardsRepository } from '../infrastructure/cards.repository' +import { BadRequestException, NotFoundException } from '@nestjs/common' + +export class DeleteCardByIdCommand { + constructor(public readonly id: string, public readonly userId: string) {} +} + +@CommandHandler(DeleteCardByIdCommand) +export class DeleteCardByIdHandler implements ICommandHandler { + constructor(private readonly cardsRepository: CardsRepository) {} + + async execute(command: DeleteCardByIdCommand) { + const card = await this.cardsRepository.findCardById(command.id) + if (!card) throw new NotFoundException(`Card with id ${command.id} not found`) + if (card.userId !== command.userId) { + throw new BadRequestException(`You can't delete a card that you don't own`) + } + return await this.cardsRepository.deleteCardById(command.id) + } +} diff --git a/src/modules/cards/use-cases/delete-deck-by-id-use-case.ts b/src/modules/cards/use-cases/delete-deck-by-id-use-case.ts deleted file mode 100644 index a0cbe48..0000000 --- a/src/modules/cards/use-cases/delete-deck-by-id-use-case.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' -import { CardsRepository } from '../infrastructure/cards.repository' -import { BadRequestException, NotFoundException } from '@nestjs/common' - -export class DeleteDeckByIdCommand { - constructor(public readonly id: string, public readonly userId: string) {} -} - -@CommandHandler(DeleteDeckByIdCommand) -export class DeleteDeckByIdHandler implements ICommandHandler { - constructor(private readonly deckRepository: CardsRepository) {} - - async execute(command: DeleteDeckByIdCommand) { - const deck = await this.deckRepository.findDeckById(command.id) - if (!deck) throw new NotFoundException(`Deck with id ${command.id} not found`) - if (deck.userId !== command.userId) { - throw new BadRequestException(`You can't delete a deck that you don't own`) - } - return await this.deckRepository.deleteDeckById(command.id) - } -} diff --git a/src/modules/cards/use-cases/get-deck-by-id-use-case.ts b/src/modules/cards/use-cases/get-deck-by-id-use-case.ts index 975b828..6dc700e 100644 --- a/src/modules/cards/use-cases/get-deck-by-id-use-case.ts +++ b/src/modules/cards/use-cases/get-deck-by-id-use-case.ts @@ -10,6 +10,6 @@ export class GetDeckByIdHandler implements ICommandHandler { constructor(private readonly deckRepository: CardsRepository) {} async execute(command: GetDeckByIdCommand) { - return await this.deckRepository.findDeckById(command.id) + return await this.deckRepository.findCardById(command.id) } } diff --git a/src/modules/cards/use-cases/index.ts b/src/modules/cards/use-cases/index.ts index 5f05762..23872ca 100644 --- a/src/modules/cards/use-cases/index.ts +++ b/src/modules/cards/use-cases/index.ts @@ -1,3 +1,3 @@ export * from './get-deck-by-id-use-case' -export * from './delete-deck-by-id-use-case' +export * from './delete-card-by-id-use-case' export * from './update-deck-use-case' diff --git a/src/modules/cards/use-cases/update-deck-use-case.ts b/src/modules/cards/use-cases/update-deck-use-case.ts index 7ca9eb7..d12ef4a 100644 --- a/src/modules/cards/use-cases/update-deck-use-case.ts +++ b/src/modules/cards/use-cases/update-deck-use-case.ts @@ -16,7 +16,7 @@ export class UpdateDeckHandler implements ICommandHandler { constructor(private readonly deckRepository: CardsRepository) {} async execute(command: UpdateDeckCommand) { - const deck = await this.deckRepository.findDeckById(command.deckId) + const deck = await this.deckRepository.findCardById(command.deckId) if (!deck) throw new NotFoundException(`Deck with id ${command.deckId} not found`) diff --git a/src/modules/decks/decks.controller.ts b/src/modules/decks/decks.controller.ts index 786a561..a276a72 100644 --- a/src/modules/decks/decks.controller.ts +++ b/src/modules/decks/decks.controller.ts @@ -22,13 +22,15 @@ import { GetAllDecksCommand, GetDeckByIdCommand, UpdateDeckCommand, + CreateCardCommand, } from './use-cases' import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' import { GetAllDecksDto } from './dto/get-all-decks.dto' import { GetAllCardsInDeckDto } from '../cards/dto/get-all-cards.dto' -import { CreateCardCommand } from './use-cases' import { CreateCardDto } from '../cards/dto/create-card.dto' import { Pagination } from '../../infrastructure/common/pagination/pagination.service' +import { GetRandomCardInDeckCommand } from './use-cases/get-random-card-in-deck-use-case' +import { SaveGradeCommand } from './use-cases/save-grade-use-case' @Controller('decks') export class DecksController { @@ -57,9 +59,21 @@ export class DecksController { @UseGuards(JwtAuthGuard) @Get(':id/cards') findCardsInDeck(@Param('id') id: string, @Req() req, @Query() query: GetAllCardsInDeckDto) { - return this.commandBus.execute(new GetAllCardsInDeckCommand(req.user.id, id, query)) + const finalQuery = Pagination.getPaginationData(query) + return this.commandBus.execute(new GetAllCardsInDeckCommand(req.user.id, id, finalQuery)) + } + @UseGuards(JwtAuthGuard) + @Get(':id/learn') + findRandomCardInDeck(@Param('id') id: string, @Req() req) { + return this.commandBus.execute(new GetRandomCardInDeckCommand(req.user.id, id)) + } + @UseGuards(JwtAuthGuard) + @Post(':id/learn') + saveGrade(@Param('id') id: string, @Req() req, @Body() body: any) { + return this.commandBus.execute( + new SaveGradeCommand(req.user.id, { cardId: body.cardId, grade: body.grade }) + ) } - @UseGuards(JwtAuthGuard) @Post(':id/cards') createCardInDeck(@Param('id') id: string, @Req() req, @Body() card: CreateCardDto) { diff --git a/src/modules/decks/decks.module.ts b/src/modules/decks/decks.module.ts index ebb3fea..9f88bcb 100644 --- a/src/modules/decks/decks.module.ts +++ b/src/modules/decks/decks.module.ts @@ -13,21 +13,26 @@ import { } from './use-cases' import { DecksRepository } from './infrastructure/decks.repository' import { CardsRepository } from '../cards/infrastructure/cards.repository' +import { GetRandomCardInDeckHandler } from './use-cases/get-random-card-in-deck-use-case' +import { GradesRepository } from './infrastructure/grades.repository' +import { SaveGradeHandler } from './use-cases/save-grade-use-case' const commandHandlers = [ CreateDeckHandler, GetAllDecksHandler, GetDeckByIdHandler, + GetRandomCardInDeckHandler, DeleteDeckByIdHandler, UpdateDeckHandler, GetAllCardsInDeckHandler, CreateCardHandler, + SaveGradeHandler, ] @Module({ imports: [CqrsModule], controllers: [DecksController], - providers: [DecksService, DecksRepository, CardsRepository, ...commandHandlers], + providers: [DecksService, DecksRepository, CardsRepository, GradesRepository, ...commandHandlers], exports: [CqrsModule], }) export class DecksModule {} diff --git a/src/modules/decks/dto/create-grade.dto.ts b/src/modules/decks/dto/create-grade.dto.ts new file mode 100644 index 0000000..4172bbf --- /dev/null +++ b/src/modules/decks/dto/create-grade.dto.ts @@ -0,0 +1,10 @@ +import { IsUUID, Max, Min } from 'class-validator' + +export class CreateDeckDto { + @Min(1) + @Max(5) + grade: number + + @IsUUID() + cardId: string +} diff --git a/src/modules/decks/dto/get-all-decks.dto.ts b/src/modules/decks/dto/get-all-decks.dto.ts index a9aa592..42444ba 100644 --- a/src/modules/decks/dto/get-all-decks.dto.ts +++ b/src/modules/decks/dto/get-all-decks.dto.ts @@ -1,6 +1,7 @@ import { IsUUID } from 'class-validator' import { IsOptionalOrEmptyString } from '../../../infrastructure/decorators/is-optional-or-empty-string' import { PaginationDto } from '../../../infrastructure/common/pagination/pagination.dto' +import { IsOrderBy } from '../../../infrastructure/decorators/is-order-by-constraint' export class GetAllDecksDto extends PaginationDto { @IsOptionalOrEmptyString() @@ -17,4 +18,7 @@ export class GetAllDecksDto extends PaginationDto { authorId?: string userId: string + + @IsOrderBy() + orderBy?: string | null } diff --git a/src/modules/decks/infrastructure/decks.repository.ts b/src/modules/decks/infrastructure/decks.repository.ts index e73df7b..c643d8e 100644 --- a/src/modules/decks/infrastructure/decks.repository.ts +++ b/src/modules/decks/infrastructure/decks.repository.ts @@ -2,6 +2,7 @@ import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common import { PrismaService } from '../../../prisma.service' import { GetAllDecksDto } from '../dto/get-all-decks.dto' import { Pagination } from '../../../infrastructure/common/pagination/pagination.service' +import { createPrismaOrderBy } from '../../../infrastructure/common/helpers/get-order-by-object' @Injectable() export class DecksRepository { @@ -48,13 +49,15 @@ export class DecksRepository { itemsPerPage, minCardsCount, maxCardsCount, + orderBy, }: GetAllDecksDto) { - console.log({ name, authorId, userId, currentPage, itemsPerPage, minCardsCount, maxCardsCount }) + console.log(minCardsCount) + console.log(Number(minCardsCount)) try { const where = { cardsCount: { - gte: Number(minCardsCount) ?? undefined, - lte: Number(maxCardsCount) ?? undefined, + gte: minCardsCount ? Number(minCardsCount) : undefined, + lte: maxCardsCount ? Number(maxCardsCount) : undefined, }, name: { contains: name, @@ -85,9 +88,7 @@ export class DecksRepository { }), this.prisma.deck.findMany({ where, - orderBy: { - created: 'desc', - }, + orderBy: createPrismaOrderBy(orderBy), include: { author: { select: { @@ -124,6 +125,24 @@ export class DecksRepository { throw new InternalServerErrorException(e?.message) } } + public async findDeckByCardId(cardId: string) { + try { + const card = await this.prisma.card.findUnique({ + where: { + id: cardId, + }, + }) + + return await this.prisma.deck.findUnique({ + where: { + id: card.deckId, + }, + }) + } catch (e) { + this.logger.error(e?.message) + throw new InternalServerErrorException(e?.message) + } + } public async deleteDeckById(id: string) { try { diff --git a/src/modules/decks/infrastructure/grades.repository.ts b/src/modules/decks/infrastructure/grades.repository.ts new file mode 100644 index 0000000..61ee7eb --- /dev/null +++ b/src/modules/decks/infrastructure/grades.repository.ts @@ -0,0 +1,59 @@ +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common' +import { PrismaService } from '../../../prisma.service' + +@Injectable() +export class GradesRepository { + constructor(private prisma: PrismaService) {} + + private readonly logger = new Logger(GradesRepository.name) + + async createGrade({ + cardId, + userId, + deckId, + grade, + }: { + cardId: string + userId: string + deckId: string + grade: number + }) { + try { + return await this.prisma.grade.upsert({ + where: { + userId, + cardId, + deckId, + }, + update: { + grade, + shots: { + increment: 1, + }, + }, + create: { + grade, + shots: 1, + user: { + connect: { + id: userId, + }, + }, + card: { + connect: { + id: cardId, + }, + }, + deck: { + connect: { + id: deckId, + }, + }, + }, + }) + } catch (e) { + this.logger.error(e?.message) + throw new InternalServerErrorException(e?.message) + } + } +} diff --git a/src/modules/decks/use-cases/get-all-cards-in-deck-use-case.ts b/src/modules/decks/use-cases/get-all-cards-in-deck-use-case.ts index 912daa4..6c8aa56 100644 --- a/src/modules/decks/use-cases/get-all-cards-in-deck-use-case.ts +++ b/src/modules/decks/use-cases/get-all-cards-in-deck-use-case.ts @@ -2,6 +2,7 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' import { CardsRepository } from '../../cards/infrastructure/cards.repository' import { GetAllCardsInDeckDto } from '../../cards/dto/get-all-cards.dto' import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { DecksRepository } from '../infrastructure/decks.repository' export class GetAllCardsInDeckCommand { constructor( @@ -13,10 +14,13 @@ export class GetAllCardsInDeckCommand { @CommandHandler(GetAllCardsInDeckCommand) export class GetAllCardsInDeckHandler implements ICommandHandler { - constructor(private readonly cardsRepository: CardsRepository) {} + constructor( + private readonly cardsRepository: CardsRepository, + private readonly decksRepository: DecksRepository + ) {} async execute(command: GetAllCardsInDeckCommand) { - const deck = await this.cardsRepository.findDeckById(command.deckId) + const deck = await this.decksRepository.findDeckById(command.deckId) if (!deck) throw new NotFoundException(`Deck with id ${command.deckId} not found`) if (deck.userId !== command.userId && deck.isPrivate) { diff --git a/src/modules/decks/use-cases/get-random-card-in-deck-use-case.ts b/src/modules/decks/use-cases/get-random-card-in-deck-use-case.ts new file mode 100644 index 0000000..14d7ce8 --- /dev/null +++ b/src/modules/decks/use-cases/get-random-card-in-deck-use-case.ts @@ -0,0 +1,53 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { CardsRepository } from '../../cards/infrastructure/cards.repository' +import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { DecksRepository } from '../infrastructure/decks.repository' +import { Prisma } from '@prisma/client' +import { pick } from 'remeda' + +export class GetRandomCardInDeckCommand { + constructor(public readonly userId: string, public readonly deckId: string) {} +} +type CardWithGrade = Prisma.cardGetPayload<{ include: { grades: true } }> +@CommandHandler(GetRandomCardInDeckCommand) +export class GetRandomCardInDeckHandler implements ICommandHandler { + constructor( + private readonly cardsRepository: CardsRepository, + private readonly decksRepository: DecksRepository + ) {} + private async getSmartRandomCard(cards: Array) { + const selectionPool: Array = [] + cards.forEach(card => { + // Calculate the average grade for the card + const averageGrade = + card.grades.length === 0 + ? 0 + : card.grades.reduce((acc, grade) => acc + grade.grade, 0) / card.grades.length + // Calculate weight for the card, higher weight for lower grade card + const weight = 6 - averageGrade + + // Add the card to the selection pool `weight` times + for (let i = 0; i < weight; i++) { + selectionPool.push(card) + } + }) + + return selectionPool[Math.floor(Math.random() * selectionPool.length)] + } + + async execute(command: GetRandomCardInDeckCommand) { + const deck = await this.decksRepository.findDeckById(command.deckId) + if (!deck) throw new NotFoundException(`Deck with id ${command.deckId} not found`) + + if (deck.userId !== command.userId && deck.isPrivate) { + throw new ForbiddenException(`You can't get a private deck that you don't own`) + } + + const cards = await this.cardsRepository.findCardsByDeckIdWithGrade( + command.userId, + command.deckId + ) + const smartRandomCard = await this.getSmartRandomCard(cards) + return pick(smartRandomCard, ['id', 'question', 'answer', 'deckId']) + } +} diff --git a/src/modules/decks/use-cases/save-grade-use-case.ts b/src/modules/decks/use-cases/save-grade-use-case.ts new file mode 100644 index 0000000..141f2dc --- /dev/null +++ b/src/modules/decks/use-cases/save-grade-use-case.ts @@ -0,0 +1,41 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { CardsRepository } from '../../cards/infrastructure/cards.repository' +import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { DecksRepository } from '../infrastructure/decks.repository' +import { GradesRepository } from '../infrastructure/grades.repository' + +export class SaveGradeCommand { + constructor( + public readonly userId: string, + public readonly args: { + cardId: string + grade: number + } + ) {} +} + +@CommandHandler(SaveGradeCommand) +export class SaveGradeHandler implements ICommandHandler { + constructor( + private readonly cardsRepository: CardsRepository, + private readonly decksRepository: DecksRepository, + private readonly gradesRepository: GradesRepository + ) {} + + async execute(command: SaveGradeCommand) { + const deck = await this.decksRepository.findDeckByCardId(command.args.cardId) + if (!deck) + throw new NotFoundException(`Deck containing card with id ${command.args.cardId} not found`) + + if (deck.userId !== command.userId && deck.isPrivate) { + throw new ForbiddenException(`You can't save cards to a private deck that you don't own`) + } + + return await this.gradesRepository.createGrade({ + userId: command.userId, + grade: command.args.grade, + cardId: command.args.cardId, + deckId: deck.id, + }) + } +} diff --git a/src/prisma.service.ts b/src/prisma.service.ts index 7422d9d..c547d25 100644 --- a/src/prisma.service.ts +++ b/src/prisma.service.ts @@ -29,10 +29,16 @@ export class PrismaService extends PrismaClient implements OnModuleInit { async onModuleInit() { await this.$connect() } - - async enableShutdownHooks(app: INestApplication) { - this.$on('beforeExit', async () => { + private exitHandler(app: INestApplication) { + return async () => { await app.close() - }) + } + } + async enableShutdownHooks(app: INestApplication) { + process.on('exit', this.exitHandler(app)) + process.on('beforeExit', this.exitHandler(app)) + process.on('SIGINT', this.exitHandler(app)) + process.on('SIGTERM', this.exitHandler(app)) + process.on('SIGUSR2', this.exitHandler(app)) } }